功能: 按用户同步斗鱼工作台

This commit is contained in:
yml2213
2026-08-13 15:47:07 +08:00
parent 090698e6b2
commit cf3fe68de3
14 changed files with 503 additions and 44 deletions
@@ -0,0 +1,68 @@
"""持久化斗鱼工作台账号归属和任务手册范围
Revision ID: 20260813_0024
Revises: 20260813_0023
Create Date: 2026-08-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260813_0024"
down_revision: Union[str, None] = "20260813_0023"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _columns(bind, table_name: str) -> set[str]:
return {column["name"] for column in sa.inspect(bind).get_columns(table_name)}
def upgrade() -> None:
bind = op.get_bind()
if "handbook_scope" not in _columns(bind, "douyu_tasks"):
op.add_column(
"douyu_tasks",
sa.Column("handbook_scope", sa.String(length=16), nullable=False, server_default="legacy"),
)
op.create_index("ix_douyu_tasks_handbook_scope", "douyu_tasks", ["handbook_scope"])
if not sa.inspect(bind).has_table("douyu_workbench_accounts"):
op.create_table(
"douyu_workbench_accounts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id"), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"),
)
op.create_index("ix_douyu_workbench_accounts_user_id", "douyu_workbench_accounts", ["user_id"])
op.create_index("ix_douyu_workbench_accounts_handbook_scope", "douyu_workbench_accounts", ["handbook_scope"])
op.create_index("ix_douyu_workbench_accounts_account_id", "douyu_workbench_accounts", ["account_id"])
if not sa.inspect(bind).has_table("douyu_workbenches"):
op.create_table(
"douyu_workbenches",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
)
op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"])
op.create_index("ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"])
def downgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("douyu_workbenches"):
op.drop_table("douyu_workbenches")
if sa.inspect(bind).has_table("douyu_workbench_accounts"):
op.drop_table("douyu_workbench_accounts")
if "handbook_scope" in _columns(bind, "douyu_tasks"):
op.drop_index("ix_douyu_tasks_handbook_scope", table_name="douyu_tasks")
op.drop_column("douyu_tasks", "handbook_scope")
@@ -0,0 +1,39 @@
"""补齐工作台空状态同步表
Revision ID: 20260813_0025
Revises: 20260813_0024
Create Date: 2026-08-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260813_0025"
down_revision: Union[str, None] = "20260813_0024"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("douyu_workbenches"):
return
op.create_table(
"douyu_workbenches",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
)
op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"])
op.create_index("ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"])
def downgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("douyu_workbenches"):
op.drop_table("douyu_workbenches")
+32 -1
View File
@@ -2,7 +2,7 @@
from datetime import datetime, timezone
from sqlalchemy import (
Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON,
Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON, UniqueConstraint,
)
from sqlalchemy.orm import relationship
from .database import Base
@@ -117,6 +117,8 @@ class DouyuTask(Base):
batch_id = Column(String(64), nullable=False, index=True)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
task_type = Column(String(64), nullable=False, index=True)
# 任务归属工作台;避免同一账号的精英/电竞/小店任务在前端串行展示或串弹二维码。
handbook_scope = Column(String(16), nullable=False, default="legacy", index=True)
status = Column(String(32), default="pending")
message = Column(String(512), default="")
result = Column(JSON, nullable=True)
@@ -127,6 +129,35 @@ class DouyuTask(Base):
account = relationship("Account", back_populates="douyu_tasks")
class DouyuWorkbenchAccount(Base):
"""用户在指定斗鱼工作台中启用的账号,跨浏览器同步。"""
__tablename__ = "douyu_workbench_accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
handbook_scope = Column(String(16), nullable=False, index=True)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False, index=True)
created_at = Column(DateTime, default=_utcnow)
__table_args__ = (
UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"),
)
class DouyuWorkbench(Base):
"""工作台配置哨兵,令空账号集合也能跨浏览器同步。"""
__tablename__ = "douyu_workbenches"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
handbook_scope = Column(String(16), nullable=False, index=True)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (
UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
)
class YybRechargeTask(Base):
"""应用宝和平精英点券充值任务。
+4 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session, defer, joinedload
from ..database import get_db
from ..models import User, Account, AuditLog, LoginTask, DouyuTask
from ..models import User, Account, AuditLog, LoginTask, DouyuTask, DouyuWorkbenchAccount
from ..schemas import AccountBulkSelection, AccountBulkTag, AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
from ..deps import get_current_user, require_permission
from ..permissions import user_has_permission
@@ -405,6 +405,7 @@ def batch_delete_accounts(
# 先删除关联的登录任务
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id.in_(ids)).delete(synchronize_session=False)
# 删除账号
deleted = db.query(Account).filter(Account.id.in_(ids)).delete(synchronize_session=False)
@@ -430,6 +431,7 @@ def batch_delete_accounts_selection(
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id.in_(ids)).delete(synchronize_session=False)
deleted = (
_visible_accounts_query(db, current)
.filter(Account.id.in_(ids))
@@ -457,6 +459,7 @@ def delete_account(
# 先删除关联的登录任务,避免外键约束失败
db.query(LoginTask).filter(LoginTask.account_id == account_id).delete(synchronize_session=False)
db.query(DouyuTask).filter(DouyuTask.account_id == account_id).delete(synchronize_session=False)
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id == account_id).delete(synchronize_session=False)
db.add(AuditLog(user_id=current.id, username=current.username,
action="account:delete", target=acc.username))
+73 -1
View File
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session, joinedload
from ..database import SessionLocal, get_db
from ..deps import authenticate_websocket, get_current_user, require_permission
from ..models import Account, DouyuConfig, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot, User
from ..models import Account, DouyuConfig, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuWorkbench, DouyuWorkbenchAccount, DouyuXpdGoodsSnapshot, User
from ..permissions import user_has_permission
from ..schemas import (
DouyuConfigOut,
@@ -21,11 +21,13 @@ from ..schemas import (
DouyuTaskAccountOut,
DouyuTaskBatchRequest,
DouyuTaskOut,
DouyuWorkbenchAccountsUpdate,
DouyuXpdGoodsOut,
)
from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry
from ..services.douyu_service import (
DOUYU_CONFIG_FIELDS,
DOUYU_HANDBOOK_TASK_TYPES,
SUPPORTED_DOUYU_TASK_TYPES,
apply_douyu_config_defaults,
cleanup_orphan_douyu_tasks,
@@ -242,6 +244,67 @@ def list_task_account_ids(
return {"account_ids": account_ids, "total": len(account_ids)}
@router.get("/workbench-accounts")
def list_workbench_accounts(
handbook_scope: str = Query(..., pattern="^(elite|esports|peace)$"),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前用户在指定工作台启用的账号,供不同浏览器同步。"""
rows = (
db.query(DouyuWorkbenchAccount.account_id)
.join(Account, Account.id == DouyuWorkbenchAccount.account_id)
.filter(
DouyuWorkbenchAccount.user_id == current.id,
DouyuWorkbenchAccount.handbook_scope == handbook_scope,
)
)
if not _can_view_all(current):
rows = rows.filter(Account.assigned_to == current.id)
account_ids = [account_id for account_id, in rows.order_by(DouyuWorkbenchAccount.id.asc()).all()]
configured = db.query(DouyuWorkbench.id).filter(
DouyuWorkbench.user_id == current.id,
DouyuWorkbench.handbook_scope == handbook_scope,
).first() is not None
return {"account_ids": account_ids, "configured": configured}
@router.put("/workbench-accounts")
def update_workbench_accounts(
req: DouyuWorkbenchAccountsUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""用当前完整账号集合覆盖一个工作台,作为跨浏览器的同步状态。"""
account_ids = sorted(set(req.account_ids))
if any(account_id < 1 for account_id in account_ids):
raise HTTPException(status_code=400, detail="无效的账号ID")
if account_ids:
_require_task_account_access(db, current, account_ids)
workbench = db.query(DouyuWorkbench).filter(
DouyuWorkbench.user_id == current.id,
DouyuWorkbench.handbook_scope == req.handbook_scope,
).first()
if workbench is None:
db.add(DouyuWorkbench(user_id=current.id, handbook_scope=req.handbook_scope))
else:
workbench.updated_at = datetime.now(timezone.utc)
db.query(DouyuWorkbenchAccount).filter(
DouyuWorkbenchAccount.user_id == current.id,
DouyuWorkbenchAccount.handbook_scope == req.handbook_scope,
).delete(synchronize_session=False)
db.add_all([
DouyuWorkbenchAccount(
user_id=current.id,
handbook_scope=req.handbook_scope,
account_id=account_id,
)
for account_id in account_ids
])
db.commit()
return {"account_ids": account_ids, "success": True}
@router.get("/config", response_model=DouyuConfigOut)
def get_config(
db: Session = Depends(get_db),
@@ -324,6 +387,7 @@ async def create_task_batch(
db,
req.account_ids,
req.task_type,
req.handbook_scope,
current.id,
req.payload,
)
@@ -355,6 +419,7 @@ async def create_task_batch(
@router.get("/tasks")
def list_tasks(
batch_id: str | None = None,
handbook_scope: str | None = Query(None, pattern="^(elite|esports|peace)$"),
include_detail: bool = Query(False, description="是否返回完整任务结果(默认否,轮询请保持 false)"),
page: int | None = Query(None, ge=1),
page_size: int = Query(100, ge=1, le=500),
@@ -365,6 +430,13 @@ def list_tasks(
query = _visible_tasks_query(db, current)
if batch_id:
query = query.filter(DouyuTask.batch_id == batch_id)
if handbook_scope:
# 旧任务没有归属字段,按历史任务类型继续展示,但绝不会触发自动二维码弹窗。
query = query.filter(or_(
DouyuTask.handbook_scope == handbook_scope,
(DouyuTask.handbook_scope == "legacy")
& DouyuTask.task_type.in_(DOUYU_HANDBOOK_TASK_TYPES[handbook_scope]),
))
total = None
if page is not None:
total = query.enable_eagerloads(False).order_by(None).count()
+8
View File
@@ -642,10 +642,16 @@ class DouyuConfigUpdate(BaseModel):
class DouyuTaskBatchRequest(BaseModel):
account_ids: list[int]
task_type: str
handbook_scope: str = Field(..., pattern="^(elite|esports|peace)$")
concurrency: int = Field(3, ge=1, le=10)
payload: dict[str, Any] = Field(default_factory=dict)
class DouyuWorkbenchAccountsUpdate(BaseModel):
handbook_scope: str = Field(..., pattern="^(elite|esports|peace)$")
account_ids: list[int] = Field(default_factory=list)
class DouyuTaskOut(BaseModel):
id: int
batch_id: str
@@ -654,6 +660,7 @@ class DouyuTaskOut(BaseModel):
account_uid: str = ""
account_nickname: str = ""
task_type: str
handbook_scope: str = "legacy"
status: str
message: str = ""
result: Optional[dict[str, Any]] = None
@@ -673,6 +680,7 @@ class DouyuTaskOut(BaseModel):
"account_uid": self.account_uid,
"account_nickname": self.account_nickname,
"task_type": self.task_type,
"handbook_scope": self.handbook_scope,
"status": self.status,
"message": self.message,
"result": self.result,
+28
View File
@@ -48,6 +48,27 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
"exchange_xpd_goods": "兑换小店商品",
}
DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"}
DOUYU_HANDBOOK_TASK_TYPES = {
"elite": {
"get_bind_qr", "confirm_bind", "create_elite_qr", "create_gold_qr", "donate_elite_gift",
"query_points", "exchange_goods", "query_game_name", "query_change_bind_time",
"query_limited_goods", "query_gold_balance", "refresh_goods", "query_exchange_records",
"prefetch_csrf_token",
},
"esports": {
"prepare_esports_bind", "get_esports_bind_qr", "query_esports_game_name", "confirm_esports_bind",
"create_esports_qr", "query_esports_points", "query_gold_balance", "query_change_bind_time",
"query_limited_goods", "refresh_esports_goods", "exchange_esports_goods", "create_gold_qr",
"donate_esports_chicken_gift", "donate_esports_firework_gift",
},
"peace": {
"get_xpd_bind_qr", "query_xpd_bind_info", "confirm_xpd_bind", "query_xpd_role",
"refresh_xpd_goods", "query_xpd_balance", "query_xpd_fragments",
"query_xpd_purchase_records", "exchange_xpd_goods",
},
}
DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7",
"rid": "9263298",
@@ -171,12 +192,17 @@ def create_douyu_planned_tasks(
db: Session,
account_ids: list[int],
task_type: str,
handbook_scope: str,
created_by: int,
payload: dict | None = None,
) -> tuple[str, int]:
"""创建斗鱼任务记录,等待后台执行器消费。"""
if task_type not in SUPPORTED_DOUYU_TASK_TYPES:
raise ValueError("不支持的任务类型")
if handbook_scope not in DOUYU_HANDBOOK_SCOPES:
raise ValueError("无效的工作台")
if task_type not in DOUYU_HANDBOOK_TASK_TYPES[handbook_scope]:
raise ValueError("该任务不属于当前工作台")
accounts = visible_douyu_task_accounts(db, account_ids)
if task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"} and accounts:
@@ -190,6 +216,7 @@ def create_douyu_planned_tasks(
batch_id=batch_id,
account_id=account.id,
task_type=task_type,
handbook_scope=handbook_scope,
status="planned",
message="任务已创建,等待执行",
result={"payload": payload} if payload else None,
@@ -333,6 +360,7 @@ def douyu_task_payload(task: DouyuTask, *, include_detail: bool = False) -> dict
"account_uid": (account.uid if account else "") or "",
"account_nickname": (account.nickname if account else "") or "",
"task_type": task.task_type or "",
"handbook_scope": task.handbook_scope or "legacy",
"status": task.status or "",
"message": task.message or "",
"result": sanitize_douyu_task_result(