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
+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