Files
live-hub-py/web/backend/routers/douyu_pool.py
T
yml2213 18bc7be4b1 feat(pool): 账号自动分配与回收(租约 + 补给链 + 独立工作台)
在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条
「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。
旧路径只做并集与可选参数扩展,行为不变(有用例守着)。

- 归属与使用权分离:新增 account_leases(生成列 + 唯一索引,并发抢号由数据库裁决),
  assigned_to 收窄为管理员/运营的维护责任;客服可见范围扩为「名下 ∪ 租用中」
- account_pool 服务:候选筛选、两梯队排序(先花快过期的 / 先选充值少的)、
  供给与成本预估、租约领取/释放/回收
- 补给链:查余额 → 充鱼翅(单次 ≤800 元,超限拆单)→ 赠精英令 → 兑换;
  运行期两道闸门:_skip_when(余额已够就别再花钱)、_depends_on(前置未成功就跳过)
- 转人工:鱼翅支付等 10 分钟未到账转 manual_required,租约先不还;
  account_lease_reaper 兜 TTL 到期 / 漏释放 / 孤儿批次
- 运营流水:业务事件补 actor_id = task.created_by;账号详情页展示操作人、渠道
  与租赁记录
- 新工作台:/douyu/pool + 菜单「账号分配台」(新建,不改造旧工作台)
- 修复 D1:官方补录的兑换事件漏写 subject_key(10,612 条主键为空),
  会让 365 天同皮肤规则漏判 87% 的历史;补代码 + 回填脚本
- 迁移 20261007_0052;权限 account:pool_use / account:pool_admin

测试:766 passed, 6 skipped(改动前基线 727);ruff 与 tsc -b 干净。
设计稿与实施记录:docs/设计-账号自动分配与回收.md
2026-10-07 12:29:08 +08:00

448 lines
16 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.
"""账号池路由:自动分配账号 + 用完自动收回。
配套文档:`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 ..database import get_db
from ..deps import require_permission
from ..models import (
Account,
AccountLease,
DouyuEsportsGoodsSnapshot,
DouyuGoodsSnapshot,
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):
model = (
DouyuGoodsSnapshot if scope == pool.SCOPE_ELITE else DouyuEsportsGoodsSnapshot
)
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 _goods_required(db: Session, scope: str, commodity_id: str) -> int:
goods = _goods_snapshot(db, scope, commodity_id)
if goods is None:
raise HTTPException(
status_code=404,
detail="找不到该商品,请先在对应工作台刷新商品列表",
)
score = int(goods.score 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.get("/supply")
def pool_supply(
scope: str = Query("elite", pattern="^(elite|esports)$"),
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]
task_type = (
"exchange_goods" if scope == pool.SCOPE_ELITE else "exchange_esports_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.get(row.account_id).username
if accounts.get(row.account_id)
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"]