From e18b2c6e0c5ebcfb5b56861e751f3171fe4ad51b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 12 Jul 2026 20:05:04 +0800 Subject: [PATCH] =?UTF-8?q?=E8=99=8E=E7=89=99=E8=87=AA=E5=8A=A8=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E6=8C=81=E4=B9=85=E5=8C=96=E6=88=90=E5=8A=9F=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E4=B8=8E=E6=89=B9=E6=AC=A1=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=BB=AD=E8=B7=91=E4=B8=8E=E9=9A=8F=E6=97=B6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将注册批次/条目/成功记录落库,成功一个写入一条流水;服务中断可恢复并续跑失败项,换电脑也能导出 txt。同时修复 pending 被误判为运行中导致页面卡住的问题。 --- web/backend/crypto_storage.py | 4 + .../20260712_0007_huya_register_history.py | 134 ++++ web/backend/models.py | 76 ++ web/backend/routers/huya.py | 128 ++- web/backend/schemas.py | 29 +- web/backend/services/huya_register_runner.py | 730 ++++++++++++++++-- web/frontend/src/api/huya.ts | 12 + web/frontend/src/api/types.ts | 26 + web/frontend/src/pages/HuyaRegisterPage.tsx | 380 ++++++++- 9 files changed, 1423 insertions(+), 96 deletions(-) create mode 100644 web/backend/migrations/versions/20260712_0007_huya_register_history.py diff --git a/web/backend/crypto_storage.py b/web/backend/crypto_storage.py index 5fe923a..c7d6410 100644 --- a/web/backend/crypto_storage.py +++ b/web/backend/crypto_storage.py @@ -150,6 +150,10 @@ _SENSITIVE_COLUMNS: tuple[tuple[str, str], ...] = ( ("login_tasks", "cookie"), ("huya_accounts", "account_password"), ("huya_accounts", "cookie"), + ("huya_register_batches", "fixed_password"), + ("huya_register_items", "password"), + ("huya_register_items", "cookie"), + ("huya_register_success_logs", "password"), ("proxy_config", "api_url"), ("proxy_config", "http"), ("proxy_config", "https"), diff --git a/web/backend/migrations/versions/20260712_0007_huya_register_history.py b/web/backend/migrations/versions/20260712_0007_huya_register_history.py new file mode 100644 index 0000000..92e1130 --- /dev/null +++ b/web/backend/migrations/versions/20260712_0007_huya_register_history.py @@ -0,0 +1,134 @@ +"""虎牙自动注册批次/条目/成功流水持久化 + +Revision ID: 20260712_0007 +Revises: 20260705_0006 +Create Date: 2026-07-12 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260712_0007" +down_revision: Union[str, None] = "20260705_0006" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_table(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _columns(bind, table_name: str) -> set[str]: + if not _has_table(bind, table_name): + return set() + return {column["name"] for column in sa.inspect(bind).get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + + if "sms_url" not in _columns(bind, "huya_accounts"): + op.add_column("huya_accounts", sa.Column("sms_url", sa.Text(), nullable=True)) + + if not _has_table(bind, "huya_register_batches"): + op.create_table( + "huya_register_batches", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("batch_id", sa.String(length=64), nullable=False), + sa.Column("tag", sa.String(length=64), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=False), + sa.Column("concurrency", sa.Integer(), nullable=True), + sa.Column("wait_seconds", sa.Integer(), nullable=True), + sa.Column("poll_interval", sa.Integer(), nullable=True), + sa.Column("password_prefix", sa.String(length=16), nullable=True), + sa.Column("fixed_password", sa.Text(), nullable=True), + sa.Column("use_proxy", sa.Boolean(), nullable=True), + sa.Column("status", sa.String(length=32), nullable=True), + sa.Column("message", sa.String(length=512), nullable=True), + sa.Column("total", sa.Integer(), nullable=True), + sa.Column("success_count", sa.Integer(), nullable=True), + sa.Column("failed_count", sa.Integer(), nullable=True), + sa.Column("stopped_count", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["created_by"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("batch_id"), + ) + op.create_index("ix_huya_register_batches_batch_id", "huya_register_batches", ["batch_id"]) + op.create_index("ix_huya_register_batches_created_by", "huya_register_batches", ["created_by"]) + op.create_index("ix_huya_register_batches_status", "huya_register_batches", ["status"]) + + if not _has_table(bind, "huya_register_items"): + op.create_table( + "huya_register_items", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("batch_db_id", sa.Integer(), nullable=False), + sa.Column("batch_id", sa.String(length=64), nullable=False), + sa.Column("line", sa.Integer(), nullable=False), + sa.Column("phone", sa.String(length=64), nullable=True), + sa.Column("provider", sa.String(length=32), nullable=True), + sa.Column("sms_url", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=32), nullable=True), + sa.Column("message", sa.String(length=512), nullable=True), + sa.Column("code", sa.String(length=32), nullable=True), + sa.Column("change_code", sa.String(length=32), nullable=True), + sa.Column("attempts", sa.Integer(), nullable=True), + sa.Column("change_attempts", sa.Integer(), nullable=True), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column("username", sa.String(length=128), nullable=True), + sa.Column("uid", sa.String(length=32), nullable=True), + sa.Column("password", sa.Text(), nullable=True), + sa.Column("password_changed", sa.Boolean(), nullable=True), + sa.Column("cookie", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["account_id"], ["huya_accounts.id"]), + sa.ForeignKeyConstraint(["batch_db_id"], ["huya_register_batches.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_huya_register_items_batch_db_id", "huya_register_items", ["batch_db_id"]) + op.create_index("ix_huya_register_items_batch_id", "huya_register_items", ["batch_id"]) + op.create_index("ix_huya_register_items_phone", "huya_register_items", ["phone"]) + op.create_index("ix_huya_register_items_status", "huya_register_items", ["status"]) + + if not _has_table(bind, "huya_register_success_logs"): + op.create_table( + "huya_register_success_logs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("batch_id", sa.String(length=64), nullable=True), + sa.Column("item_id", sa.Integer(), nullable=True), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column("phone", sa.String(length=64), nullable=True), + sa.Column("username", sa.String(length=128), nullable=True), + sa.Column("uid", sa.String(length=32), nullable=True), + sa.Column("password", sa.Text(), nullable=True), + sa.Column("sms_url", sa.Text(), nullable=True), + sa.Column("tag", sa.String(length=64), nullable=True), + sa.Column("provider", sa.String(length=32), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["account_id"], ["huya_accounts.id"]), + sa.ForeignKeyConstraint(["created_by"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_huya_register_success_logs_batch_id", "huya_register_success_logs", ["batch_id"]) + op.create_index("ix_huya_register_success_logs_phone", "huya_register_success_logs", ["phone"]) + op.create_index("ix_huya_register_success_logs_tag", "huya_register_success_logs", ["tag"]) + op.create_index("ix_huya_register_success_logs_created_at", "huya_register_success_logs", ["created_at"]) + + +def downgrade() -> None: + bind = op.get_bind() + if _has_table(bind, "huya_register_success_logs"): + op.drop_table("huya_register_success_logs") + if _has_table(bind, "huya_register_items"): + op.drop_table("huya_register_items") + if _has_table(bind, "huya_register_batches"): + op.drop_table("huya_register_batches") + if "sms_url" in _columns(bind, "huya_accounts"): + op.drop_column("huya_accounts", "sms_url") diff --git a/web/backend/models.py b/web/backend/models.py index 21126ba..298855a 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -96,6 +96,7 @@ class HuyaAccount(Base): game_name = Column(String(128), default="") game_channel = Column(String(64), default="") game_phone = Column(String(64), default="") + sms_url = Column(Text, default="") assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) created_at = Column(DateTime, default=_utcnow) updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) @@ -104,6 +105,81 @@ class HuyaAccount(Base): tasks = relationship("HuyaTask", back_populates="account") +class HuyaRegisterBatch(Base): + """虎牙自动注册批次(持久化元数据)""" + __tablename__ = "huya_register_batches" + + id = Column(Integer, primary_key=True, autoincrement=True) + batch_id = Column(String(64), unique=True, nullable=False, index=True) + tag = Column(String(64), default="") + created_by = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + concurrency = Column(Integer, default=1) + wait_seconds = Column(Integer, default=180) + poll_interval = Column(Integer, default=5) + password_prefix = Column(String(16), default="hy") + fixed_password = Column(EncryptedText(), default="") + use_proxy = Column(Boolean, default=False) + status = Column(String(32), default="pending", index=True) + message = Column(String(512), default="") + total = Column(Integer, default=0) + success_count = Column(Integer, default=0) + failed_count = Column(Integer, default=0) + stopped_count = Column(Integer, default=0) + created_at = Column(DateTime, default=_utcnow) + started_at = Column(DateTime, nullable=True) + finished_at = Column(DateTime, nullable=True) + + items = relationship("HuyaRegisterItem", back_populates="batch", cascade="all, delete-orphan") + + +class HuyaRegisterItem(Base): + """虎牙自动注册批次内单条手机号状态""" + __tablename__ = "huya_register_items" + + id = Column(Integer, primary_key=True, autoincrement=True) + batch_db_id = Column(Integer, ForeignKey("huya_register_batches.id"), nullable=False, index=True) + batch_id = Column(String(64), nullable=False, index=True) + line = Column(Integer, nullable=False) + phone = Column(String(64), default="", index=True) + provider = Column(String(32), default="") + sms_url = Column(Text, default="") + status = Column(String(32), default="pending", index=True) + message = Column(String(512), default="") + code = Column(String(32), default="") + change_code = Column(String(32), default="") + attempts = Column(Integer, default=0) + change_attempts = Column(Integer, default=0) + account_id = Column(Integer, ForeignKey("huya_accounts.id"), nullable=True) + username = Column(String(128), default="") + uid = Column(String(32), default="") + password = Column(EncryptedText(), default="") + password_changed = Column(Boolean, default=False) + cookie = Column(EncryptedText(), default="") + started_at = Column(DateTime, nullable=True) + finished_at = Column(DateTime, nullable=True) + + batch = relationship("HuyaRegisterBatch", back_populates="items") + + +class HuyaRegisterSuccessLog(Base): + """虎牙自动注册成功流水(只追加,供随时导出)""" + __tablename__ = "huya_register_success_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + batch_id = Column(String(64), default="", index=True) + item_id = Column(Integer, nullable=True) + account_id = Column(Integer, ForeignKey("huya_accounts.id"), nullable=True) + phone = Column(String(64), default="", index=True) + username = Column(String(128), default="") + uid = Column(String(32), default="") + password = Column(EncryptedText(), default="") + sms_url = Column(Text, default="") + tag = Column(String(64), default="", index=True) + provider = Column(String(32), default="") + created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=_utcnow, index=True) + + class HuyaTask(Base): """虎牙业务任务""" __tablename__ = "huya_tasks" diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 709a302..4a9e5c6 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -32,6 +32,7 @@ from ..schemas import ( HuyaAccountOut, HuyaAutoRegisterBatchOut, HuyaAutoRegisterRequest, + HuyaAutoRegisterRetryRequest, HuyaConfigOut, HuyaConfigUpdate, HuyaCookieImport, @@ -40,6 +41,7 @@ from ..schemas import ( HuyaPasswordLoginRequest, HuyaPasswordLoginSelectedRequest, HuyaRechargeGoodsOut, + HuyaRegisterSuccessLogOut, HuyaSmsCodeRequest, HuyaSmsLoginRequest, HuyaTaskBatchRequest, @@ -58,7 +60,11 @@ 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 +from ..services.huya_register_runner import ( + export_success_logs_text, + huya_register_registry, + list_success_logs, +) router = APIRouter(prefix="/api/huya", tags=["虎牙"]) @@ -366,22 +372,33 @@ def create_auto_register_batch( use_proxy=req.use_proxy, proxy_config=proxy_config, ) + runner.mark_running("批次运行中") thread = threading.Thread(target=runner.run, daemon=True) thread.start() return runner.snapshot() +@router.get("/register/batches", response_model=list[HuyaAutoRegisterBatchOut]) +def list_auto_register_batches( + limit: int = Query(50, ge=1, le=200), + current: User = Depends(get_current_user), +): + """列出历史自动注册批次(摘要,不含明细)。""" + _require_huya_perm(current, "huya:import") + return huya_register_registry.list_summaries(limit=limit) + + @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() + snapshot = huya_register_registry.get_snapshot(batch_id) + if not snapshot: + raise HTTPException(status_code=404, detail="批次不存在") + return snapshot @router.post("/register/batches/{batch_id}/stop") @@ -393,11 +410,108 @@ def stop_auto_register_batch( _require_huya_perm(current, "huya:import") runner = huya_register_registry.get(batch_id) if not runner: - raise HTTPException(status_code=404, detail="批次不存在或服务已重启") + # 无内存 runner:若 DB 中存在且处于 running/interrupted,标记停止 + snapshot = huya_register_registry.get_snapshot(batch_id) + if not snapshot: + raise HTTPException(status_code=404, detail="批次不存在") + return {"message": "批次未在本进程运行,无需停止", "success": True} runner.stop() return {"message": "已发送停止信号", "success": True} +@router.post("/register/batches/{batch_id}/retry", response_model=HuyaAutoRegisterBatchOut) +def retry_auto_register_batch( + batch_id: str, + req: HuyaAutoRegisterRetryRequest = HuyaAutoRegisterRetryRequest(), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """从失败/停止/未完成条目续跑,成功项跳过。""" + _require_huya_perm(current, "huya:import") + use_proxy = req.use_proxy + # 先看历史批次是否用过代理 + snapshot = huya_register_registry.get_snapshot(batch_id) + if not snapshot: + raise HTTPException(status_code=404, detail="批次不存在") + effective_proxy = snapshot.get("use_proxy", False) if use_proxy is None else bool(use_proxy) + proxy_config = db.query(ProxyConfig).first() if effective_proxy else None + try: + runner = huya_register_registry.retry( + batch_id, + proxy_config=proxy_config, + concurrency=req.concurrency, + wait_seconds=req.wait_seconds, + poll_interval=req.poll_interval, + password_prefix=req.password_prefix, + fixed_password=req.fixed_password, + use_proxy=use_proxy, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + thread = threading.Thread(target=runner.run, daemon=True) + thread.start() + return runner.snapshot() + + +@router.get("/register/batches/{batch_id}/export") +def export_auto_register_batch_success( + batch_id: str, + current: User = Depends(get_current_user), +): + """导出指定批次的成功账号 txt。""" + _require_huya_perm(current, "huya:import") + snapshot = huya_register_registry.get_snapshot(batch_id) + if not snapshot: + raise HTTPException(status_code=404, detail="批次不存在") + content = export_success_logs_text(batch_id=batch_id) + if not content.strip(): + raise HTTPException(status_code=404, detail="该批次没有可导出的成功记录") + return StreamingResponse( + iter([content]), + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename=huya-register-{batch_id}.txt"}, + ) + + +@router.get("/register/success-logs", response_model=list[HuyaRegisterSuccessLogOut]) +def list_register_success_logs( + batch_id: str | None = Query(None), + tag: str | None = Query(None), + limit: int = Query(200, ge=1, le=2000), + current: User = Depends(get_current_user), +): + """列出注册成功流水(换电脑也可查看)。""" + _require_huya_perm(current, "huya:import") + return list_success_logs(batch_id=batch_id, tag=tag, limit=limit) + + +@router.get("/register/success-logs/export") +def export_register_success_logs( + batch_id: str | None = Query(None), + tag: str | None = Query(None), + limit: int = Query(5000, ge=1, le=20000), + current: User = Depends(get_current_user), +): + """导出注册成功流水 txt:账号----密码----手机号----接码链接。""" + _require_huya_perm(current, "huya:import") + content = export_success_logs_text(batch_id=batch_id, tag=tag, limit=limit) + if not content.strip(): + raise HTTPException(status_code=404, detail="没有可导出的成功记录") + filename = "huya-register-success" + if batch_id: + filename += f"-{batch_id}" + if tag: + filename += f"-{tag}" + filename += ".txt" + return StreamingResponse( + iter([content]), + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + @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 28cc5e4..4f28157 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -248,6 +248,16 @@ class HuyaAutoRegisterRequest(BaseModel): use_proxy: bool = False +class HuyaAutoRegisterRetryRequest(BaseModel): + """从失败/停止/未完成条目续跑。可覆盖运行参数。""" + concurrency: Optional[int] = Field(None, ge=1, le=5) + wait_seconds: Optional[float] = Field(None, ge=15, le=600) + poll_interval: Optional[float] = Field(None, ge=1, le=30) + password_prefix: Optional[str] = Field(None, max_length=8) + fixed_password: Optional[str] = Field(None, max_length=64) + use_proxy: Optional[bool] = None + + class HuyaAutoRegisterItemOut(BaseModel): line: int phone: str @@ -289,7 +299,24 @@ class HuyaAutoRegisterBatchOut(BaseModel): created_at: Optional[datetime] = None started_at: Optional[datetime] = None finished_at: Optional[datetime] = None - items: list[HuyaAutoRegisterItemOut] + items: list[HuyaAutoRegisterItemOut] = [] + + +class HuyaRegisterSuccessLogOut(BaseModel): + id: int + batch_id: str = "" + item_id: Optional[int] = None + account_id: Optional[int] = None + phone: str = "" + username: str = "" + uid: str = "" + password: str = "" + sms_url: str = "" + tag: str = "" + provider: str = "" + created_by: Optional[int] = None + created_at: Optional[datetime] = None + export_line: str = "" class HuyaPasswordAccountImport(BaseModel): diff --git a/web/backend/services/huya_register_runner.py b/web/backend/services/huya_register_runner.py index 5ba06e5..a97ecc0 100644 --- a/web/backend/services/huya_register_runner.py +++ b/web/backend/services/huya_register_runner.py @@ -1,4 +1,4 @@ -"""虎牙自动注册批次执行器。""" +"""虎牙自动注册批次执行器(支持持久化与失败续跑)。""" from __future__ import annotations @@ -15,10 +15,22 @@ from core.huya.cookie_utils import normalize_huya_cookie from core.sms_provider import SmsLine from ..database import SessionLocal -from ..models import ProxyConfig as ProxyConfigModel +from ..models import ( + HuyaRegisterBatch as HuyaRegisterBatchModel, + HuyaRegisterItem as HuyaRegisterItemModel, + HuyaRegisterSuccessLog, + ProxyConfig as ProxyConfigModel, +) from .huya_service import upsert_huya_cookie +# 运行中状态:服务中断后视为可续跑 +RUNNING_ITEM_STATUSES = frozenset({"sending", "waiting", "changing", "logging"}) +# 可重试状态:失败 / 停止 / 中断后的运行态 / 尚未开始 +RETRYABLE_ITEM_STATUSES = frozenset({"error", "stopped", "pending"} | RUNNING_ITEM_STATUSES) +TERMINAL_BATCH_STATUSES = frozenset({"finished", "stopped", "error", "interrupted"}) + + def _now() -> datetime: return datetime.now(timezone.utc) @@ -32,7 +44,7 @@ def _cookie_preview(cookie: str) -> str: @dataclass class HuyaRegisterItemState: - """单个手机号在批次中的状态。""" + """单个手机号在批次中的状态(内存镜像)。""" line: int phone: str @@ -53,6 +65,7 @@ class HuyaRegisterItemState: cookie_preview: str = "" started_at: datetime | None = None finished_at: datetime | None = None + db_id: int | None = None def to_dict(self) -> dict: return { @@ -97,6 +110,274 @@ class HuyaRegisterBatch: created_at: datetime = field(default_factory=_now) started_at: datetime | None = None finished_at: datetime | None = None + db_id: int | None = None + + +def _item_from_db(row: HuyaRegisterItemModel) -> HuyaRegisterItemState: + cookie = normalize_huya_cookie(row.cookie or "") + exposed = "" if row.password_changed else cookie + return HuyaRegisterItemState( + line=row.line, + phone=row.phone or "", + provider=row.provider or "", + sms_url=row.sms_url or "", + status=row.status or "pending", + message=row.message or "", + code=row.code or "", + change_code=row.change_code or "", + attempts=int(row.attempts or 0), + change_attempts=int(row.change_attempts or 0), + account_id=row.account_id, + username=row.username or "", + uid=row.uid or "", + password=row.password or "", + password_changed=bool(row.password_changed), + cookie=exposed, + cookie_preview=_cookie_preview(exposed), + started_at=row.started_at, + finished_at=row.finished_at, + db_id=row.id, + ) + + +def _batch_from_db( + batch_row: HuyaRegisterBatchModel, + item_rows: list[HuyaRegisterItemModel], +) -> HuyaRegisterBatch: + items = [_item_from_db(row) for row in sorted(item_rows, key=lambda x: x.line)] + return HuyaRegisterBatch( + batch_id=batch_row.batch_id, + tag=batch_row.tag or "", + created_by=batch_row.created_by, + concurrency=int(batch_row.concurrency or 1), + wait_seconds=float(batch_row.wait_seconds or 180), + poll_interval=float(batch_row.poll_interval or 5), + items=items, + password_prefix=batch_row.password_prefix or "hy", + fixed_password=batch_row.fixed_password or "", + use_proxy=bool(batch_row.use_proxy), + status=batch_row.status or "pending", + message=batch_row.message or "", + created_at=batch_row.created_at or _now(), + started_at=batch_row.started_at, + finished_at=batch_row.finished_at, + db_id=batch_row.id, + ) + + +def snapshot_from_batch(batch: HuyaRegisterBatch) -> dict: + """从内存批次生成 API 快照。""" + total = len(batch.items) + success = sum(1 for item in batch.items if item.status == "success") + failed = sum(1 for item in batch.items if item.status == "error") + stopped = sum(1 for item in batch.items if item.status == "stopped") + running = sum(1 for item in batch.items if item.status in RUNNING_ITEM_STATUSES) + return { + "batch_id": batch.batch_id, + "status": batch.status, + "message": batch.message, + "tag": batch.tag, + "created_by": batch.created_by, + "concurrency": batch.concurrency, + "wait_seconds": batch.wait_seconds, + "poll_interval": batch.poll_interval, + "password_prefix": batch.password_prefix, + "use_proxy": batch.use_proxy, + "total": total, + "success_count": success, + "failed_count": failed, + "stopped_count": stopped, + "running_count": running, + "created_at": batch.created_at, + "started_at": batch.started_at, + "finished_at": batch.finished_at, + "items": [item.to_dict() for item in batch.items], + } + + +def load_batch_snapshot(batch_id: str, *, recover_interrupted: bool = True) -> dict | None: + """从数据库加载批次详情;若服务中断则标记为 interrupted。""" + db = SessionLocal() + try: + batch_row = ( + db.query(HuyaRegisterBatchModel) + .filter(HuyaRegisterBatchModel.batch_id == batch_id) + .first() + ) + if not batch_row: + return None + item_rows = ( + db.query(HuyaRegisterItemModel) + .filter(HuyaRegisterItemModel.batch_id == batch_id) + .order_by(HuyaRegisterItemModel.line.asc()) + .all() + ) + # 无内存 runner 时,running/pending 都视为中断(避免「准备续跑」永久卡住) + if recover_interrupted and batch_row.status in {"running", "pending"}: + now = _now() + batch_row.status = "interrupted" + batch_row.message = "服务中断或未真正启动,可从失败/未完成项续跑" + batch_row.finished_at = now + for item in item_rows: + if item.status in RUNNING_ITEM_STATUSES: + item.status = "error" + item.message = "服务中断,可续跑" + item.finished_at = now + elif item.status == "pending": + # 保留 pending 供续跑,仅更新提示 + item.message = item.message or "等待续跑" + _refresh_batch_counts(batch_row, item_rows) + db.commit() + db.refresh(batch_row) + item_rows = ( + db.query(HuyaRegisterItemModel) + .filter(HuyaRegisterItemModel.batch_id == batch_id) + .order_by(HuyaRegisterItemModel.line.asc()) + .all() + ) + batch = _batch_from_db(batch_row, item_rows) + return snapshot_from_batch(batch) + finally: + db.close() + + +def list_batch_summaries(limit: int = 50, live_batch_ids: set[str] | None = None) -> list[dict]: + """列出最近的注册批次摘要。live_batch_ids 中的 running 保持运行中。""" + live = live_batch_ids or set() + db = SessionLocal() + try: + rows = ( + db.query(HuyaRegisterBatchModel) + .order_by(HuyaRegisterBatchModel.id.desc()) + .limit(max(1, min(int(limit or 50), 200))) + .all() + ) + result = [] + for row in rows: + status = row.status or "pending" + message = row.message or "" + # 无内存 runner 且状态仍是 running/pending,展示为 interrupted + if status in {"running", "pending"} and row.batch_id not in live: + status = "interrupted" + message = message or "服务中断或未真正启动,可从失败/未完成项续跑" + running_count = 0 + if status == "running": + running_count = max( + 0, + int(row.total or 0) - int(row.success_count or 0) - int(row.failed_count or 0) - int(row.stopped_count or 0), + ) + result.append({ + "batch_id": row.batch_id, + "status": status, + "message": message, + "tag": row.tag or "", + "created_by": row.created_by, + "concurrency": int(row.concurrency or 1), + "wait_seconds": float(row.wait_seconds or 180), + "poll_interval": float(row.poll_interval or 5), + "password_prefix": row.password_prefix or "hy", + "use_proxy": bool(row.use_proxy), + "total": int(row.total or 0), + "success_count": int(row.success_count or 0), + "failed_count": int(row.failed_count or 0), + "stopped_count": int(row.stopped_count or 0), + "running_count": running_count, + "created_at": row.created_at, + "started_at": row.started_at, + "finished_at": row.finished_at, + "items": [], + }) + return result + finally: + db.close() + + +def _refresh_batch_counts(batch_row: HuyaRegisterBatchModel, items: list[HuyaRegisterItemModel]): + batch_row.total = len(items) + batch_row.success_count = sum(1 for item in items if item.status == "success") + batch_row.failed_count = sum(1 for item in items if item.status == "error") + batch_row.stopped_count = sum(1 for item in items if item.status == "stopped") + + +def format_success_export_line(username: str, uid: str, password: str, phone: str, sms_url: str) -> str: + """统一成功导出格式。""" + account = (username or uid or "").strip() + return f"{account}----{password or ''}----{phone or ''}----{sms_url or ''}" + + +def export_success_logs_text( + *, + batch_id: str | None = None, + tag: str | None = None, + limit: int = 5000, +) -> str: + """从成功流水表导出 txt。""" + db = SessionLocal() + try: + query = db.query(HuyaRegisterSuccessLog).order_by(HuyaRegisterSuccessLog.id.asc()) + if batch_id: + query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id) + if tag: + query = query.filter(HuyaRegisterSuccessLog.tag == tag) + rows = query.limit(max(1, min(int(limit or 5000), 20000))).all() + lines = [ + format_success_export_line( + row.username or "", + row.uid or "", + row.password or "", + row.phone or "", + row.sms_url or "", + ) + for row in rows + if (row.username or row.uid) and row.password + ] + return "\n".join(lines) + finally: + db.close() + + +def list_success_logs( + *, + batch_id: str | None = None, + tag: str | None = None, + limit: int = 200, +) -> list[dict]: + """列出成功流水(含密码,供管理端展示/导出)。""" + db = SessionLocal() + try: + query = db.query(HuyaRegisterSuccessLog).order_by(HuyaRegisterSuccessLog.id.desc()) + if batch_id: + query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id) + if tag: + query = query.filter(HuyaRegisterSuccessLog.tag == tag) + rows = query.limit(max(1, min(int(limit or 200), 2000))).all() + return [ + { + "id": row.id, + "batch_id": row.batch_id or "", + "item_id": row.item_id, + "account_id": row.account_id, + "phone": row.phone or "", + "username": row.username or "", + "uid": row.uid or "", + "password": row.password or "", + "sms_url": row.sms_url or "", + "tag": row.tag or "", + "provider": row.provider or "", + "created_by": row.created_by, + "created_at": row.created_at, + "export_line": format_success_export_line( + row.username or "", + row.uid or "", + row.password or "", + row.phone or "", + row.sms_url or "", + ), + } + for row in rows + ] + finally: + db.close() class HuyaRegisterRunner: @@ -107,10 +388,13 @@ class HuyaRegisterRunner: batch: HuyaRegisterBatch, sms_lines: list[SmsLine], proxy_config: Optional[ProxyConfigModel] = None, + *, + item_indices: list[int] | None = None, ): self.batch = batch self.sms_lines = sms_lines self.proxy_config = proxy_config + self.item_indices = item_indices # None 表示跑全部 self._lock = threading.Lock() self._stop = threading.Event() self._shared_proxy_fetcher = self._create_proxy_fetcher() @@ -145,54 +429,123 @@ class HuyaRegisterRunner: with self._lock: if self.batch.status == "running": self.batch.message = "正在停止" + self._persist_batch_meta() def snapshot(self) -> dict: with self._lock: - total = len(self.batch.items) + return snapshot_from_batch(self.batch) + + def _persist_batch_meta(self): + """把批次汇总写回数据库。""" + if not self.batch.db_id: + return + db = SessionLocal() + try: + row = db.query(HuyaRegisterBatchModel).filter(HuyaRegisterBatchModel.id == self.batch.db_id).first() + if not row: + return + row.status = self.batch.status + row.message = self.batch.message + row.started_at = self.batch.started_at + row.finished_at = self.batch.finished_at + row.concurrency = self.batch.concurrency + row.wait_seconds = int(self.batch.wait_seconds) + row.poll_interval = int(self.batch.poll_interval) + row.password_prefix = self.batch.password_prefix + row.fixed_password = self.batch.fixed_password + row.use_proxy = self.batch.use_proxy + row.tag = self.batch.tag + items = ( + db.query(HuyaRegisterItemModel) + .filter(HuyaRegisterItemModel.batch_id == self.batch.batch_id) + .all() + ) + _refresh_batch_counts(row, items) + # 同步内存计数到 batch 对象侧的 status 字段已足够;counts 以 DB 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", "changing", "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, - "password_prefix": self.batch.password_prefix, - "use_proxy": self.batch.use_proxy, - "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], - } + row.success_count = success + row.failed_count = failed + row.stopped_count = stopped + row.total = len(self.batch.items) + db.commit() + finally: + db.close() + + def _persist_item(self, index: int): + """把单条 item 状态写回数据库。""" + item = self.batch.items[index] + if not item.db_id: + return + db = SessionLocal() + try: + row = db.query(HuyaRegisterItemModel).filter(HuyaRegisterItemModel.id == item.db_id).first() + if not row: + return + row.status = item.status + row.message = (item.message or "")[:512] + row.code = item.code or "" + row.change_code = item.change_code or "" + row.attempts = item.attempts + row.change_attempts = item.change_attempts + row.account_id = item.account_id + row.username = item.username or "" + row.uid = item.uid or "" + row.password = item.password or "" + row.password_changed = bool(item.password_changed) + # DB 存完整 cookie;内存暴露受改密控制 + if item.cookie: + row.cookie = normalize_huya_cookie(item.cookie) + elif item.password_changed and item.status == "success": + # 改密成功时 cookie 可能被前端隐藏,保留 DB 已有值 + pass + row.started_at = item.started_at + row.finished_at = item.finished_at + db.commit() + finally: + db.close() 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) + self._persist_item(index) - def _save_cookie(self, result: HuyaAutoRegisterResult) -> tuple[int | None, str, str]: + def _save_success(self, index: int, result: HuyaAutoRegisterResult) -> tuple[int | None, str, str]: + """成功时:写账号 + 成功流水(成功一个写一条,立即可导出)。""" db = SessionLocal() try: + # upsert_huya_cookie 内部会 commit 一次 account = upsert_huya_cookie(db, result.cookie, tag=self.batch.tag, username_hint="") - account.game_phone = result.phone + account.game_phone = result.phone or account.game_phone or "" if result.username: account.username = result.username if result.password: account.account_password = result.password + sms_url = result.sms_url or self.batch.items[index].sms_url or "" + if sms_url: + account.sms_url = sms_url if result.password_changed: account.status = "password_changed" account.updated_at = _now() + + item = self.batch.items[index] + db.add(HuyaRegisterSuccessLog( + batch_id=self.batch.batch_id, + item_id=item.db_id, + account_id=account.id, + phone=result.phone or item.phone, + username=result.username or account.username or "", + uid=result.uid or account.uid or account.yyuid or "", + password=result.password or "", + sms_url=sms_url, + tag=self.batch.tag, + provider=result.provider or item.provider, + created_by=self.batch.created_by, + created_at=_now(), + )) db.commit() db.refresh(account) return account.id, account.username or "", account.uid or account.yyuid or "" @@ -228,7 +581,24 @@ class HuyaRegisterRunner: self._set_item(index, status="error", message=proxy_error, finished_at=_now()) return - self._set_item(index, status="sending", message="注册并改密", started_at=_now(), finished_at=None) + self._set_item( + index, + status="sending", + message="注册并改密", + started_at=_now(), + finished_at=None, + code="", + change_code="", + attempts=0, + change_attempts=0, + account_id=None, + username="", + uid="", + password="", + password_changed=False, + cookie="", + cookie_preview="", + ) result = register_huya_with_sms_line( item, wait_seconds=self.batch.wait_seconds, @@ -245,6 +615,7 @@ class HuyaRegisterRunner: message = result.message status = result.status cookie = result.cookie if result.success else "" + full_cookie = cookie if result.success: self._set_item( index, @@ -260,38 +631,71 @@ class HuyaRegisterRunner: password_changed=result.password_changed, ) try: - account_id, username, uid = self._save_cookie(result) + account_id, username, uid = self._save_success(index, result) except Exception as exc: status = "error" cookie = "" + full_cookie = "" message = f"账号保存失败: {exc}" exposed_cookie = "" if result.password_changed else cookie - self._set_item( - index, - status=status, - message=message, - code=result.code, - change_code=result.change_code, - attempts=result.attempts, - change_attempts=result.change_attempts, - account_id=account_id, - username=username, - uid=uid, - password=result.password, - password_changed=result.password_changed, - cookie=normalize_huya_cookie(exposed_cookie), - cookie_preview=_cookie_preview(exposed_cookie), - finished_at=_now(), - ) + # 先把完整 cookie 写内存供 persist,再把暴露值用于展示 + with self._lock: + mem = self.batch.items[index] + mem.status = status + mem.message = message + mem.code = result.code + mem.change_code = result.change_code + mem.attempts = result.attempts + mem.change_attempts = result.change_attempts + mem.account_id = account_id + mem.username = username + mem.uid = uid + mem.password = result.password + mem.password_changed = result.password_changed + mem.cookie = normalize_huya_cookie(full_cookie) + mem.cookie_preview = _cookie_preview(exposed_cookie) + mem.finished_at = _now() + # 展示用 cookie 在 to_dict 时再处理:成功且改密则隐藏 + if result.password_changed and status == "success": + # to_dict 使用 mem.cookie;这里保持 DB 有完整 cookie,API 隐藏 + pass + + self._persist_item(index) + + # API 快照中改密成功不暴露 cookie + with self._lock: + mem = self.batch.items[index] + if result.password_changed and status == "success": + mem.cookie = "" + mem.cookie_preview = "" + else: + mem.cookie = normalize_huya_cookie(exposed_cookie) + mem.cookie_preview = _cookie_preview(exposed_cookie) + + def mark_running(self, message: str = "批次运行中"): + """在启动线程前立刻标记 running,避免前端看到 pending 误判/卡住。""" + with self._lock: + self.batch.status = "running" + self.batch.message = message + self.batch.started_at = self.batch.started_at or _now() + self.batch.finished_at = None + self._persist_batch_meta() def run(self): """线程入口。""" with self._lock: - self.batch.status = "running" - self.batch.message = "批次运行中" - self.batch.started_at = _now() + if self.batch.status != "running": + self.batch.status = "running" + self.batch.message = "批次运行中" + self.batch.started_at = self.batch.started_at or _now() + self.batch.finished_at = None + self._persist_batch_meta() + + indices = self.item_indices + if indices is None: + indices = list(range(len(self.sms_lines))) try: if self._shared_proxy_fetcher: @@ -299,13 +703,15 @@ class HuyaRegisterRunner: if not ok: with self._lock: self.batch.message = f"代理白名单预热失败: {msg}" + self._persist_batch_meta() with ThreadPoolExecutor(max_workers=self.batch.concurrency) as executor: futures = [] - for index, item in enumerate(self.sms_lines): + for index in indices: if self._stop.is_set(): self._set_item(index, status="stopped", message="已停止", finished_at=_now()) continue + item = self.sms_lines[index] futures.append(executor.submit(self._run_one, index, item)) for future in as_completed(futures): @@ -315,6 +721,7 @@ class HuyaRegisterRunner: self.batch.status = "error" self.batch.message = f"批次执行异常: {exc}" self.batch.finished_at = _now() + self._persist_batch_meta() return with self._lock: @@ -325,10 +732,11 @@ class HuyaRegisterRunner: self.batch.status = "finished" self.batch.message = "批次已完成" self.batch.finished_at = _now() + self._persist_batch_meta() class HuyaRegisterRegistry: - """管理自动注册批次。""" + """管理自动注册批次(内存 runner + DB 持久化)。""" def __init__(self): self._lock = threading.Lock() @@ -348,29 +756,229 @@ class HuyaRegisterRegistry: proxy_config: Optional[ProxyConfigModel] = None, ) -> HuyaRegisterRunner: batch_id = uuid.uuid4().hex[:12] + 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)) + password_prefix = (password_prefix or "hy").strip()[:8] or "hy" + fixed_password = (fixed_password or "").strip() + tag = (tag or "").strip() + + items = [ + HuyaRegisterItemState( + line=index + 1, + phone=item.phone, + provider=item.provider, + sms_url=item.url, + message="等待开始", + ) + for index, item in enumerate(sms_lines) + ] + + db = SessionLocal() + try: + batch_row = HuyaRegisterBatchModel( + batch_id=batch_id, + tag=tag, + created_by=created_by, + concurrency=concurrency, + wait_seconds=int(wait_seconds), + poll_interval=int(poll_interval), + password_prefix=password_prefix, + fixed_password=fixed_password, + use_proxy=bool(use_proxy), + status="pending", + message="等待开始", + total=len(items), + success_count=0, + failed_count=0, + stopped_count=0, + created_at=_now(), + ) + db.add(batch_row) + db.flush() + for item in items: + row = HuyaRegisterItemModel( + batch_db_id=batch_row.id, + batch_id=batch_id, + line=item.line, + phone=item.phone, + provider=item.provider, + sms_url=item.sms_url, + status="pending", + message="等待开始", + password="", + cookie="", + ) + db.add(row) + db.flush() + item.db_id = row.id + db.commit() + db_id = batch_row.id + finally: + db.close() + 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)), - password_prefix=(password_prefix or "hy").strip()[:8] or "hy", - fixed_password=(fixed_password or "").strip(), + concurrency=concurrency, + wait_seconds=wait_seconds, + poll_interval=poll_interval, + password_prefix=password_prefix, + fixed_password=fixed_password, use_proxy=bool(use_proxy), - items=[ - HuyaRegisterItemState(line=index + 1, phone=item.phone, provider=item.provider, sms_url=item.url) - for index, item in enumerate(sms_lines) - ], + items=items, + db_id=db_id, + status="running", + message="批次运行中", + started_at=_now(), ) + # DB 同步为 running,避免返回 pending 导致前端误判 + db = SessionLocal() + try: + row = db.query(HuyaRegisterBatchModel).filter(HuyaRegisterBatchModel.id == db_id).first() + if row: + row.status = "running" + row.message = "批次运行中" + row.started_at = batch.started_at + db.commit() + finally: + db.close() + runner = HuyaRegisterRunner(batch=batch, sms_lines=sms_lines, proxy_config=proxy_config) with self._lock: self._runners[batch_id] = runner return runner + def retry( + self, + batch_id: str, + proxy_config: Optional[ProxyConfigModel] = None, + *, + concurrency: int | None = None, + wait_seconds: float | None = None, + poll_interval: float | None = None, + password_prefix: str | None = None, + fixed_password: str | None = None, + use_proxy: bool | None = None, + ) -> HuyaRegisterRunner: + """从失败/停止/未完成的条目续跑;成功项跳过。""" + with self._lock: + existing = self._runners.get(batch_id) + if existing and existing.batch.status == "running": + raise RuntimeError("批次正在运行中,请先停止或等待完成") + + db = SessionLocal() + try: + batch_row = ( + db.query(HuyaRegisterBatchModel) + .filter(HuyaRegisterBatchModel.batch_id == batch_id) + .first() + ) + if not batch_row: + raise ValueError("批次不存在") + if batch_row.status == "running": + # 无内存 runner 的 running 视为中断,允许续跑 + batch_row.status = "interrupted" + batch_row.message = "服务中断,准备续跑" + db.commit() + + item_rows = ( + db.query(HuyaRegisterItemModel) + .filter(HuyaRegisterItemModel.batch_id == batch_id) + .order_by(HuyaRegisterItemModel.line.asc()) + .all() + ) + if not item_rows: + raise ValueError("批次没有可运行条目") + + # 可选覆盖运行参数 + if concurrency is not None: + batch_row.concurrency = max(1, min(int(concurrency), 5)) + if wait_seconds is not None: + batch_row.wait_seconds = int(max(15.0, float(wait_seconds))) + if poll_interval is not None: + batch_row.poll_interval = int(max(1.0, float(poll_interval))) + if password_prefix is not None: + batch_row.password_prefix = (password_prefix or "hy").strip()[:8] or "hy" + if fixed_password is not None: + batch_row.fixed_password = (fixed_password or "").strip() + if use_proxy is not None: + batch_row.use_proxy = bool(use_proxy) + + retry_indices: list[int] = [] + for idx, row in enumerate(item_rows): + if (row.status or "pending") in RETRYABLE_ITEM_STATUSES and row.status != "success": + row.status = "pending" + row.message = "等待续跑" + row.finished_at = None + row.started_at = None + row.code = "" + row.change_code = "" + row.attempts = 0 + row.change_attempts = 0 + # 保留历史密码等成功字段不覆盖;失败项本来也没有 + retry_indices.append(idx) + + if not retry_indices: + raise ValueError("没有可续跑的失败/未完成条目") + + batch_row.status = "running" + batch_row.message = f"续跑中({len(retry_indices)} 条)" + batch_row.started_at = batch_row.started_at or _now() + batch_row.finished_at = None + _refresh_batch_counts(batch_row, item_rows) + db.commit() + + batch = _batch_from_db(batch_row, item_rows) + sms_lines = [ + SmsLine(phone=item.phone, url=item.sms_url, provider=item.provider, raw=f"{item.phone}----{item.sms_url}") + for item in batch.items + ] + finally: + db.close() + + if use_proxy is False: + proxy_config = None + elif batch.use_proxy and proxy_config is None: + # 由调用方传入;若未传则 runner 内会报代理未配置 + pass + + runner = HuyaRegisterRunner( + batch=batch, + sms_lines=sms_lines, + proxy_config=proxy_config if batch.use_proxy else None, + item_indices=retry_indices, + ) + # 内存侧也保持 running,与 DB 一致 + runner.batch.status = "running" + runner.batch.message = f"续跑中({len(retry_indices)} 条)" + 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) + def live_batch_ids(self) -> set[str]: + with self._lock: + return { + batch_id + for batch_id, runner in self._runners.items() + if runner.batch.status == "running" + } + + def list_summaries(self, limit: int = 50) -> list[dict]: + return list_batch_summaries(limit=limit, live_batch_ids=self.live_batch_ids()) + + def get_snapshot(self, batch_id: str) -> dict | None: + """优先内存 runner,否则读库(并处理中断恢复)。""" + with self._lock: + runner = self._runners.get(batch_id) + if runner: + return runner.snapshot() + return load_batch_snapshot(batch_id, recover_interrupted=True) + huya_register_registry = HuyaRegisterRegistry() diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 5b4d10a..48a1f5b 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -3,6 +3,7 @@ import type { HuyaAccountItem, HuyaAutoRegisterBatch, HuyaAutoRegisterRequest, + HuyaAutoRegisterRetryRequest, HuyaConfig, HuyaCookieItem, HuyaCookieImportResult, @@ -13,6 +14,7 @@ import type { HuyaPasswordLoginResult, HuyaPasswordLoginSelectedRequest, HuyaRechargeGoodsItem, + HuyaRegisterSuccessLog, HuyaSmsCodeRequest, HuyaSmsCodeResult, HuyaSmsLoginRequest, @@ -43,10 +45,20 @@ export const huyaApi = { api.post('/huya/accounts/sms-login', data, { timeout: 120000 }), startAutoRegister: (data: HuyaAutoRegisterRequest) => api.post('/huya/register/batches', data), + listAutoRegisterBatches: (limit = 50) => + api.get('/huya/register/batches', { params: { limit } }), getAutoRegisterBatch: (batchId: string) => api.get(`/huya/register/batches/${batchId}`), stopAutoRegisterBatch: (batchId: string) => api.post(`/huya/register/batches/${batchId}/stop`), + retryAutoRegisterBatch: (batchId: string, data?: HuyaAutoRegisterRetryRequest) => + api.post(`/huya/register/batches/${batchId}/retry`, data || {}), + exportAutoRegisterBatch: (batchId: string) => + api.get(`/huya/register/batches/${batchId}/export`, { responseType: 'blob' }), + listRegisterSuccessLogs: (params?: { batch_id?: string; tag?: string; limit?: number }) => + api.get('/huya/register/success-logs', { params }), + exportRegisterSuccessLogs: (params?: { batch_id?: string; tag?: string; limit?: number }) => + api.get('/huya/register/success-logs/export', { responseType: 'blob', params }), 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 db50651..0780856 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -223,6 +223,15 @@ export interface HuyaAutoRegisterRequest { use_proxy?: boolean; } +export interface HuyaAutoRegisterRetryRequest { + concurrency?: number; + wait_seconds?: number; + poll_interval?: number; + password_prefix?: string; + fixed_password?: string; + use_proxy?: boolean; +} + export interface HuyaAutoRegisterItem { line: number; phone: string; @@ -267,6 +276,23 @@ export interface HuyaAutoRegisterBatch { items: HuyaAutoRegisterItem[]; } +export interface HuyaRegisterSuccessLog { + id: number; + batch_id: string; + item_id: number | null; + account_id: number | null; + phone: string; + username: string; + uid: string; + password: string; + sms_url: string; + tag: string; + provider: string; + created_by: number | null; + created_at: string | null; + export_line: string; +} + export interface HuyaPasswordLoginBatchItem { line: number; username: string; diff --git a/web/frontend/src/pages/HuyaRegisterPage.tsx b/web/frontend/src/pages/HuyaRegisterPage.tsx index 73775cc..e5acf64 100644 --- a/web/frontend/src/pages/HuyaRegisterPage.tsx +++ b/web/frontend/src/pages/HuyaRegisterPage.tsx @@ -3,8 +3,15 @@ import { Button, Card, Col, Input, InputNumber, message, Row, Segmented, Space, Statistic, Switch, 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 { + DownloadOutlined, PlayCircleOutlined, RedoOutlined, ReloadOutlined, StopOutlined, +} from '@ant-design/icons'; +import { + huyaApi, + type HuyaAutoRegisterBatch, + type HuyaAutoRegisterItem, + type HuyaRegisterSuccessLog, +} from '../api/modules'; import { formatTime } from '../utils/time'; import { getErrorMessage } from '../utils/error'; @@ -48,6 +55,9 @@ const STATUS_LABELS: Record = { success: '成功', error: '失败', stopped: '已停止', + interrupted: '已中断', + finished: '已完成', + running: '运行中', }; const STATUS_COLORS: Record = { @@ -59,9 +69,14 @@ const STATUS_COLORS: Record = { success: 'success', error: 'error', stopped: 'warning', + interrupted: 'warning', + finished: 'success', + running: 'processing', }; -const RUNNING_STATUS = new Set(['pending', 'running']); +// 只有真正 running 才锁 UI / 轮询;pending 可能是历史脏数据,不能当运行中 +const RUNNING_STATUS = new Set(['running']); +const RETRYABLE_BATCH = new Set(['finished', 'stopped', 'error', 'interrupted', 'pending']); function safeNumber(value: unknown, fallback: number) { const n = Number(value); @@ -96,6 +111,15 @@ function readStoredBatchId() { } } +async function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} + export default function HuyaRegisterPage() { const [initialForm] = useState(readStoredForm); const [text, setText] = useState(initialForm.text); @@ -108,13 +132,26 @@ export default function HuyaRegisterPage() { const [fixedPassword, setFixedPassword] = useState(initialForm.fixedPassword); const [useProxy, setUseProxy] = useState(initialForm.useProxy); const [batch, setBatch] = useState(null); + const [history, setHistory] = useState([]); + const [successLogs, setSuccessLogs] = useState([]); const [starting, setStarting] = useState(false); + const [retrying, setRetrying] = useState(false); const [refreshing, setRefreshing] = useState(false); const [stopping, setStopping] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); + const [logsLoading, setLogsLoading] = useState(false); + const [exporting, setExporting] = useState(false); const restoredBatchRef = useRef(false); const batchId = batch?.batch_id || ''; - const isRunning = !!batch && RUNNING_STATUS.has(batch.status); + const isRunning = !!batch && batch.status === 'running'; + const hasUnfinished = !!batch && ( + (batch.failed_count || 0) + (batch.stopped_count || 0) > 0 + || (batch.items || []).some((item) => item.status !== 'success') + || ((batch.success_count || 0) < (batch.total || 0)) + ); + const canRetry = !!batch && !isRunning && hasUnfinished + && (RETRYABLE_BATCH.has(batch.status) || batch.status === 'pending'); const loadBatchById = useCallback(async (id: string) => { if (!id) return; @@ -125,10 +162,10 @@ export default function HuyaRegisterPage() { localStorage.setItem(BATCH_STORAGE_KEY, data.batch_id); } catch (e: unknown) { const err = getErrorMessage(e); - if (err.includes('批次不存在') || err.includes('服务已重启')) { + if (err.includes('批次不存在')) { localStorage.removeItem(BATCH_STORAGE_KEY); setBatch(null); - message.warning('上次自动注册批次已不存在,已清除恢复记录'); + message.warning('批次不存在,已清除本地恢复记录'); } else { message.error(err); } @@ -142,6 +179,30 @@ export default function HuyaRegisterPage() { await loadBatchById(batchId); }, [batchId, loadBatchById]); + const loadHistory = useCallback(async () => { + setHistoryLoading(true); + try { + const rows = await huyaApi.listAutoRegisterBatches(50); + setHistory(rows); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setHistoryLoading(false); + } + }, []); + + const loadSuccessLogs = useCallback(async () => { + setLogsLoading(true); + try { + const rows = await huyaApi.listRegisterSuccessLogs({ limit: 100 }); + setSuccessLogs(rows); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setLogsLoading(false); + } + }, []); + useEffect(() => { localStorage.setItem(FORM_STORAGE_KEY, JSON.stringify({ text, @@ -163,7 +224,9 @@ export default function HuyaRegisterPage() { if (storedBatchId) { loadBatchById(storedBatchId); } - }, [loadBatchById]); + loadHistory(); + loadSuccessLogs(); + }, [loadBatchById, loadHistory, loadSuccessLogs]); useEffect(() => { if (batch?.batch_id) { @@ -179,11 +242,28 @@ export default function HuyaRegisterPage() { return () => window.clearInterval(timer); }, [batchId, isRunning, refreshBatch]); + useEffect(() => { + if (isRunning) return undefined; + // 批次结束时刷新历史与成功流水 + if (batch && (batch.status === 'finished' || batch.status === 'stopped' || batch.status === 'error')) { + loadHistory(); + loadSuccessLogs(); + } + return undefined; + }, [batch?.status, isRunning, loadHistory, loadSuccessLogs, batch]); + const successRows = useMemo( () => (batch?.items || []).filter((item) => item.status === 'success' && item.password && (item.username || item.uid)), [batch], ); + const retryableCount = useMemo(() => { + if (!batch?.items?.length) { + return (batch?.failed_count || 0) + (batch?.stopped_count || 0); + } + return batch.items.filter((item) => item.status !== 'success').length; + }, [batch]); + const handleStart = async () => { if (!text.trim()) { message.warning('请先粘贴手机号池'); @@ -207,6 +287,7 @@ export default function HuyaRegisterPage() { }); setBatch(data); message.success('自动注册批次已启动'); + loadHistory(); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { @@ -228,21 +309,75 @@ export default function HuyaRegisterPage() { } }; - const handleExportSuccess = () => { - if (successRows.length === 0) { - message.warning('当前批次没有可导出的成功账号'); - return; + const handleRetry = async () => { + if (!batchId) return; + setRetrying(true); + try { + const data = await huyaApi.retryAutoRegisterBatch(batchId, { + concurrency, + wait_seconds: waitSeconds, + poll_interval: pollInterval, + password_prefix: passwordMode === 'random' ? (passwordPrefix.trim() || 'hy') : undefined, + fixed_password: passwordMode === 'fixed' ? fixedPassword.trim() : undefined, + use_proxy: useProxy, + }); + setBatch(data); + message.success(`已开始续跑 ${retryableCount} 条失败/未完成项`); + loadHistory(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setRetrying(false); + } + }; + + const handleExportCurrentBatch = async () => { + if (!batchId) return; + setExporting(true); + try { + // 优先服务端历史流水(换电脑可用);若无记录再回退当前页内存 + try { + const blob = await huyaApi.exportAutoRegisterBatch(batchId); + await downloadBlob(blob, `huya-register-${batchId}.txt`); + message.success('已导出当前批次成功账号'); + return; + } catch { + // fallthrough + } + if (successRows.length === 0) { + message.warning('当前批次没有可导出的成功账号'); + return; + } + const body = successRows + .map((item) => `${item.username || item.uid}----${item.password}----${item.phone}----${item.sms_url}`) + .join('\n'); + const blob = new Blob([body], { type: 'text/plain;charset=utf-8' }); + await downloadBlob(blob, `huya-register-accounts-${batchId}.txt`); + message.success('已导出当前批次成功账号'); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setExporting(false); + } + }; + + const handleExportAllSuccess = async () => { + setExporting(true); + try { + const blob = await huyaApi.exportRegisterSuccessLogs({ + tag: tag.trim() || undefined, + limit: 5000, + }); + const name = tag.trim() + ? `huya-register-success-${tag.trim()}.txt` + : 'huya-register-success-all.txt'; + await downloadBlob(blob, name); + message.success(tag.trim() ? `已按标签「${tag.trim()}」导出成功历史` : '已导出全部成功历史'); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setExporting(false); } - const body = successRows - .map((item) => `${item.username || item.uid}----${item.password}----${item.phone}----${item.sms_url}`) - .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-accounts-${batchId || 'success'}.txt`; - link.click(); - URL.revokeObjectURL(url); }; const columns: TableProps['columns'] = [ @@ -296,6 +431,125 @@ export default function HuyaRegisterPage() { }, ]; + const historyColumns: TableProps['columns'] = [ + { + title: '批次', + dataIndex: 'batch_id', + width: 130, + render: (value: string) => {value}, + }, + { + title: '标签', + dataIndex: 'tag', + width: 120, + render: (value: string) => value || '-', + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (status: string) => ( + + {STATUS_LABELS[status] || status} + + ), + }, + { + title: '进度', + width: 160, + render: (_, row) => `${row.success_count}/${row.total} 成功 · 失败 ${row.failed_count}`, + }, + { + title: '时间', + dataIndex: 'created_at', + width: 170, + render: (value: string | null) => formatTime(value), + }, + { + title: '操作', + width: 220, + render: (_, row) => ( + + + + + + ), + }, + ]; + + const logColumns: TableProps['columns'] = [ + { title: 'ID', dataIndex: 'id', width: 70 }, + { + title: '批次', + dataIndex: 'batch_id', + width: 120, + render: (value: string) => value || '-', + }, + { title: '手机号', dataIndex: 'phone', width: 140 }, + { + title: '虎牙号', + width: 140, + render: (_, row) => row.username || row.uid || '-', + }, + { title: '密码', dataIndex: 'password', width: 120 }, + { + title: '标签', + dataIndex: 'tag', + width: 100, + render: (value: string) => value || '-', + }, + { + title: '时间', + dataIndex: 'created_at', + width: 170, + render: (value: string | null) => formatTime(value), + }, + ]; + return ( 虎牙自动注册 @@ -315,7 +569,7 @@ export default function HuyaRegisterPage() { setTag(event.target.value)} - placeholder="注册批次" + placeholder="注册批次(导出历史可按标签过滤)" disabled={isRunning} /> @@ -393,8 +647,8 @@ export default function HuyaRegisterPage() { /> - - + + + + + + 成功账号会立即写入数据库成功流水;服务重启后可从历史批次查看并续跑失败项。换电脑登录同一服务即可导出 txt。 + - )} @@ -440,6 +717,11 @@ export default function HuyaRegisterPage() { + {batch?.message ? ( + + {STATUS_LABELS[batch.status] || batch.status} · {batch.message} + + ) : null} + + } loading={historyLoading} onClick={loadHistory}> + 刷新历史 + + )} + > +
({ + style: row.batch_id === batchId ? { background: 'rgba(22, 119, 255, 0.06)' } : undefined, + })} + /> + + + + + + + )} + > +
+ ); }