虎牙自动注册持久化成功流水与批次,支持失败续跑与随时导出

将注册批次/条目/成功记录落库,成功一个写入一条流水;服务中断可恢复并续跑失败项,换电脑也能导出 txt。同时修复 pending 被误判为运行中导致页面卡住的问题。
This commit is contained in:
yml2213
2026-07-12 20:05:04 +08:00
parent c3044dbf69
commit e18b2c6e0c
9 changed files with 1423 additions and 96 deletions
+4
View File
@@ -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"),
@@ -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")
+76
View File
@@ -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"
+121 -7
View File
@@ -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,
+28 -1
View File
@@ -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):
+667 -59
View File
@@ -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 有完整 cookieAPI 隐藏
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:
if self.batch.status != "running":
self.batch.status = "running"
self.batch.message = "批次运行中"
self.batch.started_at = _now()
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()
+12
View File
@@ -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<HuyaSmsLoginResult, HuyaSmsLoginResult>('/huya/accounts/sms-login', data, { timeout: 120000 }),
startAutoRegister: (data: HuyaAutoRegisterRequest) =>
api.post<HuyaAutoRegisterBatch, HuyaAutoRegisterBatch>('/huya/register/batches', data),
listAutoRegisterBatches: (limit = 50) =>
api.get<HuyaAutoRegisterBatch[], HuyaAutoRegisterBatch[]>('/huya/register/batches', { params: { limit } }),
getAutoRegisterBatch: (batchId: string) =>
api.get<HuyaAutoRegisterBatch, HuyaAutoRegisterBatch>(`/huya/register/batches/${batchId}`),
stopAutoRegisterBatch: (batchId: string) =>
api.post<MessageResponse, MessageResponse>(`/huya/register/batches/${batchId}/stop`),
retryAutoRegisterBatch: (batchId: string, data?: HuyaAutoRegisterRetryRequest) =>
api.post<HuyaAutoRegisterBatch, HuyaAutoRegisterBatch>(`/huya/register/batches/${batchId}/retry`, data || {}),
exportAutoRegisterBatch: (batchId: string) =>
api.get<Blob, Blob>(`/huya/register/batches/${batchId}/export`, { responseType: 'blob' }),
listRegisterSuccessLogs: (params?: { batch_id?: string; tag?: string; limit?: number }) =>
api.get<HuyaRegisterSuccessLog[], HuyaRegisterSuccessLog[]>('/huya/register/success-logs', { params }),
exportRegisterSuccessLogs: (params?: { batch_id?: string; tag?: string; limit?: number }) =>
api.get<Blob, Blob>('/huya/register/success-logs/export', { responseType: 'blob', params }),
assign: (id: number, assigned_to: number | null) =>
api.put<MessageResponse, MessageResponse>(`/huya/accounts/${id}/assign`, { assigned_to }),
batchAssign: (account_ids: number[], assigned_to: number | null) =>
+26
View File
@@ -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;
+346 -20
View File
@@ -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<string, string> = {
success: '成功',
error: '失败',
stopped: '已停止',
interrupted: '已中断',
finished: '已完成',
running: '运行中',
};
const STATUS_COLORS: Record<string, string> = {
@@ -59,9 +69,14 @@ const STATUS_COLORS: Record<string, string> = {
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<HuyaAutoRegisterBatch | null>(null);
const [history, setHistory] = useState<HuyaAutoRegisterBatch[]>([]);
const [successLogs, setSuccessLogs] = useState<HuyaRegisterSuccessLog[]>([]);
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,7 +309,41 @@ export default function HuyaRegisterPage() {
}
};
const handleExportSuccess = () => {
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;
@@ -237,12 +352,32 @@ export default function HuyaRegisterPage() {
.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);
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 columns: TableProps<HuyaAutoRegisterItem>['columns'] = [
@@ -296,6 +431,125 @@ export default function HuyaRegisterPage() {
},
];
const historyColumns: TableProps<HuyaAutoRegisterBatch>['columns'] = [
{
title: '批次',
dataIndex: 'batch_id',
width: 130,
render: (value: string) => <Text code>{value}</Text>,
},
{
title: '标签',
dataIndex: 'tag',
width: 120,
render: (value: string) => value || '-',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (status: string) => (
<Tag color={STATUS_COLORS[status] || 'default'}>
{STATUS_LABELS[status] || status}
</Tag>
),
},
{
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) => (
<Space size={4}>
<Button
size="small"
onClick={() => loadBatchById(row.batch_id)}
>
</Button>
<Button
size="small"
icon={<RedoOutlined />}
disabled={row.status === 'running' || (row.success_count >= row.total && row.total > 0)}
onClick={async () => {
try {
setRetrying(true);
const data = await huyaApi.retryAutoRegisterBatch(row.batch_id, {
concurrency,
wait_seconds: waitSeconds,
poll_interval: pollInterval,
use_proxy: useProxy,
});
setBatch(data);
message.success('已开始续跑失败/未完成项');
loadHistory();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setRetrying(false);
}
}}
>
</Button>
<Button
size="small"
icon={<DownloadOutlined />}
disabled={(row.success_count || 0) <= 0}
onClick={async () => {
try {
const blob = await huyaApi.exportAutoRegisterBatch(row.batch_id);
await downloadBlob(blob, `huya-register-${row.batch_id}.txt`);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
}}
>
</Button>
</Space>
),
},
];
const logColumns: TableProps<HuyaRegisterSuccessLog>['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 (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Title level={3} style={{ margin: 0 }}></Title>
@@ -315,7 +569,7 @@ export default function HuyaRegisterPage() {
<Input
value={tag}
onChange={(event) => setTag(event.target.value)}
placeholder="注册批次"
placeholder="注册批次(导出历史可按标签过滤)"
disabled={isRunning}
/>
</Col>
@@ -393,8 +647,8 @@ export default function HuyaRegisterPage() {
/>
</div>
</Col>
<Col xs={24} md={4}>
<Space style={{ width: '100%', paddingTop: 22 }}>
<Col xs={24} md={12}>
<Space wrap style={{ width: '100%', paddingTop: 22 }}>
<Button
type="primary"
icon={<PlayCircleOutlined />}
@@ -413,21 +667,44 @@ export default function HuyaRegisterPage() {
>
</Button>
<Button
icon={<RedoOutlined />}
loading={retrying}
disabled={!canRetry}
onClick={handleRetry}
>
{retryableCount > 0 ? ` (${retryableCount})` : ''}
</Button>
<Button
icon={<DownloadOutlined />}
loading={exporting}
onClick={handleExportAllSuccess}
>
</Button>
</Space>
</Col>
</Row>
<Text type="secondary">
txt
</Text>
</Space>
</Card>
<Card
title="批次结果"
title="当前批次结果"
extra={(
<Space>
<Button icon={<ReloadOutlined />} loading={refreshing} disabled={!batchId} onClick={refreshBatch}>
</Button>
<Button icon={<DownloadOutlined />} disabled={successRows.length === 0} onClick={handleExportSuccess}>
<Button
icon={<DownloadOutlined />}
loading={exporting}
disabled={!batchId}
onClick={handleExportCurrentBatch}
>
</Button>
</Space>
)}
@@ -440,6 +717,11 @@ export default function HuyaRegisterPage() {
<Col xs={12} md={4}><Statistic title="停止" value={batch?.stopped_count || 0} /></Col>
<Col xs={12} md={4}><Statistic title="批次" value={batch?.batch_id || '-'} /></Col>
</Row>
{batch?.message ? (
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
{STATUS_LABELS[batch.status] || batch.status} · {batch.message}
</Text>
) : null}
<Table
rowKey="line"
columns={columns}
@@ -449,6 +731,50 @@ export default function HuyaRegisterPage() {
scroll={{ x: 1320 }}
/>
</Card>
<Card
title="历史批次"
extra={(
<Button icon={<ReloadOutlined />} loading={historyLoading} onClick={loadHistory}>
</Button>
)}
>
<Table
rowKey="batch_id"
columns={historyColumns}
dataSource={history}
loading={historyLoading}
pagination={{ pageSize: 10 }}
scroll={{ x: 900 }}
onRow={(row) => ({
style: row.batch_id === batchId ? { background: 'rgba(22, 119, 255, 0.06)' } : undefined,
})}
/>
</Card>
<Card
title="成功流水(最近 100 条)"
extra={(
<Space>
<Button icon={<ReloadOutlined />} loading={logsLoading} onClick={loadSuccessLogs}>
</Button>
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportAllSuccess}>
</Button>
</Space>
)}
>
<Table
rowKey="id"
columns={logColumns}
dataSource={successLogs}
loading={logsLoading}
pagination={{ pageSize: 10 }}
scroll={{ x: 900 }}
/>
</Card>
</Space>
);
}