404 lines
13 KiB
Python
404 lines
13 KiB
Python
"""虎牙基础业务服务。"""
|
|
|
|
import csv
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from urllib.parse import urlparse
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from core.huya.cookie_utils import cookie_value, normalize_huya_cookie
|
|
|
|
from ..huya_defaults import HUYA_CONFIG_DEFAULTS, HUYA_CONFIG_FIELDS
|
|
from ..models import HuyaAccount, HuyaConfig, HuyaTask
|
|
|
|
SUPPORTED_TASK_TYPES = {
|
|
"get_bind_qr": "获取绑定二维码",
|
|
"query_points": "一键查询积分",
|
|
"query_game_name": "一键查询游戏名",
|
|
"query_exchange_records": "一键查询兑换记录",
|
|
"confirm_bind": "确认绑定",
|
|
"refresh_goods": "刷新商品列表",
|
|
"refresh_recharge_goods": "刷新充值商品列表",
|
|
"exchange_goods": "兑换商品",
|
|
"create_recharge_order": "生成支付二维码",
|
|
}
|
|
|
|
|
|
def huya_config_value(field: str, value: str | None) -> str:
|
|
"""读取配置值;空值自动回退到当前活动默认配置。"""
|
|
text = str(value or "").strip()
|
|
return text or HUYA_CONFIG_DEFAULTS[field]
|
|
|
|
|
|
def apply_huya_config_defaults(config: HuyaConfig) -> bool:
|
|
"""补齐虎牙配置默认值,返回是否发生变更。"""
|
|
changed = False
|
|
for field in HUYA_CONFIG_FIELDS:
|
|
if (
|
|
field == "bind_act_id"
|
|
and str(getattr(config, field, "") or "").strip() == "17096"
|
|
):
|
|
setattr(config, field, HUYA_CONFIG_DEFAULTS[field])
|
|
changed = True
|
|
continue
|
|
normalized = huya_config_value(field, getattr(config, field, None))
|
|
if getattr(config, field, None) != normalized:
|
|
setattr(config, field, normalized)
|
|
changed = True
|
|
return changed
|
|
|
|
|
|
def _looks_like_huya_cookie(value: str) -> bool:
|
|
"""判断文本是否像虎牙 Cookie。"""
|
|
return "udb_" in value or "yyuid=" in value
|
|
|
|
|
|
def parse_huya_cookie_line(line: str) -> dict | None:
|
|
"""解析单行虎牙 CK,兼容纯 CK、账号----密码----CK、CK----手机号。"""
|
|
raw = (line or "").strip()
|
|
if not raw:
|
|
return None
|
|
|
|
parts = [part.strip() for part in raw.split("----")]
|
|
username_hint = ""
|
|
game_phone = ""
|
|
|
|
if len(parts) == 1:
|
|
cookie = raw
|
|
elif _looks_like_huya_cookie(parts[0]):
|
|
cookie = parts[0]
|
|
game_phone = parts[1] if len(parts) >= 2 else ""
|
|
elif _looks_like_huya_cookie(parts[-1]):
|
|
cookie = parts[-1]
|
|
username_hint = parts[0]
|
|
else:
|
|
return None
|
|
|
|
if not _looks_like_huya_cookie(cookie):
|
|
return None
|
|
cookie = normalize_huya_cookie(cookie)
|
|
|
|
yyuid = cookie_value(cookie, "yyuid")
|
|
uid = cookie_value(cookie, "udb_uid") or yyuid
|
|
username = cookie_value(cookie, "udb_passport") or cookie_value(cookie, "username")
|
|
if not username and username_hint:
|
|
username = username_hint
|
|
|
|
if not uid and not yyuid:
|
|
return None
|
|
|
|
return {
|
|
"uid": uid,
|
|
"yyuid": yyuid or uid,
|
|
"username": username or uid or yyuid,
|
|
"cookie": cookie,
|
|
"game_phone": game_phone,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class HuyaPasswordLine:
|
|
"""虎牙账号密码导入行。"""
|
|
|
|
username: str
|
|
password: str
|
|
cookie: str = ""
|
|
phone: str = ""
|
|
sms_url: str = ""
|
|
|
|
|
|
def _looks_like_url(value: str) -> bool:
|
|
"""判断文本是否像 URL。"""
|
|
parsed = urlparse(str(value or "").strip())
|
|
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
|
|
|
|
def split_huya_password_line(line: str) -> HuyaPasswordLine | None:
|
|
"""拆分虎牙账号密码行,兼容账号----密码----手机号----验证码链接。"""
|
|
raw = (line or "").strip()
|
|
if not raw or raw.startswith("#"):
|
|
return None
|
|
|
|
if "----" in raw:
|
|
parts = [part.strip() for part in raw.split("----")]
|
|
elif "|" in raw:
|
|
parts = [part.strip() for part in raw.split("|")]
|
|
elif "\t" in raw:
|
|
parts = [part.strip() for part in raw.split("\t")]
|
|
elif "," in raw:
|
|
parts = [part.strip() for part in next(csv.reader([raw]))]
|
|
else:
|
|
parts = raw.split()
|
|
|
|
if len(parts) < 2:
|
|
return None
|
|
|
|
username = parts[0].strip()
|
|
password = parts[1].strip()
|
|
cookie = ""
|
|
phone = ""
|
|
sms_url = ""
|
|
extra = [part.strip() for part in parts[2:] if part.strip()]
|
|
if len(extra) >= 2 and _looks_like_url(extra[1]):
|
|
phone = extra[0]
|
|
sms_url = extra[1]
|
|
cookie = "----".join(extra[2:])
|
|
elif extra and _looks_like_url(extra[-1]) and len(extra) >= 2:
|
|
phone = extra[-2]
|
|
sms_url = extra[-1]
|
|
cookie = "----".join(extra[:-2])
|
|
elif extra:
|
|
cookie = "----".join(extra)
|
|
if not username or not password:
|
|
return None
|
|
return HuyaPasswordLine(
|
|
username=username,
|
|
password=password,
|
|
cookie=cookie,
|
|
phone=phone,
|
|
sms_url=sms_url,
|
|
)
|
|
|
|
|
|
def import_huya_password_accounts(
|
|
db: Session, text: str, tag: str = ""
|
|
) -> tuple[int, int]:
|
|
"""导入虎牙账号密码,返回 (导入/更新数, 跳过数)。"""
|
|
created_or_updated = 0
|
|
skipped = 0
|
|
tag = (tag or "").strip()
|
|
|
|
for line in (text or "").splitlines():
|
|
parsed = split_huya_password_line(line)
|
|
if not parsed:
|
|
if line.strip():
|
|
skipped += 1
|
|
continue
|
|
|
|
username = parsed.username
|
|
password = parsed.password
|
|
cookie = parsed.cookie
|
|
account = db.query(HuyaAccount).filter(HuyaAccount.username == username).first()
|
|
if account is None:
|
|
account = HuyaAccount(
|
|
uid="",
|
|
yyuid="",
|
|
username=username,
|
|
account_password=password,
|
|
cookie=normalize_huya_cookie(cookie) if cookie else "",
|
|
game_phone=parsed.phone,
|
|
tag=tag,
|
|
status="password_imported",
|
|
)
|
|
db.add(account)
|
|
else:
|
|
account.account_password = password
|
|
if cookie:
|
|
account.cookie = normalize_huya_cookie(cookie)
|
|
if parsed.phone:
|
|
account.game_phone = parsed.phone
|
|
if tag:
|
|
account.tag = tag
|
|
if not account.cookie:
|
|
account.status = "password_imported"
|
|
account.updated_at = datetime.now(UTC)
|
|
created_or_updated += 1
|
|
|
|
if created_or_updated:
|
|
db.commit()
|
|
return created_or_updated, skipped
|
|
|
|
|
|
def _upsert_huya_account(
|
|
db: Session, parsed: dict, tag: str = "", status: str | None = None
|
|
) -> HuyaAccount:
|
|
"""按 uid/yyuid 新增或更新虎牙账号。"""
|
|
account = None
|
|
if parsed["uid"]:
|
|
account = db.query(HuyaAccount).filter(HuyaAccount.uid == parsed["uid"]).first()
|
|
if account is None and parsed["yyuid"]:
|
|
account = (
|
|
db.query(HuyaAccount).filter(HuyaAccount.yyuid == parsed["yyuid"]).first()
|
|
)
|
|
|
|
if account is None:
|
|
account = HuyaAccount(
|
|
uid=parsed["uid"],
|
|
yyuid=parsed["yyuid"],
|
|
username=parsed["username"],
|
|
cookie=parsed["cookie"],
|
|
game_phone=parsed["game_phone"],
|
|
tag=tag,
|
|
status=status or "imported",
|
|
)
|
|
db.add(account)
|
|
else:
|
|
account.uid = parsed["uid"] or account.uid
|
|
account.yyuid = parsed["yyuid"] or account.yyuid
|
|
# 已存在账号保留原有 username(登录名/设备画像 key),避免重复导入或登录
|
|
# 前后 udb_passport 差异导致一号一设备绑定断裂。
|
|
if not account.username:
|
|
account.username = parsed["username"] or ""
|
|
account.cookie = parsed["cookie"]
|
|
account.game_phone = parsed["game_phone"] or account.game_phone
|
|
if tag:
|
|
account.tag = tag
|
|
account.status = status or "updated"
|
|
account.updated_at = datetime.now(UTC)
|
|
return account
|
|
|
|
|
|
def save_huya_login_cookie_to_account(
|
|
db: Session,
|
|
account: HuyaAccount,
|
|
cookie: str,
|
|
tag: str = "",
|
|
username_hint: str = "",
|
|
) -> HuyaAccount:
|
|
"""把登录成功后的 Cookie 回填到指定虎牙账号。"""
|
|
line = f"{username_hint}----{cookie}" if username_hint else cookie
|
|
parsed = parse_huya_cookie_line(line)
|
|
if not parsed:
|
|
raise ValueError("登录成功但 Cookie 中没有识别到虎牙 uid")
|
|
|
|
account.uid = parsed["uid"] or account.uid
|
|
account.yyuid = parsed["yyuid"] or account.yyuid
|
|
# 保留已有 username(登录名): 设备画像/指纹状态目录按登录用户名分账号隔离,
|
|
# 若被 cookie 里的 udb_passport 覆盖, 下一次登录会拿到不同的 key → 生成全新设备,
|
|
# 一号一设备绑定断裂且绑定页出现两条记录。仅在无用户名时回填。
|
|
if not account.username:
|
|
account.username = parsed["username"] or ""
|
|
account.cookie = parsed["cookie"]
|
|
account.game_phone = parsed["game_phone"] or account.game_phone
|
|
if tag:
|
|
account.tag = tag
|
|
account.status = "login_success"
|
|
account.updated_at = datetime.now(UTC)
|
|
db.commit()
|
|
db.refresh(account)
|
|
return account
|
|
|
|
|
|
def upsert_huya_cookie(
|
|
db: Session, cookie: str, tag: str = "", username_hint: str = ""
|
|
) -> HuyaAccount:
|
|
"""保存单条登录得到的虎牙 Cookie。"""
|
|
line = f"{username_hint}----{cookie}" if username_hint else cookie
|
|
parsed = parse_huya_cookie_line(line)
|
|
if not parsed:
|
|
raise ValueError("登录成功但 Cookie 中没有识别到虎牙 uid")
|
|
account = _upsert_huya_account(
|
|
db, parsed, tag=(tag or "").strip(), status="login_success"
|
|
)
|
|
db.commit()
|
|
db.refresh(account)
|
|
return account
|
|
|
|
|
|
def import_huya_cookies(db: Session, text: str, tag: str = "") -> tuple[int, int]:
|
|
"""导入虎牙 Cookie,返回 (成功数, 跳过数)。"""
|
|
created_or_updated = 0
|
|
skipped = 0
|
|
tag = (tag or "").strip()
|
|
|
|
for line in (text or "").splitlines():
|
|
parsed = parse_huya_cookie_line(line)
|
|
if not parsed:
|
|
skipped += 1
|
|
continue
|
|
|
|
_upsert_huya_account(db, parsed, tag=tag)
|
|
created_or_updated += 1
|
|
|
|
if created_or_updated:
|
|
db.commit()
|
|
return created_or_updated, skipped
|
|
|
|
|
|
def ensure_huya_config(db: Session) -> HuyaConfig:
|
|
"""获取单条虎牙配置,不存在则创建。"""
|
|
config = db.query(HuyaConfig).first()
|
|
if config:
|
|
if apply_huya_config_defaults(config):
|
|
db.commit()
|
|
db.refresh(config)
|
|
return config
|
|
config = HuyaConfig(**HUYA_CONFIG_DEFAULTS)
|
|
db.add(config)
|
|
db.commit()
|
|
db.refresh(config)
|
|
return config
|
|
|
|
|
|
HUYA_ACTIVE_TASK_STATUSES = ("planned", "pending", "running")
|
|
HUYA_STALE_TASK_STATUSES = ("pending", "running")
|
|
|
|
|
|
def cleanup_orphan_huya_tasks(
|
|
db: Session,
|
|
*,
|
|
active_batch_ids: set[str] | None = None,
|
|
batch_id: str | None = None,
|
|
statuses: tuple[str, ...] = HUYA_ACTIVE_TASK_STATUSES,
|
|
message: str = "任务已中断(服务重启或批次丢失)",
|
|
) -> int:
|
|
"""清理没有执行器接管的虎牙任务,避免前端被历史 running 状态锁死。"""
|
|
query = db.query(HuyaTask).filter(HuyaTask.status.in_(statuses))
|
|
if batch_id:
|
|
query = query.filter(HuyaTask.batch_id == batch_id)
|
|
elif active_batch_ids is not None:
|
|
if active_batch_ids:
|
|
query = query.filter(~HuyaTask.batch_id.in_(list(active_batch_ids)))
|
|
# active_batch_ids is None 且未指定 batch_id:清理全部匹配状态
|
|
|
|
tasks = query.all()
|
|
if not tasks:
|
|
return 0
|
|
|
|
now = datetime.now(UTC)
|
|
for task in tasks:
|
|
task.status = "stopped"
|
|
task.message = message
|
|
task.finished_at = now
|
|
db.commit()
|
|
return len(tasks)
|
|
|
|
|
|
def create_planned_tasks(
|
|
db: Session,
|
|
account_ids: list[int],
|
|
task_type: str,
|
|
created_by: int,
|
|
payload: dict | None = None,
|
|
) -> tuple[str, int]:
|
|
"""创建虎牙任务记录,等待后台执行器消费。"""
|
|
if task_type not in SUPPORTED_TASK_TYPES:
|
|
raise ValueError("不支持的任务类型")
|
|
|
|
batch_id = uuid.uuid4().hex[:12]
|
|
payload = payload or {}
|
|
accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all()
|
|
if (
|
|
task_type
|
|
in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"}
|
|
and accounts
|
|
):
|
|
# 全局快照和单笔支付二维码使用一个选中的 CK 即可;兑换商品需要保留多账号批量任务。
|
|
accounts = accounts[:1]
|
|
for account in accounts:
|
|
db.add(
|
|
HuyaTask(
|
|
batch_id=batch_id,
|
|
account_id=account.id,
|
|
task_type=task_type,
|
|
status="planned",
|
|
message="任务已创建,等待执行",
|
|
result={"payload": payload} if payload else None,
|
|
created_by=created_by,
|
|
)
|
|
)
|
|
db.commit()
|
|
return batch_id, len(accounts)
|