在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条 「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。 旧路径只做并集与可选参数扩展,行为不变(有用例守着)。 - 归属与使用权分离:新增 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
448 lines
16 KiB
Python
448 lines
16 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 ..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"]
|