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:
13 files changed
+510
-130
No files matched your search
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user