From af98ea8416eacdeeae2056e7de2253ddd11e527c Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 6 Jul 2026 00:31:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=99=8E=E7=89=99=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/huya/auto_register.py | 199 +++++++++++++ core/huya/sms_login.py | 7 +- core/sms_provider.py | 151 ++++++++++ web/backend/routers/huya.py | 55 ++++ web/backend/schemas.py | 45 +++ web/backend/services/huya_register_runner.py | 262 +++++++++++++++++ web/frontend/src/App.tsx | 2 + web/frontend/src/api/huya.ts | 8 + web/frontend/src/api/types.ts | 44 +++ web/frontend/src/layouts/MainLayout.tsx | 5 +- web/frontend/src/pages/HuyaRegisterPage.tsx | 284 +++++++++++++++++++ 对比 ck.txt | 17 -- 12 files changed, 1060 insertions(+), 19 deletions(-) create mode 100644 core/huya/auto_register.py create mode 100644 core/sms_provider.py create mode 100644 web/backend/services/huya_register_runner.py create mode 100644 web/frontend/src/pages/HuyaRegisterPage.tsx delete mode 100644 对比 ck.txt diff --git a/core/huya/auto_register.py b/core/huya/auto_register.py new file mode 100644 index 0000000..1520c88 --- /dev/null +++ b/core/huya/auto_register.py @@ -0,0 +1,199 @@ +"""虎牙手机号自动注册流程。""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass +from datetime import datetime + +from core.sms_provider import SmsLine, SmsProviderClient + +from .login import HuyaLoginError +from .sms_login import login_huya_sms, normalize_huya_phone, send_huya_sms_code + + +@dataclass +class HuyaAutoRegisterResult: + """单个手机号自动注册结果。""" + + phone: str + provider: str + success: bool + status: str + message: str + cookie: str = "" + code: str = "" + sdid: str = "" + normalized_phone: str = "" + attempts: int = 0 + + +def _sleep_or_stop(stop_event: threading.Event | None, seconds: float) -> bool: + """等待一小段时间;返回 False 表示收到停止信号。""" + if seconds <= 0: + return not (stop_event and stop_event.is_set()) + if stop_event: + return not stop_event.wait(seconds) + time.sleep(seconds) + return True + + +def register_huya_with_sms_line( + item: SmsLine, + wait_seconds: float = 180, + poll_interval: float = 5, + stop_event: threading.Event | None = None, +) -> HuyaAutoRegisterResult: + """使用固定手机号和接码地址完成虎牙短信注册/登录。""" + phone = item.phone.strip() + normalized_phone = normalize_huya_phone(phone) + wait_seconds = max(15.0, float(wait_seconds or 180)) + poll_interval = max(1.0, float(poll_interval or 5)) + + if stop_event and stop_event.is_set(): + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=False, + status="stopped", + message="已停止", + normalized_phone=normalized_phone, + ) + + sent_at = datetime.now() + try: + code_result = send_huya_sms_code(phone=phone) + except HuyaLoginError as exc: + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=False, + status="error", + message=str(exc), + normalized_phone=normalized_phone, + ) + except Exception as exc: + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=False, + status="error", + message=f"发送虎牙短信失败: {exc}", + normalized_phone=normalized_phone, + ) + + if not code_result.success or not code_result.state: + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=False, + status="error", + message=code_result.message or "发送虎牙短信失败", + sdid=code_result.sdid, + normalized_phone=normalized_phone, + ) + + client = SmsProviderClient() + deadline = time.monotonic() + wait_seconds + attempts = 0 + last_message = "等待验证码" + + while time.monotonic() < deadline: + if stop_event and stop_event.is_set(): + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=False, + status="stopped", + message="已停止", + sdid=code_result.sdid, + normalized_phone=normalized_phone, + attempts=attempts, + ) + + attempts += 1 + poll_result = client.poll(item, after_time=sent_at) + last_message = poll_result.message or last_message + if poll_result.has_code: + try: + login_result = login_huya_sms( + authcode=poll_result.code, + state=code_result.state, + phone=phone, + ) + except HuyaLoginError as exc: + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=False, + status="error", + message=str(exc), + code=poll_result.code, + sdid=code_result.sdid, + normalized_phone=normalized_phone, + attempts=attempts, + ) + except Exception as exc: + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=False, + status="error", + message=f"提交虎牙短信登录失败: {exc}", + code=poll_result.code, + sdid=code_result.sdid, + normalized_phone=normalized_phone, + attempts=attempts, + ) + + if login_result.success and login_result.cookie: + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=True, + status="success", + message="注册/登录成功", + cookie=login_result.cookie, + code=poll_result.code, + sdid=login_result.sdid or code_result.sdid, + normalized_phone=normalized_phone, + attempts=attempts, + ) + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=False, + status="error", + message=login_result.message or "虎牙短信登录失败", + code=poll_result.code, + sdid=login_result.sdid or code_result.sdid, + normalized_phone=normalized_phone, + attempts=attempts, + ) + + remaining = deadline - time.monotonic() + if remaining <= 0: + break + if not _sleep_or_stop(stop_event, min(poll_interval, remaining)): + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=False, + status="stopped", + message="已停止", + sdid=code_result.sdid, + normalized_phone=normalized_phone, + attempts=attempts, + ) + + return HuyaAutoRegisterResult( + phone=phone, + provider=item.provider, + success=False, + status="error", + message=f"等待验证码超时: {last_message}", + sdid=code_result.sdid, + normalized_phone=normalized_phone, + attempts=attempts, + ) diff --git a/core/huya/sms_login.py b/core/huya/sms_login.py index 94d4148..4a40ae0 100644 --- a/core/huya/sms_login.py +++ b/core/huya/sms_login.py @@ -54,7 +54,12 @@ class HuyaSmsCodeResult: def normalize_huya_phone(phone: str) -> str: """归一化虎牙短信接口手机号格式。""" - digits = "".join(ch for ch in str(phone or "") if ch.isdigit()) + raw = str(phone or "").strip() + digits = "".join(ch for ch in raw if ch.isdigit()) + if raw.startswith("+1") and len(digits) == 11: + return f"001{digits[1:]}" + if digits.startswith("001") and len(digits) == 13: + return digits if digits.startswith("086"): return digits if digits.startswith("86") and len(digits) == 13: diff --git a/core/sms_provider.py b/core/sms_provider.py new file mode 100644 index 0000000..0e19636 --- /dev/null +++ b/core/sms_provider.py @@ -0,0 +1,151 @@ +"""接码平台查询与验证码提取。""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from datetime import datetime + +import requests + + +CODE_PATTERN = re.compile( + r"(?:verification\s+code|验证码|校验码|动态码|安全码)\D{0,20}(\d{4,8})", + re.IGNORECASE, +) +FALLBACK_CODE_PATTERN = re.compile(r"(? bool: + return self.status == "code" and bool(self.code) + + +def parse_sms_lines(text: str) -> list[SmsLine]: + """解析手机号池,每行格式:手机号----查询URL。""" + rows: list[SmsLine] = [] + for raw_line in (text or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = [part.strip() for part in line.split("----", 1)] + if len(parts) != 2 or not parts[0] or not parts[1]: + continue + rows.append(SmsLine( + phone=parts[0], + url=parts[1], + provider=detect_provider(parts[1]), + raw=line, + )) + return rows + + +def detect_provider(url: str) -> str: + """根据 URL 判断接码平台类型。""" + text = (url or "").lower() + if "sms8.net" in text: + return "sms8" + return "pipe" + + +def extract_sms_code(text: str) -> str: + """从短信内容中提取验证码。""" + body = str(text or "") + match = CODE_PATTERN.search(body) + if match: + return match.group(1) + match = FALLBACK_CODE_PATTERN.search(body) + return match.group(1) if match else "" + + +def _parse_sms8_time(value: str) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S"): + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None + + +class SmsProviderClient: + """固定手机号池的短信查询客户端。""" + + def __init__(self, timeout: tuple[float, float] = (8, 20)): + self.timeout = timeout + self.session = requests.Session() + self.session.trust_env = False + + def poll(self, item: SmsLine, after_time: datetime | None = None) -> SmsPollResult: + """查询一次短信。""" + try: + response = self.session.get(item.url, timeout=self.timeout) + response.raise_for_status() + except requests.RequestException as exc: + return SmsPollResult(status="error", message=f"查询短信失败: {exc}") + + body = response.text.strip() + if item.provider == "sms8": + return self._parse_sms8(body, after_time=after_time) + return self._parse_pipe(body) + + def _parse_pipe(self, body: str) -> SmsPollResult: + if not body: + return SmsPollResult(status="waiting", message="短信为空", raw=body) + low = body.lower() + if low.startswith("no|"): + return SmsPollResult(status="waiting", message=body, raw=body) + if low.startswith("yes|"): + code = extract_sms_code(body) + if code: + return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) + return SmsPollResult(status="error", message=f"已收到短信但未识别验证码: {body}", raw=body) + code = extract_sms_code(body) + if code: + return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) + return SmsPollResult(status="waiting", message=body, raw=body) + + def _parse_sms8(self, body: str, after_time: datetime | None = None) -> SmsPollResult: + try: + payload = json.loads(body) + except json.JSONDecodeError: + code = extract_sms_code(body) + if code: + return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) + return SmsPollResult(status="error", message=f"短信平台返回非 JSON: {body[:120]}", raw=body) + + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + msg = str(payload.get("msg") or "") + code_text = str(data.get("code") or "") + code_time = _parse_sms8_time(str(data.get("code_time") or "")) + if after_time and code_time and code_time < after_time.replace(tzinfo=None): + return SmsPollResult(status="waiting", message="验证码时间早于本次发码", raw=body) + + code = extract_sms_code(code_text) + if int(payload.get("code") or 0) == 1 and code: + return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) + if code: + return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) + return SmsPollResult(status="waiting", message=msg or "暂无验证码", raw=body) diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 0cdb6ba..037b234 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -19,6 +19,7 @@ from core.huya import ( send_huya_sms_code, ) from core.huya.cookie_utils import normalize_huya_cookie +from core.sms_provider import parse_sms_lines from ..database import SessionLocal, get_db from ..deps import authenticate_websocket, get_current_user, require_permission @@ -29,6 +30,8 @@ from ..schemas import ( AccountTag, BatchAssign, HuyaAccountOut, + HuyaAutoRegisterBatchOut, + HuyaAutoRegisterRequest, HuyaConfigOut, HuyaConfigUpdate, HuyaCookieImport, @@ -55,6 +58,7 @@ from ..services.huya_service import ( upsert_huya_cookie, ) from ..services.huya_runner import HuyaBatchRunner, huya_batch_registry +from ..services.huya_register_runner import huya_register_registry router = APIRouter(prefix="/api/huya", tags=["虎牙"]) @@ -337,6 +341,57 @@ def sms_login_account( } +@router.post("/register/batches", response_model=HuyaAutoRegisterBatchOut) +def create_auto_register_batch( + req: HuyaAutoRegisterRequest, + current: User = Depends(get_current_user), +): + """启动虎牙手机号自动注册批次。""" + _require_huya_perm(current, "huya:import") + sms_lines = parse_sms_lines(req.text) + if not sms_lines: + raise HTTPException(status_code=400, detail="没有识别到有效手机号,格式为:手机号----短信查询URL") + + runner = huya_register_registry.create( + sms_lines=sms_lines, + tag=req.tag.strip(), + created_by=current.id, + concurrency=req.concurrency, + wait_seconds=req.wait_seconds, + poll_interval=req.poll_interval, + ) + thread = threading.Thread(target=runner.run, daemon=True) + thread.start() + return runner.snapshot() + + +@router.get("/register/batches/{batch_id}", response_model=HuyaAutoRegisterBatchOut) +def get_auto_register_batch( + batch_id: str, + current: User = Depends(get_current_user), +): + """查询虎牙手机号自动注册批次状态。""" + _require_huya_perm(current, "huya:import") + runner = huya_register_registry.get(batch_id) + if not runner: + raise HTTPException(status_code=404, detail="批次不存在或服务已重启") + return runner.snapshot() + + +@router.post("/register/batches/{batch_id}/stop") +def stop_auto_register_batch( + batch_id: str, + current: User = Depends(get_current_user), +): + """停止虎牙手机号自动注册批次。""" + _require_huya_perm(current, "huya:import") + runner = huya_register_registry.get(batch_id) + if not runner: + raise HTTPException(status_code=404, detail="批次不存在或服务已重启") + runner.stop() + return {"message": "已发送停止信号", "success": True} + + @router.post("/accounts/password-login/selected") def password_login_selected_accounts( req: HuyaPasswordLoginSelectedRequest, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 5ef3e01..082fb1c 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -197,6 +197,51 @@ class HuyaSmsLoginRequest(BaseModel): tag: str = "" +class HuyaAutoRegisterRequest(BaseModel): + """虎牙自动注册批次。每行格式:手机号----短信查询URL。""" + text: str = Field(..., min_length=1) + tag: str = "" + concurrency: int = Field(1, ge=1, le=5) + wait_seconds: float = Field(180, ge=15, le=600) + poll_interval: float = Field(5, ge=1, le=30) + + +class HuyaAutoRegisterItemOut(BaseModel): + line: int + phone: str + provider: str + status: str + message: str + code: str = "" + attempts: int = 0 + account_id: Optional[int] = None + uid: str = "" + cookie: str = "" + cookie_preview: str = "" + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + + +class HuyaAutoRegisterBatchOut(BaseModel): + batch_id: str + status: str + message: str + tag: str = "" + created_by: int + concurrency: int + wait_seconds: float + poll_interval: float + total: int + success_count: int + failed_count: int + stopped_count: int + running_count: int + created_at: Optional[datetime] = None + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + items: list[HuyaAutoRegisterItemOut] + + class HuyaPasswordAccountImport(BaseModel): """导入虎牙账号密码,稍后再选择登录。""" text: str = Field(..., min_length=1) diff --git a/web/backend/services/huya_register_runner.py b/web/backend/services/huya_register_runner.py new file mode 100644 index 0000000..170d205 --- /dev/null +++ b/web/backend/services/huya_register_runner.py @@ -0,0 +1,262 @@ +"""虎牙自动注册批次执行器。""" + +from __future__ import annotations + +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from core.huya.auto_register import HuyaAutoRegisterResult, register_huya_with_sms_line +from core.huya.cookie_utils import normalize_huya_cookie +from core.sms_provider import SmsLine + +from ..database import SessionLocal +from .huya_service import upsert_huya_cookie + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _cookie_preview(cookie: str) -> str: + normalized = normalize_huya_cookie(cookie or "") + if not normalized: + return "" + return normalized[:50] + "..." if len(normalized) > 50 else normalized + + +@dataclass +class HuyaRegisterItemState: + """单个手机号在批次中的状态。""" + + line: int + phone: str + provider: str + status: str = "pending" + message: str = "等待开始" + code: str = "" + attempts: int = 0 + account_id: int | None = None + uid: str = "" + cookie: str = "" + cookie_preview: str = "" + started_at: datetime | None = None + finished_at: datetime | None = None + + def to_dict(self) -> dict: + return { + "line": self.line, + "phone": self.phone, + "provider": self.provider, + "status": self.status, + "message": self.message, + "code": self.code, + "attempts": self.attempts, + "account_id": self.account_id, + "uid": self.uid, + "cookie": self.cookie, + "cookie_preview": self.cookie_preview, + "started_at": self.started_at, + "finished_at": self.finished_at, + } + + +@dataclass +class HuyaRegisterBatch: + """自动注册批次内存快照。""" + + batch_id: str + tag: str + created_by: int + concurrency: int + wait_seconds: float + poll_interval: float + items: list[HuyaRegisterItemState] + status: str = "pending" + message: str = "等待开始" + created_at: datetime = field(default_factory=_now) + started_at: datetime | None = None + finished_at: datetime | None = None + + +class HuyaRegisterRunner: + """在后台线程中批量执行虎牙手机号自动注册。""" + + def __init__( + self, + batch: HuyaRegisterBatch, + sms_lines: list[SmsLine], + ): + self.batch = batch + self.sms_lines = sms_lines + self._lock = threading.Lock() + self._stop = threading.Event() + + def stop(self): + self._stop.set() + with self._lock: + if self.batch.status == "running": + self.batch.message = "正在停止" + + def snapshot(self) -> dict: + with self._lock: + total = len(self.batch.items) + success = sum(1 for item in self.batch.items if item.status == "success") + failed = sum(1 for item in self.batch.items if item.status == "error") + stopped = sum(1 for item in self.batch.items if item.status == "stopped") + running = sum(1 for item in self.batch.items if item.status in {"sending", "waiting", "logging"}) + return { + "batch_id": self.batch.batch_id, + "status": self.batch.status, + "message": self.batch.message, + "tag": self.batch.tag, + "created_by": self.batch.created_by, + "concurrency": self.batch.concurrency, + "wait_seconds": self.batch.wait_seconds, + "poll_interval": self.batch.poll_interval, + "total": total, + "success_count": success, + "failed_count": failed, + "stopped_count": stopped, + "running_count": running, + "created_at": self.batch.created_at, + "started_at": self.batch.started_at, + "finished_at": self.batch.finished_at, + "items": [item.to_dict() for item in self.batch.items], + } + + def _set_item(self, index: int, **updates): + with self._lock: + item = self.batch.items[index] + for key, value in updates.items(): + setattr(item, key, value) + + def _save_cookie(self, result: HuyaAutoRegisterResult) -> tuple[int | None, str]: + db = SessionLocal() + try: + account = upsert_huya_cookie(db, result.cookie, tag=self.batch.tag, username_hint="") + account.game_phone = result.phone + account.updated_at = _now() + db.commit() + db.refresh(account) + return account.id, account.uid or account.yyuid or "" + finally: + db.close() + + def _run_one(self, index: int, item: SmsLine): + if self._stop.is_set(): + self._set_item(index, status="stopped", message="已停止", finished_at=_now()) + return + + self._set_item(index, status="sending", message="发送虎牙短信", started_at=_now(), finished_at=None) + result = register_huya_with_sms_line( + item, + wait_seconds=self.batch.wait_seconds, + poll_interval=self.batch.poll_interval, + stop_event=self._stop, + ) + + account_id = None + uid = "" + message = result.message + status = result.status + cookie = result.cookie if result.success else "" + if result.success: + self._set_item(index, status="logging", message="保存 Cookie", code=result.code, attempts=result.attempts) + try: + account_id, uid = self._save_cookie(result) + except Exception as exc: + status = "error" + cookie = "" + message = f"Cookie 保存失败: {exc}" + + self._set_item( + index, + status=status, + message=message, + code=result.code, + attempts=result.attempts, + account_id=account_id, + uid=uid, + cookie=normalize_huya_cookie(cookie), + cookie_preview=_cookie_preview(cookie), + finished_at=_now(), + ) + + def run(self): + """线程入口。""" + with self._lock: + self.batch.status = "running" + self.batch.message = "批次运行中" + self.batch.started_at = _now() + + try: + with ThreadPoolExecutor(max_workers=self.batch.concurrency) as executor: + futures = [] + for index, item in enumerate(self.sms_lines): + if self._stop.is_set(): + self._set_item(index, status="stopped", message="已停止", finished_at=_now()) + continue + futures.append(executor.submit(self._run_one, index, item)) + + for future in as_completed(futures): + future.result() + except Exception as exc: + with self._lock: + self.batch.status = "error" + self.batch.message = f"批次执行异常: {exc}" + self.batch.finished_at = _now() + return + + with self._lock: + if self._stop.is_set(): + self.batch.status = "stopped" + self.batch.message = "批次已停止" + else: + self.batch.status = "finished" + self.batch.message = "批次已完成" + self.batch.finished_at = _now() + + +class HuyaRegisterRegistry: + """管理自动注册批次。""" + + def __init__(self): + self._lock = threading.Lock() + self._runners: dict[str, HuyaRegisterRunner] = {} + + def create( + self, + sms_lines: list[SmsLine], + tag: str, + created_by: int, + concurrency: int, + wait_seconds: float, + poll_interval: float, + ) -> HuyaRegisterRunner: + batch_id = uuid.uuid4().hex[:12] + batch = HuyaRegisterBatch( + batch_id=batch_id, + tag=tag, + created_by=created_by, + concurrency=max(1, min(int(concurrency or 1), 5)), + wait_seconds=max(15.0, float(wait_seconds or 180)), + poll_interval=max(1.0, float(poll_interval or 5)), + items=[ + HuyaRegisterItemState(line=index + 1, phone=item.phone, provider=item.provider) + for index, item in enumerate(sms_lines) + ], + ) + runner = HuyaRegisterRunner(batch=batch, sms_lines=sms_lines) + with self._lock: + self._runners[batch_id] = runner + return runner + + def get(self, batch_id: str) -> HuyaRegisterRunner | None: + with self._lock: + return self._runners.get(batch_id) + + +huya_register_registry = HuyaRegisterRegistry() diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 34c0c1e..db2d17d 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -18,6 +18,7 @@ const CookiePage = lazy(() => import('./pages/CookiePage')); const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage')); const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage')); const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage')); +const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage')); const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage')); function RouteFallback() { @@ -66,6 +67,7 @@ function AppContent() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 5f9013a..5b4d10a 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -1,6 +1,8 @@ import api from './client'; import type { HuyaAccountItem, + HuyaAutoRegisterBatch, + HuyaAutoRegisterRequest, HuyaConfig, HuyaCookieItem, HuyaCookieImportResult, @@ -39,6 +41,12 @@ export const huyaApi = { api.post('/huya/accounts/sms-code', data, { timeout: 120000 }), smsLogin: (data: HuyaSmsLoginRequest) => api.post('/huya/accounts/sms-login', data, { timeout: 120000 }), + startAutoRegister: (data: HuyaAutoRegisterRequest) => + api.post('/huya/register/batches', data), + getAutoRegisterBatch: (batchId: string) => + api.get(`/huya/register/batches/${batchId}`), + stopAutoRegisterBatch: (batchId: string) => + api.post(`/huya/register/batches/${batchId}/stop`), assign: (id: number, assigned_to: number | null) => api.put(`/huya/accounts/${id}/assign`, { assigned_to }), batchAssign: (account_ids: number[], assigned_to: number | null) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 58466da..b559535 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -173,6 +173,50 @@ export interface HuyaSmsLoginResult extends MessageResponse { sdid: string; } +export interface HuyaAutoRegisterRequest { + text: string; + tag?: string; + concurrency?: number; + wait_seconds?: number; + poll_interval?: number; +} + +export interface HuyaAutoRegisterItem { + line: number; + phone: string; + provider: string; + status: string; + message: string; + code: string; + attempts: number; + account_id: number | null; + uid: string; + cookie: string; + cookie_preview: string; + started_at: string | null; + finished_at: string | null; +} + +export interface HuyaAutoRegisterBatch { + batch_id: string; + status: string; + message: string; + tag: string; + created_by: number; + concurrency: number; + wait_seconds: number; + poll_interval: number; + total: number; + success_count: number; + failed_count: number; + stopped_count: number; + running_count: number; + created_at: string | null; + started_at: string | null; + finished_at: string | null; + items: HuyaAutoRegisterItem[]; +} + export interface HuyaPasswordLoginBatchItem { line: number; username: string; diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index 6f54671..d651cfc 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -5,7 +5,7 @@ import { DashboardOutlined, UserOutlined, LogoutOutlined, CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined, MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined, - SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, + SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { getUser, clearAuth, type AuthUser } from '../store/auth'; @@ -71,6 +71,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) { huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: }); } + if (canAny(['huya:account', 'huya:import'])) { + huyaItems.push({ key: '/huya/register', label: '自动注册', icon: }); + } if (canAny(['huya:account', 'huya:assign'])) { huyaItems.push({ key: '/huya/assignments', label: '分配管理', icon: }); } diff --git a/web/frontend/src/pages/HuyaRegisterPage.tsx b/web/frontend/src/pages/HuyaRegisterPage.tsx new file mode 100644 index 0000000..462bcf8 --- /dev/null +++ b/web/frontend/src/pages/HuyaRegisterPage.tsx @@ -0,0 +1,284 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Button, Card, Col, Input, InputNumber, message, Row, Space, Statistic, Table, Tag, Typography, +} from 'antd'; +import type { TableProps } from 'antd'; +import { DownloadOutlined, PlayCircleOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons'; +import { huyaApi, type HuyaAutoRegisterBatch, type HuyaAutoRegisterItem } from '../api/modules'; +import { formatTime } from '../utils/time'; +import { getErrorMessage } from '../utils/error'; + +const { TextArea } = Input; +const { Text, Title } = Typography; + +const STATUS_LABELS: Record = { + pending: '等待', + sending: '发码', + waiting: '等待验证码', + logging: '保存', + success: '成功', + error: '失败', + stopped: '已停止', +}; + +const STATUS_COLORS: Record = { + pending: 'default', + sending: 'processing', + waiting: 'processing', + logging: 'processing', + success: 'success', + error: 'error', + stopped: 'warning', +}; + +const RUNNING_STATUS = new Set(['pending', 'running']); + +export default function HuyaRegisterPage() { + const [text, setText] = useState(''); + const [tag, setTag] = useState(''); + const [concurrency, setConcurrency] = useState(1); + const [waitSeconds, setWaitSeconds] = useState(180); + const [pollInterval, setPollInterval] = useState(5); + const [batch, setBatch] = useState(null); + const [starting, setStarting] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [stopping, setStopping] = useState(false); + + const batchId = batch?.batch_id || ''; + const isRunning = !!batch && RUNNING_STATUS.has(batch.status); + + const refreshBatch = useCallback(async () => { + if (!batchId) return; + setRefreshing(true); + try { + const data = await huyaApi.getAutoRegisterBatch(batchId); + setBatch(data); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setRefreshing(false); + } + }, [batchId]); + + useEffect(() => { + if (!isRunning || !batchId) return undefined; + const timer = window.setInterval(() => { + refreshBatch(); + }, 2000); + return () => window.clearInterval(timer); + }, [batchId, isRunning, refreshBatch]); + + const successRows = useMemo( + () => (batch?.items || []).filter((item) => item.status === 'success' && item.cookie), + [batch], + ); + + const handleStart = async () => { + if (!text.trim()) { + message.warning('请先粘贴手机号池'); + return; + } + setStarting(true); + try { + const data = await huyaApi.startAutoRegister({ + text, + tag: tag.trim(), + concurrency, + wait_seconds: waitSeconds, + poll_interval: pollInterval, + }); + setBatch(data); + message.success('自动注册批次已启动'); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setStarting(false); + } + }; + + const handleStop = async () => { + if (!batchId) return; + setStopping(true); + try { + const result = await huyaApi.stopAutoRegisterBatch(batchId); + message.success(result.message); + refreshBatch(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setStopping(false); + } + }; + + const handleExportSuccess = () => { + if (successRows.length === 0) { + message.warning('当前批次没有可导出的成功 CK'); + return; + } + const body = successRows.map((item) => `${item.phone}----${item.cookie}`).join('\n'); + const blob = new Blob([body], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `huya-register-${batchId || 'success'}.txt`; + link.click(); + URL.revokeObjectURL(url); + }; + + const columns: TableProps['columns'] = [ + { title: '行', dataIndex: 'line', width: 64 }, + { title: '手机号', dataIndex: 'phone', width: 150 }, + { title: '平台', dataIndex: 'provider', width: 90 }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: (status: string) => ( + + {STATUS_LABELS[status] || status} + + ), + }, + { title: '验证码', dataIndex: 'code', width: 100 }, + { title: '轮询', dataIndex: 'attempts', width: 80 }, + { + title: '账号', + width: 160, + render: (_, item) => item.uid || (item.account_id ? `#${item.account_id}` : '-'), + }, + { + title: 'Cookie', + dataIndex: 'cookie_preview', + width: 220, + ellipsis: true, + render: (value: string) => value || '-', + }, + { + title: '消息', + dataIndex: 'message', + ellipsis: true, + render: (value: string) => value || '-', + }, + { + title: '完成时间', + dataIndex: 'finished_at', + width: 170, + render: (value: string | null) => formatTime(value), + }, + ]; + + return ( + + 虎牙自动注册 + + + +