| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065 |
- """
- BUFF 扫货服务(真实链路版):
- - Steam listings 分页拉取
- - csgofloat 磨损校验
- - buylisting 购买
- - trade-confirmations/operate + buylisting(confirmation) 购买确认
- - 多 Steam 账号余额切换与失败重试
- """
- import json
- import os
- import random
- import time
- from dataclasses import dataclass
- from decimal import Decimal, InvalidOperation
- from typing import Any, Dict, List, Optional, Tuple
- from urllib.parse import urlencode
- from urllib.request import Request, build_opener, ProxyHandler
- from urllib.error import HTTPError, URLError
- from sqlalchemy.orm import Session
- from app.database import SessionLocal
- from app.models.buff_sweep_request import BuffSweepRequest
- from app.models.buff_sweep_request_item import BuffSweepRequestItem
- from app.models.buff_sweep_request_log import BuffSweepRequestLog
- from app.models.buff_selection_request_item import BuffSelectionRequestItem
- from app.models.steam_account import SteamAccount
- from app.models.system_config import SystemConfig
- from app.core.logger import logger
- STEAM_API_BASE = "http://localhost:5000"
- DEFAULT_PAGE_SIZE = 10
- DEFAULT_MAX_SCAN_PER_ITEM = 60
- MIN_BALANCE_CENTS = 50 # 0.5 USD
- @dataclass
- class LocalSteamAccount:
- steam_username: str
- mafile_path: str
- steam_id: int
- access_token: str
- refresh_token: str
- session_id: str
- steam_login_secure: str
- balance_cents: int = 0
- @dataclass
- class ProxyContext:
- mode: str # none/account/api
- proxy_url: Optional[str] = None
- api_url: Optional[str] = None
- proxy_retry_times: int = 1
- proxy_retry_interval_ms: int = 800
- def _get_system_config_int(db: Session, key: str, default: int) -> int:
- row = db.query(SystemConfig).filter(
- SystemConfig.config_key == key,
- SystemConfig.app_name == "backend-selection",
- SystemConfig.is_deleted == False,
- ).first()
- if not row or row.config_value in (None, ""):
- return default
- try:
- return int(row.config_value)
- except (ValueError, TypeError):
- return default
- def _http_request(
- method: str,
- url: str,
- headers: Optional[Dict[str, str]] = None,
- body: Optional[bytes] = None,
- timeout: int = 25,
- proxy_url: Optional[str] = None,
- ) -> Tuple[int, str]:
- req = Request(url=url, method=method.upper(), data=body, headers=headers or {})
- opener = build_opener(ProxyHandler({"http": proxy_url, "https": proxy_url})) if proxy_url else build_opener()
- try:
- with opener.open(req, timeout=timeout) as resp:
- text = resp.read().decode("utf-8", errors="replace")
- return resp.getcode(), text
- except HTTPError as e:
- return e.code, e.read().decode("utf-8", errors="replace")
- except URLError as e:
- raise RuntimeError(f"请求失败: {e}") from e
- def _post_json(
- url: str,
- payload: Dict[str, Any],
- timeout: int = 25,
- ) -> Tuple[int, Dict[str, Any], str]:
- body = json.dumps(payload).encode("utf-8")
- code, text = _http_request(
- method="POST",
- url=url,
- headers={"Content-Type": "application/json"},
- body=body,
- timeout=timeout,
- proxy_url=None,
- )
- try:
- parsed = json.loads(text) if text else {}
- except Exception:
- parsed = {}
- return code, parsed, text
- def _parse_proxy_api_response(body: str) -> Optional[str]:
- txt = (body or "").strip()
- if not txt:
- return None
- try:
- obj = json.loads(txt)
- except Exception:
- obj = None
- if isinstance(obj, str):
- return obj if "://" in obj else f"http://{obj}"
- if isinstance(obj, dict):
- for k in ("proxyUrl", "proxy_url", "url", "proxy"):
- v = obj.get(k)
- if isinstance(v, str) and v.strip():
- return v if "://" in v else f"http://{v}"
- host = obj.get("ip") or obj.get("host") or obj.get("server")
- port = obj.get("port") or obj.get("serverPort")
- if host and port:
- return f"http://{host}:{port}"
- first = txt.splitlines()[0].split(",")[0].strip()
- if not first:
- return None
- return first if "://" in first else f"http://{first}"
- def _is_proxy_related_failure(status_code: Optional[int], body: Optional[str], err_msg: Optional[str] = None) -> bool:
- if status_code in (407, 502, 503, 504):
- return True
- text = f"{body or ''} {err_msg or ''}".lower()
- tokens = [
- "proxy",
- "tunnel",
- "timed out",
- "timeout",
- "connection refused",
- "connection reset",
- "failed to connect",
- "unreachable",
- "network is unreachable",
- "name or service not known",
- ]
- return any(t in text for t in tokens)
- def _resolve_steam_msg_dir() -> Optional[str]:
- candidates = []
- env_dir = (os.environ.get("STEAM_MSG_DIR") or "").strip()
- if env_dir:
- candidates.append(env_dir)
- home = os.path.expanduser("~")
- candidates.extend([
- os.path.join(home, "Library", "Application Support", "steam-msg"),
- os.path.join(home, "AppData", "Roaming", "steam-msg"),
- os.path.join(os.getcwd(), "steam-msg"),
- ])
- # backend-client/app/services -> project root
- project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
- candidates.append(os.path.join(project_root, "steam-msg"))
- for c in candidates:
- if c and os.path.isdir(c):
- return c
- return None
- def _resolve_proxy_context() -> ProxyContext:
- steam_msg_dir = _resolve_steam_msg_dir()
- if not steam_msg_dir:
- return ProxyContext(mode="none")
- cfg_path = os.path.join(steam_msg_dir, "proxy_config.json")
- if not os.path.exists(cfg_path):
- return ProxyContext(mode="none")
- try:
- with open(cfg_path, "r", encoding="utf-8") as f:
- cfg = json.load(f)
- except Exception:
- return ProxyContext(mode="none")
- mode = (cfg.get("effectiveMode") or "").strip().lower()
- if mode not in ("api", "account"):
- return ProxyContext(mode="none")
- brand_id = cfg.get("effectiveBrandId")
- brand = (cfg.get("configs") or {}).get(brand_id) if brand_id else None
- if not isinstance(brand, dict):
- return ProxyContext(mode="none")
- if mode == "account":
- host = (brand.get("serverHost") or "").strip()
- port = int(brand.get("serverPort") or 0)
- if not host or port <= 0:
- return ProxyContext(mode="none")
- user = (brand.get("serverUsername") or "").strip()
- pwd = brand.get("serverPassword") or ""
- if user:
- return ProxyContext(mode="account", proxy_url=f"http://{user}:{pwd}@{host}:{port}")
- return ProxyContext(mode="account", proxy_url=f"http://{host}:{port}")
- api_url = (brand.get("apiUrl") or "").strip()
- if not api_url:
- return ProxyContext(mode="none")
- try:
- code, text = _http_request("GET", api_url, timeout=12)
- if code < 200 or code >= 300:
- return ProxyContext(mode="api", api_url=api_url, proxy_url=None)
- return ProxyContext(mode="api", api_url=api_url, proxy_url=_parse_proxy_api_response(text))
- except Exception:
- return ProxyContext(mode="api", api_url=api_url, proxy_url=None)
- def _refresh_api_proxy(ctx: ProxyContext) -> bool:
- if ctx.mode != "api" or not ctx.api_url:
- return False
- try:
- # 加时间戳避免代理平台缓存同一条
- sep = "&" if "?" in ctx.api_url else "?"
- refresh_url = f"{ctx.api_url}{sep}_ts={random.randint(100000, 999999)}"
- code, text = _http_request("GET", refresh_url, timeout=12)
- if code < 200 or code >= 300:
- return False
- parsed = _parse_proxy_api_response(text)
- if not parsed:
- return False
- ctx.proxy_url = parsed
- return True
- except Exception:
- return False
- def _ensure_proxy_ready(proxy_ctx: Optional[ProxyContext], refresh_if_needed: bool = True) -> Tuple[bool, str]:
- """扫货链路强制代理:没有可用代理时必须失败,禁止直连 Steam。"""
- if not proxy_ctx or proxy_ctx.mode == "none":
- return False, "未配置可用代理(扫货链路禁止直连 Steam)"
- if proxy_ctx.mode == "api" and not proxy_ctx.proxy_url and refresh_if_needed:
- _refresh_api_proxy(proxy_ctx)
- if not proxy_ctx.proxy_url:
- return False, f"代理模式={proxy_ctx.mode} 但未获取到可用代理地址(扫货链路禁止直连 Steam)"
- return True, ""
- def _http_request_with_proxy_retry(
- method: str,
- url: str,
- headers: Optional[Dict[str, str]] = None,
- body: Optional[bytes] = None,
- timeout: int = 25,
- proxy_ctx: Optional[ProxyContext] = None,
- debug_meta: Optional[Dict[str, Any]] = None,
- ) -> Tuple[int, str]:
- ok, err = _ensure_proxy_ready(proxy_ctx, refresh_if_needed=True)
- if not ok:
- raise RuntimeError(err)
- proxy_url = proxy_ctx.proxy_url if proxy_ctx else None
- retry_times = max(0, int(proxy_ctx.proxy_retry_times if proxy_ctx else 0))
- retry_interval = max(0, int(proxy_ctx.proxy_retry_interval_ms if proxy_ctx else 0))
- last_err: Optional[Exception] = None
- code: Optional[int] = None
- text: Optional[str] = None
- for attempt in range(retry_times + 1):
- try:
- code, text = _http_request(
- method,
- url,
- headers=headers,
- body=body,
- timeout=timeout,
- proxy_url=proxy_ctx.proxy_url if proxy_ctx else proxy_url,
- )
- if not (
- proxy_ctx
- and proxy_ctx.mode == "api"
- and _is_proxy_related_failure(code, text)
- and attempt < retry_times
- ):
- return code, text
- if debug_meta is not None:
- debug_meta["retry_attempts"] = int(debug_meta.get("retry_attempts", 0)) + 1
- if retry_interval > 0:
- time.sleep(retry_interval / 1000.0)
- refreshed = _refresh_api_proxy(proxy_ctx)
- if debug_meta is not None and refreshed:
- debug_meta["proxy_refreshed"] = True
- except Exception as e:
- last_err = e
- if not (
- proxy_ctx
- and proxy_ctx.mode == "api"
- and _is_proxy_related_failure(None, None, str(e))
- and attempt < retry_times
- ):
- raise
- if debug_meta is not None:
- debug_meta["retry_attempts"] = int(debug_meta.get("retry_attempts", 0)) + 1
- if retry_interval > 0:
- time.sleep(retry_interval / 1000.0)
- refreshed = _refresh_api_proxy(proxy_ctx)
- if debug_meta is not None and refreshed:
- debug_meta["proxy_refreshed"] = True
- if code is not None and text is not None:
- return code, text
- if last_err:
- raise last_err
- raise RuntimeError("代理重试后请求失败")
- def _post_steam_api_with_proxy_retry(
- path: str,
- payload: Dict[str, Any],
- proxy_ctx: Optional[ProxyContext],
- timeout: int = 25,
- debug_meta: Optional[Dict[str, Any]] = None,
- ) -> Tuple[int, Dict[str, Any], str]:
- url = f"{STEAM_API_BASE.rstrip('/')}/{path.lstrip('/')}"
- ok, err = _ensure_proxy_ready(proxy_ctx, refresh_if_needed=True)
- if not ok:
- raise RuntimeError(err)
- retry_times = max(0, int(proxy_ctx.proxy_retry_times if proxy_ctx else 0))
- retry_interval = max(0, int(proxy_ctx.proxy_retry_interval_ms if proxy_ctx else 0))
- last_code = 0
- last_text = ""
- last_parsed: Dict[str, Any] = {}
- for attempt in range(retry_times + 1):
- req_payload = dict(payload)
- if proxy_ctx and proxy_ctx.proxy_url:
- req_payload["ProxyUrl"] = proxy_ctx.proxy_url
- code, parsed, text = _post_json(url, req_payload, timeout=timeout)
- last_code, last_text, last_parsed = code, text, parsed
- if not (
- proxy_ctx
- and proxy_ctx.mode == "api"
- and _is_proxy_related_failure(code, text)
- and attempt < retry_times
- ):
- return code, parsed, text
- if debug_meta is not None:
- debug_meta["retry_attempts"] = int(debug_meta.get("retry_attempts", 0)) + 1
- if retry_interval > 0:
- time.sleep(retry_interval / 1000.0)
- refreshed = _refresh_api_proxy(proxy_ctx)
- if debug_meta is not None and refreshed:
- debug_meta["proxy_refreshed"] = True
- return last_code, last_parsed, last_text
- def _load_accounts_json() -> Dict[str, Any]:
- steam_msg_dir = _resolve_steam_msg_dir()
- if not steam_msg_dir:
- return {}
- path = os.path.join(steam_msg_dir, "accounts.json")
- if not os.path.exists(path):
- return {}
- try:
- with open(path, "r", encoding="utf-8") as f:
- root = json.load(f)
- return root.get("accounts") if isinstance(root.get("accounts"), dict) else {}
- except Exception:
- return {}
- def _build_local_account(steam_username: str, raw: Dict[str, Any]) -> Optional[LocalSteamAccount]:
- try:
- steam_id = int(raw.get("steamId") or 0)
- except Exception:
- steam_id = 0
- mafile = (raw.get("steam_guard_path") or "").strip()
- if not mafile or not os.path.exists(mafile):
- return None
- access_token = (raw.get("accessToken") or "").strip()
- refresh_token = (raw.get("refreshToken") or "").strip()
- steam_login_secure = (raw.get("steamLoginSecure") or "").strip()
- if steam_id <= 0 or not access_token or not refresh_token or not steam_login_secure:
- return None
- return LocalSteamAccount(
- steam_username=steam_username,
- mafile_path=mafile,
- steam_id=steam_id,
- access_token=access_token,
- refresh_token=refresh_token,
- session_id=(raw.get("sessionId") or "").strip(),
- steam_login_secure=steam_login_secure,
- )
- def _load_active_steam_accounts(db: Session, user_id: int) -> List[LocalSteamAccount]:
- local_accounts = _load_accounts_json()
- rows = (
- db.query(SteamAccount)
- .filter(
- SteamAccount.user_id == user_id,
- SteamAccount.is_deleted == False,
- SteamAccount.is_active == True,
- )
- .order_by(SteamAccount.id.asc())
- .all()
- )
- out: List[LocalSteamAccount] = []
- for r in rows:
- raw = local_accounts.get(r.steam_username)
- if not isinstance(raw, dict):
- continue
- acc = _build_local_account(r.steam_username, raw)
- if acc:
- out.append(acc)
- return out
- def _refresh_wallet_balance(
- account: LocalSteamAccount,
- proxy_ctx: Optional[ProxyContext],
- debug_meta: Optional[Dict[str, Any]] = None,
- ) -> int:
- payload: Dict[str, Any] = {"SteamLoginSecure": account.steam_login_secure}
- code, resp, body = _post_steam_api_with_proxy_retry(
- "wallet-balance",
- payload,
- proxy_ctx=proxy_ctx,
- timeout=25,
- debug_meta=debug_meta,
- )
- if code != 200:
- raise RuntimeError(f"获取余额失败: http={code} body={body[:200]}")
- amount_raw = str(resp.get("amount") or "0").strip()
- try:
- cents = int(float(amount_raw))
- except Exception as e:
- raise RuntimeError(f"余额格式错误: {amount_raw}") from e
- account.balance_cents = max(0, cents)
- return account.balance_cents
- def _parse_wear_range(wear_range: str) -> Optional[Tuple[Decimal, Decimal]]:
- if not wear_range:
- return None
- parts = [p.strip() for p in str(wear_range).split("-")]
- if len(parts) != 2:
- return None
- try:
- lo = Decimal(parts[0])
- hi = Decimal(parts[1])
- except (InvalidOperation, ValueError):
- return None
- if lo > hi:
- lo, hi = hi, lo
- return lo, hi
- def _format_item_exception_message(account_name: str, reason: str) -> str:
- """统一扫货异常展示文案:用户友好提示 + 内部简要原因。"""
- clean_reason = " ".join((reason or "").split())[:180] or "未知异常"
- return f"扫货异常,已跳过当前饰品并继续。原因:账号={account_name};{clean_reason}"
- def _build_render_url(steam_url: str, start: int, count: int) -> str:
- base = steam_url.rstrip("/")
- if not base.endswith("/render"):
- base = f"{base}/render"
- query = urlencode({
- "query": "",
- "start": start,
- "count": count,
- "country": "US",
- "language": "schinese",
- "currency": 1,
- })
- return f"{base}/?{query}"
- def _extract_inspect_value(page_json: Dict[str, Any], listing: Dict[str, Any]) -> Optional[str]:
- asset = listing.get("asset") or {}
- appid = str(asset.get("appid") or "")
- contextid = str(asset.get("contextid") or "")
- asset_id = str(asset.get("id") or "")
- if not appid or not contextid or not asset_id:
- return None
- assets_root = page_json.get("assets") or {}
- app_obj = assets_root.get(appid) or {}
- ctx_obj = app_obj.get(contextid) or {}
- asset_obj = ctx_obj.get(asset_id) or {}
- props = asset_obj.get("asset_properties") or []
- if not isinstance(props, list):
- return None
- for p in props:
- if str(p.get("propertyid")) == "6":
- v = p.get("string_value")
- if v:
- return str(v).strip()
- return None
- def _fetch_float_value(
- inspect_value: str,
- proxy_ctx: Optional[ProxyContext],
- debug_meta: Optional[Dict[str, Any]] = None,
- ) -> Optional[Decimal]:
- if not inspect_value:
- return None
- csgofloat_url = "https://inspect.csgofloat.xyz/?" + urlencode({
- "url": f"steam://run/730//+csgo_econ_action_preview%20{inspect_value}"
- })
- headers = {
- "Referer": "https://steamcommunity.com/",
- "Origin": "https://steamcommunity.com",
- "User-Agent": "Mozilla/5.0",
- }
- code, text = _http_request_with_proxy_retry(
- "GET",
- csgofloat_url,
- headers=headers,
- timeout=20,
- proxy_ctx=proxy_ctx,
- debug_meta=debug_meta,
- )
- if code != 200:
- return None
- try:
- data = json.loads(text)
- fv = data.get("iteminfo", {}).get("floatvalue")
- if fv is None:
- return None
- return Decimal(str(fv))
- except Exception:
- return None
- def _random_addr_fields() -> Dict[str, str]:
- return {
- "first_name": f"user{random.randint(1000, 9999)}",
- "last_name": f"ln{random.randint(1000, 9999)}",
- "billing_address": f"{random.randint(10, 9999)} Main St",
- "billing_address_two": f"Apt {random.randint(1, 999)}",
- "billing_country": "US",
- "billing_city": f"City{random.randint(10, 999)}",
- "billing_state": "AK",
- "billing_postal_code": str(random.randint(10000, 99999)),
- }
- def _post_buylisting(
- listing_id: str,
- steam_url: str,
- account: LocalSteamAccount,
- subtotal: int,
- fee: int,
- total: int,
- confirmation: str,
- proxy_ctx: Optional[ProxyContext],
- debug_meta: Optional[Dict[str, Any]] = None,
- ) -> Dict[str, Any]:
- url = f"https://steamcommunity.com/market/buylisting/{listing_id}"
- fields = _random_addr_fields()
- form_data = {
- "sessionid": account.session_id or "",
- "currency": "1",
- "subtotal": str(subtotal),
- "fee": str(fee),
- "total": str(total),
- "quantity": "1",
- "first_name": fields["first_name"],
- "last_name": fields["last_name"],
- "billing_address": fields["billing_address"],
- "billing_address_two": fields["billing_address_two"],
- "billing_country": fields["billing_country"],
- "billing_city": fields["billing_city"],
- "billing_state": fields["billing_state"],
- "billing_postal_code": fields["billing_postal_code"],
- "save_my_address": "0",
- "tradefee_tax": "0",
- "confirmation": confirmation,
- }
- body = urlencode(form_data).encode("utf-8")
- cookie = (
- "timezoneOffset=28800,0; "
- f"sessionid={account.session_id}; "
- f"steamLoginSecure={account.steam_login_secure};"
- )
- headers = {
- "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
- "Origin": "https://steamcommunity.com",
- "Referer": steam_url,
- "Cookie": cookie,
- "User-Agent": "Mozilla/5.0",
- "Accept": "*/*",
- }
- code, text = _http_request_with_proxy_retry(
- method="POST",
- url=url,
- headers=headers,
- body=body,
- timeout=25,
- proxy_ctx=proxy_ctx,
- debug_meta=debug_meta,
- )
- try:
- parsed = json.loads(text) if text else {}
- except Exception:
- parsed = {}
- if code != 200 and not parsed:
- raise RuntimeError(f"buylisting 请求失败: http={code} body={text[:200]}")
- return parsed
- def _operate_confirmation(
- account: LocalSteamAccount,
- confirmation_id: str,
- proxy_ctx: Optional[ProxyContext],
- debug_meta: Optional[Dict[str, Any]] = None,
- ) -> bool:
- payload: Dict[str, Any] = {
- "MaFilePath": account.mafile_path,
- "Session": {
- "SteamId": account.steam_id,
- "AccessToken": account.access_token,
- "RefreshToken": account.refresh_token,
- "SessionId": account.session_id or "",
- },
- "ConfirmationId": int(str(confirmation_id)),
- "Operation": "accept",
- }
- code, resp, body = _post_steam_api_with_proxy_retry(
- "trade-confirmations/operate",
- payload,
- proxy_ctx=proxy_ctx,
- timeout=35,
- debug_meta=debug_meta,
- )
- if code != 200:
- return False
- if isinstance(resp.get("session"), dict):
- session = resp.get("session") or {}
- account.session_id = str(session.get("sessionId") or account.session_id or "")
- account.access_token = str(session.get("accessToken") or account.access_token)
- account.refresh_token = str(session.get("refreshToken") or account.refresh_token)
- try:
- account.steam_id = int(session.get("steamId") or account.steam_id)
- except Exception:
- pass
- return bool(resp.get("success") is True or resp.get("success") == 1)
- def _purchase_one_listing(
- account: LocalSteamAccount,
- steam_url: str,
- listing_id: str,
- subtotal: int,
- converted_steam_fee: int,
- converted_publisher_fee: int,
- proxy_ctx: Optional[ProxyContext],
- debug_meta: Optional[Dict[str, Any]] = None,
- ) -> Tuple[bool, Optional[int], str]:
- fee = int(converted_steam_fee) + int(converted_publisher_fee)
- total = int(subtotal) + int(fee)
- if account.balance_cents < total:
- return False, None, f"账号余额不足(余额={account.balance_cents},需={total})"
- first = _post_buylisting(
- listing_id=listing_id,
- steam_url=steam_url,
- account=account,
- subtotal=subtotal,
- fee=fee,
- total=total,
- confirmation="0",
- proxy_ctx=proxy_ctx,
- debug_meta=debug_meta,
- )
- wallet = first.get("wallet_info") if isinstance(first, dict) else None
- if isinstance(wallet, dict) and int(wallet.get("success") or 0) == 1:
- try:
- account.balance_cents = int(float(str(wallet.get("wallet_balance") or "0")))
- except Exception:
- account.balance_cents = max(0, account.balance_cents - total)
- return True, total, "购买成功(无需确认)"
- if first.get("need_confirmation") is True:
- confirmation_id = str((first.get("confirmation") or {}).get("confirmation_id") or "").strip()
- if not confirmation_id:
- return False, None, "需要确认但未返回 confirmation_id"
- ok = _operate_confirmation(account, confirmation_id, proxy_ctx, debug_meta=debug_meta)
- if not ok:
- return False, None, "确认订单失败(trade-confirmations/operate)"
- second = _post_buylisting(
- listing_id=listing_id,
- steam_url=steam_url,
- account=account,
- subtotal=subtotal,
- fee=fee,
- total=total,
- confirmation=confirmation_id,
- proxy_ctx=proxy_ctx,
- debug_meta=debug_meta,
- )
- wallet2 = second.get("wallet_info") if isinstance(second, dict) else None
- if isinstance(wallet2, dict) and int(wallet2.get("success") or 0) == 1:
- try:
- account.balance_cents = int(float(str(wallet2.get("wallet_balance") or "0")))
- except Exception:
- account.balance_cents = max(0, account.balance_cents - total)
- return True, total, "购买成功(确认后完成)"
- return False, None, f"确认后购买失败: {json.dumps(second, ensure_ascii=False)[:220]}"
- return False, None, f"购买失败: {json.dumps(first, ensure_ascii=False)[:220]}"
- def _scan_item_and_try_buy(
- item: BuffSelectionRequestItem,
- target_price_usd: Decimal,
- wear_min: Decimal,
- wear_max: Decimal,
- account: LocalSteamAccount,
- proxy_ctx: Optional[ProxyContext],
- page_size: int,
- max_scan: int,
- debug_meta: Optional[Dict[str, Any]] = None,
- ) -> Tuple[bool, Optional[Decimal], Optional[Decimal], str]:
- if not item.steam_link:
- return False, None, None, "缺少 steam_link,无法扫货"
- # 仅第一页:每个饰品只请求一次 render(不翻页;单页内最多处理 max_scan 条)
- scanned = 0
- render_url = _build_render_url(item.steam_link, 0, page_size)
- headers = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
- code, text = _http_request_with_proxy_retry(
- "GET",
- render_url,
- headers=headers,
- timeout=20,
- proxy_ctx=proxy_ctx,
- debug_meta=debug_meta,
- )
- if code != 200:
- return False, None, None, f"拉取 listings 失败: http={code}"
- try:
- page_json = json.loads(text)
- except Exception:
- return False, None, None, "拉取 listings 返回非 JSON"
- listinginfo = page_json.get("listinginfo") or {}
- if not isinstance(listinginfo, dict) or not listinginfo:
- return False, None, None, "无可用 listing 数据"
- for listing_id, listing in listinginfo.items():
- if scanned >= max_scan:
- break
- scanned += 1
- try:
- subtotal = int(listing.get("converted_price") or 0)
- steam_fee = int(listing.get("converted_steam_fee") or 0)
- publisher_fee = int(listing.get("converted_publisher_fee") or 0)
- except Exception:
- continue
- total_cents = subtotal + steam_fee + publisher_fee
- total_usd = (Decimal(total_cents) / Decimal("100")).quantize(Decimal("0.01"))
- if total_usd > target_price_usd:
- continue
- inspect_value = _extract_inspect_value(page_json, listing)
- if not inspect_value:
- continue
- fv = _fetch_float_value(inspect_value, proxy_ctx, debug_meta=debug_meta)
- if fv is None or fv < wear_min or fv > wear_max:
- continue
- ok, spent_cents, msg = _purchase_one_listing(
- account=account,
- steam_url=item.steam_link,
- listing_id=str(listing_id),
- subtotal=subtotal,
- converted_steam_fee=steam_fee,
- converted_publisher_fee=publisher_fee,
- proxy_ctx=proxy_ctx,
- debug_meta=debug_meta,
- )
- if ok:
- purchase_price = (Decimal(spent_cents or total_cents) / Decimal("100")).quantize(Decimal("0.01"))
- return True, purchase_price, fv, msg
- # 购买失败(包括确认失败)按流程跳过当前 listing,继续下一个
- return False, None, None, "未命中可购买 listing 或购买失败"
- def run_sweep_task(sweep_request_id: int, user_id: int, selection_request_id: int) -> None:
- db = SessionLocal()
- try:
- req = db.query(BuffSweepRequest).filter(
- BuffSweepRequest.id == sweep_request_id,
- BuffSweepRequest.user_id == user_id,
- ).first()
- if not req:
- logger.warning("扫货请求不存在: sweep_request_id=%s user_id=%s", sweep_request_id, user_id)
- return
- if req.status != "pending":
- logger.info("扫货请求状态非 pending,跳过: %s", req.status)
- return
- if req.cancel_requested:
- req.status = "cancelled"
- db.commit()
- logger.info("扫货请求已取消,任务未启动: sweep_request_id=%s", sweep_request_id)
- return
- req.status = "running"
- db.commit()
- proxy_ctx = _resolve_proxy_context()
- proxy_ctx.proxy_retry_times = max(0, _get_system_config_int(db, "buff_sweep_proxy_retry_times", 1))
- proxy_ctx.proxy_retry_interval_ms = max(0, _get_system_config_int(db, "buff_sweep_proxy_retry_interval_ms", 800))
- proxy_ok, proxy_err = _ensure_proxy_ready(proxy_ctx, refresh_if_needed=True)
- if not proxy_ok:
- req.status = "failed"
- db.add(BuffSweepRequestLog(
- sweep_request_id=sweep_request_id,
- user_id=user_id,
- goods_id="-",
- goods_name="",
- sequence_no=0,
- result="failed",
- message=proxy_err,
- retry_attempts=0,
- proxy_refreshed=False,
- ))
- db.commit()
- return
- accounts = _load_active_steam_accounts(db, user_id)
- if not accounts:
- req.status = "failed"
- db.add(BuffSweepRequestLog(
- sweep_request_id=sweep_request_id,
- user_id=user_id,
- goods_id="-",
- goods_name="",
- sequence_no=0,
- result="failed",
- message="未找到可用 Steam 账号(请先在本地验证并绑定)",
- retry_attempts=0,
- proxy_refreshed=False,
- ))
- db.commit()
- return
- # 初始化余额;余额不足阈值的账号后续自动跳过
- for acc in accounts:
- try:
- _refresh_wallet_balance(acc, proxy_ctx)
- except Exception as e:
- acc.balance_cents = 0
- logger.warning("刷新余额失败,账号将跳过: %s err=%s", acc.steam_username, e)
- rows = (
- db.query(BuffSelectionRequestItem)
- .filter(
- BuffSelectionRequestItem.user_id == user_id,
- BuffSelectionRequestItem.request_id == selection_request_id,
- )
- .order_by(BuffSelectionRequestItem.id.asc())
- .all()
- )
- page_size = max(1, _get_system_config_int(db, "buff_sweep_steam_listings_page_size", DEFAULT_PAGE_SIZE))
- max_scan = max(1, _get_system_config_int(db, "buff_sweep_steam_listings_max_scan_per_item", DEFAULT_MAX_SCAN_PER_ITEM))
- success_count = 0
- account_idx = 0
- for seq, row in enumerate(rows, start=1):
- db.refresh(req)
- if req.cancel_requested:
- req.status = "cancelled"
- db.add(BuffSweepRequestLog(
- sweep_request_id=sweep_request_id,
- user_id=user_id,
- goods_id=row.goods_id,
- goods_name=row.goods_name,
- sequence_no=seq,
- result="stopped",
- message="用户已停止扫货",
- retry_attempts=0,
- proxy_refreshed=False,
- target_price_usd=row.price_usd,
- purchase_price_usd=None,
- ))
- db.commit()
- return
- target_price = row.price_usd
- wear = _parse_wear_range(row.wear_range or "")
- if target_price is None or target_price <= 0 or wear is None:
- db.add(BuffSweepRequestLog(
- sweep_request_id=sweep_request_id,
- user_id=user_id,
- goods_id=row.goods_id,
- goods_name=row.goods_name,
- sequence_no=seq,
- result="skipped",
- message="选品数据不完整(价格/磨损区间)",
- retry_attempts=0,
- proxy_refreshed=False,
- target_price_usd=row.price_usd,
- purchase_price_usd=None,
- ))
- db.commit()
- continue
- wear_min, wear_max = wear
- if all(a.balance_cents < MIN_BALANCE_CENTS for a in accounts):
- db.add(BuffSweepRequestLog(
- sweep_request_id=sweep_request_id,
- user_id=user_id,
- goods_id=row.goods_id,
- goods_name=row.goods_name,
- sequence_no=seq,
- result="failed",
- message="所有 Steam 账号余额不足(<0.5),停止扫货",
- retry_attempts=0,
- proxy_refreshed=False,
- target_price_usd=target_price,
- purchase_price_usd=None,
- ))
- req.status = "failed"
- db.commit()
- return
- bought = False
- final_msg = "未命中可购买 listing"
- final_purchase_price: Optional[Decimal] = None
- final_purchase_wear: Optional[Decimal] = None
- used_account = None
- item_debug = {"retry_attempts": 0, "proxy_refreshed": False}
- # 单饰品单账号轮询策略:每个饰品只分配一个账号扫描,账号按 A->B->C->A 轮转。
- selected_account: Optional[LocalSteamAccount] = None
- for _ in range(len(accounts)):
- acc = accounts[account_idx % len(accounts)]
- account_idx += 1
- if acc.balance_cents >= MIN_BALANCE_CENTS:
- selected_account = acc
- break
- if not selected_account:
- final_msg = "无可用账号(余额不足)"
- else:
- used_account = selected_account.steam_username
- try:
- ok, purchase_price_usd, purchase_wear, msg = _scan_item_and_try_buy(
- item=row,
- target_price_usd=Decimal(str(target_price)),
- wear_min=wear_min,
- wear_max=wear_max,
- account=selected_account,
- proxy_ctx=proxy_ctx,
- page_size=page_size,
- max_scan=max_scan,
- debug_meta=item_debug,
- )
- final_msg = msg
- if ok:
- bought = True
- final_purchase_price = purchase_price_usd
- final_purchase_wear = purchase_wear
- except Exception as e:
- # 单饰品仅由单一账号扫描;异常时只记录并继续下一个饰品。
- logger.exception(
- "扫货单品异常(单饰品单账号策略): sweep_request_id=%s goods_id=%s steam_account=%s error=%s",
- sweep_request_id,
- row.goods_id,
- selected_account.steam_username,
- e,
- )
- final_msg = _format_item_exception_message(selected_account.steam_username, str(e))
- if bought:
- db.add(BuffSweepRequestItem(
- sweep_request_id=sweep_request_id,
- user_id=user_id,
- selection_request_id=selection_request_id,
- selection_item_id=row.id,
- goods_id=row.goods_id,
- goods_name=row.goods_name,
- icon_url=row.icon_url,
- wear_range=row.wear_range,
- purchase_wear=final_purchase_wear,
- target_price_usd=row.price_usd,
- purchase_price_usd=final_purchase_price,
- steam_account=used_account,
- ))
- db.add(BuffSweepRequestLog(
- sweep_request_id=sweep_request_id,
- user_id=user_id,
- goods_id=row.goods_id,
- goods_name=row.goods_name,
- sequence_no=seq,
- result="success",
- message=f"扫货成功,账号={used_account}",
- retry_attempts=int(item_debug.get("retry_attempts", 0)),
- proxy_refreshed=bool(item_debug.get("proxy_refreshed", False)),
- target_price_usd=row.price_usd,
- purchase_price_usd=final_purchase_price,
- ))
- success_count += 1
- else:
- db.add(BuffSweepRequestLog(
- sweep_request_id=sweep_request_id,
- user_id=user_id,
- goods_id=row.goods_id,
- goods_name=row.goods_name,
- sequence_no=seq,
- result="skipped",
- message=(f"账号={used_account};{final_msg}" if used_account else final_msg),
- retry_attempts=int(item_debug.get("retry_attempts", 0)),
- proxy_refreshed=bool(item_debug.get("proxy_refreshed", False)),
- target_price_usd=row.price_usd,
- purchase_price_usd=None,
- ))
- db.commit()
- db.refresh(req)
- if req.cancel_requested or req.status == "cancelled":
- req.status = "cancelled"
- db.commit()
- logger.info(
- "扫货任务在收尾阶段检测到取消请求: sweep_request_id=%s selection_request_id=%s",
- sweep_request_id,
- selection_request_id,
- )
- return
- req.status = "completed"
- db.commit()
- logger.info(
- "扫货完成: sweep_request_id=%s selection_request_id=%s success=%s",
- sweep_request_id,
- selection_request_id,
- success_count,
- )
- except Exception as e:
- logger.exception("扫货任务异常: sweep_request_id=%s error=%s", sweep_request_id, e)
- try:
- req = db.query(BuffSweepRequest).filter(
- BuffSweepRequest.id == sweep_request_id,
- BuffSweepRequest.user_id == user_id,
- ).first()
- if req:
- req.status = "failed"
- db.commit()
- except Exception:
- db.rollback()
- finally:
- db.close()
|