Merge branch 'fix/pyright-env-and-type-guards' into dev
This commit is contained in:
@@ -29,7 +29,11 @@ done <<< "$staged"
|
||||
if [ "${#python_files[@]}" -gt 0 ]; then
|
||||
echo "[pre-commit] 检查 Python 格式..."
|
||||
uv run --group dev ruff format --check "${python_files[@]}"
|
||||
echo "[pre-commit] Python 格式通过"
|
||||
# ⚠️ 只跑 format 不够:format 不管「未使用导入 / 未排序导入 / 宽 except」这类
|
||||
# 问题,之前全仓攒了 6 条(含一个 F401 死导入)没人拦。两条一起跑才算校验。
|
||||
echo "[pre-commit] 检查 Python lint..."
|
||||
uv run --group dev ruff check "${python_files[@]}"
|
||||
echo "[pre-commit] Python 格式 + lint 通过"
|
||||
fi
|
||||
|
||||
if [ "$frontend_changed" -eq 1 ]; then
|
||||
|
||||
+3
-1
@@ -44,7 +44,9 @@ done <<< "$push_lines"
|
||||
if [ "${#python_files[@]}" -gt 0 ]; then
|
||||
echo "[pre-push] 检查 Python 格式..."
|
||||
uv run --group dev ruff format --check "${python_files[@]}"
|
||||
echo "[pre-push] Python 格式通过"
|
||||
echo "[pre-push] 检查 Python lint..."
|
||||
uv run --group dev ruff check "${python_files[@]}"
|
||||
echo "[pre-push] Python 格式 + lint 通过"
|
||||
fi
|
||||
|
||||
if [ "$frontend_changed" -eq 1 ]; then
|
||||
|
||||
@@ -35,11 +35,14 @@
|
||||
关键是**反向验证** —— 把 bug 注入回去,确认用例真的会变红,再还原。
|
||||
「跑了一下没报错」不算验证。
|
||||
- **提交/推送会跑钩子**,所以本地必须按同样标准自查:
|
||||
- `pre-commit`:`ruff format --check`(**只跑 `ruff check` 不够**,格式不对会直接拦下提交)
|
||||
- `pre-commit`:`ruff format --check` **和** `ruff check`(两个都要跑,见坑 #8)
|
||||
+ 前端 `tsc -b`;
|
||||
- `pre-push`:上面这些 + **全量 pytest**。
|
||||
- `pre-push`:上面这些 + **全量 pytest**(`TYPECHECK=1` 时额外跑 Pyright)。
|
||||
- **断言要看 `model_dump()`(序列化结果)**,只看属性会假绿(原因见坑 #2)。
|
||||
- **别信「测试通过」** —— 用例可能是假绿的(原因见坑 #3)。
|
||||
- **Pyright 只查 `core`/`utils`/`web`,`tests` 有意排除**(原因和重新打开的方法都写在
|
||||
`pyrightconfig.json` 的注释里)。别为了让它闭嘴去给 FastAPI 路由补返回值注解 ——
|
||||
那等于加了 `response_model`,会真的裁剪响应体。
|
||||
|
||||
## 4. 数据库与迁移
|
||||
|
||||
@@ -100,8 +103,10 @@ docker compose up -d douyu-login
|
||||
| 5 | **`docker compose run` 默认读 stdin**:用 `ssh 'bash -s' < 脚本` 喂进去时,它会把脚本剩下的行吃掉 | 用 `docker compose run -T --rm ... </dev/null` |
|
||||
| 6 | **镜像同步脚本会白等 15 分钟**:`pgrep -f 'mysqldump.*douyu_login'` 匹配到了自己所在的 shell,等待循环永不提前退出 | 判定远端进程存活别用会自匹配的 `pgrep -f` 模式 |
|
||||
| 7 | **`set -e` + 输出过滤(管道接 `grep -v`)会半途静默中止**:过滤把输出全吃掉时 grep 返回 1,被 `set -e` 当成失败(曾在「已删库、还没导入」处中止,镜像库一度是空的) | 过滤输出后别让 `set -e` 接管退出码;关键步骤显式检查成功 |
|
||||
| 8 | **`ruff check` 通过 ≠ 能提交**:钩子查的是 `ruff format` | 提交前本地跑一遍 `.githooks/pre-commit` |
|
||||
| 8 | **格式检查和 lint 检查,只跑一个都会漏**:`ruff format --check` 对「未使用导入 / 未排序导入 / 宽 except」全部放过(实测同一个文件、同一处未使用 `import json`:format 退出码 0,check 退出码 1),而 `ruff check` 又完全不管格式 | 钩子现在两条都跑;本地自查也两条都跑(`./dev.sh format-check` + `./dev.sh lint-check`) |
|
||||
| 9 | **偷换概念把「账号级状态」当成「全局字段」**:斗鱼商城 `storehome` 的 `count`(库存)确实是全局的,但 **`status` 字段是强账号维度的!`status=1` 就是「已达兑换上限」(限量 1 件已满/已兑换)**。曾把两者混为一谈,误以为商品列表全不可信,跑去依赖早已被官方清空的 `exchangeList`,导致已兑换的账号被错误打上「两个都可兑换」绿标 | 判定高价值资产准入:**以 `storehome.status==1` 为最高裁判**;`exchangeList` 仅做历史展示兜底,绝不可当作否定已拥有的依据 |
|
||||
| 10 | **Pyright 的报错数量取决于「它怎么找到解释器」**:`pyrightconfig.json` 原本没钉解释器,直接跑 `.venv/bin/pyright` 时它会去 PATH 摸 `/usr/bin/python3`(macOS 自带 3.9),进而读用户级 `~/Library/Python/3.9/site-packages` 里的 **SQLAlchemy 1.4.54** 和 3.9 语义 → 全仓报 **2926** 条(`Mapped[int]` 说「不是泛型」、`int \| None` 说「需要 3.10+」、`UTC` 说不认识),而同一个仓库 `uv run pyright` 只有 **273** 条。**90% 的「错误」是环境假象** | 必须在配置里显式写 `venvPath`/`venv`/`pythonVersion`;IDE 里出现成片这类报错先查解释器。自查命令:`pyright --verbose` 看 `Assuming Python version` 和 Search paths 里有不有 3.9 |
|
||||
| 11 | **Pyright 不认 `Field()` 的位置参数默认值**:`x: int = Field(3, ge=1, le=10)` 会被判成「必填」,凡是不传该参数的调用点全报 `Arguments missing for parameters`;`Field(default=3, ...)` 才认。运行期 pydantic 两边等价(否则测试早红了),纯静态判定差异 | schema 里一律写 `Field(default=..., ...)`;排查手法是写最小复现(一个 pydantic 模型 + 一次构造)确认到底是哪边的问题 |
|
||||
|
||||
## 8. 仓库事实(速查)
|
||||
|
||||
@@ -112,6 +117,6 @@ docker compose up -d douyu-login
|
||||
| 本地服务 | `./dev.sh`(8800 端口,`--reload`);**启动时会自动跑迁移** |
|
||||
| 本地 MySQL | 容器 `live-hub-py-mysql-1`,端口 3307;库见 §4 |
|
||||
| 生产镜像库 | `douyu_login_prod`(`scripts/refresh_local_prod_mirror.sh` 同步,只读用) |
|
||||
| 迁移 head | `main` = `20261004_0051`;`dev` = `20261007_0052`(**两侧不一致,切分支前先对齐库**) |
|
||||
| 迁移 head | `main` = `20261008_0052`;`dev` = `20261008_0053`(**两侧不一致,切分支前先对齐库**)。这行会过期,动手前用 `git ls-tree -r --name-only <ref> web/backend/migrations/versions/` 自己算一遍 head(没被任何 `down_revision` 引用的那个) |
|
||||
| 前端 | `tsc -b` 开了 `noUnusedLocals`,死代码/未用导入直接报 TS6133;镜像构建时会 `npm run build` |
|
||||
| 测试 | `pytest tests/ -q`(用 `douyu_login_test`);`ruff format` + `ruff check` |
|
||||
|
||||
@@ -145,6 +145,7 @@ uv run --group dev pytest # 或 ./dev.sh test
|
||||
uv run --group dev pytest tests/test_cookie_operations.py
|
||||
./dev.sh format tests conftest.py
|
||||
./dev.sh format-check tests conftest.py
|
||||
./dev.sh lint-check tests conftest.py
|
||||
./dev.sh type-check # 渐进式 Pyright
|
||||
docker compose --profile test run --rm test
|
||||
```
|
||||
@@ -168,9 +169,12 @@ docker compose --profile test run --rm test
|
||||
./dev.sh frontend-check # 单独跑前端 TypeScript
|
||||
```
|
||||
|
||||
- **pre-commit**:暂存的 Python 文件做 Ruff 格式检查;改了 `web/frontend/` 则跑 `tsc -b`
|
||||
- **pre-commit**:暂存的 Python 文件做 Ruff 格式检查 **+ `ruff check` lint**;改了 `web/frontend/` 则跑 `tsc -b`
|
||||
- **pre-push**:上述检查 + 全量 pytest
|
||||
- Pyright 默认不开(噪音大):`TYPECHECK=1 git push`
|
||||
—— 注意 `pyrightconfig.json` 已钉死 `.venv`/Python 3.12;**没有这段配置时 pyright 会去
|
||||
摸 macOS 自带的 Python 3.9**,读成 SQLAlchemy 1.4 并凭空多报约 2600 条假错(2926 vs 273)。
|
||||
若在 IDE 里看到成片「Mapped 不是泛型 / 联合类型需要 3.10+」,先查解释器有没有选对。
|
||||
- 紧急绕过:`git push --no-verify` 或 `SKIP_TESTS=1 git push`
|
||||
|
||||
## 技术栈
|
||||
|
||||
+47
-7
@@ -31,6 +31,7 @@ import struct
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Literal, overload
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
import requests
|
||||
@@ -245,7 +246,7 @@ def solve_safe_auth(
|
||||
k: v[0]
|
||||
for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()
|
||||
}
|
||||
app_id = str(q.get("appId") or "5002")
|
||||
app_id = q.get("appId") or "5002"
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(max_retry):
|
||||
solver = HuyaVerificationSolver(
|
||||
@@ -293,6 +294,42 @@ def parse_real_uid(resp: bytes) -> int:
|
||||
raise HuyaAppLoginError("无法从登录响应解析真实 UID")
|
||||
|
||||
|
||||
@overload
|
||||
def login_cred_with_flow(
|
||||
account: str,
|
||||
password: str,
|
||||
max_rounds: int = ...,
|
||||
device_info: dict | None = ...,
|
||||
proxies: dict | None = ...,
|
||||
include_device_token: Literal[False] = ...,
|
||||
sms_url: str = ...,
|
||||
) -> tuple[bytes, int]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def login_cred_with_flow(
|
||||
account: str,
|
||||
password: str,
|
||||
max_rounds: int = ...,
|
||||
device_info: dict | None = ...,
|
||||
proxies: dict | None = ...,
|
||||
include_device_token: Literal[True] = ...,
|
||||
sms_url: str = ...,
|
||||
) -> tuple[bytes, int, str]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def login_cred_with_flow(
|
||||
account: str,
|
||||
password: str,
|
||||
max_rounds: int = ...,
|
||||
device_info: dict | None = ...,
|
||||
proxies: dict | None = ...,
|
||||
include_device_token: bool = ...,
|
||||
sms_url: str = ...,
|
||||
) -> tuple[bytes, int] | tuple[bytes, int, str]: ...
|
||||
|
||||
|
||||
def login_cred_with_flow(
|
||||
account: str,
|
||||
password: str,
|
||||
@@ -405,7 +442,11 @@ class QrRole:
|
||||
self.req_counter = random.randint(40_000_000, 41_000_000)
|
||||
self.s.headers.update(
|
||||
{
|
||||
"User-Agent": UA_PC if pc else mobile_user_agent(device_info),
|
||||
"User-Agent": (
|
||||
UA_PC
|
||||
if pc or device_info is None
|
||||
else mobile_user_agent(device_info)
|
||||
),
|
||||
"Origin": UDB_BASE,
|
||||
"content-type": "application/json;charset=UTF-8",
|
||||
"Accept": "*/*",
|
||||
@@ -550,8 +591,7 @@ class HuyaAppPasswordLogin:
|
||||
jar = session.cookies
|
||||
if hasattr(jar, "items"):
|
||||
pairs = [
|
||||
(str(key), "" if value is None else str(value))
|
||||
for key, value in jar.items()
|
||||
(key, "" if value is None else value) for key, value in jar.items()
|
||||
]
|
||||
else:
|
||||
pairs = [
|
||||
@@ -576,13 +616,13 @@ class HuyaAppPasswordLogin:
|
||||
raw_cred = (
|
||||
base64.urlsafe_b64encode(cred).decode("ascii").rstrip("=")
|
||||
if isinstance(cred, bytes)
|
||||
else str(cred or "")
|
||||
else (cred or "")
|
||||
)
|
||||
# 本次登录/指纹响应明确返回的字段,必须覆盖旧 Session 值。
|
||||
set_value("udb_cred", raw_cred, overwrite=True)
|
||||
if uid:
|
||||
set_value("udb_uid", int(uid), overwrite=True)
|
||||
set_value("yyuid", int(uid), overwrite=True)
|
||||
set_value("udb_uid", uid, overwrite=True)
|
||||
set_value("yyuid", uid, overwrite=True)
|
||||
set_value("udb_biztoken", biztoken, overwrite=True)
|
||||
set_value("udb_version", "1.0", overwrite=True)
|
||||
set_value("udb_origin", "0", overwrite=True)
|
||||
|
||||
@@ -136,11 +136,18 @@ def launch(
|
||||
proc = subprocess.Popen(
|
||||
args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
# 上面用的是 stderr=PIPE,所以 proc.stderr 一定不是 None(静态类型是
|
||||
# IO[str] | None)。收一次窄,顺便在真拿不到 stderr 时给出明确报错。
|
||||
stderr = proc.stderr
|
||||
if stderr is None:
|
||||
proc.kill()
|
||||
raise RuntimeError("Chrome stderr 未接通,无法读取调试端口")
|
||||
ws_url = None
|
||||
deadline = time.time() + 20
|
||||
last_probe_error: Exception | None = None
|
||||
while time.time() < deadline:
|
||||
# 1) 从 DevTools 输出的 ws url(browser 级)
|
||||
line = proc.stderr.readline()
|
||||
line = stderr.readline()
|
||||
if "DevTools listening on" in line:
|
||||
browser_ws = line.strip().split(" ")[-1]
|
||||
else:
|
||||
@@ -151,23 +158,33 @@ def launch(
|
||||
f"http://127.0.0.1:{port}/json/list", timeout=2
|
||||
) as resp:
|
||||
targets = json.load(resp)
|
||||
pages = {
|
||||
# 必须是 list 而不是 set:下面要按下标取「第一个」page target,
|
||||
# set 不可下标。这里曾写成集合推导式,pages[0] 抛出的 TypeError
|
||||
# 被下面的宽 except 吞掉,于是每次都静默退回 browser 级 endpoint,
|
||||
# 页面级探测长期失效却无人察觉。回归用例:
|
||||
# tests/test_huya_browser_harvest.py::test_launch_prefers_page_target_over_browser_endpoint
|
||||
pages = [
|
||||
t
|
||||
for t in targets
|
||||
if t.get("type") == "page" and t.get("webSocketDebuggerUrl")
|
||||
}
|
||||
]
|
||||
if pages:
|
||||
ws_url = pages[0]["webSocketDebuggerUrl"]
|
||||
break
|
||||
except Exception: # noqa: BLE001, S110 - 端口未就绪时轮询重试
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - 端口未就绪时轮询重试
|
||||
# 不静默:记下最后一次探测异常,端口最终没起来时一并报出来,
|
||||
# 避免「代码 bug」被读成「端口没起来」。
|
||||
last_probe_error = exc
|
||||
# 3) 兜底:browser 级 endpoint
|
||||
if browser_ws:
|
||||
ws_url = browser_ws
|
||||
break
|
||||
if not ws_url:
|
||||
proc.kill()
|
||||
raise RuntimeError("Chrome 调试端口未就绪")
|
||||
detail = (
|
||||
f";最后一次 CDP 探测异常: {last_probe_error!r}" if last_probe_error else ""
|
||||
)
|
||||
raise RuntimeError(f"Chrome 调试端口未就绪{detail}")
|
||||
return proc, ws_url
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,13 @@ class HuyaEliteWssSession:
|
||||
self.logger = logger or (lambda _message: None)
|
||||
self.http = HuyaHttpClient(logger=self.logger)
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.client: HuyaWssClient | None = None
|
||||
# 会话内**始终**持有 client:下面(本函数的 try 之前)无条件创建,
|
||||
# close() 只置 self._closed,从不把它改回 None。原先标成 `| None = None`
|
||||
# 是陈旧声明,代价是 20 处 `self.client.xxx(...)` 全被报「可能为 None」。
|
||||
# 顺带说明:_run() 里 `self.client is None` 这条兜底因此是死代码 ——
|
||||
# 而且它本来就被求值顺序绕过(`self.client.xxx()` 作为实参先于 _run 执行),
|
||||
# 保留它只为防御未来有人真的把 client 置空。
|
||||
self.client: HuyaWssClient
|
||||
self._closed = False
|
||||
|
||||
if kind == "activity":
|
||||
|
||||
@@ -65,7 +65,7 @@ def _cookie_mapping(cookie: Mapping[str, str] | str | None) -> dict[str, str]:
|
||||
parsed = SimpleCookie()
|
||||
parsed.load(cookie)
|
||||
return {key: morsel.value for key, morsel in parsed.items()}
|
||||
return {str(key): str(value) for key, value in cookie.items()}
|
||||
return {key: value for key, value in cookie.items()}
|
||||
|
||||
|
||||
class HuyaVerificationSolver:
|
||||
@@ -214,7 +214,7 @@ class HuyaVerificationSolver:
|
||||
background = cv2.cvtColor(cv2.Canny(background, 100, 200), cv2.COLOR_GRAY2RGB)
|
||||
result = cv2.matchTemplate(background, target, cv2.TM_CCOEFF_NORMED)
|
||||
_, _, _, max_loc = cv2.minMaxLoc(result)
|
||||
return int(max_loc[0])
|
||||
return max_loc[0]
|
||||
|
||||
@staticmethod
|
||||
def _opencv_rotate_angle(
|
||||
@@ -331,7 +331,10 @@ class HuyaVerificationSolver:
|
||||
if Path(font_path).is_file():
|
||||
try:
|
||||
return ImageFont.truetype(font_path, size)
|
||||
except Exception:
|
||||
except OSError:
|
||||
# 字体文件在、但 FreeType 打不开或格式不对(实测「cannot open
|
||||
# resource」和「unknown file format」都是 OSError)—— 换下一个候选。
|
||||
# 收窄异常类型:宽 except 会把参数写错之类的真 bug 一起吞掉。
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
@@ -627,7 +630,7 @@ class HuyaVerificationSolver:
|
||||
restored_bg, tip_bytes, int(data.get("location_y") or 0)
|
||||
)
|
||||
slider_box = self.ocr.ocr_slider_box(composite)
|
||||
yolo_x = int(slider_box[0] - 13)
|
||||
yolo_x = slider_box[0] - 13
|
||||
opencv_x = self._opencv_slider_x(tip_bytes, restored_bg)
|
||||
if abs(opencv_x - yolo_x) < 10:
|
||||
logger.debug("虎牙滑块识别距离: yolo={}, opencv={}", yolo_x, opencv_x)
|
||||
@@ -666,7 +669,7 @@ class HuyaVerificationSolver:
|
||||
if tip_bytes:
|
||||
merged = self._vertical_concat_align_left(bg_bytes, tip_bytes)
|
||||
points = self.ocr.ocr_click_points(merged)
|
||||
final_points = [[int(x), int(y) - 44] for x, y in points]
|
||||
final_points = [[x, y - 44] for x, y in points]
|
||||
else:
|
||||
question = str(data.get("question") or "")
|
||||
target_chars = self._extract_target_chars_from_question(question)
|
||||
@@ -675,7 +678,7 @@ class HuyaVerificationSolver:
|
||||
synth_tip = self._synthesize_text_tip(target_chars)
|
||||
merged = self._vertical_concat_align_left(bg_bytes, synth_tip)
|
||||
points = self.ocr.ocr_click_points(merged)
|
||||
final_points = [[int(x), int(y) - 44] for x, y in points]
|
||||
final_points = [[x, y - 44] for x, y in points]
|
||||
tip_bytes = synth_tip
|
||||
else:
|
||||
logger.info(f"虎牙点选题目未能提取明确目标字: {question}")
|
||||
@@ -699,11 +702,11 @@ class HuyaVerificationSolver:
|
||||
drag_distance = max(5, int((angle / 360.0) * 260))
|
||||
track_points = generate_slide_track(drag_distance)
|
||||
act_data = {
|
||||
"point": int(round(angle)),
|
||||
"point": round(angle),
|
||||
"travel": format_track(track_points),
|
||||
"code": self.code,
|
||||
"sceneType": self.scene_type,
|
||||
"endTime": int(track_points[-1][-1]) - int(track_points[0][-1]),
|
||||
"endTime": track_points[-1][-1] - track_points[0][-1],
|
||||
}
|
||||
act = self.js_ctx.call("get_act", act_data, self.huyapk)
|
||||
self.behavior_events.append(
|
||||
@@ -726,11 +729,11 @@ class HuyaVerificationSolver:
|
||||
def _send_slider_verify(self, url: str, point: int) -> dict:
|
||||
track_points = generate_slide_track(point)
|
||||
act_data = {
|
||||
"point": int(point),
|
||||
"point": point,
|
||||
"travel": format_track(track_points),
|
||||
"code": self.code,
|
||||
"sceneType": self.scene_type,
|
||||
"endTime": int(track_points[-1][-1]) - int(track_points[0][-1]),
|
||||
"endTime": track_points[-1][-1] - track_points[0][-1],
|
||||
}
|
||||
act = self.js_ctx.call("get_act", act_data, self.huyapk)
|
||||
self.behavior_events.append(
|
||||
@@ -759,7 +762,7 @@ class HuyaVerificationSolver:
|
||||
current_time = start_time
|
||||
for point in points:
|
||||
current_time += random.randint(1000, 2000)
|
||||
click_data.append([int(point[0]), int(point[1]), current_time])
|
||||
click_data.append([point[0], point[1], current_time])
|
||||
|
||||
interval_time_list = [item[-1] for item in click_data]
|
||||
act_data = {
|
||||
@@ -958,11 +961,18 @@ class HuyaVerificationSolver:
|
||||
or "/auth/xz/" in vurl
|
||||
or "rotate" in self.strategy_url
|
||||
):
|
||||
last_result = self._send_rotate_verify(vurl, float(point))
|
||||
if isinstance(point, (int, float)):
|
||||
last_result = self._send_rotate_verify(vurl, float(point))
|
||||
else:
|
||||
logger.warning(f"旋转验证识别返回非数值结果: {point}")
|
||||
continue
|
||||
elif isinstance(point, list):
|
||||
last_result = self._send_click_verify(vurl, point)
|
||||
elif point is not None:
|
||||
last_result = self._send_slider_verify(vurl, point)
|
||||
else:
|
||||
last_result = self._send_slider_verify(vurl, int(point))
|
||||
logger.warning("验证码识别未返回有效结果")
|
||||
continue
|
||||
|
||||
if isinstance(last_result, dict) and (
|
||||
last_result.get("authId") or last_result.get("nextAuthUrl")
|
||||
|
||||
@@ -56,7 +56,7 @@ class WupRequest:
|
||||
os.write_struct(0, struct_data)
|
||||
elif isinstance(struct_data, dict):
|
||||
os.write_struct_begin(0)
|
||||
for tag_id, (field_name, field_value) in enumerate(struct_data.items()):
|
||||
for tag_id, (_, field_value) in enumerate(struct_data.items()):
|
||||
self._write_field(os, tag_id, field_value)
|
||||
os.write_struct_end()
|
||||
elif isinstance(struct_data, (list, tuple)):
|
||||
|
||||
@@ -96,6 +96,12 @@ if [ "${1:-}" = "format-check" ]; then
|
||||
exec uv run --group dev python -m ruff format --check "$@"
|
||||
fi
|
||||
|
||||
# 与 format-check 分开:ruff format 不查未使用导入/未排序导入/宽 except 这类问题。
|
||||
if [ "${1:-}" = "lint-check" ]; then
|
||||
shift
|
||||
exec uv run --group dev python -m ruff check "$@"
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "type-check" ]; then
|
||||
shift
|
||||
exec uv run --group dev python -m pyright "$@"
|
||||
|
||||
@@ -41,6 +41,10 @@ packages = ["core", "utils", "web"]
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "B", "I", "BLE"]
|
||||
ignore = ["E402", "E731", "E741", "B904", "B905"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# FastAPI evaluates dependency metadata in route parameter defaults by design.
|
||||
"web/backend/**/*.py" = ["B008"]
|
||||
|
||||
+22
-2
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"include": ["core", "utils", "web", "tests"],
|
||||
// tests/ 有意**不**做类型检查(见下方说明),所以不放进 include。
|
||||
"include": ["core", "utils", "web"],
|
||||
"exclude": [
|
||||
"**/__pycache__",
|
||||
"artifacts",
|
||||
@@ -10,5 +11,24 @@
|
||||
],
|
||||
"typeCheckingMode": "basic",
|
||||
"reportMissingImports": "none",
|
||||
"reportMissingModuleSource": "none"
|
||||
"reportMissingModuleSource": "none",
|
||||
// 必须显式钉死解释器:否则 pyright 会去 PATH 上摸 /usr/bin/python3(macOS 自带 3.9),
|
||||
// 读到用户级 ~/Library/Python/3.9 里的 SQLAlchemy 1.4 + 把语义版本当日 3.9,
|
||||
// 凭空多出约 2600 条假错(详见 pyright --verbose 的 "Assuming Python version")。
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.12",
|
||||
// ── 为什么 tests/ 不做类型检查 ───────────────────────────────────────────
|
||||
// 打开 tests 会多出约 119 条,且**全部是同一类噪音**,不是测试写错了:
|
||||
// 1) FastAPI 路由函数没有(也不该有)返回值注解,pyright 只能从 return
|
||||
// 字面量反推成一个很窄的 dict 类型,于是 `result["items"][0]` 报
|
||||
// 「No overloads for __getitem__」;给路由补注解=给 FastAPI 加了
|
||||
// response_model,会真的裁剪响应体,绝不能为了消警告去加。
|
||||
// 2) 测试故意传 `db=None` 走 mock 分支,而形参注解是 Session。
|
||||
// 3) `resp.json()` 静态类型是 object,下标取值必然报错。
|
||||
// 这三类要么得改生产签名、要么得在测试里撒 cast/ignore,属于 AGENTS.md 说的
|
||||
//「没人要的重构」。测试的正确性本来就由 pytest **真跑一遍**来保证,而
|
||||
// 未定义名/未用导入已由 `ruff check`(含 F 系列)覆盖。
|
||||
// 要临时把 tests 也检查一遍:把 "tests" 加回上面的 include 再跑
|
||||
// `./dev.sh type-check`,看完记得改回来。
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""虎牙「短信接码一键登录」端点:调用契约与成功/失败两条分支。
|
||||
|
||||
背景(这是本文件的**存在理由**,别删):该端点曾引用一个从来不存在的
|
||||
``core.huya.auto_register.auto_register_huya_phone``(git 全历史都没有 ``def``),
|
||||
所以每次调用都在 import 阶段抛 ``ImportError`` → HTTP 500,且因为导入写在函数体
|
||||
内部,静态检查之外没有任何东西拦得住它。用例锁死「端点必须调用真实存在的
|
||||
``register_huya_with_sms_line``」这个契约,名字再被改就不会静默回归。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from core.huya.auto_register import HuyaAutoRegisterResult
|
||||
from tests.support.db import get_test_engine, make_test_session
|
||||
from web.backend.models import HuyaAccount, User
|
||||
from web.backend.routers.huya.login import auto_sms_login_account
|
||||
|
||||
# 能被 parse_huya_cookie_line 认出来的最小虎牙 CK(含 yyuid= / udb_uid=)。
|
||||
LOGIN_COOKIE = "yyuid=100; udb_uid=100"
|
||||
|
||||
|
||||
def _ok_result(item, **kwargs):
|
||||
return HuyaAutoRegisterResult(
|
||||
phone=item.phone,
|
||||
provider=item.provider,
|
||||
success=True,
|
||||
status="success",
|
||||
message="接码成功",
|
||||
cookie=LOGIN_COOKIE,
|
||||
)
|
||||
|
||||
|
||||
class TestAutoSmsLoginAccount:
|
||||
def setup_method(self):
|
||||
self.engine = get_test_engine()
|
||||
self.session = make_test_session(self.engine)
|
||||
self.user = User(username="operator", password_hash="hash", role="super_admin")
|
||||
self.account = HuyaAccount(
|
||||
uid="",
|
||||
yyuid="",
|
||||
cookie="",
|
||||
game_phone="13800000000",
|
||||
sms_url="https://sms.example.com/get?p=1",
|
||||
)
|
||||
self.session.add_all([self.user, self.account])
|
||||
self.session.commit()
|
||||
|
||||
def teardown_method(self):
|
||||
self.session.close()
|
||||
|
||||
def _call(self):
|
||||
return auto_sms_login_account(
|
||||
self.account.id, db=self.session, current=self.user
|
||||
)
|
||||
|
||||
def test_calls_existing_register_helper_and_saves_cookie(self, monkeypatch):
|
||||
"""核心契约:必须调用真实存在的注册函数,并把返回的 CK 回填落库。"""
|
||||
calls: dict = {}
|
||||
|
||||
def fake_register(item, **kwargs):
|
||||
calls["item"] = item
|
||||
calls["kwargs"] = kwargs
|
||||
return _ok_result(item, **kwargs)
|
||||
|
||||
# 用字符串形式 patch 目标模块属性:端点内部是 `from ... import ...`,
|
||||
# 每次调用都会重新取模块属性,所以这层 patch 必须生效。
|
||||
monkeypatch.setattr(
|
||||
"core.huya.auto_register.register_huya_with_sms_line", fake_register
|
||||
)
|
||||
|
||||
out = self._call()
|
||||
|
||||
assert out["success"] is True
|
||||
# 断言序列化结果,不看属性(AGENTS.md 坑 #2)
|
||||
assert out["account"].model_dump()["uid"] == "100"
|
||||
assert calls["item"].phone == "13800000000"
|
||||
assert calls["item"].url == "https://sms.example.com/get?p=1"
|
||||
# 超时/轮询间隔是端点的既有行为,不能被顺手改掉
|
||||
assert calls["kwargs"]["wait_seconds"] == 90
|
||||
assert calls["kwargs"]["poll_interval"] == 2
|
||||
|
||||
self.session.refresh(self.account)
|
||||
assert self.account.status == "active"
|
||||
assert self.account.login_channel == "sms"
|
||||
assert "udb_uid=100" in self.account.cookie
|
||||
|
||||
def test_failure_marks_account_login_failed(self, monkeypatch):
|
||||
"""注册失败要落库成 login_failed 并返回 502,而不是假装成功。"""
|
||||
|
||||
def fake_register(item, **kwargs):
|
||||
return HuyaAutoRegisterResult(
|
||||
phone=item.phone,
|
||||
provider=item.provider,
|
||||
success=False,
|
||||
status="failed",
|
||||
message="接码平台超时",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.huya.auto_register.register_huya_with_sms_line", fake_register
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
self._call()
|
||||
|
||||
assert excinfo.value.status_code == 502
|
||||
assert "接码平台超时" in str(excinfo.value.detail)
|
||||
|
||||
self.session.refresh(self.account)
|
||||
assert self.account.status == "login_failed"
|
||||
|
||||
def test_missing_phone_or_sms_url_is_rejected_before_import(self):
|
||||
"""缺手机号/接码链接要在调用注册前 400,不能先崩在 import 上。"""
|
||||
self.account.game_phone = ""
|
||||
self.session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
self._call()
|
||||
|
||||
assert excinfo.value.status_code == 400
|
||||
@@ -1,3 +1,5 @@
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from core.huya import browser_harvest
|
||||
@@ -51,3 +53,50 @@ def test_launch_can_show_window_when_requested(monkeypatch):
|
||||
browser_harvest.launch(Path("/tmp/test-huya-profile"), 9556, headless=False)
|
||||
|
||||
assert "--headless=new" not in calls[0]
|
||||
|
||||
|
||||
def test_launch_prefers_page_target_over_browser_endpoint(monkeypatch):
|
||||
"""页面级 ws 必须优先于 browser 级(Network 域只能挂在 page target 上)。
|
||||
|
||||
这里原本写的是 ``pages[0]``,而 ``pages`` 是集合推导出来的 **set** ——
|
||||
``set`` 不可下标,抛出的 TypeError 又被外层 ``except Exception: pass``
|
||||
吞掉,于是每次都退回 browser 级 endpoint:页面级探测**从来没有成功过一次**,
|
||||
而且没有任何日志能看出来。这就是本用例存在的意义。
|
||||
"""
|
||||
page_ws = "ws://127.0.0.1/devtools/page/PAGE"
|
||||
browser_ws = "ws://127.0.0.1/devtools/browser/test"
|
||||
payload = json.dumps([{"type": "page", "webSocketDebuggerUrl": page_ws}])
|
||||
|
||||
monkeypatch.setattr(
|
||||
browser_harvest.subprocess, "Popen", lambda args, **kwargs: _FakeProcess()
|
||||
)
|
||||
# _FakeProcess.stderr 会给出 browser 级 endpoint,用来证明「选中了页面级」
|
||||
monkeypatch.setattr(
|
||||
browser_harvest.urllib.request,
|
||||
"urlopen",
|
||||
lambda *args, **kwargs: io.StringIO(payload),
|
||||
)
|
||||
|
||||
_proc, ws_url = browser_harvest.launch(Path("/tmp/test-huya-profile"), 9557)
|
||||
|
||||
assert ws_url == page_ws
|
||||
assert ws_url != browser_ws
|
||||
|
||||
|
||||
def test_launch_falls_back_to_browser_endpoint_without_page_target(monkeypatch):
|
||||
"""没有 page target 时才允许退回 browser 级 endpoint。"""
|
||||
browser_ws = "ws://127.0.0.1/devtools/browser/test"
|
||||
payload = json.dumps([{"type": "service_worker", "webSocketDebuggerUrl": "ws://x"}])
|
||||
|
||||
monkeypatch.setattr(
|
||||
browser_harvest.subprocess, "Popen", lambda args, **kwargs: _FakeProcess()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
browser_harvest.urllib.request,
|
||||
"urlopen",
|
||||
lambda *args, **kwargs: io.StringIO(payload),
|
||||
)
|
||||
|
||||
_proc, ws_url = browser_harvest.launch(Path("/tmp/test-huya-profile"), 9558)
|
||||
|
||||
assert ws_url == browser_ws
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
Reference in New Issue
Block a user