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