Ver Fonte

fix: 修复扫货406错误 - 添加确认交易逻辑

1. http_post_form_steam检测406确认响应并不重试,直接返回
2. 新增operate_confirmation_with_retry_snapshot确认交易函数
3. 新增purchase_one_listing_snapshot快照版本购买函数
4. 购买流程改为使用账号快照,避免长时间持有全局锁
5. post_buylisting_with_retry检测406响应并直接返回

问题根源:Steam返回406时需要确认,confirmation_id必须通过
http://localhost:5000/trade-confirmations/operate接口确认。之前代码
重试时继续用confirmation=0,导致触发Steam防刷机制(502错误)。

Made-with: Cursor
wuzilong há 6 meses atrás
pai
commit
71ab5573f5
1 ficheiros alterados com 326 adições e 19 exclusões
  1. 326 19
      client-tauri/src-tauri/src/commands/buff_sweep_runner.rs

+ 326 - 19
client-tauri/src-tauri/src/commands/buff_sweep_runner.rs

@@ -510,6 +510,7 @@ async fn interruptible_sleep_default(dur: Duration) {
     interruptible_sleep(dur, 10, 500).await;
 }
 
+#[derive(Debug, Clone)]
 struct LocalAccount {
     steam_username: String,
     mafile_path: String,
@@ -1370,6 +1371,21 @@ async fn http_post_form_steam(
                     .unwrap_or_default();
                 let elapsed_ms = request_start.elapsed().as_millis() as u64;
 
+                // 检查是否是需要确认的响应(406 + need_confirmation=true)
+                // 这类响应不应重试,应交给上层处理确认逻辑
+                let is_confirmation_needed = status == reqwest::StatusCode::NOT_ACCEPTABLE
+                    && text.contains("need_confirmation")
+                    && text.contains("true");
+                if is_confirmation_needed {
+                    let confirm_msg = format!(
+                        "[sweep:{}] Steam POST 返回需要确认(406),不重试直接返回",
+                        label
+                    );
+                    info!("{}", confirm_msg);
+                    sweep_log_diag(sweep_file, rid, "info", &confirm_msg).await;
+                    return Ok((status, text));
+                }
+
                 // 只有失败的请求才记录日志
                 if !status.is_success() {
                     log_api_call_error(
@@ -1658,6 +1674,137 @@ async fn operate_confirmation_with_retry(
     ))
 }
 
+/// 带重试的 operate_confirmation(快照版本):修改的是传入的账号快照,不修改全局账号。
+/// 返回确认是否成功。
+async fn operate_confirmation_with_retry_snapshot(
+    acc: &mut LocalAccount,  // 账号快照(修改会影响调用方)
+    confirmation_id: &str,
+    max_retries: u32,
+    sweep_file: Option<&SweepShared>,
+) -> Result<bool, String> {
+    let url = format!(
+        "{}/trade-confirmations/operate",
+        STEAM_API_BASE.trim_end_matches('/')
+    );
+    let cid: i64 = confirmation_id.parse().map_err(|_| "confirmation_id 无效".to_string())?;
+
+    // 从代理池获取代理地址(用于传递给5000端口API)
+    let proxy_url = get_proxy_from_global_pool(sweep_file).await?;
+
+    for attempt in 1..=max_retries {
+        // 直连本地5000端口,不走代理
+        let client = reqwest::Client::builder()
+            .timeout(Duration::from_secs(25))
+            .build()
+            .map_err(|e| format!("创建HTTP客户端失败: {}", e))?;
+
+        // 每次重试时重新构建 body(因为 session 可能已更新)
+        let body = json!({
+            "MaFilePath": acc.mafile_path,
+            "Session": {
+                "SteamId": acc.steam_id,
+                "AccessToken": acc.access_token,
+                "RefreshToken": acc.refresh_token,
+                "SessionId": acc.session_id,
+            },
+            "ConfirmationId": cid,
+            "Operation": "accept",
+            "ProxyUrl": proxy_url,  // 传递代理信息给5000端口API
+        });
+
+        match client
+            .post(&url)
+            .json(&body)
+            .send()
+            .await
+        {
+            Ok(resp) => {
+                let status = resp.status();
+                match resp.text().await {
+                    Ok(text) => {
+                        if !status.is_success() {
+                            info!(
+                                "[confirm] confirmation_id={} attempt={}/{} http状态码非成功: {}",
+                                confirmation_id, attempt, max_retries, status
+                            );
+                            if attempt < max_retries {
+                                tokio::time::sleep(Duration::from_millis(3000)).await;
+                                continue;
+                            }
+                            return Ok(false);
+                        }
+                        let resp_json: Value = serde_json::from_str(&text).unwrap_or(json!({}));
+
+                        // 更新快照的 session 信息
+                        if let Some(sess) = resp_json.get("session").and_then(|s| s.as_object()) {
+                            if let Some(s) = sess.get("sessionId").and_then(|x| x.as_str()) {
+                                acc.session_id = s.to_string();
+                            }
+                            if let Some(s) = sess.get("accessToken").and_then(|x| x.as_str()) {
+                                acc.access_token = s.to_string();
+                            }
+                            if let Some(s) = sess.get("refreshToken").and_then(|x| x.as_str()) {
+                                acc.refresh_token = s.to_string();
+                            }
+                            if let Some(sid) = sess.get("steamId").and_then(|x| x.as_i64()) {
+                                acc.steam_id = sid;
+                            }
+                        }
+
+                        let success = resp_json.get("success") == Some(&json!(true))
+                            || resp_json.get("success") == Some(&json!(1));
+
+                        if success {
+                            info!(
+                                "[confirm] confirmation_id={} 确认成功 attempt={}/{}",
+                                confirmation_id, attempt, max_retries
+                            );
+                            return Ok(true);
+                        } else {
+                            info!(
+                                "[confirm] confirmation_id={} 确认返回失败 attempt={}/{} resp={}",
+                                confirmation_id,
+                                attempt,
+                                max_retries,
+                                clip_buy_log(&text, 200)
+                            );
+                            if attempt < max_retries {
+                                tokio::time::sleep(Duration::from_millis(3000)).await;
+                                continue;
+                            }
+                        }
+                    }
+                    Err(e) => {
+                        info!(
+                            "[confirm] confirmation_id={} 读取响应失败 attempt={}/{}: {}",
+                            confirmation_id, attempt, max_retries, e
+                        );
+                        if attempt < max_retries {
+                            tokio::time::sleep(Duration::from_millis(3000)).await;
+                            continue;
+                        }
+                    }
+                }
+            }
+            Err(e) => {
+                info!(
+                    "[confirm] confirmation_id={} HTTP请求失败 attempt={}/{}: {}",
+                    confirmation_id, attempt, max_retries, e
+                );
+                if attempt < max_retries {
+                    tokio::time::sleep(Duration::from_millis(3000)).await;
+                    continue;
+                }
+            }
+        }
+    }
+
+    Err(format!(
+        "operate_confirmation 连续失败 {} 次",
+        max_retries
+    ))
+}
+
 async fn post_buylisting(
     acc: &LocalAccount,
     listing_id: &str,
@@ -1819,6 +1966,18 @@ async fn post_buylisting_with_retry(
                 // 解析 JSON
                 match serde_json::from_str::<Value>(&text) {
                     Ok(parsed) => {
+                        // 检查是否是需要确认的响应(406 + need_confirmation=true)
+                        // 这类响应不应重试,应直接返回
+                        if status == reqwest::StatusCode::NOT_ACCEPTABLE
+                            && parsed.get("need_confirmation") == Some(&json!(true))
+                        {
+                            info!(
+                                "[buy2] listing_id={} confirmation_id={} 返回需要确认(406) attempt={}/{}",
+                                listing_id, confirmation, attempt, max_retries
+                            );
+                            return Ok((status, text, parsed));
+                        }
+
                         // 检查是否购买成功
                         if let Some(w) = parsed.get("wallet_info").and_then(|x| x.as_object()) {
                             if w.get("success").and_then(|x| x.as_i64()) == Some(1) {
@@ -1874,6 +2033,135 @@ async fn post_buylisting_with_retry(
     Err(format!("post_buylisting 连续失败 {} 次", max_retries))
 }
 
+/// 单个 listing 购买(快照版本):使用独立的账号快照,不持有全局锁。
+/// 返回购买结果和最终的账号快照(用于成功后写回全局状态)。
+async fn purchase_one_listing_snapshot(
+    mut acc: LocalAccount,  // 账号快照(所有权)
+    steam_url: &str,
+    listing_id: &str,
+    subtotal: i64,
+    steam_fee: i64,
+    pub_fee: i64,
+    client: &reqwest::Client,
+    retry_times: u32,
+    retry_interval_ms: u64,
+    sweep_file: Option<&SweepShared>,
+    steam_account: &str,
+) -> Result<(bool, Option<i64>, String, LocalAccount), String> {
+    let fee = steam_fee + pub_fee;
+    let total = subtotal + fee;
+    let is_us = acc.is_us_account;
+    let (status_first, raw_first, first) = post_buylisting(
+        &acc,
+        listing_id,
+        steam_url,
+        subtotal,
+        fee,
+        total,
+        "0",
+        "buy",
+        retry_times,
+        retry_interval_ms,
+        sweep_file,
+        steam_account,
+        is_us,
+    )
+    .await?;
+    if let Some(w) = first.get("wallet_info").and_then(|x| x.as_object()) {
+        if w.get("success").and_then(|x| x.as_i64()) == Some(1) {
+            return Ok((true, Some(total), "购买成功(无需确认)".into(), acc));
+        }
+    }
+    if first.get("need_confirmation") == Some(&json!(true)) {
+        let confirmation_id = first
+            .get("confirmation")
+            .and_then(|x| x.get("confirmation_id"))
+            .and_then(|x| x.as_str())
+            .unwrap_or("")
+            .trim()
+            .to_string();
+        if confirmation_id.is_empty() {
+            return Ok((false, None, "需要确认但未返回 confirmation_id".into(), acc));
+        }
+
+        // 第一步:确认交易(修改快照,不修改全局账号)
+        let confirm_result = operate_confirmation_with_retry_snapshot(&mut acc, &confirmation_id, 10, sweep_file).await;
+        let ok = match confirm_result {
+            Ok(result) => result,
+            Err(e) => {
+                info!(
+                    "[confirm] confirmation_id={} 连续10次失败,尝试直接 buy2: {}",
+                    confirmation_id, e
+                );
+                false
+            }
+        };
+
+        if !ok {
+            info!(
+                "[confirm] confirmation_id={} 确认失败,但继续尝试 buy2",
+                confirmation_id
+            );
+        }
+
+        // 第二步:使用幂等重试完成购买(最多10次)
+        let buy2_result = post_buylisting_with_retry(
+            &acc,
+            listing_id,
+            steam_url,
+            subtotal,
+            fee,
+            total,
+            &confirmation_id,
+            "buy2",
+            retry_times,
+            retry_interval_ms,
+            sweep_file,
+            steam_account,
+            is_us,
+            10,
+        )
+        .await;
+
+        match buy2_result {
+            Ok((status_second, raw_second, second)) => {
+                if let Some(w) = second.get("wallet_info").and_then(|x| x.as_object()) {
+                    if w.get("success").and_then(|x| x.as_i64()) == Some(1) {
+                        return Ok((true, Some(total), "购买成功(确认后完成)".into(), acc));
+                    }
+                }
+                return Ok((
+                    false,
+                    None,
+                    format!(
+                        "确认后购买失败: {}",
+                        summarize_buylisting_response(status_second, &raw_second, &second)
+                    ),
+                    acc,
+                ));
+            }
+            Err(e) => {
+                return Ok((
+                    false,
+                    None,
+                    format!("buy2 连续失败: {}", e),
+                    acc,
+                ));
+            }
+        }
+    }
+    Ok((
+        false,
+        None,
+        format!(
+            "购买失败: {}",
+            summarize_buylisting_response(status_first, &raw_first, &first)
+        ),
+        acc,
+    ))
+}
+
+/// 单个 listing 购买(原始版本,保留兼容性)。
 async fn purchase_one_listing(
     acc: &mut LocalAccount,
     steam_url: &str,
@@ -1928,7 +2216,6 @@ async fn purchase_one_listing(
         let ok = match confirm_result {
             Ok(result) => result,
             Err(e) => {
-                // 10次都失败,继续尝试 buy2(可能确认实际上成功了)
                 info!(
                     "[confirm] confirmation_id={} 连续10次失败,尝试直接 buy2: {}",
                     confirmation_id, e
@@ -2702,31 +2989,44 @@ async fn purchase_ready_with_account_rotation(
     let mut used_account = String::new();
     let mut attempt_traces: Vec<String> = Vec::new();
 
-    // 按轮询顺序尝试购买
+    // 按轮询顺序尝试购买(使用账号快照,避免长时间持有锁)
     for uname in &account_rotation {
         if cancelled() {
             return;
         }
-        // 找到对应的账号锁并获取锁
-        let mut acc_guard_opt = None;
-        for acc_arc in shared.accounts.iter() {
-            let acc = acc_arc.lock().await;
-            if acc.steam_username == *uname {
-                acc_guard_opt = Some((Arc::clone(&acc_arc), uname.clone()));
-                break;
+        let uname_clone = uname.clone();  // 克隆用户名供后续使用
+        
+        // 1. 快速查找账号 Arc(不持有锁)
+        let acc_arc_opt = {
+            // 临时短锁,只为了查找对应的 Arc
+            let mut found: Option<Arc<AsyncMutex<LocalAccount>>> = None;
+            for acc_arc in shared.accounts.iter() {
+                let guard = acc_arc.lock().await;
+                if guard.steam_username == *uname {
+                    found = Some(Arc::clone(acc_arc));
+                    break;
+                }
             }
-        }
-        let (acc_arc, uname_clone) = match acc_guard_opt {
-            Some(tuple) => tuple,
+            found
+        };
+        
+        let acc_arc = match acc_arc_opt {
+            Some(arc) => arc,
             None => {
                 attempt_traces.push(format!("{}: 账号未找到", uname));
                 continue;
             }
         };
-        let mut acc_guard = acc_arc.lock().await;
 
-        match purchase_one_listing(
-            &mut *acc_guard,
+        // 2. 读取账号快照(短锁,立即释放)
+        let account_snapshot = {
+            let guard = acc_arc.lock().await;
+            (*guard).clone()  // 克隆整个账号作为快照
+        };  // ← 锁在这里立即释放!
+
+        // 3. 使用快照进行购买(无锁,并发执行)
+        let purchase_result = purchase_one_listing_snapshot(
+            account_snapshot,
             &ready.steam_link,
             ready.candidate.listing_id.as_str(),
             ready.candidate.subtotal,
@@ -2738,16 +3038,23 @@ async fn purchase_ready_with_account_rotation(
             Some(shared.as_ref()),
             &uname_clone,
         )
-        .await
-        {
-            Ok((true, spent, _msg)) => {
+        .await;
+
+        match purchase_result {
+            Ok((true, spent, _msg, final_account)) => {
                 bought = true;
                 purchase_price = spent.map(|cents| cents as f64 / 100.0);
                 used_account = uname_clone.clone();
+                
+                // 4. 成功后写回全局账号(短锁)
+                let mut guard = acc_arc.lock().await;
+                *guard = final_account;  // 更新为最终状态
+                
                 break;
             }
-            Ok((false, _, msg)) => {
+            Ok((false, _, msg, _final_account)) => {
                 attempt_traces.push(format!("{}: {}", uname_clone, msg));
+                // 失败,继续下一个账号(不持有锁)
                 continue;
             }
             Err(e) => {