|
|
@@ -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) => {
|