Merge branch 'fix/pyright-env-and-type-guards' into dev

This commit is contained in:
yml2213
2026-10-08 12:34:11 +08:00
31 changed files with 599 additions and 96 deletions
@@ -10,6 +10,7 @@ from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from web.backend.services.login_failure import classify_failure
revision: str = "20261008_0052"
-1
View File
@@ -57,7 +57,6 @@ from ..services.account_lifecycle import (
set_manual_state,
)
from ..services.account_service import (
cookie_account_ids_query,
parse_and_build_accounts,
)
from ..services.account_value import (
+5 -3
View File
@@ -58,10 +58,12 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict:
for task, account in tasks_and_accounts:
task_result = task.result if isinstance(task.result, dict) else {}
channel = str(task_result.get("recharge_channel") or "")
# 先落到局部变量再判 isinstance:直接写
# `x.get(k) if isinstance(x.get(k), dict) else {}` 是两次独立取值,
# 类型检查器无法据此收窄,会报「可能为 None」。
raw_supplier_order = task_result.get("supplier_order")
supplier_order = (
task_result.get("supplier_order")
if isinstance(task_result.get("supplier_order"), dict)
else {}
raw_supplier_order if isinstance(raw_supplier_order, dict) else {}
)
supplier_order_id = str(
supplier_order.get("order_id")
+13 -1
View File
@@ -544,4 +544,16 @@ def _unbind_device_accounts(accounts: list[str]) -> tuple[list[str], list[str]]:
return removed, missing
__all__ = [name for name in globals() if not name.startswith("__")]
# 运行期导出清单:显式列出「非 dunder」的全部模块级名字(**含下划线开头的内部
# helper**),因为 python 的 `import *` 只要模块定义了 `__all__` 就照单全收。
#
# ⚠️ 六个 huya 路由模块(accounts/cookies/register/workbench/login/devices)原本
# 用 `from ._common import *`,全部已改为**显式导入**。原因不是风格洁癖:pyright
# 不会执行上面的推导式,只能按 PEP 8 保守假设「下划线名不导出」,于是那 108 处
# helper 调用全被报成「未定义」—— 更糟的是,它让这 6 个模块的类型检查彻底失明
# (helper 改名/删除时静态检查一声不吭,只能等运行期 500;曾因此漏掉一个
# `ImportError` 死端点)。新增代码请一律用显式导入。
#
# 这里保留计算式 `__all__` 是为了不改动任何运行期行为:万一还有别处 `import *`,
# 行为与改造前完全一致。pyright 无法静态求值它,故就地忽略该诊断。
__all__ = [name for name in globals() if not name.startswith("__")] # pyright: ignore[reportUnsupportedDunderAll]
+47 -1
View File
@@ -2,7 +2,53 @@
from fastapi import APIRouter
from ._common import *
from ._common import (
DEFAULT_EXCHANGE_TARGET,
AccountAssign,
AccountBulkSelection,
AccountBulkTag,
AccountTag,
BatchAssign,
Depends,
HTTPException,
HuyaAccount,
HuyaBrowserExchangeRequest,
HuyaCookieImport,
HuyaPasswordAccountImport,
HuyaTask,
HuyaWorkbenchAccount,
Query,
Session,
User,
_account_out,
_account_out_light,
_can_view_huya_all,
_can_view_huya_assigned,
_can_view_huya_cookie,
_clear_huya_account_references,
_filter_huya_accounts_query,
_huya_cookie_state_map,
_huya_password_state_map,
_require_huya_perm,
_require_huya_tag_permission,
_selected_huya_account_ids,
_visible_huya_accounts_query,
browser_login_script,
cookie_pairs,
defer,
ensure_huya_config,
exchange_target_options,
exchange_target_url,
func,
get_current_user,
get_db,
huya_config_value,
import_huya_cookies,
import_huya_password_accounts,
normalize_huya_cookie,
or_,
record_audit,
)
router = APIRouter()
+24 -1
View File
@@ -2,7 +2,30 @@
from fastapi import APIRouter
from ._common import *
from ._common import (
Depends,
HTTPException,
HuyaAccount,
Query,
Session,
StreamingResponse,
User,
_can_view_huya_cookie,
_cookie_out,
_cookie_out_light,
_has_huya_perm,
_require_huya_perm,
_visible_huya_accounts_query,
csv,
defer,
get_current_user,
get_db,
io,
normalize_huya_cookie,
or_,
rebuild_web_cookie_from_raw,
record_audit,
)
router = APIRouter()
+19 -1
View File
@@ -2,7 +2,25 @@
from fastapi import APIRouter
from ._common import *
from ._common import (
_DEVICE_BINDINGS_PERMS_VIEW,
Depends,
HTTPException,
HuyaAccount,
HuyaDeviceBindingsBatchDeleteRequest,
Session,
User,
_fp_state_dir,
_load_profile_db,
_mask_hex,
_require_huya_perm,
_save_profile_db,
_unbind_device_accounts,
get_current_user,
get_db,
shutil,
user_has_permission,
)
router = APIRouter()
+42 -3
View File
@@ -4,7 +4,41 @@ import time
from fastapi import APIRouter
from ._common import *
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()
@@ -287,7 +321,12 @@ def auto_sms_login_account(
sms_url = (account.sms_url or "").strip()
if not phone or not sms_url:
raise HTTPException(status_code=400, detail="该账号缺少手机号或接码链接")
from core.huya.auto_register import auto_register_huya_phone
# ⚠️ 这里原来是 `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(
@@ -296,7 +335,7 @@ def auto_sms_login_account(
provider=detect_provider(sms_url),
raw=f"{phone}----{sms_url}",
)
res = auto_register_huya_phone(item, wait_seconds=90, poll_interval=2)
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)
+21 -1
View File
@@ -2,7 +2,27 @@
from fastapi import APIRouter
from ._common import *
from ._common import (
Depends,
HTTPException,
HuyaAutoRegisterBatchOut,
HuyaAutoRegisterRequest,
HuyaAutoRegisterRetryRequest,
HuyaRegisterSuccessLogOut,
ProxyConfig,
Query,
Session,
StreamingResponse,
User,
_require_huya_perm,
export_success_logs_text,
get_current_user,
get_db,
huya_register_registry,
list_success_logs,
parse_sms_lines,
threading,
)
router = APIRouter()
+46 -1
View File
@@ -2,7 +2,52 @@
from fastapi import APIRouter
from ._common import *
from ._common import (
HUYA_CONFIG_FIELDS,
SUPPORTED_TASK_TYPES,
UTC,
Depends,
HTTPException,
HuyaBatchRunner,
HuyaConfigOut,
HuyaConfigUpdate,
HuyaGoodsOut,
HuyaGoodsSnapshot,
HuyaRechargeGoodsOut,
HuyaRechargeGoodsSnapshot,
HuyaTask,
HuyaTaskBatchRequest,
HuyaTaskOut,
HuyaWorkbench,
HuyaWorkbenchAccount,
HuyaWorkbenchAccountsUpdate,
Query,
Session,
SessionLocal,
User,
WebSocket,
WebSocketDisconnect,
_config_out,
_has_huya_perm,
_huya_task_summary,
_require_huya_batch_owner,
_require_huya_task_account_access,
_task_out,
_visible_huya_tasks_query,
_workbench_account_ids,
apply_huya_config_defaults,
asyncio,
authenticate_websocket,
cleanup_orphan_huya_tasks,
create_planned_tasks,
datetime,
ensure_huya_config,
get_db,
huya_batch_registry,
record_audit,
require_permission,
threading,
)
router = APIRouter()
+31 -31
View File
@@ -20,7 +20,7 @@ class YybTaskCreateRequest(BaseModel):
class YybLoginRequest(BaseModel):
provider: str = Field(..., pattern="^(qq|wechat)$")
timeout: int = Field(600, ge=60, le=1800)
timeout: int = Field(default=600, ge=60, le=1800)
class YybSelectionRequest(BaseModel):
@@ -28,7 +28,7 @@ class YybSelectionRequest(BaseModel):
points: int = Field(..., gt=0)
product_id: str = Field(..., min_length=1, max_length=128)
zone_id: str = Field(..., min_length=1, max_length=64)
zone_name: str = Field("", max_length=128)
zone_name: str = Field(default="", max_length=128)
role_id: str = Field(..., min_length=1, max_length=64)
role_name: str = Field(..., min_length=1, max_length=128)
@@ -91,7 +91,7 @@ class UserInfo(BaseModel):
class UserCreate(BaseModel):
username: str = Field(..., min_length=2, max_length=64)
password: str = Field(..., min_length=6, max_length=128)
role: str = Field("support", pattern="^(super_admin|operation|support)$")
role: str = Field(default="support", pattern="^(super_admin|operation|support)$")
remark: str = ""
@@ -153,7 +153,7 @@ class AccountBulkSelection(BaseModel):
class AccountExportRequest(AccountBulkSelection):
"""账号导出请求:支持纯账号名单(txt)或结构化明细(csv)。"""
format: str = Field("txt", pattern="^(txt|csv)$")
format: str = Field(default="txt", pattern="^(txt|csv)$")
class BatchAssign(AccountBulkSelection):
@@ -295,7 +295,7 @@ class LoginBatchRequest(BaseModel):
concurrency: int = 3 # 并发数,1-10
api_strategy: str = "wgapi" # 接口策略: wgapi(新版)或 iframe(旧版备选)
mode: str = Field(
"login", pattern="^(login|check)$"
default="login", pattern="^(login|check)$"
) # login=登录取CK,check=账号状态检测
@@ -352,9 +352,9 @@ class AccountCheckBatchRequest(BaseModel):
status: str = ""
ck_status: str = ""
bind_status: str = ""
concurrency: int = Field(3, ge=1, le=10)
max_login_retries: int = Field(0, ge=0, le=50)
max_total_time: float = Field(300, ge=0, le=3600)
concurrency: int = Field(default=3, ge=1, le=10)
max_login_retries: int = Field(default=0, ge=0, le=50)
max_total_time: float = Field(default=300, ge=0, le=3600)
use_proxy: bool = False
# 登录接口策略,与登录任务同一套取值:wgapi(默认,保持原行为)或 iframe。
# 有些人只有其中一套能收到邮箱验证码,所以要能选。
@@ -446,11 +446,11 @@ class HuyaAutoRegisterRequest(BaseModel):
text: str = Field(..., min_length=1)
tag: str = ""
concurrency: int = Field(1, ge=1, le=5)
wait_seconds: float = Field(180, ge=15, le=600)
poll_interval: float = Field(5, ge=1, le=30)
password_prefix: str = Field("hy", max_length=8)
fixed_password: str = Field("", max_length=64)
concurrency: int = Field(default=1, ge=1, le=5)
wait_seconds: float = Field(default=180, ge=15, le=600)
poll_interval: float = Field(default=5, ge=1, le=30)
password_prefix: str = Field(default="hy", max_length=8)
fixed_password: str = Field(default="", max_length=64)
use_proxy: bool = False
@@ -463,12 +463,12 @@ class HuyaAutoRegisterRetryRequest(BaseModel):
- all_unfinished: 失败+未完成都跑
"""
mode: str = Field("continue", max_length=32)
concurrency: int | None = Field(None, ge=1, le=5)
wait_seconds: float | None = Field(None, ge=15, le=600)
poll_interval: float | None = Field(None, ge=1, le=30)
password_prefix: str | None = Field(None, max_length=8)
fixed_password: str | None = Field(None, max_length=64)
mode: str = Field(default="continue", max_length=32)
concurrency: int | None = Field(default=None, ge=1, le=5)
wait_seconds: float | None = Field(default=None, ge=15, le=600)
poll_interval: float | None = Field(default=None, ge=1, le=30)
password_prefix: str | None = Field(default=None, max_length=8)
fixed_password: str | None = Field(default=None, max_length=64)
use_proxy: bool | None = None
@@ -550,9 +550,9 @@ class HuyaPasswordLoginSelectedRequest(BaseModel):
assigned_only: bool = False
has_cookie: bool = False
force_new_device: bool = False
max_attempts: int = Field(2, ge=1, le=3)
interval_seconds: float = Field(8, ge=0, le=60)
retry_delay_seconds: float = Field(3, ge=0, le=30)
max_attempts: int = Field(default=2, ge=1, le=3)
interval_seconds: float = Field(default=8, ge=0, le=60)
retry_delay_seconds: float = Field(default=3, ge=0, le=30)
class HuyaDeviceBindingsBatchDeleteRequest(BaseModel):
@@ -647,7 +647,7 @@ class HuyaConfigUpdate(BaseModel):
class HuyaTaskBatchRequest(BaseModel):
account_ids: list[int]
task_type: str
handbook_scope: str = Field("legacy", pattern="^(legacy|elite)$")
handbook_scope: str = Field(default="legacy", pattern="^(legacy|elite)$")
concurrency: int = 3
payload: dict[str, Any] = Field(default_factory=dict)
@@ -660,7 +660,7 @@ class HuyaWorkbenchAccountsUpdate(BaseModel):
class HuyaBrowserExchangeRequest(BaseModel):
"""浏览器兑换的目标页选择(常量见 services/huya_exchange_targets.py)。"""
target: str | None = Field(None, pattern="^(live_room|elite|esports)$")
target: str | None = Field(default=None, pattern="^(live_room|elite|esports)$")
class HuyaTaskOut(BaseModel):
@@ -833,10 +833,10 @@ class DouyuConfigUpdate(BaseModel):
confirm_act_alias: str | None = None
legacy_act_alias: str | None = None
room_id: str | None = None
elite_amount: int | None = Field(None, ge=1)
elite_amount: int | None = Field(default=None, ge=1)
esports_manual_id: str | None = None
esports_act_alias: str | None = None
esports_amount: int | None = Field(None, ge=1)
esports_amount: int | None = Field(default=None, ge=1)
esports_chicken_gift_id: str | None = None
esports_chicken_skin_id: str | None = None
esports_firework_gift_id: str | None = None
@@ -845,12 +845,12 @@ class DouyuConfigUpdate(BaseModel):
xpd_act_alias: str | None = None
xpd_act_id: str | None = None
xpd_rid: str | None = None
gold_pay_type: int | None = Field(None, ge=1, le=9)
gold_pay_type: int | None = Field(default=None, ge=1, le=9)
gold_recharge_channel: str | None = Field(
None, pattern="^(wechat_qr|supplier_api)$"
default=None, pattern="^(wechat_qr|supplier_api)$"
)
gold_api_product_id: str | None = Field(None, max_length=128)
gold_api_account_template_name: str | None = Field(None, max_length=64)
gold_api_product_id: str | None = Field(default=None, max_length=128)
gold_api_account_template_name: str | None = Field(default=None, max_length=64)
gift_id: str | None = None
skin_id: str | None = None
@@ -859,7 +859,7 @@ class DouyuTaskBatchRequest(BaseModel):
account_ids: list[int]
task_type: str
handbook_scope: str = Field(..., pattern="^(elite|esports|peace)$")
concurrency: int = Field(3, ge=1, le=10)
concurrency: int = Field(default=3, ge=1, le=10)
payload: dict[str, Any] = Field(default_factory=dict)
@@ -41,10 +41,10 @@ from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from typing import Any
from typing import Any, cast
from loguru import logger
from sqlalchemy import text
from sqlalchemy import CursorResult, text
from sqlalchemy.orm import Session
from ....models import AutomationJob
@@ -133,7 +133,9 @@ def _delete_batch(
),
params,
)
return int(result.rowcount or 0)
# Session.execute 的静态返回类型是 Result[Any],但 DML 语句实际拿到的是
# CursorResult(rowcount 只定义在它上面)。运行期行为不变,只是把这一点写实。
return int(cast(CursorResult[Any], result).rowcount or 0)
def run_douyu_tasks_retention(db: Session, job: AutomationJob) -> dict[str, Any]:
+5 -3
View File
@@ -221,9 +221,11 @@ class ManualMixin:
account.esports_opened = manual_type is not None and manual_type >= 1
# 到期时间也落库 —— 否则界面只能显示"已开通",看不到什么时候到期
expire_raw = result.get("expire_time")
expire_int = (
int(expire_raw) if str(expire_raw or "").strip().isdigit() else None
)
# 先规整成字符串再判 isdigit:原来写 `int(expire_raw) if str(expire_raw
# or "").strip().isdigit()` —— 判的是 A、转的是 B,类型检查器收窄不了,
# 而且同一份值取了两遍。规整到一个局部变量后两个问题一起没了。
expire_text = str(expire_raw or "").strip()
expire_int = int(expire_text) if expire_text.isdigit() else None
# ⚠️ 斗鱼用 0 表示「没有到期时间」。直接存 0 的话界面会显示 1970/1/1
# (这个坑在积分到期上踩过一次,这里别再踩)。
account.esports_expire_time = (
@@ -67,9 +67,12 @@ def _match_suit_item(want: str, rows: list[dict[str, Any]]) -> dict[str, Any] |
# 优先找带「套装」前缀的
for it in rows:
cname = str(it.get("commodityName") or "").strip()
if core in cname and not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS):
if "套装" in cname:
return it
if (
core in cname
and not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS)
and "套装" in cname
):
return it
# 3. 兜底匹配不带排除词的项
for it in rows:
+4 -2
View File
@@ -7,7 +7,7 @@ import copy
import threading
import time
from datetime import UTC, datetime
from typing import Any
from typing import Any, Literal
from loguru import logger
from sqlalchemy.orm import Session
@@ -51,7 +51,9 @@ class HuyaBatchRunnerCore:
def stop(self):
self._stop.set()
def _open_wss_session(self, uid: int, cookie: str, kind: str):
def _open_wss_session(
self, uid: int, cookie: str, kind: Literal["activity", "shop"]
):
"""在当前任务线程创建并登记 WSS 会话,统一由 finally 回收。"""
from core.huya.elite_session import HuyaEliteWssSession
+4 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from sqlalchemy.orm import Session
@@ -40,7 +40,9 @@ class RechargeMixin:
def _push_log(self, level: str, message: str) -> None: ...
def _mark_task(self, *args: Any, **kwargs: Any) -> None: ...
def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ...
def _open_wss_session(self, uid: int, cookie: str, kind: str): ...
def _open_wss_session(
self, uid: int, cookie: str, kind: Literal["activity", "shop"]
): ...
def _shop_client(self, uid: int, cookie: str): ...
@staticmethod
@@ -71,9 +71,9 @@ full = gradient_rounded(S, RADIUS)
draw_gift(full)
base = "web/extension/huya-exchange-helper/"
for size in (128, 48):
full.resize((size, size), Image.LANCZOS).save(f"{base}icon{size}.png")
full.resize((size, size), Image.Resampling.LANCZOS).save(f"{base}icon{size}.png")
simple = gradient_rounded(512, 100)
draw_gift_simple(simple)
simple.resize((16, 16), Image.LANCZOS).save(f"{base}icon16.png")
simple.resize((16, 16), Image.Resampling.LANCZOS).save(f"{base}icon16.png")
print("icons regenerated")