Files
live-hub-py/web/backend/routers/huya/login.py
T
yml2213 a3c3a46b9c fix(tooling): 修复 pyright 解释器配置与类型守卫,补齐 ruff 门禁与两处历史 bug
- pyrightconfig.json 钉死 Python 3.12 虚拟环境,消除由于查找系统 Python 3.9 带来的假性报错
- 修复 huya/login.py 引用不存在函数的问题并补充单元测试
- 修复 browser_harvest.py 对 set 下标访问的错误并提升报错信息质量
- 虎牙 6 个子路由显式导入依赖,消除 import * 带来的未定义变量报警
- 规范化 Pydantic Field(default=...) 默认值声明
- 完善 solver.py / app_login.py 等核心路径类型守卫与 @overload 重载
- 在 pyproject.toml 中显式锁定 ruff lint 规则集 (E4, E7, E9, F, B, I, BLE)
- 门禁脚本同步增加 ruff check 检查并在 AGENTS.md 补齐实践经验总结
2026-10-08 12:33:39 +08:00

715 lines
24 KiB
Python

"""虎牙登录:Web / App / 短信及批量选中登录。"""
import time
from fastapi import APIRouter
from ._common import (
UTC,
AccountBulkSelection,
Depends,
HTTPException,
HuyaAccount,
HuyaAppLoginError,
HuyaAppPasswordLoginRequest,
HuyaAppQrAuthRequiredError,
HuyaCredentialError,
HuyaLoginError,
HuyaPasswordLoginRequest,
HuyaPasswordLoginSelectedRequest,
HuyaSmsCodeRequest,
HuyaSmsLoginRequest,
HuyaTestSmsUrlRequest,
Session,
User,
_account_out,
_can_view_huya_cookie,
_require_huya_perm,
_selected_huya_account_ids,
_visible_huya_accounts_query,
cast,
datetime,
get_current_user,
get_db,
logger,
login_huya_app_password,
login_huya_password,
login_huya_sms,
save_huya_login_cookie_to_account,
send_huya_sms_code,
upsert_huya_cookie,
)
router = APIRouter()
@router.post("/accounts/password-login")
def password_login_account(
req: HuyaPasswordLoginRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""使用 Web 方式账号密码登录虎牙(旧版),成功后保存 Cookie。"""
_require_huya_perm(current, "huya:import")
try:
result = login_huya_password(
username=req.username.strip(),
password=req.password,
cookie=req.cookie.strip() or None,
)
except HuyaCredentialError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HuyaLoginError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502, detail=f"虎牙 Web 密码登录失败: {exc}"
) from exc
if not result.success or not result.cookie:
raise HTTPException(
status_code=502, detail=result.message or "虎牙 Web 密码登录失败"
)
try:
account = upsert_huya_cookie(
db, result.cookie, tag=req.tag, username_hint=req.username
)
if hasattr(account, "account_password") and req.password:
account.account_password = req.password
account.status = "active"
account.login_channel = "web"
db.commit()
except ValueError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {
"message": "Web 登录成功,Cookie 已保存",
"success": True,
"account": _account_out(account),
"sdid": result.sdid,
}
@router.post("/accounts/app-password-login")
def app_password_login_account(
req: HuyaAppPasswordLoginRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""使用 App 协议密码登录虎牙(推荐),自动过滑块并保存 Cookie。"""
_require_huya_perm(current, "huya:import")
username_clean = req.username.strip()
account_db = (
db.query(HuyaAccount).filter(HuyaAccount.username == username_clean).first()
)
sms_url = account_db.sms_url if account_db and account_db.sms_url else ""
try:
result = login_huya_app_password(
username=username_clean,
password=req.password,
force_new_device=req.force_new_device,
sms_url=sms_url,
)
except HuyaAppQrAuthRequiredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HuyaCredentialError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except (HuyaAppLoginError, HuyaLoginError) as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502, detail=f"虎牙 App 密码登录失败: {exc}"
) from exc
if not result.success or not result.cookie:
logger.warning(
f"[huya-app] 账号 {req.username} 密码登录未完成: {result.message} (code={result.code})"
)
raise HTTPException(
status_code=400
if result.code in ("SMS_AUTH_REQUIRED", "QR_AUTH_REQUIRED")
else 502,
detail=result.message or "虎牙 App 密码登录失败",
)
try:
account = upsert_huya_cookie(
db,
result.cookie,
tag=req.tag,
username_hint=req.username,
raw_cookie=getattr(result, "raw_cookie", ""),
)
if hasattr(account, "account_password") and req.password:
account.account_password = req.password
account.status = "active"
account.login_channel = "app"
db.commit()
except ValueError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {
"message": "App 协议登录成功,Cookie 已保存",
"success": True,
"account": _account_out(account),
"sdid": result.sdid,
}
@router.post("/accounts/sms-code")
def send_sms_code(
req: HuyaSmsCodeRequest,
current: User = Depends(get_current_user),
):
"""发送虎牙短信验证码,返回提交登录所需 state。"""
_require_huya_perm(current, "huya:import")
try:
result = send_huya_sms_code(
phone=req.phone.strip(),
cookie=req.cookie.strip() or None,
)
except HuyaLoginError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"虎牙短信发码失败: {exc}") from exc
if not result.success or not result.state:
raise HTTPException(
status_code=502, detail=result.message or "虎牙短信发码失败"
)
return {
"message": result.message or "短信已发送",
"success": True,
"state": result.state,
"sdid": result.sdid,
"context": result.context,
"request_id": result.request_id,
}
@router.post("/accounts/sms-login")
def sms_login_account(
req: HuyaSmsLoginRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""提交虎牙短信验证码登录,成功后保存 Cookie。"""
_require_huya_perm(current, "huya:import")
try:
result = login_huya_sms(
authcode=req.authcode.strip(),
state=req.state.strip(),
phone=req.phone.strip(),
)
except HuyaLoginError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"虎牙短信登录失败: {exc}") from exc
if not result.success or not result.cookie:
raise HTTPException(
status_code=502, detail=result.message or "虎牙短信登录失败"
)
try:
account = upsert_huya_cookie(db, result.cookie, tag=req.tag, username_hint="")
account.login_channel = "sms"
phone = (req.phone or "").strip()
if phone:
account.game_phone = phone
account.updated_at = datetime.now(UTC)
db.commit()
db.refresh(account)
except ValueError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {
"message": "登录成功,Cookie 已保存",
"success": True,
"account": _account_out(account),
"sdid": result.sdid,
}
@router.post("/accounts/test-sms-url")
def test_sms_url_endpoint(
req: HuyaTestSmsUrlRequest,
current: User = Depends(get_current_user),
):
"""测试从指定接码链接获取验证码。"""
_require_huya_perm(current, "huya:import")
url = req.url.strip()
if not url:
raise HTTPException(status_code=400, detail="接码链接不能为空")
from core.sms_provider import SmsLine, SmsProviderClient, detect_provider
client = SmsProviderClient(timeout=(8, 15))
item = SmsLine(
phone=req.phone.strip(),
url=url,
provider=detect_provider(url),
raw=f"{req.phone}----{url}",
)
poll_result = client.poll(item)
return {
"success": poll_result.has_code,
"code": poll_result.code,
"status": poll_result.status,
"message": poll_result.message
or ("成功识别验证码" if poll_result.has_code else "未识别到验证码"),
"raw": poll_result.raw[:500],
}
@router.post("/accounts/{account_id}/fetch-sms-code")
def fetch_account_sms_code(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""通过该账号绑定的接码链接获取最新短信验证码。"""
_require_huya_perm(current, "huya:import")
account = (
_visible_huya_accounts_query(db, current)
.filter(HuyaAccount.id == account_id)
.first()
)
if account is None:
raise HTTPException(status_code=404, detail="虎牙账号不存在或无权操作")
if not account.sms_url:
raise HTTPException(status_code=400, detail="该账号未配置接码链接 (sms_url)")
from core.sms_provider import SmsLine, SmsProviderClient, detect_provider
client = SmsProviderClient(timeout=(8, 15))
item = SmsLine(
phone=account.game_phone or "",
url=account.sms_url,
provider=detect_provider(account.sms_url),
raw=f"{account.game_phone}----{account.sms_url}",
)
poll_result = client.poll(item)
return {
"account_id": account.id,
"username": account.username or "",
"phone": account.game_phone or "",
"sms_url": account.sms_url,
"success": poll_result.has_code,
"code": poll_result.code,
"status": poll_result.status,
"message": poll_result.message
or ("成功识别验证码" if poll_result.has_code else "未识别到验证码"),
"raw": poll_result.raw[:500],
}
@router.post("/accounts/{account_id}/auto-sms-login")
def auto_sms_login_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""使用该账号绑定的手机号与接码链接一键执行虎牙短信登录。"""
_require_huya_perm(current, "huya:import")
account = (
_visible_huya_accounts_query(db, current)
.filter(HuyaAccount.id == account_id)
.first()
)
if account is None:
raise HTTPException(status_code=404, detail="虎牙账号不存在或无权操作")
phone = (account.game_phone or "").strip()
sms_url = (account.sms_url or "").strip()
if not phone or not sms_url:
raise HTTPException(status_code=400, detail="该账号缺少手机号或接码链接")
# ⚠️ 这里原来是 `auto_register_huya_phone` —— 该名字在 core.huya.auto_register
# 里**从来没存在过**(2026-09-06 起就是死的),每次调用都在 import 阶段抛
# ImportError → 500。真实函数名是 register_huya_with_sms_line,返回的
# HuyaAutoRegisterResult 同样有 .success / .cookie / .message。
# 回归用例:tests/test_huya_auto_sms_login.py
from core.huya.auto_register import register_huya_with_sms_line
from core.sms_provider import SmsLine, detect_provider
item = SmsLine(
phone=phone,
url=sms_url,
provider=detect_provider(sms_url),
raw=f"{phone}----{sms_url}",
)
res = register_huya_with_sms_line(item, wait_seconds=90, poll_interval=2)
if not res.success or not res.cookie:
account.status = "login_failed"
account.updated_at = datetime.now(UTC)
db.commit()
raise HTTPException(
status_code=502, detail=res.message or "虎牙短信接码登录失败"
)
saved = save_huya_login_cookie_to_account(
db,
account,
res.cookie,
tag=account.tag or "",
username_hint=account.username or "",
)
saved.status = "active"
saved.login_channel = "sms"
db.commit()
return {
"message": "短信接码登录成功,Cookie 已保存",
"success": True,
"account": _account_out(saved),
}
_NON_RETRYABLE_LOGIN_CODES = frozenset({"QR_AUTH_REQUIRED", "SMS_AUTH_REQUIRED"})
def _sleep_seconds(seconds: float) -> None:
if seconds and seconds > 0:
time.sleep(seconds)
def _is_retryable_login_code(code: object) -> bool:
return str(code or "") not in _NON_RETRYABLE_LOGIN_CODES
def _login_fail_item(
*,
account_id: int,
username: str,
message: str,
account=None,
include_cookie: bool = False,
sdid: str = "",
) -> dict:
item: dict = {
"line": account_id,
"username": username,
"success": False,
"message": message,
}
if account is not None:
item["account"] = _account_out(account, include_cookie=include_cookie)
if sdid:
item["sdid"] = sdid
return item
def _mark_account_login_failed(db: Session, account) -> None:
account.status = "login_failed"
account.updated_at = datetime.now(UTC)
db.commit()
def _attempt_web_password_login(
account, *, include_cookie: bool, db: Session
) -> tuple[dict, bool]:
username = (account.username or "").strip()
try:
result = login_huya_password(
username=username,
password=(account.account_password or "").strip(),
cookie=account.cookie or None,
)
if not result.success or not result.cookie:
_mark_account_login_failed(db, account)
return (
_login_fail_item(
account_id=account.id,
username=username,
message=result.message or "虎牙密码登录失败",
account=account,
include_cookie=include_cookie,
sdid=result.sdid,
),
_is_retryable_login_code(result.code),
)
saved = save_huya_login_cookie_to_account(
db,
account,
result.cookie,
tag=account.tag or "",
username_hint=username,
)
saved.status = "active"
saved.login_channel = "web"
db.commit()
return (
{
"line": account.id,
"username": username,
"success": True,
"message": "登录成功,Cookie 已保存",
"account": _account_out(saved, include_cookie=include_cookie),
"sdid": result.sdid,
},
False,
)
except HuyaCredentialError as exc:
_mark_account_login_failed(db, account)
return (
_login_fail_item(
account_id=account.id,
username=username,
message=str(exc),
account=account,
include_cookie=include_cookie,
),
False,
)
except (HuyaLoginError, ValueError) as exc:
_mark_account_login_failed(db, account)
return (
_login_fail_item(
account_id=account.id,
username=username,
message=str(exc),
account=account,
include_cookie=include_cookie,
),
True,
)
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
_mark_account_login_failed(db, account)
return (
_login_fail_item(
account_id=account.id,
username=username,
message=f"虎牙密码登录失败: {exc}",
account=account,
include_cookie=include_cookie,
),
True,
)
def _attempt_app_password_login(
account, *, force_new_device: bool, include_cookie: bool, db: Session
) -> tuple[dict, bool]:
username = (account.username or "").strip()
sms_url = (account.sms_url or "").strip()
try:
result = login_huya_app_password(
username=username,
password=(account.account_password or "").strip(),
force_new_device=force_new_device,
sms_url=sms_url,
)
if not result.success or not result.cookie:
_mark_account_login_failed(db, account)
logger.warning(
f"[huya-app] 账号 {username} 登录未完成: {result.message} (code={result.code})"
)
return (
_login_fail_item(
account_id=account.id,
username=username,
message=result.message or "虎牙 App 密码登录失败",
account=account,
include_cookie=include_cookie,
sdid=result.sdid,
),
_is_retryable_login_code(result.code),
)
saved = save_huya_login_cookie_to_account(
db,
account,
result.cookie,
tag=account.tag or "",
username_hint=username,
)
saved.status = "active"
saved.login_channel = "app"
db.commit()
return (
{
"line": account.id,
"username": username,
"success": True,
"message": "App 登录成功,Cookie 已保存",
"account": _account_out(saved, include_cookie=include_cookie),
"sdid": result.sdid,
},
False,
)
except HuyaAppQrAuthRequiredError as exc:
_mark_account_login_failed(db, account)
return (
_login_fail_item(
account_id=account.id,
username=username,
message=str(exc),
account=account,
include_cookie=include_cookie,
),
False,
)
except HuyaCredentialError as exc:
_mark_account_login_failed(db, account)
return (
_login_fail_item(
account_id=account.id,
username=username,
message=str(exc),
account=account,
include_cookie=include_cookie,
),
False,
)
except (HuyaAppLoginError, HuyaLoginError, ValueError) as exc:
_mark_account_login_failed(db, account)
return (
_login_fail_item(
account_id=account.id,
username=username,
message=str(exc),
account=account,
include_cookie=include_cookie,
),
True,
)
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
_mark_account_login_failed(db, account)
return (
_login_fail_item(
account_id=account.id,
username=username,
message=f"虎牙 App 密码登录失败: {exc}",
account=account,
include_cookie=include_cookie,
),
True,
)
def _run_selected_password_logins(
*,
channel: str,
req: HuyaPasswordLoginSelectedRequest,
db: Session,
current: User,
) -> dict:
"""单线程逐个登录:同一时间只登一个号,失败最多重试,号与号之间留间隔。"""
_require_huya_perm(current, "huya:import")
selected_ids = _selected_huya_account_ids(
db, current, cast(AccountBulkSelection, req)
)
if not selected_ids:
raise HTTPException(status_code=400, detail="请选择虎牙账号")
accounts = (
_visible_huya_accounts_query(db, current)
.filter(HuyaAccount.id.in_(selected_ids))
.all()
)
account_map = {account.id: account for account in accounts}
include_cookie = _can_view_huya_cookie(current)
results: list[dict] = []
success_count = 0
failed_count = 0
max_attempts = req.max_attempts
interval_seconds = req.interval_seconds
retry_delay_seconds = req.retry_delay_seconds
label = "App" if channel == "app" else "Web"
for index, account_id in enumerate(selected_ids):
if index > 0:
_sleep_seconds(interval_seconds)
account = account_map.get(account_id)
if account is None:
failed_count += 1
results.append(
_login_fail_item(
account_id=account_id,
username="",
message="账号不存在或无权登录",
)
)
continue
username = (account.username or "").strip()
password = (account.account_password or "").strip()
if not username or not password:
failed_count += 1
results.append(
_login_fail_item(
account_id=account.id,
username=username,
message="该账号未导入密码",
account=account,
include_cookie=include_cookie,
)
)
continue
last_item: dict | None = None
used_attempts = 0
for attempt in range(1, max_attempts + 1):
if attempt > 1:
_sleep_seconds(retry_delay_seconds)
used_attempts = attempt
if channel == "app":
last_item, retryable = _attempt_app_password_login(
account,
force_new_device=req.force_new_device,
include_cookie=include_cookie,
db=db,
)
else:
last_item, retryable = _attempt_web_password_login(
account,
include_cookie=include_cookie,
db=db,
)
if last_item["success"] or not retryable:
break
if attempt < max_attempts:
logger.warning(
f"[huya-{channel}] 账号 {username} 第 {attempt}/{max_attempts} 次失败,"
f"{retry_delay_seconds}s 后重试: {last_item['message']}"
)
assert last_item is not None
last_item["attempts"] = used_attempts
if last_item["success"]:
success_count += 1
if used_attempts > 1:
last_item["message"] = (
f"第 {used_attempts} 次尝试成功。" + last_item["message"]
)
else:
failed_count += 1
if used_attempts > 1:
last_item["message"] = (
f"已尝试 {used_attempts} 次:{last_item['message']}"
)
results.append(last_item)
return {
"message": f"{label} 登录完成:成功 {success_count} 条,失败 {failed_count} 条",
"success": True,
"count": success_count,
"failed": failed_count,
"results": results,
}
@router.post("/accounts/password-login/selected")
def password_login_selected_accounts(
req: HuyaPasswordLoginSelectedRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""对已导入的虎牙账号执行 Web 密码登录(单线程,失败可重试)。"""
return _run_selected_password_logins(channel="web", req=req, db=db, current=current)
@router.post("/accounts/app-password-login/selected")
def app_password_login_selected_accounts(
req: HuyaPasswordLoginSelectedRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""对已导入的虎牙账号使用 App 协议逐个登录(推荐,单线程)。"""
return _run_selected_password_logins(channel="app", req=req, db=db, current=current)