feat(douyu): 精英宝典支持查询开通状态,开通前先校验且不再误判到账
线上两个问题:工作台看不到账号有没有开通精英宝典;点开通时二维码会「闪一下 就变小」——大图弹窗 2~4 秒后自动关闭,只剩列表里 56px 的小二维码。 根因: - getUserInfo 的 manualType/expireTime 才是开通状态的权威口径,但只有兑换 流程用过一次,没有任务类型对外暴露;前端只看积分列,而「开通后积分花完」 和「从未开通」都是 0 分,区分不了(实测 13456 manualType=1/score=0); - 开通前不查状态,且到账判定写成「积分 >= 300」:账上本来就有 680 分的账号 在出码后第一轮轮询就被判成功,前端按 paymentArrived 自动关掉了弹窗。 改动: - 新增任务类型 query_elite_status:查 manualType/manualScore/expireTime 并 落库到 accounts.elite_opened / elite_expire_time(迁移 20261001_0036), 精英工作台加查询按钮与「宝典状态」列(已开通+到期 / 未开通); - create_elite_qr 开通前先查状态:已开通直接判成功、不再生成支付码(方案 A), 既消除二维码闪现,也避免给已开通账号开出真实支付单; - 到账判定改为「积分比开通前增长」,基线用与轮询同源的 credit/balance 取, 取不到基线才退回旧口径; - 支付弹窗不再自动关闭:到账/失败/停止都在弹窗里给结论 + 手动关闭;列表小 二维码在任务进终态后隐藏。 新增 9 条测试覆盖上述行为,回退 runner 时其中 8 条失败。
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
"""精英宝典开通流程:先查状态、不出无用支付码、按积分基线判到账。
|
||||
|
||||
线上问题:工作台看不到「是否开通」,而开通时二维码会闪一下就变小 ——
|
||||
原因是开通前不查状态,且到账判定写成「积分 >= 300」:账上本来就有积分的
|
||||
账号在出码后第一轮轮询(2~4 秒)就被判成功,前端按 paymentArrived 自动
|
||||
关闭了支付弹窗,只剩列表里 56px 的小二维码。
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from web.backend.database import Base
|
||||
from web.backend.models import Account, DouyuTask, User
|
||||
from web.backend.services import douyu_runner_manual
|
||||
from web.backend.services.douyu_runner import DouyuBatchRunner
|
||||
from web.backend.services.douyu_service import (
|
||||
DOUYU_HANDBOOK_TASK_TYPES,
|
||||
SUPPORTED_DOUYU_TASK_TYPES,
|
||||
)
|
||||
|
||||
|
||||
class TestEliteHandbookOpenFlow:
|
||||
def setup_method(self):
|
||||
self.engine = create_engine("sqlite://")
|
||||
|
||||
@event.listens_for(self.engine, "connect")
|
||||
def enable_foreign_keys(connection, _):
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = sessionmaker(bind=self.engine)()
|
||||
self.user = User(username="operator", password_hash="hash", role="super_admin")
|
||||
self.account = Account(
|
||||
username="dyuser",
|
||||
password="pw",
|
||||
email="a@example.com",
|
||||
email_password="em",
|
||||
)
|
||||
self.session.add_all([self.user, self.account])
|
||||
self.session.commit()
|
||||
|
||||
def teardown_method(self):
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _runner(self, task_type: str) -> DouyuBatchRunner:
|
||||
return DouyuBatchRunner(db=self.session, batch_id="batch1", task_type=task_type)
|
||||
|
||||
def _task(self, task_type: str) -> DouyuTask:
|
||||
task = DouyuTask(
|
||||
batch_id="batch1",
|
||||
account_id=self.account.id,
|
||||
task_type=task_type,
|
||||
handbook_scope="elite",
|
||||
status="running",
|
||||
result={"payload": {}},
|
||||
created_by=self.user.id,
|
||||
)
|
||||
self.session.add(task)
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
def _config(self) -> dict:
|
||||
return {
|
||||
"manual_id": "G4KA4Qnz4LDp7",
|
||||
"rid": "9263298",
|
||||
"room_id": "9252212",
|
||||
"elite_amount": 3000,
|
||||
"bind_act_alias": "20260120QYOOB",
|
||||
"confirm_act_alias": "20260120QYOOB",
|
||||
}
|
||||
|
||||
def test_task_type_registered_for_elite_scope(self):
|
||||
assert SUPPORTED_DOUYU_TASK_TYPES["query_elite_status"] == "查询精英宝典状态"
|
||||
assert "query_elite_status" in DOUYU_HANDBOOK_TASK_TYPES["elite"]
|
||||
|
||||
def test_query_elite_status_writes_account_state(self):
|
||||
task = self._task("query_elite_status")
|
||||
runner = self._runner("query_elite_status")
|
||||
client = Mock()
|
||||
client.elite_user_info.return_value = {
|
||||
"manual_type": 1,
|
||||
"manual_score": 300,
|
||||
"expire_time": 1793462399,
|
||||
}
|
||||
runner._client = Mock(return_value=client)
|
||||
|
||||
runner._execute_query_elite_status(
|
||||
self.session, task, self.account, "acf_uid=100", self._config()
|
||||
)
|
||||
|
||||
self.session.refresh(task)
|
||||
self.session.refresh(self.account)
|
||||
assert task.status == "success"
|
||||
assert "已开通" in task.message
|
||||
assert "到期" in task.message
|
||||
assert self.account.elite_opened is True
|
||||
assert self.account.elite_expire_time == 1793462399
|
||||
assert self.account.points == 300
|
||||
|
||||
def test_query_elite_status_marks_unopened(self):
|
||||
task = self._task("query_elite_status")
|
||||
runner = self._runner("query_elite_status")
|
||||
client = Mock()
|
||||
client.elite_user_info.return_value = {
|
||||
"manual_type": 0,
|
||||
"manual_score": 0,
|
||||
"expire_time": 0,
|
||||
}
|
||||
runner._client = Mock(return_value=client)
|
||||
|
||||
runner._execute_query_elite_status(
|
||||
self.session, task, self.account, "acf_uid=100", self._config()
|
||||
)
|
||||
|
||||
self.session.refresh(task)
|
||||
self.session.refresh(self.account)
|
||||
assert task.status == "success"
|
||||
assert "未开通" in task.message
|
||||
assert self.account.elite_opened is False
|
||||
assert self.account.elite_expire_time is None
|
||||
|
||||
def test_create_elite_qr_skips_payment_when_already_opened(self):
|
||||
"""已开通的账号不再生成支付码(避免二维码闪现与白付)。"""
|
||||
task = self._task("create_elite_qr")
|
||||
runner = self._runner("create_elite_qr")
|
||||
client = Mock()
|
||||
client.elite_user_info.return_value = {
|
||||
"manual_type": 1,
|
||||
"manual_score": 300,
|
||||
"expire_time": 1793462399,
|
||||
}
|
||||
runner._client = Mock(return_value=client)
|
||||
|
||||
runner._execute_create_elite_qr(
|
||||
self.session, task, self.account, "acf_uid=100", self._config()
|
||||
)
|
||||
|
||||
self.session.refresh(task)
|
||||
self.session.refresh(self.account)
|
||||
assert task.status == "success"
|
||||
assert "已开通" in task.message
|
||||
assert task.result["elite_already_opened"] is True
|
||||
assert "pay_url" not in task.result
|
||||
client.create_elite_qr.assert_not_called()
|
||||
assert self.account.bind_status == "elite_opened"
|
||||
|
||||
def test_create_elite_qr_uses_points_baseline(self, monkeypatch):
|
||||
"""账上已有 680 积分时不能立刻判成功,必须等积分真的增长。"""
|
||||
monkeypatch.setattr(douyu_runner_manual, "DOUYU_PAYMENT_POLL_SECONDS", 0.05)
|
||||
monkeypatch.setattr(douyu_runner_manual, "DOUYU_PAYMENT_POLL_INTERVAL", 0.01)
|
||||
|
||||
task = self._task("create_elite_qr")
|
||||
runner = self._runner("create_elite_qr")
|
||||
client = Mock()
|
||||
client.acf_ccn.return_value = "ctn-1"
|
||||
client.elite_user_info.return_value = {
|
||||
"manual_type": 0,
|
||||
"manual_score": 680,
|
||||
"expire_time": 0,
|
||||
}
|
||||
client.create_elite_qr.return_value = {"pay_url": "https://example.test/pay"}
|
||||
runner._client = Mock(return_value=client)
|
||||
# 基线取自 credit/balance,与到账轮询同源:开通前后都是 680 -> 没有增长
|
||||
runner._refresh_account_points = Mock(return_value={"points": 680})
|
||||
|
||||
runner._execute_create_elite_qr(
|
||||
self.session, task, self.account, "acf_uid=100", self._config()
|
||||
)
|
||||
|
||||
self.session.refresh(task)
|
||||
# 积分没有增长 -> 不能判成功(旧口径会立刻 success,二维码随即消失)
|
||||
assert task.status == "failed"
|
||||
assert task.result["elite_opened"] is False
|
||||
assert task.result["elite_points_baseline"] == 680
|
||||
client.create_elite_qr.assert_called_once()
|
||||
|
||||
def test_create_elite_qr_succeeds_when_points_grow(self, monkeypatch):
|
||||
monkeypatch.setattr(douyu_runner_manual, "DOUYU_PAYMENT_POLL_SECONDS", 0.05)
|
||||
monkeypatch.setattr(douyu_runner_manual, "DOUYU_PAYMENT_POLL_INTERVAL", 0.01)
|
||||
|
||||
task = self._task("create_elite_qr")
|
||||
runner = self._runner("create_elite_qr")
|
||||
client = Mock()
|
||||
client.acf_ccn.return_value = "ctn-1"
|
||||
client.elite_user_info.return_value = {
|
||||
"manual_type": 0,
|
||||
"manual_score": 0,
|
||||
"expire_time": 0,
|
||||
}
|
||||
client.create_elite_qr.return_value = {"pay_url": "https://example.test/pay"}
|
||||
runner._client = Mock(return_value=client)
|
||||
# 第一次是开通前的基线 0,之后轮询到账 300
|
||||
runner._refresh_account_points = Mock(
|
||||
side_effect=[{"points": 0}, {"points": 300}]
|
||||
)
|
||||
|
||||
runner._execute_create_elite_qr(
|
||||
self.session, task, self.account, "acf_uid=100", self._config()
|
||||
)
|
||||
|
||||
self.session.refresh(task)
|
||||
self.session.refresh(self.account)
|
||||
assert task.status == "success"
|
||||
assert task.result["elite_opened"] is True
|
||||
assert self.account.elite_opened is True
|
||||
|
||||
def test_wait_points_falls_back_without_baseline(self, monkeypatch):
|
||||
"""拿不到基线时退回旧口径,避免完全无法判定到账。"""
|
||||
monkeypatch.setattr(douyu_runner_manual, "DOUYU_PAYMENT_POLL_SECONDS", 0.05)
|
||||
monkeypatch.setattr(douyu_runner_manual, "DOUYU_PAYMENT_POLL_INTERVAL", 0.01)
|
||||
|
||||
task = self._task("create_elite_qr")
|
||||
runner = self._runner("create_elite_qr")
|
||||
client = Mock()
|
||||
runner._refresh_account_points = Mock(return_value={"points": 680})
|
||||
|
||||
opened = runner._wait_points_after_payment(
|
||||
self.session,
|
||||
task,
|
||||
self.account,
|
||||
client,
|
||||
"acf_uid=100",
|
||||
"ctn-1",
|
||||
{},
|
||||
baseline_points=None,
|
||||
)
|
||||
|
||||
assert opened is True
|
||||
|
||||
@pytest.mark.parametrize("already_points", [300, 680])
|
||||
def test_wait_points_ignores_existing_points(self, monkeypatch, already_points):
|
||||
monkeypatch.setattr(douyu_runner_manual, "DOUYU_PAYMENT_POLL_SECONDS", 0.05)
|
||||
monkeypatch.setattr(douyu_runner_manual, "DOUYU_PAYMENT_POLL_INTERVAL", 0.01)
|
||||
|
||||
task = self._task("create_elite_qr")
|
||||
runner = self._runner("create_elite_qr")
|
||||
client = Mock()
|
||||
runner._refresh_account_points = Mock(return_value={"points": already_points})
|
||||
|
||||
opened = runner._wait_points_after_payment(
|
||||
self.session,
|
||||
task,
|
||||
self.account,
|
||||
client,
|
||||
"acf_uid=100",
|
||||
"ctn-1",
|
||||
{},
|
||||
baseline_points=already_points,
|
||||
)
|
||||
|
||||
assert opened is False
|
||||
@@ -22,7 +22,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = TESTS_DIR.parent
|
||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||
|
||||
HEAD_REVISION = "20261001_0035"
|
||||
HEAD_REVISION = "20261001_0036"
|
||||
|
||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||
EXTRA_INDEXES = (
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""accounts 增加精英宝典开通状态字段。
|
||||
|
||||
原来只能靠「积分」列猜有没有开通,但开通后积分花完与从未开通过都是 0 分,
|
||||
根本区分不了(线上实测:某账号 manualType=1/manualScore=0,另一个从未开通的
|
||||
是 manualType=0/manualScore=0)。getUserInfo 的 manualType/expireTime 才是
|
||||
权威口径,这里落库供工作台直接展示。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20261001_0036"
|
||||
down_revision: str | None = "20261001_0035"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
columns = {item["name"] for item in sa.inspect(bind).get_columns("accounts")}
|
||||
if "elite_opened" not in columns:
|
||||
op.add_column(
|
||||
"accounts",
|
||||
sa.Column("elite_opened", sa.Boolean(), nullable=True),
|
||||
)
|
||||
if "elite_expire_time" not in columns:
|
||||
op.add_column(
|
||||
"accounts",
|
||||
sa.Column("elite_expire_time", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
columns = {item["name"] for item in sa.inspect(bind).get_columns("accounts")}
|
||||
for name in ("elite_expire_time", "elite_opened"):
|
||||
if name in columns:
|
||||
op.drop_column("accounts", name)
|
||||
@@ -97,6 +97,10 @@ class Account(Base):
|
||||
exchange_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
bind_status: Mapped[str] = mapped_column(String(32), default="")
|
||||
change_role_wait_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 精英宝典开通状态(getUserInfo 的 manualType / expireTime)。
|
||||
# 光看积分区分不了「开通后积分花完」和「从未开通」,必须单独落库。
|
||||
elite_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
elite_expire_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
esports_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
esports_game_name: Mapped[str] = mapped_column(String(128), default="")
|
||||
esports_game_channel: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
@@ -319,6 +319,8 @@ def _account_out(account: Account) -> DouyuTaskAccountOut:
|
||||
exchange_balance=account.exchange_balance,
|
||||
bind_status=account.bind_status or "",
|
||||
change_role_wait_time=account.change_role_wait_time,
|
||||
elite_opened=account.elite_opened,
|
||||
elite_expire_time=account.elite_expire_time,
|
||||
esports_points=account.esports_points,
|
||||
esports_game_name=account.esports_game_name or "",
|
||||
esports_game_channel=account.esports_game_channel or "",
|
||||
|
||||
@@ -811,6 +811,8 @@ class DouyuTaskAccountOut(BaseModel):
|
||||
exchange_balance: int | None = None
|
||||
bind_status: str = ""
|
||||
change_role_wait_time: int | None = None
|
||||
elite_opened: bool | None = None
|
||||
elite_expire_time: int | None = None
|
||||
esports_points: int | None = None
|
||||
esports_game_name: str = ""
|
||||
esports_game_channel: str = ""
|
||||
|
||||
@@ -90,6 +90,7 @@ class DouyuBatchRunner(
|
||||
"get_bind_qr": self._execute_get_bind_qr,
|
||||
"confirm_bind": self._execute_confirm_bind,
|
||||
"create_elite_qr": self._execute_create_elite_qr,
|
||||
"query_elite_status": self._execute_query_elite_status,
|
||||
"prepare_esports_bind": self._execute_prepare_esports_bind,
|
||||
"get_esports_bind_qr": self._execute_get_esports_bind_qr,
|
||||
"query_esports_game_name": self._execute_query_esports_game_name,
|
||||
|
||||
@@ -57,6 +57,74 @@ class ManualMixin:
|
||||
account.updated_at = datetime.now(UTC)
|
||||
return {"points": points, "points_query": result}
|
||||
|
||||
@staticmethod
|
||||
def _format_expire_text(expire_time: int | None) -> str:
|
||||
"""把到期时间戳格式化成中文描述,缺失或为 0 时返回空串。"""
|
||||
if not expire_time or expire_time <= 0:
|
||||
return ""
|
||||
expire_at = datetime.fromtimestamp(expire_time, tz=UTC).astimezone()
|
||||
return f"(到期 {expire_at:%Y-%m-%d %H:%M})"
|
||||
|
||||
def _refresh_elite_handbook(
|
||||
self,
|
||||
client: DouyuActivityClient,
|
||||
account: Account,
|
||||
*,
|
||||
manual_id: str,
|
||||
rid: str = "",
|
||||
) -> dict:
|
||||
"""查询精英宝典开通状态(manualType/expireTime)并写回账号。
|
||||
|
||||
积分区分不了「开通后积分花完」和「从未开通」,manualType 才是权威口径:
|
||||
0=未开通,>=1=已开通。
|
||||
"""
|
||||
result = client.elite_user_info(manual_id=manual_id, rid=rid)
|
||||
manual_type = self._to_int(result.get("manual_type")) or 0
|
||||
manual_score = self._to_int(result.get("manual_score"))
|
||||
expire_time = self._to_int(result.get("expire_time")) or 0
|
||||
opened = manual_type >= 1
|
||||
account.elite_opened = opened
|
||||
account.elite_expire_time = expire_time or None
|
||||
if manual_score is not None:
|
||||
account.points = manual_score
|
||||
account.updated_at = datetime.now(UTC)
|
||||
return {
|
||||
"elite_manual_type": manual_type,
|
||||
"elite_manual_score": manual_score,
|
||||
"elite_expire_time": expire_time,
|
||||
"elite_opened": opened,
|
||||
"elite_user_info": result,
|
||||
"points": manual_score,
|
||||
}
|
||||
|
||||
def _execute_query_elite_status(
|
||||
self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict
|
||||
):
|
||||
"""查询精英宝典是否已开通、到期时间与积分。"""
|
||||
manual_id = str(config.get("manual_id") or "").strip()
|
||||
if not manual_id:
|
||||
self._mark_task(db, task, "failed", "请先配置精英宝典 manualID")
|
||||
return
|
||||
client = self._client(cookie)
|
||||
result = self._refresh_elite_handbook(
|
||||
client,
|
||||
account,
|
||||
manual_id=manual_id,
|
||||
rid=str(config.get("rid") or ""),
|
||||
)
|
||||
account.bind_status = "elite_status_queried"
|
||||
account.updated_at = datetime.now(UTC)
|
||||
state = "已开通" if result["elite_opened"] else "未开通"
|
||||
expire_text = self._format_expire_text(result["elite_expire_time"])
|
||||
points = result["elite_manual_score"]
|
||||
self._mark_task(
|
||||
db,
|
||||
task,
|
||||
"success",
|
||||
f"精英宝典{state}{expire_text},积分: {points if points is not None else '-'}",
|
||||
result,
|
||||
)
|
||||
|
||||
def _wait_points_after_payment(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -66,11 +134,19 @@ class ManualMixin:
|
||||
cookie: str,
|
||||
ctn: str,
|
||||
result: dict,
|
||||
*,
|
||||
baseline_points: int | None = None,
|
||||
) -> bool:
|
||||
"""等待宝典支付到账;积分达到 300 视为开通成功。"""
|
||||
"""等待宝典支付到账。
|
||||
|
||||
判定口径是「积分比开通前有增长」:旧口径 `积分 >= 300` 会让账上本来就
|
||||
有 300+ 积分的账号在出码后第一轮轮询就被判成功,二维码刚弹出来就消失
|
||||
(线上 2~4 秒 success 的案例都是这种)。拿不到基线时才退回旧口径。
|
||||
"""
|
||||
deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS
|
||||
result["payment_polling"] = True
|
||||
result["payment_target_points"] = 300
|
||||
result["elite_points_baseline"] = baseline_points
|
||||
poll_count = 0
|
||||
last_points = None
|
||||
while not self._stop.is_set() and time.monotonic() <= deadline:
|
||||
@@ -84,10 +160,16 @@ class ManualMixin:
|
||||
result.update(points_result)
|
||||
result["payment_poll_count"] = poll_count
|
||||
result["payment_polling"] = True
|
||||
if last_points is not None and last_points >= 300:
|
||||
result["payment_polling"] = False
|
||||
result["elite_opened"] = True
|
||||
return True
|
||||
if last_points is not None:
|
||||
arrived = (
|
||||
last_points > baseline_points
|
||||
if baseline_points is not None
|
||||
else last_points >= 300
|
||||
)
|
||||
if arrived:
|
||||
result["payment_polling"] = False
|
||||
result["elite_opened"] = True
|
||||
return True
|
||||
self._update_task_progress(
|
||||
db,
|
||||
task,
|
||||
@@ -213,6 +295,59 @@ class ManualMixin:
|
||||
if not act_alias:
|
||||
self._mark_task(db, task, "failed", "请先配置开通宝典活动 actAlias")
|
||||
return
|
||||
|
||||
# 开通前先查状态(对齐电竞手册):已开通的账号直接给出结论,不再生成
|
||||
# 支付码 —— 既避免二维码刚弹出来就因「积分本来就够」被判成功而消失,
|
||||
# 也避免给已开通账号生成一张无意义的真实支付单。
|
||||
manual_id = str(config.get("manual_id") or "").strip()
|
||||
baseline_points: int | None = None
|
||||
if manual_id:
|
||||
try:
|
||||
baseline = self._refresh_elite_handbook(
|
||||
client,
|
||||
account,
|
||||
manual_id=manual_id,
|
||||
rid=str(config.get("rid") or ""),
|
||||
)
|
||||
db.commit()
|
||||
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||||
self._push_log(
|
||||
"warning", f"开通前查询精英宝典状态失败,按未开通流程继续: {exc}"
|
||||
)
|
||||
else:
|
||||
if baseline["elite_opened"]:
|
||||
expire_text = self._format_expire_text(
|
||||
baseline["elite_expire_time"]
|
||||
)
|
||||
points = baseline["elite_manual_score"]
|
||||
account.bind_status = "elite_opened"
|
||||
account.updated_at = datetime.now(UTC)
|
||||
self._mark_task(
|
||||
db,
|
||||
task,
|
||||
"success",
|
||||
f"精英宝典已开通{expire_text},积分: "
|
||||
f"{points if points is not None else '-'}",
|
||||
{
|
||||
**baseline,
|
||||
"elite_already_opened": True,
|
||||
"payment_polling": False,
|
||||
},
|
||||
)
|
||||
return
|
||||
baseline_points = baseline["elite_manual_score"]
|
||||
# 到账轮询读的是 credit/balance,基线也用同一个接口取,
|
||||
# 避免两个口径不一致时把「余额本来就高」误判成刚到账。
|
||||
try:
|
||||
baseline_points = self._refresh_account_points(
|
||||
client, account, cookie, ctn=ctn
|
||||
)["points"]
|
||||
db.commit()
|
||||
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||||
self._push_log(
|
||||
"warning", f"开通前查询积分基线失败,改用手册积分: {exc}"
|
||||
)
|
||||
|
||||
result = client.create_elite_qr(
|
||||
ctn=ctn,
|
||||
act_alias=act_alias,
|
||||
@@ -225,13 +360,21 @@ class ManualMixin:
|
||||
db, task, "running", "精英宝典支付码已生成,等待开通到账", result
|
||||
)
|
||||
opened = self._wait_points_after_payment(
|
||||
db, task, account, client, cookie, ctn, result
|
||||
db,
|
||||
task,
|
||||
account,
|
||||
client,
|
||||
cookie,
|
||||
ctn,
|
||||
result,
|
||||
baseline_points=baseline_points,
|
||||
)
|
||||
if self._stop.is_set():
|
||||
self._mark_task(db, task, "stopped", "任务已停止", result)
|
||||
return
|
||||
if opened:
|
||||
account.bind_status = "elite_opened"
|
||||
account.elite_opened = True
|
||||
account.updated_at = datetime.now(UTC)
|
||||
self._mark_task(
|
||||
db, task, "success", f"精英宝典已开通,积分: {account.points}", result
|
||||
|
||||
@@ -17,6 +17,7 @@ SUPPORTED_DOUYU_TASK_TYPES = {
|
||||
"get_bind_qr": "获取绑定二维码",
|
||||
"confirm_bind": "确认绑定",
|
||||
"create_elite_qr": "开通精英宝典30",
|
||||
"query_elite_status": "查询精英宝典状态",
|
||||
"prepare_esports_bind": "绑定电竞手册角色",
|
||||
"get_esports_bind_qr": "切换电竞角色",
|
||||
"query_esports_game_name": "查询最新游戏角色",
|
||||
@@ -57,6 +58,7 @@ DOUYU_HANDBOOK_TASK_TYPES = {
|
||||
"get_bind_qr",
|
||||
"confirm_bind",
|
||||
"create_elite_qr",
|
||||
"query_elite_status",
|
||||
"create_gold_qr",
|
||||
"donate_elite_gift",
|
||||
"query_points",
|
||||
|
||||
@@ -340,6 +340,8 @@ export interface DouyuTaskAccountItem {
|
||||
exchange_balance: number | null;
|
||||
bind_status: string;
|
||||
change_role_wait_time: number | null;
|
||||
elite_opened: boolean | null;
|
||||
elite_expire_time: number | null;
|
||||
esports_points: number | null;
|
||||
esports_game_name: string;
|
||||
esports_game_channel: string;
|
||||
|
||||
@@ -43,7 +43,8 @@ import {
|
||||
resultText,
|
||||
taskPayloadText,
|
||||
resultFlag,
|
||||
taskQrUrl,
|
||||
activeTaskQrUrl,
|
||||
eliteStatusText,
|
||||
bindReadyForConfirm,
|
||||
hasBindQrcode,
|
||||
hasPaymentQrcode,
|
||||
@@ -74,6 +75,7 @@ function parseAccountNames(text: string): string[] {
|
||||
export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) {
|
||||
const isPeaceHandbook = handbook === 'peace';
|
||||
const isEsportsHandbook = handbook === 'esports';
|
||||
const isEliteHandbook = handbook === 'elite';
|
||||
const { title: handbookTitle, description: handbookDescription, taskTypes: activeTaskTypes, quickActions } = HANDBOOK_META[handbook];
|
||||
const { can } = usePermissions();
|
||||
const { token } = theme.useToken();
|
||||
@@ -665,10 +667,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
if (!payTask) return;
|
||||
const latest = tasks.find((task) => task.id === payTask.id);
|
||||
if (!latest) return;
|
||||
if (paymentArrived(latest)) {
|
||||
setPayTask(null);
|
||||
return;
|
||||
}
|
||||
// 任务进终态后不再自动关闭弹窗:二维码刚弹出来就消失会让人以为"闪一下就没了",
|
||||
// 改成在弹窗里显示结果,由用户手动关闭。
|
||||
if (latest !== payTask) setPayTask(latest);
|
||||
}, [payTask, tasks]);
|
||||
|
||||
@@ -1239,6 +1239,18 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
width: 80,
|
||||
render: (v) => v ?? <Text type="secondary">-</Text>,
|
||||
},
|
||||
// 精英宝典开通状态:积分区分不了「开通后积分花完」和「从未开通」,单独展示
|
||||
...(isEliteHandbook
|
||||
? [{
|
||||
title: '宝典状态', dataIndex: 'elite_opened', width: 150,
|
||||
render: (_: unknown, record: DouyuTaskAccountItem) => {
|
||||
const text = eliteStatusText(record);
|
||||
if (!text) return <Text type="secondary">未查</Text>;
|
||||
if (record.elite_opened) return <Tag color="green">{text}</Tag>;
|
||||
return <Tag color="default">未开通</Tag>;
|
||||
},
|
||||
}] as NonNullable<TableProps<DouyuTaskAccountItem>['columns']>
|
||||
: []),
|
||||
// 和平小店无鱼翅/限制兑换/换绑时间概念(可无限制换绑),展示扭蛋碎片
|
||||
...(isPeaceHandbook
|
||||
? [{
|
||||
@@ -1348,7 +1360,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
title: '二维码', width: 76, align: 'center',
|
||||
render: (_, record) => {
|
||||
const task = latestTaskByAccount.get(record.id);
|
||||
const url = taskQrUrl(task);
|
||||
// 终态任务(成功/失败/停止)的二维码不再有用,不展示小图
|
||||
const url = activeTaskQrUrl(task);
|
||||
if (!url) return null;
|
||||
const openTask = () => {
|
||||
if (!task) return;
|
||||
@@ -1673,7 +1686,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
<span>开通、充值与送礼</span>
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={6}>
|
||||
{renderActionButton('create_elite_qr', 'primary')}
|
||||
<div style={actionGridStyle}>
|
||||
{renderActionButton('query_elite_status')}
|
||||
{renderActionButton('create_elite_qr', 'primary')}
|
||||
</div>
|
||||
<Tag color={isSupplierGoldRecharge ? 'green' : 'blue'}>{`当前渠道:${goldRechargeChannelLabel}`}</Tag>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<InputNumber
|
||||
@@ -2348,17 +2364,44 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
{payAccountName && (
|
||||
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>{payAccountName}</Text>
|
||||
)}
|
||||
<Space direction="vertical" align="center" size={12}>
|
||||
{payUrl ? (
|
||||
<div ref={payQrCanvasWrapRef}>
|
||||
<QRCode value={payUrl} size={220} bordered={false} />
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">支付码生成中...</Text>
|
||||
)}
|
||||
<QrActions wrapRef={payQrCanvasWrapRef} url={payUrl} disabled={!payUrl} />
|
||||
<Text>{payTask.message || '请扫码支付'}</Text>
|
||||
</Space>
|
||||
{paymentArrived(payTask) ? (
|
||||
// 已到账/已开通:二维码不再有意义,留在弹窗里给结论 + 手动关闭
|
||||
<Space direction="vertical" align="center" size={12}>
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message={payTask.task_type === 'create_elite_qr'
|
||||
? '精英宝典已处理完成'
|
||||
: payTask.task_type === 'create_esports_qr'
|
||||
? '电竞手册已处理完成'
|
||||
: '充值已到账'}
|
||||
description={payTask.message || ''}
|
||||
/>
|
||||
<Button type="primary" onClick={() => setPayTask(null)}>关闭</Button>
|
||||
</Space>
|
||||
) : ['failed', 'error', 'stopped'].includes(payTask.status) ? (
|
||||
<Space direction="vertical" align="center" size={12}>
|
||||
<Alert
|
||||
type={payTask.status === 'stopped' ? 'warning' : 'error'}
|
||||
showIcon
|
||||
message={payTask.status === 'stopped' ? '任务已停止' : '支付码任务未完成'}
|
||||
description={payTask.message || ''}
|
||||
/>
|
||||
<Button onClick={() => setPayTask(null)}>关闭</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space direction="vertical" align="center" size={12}>
|
||||
{payUrl ? (
|
||||
<div ref={payQrCanvasWrapRef}>
|
||||
<QRCode value={payUrl} size={220} bordered={false} />
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">支付码生成中...</Text>
|
||||
)}
|
||||
<QrActions wrapRef={payQrCanvasWrapRef} url={payUrl} disabled={!payUrl} />
|
||||
<Text>{payTask.message || '请扫码支付'}</Text>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -30,6 +30,7 @@ export const TASK_STATUS_LABELS: Record<string, string> = {
|
||||
export const ELITE_QUICK_ACTIONS = [
|
||||
{ key: 'get_bind_qr', icon: <QrcodeOutlined /> },
|
||||
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
|
||||
{ key: 'query_elite_status', icon: <SearchOutlined /> },
|
||||
{ key: 'create_elite_qr', icon: <CreditCardOutlined /> },
|
||||
{ key: 'query_points', icon: <SearchOutlined /> },
|
||||
{ key: 'query_game_name', icon: <SearchOutlined /> },
|
||||
@@ -73,6 +74,7 @@ export const ELITE_TASK_TYPES = new Set([
|
||||
'get_bind_qr',
|
||||
'confirm_bind',
|
||||
'create_elite_qr',
|
||||
'query_elite_status',
|
||||
'create_gold_qr',
|
||||
'donate_elite_gift',
|
||||
'query_points',
|
||||
@@ -154,6 +156,30 @@ export function taskQrUrl(task: DouyuTaskItem | null | undefined): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 任务已进终态后二维码不再有意义(支付已完成/已失败/已停止),列表里不再展示,
|
||||
// 避免大图弹窗关闭后只剩一个小图,看起来像"二维码变小了还能继续付"。
|
||||
const QR_TERMINAL_STATUSES = new Set(['success', 'failed', 'error', 'stopped']);
|
||||
|
||||
export function activeTaskQrUrl(task: DouyuTaskItem | null | undefined): string {
|
||||
if (!task || QR_TERMINAL_STATUSES.has(task.status)) return '';
|
||||
return taskQrUrl(task);
|
||||
}
|
||||
|
||||
// 精英宝典开通状态展示:积分区分不了"开通后积分花完"和"从未开通",
|
||||
// 以后端落库的 manualType(elite_opened)为准。
|
||||
export function eliteStatusText(
|
||||
account: { elite_opened?: boolean | null; elite_expire_time?: number | null },
|
||||
): string {
|
||||
if (account.elite_opened == null) return '';
|
||||
if (!account.elite_opened) return '未开通';
|
||||
const expire = account.elite_expire_time;
|
||||
if (!expire || expire <= 0) return '已开通';
|
||||
const at = new Date(expire * 1000);
|
||||
if (Number.isNaN(at.getTime())) return '已开通';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `已开通 ${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} 到期`;
|
||||
}
|
||||
|
||||
export function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean {
|
||||
if (!task) return false;
|
||||
if (task.task_type === 'get_xpd_bind_qr') {
|
||||
|
||||
Reference in New Issue
Block a user