- 扩展号池支持 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 全部通过
586 lines
21 KiB
Python
586 lines
21 KiB
Python
"""账号池路由:自动分配账号 + 用完自动收回。
|
||
|
||
配套文档:`docs/设计-账号自动分配与回收.md`。
|
||
|
||
**这是新增的一条路,不是改造旧的**:
|
||
- 旧路径(管理员分配 → 客服导入工作台 → 手动选号)在 `routers/douyu.py` 里一字未动;
|
||
- 本模块只提供 `/api/douyu/pool/*`,前端对应**新建**的「账号分配台」页面。
|
||
|
||
与旧路径共享的东西只有三处,都是**扩展而非替换**:
|
||
1. `create_douyu_planned_tasks` 多了一个可选参数(默认行为不变);
|
||
2. `_require_task_account_access` 的可操作集合变成「我名下 ∪ 我持有的有效租约」;
|
||
3. `start_douyu_batch` 抽成函数,两条路径用同一套执行方式。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import datetime
|
||
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 (
|
||
Account,
|
||
AccountLease,
|
||
DouyuEsportsGoodsSnapshot,
|
||
DouyuGoodsSnapshot,
|
||
DouyuXpdGoodsSnapshot,
|
||
User,
|
||
)
|
||
from ..permissions import user_has_permission
|
||
from ..schemas import (
|
||
DouyuPoolAccountOut,
|
||
DouyuPoolExchangeRequest,
|
||
DouyuPoolExchangeResponse,
|
||
DouyuPoolLeaseOut,
|
||
)
|
||
from ..services import account_pool as pool
|
||
from ..services.audit_service import record_audit
|
||
from ..services.douyu_service import create_douyu_task, ensure_douyu_config
|
||
from .douyu import start_douyu_batch
|
||
|
||
router = APIRouter(prefix="/api/douyu/pool", tags=["账号池"])
|
||
|
||
#: 池路径一账号一件(一期)。批量商品的 `num` 以后再开。
|
||
ITEM_PER_ACCOUNT = 1
|
||
|
||
#: 池批次默认并发。**串行**是为了让「充值 → 赠令 → 兑换」的先后成立:
|
||
#: `DouyuBatchRunner` 按 batch_id 取全部 planned 任务并发执行,顺序没有保证。
|
||
POOL_BATCH_CONCURRENCY = 1
|
||
|
||
|
||
def _goods_snapshot(db: Session, scope: str, commodity_id: str):
|
||
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)
|
||
.order_by(model.id.desc())
|
||
.first()
|
||
)
|
||
|
||
|
||
def _require_scope(scope: str) -> str:
|
||
if scope not in pool.SUPPORTED_POOL_SCOPES:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"该作用域暂不支持自动分配:{scope}",
|
||
)
|
||
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(getattr(goods, "score", None) or getattr(goods, "price", None) or 0)
|
||
if score <= 0:
|
||
raise HTTPException(status_code=400, detail="该商品没有有效积分价格,无法分配")
|
||
return score
|
||
|
||
|
||
def _iso(value: datetime | None) -> str | None:
|
||
return value.isoformat() if value else None
|
||
|
||
|
||
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|peace)$"),
|
||
commodity_id: str = Query(..., min_length=1, max_length=64),
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("account:pool_use")),
|
||
) -> dict[str, Any]:
|
||
"""**不锁号**地预览「现在能分出几个、要花多少钱」。"""
|
||
scope = _require_scope(scope)
|
||
score = _goods_required(db, scope, commodity_id)
|
||
supply = pool.estimate_supply(
|
||
db, scope=scope, score=score, commodity_id=commodity_id
|
||
)
|
||
goods = _goods_snapshot(db, scope, commodity_id)
|
||
channel = str(douyu_config_value(db, "gold_recharge_channel") or "wechat_qr")
|
||
supply["commodity"] = {
|
||
"commodity_id": commodity_id,
|
||
"name": (goods.name if goods else "") or "",
|
||
"score": score,
|
||
}
|
||
supply["cooling"] = _cooling_summary(db)
|
||
supply["recharge_channel"] = channel
|
||
supply["manual_payment_required"] = channel != "supplier_api"
|
||
return supply
|
||
|
||
|
||
def douyu_config_value(db: Session, key: str) -> Any:
|
||
"""读斗鱼配置(**只读**)。
|
||
|
||
池路径**绝不写** `gold_recharge_channel` —— 那个开关由管理员在
|
||
`PUT /api/douyu/config` 手动切换(见设计 §3.4.1)。
|
||
"""
|
||
config = ensure_douyu_config(db)
|
||
return getattr(config, key, None)
|
||
|
||
|
||
def _cooling_summary(db: Session) -> dict[str, int]:
|
||
"""换绑冷却概览(**只记录不拦截**,给前端提示用)。
|
||
|
||
按第 3 条:不同活动的换绑冷却不同,一期只把「什么时候能换绑」记下来给客服看,
|
||
不作为候选过滤条件 —— 真卡这条,池子会从几千个掉到几百个。
|
||
"""
|
||
moment = _utcnow()
|
||
base = db.query(Account).filter(
|
||
Account.status == "active", Account.ck_check_status == "valid"
|
||
)
|
||
in_cooldown = base.filter(
|
||
Account.role_change_available_at.isnot(None),
|
||
Account.role_change_available_at > moment,
|
||
).count()
|
||
changeable = base.filter(
|
||
(Account.role_change_available_at.is_(None))
|
||
| (Account.role_change_available_at <= moment)
|
||
).count()
|
||
return {"role_change_in_cooldown": in_cooldown, "changeable_now": changeable}
|
||
|
||
|
||
def _utcnow() -> datetime:
|
||
from datetime import UTC
|
||
|
||
return datetime.now(UTC)
|
||
|
||
|
||
@router.post("/exchange", response_model=DouyuPoolExchangeResponse)
|
||
async def pool_exchange(
|
||
req: DouyuPoolExchangeRequest,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("account:pool_use")),
|
||
) -> DouyuPoolExchangeResponse:
|
||
"""提交需求:分配账号 → 建任务 → 启动批次。用完由执行器自动释放租约。"""
|
||
scope = _require_scope(req.scope)
|
||
score = _goods_required(db, scope, req.commodity_id)
|
||
|
||
candidates = pool.eligible_accounts(
|
||
db,
|
||
scope=scope,
|
||
score=score,
|
||
commodity_id=req.commodity_id,
|
||
limit=max(req.quantity, 1) * 3,
|
||
)
|
||
reasons: list[str] = []
|
||
if not req.allow_topup:
|
||
before = len(candidates)
|
||
candidates = [c for c in candidates if not c.needs_topup]
|
||
if len(candidates) < before:
|
||
reasons.append(
|
||
f"已排除 {before - len(candidates)} 个需要充值积分的账号(本次未允许充值)"
|
||
)
|
||
if len(candidates) < req.quantity:
|
||
supply = pool.estimate_supply(
|
||
db, scope=scope, score=score, commodity_id=req.commodity_id
|
||
)
|
||
reasons.append(
|
||
f"可用账号不足:满足条件仅 {len(candidates)} 个"
|
||
f"(可直接兑 {supply['zero_topup']} 个,充值后可兑 {supply['after_topup']} 个)"
|
||
)
|
||
if not candidates:
|
||
return DouyuPoolExchangeResponse(
|
||
requested=req.quantity,
|
||
allocated=0,
|
||
shortfall=req.quantity,
|
||
reasons=reasons or ["没有可用账号"],
|
||
recharge_channel=str(douyu_config_value(db, "gold_recharge_channel") or ""),
|
||
manual_payment_required=(
|
||
str(douyu_config_value(db, "gold_recharge_channel") or "")
|
||
!= "supplier_api"
|
||
),
|
||
)
|
||
|
||
leases = pool.lease_accounts(
|
||
db,
|
||
holder_user_id=current.id,
|
||
candidates=candidates,
|
||
scope=scope,
|
||
commodity_id=req.commodity_id,
|
||
quantity=req.quantity,
|
||
)
|
||
if not leases:
|
||
return DouyuPoolExchangeResponse(
|
||
requested=req.quantity,
|
||
allocated=0,
|
||
shortfall=req.quantity,
|
||
reasons=[*reasons, "账号刚被其他客服领走,请重试"],
|
||
recharge_channel=str(douyu_config_value(db, "gold_recharge_channel") or ""),
|
||
)
|
||
|
||
account_ids = [lease.account_id for lease in leases]
|
||
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 梯队是「查余额 → 充值 → 查积分 → 赠令 → 兑换」。
|
||
# 每个账号的充值额/赠令数都不一样,所以逐账号建任务(不能共用一份 payload)。
|
||
# 顺序由「同一批次 + 并发 1」保证;运行期另有两道闸门
|
||
# (`_depends_on` 前置失败就跳过、`_skip_when` 条件已满足就跳过,见 runner)。
|
||
batch_id = uuid.uuid4().hex[:12]
|
||
accounts_by_id = {
|
||
acc.id: acc
|
||
for acc in db.query(Account).filter(Account.id.in_(account_ids)).all()
|
||
}
|
||
created = 0
|
||
no_ck: list[AccountLease] = []
|
||
for lease in leases:
|
||
account = accounts_by_id.get(lease.account_id)
|
||
if account is None:
|
||
continue
|
||
if not (account.cookie or ""):
|
||
no_ck.append(lease)
|
||
continue
|
||
previous_id: int | None = None
|
||
for step in pool.build_supply_chain(
|
||
by_id[lease.account_id]
|
||
if lease.account_id in by_id
|
||
else pool.make_candidate(
|
||
account_id=account.id,
|
||
username=account.username,
|
||
points=account.points,
|
||
score=score,
|
||
),
|
||
commodity_id=req.commodity_id,
|
||
exchange_task_type=task_type,
|
||
):
|
||
payload = dict(step.payload)
|
||
if previous_id is not None:
|
||
payload["_depends_on"] = previous_id
|
||
task = create_douyu_task(
|
||
db,
|
||
account,
|
||
step.task_type,
|
||
scope,
|
||
current.id,
|
||
payload,
|
||
batch_id=batch_id,
|
||
# 池路径:任务列表对客服的过滤走这一列,写**租用人**客服才看得到自己的单子。
|
||
account_assigned_to=current.id,
|
||
)
|
||
previous_id = task.id
|
||
created += 1
|
||
|
||
if no_ck:
|
||
# 账号没有可用 CK:把刚领的租约立刻还回去,别占着。
|
||
pool.release_many(db, no_ck, reason="no_ck")
|
||
pool.bind_leases_to_batch(db, leases, batch_id)
|
||
|
||
if created == 0:
|
||
pool.release_many(db, leases, reason="no_ck")
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="分配到的账号当前没有可用 Cookie,已释放,请稍后重试",
|
||
)
|
||
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="douyu:pool:exchange",
|
||
target=f"douyu_batch:{batch_id}",
|
||
detail={
|
||
"scope": scope,
|
||
"commodity_id": req.commodity_id,
|
||
"requested": req.quantity,
|
||
"allocated": len(leases),
|
||
"account_ids": account_ids,
|
||
"topup_gifts": sum(c.topup_gifts for c in candidates[: len(leases)]),
|
||
},
|
||
)
|
||
db.commit()
|
||
|
||
start_douyu_batch(
|
||
batch_id=batch_id,
|
||
task_type=task_type,
|
||
payload={"commodity_id": req.commodity_id, "num": ITEM_PER_ACCOUNT},
|
||
concurrency=POOL_BATCH_CONCURRENCY,
|
||
)
|
||
|
||
channel = str(douyu_config_value(db, "gold_recharge_channel") or "")
|
||
return DouyuPoolExchangeResponse(
|
||
batch_id=batch_id,
|
||
requested=req.quantity,
|
||
allocated=len(leases),
|
||
shortfall=max(0, req.quantity - len(leases)),
|
||
accounts=[
|
||
_account_out(db, lease, by_id.get(lease.account_id)) for lease in leases
|
||
],
|
||
reasons=reasons,
|
||
recharge_channel=channel,
|
||
manual_payment_required=channel != "supplier_api",
|
||
)
|
||
|
||
|
||
def _account_out(db: Session, lease: AccountLease, candidate) -> DouyuPoolAccountOut:
|
||
account = db.query(Account).filter(Account.id == lease.account_id).first()
|
||
topup = int(candidate.topup_gifts) if candidate else 0
|
||
return DouyuPoolAccountOut(
|
||
account_id=lease.account_id,
|
||
username=(account.username if account else ""),
|
||
tier=(candidate.tier if candidate else "A"),
|
||
points=int(candidate.points) if candidate else 0,
|
||
topup_gifts=topup,
|
||
topup_yuan=topup,
|
||
recharge_orders=pool.recharge_amounts_for(topup),
|
||
role_change_available_at=_iso(
|
||
account.role_change_available_at if account else None
|
||
),
|
||
)
|
||
|
||
|
||
@router.get("/leases", response_model=list[DouyuPoolLeaseOut])
|
||
def list_leases(
|
||
mine: bool = Query(True, description="只看我自己的(默认)"),
|
||
account_id: int | None = Query(
|
||
None, description="按账号查它的租赁记录(账号详情页的「租赁记录」用)"
|
||
),
|
||
include_finished: bool = Query(
|
||
False, description="是否带上已释放/已过期的历史记录"
|
||
),
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("account:pool_use")),
|
||
) -> list[DouyuPoolLeaseOut]:
|
||
query = db.query(AccountLease)
|
||
if not include_finished:
|
||
query = query.filter(AccountLease.status == "active")
|
||
if account_id is not None:
|
||
query = query.filter(AccountLease.account_id == int(account_id))
|
||
# 查某个账号的租赁记录时不再要求「必须是我租的」——
|
||
# 否则管理员复盘某账号时只会看到空白。能看到这个账号的人就能看它的租约史。
|
||
elif mine or not _is_pool_admin(current):
|
||
query = query.filter(AccountLease.holder_user_id == current.id)
|
||
rows = query.order_by(AccountLease.id.desc()).limit(500).all()
|
||
account_ids = [row.account_id for row in rows]
|
||
accounts: dict[int, Account] = {}
|
||
if account_ids:
|
||
accounts = {
|
||
acc.id: acc
|
||
for acc in db.query(Account).filter(Account.id.in_(account_ids)).all()
|
||
}
|
||
holder_ids = {row.holder_user_id for row in rows}
|
||
holders: dict[int, str] = {}
|
||
if holder_ids:
|
||
holders = {
|
||
user_id: name
|
||
for user_id, name in db.query(User.id, User.username)
|
||
.filter(User.id.in_(holder_ids))
|
||
.all()
|
||
}
|
||
return [
|
||
DouyuPoolLeaseOut(
|
||
id=row.id,
|
||
account_id=row.account_id,
|
||
username=(
|
||
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, ""),
|
||
scope=row.scope,
|
||
commodity_id=row.commodity_id,
|
||
batch_id=row.batch_id,
|
||
status=row.status,
|
||
leased_at=_iso(row.leased_at),
|
||
expires_at=_iso(row.expires_at),
|
||
role_change_available_at=_iso(
|
||
accounts[row.account_id].role_change_available_at
|
||
if row.account_id in accounts
|
||
else None
|
||
),
|
||
points=(
|
||
accounts[row.account_id].points if row.account_id in accounts else None
|
||
),
|
||
)
|
||
for row in rows
|
||
]
|
||
|
||
|
||
@router.post("/leases/{lease_id}/release")
|
||
def release_lease(
|
||
lease_id: int,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("account:pool_use")),
|
||
) -> dict[str, Any]:
|
||
"""手动释放租约(客服放弃、或用完提前还)。"""
|
||
lease = db.query(AccountLease).filter(AccountLease.id == lease_id).first()
|
||
if lease is None:
|
||
raise HTTPException(status_code=404, detail="租约不存在")
|
||
if lease.holder_user_id != current.id and not _is_pool_admin(current):
|
||
raise HTTPException(status_code=403, detail="无权释放他人的租约")
|
||
released = pool.release_lease(db, lease, reason="manual_release")
|
||
return {"success": True, "released": released}
|
||
|
||
|
||
@router.post("/leases/release-batch/{batch_id}")
|
||
def release_batch(
|
||
batch_id: str,
|
||
db: Session = Depends(get_db),
|
||
current: User = Depends(require_permission("account:pool_admin")),
|
||
) -> dict[str, Any]:
|
||
"""超管强制释放一个批次的全部有效租约。"""
|
||
count = pool.release_batch(db, batch_id, reason="admin_release")
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="douyu:pool:release_batch",
|
||
target=f"douyu_batch:{batch_id}",
|
||
detail={"released": count},
|
||
)
|
||
db.commit()
|
||
return {"success": True, "released": count}
|
||
|
||
|
||
__all__ = ["router", "start_douyu_batch"]
|