"""账号池路由:自动分配账号 + 用完自动收回。 配套文档:`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"]