Files
live-hub-py/web/backend/routers/douyu.py
T
yml2213 a64380fbcc feat(account): S6 bind_status 三列拆解 —— 四值枚举、唯一写入口、前端改读账号
`accounts.bind_status` 是个**语义垃圾场**:一个自由文本列,6 个文件、**29 处写入**、
20 多种取值,混了三种东西 —— 真状态(`bind_confirmed`)、动作痕迹(`goods_locked`)、
纯查询痕迹(`points_queried`)。它是**覆盖式**的,所以 `bind_confirmed` 会被之后
任意一次查询盖掉 —— 「这个账号到底绑没绑」根本查不出来。
`esports_bind_status` / `xpd_bind_status` 是同一个病。

## 收敛成四值 + 三列一义

`unknown` / `unbound` / `pending` / `bound`,每个作用域一列:

| 列 | 作用域 |
| --- | --- |
| `accounts.role_bind_status`(**新增**) | 精英宝典角色 |
| `accounts.esports_bind_status`(收敛) | 电竞手册角色 |
| `accounts.xpd_bind_status`(收敛) | 和平小店角色 |

- 新增 `services/account_binding.py`:唯一写入口 `set_bind_status()`,
  传旧自由文本直接 `ValueError`(否则垃圾会被写回去);`map_legacy_bind_status()` 负责旧值映射。
- **29 处自由文本写入全部删除或改走枚举**:真状态走枚举,动作/查询痕迹**不再写状态列**
  (动作已由 S5 的直发事件记录,查询痕迹本来就不该是状态)。
- `accounts.bind_status` 保留原值便于比对,**已无任何写入方**,待 S8 drop。
- 一条**源码扫描用例**禁止任何地方再出现 `account.bind_status = "..."` 这类写入。

## 回填不猜(设计文档 §5.5)

只映射**明确陈述了绑定关系**的取值(`bind_confirmed`/`esports_bound`/`xpd_bound` → bound,
`*_qr_*`/`*_ready` → pending,`game_not_bound`/`xpd_not_bound` → unbound),
**其余一律 unknown**。因为列是覆盖式的,「最后一件事是查询」并不代表没绑定。
迁移 SQL 与 Python 映射有一致性用例:**解析迁移源码里的 CASE 逐条比对**
(已反向验证:故意改错一条映射,用例立刻红)。

## ⚠️ 顺带救回一个差点被我自己丢掉的状态

「**电竞手册是否已开通**」以前**只寄生在 `esports_bind_status` 的 `esports_opened`
取值里**(`elite_opened` 有独立布尔列,电竞没有)。归一化会把那个取值抹成 `unknown`
→ **静默丢状态**。所以:
- 新增 `accounts.esports_opened` 布尔列(与 `elite_opened` 对称),开通任务成功时置位;
- 迁移里**必须先 `save_esports_opened()` 再归一化**,顺序用例钉住;
- 详情页「电竞手册」开通状态改读 `esports_opened`。

## 前端不再从任务日志反推账号状态

工作台原来从**当前加载的那一页任务**里挑每个账号最新的一条来推绑定状态
(`latestConfirmBindTaskByAccount` 等)。两个问题:① 支撑任务一旦翻出窗口,
**显示的状态就是错的**;② 这让任务日志变成「账号状态库」,于是它永远不敢清理。

现在状态读账号字段,任务日志只补「扫码流程中的瞬时信息」(如确认前的
`pending_role_name`)。删掉了两个不再需要的派生与对应的 `clearPending` 绕行逻辑。

## 踩到并修掉的东西

- **模型与迁移不一致**:我把模型收窄成 `String(16)`,迁移却留着 `String(32)` ——
  测试库按模型建、生产按迁移建,两边列宽不同。已让迁移也收窄,且**收窄必须在
  归一化之后**(旧取值最长 19 字符,先收窄会截断成废值)。downgrade 也补齐了反向操作,
  已实测 `downgrade -1 && upgrade head` 干净。
- **parity 用例第一版是抄了一份 SQL**(迁移改了用例也不会红,等于没测)——
  改成解析迁移源码;后来又因为列已收窄、19 字符的旧值塞不进测试库,
  干脆改成源码级解析比对(不造数据)。

## 验证

- `TZ=Asia/Shanghai`:**490 passed / 6 skipped**(S5 后 450 → 净增 40)
- `TZ=UTC`(相关文件)106 passed;迁移链 3 passed;ruff 全绿
- 前端 `tsc -b && vite build` 通过、`npm run lint` **0 errors**
- 真实开发库:迁移重放后三列均为 `varchar(16) NOT NULL DEFAULT 'unknown'`;
  12 个账号的旧值 `elite_status_queried`(纯查询痕迹)→ **unknown**(没有猜成 bound)

未做(后续):S6 的**账号页重构**(列表关键列 / 生命周期主页)保留;
`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」现在技术上可下探到 7 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
2026-10-04 16:28:06 +08:00

931 lines
32 KiB
Python
Raw 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,
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="无权操作斗鱼账号")
query = query.filter(Account.assigned_to == current.id)
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 _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)))
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"),
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)
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()
log_queue = asyncio.Queue()
loop = asyncio.get_running_loop()
thread_db = SessionLocal()
runner = DouyuBatchRunner(
db=thread_db,
batch_id=batch_id,
task_type=req.task_type,
payload=req.payload,
log_queue=log_queue,
loop=loop,
concurrency=req.concurrency,
)
douyu_batch_registry.register(batch_id, log_queue, loop, runner)
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
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