fix(douyu): reject invalid activity cookies

This commit is contained in:
yml2213
2026-08-28 15:51:59 +08:00
parent 9ee4e49a50
commit 6ded82ac8b
5 changed files with 135 additions and 14 deletions
+40 -8
View File
@@ -7,7 +7,7 @@ import re
import time import time
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from urllib.parse import parse_qs, unquote, urlsplit from urllib.parse import parse_qs, unquote, urlsplit, urlunsplit
import requests import requests
@@ -118,6 +118,28 @@ class DouyuActivityClient:
"Cookie": self.cookie, "Cookie": self.cookie,
} }
@staticmethod
def _safe_url(url: str) -> str:
"""移除 query/fragment,避免将 Cookie、签名或一次性参数写入日志。"""
parsed = urlsplit(url)
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
def _redirect_error(
self,
method: str,
url: str,
source: str,
response: requests.Response,
) -> DouyuActivityError:
location = response.headers.get("Location", "")
safe_location = self._safe_url(location) if location else "-"
trace = (
f"method={method.upper()} url={self._safe_url(url)} "
f"location={safe_location} history={response.status_code}->{safe_location}"
)
self.logger(f"{source}: 已拦截重定向 | {trace}")
return DouyuActivityError(f"{source} 请求被重定向,Cookie 已失效,请重新登录({trace}")
def _request( def _request(
self, self,
method: str, method: str,
@@ -130,14 +152,24 @@ class DouyuActivityClient:
req_headers = self._headers() req_headers = self._headers()
if headers: if headers:
req_headers.update(headers) req_headers.update(headers)
response = self.session.request( # 活动 JSON 接口不应自动跳到登录页;忽略调用方传入的覆盖值。
method, kwargs.pop("allow_redirects", None)
url, try:
headers=req_headers, response = self.session.request(
timeout=kwargs.pop("timeout", self.timeout), method,
**kwargs, url,
) headers=req_headers,
timeout=kwargs.pop("timeout", self.timeout),
allow_redirects=False,
**kwargs,
)
except requests.RequestException as exc:
raise DouyuActivityError(
f"{source} 请求失败: {method.upper()} {self._safe_url(url)}: {exc}"
) from exc
self._merge_response_cookie(response) self._merge_response_cookie(response)
if 300 <= response.status_code < 400:
raise self._redirect_error(method, url, source, response)
self.logger(f"{source}: {response.status_code}") self.logger(f"{source}: {response.status_code}")
response.raise_for_status() response.raise_for_status()
return response return response
+27
View File
@@ -1,6 +1,8 @@
import unittest import unittest
from unittest.mock import Mock from unittest.mock import Mock
import requests
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
@@ -100,6 +102,31 @@ class EliteLockExchangeTests(unittest.TestCase):
"page": 2, "pageSize": 20, "manualID": "manual", "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"
self.client.session.request = Mock(return_value=response)
self.client.logger = Mock()
with self.assertRaisesRegex(DouyuActivityError, "Cookie 已失效,请重新登录") as caught:
self.client._request(
"get",
"https://www.douyu.com/japi/example?request_secret=hidden",
source="测试活动接口",
)
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__": if __name__ == "__main__":
unittest.main() unittest.main()
+41 -1
View File
@@ -12,7 +12,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from web.backend.database import Base from web.backend.database import Base
from web.backend.models import Account, AuditLog, DouyuTask, User from web.backend.models import Account, AuditLog, DouyuTask, LoginTask, User
from web.backend.routers.douyu import get_recharge_channel, supplier_recharge_callback from web.backend.routers.douyu import get_recharge_channel, supplier_recharge_callback
from web.backend.services.douyu_runner import DouyuBatchRunner from web.backend.services.douyu_runner import DouyuBatchRunner
from web.backend.services.douyu_service import ensure_douyu_config from web.backend.services.douyu_service import ensure_douyu_config
@@ -176,6 +176,46 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
self.assertEqual(self.task.status, "failed") self.assertEqual(self.task.status, "failed")
self.assertEqual(self.task.message, "商品已下架") self.assertEqual(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):
login_task = LoginTask(
batch_id="login-batch",
account_id=self.account.id,
created_by=self.task.created_by,
status="success",
cookie="acf_uid=10001",
)
self.task.status = "planned"
self.session.add(login_task)
self.session.commit()
task_id = self.task.id
login_task_id = login_task.id
session_local.return_value = self.session
check_cookie.return_value = {
"valid": False,
"message": "鱼丸接口: 未登录;等级接口: 未登录",
"fish_ball": None,
"nickname": None,
"level": None,
"checked_at": None,
}
self.runner._execute_create_gold_qr = Mock()
self.runner._execute_one(task_id, {}, 1)
self.runner._execute_create_gold_qr.assert_not_called()
verify_session = sessionmaker(bind=self.engine)()
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"], "鱼丸接口: 未登录;等级接口: 未登录")
finally:
verify_session.close()
def test_supplier_callback_verifies_signature_and_updates_terminal_task(self): def test_supplier_callback_verifies_signature_and_updates_terminal_task(self):
self.task.supplier_out_order_id = f"DYGFBATCHT{self.task.id}" 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}
+19 -2
View File
@@ -29,10 +29,11 @@ from .douyu_service import (
account_uid, account_uid,
douyu_config_value, douyu_config_value,
ensure_douyu_config, ensure_douyu_config,
latest_success_cookie, latest_success_login_task,
douyu_task_payload, douyu_task_payload,
update_account_profile_from_cookie, update_account_profile_from_cookie,
) )
from .cookie_check_service import check_douyu_cookie
DOUYU_LEGACY_BIND_ACT_ALIAS = "20250213NQCYX" DOUYU_LEGACY_BIND_ACT_ALIAS = "20250213NQCYX"
@@ -3159,11 +3160,27 @@ class DouyuBatchRunner:
current = self._started current = self._started
self._push_log("info", f"[{current}/{total}] 开始: {self._account_name(account)}") self._push_log("info", f"[{current}/{total}] 开始: {self._account_name(account)}")
cookie = latest_success_cookie(worker_db, account.id) login_task = latest_success_login_task(worker_db, account.id)
cookie = login_task.cookie if login_task else ""
if not cookie: if not cookie:
self._mark_task(worker_db, task, "failed", "账号没有成功登录 Cookie") self._mark_task(worker_db, task, "failed", "账号没有成功登录 Cookie")
self._push_log("warning", f"[{current}] {self._account_name(account)} 无 Cookie") self._push_log("warning", f"[{current}] {self._account_name(account)} 无 Cookie")
return return
cookie_check = check_douyu_cookie(cookie)
login_task.ck_check_status = "valid" if cookie_check["valid"] else "invalid"
login_task.ck_check_result = {
key: cookie_check.get(key)
for key in ("fish_ball", "nickname", "level", "message")
}
login_task.ck_checked_at = cookie_check["checked_at"]
worker_db.commit()
if not cookie_check["valid"]:
message = f"Cookie 已失效,请重新登录:{cookie_check['message']}"
self._mark_task(worker_db, task, "failed", message)
self._push_log("warning", f"[{current}] {self._account_name(account)} {message}")
return
update_account_profile_from_cookie(account, cookie) update_account_profile_from_cookie(account, cookie)
handler = { handler = {
+8 -3
View File
@@ -145,9 +145,9 @@ def ensure_douyu_config(db: Session) -> DouyuConfig:
return config return config
def latest_success_cookie(db: Session, account_id: int) -> str: def latest_success_login_task(db: Session, account_id: int) -> LoginTask | None:
"""读取账号最近一次成功登录 Cookie""" """读取账号最近一次成功登录记录"""
task = ( return (
db.query(LoginTask) db.query(LoginTask)
.filter( .filter(
LoginTask.account_id == account_id, LoginTask.account_id == account_id,
@@ -157,6 +157,11 @@ def latest_success_cookie(db: Session, account_id: int) -> str:
.order_by(LoginTask.id.desc()) .order_by(LoginTask.id.desc())
.first() .first()
) )
def latest_success_cookie(db: Session, account_id: int) -> str:
"""读取账号最近一次成功登录 Cookie。"""
task = latest_success_login_task(db, account_id)
return task.cookie if task else "" return task.cookie if task else ""