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 (
+
+ 虎牙自动注册
+
+
+
+
+
+
+
+ } loading={refreshing} disabled={!batchId} onClick={refreshBatch}>
+ 刷新
+
+ } disabled={successRows.length === 0} onClick={handleExportSuccess}>
+ 导出成功 CK
+
+
+ )}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/对比 ck.txt b/对比 ck.txt
deleted file mode 100644
index f6db7b0..0000000
--- a/对比 ck.txt
+++ /dev/null
@@ -1,17 +0,0 @@
-
-目标 ck
-cvl_csrf_token=62083f188904477e9bc1c5a70537145c; acf_ccn=8b025ffcbf403d5e0d880ad6327bb3cf; PHPSESSID=86k3denvoc1j16rl1fsv5l2mc2; acf_auth=5a8eUzBoDc8uyLasCP7LXaO9a2vUK%2FDaNFwYi0MVdUFMpUcMBfsoZfifJZKCQC8WgJuKq7WB78VZrRlRXnZUSt87TZM0tLNdnplqtuL87c7idAqo%2Fal57ls; acf_jwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjk0MjQxNjQ1MSwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImR5Iiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo2OTE2MTY4NSwic3RrIjoiNGZkNDY5ZDE2OWY3NzM0YiIsImV4cCI6MTc4MjMyMjc2OSwiaWF0IjoxNzgxNzE3OTcwLCJrZXkiOiJkeS1qd3QtbWQ1In0.NDc0YzMyNjQ0OGIzNTkxYzkxNGQ1NDk0Y2U0NTk1NTU; acf_dmjwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjk0MjQxNjQ1MSwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImRtIiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo2OTE2MTY4NSwic3RrIjoiNGZkNDY5ZDE2OWY3NzM0YiIsImV4cCI6MTc4MjMyMjc2OSwiaWF0IjoxNzgxNzE3OTcwLCJrZXkiOiJkeS1qd3QtbWQ1In0.NWM1ZDgwZjE1N2ZhNTE3NDUxNmQ4ZjljMzI5ODQ0ZjM; dy_auth=29bfEdmmHp50M9Zr2gxQ3%2F2AJD2DAdCK6I%2FoX92U5n13i64R%2B%2FywJZf%2FqG4%2BCJvbUQmFaUSu3dFbJj4%2Be1gU1DpW6ligJYaJLnNuVbmKMeZCtayBmGtns6A; wan_auth37wan=cf514c616a0fzsvhClbvRbju0sSZb4aDqHQQabpd66aLoVky4bvRRXYDfYMX3iSUNiDuh%2BHt%2B20QXW0f5JLZ6lBtOW8LIIwb31iX8OiZurGB0aztSKA; acf_uid=942416451; acf_username=942416451; acf_nickname=%E7%94%A8%E6%88%B74512197651; acf_own_room=0; acf_groupid=1; acf_phonestatus=1; acf_avatar=https%3A%2F%2Fapic.douyucdn.cn%2Fupload%2Favatar%2Fdefault%2F18_; acf_ct=0; acf_ltkid=69161685; acf_biz=1; acf_stk=4fd469d169f7734b; acf_devid=9869cce23d84da0fd8b1cd20162b3def
-
-
-
-
-
-======
-自己跑的
-
-
-dy_accounts_main=; dy_auth=144fZeqHQY%2F5Q50DbCp7oUK%2FI9%2B7tlIme7rNTAzIvKBMRg0dhiiPgJpK%2B3T3OhZrH5UtVwfQW5ZCszgDFbTR9F8BF%2Fc3Ur7ysvx9Uf%2FT1WsIbZUrWW8BnoI; wan_auth37wan=7d824d0ad6c2wGkeajI%2Fsvzj%2FmYcOzG%2FgdI8oWY6Q4%2FoTS%2F%2FC9sTYuuIj6ri6JJBbmgFykBHwsAAFvMVnLnaabjU2t58%2F2jBuNkq6xn%2BgfgUS6cAMaQ; LTP0=eyJhbGciOiJtZDUiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsicGFzc3BvcnQiXSwiY3QiOjAsImN0aW1lIjoxNzgyMjYwNjUzLCJleHAiOjE3OTgwMjg2NjcsImtleSI6ImR5LWp3dC1tZDUiLCJsdGsiOiIzOWM3MDRiYzIyZmU5Nzg2IiwibHRraWQiOjQ0MDI1MTUzLCJzdWIiOiJsdCIsInVpZCI6Njc4MTIyNDkxfQ.ZWQ5NmIxOTAyN2IwYjNhMDE4YmVlM2UyZmFjODFmNTk; last_login_way=nickname; PHPSESSID=6ss9t7f10kpujqh7fpp4a0trh3; acf_auth=f204nj4qW9rQLVFTJu7LrwhJqWaioV%2F7iJjzoycsgLLAMCpnRFgFs8vpaa%2B0RQZPrCYkFYJUICxfVD53UylvFXGAtXq6uTPVtv8sNMn%2B0ukwX1CHbQkUJrs; acf_jwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjY3ODEyMjQ5MSwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImR5Iiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo0NDAyNTE1Mywic3RrIjoiZjJhOGFmZTc0ZDc0YTdkMCIsImV4cCI6MTc4Mjg2NTQ1MywiaWF0IjoxNzgyMjYwNjUzLCJrZXkiOiJkeS1qd3QtbWQ1In0.MTM0ZWJkYmQxMDJmODdkMGQwZWE3Y2M3OGE4Y2E3OTM; acf_dmjwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjY3ODEyMjQ5MSwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImRtIiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo0NDAyNTE1Mywic3RrIjoiZjJhOGFmZTc0ZDc0YTdkMCIsImV4cCI6MTc4Mjg2NTQ1MywiaWF0IjoxNzgyMjYwNjUzLCJrZXkiOiJkeS1qd3QtbWQ1In0.YjUwODJmYThkMmY2NWNmMTNiM2NkNTk4ZTRhMDE0Y2M; acf_uid=678122491; acf_username=678122491; acf_nickname=%E7%94%A8%E6%88%B77808724800; acf_own_room=0; acf_groupid=1; acf_phonestatus=1; acf_avatar=https%3A%2F%2Fapic.douyucdn.cn%2Fupload%2Favatar%2Fdefault%2F19_; acf_ct=0; acf_ltkid=44025153; acf_biz=1; acf_stk=f2a8afe74d74a7d0; acf_devid=6d4bd35a8ad7facc50bc51ab1792558f; cvl_csrf_token=7252fcd623794ba78c7cb6588caee2e9; acf_ccn=40a9800778aca38c01c39ff7d45eabc8
-
-
-
-========