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.
This commit is contained in:
mizorewww committed 2026-10-04 14:27:26 +08:00
1 parent 83ac3f00ae
commit 4cb9119dbf
13 files changed
+510 -130

No files matched your search

+2 -2
View File
@@ -41,7 +41,7 @@ npm run preview
3. **代理(可选)**:服务器能直接访问 x.com 就选「直连」;否则准备一个代理节点。支持 sing-box 的任意 outbound 类型(anytls、socks、http、shadowsocks、vmess、vless、trojan 等),也可以直接粘贴完整 sing-box 配置。
4. **Stripe 公钥**:X 结账页面使用的 `pk_live_` 开头公钥。使用默认 X Premium 目录时向导会说明;它与商户、商品、价格一起保存在「目录」配置中,也可以完全自定义。
网络路径独立配置:X 查询及生成链接使用原 `proxy`;Stripe 可使用包含 `direct` 的 `payment-outbounds` 节点池。连接故障触发 6 小时冷却,安全查询最多尝试 3 个出口;付款确认不会自动重放。未配置或空数组时,新订单直连。Stripe 不读取环境代理。详见 [付款节点池配置](docs/payment-outbounds.md)。直接在浏览器打开 Stripe 链接时使用浏览器网络。
网络路径独立配置:X 账号与资格检查始终直连;生成付款链接所需的地区报价校验与创建链接使用同一个 `proxy` 出口,避免币种和金额因地区不同而变化;X 请求不读取环境代理;Stripe 可使用包含 `direct` 的 `payment-outbounds` 节点池。连接故障触发 6 小时冷却,安全查询最多尝试 3 个出口;付款确认不会自动重放。未配置或空数组时,新订单直连。Stripe 不读取环境代理。详见 [付款节点池配置](docs/payment-outbounds.md)。直接在浏览器打开 Stripe 链接时使用浏览器网络。
另外需要:一台 Linux 服务器、一个指向该服务器的域名、服务器上安装 Go 1.27+(或在自己电脑上构建后上传二进制)。
@@ -140,7 +140,7 @@ echo '{"merchant":"acct_...","currency":"bdt","plans":[...]}' | ./bin/xgift put
**想暂停充值?** 把 `site.env` 里 `XGIFT_PAYMENTS_ENABLED` 改为 `false` 并重启服务。用户兑换会被婉拒,兑换码不消耗。
**付款被拒怎么办?** 明确拒付会显示失败说明并阻止重复提交,不再显示自动核实。网页和 CLI 在同一个 `checkout.lock` 下执行付款,提交间隔至少 30 秒,重启仍保留间隔。付款按「卡 × 节点」组合随机轮换,每 3 个连续订单使用同一组合;普通拒付会立即结束当前组合,被拒那张卡进入 30 分钟冷却(其他卡继续轮换,可用卡不足时自动回退),下一次提交(含补单)自动换到新的卡和节点,不因连续次数暂停全站。支付端明确返回 `do_not_try_again` 时只封锁被拒的那张卡;仅当所有卡都被永久封锁时才暂停全站,可用 `xgift cards unblock` 显式解除(不能通过补单确认框解除)。该命令不付款,也不会修改 `XGIFT_PAYMENTS_ENABLED`。手动完成原账单后,可查询原订单以核对成功状态。
**付款被拒怎么办?** 明确拒付会显示失败说明并阻止重复提交,不再显示自动核实。网页和 CLI 在同一个 `checkout.lock` 下执行付款,提交间隔至少 30 秒,重启仍保留间隔。付款按「卡 × 节点」组合随机轮换,每 3 个连续订单使用同一组合;普通拒付会立即结束当前组合,并**先冷却该出口节点及其共享 IP(30 分钟)**,同一张卡马上换其他节点继续付款;只有同一张卡在两个不同节点都被拒,才冷却整卡 30 分钟。其他卡继续轮换,下一次提交(含补单)自动换到新的组合,不因连续次数暂停全站。支付端明确返回 `do_not_try_again` 时只永久封锁被拒的那张卡;仅当所有卡都被永久封锁时才暂停全站,可用 `xgift cards unblock` 显式解除(同时清除冷却,不能通过补单确认框解除)。后台补单页会实时显示每张卡的可用/冷却状态和当前组合。该命令不付款,也不会修改 `XGIFT_PAYMENTS_ENABLED`。手动完成原账单后,可查询原订单以核对成功状态。
**付款结果不明怎么办?** 系统宁可标记「待核实」也不会重复扣款。用户用原兑换码点「重新检查并继续兑换」即可自动核对,确认成功后自动补上状态。
+4 -1
View File
@@ -408,11 +408,14 @@ func runCards(v *vault.Vault, sub, last4 string) error {
}
if s.Blocked != "" {
if s.CoolingSeconds > 0 {
line += fmt.Sprintf("(被拒冷却:%s,剩 %d 分钟)", s.Blocked, (s.CoolingSeconds+59)/60)
line += fmt.Sprintf("(整卡冷却:%s,剩 %d 分钟)", s.Blocked, (s.CoolingSeconds+59)/60)
} else {
line += "(已封锁:" + s.Blocked + ")"
}
}
if s.PairCooling > 0 {
line += fmt.Sprintf("(%d 组卡+节点组合冷却中)", s.PairCooling)
}
fmt.Println(line)
}
if rotation.CardLast4 == "" {
+4 -4
View File
@@ -1,6 +1,6 @@
# Stripe 付款节点池
X 资格检查、报价和生成链接继续使用 vault 中的 `proxy` 配置。Stripe 账单初始化、核验、卡信息提交、付款确认和结果查询使用独立的 `payment-outbounds`。后台批量、单客户补单与 CLI 共用此配置。
X 账号与资格检查始终直连;生成付款链接所需的地区报价校验与创建链接使用 vault 中同一个 `proxy` 出口。报价取决于出口地区,不能将其与链接创建分开路由。X 请求不读取环境代理。Stripe 账单初始化、核验、卡信息提交、付款确认和结果查询使用独立的 `payment-outbounds`。后台批量、单客户补单与 CLI 共用此配置。
## 配置格式
@@ -80,8 +80,8 @@ PY
付款卡保存在加密的 `cards` 数组(兼容旧的单张 `card` 记录),Stripe 付款按「卡 × 节点」组合随机轮换:
- 每 3 个连续付款订单使用同一组合(不按客户,按提交顺序)。第 4 个订单重新随机选择卡和可用节点;同一张卡不会连续被选为相邻组合(可用卡足够时)。
- 任一订单被银行拒付后,当前组合立即作废;下一次提交(包括管理员补单)自动换到另一张卡和一个可用节点。被拒的那张卡进入 30 分钟冷却,期间不再参与轮换;其他卡不够用时会自动回退重新启用。普通拒付不再因累计次数暂停全站,也不影响其他组合。
- 只有支付方明确返回 `do_not_try_again` 时才永久封锁对应那张卡;其余卡继续轮换,全部卡都被永久封锁才暂停全站,可用 `xgift cards unblock` 显式解除(同时清除拒绝冷却)。
- 任一订单被银行拒付后,当前组合立即作废,并**先冷却该出口节点及其共享出口 IP(30 分钟)**——换节点是首选处理,同一张卡马上可以通过其他节点继续付款。只有同一张卡在两个不同节点都被拒时,才冷却整卡 30 分钟;冷却期内其他卡继续轮换,全部组合不可用时自动回退。普通拒付不再因累计次数暂停全站,也不影响其他组合。
- 只有支付方明确返回 `do_not_try_again` 时才永久封锁对应那张卡;其余卡继续轮换,全部卡都被永久封锁才暂停全站,可用 `xgift cards unblock` 显式解除(同时清除所有拒绝冷却和组合冷却)。后台补单面板实时显示每张卡的可用/冷却/封锁状态与当前轮换组合。
- 旧单卡时代的已拒订单没有卡指纹记录,首次补单会按旧 `card` 记录给对应卡补上冷却,避免用旧卡反复重试。
- 补单与普通付款共用同一套轮换逻辑;已被拒订单不会重复使用原卡重试,而未提交付款的订单重复尝试会继续沿用已绑定的组合,不重复占用轮换名额。
- 卡池变化(增删卡)会形成新的集合指纹,自动重置支付保护状态和后台补单预览;仅轮换不会改变指纹。CLI 用 `xgift cards list|add|remove|unblock|rotate` 管理。
@@ -97,7 +97,7 @@ PY
## 网络重试与 6 小时冷却
- Stripe 连接失败、TLS/连接超时或成功响应中断,会把当前节点冷却 6 小时。冷却按节点、服务器地址,以及最近探测到的公网 IP 共同排除;共享出口 IP 的节点一起退出可选池。`direct` 也适用。取消操作和 API 重定向拒绝不触发冷却。
- Stripe 连接失败、TLS/连接超时或成功响应中断,会把当前节点冷却 6 小时;银行卡拒付触发的是 30 分钟的出口 IP 冷却(先冷却 IP 再考虑冷却卡)。冷却按节点、服务器地址,以及最近探测到的公网 IP 共同排除;共享出口 IP 的节点一起退出可选池。`direct` 也适用。取消操作和 API 重定向拒绝不触发冷却。
- 冷却记录保存在加密的 `payment-node-cooldown:*`,服务重启不会清除;到期自动恢复候选资格。所有节点均冷却时停止,不偷偷走未配置出口。
- **GET 查询及 `payment_pages/<session>/init` 初始化核验**可换节点重试,每个请求最多 3 次(含首次)。这不包含生成新账单的 X mutation。
- **卡信息提交、付款确认等写请求不自动重放**。即使网络出错并记录冷却,也立即返回失败/待核实状态;之后只能查询原结果,不会在别的出口重新扣款。
+80 -13
View File
@@ -23,9 +23,11 @@ const (
cardBlockRecord = "payment-card-blocks"
)
// paymentCardCooldown is how long a declined card stays out of rotation while
// other cards are available. A provider no-retry instruction blocks it until an
// operator explicitly unblocks it.
// paymentCardCooldown is how long a declined card+node pair stays out of
// rotation while another node for the same card is available. A second decline
// of the same card on a different node cools the whole card for the same
// window. A provider no-retry instruction blocks the card until an operator
// explicitly unblocks it.
const paymentCardCooldown = 30 * time.Minute
var ErrNoUsableCard = errors.New("no usable payment card is available; every configured card may be blocked or cooling down")
@@ -239,8 +241,16 @@ func touchCardUsageLocked(v *vault.Vault, fingerprint string) error {
return v.Put(cardUsageRecord, b)
}
// paymentCardBlocked reports whether a card is currently out of rotation,
// either permanently blocked or inside its post-decline cooldown.
// pairBlockKey scopes a cooldown to one card+node combination so the same card
// can be retried through a different node.
func pairBlockKey(fingerprint, nodeID string) string { return fingerprint + ":" + nodeID }
func activeCardBlock(block cardBlock, now int64) bool {
return block.Until == 0 || block.Until > now
}
// paymentCardBlocked reports whether the whole card is currently out of
// rotation, either permanently blocked or inside a card-level cooldown.
func paymentCardBlocked(v *vault.Vault, fingerprint string) (bool, error) {
blocks, err := readCardBlocks(v)
if err != nil {
@@ -250,7 +260,20 @@ func paymentCardBlocked(v *vault.Vault, fingerprint string) (bool, error) {
if !blocked {
return false, nil
}
return block.Until == 0 || block.Until > time.Now().Unix(), nil
return activeCardBlock(block, time.Now().Unix()), nil
}
// paymentPairBlocked reports whether this card+node combination is cooling.
func paymentPairBlocked(v *vault.Vault, fingerprint, nodeID string) (bool, error) {
if fingerprint == "" || nodeID == "" {
return false, nil
}
blocks, err := readCardBlocks(v)
if err != nil {
return false, err
}
block, blocked := blocks[pairBlockKey(fingerprint, nodeID)]
return blocked && activeCardBlock(block, time.Now().Unix()), nil
}
// blockPaymentCard excludes one card from rotation until an operator unblocks
@@ -292,7 +315,37 @@ func coolPaymentCardLocked(v *vault.Vault, fingerprint, reason string) error {
if existing, ok := blocks[fingerprint]; ok && existing.Until == 0 {
return nil
}
blocks[fingerprint] = cardBlock{Reason: reason, BlockedAt: time.Now().Unix(), Until: time.Now().Add(paymentCardCooldown).Unix()}
now := time.Now()
blocks[fingerprint] = cardBlock{Reason: reason, BlockedAt: now.Unix(), Until: now.Add(paymentCardCooldown).Unix()}
return saveCardBlocks(v, blocks)
}
// coolPaymentPairLocked cools one card+node combination. When the same card has
// declined on two distinct nodes inside the window, the whole card is cooled so
// a bad card cannot be tested across the whole node pool. Caller must hold
// paymentRouteMu.
func coolPaymentPairLocked(v *vault.Vault, fingerprint, nodeID, reason string) error {
if fingerprint == "" || nodeID == "" {
return nil
}
blocks, err := readCardBlocks(v)
if err != nil {
return err
}
now := time.Now()
until := now.Add(paymentCardCooldown).Unix()
blocks[pairBlockKey(fingerprint, nodeID)] = cardBlock{Reason: reason, BlockedAt: now.Unix(), Until: until}
distinct := 0
for key, block := range blocks {
if strings.HasPrefix(key, fingerprint+":") && block.Until > now.Unix() {
distinct++
}
}
if distinct >= 2 {
if existing, ok := blocks[fingerprint]; !ok || existing.Until != 0 {
blocks[fingerprint] = cardBlock{Reason: "declined_multi_node", BlockedAt: now.Unix(), Until: until}
}
}
return saveCardBlocks(v, blocks)
}
@@ -355,17 +408,19 @@ func hasUsableCard(v *vault.Vault) (bool, error) {
return false, nil
}
// CardStatus is a number-free view of one configured card for the CLI.
// CardStatus is a number-free view of one configured card for the CLI and the
// admin dashboard.
type CardStatus struct {
Last4 string `json:"last4"`
Usable bool `json:"usable"`
Problem string `json:"problem,omitempty"`
Blocked string `json:"blocked,omitempty"`
CoolingSeconds int64 `json:"cooling_seconds,omitempty"`
PairCooling int `json:"pair_cooling,omitempty"`
}
func CardsStatus(v *vault.Vault) ([]CardStatus, error) {
cards, err := readCards(v)
cards, err := readCardsOptional(v)
if err != nil {
return nil, err
}
@@ -373,17 +428,29 @@ func CardsStatus(v *vault.Vault) ([]CardStatus, error) {
if err != nil {
return nil, err
}
now := time.Now().Unix()
status := make([]CardStatus, 0, len(cards))
for _, c := range cards {
entry := CardStatus{Last4: cardTail(c), Usable: true}
fingerprint := cardFingerprint(c)
if problem := validateCard(c); problem != nil {
entry.Usable = false
entry.Problem = problem.Error()
}
if block, ok := blocks[cardFingerprint(c)]; ok {
// Expired cooldowns no longer keep the card out of rotation, so they
// must not be reported as blocks.
if block, ok := blocks[fingerprint]; ok && activeCardBlock(block, now) {
entry.Blocked = block.Reason
if remaining := block.Until - time.Now().Unix(); block.Until > 0 && remaining > 0 {
entry.CoolingSeconds = remaining
if block.Until > 0 {
entry.CoolingSeconds = block.Until - now
}
}
for key, block := range blocks {
if !strings.HasPrefix(key, fingerprint+":") {
continue
}
if block.Until > now {
entry.PairCooling++
}
}
status = append(status, entry)
@@ -605,7 +672,7 @@ type RotationInfo struct {
}
func PaymentRotationStatus(v *vault.Vault) (RotationInfo, error) {
cards, err := readCards(v)
cards, err := readCardsOptional(v)
if err != nil {
return RotationInfo{}, err
}
+47
View File
@@ -3,6 +3,7 @@ package checkout
import (
"strings"
"testing"
"time"
)
const (
@@ -152,3 +153,49 @@ func TestBlockedCardsAreSkippedUntilExplicitlyUnblocked(t *testing.T) {
t.Fatal("unblock did not restore rotation")
}
}
func TestCardsStatusReportsEmptyWhenUnconfigured(t *testing.T) {
v := controlFixture(t)
status, err := CardsStatus(v)
if err != nil || len(status) != 0 {
t.Fatalf("status=%+v err=%v", status, err)
}
rotation, err := PaymentRotationStatus(v)
if err != nil || rotation.Cards != 0 || rotation.CardLast4 != "" {
t.Fatalf("rotation=%+v err=%v", rotation, err)
}
}
func TestCardsStatusDropsExpiredCooldown(t *testing.T) {
v := controlFixture(t)
if _, err := AddCardRecords(v, []byte(testCardOne)); err != nil {
t.Fatal(err)
}
cards, _ := readCards(v)
fingerprint := cardFingerprint(cards[0])
if err := coolPaymentCard(v, fingerprint, "declined"); err != nil {
t.Fatal(err)
}
blocks, _ := readCardBlocks(v)
entry := blocks[fingerprint]
entry.Until = time.Now().Add(-time.Second).Unix()
blocks[fingerprint] = entry
if err := saveCardBlocks(v, blocks); err != nil {
t.Fatal(err)
}
status, err := CardsStatus(v)
if err != nil || len(status) != 1 {
t.Fatalf("status=%+v err=%v", status, err)
}
if status[0].Blocked != "" || status[0].CoolingSeconds != 0 {
t.Fatalf("expired cooldown still reported: %+v", status[0])
}
// An active cooldown still reports the reason and remaining seconds.
if err := coolPaymentCard(v, fingerprint, "declined"); err != nil {
t.Fatal(err)
}
status, _ = CardsStatus(v)
if status[0].Blocked == "" || status[0].CoolingSeconds <= 0 {
t.Fatalf("active cooldown not reported: %+v", status[0])
}
}
+13 -3
View File
@@ -12,7 +12,10 @@ import (
"xgift/internal/vault"
)
const paymentNodeCooldown = 6 * time.Hour
const (
paymentNodeCooldown = 6 * time.Hour
paymentNodeDeclineCooldown = 30 * time.Minute
)
var ErrPaymentNodesCooling = errors.New("all payment nodes are cooling down; no alternate route available")
@@ -79,11 +82,18 @@ func nodeCoolingUntil(v *vault.Vault, node json.RawMessage) (int64, error) {
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(paymentNodeCooldown).Unix()
until := time.Now().Add(window).Unix()
old, e := nodeCoolingUntil(v, node)
if e != nil {
return e
@@ -91,7 +101,7 @@ func coolPaymentNode(v *vault.Vault, node json.RawMessage) error {
if old > until {
until = old
}
b, _ := json.Marshal(nodeCooldown{Until: until, Reason: "transport_failure"})
b, _ := json.Marshal(nodeCooldown{Until: until, Reason: reason})
defer clear(b)
for _, key := range keys {
if e = v.Put(key, b); e != nil {
+143 -75
View File
@@ -76,20 +76,93 @@ func ResetPaymentRotation(v *vault.Vault) error {
return savePaymentRotation(v, rotation)
}
// choosePaymentPair picks one random usable card and one random available node.
// Cards that are blocked or cooling are skipped; keepCard pins the running
// batch to its card; forbidden replaces the given node when possible.
// pickCard returns the pinned card when it is still available, otherwise the
// least recently used candidate (random tie-break) so rotation stays balanced.
func pickCard(v *vault.Vault, candidates []card, pinned string) (card, error) {
if pinned != "" {
for _, c := range candidates {
if cardFingerprint(c) == pinned {
return c, nil
}
}
}
usage, err := readCardUsage(v)
if err != nil {
return card{}, err
}
oldest := int64(-1)
preferred := make([]card, 0, len(candidates))
for _, c := range candidates {
used := usage[cardFingerprint(c)]
if oldest < 0 || used < oldest {
oldest = used
preferred = append(preferred[:0], c)
} else if used == oldest {
preferred = append(preferred, c)
}
}
index, err := rand.Int(rand.Reader, big.NewInt(int64(len(preferred))))
if err != nil {
return card{}, errors.New("cannot choose payment card")
}
return preferred[index.Int64()], nil
}
// preferNode favours a different server than the last used one and drops the
// forbidden node unless it is the only option left.
func preferNode(list []json.RawMessage, lastGroup, forbidden string) []json.RawMessage {
out := list
if lastGroup != "" {
filtered := make([]json.RawMessage, 0, len(out))
for _, node := range out {
if routeGroup(node) != lastGroup {
filtered = append(filtered, node)
}
}
if len(filtered) > 0 {
out = filtered
}
}
if len(out) > 1 && forbidden != "" {
filtered := make([]json.RawMessage, 0, len(out))
for _, node := range out {
if outboundID(node) != forbidden {
filtered = append(filtered, node)
}
}
if len(filtered) > 0 {
out = filtered
}
}
return out
}
func pickNode(list []json.RawMessage) (json.RawMessage, error) {
if len(list) == 0 {
return nil, ErrNoUsableCard
}
index, err := rand.Int(rand.Reader, big.NewInt(int64(len(list))))
if err != nil {
return nil, errors.New("cannot choose payment node")
}
return list[index.Int64()], nil
}
// choosePaymentPair picks one card and one node. Whole-card blocks cool the
// card everywhere; a declined pair only cools that card+node combination so the
// same card can be retried through another node.
func choosePaymentPair(v *vault.Vault, cards []card, nodes []json.RawMessage, keepCard, forbidden string) (card, json.RawMessage, error) {
blocks, err := readCardBlocks(v)
if err != nil {
return card{}, nil, err
}
now := time.Now().Unix()
usable := make([]card, 0, len(cards))
for _, c := range cards {
if validateCard(c) != nil {
continue
}
if _, blocked := blocks[cardFingerprint(c)]; blocked {
if block, ok := blocks[cardFingerprint(c)]; ok && activeCardBlock(block, now) {
continue
}
usable = append(usable, c)
@@ -97,42 +170,11 @@ func choosePaymentPair(v *vault.Vault, cards []card, nodes []json.RawMessage, ke
if len(usable) == 0 {
return card{}, nil, ErrNoUsableCard
}
pickable := usable
if keepCard != "" {
for _, c := range usable {
if cardFingerprint(c) == keepCard {
pickable = []card{c}
break
}
}
}
// New batches prefer the usable card that has gone unused the longest, so a
// two-card set alternates instead of streaking by chance.
if keepCard == "" {
usage, err := readCardUsage(v)
if len(nodes) == 0 {
chosen, err := pickCard(v, usable, keepCard)
if err != nil {
return card{}, nil, err
}
oldest := int64(-1)
preferred := make([]card, 0, len(pickable))
for _, c := range pickable {
used := usage[cardFingerprint(c)]
if oldest < 0 || used < oldest {
oldest = used
preferred = append(preferred[:0], c)
} else if used == oldest {
preferred = append(preferred, c)
}
}
pickable = preferred
}
cardIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(pickable))))
if err != nil {
return card{}, nil, errors.New("cannot choose payment card")
}
chosen := pickable[cardIndex.Int64()]
if len(nodes) == 0 {
return chosen, json.RawMessage(`{"type":"direct","tag":"direct"}`), nil
}
available, err := availablePaymentNodes(v, nodes)
@@ -142,9 +184,8 @@ func choosePaymentPair(v *vault.Vault, cards []card, nodes []json.RawMessage, ke
if len(available) == 0 {
return card{}, nil, ErrPaymentNodesCooling
}
choices := available
lastGroup := ""
if last, e := v.Get("stripe-route:last-node"); e == nil && len(last) > 0 {
lastGroup := ""
for _, node := range nodes {
if outboundID(node) == string(last) {
lastGroup = routeGroup(node)
@@ -152,34 +193,36 @@ func choosePaymentPair(v *vault.Vault, cards []card, nodes []json.RawMessage, ke
}
}
clear(last)
if lastGroup != "" {
filtered := make([]json.RawMessage, 0, len(choices))
for _, node := range choices {
if routeGroup(node) != lastGroup {
filtered = append(filtered, node)
}
}
if len(filtered) > 0 {
choices = filtered
}
}
}
if len(choices) > 1 && forbidden != "" {
filtered := make([]json.RawMessage, 0, len(choices))
for _, node := range choices {
if outboundID(node) != forbidden {
filtered = append(filtered, node)
freeNodes := make(map[string][]json.RawMessage, len(usable))
candidates := make([]card, 0, len(usable))
for _, c := range usable {
fingerprint := cardFingerprint(c)
list := make([]json.RawMessage, 0, len(available))
for _, node := range available {
if block, ok := blocks[pairBlockKey(fingerprint, outboundID(node))]; ok && activeCardBlock(block, now) {
continue
}
list = append(list, node)
}
if len(filtered) > 0 {
choices = filtered
if len(list) == 0 {
continue
}
freeNodes[fingerprint] = preferNode(list, lastGroup, forbidden)
candidates = append(candidates, c)
}
nodeIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(choices))))
if len(candidates) == 0 {
return card{}, nil, ErrNoUsableCard
}
chosen, err := pickCard(v, candidates, keepCard)
if err != nil {
return card{}, nil, errors.New("cannot choose payment node")
return card{}, nil, err
}
return chosen, choices[nodeIndex.Int64()], nil
node, err := pickNode(freeNodes[cardFingerprint(chosen)])
if err != nil {
return card{}, nil, err
}
return chosen, node, nil
}
func paymentNodes(v *vault.Vault) ([]json.RawMessage, error) {
@@ -207,18 +250,28 @@ func continueOrRotate(v *vault.Vault, cards []card, nodes []json.RawMessage, rot
if len(rotation.Outbound) == 0 {
return c, json.RawMessage(`{"type":"direct","tag":"direct"}`), rotation.Used + 1, nil
}
pairCooling, e := paymentPairBlocked(v, rotation.CardFingerprint, rotation.NodeID)
if e != nil {
return card{}, nil, 0, e
}
until, e := nodeCoolingUntil(v, rotation.Outbound)
if e != nil {
return card{}, nil, 0, e
}
if until <= time.Now().Unix() {
if !pairCooling && until <= time.Now().Unix() {
return c, rotation.Outbound, rotation.Used + 1, nil
}
// The running card keeps the batch but moves to another node
// when its own pair or node is cooling.
next, node, e := choosePaymentPair(v, cards, nodes, rotation.CardFingerprint, rotation.NodeID)
if e != nil {
return card{}, nil, 0, e
}
return next, node, rotation.Used + 1, nil
used := rotation.Used + 1
if cardFingerprint(next) != rotation.CardFingerprint {
used = 1
}
return next, node, used, nil
}
}
}
@@ -270,20 +323,27 @@ func assignPaymentRoute(v *vault.Vault, recipient string, rotate bool) (*payment
}
if routeErr == nil && !rotate && boundOK {
// The order keeps its pinned pair; only a durable transport cooldown may
// move the node, and the card always stays with the order.
until, e := nodeCoolingUntil(v, route.Outbound)
// The order keeps its pinned pair; only a durable node cooldown or a
// cooling card+node pair may move it, and the card always stays with the
// order.
pairCooling, e := paymentPairBlocked(v, route.Card, route.NodeID)
if e != nil {
return nil, card{}, e
}
if until > time.Now().Unix() {
next, e := rotateCoolingRouteLocked(v, route)
if !pairCooling {
until, e := nodeCoolingUntil(v, route.Outbound)
if e != nil {
return nil, card{}, e
}
return next, bound, nil
if until > time.Now().Unix() {
next, e := rotateCoolingRouteLocked(v, route)
if e != nil {
return nil, card{}, e
}
return next, bound, nil
}
return route, bound, nil
}
return route, bound, nil
}
// A fresh pair is needed: first attempt, a route with no usable card, or an
@@ -295,7 +355,7 @@ func assignPaymentRoute(v *vault.Vault, recipient string, rotate bool) (*payment
}
if rotate && routeErr == nil && route.Card != "" {
if _, ok := cardByFingerprint(cards, route.Card); ok {
if err = coolPaymentCardLocked(v, route.Card, "declined"); err != nil {
if err = coolPaymentPairLocked(v, route.Card, route.NodeID, "declined"); err != nil {
return nil, card{}, err
}
}
@@ -363,8 +423,9 @@ func assignPaymentRoute(v *vault.Vault, recipient string, rotate bool) (*payment
return next, chosen, nil
}
// markPaymentDecline ends the running batch and keeps the declined card out of
// rotation for the cooldown while other cards remain available.
// markPaymentDecline ends the running batch and applies the cooling order the
// operator requested: the exit node (and its shared egress IP) is cooled first,
// and only a card that keeps declining on another node is cooled as well.
func markPaymentDecline(v *vault.Vault, recipient string) error {
if recipient == "" {
return nil
@@ -378,9 +439,16 @@ func markPaymentDecline(v *vault.Vault, recipient string) error {
if rotation == nil {
rotation = &paymentRotation{}
}
if route, e := readPaymentRoute(v, recipient); e == nil && route.Card != "" {
if err = coolPaymentCardLocked(v, route.Card, "declined"); err != nil {
return err
if route, e := readPaymentRoute(v, recipient); e == nil {
if route.Card != "" {
if err = coolPaymentPairLocked(v, route.Card, route.NodeID, "declined"); err != nil {
return err
}
}
if len(route.Outbound) > 0 {
if err = coolPaymentNodeLocked(v, route.Outbound, "declined", paymentNodeDeclineCooldown); err != nil {
return err
}
}
}
rotation.Used = PaymentBatchSize
+41 -6
View File
@@ -6,6 +6,7 @@ import (
"testing"
"time"
"xgift/internal/proxy"
"xgift/internal/vault"
)
@@ -64,13 +65,18 @@ func TestDeclineEndsBatchAndNextRotationAvoidsCard(t *testing.T) {
if err != nil || rotation.Used != PaymentBatchSize {
t.Fatalf("decline was not recorded as a batch end: %+v %v", rotation, err)
}
if blocked, _ := paymentCardBlocked(v, card); !blocked {
t.Fatal("declined card was not cooled down")
if blocked, _ := paymentPairBlocked(v, card, node); !blocked {
t.Fatal("declined card+node pair was not cooled down")
}
nextNode, nextCard := assignPair(t, v, "202", false)
if nextCard == card {
t.Fatal("next order reused the declined card")
if route, e := readPaymentRoute(v, "201"); e != nil {
t.Fatal(e)
} else if until, e := nodeCoolingUntil(v, route.Outbound); e != nil || until <= time.Now().Unix() {
t.Fatal("declined exit node and its shared IP were not cooled")
}
if blocked, _ := paymentCardBlocked(v, card); blocked {
t.Fatal("first decline cooled the whole card instead of the node")
}
nextNode, _ := assignPair(t, v, "202", false)
if nextNode == node {
t.Fatal("next order reused the declined node")
}
@@ -79,6 +85,35 @@ func TestDeclineEndsBatchAndNextRotationAvoidsCard(t *testing.T) {
}
}
func TestDeclinedPairLeavesOtherNodesForTheSameCard(t *testing.T) {
v := rotationSetup(t, testCardOne, testCardTwo, testCardThree)
cards, _ := readCards(v)
nodes, err := proxy.ParseOutboundPool([]byte(twoPaymentNodes))
if err != nil {
t.Fatal(err)
}
first := cardFingerprint(cards[0])
if err := coolPaymentPairLocked(v, first, outboundID(nodes[0]), "declined"); err != nil {
t.Fatal(err)
}
if blocked, _ := paymentCardBlocked(v, first); blocked {
t.Fatal("one pair decline cooled the whole card")
}
picked, node, err := choosePaymentPair(v, []card{cards[0]}, nodes, "", "")
if err != nil {
t.Fatal(err)
}
if cardFingerprint(picked) != first || outboundID(node) == outboundID(nodes[0]) {
t.Fatal("same card was not retried through another node")
}
if err := coolPaymentPairLocked(v, first, outboundID(nodes[1]), "declined"); err != nil {
t.Fatal(err)
}
if blocked, _ := paymentCardBlocked(v, first); !blocked {
t.Fatal("second decline on another node did not cool the card")
}
}
func TestRetryAfterDeclineRotatesPair(t *testing.T) {
v := rotationSetup(t, testCardOne, testCardTwo, testCardThree)
node, card := assignPair(t, v, "301", false)
@@ -209,7 +244,7 @@ func TestDoNotTryAgainBlocksOneCardWithoutGlobalPause(t *testing.T) {
if paused, _ := PaymentPaused(v); !paused {
t.Fatal("all cards blocked but the site stayed open")
}
if cleared, err := UnblockPaymentCards(v); err != nil || cleared != 3 {
if cleared, err := UnblockPaymentCards(v); err != nil || cleared < 3 {
t.Fatalf("cleared=%d err=%v", cleared, err)
}
if paused, _ := PaymentPaused(v); paused {
+101 -8
View File
@@ -2,6 +2,8 @@ package checkout
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
@@ -12,7 +14,7 @@ import (
"testing"
)
func TestStripeDirectAndXProxyStaySeparate(t *testing.T) {
func TestXIdentityDirectAndRegionalCheckoutProxyStaySeparate(t *testing.T) {
var proxyCalls atomic.Int32
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyCalls.Add(1)
@@ -72,17 +74,108 @@ func TestStripeDirectAndXProxyStaySeparate(t *testing.T) {
t.Fatal(err)
}
defer x.close()
req, _ := http.NewRequest(http.MethodGet, "https://x.com/", nil)
p, err := x.http.Transport.(*http.Transport).Proxy(req)
if err != nil || p.String() != proxy.URL {
t.Fatal("X must retain its configured proxy")
xtr := x.http.Transport.(*http.Transport)
if xtr.Proxy != nil {
t.Fatal("X must ignore both the legacy proxy port and environment proxies")
}
res, err := x.http.Get("http://x-egress-test.invalid/")
xtr.TLSClientConfig = target.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
for _, method := range []string{http.MethodGet, http.MethodPost} {
req, _ := http.NewRequest(method, target.URL, nil)
res, err := x.http.Do(req)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
if res.StatusCode != http.StatusNoContent {
t.Fatal("unexpected X direct response")
}
}
if proxyCalls.Load() != 0 || directCalls.Load() != 4 {
t.Fatal("X traffic did not stay direct")
}
request, _ := http.NewRequest(http.MethodGet, "https://x.com/", nil)
configured, err := x.regionalHTTP.Transport.(*http.Transport).Proxy(request)
if err != nil || configured.String() != proxy.URL {
t.Fatal("creation lost the configured X proxy")
}
res, err := x.regionalHTTP.Get("http://x-egress-test.invalid/")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if proxyCalls.Load() != 1 || directCalls.Load() != 2 {
t.Fatal("X traffic did not reach its configured proxy")
if proxyCalls.Load() != 1 {
t.Fatal("creation transport did not use the configured proxy")
}
}
func TestXOperationsChooseTheirRequiredRoute(t *testing.T) {
v := controlFixture(t)
var reads, creates []string
route := func(paths *[]string, fail bool) *http.Client {
return &http.Client{Transport: mockXTransport(func(r *http.Request) (*http.Response, error) {
*paths = append(*paths, r.Method+" "+r.URL.Path)
if fail {
return nil, errors.New("proxy unavailable")
}
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"data":{}}`))}, nil
})}
}
c := &xClient{vault: v, headers: http.Header{}, http: route(&reads, false), regionalHTTP: route(&creates, false)}
for _, operation := range []struct {
name string
mutation bool
}{
{"PremiumGiftingQuery", false},
{"useSubscriptionProductDetailsByRestIdQuery", false},
{"useOneTimePurchaseGiftMutation", true},
} {
var out json.RawMessage
if err := c.call(context.Background(), "fixture", operation.name, "fixture", map[string]string{}, operation.mutation, &out); err != nil {
t.Fatal(err)
}
}
if len(reads) != 1 || len(creates) != 2 || !strings.Contains(creates[1], "POST /i/api/graphql/fixture/useOneTimePurchaseGiftMutation") {
t.Fatalf("reads=%v creates=%v", reads, creates)
}
c.regionalHTTP = route(&creates, true)
var out json.RawMessage
if err := c.call(context.Background(), "fixture", "useOneTimePurchaseGiftMutation", "fixture", map[string]string{}, true, &out); err == nil {
t.Fatal("failed proxy must stop creation")
}
if len(reads) != 1 || len(creates) != 3 {
t.Fatal("creation retried or fell back to direct")
}
if err := c.call(context.Background(), "fixture", "PremiumGiftingQuery", "fixture", map[string]string{}, false, &out); err != nil {
t.Fatal("proxy failure blocked direct reads", err)
}
}
func TestXQuoteUsesSameRegionAsCheckout(t *testing.T) {
var directCalls, regionalCalls int
for _, months := range []int{3, 6} {
p := Plan{Months: months, Minor: months * 10000, Currency: "bdt", ProductID: "synthetic-product"}
transport := func(regional bool) *http.Client {
return &http.Client{Transport: mockXTransport(func(r *http.Request) (*http.Response, error) {
currency, amount := "Myr", int64(102000000)
if regional {
regionalCalls++
currency = "Bdt"
amount = int64(p.Minor) * 10000
} else {
directCalls++
}
body, _ := json.Marshal(map[string]any{"data": map[string]any{"web_subscription_product_details_by_rest_id": map[string]any{"rest_id": p.ProductID, "prices": []any{map[string]any{"currency_code": currency, "amount_local_micro": amount, "price_type": "OneTime"}}}}})
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(string(body)))}, nil
})}
}
c := &xClient{http: transport(false), regionalHTTP: transport(true), headers: http.Header{}}
if err := c.quote(context.Background(), "fixture", p); err != nil {
t.Fatalf("%d months: %v", months, err)
}
}
if directCalls != 0 || regionalCalls != 2 {
t.Fatalf("price routing: direct=%d regional=%d", directCalls, regionalCalls)
}
}
+24 -7
View File
@@ -38,9 +38,10 @@ func Eligibility(ctx context.Context, v *vault.Vault, user string, port int) (st
func (p Plan) Name() string { return fmt.Sprintf("Premium Gift - %d months", p.Months) }
type xClient struct {
vault *vault.Vault
http *http.Client
headers http.Header
vault *vault.Vault
http *http.Client
regionalHTTP *http.Client
headers http.Header
}
func newXClient(v *vault.Vault, port int) (*xClient, error) {
@@ -85,11 +86,20 @@ func newXClient(v *vault.Vault, port int) (*xClient, error) {
if !found["ct0"] || !found["auth_token"] {
return nil, errors.New("required X cookies missing")
}
// Account checks connect directly. Regional pricing and checkout creation
// must share the configured exit because X prices depend on its country.
newClient := func(proxy func(*http.Request) (*url.URL, error)) *http.Client {
return &http.Client{Transport: &http.Transport{Proxy: proxy, TLSHandshakeTimeout: 15 * time.Second}, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("unexpected X API redirect") }}
}
p, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", port))
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(p), TLSHandshakeTimeout: 15 * time.Second}, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("unexpected X API redirect") }}
return &xClient{http: client, headers: h, vault: v}, nil
return &xClient{http: newClient(nil), regionalHTTP: newClient(http.ProxyURL(p)), headers: h, vault: v}, nil
}
func (c *xClient) close() {
c.http.CloseIdleConnections()
if c.regionalHTTP != nil {
c.regionalHTTP.CloseIdleConnections()
}
}
func (c *xClient) close() { c.http.CloseIdleConnections() }
func (c *xClient) call(ctx context.Context, user, name, id string, variables any, mutation bool, out any) error {
if mutation {
return c.callOnce(ctx, user, name, id, variables, true, out)
@@ -175,7 +185,14 @@ func (c *xClient) callOnce(ctx context.Context, user, name, id string, variables
}
req.Header = c.headers.Clone()
req.Header.Set("Referer", "https://x.com/"+user+"/gift-premium")
res, e := c.http.Do(req)
client := c.http
if mutation || name == "useSubscriptionProductDetailsByRestIdQuery" {
if c.regionalHTTP == nil {
return errors.New("X regional checkout proxy is unavailable")
}
client = c.regionalHTTP
}
res, e := client.Do(req)
if e != nil {
audit.Phase, audit.Cause = "transport_failed", e.Error()
return temporary(fmt.Errorf("X %s request failed", name))
+1
View File
@@ -59,6 +59,7 @@ func TestXQuery403Recovery(t *testing.T) {
}
return &http.Response{StatusCode: code, Header: http.Header{"Retry-After": {tt.retryAfter}, "Content-Type": {"application/json"}, "X-Request-Id": {"fixture-request"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
})}}
c.regionalHTTP = c.http
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var out json.RawMessage
+36 -11
View File
@@ -114,13 +114,28 @@ func (s *server) recoveryStatus(w http.ResponseWriter, r *http.Request) {
message(w, 503, "付款节点池配置无效,请检查配置。")
return
}
cards, err := checkout.CardsStatus(s.vault)
if err != nil {
message(w, 503, "无法读取付款卡状态。")
return
}
rotation, err := checkout.PaymentRotationStatus(s.vault)
if err != nil {
message(w, 503, "无法读取付款卡轮换状态。")
return
}
paused, err := checkout.PaymentPaused(s.vault)
if err != nil {
message(w, 503, "无法读取付款保护状态。")
return
}
// Queue counts describe current orders, independently of the saved last task.
var review, processing int
if err = s.db.QueryRow("SELECT COUNT(CASE WHEN status='review' THEN 1 END),COUNT(CASE WHEN status='processing' THEN 1 END) FROM codes").Scan(&review, &processing); err != nil {
message(w, 503, "无法读取当前订单状态。")
return
}
reply(w, 200, map[string]any{"batch": recoveryView(q), "network": network, "summary": map[string]int{"review": review, "processing": processing}})
reply(w, 200, map[string]any{"batch": recoveryView(q), "network": network, "cards": cards, "rotation": rotation, "paused": paused, "summary": map[string]int{"review": review, "processing": processing}})
}
func (s *server) recoveryCandidate(id string) (recoveryItem, error) {
item := recoveryItem{ID: id, State: "skipped"}
@@ -140,7 +155,7 @@ func (s *server) recoveryCandidate(id string) (recoveryItem, error) {
}
raw, err := s.vault.Get("checkout:" + item.Recipient)
if errors.Is(err, sql.ErrNoRows) {
item.Detail = "没有可核实的原账单,需单独检查"
item.Detail = "尚未创建账单,未提交付款;请使用原兑换码和账号重新检查并继续兑换"
return item, nil
}
if err != nil {
@@ -210,8 +225,12 @@ func (s *server) recoveryPreview(w http.ResponseWriter, r *http.Request) {
}
last4, cardCount, binding, err := checkout.CardSummary(s.vault)
if err != nil {
message(w, 503, "付款方式配置无法读取。")
return
if in.Mode != "links" {
message(w, 503, "付款方式配置无法读取。")
return
}
// 仅生成链接不提交付款卡;未配置付款卡时也允许预览。
last4, cardCount, binding = "", 0, ""
}
paused, err := checkout.PaymentPaused(s.vault)
if err != nil {
@@ -320,10 +339,14 @@ func (s *server) recoveryStart(w http.ResponseWriter, r *http.Request) {
release()
}
}()
_, _, binding, err := checkout.CardSummary(s.vault)
if err != nil || binding != q.Binding {
message(w, 409, "付款方式已变化,请重新预览。")
return
binding := q.Binding
// 仅生成链接不涉及付款卡,跳过卡集一致性核验。
if q.Mode != "links" {
_, _, current, err := checkout.CardSummary(s.vault)
if err != nil || current != binding {
message(w, 409, "付款方式已变化,请重新预览。")
return
}
}
count := 0
for _, item := range q.Items {
@@ -507,9 +530,11 @@ func (s *server) recoverOne(item recoveryItem, binding string) (state, detail st
return s.recoverOneOptions(item, binding, "pay", false)
}
func (s *server) recoverOneOptions(item recoveryItem, binding, mode string, verified bool) (state, detail string, stop bool) {
_, _, current, err := checkout.CardSummary(s.vault)
if err != nil || current != binding {
return "blocked", "付款方式配置变化,已停止", true
if mode != "links" {
_, _, current, err := checkout.CardSummary(s.vault)
if err != nil || current != binding {
return "blocked", "付款方式配置变化,已停止", true
}
}
checked, err := s.recoveryCandidate(item.ID)
if err != nil || checked != item {
+14
View File
@@ -220,3 +220,17 @@ func TestRecoveryBlockedAdviceIsNotQueued(t *testing.T) {
t.Fatal("no-retry order queued")
}
}
func TestRecoveryLinksPreviewWithoutCards(t *testing.T) {
s := resumeFixture(t, "review", "created")
if w := recoveryRequest(s, "preview", `{"mode":"links"}`); w.Code != 200 {
t.Fatal(w.Body.String())
}
q, _ := s.loadRecovery()
if q.Mode != "links" || q.Last4 != "" || q.Binding != "" {
t.Fatalf("bad links preview: %+v", q)
}
if w := recoveryRequest(s, "preview", `{}`); w.Code != 503 {
t.Fatal("pay preview accepted without cards")
}
}