初步增加, 扫码登录成功
This commit is contained in:
@@ -154,6 +154,8 @@ _SENSITIVE_COLUMNS: tuple[tuple[str, str], ...] = (
|
||||
("huya_register_items", "password"),
|
||||
("huya_register_items", "cookie"),
|
||||
("huya_register_success_logs", "password"),
|
||||
("yyb_recharge_tasks", "login_qr_data"),
|
||||
("yyb_recharge_tasks", "payment_qr_data"),
|
||||
("proxy_config", "api_url"),
|
||||
("proxy_config", "http"),
|
||||
("proxy_config", "https"),
|
||||
|
||||
+6
-1
@@ -12,7 +12,7 @@ from fastapi.responses import FileResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .database import init_db
|
||||
from .routers import auth, users, accounts, account_check, dashboard, login, proxy, cookies, huya, douyu
|
||||
from .routers import auth, users, accounts, account_check, dashboard, login, proxy, cookies, huya, douyu, yyb
|
||||
from .schemas import AppInfo
|
||||
from .version import get_app_version
|
||||
from utils import setup_logger
|
||||
@@ -41,6 +41,10 @@ async def lifespan(app: FastAPI):
|
||||
cleaned_douyu = cleanup_orphan_douyu_tasks(db, message="任务已中断(服务重启)")
|
||||
if cleaned_douyu:
|
||||
logger.info(f"启动清理斗鱼残留任务: {cleaned_douyu} 条")
|
||||
from .services.yyb_service import cleanup_orphan_yyb_tasks
|
||||
cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)")
|
||||
if cleaned_yyb:
|
||||
logger.info(f"启动清理应用宝残留任务: {cleaned_yyb} 条")
|
||||
finally:
|
||||
db.close()
|
||||
yield
|
||||
@@ -97,6 +101,7 @@ app.include_router(proxy.router)
|
||||
app.include_router(cookies.router)
|
||||
app.include_router(huya.router)
|
||||
app.include_router(douyu.router)
|
||||
app.include_router(yyb.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""增加应用宝和平精英充值任务"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "20260812_0020"
|
||||
down_revision: Union[str, None] = "20260808_0019"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if sa.inspect(bind).has_table("yyb_recharge_tasks"):
|
||||
return
|
||||
op.create_table(
|
||||
"yyb_recharge_tasks",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("task_id", sa.String(64), nullable=False),
|
||||
sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("worker_job_id", sa.String(64), nullable=False),
|
||||
sa.Column("provider", sa.String(16), nullable=False, server_default=""),
|
||||
sa.Column("platform", sa.String(16), nullable=False, server_default="android"),
|
||||
sa.Column("points", sa.Integer(), nullable=True),
|
||||
sa.Column("product_id", sa.String(128), nullable=True, server_default=""),
|
||||
sa.Column("zone_id", sa.String(64), nullable=True, server_default=""),
|
||||
sa.Column("zone_name", sa.String(128), nullable=True, server_default=""),
|
||||
sa.Column("role_id", sa.String(64), nullable=True, server_default=""),
|
||||
sa.Column("role_name", sa.String(128), nullable=True, server_default=""),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="created"),
|
||||
sa.Column("phase", sa.String(32), nullable=False, server_default="login"),
|
||||
sa.Column("message", sa.String(512), nullable=True, server_default=""),
|
||||
sa.Column("result", sa.JSON(), nullable=True),
|
||||
sa.Column("login_qr_data", sa.Text(), nullable=True),
|
||||
sa.Column("payment_qr_data", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index("uq_yyb_recharge_tasks_task_id", "yyb_recharge_tasks", ["task_id"], unique=True)
|
||||
op.create_index("ix_yyb_recharge_tasks_created_by", "yyb_recharge_tasks", ["created_by"])
|
||||
op.create_index("ix_yyb_recharge_tasks_worker_job_id", "yyb_recharge_tasks", ["worker_job_id"])
|
||||
op.create_index("ix_yyb_recharge_tasks_status", "yyb_recharge_tasks", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if sa.inspect(bind).has_table("yyb_recharge_tasks"):
|
||||
op.drop_table("yyb_recharge_tasks")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""扩展应用宝二维码密文字段容量"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260812_0021"
|
||||
down_revision: Union[str, None] = "20260812_0020"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _is_mysql() -> bool:
|
||||
return op.get_bind().dialect.name in {"mysql", "mariadb"}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _is_mysql():
|
||||
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data MEDIUMTEXT NULL")
|
||||
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data MEDIUMTEXT NULL")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _is_mysql():
|
||||
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data TEXT NULL")
|
||||
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data TEXT NULL")
|
||||
@@ -125,6 +125,35 @@ class DouyuTask(Base):
|
||||
account = relationship("Account", back_populates="douyu_tasks")
|
||||
|
||||
|
||||
class YybRechargeTask(Base):
|
||||
"""应用宝和平精英点券充值任务。
|
||||
|
||||
YYB 登录身份与斗鱼账号无关,因此任务只关联创建者,不复用 Account。
|
||||
"""
|
||||
__tablename__ = "yyb_recharge_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
worker_job_id = Column(String(64), nullable=False, index=True)
|
||||
provider = Column(String(16), default="", nullable=False)
|
||||
platform = Column(String(16), default="android", nullable=False)
|
||||
points = Column(Integer, nullable=True)
|
||||
product_id = Column(String(128), default="")
|
||||
zone_id = Column(String(64), default="")
|
||||
zone_name = Column(String(128), default="")
|
||||
role_id = Column(String(64), default="")
|
||||
role_name = Column(String(128), default="")
|
||||
status = Column(String(32), default="created", nullable=False, index=True)
|
||||
phase = Column(String(32), default="login", nullable=False)
|
||||
message = Column(String(512), default="")
|
||||
result = Column(JSON, nullable=True)
|
||||
login_qr_data = Column(EncryptedText(), default="")
|
||||
payment_qr_data = Column(EncryptedText(), default="")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class DouyuConfig(Base):
|
||||
"""斗鱼业务配置"""
|
||||
__tablename__ = "douyu_config"
|
||||
|
||||
@@ -26,6 +26,9 @@ PERMISSIONS = {
|
||||
# 斗鱼活动
|
||||
"douyu:task": "斗鱼任务管理",
|
||||
"douyu:config": "斗鱼配置管理",
|
||||
"yyb:session": "应用宝扫码登录与角色查询",
|
||||
"yyb:recharge": "应用宝充值与付款",
|
||||
"yyb:history": "查看应用宝充值历史",
|
||||
# 虎牙
|
||||
"huya:account": "虎牙账号管理(兼容旧权限)",
|
||||
"huya:view_all": "查看所有虎牙账号",
|
||||
@@ -62,6 +65,9 @@ ROLE_PERMISSIONS = {
|
||||
"cookie:export",
|
||||
"douyu:task",
|
||||
"douyu:config",
|
||||
"yyb:session",
|
||||
"yyb:recharge",
|
||||
"yyb:history",
|
||||
"huya:account",
|
||||
"huya:view_all",
|
||||
"huya:import",
|
||||
@@ -80,6 +86,8 @@ ROLE_PERMISSIONS = {
|
||||
"support": [
|
||||
"account:view_assigned",
|
||||
"douyu:task",
|
||||
"yyb:session",
|
||||
"yyb:history",
|
||||
"huya:view_assigned",
|
||||
"huya:task",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""应用宝和平精英充值工作台 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_permission
|
||||
from ..models import User, YybRechargeTask
|
||||
from ..permissions import user_has_permission
|
||||
from ..schemas import YybLoginRequest, YybSelectionRequest, YybTaskCreateRequest
|
||||
from ..services.yyb_service import public_task, sync_task
|
||||
from ..services.yyb_worker_client import YybWorkerClient, YybWorkerError
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"])
|
||||
|
||||
|
||||
def _get_task(db: Session, task_id: int, current: User) -> YybRechargeTask:
|
||||
task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "充值任务不存在")
|
||||
if task.created_by != current.id and not user_has_permission(current, "yyb:history"):
|
||||
raise HTTPException(403, "无权查看该充值任务")
|
||||
return task
|
||||
|
||||
|
||||
def _worker_call(call):
|
||||
try:
|
||||
return call()
|
||||
except YybWorkerError as exc:
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
||||
data = _worker_call(YybWorkerClient().create_job)
|
||||
task = YybRechargeTask(task_id=uuid.uuid4().hex[:16], created_by=current.id,
|
||||
worker_job_id=str(data["job_id"]), status=str(data.get("status", "created")),
|
||||
phase="login", message="请选择登录方式")
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return public_task(task, include_qr=False)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/login")
|
||||
def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
||||
task = _get_task(db, task_id, current)
|
||||
data = _worker_call(lambda: YybWorkerClient().login(task.worker_job_id, payload.provider, payload.timeout))
|
||||
task.provider = payload.provider
|
||||
task.status = str(data.get("status", "waiting_login"))
|
||||
task.phase = "login"
|
||||
task.message = "请扫码登录并在手机确认"
|
||||
if data.get("qr_data"):
|
||||
task.login_qr_data = data["qr_data"]
|
||||
db.commit()
|
||||
return public_task(task)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
def get_task(task_id: int, db: Session = Depends(get_db), current: User = Depends(get_current_user)):
|
||||
task = _get_task(db, task_id, current)
|
||||
try:
|
||||
sync_task(db, task, YybWorkerClient())
|
||||
except YybWorkerError:
|
||||
# Worker 暂时重启时仍返回最近一次持久化状态。
|
||||
pass
|
||||
return public_task(task, include_qr=user_has_permission(current, "yyb:session"),
|
||||
include_payment_qr=user_has_permission(current, "yyb:recharge"))
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
def list_tasks(limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:history"))):
|
||||
tasks = db.query(YybRechargeTask).order_by(YybRechargeTask.id.desc()).limit(limit).all()
|
||||
return [public_task(task, include_qr=False) for task in tasks]
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/selection-options")
|
||||
def selection_options(task_id: int, platform: str = Query("android"), points: int | None = Query(None), zone_id: str | None = Query(None), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
||||
task = _get_task(db, task_id, current)
|
||||
data = _worker_call(lambda: YybWorkerClient().selection_options(task.worker_job_id, platform, points, zone_id))
|
||||
task.platform = platform
|
||||
db.commit()
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/selection")
|
||||
def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
||||
task = _get_task(db, task_id, current)
|
||||
data = _worker_call(lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump()))
|
||||
selected = data.get("selection", payload.model_dump())
|
||||
for field in ("platform", "points", "product_id", "zone_id", "zone_name", "role_id", "role_name"):
|
||||
setattr(task, field, selected[field])
|
||||
task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以创建付款码"
|
||||
db.commit()
|
||||
return public_task(task)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/payment")
|
||||
def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))):
|
||||
task = _get_task(db, task_id, current)
|
||||
if not task.product_id or not task.role_id:
|
||||
raise HTTPException(400, "请先完成商品、区服和角色选择")
|
||||
data = _worker_call(lambda: YybWorkerClient().payment(task.worker_job_id))
|
||||
task.status = str(data.get("status", "running"))
|
||||
task.phase = "payment"
|
||||
task.message = "正在创建订单和付款码"
|
||||
db.commit()
|
||||
return public_task(task)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/stop")
|
||||
def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
||||
task = _get_task(db, task_id, current)
|
||||
data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id))
|
||||
task.status = "failed"
|
||||
task.phase = "stopped"
|
||||
task.message = "任务已停止"
|
||||
db.commit()
|
||||
return public_task(task)
|
||||
@@ -13,6 +13,25 @@ from .huya_defaults import (
|
||||
)
|
||||
|
||||
|
||||
class YybTaskCreateRequest(BaseModel):
|
||||
"""创建 YYB 任务;登录方式在下一步选择。"""
|
||||
|
||||
|
||||
class YybLoginRequest(BaseModel):
|
||||
provider: str = Field(..., pattern="^(qq|wechat)$")
|
||||
timeout: int = Field(600, ge=60, le=1800)
|
||||
|
||||
|
||||
class YybSelectionRequest(BaseModel):
|
||||
platform: str = Field(..., pattern="^(android|ios)$")
|
||||
points: int = Field(..., gt=0)
|
||||
product_id: str = Field(..., min_length=1, max_length=128)
|
||||
zone_id: str = Field(..., min_length=1, max_length=64)
|
||||
zone_name: str = Field("", max_length=128)
|
||||
role_id: str = Field(..., min_length=1, max_length=64)
|
||||
role_name: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
def _ensure_tz(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
"""确保 datetime 带有 UTC 时区信息,无时区的视为 UTC。"""
|
||||
if dt is None:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""应用宝充值任务服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import YybRechargeTask
|
||||
from .yyb_worker_client import YybWorkerClient
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int:
|
||||
rows = db.query(YybRechargeTask).filter(YybRechargeTask.status.in_(["created", "waiting_login", "running", "ready"])).all()
|
||||
for task in rows:
|
||||
task.status = "failed"
|
||||
task.message = message
|
||||
task.finished_at = _utcnow()
|
||||
if rows:
|
||||
db.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> YybRechargeTask:
|
||||
data = worker.get_job(task.worker_job_id)
|
||||
task.status = str(data.get("status", task.status))
|
||||
task.phase = str(data.get("phase", task.phase))
|
||||
task.message = str(data.get("message", task.message))
|
||||
if data.get("provider"):
|
||||
task.provider = str(data["provider"])
|
||||
if data.get("qr_data"):
|
||||
task.login_qr_data = str(data["qr_data"])
|
||||
task.result = {**(task.result or {}), "login_qr_mime_type": data.get("qr_mime_type", "image/jpeg")}
|
||||
if data.get("payment_qr_data"):
|
||||
task.payment_qr_data = str(data["payment_qr_data"])
|
||||
task.result = {**(task.result or {}), "payment_qr_mime_type": data.get("payment_qr_mime_type", "image/png")}
|
||||
task.result = {
|
||||
**(task.result or {}),
|
||||
"logs": data.get("logs", []),
|
||||
"payment_status": data.get("payment_status"),
|
||||
}
|
||||
if task.status in {"success", "failed"} and task.finished_at is None:
|
||||
task.finished_at = _utcnow()
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
def public_task(task: YybRechargeTask, include_qr: bool = True,
|
||||
include_payment_qr: bool | None = None) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"id": task.id, "task_id": task.task_id,
|
||||
"provider": task.provider, "platform": task.platform, "points": task.points,
|
||||
"product_id": task.product_id, "zone_id": task.zone_id, "zone_name": task.zone_name,
|
||||
"role_id": task.role_id, "role_name": task.role_name, "status": task.status,
|
||||
"phase": task.phase, "message": task.message, "result": task.result,
|
||||
"created_by": task.created_by, "created_at": task.created_at,
|
||||
"finished_at": task.finished_at,
|
||||
}
|
||||
if task.result:
|
||||
result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg")
|
||||
result["payment_qr_mime_type"] = task.result.get("payment_qr_mime_type", "image/png")
|
||||
if include_payment_qr is None:
|
||||
include_payment_qr = include_qr
|
||||
if include_qr:
|
||||
result["login_qr_data"] = task.login_qr_data or ""
|
||||
if include_payment_qr:
|
||||
result["payment_qr_data"] = task.payment_qr_data or ""
|
||||
return result
|
||||
@@ -0,0 +1,57 @@
|
||||
"""应用宝 Worker HTTP 客户端。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class YybWorkerError(RuntimeError):
|
||||
"""Worker 返回业务错误。"""
|
||||
|
||||
|
||||
class YybWorkerClient:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = os.getenv("YYB_WORKER_URL", "http://127.0.0.1:8810").rstrip("/")
|
||||
self.key = os.getenv("YYB_WORKER_KEY", "")
|
||||
self.timeout = float(os.getenv("YYB_WORKER_TIMEOUT", "30"))
|
||||
|
||||
def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
headers = {"Accept": "application/json"}
|
||||
if self.key:
|
||||
headers["Authorization"] = f"Bearer {self.key}"
|
||||
try:
|
||||
response = requests.request(method, self.base_url + path, json=payload,
|
||||
headers=headers, timeout=self.timeout)
|
||||
data = response.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
raise YybWorkerError(f"应用宝 Worker 不可用: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise YybWorkerError(str(data.get("detail", "Worker 请求失败")))
|
||||
return data
|
||||
|
||||
def create_job(self) -> dict[str, Any]:
|
||||
return self._request("POST", "/v1/jobs")
|
||||
|
||||
def login(self, worker_job_id: str, provider: str, timeout: int = 600) -> dict[str, Any]:
|
||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/login",
|
||||
{"provider": provider, "timeout": timeout})
|
||||
|
||||
def get_job(self, worker_job_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/v1/jobs/{worker_job_id}")
|
||||
|
||||
def selection_options(self, worker_job_id: str, platform: str,
|
||||
points: int | None = None, zone_id: str | None = None) -> dict[str, Any]:
|
||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/selection-options",
|
||||
{"platform": platform, "points": points, "zone_id": zone_id})
|
||||
|
||||
def selection(self, worker_job_id: str, selection: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/selection", selection)
|
||||
|
||||
def payment(self, worker_job_id: str) -> dict[str, Any]:
|
||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/payment")
|
||||
|
||||
def stop(self, worker_job_id: str) -> dict[str, Any]:
|
||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/stop")
|
||||
@@ -23,6 +23,7 @@ const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
|
||||
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
|
||||
const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
|
||||
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
|
||||
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
@@ -81,6 +82,7 @@ function AppContent() {
|
||||
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
|
||||
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
|
||||
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
|
||||
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
|
||||
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
|
||||
<Route path="users" element={lazyRoute(<UsersPage />)} />
|
||||
</Route>
|
||||
|
||||
@@ -10,3 +10,4 @@ export { huyaApi } from './huya';
|
||||
export { loginApi } from './login';
|
||||
export { proxyApi } from './proxy';
|
||||
export { userApi } from './users';
|
||||
export { yybApi } from './yyb';
|
||||
|
||||
@@ -147,6 +147,39 @@ export interface LoginTaskItem {
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface YybTask {
|
||||
id: number;
|
||||
task_id: string;
|
||||
worker_job_id?: string;
|
||||
created_by: number;
|
||||
provider: string;
|
||||
platform: 'android' | 'ios';
|
||||
points: number | null;
|
||||
product_id: string;
|
||||
zone_id: string;
|
||||
zone_name: string;
|
||||
role_id: string;
|
||||
role_name: string;
|
||||
status: string;
|
||||
phase: string;
|
||||
message: string;
|
||||
result: { logs?: string[]; payment_status?: Record<string, unknown> | null } | null;
|
||||
login_qr_data?: string;
|
||||
login_qr_mime_type?: string;
|
||||
payment_qr_data?: string;
|
||||
payment_qr_mime_type?: string;
|
||||
created_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface YybSelectionOptions {
|
||||
products: Array<{ points: number; product_id: string; price_fen: number; offer_id: string; name: string }>;
|
||||
zones: Array<{ zone_id: string; name: string }>;
|
||||
roles: Array<{ role_id: string; name: string; ban_status: string }>;
|
||||
default_product: { points: number; product_id: string; price_fen: number; offer_id: string; name: string } | null;
|
||||
default_zone: { zone_id: string; name: string } | null;
|
||||
}
|
||||
|
||||
export interface BatchLoginResult {
|
||||
batch_id: string;
|
||||
count: number;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import api from './client';
|
||||
import type { YybTask, YybSelectionOptions } from './types';
|
||||
|
||||
export const yybApi = {
|
||||
createTask: () => api.post<YybTask, YybTask>('/yyb/tasks', {}),
|
||||
login: (id: number, provider: 'qq' | 'wechat') =>
|
||||
api.post<YybTask, YybTask>(`/yyb/tasks/${id}/login`, { provider, timeout: 600 }),
|
||||
getTask: (id: number) => api.get<YybTask, YybTask>(`/yyb/tasks/${id}`),
|
||||
listTasks: () => api.get<YybTask[], YybTask[]>('/yyb/tasks'),
|
||||
options: (id: number, platform: 'android' | 'ios', points?: number, zone_id?: string) =>
|
||||
api.get<YybSelectionOptions, YybSelectionOptions>(`/yyb/tasks/${id}/selection-options`, {
|
||||
params: { platform, ...(points ? { points } : {}), ...(zone_id ? { zone_id } : {}) },
|
||||
}),
|
||||
select: (id: number, data: { platform: 'android' | 'ios'; points: number; product_id: string; zone_id: string; zone_name: string; role_id: string; role_name: string }) =>
|
||||
api.post<YybTask, YybTask>(`/yyb/tasks/${id}/selection`, data),
|
||||
payment: (id: number) => api.post<YybTask, YybTask>(`/yyb/tasks/${id}/payment`),
|
||||
};
|
||||
@@ -94,6 +94,10 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
|
||||
}
|
||||
|
||||
if (canAny(['yyb:session', 'yyb:history'])) {
|
||||
douyuItems.push({ key: '/yyb/recharge', label: '应用宝充值', icon: <ShoppingCartOutlined /> });
|
||||
}
|
||||
|
||||
if (douyuItems.length > 0) {
|
||||
menuItems.push({ key: 'group-douyu', type: 'group', label: '斗鱼', children: douyuItems });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Col, Descriptions, Image, Radio, Row, Select, Space, Steps, Tag, Typography, message } from 'antd';
|
||||
import { CheckCircleOutlined, QrcodeOutlined, ReloadOutlined, ShoppingCartOutlined, WechatOutlined } from '@ant-design/icons';
|
||||
import { yybApi } from '../api/modules';
|
||||
import type { YybSelectionOptions, YybTask } from '../api/types';
|
||||
import { getUser } from '../store/auth';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const phaseIndex: Record<string, number> = { login: 0, selection: 1, payment: 2, completed: 3 };
|
||||
|
||||
export default function YybRechargePage() {
|
||||
const [task, setTask] = useState<YybTask | null>(null);
|
||||
const [options, setOptions] = useState<YybSelectionOptions | null>(null);
|
||||
const [platform, setPlatform] = useState<'android' | 'ios'>('android');
|
||||
const [points, setPoints] = useState<number>();
|
||||
const [zoneId, setZoneId] = useState<string>();
|
||||
const [roleId, setRoleId] = useState<string>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { can } = usePermissions(getUser());
|
||||
|
||||
const refresh = async () => {
|
||||
if (!task) return;
|
||||
try { setTask(await yybApi.getTask(task.id)); } catch { /* 保留当前状态 */ }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!task || ['success', 'failed'].includes(task.status)) return;
|
||||
const timer = window.setInterval(refresh, 2500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [task?.id, task?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (task?.status === 'ready' && task.phase === 'selection' && !options) {
|
||||
void loadOptions(platform);
|
||||
}
|
||||
}, [task?.status, task?.phase]);
|
||||
|
||||
const loadOptions = async (nextPlatform: 'android' | 'ios', nextPoints?: number, nextZone?: string) => {
|
||||
if (!task) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await yybApi.options(task.id, nextPlatform, nextPoints, nextZone);
|
||||
setOptions(data);
|
||||
if (nextPoints === undefined && data.default_product) setPoints(data.default_product.points);
|
||||
if (nextZone === undefined && data.default_zone) setZoneId(data.default_zone.zone_id);
|
||||
setRoleId(undefined);
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '查询商品失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const selectedProduct = useMemo(() => options?.products.find(item => item.points === points), [options, points]);
|
||||
const selectedZone = useMemo(() => options?.zones.find(item => item.zone_id === zoneId), [options, zoneId]);
|
||||
const selectedRole = useMemo(() => options?.roles.find(item => item.role_id === roleId), [options, roleId]);
|
||||
|
||||
const createTask = async () => {
|
||||
setLoading(true);
|
||||
try { setTask(await yybApi.createTask()); setOptions(null); } catch (error) { message.error(error instanceof Error ? error.message : '创建任务失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
const startLogin = async (provider: 'qq' | 'wechat') => {
|
||||
if (!task) return;
|
||||
setLoading(true);
|
||||
try { setTask(await yybApi.login(task.id, provider)); } catch (error) { message.error(error instanceof Error ? error.message : '启动登录失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
const submitSelection = async () => {
|
||||
if (!task || !selectedProduct || !selectedZone || !selectedRole) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setTask(await yybApi.select(task.id, { platform, points: selectedProduct.points, product_id: selectedProduct.product_id, zone_id: selectedZone.zone_id, zone_name: selectedZone.name, role_id: selectedRole.role_id, role_name: selectedRole.name }));
|
||||
message.success('选择已保存');
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '保存选择失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
const createPayment = async () => {
|
||||
if (!task) return;
|
||||
setLoading(true);
|
||||
try { setTask(await yybApi.payment(task.id)); } catch (error) { message.error(error instanceof Error ? error.message : '创建付款码失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return <Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Row justify="space-between" align="middle"><Title level={3} style={{ margin: 0 }}>应用宝和平精英充值</Title><Button icon={<ReloadOutlined />} onClick={task ? refresh : createTask}>{task ? '刷新任务' : '新建充值任务'}</Button></Row>
|
||||
<Steps current={phaseIndex[task?.phase || 'login']} items={[{ title: '扫码登录' }, { title: '选择充值信息' }, { title: '微信付款' }, { title: '完成' }]} />
|
||||
{!task && <Card><Space direction="vertical"><Text>每次充值使用独立的 YYB 登录会话。</Text><Button type="primary" icon={<ShoppingCartOutlined />} onClick={createTask} loading={loading}>开始充值</Button></Space></Card>}
|
||||
{task && <>
|
||||
{task.status === 'failed' && <Alert type="error" showIcon message={task.message || '任务失败'} />}
|
||||
{task.phase === 'login' && <Card title="扫码登录"><Space direction="vertical" size={12}><Radio.Group value={task.provider || undefined} onChange={event => void startLogin(event.target.value)} disabled={loading}><Radio.Button value="qq">QQ 登录</Radio.Button><Radio.Button value="wechat"><WechatOutlined /> 微信登录</Radio.Button></Radio.Group>{task.login_qr_data && <Image width={240} preview src={`data:${task.login_qr_mime_type || 'image/jpeg'};base64,${task.login_qr_data}`} />}<Text type="secondary">{task.message}</Text></Space></Card>}
|
||||
{task.phase === 'selection' && <Card title="选择充值信息" loading={loading}><Space direction="vertical" style={{ width: '100%' }}><Radio.Group value={platform} onChange={event => { const value = event.target.value; setPlatform(value); void loadOptions(value); }}><Radio.Button value="android">Android 区</Radio.Button><Radio.Button value="ios">iOS 区</Radio.Button></Radio.Group>{options && <Row gutter={[12, 12]}><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="点券档位" value={points} options={options.products.map(item => ({ value: item.points, label: `${item.points} 点券(${(item.price_fen / 100).toFixed(2)} 元)` }))} onChange={value => { setPoints(value); void loadOptions(platform, value, zoneId); }} /></Col><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="区服" value={zoneId} options={options.zones.map(item => ({ value: item.zone_id, label: `${item.name}(${item.zone_id})` }))} onChange={value => { setZoneId(value); void loadOptions(platform, points, value); }} /></Col><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="角色" value={roleId} options={options.roles.filter(item => item.ban_status !== '1').map(item => ({ value: item.role_id, label: `${item.name}(${item.role_id})` }))} onChange={setRoleId} /></Col></Row>}<Button type="primary" disabled={!selectedProduct || !selectedZone || !selectedRole} onClick={submitSelection}>确认充值信息</Button></Space></Card>}
|
||||
{task.phase === 'payment' && <Card title="微信付款"><Descriptions column={1} size="small"><Descriptions.Item label="平台">{task.platform === 'ios' ? 'iOS' : 'Android'}</Descriptions.Item><Descriptions.Item label="商品">{task.points} 点券</Descriptions.Item><Descriptions.Item label="区服">{task.zone_name}</Descriptions.Item><Descriptions.Item label="角色">{task.role_name}({task.role_id})</Descriptions.Item></Descriptions>{task.payment_qr_data ? <Space direction="vertical"><Image width={280} preview src={`data:${task.payment_qr_mime_type || 'image/png'};base64,${task.payment_qr_data}`} /><Tag icon={<QrcodeOutlined />} color="blue">请使用微信扫码付款</Tag></Space> : can('yyb:recharge') ? <Button type="primary" icon={<WechatOutlined />} onClick={createPayment} loading={loading || task.status === 'running'} disabled={task.status === 'running'}>{task.status === 'running' ? '正在生成付款码' : '生成微信付款码'}</Button> : <Text type="secondary">当前账号没有创建付款码权限。</Text>}<div><Text type="secondary">{task.message}</Text></div></Card>}
|
||||
{task.phase === 'completed' && <Alert type="success" showIcon icon={<CheckCircleOutlined />} message="充值订单已确认完成" description={task.message} />}
|
||||
</>}
|
||||
</Space>;
|
||||
}
|
||||
Reference in New Issue
Block a user