feat(weibo): 增加微博微豆基础自动化

This commit is contained in:
yml2213
2026-11-07 20:33:15 +08:00
parent 16196332f2
commit b7f34df04d
21 changed files with 4989 additions and 1 deletions
+39
View File
@@ -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",
]
+899
View File
@@ -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
@@ -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=<Date.now()>
```
`_t` 是缓存破坏参数,未观察到参与签名。
## 3. 用户、绑定和任务入口
```http
GET https://vipclub.e.weibo.com/aj/mall/home?F=<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=<username>&path=<encoded-path>
```
输入也可能直接来自其他微博小程序跳转:
```text
sinaweibo://wbdiversion?username=gh_<username>&path=/pages/gameAccountBind/index?<inner_query>
```
其中:
- `username=gh_...` 是微博侧小程序标识,不传给来联接口。
- `sinaweibo://wbdiversion` 只用于微博客户端唤起,不传给来联接口。
- `<inner_query>` 是 `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?<inner_query>
https://livelink.qq.com/h5-open-mp/qq?<inner_query>
```
PC 页面不会直接展示二维码,而是请求:
```http
POST https://livelinkbind.game.qq.com/livelink?c=Sdk&a=GenMiniappScheme&<path内层参数>
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<dynamic>
```
QQ:
```text
mqqapi://miniapp/open?_atype=0&_mappid=1111906964&_miniapptype=1&_mvid=&_path=pages%2FgameAccountBind%2Findex&_vt=3&via=qq_txyxll&_sig=<dynamic>&_nq=<encoded-inner-query>
```
本次样本确认:
| 字段 | 微信 | 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=<encoded-subscribe-url>"
}
```
解码后进入:
```text
https://vipclub.weibo.com/vmember/subscribe?vuid=<vuid>&source_itemid=20601&F=<F>
```
### 4.2 获取助力目标
```http
GET https://vipclub.weibo.com/aj/vmember/gfsupport?vuid=<vuid>&source_itemid=20601&F=<F>&_t=<ms>
```
响应提供:
- `data.uid`
- `data.vuid`
- `data.u_name`
- 被助威账号信息
- 是否已助力/会员
### 4.3 获取收银台和商品档位
```http
GET https://vipclub.weibo.com/aj/paycomponent/cashierinfo
?F=<F>
&tab_id=536
&cashier_id=205
&cashier_version=0.2.3
&vuid=<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=<vuid>&source_itemid=20601&F=<F>
Cookie: <微博 CK>
```
样本核心 Body:
```text
subitemid=2060101
itemid=20601
appkey=2000
commodity_id=2060101
num=1
cashier_id=205
F=<F>
is_from_cashier=1
cashierpw=1
vuid=<vuid>
tab_type=8
cashier_version=0.2.3
single_reward=100
ext={"bid":"<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
<input type="hidden" name="pay_id" value="<pay_id>">
<img node-type="QRCode" src="https://qr.weibo.cn/inf/gen?...">
<a href="https://pay.sc.weibo.com/pay/pc/comb?pay_id=<pay_id>...">立即支付</a>
```
支付二维码由收银台服务端生成带 `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=<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": "<createorder.data.orderid>",
"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=<F>
&page_no=1
&page_size=20
&_t=<ms>
```
响应:
```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=<F>&_t=<ms>
```
响应:
```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=<F>&goods_id=<goods_id>&_t=<ms>
```
响应包含:
```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=<F>&goods_id=<goods_id>&_t=<ms>
```
请求参数只有 `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=<F>
&page=1
&page_size=20
&_t=<ms>
```
响应:
```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` 是否跨活动变化;实现时应从页面动态读取。
+1 -1
View File
@@ -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 = (
+404
View File
@@ -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
+530
View File
@@ -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 = """
<input type="hidden" value="1220351218153064346" node-type="pay_id" name="pay_id">
<img node-type="QRCode" src="https://qr.weibo.cn/inf/gen?sign=test">
<a href="https://pay.sc.weibo.com/pay/pc/comb?pay_id=1220351218153064346">立即支付</a>
"""
alipay_form_html = """
<form action="https://mapi.alipay.com/gateway.do" method="GET">
<input name="out_trade_no" value="1020351218229058766">
<input name="total_fee" value="1.00">
</form>
"""
alipay_html = """
<input name="qrCode" type="hidden" value="https://qr.alipay.com/upx-test" id="J_qrCode" />
"""
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)
+1
View File
@@ -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"),
+2
View File
@@ -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)
@@ -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)
@@ -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")
+38
View File
@@ -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):
"""虎牙自动注册批次(持久化元数据)"""
+13
View File
@@ -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",
+602
View File
@@ -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}
+154
View File
@@ -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。"""
+284
View File
@@ -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
+2
View File
@@ -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() {
<Route path="huya/elite" element={lazyRoute(<HuyaElitePage />)} />
<Route path="huya/collab" element={lazyRoute(<HuyaCollabPage />)} />
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
<Route path="weibo/accounts" element={lazyRoute(<WeiboAccountsPage />)} />
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
<Route path="automation" element={lazyRoute(<AutomationPage />)} />
+1
View File
@@ -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';
+132
View File
@@ -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<string, unknown>;
}
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 {
+84
View File
@@ -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<WeiboAccountItem>, PaginatedResponse<WeiboAccountItem>>(
'/weibo/accounts',
{ params },
),
importCookies: (text: string, tag = '') =>
api.post<WeiboImportResult, WeiboImportResult>('/weibo/accounts/import-cookies', {
text,
tag,
}),
refreshAccount: (accountId: number) =>
api.post<WeiboAccountItem, WeiboAccountItem>(
`/weibo/accounts/${accountId}/refresh`,
),
bindQrcodes: (accountId: number) =>
api.post<WeiboBindQrResult, WeiboBindQrResult>(
`/weibo/accounts/${accountId}/bind-qrcodes`,
undefined,
{ timeout: 60000 },
),
mallList: (accountId: number) =>
api.post<WeiboMallListResult, WeiboMallListResult>(
`/weibo/accounts/${accountId}/mall-list`,
undefined,
{ timeout: 60000 },
),
exchangeGoods: (accountId: number, goodsId: number, expectedPrice: number) =>
api.post<WeiboExchangeResult, WeiboExchangeResult>(
`/weibo/accounts/${accountId}/exchange`,
{
goods_id: goodsId,
expected_price: expectedPrice,
confirm: true,
},
{ timeout: 120000 },
),
exchangeRecords: (accountId: number, page = 1, pageSize = 20) =>
api.post<WeiboExchangeRecordsResult, WeiboExchangeRecordsResult>(
`/weibo/accounts/${accountId}/exchange-records`,
undefined,
{ params: { page, page_size: pageSize }, timeout: 60000 },
),
pointHistory: (accountId: number, page = 1, pageSize = 20) =>
api.post<WeiboPointHistoryResult, WeiboPointHistoryResult>(
`/weibo/accounts/${accountId}/point-history`,
undefined,
{ params: { page, page_size: pageSize }, timeout: 60000 },
),
createRechargeOrder: (accountId: number, amountYuan: number) =>
api.post<WeiboRechargeOrderResult, WeiboRechargeOrderResult>(
`/weibo/accounts/${accountId}/recharge/orders`,
{ amount_yuan: amountYuan },
{ timeout: 120000 },
),
rechargeStatus: (accountId: number, payId: string) =>
api.post<WeiboPaymentStatusResult, WeiboPaymentStatusResult>(
`/weibo/accounts/${accountId}/recharge/status`,
{ pay_id: payId },
{ timeout: 30000 },
),
deleteAccount: (accountId: number) =>
api.delete<MessageResponse, MessageResponse>(`/weibo/accounts/${accountId}`),
};
+9
View File
@@ -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<MenuProps['items']> = [];
const huyaItems: NonNullable<MenuProps['items']> = [];
const weiboItems: NonNullable<MenuProps['items']> = [];
const systemItems: NonNullable<MenuProps['items']> = [];
// Dashboard - 所有人可见
@@ -124,12 +126,19 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
huyaItems.push({ key: '/huya/collab', label: '外部单协作', icon: <FileExcelOutlined /> });
}
if (can('weibo:view')) {
weiboItems.push({ key: '/weibo/accounts', label: '账号与绑定', icon: <MessageOutlined /> });
}
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')) {
File diff suppressed because it is too large Load Diff