feat(douyu-pool): 支持和平精英号池兑换与商品自动刷新,优化号池操作面板

- 扩展号池支持 peace (和平精英/小跑道) 活动 scope,映射 xpd_balance 与 exchange_xpd_goods 任务
- 新增 POST /douyu/pool/refresh-goods 接口与前端一键刷新,支持复用库内有效 CK 自动更新商品快照
- 前端 DouyuPoolPage 升级视觉设计与栅格布局,商品选择器支持按活动类型分类联动
- 修复 DouyuTask.result 类型守卫、account_events actor_id 类型防御与 runner 过滤规则边界值处理
- 验证方式:全量 pytest (797 通过)、pyright (0 错误)、tsc -b 及 ruff format/check 全部通过
This commit is contained in:
yml2213
2026-10-08 13:15:21 +08:00
parent 88422f2f05
commit bb20533c90
8 changed files with 669 additions and 211 deletions
+4 -1
View File
@@ -1642,7 +1642,10 @@ def account_events(
.all()
}
for item in items:
item["actor_name"] = actor_names.get(item.get("actor_id"), "")
actor_id = item.get("actor_id")
item["actor_name"] = (
actor_names.get(actor_id, "") if isinstance(actor_id, int) else ""
)
return {
"items": items,
+149 -11
View File
@@ -21,6 +21,8 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient, DouyuActivityError
from ..database import get_db
from ..deps import require_permission
from ..models import (
@@ -28,6 +30,7 @@ from ..models import (
AccountLease,
DouyuEsportsGoodsSnapshot,
DouyuGoodsSnapshot,
DouyuXpdGoodsSnapshot,
User,
)
from ..permissions import user_has_permission
@@ -53,9 +56,12 @@ POOL_BATCH_CONCURRENCY = 1
def _goods_snapshot(db: Session, scope: str, commodity_id: str):
model = (
DouyuGoodsSnapshot if scope == pool.SCOPE_ELITE else DouyuEsportsGoodsSnapshot
)
if scope == pool.SCOPE_ELITE:
model = DouyuGoodsSnapshot
elif scope == pool.SCOPE_ESPORTS:
model = DouyuEsportsGoodsSnapshot
else:
model = DouyuXpdGoodsSnapshot
return (
db.query(model)
.filter(model.commodity_id == commodity_id)
@@ -73,14 +79,124 @@ def _require_scope(scope: str) -> str:
return scope
def _try_auto_refresh_goods(db: Session, scope: str) -> bool:
"""尝试从库内找一个有效账号刷新该作用域的商品快照。"""
acc = (
db.query(Account)
.filter(
Account.status == "active",
Account.ck_check_status == "valid",
Account.cookie.isnot(None),
Account.cookie != "",
)
.first()
)
if not acc or not acc.cookie:
return False
try:
client = DouyuActivityClient(cookie=str(acc.cookie))
cfg = ensure_douyu_config(db)
now = _utcnow()
if scope == pool.SCOPE_ELITE:
res = client.list_goods(manual_id=str(cfg.manual_id), rid=str(cfg.rid))
for raw in res.get("goods", []):
cid = str(raw.get("commodityId") or raw.get("commodity_id") or "")
if not cid:
continue
row = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == cid)
.first()
)
if row is None:
row = DouyuGoodsSnapshot(commodity_id=cid)
db.add(row)
row.name = str(raw.get("commodityName") or raw.get("name") or "")
row.score = int(raw.get("score") or 0)
row.status = str(raw.get("status") or "")
row.raw = raw
row.updated_at = now
db.commit()
return True
elif scope == pool.SCOPE_ESPORTS:
res = client.list_esports_goods(
manual_id=str(cfg.esports_manual_id), rid=str(cfg.room_id)
)
for raw in res.get("goods", []):
cid = str(raw.get("commodityId") or raw.get("commodity_id") or "")
if not cid:
continue
row = (
db.query(DouyuEsportsGoodsSnapshot)
.filter(DouyuEsportsGoodsSnapshot.commodity_id == cid)
.first()
)
if row is None:
row = DouyuEsportsGoodsSnapshot(commodity_id=cid)
db.add(row)
row.name = str(raw.get("commodityName") or raw.get("name") or "")
row.score = int(raw.get("score") or 0)
row.status = str(raw.get("status") or "")
row.raw = raw
row.updated_at = now
db.commit()
return True
elif scope == "peace":
ctx = client.xpd_embed_query(
act_alias=str(cfg.xpd_act_alias), rid=str(cfg.xpd_rid)
)
embed_q = ctx.get("query") or {}
role_res = client.xpd_get_role(
embed_query=embed_q,
act_id=str(cfg.xpd_act_id),
rid=str(cfg.xpd_rid),
)
role = role_res.get("info") or {}
res = client.xpd_list_goods(
embed_query=embed_q,
act_id=str(cfg.xpd_act_id),
openid=str(role.get("game_open_id") or ""),
roleid=str(role.get("role_id") or ""),
areaid=str(acc.xpd_area_id or 1),
)
for raw in res.get("goods", []):
cid = str(raw.get("commodity_id") or raw.get("commodityId") or "")
if not cid:
continue
row = (
db.query(DouyuXpdGoodsSnapshot)
.filter(DouyuXpdGoodsSnapshot.commodity_id == cid)
.first()
)
if row is None:
row = DouyuXpdGoodsSnapshot(commodity_id=cid)
db.add(row)
row.name = str(raw.get("name") or raw.get("commodityName") or "")
row.price = int(raw.get("price") or 0)
row.org_price = int(raw.get("org_price") or 0)
row.category = str(raw.get("category") or "")
row.goods_left = raw.get("goods_left")
row.raw = raw
row.updated_at = now
db.commit()
return True
except (DouyuActivityError, OSError, ValueError, KeyError, TypeError):
db.rollback()
return False
return False
def _goods_required(db: Session, scope: str, commodity_id: str) -> int:
goods = _goods_snapshot(db, scope, commodity_id)
if goods is None:
_try_auto_refresh_goods(db, scope)
goods = _goods_snapshot(db, scope, commodity_id)
if goods is None:
raise HTTPException(
status_code=404,
detail="找不到该商品,请先在对应工作台刷新商品列表",
)
score = int(goods.score or 0)
score = int(getattr(goods, "score", None) or getattr(goods, "price", None) or 0)
if score <= 0:
raise HTTPException(status_code=400, detail="该商品没有有效积分价格,无法分配")
return score
@@ -94,9 +210,30 @@ def _is_pool_admin(current: User) -> bool:
return user_has_permission(current, "account:pool_admin")
@router.post("/refresh-goods")
def pool_refresh_goods(
scope: str = Query("elite", pattern="^(elite|esports|peace)$"),
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:pool_use")),
) -> dict[str, Any]:
"""使用库内有效 CK 自动刷新商品快照。"""
ok = _try_auto_refresh_goods(db, scope)
model = (
DouyuGoodsSnapshot
if scope == pool.SCOPE_ELITE
else (
DouyuEsportsGoodsSnapshot
if scope == pool.SCOPE_ESPORTS
else DouyuXpdGoodsSnapshot
)
)
count = db.query(model).count()
return {"success": ok, "scope": scope, "count": count}
@router.get("/supply")
def pool_supply(
scope: str = Query("elite", pattern="^(elite|esports)$"),
scope: str = Query("elite", pattern="^(elite|esports|peace)$"),
commodity_id: str = Query(..., min_length=1, max_length=64),
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:pool_use")),
@@ -221,9 +358,12 @@ async def pool_exchange(
)
account_ids = [lease.account_id for lease in leases]
task_type = (
"exchange_goods" if scope == pool.SCOPE_ELITE else "exchange_esports_goods"
)
if scope == pool.SCOPE_ELITE:
task_type = "exchange_goods"
elif scope == pool.SCOPE_ESPORTS:
task_type = "exchange_esports_goods"
else:
task_type = "exchange_xpd_goods"
by_id = {c.account_id: c for c in candidates}
# 补给链:A 梯队只有「兑换」一步;B 梯队是「查余额 → 充值 → 查积分 → 赠令 → 兑换」。
@@ -384,9 +524,7 @@ def list_leases(
id=row.id,
account_id=row.account_id,
username=(
accounts.get(row.account_id).username
if accounts.get(row.account_id)
else ""
accounts[row.account_id].username if row.account_id in accounts else ""
),
holder_user_id=row.holder_user_id,
holder_name=holders.get(row.holder_user_id, ""),
+2 -1
View File
@@ -32,7 +32,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ..models import Account, AccountEvent, AccountLease, DouyuTask, User
from .account_binding import SCOPE_ELITE, SCOPE_ESPORTS
from .account_binding import SCOPE_ELITE, SCOPE_ESPORTS, SCOPE_PEACE
#: 租约默认有效期(分钟)。到点由 `account_lease_reaper` 兜底回收。
DEFAULT_LEASE_TTL_MINUTES = 60
@@ -53,6 +53,7 @@ _POOL_OWNER_ROLES = ("super_admin", "operation")
_SCOPE_COLUMNS: dict[str, tuple[str, str | None]] = {
SCOPE_ELITE: ("points", "elite_opened"),
SCOPE_ESPORTS: ("esports_points", "esports_opened"),
SCOPE_PEACE: ("xpd_balance", None),
}
SUPPORTED_POOL_SCOPES = tuple(_SCOPE_COLUMNS)
+4 -1
View File
@@ -291,8 +291,11 @@ class DouyuBatchRunnerCore:
current = getattr(account, column, None)
if current is None:
return ""
raw_val = rule.get("value")
if raw_val is None:
return ""
try:
target = int(rule.get("value"))
target = int(raw_val)
except (TypeError, ValueError):
return ""
if compare(int(current), target):