功能: 按用户同步斗鱼工作台
This commit is contained in:
@@ -200,6 +200,37 @@ cmd_migrate_mysql() {
|
|||||||
python scripts/migrate_sqlite_to_mysql.py --source /app/data/web.db
|
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() {
|
cmd_stop() {
|
||||||
COMPOSE=$(detect_compose)
|
COMPOSE=$(detect_compose)
|
||||||
if [ -z "$COMPOSE" ]; then
|
if [ -z "$COMPOSE" ]; then
|
||||||
@@ -216,8 +247,9 @@ cmd_restart() {
|
|||||||
echo "❌ 未检测到 docker compose"
|
echo "❌ 未检测到 docker compose"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
$COMPOSE restart
|
# 重建镜像和应用容器,使新迁移文件与入口迁移逻辑都能生效。
|
||||||
echo "✅ 服务已重启"
|
$COMPOSE up -d --build --force-recreate douyu-login
|
||||||
|
echo "✅ 服务已重建并重启(已自动检查数据库迁移)"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_dev() {
|
cmd_dev() {
|
||||||
@@ -238,6 +270,7 @@ cmd_help() {
|
|||||||
echo " (无参数) 构建并部署"
|
echo " (无参数) 构建并部署"
|
||||||
echo " reset 删除所有数据并重置(需确认)"
|
echo " reset 删除所有数据并重置(需确认)"
|
||||||
echo " migrate-mysql 先启动 MySQL 并迁移 data/web.db"
|
echo " migrate-mysql 先启动 MySQL 并迁移 data/web.db"
|
||||||
|
echo " migrate 仅执行数据库结构迁移"
|
||||||
echo " logs 查看实时日志"
|
echo " logs 查看实时日志"
|
||||||
echo " stop 停止服务"
|
echo " stop 停止服务"
|
||||||
echo " restart 重启服务"
|
echo " restart 重启服务"
|
||||||
@@ -255,6 +288,7 @@ cmd_help() {
|
|||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
reset) cmd_reset ;;
|
reset) cmd_reset ;;
|
||||||
migrate-mysql) cmd_migrate_mysql ;;
|
migrate-mysql) cmd_migrate_mysql ;;
|
||||||
|
migrate) cmd_migrate ;;
|
||||||
logs) cmd_logs ;;
|
logs) cmd_logs ;;
|
||||||
stop) cmd_stop ;;
|
stop) cmd_stop ;;
|
||||||
restart) cmd_restart ;;
|
restart) cmd_restart ;;
|
||||||
|
|||||||
@@ -177,8 +177,6 @@ start_yyb_worker() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
start_yyb_worker
|
|
||||||
|
|
||||||
echo "正在启动本地 MySQL..."
|
echo "正在启动本地 MySQL..."
|
||||||
MYSQL_IMAGE="$MYSQL_IMAGE" \
|
MYSQL_IMAGE="$MYSQL_IMAGE" \
|
||||||
MYSQL_BIND_HOST="$MYSQL_BIND_HOST" \
|
MYSQL_BIND_HOST="$MYSQL_BIND_HOST" \
|
||||||
@@ -199,6 +197,15 @@ for i in $(seq 1 30); do
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
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 "=============================="
|
echo "=============================="
|
||||||
echo " 本地调试模式"
|
echo " 本地调试模式"
|
||||||
@@ -208,6 +215,7 @@ echo " 前端: http://localhost:${FRONTEND_PORT}"
|
|||||||
echo " API 代理: ${BACKEND_PROXY_TARGET}"
|
echo " API 代理: ${BACKEND_PROXY_TARGET}"
|
||||||
echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||||
echo " YYB Worker: ${DEV_YYB_WORKER_URL}"
|
echo " YYB Worker: ${DEV_YYB_WORKER_URL}"
|
||||||
|
echo " 数据库迁移: 已检查至最新版本"
|
||||||
echo " 应用日志: ${APP_LOG}"
|
echo " 应用日志: ${APP_LOG}"
|
||||||
echo " 前端与 Worker 输出: 当前终端"
|
echo " 前端与 Worker 输出: 当前终端"
|
||||||
echo " 退出: Ctrl+C"
|
echo " 退出: Ctrl+C"
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ if [ "$#" -gt 0 ]; then
|
|||||||
exec "$@"
|
exec "$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "正在执行数据库迁移..."
|
||||||
|
python -c 'from web.backend.database import run_migrations; run_migrations()'
|
||||||
|
echo "数据库迁移完成"
|
||||||
|
|
||||||
# Worker 仅监听容器回环地址,Web 后端通过 127.0.0.1 调用。
|
# Worker 仅监听容器回环地址,Web 后端通过 127.0.0.1 调用。
|
||||||
python /app/services/yyb-worker/runtime/scripts/yyb-worker.py \
|
python /app/services/yyb-worker/runtime/scripts/yyb-worker.py \
|
||||||
--host 127.0.0.1 \
|
--host 127.0.0.1 \
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 datetime import datetime, timezone
|
||||||
from sqlalchemy import (
|
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 sqlalchemy.orm import relationship
|
||||||
from .database import Base
|
from .database import Base
|
||||||
@@ -117,6 +117,8 @@ class DouyuTask(Base):
|
|||||||
batch_id = Column(String(64), nullable=False, index=True)
|
batch_id = Column(String(64), nullable=False, index=True)
|
||||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
|
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
|
||||||
task_type = Column(String(64), nullable=False, index=True)
|
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")
|
status = Column(String(32), default="pending")
|
||||||
message = Column(String(512), default="")
|
message = Column(String(512), default="")
|
||||||
result = Column(JSON, nullable=True)
|
result = Column(JSON, nullable=True)
|
||||||
@@ -127,6 +129,35 @@ class DouyuTask(Base):
|
|||||||
account = relationship("Account", back_populates="douyu_tasks")
|
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):
|
class YybRechargeTask(Base):
|
||||||
"""应用宝和平精英点券充值任务。
|
"""应用宝和平精英点券充值任务。
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from sqlalchemy import func, or_
|
|||||||
from sqlalchemy.orm import Session, defer, joinedload
|
from sqlalchemy.orm import Session, defer, joinedload
|
||||||
|
|
||||||
from ..database import get_db
|
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 ..schemas import AccountBulkSelection, AccountBulkTag, AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
|
||||||
from ..deps import get_current_user, require_permission
|
from ..deps import get_current_user, require_permission
|
||||||
from ..permissions import user_has_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(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(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)
|
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(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(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 = (
|
deleted = (
|
||||||
_visible_accounts_query(db, current)
|
_visible_accounts_query(db, current)
|
||||||
.filter(Account.id.in_(ids))
|
.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(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(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,
|
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||||
action="account:delete", target=acc.username))
|
action="account:delete", target=acc.username))
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session, joinedload
|
|||||||
|
|
||||||
from ..database import SessionLocal, get_db
|
from ..database import SessionLocal, get_db
|
||||||
from ..deps import authenticate_websocket, get_current_user, require_permission
|
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 ..permissions import user_has_permission
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
DouyuConfigOut,
|
DouyuConfigOut,
|
||||||
@@ -21,11 +21,13 @@ from ..schemas import (
|
|||||||
DouyuTaskAccountOut,
|
DouyuTaskAccountOut,
|
||||||
DouyuTaskBatchRequest,
|
DouyuTaskBatchRequest,
|
||||||
DouyuTaskOut,
|
DouyuTaskOut,
|
||||||
|
DouyuWorkbenchAccountsUpdate,
|
||||||
DouyuXpdGoodsOut,
|
DouyuXpdGoodsOut,
|
||||||
)
|
)
|
||||||
from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry
|
from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry
|
||||||
from ..services.douyu_service import (
|
from ..services.douyu_service import (
|
||||||
DOUYU_CONFIG_FIELDS,
|
DOUYU_CONFIG_FIELDS,
|
||||||
|
DOUYU_HANDBOOK_TASK_TYPES,
|
||||||
SUPPORTED_DOUYU_TASK_TYPES,
|
SUPPORTED_DOUYU_TASK_TYPES,
|
||||||
apply_douyu_config_defaults,
|
apply_douyu_config_defaults,
|
||||||
cleanup_orphan_douyu_tasks,
|
cleanup_orphan_douyu_tasks,
|
||||||
@@ -242,6 +244,67 @@ def list_task_account_ids(
|
|||||||
return {"account_ids": account_ids, "total": len(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)
|
@router.get("/config", response_model=DouyuConfigOut)
|
||||||
def get_config(
|
def get_config(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -324,6 +387,7 @@ async def create_task_batch(
|
|||||||
db,
|
db,
|
||||||
req.account_ids,
|
req.account_ids,
|
||||||
req.task_type,
|
req.task_type,
|
||||||
|
req.handbook_scope,
|
||||||
current.id,
|
current.id,
|
||||||
req.payload,
|
req.payload,
|
||||||
)
|
)
|
||||||
@@ -355,6 +419,7 @@ async def create_task_batch(
|
|||||||
@router.get("/tasks")
|
@router.get("/tasks")
|
||||||
def list_tasks(
|
def list_tasks(
|
||||||
batch_id: str | None = None,
|
batch_id: str | None = None,
|
||||||
|
handbook_scope: str | None = Query(None, pattern="^(elite|esports|peace)$"),
|
||||||
include_detail: bool = Query(False, description="是否返回完整任务结果(默认否,轮询请保持 false)"),
|
include_detail: bool = Query(False, description="是否返回完整任务结果(默认否,轮询请保持 false)"),
|
||||||
page: int | None = Query(None, ge=1),
|
page: int | None = Query(None, ge=1),
|
||||||
page_size: int = Query(100, ge=1, le=500),
|
page_size: int = Query(100, ge=1, le=500),
|
||||||
@@ -365,6 +430,13 @@ def list_tasks(
|
|||||||
query = _visible_tasks_query(db, current)
|
query = _visible_tasks_query(db, current)
|
||||||
if batch_id:
|
if batch_id:
|
||||||
query = query.filter(DouyuTask.batch_id == 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
|
total = None
|
||||||
if page is not None:
|
if page is not None:
|
||||||
total = query.enable_eagerloads(False).order_by(None).count()
|
total = query.enable_eagerloads(False).order_by(None).count()
|
||||||
|
|||||||
@@ -642,10 +642,16 @@ class DouyuConfigUpdate(BaseModel):
|
|||||||
class DouyuTaskBatchRequest(BaseModel):
|
class DouyuTaskBatchRequest(BaseModel):
|
||||||
account_ids: list[int]
|
account_ids: list[int]
|
||||||
task_type: str
|
task_type: str
|
||||||
|
handbook_scope: str = Field(..., pattern="^(elite|esports|peace)$")
|
||||||
concurrency: int = Field(3, ge=1, le=10)
|
concurrency: int = Field(3, ge=1, le=10)
|
||||||
payload: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class DouyuTaskOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
batch_id: str
|
batch_id: str
|
||||||
@@ -654,6 +660,7 @@ class DouyuTaskOut(BaseModel):
|
|||||||
account_uid: str = ""
|
account_uid: str = ""
|
||||||
account_nickname: str = ""
|
account_nickname: str = ""
|
||||||
task_type: str
|
task_type: str
|
||||||
|
handbook_scope: str = "legacy"
|
||||||
status: str
|
status: str
|
||||||
message: str = ""
|
message: str = ""
|
||||||
result: Optional[dict[str, Any]] = None
|
result: Optional[dict[str, Any]] = None
|
||||||
@@ -673,6 +680,7 @@ class DouyuTaskOut(BaseModel):
|
|||||||
"account_uid": self.account_uid,
|
"account_uid": self.account_uid,
|
||||||
"account_nickname": self.account_nickname,
|
"account_nickname": self.account_nickname,
|
||||||
"task_type": self.task_type,
|
"task_type": self.task_type,
|
||||||
|
"handbook_scope": self.handbook_scope,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
"result": self.result,
|
"result": self.result,
|
||||||
|
|||||||
@@ -48,6 +48,27 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
|
|||||||
"exchange_xpd_goods": "兑换小店商品",
|
"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",
|
DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7",
|
||||||
"rid": "9263298",
|
"rid": "9263298",
|
||||||
@@ -171,12 +192,17 @@ def create_douyu_planned_tasks(
|
|||||||
db: Session,
|
db: Session,
|
||||||
account_ids: list[int],
|
account_ids: list[int],
|
||||||
task_type: str,
|
task_type: str,
|
||||||
|
handbook_scope: str,
|
||||||
created_by: int,
|
created_by: int,
|
||||||
payload: dict | None = None,
|
payload: dict | None = None,
|
||||||
) -> tuple[str, int]:
|
) -> tuple[str, int]:
|
||||||
"""创建斗鱼任务记录,等待后台执行器消费。"""
|
"""创建斗鱼任务记录,等待后台执行器消费。"""
|
||||||
if task_type not in SUPPORTED_DOUYU_TASK_TYPES:
|
if task_type not in SUPPORTED_DOUYU_TASK_TYPES:
|
||||||
raise ValueError("不支持的任务类型")
|
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)
|
accounts = visible_douyu_task_accounts(db, account_ids)
|
||||||
if task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"} and accounts:
|
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,
|
batch_id=batch_id,
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
|
handbook_scope=handbook_scope,
|
||||||
status="planned",
|
status="planned",
|
||||||
message="任务已创建,等待执行",
|
message="任务已创建,等待执行",
|
||||||
result={"payload": payload} if payload else None,
|
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_uid": (account.uid if account else "") or "",
|
||||||
"account_nickname": (account.nickname if account else "") or "",
|
"account_nickname": (account.nickname if account else "") or "",
|
||||||
"task_type": task.task_type or "",
|
"task_type": task.task_type or "",
|
||||||
|
"handbook_scope": task.handbook_scope or "legacy",
|
||||||
"status": task.status or "",
|
"status": task.status or "",
|
||||||
"message": task.message or "",
|
"message": task.message or "",
|
||||||
"result": sanitize_douyu_task_result(
|
"result": sanitize_douyu_task_result(
|
||||||
|
|||||||
@@ -27,8 +27,18 @@ export const douyuApi = {
|
|||||||
listPeaceGoods: () => api.get<DouyuXpdGoodsItem[], DouyuXpdGoodsItem[]>('/douyu/xpd-goods'),
|
listPeaceGoods: () => api.get<DouyuXpdGoodsItem[], DouyuXpdGoodsItem[]>('/douyu/xpd-goods'),
|
||||||
createTasks: (data: DouyuTaskBatchRequest) =>
|
createTasks: (data: DouyuTaskBatchRequest) =>
|
||||||
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
|
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
|
||||||
listTasks: (batchId?: string) =>
|
listTasks: (handbookScope: 'elite' | 'esports' | 'peace', batchId?: string) =>
|
||||||
api.get<DouyuTaskItem[], DouyuTaskItem[]>('/douyu/tasks', { params: batchId ? { batch_id: batchId } : {} }),
|
api.get<DouyuTaskItem[], DouyuTaskItem[]>('/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 }) =>
|
listTasksPaged: (params: PageParams & { batch_id?: string; include_detail?: boolean }) =>
|
||||||
api.get<PaginatedResponse<DouyuTaskItem>, PaginatedResponse<DouyuTaskItem>>('/douyu/tasks', { params }),
|
api.get<PaginatedResponse<DouyuTaskItem>, PaginatedResponse<DouyuTaskItem>>('/douyu/tasks', { params }),
|
||||||
getTask: (taskId: number) => api.get<DouyuTaskItem, DouyuTaskItem>(`/douyu/tasks/${taskId}`),
|
getTask: (taskId: number) => api.get<DouyuTaskItem, DouyuTaskItem>(`/douyu/tasks/${taskId}`),
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export interface DouyuConfig {
|
|||||||
export interface DouyuTaskBatchRequest {
|
export interface DouyuTaskBatchRequest {
|
||||||
account_ids: number[];
|
account_ids: number[];
|
||||||
task_type: string;
|
task_type: string;
|
||||||
|
handbook_scope: 'elite' | 'esports' | 'peace';
|
||||||
concurrency?: number;
|
concurrency?: number;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -365,6 +366,7 @@ export interface DouyuTaskItem {
|
|||||||
account_uid: string;
|
account_uid: string;
|
||||||
account_nickname: string;
|
account_nickname: string;
|
||||||
task_type: string;
|
task_type: string;
|
||||||
|
handbook_scope: 'elite' | 'esports' | 'peace' | 'legacy';
|
||||||
status: string;
|
status: string;
|
||||||
message: string;
|
message: string;
|
||||||
result: Record<string, unknown> | null;
|
result: Record<string, unknown> | null;
|
||||||
|
|||||||
@@ -358,12 +358,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
|
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
|
||||||
// 工作台已导入账号 ID(localStorage 持久化,默认空白,用户手动导入/移除)
|
// 工作台账号归属由服务端按用户保存;localStorage 仅用于首次升级迁移旧浏览器数据。
|
||||||
const [workbenchIds, setWorkbenchIds] = useState<number[]>(() => {
|
const [workbenchIds, setWorkbenchIds] = useState<number[]>([]);
|
||||||
const raw = localStorage.getItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook));
|
const [workbenchReady, setWorkbenchReady] = useState(false);
|
||||||
if (!raw) return [];
|
|
||||||
return raw.split(',').map(Number).filter(Number.isInteger).filter((id) => id >= 1);
|
|
||||||
});
|
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [goods, setGoods] = useState<DouyuGoodsItem[]>([]);
|
const [goods, setGoods] = useState<DouyuGoodsItem[]>([]);
|
||||||
const [tasks, setTasks] = useState<DouyuTaskItem[]>([]);
|
const [tasks, setTasks] = useState<DouyuTaskItem[]>([]);
|
||||||
@@ -421,6 +418,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
const [confirmFailCountdown, setConfirmFailCountdown] = useState(0);
|
const [confirmFailCountdown, setConfirmFailCountdown] = useState(0);
|
||||||
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
|
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
|
||||||
const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState<number | null>(null);
|
const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState<number | null>(null);
|
||||||
|
const locallyStartedBatchIds = useRef<Set<string>>(new Set());
|
||||||
const autoOpenQrReady = useRef(false);
|
const autoOpenQrReady = useRef(false);
|
||||||
const autoOpenEsportsBindReady = useRef(false);
|
const autoOpenEsportsBindReady = useRef(false);
|
||||||
const autoOpenPayReady = useRef(false);
|
const autoOpenPayReady = useRef(false);
|
||||||
@@ -594,7 +592,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
isPeaceHandbook
|
isPeaceHandbook
|
||||||
? douyuApi.listPeaceGoods()
|
? douyuApi.listPeaceGoods()
|
||||||
: (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()),
|
: (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()),
|
||||||
douyuApi.listTasks(),
|
douyuApi.listTasks(handbook),
|
||||||
canConfig ? douyuApi.getConfig() : Promise.resolve(null),
|
canConfig ? douyuApi.getConfig() : Promise.resolve(null),
|
||||||
douyuApi.taskTypes(),
|
douyuApi.taskTypes(),
|
||||||
]);
|
]);
|
||||||
@@ -608,14 +606,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
|
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
|
||||||
if (taskResult.status === 'fulfilled') {
|
if (taskResult.status === 'fulfilled') {
|
||||||
setTasks(taskResult.value);
|
setTasks(taskResult.value);
|
||||||
// 仅首次加载时屏蔽历史二维码自动弹窗;后续刷新不能把新任务一并标记掉
|
// 首次加载只压制历史的复制/失败提示。二维码只会因当前页面创建的批次自动打开。
|
||||||
if (!autoOpenQrReady.current) {
|
if (!autoOpenQrReady.current) {
|
||||||
for (const task of taskResult.value) {
|
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);
|
if (exchangeTaskSucceeded(task)) autoCopiedExchangeTaskIds.current.add(task.id);
|
||||||
// 历史确认绑定失败任务不重复弹提示(只提示一次)
|
// 历史确认绑定失败任务不重复弹提示(只提示一次)
|
||||||
@@ -637,13 +630,13 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [accountPage, accountPageSize, accountSearch, canConfig, isEsportsHandbook, isPeaceHandbook, workbenchIds]);
|
}, [accountPage, accountPageSize, accountSearch, canConfig, handbook, isEsportsHandbook, isPeaceHandbook, workbenchIds]);
|
||||||
|
|
||||||
const loadTasks = useCallback(async () => {
|
const loadTasks = useCallback(async () => {
|
||||||
if (tasksLoadingRef.current) return;
|
if (tasksLoadingRef.current) return;
|
||||||
tasksLoadingRef.current = true;
|
tasksLoadingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const data = await douyuApi.listTasks();
|
const data = await douyuApi.listTasks(handbook);
|
||||||
setTasks(data);
|
setTasks(data);
|
||||||
if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) {
|
if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) {
|
||||||
// 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住
|
// 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住
|
||||||
@@ -654,22 +647,43 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
} finally {
|
} finally {
|
||||||
tasksLoadingRef.current = false;
|
tasksLoadingRef.current = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [handbook]);
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
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(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(DOUYU_LAYOUT_MODE_STORAGE_KEY, layoutMode);
|
localStorage.setItem(DOUYU_LAYOUT_MODE_STORAGE_KEY, layoutMode);
|
||||||
}, [layoutMode]);
|
}, [layoutMode]);
|
||||||
|
|
||||||
// 工作台账号集合持久化:移除账号后刷新不再出现
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(
|
|
||||||
DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook),
|
|
||||||
workbenchIds.length ? workbenchIds.join(',') : '',
|
|
||||||
);
|
|
||||||
}, [handbook, workbenchIds]);
|
|
||||||
|
|
||||||
// 监听账号表格区域高度变化,动态计算 scroll.y 实现表体内部滚动
|
// 监听账号表格区域高度变化,动态计算 scroll.y 实现表体内部滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = accountTableAreaRef.current;
|
const node = accountTableAreaRef.current;
|
||||||
@@ -770,7 +784,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoOpenQrReady.current) return;
|
if (!autoOpenQrReady.current) return;
|
||||||
const candidates = visibleTasks
|
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);
|
.sort((a, b) => b.id - a.id);
|
||||||
if (candidates.length === 0) return;
|
if (candidates.length === 0) return;
|
||||||
// 全部加入标签栏但不抢焦点;仅在当前无 active 标签时激活最新(id 最大)的
|
// 全部加入标签栏但不抢焦点;仅在当前无 active 标签时激活最新(id 最大)的
|
||||||
@@ -783,7 +801,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
if (!autoOpenEsportsBindReady.current || esportsBindTask) return;
|
if (!autoOpenEsportsBindReady.current || esportsBindTask) return;
|
||||||
const nextTask = visibleTasks
|
const nextTask = visibleTasks
|
||||||
.filter((task) => (
|
.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
|
&& task.result?.esports_bind_dialog === true
|
||||||
&& !autoOpenedEsportsBindTaskIds.current.has(task.id)
|
&& !autoOpenedEsportsBindTaskIds.current.has(task.id)
|
||||||
))
|
))
|
||||||
@@ -795,7 +814,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoOpenPayReady.current || payTask) return;
|
if (!autoOpenPayReady.current || payTask) return;
|
||||||
const nextPayTask = visibleTasks
|
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];
|
.sort((a, b) => b.id - a.id)[0];
|
||||||
if (nextPayTask) openPayTask(nextPayTask);
|
if (nextPayTask) openPayTask(nextPayTask);
|
||||||
}, [openPayTask, payTask, visibleTasks]);
|
}, [openPayTask, payTask, visibleTasks]);
|
||||||
@@ -956,9 +980,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
const result = await douyuApi.createTasks({
|
const result = await douyuApi.createTasks({
|
||||||
account_ids: ids,
|
account_ids: ids,
|
||||||
task_type: taskType,
|
task_type: taskType,
|
||||||
|
handbook_scope: handbook,
|
||||||
concurrency,
|
concurrency,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
|
// 只有本页面创建的批次才允许主动弹二维码,防止其他工作台/浏览器的任务打扰当前操作。
|
||||||
|
locallyStartedBatchIds.current.add(result.batch_id);
|
||||||
setRunningBatchIds((prev) => new Set(prev).add(result.batch_id));
|
setRunningBatchIds((prev) => new Set(prev).add(result.batch_id));
|
||||||
logs.connectBatch(result.batch_id, `/api/douyu/ws/${result.batch_id}`, {
|
logs.connectBatch(result.batch_id, `/api/douyu/ws/${result.batch_id}`, {
|
||||||
clear: true,
|
clear: true,
|
||||||
@@ -972,6 +999,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
},
|
},
|
||||||
onTask: (rawTask) => {
|
onTask: (rawTask) => {
|
||||||
const task = rawTask as unknown as DouyuTaskItem;
|
const task = rawTask as unknown as DouyuTaskItem;
|
||||||
|
if (task.handbook_scope !== handbook) return;
|
||||||
// 已有任务原位更新保持排序;新任务插入头部(与后端 id desc 一致)
|
// 已有任务原位更新保持排序;新任务插入头部(与后端 id desc 一致)
|
||||||
setTasks((prev) => {
|
setTasks((prev) => {
|
||||||
if (prev.some((t) => t.id === task.id)) {
|
if (prev.some((t) => t.id === task.id)) {
|
||||||
@@ -1039,9 +1067,21 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
}, [importSearch, importTag]);
|
}, [importSearch, importTag]);
|
||||||
useEffect(() => { setImportSelectedIds([]); }, [importPage]);
|
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));
|
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) => {
|
setAccounts((prev) => {
|
||||||
const existing = new Set(prev.map((a) => a.id));
|
const existing = new Set(prev.map((a) => a.id));
|
||||||
return [...prev, ...toImport.filter((a) => !existing.has(a.id))];
|
return [...prev, ...toImport.filter((a) => !existing.has(a.id))];
|
||||||
@@ -1063,7 +1103,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
message.info('当前筛选条件下没有可新增的账号');
|
message.info('当前筛选条件下没有可新增的账号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setWorkbenchIds((prev) => [...new Set([...prev, ...imported])]);
|
await saveWorkbenchIds([...new Set([...workbenchIds, ...imported])]);
|
||||||
setImportSelectedIds([]);
|
setImportSelectedIds([]);
|
||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
message.success(`已一键导入 ${imported.length} 个账号`);
|
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);
|
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)));
|
setAccounts((prev) => prev.filter((a) => !sel.has(a.id)));
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
message.success(`已移出 ${sel.size} 个账号`);
|
message.success(`已移出 ${sel.size} 个账号`);
|
||||||
@@ -1873,7 +1918,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
className="douyu-task-record-table"
|
className="douyu-task-record-table"
|
||||||
loading={loading}
|
loading={loading || !workbenchReady}
|
||||||
locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }}
|
locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedIds,
|
selectedRowKeys: selectedIds,
|
||||||
@@ -2099,13 +2144,13 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap style={{ width: '100%', justifyContent: 'flex-start' }}>
|
<Space wrap style={{ width: '100%', justifyContent: 'flex-start' }}>
|
||||||
<Button onClick={() => importAccounts(importPool.map((a) => a.id))}>
|
<Button onClick={() => void importAccounts(importPool.map((a) => a.id))}>
|
||||||
导入本页 ({importPool.length})
|
导入本页 ({importPool.length})
|
||||||
</Button>
|
</Button>
|
||||||
<Button loading={importAllLoading} onClick={() => void importAllAccounts()}>
|
<Button loading={importAllLoading} onClick={() => void importAllAccounts()}>
|
||||||
一键导入全部
|
一键导入全部
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" onClick={() => importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}>
|
<Button type="primary" onClick={() => void importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}>
|
||||||
导入选中 ({importSelectedIds.length})
|
导入选中 ({importSelectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
Reference in New Issue
Block a user