diff --git a/tests/test_account_sensitive_fields.py b/tests/test_account_sensitive_fields.py index 86203ff..957bc16 100644 --- a/tests/test_account_sensitive_fields.py +++ b/tests/test_account_sensitive_fields.py @@ -35,7 +35,7 @@ class TestAccountSensitiveFields: def test_sensitive_fields_are_hidden_by_default_even_for_admin(self): result = list_accounts( assigned_only=False, - tag=None, + tag="", has_cookie=False, search="", page=1, @@ -45,6 +45,7 @@ class TestAccountSensitiveFields: current=self.admin, ) + assert isinstance(result, dict) item = result["items"][0] assert item.password is None assert item.email is None @@ -53,7 +54,7 @@ class TestAccountSensitiveFields: def test_admin_can_explicitly_request_sensitive_fields(self): result = list_accounts( assigned_only=False, - tag=None, + tag="", has_cookie=False, search="", page=1, @@ -63,6 +64,7 @@ class TestAccountSensitiveFields: current=self.admin, ) + assert isinstance(result, dict) item = result["items"][0] assert item.password == "account-password" assert item.email == "account@example.com" diff --git a/tests/test_cookie_operations.py b/tests/test_cookie_operations.py index 68ab5e2..78c1579 100644 --- a/tests/test_cookie_operations.py +++ b/tests/test_cookie_operations.py @@ -1,12 +1,13 @@ import pytest from types import SimpleNamespace from unittest.mock import patch +from typing import cast from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from web.backend.database import Base -from web.backend.models import Account, LoginTask, User +from web.backend.models import Account, LoginTask, ProxyConfig, User from web.backend.services.login_service import ( LoginBatchRunner, cleanup_orphan_relogin_tasks, @@ -137,6 +138,7 @@ class TestCookieOperation: current=admin, ) + assert isinstance(result, dict) assert result["total"] == 1 assert [item["id"] for item in result["items"]] == [self.owned_task.id] @@ -200,7 +202,7 @@ class TestCookieOperation: page=1, page_size=20, db=self.session, - current=no_permission_user, + current=cast(User, no_permission_user), ) def test_relogin_failed_record_stays_visible_and_can_be_checked(self): @@ -235,7 +237,7 @@ class TestCookieOperation: assert self.other_task.status == "success" def test_runner_copies_proxy_config_before_background_execution(self): - proxy = SimpleNamespace( + proxy = ProxyConfig( enabled=True, http="http://127.0.0.1:8080", https="", diff --git a/tests/test_douyu_gold_recharge_channel.py b/tests/test_douyu_gold_recharge_channel.py index 66d03d4..3337672 100644 --- a/tests/test_douyu_gold_recharge_channel.py +++ b/tests/test_douyu_gold_recharge_channel.py @@ -94,16 +94,18 @@ class TestDouyuGoldRechargeChannel: assert str(supplier.create_order.call_args.kwargs["pay_amount"]) == "10" self.session.refresh(self.task) assert self.task.status == "success" - assert self.task.result["recharge_channel"] == "supplier_api" - assert self.task.result["supplier_order_status"] == 2 - assert self.task.result["buy_num"] == 10 - assert self.task.result["out_order_id"] == expected_order_id - assert self.task.result["recharge_account"] == "罗炅729" - assert self.task.result["douyu_uid"] == "10001" - assert self.task.result["order_id"] == "supplier-001" - assert self.task.result["pay_amount"] == "10" - assert "pay_url" not in self.task.result - assert "sign" not in self.task.result["supplier_order"] + task_result = self.task.result + assert isinstance(task_result, dict) + assert task_result["recharge_channel"] == "supplier_api" + assert task_result["supplier_order_status"] == 2 + assert task_result["buy_num"] == 10 + assert task_result["out_order_id"] == expected_order_id + assert task_result["recharge_account"] == "罗炅729" + assert task_result["douyu_uid"] == "10001" + assert task_result["order_id"] == "supplier-001" + assert task_result["pay_amount"] == "10" + assert "pay_url" not in task_result + assert "sign" not in task_result["supplier_order"] @patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient") def test_supplier_channel_rejects_account_without_nickname(self, client_class): @@ -226,13 +228,14 @@ class TestDouyuGoldRechargeChannel: try: task = verify_session.get(DouyuTask, task_id) checked_login = verify_session.get(LoginTask, login_task_id) + assert task is not None + assert checked_login is not None assert task.status == "failed" assert "Cookie 已失效,请重新登录" in task.message assert checked_login.ck_check_status == "invalid" - assert ( - checked_login.ck_check_result["message"] - == "鱼丸接口: 未登录;等级接口: 未登录" - ) + check_result = checked_login.ck_check_result + assert isinstance(check_result, dict) + assert check_result["message"] == "鱼丸接口: 未登录;等级接口: 未登录" finally: verify_session.close() @@ -273,8 +276,10 @@ class TestDouyuGoldRechargeChannel: assert self.task.message == "供应商直充成功(异步通知)" self.session.refresh(self.account) assert self.account.bind_status == "gold_recharged" - assert self.task.result["order_id"] == "supplier-001" - assert self.task.result["supplier_order_status"] == 2 + task_result = self.task.result + assert isinstance(task_result, dict) + assert task_result["order_id"] == "supplier-001" + assert task_result["supplier_order_status"] == 2 audit = ( self.session.query(AuditLog) .filter(AuditLog.action == "recharge:douyu:callback") diff --git a/tests/test_douyu_mobile_bind_skip.py b/tests/test_douyu_mobile_bind_skip.py index 5fc64b3..cb62697 100644 --- a/tests/test_douyu_mobile_bind_skip.py +++ b/tests/test_douyu_mobile_bind_skip.py @@ -1,5 +1,6 @@ from types import SimpleNamespace from unittest.mock import Mock +from typing import Any, cast from core.douyu.login import DouyuLogin from core.douyu.login_api_wgapi import WgapiLoginAPI @@ -9,13 +10,14 @@ class TestMobileBindSkip: def setup_method(self): self.login = DouyuLogin.__new__(DouyuLogin) self.login.api = WgapiLoginAPI() - self.login.account = SimpleNamespace( - username="test-user", password="test-password" + self.login.account = cast( + Any, SimpleNamespace(username="test-user", password="test-password") ) - self.login._request_json = Mock() + self.request_json = Mock() + self.login.__dict__["_request_json"] = self.request_json def test_second_login_skips_mobile_bind_with_unique_key(self): - self.login._request_json.side_effect = [ + self.request_json.side_effect = [ { "error": 130014, "msg": "需要进行手机号绑定", @@ -41,8 +43,8 @@ class TestMobileBindSkip: assert ( login_url == "https://www.douyu.com/api/passport/login?code=callback-code" ) - assert self.login._request_json.call_count == 2 - skip_call = self.login._request_json.call_args_list[1] + assert self.request_json.call_count == 2 + skip_call = self.request_json.call_args_list[1] assert skip_call.args[:3] == ( "post", "https://passport.douyu.com/wgapi/member/passport/login", @@ -55,7 +57,7 @@ class TestMobileBindSkip: } def test_second_login_preserves_remote_email_flow_without_unique_key(self): - self.login._request_json.return_value = { + self.request_json.return_value = { "error": 130014, "data": {"remoteLogin": {"code": "remote-code"}}, } @@ -69,4 +71,4 @@ class TestMobileBindSkip: ) assert (next_step, remote_code) == ("remote_email", "remote-code") - assert self.login._request_json.call_count == 1 + assert self.request_json.call_count == 1 diff --git a/tests/test_douyu_workbench_scopes.py b/tests/test_douyu_workbench_scopes.py index 20a0df9..24e992a 100644 --- a/tests/test_douyu_workbench_scopes.py +++ b/tests/test_douyu_workbench_scopes.py @@ -1,5 +1,4 @@ import pytest -from types import SimpleNamespace from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker @@ -54,12 +53,6 @@ class TestDouyuWorkbenchScope: ) ) self.session.commit() - self.current = SimpleNamespace( - id=self.user.id, - username=self.user.username, - role=self.user.role, - custom_permissions=None, - ) def teardown_method(self): self.session.close() @@ -72,12 +65,10 @@ class TestDouyuWorkbenchScope: handbook_scope="elite", account_ids=[self.account.id] ), db=self.session, - current=self.current, - ) - elite = list_workbench_accounts("elite", db=self.session, current=self.current) - esports = list_workbench_accounts( - "esports", db=self.session, current=self.current + current=self.user, ) + elite = list_workbench_accounts("elite", db=self.session, current=self.user) + esports = list_workbench_accounts("esports", db=self.session, current=self.user) assert elite["account_ids"] == [self.account.id] assert esports["account_ids"] == [] assert elite["configured"] @@ -87,10 +78,10 @@ class TestDouyuWorkbenchScope: update_workbench_accounts( DouyuWorkbenchAccountsUpdate(handbook_scope="elite", account_ids=[]), db=self.session, - current=self.current, + current=self.user, ) empty_elite = list_workbench_accounts( - "elite", db=self.session, current=self.current + "elite", db=self.session, current=self.user ) assert empty_elite["account_ids"] == [] assert empty_elite["configured"] @@ -118,7 +109,7 @@ class TestDouyuWorkbenchScope: page=None, page_size=100, db=self.session, - current=self.current, + current=self.user, ) esports = list_tasks( batch_id=None, @@ -127,8 +118,10 @@ class TestDouyuWorkbenchScope: page=None, page_size=100, db=self.session, - current=self.current, + current=self.user, ) + assert isinstance(elite, list) + assert isinstance(esports, list) assert {item.batch_id for item in elite} == {elite_batch} assert {item.batch_id for item in esports} == {esports_batch} assert { @@ -182,6 +175,6 @@ class TestDouyuWorkbenchScope: ) self.session.commit() - delete_account(self.account.id, db=self.session, current=self.current) + delete_account(self.account.id, db=self.session, current=self.user) assert self.session.query(DouyuWorkbenchAccount).count() == 0 diff --git a/tests/test_user_deletion.py b/tests/test_user_deletion.py index a086d0a..c7a7bd5 100644 --- a/tests/test_user_deletion.py +++ b/tests/test_user_deletion.py @@ -1,6 +1,4 @@ import pytest -from types import SimpleNamespace - from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker @@ -53,11 +51,12 @@ class TestUserDeletion: result = delete_user( self.user.id, db=self.session, - current=SimpleNamespace(id=self.admin.id, username=self.admin.username), + current=self.admin, ) deleted = self.session.get(User, self.user.id) account = self.session.query(Account).one() + assert deleted is not None assert result["success"] assert result["released_accounts"] == 1 assert not deleted.is_active @@ -74,7 +73,7 @@ class TestUserDeletion: delete_user( self.user.id, db=self.session, - current=SimpleNamespace(id=self.user.id, username=self.user.username), + current=self.user, ) def test_rename_preserves_user_id_and_related_data(self): @@ -82,12 +81,13 @@ class TestUserDeletion: self.user.id, UserRename(username="renamed-support"), db=self.session, - current=SimpleNamespace(id=self.admin.id, username=self.admin.username), + current=self.admin, ) renamed = self.session.get(User, self.user.id) account = self.session.query(Account).one() task = self.session.query(LoginTask).one() + assert renamed is not None assert result.username == "renamed-support" assert renamed.id == self.user.id assert account.assigned_to == self.user.id diff --git a/web/backend/schemas.py b/web/backend/schemas.py index e582f62..388b488 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -41,6 +41,11 @@ def _ensure_tz(dt: Optional[datetime]) -> Optional[datetime]: return dt +def _isoformat(dt: Optional[datetime]) -> str | None: + normalized = _ensure_tz(dt) + return normalized.isoformat() if normalized is not None else None + + # ---- 认证 ---- class LoginRequest(BaseModel): username: str @@ -75,7 +80,7 @@ class UserInfo(BaseModel): "role": self.role, "is_active": self.is_active, "remark": self.remark, - "created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None, + "created_at": _isoformat(self.created_at), "permissions": self.permissions, "custom_permissions": self.custom_permissions, } @@ -108,6 +113,7 @@ class AccountImport(BaseModel): tag 为本次导入的统一标签兜底:某行未单独写标签时使用该值, 行内第5列标签优先。 """ + text: str tag: str = "" @@ -123,6 +129,7 @@ class BatchAssign(BaseModel): class AccountBulkSelection(BaseModel): """批量操作选择范围:显式 ID 或当前筛选条件下全部账号。""" + account_ids: list[int] = Field(default_factory=list) all_matching: bool = False search: str = "" @@ -167,7 +174,7 @@ class AccountOut(BaseModel): "assigned_to": self.assigned_to, "assigned_username": self.assigned_username, "remark": self.remark, - "created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None, + "created_at": _isoformat(self.created_at), } @@ -178,7 +185,9 @@ class LoginBatchRequest(BaseModel): max_total_time: float = 300 # 单账号登录总时长上限(秒),0=不限制 concurrency: int = 3 # 并发数,1-10 api_strategy: str = "wgapi" # 接口策略: wgapi(新版)或 iframe(旧版备选) - mode: str = Field("login", pattern="^(login|check)$") # login=登录取CK,check=账号状态检测 + mode: str = Field( + "login", pattern="^(login|check)$" + ) # login=登录取CK,check=账号状态检测 class CookieReloginRequest(BaseModel): @@ -210,8 +219,8 @@ class LoginTaskOut(BaseModel): "cookie": self.cookie, "message": self.message, "created_by": self.created_by, - "created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None, - "finished_at": _ensure_tz(self.finished_at).isoformat() if self.finished_at else None, + "created_at": _isoformat(self.created_at), + "finished_at": _isoformat(self.finished_at), } @@ -256,12 +265,14 @@ class AccountCheckBatchOut(BaseModel): # ---- 虎牙 ---- class HuyaCookieImport(BaseModel): """批量导入虎牙 Cookie。支持纯 CK 或 账号----密码----CK。""" + text: str tag: str = "" class HuyaPasswordLoginRequest(BaseModel): """虎牙账号 Web 密码登录并保存 Cookie(旧版)。""" + username: str = Field(..., min_length=1, max_length=128) password: str = Field(..., min_length=1, max_length=128) tag: str = "" @@ -270,6 +281,7 @@ class HuyaPasswordLoginRequest(BaseModel): class HuyaAppPasswordLoginRequest(BaseModel): """虎牙账号 App 协议密码登录并保存 Cookie(推荐)。""" + username: str = Field(..., min_length=1, max_length=128) password: str = Field(..., min_length=1, max_length=128) tag: str = "" @@ -278,12 +290,14 @@ class HuyaAppPasswordLoginRequest(BaseModel): class HuyaSmsCodeRequest(BaseModel): """发送虎牙短信验证码。""" + phone: str = Field(..., min_length=5, max_length=32) cookie: str = "" class HuyaSmsLoginRequest(BaseModel): """提交虎牙短信验证码并保存 Cookie。""" + authcode: str = Field(..., min_length=4, max_length=8) state: str = Field(..., min_length=1) phone: str = "" @@ -292,6 +306,7 @@ class HuyaSmsLoginRequest(BaseModel): class HuyaAutoRegisterRequest(BaseModel): """虎牙自动注册批次。每行格式:手机号----短信查询URL。""" + text: str = Field(..., min_length=1) tag: str = "" concurrency: int = Field(1, ge=1, le=5) @@ -310,6 +325,7 @@ class HuyaAutoRegisterRetryRequest(BaseModel): - retry_failed: 只重试 error - all_unfinished: 失败+未完成都跑 """ + mode: str = Field("continue", max_length=32) concurrency: Optional[int] = Field(None, ge=1, le=5) wait_seconds: Optional[float] = Field(None, ge=15, le=600) @@ -382,12 +398,14 @@ class HuyaRegisterSuccessLogOut(BaseModel): class HuyaPasswordAccountImport(BaseModel): """导入虎牙账号密码,稍后再选择登录。""" + text: str = Field(..., min_length=1) tag: str = "" class HuyaPasswordLoginSelectedRequest(BaseModel): """选择已导入的虎牙账号执行密码登录。""" + account_ids: list[int] = Field(default_factory=list) all_matching: bool = False search: str = "" @@ -399,6 +417,7 @@ class HuyaPasswordLoginSelectedRequest(BaseModel): class HuyaDeviceBindingsBatchDeleteRequest(BaseModel): """批量解绑设备:删除账号画像与 hydevice 指纹状态。""" + accounts: list[str] = Field(..., min_length=1) @@ -447,8 +466,8 @@ class HuyaAccountOut(BaseModel): "game_phone": self.game_phone, "assigned_to": self.assigned_to, "assigned_username": self.assigned_username, - "created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None, - "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + "created_at": _isoformat(self.created_at), + "updated_at": _isoformat(self.updated_at), } @@ -468,7 +487,7 @@ class HuyaConfigOut(BaseModel): "outer_act_id": self.outer_act_id, "bind_act_id": self.bind_act_id, "pay_channel": self.pay_channel, - "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + "updated_at": _isoformat(self.updated_at), } @@ -516,8 +535,8 @@ class HuyaTaskOut(BaseModel): "message": self.message, "result": self.result, "created_by": self.created_by, - "created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None, - "finished_at": _ensure_tz(self.finished_at).isoformat() if self.finished_at else None, + "created_at": _isoformat(self.created_at), + "finished_at": _isoformat(self.finished_at), } @@ -541,7 +560,7 @@ class HuyaGoodsOut(BaseModel): "price": self.price, "remain_text": self.remain_text, "raw": self.raw, - "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + "updated_at": _isoformat(self.updated_at), } @@ -577,7 +596,7 @@ class HuyaRechargeGoodsOut(BaseModel): "task_id": self.task_id, "task_name": self.task_name, "raw": self.raw, - "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + "updated_at": _isoformat(self.updated_at), } @@ -634,7 +653,7 @@ class DouyuConfigOut(BaseModel): "gold_api_account_template_name": self.gold_api_account_template_name, "gift_id": self.gift_id, "skin_id": self.skin_id, - "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + "updated_at": _isoformat(self.updated_at), } @@ -657,7 +676,9 @@ class DouyuConfigUpdate(BaseModel): xpd_act_id: Optional[str] = None xpd_rid: Optional[str] = None gold_pay_type: Optional[int] = Field(None, ge=1, le=9) - gold_recharge_channel: Optional[str] = Field(None, pattern="^(wechat_qr|supplier_api)$") + gold_recharge_channel: Optional[str] = Field( + None, pattern="^(wechat_qr|supplier_api)$" + ) gold_api_product_id: Optional[str] = Field(None, max_length=128) gold_api_account_template_name: Optional[str] = Field(None, max_length=64) gift_id: Optional[str] = None @@ -710,8 +731,8 @@ class DouyuTaskOut(BaseModel): "message": self.message, "result": self.result, "created_by": self.created_by, - "created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None, - "finished_at": _ensure_tz(self.finished_at).isoformat() if self.finished_at else None, + "created_at": _isoformat(self.created_at), + "finished_at": _isoformat(self.finished_at), } @@ -735,7 +756,7 @@ class DouyuGoodsOut(BaseModel): "score": self.score, "status": self.status, "raw": self.raw, - "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + "updated_at": _isoformat(self.updated_at), } @@ -794,7 +815,7 @@ class DouyuXpdGoodsOut(BaseModel): "category": self.category, "goods_left": self.goods_left, "raw": self.raw, - "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + "updated_at": _isoformat(self.updated_at), } @@ -828,6 +849,7 @@ class ProxyConfigUpdate(BaseModel): # ---- 代理平台元信息 ---- class PlatformFieldDef(BaseModel): """平台凭据字段定义。""" + key: str label: str placeholder: str = "" @@ -835,6 +857,7 @@ class PlatformFieldDef(BaseModel): class PlatformInfo(BaseModel): """平台元信息。""" + name: str label: str credential_fields: list[PlatformFieldDef]