功能: 按用户同步斗鱼工作台
This commit is contained in:
@@ -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
@@ -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):
|
||||
"""应用宝和平精英点券充值任务。
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user