diff --git a/README.md b/README.md index 67dd9cd..674cb2e 100644 --- a/README.md +++ b/README.md @@ -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`。手动完成原账单后,可查询原订单以核对成功状态。 **付款结果不明怎么办?** 系统宁可标记「待核实」也不会重复扣款。用户用原兑换码点「重新检查并继续兑换」即可自动核对,确认成功后自动补上状态。 diff --git a/cmd/xgift/main.go b/cmd/xgift/main.go index 0feefd7..31289f1 100644 --- a/cmd/xgift/main.go +++ b/cmd/xgift/main.go @@ -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 == "" { diff --git a/docs/payment-outbounds.md b/docs/payment-outbounds.md index aa2f602..704efd8 100644 --- a/docs/payment-outbounds.md +++ b/docs/payment-outbounds.md @@ -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//init` 初始化核验**可换节点重试,每个请求最多 3 次(含首次)。这不包含生成新账单的 X mutation。 - **卡信息提交、付款确认等写请求不自动重放**。即使网络出错并记录冷却,也立即返回失败/待核实状态;之后只能查询原结果,不会在别的出口重新扣款。 diff --git a/internal/checkout/cards.go b/internal/checkout/cards.go index e4d0fea..1009e4d 100644 --- a/internal/checkout/cards.go +++ b/internal/checkout/cards.go @@ -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 } diff --git a/internal/checkout/cards_test.go b/internal/checkout/cards_test.go index 4b360fc..af1564a 100644 --- a/internal/checkout/cards_test.go +++ b/internal/checkout/cards_test.go @@ -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]) + } +} diff --git a/internal/checkout/payment_cooldown.go b/internal/checkout/payment_cooldown.go index 91e63a3..5b29e0c 100644 --- a/internal/checkout/payment_cooldown.go +++ b/internal/checkout/payment_cooldown.go @@ -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 { diff --git a/internal/checkout/payment_rotation.go b/internal/checkout/payment_rotation.go index f7c2a38..ec19393 100644 --- a/internal/checkout/payment_rotation.go +++ b/internal/checkout/payment_rotation.go @@ -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 diff --git a/internal/checkout/payment_rotation_test.go b/internal/checkout/payment_rotation_test.go index 50e8514..0178fd0 100644 --- a/internal/checkout/payment_rotation_test.go +++ b/internal/checkout/payment_rotation_test.go @@ -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 { diff --git a/internal/checkout/transport_test.go b/internal/checkout/transport_test.go index 663cbbb..3713c41 100644 --- a/internal/checkout/transport_test.go +++ b/internal/checkout/transport_test.go @@ -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) } } diff --git a/internal/checkout/xapi.go b/internal/checkout/xapi.go index 60993f3..b53490e 100644 --- a/internal/checkout/xapi.go +++ b/internal/checkout/xapi.go @@ -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)) diff --git a/internal/checkout/xapi_retry_test.go b/internal/checkout/xapi_retry_test.go index 42b6520..a9acc09 100644 --- a/internal/checkout/xapi_retry_test.go +++ b/internal/checkout/xapi_retry_test.go @@ -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 diff --git a/internal/site/recovery.go b/internal/site/recovery.go index ab05554..120f8b0 100644 --- a/internal/site/recovery.go +++ b/internal/site/recovery.go @@ -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 { diff --git a/internal/site/recovery_test.go b/internal/site/recovery_test.go index 1c9be48..a4b58a8 100644 --- a/internal/site/recovery_test.go +++ b/internal/site/recovery_test.go @@ -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") + } +}