diff --git a/tests/test_douyu_elite_open_flow.py b/tests/test_douyu_elite_open_flow.py new file mode 100644 index 0000000..291e7c6 --- /dev/null +++ b/tests/test_douyu_elite_open_flow.py @@ -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 diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 9bd0c04..cd993e1 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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 = ( diff --git a/web/backend/migrations/versions/20261001_0036_elite_handbook_status.py b/web/backend/migrations/versions/20261001_0036_elite_handbook_status.py new file mode 100644 index 0000000..47dcadc --- /dev/null +++ b/web/backend/migrations/versions/20261001_0036_elite_handbook_status.py @@ -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) diff --git a/web/backend/models.py b/web/backend/models.py index 26eba62..566c84d 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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="") diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 12ceb91..5367388 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -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 "", diff --git a/web/backend/schemas.py b/web/backend/schemas.py index e013975..f185580 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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 = "" diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 2b1aad4..d8281b7 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -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, diff --git a/web/backend/services/douyu_runner_manual.py b/web/backend/services/douyu_runner_manual.py index 41338fc..6dcd180 100644 --- a/web/backend/services/douyu_runner_manual.py +++ b/web/backend/services/douyu_runner_manual.py @@ -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 diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 1888702..587e62d 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -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", diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 685c4ee..f2ee7c7 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx b/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx index 8de9727..d2dcb5d 100644 --- a/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx @@ -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 ?? -, }, + // 精英宝典开通状态:积分区分不了「开通后积分花完」和「从未开通」,单独展示 + ...(isEliteHandbook + ? [{ + title: '宝典状态', dataIndex: 'elite_opened', width: 150, + render: (_: unknown, record: DouyuTaskAccountItem) => { + const text = eliteStatusText(record); + if (!text) return 未查; + if (record.elite_opened) return {text}; + return 未开通; + }, + }] as NonNullable['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 }) 开通、充值与送礼 - {renderActionButton('create_elite_qr', 'primary')} +
+ {renderActionButton('query_elite_status')} + {renderActionButton('create_elite_qr', 'primary')} +
{`当前渠道:${goldRechargeChannelLabel}`} {payAccountName} )} - - {payUrl ? ( -
- -
- ) : ( - 支付码生成中... - )} - - {payTask.message || '请扫码支付'} -
+ {paymentArrived(payTask) ? ( + // 已到账/已开通:二维码不再有意义,留在弹窗里给结论 + 手动关闭 + + + + + ) : ['failed', 'error', 'stopped'].includes(payTask.status) ? ( + + + + + ) : ( + + {payUrl ? ( +
+ +
+ ) : ( + 支付码生成中... + )} + + {payTask.message || '请扫码支付'} +
+ )} )} diff --git a/web/frontend/src/pages/douyu-tasks/config.tsx b/web/frontend/src/pages/douyu-tasks/config.tsx index 2af4a6f..8f96ff2 100644 --- a/web/frontend/src/pages/douyu-tasks/config.tsx +++ b/web/frontend/src/pages/douyu-tasks/config.tsx @@ -30,6 +30,7 @@ export const TASK_STATUS_LABELS: Record = { export const ELITE_QUICK_ACTIONS = [ { key: 'get_bind_qr', icon: }, { key: 'confirm_bind', icon: }, + { key: 'query_elite_status', icon: }, { key: 'create_elite_qr', icon: }, { key: 'query_points', icon: }, { key: 'query_game_name', icon: }, @@ -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') {