Files

969 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""斗鱼活动任务路由。"""
from __future__ import annotations
import asyncio
import threading
from datetime import UTC, datetime
from typing import Any, cast
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Request,
WebSocket,
WebSocketDisconnect,
)
from loguru import logger
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session, joinedload
from core.douyu import (
FishFinRechargeClient,
FishFinRechargeConfig,
FishFinRechargeConfigError,
)
from ..database import SessionLocal, get_db
from ..deps import authenticate_websocket, get_current_user, require_permission
from ..models import (
Account,
AccountLease,
DouyuConfig,
DouyuEsportsGoodsSnapshot,
DouyuGoodsSnapshot,
DouyuTask,
DouyuWorkbench,
DouyuWorkbenchAccount,
DouyuXpdGoodsSnapshot,
User,
)
from ..permissions import user_has_permission
from ..schemas import (
DouyuConfigOut,
DouyuConfigUpdate,
DouyuGoodsOut,
DouyuTaskAccountOut,
DouyuTaskBatchRequest,
DouyuTaskOut,
DouyuWorkbenchAccountsUpdate,
DouyuXpdGoodsOut,
)
from ..services.account_binding import bind_payload
from ..services.audit_service import record_audit
from ..services.business_events import record_recharge
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,
cookie_holding_filter,
create_douyu_planned_tasks,
douyu_config_value,
douyu_task_payload,
ensure_douyu_config,
)
router = APIRouter(prefix="/api/douyu", tags=["斗鱼活动"])
def _supplier_response_value(payload: dict, *keys: str):
"""兼容供应商把订单字段放在根节点、data 或 result 中。"""
data_raw = payload.get("data")
data = cast(dict[str, Any], data_raw) if isinstance(data_raw, dict) else {}
result_raw = payload.get("result")
result = cast(dict[str, Any], result_raw) if isinstance(result_raw, dict) else {}
for source in (payload, data, result):
for key in keys:
if source.get(key) is not None:
return source[key]
return None
@router.post("/supplier-recharge/callback")
async def supplier_recharge_callback(request: Request, db: Session = Depends(get_db)):
"""接收供应商直充终态通知,验签后按 out_order_id 幂等更新任务。"""
try:
payload = await request.json()
except ValueError as exc:
raise HTTPException(status_code=400, detail="供应商回调不是 JSON") from exc
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="供应商回调格式无效")
try:
client = FishFinRechargeClient(FishFinRechargeConfig.from_env())
except FishFinRechargeConfigError as exc:
logger.error("[douyu] 供应商直充回调配置无效: {}", exc)
raise HTTPException(status_code=503, detail="供应商直充回调未配置") from exc
if not client.verify_response_sign(payload, "POST"):
logger.warning("[douyu] 供应商直充回调验签失败: keys={}", sorted(payload))
raise HTTPException(status_code=401, detail="供应商回调签名无效")
out_order_id = str(_supplier_response_value(payload, "out_order_id") or "").strip()
if not out_order_id:
raise HTTPException(status_code=400, detail="供应商回调缺少 out_order_id")
task = (
db.query(DouyuTask)
.filter(
DouyuTask.supplier_out_order_id == out_order_id,
DouyuTask.task_type == "create_gold_qr",
)
.first()
)
if not task:
raise HTTPException(status_code=404, detail="供应商回调订单不存在")
status = DouyuBatchRunner._to_int(
_supplier_response_value(payload, "order_status", "orderStatus")
)
result = dict(task.result) if isinstance(task.result, dict) else {}
result.update(
{
"out_order_id": out_order_id,
"order_id": _supplier_response_value(payload, "order_id", "orderId")
or result.get("order_id"),
"supplier_order_status": status,
"supplier_order": DouyuBatchRunner._supplier_result(payload),
"supplier_callback_received": True,
}
)
# 这笔充值到没到账,用**回调自己说的终态**判断,而不是「任务状态有没有被改」。
# 否则会漏掉一个真实黑洞:runner 轮询超时先判了 failed(gold.py 末尾),
# 回调随后才到达成功 —— 守卫会跳过状态更新,这笔真金白银永远不进时间线。
recharge_confirmed = status == 2
if task.status not in {"success", "failed", "stopped"}:
if status == 2:
task.status = "success"
task.message = "供应商直充成功(异步通知)"
task.finished_at = datetime.now(UTC)
if task.account:
task.account.updated_at = datetime.now(UTC)
elif status in {3, 4}:
reason = str(
_supplier_response_value(payload, "fail_reason", "message", "msg")
or "供应商直充失败"
)
task.status = "failed"
task.message = reason[:512]
task.finished_at = datetime.now(UTC)
else:
task.status = "running"
task.message = (
f"供应商直充订单处理中(状态 {status if status is not None else '-'})"
)
task.result = result
callback_success = status not in {3, 4}
# 回调说成功了就补一条充值事件。幂等键与 runner 那条**完全一致**
# (business_events.task_ref),所以「runner 也观测到了」时不会重复记账;
# 而「runner 已判超时/失败、回调才到」这个黑洞则能被补上。
if recharge_confirmed and task.account is not None:
record_recharge(
db,
task.account,
task_id=task.id,
amount=_to_int_or_none(result.get("buy_num")),
order_id=str(result.get("out_order_id") or ""),
supplier_order_id=str(result.get("order_id") or ""),
channel="supplier_api",
douyu_uid=str(result.get("douyu_uid") or ""),
balance_after=task.account.gold_balance,
confirmed=False,
)
record_audit(
db,
None,
action="recharge:douyu:callback",
target=f"supplier_order:{out_order_id}",
detail={
"out_order_id": out_order_id,
"task_id": task.id,
"order_id": result.get("order_id"),
"supplier_order_status": status,
"task_status": task.status,
"message": task.message,
},
success=callback_success,
)
db.commit()
logger.info(
"[douyu] 供应商直充回调已处理: out_order_id={} status={}", out_order_id, status
)
acknowledgement = {"code": 200, "message": "success"}
acknowledgement["sign"] = client.sign(acknowledgement, "POST")
return acknowledgement
def _can_view_all(user: User) -> bool:
return user_has_permission(user, "account:view_all")
def _visible_task_accounts_query(db: Session, current: User):
"""返回当前用户可用于斗鱼任务的账号查询。
只要账号还持有 Cookie 就可见:排队中的普通登录(pending/running)和
重登失败(relogin_failed)都会保留旧 CK,不能让账号从工作台消失。
"""
cookie_ids = select(Account.id).where(cookie_holding_filter())
query = (
db.query(Account)
.options(joinedload(Account.assigned_user))
.filter(Account.id.in_(cookie_ids))
)
if _can_view_all(current):
return query
if user_has_permission(current, "account:view_assigned"):
return query.filter(Account.assigned_to == current.id)
raise HTTPException(status_code=403, detail="无权查看斗鱼账号")
def _visible_tasks_query(db: Session, current: User):
"""返回当前用户可查看的斗鱼任务查询。"""
# 任务列表只展示账号识别信息,避免 joinedload 把密码、邮箱等加密大字段带出。
account_loader = joinedload(DouyuTask.account).load_only(
Account.id,
Account.username,
Account.uid,
Account.nickname,
Account.assigned_to,
)
query = db.query(DouyuTask).options(account_loader)
if _can_view_all(current):
return query
if user_has_permission(current, "account:view_assigned"):
# 彻底消除全表 filesort:直接通过 douyu_tasks 上的 account_assigned_to 复合索引倒序定位
return query.filter(DouyuTask.account_assigned_to == current.id)
raise HTTPException(status_code=403, detail="无权查看斗鱼任务")
def _require_task_account_access(
db: Session, current: User, account_ids: list[int]
) -> None:
"""确保任务只会提交到当前用户可操作的账号。
可操作 = **我名下** ∪ **我当前持有有效租约**。
后半条是账号池加的(归属是管理员/运营的维护责任,客服只按需租用)——
旧路径「名下账号」一字未改,这里只做**并集**。
"""
requested_ids = set(account_ids)
query = db.query(Account.id).filter(Account.id.in_(requested_ids))
if not _can_view_all(current):
if not user_has_permission(current, "account:view_assigned"):
raise HTTPException(status_code=403, detail="无权操作斗鱼账号")
my_leases = select(AccountLease.account_id).where(
AccountLease.holder_user_id == current.id,
AccountLease.status == "active",
)
query = query.filter(
or_(Account.assigned_to == current.id, Account.id.in_(my_leases))
)
allowed_ids = {account_id for (account_id,) in query.all()}
if allowed_ids != requested_ids:
raise HTTPException(status_code=403, detail="包含无权操作的斗鱼账号")
def _require_batch_owner(db: Session, current: User, batch_id: str) -> None:
"""客服只能停止或订阅自己创建的任务批次。"""
if _can_view_all(current):
return
exists = (
db.query(DouyuTask.id)
.filter(DouyuTask.batch_id == batch_id, DouyuTask.created_by == current.id)
.first()
)
if not exists:
raise HTTPException(status_code=403, detail="无权操作该斗鱼任务批次")
def start_douyu_batch(
*,
batch_id: str,
task_type: str,
payload: dict,
concurrency: int,
) -> None:
"""在线程里启动一个批次的执行器(**必须在有事件循环的 async 路由里调用**)。
抽出来是因为账号池也要起批次:两条路径的执行方式必须完全一致 ——
复制一份的话,将来改了注册表或线程模型就会出现"老路径好了、池路径没好"。
⚠️ `DouyuBatchRunner` 按 `batch_id` 取**全部 planned 任务、不看 `task_type`**,
再按每条任务自己的类型分发。所以**一个批次里可以放不同类型的任务**;
但它们会被并发执行(`concurrency` 个 worker),顺序没有保证 ——
需要严格先后(充值 → 赠令 → 兑换)时只能把 `concurrency` 设成 1。
"""
log_queue = asyncio.Queue()
loop = asyncio.get_running_loop()
thread_db = SessionLocal()
runner = DouyuBatchRunner(
db=thread_db,
batch_id=batch_id,
task_type=task_type,
payload=payload,
log_queue=log_queue,
loop=loop,
concurrency=concurrency,
)
douyu_batch_registry.register(batch_id, log_queue, loop, runner)
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
def _parse_account_names(raw_names: str) -> list[str]:
"""解析前端粘贴的 Excel 账号名,每行一个并去重。"""
names: list[str] = []
seen: set[str] = set()
for value in (
(raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
):
name = value.strip()
if name and name not in seen:
names.append(name)
seen.add(name)
return names
def _apply_task_account_filters(
query,
*,
search: str = "",
tag: str = "",
account_names: str = "",
ids: str = "",
):
"""按工作台导入弹窗的搜索、标签、多行账号名筛选任务账号。"""
id_list = [int(item) for item in ids.split(",") if item.strip().isdigit()]
if id_list:
query = query.filter(Account.id.in_(id_list))
tag_text = (tag or "").strip()
if tag_text:
query = query.filter(Account.tag == tag_text)
selected_names = _parse_account_names(account_names)
if selected_names:
query = query.filter(Account.username.in_(selected_names))
search_text = (search or "").strip()
if search_text:
pattern = f"%{search_text}%"
query = query.filter(
or_(
Account.username.ilike(pattern),
Account.uid.ilike(pattern),
Account.nickname.ilike(pattern),
Account.tag.ilike(pattern),
Account.game_name.ilike(pattern),
Account.esports_game_name.ilike(pattern),
Account.xpd_game_name.ilike(pattern),
)
)
return query, selected_names
def _order_task_accounts(query, selected_names: list[str]):
"""自定义账号名按粘贴顺序排序,未指定时保持最新账号优先。"""
if selected_names:
input_order = case(
{name: index for index, name in enumerate(selected_names)},
value=Account.username,
else_=len(selected_names),
)
return query.order_by(input_order, Account.id.desc())
return query.order_by(Account.id.desc())
def _account_out(account: Account) -> DouyuTaskAccountOut:
return DouyuTaskAccountOut(
id=account.id,
username=account.username,
uid=account.uid or "",
nickname=account.nickname or "",
tag=account.tag or "",
points=account.points,
game_name=account.game_name or "",
game_channel=account.game_channel or "",
gold_balance=account.gold_balance,
exchange_balance=account.exchange_balance,
**bind_payload(account),
change_role_wait_time=account.change_role_wait_time,
elite_opened=account.elite_opened,
elite_expire_time=account.elite_expire_time,
esports_points=account.esports_points,
esports_game_name=account.esports_game_name or "",
esports_game_channel=account.esports_game_channel or "",
esports_change_role_wait_time=account.esports_change_role_wait_time,
esports_can_change_time=account.esports_can_change_time,
xpd_game_name=account.xpd_game_name or "",
xpd_openid=account.xpd_openid or "",
xpd_role_id=account.xpd_role_id or "",
xpd_plat_id=account.xpd_plat_id,
xpd_area_id=account.xpd_area_id,
xpd_balance=account.xpd_balance,
xpd_fragments=account.xpd_fragments,
assigned_to=account.assigned_to,
assigned_username=account.assigned_user.username
if account.assigned_user
else None,
)
def _task_out(task: DouyuTask, *, include_detail: bool = False) -> DouyuTaskOut:
return DouyuTaskOut(**douyu_task_payload(task, include_detail=include_detail))
def _config_out(config: DouyuConfig) -> DouyuConfigOut:
text = lambda field: str(douyu_config_value(field, getattr(config, field)))
number = lambda field: int(douyu_config_value(field, getattr(config, field)))
boolean = lambda field: bool(douyu_config_value(field, getattr(config, field)))
return DouyuConfigOut(
manual_id=text("manual_id"),
rid=text("rid"),
bind_act_alias=text("bind_act_alias"),
confirm_act_alias=text("confirm_act_alias"),
legacy_act_alias=text("legacy_act_alias"),
room_id=text("room_id"),
elite_amount=number("elite_amount"),
esports_manual_id=text("esports_manual_id"),
esports_act_alias=text("esports_act_alias"),
esports_amount=number("esports_amount"),
esports_chicken_gift_id=text("esports_chicken_gift_id"),
esports_chicken_skin_id=text("esports_chicken_skin_id"),
esports_firework_gift_id=text("esports_firework_gift_id"),
esports_firework_skin_id=text("esports_firework_skin_id"),
peace_enabled=boolean("peace_enabled"),
xpd_act_alias=text("xpd_act_alias"),
xpd_act_id=text("xpd_act_id"),
xpd_rid=text("xpd_rid"),
gold_pay_type=number("gold_pay_type"),
gold_recharge_channel=text("gold_recharge_channel"),
gold_api_product_id=text("gold_api_product_id"),
gold_api_account_template_name=text("gold_api_account_template_name"),
gift_id=text("gift_id"),
skin_id=text("skin_id"),
updated_at=config.updated_at,
)
@router.get("/task-types")
def task_types(current: User = Depends(require_permission("douyu:task"))):
"""返回斗鱼任务类型。"""
return SUPPORTED_DOUYU_TASK_TYPES
@router.get("/accounts")
def list_task_accounts(
search: str = Query(""),
tag: str = Query(""),
ids: str = Query(""),
account_names: str = Query(""),
page: int | None = Query(None, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""查看可执行斗鱼任务的账号(必须有成功 Cookie)。
tag 按账号标签精确筛选;ids 为逗号分隔的账号 ID,用于工作台按已导入账号过滤;
account_names 为多行斗鱼账户名(每行一个),按粘贴顺序精确匹配;为空时返回全部。
"""
query, selected_names = _apply_task_account_filters(
_visible_task_accounts_query(db, current),
search=search,
tag=tag,
account_names=account_names,
ids=ids,
)
total = None
if page is not None:
total = query.order_by(None).count()
query = _order_task_accounts(query, selected_names)
if page is not None:
query = query.offset((page - 1) * page_size).limit(page_size)
else:
query = query.limit(500)
result = [_account_out(account) for account in query.all()]
if page is not None:
return {
"items": result,
"total": total or 0,
"page": page,
"page_size": page_size,
}
return result
@router.get("/accounts/ids")
def list_task_account_ids(
search: str = Query(""),
tag: str = Query(""),
account_names: str = Query(""),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前用户可导入工作台的全部账号 ID,不受列表分页限制。"""
query, selected_names = _apply_task_account_filters(
_visible_task_accounts_query(db, current),
search=search,
tag=tag,
account_names=account_names,
)
account_ids = [
account_id
for (account_id,) in _order_task_accounts(
query.enable_eagerloads(False).with_entities(Account.id),
selected_names,
).all()
]
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(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),
current: User = Depends(require_permission("douyu:config")),
):
"""获取斗鱼活动配置。"""
return _config_out(ensure_douyu_config(db))
@router.get("/recharge-channel")
def get_recharge_channel(
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""返回当前鱼翅充值渠道,不暴露供应商商品等配置细节。"""
config = ensure_douyu_config(db)
return {
"gold_recharge_channel": douyu_config_value(
"gold_recharge_channel",
config.gold_recharge_channel,
),
}
@router.put("/config", response_model=DouyuConfigOut)
def update_config(
req: DouyuConfigUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:config")),
):
"""更新斗鱼活动配置。"""
config = ensure_douyu_config(db)
changed_fields = []
for field in DOUYU_CONFIG_FIELDS:
value = getattr(req, field, None)
if value is None:
continue
setattr(config, field, value.strip() if isinstance(value, str) else value)
changed_fields.append(field)
apply_douyu_config_defaults(config)
config.updated_at = datetime.now(UTC)
recharge_fields = [field for field in changed_fields if field.startswith("gold_")]
if recharge_fields:
record_audit(
db,
current,
action="recharge:douyu:config",
target="douyu_config",
detail={"changed_fields": recharge_fields},
)
db.commit()
db.refresh(config)
return _config_out(config)
@router.get("/goods", response_model=list[DouyuGoodsOut])
def list_goods(
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""查看已缓存的斗鱼商品快照。"""
rows = db.query(DouyuGoodsSnapshot).order_by(DouyuGoodsSnapshot.id.asc()).all()
return rows
@router.get("/esports-goods", response_model=list[DouyuGoodsOut])
def list_esports_goods(
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""查看已缓存的电竞手册皮肤商城商品。"""
rows = (
db.query(DouyuEsportsGoodsSnapshot)
.order_by(DouyuEsportsGoodsSnapshot.id.asc())
.all()
)
return rows
@router.get("/xpd-goods", response_model=list[DouyuXpdGoodsOut])
def list_xpd_goods(
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""查看已缓存的和平小店商品。"""
rows = (
db.query(DouyuXpdGoodsSnapshot).order_by(DouyuXpdGoodsSnapshot.id.asc()).all()
)
return rows
@router.post("/tasks/batch")
async def create_task_batch(
req: DouyuTaskBatchRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""创建斗鱼任务记录并启动后台执行器。"""
if not req.account_ids:
raise HTTPException(status_code=400, detail="请选择斗鱼账号")
_require_task_account_access(db, current, req.account_ids)
cleanup_orphan_douyu_tasks(
db,
active_batch_ids=douyu_batch_registry.active_ids(),
statuses=("pending", "running"),
message="任务已中断(无执行器接管)",
)
try:
batch_id, count = create_douyu_planned_tasks(
db,
req.account_ids,
req.task_type,
req.handbook_scope,
current.id,
req.payload,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if count == 0:
raise HTTPException(
status_code=400, detail="没有可执行的斗鱼账号,请先登录获取 Cookie"
)
if req.task_type == "create_gold_qr":
config = ensure_douyu_config(db)
recharge_channel = str(
douyu_config_value("gold_recharge_channel", config.gold_recharge_channel)
).strip()
payment_method = (
"API 直充支付" if recharge_channel == "supplier_api" else "微信扫码支付"
)
recharge_accounts = (
db.query(DouyuTask, Account)
.join(Account, Account.id == DouyuTask.account_id)
.filter(
DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr"
)
.order_by(DouyuTask.id.asc())
.limit(100)
.all()
)
record_audit(
db,
current,
action="recharge:douyu:create",
target=f"douyu_batch:{batch_id}",
detail={
"batch_id": batch_id,
"task_type": req.task_type,
"count": count,
"account_count": len(req.account_ids),
"handbook_scope": req.handbook_scope,
"recharge_channel": recharge_channel,
"payment_method": payment_method,
# 仅记录充值身份,禁止在审计中写入 Cookie、密码等凭据。
"recharge_accounts": [
{
"task_id": task.id,
"username": account.username,
"douyu_uid": account.uid or "",
"douyu_nickname": account.nickname or "",
}
for task, account in recharge_accounts
],
"recharge_accounts_truncated": count > len(recharge_accounts),
},
)
db.commit()
start_douyu_batch(
batch_id=batch_id,
task_type=req.task_type,
payload=req.payload,
concurrency=req.concurrency,
)
return {"batch_id": batch_id, "count": count, "success": True}
@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),
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""查看斗鱼任务记录,默认只返回最近 100 条。"""
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)
.with_entities(func.count(DouyuTask.id))
.scalar()
or 0
)
query = query.order_by(DouyuTask.id.desc())
if page is not None:
query = query.offset((page - 1) * page_size).limit(page_size)
else:
query = query.limit(page_size)
result = [_task_out(task, include_detail=include_detail) for task in query.all()]
if page is not None:
return {
"items": result,
"total": total or 0,
"page": page,
"page_size": page_size,
}
return result
@router.post("/tasks/cleanup-orphans")
def cleanup_orphan_tasks(
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""手动清理没有内存执行器接管的斗鱼任务。"""
cleaned = cleanup_orphan_douyu_tasks(
db,
active_batch_ids=douyu_batch_registry.active_ids(),
statuses=("pending", "running"),
message="任务已中断(无执行器接管)",
)
return {
"message": f"已清理 {cleaned} 个残留任务",
"cleaned": cleaned,
"success": True,
}
@router.get("/tasks/{task_id}", response_model=DouyuTaskOut)
def get_task(
task_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""获取单条斗鱼任务详情。"""
task = _visible_tasks_query(db, current).filter(DouyuTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return _task_out(task, include_detail=True)
@router.post("/stop/{batch_id}")
def stop_batch(
batch_id: str,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""停止正在运行的斗鱼批次。"""
_require_batch_owner(db, current, batch_id)
is_recharge_batch = (
db.query(DouyuTask.id)
.filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr")
.first()
is not None
)
batch = douyu_batch_registry.get(batch_id)
if batch:
if batch.get("finished"):
douyu_batch_registry.pop(batch_id)
cleaned = cleanup_orphan_douyu_tasks(
db, batch_id=batch_id, message="批次已结束"
)
if cleaned:
return {
"message": f"批次已结束,已清理 {cleaned} 个残留任务",
"success": True,
}
raise HTTPException(status_code=404, detail="批次已结束")
batch["runner"].stop()
if is_recharge_batch:
record_audit(
db,
current,
action="recharge:douyu:stop",
target=f"douyu_batch:{batch_id}",
detail={"batch_id": batch_id, "mode": "running"},
)
db.commit()
return {"message": "已发送停止信号", "success": True}
cleaned = cleanup_orphan_douyu_tasks(
db, batch_id=batch_id, message="任务已停止(批次不存在)"
)
if cleaned:
if is_recharge_batch:
record_audit(
db,
current,
action="recharge:douyu:stop",
target=f"douyu_batch:{batch_id}",
detail={
"batch_id": batch_id,
"mode": "orphan_cleanup",
"cleaned": cleaned,
},
)
db.commit()
return {"message": f"已清理 {cleaned} 个残留任务", "success": True}
raise HTTPException(status_code=404, detail="批次不存在或已结束")
@router.websocket("/ws/{batch_id}")
async def ws_douyu_logs(websocket: WebSocket, batch_id: str):
"""斗鱼实时日志推送通道。"""
user = authenticate_websocket(websocket)
if not user:
await websocket.close(code=1008, reason="未授权")
return
if not user_has_permission(user, "douyu:task"):
await websocket.close(code=1008, reason="无权限")
return
db = SessionLocal()
try:
_require_batch_owner(db, user, batch_id)
except HTTPException:
await websocket.close(code=1008, reason="无权访问该任务批次")
return
finally:
db.close()
await websocket.accept()
batch = douyu_batch_registry.get(batch_id)
if not batch:
await websocket.send_json({"level": "error", "message": "批次不存在或已结束"})
await websocket.close()
return
log_queue: asyncio.Queue = batch["log_queue"]
try:
while True:
try:
msg = await asyncio.wait_for(log_queue.get(), timeout=30)
await websocket.send_json(msg)
if msg.get("level") == "result":
await asyncio.sleep(0.1)
break
except TimeoutError:
await websocket.send_json({"level": "heartbeat", "message": ""})
except WebSocketDisconnect:
pass
finally:
latest = douyu_batch_registry.get(batch_id)
if latest and latest.get("finished"):
douyu_batch_registry.pop(batch_id)
def _to_int_or_none(value: object) -> int | None:
"""回调里的 `buy_num` 可能是字符串/Decimal,安全转 int。"""
try:
return int(float(str(value)))
except (TypeError, ValueError):
return None