diff --git a/deploy.sh b/deploy.sh index 662fed5..eb174fa 100755 --- a/deploy.sh +++ b/deploy.sh @@ -200,6 +200,37 @@ cmd_migrate_mysql() { python scripts/migrate_sqlite_to_mysql.py --source /app/data/web.db } +cmd_migrate() { + COMPOSE=$(detect_compose) + if [ -z "$COMPOSE" ]; then + echo "❌ 未检测到 docker compose" + exit 1 + fi + + echo "正在启动 MySQL..." + $COMPOSE up -d mysql + + echo "等待 MySQL 就绪..." + for i in $(seq 1 30); do + if $COMPOSE exec -T mysql sh -c 'mysqladmin ping -h localhost -uroot -p"$MYSQL_ROOT_PASSWORD" --silent' >/dev/null 2>&1; then + break + fi + if [ "$i" = "30" ]; then + echo "❌ MySQL 启动超时,查看日志:" + $COMPOSE logs --tail 50 mysql + exit 1 + fi + sleep 2 + done + + echo "正在执行数据库迁移..." + echo "正在构建最新应用镜像..." + $COMPOSE build douyu-login + $COMPOSE run --rm --no-deps douyu-login \ + python -c 'from web.backend.database import run_migrations; run_migrations()' + echo "✅ 数据库迁移完成" +} + cmd_stop() { COMPOSE=$(detect_compose) if [ -z "$COMPOSE" ]; then @@ -216,8 +247,9 @@ cmd_restart() { echo "❌ 未检测到 docker compose" exit 1 fi - $COMPOSE restart - echo "✅ 服务已重启" + # 重建镜像和应用容器,使新迁移文件与入口迁移逻辑都能生效。 + $COMPOSE up -d --build --force-recreate douyu-login + echo "✅ 服务已重建并重启(已自动检查数据库迁移)" } cmd_dev() { @@ -238,6 +270,7 @@ cmd_help() { echo " (无参数) 构建并部署" echo " reset 删除所有数据并重置(需确认)" echo " migrate-mysql 先启动 MySQL 并迁移 data/web.db" + echo " migrate 仅执行数据库结构迁移" echo " logs 查看实时日志" echo " stop 停止服务" echo " restart 重启服务" @@ -255,6 +288,7 @@ cmd_help() { case "${1:-}" in reset) cmd_reset ;; migrate-mysql) cmd_migrate_mysql ;; + migrate) cmd_migrate ;; logs) cmd_logs ;; stop) cmd_stop ;; restart) cmd_restart ;; diff --git a/dev.sh b/dev.sh index 930036e..138626e 100755 --- a/dev.sh +++ b/dev.sh @@ -177,8 +177,6 @@ start_yyb_worker() { done } -start_yyb_worker - echo "正在启动本地 MySQL..." MYSQL_IMAGE="$MYSQL_IMAGE" \ MYSQL_BIND_HOST="$MYSQL_BIND_HOST" \ @@ -199,6 +197,15 @@ for i in $(seq 1 30); do sleep 2 done +echo "正在执行数据库迁移..." +( + export DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD + uv run python -c 'from web.backend.database import run_migrations; run_migrations()' +) +echo "数据库迁移完成" + +start_yyb_worker + echo "" echo "==============================" echo " 本地调试模式" @@ -208,6 +215,7 @@ echo " 前端: http://localhost:${FRONTEND_PORT}" echo " API 代理: ${BACKEND_PROXY_TARGET}" echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}" echo " YYB Worker: ${DEV_YYB_WORKER_URL}" +echo " 数据库迁移: 已检查至最新版本" echo " 应用日志: ${APP_LOG}" echo " 前端与 Worker 输出: 当前终端" echo " 退出: Ctrl+C" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 53c46bf..21a6624 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,6 +6,10 @@ if [ "$#" -gt 0 ]; then exec "$@" fi +echo "正在执行数据库迁移..." +python -c 'from web.backend.database import run_migrations; run_migrations()' +echo "数据库迁移完成" + # Worker 仅监听容器回环地址,Web 后端通过 127.0.0.1 调用。 python /app/services/yyb-worker/runtime/scripts/yyb-worker.py \ --host 127.0.0.1 \ diff --git a/tests/test_douyu_workbench_scopes.py b/tests/test_douyu_workbench_scopes.py new file mode 100644 index 0000000..f799f66 --- /dev/null +++ b/tests/test_douyu_workbench_scopes.py @@ -0,0 +1,107 @@ +import os +import unittest +from types import SimpleNamespace + +os.environ.setdefault("DATABASE_URL", "sqlite://") +os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=") + +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +from web.backend.database import Base +from web.backend.models import Account, DouyuTask, DouyuWorkbench, DouyuWorkbenchAccount, LoginTask, User +from web.backend.routers.accounts import delete_account +from web.backend.routers.douyu import list_tasks, list_workbench_accounts, update_workbench_accounts +from web.backend.schemas import DouyuWorkbenchAccountsUpdate +from web.backend.services.douyu_service import create_douyu_planned_tasks + + +class DouyuWorkbenchScopeTests(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite://") + + @event.listens_for(self.engine, "connect") + def enable_foreign_keys(connection, _): + connection.execute("PRAGMA foreign_keys=ON") + + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine)() + self.user = User(username="operator", password_hash="hash", role="super_admin") + self.session.add(self.user) + self.session.commit() + self.account = Account(username="douyu-user", password="password", email="mail@example.com", email_password="mail-password") + self.session.add(self.account) + self.session.commit() + self.session.add(LoginTask(batch_id="login", account_id=self.account.id, created_by=self.user.id, status="success", cookie="acf_uid=1")) + self.session.commit() + self.current = SimpleNamespace(id=self.user.id, username=self.user.username, role=self.user.role, custom_permissions=None) + + def tearDown(self): + self.session.close() + Base.metadata.drop_all(self.engine) + self.engine.dispose() + + def test_workbench_accounts_are_per_user_and_scope(self): + update_workbench_accounts( + DouyuWorkbenchAccountsUpdate(handbook_scope="elite", account_ids=[self.account.id]), + db=self.session, + current=self.current, + ) + elite = list_workbench_accounts("elite", db=self.session, current=self.current) + esports = list_workbench_accounts("esports", db=self.session, current=self.current) + self.assertEqual(elite["account_ids"], [self.account.id]) + self.assertEqual(esports["account_ids"], []) + self.assertTrue(elite["configured"]) + self.assertFalse(esports["configured"]) + self.assertEqual(self.session.query(DouyuWorkbenchAccount).count(), 1) + + update_workbench_accounts( + DouyuWorkbenchAccountsUpdate(handbook_scope="elite", account_ids=[]), + db=self.session, + current=self.current, + ) + empty_elite = list_workbench_accounts("elite", db=self.session, current=self.current) + self.assertEqual(empty_elite["account_ids"], []) + self.assertTrue(empty_elite["configured"]) + self.assertEqual(self.session.query(DouyuWorkbench).count(), 1) + + def test_task_scope_is_persisted_and_filters_shared_task_types(self): + elite_batch, _ = create_douyu_planned_tasks( + self.session, [self.account.id], "create_gold_qr", "elite", self.user.id, + ) + esports_batch, _ = create_douyu_planned_tasks( + self.session, [self.account.id], "create_gold_qr", "esports", self.user.id, + ) + elite = list_tasks( + batch_id=None, handbook_scope="elite", include_detail=False, page=None, page_size=100, + db=self.session, current=self.current, + ) + esports = list_tasks( + batch_id=None, handbook_scope="esports", include_detail=False, page=None, page_size=100, + db=self.session, current=self.current, + ) + self.assertEqual({item.batch_id for item in elite}, {elite_batch}) + self.assertEqual({item.batch_id for item in esports}, {esports_batch}) + self.assertEqual({task.handbook_scope for task in self.session.query(DouyuTask).all()}, {"elite", "esports"}) + + def test_rejects_task_from_wrong_workbench(self): + with self.assertRaisesRegex(ValueError, "不属于当前工作台"): + create_douyu_planned_tasks( + self.session, [self.account.id], "get_xpd_bind_qr", "elite", self.user.id, + ) + + def test_account_deletion_removes_workbench_membership(self): + self.session.add(DouyuWorkbenchAccount( + user_id=self.user.id, + handbook_scope="elite", + account_id=self.account.id, + )) + self.session.commit() + + delete_account(self.account.id, db=self.session, current=self.current) + + self.assertEqual(self.session.query(DouyuWorkbenchAccount).count(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/backend/migrations/versions/20260813_0024_douyu_workbench_scopes.py b/web/backend/migrations/versions/20260813_0024_douyu_workbench_scopes.py new file mode 100644 index 0000000..1e3f17d --- /dev/null +++ b/web/backend/migrations/versions/20260813_0024_douyu_workbench_scopes.py @@ -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") diff --git a/web/backend/migrations/versions/20260813_0025_douyu_workbench_empty_state.py b/web/backend/migrations/versions/20260813_0025_douyu_workbench_empty_state.py new file mode 100644 index 0000000..bbf0ab5 --- /dev/null +++ b/web/backend/migrations/versions/20260813_0025_douyu_workbench_empty_state.py @@ -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") diff --git a/web/backend/models.py b/web/backend/models.py index c4273e1..6c6bf6c 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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): """应用宝和平精英点券充值任务。 diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 76d27d1..ad86ad4 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -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)) diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 36773f7..265da1e 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -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() diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 4e17594..ab6b8c7 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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, diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index c1f7cd8..debe187 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -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( diff --git a/web/frontend/src/api/douyu.ts b/web/frontend/src/api/douyu.ts index 46e548f..5809d6e 100644 --- a/web/frontend/src/api/douyu.ts +++ b/web/frontend/src/api/douyu.ts @@ -27,8 +27,18 @@ export const douyuApi = { listPeaceGoods: () => api.get('/douyu/xpd-goods'), createTasks: (data: DouyuTaskBatchRequest) => api.post('/douyu/tasks/batch', data), - listTasks: (batchId?: string) => - api.get('/douyu/tasks', { params: batchId ? { batch_id: batchId } : {} }), + listTasks: (handbookScope: 'elite' | 'esports' | 'peace', batchId?: string) => + api.get('/douyu/tasks', { + params: { handbook_scope: handbookScope, ...(batchId ? { batch_id: batchId } : {}) }, + }), + listWorkbenchAccounts: (handbookScope: 'elite' | 'esports' | 'peace') => + api.get<{ account_ids: number[]; configured: boolean }, { account_ids: number[]; configured: boolean }>('/douyu/workbench-accounts', { + params: { handbook_scope: handbookScope }, + }), + updateWorkbenchAccounts: (handbookScope: 'elite' | 'esports' | 'peace', accountIds: number[]) => + api.put<{ account_ids: number[]; success: boolean }, { account_ids: number[]; success: boolean }>( + '/douyu/workbench-accounts', { handbook_scope: handbookScope, account_ids: accountIds }, + ), listTasksPaged: (params: PageParams & { batch_id?: string; include_detail?: boolean }) => api.get, PaginatedResponse>('/douyu/tasks', { params }), getTask: (taskId: number) => api.get(`/douyu/tasks/${taskId}`), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 0f58479..0ea86f3 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -347,6 +347,7 @@ export interface DouyuConfig { export interface DouyuTaskBatchRequest { account_ids: number[]; task_type: string; + handbook_scope: 'elite' | 'esports' | 'peace'; concurrency?: number; payload?: Record; } @@ -365,6 +366,7 @@ export interface DouyuTaskItem { account_uid: string; account_nickname: string; task_type: string; + handbook_scope: 'elite' | 'esports' | 'peace' | 'legacy'; status: string; message: string; result: Record | null; diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index 13bd880..57c7b13 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -358,12 +358,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const { can } = usePermissions(); const { token } = theme.useToken(); const [accounts, setAccounts] = useState([]); - // 工作台已导入账号 ID(localStorage 持久化,默认空白,用户手动导入/移除) - const [workbenchIds, setWorkbenchIds] = useState(() => { - const raw = localStorage.getItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)); - if (!raw) return []; - return raw.split(',').map(Number).filter(Number.isInteger).filter((id) => id >= 1); - }); + // 工作台账号归属由服务端按用户保存;localStorage 仅用于首次升级迁移旧浏览器数据。 + const [workbenchIds, setWorkbenchIds] = useState([]); + const [workbenchReady, setWorkbenchReady] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [goods, setGoods] = useState([]); const [tasks, setTasks] = useState([]); @@ -421,6 +418,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const [confirmFailCountdown, setConfirmFailCountdown] = useState(0); const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null); const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState(null); + const locallyStartedBatchIds = useRef>(new Set()); const autoOpenQrReady = useRef(false); const autoOpenEsportsBindReady = useRef(false); const autoOpenPayReady = useRef(false); @@ -594,7 +592,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) isPeaceHandbook ? douyuApi.listPeaceGoods() : (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()), - douyuApi.listTasks(), + douyuApi.listTasks(handbook), canConfig ? douyuApi.getConfig() : Promise.resolve(null), douyuApi.taskTypes(), ]); @@ -608,14 +606,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value); if (taskResult.status === 'fulfilled') { setTasks(taskResult.value); - // 仅首次加载时屏蔽历史二维码自动弹窗;后续刷新不能把新任务一并标记掉 + // 首次加载只压制历史的复制/失败提示。二维码只会因当前页面创建的批次自动打开。 if (!autoOpenQrReady.current) { for (const task of taskResult.value) { - if (hasBindQrcode(task)) autoOpenedQrTaskIds.current.add(task.id); - if (task.task_type === 'prepare_esports_bind' && task.result?.esports_bind_dialog === true) { - autoOpenedEsportsBindTaskIds.current.add(task.id); - } - if (hasPaymentQrcode(task)) autoOpenedPayTaskIds.current.add(task.id); // 历史兑换成功任务不触发自动复制,避免页面加载时打扰 if (exchangeTaskSucceeded(task)) autoCopiedExchangeTaskIds.current.add(task.id); // 历史确认绑定失败任务不重复弹提示(只提示一次) @@ -637,13 +630,13 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) } finally { setLoading(false); } - }, [accountPage, accountPageSize, accountSearch, canConfig, isEsportsHandbook, isPeaceHandbook, workbenchIds]); + }, [accountPage, accountPageSize, accountSearch, canConfig, handbook, isEsportsHandbook, isPeaceHandbook, workbenchIds]); const loadTasks = useCallback(async () => { if (tasksLoadingRef.current) return; tasksLoadingRef.current = true; try { - const data = await douyuApi.listTasks(); + const data = await douyuApi.listTasks(handbook); setTasks(data); if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) { // 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住 @@ -654,22 +647,43 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) } finally { tasksLoadingRef.current = false; } - }, []); + }, [handbook]); useEffect(() => { loadData(); }, [loadData]); + useEffect(() => { + let cancelled = false; + void douyuApi.listWorkbenchAccounts(handbook) + .then(async ({ account_ids, configured }) => { + if (cancelled) return; + if (configured) { + setWorkbenchIds(account_ids); + localStorage.removeItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)); + return; + } + // 仅在服务端尚未保存时迁移当前浏览器的旧工作台列表,避免覆盖其他设备的配置。 + const legacy = (localStorage.getItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)) || '') + .split(',').map(Number).filter(Number.isInteger).filter((id) => id >= 1); + if (legacy.length === 0) return; + const saved = await douyuApi.updateWorkbenchAccounts(handbook, legacy); + if (!cancelled) { + setWorkbenchIds(saved.account_ids); + localStorage.removeItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)); + } + }) + .catch((error) => { + if (!cancelled) message.error(`加载工作台账号失败: ${getErrorMessage(error)}`); + }) + .finally(() => { + if (!cancelled) setWorkbenchReady(true); + }); + return () => { cancelled = true; }; + }, [handbook]); + useEffect(() => { localStorage.setItem(DOUYU_LAYOUT_MODE_STORAGE_KEY, layoutMode); }, [layoutMode]); - // 工作台账号集合持久化:移除账号后刷新不再出现 - useEffect(() => { - localStorage.setItem( - DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook), - workbenchIds.length ? workbenchIds.join(',') : '', - ); - }, [handbook, workbenchIds]); - // 监听账号表格区域高度变化,动态计算 scroll.y 实现表体内部滚动 useEffect(() => { const node = accountTableAreaRef.current; @@ -770,7 +784,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) useEffect(() => { if (!autoOpenQrReady.current) return; const candidates = visibleTasks - .filter((task) => hasBindQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id)) + .filter((task) => ( + locallyStartedBatchIds.current.has(task.batch_id) + && hasBindQrcode(task) + && !autoOpenedQrTaskIds.current.has(task.id) + )) .sort((a, b) => b.id - a.id); if (candidates.length === 0) return; // 全部加入标签栏但不抢焦点;仅在当前无 active 标签时激活最新(id 最大)的 @@ -783,7 +801,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) if (!autoOpenEsportsBindReady.current || esportsBindTask) return; const nextTask = visibleTasks .filter((task) => ( - task.task_type === 'prepare_esports_bind' + locallyStartedBatchIds.current.has(task.batch_id) + && task.task_type === 'prepare_esports_bind' && task.result?.esports_bind_dialog === true && !autoOpenedEsportsBindTaskIds.current.has(task.id) )) @@ -795,7 +814,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) useEffect(() => { if (!autoOpenPayReady.current || payTask) return; const nextPayTask = visibleTasks - .filter((task) => hasPaymentQrcode(task) && !paymentArrived(task) && !autoOpenedPayTaskIds.current.has(task.id)) + .filter((task) => ( + locallyStartedBatchIds.current.has(task.batch_id) + && hasPaymentQrcode(task) + && !paymentArrived(task) + && !autoOpenedPayTaskIds.current.has(task.id) + )) .sort((a, b) => b.id - a.id)[0]; if (nextPayTask) openPayTask(nextPayTask); }, [openPayTask, payTask, visibleTasks]); @@ -956,9 +980,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const result = await douyuApi.createTasks({ account_ids: ids, task_type: taskType, + handbook_scope: handbook, concurrency, payload, }); + // 只有本页面创建的批次才允许主动弹二维码,防止其他工作台/浏览器的任务打扰当前操作。 + locallyStartedBatchIds.current.add(result.batch_id); setRunningBatchIds((prev) => new Set(prev).add(result.batch_id)); logs.connectBatch(result.batch_id, `/api/douyu/ws/${result.batch_id}`, { clear: true, @@ -972,6 +999,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) }, onTask: (rawTask) => { const task = rawTask as unknown as DouyuTaskItem; + if (task.handbook_scope !== handbook) return; // 已有任务原位更新保持排序;新任务插入头部(与后端 id desc 一致) setTasks((prev) => { if (prev.some((t) => t.id === task.id)) { @@ -1039,9 +1067,21 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) }, [importSearch, importTag]); useEffect(() => { setImportSelectedIds([]); }, [importPage]); - const importAccounts = (ids: number[]) => { + const saveWorkbenchIds = async (nextIds: number[]) => { + const saved = await douyuApi.updateWorkbenchAccounts(handbook, nextIds); + setWorkbenchIds(saved.account_ids); + return saved.account_ids; + }; + + const importAccounts = async (ids: number[]) => { const toImport = importPool.filter((a) => ids.includes(a.id)); - setWorkbenchIds((prev) => [...new Set([...prev, ...toImport.map((a) => a.id)])]); + const nextIds = [...new Set([...workbenchIds, ...toImport.map((a) => a.id)])]; + try { + await saveWorkbenchIds(nextIds); + } catch (error) { + message.error(getErrorMessage(error)); + return; + } setAccounts((prev) => { const existing = new Set(prev.map((a) => a.id)); return [...prev, ...toImport.filter((a) => !existing.has(a.id))]; @@ -1063,7 +1103,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) message.info('当前筛选条件下没有可新增的账号'); return; } - setWorkbenchIds((prev) => [...new Set([...prev, ...imported])]); + await saveWorkbenchIds([...new Set([...workbenchIds, ...imported])]); setImportSelectedIds([]); setImportOpen(false); message.success(`已一键导入 ${imported.length} 个账号`); @@ -1074,9 +1114,14 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) } }; - const removeSelected = () => { + const removeSelected = async () => { const sel = new Set(selectedIds); - setWorkbenchIds((prev) => prev.filter((id) => !sel.has(id))); + try { + await saveWorkbenchIds(workbenchIds.filter((id) => !sel.has(id))); + } catch (error) { + message.error(getErrorMessage(error)); + return; + } setAccounts((prev) => prev.filter((a) => !sel.has(a.id))); setSelectedIds([]); message.success(`已移出 ${sel.size} 个账号`); @@ -1873,7 +1918,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) rowKey="id" size="small" className="douyu-task-record-table" - loading={loading} + loading={loading || !workbenchReady} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} rowSelection={{ selectedRowKeys: selectedIds, @@ -2099,13 +2144,13 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) /> - -