test: 全面迁移 pytest 并加入格式门禁

This commit is contained in:
yml2213
2026-08-30 19:19:32 +08:00
parent 3ce1c7a51b
commit 13b5aedd1d
26 changed files with 1374 additions and 888 deletions
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
push_lines="$(cat)"
while read -r local_ref local_sha remote_ref remote_sha; do
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
echo "[pre-push] 分支删除推送,跳过测试"
exit 0
fi
done <<< "$push_lines"
if [ "${SKIP_TESTS:-0}" = "1" ]; then
echo "[pre-push] SKIP_TESTS=1,跳过测试"
exit 0
fi
python_files=()
while read -r local_ref local_sha remote_ref remote_sha; do
[ -z "$local_sha" ] && continue
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
continue
fi
if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
changed_paths="$(git diff-tree --no-commit-id --name-only -r --root "$local_sha" -- '*.py')"
else
changed_paths="$(git diff --name-only "$remote_sha..$local_sha" -- '*.py')"
fi
while IFS= read -r path; do
[ -n "$path" ] && python_files+=("$path")
done <<< "$changed_paths"
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 格式通过"
fi
echo "[pre-push] 运行 pytest..."
./dev.sh test -q
echo "[pre-push] pytest 通过"
+2 -1
View File
@@ -27,6 +27,7 @@ evidence/
# ── 逆向实验脚本(保留业务脚本 migrate_sqlite_to_mysql.py
scripts/*
!scripts/migrate_sqlite_to_mysql.py
!scripts/install-git-hooks.sh
# ── 逆向工具集(frida hook、unidbg hydev、虎牙协议复刻脚本等)
tools/
@@ -37,4 +38,4 @@ web/frontend/dist/
.reasonix/
.tmp_reverse/
apks/
work/
work/
+12 -2
View File
@@ -115,6 +115,10 @@ 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
# 容器内回归(镜像内为构建时代码,改动后需重新构建)
docker compose --profile test run --rm test
```
@@ -125,8 +129,14 @@ docker compose --profile test run --rm test
编译 + 空库整链 `upgrade head` 后与模型元数据表/列/索引对齐;MySQL
专属 DDL 由迁移脚本内的方言防护跳过,真实执行仍以部署流程
`docker-entrypoint.sh` / `./deploy.sh` / `./dev.sh` 的 MySQL 为准)。
已安装本地 pre-push 钩子:推送前自动跑全量测试,失败阻止推送
(紧急绕过:`git push --no-verify``SKIP_TESTS=1 git push`)。
启用本地 pre-push 门禁(Ruff 格式检查 + pytest):
```bash
./scripts/install-git-hooks.sh
```
推送前自动跑全量测试,失败阻止推送(紧急绕过:`git push --no-verify`
`SKIP_TESTS=1 git push`)。
## 技术栈
+3 -1
View File
@@ -9,4 +9,6 @@ web/backend 的模块在 import 阶段就会读取 DATABASE_URL 创建全局引
import os
os.environ.setdefault("DATABASE_URL", "sqlite://")
os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
os.environ.setdefault(
"APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
)
+10
View File
@@ -61,6 +61,16 @@ if [ "${1:-}" = "test" ]; then
exec uv run --group dev pytest "$@"
fi
if [ "${1:-}" = "format" ]; then
shift
exec uv run --group dev ruff format "$@"
fi
if [ "${1:-}" = "format-check" ]; then
shift
exec uv run --group dev ruff format --check "$@"
fi
BACKEND_PID=""
FRONTEND_PID=""
WORKER_PID=""
+1
View File
@@ -41,4 +41,5 @@ testpaths = ["tests"]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"ruff>=0.12.0",
]
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
git -C "$ROOT_DIR" config core.hooksPath .githooks
echo "已启用 .githooksRuff 格式检查 + pytest pre-push 门禁)"
+40 -30
View File
@@ -1,4 +1,4 @@
import unittest
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@@ -10,22 +10,24 @@ from web.backend.routers.accounts import list_accounts, set_account_tag
from web.backend.schemas import AccountTag
class AccountSensitiveFieldsTests(unittest.TestCase):
def setUp(self):
class TestAccountSensitiveFields:
def setup_method(self):
self.engine = create_engine("sqlite://")
Base.metadata.create_all(self.engine)
self.session = sessionmaker(bind=self.engine)()
self.admin = User(username="admin", password_hash="hash", role="super_admin")
self.session.add(self.admin)
self.session.add(Account(
username="account",
password="account-password",
email="account@example.com",
email_password="email-password",
))
self.session.add(
Account(
username="account",
password="account-password",
email="account@example.com",
email_password="email-password",
)
)
self.session.commit()
def tearDown(self):
def teardown_method(self):
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
@@ -44,9 +46,9 @@ class AccountSensitiveFieldsTests(unittest.TestCase):
)
item = result["items"][0]
self.assertIsNone(item.password)
self.assertIsNone(item.email)
self.assertIsNone(item.email_password)
assert item.password is None
assert item.email is None
assert item.email_password is None
def test_admin_can_explicitly_request_sensitive_fields(self):
result = list_accounts(
@@ -62,34 +64,42 @@ class AccountSensitiveFieldsTests(unittest.TestCase):
)
item = result["items"][0]
self.assertEqual(item.password, "account-password")
self.assertEqual(item.email, "account@example.com")
self.assertEqual(item.email_password, "email-password")
assert item.password == "account-password"
assert item.email == "account@example.com"
assert item.email_password == "email-password"
def test_support_can_only_change_tags_on_assigned_accounts(self):
support = User(username="support", password_hash="hash", role="support")
other_support = User(username="other-support", password_hash="hash", role="support")
other_support = User(
username="other-support", password_hash="hash", role="support"
)
self.session.add_all([support, other_support])
self.session.commit()
assigned = Account(
username="assigned", password="password", email="assigned@example.com",
email_password="mail-password", assigned_to=support.id,
username="assigned",
password="password",
email="assigned@example.com",
email_password="mail-password",
assigned_to=support.id,
)
other = Account(
username="other", password="password", email="other@example.com",
email_password="mail-password", assigned_to=other_support.id,
username="other",
password="password",
email="other@example.com",
email_password="mail-password",
assigned_to=other_support.id,
)
self.session.add_all([assigned, other])
self.session.commit()
set_account_tag(assigned.id, AccountTag(tag="客服组"), db=self.session, current=support)
set_account_tag(
assigned.id, AccountTag(tag="客服组"), db=self.session, current=support
)
self.session.refresh(assigned)
self.assertEqual(assigned.tag, "客服组")
assert assigned.tag == "客服组"
with self.assertRaises(HTTPException) as context:
set_account_tag(other.id, AccountTag(tag="越权"), db=self.session, current=support)
self.assertEqual(context.exception.status_code, 404)
if __name__ == "__main__":
unittest.main()
with pytest.raises(HTTPException) as context:
set_account_tag(
other.id, AccountTag(tag="越权"), db=self.session, current=support
)
assert context.value.status_code == 404
+80 -41
View File
@@ -1,7 +1,8 @@
"""充值审计日志的权限、查询和脱敏测试。"""
import pytest
import json
import unittest
from fastapi import HTTPException
from sqlalchemy import create_engine
@@ -13,89 +14,127 @@ from web.backend.routers.audit import list_audit_logs, require_super_admin
from web.backend.services.audit_service import record_audit
class AuditLogTests(unittest.TestCase):
def setUp(self):
class TestAuditLog:
def setup_method(self):
self.engine = create_engine("sqlite://")
Base.metadata.create_all(self.engine)
self.session = sessionmaker(bind=self.engine)()
self.admin = User(username="admin", password_hash="hash", role="super_admin")
self.operator = User(
username="operator", password_hash="hash", role="operation",
username="operator",
password_hash="hash",
role="operation",
custom_permissions=["audit:view"],
)
self.session.add_all([self.admin, self.operator])
self.session.commit()
def tearDown(self):
def teardown_method(self):
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
def test_only_super_admin_can_read_audit_logs(self):
with self.assertRaises(HTTPException) as context:
with pytest.raises(HTTPException) as context:
require_super_admin(self.operator)
self.assertEqual(context.exception.status_code, 403)
self.assertIs(require_super_admin(self.admin), self.admin)
assert context.value.status_code == 403
assert require_super_admin(self.admin) is self.admin
def test_list_audit_logs_supports_pagination_and_result_filter(self):
for index in range(3):
record_audit(
self.session, self.admin, action="recharge:yyb:payment",
target=f"yyb_task:{index}", detail={"task_id": index}, success=index != 1,
self.session,
self.admin,
action="recharge:yyb:payment",
target=f"yyb_task:{index}",
detail={"task_id": index},
success=index != 1,
)
self.session.commit()
result = list_audit_logs(
page=1, page_size=1, username=None, action="recharge:yyb:payment",
keyword=None, success=True, start_time=None, end_time=None,
db=self.session, _=self.admin,
page=1,
page_size=1,
username=None,
action="recharge:yyb:payment",
keyword=None,
success=True,
start_time=None,
end_time=None,
db=self.session,
_=self.admin,
)
self.assertEqual(result["total"], 2)
self.assertEqual(len(result["items"]), 1)
self.assertTrue(result["items"][0]["success"])
assert result["total"] == 2
assert len(result["items"]) == 1
assert result["items"][0]["success"]
def test_audit_detail_masks_sensitive_fields(self):
record_audit(
self.session, self.admin, action="recharge:yyb:payment",
detail={"token": "token-value", "nested": {"cookie": "cookie-value"}, "order_id": "order-1"},
self.session,
self.admin,
action="recharge:yyb:payment",
detail={
"token": "token-value",
"nested": {"cookie": "cookie-value"},
"order_id": "order-1",
},
)
self.session.commit()
entry = self.session.query(AuditLog).one()
detail = json.loads(entry.detail)
self.assertEqual(detail["token"], "[已脱敏]")
self.assertEqual(detail["nested"]["cookie"], "[已脱敏]")
self.assertEqual(detail["order_id"], "order-1")
assert detail["token"] == "[已脱敏]"
assert detail["nested"]["cookie"] == "[已脱敏]"
assert detail["order_id"] == "order-1"
def test_old_douyu_recharge_log_is_enriched_with_account_identity(self):
account = Account(
username="douyu-login", password="password", email="mail@example.com",
email_password="mail-password", uid="10001", nickname="斗鱼昵称",
username="douyu-login",
password="password",
email="mail@example.com",
email_password="mail-password",
uid="10001",
nickname="斗鱼昵称",
)
self.session.add(account)
self.session.commit()
self.session.add(DouyuTask(
batch_id="batch-old", account_id=account.id, task_type="create_gold_qr",
handbook_scope="elite", created_by=self.admin.id,
))
self.session.add(AuditLog(
user_id=self.admin.id, username=self.admin.username,
action="recharge:douyu:create", target="douyu_batch:batch-old", detail="{}", success=True,
))
self.session.add(
DouyuTask(
batch_id="batch-old",
account_id=account.id,
task_type="create_gold_qr",
handbook_scope="elite",
created_by=self.admin.id,
)
)
self.session.add(
AuditLog(
user_id=self.admin.id,
username=self.admin.username,
action="recharge:douyu:create",
target="douyu_batch:batch-old",
detail="{}",
success=True,
)
)
self.session.commit()
result = list_audit_logs(
page=1, page_size=50, username=None, action=None, keyword=None, success=None,
start_time=None, end_time=None, db=self.session, _=self.admin,
page=1,
page_size=50,
username=None,
action=None,
keyword=None,
success=None,
start_time=None,
end_time=None,
db=self.session,
_=self.admin,
)
detail = json.loads(result["items"][0]["detail"])
self.assertEqual(detail["recharge_accounts"][0]["username"], "douyu-login")
self.assertEqual(detail["recharge_accounts"][0]["douyu_uid"], "10001")
self.assertEqual(detail["recharge_channel"], "wechat_qr")
self.assertEqual(detail["payment_method"], "微信扫码支付")
if __name__ == "__main__":
unittest.main()
assert detail["recharge_accounts"][0]["username"] == "douyu-login"
assert detail["recharge_accounts"][0]["douyu_uid"] == "10001"
assert detail["recharge_channel"] == "wechat_qr"
assert detail["payment_method"] == "微信扫码支付"
+20 -30
View File
@@ -1,4 +1,3 @@
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
@@ -9,8 +8,8 @@ from web.backend.models import Account, LoginTask, User
from web.backend.routers.cookies import _order_cookie_tasks
class CustomCookieOrderTests(unittest.TestCase):
def setUp(self):
class TestCustomCookieOrder:
def setup_method(self):
self.engine = create_engine("sqlite://")
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
@@ -27,41 +26,32 @@ class CustomCookieOrderTests(unittest.TestCase):
self.db.flush()
# 完成时间特意与用户输入顺序相反。
for index, name in enumerate(("account-a", "account-b", "account-c")):
self.db.add(LoginTask(
batch_id=f"batch-{name}",
account_id=accounts[name].id,
status="success",
created_by=self.user.id,
finished_at=now + timedelta(minutes=index),
))
self.db.add(
LoginTask(
batch_id=f"batch-{name}",
account_id=accounts[name].id,
status="success",
created_by=self.user.id,
finished_at=now + timedelta(minutes=index),
)
)
self.db.commit()
def tearDown(self):
def teardown_method(self):
self.db.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
def test_custom_cookie_order_follows_input_not_finished_time(self):
selected_names = ["account-c", "account-a", "account-b"]
tasks = (
_order_cookie_tasks(
self.db.query(LoginTask)
.join(Account, LoginTask.account_id == Account.id)
.filter(Account.username.in_(selected_names)),
selected_names,
)
.all()
)
tasks = _order_cookie_tasks(
self.db.query(LoginTask)
.join(Account, LoginTask.account_id == Account.id)
.filter(Account.username.in_(selected_names)),
selected_names,
).all()
account_names = {
account.id: account.username
for account in self.db.query(Account).all()
account.id: account.username for account in self.db.query(Account).all()
}
self.assertEqual(
[account_names[task.account_id] for task in tasks],
selected_names,
)
if __name__ == "__main__":
unittest.main()
assert [account_names[task.account_id] for task in tasks] == selected_names
+107 -63
View File
@@ -1,4 +1,4 @@
import unittest
import pytest
from types import SimpleNamespace
from unittest.mock import patch
@@ -7,7 +7,10 @@ from sqlalchemy.orm import sessionmaker
from web.backend.database import Base
from web.backend.models import Account, LoginTask, User
from web.backend.services.login_service import LoginBatchRunner, cleanup_orphan_relogin_tasks
from web.backend.services.login_service import (
LoginBatchRunner,
cleanup_orphan_relogin_tasks,
)
from web.backend.services.cookie_check_service import check_douyu_cookie
from web.backend.routers.cookies import (
check_cookie_operations,
@@ -19,14 +22,16 @@ from web.backend.routers.cookies import (
)
class CookieOperationTests(unittest.TestCase):
def setUp(self):
class TestCookieOperation:
def setup_method(self):
self.engine = create_engine("sqlite://")
Base.metadata.create_all(self.engine)
self.session = sessionmaker(bind=self.engine)()
self.support = User(username="support", password_hash="hash", role="support")
self.other_support = User(username="other", password_hash="hash", role="support")
self.other_support = User(
username="other", password_hash="hash", role="support"
)
self.session.add_all([self.support, self.other_support])
self.session.commit()
@@ -46,15 +51,33 @@ class CookieOperationTests(unittest.TestCase):
)
self.session.add_all([owned, other])
self.session.commit()
self.session.add_all([
LoginTask(batch_id="owned", account_id=owned.id, created_by=self.support.id, status="success", cookie="owned-secret"),
LoginTask(batch_id="other", account_id=other.id, created_by=self.other_support.id, status="success", cookie="other-secret"),
])
self.session.add_all(
[
LoginTask(
batch_id="owned",
account_id=owned.id,
created_by=self.support.id,
status="success",
cookie="owned-secret",
),
LoginTask(
batch_id="other",
account_id=other.id,
created_by=self.other_support.id,
status="success",
cookie="other-secret",
),
]
)
self.session.commit()
self.owned_task = self.session.query(LoginTask).filter(LoginTask.batch_id == "owned").one()
self.other_task = self.session.query(LoginTask).filter(LoginTask.batch_id == "other").one()
self.owned_task = (
self.session.query(LoginTask).filter(LoginTask.batch_id == "owned").one()
)
self.other_task = (
self.session.query(LoginTask).filter(LoginTask.batch_id == "other").one()
)
def tearDown(self):
def teardown_method(self):
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
@@ -69,32 +92,38 @@ class CookieOperationTests(unittest.TestCase):
current=self.support,
)
self.assertEqual(result["total"], 1)
self.assertEqual(result["items"], [{
"id": self.owned_task.id,
"account_username": "owned-account",
"tag": "",
"ck_check_status": "",
"ck_checked_at": None,
"created_at": None,
"relogin_status": "",
"relogin_message": "",
"relogin_batch_id": "",
}])
self.assertNotIn("owned-secret", str(result))
self.assertNotIn("owned-password", str(result))
assert result["total"] == 1
assert result["items"] == [
{
"id": self.owned_task.id,
"account_username": "owned-account",
"tag": "",
"ck_check_status": "",
"ck_checked_at": None,
"created_at": None,
"relogin_status": "",
"relogin_message": "",
"relogin_batch_id": "",
}
]
assert "owned-secret" not in str(result)
assert "owned-password" not in str(result)
def test_support_cannot_use_cookie_management_endpoints(self):
with self.assertRaisesRegex(Exception, "cookie:view"):
with pytest.raises(Exception, match="cookie:view"):
list_cookies(db=self.session, current=self.support)
with self.assertRaisesRegex(Exception, "cookie:view"):
with pytest.raises(Exception, match="cookie:view"):
get_cookie(self.owned_task.id, db=self.session, current=self.support)
def test_cookie_list_filters_by_account_tag(self):
admin = User(username="admin", password_hash="hash", role="super_admin")
self.session.add(admin)
self.session.query(Account).filter(Account.id == self.owned_task.account_id).update({"tag": "A组"})
self.session.query(Account).filter(Account.id == self.other_task.account_id).update({"tag": "B组"})
self.session.query(Account).filter(
Account.id == self.owned_task.account_id
).update({"tag": "A组"})
self.session.query(Account).filter(
Account.id == self.other_task.account_id
).update({"tag": "B组"})
self.session.commit()
result = list_cookies(
@@ -108,12 +137,16 @@ class CookieOperationTests(unittest.TestCase):
current=admin,
)
self.assertEqual(result["total"], 1)
self.assertEqual([item["id"] for item in result["items"]], [self.owned_task.id])
assert result["total"] == 1
assert [item["id"] for item in result["items"]] == [self.owned_task.id]
def test_support_operation_tag_filter_and_list_stay_scoped(self):
self.session.query(Account).filter(Account.id == self.owned_task.account_id).update({"tag": "我的标签"})
self.session.query(Account).filter(Account.id == self.other_task.account_id).update({"tag": "别人的标签"})
self.session.query(Account).filter(
Account.id == self.owned_task.account_id
).update({"tag": "我的标签"})
self.session.query(Account).filter(
Account.id == self.other_task.account_id
).update({"tag": "别人的标签"})
self.session.commit()
tags = list_cookie_operation_tags(db=self.session, current=self.support)
@@ -126,8 +159,8 @@ class CookieOperationTests(unittest.TestCase):
current=self.support,
)
self.assertEqual(tags, ["我的标签"])
self.assertEqual(result["total"], 0)
assert tags == ["我的标签"]
assert result["total"] == 0
@patch("web.backend.routers.cookies._check_one_cookie")
def test_support_can_check_only_assigned_cookie(self, check_one_cookie):
@@ -147,11 +180,11 @@ class CookieOperationTests(unittest.TestCase):
current=self.support,
)
self.assertEqual([item["id"] for item in result["results"]], [self.owned_task.id])
assert [item["id"] for item in result["results"]] == [self.owned_task.id]
self.session.refresh(self.owned_task)
self.session.refresh(self.other_task)
self.assertEqual(self.owned_task.ck_check_status, "valid")
self.assertEqual(self.other_task.ck_check_status, "")
assert self.owned_task.ck_check_status == "valid"
assert self.other_task.ck_check_status == ""
def test_operation_permission_does_not_allow_unrelated_users(self):
no_permission_user = SimpleNamespace(
@@ -160,7 +193,7 @@ class CookieOperationTests(unittest.TestCase):
role="support",
custom_permissions=[],
)
with self.assertRaisesRegex(Exception, "cookie:operate"):
with pytest.raises(Exception, match="cookie:operate"):
list_cookie_operations(
search="",
tag="",
@@ -176,12 +209,17 @@ class CookieOperationTests(unittest.TestCase):
self.session.commit()
result = list_cookie_operations(
search="", tag="", page=1, page_size=20, db=self.session, current=self.support,
search="",
tag="",
page=1,
page_size=20,
db=self.session,
current=self.support,
)
self.assertEqual(result["total"], 1)
self.assertEqual(result["items"][0]["relogin_status"], "relogin_failed")
self.assertIn("旧 Cookie 已保留", result["items"][0]["relogin_message"])
assert result["total"] == 1
assert result["items"][0]["relogin_status"] == "relogin_failed"
assert "旧 Cookie 已保留" in result["items"][0]["relogin_message"]
def test_service_restart_cleans_orphan_relogin_tasks(self):
self.owned_task.status = "relogin_running"
@@ -189,12 +227,12 @@ class CookieOperationTests(unittest.TestCase):
cleaned = cleanup_orphan_relogin_tasks(self.session)
self.assertEqual(cleaned, 1)
assert cleaned == 1
self.session.refresh(self.owned_task)
self.session.refresh(self.other_task)
self.assertEqual(self.owned_task.status, "relogin_failed")
self.assertIn("服务重启", self.owned_task.message)
self.assertEqual(self.other_task.status, "success")
assert self.owned_task.status == "relogin_failed"
assert "服务重启" in self.owned_task.message
assert self.other_task.status == "success"
def test_runner_copies_proxy_config_before_background_execution(self):
proxy = SimpleNamespace(
@@ -219,40 +257,46 @@ class CookieOperationTests(unittest.TestCase):
proxy_dict, _ = runner._resolve_static_proxy()
self.assertEqual(proxy_dict, {
assert proxy_dict == {
"http": "http://127.0.0.1:8080",
"https": "http://127.0.0.1:8080",
})
}
@patch("web.backend.services.cookie_check_service.requests.get")
def test_cookie_check_uses_new_cookie_result(self, mock_get):
mock_get.side_effect = [
SimpleNamespace(json=lambda: {"error": 0, "data": {"count": 9}}),
SimpleNamespace(json=lambda: {"error": 0, "data": {"nn": "new-name", "lv": 12}}),
SimpleNamespace(
json=lambda: {"error": 0, "data": {"nn": "new-name", "lv": 12}}
),
]
result = check_douyu_cookie("new-cookie")
self.assertTrue(result["valid"])
self.assertEqual(result["fish_ball"], 9)
self.assertEqual(result["nickname"], "new-name")
self.assertEqual(result["level"], 12)
assert result["valid"]
assert result["fish_ball"] == 9
assert result["nickname"] == "new-name"
assert result["level"] == 12
@patch("web.backend.routers.cookies._start_relogin_tasks")
def test_relogin_invalid_only_targets_support_visible_accounts(self, start_relogin):
self.owned_task.ck_check_status = "invalid"
self.other_task.ck_check_status = "invalid"
self.session.commit()
start_relogin.return_value = {"batch_id": "batch", "count": 1, "skipped": 0, "success": True}
start_relogin.return_value = {
"batch_id": "batch",
"count": 1,
"skipped": 0,
"success": True,
}
result = relogin_invalid_cookie_operations(
search="", tag="", db=self.session, current=self.support,
search="",
tag="",
db=self.session,
current=self.support,
)
self.assertTrue(result["success"])
assert result["success"]
selected_tasks = start_relogin.call_args.args[0]
self.assertEqual([task.id for task in selected_tasks], [self.owned_task.id])
if __name__ == "__main__":
unittest.main()
assert [task.id for task in selected_tasks] == [self.owned_task.id]
+47 -42
View File
@@ -1,17 +1,18 @@
"""精英手册兑换:csrf 复用、新链路接口与浏览器状态机路由的测试。"""
import unittest
import pytest
from unittest.mock import Mock, call
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
class CsrfReuseTests(unittest.TestCase):
class TestCsrfReuse:
def test_reuses_cookie_token_without_any_http_call(self):
client = DouyuActivityClient("acf_uid=100; cvl_csrf_token=existing-token")
client._request_json = Mock()
self.assertEqual(client.csrf_token(), "existing-token")
assert client.csrf_token() == "existing-token"
client._request_json.assert_not_called()
def test_generates_csrf_when_cookie_missing(self):
@@ -24,7 +25,7 @@ class CsrfReuseTests(unittest.TestCase):
client._request_json = Mock(side_effect=fake_generate)
self.assertEqual(client.csrf_token(), "new-token")
assert client.csrf_token() == "new-token"
client._request_json.assert_called_once()
def test_force_refresh_rotates_token(self):
@@ -32,45 +33,51 @@ class CsrfReuseTests(unittest.TestCase):
client._request_json = Mock(return_value={"error": 0})
client._set_cookie_value("cvl_csrf_token", "rotated-token")
self.assertEqual(client.csrf_token(force_refresh=True), "rotated-token")
assert client.csrf_token(force_refresh=True) == "rotated-token"
def test_refreshes_csrf_once_on_token_error(self):
client = DouyuActivityClient("acf_uid=100")
client.csrf_token = Mock(side_effect=["bad-token", "good-token"])
client._request_json = Mock(side_effect=[
{"error": 1, "msg": "csrfToken 校验失败"},
{"error": 0, "data": {"orderId": "9", "expireSeconds": "300"}},
])
client._request_json = Mock(
side_effect=[
{"error": 1, "msg": "csrfToken 校验失败"},
{"error": 0, "data": {"orderId": "9", "expireSeconds": "300"}},
]
)
result = client.create_exchange_order(manual_id="m", rid="r", commodity_id="c")
self.assertEqual(result["order_id"], "9")
self.assertEqual(client.csrf_token.call_args_list, [call(), call(force_refresh=True)])
assert result["order_id"] == "9"
assert client.csrf_token.call_args_list == [call(), call(force_refresh=True)]
def test_no_refresh_on_regular_error(self):
client = DouyuActivityClient("acf_uid=100")
client.csrf_token = Mock(return_value="t")
client._request_json = Mock(return_value={"error": 1, "msg": "需要开通精英手册,才可以兑换本道具哦"})
client._request_json = Mock(
return_value={"error": 1, "msg": "需要开通精英手册,才可以兑换本道具哦"}
)
with self.assertRaisesRegex(DouyuActivityError, "需要开通精英手册"):
with pytest.raises(DouyuActivityError, match="需要开通精英手册"):
client.create_exchange_order(manual_id="m", rid="r", commodity_id="c")
client.csrf_token.assert_called_once_with()
def test_pay_referer_includes_room_id_like_browser(self):
client = DouyuActivityClient("acf_uid=100")
client.csrf_token = Mock(return_value="t")
client._request_json = Mock(return_value={"error": 0, "data": {"exchangeId": "11098933"}})
client._request_json = Mock(
return_value={"error": 0, "data": {"exchangeId": "11098933"}}
)
client.pay_exchange_order(manual_id="m", order_id="5874", rid="9263298")
headers = client._request_json.call_args.kwargs["headers"]
self.assertEqual(
headers["Referer"],
"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298",
assert (
headers["Referer"]
== "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298"
)
class ExchangePlanTests(unittest.TestCase):
class TestExchangePlan:
"""对齐浏览器兑换按钮状态机($e / Ve / Ge)的路由判定。"""
@staticmethod
@@ -92,72 +99,70 @@ class ExchangePlanTests(unittest.TestCase):
def test_normal_item_uses_classic_flow(self):
plan = DouyuActivityClient.resolve_exchange_plan(self.detail())
self.assertEqual(plan["action"], "classic")
self.assertEqual(plan["max_num"], 1)
assert plan["action"] == "classic"
assert plan["max_num"] == 1
def test_pre_exchange_order_still_classic(self):
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(preExchange={"isExists": 1}))
self.assertEqual(plan["action"], "classic")
plan = DouyuActivityClient.resolve_exchange_plan(
self.detail(preExchange={"isExists": 1})
)
assert plan["action"] == "classic"
def test_reached_exchange_limit_blocked(self):
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(status=1))
self.assertEqual(plan["action"], "blocked")
self.assertIn("上限", plan["text"])
assert plan["action"] == "blocked"
assert "上限" in plan["text"]
def test_no_stock_blocked(self):
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(storeStatus=0))
self.assertEqual(plan["action"], "blocked")
self.assertIn("无库存", plan["text"])
assert plan["action"] == "blocked"
assert "无库存" in plan["text"]
def test_room_limited_and_not_in_room_blocked(self):
plan = DouyuActivityClient.resolve_exchange_plan(
self.detail(roomLimited=1, isLimitRoom=0)
)
self.assertEqual(plan["action"], "blocked")
self.assertIn("限定房间", plan["text"])
assert plan["action"] == "blocked"
assert "限定房间" in plan["text"]
def test_not_open_subscribe_item_subscribes(self):
plan = DouyuActivityClient.resolve_exchange_plan(
self.detail(openStatus=0, subscribeType=1)
)
self.assertEqual(plan["action"], "subscribe")
assert plan["action"] == "subscribe"
def test_not_open_pre_exchange_item_pre_exchanges(self):
plan = DouyuActivityClient.resolve_exchange_plan(
self.detail(openStatus=0, subscribeType=2)
)
self.assertEqual(plan["action"], "pre_exchange")
assert plan["action"] == "pre_exchange"
def test_not_open_plain_item_blocked(self):
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(openStatus=0))
self.assertEqual(plan["action"], "blocked")
self.assertIn("未开启", plan["text"])
assert plan["action"] == "blocked"
assert "未开启" in plan["text"]
def test_elite_limited_requires_manual(self):
plan = DouyuActivityClient.resolve_exchange_plan(
self.detail(eliteLimited=1), manual_type=0
)
self.assertEqual(plan["action"], "blocked")
self.assertIn("精英手册", plan["text"])
assert plan["action"] == "blocked"
assert "精英手册" in plan["text"]
# 已开通手册(manualType=1)不受精英专享限制
plan = DouyuActivityClient.resolve_exchange_plan(
self.detail(eliteLimited=1), manual_type=1
)
self.assertEqual(plan["action"], "classic")
assert plan["action"] == "classic"
def test_pre_exchanged_waits_for_open(self):
plan = DouyuActivityClient.resolve_exchange_plan(
self.detail(subscribeType=2, exchangeInfo={"userStatus": 1})
)
self.assertEqual(plan["action"], "wait")
assert plan["action"] == "wait"
def test_batch_exchange_clamps_num(self):
plan = DouyuActivityClient.resolve_exchange_plan(
self.detail(batchExchange=1), batch_num_limit="5"
)
self.assertEqual(plan["max_num"], 5)
self.assertTrue(plan["batch_exchange"])
if __name__ == "__main__":
unittest.main()
assert plan["max_num"] == 5
assert plan["batch_exchange"]
+81 -69
View File
@@ -1,4 +1,4 @@
import unittest
import pytest
from unittest.mock import Mock
import requests
@@ -6,8 +6,8 @@ import requests
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
class EliteLockExchangeTests(unittest.TestCase):
def setUp(self):
class TestEliteLockExchange:
def setup_method(self):
self.client = DouyuActivityClient("acf_uid=100")
self.client.csrf_token = Mock(side_effect=["csrf-create", "csrf-pay"])
@@ -16,33 +16,35 @@ class EliteLockExchangeTests(unittest.TestCase):
return {key: value[1] for key, value in files.items()}
def test_exchange_locks_then_pays_with_fresh_csrf_tokens(self):
self.client._request_json = Mock(side_effect=[
{
"error": 0,
"msg": "请求正常",
"data": {
"orderId": "5874",
"commodityName": "套装-兔兔白日梦",
"commodityImage": "https://example.test/item.png",
"score": 1280,
"num": 1,
"expireSeconds": "300",
self.client._request_json = Mock(
side_effect=[
{
"error": 0,
"msg": "请求正常",
"data": {
"orderId": "5874",
"commodityName": "套装-兔兔白日梦",
"commodityImage": "https://example.test/item.png",
"score": 1280,
"num": 1,
"expireSeconds": "300",
},
},
},
{
"error": 0,
"msg": "请求正常",
"data": {
"orderId": "5874",
"exchangeId": "11098933",
"commodityType": 3,
"commodityImage": "https://example.test/item.png",
"exchangeNum": 1,
"sType": 1,
"gType": 2,
{
"error": 0,
"msg": "请求正常",
"data": {
"orderId": "5874",
"exchangeId": "11098933",
"commodityType": 3,
"commodityImage": "https://example.test/item.png",
"exchangeNum": 1,
"sType": 1,
"gType": 2,
},
},
},
])
]
)
result = self.client.exchange_goods(
manual_id="G4KA4Qnz4LDp7",
@@ -50,34 +52,38 @@ class EliteLockExchangeTests(unittest.TestCase):
commodity_id="20260120QYOOB_bag10",
)
self.assertEqual(result["order_id"], "5874")
self.assertEqual(result["exchange_id"], "11098933")
self.assertEqual(self.client.csrf_token.call_count, 2)
assert result["order_id"] == "5874"
assert result["exchange_id"] == "11098933"
assert self.client.csrf_token.call_count == 2
create_call, pay_call = self.client._request_json.call_args_list
self.assertEqual(create_call.args[:3], (
"post", self.client.CREATE_EXCHANGE_ORDER_API, "锁定兑换商品",
))
self.assertEqual(self.multipart_values(create_call.kwargs["files"]), {
assert create_call.args[:3] == (
"post",
self.client.CREATE_EXCHANGE_ORDER_API,
"锁定兑换商品",
)
assert self.multipart_values(create_call.kwargs["files"]) == {
"manualID": "G4KA4Qnz4LDp7",
"rid": "9263298",
"commodityID": "20260120QYOOB_bag10",
"num": "1",
"csrfToken": "csrf-create",
})
self.assertNotIn("Content-Type", create_call.kwargs["headers"])
self.assertEqual(pay_call.args[:3], (
"post", self.client.PAY_EXCHANGE_ORDER_API, "支付锁单",
))
self.assertEqual(self.multipart_values(pay_call.kwargs["files"]), {
}
assert "Content-Type" not in create_call.kwargs["headers"]
assert pay_call.args[:3] == (
"post",
self.client.PAY_EXCHANGE_ORDER_API,
"支付锁单",
)
assert self.multipart_values(pay_call.kwargs["files"]) == {
"orderID": "5874",
"manualID": "G4KA4Qnz4LDp7",
"csrfToken": "csrf-pay",
})
}
def test_lock_requires_order_id_in_success_response(self):
self.client._request_json = Mock(return_value={"error": 0, "data": {}})
with self.assertRaisesRegex(DouyuActivityError, "没有 orderId"):
with pytest.raises(DouyuActivityError, match="没有 orderId"):
self.client.create_exchange_order(
manual_id="manual",
rid="9263298",
@@ -85,32 +91,42 @@ class EliteLockExchangeTests(unittest.TestCase):
)
def test_locked_order_list_uses_captured_query_shape(self):
self.client._request_json = Mock(return_value={
"error": 0,
"data": {"total": 1, "list": [{"orderId": "5874"}]},
})
self.client._request_json = Mock(
return_value={
"error": 0,
"data": {"total": 1, "list": [{"orderId": "5874"}]},
}
)
result = self.client.exchange_orders(manual_id="manual", page=2, page_size=20)
self.assertEqual(result["total"], 1)
self.assertEqual(result["orders"], [{"orderId": "5874"}])
assert result["total"] == 1
assert result["orders"] == [{"orderId": "5874"}]
call = self.client._request_json.call_args
self.assertEqual(call.args[:3], (
"get", self.client.EXCHANGE_ORDER_LIST_API, "查询锁单",
))
self.assertEqual(call.kwargs["params"], {
"page": 2, "pageSize": 20, "manualID": "manual",
})
assert call.args[:3] == (
"get",
self.client.EXCHANGE_ORDER_LIST_API,
"查询锁单",
)
assert call.kwargs["params"] == {
"page": 2,
"pageSize": 20,
"manualID": "manual",
}
def test_redirect_is_not_followed_and_is_reported_without_query_values(self):
response = requests.Response()
response.status_code = 302
response.url = "https://www.douyu.com/japi/example?request_secret=hidden"
response.headers["Location"] = "https://www.douyu.com/member/login?ticket=secret"
response.headers["Location"] = (
"https://www.douyu.com/member/login?ticket=secret"
)
self.client.session.request = Mock(return_value=response)
self.client.logger = Mock()
with self.assertRaisesRegex(DouyuActivityError, "Cookie 已失效,请重新登录") as caught:
with pytest.raises(
DouyuActivityError, match="Cookie 已失效,请重新登录"
) as caught:
self.client._request(
"get",
"https://www.douyu.com/japi/example?request_secret=hidden",
@@ -118,15 +134,11 @@ class EliteLockExchangeTests(unittest.TestCase):
)
self.client.session.request.assert_called_once()
self.assertFalse(self.client.session.request.call_args.kwargs["allow_redirects"])
message = str(caught.exception)
self.assertIn("method=GET", message)
self.assertIn("url=https://www.douyu.com/japi/example", message)
self.assertIn("location=https://www.douyu.com/member/login", message)
self.assertNotIn("request_secret", message)
self.assertNotIn("ticket=secret", message)
self.assertIn("已拦截重定向", self.client.logger.call_args.args[0])
if __name__ == "__main__":
unittest.main()
assert not self.client.session.request.call_args.kwargs["allow_redirects"]
message = str(caught.value)
assert "method=GET" in message
assert "url=https://www.douyu.com/japi/example" in message
assert "location=https://www.douyu.com/member/login" in message
assert "request_secret" not in message
assert "ticket=secret" not in message
assert "已拦截重定向" in self.client.logger.call_args.args[0]
+99 -69
View File
@@ -2,8 +2,7 @@
import asyncio
import os
import unittest
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import ANY, AsyncMock, Mock, patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@@ -17,8 +16,8 @@ from web.backend.services.audit_service import record_audit
from core.douyu import FishFinRechargeClient, FishFinRechargeConfig
class DouyuGoldRechargeChannelTests(unittest.TestCase):
def setUp(self):
class TestDouyuGoldRechargeChannel:
def setup_method(self):
self.engine = create_engine("sqlite://")
Base.metadata.create_all(self.engine)
self.session = sessionmaker(bind=self.engine)()
@@ -36,8 +35,12 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
self.session.add(self.account)
self.session.commit()
self.task = DouyuTask(
batch_id="batch", account_id=self.account.id, task_type="create_gold_qr",
handbook_scope="elite", status="running", created_by=user.id,
batch_id="batch",
account_id=self.account.id,
task_type="create_gold_qr",
handbook_scope="elite",
status="running",
created_by=user.id,
)
self.session.add(self.task)
self.session.commit()
@@ -46,13 +49,15 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
os.environ["FISH_FIN_RECHARGE_APP_ID"] = "test-app"
os.environ["FISH_FIN_RECHARGE_APP_SECRET"] = "test-secret"
def tearDown(self):
def teardown_method(self):
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
def test_supplier_channel_creates_order_using_nickname_and_finishes_on_success(self, client_class):
def test_supplier_channel_creates_order_using_nickname_and_finishes_on_success(
self, client_class
):
supplier = Mock()
supplier.create_order.return_value = {
"code": 200,
@@ -69,32 +74,36 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
self.task.result = {"payload": {"amount": 10}}
self.session.commit()
self.runner._execute_create_gold_qr(
self.session, self.task, self.account, "acf_uid=10001", config,
self.session,
self.task,
self.account,
"acf_uid=10001",
config,
)
expected_order_id = self.runner._supplier_out_order_id(self.task)
supplier.create_order.assert_called_once_with(
buy_num=10,
pay_amount=unittest.mock.ANY,
pay_amount=ANY,
out_order_id=expected_order_id,
product_id="gold-product",
recharge_arg=[{"templateName": "斗鱼账号", "templateVal": "罗炅729"}],
order_type=0,
notify_url=unittest.mock.ANY,
notify_url=ANY,
)
self.assertEqual(str(supplier.create_order.call_args.kwargs["pay_amount"]), "10")
assert str(supplier.create_order.call_args.kwargs["pay_amount"]) == "10"
self.session.refresh(self.task)
self.assertEqual(self.task.status, "success")
self.assertEqual(self.task.result["recharge_channel"], "supplier_api")
self.assertEqual(self.task.result["supplier_order_status"], 2)
self.assertEqual(self.task.result["buy_num"], 10)
self.assertEqual(self.task.result["out_order_id"], expected_order_id)
self.assertEqual(self.task.result["recharge_account"], "罗炅729")
self.assertEqual(self.task.result["douyu_uid"], "10001")
self.assertEqual(self.task.result["order_id"], "supplier-001")
self.assertEqual(self.task.result["pay_amount"], "10")
self.assertNotIn("pay_url", self.task.result)
self.assertNotIn("sign", self.task.result["supplier_order"])
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"]
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
def test_supplier_channel_rejects_account_without_nickname(self, client_class):
@@ -107,20 +116,21 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
}
self.runner._execute_create_gold_qr(
self.session, self.task, self.account, "acf_uid=10001", config,
self.session,
self.task,
self.account,
"acf_uid=10001",
config,
)
client_class.assert_not_called()
self.session.refresh(self.task)
self.assertEqual(self.task.status, "failed")
self.assertIn("斗鱼昵称", self.task.message)
assert self.task.status == "failed"
assert "斗鱼昵称" in self.task.message
def test_supplier_out_order_id_reuses_persisted_value(self):
self.task.supplier_out_order_id = "DYGFHISTORICAL-001"
self.assertEqual(
self.runner._supplier_out_order_id(self.task),
"DYGFHISTORICAL-001",
)
assert self.runner._supplier_out_order_id(self.task) == "DYGFHISTORICAL-001"
def test_recharge_channel_can_be_read_without_full_config(self):
config = ensure_douyu_config(self.session)
@@ -132,7 +142,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
current=self.session.get(User, self.task.created_by),
)
self.assertEqual(channel, {"gold_recharge_channel": "supplier_api"})
assert channel == {"gold_recharge_channel": "supplier_api"}
def test_recharge_audit_detail_includes_safe_account_identity(self):
record_audit(
@@ -140,22 +150,26 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
self.session.get(User, self.task.created_by),
action="recharge:douyu:create",
detail={
"recharge_accounts": [{
"username": self.account.username,
"douyu_uid": self.account.uid,
"douyu_nickname": self.account.nickname,
}],
"recharge_accounts": [
{
"username": self.account.username,
"douyu_uid": self.account.uid,
"douyu_nickname": self.account.nickname,
}
],
},
)
self.session.commit()
audit = self.session.query(AuditLog).one()
self.assertIn("douyu_uid", audit.detail)
self.assertIn("10001", audit.detail)
self.assertNotIn("password", audit.detail)
assert "douyu_uid" in audit.detail
assert "10001" in audit.detail
assert "password" not in audit.detail
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
def test_supplier_channel_marks_non_200_create_response_as_failed(self, client_class):
def test_supplier_channel_marks_non_200_create_response_as_failed(
self, client_class
):
supplier = Mock()
supplier.create_order.return_value = {"code": 400, "msg": "商品已下架"}
client_class.return_value = supplier
@@ -166,16 +180,22 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
}
self.runner._execute_create_gold_qr(
self.session, self.task, self.account, "acf_uid=10001", config,
self.session,
self.task,
self.account,
"acf_uid=10001",
config,
)
self.session.refresh(self.task)
self.assertEqual(self.task.status, "failed")
self.assertEqual(self.task.message, "商品已下架")
assert self.task.status == "failed"
assert self.task.message == "商品已下架"
@patch("web.backend.services.douyu_runner.check_douyu_cookie")
@patch("web.backend.services.douyu_runner.SessionLocal")
def test_invalid_cookie_skips_activity_and_marks_relogin_required(self, session_local, check_cookie):
def test_invalid_cookie_skips_activity_and_marks_relogin_required(
self, session_local, check_cookie
):
login_task = LoginTask(
batch_id="login-batch",
account_id=self.account.id,
@@ -206,20 +226,30 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
try:
task = verify_session.get(DouyuTask, task_id)
checked_login = verify_session.get(LoginTask, login_task_id)
self.assertEqual(task.status, "failed")
self.assertIn("Cookie 已失效,请重新登录", task.message)
self.assertEqual(checked_login.ck_check_status, "invalid")
self.assertEqual(checked_login.ck_check_result["message"], "鱼丸接口: 未登录;等级接口: 未登录")
assert task.status == "failed"
assert "Cookie 已失效,请重新登录" in task.message
assert checked_login.ck_check_status == "invalid"
assert (
checked_login.ck_check_result["message"]
== "鱼丸接口: 未登录;等级接口: 未登录"
)
finally:
verify_session.close()
def test_supplier_callback_verifies_signature_and_updates_terminal_task(self):
self.task.supplier_out_order_id = f"DYGFBATCHT{self.task.id}"
self.task.result = {"recharge_channel": "supplier_api", "out_order_id": self.task.supplier_out_order_id}
self.task.result = {
"recharge_channel": "supplier_api",
"out_order_id": self.task.supplier_out_order_id,
}
self.session.commit()
client = FishFinRechargeClient(FishFinRechargeConfig(
base_url="https://supplier.example", app_id="test-app", app_secret="test-secret",
))
client = FishFinRechargeClient(
FishFinRechargeConfig(
base_url="https://supplier.example",
app_id="test-app",
app_secret="test-secret",
)
)
payload = {
"app_id": "test-app",
"timestamp": 1786629000,
@@ -235,25 +265,25 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
response = asyncio.run(supplier_recharge_callback(request, self.session))
self.assertEqual(response["code"], 200)
self.assertEqual(response["message"], "success")
self.assertTrue(client.verify_response_sign(response, "POST"))
assert response["code"] == 200
assert response["message"] == "success"
assert client.verify_response_sign(response, "POST")
self.session.refresh(self.task)
self.assertEqual(self.task.status, "success")
self.assertEqual(self.task.message, "供应商直充成功(异步通知)")
assert self.task.status == "success"
assert self.task.message == "供应商直充成功(异步通知)"
self.session.refresh(self.account)
self.assertEqual(self.account.bind_status, "gold_recharged")
self.assertEqual(self.task.result["order_id"], "supplier-001")
self.assertEqual(self.task.result["supplier_order_status"], 2)
audit = self.session.query(AuditLog).filter(AuditLog.action == "recharge:douyu:callback").one()
self.assertEqual(audit.username, "supplier")
self.assertTrue(audit.success)
self.assertNotIn("sign", audit.detail)
assert self.account.bind_status == "gold_recharged"
assert self.task.result["order_id"] == "supplier-001"
assert self.task.result["supplier_order_status"] == 2
audit = (
self.session.query(AuditLog)
.filter(AuditLog.action == "recharge:douyu:callback")
.one()
)
assert audit.username == "supplier"
assert audit.success
assert "sign" not in audit.detail
# 同一终态回调可重复投递,不会改写已结束任务。
asyncio.run(supplier_recharge_callback(request, self.session))
self.session.refresh(self.task)
self.assertEqual(self.task.status, "success")
if __name__ == "__main__":
unittest.main()
assert self.task.status == "success"
+40 -34
View File
@@ -1,4 +1,3 @@
import unittest
from types import SimpleNamespace
from unittest.mock import Mock
@@ -6,61 +5,68 @@ from core.douyu.login import DouyuLogin
from core.douyu.login_api_wgapi import WgapiLoginAPI
class MobileBindSkipTests(unittest.TestCase):
def setUp(self):
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 = SimpleNamespace(
username="test-user", password="test-password"
)
self.login._request_json = Mock()
def test_second_login_skips_mobile_bind_with_unique_key(self):
self.login._request_json.side_effect = [
{
'error': 130014,
'msg': '需要进行手机号绑定',
'data': {'mobileBind': {'uniqueKey': 'one-time-key'}},
"error": 130014,
"msg": "需要进行手机号绑定",
"data": {"mobileBind": {"uniqueKey": "one-time-key"}},
},
{
'error': 0,
'data': {'url': '//www.douyu.com/api/passport/login?code=callback-code'},
"error": 0,
"data": {
"url": "//www.douyu.com/api/passport/login?code=callback-code"
},
},
]
next_step, login_url = self.login._second_login(
'gt', 'challenge', 'validate', 'validate|jordan', 'code-token',
"gt",
"challenge",
"validate",
"validate|jordan",
"code-token",
)
self.assertEqual(next_step, 'mobile_bind_skip')
self.assertEqual(
login_url,
'https://www.douyu.com/api/passport/login?code=callback-code',
assert next_step == "mobile_bind_skip"
assert (
login_url == "https://www.douyu.com/api/passport/login?code=callback-code"
)
self.assertEqual(self.login._request_json.call_count, 2)
assert self.login._request_json.call_count == 2
skip_call = self.login._request_json.call_args_list[1]
self.assertEqual(skip_call.args[:3], (
'post',
'https://passport.douyu.com/wgapi/member/passport/login',
'跳过手机号绑定接口',
))
self.assertEqual(skip_call.kwargs['data'], {
'type': '3',
'uniqueKey': 'one-time-key',
'biz_type': '1',
})
assert skip_call.args[:3] == (
"post",
"https://passport.douyu.com/wgapi/member/passport/login",
"跳过手机号绑定接口",
)
assert skip_call.kwargs["data"] == {
"type": "3",
"uniqueKey": "one-time-key",
"biz_type": "1",
}
def test_second_login_preserves_remote_email_flow_without_unique_key(self):
self.login._request_json.return_value = {
'error': 130014,
'data': {'remoteLogin': {'code': 'remote-code'}},
"error": 130014,
"data": {"remoteLogin": {"code": "remote-code"}},
}
next_step, remote_code = self.login._second_login(
'gt', 'challenge', 'validate', 'validate|jordan', 'code-token',
"gt",
"challenge",
"validate",
"validate|jordan",
"code-token",
)
self.assertEqual((next_step, remote_code), ('remote_email', 'remote-code'))
self.assertEqual(self.login._request_json.call_count, 1)
if __name__ == '__main__':
unittest.main()
assert (next_step, remote_code) == ("remote_email", "remote-code")
assert self.login._request_json.call_count == 1
+58 -42
View File
@@ -1,11 +1,13 @@
"""斗鱼任务代理接入三模式行为测试(静态 / API / 关闭)+ 写读分离。"""
import unittest
from unittest.mock import Mock
from core.douyu.activity_client import DouyuActivityClient
from web.backend.services.douyu_runner import DouyuBatchRunner
from web.backend.services.douyu_runner_core import DouyuBatchRunnerCore, DOUYU_PROXY_TASK_TYPES
from web.backend.services.douyu_runner_core import (
DouyuBatchRunnerCore,
DOUYU_PROXY_TASK_TYPES,
)
from web.backend.models import ProxyConfig
@@ -36,90 +38,104 @@ def _runner(db_cfg: ProxyConfig, task_type: str = "query_points"):
return runner
class DouyuProxyModeTests(unittest.TestCase):
class TestDouyuProxyMode:
def test_disabled_mode_goes_direct(self):
runner = _runner(_cfg(enabled=False), task_type="exchange_goods")
self.assertIsNone(runner._proxies_for_task())
self.assertIsNone(runner._proxy_fetcher)
assert runner._proxies_for_task() is None
assert runner._proxy_fetcher is None
client = runner._client("acf_uid=100")
self.assertEqual(client.session.proxies, {})
assert client.session.proxies == {}
# 未开启代理时 execute 客户端不带 proxies 参数
self.assertIsInstance(client, DouyuActivityClient)
assert isinstance(client, DouyuActivityClient)
def test_static_mode_uses_configured_proxies_for_write_tasks(self):
runner = _runner(_cfg(enabled=True, http="http://10.0.0.1:8080"), task_type="exchange_goods")
runner = _runner(
_cfg(enabled=True, http="http://10.0.0.1:8080"), task_type="exchange_goods"
)
proxies = runner._proxies_for_task()
self.assertEqual(proxies, {"http": "http://10.0.0.1:8080", "https": "http://10.0.0.1:8080"})
assert proxies == {
"http": "http://10.0.0.1:8080",
"https": "http://10.0.0.1:8080",
}
client = runner._client("acf_uid=100")
self.assertEqual(client.session.proxies["https"], "http://10.0.0.1:8080")
assert client.session.proxies["https"] == "http://10.0.0.1:8080"
# 每次任务都取同一静态代理
self.assertEqual(runner._proxies_for_task(), proxies)
assert runner._proxies_for_task() == proxies
def test_api_mode_fetches_new_proxy_per_write_task(self):
cfg = _cfg(enabled=True, api_url="https://proxy-api.example/fetch")
runner = _runner(cfg, task_type="exchange_goods")
self.assertIsNotNone(runner._proxy_fetcher)
assert runner._proxy_fetcher is not None
fake = Mock()
fake.fetch_new_proxy.return_value = "http://10.0.0.2:3128"
runner._proxy_fetcher = fake
proxies = runner._proxies_for_task()
self.assertEqual(proxies, {"http": "http://10.0.0.2:3128", "https": "http://10.0.0.2:3128"})
assert proxies == {
"http": "http://10.0.0.2:3128",
"https": "http://10.0.0.2:3128",
}
client = runner._client("acf_uid=100")
self.assertEqual(client.session.proxies["http"], "http://10.0.0.2:3128")
assert client.session.proxies["http"] == "http://10.0.0.2:3128"
# 每任务取新代理:_client 也会再 fetch 一次(客户端构造即取代理)
fake.fetch_new_proxy.return_value = "http://10.0.0.3:3128"
self.assertEqual(runner._proxies_for_task()["http"], "http://10.0.0.3:3128")
self.assertEqual(fake.fetch_new_proxy.call_count, 3)
assert runner._proxies_for_task()["http"] == "http://10.0.0.3:3128"
assert fake.fetch_new_proxy.call_count == 3
def test_api_mode_failure_falls_back_to_direct(self):
runner = _runner(_cfg(enabled=True, api_url="https://proxy-api.example/fetch"), task_type="exchange_goods")
runner = _runner(
_cfg(enabled=True, api_url="https://proxy-api.example/fetch"),
task_type="exchange_goods",
)
runner._proxy_fetcher = Mock()
runner._proxy_fetcher.fetch_new_proxy.return_value = None
runner._push_log = Mock()
self.assertIsNone(runner._proxies_for_task())
assert runner._proxies_for_task() is None
client = runner._client("acf_uid=100")
self.assertEqual(client.session.proxies, {})
assert client.session.proxies == {}
runner._push_log.assert_called()
def test_read_tasks_stay_direct_even_with_proxy_enabled(self):
"""写读分离:查询/刷新/绑定等读任务即使代理开启也直连,不消耗代理额度。"""
runner = _runner(_cfg(enabled=True, http="http://10.0.0.1:8080"), task_type="query_points")
self.assertIsNotNone(runner._static_proxies)
self.assertIsNone(runner._proxies_for_task())
runner = _runner(
_cfg(enabled=True, http="http://10.0.0.1:8080"), task_type="query_points"
)
assert runner._static_proxies is not None
assert runner._proxies_for_task() is None
client = runner._client("acf_uid=100")
self.assertEqual(client.session.proxies, {})
assert client.session.proxies == {}
# API 模式同理:读任务不触发 fetch
runner = _runner(_cfg(enabled=True, api_url="https://proxy-api.example/fetch"), task_type="query_exchange_records")
runner = _runner(
_cfg(enabled=True, api_url="https://proxy-api.example/fetch"),
task_type="query_exchange_records",
)
runner._proxy_fetcher = Mock()
runner._proxy_fetcher.fetch_new_proxy.side_effect = AssertionError("读任务不应取代理")
self.assertIsNone(runner._proxies_for_task())
runner._proxy_fetcher.fetch_new_proxy.side_effect = AssertionError(
"读任务不应取代理"
)
assert runner._proxies_for_task() is None
def test_proxy_task_types_covers_all_write_tasks(self):
"""白名单集合应覆盖全部会消耗/下单的写操作任务,且不含查询类。"""
self.assertIn("exchange_goods", DOUYU_PROXY_TASK_TYPES)
self.assertIn("lock_goods", DOUYU_PROXY_TASK_TYPES)
self.assertIn("pay_locked_order", DOUYU_PROXY_TASK_TYPES)
self.assertIn("exchange_esports_goods", DOUYU_PROXY_TASK_TYPES)
self.assertIn("exchange_xpd_goods", DOUYU_PROXY_TASK_TYPES)
self.assertIn("create_gold_qr", DOUYU_PROXY_TASK_TYPES)
self.assertIn("donate_elite_gift", DOUYU_PROXY_TASK_TYPES)
self.assertNotIn("query_points", DOUYU_PROXY_TASK_TYPES)
self.assertNotIn("refresh_goods", DOUYU_PROXY_TASK_TYPES)
self.assertNotIn("get_bind_qr", DOUYU_PROXY_TASK_TYPES)
assert "exchange_goods" in DOUYU_PROXY_TASK_TYPES
assert "lock_goods" in DOUYU_PROXY_TASK_TYPES
assert "pay_locked_order" in DOUYU_PROXY_TASK_TYPES
assert "exchange_esports_goods" in DOUYU_PROXY_TASK_TYPES
assert "exchange_xpd_goods" in DOUYU_PROXY_TASK_TYPES
assert "create_gold_qr" in DOUYU_PROXY_TASK_TYPES
assert "donate_elite_gift" in DOUYU_PROXY_TASK_TYPES
assert "query_points" not in DOUYU_PROXY_TASK_TYPES
assert "refresh_goods" not in DOUYU_PROXY_TASK_TYPES
assert "get_bind_qr" not in DOUYU_PROXY_TASK_TYPES
def test_activity_client_accepts_proxies_at_session_level(self):
client = DouyuActivityClient(
"acf_uid=100",
proxies={"http": "http://10.0.0.9:7890", "https": "http://10.0.0.9:7890"},
)
self.assertEqual(client.session.proxies["http"], "http://10.0.0.9:7890")
assert client.session.proxies["http"] == "http://10.0.0.9:7890"
# trust_env=False 维持不变:不受环境变量代理影响
self.assertFalse(client.session.trust_env)
if __name__ == "__main__":
unittest.main()
assert not client.session.trust_env
+105 -46
View File
@@ -1,19 +1,30 @@
import unittest
import pytest
from types import SimpleNamespace
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from web.backend.database import Base
from web.backend.models import Account, DouyuTask, DouyuWorkbench, DouyuWorkbenchAccount, LoginTask, User
from web.backend.models import (
Account,
DouyuTask,
DouyuWorkbench,
DouyuWorkbenchAccount,
LoginTask,
User,
)
from web.backend.routers.accounts import delete_account
from web.backend.routers.douyu import list_tasks, list_workbench_accounts, update_workbench_accounts
from web.backend.routers.douyu import (
list_tasks,
list_workbench_accounts,
update_workbench_accounts,
)
from web.backend.schemas import DouyuWorkbenchAccountsUpdate
from web.backend.services.douyu_service import create_douyu_planned_tasks
class DouyuWorkbenchScopeTests(unittest.TestCase):
def setUp(self):
class TestDouyuWorkbenchScope:
def setup_method(self):
self.engine = create_engine("sqlite://")
@event.listens_for(self.engine, "connect")
@@ -25,65 +36,113 @@ class DouyuWorkbenchScopeTests(unittest.TestCase):
self.user = User(username="operator", password_hash="hash", role="super_admin")
self.session.add(self.user)
self.session.commit()
self.account = Account(username="douyu-user", password="password", email="mail@example.com", email_password="mail-password")
self.account = Account(
username="douyu-user",
password="password",
email="mail@example.com",
email_password="mail-password",
)
self.session.add(self.account)
self.session.commit()
self.session.add(LoginTask(batch_id="login", account_id=self.account.id, created_by=self.user.id, status="success", cookie="acf_uid=1"))
self.session.add(
LoginTask(
batch_id="login",
account_id=self.account.id,
created_by=self.user.id,
status="success",
cookie="acf_uid=1",
)
)
self.session.commit()
self.current = SimpleNamespace(id=self.user.id, username=self.user.username, role=self.user.role, custom_permissions=None)
self.current = SimpleNamespace(
id=self.user.id,
username=self.user.username,
role=self.user.role,
custom_permissions=None,
)
def tearDown(self):
def teardown_method(self):
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
def test_workbench_accounts_are_per_user_and_scope(self):
update_workbench_accounts(
DouyuWorkbenchAccountsUpdate(handbook_scope="elite", account_ids=[self.account.id]),
DouyuWorkbenchAccountsUpdate(
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)
self.assertEqual(elite["account_ids"], [self.account.id])
self.assertEqual(esports["account_ids"], [])
self.assertTrue(elite["configured"])
self.assertFalse(esports["configured"])
self.assertEqual(self.session.query(DouyuWorkbenchAccount).count(), 1)
esports = list_workbench_accounts(
"esports", db=self.session, current=self.current
)
assert elite["account_ids"] == [self.account.id]
assert esports["account_ids"] == []
assert elite["configured"]
assert not esports["configured"]
assert self.session.query(DouyuWorkbenchAccount).count() == 1
update_workbench_accounts(
DouyuWorkbenchAccountsUpdate(handbook_scope="elite", account_ids=[]),
db=self.session,
current=self.current,
)
empty_elite = list_workbench_accounts("elite", db=self.session, current=self.current)
self.assertEqual(empty_elite["account_ids"], [])
self.assertTrue(empty_elite["configured"])
self.assertEqual(self.session.query(DouyuWorkbench).count(), 1)
empty_elite = list_workbench_accounts(
"elite", db=self.session, current=self.current
)
assert empty_elite["account_ids"] == []
assert empty_elite["configured"]
assert self.session.query(DouyuWorkbench).count() == 1
def test_task_scope_is_persisted_and_filters_shared_task_types(self):
elite_batch, _ = create_douyu_planned_tasks(
self.session, [self.account.id], "create_gold_qr", "elite", self.user.id,
self.session,
[self.account.id],
"create_gold_qr",
"elite",
self.user.id,
)
esports_batch, _ = create_douyu_planned_tasks(
self.session, [self.account.id], "create_gold_qr", "esports", self.user.id,
self.session,
[self.account.id],
"create_gold_qr",
"esports",
self.user.id,
)
elite = list_tasks(
batch_id=None, handbook_scope="elite", include_detail=False, page=None, page_size=100,
db=self.session, current=self.current,
batch_id=None,
handbook_scope="elite",
include_detail=False,
page=None,
page_size=100,
db=self.session,
current=self.current,
)
esports = list_tasks(
batch_id=None, handbook_scope="esports", include_detail=False, page=None, page_size=100,
db=self.session, current=self.current,
batch_id=None,
handbook_scope="esports",
include_detail=False,
page=None,
page_size=100,
db=self.session,
current=self.current,
)
self.assertEqual({item.batch_id for item in elite}, {elite_batch})
self.assertEqual({item.batch_id for item in esports}, {esports_batch})
self.assertEqual({task.handbook_scope for task in self.session.query(DouyuTask).all()}, {"elite", "esports"})
assert {item.batch_id for item in elite} == {elite_batch}
assert {item.batch_id for item in esports} == {esports_batch}
assert {
task.handbook_scope for task in self.session.query(DouyuTask).all()
} == {"elite", "esports"}
def test_rejects_task_from_wrong_workbench(self):
with self.assertRaisesRegex(ValueError, "不属于当前工作台"):
with pytest.raises(ValueError, match="不属于当前工作台"):
create_douyu_planned_tasks(
self.session, [self.account.id], "get_xpd_bind_qr", "elite", self.user.id,
self.session,
[self.account.id],
"get_xpd_bind_qr",
"elite",
self.user.id,
)
def test_elite_workbench_accepts_lock_goods_task(self):
@@ -96,10 +155,12 @@ class DouyuWorkbenchScopeTests(unittest.TestCase):
{"commodity_id": "goods-1"},
)
task = self.session.query(DouyuTask).filter(DouyuTask.batch_id == batch_id).one()
self.assertEqual(count, 1)
self.assertEqual(task.task_type, "lock_goods")
self.assertEqual(task.result, {"payload": {"commodity_id": "goods-1"}})
task = (
self.session.query(DouyuTask).filter(DouyuTask.batch_id == batch_id).one()
)
assert count == 1
assert task.task_type == "lock_goods"
assert task.result == {"payload": {"commodity_id": "goods-1"}}
_, pay_count = create_douyu_planned_tasks(
self.session,
@@ -109,20 +170,18 @@ class DouyuWorkbenchScopeTests(unittest.TestCase):
self.user.id,
{"order_id": "5874", "commodity_id": "goods-1"},
)
self.assertEqual(pay_count, 1)
assert pay_count == 1
def test_account_deletion_removes_workbench_membership(self):
self.session.add(DouyuWorkbenchAccount(
user_id=self.user.id,
handbook_scope="elite",
account_id=self.account.id,
))
self.session.add(
DouyuWorkbenchAccount(
user_id=self.user.id,
handbook_scope="elite",
account_id=self.account.id,
)
)
self.session.commit()
delete_account(self.account.id, db=self.session, current=self.current)
self.assertEqual(self.session.query(DouyuWorkbenchAccount).count(), 0)
if __name__ == "__main__":
unittest.main()
assert self.session.query(DouyuWorkbenchAccount).count() == 0
+194 -143
View File
@@ -1,202 +1,253 @@
import pytest
import json
import re
import unittest
from types import SimpleNamespace
from unittest.mock import Mock
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
class XpdExchangeTests(unittest.TestCase):
def setUp(self):
class TestXpdExchange:
def setup_method(self):
self.client = DouyuActivityClient(
'acf_uid=100; acf_stk=stk; acf_ltkid=ltkid',
"acf_uid=100; acf_stk=stk; acf_ltkid=ltkid",
)
def test_exchange_uses_fresh_embed_authorization_and_point_price(self):
self.client._request = Mock(return_value=SimpleNamespace(
text='var buyInfo={"ret":"0","msg":"ok","serial":"order-1","newBalance":88};',
))
self.client._request = Mock(
return_value=SimpleNamespace(
text='var buyInfo={"ret":"0","msg":"ok","serial":"order-1","newBalance":88};',
)
)
result = self.client.xpd_exchange_goods(
embed_query={
'gameId': 'cjm', 'actId': '46195', 'appId': 'bp_cf',
'livePlatId': 'douyu', 'code': 'fresh-code',
'timestamp': '123', 'v': '2.0', 'sig': 'fresh-signature',
"gameId": "cjm",
"actId": "46195",
"appId": "bp_cf",
"livePlatId": "douyu",
"code": "fresh-code",
"timestamp": "123",
"v": "2.0",
"sig": "fresh-signature",
},
act_id='46195',
rid='9263298',
commodity_id='goods-1',
act_id="46195",
rid="9263298",
commodity_id="goods-1",
price=1200,
picture='https://cdn.example/item.jpg',
picture="https://cdn.example/item.jpg",
pay_type=1,
action_id='46195',
action_id="46195",
)
self.assertEqual(result['serial'], 'order-1')
self.assertEqual(result['new_balance'], 88)
assert result["serial"] == "order-1"
assert result["new_balance"] == 88
call = self.client._request.call_args
self.assertEqual(call.args[:2], ('get', self.client.XPD_LIVE_BUY_API))
params = call.kwargs['params']
self.assertEqual(params['code'], 'fresh-code')
self.assertEqual(params['sig'], 'fresh-signature')
self.assertEqual(params['propid'], 'goods-1')
self.assertEqual(params['paytype'], '1')
self.assertEqual(json.loads(params['appext']), {
'user_price': 1200,
'sGoodsPic': 'https://cdn.example/item.jpg',
'paytype': 1,
})
assert call.args[:2] == ("get", self.client.XPD_LIVE_BUY_API)
params = call.kwargs["params"]
assert params["code"] == "fresh-code"
assert params["sig"] == "fresh-signature"
assert params["propid"] == "goods-1"
assert params["paytype"] == "1"
assert json.loads(params["appext"]) == {
"user_price": 1200,
"sGoodsPic": "https://cdn.example/item.jpg",
"paytype": 1,
}
def test_role_and_balance_preserve_ios_platform_zero(self):
self.client._request = Mock(return_value=SimpleNamespace(
text=(
'var info={"gameOpenId":"openid-ios","roleId":"role-ios",'
'"roleName":"iOS角色","type":"wx","platId":0,"area":1};'
),
))
role = self.client.xpd_get_role(embed_query={}, act_id='46195', rid='9263298')
self.assertEqual(role['plat_id'], '0')
self.assertEqual(role['area'], '1')
self.client._request = Mock(return_value=SimpleNamespace(
text='var banlanceInfo={"ret":0,"balance":2199};',
))
result = self.client.xpd_balance(
embed_query={}, act_id='46195', openid='openid-ios',
roleid='role-ios', plat='0', areaid='1',
self.client._request = Mock(
return_value=SimpleNamespace(
text=(
'var info={"gameOpenId":"openid-ios","roleId":"role-ios",'
'"roleName":"iOS角色","type":"wx","platId":0,"area":1};'
),
)
)
params = self.client._request.call_args.kwargs['params']
self.assertEqual(params['plat'], '0')
self.assertTrue(re.fullmatch(r'\d{13}', params['_time']))
self.assertEqual(result['balance'], 2199)
role = self.client.xpd_get_role(embed_query={}, act_id="46195", rid="9263298")
assert role["plat_id"] == "0"
assert role["area"] == "1"
self.client._request = Mock(
return_value=SimpleNamespace(
text='var banlanceInfo={"ret":0,"balance":2199};',
)
)
result = self.client.xpd_balance(
embed_query={},
act_id="46195",
openid="openid-ios",
roleid="role-ios",
plat="0",
areaid="1",
)
params = self.client._request.call_args.kwargs["params"]
assert params["plat"] == "0"
assert re.fullmatch(r"\d{13}", params["_time"])
assert result["balance"] == 2199
def test_exchange_surfaces_daoju_failure_message(self):
self.client._request = Mock(return_value=SimpleNamespace(
text='var buyInfo={"ret":"-1","msg":"点券不足"};',
))
self.client._request = Mock(
return_value=SimpleNamespace(
text='var buyInfo={"ret":"-1","msg":"点券不足"};',
)
)
with self.assertRaisesRegex(DouyuActivityError, '点券不足'):
with pytest.raises(DouyuActivityError, match="点券不足"):
self.client.xpd_exchange_goods(
embed_query={'code': 'fresh-code'},
act_id='46195',
rid='9263298',
commodity_id='goods-1',
embed_query={"code": "fresh-code"},
act_id="46195",
rid="9263298",
commodity_id="goods-1",
price=1200,
)
def test_list_goods_requests_and_preserves_inventory(self):
self.client._request = Mock(return_value=SimpleNamespace(
text=(
'var recommend={"data":{"client_data":{"itemsdetail":[{'
'"iGoodsId":"goods-1","sGoodsName":"测试商品","iPrice":"6800",'
'"iJb2Price":"0","iGoodsLeft":"0"}]}}};'
),
))
result = self.client.xpd_list_goods(
embed_query={}, act_id='46195', openid='openid', roleid='roleid',
self.client._request = Mock(
return_value=SimpleNamespace(
text=(
'var recommend={"data":{"client_data":{"itemsdetail":[{'
'"iGoodsId":"goods-1","sGoodsName":"测试商品","iPrice":"6800",'
'"iJb2Price":"0","iGoodsLeft":"0"}]}}};'
),
)
)
self.assertEqual(result['goods'][0]['price'], 6800)
self.assertEqual(result['goods'][0]['goods_left'], 0)
params = self.client._request.call_args.kwargs['params']
self.assertEqual(params['excludeFields'], 'iCategoryId_76')
self.assertNotIn('category', params)
result = self.client.xpd_list_goods(
embed_query={},
act_id="46195",
openid="openid",
roleid="roleid",
)
assert result["goods"][0]["price"] == 6800
assert result["goods"][0]["goods_left"] == 0
params = self.client._request.call_args.kwargs["params"]
assert params["excludeFields"] == "iCategoryId_76"
assert "category" not in params
def test_list_goods_fetches_every_page_and_deduplicates_goods(self):
first_page = [
{'iGoodsId': f'goods-{index}', 'sGoodsName': f'商品 {index}'}
{"iGoodsId": f"goods-{index}", "sGoodsName": f"商品 {index}"}
for index in range(10)
]
second_page = [
{'iGoodsId': 'goods-9', 'sGoodsName': '重复商品'},
{'iGoodsId': 'goods-10', 'sGoodsName': '商品 10'},
{"iGoodsId": "goods-9", "sGoodsName": "重复商品"},
{"iGoodsId": "goods-10", "sGoodsName": "商品 10"},
]
self.client._request = Mock(side_effect=[
SimpleNamespace(text=f'var recommend={{"errcode":0,"data":{{"client_data":{{"itemsdetail":{json.dumps(first_page)}}}}}}};'),
SimpleNamespace(text=f'var recommend={{"errcode":0,"data":{{"client_data":{{"itemsdetail":{json.dumps(second_page)}}}}}}};'),
])
result = self.client.xpd_list_goods(
embed_query={}, act_id='46195', openid='openid', roleid='roleid',
self.client._request = Mock(
side_effect=[
SimpleNamespace(
text=f'var recommend={{"errcode":0,"data":{{"client_data":{{"itemsdetail":{json.dumps(first_page)}}}}}}};'
),
SimpleNamespace(
text=f'var recommend={{"errcode":0,"data":{{"client_data":{{"itemsdetail":{json.dumps(second_page)}}}}}}};'
),
]
)
self.assertEqual(len(result['goods']), 11)
self.assertEqual([item['commodity_id'] for item in result['goods']][-1], 'goods-10')
self.assertEqual(self.client._request.call_count, 2)
first_params = self.client._request.call_args_list[0].kwargs['params']
second_params = self.client._request.call_args_list[1].kwargs['params']
self.assertEqual((first_params['page_begin'], first_params['page_num']), ('0', '1'))
self.assertEqual((second_params['page_begin'], second_params['page_num']), ('10', '2'))
result = self.client.xpd_list_goods(
embed_query={},
act_id="46195",
openid="openid",
roleid="roleid",
)
assert len(result["goods"]) == 11
assert [item["commodity_id"] for item in result["goods"]][-1] == "goods-10"
assert self.client._request.call_count == 2
first_params = self.client._request.call_args_list[0].kwargs["params"]
second_params = self.client._request.call_args_list[1].kwargs["params"]
assert (first_params["page_begin"], first_params["page_num"]) == ("0", "1")
assert (second_params["page_begin"], second_params["page_num"]) == ("10", "2")
def test_purchase_records_parses_order_list_and_paginates(self):
first_record = {
'sSerialNum': 'order-1',
'dtBuyTime': '2026-08-08 01:00:42',
'dtPayTime': '2026-08-08 01:00:55',
'iStatus': '3',
'iPrice': '800',
'iPayAmount': '800',
'sRoleName': '测试角色',
'sGoodsInfo': json.dumps({
'list': [{'sGoodsName': '改名卡', 'sGoodsPic': 'https://cdn.example/item.jpg'}],
}),
"sSerialNum": "order-1",
"dtBuyTime": "2026-08-08 01:00:42",
"dtPayTime": "2026-08-08 01:00:55",
"iStatus": "3",
"iPrice": "800",
"iPayAmount": "800",
"sRoleName": "测试角色",
"sGoodsInfo": json.dumps(
{
"list": [
{
"sGoodsName": "改名卡",
"sGoodsPic": "https://cdn.example/item.jpg",
}
],
}
),
}
self.client._request = Mock(side_effect=[
SimpleNamespace(text=f'var info={{"ret":"0","msg":"ok","data":{{"cnt":11,"list":{json.dumps([first_record])}}}}};'),
SimpleNamespace(text='var info={"ret":"0","msg":"ok","data":{"cnt":11,"list":[]}};'),
])
self.client._request = Mock(
side_effect=[
SimpleNamespace(
text=f'var info={{"ret":"0","msg":"ok","data":{{"cnt":11,"list":{json.dumps([first_record])}}}}};'
),
SimpleNamespace(
text='var info={"ret":"0","msg":"ok","data":{"cnt":11,"list":[]}};'
),
]
)
result = self.client.xpd_purchase_records(
embed_query={
'gameId': 'cjm', 'actId': '18882', 'appId': 'bp_cf',
'livePlatId': 'douyu', 'code': 'fresh-code',
'timestamp': '123', 'v': '2.0', 'sig': 'fresh-signature',
"gameId": "cjm",
"actId": "18882",
"appId": "bp_cf",
"livePlatId": "douyu",
"code": "fresh-code",
"timestamp": "123",
"v": "2.0",
"sig": "fresh-signature",
},
act_id='46195',
act_id="46195",
page_size=1,
)
self.assertEqual(result['total'], 11)
self.assertEqual(result['records'][0]['goods_name'], '改名卡')
self.assertEqual(result['records'][0]['serial'], 'order-1')
self.assertEqual(self.client._request.call_count, 2)
params = self.client._request.call_args_list[0].kwargs['params']
self.assertEqual(params['_service'], 'order.list')
self.assertEqual(params['pn'], '1')
self.assertEqual(params['code'], 'fresh-code')
assert result["total"] == 11
assert result["records"][0]["goods_name"] == "改名卡"
assert result["records"][0]["serial"] == "order-1"
assert self.client._request.call_count == 2
params = self.client._request.call_args_list[0].kwargs["params"]
assert params["_service"] == "order.list"
assert params["pn"] == "1"
assert params["code"] == "fresh-code"
def test_exchange_keeps_raw_price_from_goods_list(self):
self.client._request = Mock(side_effect=[
SimpleNamespace(
text='var recommend={"errcode":0,"data":{"client_data":{"itemsdetail":[{'
'"iGoodsId":"goods-1","sGoodsName":"测试商品","iPrice":"6800",'
'"iJb2Price":"0","iGoodsLeft":"-1"}]}}};',
),
SimpleNamespace(text='var buyInfo={"ret":"0","msg":"ok","serial":"order-1","newBalance":88};'),
])
goods = self.client.xpd_list_goods(
embed_query={}, act_id='46195', openid='openid', roleid='roleid',
)['goods']
result = self.client.xpd_exchange_goods(
embed_query={'code': 'fresh-code'},
act_id='46195',
rid='9263298',
commodity_id=goods[0]['commodity_id'],
price=goods[0]['price'],
picture='',
pay_type=1,
action_id='46195',
self.client._request = Mock(
side_effect=[
SimpleNamespace(
text='var recommend={"errcode":0,"data":{"client_data":{"itemsdetail":[{'
'"iGoodsId":"goods-1","sGoodsName":"测试商品","iPrice":"6800",'
'"iJb2Price":"0","iGoodsLeft":"-1"}]}}};',
),
SimpleNamespace(
text='var buyInfo={"ret":"0","msg":"ok","serial":"order-1","newBalance":88};'
),
]
)
self.assertEqual(goods[0]['price'], 6800)
self.assertEqual(result['serial'], 'order-1')
params = self.client._request.call_args.kwargs['params']
self.assertEqual(json.loads(params['appext'])['user_price'], 6800)
goods = self.client.xpd_list_goods(
embed_query={},
act_id="46195",
openid="openid",
roleid="roleid",
)["goods"]
result = self.client.xpd_exchange_goods(
embed_query={"code": "fresh-code"},
act_id="46195",
rid="9263298",
commodity_id=goods[0]["commodity_id"],
price=goods[0]["price"],
picture="",
pay_type=1,
action_id="46195",
)
if __name__ == '__main__':
unittest.main()
assert goods[0]["price"] == 6800
assert result["serial"] == "order-1"
params = self.client._request.call_args.kwargs["params"]
assert json.loads(params["appext"])["user_price"] == 6800
+103 -67
View File
@@ -1,6 +1,7 @@
"""鱼翅直充供应商 API 客户端测试。"""
import unittest
import pytest
from unittest.mock import Mock
from core.douyu.recharge_api import (
@@ -10,8 +11,8 @@ from core.douyu.recharge_api import (
)
class FishFinRechargeClientTests(unittest.TestCase):
def setUp(self):
class TestFishFinRechargeClient:
def setup_method(self):
self.config = FishFinRechargeConfig(
base_url="https://supplier.example/",
app_id="15945681",
@@ -21,18 +22,21 @@ class FishFinRechargeClientTests(unittest.TestCase):
self.client = FishFinRechargeClient(self.config, session=self.session)
def test_sign_matches_document_example(self):
sign = self.client.sign({
"app_id": "15945681 ",
"timestamp": 1740916504,
"charge_account": "18888888888",
"buy_num": 2,
"customer_price": 1.21,
"customer_order_no": "DD202507010010208888",
"product_id": "66668888",
"empty": " \n",
"sign": "ignored",
}, "POST")
self.assertEqual(sign, "4087a959f3488ecb13efe6ef58e3bc67")
sign = self.client.sign(
{
"app_id": "15945681 ",
"timestamp": 1740916504,
"charge_account": "18888888888",
"buy_num": 2,
"customer_price": 1.21,
"customer_order_no": "DD202507010010208888",
"product_id": "66668888",
"empty": " \n",
"sign": "ignored",
},
"POST",
)
assert sign == "4087a959f3488ecb13efe6ef58e3bc67"
def test_create_order_posts_new_document_payload(self):
response = Mock()
@@ -48,40 +52,52 @@ class FishFinRechargeClientTests(unittest.TestCase):
ext_arg={"skuid": 12},
)
self.assertEqual(result["code"], 200)
assert result["code"] == 200
kwargs = self.session.post.call_args.kwargs
self.assertEqual(self.session.post.call_args.args[0], "https://supplier.example/adapter-apiaccess/open/api/createOrderV2")
self.assertEqual(kwargs["json"]["pay_amount"], 1.2)
self.assertEqual(kwargs["json"]["out_order_id"], "OUT202503010010208888")
self.assertEqual(kwargs["json"]["order_type"], 0)
self.assertEqual(kwargs["json"]["recharge_arg"], '[{"templateName":"斗鱼账号","templateVal":"10001"}]')
self.assertEqual(kwargs["json"]["ext_arg"], '{"skuid":12}')
self.assertNotIn("charge_account", kwargs["json"])
self.assertNotIn("customer_price", kwargs["json"])
self.assertNotIn("customer_order_no", kwargs["json"])
self.assertEqual(kwargs["json"]["sign"], self.client.sign(kwargs["json"], "POST"))
self.assertEqual(kwargs["timeout"], (8, 20))
assert (
self.session.post.call_args.args[0]
== "https://supplier.example/adapter-apiaccess/open/api/createOrderV2"
)
assert kwargs["json"]["pay_amount"] == 1.2
assert kwargs["json"]["out_order_id"] == "OUT202503010010208888"
assert kwargs["json"]["order_type"] == 0
assert (
kwargs["json"]["recharge_arg"]
== '[{"templateName":"斗鱼账号","templateVal":"10001"}]'
)
assert kwargs["json"]["ext_arg"] == '{"skuid":12}'
assert "charge_account" not in kwargs["json"]
assert "customer_price" not in kwargs["json"]
assert "customer_order_no" not in kwargs["json"]
assert kwargs["json"]["sign"] == self.client.sign(kwargs["json"], "POST")
assert kwargs["timeout"] == (8, 20)
def test_create_order_omits_empty_optional_fields_and_serializes_integer_price(self):
def test_create_order_omits_empty_optional_fields_and_serializes_integer_price(
self,
):
response = Mock()
response.json.return_value = {"code": 200}
self.session.post.return_value = response
self.client.create_order(
buy_num=1, pay_amount="1.0",
out_order_id="merchant-001", product_id="111570",
buy_num=1,
pay_amount="1.0",
out_order_id="merchant-001",
product_id="111570",
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
)
body = self.session.post.call_args.kwargs["json"]
self.assertEqual(body["pay_amount"], 1)
self.assertNotIn("notify_url", body)
self.assertEqual(body["recharge_arg"], '[{"templateName":"斗鱼UID","templateVal":"10001"}]')
self.assertNotIn("ext_arg", body)
self.assertEqual(body["sign"], self.client.sign(body, "POST"))
assert body["pay_amount"] == 1
assert "notify_url" not in body
assert (
body["recharge_arg"] == '[{"templateName":"斗鱼UID","templateVal":"10001"}]'
)
assert "ext_arg" not in body
assert body["sign"] == self.client.sign(body, "POST")
def test_query_order_requires_identifier_and_uses_get_params(self):
with self.assertRaisesRegex(ValueError, "out_order_id"):
with pytest.raises(ValueError, match="out_order_id"):
self.client.query_order("")
response = Mock()
@@ -90,13 +106,18 @@ class FishFinRechargeClientTests(unittest.TestCase):
self.client.query_order("OUT202503010010208888")
kwargs = self.session.get.call_args.kwargs
self.assertEqual(self.session.get.call_args.args[0], "https://supplier.example/adapter-apiaccess/open/api/queryOrderV2")
self.assertEqual(kwargs["params"]["out_order_id"], "OUT202503010010208888")
self.assertEqual(kwargs["params"]["sign"], self.client.sign(kwargs["params"], "GET"))
assert (
self.session.get.call_args.args[0]
== "https://supplier.example/adapter-apiaccess/open/api/queryOrderV2"
)
assert kwargs["params"]["out_order_id"] == "OUT202503010010208888"
assert kwargs["params"]["sign"] == self.client.sign(kwargs["params"], "GET")
def test_missing_configuration_is_rejected_before_request(self):
with self.assertRaises(FishFinRechargeConfigError):
FishFinRechargeClient(FishFinRechargeConfig(base_url="", app_id="", app_secret=""))
with pytest.raises(FishFinRechargeConfigError):
FishFinRechargeClient(
FishFinRechargeConfig(base_url="", app_id="", app_secret="")
)
def test_trace_excludes_replayable_signature_and_nested_account_data(self):
response = Mock()
@@ -104,47 +125,62 @@ class FishFinRechargeClientTests(unittest.TestCase):
response.json.return_value = {"code": 200, "result": {"order_status": 0}}
self.session.post.return_value = response
events = []
client = FishFinRechargeClient(self.config, session=self.session, trace=events.append)
client = FishFinRechargeClient(
self.config, session=self.session, trace=events.append
)
client.create_order(
buy_num=1, pay_amount="1",
out_order_id="merchant-001", product_id="111570",
buy_num=1,
pay_amount="1",
out_order_id="merchant-001",
product_id="111570",
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
)
self.assertEqual([event["stage"] for event in events], ["request", "response"])
self.assertNotIn("sign", events[0]["params"])
self.assertNotIn("recharge_arg", events[0]["params"])
self.assertEqual(events[0]["params"]["pay_amount"], 1)
assert [event["stage"] for event in events] == ["request", "response"]
assert "sign" not in events[0]["params"]
assert "recharge_arg" not in events[0]["params"]
assert events[0]["params"]["pay_amount"] == 1
def test_debug_trace_includes_complete_request_and_response_bodies(self):
response = Mock()
response.status_code = 200
response.headers = {"Content-Type": "application/json", "X-Request-Id": "request-1"}
response.text = '{"code":1000,"message":"未传递支付金额","sign":"response-sign"}'
response.json.return_value = {"code": 1000, "message": "未传递支付金额", "sign": "response-sign"}
response.headers = {
"Content-Type": "application/json",
"X-Request-Id": "request-1",
}
response.text = (
'{"code":1000,"message":"未传递支付金额","sign":"response-sign"}'
)
response.json.return_value = {
"code": 1000,
"message": "未传递支付金额",
"sign": "response-sign",
}
self.session.post.return_value = response
events = []
config = FishFinRechargeConfig(
base_url=self.config.base_url, app_id=self.config.app_id,
app_secret=self.config.app_secret, debug=True,
base_url=self.config.base_url,
app_id=self.config.app_id,
app_secret=self.config.app_secret,
debug=True,
)
client = FishFinRechargeClient(
config, session=self.session, trace=events.append
)
client = FishFinRechargeClient(config, session=self.session, trace=events.append)
client.create_order(
buy_num=1, pay_amount="1",
out_order_id="merchant-001", product_id="111570",
buy_num=1,
pay_amount="1",
out_order_id="merchant-001",
product_id="111570",
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
)
self.assertEqual(events[0]["json_body"]["sign"], "<redacted>")
self.assertEqual(events[0]["json_body"]["pay_amount"], 1)
self.assertNotIn("sign", events[0]["sign_params"])
self.assertNotIn(self.config.app_secret, str(events[0]))
self.assertIn("sign_source_digest", events[0])
self.assertEqual(events[1]["response_body"]["sign"], "<redacted>")
self.assertNotIn(self.config.app_secret, str(events[1]))
if __name__ == "__main__":
unittest.main()
assert events[0]["json_body"]["sign"] == "<redacted>"
assert events[0]["json_body"]["pay_amount"] == 1
assert "sign" not in events[0]["sign_params"]
assert self.config.app_secret not in str(events[0])
assert "sign_source_digest" in events[0]
assert events[1]["response_body"]["sign"] == "<redacted>"
assert self.config.app_secret not in str(events[1])
+87 -63
View File
@@ -1,9 +1,10 @@
"""虎牙 App 密码登录及相关组件测试。"""
import pytest
import base64
import os
import struct
import unittest
from unittest.mock import patch, MagicMock
from sqlalchemy import create_engine
@@ -32,16 +33,18 @@ from core.huya.wup_encoder import build_password_login_wup
from web.backend.database import Base
from web.backend.models import User, HuyaAccount
from web.backend.schemas import HuyaAppPasswordLoginRequest, HuyaPasswordLoginSelectedRequest
from web.backend.schemas import (
HuyaAppPasswordLoginRequest,
HuyaPasswordLoginSelectedRequest,
)
from web.backend.routers.huya import (
app_password_login_account,
app_password_login_selected_accounts,
)
class TestHuyaAppLogin(unittest.TestCase):
def setUp(self):
class TestHuyaAppLogin:
def setup_method(self):
self.engine = create_engine("sqlite://")
Base.metadata.create_all(self.engine)
self.session = sessionmaker(bind=self.engine)()
@@ -49,7 +52,7 @@ class TestHuyaAppLogin(unittest.TestCase):
self.session.add(self.admin)
self.session.commit()
def tearDown(self):
def teardown_method(self):
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
@@ -59,54 +62,54 @@ class TestHuyaAppLogin(unittest.TestCase):
plain = b"Hello, Huya App Login Protocol!"
enc = udb_encrypt(key, plain)
dec = udb_decrypt(key, enc)
self.assertEqual(dec, plain)
assert dec == plain
def test_cert_and_nonce_forge(self):
uid = 1199666914671
rnd = gen_nonce(uid, K1_DEFAULT)
self.assertEqual(len(rnd), 20)
assert len(rnd) == 20
fp = b"02df398797432eadefcc12767119ad5e80999389"
cred = b"\x0a" + os.urandom(113)
p1 = build_p1(b"5008", fp, cred, rnd=rnd)
self.assertEqual(len(p1), 187)
assert len(p1) == 187
cert = forge_cert(p1)
dec = decrypt_cert(cert)
parsed = parse_p1(dec)
self.assertEqual(parsed["rnd"], rnd)
self.assertEqual(parsed["fingerprint"], fp)
self.assertEqual(parsed["cred"], cred)
assert parsed["rnd"] == rnd
assert parsed["fingerprint"] == fp
assert parsed["cred"] == cred
def test_envelope_patching(self):
env = Envelope.load()
self.assertGreater(env.uid, 0)
self.assertEqual(len(env.cert_b64), 260)
assert env.uid > 0
assert len(env.cert_b64) == 260
new_uid = 1199666911746
env.patch_uid(new_uid)
self.assertEqual(env.uid, new_uid)
assert env.uid == new_uid
fake_cert = bytes([0x0C, 0x20]) + os.urandom(192)
env.patch_cert(fake_cert)
self.assertEqual(len(env.cert_b64), 260)
assert len(env.cert_b64) == 260
wup_b64 = env.wup_b64()
self.assertGreater(len(wup_b64), 0)
assert len(wup_b64) > 0
def test_device_profile_generation(self):
p1 = generate_profile()
self.assertEqual(p1["os"], "android")
self.assertEqual(len(p1["fingerprint"]), 40)
self.assertEqual(len(p1["device_id"]), 40)
self.assertEqual(p1["hdid"], "ed0db8334cadd236c00cadf7e11ab5a5")
assert p1["os"] == "android"
assert len(p1["fingerprint"]) == 40
assert len(p1["device_id"]) == 40
assert p1["hdid"] == "ed0db8334cadd236c00cadf7e11ab5a5"
# 画像不再承载 safedeviceid:该令牌由 dfp_register 注册链每次登录前签发
self.assertNotIn("safedeviceid", p1)
self.assertNotIn("safedeviceid", DEFAULT_GOLDEN_DEV)
assert "safedeviceid" not in p1
assert "safedeviceid" not in DEFAULT_GOLDEN_DEV
p2 = get_profile("test_user_account_123")
p3 = get_profile("test_user_account_123")
self.assertEqual(p2["fingerprint"], p3["fingerprint"])
assert p2["fingerprint"] == p3["fingerprint"]
def test_wup_encoder_output(self):
dev = generate_profile()
@@ -121,18 +124,22 @@ class TestHuyaAppLogin(unittest.TestCase):
user_action_json="{}",
device_info=dev,
)
self.assertGreater(len(pkt), 500)
assert len(pkt) > 500
total_len = struct.unpack(">I", pkt[:4])[0]
self.assertEqual(total_len, len(pkt))
assert total_len == len(pkt)
# ---- 新设备注册链 (core/huya/dfp_register) 生产接入测试 ----
def test_wup_login_skips_registration_when_safedeviceid_given(self):
"""显式传入 safedeviceid 时不再触发注册链(风控重试复用同一注册结果)。"""
with patch("core.huya.app_login.register_device") as m_reg, \
patch("core.huya.app_login.build_password_login_wup", return_value=b"pkt"), \
patch("core.huya.app_login.requests.post",
return_value=MagicMock(status_code=200, content=b"")):
with (
patch("core.huya.app_login.register_device") as m_reg,
patch("core.huya.app_login.build_password_login_wup", return_value=b"pkt"),
patch(
"core.huya.app_login.requests.post",
return_value=MagicMock(status_code=200, content=b""),
),
):
wup_password_login_raw("300023887", "pw", safedeviceid="A" * 180)
m_reg.assert_not_called()
@@ -146,37 +153,48 @@ class TestHuyaAppLogin(unittest.TestCase):
captured["args"] = args
return b"pkt"
with patch("core.huya.app_login.register_device",
return_value=("a" * 32, new_action, new_device_id)) as m_reg, \
patch("core.huya.app_login.build_password_login_wup", side_effect=fake_build), \
patch("core.huya.app_login.requests.post",
return_value=MagicMock(status_code=200, content=b"")):
with (
patch(
"core.huya.app_login.register_device",
return_value=("a" * 32, new_action, new_device_id),
) as m_reg,
patch(
"core.huya.app_login.build_password_login_wup", side_effect=fake_build
),
patch(
"core.huya.app_login.requests.post",
return_value=MagicMock(status_code=200, content=b""),
),
):
wup_password_login_raw("300023887", "pw")
m_reg.assert_called_once()
# args: uid_str, sha1, safedeviceid, hdid, session, traceId, ua, dev
self.assertEqual(captured["args"][2], new_action)
self.assertEqual(captured["args"][7]["device_id"], new_device_id)
assert captured["args"][2] == new_action
assert captured["args"][7]["device_id"] == new_device_id
# 画像默认值里的旧 device_id 被注册结果覆盖,而非沿用
self.assertNotEqual(
captured["args"][7]["device_id"],
DEFAULT_GOLDEN_DEV["device_id"],
)
assert captured["args"][7]["device_id"] != DEFAULT_GOLDEN_DEV["device_id"]
def test_wup_login_registration_failure_is_explicit(self):
"""注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。"""
with patch("core.huya.app_login.register_device",
side_effect=DfpRegistrationError("注册链超时")) as m_reg, \
patch("core.huya.app_login.requests.post") as m_post:
with self.assertRaises(HuyaAppLoginError):
with (
patch(
"core.huya.app_login.register_device",
side_effect=DfpRegistrationError("注册链超时"),
) as m_reg,
patch("core.huya.app_login.requests.post") as m_post,
):
with pytest.raises(HuyaAppLoginError):
wup_password_login_raw("300023887", "pw")
m_reg.assert_called_once()
m_post.assert_not_called()
def test_login_cred_flow_registration_failure_is_explicit(self):
"""login_cred_with_flow 注册失败同样包装为 HuyaAppLoginError 显式失败。"""
with patch("core.huya.app_login.register_device",
side_effect=DfpRegistrationError("注册链 HTTP 500")):
with self.assertRaisesRegex(HuyaAppLoginError, "注册失败"):
with patch(
"core.huya.app_login.register_device",
side_effect=DfpRegistrationError("注册链 HTTP 500"),
):
with pytest.raises(HuyaAppLoginError, match="注册失败"):
login_cred_with_flow("300023887", "pw")
def test_router_functions(self):
@@ -187,26 +205,32 @@ class TestHuyaAppLogin(unittest.TestCase):
sdid="mock_sdid_123",
)
with patch("web.backend.routers.huya.login_huya_app_password", return_value=mock_res):
with patch(
"web.backend.routers.huya.login_huya_app_password", return_value=mock_res
):
req = HuyaAppPasswordLoginRequest(
username="mock_test_huya_user",
password="mock_password_123",
tag="test_tag",
)
resp = app_password_login_account(req=req, db=self.session, current=self.admin)
self.assertTrue(resp["success"])
self.assertIn("App", resp["message"])
self.assertEqual(resp["account"].username, "mock_test_huya_user")
resp = app_password_login_account(
req=req, db=self.session, current=self.admin
)
assert resp["success"]
assert "App" in resp["message"]
assert resp["account"].username == "mock_test_huya_user"
# Test batch app login router function
acc = self.session.query(HuyaAccount).filter(HuyaAccount.username == "mock_test_huya_user").first()
acc = (
self.session.query(HuyaAccount)
.filter(HuyaAccount.username == "mock_test_huya_user")
.first()
)
batch_req = HuyaPasswordLoginSelectedRequest(account_ids=[acc.id])
batch_resp = app_password_login_selected_accounts(req=batch_req, db=self.session, current=self.admin)
self.assertTrue(batch_resp["success"])
self.assertEqual(batch_resp["count"], 1)
self.assertEqual(len(batch_resp["results"]), 1)
self.assertTrue(batch_resp["results"][0]["success"])
if __name__ == "__main__":
unittest.main()
batch_resp = app_password_login_selected_accounts(
req=batch_req, db=self.session, current=self.admin
)
assert batch_resp["success"]
assert batch_resp["count"] == 1
assert len(batch_resp["results"]) == 1
assert batch_resp["results"][0]["success"]
+60 -53
View File
@@ -7,9 +7,10 @@
注册结果三元组返回;
* 失败语义: 模板缺失/HTTP 异常 -> DfpRegistrationError (不静默、不发登录帧)。
"""
import pytest
import base64
import json
import unittest
from pathlib import Path
from unittest.mock import patch
@@ -29,9 +30,7 @@ TAIL = bytes.fromhex("3600400c0b8c980ca80c")
def _fake_resp() -> bytes:
"""合成 dfpReport 响应: t1(32hex) + t2(180B base64 action) + t5(40hex)。"""
return (b"\x16\x20" + b"a" * 32
+ b"\x26\xb4" + b"A" * 180
+ b"\x56\x28" + b"b" * 40)
return b"\x16\x20" + b"a" * 32 + b"\x26\xb4" + b"A" * 180 + b"\x56\x28" + b"b" * 40
_GOLDEN_FP = b"02df398797432eadefcc12767119ad5e80999389"
@@ -46,28 +45,26 @@ def _dummy_chain() -> dict:
}
class TestRandomDfpBody(unittest.TestCase):
class TestRandomDfpBody:
def test_body_shape(self):
body = _build_random_dfp_body()
self.assertEqual(len(body), 4226)
assert len(body) == 4226
cw = body[-CW_LEN:]
self.assertEqual(len(cw), CW_LEN) # 4146
self.assertTrue(cw.endswith(TAIL), "cw 尾部应为固定 10B")
assert len(cw) == CW_LEN # 4146
assert cw.endswith(TAIL)
def test_body_is_randomized(self):
b1 = _build_random_dfp_body()
b2 = _build_random_dfp_body()
self.assertNotEqual(b1, b2, "每次注册应生成不同的随机 cw")
assert b1 != b2, "每次注册应生成不同的随机 cw"
class TestParseResponse(unittest.TestCase):
class TestParseResponse:
def test_parse_ok(self):
t1, t2, t5 = _parse_response(_fake_resp())
self.assertEqual(t1, "a" * 32)
self.assertEqual(t2, "A" * 180)
self.assertEqual(t5, "b" * 40)
assert t1 == "a" * 32
assert t2 == "A" * 180
assert t5 == "b" * 40
def test_parse_golden_evidence(self):
"""真实抓包模板响应必须可解析 (证据: evidence/dfp_chain_golden.json)。"""
@@ -76,77 +73,87 @@ class TestParseResponse(unittest.TestCase):
data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
resp = base64.b64decode(data["dfpReport"]["resp_b64"])
t1, t2, t5 = _parse_response(resp)
self.assertEqual(len(t1), 32)
self.assertEqual(len(t2), 180)
self.assertEqual(len(t5), 40)
assert len(t1) == 32
assert len(t2) == 180
assert len(t5) == 40
def test_parse_missing_fields_raises(self):
with self.assertRaises(DfpRegistrationError):
with pytest.raises(DfpRegistrationError):
_parse_response(b"\x16\x20" + b"a" * 32) # 缺 t2/t5
with self.assertRaises(DfpRegistrationError):
with pytest.raises(DfpRegistrationError):
_parse_response(b"\x26\xb4" + b"A" * 180) # 缺 t1/t5
with self.assertRaises(DfpRegistrationError):
with pytest.raises(DfpRegistrationError):
_parse_response(b"\x56\x28" + b"b" * 40) # 缺 t1/t2
class TestRegisterDevice(unittest.TestCase):
class TestRegisterDevice:
def test_flow_three_steps_and_returns_triple(self):
calls = []
def spy_post(body, content_type="application/octet-stream",
timeout=20, proxies=None):
def spy_post(
body, content_type="application/octet-stream", timeout=20, proxies=None
):
calls.append(content_type)
return _fake_resp()
with patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()), \
patch("core.huya.dfp_register._post", side_effect=spy_post):
with (
patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()),
patch("core.huya.dfp_register._post", side_effect=spy_post),
):
t1, t2, t5 = register_device(fingerprint=None)
self.assertEqual((t1, t2, t5), ("a" * 32, "A" * 180, "b" * 40))
assert (t1, t2, t5) == ("a" * 32, "A" * 180, "b" * 40)
# getDfpConfig -> selectOperator -> dfpReport 的 content-type 序列
self.assertEqual(
calls,
["application/octet-stream", "application/x-wup", "application/octet-stream"],
)
assert calls == [
"application/octet-stream",
"application/x-wup",
"application/octet-stream",
]
def test_select_operator_injects_fingerprint(self):
captured = []
def spy_post(body, content_type="application/octet-stream",
timeout=20, proxies=None):
def spy_post(
body, content_type="application/octet-stream", timeout=20, proxies=None
):
captured.append((body, content_type))
return _fake_resp()
new_fp = b"0" * 20 + b"f" * 20
with patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()), \
patch("core.huya.dfp_register._post", side_effect=spy_post):
with (
patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()),
patch("core.huya.dfp_register._post", side_effect=spy_post),
):
register_device(fingerprint=new_fp.decode("ascii"))
sel_body, sel_ct = captured[1]
self.assertEqual(sel_ct, "application/x-wup")
self.assertIn(new_fp, sel_body, "selectOperator 应注入当前账号画像 fingerprint")
self.assertNotIn(_GOLDEN_FP, sel_body)
assert sel_ct == "application/x-wup"
assert new_fp in sel_body, "selectOperator 应注入当前账号画像 fingerprint"
assert _GOLDEN_FP not in sel_body
# dfpReport 请求体为随机 cw 结构 (零设备生成)
dfp_body, dfp_ct = captured[2]
self.assertEqual(len(dfp_body), 4226)
self.assertEqual(dfp_ct, "application/octet-stream")
assert len(dfp_body) == 4226
assert dfp_ct == "application/octet-stream"
def test_chain_template_missing_raises(self):
"""模板缺失 -> 注册链直接报错, 不应发起任何网络请求。"""
with patch("core.huya.dfp_register.CHAIN_FILE",
Path("/tmp/definitely_missing_chain.json")), \
patch("core.huya.dfp_register._post") as m_post:
with self.assertRaises(DfpRegistrationError):
with (
patch(
"core.huya.dfp_register.CHAIN_FILE",
Path("/tmp/definitely_missing_chain.json"),
),
patch("core.huya.dfp_register._post") as m_post,
):
with pytest.raises(DfpRegistrationError):
register_device()
m_post.assert_not_called()
def test_http_failure_raises(self):
"""HTTP 层异常由 _post 转换为 DfpRegistrationError 后向上传播 (不静默)。"""
with patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()), \
patch("core.huya.dfp_register.requests.post",
side_effect=requests.RequestException("connection reset")):
with self.assertRaises(DfpRegistrationError):
with (
patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()),
patch(
"core.huya.dfp_register.requests.post",
side_effect=requests.RequestException("connection reset"),
),
):
with pytest.raises(DfpRegistrationError):
register_device()
if __name__ == "__main__":
unittest.main()
+25 -20
View File
@@ -3,23 +3,28 @@
import gzip
import logging
import tempfile
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from utils.logger import _SensitiveDataFilter, _SizeAndDayRotatingFileHandler, _parse_size
from utils.logger import (
_SensitiveDataFilter,
_SizeAndDayRotatingFileHandler,
_parse_size,
)
class LoggerTests(unittest.TestCase):
class TestLogger:
def test_size_parser_accepts_human_readable_values(self):
self.assertEqual(_parse_size("2K"), 2 * 1024)
self.assertEqual(_parse_size("3MiB"), 3 * 1024 * 1024)
self.assertEqual(_parse_size("invalid", default=123), 123)
assert _parse_size("2K") == 2 * 1024
assert _parse_size("3MiB") == 3 * 1024 * 1024
assert _parse_size("invalid", default=123) == 123
def test_file_log_redacts_credentials_and_compresses_rotation(self):
with tempfile.TemporaryDirectory() as tmpdir:
log_path = Path(tmpdir) / "app-2026-08-28.log"
handler = _SizeAndDayRotatingFileHandler(log_path, max_bytes=1, retention_days=14)
handler = _SizeAndDayRotatingFileHandler(
log_path, max_bytes=1, retention_days=14
)
handler.setFormatter(logging.Formatter("%(message)s"))
handler.addFilter(_SensitiveDataFilter())
test_logger = logging.getLogger("tests.logger.redaction")
@@ -34,20 +39,24 @@ class LoggerTests(unittest.TestCase):
test_logger.handlers = []
archives = list(Path(tmpdir).glob("app-2026-08-28.log.*.gz"))
self.assertEqual(len(archives), 1)
assert len(archives) == 1
archived_content = gzip.open(archives[0], "rt", encoding="utf-8").read()
current_content = log_path.read_text(encoding="utf-8")
combined = archived_content + current_content
self.assertNotIn("super-secret", combined)
self.assertNotIn("top-secret-token", combined)
self.assertIn("Cookie: [REDACTED]", combined)
self.assertIn("Authorization: [REDACTED]", combined)
assert "super-secret" not in combined
assert "top-secret-token" not in combined
assert "Cookie: [REDACTED]" in combined
assert "Authorization: [REDACTED]" in combined
def test_daily_log_switches_to_a_new_dated_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
today = datetime.now().date()
old_path = Path(tmpdir) / f"app-{(today - timedelta(days=1)).isoformat()}.log"
handler = _SizeAndDayRotatingFileHandler(old_path, max_bytes=1024, retention_days=14)
old_path = (
Path(tmpdir) / f"app-{(today - timedelta(days=1)).isoformat()}.log"
)
handler = _SizeAndDayRotatingFileHandler(
old_path, max_bytes=1024, retention_days=14
)
handler.setFormatter(logging.Formatter("%(message)s"))
handler._active_day = today - timedelta(days=1)
test_logger = logging.getLogger("tests.logger.daily")
@@ -61,9 +70,5 @@ class LoggerTests(unittest.TestCase):
test_logger.handlers = []
current_path = Path(tmpdir) / f"app-{today.isoformat()}.log"
self.assertTrue(current_path.exists())
self.assertEqual(current_path.read_text(encoding="utf-8").strip(), "today")
if __name__ == "__main__":
unittest.main()
assert current_path.exists()
assert current_path.read_text(encoding="utf-8").strip() == "today"
+20 -23
View File
@@ -11,7 +11,6 @@
"""
import tempfile
import unittest
from pathlib import Path
from alembic import command
@@ -42,28 +41,24 @@ def _migration_config() -> Config:
return config
class MigrationSmokeTests(unittest.TestCase):
class TestMigrationSmoke:
def test_chain_is_linear_and_head_matches_latest_file(self):
directory = ScriptDirectory.from_config(_migration_config())
heads = directory.get_heads()
self.assertEqual(len(heads), 1, f"迁移链出现多个头版本(存在分叉): {heads}")
self.assertEqual(heads[0], HEAD_REVISION, f"头版本 {heads[0]} 与最新迁移不一致")
assert len(heads) == 1, f"迁移链出现多个头版本(存在分叉): {heads}"
assert heads[0] == HEAD_REVISION, f"头版本 {heads[0]} 与最新迁移不一致"
version_files = sorted(path.name for path in VERSIONS_DIR.glob("*.py"))
revisions = list(directory.walk_revisions())
revision_ids = {revision.revision for revision in revisions}
self.assertEqual(
len(revision_ids),
len(version_files),
"迁移文件数量与已加载修订数量不一致(存在无法加载的脚本)",
assert len(revision_ids) == len(version_files), (
"迁移文件数量与已加载修订数量不一致(存在无法加载的脚本)"
)
for revision in revisions:
if revision.down_revision is not None:
self.assertIn(
revision.down_revision,
revision_ids,
f"{revision.revision} 引用了不存在的父版本 {revision.down_revision}",
assert revision.down_revision in revision_ids, (
f"{revision.revision} 引用了不存在的父版本 {revision.down_revision}"
)
def test_all_migration_scripts_compile(self):
@@ -86,18 +81,20 @@ class MigrationSmokeTests(unittest.TestCase):
inspector = inspect(create_engine(f"sqlite:///{db_path}"))
db_tables = set(inspector.get_table_names())
self.assertTrue(db_tables, "迁移链未创建任何表")
assert db_tables
model_tables = Base.metadata.tables
missing_tables = set(model_tables) - db_tables
self.assertFalse(missing_tables, f"模型要求但迁移未创建的表: {missing_tables}")
assert not missing_tables
all_db_indexes = set()
for name, table in model_tables.items():
db_columns = {column["name"] for column in inspector.get_columns(name)}
db_columns = {
column["name"] for column in inspector.get_columns(name)
}
model_columns = set(table.columns.keys())
missing_columns = model_columns - db_columns
self.assertFalse(missing_columns, f"{name} 迁移后缺少列: {missing_columns}")
assert not missing_columns
db_indexes = {
index["name"]
@@ -105,16 +102,16 @@ class MigrationSmokeTests(unittest.TestCase):
if not index["name"].startswith("sqlite_autoindex")
}
all_db_indexes |= db_indexes
model_indexes = {index.name for index in table.indexes if index.name}
model_indexes = {
index.name for index in table.indexes if index.name
}
missing_indexes = model_indexes - db_indexes
self.assertFalse(
missing_indexes, f"{name} 迁移后缺少索引: {missing_indexes}"
)
assert not missing_indexes
# 迁移新增的复合查询索引(不入模型,但应存在)。
for index_name in EXTRA_INDEXES:
self.assertIn(
index_name, all_db_indexes, f"迁移链未创建索引 {index_name}"
assert index_name in all_db_indexes, (
f"迁移链未创建索引 {index_name}"
)
finally:
database_module.DATABASE_URL = original_url
database_module.DATABASE_URL = original_url
+36 -36
View File
@@ -1,4 +1,4 @@
import unittest
import pytest
from types import SimpleNamespace
from sqlalchemy import create_engine, event
@@ -10,8 +10,8 @@ from web.backend.routers.users import delete_user, list_users, rename_user
from web.backend.schemas import UserRename
class UserDeletionTests(unittest.TestCase):
def setUp(self):
class TestUserDeletion:
def setup_method(self):
self.engine = create_engine("sqlite://")
@event.listens_for(self.engine, "connect")
@@ -24,23 +24,27 @@ class UserDeletionTests(unittest.TestCase):
self.user = User(username="support", password_hash="hash", role="support")
self.session.add_all([self.admin, self.user])
self.session.commit()
self.session.add(Account(
username="douyu-user",
password="password",
email="mail@example.com",
email_password="mail-password",
assigned_to=self.user.id,
))
self.session.add(
Account(
username="douyu-user",
password="password",
email="mail@example.com",
email_password="mail-password",
assigned_to=self.user.id,
)
)
self.session.commit()
account = self.session.query(Account).one()
self.session.add(LoginTask(
batch_id="batch-1",
account_id=account.id,
created_by=self.user.id,
))
self.session.add(
LoginTask(
batch_id="batch-1",
account_id=account.id,
created_by=self.user.id,
)
)
self.session.commit()
def tearDown(self):
def teardown_method(self):
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
@@ -54,19 +58,19 @@ class UserDeletionTests(unittest.TestCase):
deleted = self.session.get(User, self.user.id)
account = self.session.query(Account).one()
self.assertTrue(result["success"])
self.assertEqual(result["released_accounts"], 1)
self.assertFalse(deleted.is_active)
self.assertIsNotNone(deleted.deleted_at)
self.assertEqual(deleted.deleted_username, "support")
self.assertTrue(deleted.username.startswith(f"deleted-{deleted.id}-"))
self.assertIsNone(account.assigned_to)
self.assertEqual(self.session.query(AuditLog).one().target, "support")
self.assertEqual(self.session.query(LoginTask).one().created_by, deleted.id)
self.assertEqual([item.id for item in list_users(db=self.session)], [self.admin.id])
assert result["success"]
assert result["released_accounts"] == 1
assert not deleted.is_active
assert deleted.deleted_at is not None
assert deleted.deleted_username == "support"
assert deleted.username.startswith(f"deleted-{deleted.id}-")
assert account.assigned_to is None
assert self.session.query(AuditLog).one().target == "support"
assert self.session.query(LoginTask).one().created_by == deleted.id
assert [item.id for item in list_users(db=self.session)] == [self.admin.id]
def test_cannot_delete_current_user(self):
with self.assertRaisesRegex(Exception, "不能删除当前登录用户"):
with pytest.raises(Exception, match="不能删除当前登录用户"):
delete_user(
self.user.id,
db=self.session,
@@ -84,12 +88,8 @@ class UserDeletionTests(unittest.TestCase):
renamed = self.session.get(User, self.user.id)
account = self.session.query(Account).one()
task = self.session.query(LoginTask).one()
self.assertEqual(result.username, "renamed-support")
self.assertEqual(renamed.id, self.user.id)
self.assertEqual(account.assigned_to, self.user.id)
self.assertEqual(task.created_by, self.user.id)
self.assertEqual(self.session.query(AuditLog).one().target, "support -> renamed-support")
if __name__ == "__main__":
unittest.main()
assert result.username == "renamed-support"
assert renamed.id == self.user.id
assert account.assigned_to == self.user.id
assert task.created_by == self.user.id
assert self.session.query(AuditLog).one().target == "support -> renamed-support"
Generated
+30 -1
View File
@@ -255,6 +255,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
@@ -283,7 +284,10 @@ requires-dist = [
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.1.1" }]
dev = [
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "ruff", specifier = ">=0.12.0" },
]
[[package]]
name = "ecdsa"
@@ -751,6 +755,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "ruff"
version = "0.16.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" },
{ url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" },
{ url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" },
{ url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" },
{ url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" },
{ url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" },
{ url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" },
{ url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" },
{ url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" },
{ url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" },
{ url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" },
{ url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" },
]
[[package]]
name = "scipy"
version = "1.18.0"
+65 -12
View File
@@ -2,7 +2,15 @@
from datetime import datetime, timezone
from sqlalchemy import (
Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON, UniqueConstraint,
Column,
Integer,
String,
Boolean,
Text,
DateTime,
ForeignKey,
JSON,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from .database import Base
@@ -23,27 +31,39 @@ def _utcnow():
class User(Base):
"""系统用户"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(64), unique=True, nullable=False, index=True)
password_hash = Column(String(256), nullable=False)
role = Column(String(32), nullable=False, default="support") # super_admin / operation / support
role = Column(
String(32), nullable=False, default="support"
) # super_admin / operation / support
is_active = Column(Boolean, default=True)
remark = Column(String(256), default="")
custom_permissions = Column(JSON, nullable=True, comment="自定义权限列表,null表示使用角色默认权限")
custom_permissions = Column(
JSON, nullable=True, comment="自定义权限列表,null表示使用角色默认权限"
)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
deleted_at = Column(DateTime, nullable=True, index=True)
deleted_username = Column(String(64), default="")
# 客服被分配的账号
assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to")
huya_accounts = relationship("HuyaAccount", back_populates="assigned_user", foreign_keys="HuyaAccount.assigned_to")
assigned_accounts = relationship(
"Account", back_populates="assigned_user", foreign_keys="Account.assigned_to"
)
huya_accounts = relationship(
"HuyaAccount",
back_populates="assigned_user",
foreign_keys="HuyaAccount.assigned_to",
)
class Account(Base):
"""斗鱼账号"""
__tablename__ = "accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -83,19 +103,24 @@ class Account(Base):
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
assigned_user = relationship("User", back_populates="assigned_accounts", foreign_keys=[assigned_to])
assigned_user = relationship(
"User", back_populates="assigned_accounts", foreign_keys=[assigned_to]
)
login_tasks = relationship("LoginTask", back_populates="account")
douyu_tasks = relationship("DouyuTask", back_populates="account")
class LoginTask(Base):
"""单次登录任务"""
__tablename__ = "login_tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
batch_id = Column(String(64), nullable=False, index=True) # 批次ID
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
status = Column(String(32), default="pending") # pending / running / success / failed / error
status = Column(
String(32), default="pending"
) # pending / running / success / failed / error
cookie = Column(EncryptedText(), default="")
message = Column(String(512), default="")
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
@@ -103,7 +128,9 @@ class LoginTask(Base):
finished_at = Column(DateTime, nullable=True)
# Cookie 有效性检测结果(持久化,刷新不丢失)
ck_check_status = Column(String(16), default="") # valid / invalid / 空=未检测
ck_check_result = Column(JSON, nullable=True) # {fish_ball, nickname, level, message}
ck_check_result = Column(
JSON, nullable=True
) # {fish_ball, nickname, level, message}
ck_checked_at = Column(DateTime, nullable=True)
account = relationship("Account", back_populates="login_tasks")
@@ -111,6 +138,7 @@ class LoginTask(Base):
class DouyuTask(Base):
"""斗鱼业务任务"""
__tablename__ = "douyu_tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -133,6 +161,7 @@ class DouyuTask(Base):
class DouyuWorkbenchAccount(Base):
"""用户在指定斗鱼工作台中启用的账号,跨浏览器同步。"""
__tablename__ = "douyu_workbench_accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -142,12 +171,15 @@ class DouyuWorkbenchAccount(Base):
created_at = Column(DateTime, default=_utcnow)
__table_args__ = (
UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"),
UniqueConstraint(
"user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"
),
)
class DouyuWorkbench(Base):
"""工作台配置哨兵,令空账号集合也能跨浏览器同步。"""
__tablename__ = "douyu_workbenches"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -165,6 +197,7 @@ class YybRechargeTask(Base):
YYB 登录身份与斗鱼账号无关,因此任务只关联创建者,不复用 Account。
"""
__tablename__ = "yyb_recharge_tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -197,6 +230,7 @@ class YybRechargeTask(Base):
class DouyuConfig(Base):
"""斗鱼业务配置"""
__tablename__ = "douyu_config"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -228,6 +262,7 @@ class DouyuConfig(Base):
class DouyuGoodsSnapshot(Base):
"""斗鱼兑换商品快照"""
__tablename__ = "douyu_goods_snapshot"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -241,6 +276,7 @@ class DouyuGoodsSnapshot(Base):
class DouyuXpdGoodsSnapshot(Base):
"""斗鱼和平小店商品快照"""
__tablename__ = "douyu_xpd_goods_snapshot"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -256,6 +292,7 @@ class DouyuXpdGoodsSnapshot(Base):
class DouyuEsportsGoodsSnapshot(Base):
"""斗鱼电竞手册皮肤商城商品快照"""
__tablename__ = "douyu_esports_goods_snapshot"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -269,6 +306,7 @@ class DouyuEsportsGoodsSnapshot(Base):
class HuyaAccount(Base):
"""虎牙账号"""
__tablename__ = "huya_accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -292,12 +330,15 @@ class HuyaAccount(Base):
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
assigned_user = relationship("User", back_populates="huya_accounts", foreign_keys=[assigned_to])
assigned_user = relationship(
"User", back_populates="huya_accounts", foreign_keys=[assigned_to]
)
tasks = relationship("HuyaTask", back_populates="account")
class HuyaRegisterBatch(Base):
"""虎牙自动注册批次(持久化元数据)"""
__tablename__ = "huya_register_batches"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -320,15 +361,20 @@ class HuyaRegisterBatch(Base):
started_at = Column(DateTime, nullable=True)
finished_at = Column(DateTime, nullable=True)
items = relationship("HuyaRegisterItem", back_populates="batch", cascade="all, delete-orphan")
items = relationship(
"HuyaRegisterItem", back_populates="batch", cascade="all, delete-orphan"
)
class HuyaRegisterItem(Base):
"""虎牙自动注册批次内单条手机号状态"""
__tablename__ = "huya_register_items"
id = Column(Integer, primary_key=True, autoincrement=True)
batch_db_id = Column(Integer, ForeignKey("huya_register_batches.id"), nullable=False, index=True)
batch_db_id = Column(
Integer, ForeignKey("huya_register_batches.id"), nullable=False, index=True
)
batch_id = Column(String(64), nullable=False, index=True)
line = Column(Integer, nullable=False)
phone = Column(String(64), default="", index=True)
@@ -354,6 +400,7 @@ class HuyaRegisterItem(Base):
class HuyaRegisterSuccessLog(Base):
"""虎牙自动注册成功流水(只追加,供随时导出)"""
__tablename__ = "huya_register_success_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -373,6 +420,7 @@ class HuyaRegisterSuccessLog(Base):
class HuyaTask(Base):
"""虎牙业务任务"""
__tablename__ = "huya_tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -391,6 +439,7 @@ class HuyaTask(Base):
class HuyaConfig(Base):
"""虎牙业务配置"""
__tablename__ = "huya_config"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -404,6 +453,7 @@ class HuyaConfig(Base):
class HuyaGoodsSnapshot(Base):
"""虎牙兑换商品快照"""
__tablename__ = "huya_goods_snapshot"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -417,6 +467,7 @@ class HuyaGoodsSnapshot(Base):
class HuyaRechargeGoodsSnapshot(Base):
"""虎牙充值商品快照"""
__tablename__ = "huya_recharge_goods_snapshot"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -436,6 +487,7 @@ class HuyaRechargeGoodsSnapshot(Base):
class ProxyConfig(Base):
"""代理配置(全局单条记录)"""
__tablename__ = "proxy_config"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -454,6 +506,7 @@ class ProxyConfig(Base):
class AuditLog(Base):
"""操作审计日志"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, autoincrement=True)