buff_sweep_service.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065
  1. """
  2. BUFF 扫货服务(真实链路版):
  3. - Steam listings 分页拉取
  4. - csgofloat 磨损校验
  5. - buylisting 购买
  6. - trade-confirmations/operate + buylisting(confirmation) 购买确认
  7. - 多 Steam 账号余额切换与失败重试
  8. """
  9. import json
  10. import os
  11. import random
  12. import time
  13. from dataclasses import dataclass
  14. from decimal import Decimal, InvalidOperation
  15. from typing import Any, Dict, List, Optional, Tuple
  16. from urllib.parse import urlencode
  17. from urllib.request import Request, build_opener, ProxyHandler
  18. from urllib.error import HTTPError, URLError
  19. from sqlalchemy.orm import Session
  20. from app.database import SessionLocal
  21. from app.models.buff_sweep_request import BuffSweepRequest
  22. from app.models.buff_sweep_request_item import BuffSweepRequestItem
  23. from app.models.buff_sweep_request_log import BuffSweepRequestLog
  24. from app.models.buff_selection_request_item import BuffSelectionRequestItem
  25. from app.models.steam_account import SteamAccount
  26. from app.models.system_config import SystemConfig
  27. from app.core.logger import logger
  28. STEAM_API_BASE = "http://localhost:5000"
  29. DEFAULT_PAGE_SIZE = 10
  30. DEFAULT_MAX_SCAN_PER_ITEM = 60
  31. MIN_BALANCE_CENTS = 50 # 0.5 USD
  32. @dataclass
  33. class LocalSteamAccount:
  34. steam_username: str
  35. mafile_path: str
  36. steam_id: int
  37. access_token: str
  38. refresh_token: str
  39. session_id: str
  40. steam_login_secure: str
  41. balance_cents: int = 0
  42. @dataclass
  43. class ProxyContext:
  44. mode: str # none/account/api
  45. proxy_url: Optional[str] = None
  46. api_url: Optional[str] = None
  47. proxy_retry_times: int = 1
  48. proxy_retry_interval_ms: int = 800
  49. def _get_system_config_int(db: Session, key: str, default: int) -> int:
  50. row = db.query(SystemConfig).filter(
  51. SystemConfig.config_key == key,
  52. SystemConfig.app_name == "backend-selection",
  53. SystemConfig.is_deleted == False,
  54. ).first()
  55. if not row or row.config_value in (None, ""):
  56. return default
  57. try:
  58. return int(row.config_value)
  59. except (ValueError, TypeError):
  60. return default
  61. def _http_request(
  62. method: str,
  63. url: str,
  64. headers: Optional[Dict[str, str]] = None,
  65. body: Optional[bytes] = None,
  66. timeout: int = 25,
  67. proxy_url: Optional[str] = None,
  68. ) -> Tuple[int, str]:
  69. req = Request(url=url, method=method.upper(), data=body, headers=headers or {})
  70. opener = build_opener(ProxyHandler({"http": proxy_url, "https": proxy_url})) if proxy_url else build_opener()
  71. try:
  72. with opener.open(req, timeout=timeout) as resp:
  73. text = resp.read().decode("utf-8", errors="replace")
  74. return resp.getcode(), text
  75. except HTTPError as e:
  76. return e.code, e.read().decode("utf-8", errors="replace")
  77. except URLError as e:
  78. raise RuntimeError(f"请求失败: {e}") from e
  79. def _post_json(
  80. url: str,
  81. payload: Dict[str, Any],
  82. timeout: int = 25,
  83. ) -> Tuple[int, Dict[str, Any], str]:
  84. body = json.dumps(payload).encode("utf-8")
  85. code, text = _http_request(
  86. method="POST",
  87. url=url,
  88. headers={"Content-Type": "application/json"},
  89. body=body,
  90. timeout=timeout,
  91. proxy_url=None,
  92. )
  93. try:
  94. parsed = json.loads(text) if text else {}
  95. except Exception:
  96. parsed = {}
  97. return code, parsed, text
  98. def _parse_proxy_api_response(body: str) -> Optional[str]:
  99. txt = (body or "").strip()
  100. if not txt:
  101. return None
  102. try:
  103. obj = json.loads(txt)
  104. except Exception:
  105. obj = None
  106. if isinstance(obj, str):
  107. return obj if "://" in obj else f"http://{obj}"
  108. if isinstance(obj, dict):
  109. for k in ("proxyUrl", "proxy_url", "url", "proxy"):
  110. v = obj.get(k)
  111. if isinstance(v, str) and v.strip():
  112. return v if "://" in v else f"http://{v}"
  113. host = obj.get("ip") or obj.get("host") or obj.get("server")
  114. port = obj.get("port") or obj.get("serverPort")
  115. if host and port:
  116. return f"http://{host}:{port}"
  117. first = txt.splitlines()[0].split(",")[0].strip()
  118. if not first:
  119. return None
  120. return first if "://" in first else f"http://{first}"
  121. def _is_proxy_related_failure(status_code: Optional[int], body: Optional[str], err_msg: Optional[str] = None) -> bool:
  122. if status_code in (407, 502, 503, 504):
  123. return True
  124. text = f"{body or ''} {err_msg or ''}".lower()
  125. tokens = [
  126. "proxy",
  127. "tunnel",
  128. "timed out",
  129. "timeout",
  130. "connection refused",
  131. "connection reset",
  132. "failed to connect",
  133. "unreachable",
  134. "network is unreachable",
  135. "name or service not known",
  136. ]
  137. return any(t in text for t in tokens)
  138. def _resolve_steam_msg_dir() -> Optional[str]:
  139. candidates = []
  140. env_dir = (os.environ.get("STEAM_MSG_DIR") or "").strip()
  141. if env_dir:
  142. candidates.append(env_dir)
  143. home = os.path.expanduser("~")
  144. candidates.extend([
  145. os.path.join(home, "Library", "Application Support", "steam-msg"),
  146. os.path.join(home, "AppData", "Roaming", "steam-msg"),
  147. os.path.join(os.getcwd(), "steam-msg"),
  148. ])
  149. # backend-client/app/services -> project root
  150. project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
  151. candidates.append(os.path.join(project_root, "steam-msg"))
  152. for c in candidates:
  153. if c and os.path.isdir(c):
  154. return c
  155. return None
  156. def _resolve_proxy_context() -> ProxyContext:
  157. steam_msg_dir = _resolve_steam_msg_dir()
  158. if not steam_msg_dir:
  159. return ProxyContext(mode="none")
  160. cfg_path = os.path.join(steam_msg_dir, "proxy_config.json")
  161. if not os.path.exists(cfg_path):
  162. return ProxyContext(mode="none")
  163. try:
  164. with open(cfg_path, "r", encoding="utf-8") as f:
  165. cfg = json.load(f)
  166. except Exception:
  167. return ProxyContext(mode="none")
  168. mode = (cfg.get("effectiveMode") or "").strip().lower()
  169. if mode not in ("api", "account"):
  170. return ProxyContext(mode="none")
  171. brand_id = cfg.get("effectiveBrandId")
  172. brand = (cfg.get("configs") or {}).get(brand_id) if brand_id else None
  173. if not isinstance(brand, dict):
  174. return ProxyContext(mode="none")
  175. if mode == "account":
  176. host = (brand.get("serverHost") or "").strip()
  177. port = int(brand.get("serverPort") or 0)
  178. if not host or port <= 0:
  179. return ProxyContext(mode="none")
  180. user = (brand.get("serverUsername") or "").strip()
  181. pwd = brand.get("serverPassword") or ""
  182. if user:
  183. return ProxyContext(mode="account", proxy_url=f"http://{user}:{pwd}@{host}:{port}")
  184. return ProxyContext(mode="account", proxy_url=f"http://{host}:{port}")
  185. api_url = (brand.get("apiUrl") or "").strip()
  186. if not api_url:
  187. return ProxyContext(mode="none")
  188. try:
  189. code, text = _http_request("GET", api_url, timeout=12)
  190. if code < 200 or code >= 300:
  191. return ProxyContext(mode="api", api_url=api_url, proxy_url=None)
  192. return ProxyContext(mode="api", api_url=api_url, proxy_url=_parse_proxy_api_response(text))
  193. except Exception:
  194. return ProxyContext(mode="api", api_url=api_url, proxy_url=None)
  195. def _refresh_api_proxy(ctx: ProxyContext) -> bool:
  196. if ctx.mode != "api" or not ctx.api_url:
  197. return False
  198. try:
  199. # 加时间戳避免代理平台缓存同一条
  200. sep = "&" if "?" in ctx.api_url else "?"
  201. refresh_url = f"{ctx.api_url}{sep}_ts={random.randint(100000, 999999)}"
  202. code, text = _http_request("GET", refresh_url, timeout=12)
  203. if code < 200 or code >= 300:
  204. return False
  205. parsed = _parse_proxy_api_response(text)
  206. if not parsed:
  207. return False
  208. ctx.proxy_url = parsed
  209. return True
  210. except Exception:
  211. return False
  212. def _ensure_proxy_ready(proxy_ctx: Optional[ProxyContext], refresh_if_needed: bool = True) -> Tuple[bool, str]:
  213. """扫货链路强制代理:没有可用代理时必须失败,禁止直连 Steam。"""
  214. if not proxy_ctx or proxy_ctx.mode == "none":
  215. return False, "未配置可用代理(扫货链路禁止直连 Steam)"
  216. if proxy_ctx.mode == "api" and not proxy_ctx.proxy_url and refresh_if_needed:
  217. _refresh_api_proxy(proxy_ctx)
  218. if not proxy_ctx.proxy_url:
  219. return False, f"代理模式={proxy_ctx.mode} 但未获取到可用代理地址(扫货链路禁止直连 Steam)"
  220. return True, ""
  221. def _http_request_with_proxy_retry(
  222. method: str,
  223. url: str,
  224. headers: Optional[Dict[str, str]] = None,
  225. body: Optional[bytes] = None,
  226. timeout: int = 25,
  227. proxy_ctx: Optional[ProxyContext] = None,
  228. debug_meta: Optional[Dict[str, Any]] = None,
  229. ) -> Tuple[int, str]:
  230. ok, err = _ensure_proxy_ready(proxy_ctx, refresh_if_needed=True)
  231. if not ok:
  232. raise RuntimeError(err)
  233. proxy_url = proxy_ctx.proxy_url if proxy_ctx else None
  234. retry_times = max(0, int(proxy_ctx.proxy_retry_times if proxy_ctx else 0))
  235. retry_interval = max(0, int(proxy_ctx.proxy_retry_interval_ms if proxy_ctx else 0))
  236. last_err: Optional[Exception] = None
  237. code: Optional[int] = None
  238. text: Optional[str] = None
  239. for attempt in range(retry_times + 1):
  240. try:
  241. code, text = _http_request(
  242. method,
  243. url,
  244. headers=headers,
  245. body=body,
  246. timeout=timeout,
  247. proxy_url=proxy_ctx.proxy_url if proxy_ctx else proxy_url,
  248. )
  249. if not (
  250. proxy_ctx
  251. and proxy_ctx.mode == "api"
  252. and _is_proxy_related_failure(code, text)
  253. and attempt < retry_times
  254. ):
  255. return code, text
  256. if debug_meta is not None:
  257. debug_meta["retry_attempts"] = int(debug_meta.get("retry_attempts", 0)) + 1
  258. if retry_interval > 0:
  259. time.sleep(retry_interval / 1000.0)
  260. refreshed = _refresh_api_proxy(proxy_ctx)
  261. if debug_meta is not None and refreshed:
  262. debug_meta["proxy_refreshed"] = True
  263. except Exception as e:
  264. last_err = e
  265. if not (
  266. proxy_ctx
  267. and proxy_ctx.mode == "api"
  268. and _is_proxy_related_failure(None, None, str(e))
  269. and attempt < retry_times
  270. ):
  271. raise
  272. if debug_meta is not None:
  273. debug_meta["retry_attempts"] = int(debug_meta.get("retry_attempts", 0)) + 1
  274. if retry_interval > 0:
  275. time.sleep(retry_interval / 1000.0)
  276. refreshed = _refresh_api_proxy(proxy_ctx)
  277. if debug_meta is not None and refreshed:
  278. debug_meta["proxy_refreshed"] = True
  279. if code is not None and text is not None:
  280. return code, text
  281. if last_err:
  282. raise last_err
  283. raise RuntimeError("代理重试后请求失败")
  284. def _post_steam_api_with_proxy_retry(
  285. path: str,
  286. payload: Dict[str, Any],
  287. proxy_ctx: Optional[ProxyContext],
  288. timeout: int = 25,
  289. debug_meta: Optional[Dict[str, Any]] = None,
  290. ) -> Tuple[int, Dict[str, Any], str]:
  291. url = f"{STEAM_API_BASE.rstrip('/')}/{path.lstrip('/')}"
  292. ok, err = _ensure_proxy_ready(proxy_ctx, refresh_if_needed=True)
  293. if not ok:
  294. raise RuntimeError(err)
  295. retry_times = max(0, int(proxy_ctx.proxy_retry_times if proxy_ctx else 0))
  296. retry_interval = max(0, int(proxy_ctx.proxy_retry_interval_ms if proxy_ctx else 0))
  297. last_code = 0
  298. last_text = ""
  299. last_parsed: Dict[str, Any] = {}
  300. for attempt in range(retry_times + 1):
  301. req_payload = dict(payload)
  302. if proxy_ctx and proxy_ctx.proxy_url:
  303. req_payload["ProxyUrl"] = proxy_ctx.proxy_url
  304. code, parsed, text = _post_json(url, req_payload, timeout=timeout)
  305. last_code, last_text, last_parsed = code, text, parsed
  306. if not (
  307. proxy_ctx
  308. and proxy_ctx.mode == "api"
  309. and _is_proxy_related_failure(code, text)
  310. and attempt < retry_times
  311. ):
  312. return code, parsed, text
  313. if debug_meta is not None:
  314. debug_meta["retry_attempts"] = int(debug_meta.get("retry_attempts", 0)) + 1
  315. if retry_interval > 0:
  316. time.sleep(retry_interval / 1000.0)
  317. refreshed = _refresh_api_proxy(proxy_ctx)
  318. if debug_meta is not None and refreshed:
  319. debug_meta["proxy_refreshed"] = True
  320. return last_code, last_parsed, last_text
  321. def _load_accounts_json() -> Dict[str, Any]:
  322. steam_msg_dir = _resolve_steam_msg_dir()
  323. if not steam_msg_dir:
  324. return {}
  325. path = os.path.join(steam_msg_dir, "accounts.json")
  326. if not os.path.exists(path):
  327. return {}
  328. try:
  329. with open(path, "r", encoding="utf-8") as f:
  330. root = json.load(f)
  331. return root.get("accounts") if isinstance(root.get("accounts"), dict) else {}
  332. except Exception:
  333. return {}
  334. def _build_local_account(steam_username: str, raw: Dict[str, Any]) -> Optional[LocalSteamAccount]:
  335. try:
  336. steam_id = int(raw.get("steamId") or 0)
  337. except Exception:
  338. steam_id = 0
  339. mafile = (raw.get("steam_guard_path") or "").strip()
  340. if not mafile or not os.path.exists(mafile):
  341. return None
  342. access_token = (raw.get("accessToken") or "").strip()
  343. refresh_token = (raw.get("refreshToken") or "").strip()
  344. steam_login_secure = (raw.get("steamLoginSecure") or "").strip()
  345. if steam_id <= 0 or not access_token or not refresh_token or not steam_login_secure:
  346. return None
  347. return LocalSteamAccount(
  348. steam_username=steam_username,
  349. mafile_path=mafile,
  350. steam_id=steam_id,
  351. access_token=access_token,
  352. refresh_token=refresh_token,
  353. session_id=(raw.get("sessionId") or "").strip(),
  354. steam_login_secure=steam_login_secure,
  355. )
  356. def _load_active_steam_accounts(db: Session, user_id: int) -> List[LocalSteamAccount]:
  357. local_accounts = _load_accounts_json()
  358. rows = (
  359. db.query(SteamAccount)
  360. .filter(
  361. SteamAccount.user_id == user_id,
  362. SteamAccount.is_deleted == False,
  363. SteamAccount.is_active == True,
  364. )
  365. .order_by(SteamAccount.id.asc())
  366. .all()
  367. )
  368. out: List[LocalSteamAccount] = []
  369. for r in rows:
  370. raw = local_accounts.get(r.steam_username)
  371. if not isinstance(raw, dict):
  372. continue
  373. acc = _build_local_account(r.steam_username, raw)
  374. if acc:
  375. out.append(acc)
  376. return out
  377. def _refresh_wallet_balance(
  378. account: LocalSteamAccount,
  379. proxy_ctx: Optional[ProxyContext],
  380. debug_meta: Optional[Dict[str, Any]] = None,
  381. ) -> int:
  382. payload: Dict[str, Any] = {"SteamLoginSecure": account.steam_login_secure}
  383. code, resp, body = _post_steam_api_with_proxy_retry(
  384. "wallet-balance",
  385. payload,
  386. proxy_ctx=proxy_ctx,
  387. timeout=25,
  388. debug_meta=debug_meta,
  389. )
  390. if code != 200:
  391. raise RuntimeError(f"获取余额失败: http={code} body={body[:200]}")
  392. amount_raw = str(resp.get("amount") or "0").strip()
  393. try:
  394. cents = int(float(amount_raw))
  395. except Exception as e:
  396. raise RuntimeError(f"余额格式错误: {amount_raw}") from e
  397. account.balance_cents = max(0, cents)
  398. return account.balance_cents
  399. def _parse_wear_range(wear_range: str) -> Optional[Tuple[Decimal, Decimal]]:
  400. if not wear_range:
  401. return None
  402. parts = [p.strip() for p in str(wear_range).split("-")]
  403. if len(parts) != 2:
  404. return None
  405. try:
  406. lo = Decimal(parts[0])
  407. hi = Decimal(parts[1])
  408. except (InvalidOperation, ValueError):
  409. return None
  410. if lo > hi:
  411. lo, hi = hi, lo
  412. return lo, hi
  413. def _format_item_exception_message(account_name: str, reason: str) -> str:
  414. """统一扫货异常展示文案:用户友好提示 + 内部简要原因。"""
  415. clean_reason = " ".join((reason or "").split())[:180] or "未知异常"
  416. return f"扫货异常,已跳过当前饰品并继续。原因:账号={account_name};{clean_reason}"
  417. def _build_render_url(steam_url: str, start: int, count: int) -> str:
  418. base = steam_url.rstrip("/")
  419. if not base.endswith("/render"):
  420. base = f"{base}/render"
  421. query = urlencode({
  422. "query": "",
  423. "start": start,
  424. "count": count,
  425. "country": "US",
  426. "language": "schinese",
  427. "currency": 1,
  428. })
  429. return f"{base}/?{query}"
  430. def _extract_inspect_value(page_json: Dict[str, Any], listing: Dict[str, Any]) -> Optional[str]:
  431. asset = listing.get("asset") or {}
  432. appid = str(asset.get("appid") or "")
  433. contextid = str(asset.get("contextid") or "")
  434. asset_id = str(asset.get("id") or "")
  435. if not appid or not contextid or not asset_id:
  436. return None
  437. assets_root = page_json.get("assets") or {}
  438. app_obj = assets_root.get(appid) or {}
  439. ctx_obj = app_obj.get(contextid) or {}
  440. asset_obj = ctx_obj.get(asset_id) or {}
  441. props = asset_obj.get("asset_properties") or []
  442. if not isinstance(props, list):
  443. return None
  444. for p in props:
  445. if str(p.get("propertyid")) == "6":
  446. v = p.get("string_value")
  447. if v:
  448. return str(v).strip()
  449. return None
  450. def _fetch_float_value(
  451. inspect_value: str,
  452. proxy_ctx: Optional[ProxyContext],
  453. debug_meta: Optional[Dict[str, Any]] = None,
  454. ) -> Optional[Decimal]:
  455. if not inspect_value:
  456. return None
  457. csgofloat_url = "https://inspect.csgofloat.xyz/?" + urlencode({
  458. "url": f"steam://run/730//+csgo_econ_action_preview%20{inspect_value}"
  459. })
  460. headers = {
  461. "Referer": "https://steamcommunity.com/",
  462. "Origin": "https://steamcommunity.com",
  463. "User-Agent": "Mozilla/5.0",
  464. }
  465. code, text = _http_request_with_proxy_retry(
  466. "GET",
  467. csgofloat_url,
  468. headers=headers,
  469. timeout=20,
  470. proxy_ctx=proxy_ctx,
  471. debug_meta=debug_meta,
  472. )
  473. if code != 200:
  474. return None
  475. try:
  476. data = json.loads(text)
  477. fv = data.get("iteminfo", {}).get("floatvalue")
  478. if fv is None:
  479. return None
  480. return Decimal(str(fv))
  481. except Exception:
  482. return None
  483. def _random_addr_fields() -> Dict[str, str]:
  484. return {
  485. "first_name": f"user{random.randint(1000, 9999)}",
  486. "last_name": f"ln{random.randint(1000, 9999)}",
  487. "billing_address": f"{random.randint(10, 9999)} Main St",
  488. "billing_address_two": f"Apt {random.randint(1, 999)}",
  489. "billing_country": "US",
  490. "billing_city": f"City{random.randint(10, 999)}",
  491. "billing_state": "AK",
  492. "billing_postal_code": str(random.randint(10000, 99999)),
  493. }
  494. def _post_buylisting(
  495. listing_id: str,
  496. steam_url: str,
  497. account: LocalSteamAccount,
  498. subtotal: int,
  499. fee: int,
  500. total: int,
  501. confirmation: str,
  502. proxy_ctx: Optional[ProxyContext],
  503. debug_meta: Optional[Dict[str, Any]] = None,
  504. ) -> Dict[str, Any]:
  505. url = f"https://steamcommunity.com/market/buylisting/{listing_id}"
  506. fields = _random_addr_fields()
  507. form_data = {
  508. "sessionid": account.session_id or "",
  509. "currency": "1",
  510. "subtotal": str(subtotal),
  511. "fee": str(fee),
  512. "total": str(total),
  513. "quantity": "1",
  514. "first_name": fields["first_name"],
  515. "last_name": fields["last_name"],
  516. "billing_address": fields["billing_address"],
  517. "billing_address_two": fields["billing_address_two"],
  518. "billing_country": fields["billing_country"],
  519. "billing_city": fields["billing_city"],
  520. "billing_state": fields["billing_state"],
  521. "billing_postal_code": fields["billing_postal_code"],
  522. "save_my_address": "0",
  523. "tradefee_tax": "0",
  524. "confirmation": confirmation,
  525. }
  526. body = urlencode(form_data).encode("utf-8")
  527. cookie = (
  528. "timezoneOffset=28800,0; "
  529. f"sessionid={account.session_id}; "
  530. f"steamLoginSecure={account.steam_login_secure};"
  531. )
  532. headers = {
  533. "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
  534. "Origin": "https://steamcommunity.com",
  535. "Referer": steam_url,
  536. "Cookie": cookie,
  537. "User-Agent": "Mozilla/5.0",
  538. "Accept": "*/*",
  539. }
  540. code, text = _http_request_with_proxy_retry(
  541. method="POST",
  542. url=url,
  543. headers=headers,
  544. body=body,
  545. timeout=25,
  546. proxy_ctx=proxy_ctx,
  547. debug_meta=debug_meta,
  548. )
  549. try:
  550. parsed = json.loads(text) if text else {}
  551. except Exception:
  552. parsed = {}
  553. if code != 200 and not parsed:
  554. raise RuntimeError(f"buylisting 请求失败: http={code} body={text[:200]}")
  555. return parsed
  556. def _operate_confirmation(
  557. account: LocalSteamAccount,
  558. confirmation_id: str,
  559. proxy_ctx: Optional[ProxyContext],
  560. debug_meta: Optional[Dict[str, Any]] = None,
  561. ) -> bool:
  562. payload: Dict[str, Any] = {
  563. "MaFilePath": account.mafile_path,
  564. "Session": {
  565. "SteamId": account.steam_id,
  566. "AccessToken": account.access_token,
  567. "RefreshToken": account.refresh_token,
  568. "SessionId": account.session_id or "",
  569. },
  570. "ConfirmationId": int(str(confirmation_id)),
  571. "Operation": "accept",
  572. }
  573. code, resp, body = _post_steam_api_with_proxy_retry(
  574. "trade-confirmations/operate",
  575. payload,
  576. proxy_ctx=proxy_ctx,
  577. timeout=35,
  578. debug_meta=debug_meta,
  579. )
  580. if code != 200:
  581. return False
  582. if isinstance(resp.get("session"), dict):
  583. session = resp.get("session") or {}
  584. account.session_id = str(session.get("sessionId") or account.session_id or "")
  585. account.access_token = str(session.get("accessToken") or account.access_token)
  586. account.refresh_token = str(session.get("refreshToken") or account.refresh_token)
  587. try:
  588. account.steam_id = int(session.get("steamId") or account.steam_id)
  589. except Exception:
  590. pass
  591. return bool(resp.get("success") is True or resp.get("success") == 1)
  592. def _purchase_one_listing(
  593. account: LocalSteamAccount,
  594. steam_url: str,
  595. listing_id: str,
  596. subtotal: int,
  597. converted_steam_fee: int,
  598. converted_publisher_fee: int,
  599. proxy_ctx: Optional[ProxyContext],
  600. debug_meta: Optional[Dict[str, Any]] = None,
  601. ) -> Tuple[bool, Optional[int], str]:
  602. fee = int(converted_steam_fee) + int(converted_publisher_fee)
  603. total = int(subtotal) + int(fee)
  604. if account.balance_cents < total:
  605. return False, None, f"账号余额不足(余额={account.balance_cents},需={total})"
  606. first = _post_buylisting(
  607. listing_id=listing_id,
  608. steam_url=steam_url,
  609. account=account,
  610. subtotal=subtotal,
  611. fee=fee,
  612. total=total,
  613. confirmation="0",
  614. proxy_ctx=proxy_ctx,
  615. debug_meta=debug_meta,
  616. )
  617. wallet = first.get("wallet_info") if isinstance(first, dict) else None
  618. if isinstance(wallet, dict) and int(wallet.get("success") or 0) == 1:
  619. try:
  620. account.balance_cents = int(float(str(wallet.get("wallet_balance") or "0")))
  621. except Exception:
  622. account.balance_cents = max(0, account.balance_cents - total)
  623. return True, total, "购买成功(无需确认)"
  624. if first.get("need_confirmation") is True:
  625. confirmation_id = str((first.get("confirmation") or {}).get("confirmation_id") or "").strip()
  626. if not confirmation_id:
  627. return False, None, "需要确认但未返回 confirmation_id"
  628. ok = _operate_confirmation(account, confirmation_id, proxy_ctx, debug_meta=debug_meta)
  629. if not ok:
  630. return False, None, "确认订单失败(trade-confirmations/operate)"
  631. second = _post_buylisting(
  632. listing_id=listing_id,
  633. steam_url=steam_url,
  634. account=account,
  635. subtotal=subtotal,
  636. fee=fee,
  637. total=total,
  638. confirmation=confirmation_id,
  639. proxy_ctx=proxy_ctx,
  640. debug_meta=debug_meta,
  641. )
  642. wallet2 = second.get("wallet_info") if isinstance(second, dict) else None
  643. if isinstance(wallet2, dict) and int(wallet2.get("success") or 0) == 1:
  644. try:
  645. account.balance_cents = int(float(str(wallet2.get("wallet_balance") or "0")))
  646. except Exception:
  647. account.balance_cents = max(0, account.balance_cents - total)
  648. return True, total, "购买成功(确认后完成)"
  649. return False, None, f"确认后购买失败: {json.dumps(second, ensure_ascii=False)[:220]}"
  650. return False, None, f"购买失败: {json.dumps(first, ensure_ascii=False)[:220]}"
  651. def _scan_item_and_try_buy(
  652. item: BuffSelectionRequestItem,
  653. target_price_usd: Decimal,
  654. wear_min: Decimal,
  655. wear_max: Decimal,
  656. account: LocalSteamAccount,
  657. proxy_ctx: Optional[ProxyContext],
  658. page_size: int,
  659. max_scan: int,
  660. debug_meta: Optional[Dict[str, Any]] = None,
  661. ) -> Tuple[bool, Optional[Decimal], Optional[Decimal], str]:
  662. if not item.steam_link:
  663. return False, None, None, "缺少 steam_link,无法扫货"
  664. # 仅第一页:每个饰品只请求一次 render(不翻页;单页内最多处理 max_scan 条)
  665. scanned = 0
  666. render_url = _build_render_url(item.steam_link, 0, page_size)
  667. headers = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
  668. code, text = _http_request_with_proxy_retry(
  669. "GET",
  670. render_url,
  671. headers=headers,
  672. timeout=20,
  673. proxy_ctx=proxy_ctx,
  674. debug_meta=debug_meta,
  675. )
  676. if code != 200:
  677. return False, None, None, f"拉取 listings 失败: http={code}"
  678. try:
  679. page_json = json.loads(text)
  680. except Exception:
  681. return False, None, None, "拉取 listings 返回非 JSON"
  682. listinginfo = page_json.get("listinginfo") or {}
  683. if not isinstance(listinginfo, dict) or not listinginfo:
  684. return False, None, None, "无可用 listing 数据"
  685. for listing_id, listing in listinginfo.items():
  686. if scanned >= max_scan:
  687. break
  688. scanned += 1
  689. try:
  690. subtotal = int(listing.get("converted_price") or 0)
  691. steam_fee = int(listing.get("converted_steam_fee") or 0)
  692. publisher_fee = int(listing.get("converted_publisher_fee") or 0)
  693. except Exception:
  694. continue
  695. total_cents = subtotal + steam_fee + publisher_fee
  696. total_usd = (Decimal(total_cents) / Decimal("100")).quantize(Decimal("0.01"))
  697. if total_usd > target_price_usd:
  698. continue
  699. inspect_value = _extract_inspect_value(page_json, listing)
  700. if not inspect_value:
  701. continue
  702. fv = _fetch_float_value(inspect_value, proxy_ctx, debug_meta=debug_meta)
  703. if fv is None or fv < wear_min or fv > wear_max:
  704. continue
  705. ok, spent_cents, msg = _purchase_one_listing(
  706. account=account,
  707. steam_url=item.steam_link,
  708. listing_id=str(listing_id),
  709. subtotal=subtotal,
  710. converted_steam_fee=steam_fee,
  711. converted_publisher_fee=publisher_fee,
  712. proxy_ctx=proxy_ctx,
  713. debug_meta=debug_meta,
  714. )
  715. if ok:
  716. purchase_price = (Decimal(spent_cents or total_cents) / Decimal("100")).quantize(Decimal("0.01"))
  717. return True, purchase_price, fv, msg
  718. # 购买失败(包括确认失败)按流程跳过当前 listing,继续下一个
  719. return False, None, None, "未命中可购买 listing 或购买失败"
  720. def run_sweep_task(sweep_request_id: int, user_id: int, selection_request_id: int) -> None:
  721. db = SessionLocal()
  722. try:
  723. req = db.query(BuffSweepRequest).filter(
  724. BuffSweepRequest.id == sweep_request_id,
  725. BuffSweepRequest.user_id == user_id,
  726. ).first()
  727. if not req:
  728. logger.warning("扫货请求不存在: sweep_request_id=%s user_id=%s", sweep_request_id, user_id)
  729. return
  730. if req.status != "pending":
  731. logger.info("扫货请求状态非 pending,跳过: %s", req.status)
  732. return
  733. if req.cancel_requested:
  734. req.status = "cancelled"
  735. db.commit()
  736. logger.info("扫货请求已取消,任务未启动: sweep_request_id=%s", sweep_request_id)
  737. return
  738. req.status = "running"
  739. db.commit()
  740. proxy_ctx = _resolve_proxy_context()
  741. proxy_ctx.proxy_retry_times = max(0, _get_system_config_int(db, "buff_sweep_proxy_retry_times", 1))
  742. proxy_ctx.proxy_retry_interval_ms = max(0, _get_system_config_int(db, "buff_sweep_proxy_retry_interval_ms", 800))
  743. proxy_ok, proxy_err = _ensure_proxy_ready(proxy_ctx, refresh_if_needed=True)
  744. if not proxy_ok:
  745. req.status = "failed"
  746. db.add(BuffSweepRequestLog(
  747. sweep_request_id=sweep_request_id,
  748. user_id=user_id,
  749. goods_id="-",
  750. goods_name="",
  751. sequence_no=0,
  752. result="failed",
  753. message=proxy_err,
  754. retry_attempts=0,
  755. proxy_refreshed=False,
  756. ))
  757. db.commit()
  758. return
  759. accounts = _load_active_steam_accounts(db, user_id)
  760. if not accounts:
  761. req.status = "failed"
  762. db.add(BuffSweepRequestLog(
  763. sweep_request_id=sweep_request_id,
  764. user_id=user_id,
  765. goods_id="-",
  766. goods_name="",
  767. sequence_no=0,
  768. result="failed",
  769. message="未找到可用 Steam 账号(请先在本地验证并绑定)",
  770. retry_attempts=0,
  771. proxy_refreshed=False,
  772. ))
  773. db.commit()
  774. return
  775. # 初始化余额;余额不足阈值的账号后续自动跳过
  776. for acc in accounts:
  777. try:
  778. _refresh_wallet_balance(acc, proxy_ctx)
  779. except Exception as e:
  780. acc.balance_cents = 0
  781. logger.warning("刷新余额失败,账号将跳过: %s err=%s", acc.steam_username, e)
  782. rows = (
  783. db.query(BuffSelectionRequestItem)
  784. .filter(
  785. BuffSelectionRequestItem.user_id == user_id,
  786. BuffSelectionRequestItem.request_id == selection_request_id,
  787. )
  788. .order_by(BuffSelectionRequestItem.id.asc())
  789. .all()
  790. )
  791. page_size = max(1, _get_system_config_int(db, "buff_sweep_steam_listings_page_size", DEFAULT_PAGE_SIZE))
  792. max_scan = max(1, _get_system_config_int(db, "buff_sweep_steam_listings_max_scan_per_item", DEFAULT_MAX_SCAN_PER_ITEM))
  793. success_count = 0
  794. account_idx = 0
  795. for seq, row in enumerate(rows, start=1):
  796. db.refresh(req)
  797. if req.cancel_requested:
  798. req.status = "cancelled"
  799. db.add(BuffSweepRequestLog(
  800. sweep_request_id=sweep_request_id,
  801. user_id=user_id,
  802. goods_id=row.goods_id,
  803. goods_name=row.goods_name,
  804. sequence_no=seq,
  805. result="stopped",
  806. message="用户已停止扫货",
  807. retry_attempts=0,
  808. proxy_refreshed=False,
  809. target_price_usd=row.price_usd,
  810. purchase_price_usd=None,
  811. ))
  812. db.commit()
  813. return
  814. target_price = row.price_usd
  815. wear = _parse_wear_range(row.wear_range or "")
  816. if target_price is None or target_price <= 0 or wear is None:
  817. db.add(BuffSweepRequestLog(
  818. sweep_request_id=sweep_request_id,
  819. user_id=user_id,
  820. goods_id=row.goods_id,
  821. goods_name=row.goods_name,
  822. sequence_no=seq,
  823. result="skipped",
  824. message="选品数据不完整(价格/磨损区间)",
  825. retry_attempts=0,
  826. proxy_refreshed=False,
  827. target_price_usd=row.price_usd,
  828. purchase_price_usd=None,
  829. ))
  830. db.commit()
  831. continue
  832. wear_min, wear_max = wear
  833. if all(a.balance_cents < MIN_BALANCE_CENTS for a in accounts):
  834. db.add(BuffSweepRequestLog(
  835. sweep_request_id=sweep_request_id,
  836. user_id=user_id,
  837. goods_id=row.goods_id,
  838. goods_name=row.goods_name,
  839. sequence_no=seq,
  840. result="failed",
  841. message="所有 Steam 账号余额不足(<0.5),停止扫货",
  842. retry_attempts=0,
  843. proxy_refreshed=False,
  844. target_price_usd=target_price,
  845. purchase_price_usd=None,
  846. ))
  847. req.status = "failed"
  848. db.commit()
  849. return
  850. bought = False
  851. final_msg = "未命中可购买 listing"
  852. final_purchase_price: Optional[Decimal] = None
  853. final_purchase_wear: Optional[Decimal] = None
  854. used_account = None
  855. item_debug = {"retry_attempts": 0, "proxy_refreshed": False}
  856. # 单饰品单账号轮询策略:每个饰品只分配一个账号扫描,账号按 A->B->C->A 轮转。
  857. selected_account: Optional[LocalSteamAccount] = None
  858. for _ in range(len(accounts)):
  859. acc = accounts[account_idx % len(accounts)]
  860. account_idx += 1
  861. if acc.balance_cents >= MIN_BALANCE_CENTS:
  862. selected_account = acc
  863. break
  864. if not selected_account:
  865. final_msg = "无可用账号(余额不足)"
  866. else:
  867. used_account = selected_account.steam_username
  868. try:
  869. ok, purchase_price_usd, purchase_wear, msg = _scan_item_and_try_buy(
  870. item=row,
  871. target_price_usd=Decimal(str(target_price)),
  872. wear_min=wear_min,
  873. wear_max=wear_max,
  874. account=selected_account,
  875. proxy_ctx=proxy_ctx,
  876. page_size=page_size,
  877. max_scan=max_scan,
  878. debug_meta=item_debug,
  879. )
  880. final_msg = msg
  881. if ok:
  882. bought = True
  883. final_purchase_price = purchase_price_usd
  884. final_purchase_wear = purchase_wear
  885. except Exception as e:
  886. # 单饰品仅由单一账号扫描;异常时只记录并继续下一个饰品。
  887. logger.exception(
  888. "扫货单品异常(单饰品单账号策略): sweep_request_id=%s goods_id=%s steam_account=%s error=%s",
  889. sweep_request_id,
  890. row.goods_id,
  891. selected_account.steam_username,
  892. e,
  893. )
  894. final_msg = _format_item_exception_message(selected_account.steam_username, str(e))
  895. if bought:
  896. db.add(BuffSweepRequestItem(
  897. sweep_request_id=sweep_request_id,
  898. user_id=user_id,
  899. selection_request_id=selection_request_id,
  900. selection_item_id=row.id,
  901. goods_id=row.goods_id,
  902. goods_name=row.goods_name,
  903. icon_url=row.icon_url,
  904. wear_range=row.wear_range,
  905. purchase_wear=final_purchase_wear,
  906. target_price_usd=row.price_usd,
  907. purchase_price_usd=final_purchase_price,
  908. steam_account=used_account,
  909. ))
  910. db.add(BuffSweepRequestLog(
  911. sweep_request_id=sweep_request_id,
  912. user_id=user_id,
  913. goods_id=row.goods_id,
  914. goods_name=row.goods_name,
  915. sequence_no=seq,
  916. result="success",
  917. message=f"扫货成功,账号={used_account}",
  918. retry_attempts=int(item_debug.get("retry_attempts", 0)),
  919. proxy_refreshed=bool(item_debug.get("proxy_refreshed", False)),
  920. target_price_usd=row.price_usd,
  921. purchase_price_usd=final_purchase_price,
  922. ))
  923. success_count += 1
  924. else:
  925. db.add(BuffSweepRequestLog(
  926. sweep_request_id=sweep_request_id,
  927. user_id=user_id,
  928. goods_id=row.goods_id,
  929. goods_name=row.goods_name,
  930. sequence_no=seq,
  931. result="skipped",
  932. message=(f"账号={used_account};{final_msg}" if used_account else final_msg),
  933. retry_attempts=int(item_debug.get("retry_attempts", 0)),
  934. proxy_refreshed=bool(item_debug.get("proxy_refreshed", False)),
  935. target_price_usd=row.price_usd,
  936. purchase_price_usd=None,
  937. ))
  938. db.commit()
  939. db.refresh(req)
  940. if req.cancel_requested or req.status == "cancelled":
  941. req.status = "cancelled"
  942. db.commit()
  943. logger.info(
  944. "扫货任务在收尾阶段检测到取消请求: sweep_request_id=%s selection_request_id=%s",
  945. sweep_request_id,
  946. selection_request_id,
  947. )
  948. return
  949. req.status = "completed"
  950. db.commit()
  951. logger.info(
  952. "扫货完成: sweep_request_id=%s selection_request_id=%s success=%s",
  953. sweep_request_id,
  954. selection_request_id,
  955. success_count,
  956. )
  957. except Exception as e:
  958. logger.exception("扫货任务异常: sweep_request_id=%s error=%s", sweep_request_id, e)
  959. try:
  960. req = db.query(BuffSweepRequest).filter(
  961. BuffSweepRequest.id == sweep_request_id,
  962. BuffSweepRequest.user_id == user_id,
  963. ).first()
  964. if req:
  965. req.status = "failed"
  966. db.commit()
  967. except Exception:
  968. db.rollback()
  969. finally:
  970. db.close()