From b7f34df04de40b34c89405d4d249d52ddd32c797 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 7 Nov 2026 20:33:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E5=A2=9E=E5=8A=A0=E5=BE=AE?= =?UTF-8?q?=E5=8D=9A=E5=BE=AE=E8=B1=86=E5=9F=BA=E7=A1=80=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/weibo/__init__.py | 39 + core/weibo/client.py | 899 +++++++++++++++ ...博微豆-CK接口与兑换链路确认-2026-11-07.md | 696 ++++++++++++ tests/test_migrations.py | 2 +- tests/test_weibo_accounts.py | 404 +++++++ tests/test_weibo_client.py | 530 +++++++++ web/backend/crypto_storage.py | 1 + web/backend/main.py | 2 + .../versions/20261107_0068_weibo_accounts.py | 55 + .../versions/20261107_0069_weibo_activity.py | 34 + web/backend/models.py | 38 + web/backend/permissions.py | 13 + web/backend/routers/weibo.py | 602 ++++++++++ web/backend/schemas.py | 154 +++ web/backend/services/weibo_service.py | 284 +++++ web/frontend/src/App.tsx | 2 + web/frontend/src/api/modules.ts | 1 + web/frontend/src/api/types.ts | 132 +++ web/frontend/src/api/weibo.ts | 84 ++ web/frontend/src/layouts/MainLayout.tsx | 9 + web/frontend/src/pages/WeiboAccountsPage.tsx | 1009 +++++++++++++++++ 21 files changed, 4989 insertions(+), 1 deletion(-) create mode 100644 core/weibo/__init__.py create mode 100644 core/weibo/client.py create mode 100644 docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md create mode 100644 tests/test_weibo_accounts.py create mode 100644 tests/test_weibo_client.py create mode 100644 web/backend/migrations/versions/20261107_0068_weibo_accounts.py create mode 100644 web/backend/migrations/versions/20261107_0069_weibo_activity.py create mode 100644 web/backend/routers/weibo.py create mode 100644 web/backend/services/weibo_service.py create mode 100644 web/frontend/src/api/weibo.ts create mode 100644 web/frontend/src/pages/WeiboAccountsPage.tsx diff --git a/core/weibo/__init__.py b/core/weibo/__init__.py new file mode 100644 index 0000000..279e735 --- /dev/null +++ b/core/weibo/__init__.py @@ -0,0 +1,39 @@ +"""微博微豆基础客户端。""" + +from .client import ( + WEIBO_HOME_URL, + WeiboApiError, + WeiboAuthError, + WeiboBindError, + WeiboClient, + WeiboError, + WeiboExchangeError, + WeiboExchangeResult, + WeiboExchangeUncertainError, + WeiboHomeSnapshot, + WeiboPaymentStatus, + WeiboRechargeError, + WeiboRechargeOrder, + WeiboTransportError, + extract_bind_raw_url, + normalize_weibo_cookie, +) + +__all__ = [ + "WEIBO_HOME_URL", + "WeiboApiError", + "WeiboAuthError", + "WeiboBindError", + "WeiboClient", + "WeiboError", + "WeiboExchangeError", + "WeiboExchangeResult", + "WeiboExchangeUncertainError", + "WeiboHomeSnapshot", + "WeiboPaymentStatus", + "WeiboRechargeError", + "WeiboRechargeOrder", + "WeiboTransportError", + "extract_bind_raw_url", + "normalize_weibo_cookie", +] diff --git a/core/weibo/client.py b/core/weibo/client.py new file mode 100644 index 0000000..a47e742 --- /dev/null +++ b/core/weibo/client.py @@ -0,0 +1,899 @@ +"""微博微豆 CK、资料、商城、充值账单与绑定 Scheme 客户端。 + +覆盖手动触发的只读查询和充值下单: +- 微博 CK -> 微豆首页资料 +- bind_url -> 微信/QQ 小程序 URL Scheme +- 商品列表、兑换记录、微豆获取明细 +- 创建充值订单并解析支付二维码,不自动完成支付 + +不提交角色绑定、不自动兑换。 +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from html.parser import HTMLParser +from typing import Any +from urllib.parse import parse_qs, urljoin, urlsplit + +import requests + +WEIBO_HOME_URL = "https://vipclub.e.weibo.com/aj/mall/home" +WEIBO_API_BASE = "https://vipclub.e.weibo.com/aj" +WEIBO_VPLUS_API_BASE = "https://vipclub.weibo.com/aj" +WEIBO_PAY_BASE = "https://pay.sc.weibo.com" +WEIBO_SCHEME_URL = "https://livelinkbind.game.qq.com/livelink" +WEIBO_MOBILE_UA = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Weibo" +) +WEIBO_DESKTOP_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/156.0.0.0 Safari/537.36" +) +WEIBO_AUTH_ERROR_CODES = {100002, 100004, 400000, 400002} + + +class WeiboError(RuntimeError): + """微博基础流程的基类异常。""" + + +class WeiboTransportError(WeiboError): + """网络或 HTTP 层错误。""" + + +class WeiboAuthError(WeiboError): + """CK 无效或登录态失效。""" + + +class WeiboApiError(WeiboError): + """微博业务接口返回非成功状态。""" + + +class WeiboBindError(WeiboError): + """绑定链接或二维码 Scheme 无法生成。""" + + +class WeiboRechargeError(WeiboError): + """充值下单、收银台或支付状态解析失败。""" + + +class WeiboExchangeError(WeiboError): + """兑换前校验失败或服务端明确拒绝。""" + + +class WeiboExchangeUncertainError(WeiboExchangeError): + """兑换请求已发出但结果无法确认。""" + + +@dataclass(frozen=True, slots=True) +class WeiboHomeSnapshot: + """微豆首页中基础账号流程需要的字段。""" + + uid: str + nickname: str + is_bound: bool + role_name: str + can_rebind: bool + rebind_remain_days: int + weidou_balance: int + bind_url: str + recharge_url: str = "" + activity: str = "" + + +@dataclass(frozen=True, slots=True) +class WeiboRechargeOrder: + order_id: str + pay_id: str + amount_yuan: int + single_reward: int + alipay_qr_code: str + desktop_pay_url: str + + +@dataclass(frozen=True, slots=True) +class WeiboPaymentStatus: + pay_id: str + out_pay_id: str + status: str + sub_status: str + + +@dataclass(frozen=True, slots=True) +class WeiboExchangeResult: + goods_id: int + goods_name: str + price: int + balance_before: int + balance_after: int | None + remain_before: int | None + remain_after: int | None + verified: bool + verify_error: str = "" + + +class _CashierParser(HTMLParser): + """从微博 PC 收银台 HTML 中提取支付要素。""" + + def __init__(self) -> None: + super().__init__() + self.pay_id = "" + self.desktop_pay_url = "" + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = {name: value or "" for name, value in attrs} + if tag == "input" and ( + values.get("name") == "pay_id" or values.get("node-type") == "pay_id" + ): + self.pay_id = values.get("value", "").strip() + elif tag == "a" and values.get("href", "").startswith( + f"{WEIBO_PAY_BASE}/pay/pc/comb?" + ): + self.desktop_pay_url = values["href"].strip() + + +class _AlipayCashierParser(HTMLParser): + """从支付宝电脑收银台 HTML 提取实际支付二维码内容。""" + + def __init__(self) -> None: + super().__init__() + self.qr_code = "" + self.qr_image_url = "" + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag != "input": + return + values = {name: value or "" for name, value in attrs} + input_id = values.get("id", "") + input_name = values.get("name", "") + value = values.get("value", "").strip() + if input_id == "J_qrCode" or input_name == "qrCode": + self.qr_code = value + elif input_id == "J_qrImgUrl" or input_name == "qrImgUrl": + self.qr_image_url = value + + +class _HtmlFormParser(HTMLParser): + """提取简单 HTML 表单,用于跟随支付宝网关的自动提交页。""" + + def __init__(self) -> None: + super().__init__() + self.action = "" + self.method = "GET" + self.fields: dict[str, str] = {} + self._inside_form = False + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = {name: value or "" for name, value in attrs} + if tag == "form" and not self.action: + self.action = values.get("action", "").strip() + self.method = values.get("method", "GET").strip().upper() or "GET" + self._inside_form = True + elif tag == "input" and self._inside_form: + name = values.get("name", "").strip() + if name: + self.fields[name] = values.get("value", "") + + def handle_endtag(self, tag: str) -> None: + if tag == "form": + self._inside_form = False + + +def normalize_weibo_cookie(raw: str) -> str: + """把粘贴内容整理成一行 Cookie。 + + 支持带 ``Cookie:`` 前缀、多行 Cookie 和普通分号格式。不解析或改写值, + 避免破坏微博 Cookie 中可能包含的百分号编码。 + """ + + text = (raw or "").strip() + if not text: + raise ValueError("CK 为空") + if text.lower().startswith("cookie:"): + text = text.split(":", 1)[1].strip() + + normalized = text.replace("\r", ";").replace("\n", ";") + pairs: list[str] = [] + for part in normalized.split(";"): + item = part.strip() + if not item: + continue + if "=" not in item: + raise ValueError(f"Cookie 片段格式错误: {item[:40]}") + pairs.append(item) + if not pairs: + raise ValueError("CK 为空") + return "; ".join(pairs) + + +def _parse_cookie_pairs(cookie: str) -> dict[str, str]: + pairs: dict[str, str] = {} + for item in cookie.split(";"): + name, separator, value = item.strip().partition("=") + if separator and name: + pairs[name] = value + return pairs + + +def _extract_subscribe_url(action_url: str) -> str: + """把 ``sinaweibo://transparentbrowser`` 还原成实际 V+ 页面 URL。""" + + value = (action_url or "").strip() + if not value: + return "" + if value.startswith("sinaweibo://transparentbrowser?"): + query = parse_qs(urlsplit(value).query, keep_blank_values=True) + return str((query.get("url") or [""])[0]).strip() + return value + + +def _subscribe_params(action_url: str) -> dict[str, str]: + url = _extract_subscribe_url(action_url) + if not url: + return {} + query = parse_qs(urlsplit(url).query, keep_blank_values=True) + return {key: str((values or [""])[0]) for key, values in query.items()} + + +def extract_bind_raw_url(bind_url: str) -> str: + """从微博 ``sinaweibo://wbdiversion`` 链接提取来联内层 Query。 + + outer query 只解码一次,得到 ``path``;内层 ``code/faceUrl`` 仍保留 + 抓包中的多重编码,不能再 unquote。 + """ + + value = (bind_url or "").strip() + if not value: + raise WeiboBindError("微博未返回 bind_url") + + try: + outer = parse_qs(urlsplit(value).query, keep_blank_values=True) + except ValueError as exc: + raise WeiboBindError("微博 bind_url 格式错误") from exc + path = (outer.get("path") or [""])[0] + marker = "/pages/gameAccountBind/index?" + if marker not in path: + raise WeiboBindError("bind_url 中缺少绑定小程序路径") + raw_url = path.split(marker, 1)[1] + if not raw_url: + raise WeiboBindError("bind_url 内层参数为空") + return raw_url + + +class WeiboClient: + """微博微豆基础客户端。""" + + def __init__( + self, + cookie: str = "", + *, + timeout: float = 20.0, + session: requests.Session | None = None, + ): + self.cookie = normalize_weibo_cookie(cookie) if cookie else "" + self.timeout = timeout + self.session = session or requests.Session() + self.session.headers.update( + { + "User-Agent": WEIBO_MOBILE_UA, + "Accept": "application/json, text/plain, */*", + } + ) + # 微博 Cookie 只挂到 .weibo.com,避免把 CK 发给来联/QQ 或支付宝域名。 + for name, value in _parse_cookie_pairs(self.cookie).items(): + self.session.cookies.set(name, value, domain=".weibo.com", path="/") + + def _request_json( + self, + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: + try: + response = self.session.request( + method, + url, + headers=headers, + params=params, + data=data, + json=json, + timeout=self.timeout, + ) + response.raise_for_status() + payload = response.json() + except requests.RequestException as exc: + raise WeiboTransportError(f"请求微博失败: {exc}") from exc + except ValueError as exc: + raise WeiboTransportError("微博返回内容不是合法 JSON") from exc + if not isinstance(payload, dict): + raise WeiboTransportError("微博返回 JSON 结构异常") + return payload + + def _request_text( + self, + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + ) -> str: + try: + response = self.session.request( + method, + url, + headers=headers, + params=params, + data=data, + timeout=self.timeout, + ) + response.raise_for_status() + return response.text + except requests.RequestException as exc: + raise WeiboTransportError(f"请求微博收银台失败: {exc}") from exc + + def _alipay_qr_from_page(self, html: str, page_url: str) -> str: + """穿过支付宝网关自动提交表单,提取扫码支付内容。""" + + current_html = html + current_url = page_url + for _ in range(4): + cashier = _AlipayCashierParser() + cashier.feed(current_html) + if cashier.qr_code: + return cashier.qr_code + + form = _HtmlFormParser() + form.feed(current_html) + if not form.action: + break + action_url = urljoin(current_url, form.action) + current_html = self._request_text( + form.method, + action_url, + headers={ + "User-Agent": WEIBO_DESKTOP_UA, + "Referer": current_url, + }, + params=form.fields if form.method == "GET" else None, + data=form.fields if form.method != "GET" else None, + ) + current_url = action_url + raise WeiboRechargeError("支付宝收银台没有返回扫码支付内容") + + def _weibo_api( + self, + method: str, + path: str, + *, + params: dict[str, Any], + data: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + base: str = WEIBO_API_BASE, + ) -> dict[str, Any]: + payload = self._request_json( + method, + f"{base}{path}", + params=params, + data=data, + headers=headers, + ) + code = payload.get("code") + if code in WEIBO_AUTH_ERROR_CODES: + raise WeiboAuthError(str(payload.get("msg") or "微博 CK 已失效")) + if code != 100000: + raise WeiboApiError(str(payload.get("msg") or f"微博接口返回 code={code}")) + return payload + + def fetch_home(self, activity: str = "") -> WeiboHomeSnapshot: + """读取微豆首页并校验 CK。""" + + if not self.cookie: + raise WeiboAuthError("CK 为空") + url = f"{WEIBO_HOME_URL}?F={activity}&_t={int(time.time() * 1000)}" + payload = self._request_json( + "GET", + url, + headers={ + "Referer": f"https://vipclub.e.weibo.com/game/homepage?F={activity}", + }, + ) + code = payload.get("code") + if code in WEIBO_AUTH_ERROR_CODES: + raise WeiboAuthError(str(payload.get("msg") or "微博 CK 已失效")) + if code != 100000: + trace_id = str(payload.get("trace_id") or "") + message = str(payload.get("msg") or f"微博接口返回 code={code}") + if trace_id: + message = f"{message} (trace_id={trace_id})" + raise WeiboApiError(message) + + data = payload.get("data") or {} + user_info = data.get("user_info") or {} + game_role = data.get("game_role") or {} + balance = data.get("weidou_balance") or {} + recharge_url = "" + for task in data.get("task_list") or []: + if not isinstance(task, dict) or task.get("button_type") != "cashier": + continue + candidate = _extract_subscribe_url(str(task.get("action_url") or "")) + if candidate: + recharge_url = candidate + break + subscribe = _subscribe_params(recharge_url) + resolved_activity = subscribe.get("F") or activity + uid = str(user_info.get("uid") or "").strip() + bind_url = str(game_role.get("bind_url") or "").strip() + if not uid: + raise WeiboApiError("微博首页没有返回 uid") + + return WeiboHomeSnapshot( + uid=uid, + nickname=str(user_info.get("nickname") or ""), + is_bound=bool(game_role.get("is_bound")), + role_name=str(game_role.get("role_name") or ""), + can_rebind=bool(game_role.get("can_rebind")), + rebind_remain_days=int(game_role.get("rebind_remain_days") or 0), + weidou_balance=int(balance.get("balance") or 0), + bind_url=bind_url, + recharge_url=recharge_url, + activity=resolved_activity, + ) + + def list_mall(self, activity: str = "") -> dict[str, Any]: + """查询微豆商城商品列表。""" + + payload = self._weibo_api( + "GET", + "/mall/list", + params={"F": activity, "_t": int(time.time() * 1000)}, + ) + return payload.get("data") or {} + + def exchange_goods( + self, + goods_id: int, + *, + expected_price: int, + activity: str = "", + ) -> WeiboExchangeResult: + """手动兑换商品:预检后只提交一次,成功后尽力复核。""" + + before = self.list_mall(activity) + goods_list = before.get("list") or [] + goods = next( + ( + item + for item in goods_list + if isinstance(item, dict) and int(item.get("id") or 0) == goods_id + ), + None, + ) + if goods is None: + raise WeiboExchangeError("商品不存在或已下架") + price = int(goods.get("price") or 0) + balance_before = int(before.get("weidou_balance") or 0) + remain_before = ( + int(goods["remain_count"]) + if goods.get("remain_count") is not None + else None + ) + if expected_price > 0 and price != expected_price: + raise WeiboExchangeError( + f"商品价格已变化:页面 {expected_price},当前 {price}" + ) + if goods.get("is_sold_out") or remain_before == 0: + raise WeiboExchangeError("商品库存不足") + if balance_before < price: + raise WeiboExchangeError( + f"微豆余额不足:当前 {balance_before},需要 {price}" + ) + + try: + payload = self._weibo_api( + "GET", + "/game/exchangegoods", + params={ + "F": activity, + "goods_id": goods_id, + "_t": int(time.time() * 1000), + }, + headers={ + "User-Agent": WEIBO_DESKTOP_UA, + "Referer": f"https://vipclub.e.weibo.com/game/mall?F={activity}", + }, + ) + except WeiboTransportError as exc: + raise WeiboExchangeUncertainError( + "兑换请求已发出,但网络中断导致结果未知;请先查兑换记录,不要立即重试" + ) from exc + if payload.get("code") != 100000: + raise WeiboExchangeError( + str(payload.get("msg") or f"兑换失败 code={payload.get('code')}") + ) + + balance_after: int | None = None + remain_after: int | None = None + verified = False + verify_error = "" + try: + after = self.list_mall(activity) + balance_after = int(after.get("weidou_balance") or 0) + refreshed = next( + ( + item + for item in (after.get("list") or []) + if isinstance(item, dict) and int(item.get("id") or 0) == goods_id + ), + None, + ) + if refreshed and refreshed.get("remain_count") is not None: + remain_after = int(refreshed["remain_count"]) + verified = balance_after != balance_before or ( + remain_after is not None + and remain_before is not None + and remain_after != remain_before + ) + if not verified: + verify_error = "兑换成功,但余额和库存暂未变化,请查兑换记录复核" + except (WeiboError, ValueError, TypeError) as exc: + verify_error = f"兑换已提交,但复核失败: {exc}" + + return WeiboExchangeResult( + goods_id=goods_id, + goods_name=str(goods.get("goods_name") or ""), + price=price, + balance_before=balance_before, + balance_after=balance_after, + remain_before=remain_before, + remain_after=remain_after, + verified=verified, + verify_error=verify_error, + ) + + def list_exchange_records( + self, + activity: str = "", + *, + page: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """查询微豆兑换记录。""" + + payload = self._weibo_api( + "GET", + "/mall/exchange", + params={ + "F": activity, + "page": page, + "page_size": page_size, + "_t": int(time.time() * 1000), + }, + ) + return payload.get("data") or {} + + def list_point_history( + self, + activity: str = "", + *, + page: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """查询微豆获取/充值明细。""" + + payload = self._weibo_api( + "GET", + "/point/get_point_recharge_history", + params={ + "F": activity, + "page_no": page, + "page_size": page_size, + "_t": int(time.time() * 1000), + }, + ) + return payload.get("data") or {} + + def _load_recharge_context( + self, + activity: str, + ) -> tuple[dict[str, str], dict[str, Any], dict[str, Any]]: + home = self.fetch_home(activity) + if not home.recharge_url: + raise WeiboRechargeError("当前活动没有可用的助威充值入口") + subscribe = _subscribe_params(home.recharge_url) + vuid = subscribe.get("vuid", "") + source_itemid = subscribe.get("source_itemid", "") + if not vuid or not source_itemid: + raise WeiboRechargeError("充值入口缺少 vuid 或 source_itemid") + resolved_activity = subscribe.get("F") or home.activity or activity + subscribe["F"] = resolved_activity + page_headers = { + "User-Agent": WEIBO_DESKTOP_UA, + "Referer": home.recharge_url, + } + + self._weibo_api( + "GET", + "/vmember/gfsupport", + params={ + "vuid": vuid, + "source_itemid": source_itemid, + "F": resolved_activity, + "_t": int(time.time() * 1000), + }, + base=WEIBO_VPLUS_API_BASE, + headers=page_headers, + ) + user_payload = self._weibo_api( + "GET", + "/paycomponent/userinfo", + params={ + "vuid": vuid, + "source_itemid": source_itemid, + "_t": int(time.time() * 1000), + }, + base=WEIBO_VPLUS_API_BASE, + headers=page_headers, + ) + user_data = user_payload.get("data") or {} + tabs = user_data.get("tab_info") or [] + current_tab = next( + (tab for tab in tabs if isinstance(tab, dict) and tab.get("is_current")), + tabs[0] if tabs and isinstance(tabs[0], dict) else {}, + ) + tab_id = str(current_tab.get("tab_id") or "") + cashier_id = str( + current_tab.get("cashier_id") or user_data.get("cashier_id") or "" + ) + if not tab_id or not cashier_id: + raise WeiboRechargeError("收银台没有返回 tab_id 或 cashier_id") + + cashier_payload = self._weibo_api( + "GET", + "/paycomponent/cashierinfo", + params={ + "F": resolved_activity, + "appName": "", + "from": "", + "wm": "", + "profileuid": "", + "tab_id": tab_id, + "cashier_id": cashier_id, + "device_id": "", + "tab_type": "", + "cashier_version": "0.2.3", + "vuid": vuid, + "bid": "", + "source_itemid": source_itemid, + }, + base=WEIBO_VPLUS_API_BASE, + headers=page_headers, + ) + return subscribe, current_tab, cashier_payload.get("data") or {} + + def _recharge_bid( + self, + vuid: str, + source_itemid: str, + tab_type: str, + activity: str, + ) -> str: + payload = self._weibo_api( + "GET", + "/paycomponent/privilege", + params={ + "tab_type": tab_type, + "vuid": vuid, + "F": activity, + "_t": int(time.time() * 1000), + }, + base=WEIBO_VPLUS_API_BASE, + headers={ + "User-Agent": WEIBO_DESKTOP_UA, + "Referer": f"https://vipclub.weibo.com/vmember/subscribe?vuid={vuid}&source_itemid={source_itemid}&F={activity}", + }, + ) + data = payload.get("data") or {} + privilege = data.get("privilege") or {} + url = str(privilege.get("url") or "") + return str((parse_qs(urlsplit(url).query).get("bid") or [""])[0]) + + def create_recharge_order( + self, + *, + amount_yuan: int, + activity: str = "", + ) -> WeiboRechargeOrder: + """创建人工充值订单并解析支付二维码,不执行支付。""" + + if amount_yuan < 1: + raise WeiboRechargeError("充值金额至少为 1 元") + subscribe, current_tab, cashier_data = self._load_recharge_context(activity) + tabs = cashier_data.get("tab_info") or [] + tab = next( + ( + item + for item in tabs + if isinstance(item, dict) and item.get("is_current") + ), + tabs[0] if tabs and isinstance(tabs[0], dict) else current_tab, + ) + menus = tab.get("menu") or [] + menu = next( + ( + item + for item in menus + if isinstance(item, dict) + and item.get("max_money") is not None + and str(item.get("subitemid") or "") + ), + None, + ) + if menu is None: + raise WeiboRechargeError("收银台没有返回自定义微豆充值档位") + max_money = int(menu.get("max_money") or 0) + if max_money and amount_yuan > max_money: + raise WeiboRechargeError(f"充值金额不能超过 {max_money} 元") + + vuid = subscribe["vuid"] + source_itemid = subscribe["source_itemid"] + resolved_activity = subscribe["F"] + tab_type = str(tab.get("tab_type") or current_tab.get("tab_type") or "") + cashier_id = str(tab.get("cashier_id") or current_tab.get("cashier_id") or "") + bid = self._recharge_bid( + vuid, + source_itemid, + tab_type, + resolved_activity, + ) + single_reward = amount_yuan * 100 + ext = json.dumps( + {"bid": bid, "send_blog": 1}, + ensure_ascii=False, + separators=(",", ":"), + ) + params = { + "subitemid": str(menu.get("subitemid") or ""), + "itemid": str(menu.get("itemid") or ""), + "appkey": str(menu.get("appkey") or ""), + "commodity_id": str(menu.get("commodity_id") or ""), + "num": "1", + "salestrategy_id": "", + "discount_id": "", + "from": "", + "device_id": "", + "level_up_tag": "0", + "cashier_id": cashier_id, + "F": resolved_activity, + "to_uid": "", + "is_from_cashier": "1", + "return_url": "", + "ext": ext, + "ori_t_id": "", + "will_auto_renew": "0", + "cashierpw": "1", + "subpw": "", + "account": "", + "follow": "false", + "coupon_id": "", + "vuid": vuid, + "tab_type": tab_type, + "cashier_version": "0.2.3", + "gc": "", + "bid": "", + "mid": "", + "single_reward": str(single_reward), + } + payload = self._request_json( + "POST", + f"{WEIBO_VPLUS_API_BASE}/paycomponent/createorder", + params=params, + data=params, + headers={ + "User-Agent": WEIBO_DESKTOP_UA, + "Origin": "https://vipclub.weibo.com", + "Referer": f"https://vipclub.weibo.com/vmember/subscribe?vuid={vuid}&source_itemid={source_itemid}&F={resolved_activity}", + }, + ) + if payload.get("code") in WEIBO_AUTH_ERROR_CODES: + raise WeiboAuthError(str(payload.get("msg") or "微博 CK 已失效")) + if payload.get("code") != 100000: + raise WeiboRechargeError(str(payload.get("msg") or "创建充值订单失败")) + order_data = payload.get("data") or {} + cashier_url = str(order_data.get("url") or "") + order_id = str(order_data.get("orderid") or "") + if not cashier_url or not order_id: + raise WeiboRechargeError("充值订单缺少支付地址或订单号") + + html = self._request_text( + "GET", + cashier_url, + headers={ + "User-Agent": WEIBO_DESKTOP_UA, + "Referer": "https://vipclub.weibo.com/", + }, + ) + parser = _CashierParser() + parser.feed(html) + if not parser.pay_id: + raise WeiboRechargeError("收银台页面没有返回 pay_id") + if not parser.desktop_pay_url: + raise WeiboRechargeError("收银台页面没有返回电脑端支付链接") + + alipay_html = self._request_text( + "GET", + parser.desktop_pay_url, + headers={ + "User-Agent": WEIBO_DESKTOP_UA, + "Referer": "https://pay.sc.weibo.com/pay/pc/cashier", + }, + ) + alipay_qr_code = self._alipay_qr_from_page( + alipay_html, + parser.desktop_pay_url, + ) + return WeiboRechargeOrder( + order_id=order_id, + pay_id=parser.pay_id, + amount_yuan=amount_yuan, + single_reward=single_reward, + alipay_qr_code=alipay_qr_code, + desktop_pay_url=parser.desktop_pay_url, + ) + + def get_payment_status(self, pay_id: str) -> WeiboPaymentStatus: + """手动查询充值支付状态。""" + + if not pay_id.strip(): + raise ValueError("pay_id 为空") + payload = self._request_json( + "POST", + f"{WEIBO_PAY_BASE}/aj/pay/status", + data={"pay_id": pay_id, "ispage": "1", "_t": "0"}, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": WEIBO_DESKTOP_UA, + "X-Requested-With": "XMLHttpRequest", + "Origin": WEIBO_PAY_BASE, + "Referer": f"{WEIBO_PAY_BASE}/pay/pc/cashier", + }, + ) + if str(payload.get("code")) != "100000": + raise WeiboRechargeError(str(payload.get("msg") or "查询支付状态失败")) + data = payload.get("data") or {} + return WeiboPaymentStatus( + pay_id=str(data.get("pay_id") or pay_id), + out_pay_id=str(data.get("out_pay_id") or ""), + status=str(data.get("status") or ""), + sub_status=str(data.get("sub_status") or ""), + ) + + def generate_bind_scheme(self, bind_url: str, account_type: str) -> str: + """生成微信或 QQ 小程序 URL Scheme。""" + + if account_type not in {"wx", "qq"}: + raise ValueError("account_type 只能是 wx 或 qq") + raw_url = extract_bind_raw_url(bind_url) + url = f"{WEIBO_SCHEME_URL}?c=Sdk&a=GenMiniappScheme&{raw_url}" + payload = self._request_json( + "POST", + url, + headers={"Content-Type": "application/json"}, + json={"accountType": account_type}, + ) + if payload.get("iRet") != 0: + raise WeiboBindError( + f"生成绑定 Scheme 失败: {payload.get('sMsg') or payload}" + ) + scheme = str((payload.get("jData") or {}).get("urlScheme") or "").strip() + if not scheme: + raise WeiboBindError("来联接口没有返回 urlScheme") + return scheme diff --git a/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md b/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md new file mode 100644 index 0000000..af7ec6c --- /dev/null +++ b/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md @@ -0,0 +1,696 @@ +--- +type: 协议确认 +description: 微博微豆自动化唯一协议,覆盖 CK 登录态、微信/QQ 绑定二维码、助力充值、支付二维码、微豆明细、商城列表、兑换和兑换记录。 +tags: [微博, 微豆, VPlus, 助威, 充值, 兑换, 微信小程序, QQ小程序, URL Scheme, 二维码, CK] +generated: + by: ai/codex + at: 2026-11-07T19:03:41+08:00 +status: stable +verified: + - { by: ai/codex, at: 2026-11-07T19:03:41+08:00 } +--- + +# 微博微豆 CK 接口与兑换链路确认 + +确认日期:2026-11-07 + +原始样本:用户提供的 `充值1元-查询明细-兑换列表-兑换1天会员.har`。该 HAR 含账号 Cookie、用户标识、昵称和支付数据,不纳入仓库。 + +## 1. 结论 + +微博业务接口只需要微博登录 Cookie(下文简称 CK),未观察到独立的 access token、CSRF token 或客户端签名 Header。绑定二维码转换接口 `GenMiniappScheme` 不需要微博、微信或 QQ 的账号 Cookie,但仍依赖微博链接内层的 `code`、`nonce`、`sig`、`t` 业务参数。 + +核心链路: + +```text +CK + F + -> GET /aj/mall/home 获取用户、绑定状态、余额、任务、商城入口 + | + +-> 未绑定:game_role.bind_url + | -> POST GenMiniappScheme + | -> 微信/QQ urlScheme + | -> 生成绑定二维码 + | + +-> 助威得微豆: + GET /aj/vmember/gfsupport 获取助力目标 uid/vuid + GET /aj/paycomponent/cashierinfo 获取收银台、商品档位、活动参数 + POST /aj/paycomponent/createorder 创建充值订单 + 打开 data.url,读取收银台 HTML 中的支付二维码 + POST /aj/pay/status 轮询支付状态 + GET /aj/point/get_point_recharge_history 查询微豆获取明细 + | + +-> 微豆兑换: + GET /aj/mall/list 查询商城列表与余额 + GET /aj/mall/detail 查询商品详情 + GET /aj/game/exchangegoods 执行兑换 + GET /aj/mall/list 复核余额和库存 + GET /aj/mall/exchange 查询兑换记录 +``` + +## 2. 共同会话与 Header + +### 2.1 必需状态 + +- 微博 CK:必需。实测不带 Cookie 时: + - `/aj/mall/home` 返回 `code=400002`、`msg=未登陆`。 + - `/aj/point/get_point_recharge_history` 返回 `code=400000`、`msg=user not login`。 +- `F`:活动来源标识。样本为 `bowen_tjg`,应作为活动入口参数配置,不要推导算法。 +- `uid`:微博用户 ID,从 `/aj/vmember/gfsupport`、收银台响应或页面状态获取。 +- `vuid`:被助威的 V+ 账号 ID,从 `/vmember/subscribe` URL、`task_list.action_url` 或对应接口获取。 + +CK 是否可缩减为只有 `SUB` 尚未验证。样本请求中同时出现 `SUB`、`SUBP`、`SCF`、`ALF`、`PC_TOKEN`、`v!pay` 等 Cookie,建议先维护完整 Cookie Jar,再逐步做最小化测试。 + +### 2.2 请求头 + +样本未观察到: + +- `Authorization` +- `X-CSRF-Token` +- 自定义签名 Header +- 客户端加密 Body + +GET 请求至少携带: + +```http +Cookie: <微博 CK> +Referer: <对应页面 URL> +``` + +POST 请求使用: + +```http +Content-Type: application/x-www-form-urlencoded +Origin: https://vipclub.weibo.com +Referer: https://vipclub.weibo.com/vmember/subscribe?... +Cookie: <微博 CK> +``` + +前端每个 GET 请求都会自动追加毫秒时间戳: + +```text +_t= +``` + +`_t` 是缓存破坏参数,未观察到参与签名。 + +## 3. 用户、绑定和任务入口 + +```http +GET https://vipclub.e.weibo.com/aj/mall/home?F=&_t=<毫秒时间戳> +``` + +关键响应字段: + +| 字段 | 含义 | +| --- | --- | +| `data.user_info.uid` | 当前登录微博 uid | +| `data.game_role.is_bound` | 是否已绑定游戏角色 | +| `data.game_role.role_name` | 已绑定角色 | +| `data.game_role.bind_url` | 微博小程序绑定链接 | +| `data.game_role.can_rebind` | 当前是否允许换绑 | +| `data.game_role.rebind_remain_days` | 下次可换绑的剩余天数 | +| `data.weidou_balance.balance` | 当前微豆余额 | +| `data.task_list` | 微豆任务列表 | +| `data.recommend_list` | 推荐商品 | +| `data.mall_url` | 商城入口 | + +### 3.1 微信/QQ 绑定二维码 + +本次 HAR 的状态是: + +```json +{ + "is_bound": true, + "can_rebind": false +} +``` + +因此本次没有重新走绑定动作。页面逻辑是: + +- `is_bound=false`:展示“绑定角色”,点击后打开 `game_role.bind_url`。 +- `is_bound=true && can_rebind=true`:进入换绑。 +- `is_bound=true && can_rebind=false`:前端直接提示剩余天数,不打开链接。 + +`bind_url` 形如: + +```text +sinaweibo://wbdiversion?username=&path= +``` + +输入也可能直接来自其他微博小程序跳转: + +```text +sinaweibo://wbdiversion?username=gh_&path=/pages/gameAccountBind/index? +``` + +其中: + +- `username=gh_...` 是微博侧小程序标识,不传给来联接口。 +- `sinaweibo://wbdiversion` 只用于微博客户端唤起,不传给来联接口。 +- `` 是 `path=/pages/gameAccountBind/index?` 后面的全部原始 Query。 + +实测内层业务参数包括: + +```text +actId +code +faceUrl +gameId +gameIdList +livePlatId +nickName +nonce +sig +t +v +``` + +必须保留原始百分号编码。不要先 `URLDecoder`、`parse_qs` 或重新拼 Query,否则 `code`、`faceUrl` 中的多重编码可能被改变。 + +#### 3.1.1 生成微信/QQ URL Scheme + +页面入口是: + +```text +https://livelink.qq.com/h5-open-mp/wx? +https://livelink.qq.com/h5-open-mp/qq? +``` + +PC 页面不会直接展示二维码,而是请求: + +```http +POST https://livelinkbind.game.qq.com/livelink?c=Sdk&a=GenMiniappScheme& +Content-Type: application/json +``` + +微信请求体: + +```json +{"accountType":"wx"} +``` + +QQ 请求体: + +```json +{"accountType":"qq"} +``` + +成功响应: + +```json +{ + "iRet": 0, + "sMsg": "ok", + "jData": { + "urlScheme": "<小程序 URL Scheme>" + }, + "v": 2, + "apiName": "GenMiniappScheme" +} +``` + +二维码必须编码 `jData.urlScheme` 的完整字符串,不应编码原 `livelink.qq.com` 页面地址。 + +#### 3.1.2 Scheme 格式 + +微信: + +```text +weixin://dl/business/?appid=wx707e2eb408780a5b&path=pages/gameAccountBind/index&query=scheme_id%3D +``` + +QQ: + +```text +mqqapi://miniapp/open?_atype=0&_mappid=1111906964&_miniapptype=1&_mvid=&_path=pages%2FgameAccountBind%2Findex&_vt=3&via=qq_txyxll&_sig=&_nq= +``` + +本次样本确认: + +| 字段 | 微信 | QQ | +| --- | --- | --- | +| 小程序 AppID | `wx707e2eb408780a5b` | `1111906964` | +| 页面路径 | `pages/gameAccountBind/index` | `pages/gameAccountBind/index` | +| 动态凭证 | `scheme_id` | `_sig`、`_nq` 内的业务参数 | +| 二维码内容 | 返回的 `urlScheme` | 返回的 `urlScheme` | + +AppID、路径和渠道字段只代表本次活动样本;业务参数不能写死为常量。 + +#### 3.1.3 鉴权与时效 + +`GenMiniappScheme` 实测只发送: + +```http +Content-Type: application/json +``` + +没有发送: + +- 微博 Cookie +- 微信/QQ Cookie +- Bearer Token +- 微信/QQ access token +- 微信/QQ OAuth 回调参数 +- 用户登录会话 + +微信和 QQ 两个请求均返回 HTTP 200、`iRet=0`,响应没有 `Set-Cookie`。 + +这里的“不需要鉴权”只表示 Scheme 生成接口不依赖账号登录态。`code`、`nonce`、`sig`、`t` 仍是来联业务的防篡改和时效参数,不能删除或改写。 + +- `t` 是 Unix 秒时间戳。 +- `scheme_id` 和 QQ `_sig` 由服务端动态生成。 +- 样本在 `t` 后约 54 分钟仍可生成 Scheme,但不足以确定固定 TTL。 +- 每次都应从新的微博链接或新的 `bind_url` 提取内层 Query,不能长期缓存。 + +微信页面在“微信内直接唤起小程序”的分支会调用 `/api/common/getWxJsApiTicket` 配置 `wx-open-launch-weapp`;该 JS-SDK 分支不是二维码生成链路,二维码生成不需要它。 + +本次没有抓到 `is_bound=false` 的独立样本,因此未绑定场景下 `bind_url` 的值仍需下一次抓包确认;但当前已绑定响应中同样存在 `bind_url`,且同一 `GenMiniappScheme` 链路已实测可用。 + +## 4. 助威得微豆与充值 + +### 4.1 助威入口 + +`/aj/mall/home` 的 `task_list` 样本中包含: + +```json +{ + "title": "助威得微豆", + "button_type": "cashier", + "action_url": "sinaweibo://transparentbrowser?url=" +} +``` + +解码后进入: + +```text +https://vipclub.weibo.com/vmember/subscribe?vuid=&source_itemid=20601&F= +``` + +### 4.2 获取助力目标 + +```http +GET https://vipclub.weibo.com/aj/vmember/gfsupport?vuid=&source_itemid=20601&F=&_t= +``` + +响应提供: + +- `data.uid` +- `data.vuid` +- `data.u_name` +- 被助威账号信息 +- 是否已助力/会员 + +### 4.3 获取收银台和商品档位 + +```http +GET https://vipclub.weibo.com/aj/paycomponent/cashierinfo + ?F= + &tab_id=536 + &cashier_id=205 + &cashier_version=0.2.3 + &vuid= + &source_itemid=20601 +``` + +页面输入还包含空值参数: + +```text +appName=&from=&wm=&profileuid=&device_id=&tab_type=&bid= +``` + +这些空参数可以保留以贴合浏览器行为,尚未逐一测试是否可省略。 + +关键响应: + +```text +data.cashier_id +data.tab_info[].tab_id +data.tab_info[].tab_type +data.tab_info[].menu[] +data.user_info.uid +``` + +样本“1 元 = 100 微豆”档位: + +| 字段 | 值 | +| --- | --- | +| `commodity_id` | `2060101` | +| `subitemid` | `2060101` | +| `itemid` | `20601` | +| `appkey` | `2000` | +| `cashier_id` | `205` | +| `tab_type` | `8` | +| `max_money` | `1000` | +| `discount_info.desc` | `1元=100微豆` | + +页面允许自定义 1–1000 元,提交时把金额换算成: + +```text +single_reward = 金额(元) * 100 +``` + +所以 1 元对应 `single_reward=100`。 + +### 4.4 创建充值订单 + +```http +POST https://vipclub.weibo.com/aj/paycomponent/createorder?<与 Body 相同的业务参数> +Content-Type: application/x-www-form-urlencoded +Origin: https://vipclub.weibo.com +Referer: https://vipclub.weibo.com/vmember/subscribe?vuid=&source_itemid=20601&F= +Cookie: <微博 CK> +``` + +样本核心 Body: + +```text +subitemid=2060101 +itemid=20601 +appkey=2000 +commodity_id=2060101 +num=1 +cashier_id=205 +F= +is_from_cashier=1 +cashierpw=1 +vuid= +tab_type=8 +cashier_version=0.2.3 +single_reward=100 +ext={"bid":"","send_blog":1} +``` + +其他样本字段: + +```text +salestrategy_id= +discount_id= +from= +device_id= +level_up_tag=0 +to_uid= +return_url= +ori_t_id= +will_auto_renew=0 +subpw= +account= +follow=false +coupon_id= +gc= +bid= +mid= +``` + +浏览器前端拦截器会把同一组参数同时放进 URL Query 和 form Body。样本服务端接受了这种重复传参。实现时建议保持 Query + Body 一致,除非后续实测证明只传 Body 也可以。 + +样本 Body 中还有 `is_eligible=undefined`,这是前端直接序列化未定义值产生的字符串,不应作为可靠业务参数。 + +成功响应关键字段: + +| 字段 | 含义 | +| --- | --- | +| `data.url` | 微博支付收银台 URL | +| `data.orderid` | 支付订单号,后续微豆明细的 `source_id` | +| `data.pay_return_url` | 支付完成后的业务回跳地址 | +| `data.return_url` | 带订单号的回跳地址 | +| `data.channel` | 样本为 `weibo_web` | + +### 4.5 支付收银台和二维码 + +使用同一 Cookie 打开 `data.url`,经过: + +```text +GET /api/merchant/pay/cashier + -> 302 /pay/pc/cashier + -> 200 收银台 HTML +``` + +收银台 HTML 内直接包含支付信息: + +```html + + +立即支付 +``` + +支付二维码由收银台服务端生成带 `api_key`、`data`、`sign` 的 `qr.weibo.cn/inf/gen` URL。客户端不需要自行计算该签名,按 HTML 解析即可。 + +### 4.6 支付状态轮询 + +```http +POST https://pay.sc.weibo.com/aj/pay/status +Content-Type: application/x-www-form-urlencoded +X-Requested-With: XMLHttpRequest +Origin: https://pay.sc.weibo.com +Referer: <收银台 URL> +Cookie: <微博 CK> + +pay_id=&ispage=1&_t=0 +``` + +样本状态: + +```text +code=100000 +data.status=PAY_STATUS_PENDING +data.sub_status=PAY_SUB_STATUS_INITIAL +``` + +样本约每 1 秒轮询一次,共 16 次待支付状态;用户在支付宝扫码并完成支付后,流程跳转成功页。支付动作本身属于外部支付流程,不应由 CK 自动完成或绕过。 + +### 4.7 到账确认 + +支付完成后再次请求 `/aj/mall/home`: + +```text +data.weidou_balance.balance = 100 +``` + +微豆明细出现: + +```json +{ + "source_type": 101, + "record_type": 208681, + "source_id": "", + "source_name": "V+助威", + "point_num": 100 +} +``` + +本次充值 1 元,到账 100 微豆。 + +### 4.8 `pay/modular/prepare` + +样本还出现: + +```http +POST https://pay.sc.weibo.com/aj/pay/modular/prepare +``` + +请求体是页面初始“超能爆发”档位的 `total_amount=6000`,响应为空。本次 1 元自定义订单没有从该响应读取任何字段,实际下单依赖的是 `/aj/paycomponent/createorder`。暂不把它列入 1 元充值必需链路。 + +## 5. 微豆明细 + +```http +GET https://vipclub.e.weibo.com/aj/point/get_point_recharge_history + ?F= + &page_no=1 + &page_size=20 + &_t= +``` + +响应: + +```text +data.page_num +data.page_size +data.total +data.list[] +data.expire_remind +``` + +样本明细: + +| 字段 | 含义 | +| --- | --- | +| `id` | 明细 ID | +| `source_type` | 来源类型,样本为 `101` | +| `record_type` | 记录类型,样本为 `208681` | +| `source_id` | 支付订单号 | +| `source_name` | 来源名,样本为 `V+助威` | +| `point_num` | 到账微豆,样本为 `100` | +| `create_time` | 到账时间 | +| `expire_time` | 微豆过期时间 | + +该接口从名称和样本看是“微豆获取/充值明细”。兑换消耗记录使用 `/aj/mall/exchange`,不要把两个接口混为一谈。 + +## 6. 微豆商城列表 + +```http +GET https://vipclub.e.weibo.com/aj/mall/list?F=&_t= +``` + +响应: + +```text +data.list[] +data.weidou_balance +data.exchange_url +``` + +商品字段: + +```text +id +goods_name +goods_image +price +remain_count +is_sold_out +goods_desc +``` + +本次抓包中的商品 ID: + +| goods_id | 商品 | 价格/微豆 | +| --- | --- | ---: | +| 21 | 汪仔布丁大礼包 | 28000 | +| 22 | 套装-暮夜私语 | 9880 | +| 23 | 套装-墨青刃影 | 7880 | +| 12 | 套装-暗影哥特 | 8880 | +| 14 | 套装-浪漫天命 | 3600 | +| 13 | 黑色高级特训官上衣 | 3600 | +| 18 | 幸运币礼包(小) | 200 | +| 19 | 幸运币礼包(中) | 3000 | +| 20 | 幸运币礼包(大) | 9000 | +| 15 | 荣耀勋章礼包(小) | 200 | +| 16 | 荣耀勋章礼包(中) | 3000 | +| 17 | 荣耀勋章礼包(大) | 9000 | +| 42 | 微博VIP年卡 | 10800 | +| 41 | 微博VIP月卡 | 1000 | +| 40 | 微博VIP周卡 | 400 | +| 39 | 微博会员5天卡 | 300 | +| 38 | 微博会员3天卡 | 200 | +| 37 | 微博会员1天卡 | 100 | + +`remain_count` 是实时库存,不能写成常量。 + +## 7. 商品详情与兑换 + +### 7.1 商品详情 + +```http +GET https://vipclub.e.weibo.com/aj/mall/detail?F=&goods_id=&_t= +``` + +响应包含: + +```text +goods_info +weidou_balance +payment_button +nickname +game_nickname +``` + +只有 `payment_button.clickable=true` 且 `payment_button.type=exchange` 时,前端才允许兑换。 + +### 7.2 执行兑换 + +```http +GET https://vipclub.e.weibo.com/aj/game/exchangegoods?F=&goods_id=&_t= +``` + +请求参数只有 `F`、`goods_id`、`_t`,没有额外确认 Token。 + +成功响应: + +```json +{ + "code": 100000, + "msg": "success", + "data": null +} +``` + +样本兑换 `goods_id=37`(微博会员1天卡)后再次请求 `/aj/mall/list`: + +- `data.weidou_balance` 从 `100` 变为 `0`; +- goods 37 的 `remain_count` 从 `4639` 变为 `4638`。 + +因此 `code=100000` 表示请求成功,但仍应刷新商城列表确认余额和库存变化。 + +注意:这是使用 GET 执行状态变更的接口。不能做自动重试、并发点击或把一次成功当成幂等。 + +### 7.3 兑换记录 + +```http +GET https://vipclub.e.weibo.com/aj/mall/exchange + ?F= + &page=1 + &page_size=20 + &_t= +``` + +响应: + +```text +data.page +data.pageSize +data.list +data.totalCount +``` + +本次在兑换前查询两次,结果均为空列表。兑换后没有再请求该接口,因此兑换成功后的记录字段仍需补抓一次。 + +## 8. 同一页面的辅助接口 + +以下接口出现在页面加载流程中,但不是上述业务的必需前置: + +| 接口 | 用途 | +| --- | --- | +| `/aj/paycomponent/userinfo` | 获取收银台 tab | +| `/aj/paycomponent/couponlist` | 优惠券状态 | +| `/aj/strategy/info` | 活动策略提示 | +| `/aj/paycomponent/privilege` | V+ 权益展示 | +| `/actionlog` | 前端埋点 | + +## 9. 实现约束 + +1. 所有微博业务接口都依赖与目标用户一致的 Cookie;先验证 `/aj/mall/home` 返回 `code=100000`。 +2. 使用 Cookie Jar,保留响应和收银台页面产生的 Cookie 更新。 +3. `_t` 使用当前毫秒时间,不复用抓包值。 +4. `uid`、`vuid`、`cashier_id`、`tab_id`、`itemid`、`appkey` 和 `single_reward` 来自页面响应,业务代码只保留活动配置。 +5. 充值金额以 `single_reward` 为准:1 元填 `100`。 +6. 兑换接口是 GET 状态变更,必须设置本地防重锁并禁止自动重试。 +7. 支付二维码从 `createorder.data.url` 对应的收银台 HTML 中解析,不要重新计算 `qr.weibo.cn` 的 `sign`。 +8. 不记录或回显完整 Cookie、支付 URL、二维码短链、uid、vuid、昵称和订单号。 + +## 10. 鉴权与错误处理 + +已确认: + +- 没有独立业务 Token;CK 是核心认证状态。 +- 没有观察到 CSRF Header 或请求签名算法。 +- 未登录时接口会返回 `400002`、`400000` 或跳转微博登录页。 + +建议处理: + +| code | 处理 | +| --- | --- | +| `100000` | 成功 | +| `100002` | 登录失效,停止任务并要求更新 CK | +| `100004` | 进入微博错误页,停止任务 | +| `400002` | `未登陆`,CK 无效 | +| `400000` | `user not login`,CK 无效 | +| 其他 | 保留服务端 `msg` 和 `trace_id`,不要盲目重试状态变更接口 | + +## 11. 尚未确认 + +- 是否只需要 `SUB`,还是必须完整 Cookie Jar。 +- 本次未抓 `is_bound=false` 的绑定响应。 +- 本次未抓兑换后的 `/aj/mall/exchange` 记录。 +- `/aj/game/exchangegoods` 在余额不足、重复兑换、角色未绑定时的错误码。 +- 支付订单在支付宝侧取消、超时、退款后的业务状态。 +- 各活动的 `F`、`itemid`、`source_itemid`、`cashier_id`、`tab_id` 是否跨活动变化;实现时应从页面动态读取。 diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 1d3fcc0..64ad71c 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261107_0067" +HEAD_REVISION = "20261107_0069" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py new file mode 100644 index 0000000..400e8f7 --- /dev/null +++ b/tests/test_weibo_accounts.py @@ -0,0 +1,404 @@ +"""微博 CK 导入与绑定二维码接口契约。""" + +from __future__ import annotations + +from typing import ClassVar + +import pytest +from fastapi import HTTPException +from sqlalchemy import text + +from core.weibo import ( + WeiboAuthError, + WeiboBindError, + WeiboExchangeResult, + WeiboHomeSnapshot, + WeiboPaymentStatus, + WeiboRechargeOrder, +) +from web.backend.models import User, WeiboAccount +from web.backend.routers import weibo as weibo_router +from web.backend.routers.weibo import ( + create_bind_qrcodes, + import_cookies, + list_accounts, +) +from web.backend.schemas import ( + WeiboCookieImport, + WeiboExchangeRequest, + WeiboPaymentStatusRequest, + WeiboRechargeRequest, +) +from web.backend.services import weibo_service + +BIND_URL = ( + "sinaweibo://wbdiversion?username=gh_test" + "&path=%2Fpages%2FgameAccountBind%2Findex%3FactId%3D16722" + "%26code%3Dabc%252Bdef%253D%26gameId%3Dcjm" + "%26livePlatId%3Dweiyouxi%26nonce%3Dabc123" + "%26sig%3D0123456789abcdef%26t%3D1794046086%26v%3D2.0" +) + + +class StubWeiboClient: + snapshot = WeiboHomeSnapshot( + uid="4014000001", + nickname="测试微博", + is_bound=False, + role_name="", + can_rebind=True, + rebind_remain_days=0, + weidou_balance=100, + bind_url=BIND_URL, + ) + scheme_errors: ClassVar[dict[str, str]] = {} + calls: ClassVar[list[tuple[str, str]]] = [] + + def __init__(self, cookie: str = "", **_kwargs): + self.cookie = cookie + + def fetch_home(self, activity: str = ""): + self.calls.append(("home", activity)) + if "expired" in self.cookie: + raise WeiboAuthError("未登陆") + return self.snapshot + + def generate_bind_scheme(self, bind_url: str, account_type: str): + self.calls.append(("scheme", account_type)) + error = self.scheme_errors.get(account_type) + if error: + raise WeiboBindError(error) + prefix = ( + "weixin://dl/business/?appid=wx-test" + if account_type == "wx" + else "mqqapi://miniapp/open?_mappid=qq-test" + ) + return prefix + + +@pytest.fixture +def current_user(db): + user = User(username="weibo-operator", password_hash="hash", role="super_admin") + db.add(user) + db.commit() + return user + + +@pytest.fixture(autouse=True) +def stub_client(monkeypatch): + StubWeiboClient.calls = [] + StubWeiboClient.scheme_errors = {} + StubWeiboClient.snapshot = WeiboHomeSnapshot( + uid="4014000001", + nickname="测试微博", + is_bound=False, + role_name="", + can_rebind=True, + rebind_remain_days=0, + weidou_balance=100, + bind_url=BIND_URL, + ) + monkeypatch.setattr(weibo_service, "WeiboClient", StubWeiboClient) + + +def test_import_creates_encrypted_account_and_is_idempotent(db, current_user): + first = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=b", tag="运营"), + db=db, + current=current_user, + ) + second = import_cookies( + WeiboCookieImport(text="SCF=c; SUB=d", tag="新标签"), + db=db, + current=current_user, + ) + + assert first.count == 1 + assert second.count == 1 + assert db.query(WeiboAccount).count() == 1 + account = db.query(WeiboAccount).one() + assert account.uid == "4014000001" + assert account.cookie == "SCF=c; SUB=d" + assert account.tag == "新标签" + assert account.ck_check_status == "valid" + raw_cookie = db.execute(text("SELECT cookie FROM weibo_accounts")).scalar_one() + assert raw_cookie.startswith("enc:v1:") + assert "SUB=d" not in raw_cookie + + dumped = second.accounts[0].model_dump() + assert dumped["uid"] == "4014000001" + assert dumped["is_bound"] is False + assert dumped["weidou_balance"] == 100 + assert "cookie" not in dumped + + +def test_import_reports_invalid_ck_per_line_and_keeps_valid_account(db, current_user): + result = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=expired\nSCF=b; SUB=valid", tag=""), + db=db, + current=current_user, + ) + + assert result.count == 1 + assert len(result.errors) == 1 + assert result.errors[0].line == 1 + assert "未登陆" in result.errors[0].message + assert db.query(WeiboAccount).count() == 1 + + +def test_list_accounts_returns_parallel_page_shape(db, current_user): + assert ( + import_cookies( + WeiboCookieImport(text="SCF=a; SUB=b", tag=""), + db=db, + current=current_user, + ).count + == 1 + ) + + page = list_accounts(page=1, page_size=20, db=db, current=current_user) + + assert page["total"] == 1 + dumped = page["items"][0].model_dump() + assert set(dumped) >= { + "id", + "uid", + "nickname", + "is_bound", + "can_rebind", + "rebind_remain_days", + "weidou_balance", + "ck_check_status", + } + + +def test_bind_qrcodes_returns_both_channels_and_partial_error(db, current_user): + account = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=b", tag=""), + db=db, + current=current_user, + ).accounts[0] + StubWeiboClient.scheme_errors = {"qq": "QQ Scheme 暂时不可用"} + + result = create_bind_qrcodes(account.id, db=db, current=current_user) + + dumped = result.model_dump() + assert dumped["account_id"] == account.id + assert dumped["is_bound"] is False + assert dumped["can_rebind"] is True + by_channel = {item["channel"]: item for item in dumped["schemes"]} + assert by_channel["wx"]["scheme"].startswith("weixin://") + assert by_channel["wx"]["error"] == "" + assert by_channel["qq"]["scheme"] == "" + assert by_channel["qq"]["error"] == "QQ Scheme 暂时不可用" + + +def test_bind_qrcodes_rejects_bound_account_without_rebind(db, current_user): + account = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=b", tag=""), + db=db, + current=current_user, + ).accounts[0] + StubWeiboClient.snapshot = WeiboHomeSnapshot( + uid=account.uid, + nickname=account.nickname, + is_bound=True, + role_name="测试角色", + can_rebind=False, + rebind_remain_days=7, + weidou_balance=100, + bind_url=BIND_URL, + ) + + with pytest.raises(HTTPException) as exc_info: + create_bind_qrcodes(account.id, db=db, current=current_user) + + assert exc_info.value.status_code == 409 + assert "7 天" in str(exc_info.value.detail) + + +def test_manual_mall_exchange_and_point_routes_serialize(monkeypatch, db, current_user): + account = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=b", tag=""), + db=db, + current=current_user, + ).accounts[0] + monkeypatch.setattr( + weibo_router, + "query_weibo_mall", + lambda *_args, **_kwargs: { + "weidou_balance": 120, + "list": [ + { + "id": 37, + "goods_name": "会员1天卡", + "price": 100, + "remain_count": 99, + "is_sold_out": False, + } + ], + }, + ) + monkeypatch.setattr( + weibo_router, + "query_weibo_exchange_records", + lambda *_args, **_kwargs: { + "page": 1, + "pageSize": 20, + "totalCount": 1, + "list": [ + { + "goods_name": "会员1天卡", + "game_account": "测试角色", + "cost_weidou": 100, + "exchange_time": "2026-11-07 18:00:00", + "reward_status": 2, + } + ], + }, + ) + monkeypatch.setattr( + weibo_router, + "query_weibo_point_history", + lambda *_args, **_kwargs: { + "page_num": 1, + "page_size": 20, + "total": 1, + "list": [ + { + "id": 1, + "source_name": "V+助威", + "point_num": 100, + "source_id": "order-1", + } + ], + }, + ) + + mall = weibo_router.list_mall_goods( + account.id, + db=db, + current=current_user, + ).model_dump() + exchange = weibo_router.list_exchange_records( + account.id, + db=db, + current=current_user, + ).model_dump() + points = weibo_router.list_point_history( + account.id, + db=db, + current=current_user, + ).model_dump() + + assert mall["goods"][0]["goods_name"] == "会员1天卡" + assert mall["weidou_balance"] == 120 + exchange_item = exchange["items"][0] + assert set(exchange_item) == { + "goods_name", + "game_account", + "cost_weidou", + "exchange_time", + "reward_status", + "raw", + } + assert exchange_item["game_account"] == "测试角色" + assert exchange_item["cost_weidou"] == 100 + assert exchange_item["exchange_time"] == "2026-11-07 18:00:00" + assert exchange_item["reward_status"] == 2 + assert "id" not in exchange_item + assert exchange["total"] == 1 + assert points["items"][0]["source_id"] == "order-1" + + +def test_manual_recharge_order_and_status_routes_serialize( + monkeypatch, db, current_user +): + account = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=b", tag=""), + db=db, + current=current_user, + ).accounts[0] + monkeypatch.setattr( + weibo_router, + "create_weibo_recharge_order", + lambda *_args, **_kwargs: WeiboRechargeOrder( + order_id="order-1", + pay_id="pay-1", + amount_yuan=1, + single_reward=100, + alipay_qr_code="https://qr.alipay.com/upx-test", + desktop_pay_url="https://pay.sc.weibo.com/pay/pc/comb?pay_id=pay-1", + ), + ) + monkeypatch.setattr( + weibo_router, + "query_weibo_payment_status", + lambda *_args, **_kwargs: WeiboPaymentStatus( + pay_id="pay-1", + out_pay_id="order-1", + status="PAY_STATUS_PENDING", + sub_status="PAY_SUB_STATUS_INITIAL", + ), + ) + + order = weibo_router.create_recharge_order( + account.id, + WeiboRechargeRequest(amount_yuan=1), + db=db, + current=current_user, + ).model_dump() + status = weibo_router.get_recharge_status( + account.id, + WeiboPaymentStatusRequest(pay_id="pay-1"), + db=db, + current=current_user, + ).model_dump() + + assert order["single_reward"] == 100 + assert order["alipay_qr_code"] == "https://qr.alipay.com/upx-test" + assert status["status"] == "PAY_STATUS_PENDING" + + +def test_manual_exchange_requires_confirm_and_serializes_result( + monkeypatch, db, current_user +): + account = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=b", tag=""), + db=db, + current=current_user, + ).accounts[0] + monkeypatch.setattr( + weibo_router, + "exchange_weibo_goods", + lambda *_args, **_kwargs: WeiboExchangeResult( + goods_id=37, + goods_name="微博会员1天卡", + price=100, + balance_before=200, + balance_after=100, + remain_before=10, + remain_after=9, + verified=True, + ), + ) + + with pytest.raises(HTTPException) as exc_info: + weibo_router.exchange_goods( + account.id, + WeiboExchangeRequest(goods_id=37, expected_price=100, confirm=False), + db=db, + current=current_user, + ) + assert exc_info.value.status_code == 400 + + result = weibo_router.exchange_goods( + account.id, + WeiboExchangeRequest(goods_id=37, expected_price=100, confirm=True), + db=db, + current=current_user, + ).model_dump() + + assert result["goods_id"] == 37 + assert result["balance_after"] == 100 + assert result["verified"] is True diff --git a/tests/test_weibo_client.py b/tests/test_weibo_client.py new file mode 100644 index 0000000..f0a1e6a --- /dev/null +++ b/tests/test_weibo_client.py @@ -0,0 +1,530 @@ +"""微博微豆基础客户端协议测试。""" + +from __future__ import annotations + +import pytest +import requests + +from core.weibo import ( + WeiboApiError, + WeiboAuthError, + WeiboBindError, + WeiboClient, + WeiboExchangeError, + WeiboExchangeUncertainError, + WeiboRechargeError, + extract_bind_raw_url, + normalize_weibo_cookie, +) + +BIND_URL = ( + "sinaweibo://wbdiversion?username=gh_test" + "&path=%2Fpages%2FgameAccountBind%2Findex%3FactId%3D16722" + "%26code%3Dabc%252Bdef%253D%26gameId%3Dcjm" + "%26livePlatId%3Dweiyouxi%26nonce%3Dabc123" + "%26sig%3D0123456789abcdef%26t%3D1794046086%26v%3D2.0" +) + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + self.text = payload if isinstance(payload, str) else "" + + def raise_for_status(self): + return None + + def json(self): + if not isinstance(self.payload, dict): + raise TypeError("not json") + return self.payload + + +class FakeSession(requests.Session): + def __init__(self, responses): + super().__init__() + self.responses = list(responses) + self.calls = [] + + def request(self, method, url, **_kwargs): + kwargs = _kwargs + self.calls.append((method, url, kwargs)) + result = self.responses.pop(0) + if isinstance(result, Exception): + raise result + return FakeResponse(result) + + +def test_normalize_cookie_accepts_prefix_and_multiline(): + raw = "Cookie: SCF=a;\n SUB=b;SUBP=c\n" + assert normalize_weibo_cookie(raw) == "SCF=a; SUB=b; SUBP=c" + + +def test_extract_bind_raw_url_preserves_inner_encoding(): + raw = extract_bind_raw_url(BIND_URL) + assert raw.startswith("actId=16722&code=abc%2Bdef%3D") + assert "faceUrl" not in raw + # 内层 code 的多重编码不能被再次解开 + assert "abc+def=" not in raw + + +def test_extract_bind_raw_url_rejects_wrong_path(): + with pytest.raises(WeiboBindError, match="绑定小程序路径"): + extract_bind_raw_url("sinaweibo://wbdiversion?path=%2Fwrong") + + +def test_fetch_home_returns_basic_snapshot(): + session = FakeSession( + [ + { + "code": 100000, + "msg": "success", + "data": { + "user_info": {"uid": "4014000001", "nickname": "测试微博"}, + "game_role": { + "is_bound": False, + "role_name": "", + "can_rebind": True, + "rebind_remain_days": 0, + "bind_url": BIND_URL, + }, + "weidou_balance": {"balance": 100}, + "task_list": [ + { + "button_type": "cashier", + "action_url": ( + "https://vipclub.weibo.com/vmember/subscribe" + "?vuid=8455793145&source_itemid=20601&F=bowen_tjg" + ), + } + ], + }, + } + ] + ) + client = WeiboClient("SCF=a; SUB=b", session=session) + + snapshot = client.fetch_home("bowen_tjg") + + assert snapshot.uid == "4014000001" + assert snapshot.nickname == "测试微博" + assert snapshot.is_bound is False + assert snapshot.can_rebind is True + assert snapshot.weidou_balance == 100 + assert snapshot.activity == "bowen_tjg" + assert "source_itemid=20601" in snapshot.recharge_url + method, url, kwargs = session.calls[0] + assert method == "GET" + assert "F=bowen_tjg" in url + assert "Cookie" not in kwargs["headers"] + assert session.cookies.get("SUB", domain=".weibo.com") == "b" + + +def test_fetch_home_marks_auth_error(): + session = FakeSession([{"code": 400002, "msg": "未登陆"}]) + client = WeiboClient("SUB=expired", session=session) + + with pytest.raises(WeiboAuthError, match="未登陆"): + client.fetch_home() + + +def test_fetch_home_keeps_trace_id_for_other_api_error(): + session = FakeSession( + [{"code": 100003, "msg": "活动异常", "trace_id": "trace-001"}] + ) + client = WeiboClient("SUB=test", session=session) + + with pytest.raises(WeiboApiError, match="trace-001"): + client.fetch_home() + + +def test_generate_bind_scheme_posts_account_type_without_weibo_cookie(): + session = FakeSession( + [ + { + "iRet": 0, + "sMsg": "ok", + "jData": { + "urlScheme": "weixin://dl/business/?appid=wx-test&query=scheme_id%3D1" + }, + "apiName": "GenMiniappScheme", + } + ] + ) + client = WeiboClient(session=session) + + scheme = client.generate_bind_scheme(BIND_URL, "wx") + + assert scheme.startswith("weixin://dl/business/") + method, url, kwargs = session.calls[0] + assert method == "POST" + assert "c=Sdk&a=GenMiniappScheme&actId=16722" in url + assert kwargs["json"] == {"accountType": "wx"} + assert "Cookie" not in kwargs["headers"] + + +def test_generate_bind_scheme_rejects_missing_url_scheme(): + session = FakeSession([{"iRet": 0, "sMsg": "ok", "jData": {}}]) + client = WeiboClient(session=session) + + with pytest.raises(WeiboBindError, match="urlScheme"): + client.generate_bind_scheme(BIND_URL, "qq") + + +def test_mall_exchange_and_point_queries(): + session = FakeSession( + [ + { + "code": 100000, + "data": { + "list": [{"id": 37, "goods_name": "会员1天卡", "price": 100}], + "weidou_balance": 100, + }, + }, + { + "code": 100000, + "data": { + "page": 1, + "pageSize": 20, + "totalCount": 0, + "list": [], + }, + }, + { + "code": 100000, + "data": { + "page_num": 1, + "page_size": 20, + "total": 1, + "list": [{"id": 1, "source_name": "V+助威", "point_num": 100}], + }, + }, + ] + ) + client = WeiboClient("SUB=valid", session=session) + + mall = client.list_mall("bowen_tjg") + records = client.list_exchange_records("bowen_tjg") + points = client.list_point_history("bowen_tjg") + + assert mall["weidou_balance"] == 100 + assert records["totalCount"] == 0 + assert points["total"] == 1 + assert session.calls[0][2]["params"]["F"] == "bowen_tjg" + + +def test_create_recharge_order_full_manual_chain(): + home = { + "code": 100000, + "data": { + "user_info": {"uid": "4014000001", "nickname": "测试微博"}, + "game_role": { + "is_bound": True, + "role_name": "测试角色", + "can_rebind": False, + "rebind_remain_days": 7, + "bind_url": BIND_URL, + }, + "weidou_balance": {"balance": 0}, + "task_list": [ + { + "button_type": "cashier", + "action_url": ( + "https://vipclub.weibo.com/vmember/subscribe" + "?vuid=8455793145&source_itemid=20601&F=bowen_tjg" + ), + } + ], + }, + } + userinfo = { + "code": 100000, + "data": { + "cashier_id": 205, + "tab_info": [ + { + "cashier_id": "205", + "tab_id": "536", + "tab_type": "8", + "is_current": 1, + } + ], + }, + } + cashierinfo = { + "code": 100000, + "data": { + "tab_info": [ + { + "cashier_id": "205", + "tab_id": "536", + "tab_type": "8", + "is_current": 1, + "menu": [ + { + "commodity_id": "2060101", + "subitemid": "2060101", + "itemid": 20601, + "appkey": 2000, + "max_money": 1000, + "discount_info": {"desc": "1元=100微豆"}, + } + ], + } + ] + }, + } + privilege = { + "code": 100000, + "data": { + "privilege": { + "url": "https://vipclub.weibo.com/appreciate/description?bid=1000294191" + } + }, + } + createorder = { + "code": 100000, + "msg": "下单成功", + "data": { + "url": "https://pay.sc.weibo.com/api/merchant/pay/cashier?sign=test", + "orderid": "26111000000000000001", + }, + } + cashier_html = """ + + + 立即支付 + """ + alipay_form_html = """ +
+ + +
+ """ + alipay_html = """ + + """ + session = FakeSession( + [ + home, + {"code": 100000, "data": {}}, + userinfo, + cashierinfo, + privilege, + createorder, + cashier_html, + alipay_form_html, + alipay_html, + ] + ) + client = WeiboClient("SCF=a; SUB=b", session=session) + + order = client.create_recharge_order(amount_yuan=1, activity="bowen_tjg") + + assert order.order_id == "26111000000000000001" + assert order.pay_id == "1220351218153064346" + assert order.single_reward == 100 + assert order.alipay_qr_code == "https://qr.alipay.com/upx-test" + assert order.desktop_pay_url.startswith("https://pay.sc.weibo.com/") + create_call = next( + call for call in session.calls if call[1].endswith("/paycomponent/createorder") + ) + assert create_call[2]["data"]["single_reward"] == "100" + assert '"bid":"1000294191"' in create_call[2]["data"]["ext"] + assert create_call[2]["params"] == create_call[2]["data"] + + +def test_create_recharge_order_rejects_missing_dynamic_menu(): + home = { + "code": 100000, + "data": { + "user_info": {"uid": "1", "nickname": "测试"}, + "game_role": { + "is_bound": False, + "role_name": "", + "can_rebind": True, + "rebind_remain_days": 0, + "bind_url": BIND_URL, + }, + "weidou_balance": {"balance": 0}, + "task_list": [ + { + "button_type": "cashier", + "action_url": ( + "https://vipclub.weibo.com/vmember/subscribe" + "?vuid=8455793145&source_itemid=20601&F=bowen_tjg" + ), + } + ], + }, + } + userinfo = { + "code": 100000, + "data": { + "cashier_id": 205, + "tab_info": [ + { + "cashier_id": "205", + "tab_id": "536", + "tab_type": "8", + "is_current": 1, + } + ], + }, + } + cashierinfo = { + "code": 100000, + "data": { + "tab_info": [ + { + "cashier_id": "205", + "tab_id": "536", + "tab_type": "8", + "is_current": 1, + "menu": [], + } + ] + }, + } + session = FakeSession([home, {"code": 100000, "data": {}}, userinfo, cashierinfo]) + client = WeiboClient("SUB=valid", session=session) + + with pytest.raises(WeiboRechargeError, match="自定义微豆充值档位"): + client.create_recharge_order(amount_yuan=1) + + +def test_get_payment_status(): + session = FakeSession( + [ + { + "code": "100000", + "msg": "操作成功", + "data": { + "pay_id": "pay-1", + "out_pay_id": "out-1", + "status": "PAY_STATUS_PENDING", + "sub_status": "PAY_SUB_STATUS_INITIAL", + }, + } + ] + ) + client = WeiboClient("SUB=valid", session=session) + + status = client.get_payment_status("pay-1") + + assert status.status == "PAY_STATUS_PENDING" + assert session.calls[0][2]["data"]["pay_id"] == "pay-1" + + +def test_exchange_goods_executes_once_and_verifies_balance_and_stock(): + before = { + "code": 100000, + "data": { + "weidou_balance": 200, + "list": [ + { + "id": 37, + "goods_name": "微博会员1天卡", + "price": 100, + "remain_count": 10, + "is_sold_out": False, + } + ], + }, + } + after = { + "code": 100000, + "data": { + "weidou_balance": 100, + "list": [ + { + "id": 37, + "goods_name": "微博会员1天卡", + "price": 100, + "remain_count": 9, + "is_sold_out": False, + } + ], + }, + } + session = FakeSession( + [ + before, + {"code": 100000, "msg": "success", "data": None}, + after, + ] + ) + client = WeiboClient("SUB=valid", session=session) + + result = client.exchange_goods( + 37, + expected_price=100, + activity="bowen_tjg", + ) + + assert result.goods_name == "微博会员1天卡" + assert result.balance_before == 200 + assert result.balance_after == 100 + assert result.remain_before == 10 + assert result.remain_after == 9 + assert result.verified is True + exchange_calls = [ + call for call in session.calls if call[1].endswith("/game/exchangegoods") + ] + assert len(exchange_calls) == 1 + assert exchange_calls[0][2]["params"]["goods_id"] == 37 + + +def test_exchange_goods_rejects_insufficient_balance_before_write(): + session = FakeSession( + [ + { + "code": 100000, + "data": { + "weidou_balance": 99, + "list": [ + { + "id": 37, + "goods_name": "微博会员1天卡", + "price": 100, + "remain_count": 10, + "is_sold_out": False, + } + ], + }, + } + ] + ) + client = WeiboClient("SUB=valid", session=session) + + with pytest.raises(WeiboExchangeError, match="余额不足"): + client.exchange_goods(37, expected_price=100) + + assert not any(call[1].endswith("/game/exchangegoods") for call in session.calls) + + +def test_exchange_goods_marks_transport_failure_as_uncertain(): + session = FakeSession( + [ + { + "code": 100000, + "data": { + "weidou_balance": 200, + "list": [ + { + "id": 37, + "goods_name": "微博会员1天卡", + "price": 100, + "remain_count": 10, + "is_sold_out": False, + } + ], + }, + }, + requests.ConnectionError("connection reset"), + ] + ) + client = WeiboClient("SUB=valid", session=session) + + with pytest.raises(WeiboExchangeUncertainError, match="结果未知"): + client.exchange_goods(37, expected_price=100) diff --git a/web/backend/crypto_storage.py b/web/backend/crypto_storage.py index 3e1a26b..a98654b 100644 --- a/web/backend/crypto_storage.py +++ b/web/backend/crypto_storage.py @@ -155,6 +155,7 @@ _SENSITIVE_COLUMNS: tuple[tuple[str, str], ...] = ( ("login_tasks", "cookie"), ("huya_accounts", "account_password"), ("huya_accounts", "cookie"), + ("weibo_accounts", "cookie"), ("huya_register_batches", "fixed_password"), ("huya_register_items", "password"), ("huya_register_items", "cookie"), diff --git a/web/backend/main.py b/web/backend/main.py index dbdff3e..7ada324 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -33,6 +33,7 @@ from .routers import ( login, proxy, users, + weibo, yyb, ) from .schemas import AppInfo @@ -165,6 +166,7 @@ app.include_router(login.router) app.include_router(proxy.router) app.include_router(cookies.router) app.include_router(huya.router) +app.include_router(weibo.router) app.include_router(douyu.router) app.include_router(douyu_collab.router) app.include_router(yyb.router) diff --git a/web/backend/migrations/versions/20261107_0068_weibo_accounts.py b/web/backend/migrations/versions/20261107_0068_weibo_accounts.py new file mode 100644 index 0000000..dd44a0d --- /dev/null +++ b/web/backend/migrations/versions/20261107_0068_weibo_accounts.py @@ -0,0 +1,55 @@ +"""微博微豆账号基础表。 + +第一版只支持 CK 导入、账号资料刷新和绑定二维码展示。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261107_0068" +down_revision: str | None = "20261107_0067" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "weibo_accounts" + + +def upgrade() -> None: + bind = op.get_bind() + if TABLE in set(sa.inspect(bind).get_table_names()): + return + op.create_table( + TABLE, + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("uid", sa.String(length=32), nullable=False), + sa.Column("nickname", sa.String(length=128), nullable=False, server_default=""), + # EncryptedText 在应用层加解密,DDL 是普通文本列。 + sa.Column("cookie", sa.Text(), nullable=False), + sa.Column("cookie_updated_at", sa.DateTime(), nullable=True), + sa.Column("tag", sa.String(length=64), nullable=False, server_default=""), + sa.Column( + "ck_check_status", sa.String(length=16), nullable=False, server_default="" + ), + sa.Column("ck_check_message", sa.Text(), nullable=False), + sa.Column("ck_checked_at", sa.DateTime(), nullable=True), + sa.Column("is_bound", sa.Boolean(), nullable=True), + sa.Column( + "game_role_name", sa.String(length=128), nullable=False, server_default="" + ), + sa.Column("can_rebind", sa.Boolean(), nullable=True), + sa.Column("rebind_remain_days", sa.Integer(), nullable=True), + sa.Column("weidou_balance", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_index(f"ix_{TABLE}_uid", TABLE, ["uid"], unique=True) + + +def downgrade() -> None: + bind = op.get_bind() + if TABLE in set(sa.inspect(bind).get_table_names()): + op.drop_table(TABLE) diff --git a/web/backend/migrations/versions/20261107_0069_weibo_activity.py b/web/backend/migrations/versions/20261107_0069_weibo_activity.py new file mode 100644 index 0000000..8d878f1 --- /dev/null +++ b/web/backend/migrations/versions/20261107_0069_weibo_activity.py @@ -0,0 +1,34 @@ +"""记录微博账号最近识别的活动标识。""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261107_0069" +down_revision: str | None = "20261107_0068" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "weibo_accounts" + + +def upgrade() -> None: + bind = op.get_bind() + columns = {column["name"] for column in sa.inspect(bind).get_columns(TABLE)} + if "activity" not in columns: + op.add_column( + TABLE, + sa.Column( + "activity", sa.String(length=64), nullable=False, server_default="" + ), + ) + + +def downgrade() -> None: + bind = op.get_bind() + columns = {column["name"] for column in sa.inspect(bind).get_columns(TABLE)} + if "activity" in columns: + op.drop_column(TABLE, "activity") diff --git a/web/backend/models.py b/web/backend/models.py index bc71278..d1f2bbd 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -912,6 +912,44 @@ class HuyaAccount(Base): tasks: Mapped[list[HuyaTask]] = relationship("HuyaTask", back_populates="account") +class WeiboAccount(Base): + """微博微豆账号。 + + 第一版只保存 CK 与微豆首页快照,不承载登录密码、支付或兑换数据。 + """ + + __tablename__ = "weibo_accounts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + uid: Mapped[str] = mapped_column( + String(32), unique=True, nullable=False, index=True + ) + nickname: Mapped[str] = mapped_column(String(128), default="") + cookie: Mapped[str] = mapped_column(EncryptedText(), nullable=False) + cookie_updated_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True + ) + tag: Mapped[str] = mapped_column(String(64), default="") + activity: Mapped[str] = mapped_column(String(64), default="") + + ck_check_status: Mapped[str] = mapped_column( + String(16), nullable=False, default="", server_default="" + ) + ck_check_message: Mapped[str] = mapped_column(Text, default="") + ck_checked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True) + + is_bound: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + game_role_name: Mapped[str] = mapped_column(String(128), default="") + can_rebind: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + rebind_remain_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + weidou_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) + + created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow) + updated_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=_utcnow, onupdate=_utcnow + ) + + class HuyaRegisterBatch(Base): """虎牙自动注册批次(持久化元数据)""" diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 96af602..281259f 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -41,6 +41,13 @@ PERMISSIONS = { "yyb:recharge": "应用宝充值与付款", "yyb:manage": "接管并操作他人的应用宝任务", "yyb:history": "查看应用宝充值历史", + # 微博微豆 + "weibo:view": "查看微博账号", + "weibo:import": "导入/刷新微博 CK", + "weibo:bind": "生成微博绑定二维码", + "weibo:recharge": "创建微博助威充值订单", + "weibo:exchange": "执行微博微豆兑换", + "weibo:delete": "删除微博账号", # 虎牙 "huya:account": "虎牙账号管理(兼容旧权限)", "huya:view_all": "查看所有虎牙账号", @@ -101,6 +108,12 @@ ROLE_PERMISSIONS = { "yyb:recharge", "yyb:manage", "yyb:history", + "weibo:view", + "weibo:import", + "weibo:bind", + "weibo:recharge", + "weibo:exchange", + "weibo:delete", "huya:account", "huya:view_all", "huya:import", diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py new file mode 100644 index 0000000..2834d5b --- /dev/null +++ b/web/backend/routers/weibo.py @@ -0,0 +1,602 @@ +"""微博微豆:CK 导入、账号刷新与绑定二维码。""" + +from __future__ import annotations + +import os + +from fastapi import APIRouter, Depends, HTTPException, Query +from loguru import logger +from sqlalchemy import or_ +from sqlalchemy.orm import Session, defer + +from core.weibo import WeiboAuthError, WeiboError, WeiboExchangeUncertainError + +from ..database import get_db +from ..deps import get_current_user +from ..models import User, WeiboAccount +from ..permissions import user_has_permission +from ..schemas import ( + WeiboAccountOut, + WeiboBindQrOut, + WeiboBindSchemeOut, + WeiboCookieImport, + WeiboExchangeOut, + WeiboExchangeRecordItem, + WeiboExchangeRecordsOut, + WeiboExchangeRequest, + WeiboGoodsOut, + WeiboImportError, + WeiboImportResult, + WeiboMallListOut, + WeiboPaymentStatusOut, + WeiboPaymentStatusRequest, + WeiboPointHistoryOut, + WeiboPointRecordItem, + WeiboRechargeOrderOut, + WeiboRechargeRequest, +) +from ..services.audit_service import record_audit +from ..services.weibo_service import ( + create_weibo_recharge_order, + exchange_weibo_goods, + generate_weibo_bind_schemes, + import_weibo_cookies, + query_weibo_exchange_records, + query_weibo_mall, + query_weibo_payment_status, + query_weibo_point_history, + refresh_weibo_account, +) + +router = APIRouter(prefix="/api/weibo", tags=["微博微豆"]) +WEIBO_DEFAULT_ACTIVITY = os.getenv("WEIBO_DEFAULT_ACTIVITY", "").strip() + + +def _require_weibo_perm(user: User, permission: str) -> None: + if not user_has_permission(user, permission): + raise HTTPException(status_code=403, detail="权限不足") + + +def _query_str(value) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _int_or_none(value: object) -> int | None: + if value is None or value == "": + return None + try: + return int(str(value)) + except (TypeError, ValueError): + return None + + +def _get_account(db: Session, account_id: int) -> WeiboAccount: + account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first() + if account is None: + raise HTTPException(status_code=404, detail="微博账号不存在") + return account + + +def _account_out(account: WeiboAccount) -> WeiboAccountOut: + return WeiboAccountOut.model_validate(account) + + +def _refresh_or_http(account: WeiboAccount, db: Session): + try: + return refresh_weibo_account( + db, + account, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except WeiboAuthError as exc: + raise HTTPException(status_code=400, detail=f"CK 已失效: {exc}") from exc + except WeiboError as exc: + raise HTTPException(status_code=502, detail=f"刷新微博账号失败: {exc}") from exc + + +def _manual_http_error(exc: Exception) -> HTTPException: + if isinstance(exc, WeiboAuthError): + return HTTPException(status_code=400, detail=f"CK 已失效: {exc}") + return HTTPException(status_code=502, detail=str(exc)) + + +def _record_audit_safely( + db: Session, + current: User, + *, + action: str, + target: str, + detail: dict, +) -> None: + """审计是旁路,失败只记 warning,不回滚已经完成的账号操作。""" + + try: + record_audit( + db, + current, + action=action, + target=target, + detail=detail, + ) + db.commit() + except Exception as exc: # noqa: BLE001 旁路写入不能带崩主流程 + db.rollback() + logger.warning(f"记录微博审计失败: {exc}") + + +@router.get("/accounts") +def list_accounts( + search: str = Query(""), + bind_status: str = Query(""), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """查看已导入的微博账号。""" + + _require_weibo_perm(current, "weibo:view") + query = db.query(WeiboAccount) + search_text = _query_str(search) + if search_text: + pattern = f"%{search_text}%" + query = query.filter( + or_( + WeiboAccount.uid.ilike(pattern), + WeiboAccount.nickname.ilike(pattern), + WeiboAccount.game_role_name.ilike(pattern), + WeiboAccount.tag.ilike(pattern), + ) + ) + bind_value = _query_str(bind_status) + if bind_value == "unbound": + query = query.filter(WeiboAccount.is_bound.is_(False)) + elif bind_value == "bound": + query = query.filter(WeiboAccount.is_bound.is_(True)) + elif bind_value == "rebindable": + query = query.filter( + WeiboAccount.is_bound.is_(True), + WeiboAccount.can_rebind.is_(True), + ) + + total = query.count() + rows = ( + query.options(defer(WeiboAccount.cookie)) + .order_by(WeiboAccount.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return { + "items": [_account_out(account) for account in rows], + "total": total, + "page": page, + "page_size": page_size, + } + + +@router.post("/accounts/import-cookies", response_model=WeiboImportResult) +def import_cookies( + req: WeiboCookieImport, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """导入微博 CK;每个非空行一条完整 Cookie。""" + + _require_weibo_perm(current, "weibo:import") + accounts, errors, skipped = import_weibo_cookies( + db, + req.text, + req.tag, + WEIBO_DEFAULT_ACTIVITY, + ) + typed_errors = [ + WeiboImportError(line=item.line, message=item.message) for item in errors + ] + if accounts: + _record_audit_safely( + db, + current, + action="weibo:account:import", + target=f"count:{len(accounts)}", + detail={ + "uids": [account.uid for account in accounts], + "count": len(accounts), + "skipped": skipped, + "errors": len(errors), + }, + ) + message = f"成功导入/更新 {len(accounts)} 个微博账号" + if errors: + message += f",失败 {len(errors)} 条" + if skipped: + message += f",跳过 {skipped} 条重复" + return WeiboImportResult( + success=bool(accounts), + message=message, + count=len(accounts), + skipped=skipped, + errors=typed_errors, + accounts=[_account_out(account) for account in accounts], + ) + + +@router.post("/accounts/{account_id}/refresh", response_model=WeiboAccountOut) +def refresh_account( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """立即刷新一个微博账号的 CK 状态和资料。""" + + _require_weibo_perm(current, "weibo:view") + account = _get_account(db, account_id) + _refresh_or_http(account, db) + _record_audit_safely( + db, + current, + action="weibo:account:refresh", + target=f"weibo_account:{account.id}", + detail={"account_id": account.id, "uid": account.uid}, + ) + return _account_out(account) + + +@router.post("/accounts/{account_id}/bind-qrcodes", response_model=WeiboBindQrOut) +def create_bind_qrcodes( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """生成微信和 QQ 绑定 Scheme,供前端直接渲染二维码。""" + + _require_weibo_perm(current, "weibo:bind") + account = _get_account(db, account_id) + snapshot = _refresh_or_http(account, db) + if snapshot.is_bound and not snapshot.can_rebind: + detail = ( + f"账号已绑定角色「{snapshot.role_name}」,当前不可换绑" + if snapshot.role_name + else "账号已绑定角色,当前不可换绑" + ) + if snapshot.rebind_remain_days: + detail += f",还需等待 {snapshot.rebind_remain_days} 天" + raise HTTPException(status_code=409, detail=detail) + if not snapshot.bind_url: + raise HTTPException(status_code=409, detail="微博没有返回当前账号的绑定链接") + + schemes = [ + WeiboBindSchemeOut(**item) + for item in generate_weibo_bind_schemes(account, snapshot.bind_url) + ] + successful = [item for item in schemes if item.scheme] + if not successful: + raise HTTPException( + status_code=502, + detail="微信和 QQ 绑定二维码都生成失败: " + + "; ".join(f"{item.label}={item.error}" for item in schemes if item.error), + ) + + _record_audit_safely( + db, + current, + action="weibo:account:bind_qrcode", + target=f"weibo_account:{account.id}", + detail={ + "account_id": account.id, + "uid": account.uid, + "channels": [ + { + "channel": item.channel, + "success": bool(item.scheme), + "error": item.error, + } + for item in schemes + ], + }, + ) + return WeiboBindQrOut( + account_id=account.id, + is_bound=snapshot.is_bound, + can_rebind=snapshot.can_rebind, + rebind_remain_days=snapshot.rebind_remain_days, + schemes=schemes, + ) + + +@router.post("/accounts/{account_id}/mall-list", response_model=WeiboMallListOut) +def list_mall_goods( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """手动查询微豆商城商品列表。""" + + _require_weibo_perm(current, "weibo:view") + account = _get_account(db, account_id) + try: + data = query_weibo_mall( + db, + account, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except (WeiboError, ValueError) as exc: + raise _manual_http_error(exc) from exc + goods: list[WeiboGoodsOut] = [] + for item in data.get("list") or []: + if not isinstance(item, dict): + continue + try: + goods_id = int(item.get("id") or 0) + except (TypeError, ValueError): + continue + if goods_id <= 0: + continue + goods.append( + WeiboGoodsOut( + id=goods_id, + goods_name=str(item.get("goods_name") or ""), + goods_image=str(item.get("goods_image") or ""), + price=_int_or_none(item.get("price")) or 0, + remain_count=_int_or_none(item.get("remain_count")), + is_sold_out=bool(item.get("is_sold_out")), + goods_desc=str(item.get("goods_desc") or ""), + ) + ) + return WeiboMallListOut( + account_id=account.id, + weidou_balance=int(data.get("weidou_balance") or 0), + goods=goods, + ) + + +@router.post( + "/accounts/{account_id}/exchange", + response_model=WeiboExchangeOut, +) +def exchange_goods( + account_id: int, + req: WeiboExchangeRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """手动兑换商品;服务端只提交一次,不自动重试。""" + + _require_weibo_perm(current, "weibo:exchange") + if not req.confirm: + raise HTTPException(status_code=400, detail="请先确认兑换商品") + account = _get_account(db, account_id) + try: + result = exchange_weibo_goods( + db, + account, + goods_id=req.goods_id, + expected_price=req.expected_price, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except WeiboExchangeUncertainError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + except (WeiboError, ValueError) as exc: + raise _manual_http_error(exc) from exc + _record_audit_safely( + db, + current, + action="weibo:mall:exchange", + target=f"weibo_account:{account.id}", + detail={ + "account_id": account.id, + "uid": account.uid, + "goods_id": result.goods_id, + "goods_name": result.goods_name, + "price": result.price, + "balance_before": result.balance_before, + "balance_after": result.balance_after, + "verified": result.verified, + }, + ) + return WeiboExchangeOut( + account_id=account.id, + goods_id=result.goods_id, + goods_name=result.goods_name, + price=result.price, + balance_before=result.balance_before, + balance_after=result.balance_after, + remain_before=result.remain_before, + remain_after=result.remain_after, + verified=result.verified, + verify_error=result.verify_error, + ) + + +@router.post( + "/accounts/{account_id}/exchange-records", + response_model=WeiboExchangeRecordsOut, +) +def list_exchange_records( + account_id: int, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """手动查询微豆兑换记录。""" + + _require_weibo_perm(current, "weibo:view") + account = _get_account(db, account_id) + try: + data = query_weibo_exchange_records( + db, + account, + page=page, + page_size=page_size, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except (WeiboError, ValueError) as exc: + raise _manual_http_error(exc) from exc + items: list[WeiboExchangeRecordItem] = [] + for item in data.get("list") or []: + if not isinstance(item, dict): + continue + items.append( + WeiboExchangeRecordItem( + goods_name=str(item.get("goods_name") or ""), + game_account=str(item.get("game_account") or ""), + cost_weidou=_int_or_none(item.get("cost_weidou")), + exchange_time=str(item.get("exchange_time") or ""), + reward_status=_int_or_none(item.get("reward_status")), + raw=item, + ) + ) + return WeiboExchangeRecordsOut( + account_id=account.id, + page=int(data.get("page") or data.get("page_no") or page), + page_size=int(data.get("pageSize") or data.get("page_size") or page_size), + total=int(data.get("totalCount") or data.get("total") or 0), + items=items, + ) + + +@router.post( + "/accounts/{account_id}/point-history", + response_model=WeiboPointHistoryOut, +) +def list_point_history( + account_id: int, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """手动查询微豆获取和充值明细。""" + + _require_weibo_perm(current, "weibo:view") + account = _get_account(db, account_id) + try: + data = query_weibo_point_history( + db, + account, + page=page, + page_size=page_size, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except (WeiboError, ValueError) as exc: + raise _manual_http_error(exc) from exc + items: list[WeiboPointRecordItem] = [] + for item in data.get("list") or []: + if not isinstance(item, dict): + continue + items.append( + WeiboPointRecordItem( + id=str(item.get("id") or ""), + source_type=_int_or_none(item.get("source_type")), + record_type=_int_or_none(item.get("record_type")), + source_id=str(item.get("source_id") or ""), + source_name=str(item.get("source_name") or ""), + point_num=_int_or_none(item.get("point_num")) or 0, + create_time=str(item.get("create_time") or ""), + expire_time=str(item.get("expire_time") or ""), + ) + ) + return WeiboPointHistoryOut( + account_id=account.id, + page=int(data.get("page_num") or page), + page_size=int(data.get("page_size") or page_size), + total=int(data.get("total") or 0), + items=items, + ) + + +@router.post( + "/accounts/{account_id}/recharge/orders", + response_model=WeiboRechargeOrderOut, +) +def create_recharge_order( + account_id: int, + req: WeiboRechargeRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """手动创建助威充值订单并返回支付二维码。""" + + _require_weibo_perm(current, "weibo:recharge") + account = _get_account(db, account_id) + try: + order = create_weibo_recharge_order( + db, + account, + amount_yuan=req.amount_yuan, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except (WeiboError, ValueError) as exc: + raise _manual_http_error(exc) from exc + _record_audit_safely( + db, + current, + action="weibo:recharge:create_order", + target=f"weibo_account:{account.id}", + detail={ + "account_id": account.id, + "uid": account.uid, + "amount_yuan": order.amount_yuan, + "single_reward": order.single_reward, + "order_id": order.order_id, + }, + ) + return WeiboRechargeOrderOut( + account_id=account.id, + order_id=order.order_id, + pay_id=order.pay_id, + amount_yuan=order.amount_yuan, + single_reward=order.single_reward, + alipay_qr_code=order.alipay_qr_code, + desktop_pay_url=order.desktop_pay_url, + ) + + +@router.post( + "/accounts/{account_id}/recharge/status", + response_model=WeiboPaymentStatusOut, +) +def get_recharge_status( + account_id: int, + req: WeiboPaymentStatusRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """手动查询支付状态;支付动作仍由用户在微信/支付宝完成。""" + + _require_weibo_perm(current, "weibo:recharge") + account = _get_account(db, account_id) + try: + status = query_weibo_payment_status(db, account, pay_id=req.pay_id) + except (WeiboError, ValueError) as exc: + raise _manual_http_error(exc) from exc + return WeiboPaymentStatusOut( + pay_id=status.pay_id, + out_pay_id=status.out_pay_id, + status=status.status, + sub_status=status.sub_status, + ) + + +@router.delete("/accounts/{account_id}") +def delete_account( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """删除一个微博账号。""" + + _require_weibo_perm(current, "weibo:delete") + account = _get_account(db, account_id) + uid = account.uid + db.delete(account) + db.commit() + _record_audit_safely( + db, + current, + action="weibo:account:delete", + target=f"weibo_account:{account_id}", + detail={"account_id": account_id, "uid": uid}, + ) + return {"message": "微博账号已删除", "success": True} diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 660021f..e532c15 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -462,6 +462,160 @@ class AccountCheckBatchOut(BaseModel): items: list[AccountCheckItemOut] +# ---- 微博微豆 ---- +class WeiboCookieImport(BaseModel): + """批量导入微博 CK;每行一个完整 Cookie。""" + + text: str = Field(..., min_length=1) + tag: str = Field("", max_length=64) + + +class WeiboImportError(BaseModel): + line: int + message: str + + +class WeiboAccountOut(BaseModel): + id: int + uid: str + nickname: str = "" + tag: str = "" + activity: str = "" + ck_check_status: str = "" + ck_check_message: str = "" + ck_checked_at: datetime | None = None + is_bound: bool | None = None + game_role_name: str = "" + can_rebind: bool | None = None + rebind_remain_days: int | None = None + weidou_balance: int | None = None + cookie_updated_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = ConfigDict(from_attributes=True) + + +class WeiboImportResult(BaseModel): + success: bool + message: str + count: int + skipped: int + errors: list[WeiboImportError] = Field(default_factory=list) + accounts: list[WeiboAccountOut] = Field(default_factory=list) + + +class WeiboBindSchemeOut(BaseModel): + channel: str + label: str + scheme: str = "" + error: str = "" + + +class WeiboBindQrOut(BaseModel): + account_id: int + is_bound: bool + can_rebind: bool + rebind_remain_days: int + schemes: list[WeiboBindSchemeOut] + + +class WeiboGoodsOut(BaseModel): + id: int + goods_name: str = "" + goods_image: str = "" + price: int = 0 + remain_count: int | None = None + is_sold_out: bool = False + goods_desc: str = "" + + +class WeiboMallListOut(BaseModel): + account_id: int + weidou_balance: int = 0 + goods: list[WeiboGoodsOut] = Field(default_factory=list) + + +class WeiboExchangeRequest(BaseModel): + goods_id: int = Field(..., gt=0) + expected_price: int = Field(..., gt=0) + confirm: bool = False + + +class WeiboExchangeOut(BaseModel): + account_id: int + goods_id: int + goods_name: str + price: int + balance_before: int + balance_after: int | None = None + remain_before: int | None = None + remain_after: int | None = None + verified: bool + verify_error: str = "" + + +class WeiboExchangeRecordItem(BaseModel): + goods_name: str = "" + game_account: str = "" + cost_weidou: int | None = None + exchange_time: str = "" + reward_status: int | None = None + raw: dict[str, Any] = Field(default_factory=dict) + + +class WeiboExchangeRecordsOut(BaseModel): + account_id: int + page: int + page_size: int + total: int + items: list[WeiboExchangeRecordItem] = Field(default_factory=list) + + +class WeiboPointRecordItem(BaseModel): + id: str = "" + source_type: int | None = None + record_type: int | None = None + source_id: str = "" + source_name: str = "" + point_num: int = 0 + create_time: str = "" + expire_time: str = "" + + +class WeiboPointHistoryOut(BaseModel): + account_id: int + page: int + page_size: int + total: int + items: list[WeiboPointRecordItem] = Field(default_factory=list) + + +class WeiboRechargeRequest(BaseModel): + amount_yuan: int = Field(1, ge=1, le=1000) + + +class WeiboRechargeOrderOut(BaseModel): + account_id: int + order_id: str + pay_id: str + amount_yuan: int + single_reward: int + alipay_qr_code: str + desktop_pay_url: str = "" + + +class WeiboPaymentStatusRequest(BaseModel): + pay_id: str = Field(..., min_length=1, max_length=128) + + +class WeiboPaymentStatusOut(BaseModel): + pay_id: str + out_pay_id: str = "" + status: str + sub_status: str = "" + + # ---- 虎牙 ---- class HuyaCookieImport(BaseModel): """批量导入虎牙 Cookie。支持纯 CK 或 账号----密码----CK。""" diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py new file mode 100644 index 0000000..5b7df72 --- /dev/null +++ b/web/backend/services/weibo_service.py @@ -0,0 +1,284 @@ +"""微博微豆账号导入、刷新与绑定 Scheme 服务。""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from core.weibo import ( + WeiboAuthError, + WeiboClient, + WeiboError, + WeiboExchangeResult, + WeiboHomeSnapshot, + WeiboPaymentStatus, + WeiboRechargeOrder, + normalize_weibo_cookie, +) + +from ..models import WeiboAccount + + +@dataclass(frozen=True, slots=True) +class WeiboImportIssue: + line: int + message: str + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _error_status(exc: Exception) -> str: + return "invalid" if isinstance(exc, WeiboAuthError) else "error" + + +def _apply_snapshot( + account: WeiboAccount, + snapshot: WeiboHomeSnapshot, + cookie: str, +) -> None: + account.uid = snapshot.uid + account.nickname = snapshot.nickname + account.cookie = cookie + account.cookie_updated_at = _now() + account.is_bound = snapshot.is_bound + account.game_role_name = snapshot.role_name + account.can_rebind = snapshot.can_rebind + account.rebind_remain_days = snapshot.rebind_remain_days + account.weidou_balance = snapshot.weidou_balance + if snapshot.activity: + account.activity = snapshot.activity + account.ck_check_status = "valid" + account.ck_check_message = "" + account.ck_checked_at = _now() + + +def import_weibo_cookies( + db: Session, + text: str, + tag: str = "", + activity: str = "", +) -> tuple[list[WeiboAccount], list[WeiboImportIssue], int]: + """逐行验证并导入微博 CK。 + + 返回 ``(成功账号, 错误列表, 跳过数)``。单行 CK 失效或网络失败只记录该行, + 不阻断其余账号导入。 + """ + + accounts: list[WeiboAccount] = [] + errors: list[WeiboImportIssue] = [] + skipped = 0 + seen_uids: set[str] = set() + clean_tag = (tag or "").strip() + + for line_no, raw_line in enumerate((text or "").splitlines(), 1): + raw = raw_line.strip() + if not raw: + continue + try: + cookie = normalize_weibo_cookie(raw) + snapshot = WeiboClient(cookie).fetch_home(activity) + except (ValueError, WeiboError) as exc: + errors.append(WeiboImportIssue(line=line_no, message=str(exc))) + continue + + if snapshot.uid in seen_uids: + skipped += 1 + continue + seen_uids.add(snapshot.uid) + + account = ( + db.query(WeiboAccount).filter(WeiboAccount.uid == snapshot.uid).first() + ) + if account is None: + account = WeiboAccount(uid=snapshot.uid, cookie=cookie) + db.add(account) + _apply_snapshot(account, snapshot, cookie) + if clean_tag: + account.tag = clean_tag + accounts.append(account) + + if accounts: + db.commit() + for account in accounts: + db.refresh(account) + return accounts, errors, skipped + + +def refresh_weibo_account( + db: Session, + account: WeiboAccount, + *, + activity: str = "", +) -> WeiboHomeSnapshot: + """刷新一个微博账号;失败时保留旧资料并记录本次检查状态。""" + + try: + snapshot = WeiboClient(account.cookie or "").fetch_home(activity) + except (WeiboError, ValueError) as exc: + account.ck_check_status = _error_status(exc) + account.ck_check_message = str(exc) + account.ck_checked_at = _now() + db.commit() + raise + + _apply_snapshot(account, snapshot, account.cookie or "") + db.commit() + db.refresh(account) + return snapshot + + +def generate_weibo_bind_schemes( + account: WeiboAccount, + bind_url: str, +) -> list[dict[str, str]]: + """分别生成微信、QQ Scheme;单渠道失败不阻断另一渠道。""" + + client = WeiboClient() + result: list[dict[str, str]] = [] + for channel, label in (("wx", "微信"), ("qq", "QQ")): + try: + scheme = client.generate_bind_scheme(bind_url, channel) + result.append( + {"channel": channel, "label": label, "scheme": scheme, "error": ""} + ) + except (WeiboError, ValueError) as exc: + result.append( + { + "channel": channel, + "label": label, + "scheme": "", + "error": str(exc), + } + ) + return result + + +def _effective_activity(account: WeiboAccount, fallback: str) -> str: + return account.activity or fallback + + +def _mark_manual_failure( + db: Session, + account: WeiboAccount, + exc: Exception, +) -> None: + account.ck_check_status = _error_status(exc) + account.ck_check_message = str(exc) + account.ck_checked_at = _now() + db.commit() + + +def query_weibo_mall( + db: Session, + account: WeiboAccount, + *, + activity: str = "", +) -> dict: + try: + data = WeiboClient(account.cookie or "").list_mall( + _effective_activity(account, activity) + ) + except (WeiboError, ValueError) as exc: + _mark_manual_failure(db, account, exc) + raise + if isinstance(data.get("weidou_balance"), int): + account.weidou_balance = data["weidou_balance"] + db.commit() + return data + + +def query_weibo_exchange_records( + db: Session, + account: WeiboAccount, + *, + page: int, + page_size: int, + activity: str = "", +) -> dict: + try: + return WeiboClient(account.cookie or "").list_exchange_records( + _effective_activity(account, activity), + page=page, + page_size=page_size, + ) + except (WeiboError, ValueError) as exc: + _mark_manual_failure(db, account, exc) + raise + + +def exchange_weibo_goods( + db: Session, + account: WeiboAccount, + *, + goods_id: int, + expected_price: int, + activity: str = "", +) -> WeiboExchangeResult: + """预检并执行一次微博微豆兑换,然后刷新账号余额。""" + + try: + result = WeiboClient(account.cookie or "").exchange_goods( + goods_id, + expected_price=expected_price, + activity=_effective_activity(account, activity), + ) + except (WeiboError, ValueError) as exc: + _mark_manual_failure(db, account, exc) + raise + if result.balance_after is not None: + account.weidou_balance = result.balance_after + db.commit() + return result + + +def query_weibo_point_history( + db: Session, + account: WeiboAccount, + *, + page: int, + page_size: int, + activity: str = "", +) -> dict: + try: + return WeiboClient(account.cookie or "").list_point_history( + _effective_activity(account, activity), + page=page, + page_size=page_size, + ) + except (WeiboError, ValueError) as exc: + _mark_manual_failure(db, account, exc) + raise + + +def create_weibo_recharge_order( + db: Session, + account: WeiboAccount, + *, + amount_yuan: int, + activity: str = "", +) -> WeiboRechargeOrder: + try: + return WeiboClient(account.cookie or "").create_recharge_order( + amount_yuan=amount_yuan, + activity=_effective_activity(account, activity), + ) + except (WeiboError, ValueError) as exc: + _mark_manual_failure(db, account, exc) + raise + + +def query_weibo_payment_status( + db: Session, + account: WeiboAccount, + *, + pay_id: str, +) -> WeiboPaymentStatus: + try: + return WeiboClient(account.cookie or "").get_payment_status(pay_id) + except Exception as exc: + _mark_manual_failure(db, account, exc) + raise diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index b794225..4d307f1 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -31,6 +31,7 @@ const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage')); const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage')); const HuyaCollabPage = lazy(() => import('./pages/HuyaCollabPage')); const HuyaBrowserExchangePage = lazy(() => import('./pages/HuyaBrowserExchangePage')); +const WeiboAccountsPage = lazy(() => import('./pages/WeiboAccountsPage')); const YybRechargePage = lazy(() => import('./pages/YybRechargePage')); const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage')); @@ -105,6 +106,7 @@ function AppContent() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index 08f649f..a31f75a 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -14,4 +14,5 @@ export { huyaApi } from './huya'; export { loginApi } from './login'; export { proxyApi } from './proxy'; export { userApi } from './users'; +export { weiboApi } from './weibo'; export { yybApi } from './yyb'; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 202b346..3fd43be 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -634,6 +634,7 @@ export interface DouyuTaskAccountItem { uid: string; nickname: string; tag: string; + activity: string; ck_check_status: string; ck_checked_at: string | null; points: number | null; @@ -745,6 +746,137 @@ export interface DouyuXpdGoodsItem extends DouyuGoodsItem { goods_left: number | null; } +// ==================== Weibo ==================== + +export interface WeiboAccountItem { + id: number; + uid: string; + nickname: string; + tag: string; + ck_check_status: string; + ck_check_message: string; + ck_checked_at: string | null; + is_bound: boolean | null; + game_role_name: string; + can_rebind: boolean | null; + rebind_remain_days: number | null; + weidou_balance: number | null; + cookie_updated_at: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface WeiboImportError { + line: number; + message: string; +} + +export interface WeiboImportResult { + success: boolean; + message: string; + count: number; + skipped: number; + errors: WeiboImportError[]; + accounts: WeiboAccountItem[]; +} + +export interface WeiboBindScheme { + channel: 'wx' | 'qq'; + label: string; + scheme: string; + error: string; +} + +export interface WeiboBindQrResult { + account_id: number; + is_bound: boolean; + can_rebind: boolean; + rebind_remain_days: number; + schemes: WeiboBindScheme[]; +} + +export interface WeiboGoodsItem { + id: number; + goods_name: string; + goods_image: string; + price: number; + remain_count: number | null; + is_sold_out: boolean; + goods_desc: string; +} + +export interface WeiboMallListResult { + account_id: number; + weidou_balance: number; + goods: WeiboGoodsItem[]; +} + +export interface WeiboExchangeRecordItem { + goods_name: string; + game_account: string; + cost_weidou: number | null; + exchange_time: string; + reward_status: number | null; + raw: Record; +} + +export interface WeiboExchangeRecordsResult { + account_id: number; + page: number; + page_size: number; + total: number; + items: WeiboExchangeRecordItem[]; +} + +export interface WeiboExchangeResult { + account_id: number; + goods_id: number; + goods_name: string; + price: number; + balance_before: number; + balance_after: number | null; + remain_before: number | null; + remain_after: number | null; + verified: boolean; + verify_error: string; +} + +export interface WeiboPointRecordItem { + id: string; + source_type: number | null; + record_type: number | null; + source_id: string; + source_name: string; + point_num: number; + create_time: string; + expire_time: string; +} + +export interface WeiboPointHistoryResult { + account_id: number; + page: number; + page_size: number; + total: number; + items: WeiboPointRecordItem[]; +} + +export interface WeiboRechargeOrderResult { + account_id: number; + order_id: string; + pay_id: string; + amount_yuan: number; + single_reward: number; + alipay_qr_code: string; + desktop_pay_url: string; +} + +export interface WeiboPaymentStatusResult { + pay_id: string; + out_pay_id: string; + status: string; + sub_status: string; +} + // ==================== Huya ==================== export interface HuyaAccountItem { diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts new file mode 100644 index 0000000..84f42d9 --- /dev/null +++ b/web/frontend/src/api/weibo.ts @@ -0,0 +1,84 @@ +import api from './client'; +import type { + MessageResponse, + PaginatedResponse, + WeiboAccountItem, + WeiboBindQrResult, + WeiboExchangeRecordsResult, + WeiboExchangeResult, + WeiboMallListResult, + WeiboPaymentStatusResult, + WeiboPointHistoryResult, + WeiboRechargeOrderResult, + WeiboImportResult, +} from './types'; + +export const weiboApi = { + listAccounts: (params: { + search?: string; + bind_status?: string; + page: number; + page_size: number; + }) => + api.get, PaginatedResponse>( + '/weibo/accounts', + { params }, + ), + importCookies: (text: string, tag = '') => + api.post('/weibo/accounts/import-cookies', { + text, + tag, + }), + refreshAccount: (accountId: number) => + api.post( + `/weibo/accounts/${accountId}/refresh`, + ), + bindQrcodes: (accountId: number) => + api.post( + `/weibo/accounts/${accountId}/bind-qrcodes`, + undefined, + { timeout: 60000 }, + ), + mallList: (accountId: number) => + api.post( + `/weibo/accounts/${accountId}/mall-list`, + undefined, + { timeout: 60000 }, + ), + exchangeGoods: (accountId: number, goodsId: number, expectedPrice: number) => + api.post( + `/weibo/accounts/${accountId}/exchange`, + { + goods_id: goodsId, + expected_price: expectedPrice, + confirm: true, + }, + { timeout: 120000 }, + ), + exchangeRecords: (accountId: number, page = 1, pageSize = 20) => + api.post( + `/weibo/accounts/${accountId}/exchange-records`, + undefined, + { params: { page, page_size: pageSize }, timeout: 60000 }, + ), + pointHistory: (accountId: number, page = 1, pageSize = 20) => + api.post( + `/weibo/accounts/${accountId}/point-history`, + undefined, + { params: { page, page_size: pageSize }, timeout: 60000 }, + ), + createRechargeOrder: (accountId: number, amountYuan: number) => + api.post( + `/weibo/accounts/${accountId}/recharge/orders`, + { amount_yuan: amountYuan }, + { timeout: 120000 }, + ), + rechargeStatus: (accountId: number, payId: string) => + api.post( + `/weibo/accounts/${accountId}/recharge/status`, + { pay_id: payId }, + { timeout: 30000 }, + ), + deleteAccount: (accountId: number) => + api.delete(`/weibo/accounts/${accountId}`), +}; diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index f1dd9c8..98b9104 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -8,6 +8,7 @@ import { SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined, BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined, SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined, FileExcelOutlined, + MessageOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { getUser, clearAuth, type AuthUser } from '../store/auth'; @@ -52,6 +53,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { const menuItems: MenuProps['items'] = []; const douyuItems: NonNullable = []; const huyaItems: NonNullable = []; + const weiboItems: NonNullable = []; const systemItems: NonNullable = []; // Dashboard - 所有人可见 @@ -124,12 +126,19 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { huyaItems.push({ key: '/huya/collab', label: '外部单协作', icon: }); } + if (can('weibo:view')) { + weiboItems.push({ key: '/weibo/accounts', label: '账号与绑定', icon: }); + } + if (douyuItems.length > 0) { menuItems.push({ key: 'group-douyu', type: 'group', label: '斗鱼', children: douyuItems }); } if (huyaItems.length > 0) { menuItems.push({ key: 'group-huya', type: 'group', label: '虎牙', children: huyaItems }); } + if (weiboItems.length > 0) { + menuItems.push({ key: 'group-weibo', type: 'group', label: '微博', children: weiboItems }); + } // 系统 if (can('proxy:manage')) { diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx new file mode 100644 index 0000000..32cd693 --- /dev/null +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -0,0 +1,1009 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Button, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + QRCode, + Select, + Space, + Spin, + Table, + Tabs, + Tag, + Tooltip, + Typography, + theme, +} from 'antd'; +import type { TableProps } from 'antd'; +import { + DeleteOutlined, + AppstoreOutlined, + ImportOutlined, + QrcodeOutlined, + ReloadOutlined, + SearchOutlined, +} from '@ant-design/icons'; +import { + weiboApi, + type PaginatedResponse, + type WeiboAccountItem, + type WeiboBindQrResult, + type WeiboExchangeRecordsResult, + type WeiboImportError, + type WeiboMallListResult, + type WeiboPaymentStatusResult, + type WeiboPointHistoryResult, + type WeiboRechargeOrderResult, +} from '../api/modules'; +import { usePermissions } from '../hooks/usePermissions'; +import { formatTime } from '../utils/time'; +import { getErrorMessage } from '../utils/error'; +import { message } from '../utils/antdMessage'; + +const { Text, Title } = Typography; +const { TextArea } = Input; + +const CK_STATUS: Record = { + valid: { label: '有效', color: 'success' }, + invalid: { label: '失效', color: 'error' }, + error: { label: '查询失败', color: 'warning' }, +}; + +const PAYMENT_STATUS_LABELS: Record = { + PAY_STATUS_PENDING: '等待支付', + PAY_STATUS_SUCCESS: '支付成功', + PAY_STATUS_FAILED: '支付失败', + PAY_STATUS_CLOSED: '订单关闭', +}; + +function bindStatus(account: WeiboAccountItem) { + if (account.is_bound === true && account.can_rebind === true) { + return 可换绑; + } + if (account.is_bound === true) { + const days = account.rebind_remain_days ?? 0; + return {days > 0 ? `${days} 天后可换绑` : '已绑定'}; + } + if (account.is_bound === false) return 未绑定; + return 未查询; +} + +export default function WeiboAccountsPage() { + const { token } = theme.useToken(); + const { can } = usePermissions(); + const canImport = can('weibo:import'); + const canBind = can('weibo:bind'); + const canRecharge = can('weibo:recharge'); + const canExchange = can('weibo:exchange'); + const canDelete = can('weibo:delete'); + + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [search, setSearch] = useState(''); + const [bindFilter, setBindFilter] = useState(''); + const [loading, setLoading] = useState(false); + const [refreshingId, setRefreshingId] = useState(null); + + const [importOpen, setImportOpen] = useState(false); + const [importing, setImporting] = useState(false); + const [importErrors, setImportErrors] = useState([]); + const [form] = Form.useForm<{ text: string; tag?: string }>(); + + const [qrOpen, setQrOpen] = useState(false); + const [qrAccount, setQrAccount] = useState(null); + const [qrLoading, setQrLoading] = useState(false); + const [qrResult, setQrResult] = useState(null); + + const [featureOpen, setFeatureOpen] = useState(false); + const [featureAccount, setFeatureAccount] = useState(null); + const [featureTab, setFeatureTab] = useState('goods'); + const [mallLoading, setMallLoading] = useState(false); + const [mallResult, setMallResult] = useState(null); + const [exchangeLoading, setExchangeLoading] = useState(false); + const [exchangeResult, setExchangeResult] = + useState(null); + const [pointLoading, setPointLoading] = useState(false); + const [pointResult, setPointResult] = useState(null); + const [exchangingGoodsId, setExchangingGoodsId] = useState(null); + const [rechargeAmount, setRechargeAmount] = useState(1); + const [rechargeCreating, setRechargeCreating] = useState(false); + const [rechargeOrder, setRechargeOrder] = + useState(null); + const [paymentStatusLoading, setPaymentStatusLoading] = useState(false); + const [paymentStatus, setPaymentStatus] = + useState(null); + + const loadAccounts = useCallback(async () => { + setLoading(true); + try { + const data: PaginatedResponse = await weiboApi.listAccounts({ + search: search.trim() || undefined, + bind_status: bindFilter || undefined, + page, + page_size: pageSize, + }); + setItems(data.items); + setTotal(data.total); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setLoading(false); + } + }, [bindFilter, page, pageSize, search]); + + useEffect(() => { + void loadAccounts(); + }, [loadAccounts]); + + const handleImport = async () => { + const values = await form.validateFields(); + setImporting(true); + try { + const result = await weiboApi.importCookies(values.text, values.tag || ''); + setImportErrors(result.errors); + if (result.count > 0) { + message.success(result.message); + await loadAccounts(); + } + if (result.errors.length === 0 && result.count > 0) { + setImportOpen(false); + form.resetFields(); + } else if (result.count === 0) { + message.error(result.message); + } + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setImporting(false); + } + }; + + const handleRefresh = async (account: WeiboAccountItem) => { + setRefreshingId(account.id); + try { + const updated = await weiboApi.refreshAccount(account.id); + setItems((current) => + current.map((item) => (item.id === updated.id ? updated : item)), + ); + setFeatureAccount((current) => + current?.id === updated.id ? updated : current, + ); + message.success('账号资料已刷新'); + } catch (error) { + message.error(getErrorMessage(error)); + void loadAccounts(); + } finally { + setRefreshingId(null); + } + }; + + const openQr = async (account: WeiboAccountItem) => { + setQrAccount(account); + setQrResult(null); + setQrOpen(true); + setQrLoading(true); + try { + const result = await weiboApi.bindQrcodes(account.id); + setQrResult(result); + } catch (error) { + message.error(getErrorMessage(error)); + setQrOpen(false); + void loadAccounts(); + } finally { + setQrLoading(false); + } + }; + + const handleDelete = async (account: WeiboAccountItem) => { + try { + await weiboApi.deleteAccount(account.id); + message.success('微博账号已删除'); + if (items.length === 1 && page > 1) setPage(page - 1); + else await loadAccounts(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const loadMall = async (account: WeiboAccountItem) => { + setMallLoading(true); + try { + setMallResult(await weiboApi.mallList(account.id)); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setMallLoading(false); + } + }; + + const handleExchange = async (goods: WeiboMallListResult['goods'][number]) => { + if (!featureAccount) return; + setExchangingGoodsId(goods.id); + try { + const result = await weiboApi.exchangeGoods( + featureAccount.id, + goods.id, + goods.price, + ); + if (result.verified) { + message.success( + `兑换成功:${result.goods_name},余额 ${result.balance_after?.toLocaleString() ?? '-'}`, + ); + } else { + message.warning(result.verify_error || '兑换已提交,请查兑换记录复核'); + } + await loadMall(featureAccount); + await loadExchangeRecords(featureAccount); + await handleRefresh(featureAccount); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setExchangingGoodsId(null); + } + }; + + const loadExchangeRecords = async (account: WeiboAccountItem) => { + setExchangeLoading(true); + try { + setExchangeResult(await weiboApi.exchangeRecords(account.id)); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setExchangeLoading(false); + } + }; + + const loadPointHistory = async (account: WeiboAccountItem) => { + setPointLoading(true); + try { + setPointResult(await weiboApi.pointHistory(account.id)); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setPointLoading(false); + } + }; + + const openFeatures = (account: WeiboAccountItem) => { + setFeatureAccount(account); + setFeatureOpen(true); + setFeatureTab('goods'); + setMallResult(null); + setExchangeResult(null); + setPointResult(null); + setRechargeOrder(null); + setPaymentStatus(null); + setRechargeAmount(1); + void loadMall(account); + }; + + const switchFeatureTab = (key: string) => { + setFeatureTab(key); + if (!featureAccount) return; + if (key === 'goods' && !mallResult) void loadMall(featureAccount); + if (key === 'exchange' && !exchangeResult) { + void loadExchangeRecords(featureAccount); + } + if (key === 'points' && !pointResult) void loadPointHistory(featureAccount); + }; + + const createRecharge = async () => { + if (!featureAccount) return; + setRechargeCreating(true); + setPaymentStatus(null); + try { + const order = await weiboApi.createRechargeOrder( + featureAccount.id, + rechargeAmount, + ); + setRechargeOrder(order); + message.success(`已创建 ${order.amount_yuan} 元充值订单`); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setRechargeCreating(false); + } + }; + + const refreshPaymentStatus = async () => { + if (!featureAccount || !rechargeOrder) return; + setPaymentStatusLoading(true); + try { + const status = await weiboApi.rechargeStatus( + featureAccount.id, + rechargeOrder.pay_id, + ); + setPaymentStatus(status); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setPaymentStatusLoading(false); + } + }; + + const refreshAfterRecharge = async () => { + if (!featureAccount) return; + await handleRefresh(featureAccount); + await loadPointHistory(featureAccount); + }; + + const columns: TableProps['columns'] = [ + { + title: '微博账号', + key: 'account', + width: 220, + render: (_, account) => ( + + {account.nickname || account.uid} + + UID {account.uid} + + + ), + }, + { + title: '标签', + dataIndex: 'tag', + width: 120, + render: (value: string) => value || -, + }, + { + title: 'CK', + key: 'ck', + width: 120, + render: (_, account) => { + const state = CK_STATUS[account.ck_check_status] || { + label: '未检测', + color: 'default', + }; + return ( + + {state.label} + + ); + }, + }, + { + title: '角色绑定', + key: 'bind', + width: 150, + render: (_, account) => ( + + {bindStatus(account)} + {account.game_role_name && ( + + {account.game_role_name} + + )} + + ), + }, + { + title: '微豆', + dataIndex: 'weidou_balance', + width: 100, + align: 'right', + render: (value: number | null) => + value == null ? - : value.toLocaleString(), + }, + { + title: '最近检查', + dataIndex: 'ck_checked_at', + width: 170, + render: (value: string | null) => + value ? formatTime(value) : -, + }, + { + title: '操作', + key: 'actions', + width: 390, + fixed: 'right', + render: (_, account) => { + const cannotBind = + account.is_bound === true && account.can_rebind !== true; + return ( + + + + + + + {canDelete && ( + void handleDelete(account)} + > + + + ); + }, + }, + ]; + + const exchangeColumns: TableProps< + WeiboExchangeRecordsResult['items'][number] + >['columns'] = [ + { title: '商品', dataIndex: 'goods_name' }, + { title: '游戏角色', dataIndex: 'game_account', width: 180 }, + { + title: '微豆', + dataIndex: 'cost_weidou', + width: 100, + align: 'right', + render: (value: number | null) => value ?? '-', + }, + { title: '兑换时间', dataIndex: 'exchange_time', width: 170 }, + { + title: '状态', + dataIndex: 'reward_status', + width: 100, + render: (value: number | null) => value ?? '-', + }, + ]; + + const pointColumns: TableProps< + WeiboPointHistoryResult['items'][number] + >['columns'] = [ + { title: '来源', dataIndex: 'source_name', width: 150 }, + { + title: '微豆', + dataIndex: 'point_num', + width: 100, + align: 'right', + render: (value: number) => ( + = 0 ? 'success' : 'danger'}> + {value > 0 ? `+${value}` : value} + + ), + }, + { title: '到账时间', dataIndex: 'create_time', width: 170 }, + { title: '过期时间', dataIndex: 'expire_time', width: 170 }, + { title: '订单号', dataIndex: 'source_id' }, + ]; + + return ( + +
+
+ + 微博账号 + + CK 导入、微豆资料刷新和角色绑定二维码 +
+ + } + placeholder="UID、昵称、角色、标签" + value={search} + onChange={(event) => { + setSearch(event.target.value); + setPage(1); + }} + style={{ width: 240 }} + /> +