Files
yml2213 bb20533c90 feat(douyu-pool): 支持和平精英号池兑换与商品自动刷新,优化号池操作面板
- 扩展号池支持 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 全部通过
2026-10-08 13:15:21 +08:00

742 lines
25 KiB
Python
Raw Permalink 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`(§3.3 候选与排序、§3.4 补给链、
§3.5 原子领取、§3.9 自动收回)。
## 为什么要拆「纯函数」与「SQL」
排序与充值量计算是**业务规则的集中地**(先花快过期的、先用小余额、
缺多少补多少),也是最容易被改错、必须能被反向验证的部分 ——
所以它们是不碰数据库的纯函数,用例可以脱离 MySQL 直接喂数据。
SQL 只负责**筛选**(谁的 CK 有效、谁已开通、谁 365 天内没兑过、谁没被租走)。
## 两个必须记住的约束
1. **`elite_opened = 1` 是硬门槛**:开通精英宝典要人工扫码
(`create_elite_qr` 生成支付码),系统不能自动开通 —— 未开通的账号进池也白进。
2. **并发抢号靠数据库**:`account_leases.active_account_id` 上的唯一索引裁决胜负,
撞键就换下一个候选(SAVEPOINT 包住,不影响已经领到的那些)。
不要退回「先查再插」——那在并发下必然双发。
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import and_, func, not_, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ..models import Account, AccountEvent, AccountLease, DouyuTask, User
from .account_binding import SCOPE_ELITE, SCOPE_ESPORTS, SCOPE_PEACE
#: 租约默认有效期(分钟)。到点由 `account_lease_reaper` 兜底回收。
DEFAULT_LEASE_TTL_MINUTES = 60
#: 同一件皮肤多久内不允许在同一账号再兑(滚动天数)。
EXCHANGE_COOLDOWN_DAYS = 365
#: 1 个精英令 = 10 精英积分 = 1 鱼翅 = 1 元(2026-10-07 生产实测)。
POINTS_PER_GIFT = 10
#: 单次鱼翅充值上限(元)。见设计 §3.4.2。
MAX_RECHARGE_AMOUNT = 800
#: 池成员 = 未分配 ∪ 管理员/运营名下;客服名下账号不进池(不跨归属)。
_POOL_OWNER_ROLES = ("super_admin", "operation")
#: 作用域 → (积分列名,开通标记列名)。和平小店没有「开通」概念。
_SCOPE_COLUMNS: dict[str, tuple[str, str | None]] = {
SCOPE_ELITE: ("points", "elite_opened"),
SCOPE_ESPORTS: ("esports_points", "esports_opened"),
SCOPE_PEACE: ("xpd_balance", None),
}
SUPPORTED_POOL_SCOPES = tuple(_SCOPE_COLUMNS)
def _now(now: datetime | None = None) -> datetime:
return now or datetime.now(UTC)
@dataclass(frozen=True)
class Candidate:
"""一个可分配账号在**本次兑换**下的画像。"""
account_id: int
username: str
points: int
score: int
tier: str # "A" = 积分已够(免充值);"B" = 需要充值
topup_gifts: int
points_expire_at: datetime | None = None
role_change_available_at: datetime | None = None
@property
def topup_yuan(self) -> int:
"""需要充多少元(1 元 = 1 鱼翅 = 1 精英令)。"""
return self.topup_gifts
@property
def needs_topup(self) -> bool:
return self.topup_gifts > 0
def topup_gifts_for(points: int | None, score: int) -> int:
"""凑够 `score` 还差几个精英令(向下取整的缺口向上取整到整数个令)。
>>> topup_gifts_for(300, 1280)
98
>>> topup_gifts_for(1280, 1280)
0
>>> topup_gifts_for(None, 20)
2
"""
current = int(points or 0)
missing = max(0, int(score) - current)
return (missing + POINTS_PER_GIFT - 1) // POINTS_PER_GIFT
def _sort_key(candidate: Candidate):
"""两梯队排序键(文档 §3.3)。
A 梯队(免充值):先花**快过期**的 → 再用**小余额**(把大额账号留给贵商品)。
B 梯队(需充值):先选**充值最少**的 → 再用**大余额**(留存量的先用掉)。
`points_expire_at` 为空排最后;最后统一用 `account_id` 兜稳定顺序,
保证同样的输入永远得到同样的顺序(否则用例无法断言)。
"""
if candidate.tier == "A":
expire_missing = candidate.points_expire_at is None
expire_key = candidate.points_expire_at or datetime.max.replace(tzinfo=UTC)
return (0, expire_missing, expire_key, candidate.points, candidate.account_id)
return (
1,
candidate.topup_gifts,
-candidate.points,
candidate.account_id,
)
def make_candidate(
*,
account_id: int,
username: str,
points: int | None,
score: int,
points_expire_at: datetime | None = None,
role_change_available_at: datetime | None = None,
) -> Candidate:
"""由账号行构造候选(纯函数,便于用例直接喂数据)。"""
current = int(points or 0)
gifts = topup_gifts_for(current, score)
return Candidate(
account_id=account_id,
username=username,
points=current,
score=int(score),
tier="A" if gifts == 0 else "B",
topup_gifts=gifts,
points_expire_at=points_expire_at,
role_change_available_at=role_change_available_at,
)
def rank_candidates(candidates: Iterable[Candidate]) -> list[Candidate]:
"""按两梯队规则排序(纯函数)。"""
return sorted(candidates, key=_sort_key)
def latest_exchange_exists_clause(commodity_id: str, since: datetime):
"""「该账号在 `since` 之后兑过这件商品」的子查询条件。"""
return (
select(AccountEvent.id)
.where(
AccountEvent.account_id == Account.id,
AccountEvent.event_type == "exchange",
# 只按 subject_key 判:D1 回填后我方任务与官方补录都是商品 ID。
# 不额外约束 subject_type —— 部分回填的历史行 subject_type 仍是 goods,
# 按它过滤会漏判(这正是 D1 要修的东西)。
AccountEvent.subject_key == commodity_id,
AccountEvent.occurred_at >= since,
)
.exists()
)
def active_lease_exists_clause():
"""「该账号存在有效租约」的子查询条件。"""
return (
select(AccountLease.id)
.where(AccountLease.active_account_id == Account.id)
.exists()
)
def pool_membership_clause():
"""池成员:未分配 ∪ 管理员/运营名下(客服名下账号不进池)。"""
owner_ids = select(User.id).where(User.role.in_(_POOL_OWNER_ROLES))
return or_(Account.assigned_to.is_(None), Account.assigned_to.in_(owner_ids))
def candidate_query(
*,
scope: str,
score: int,
commodity_id: str,
now: datetime | None = None,
):
"""候选账号的基础查询(**未排序、未限量**,供计数与取行共用)。"""
if scope not in _SCOPE_COLUMNS:
raise ValueError(f"暂不支持的作用域: {scope}")
points_column_name, opened_column_name = _SCOPE_COLUMNS[scope]
points_column = getattr(Account, points_column_name)
since = _now(now) - timedelta(days=EXCHANGE_COOLDOWN_DAYS)
conditions: list[Any] = [
Account.status == "active",
Account.ck_check_status == "valid",
Account.cookie.isnot(None),
Account.cookie != "",
Account.auto_managed.is_(True),
pool_membership_clause(),
not_(active_lease_exists_clause()),
not_(latest_exchange_exists_clause(commodity_id, since)),
]
if opened_column_name:
# 未开通的账号系统开不了(要人工扫码),直接排除。
# `NULL` = 没查过,同样排除 —— 不能把「不知道」当「已开通」放行。
conditions.append(getattr(Account, opened_column_name).is_(True))
# 积分未知(没查过)时按 0 处理会把充值量算错,先排除。
conditions.append(points_column.isnot(None))
return select(Account).where(and_(*conditions)), points_column
def eligible_accounts(
db: Session,
*,
scope: str = SCOPE_ELITE,
score: int,
commodity_id: str,
now: datetime | None = None,
limit: int | None = None,
) -> list[Candidate]:
"""筛出可分配账号并按两梯队排序。
`limit` 是为了「要 5 个」时不用把整个池拉出来 —— 但**排序键里有 Python 侧
的表达式**(到期时间、充值量),所以 SQL 只做粗筛 + 有界取行,再在 Python 里排。
取值上限给足余量(默认 `limit*5`,至少 200),否则「积分够的账号排在后面」
会被截断掉。
"""
query, points_column = candidate_query(
scope=scope, score=score, commodity_id=commodity_id, now=now
)
query = query.order_by(points_column.desc(), Account.id.asc())
if limit is not None:
query = query.limit(max(200, int(limit) * 5))
rows = db.execute(query).scalars().all()
candidates = [
make_candidate(
account_id=row.id,
username=row.username,
points=getattr(row, _SCOPE_COLUMNS[scope][0]),
score=score,
points_expire_at=row.points_expire_at,
role_change_available_at=row.role_change_available_at,
)
for row in rows
]
return rank_candidates(candidates)
def estimate_supply(
db: Session,
*,
scope: str = SCOPE_ELITE,
score: int,
commodity_id: str,
now: datetime | None = None,
) -> dict[str, Any]:
"""不做任何锁定,预估「现在能分出几个、要花多少钱」(文档 §3.8)。
返回的四个数在写死的口径下**互斥且可加**:
`zero_topup + (after_topup - zero_topup) + blocked 三项 = 池外无关账号` 不计。
"""
if scope not in _SCOPE_COLUMNS:
raise ValueError(f"暂不支持的作用域: {scope}")
points_column_name, opened_column_name = _SCOPE_COLUMNS[scope]
points_column = getattr(Account, points_column_name)
opened_column = getattr(Account, opened_column_name) if opened_column_name else None
since = _now(now) - timedelta(days=EXCHANGE_COOLDOWN_DAYS)
valid_base = [
Account.status == "active",
Account.ck_check_status == "valid",
Account.cookie.isnot(None),
Account.cookie != "",
Account.auto_managed.is_(True),
pool_membership_clause(),
]
not_redeemed = not_(latest_exchange_exists_clause(commodity_id, since))
def count(*extra) -> int:
return int(
db.execute(
select(func.count(Account.id)).where(and_(*valid_base, *extra))
).scalar()
or 0
)
opened_ok = (
[opened_column.is_(True), points_column.isnot(None)]
if opened_column is not None
else [points_column.isnot(None)]
)
eligible = count(*opened_ok, not_redeemed, not_(active_lease_exists_clause()))
zero_topup = count(
*opened_ok,
not_redeemed,
not_(active_lease_exists_clause()),
points_column >= score,
)
# 已兑过(含仍在冷却期)的账号里,只看「手册已开通」的那部分 ——
# 未开通的账号无论如何都用不了,混进这个数会让人误以为是"冷却挡住了"。
redeemed = count(*opened_ok, latest_exchange_exists_clause(commodity_id, since))
not_opened = (
count(points_column.isnot(None), not_(opened_column.is_(True)))
if opened_column is not None
else 0
)
leased = count(*opened_ok, not_redeemed, active_lease_exists_clause())
gap_expr = func.greatest(0, score - func.coalesce(points_column, 0))
total_gap = int(
db.execute(
select(func.coalesce(func.sum(gap_expr), 0)).where(
and_(
*valid_base,
*opened_ok,
not_redeemed,
not_(active_lease_exists_clause()),
)
)
).scalar()
or 0
)
est_gifts = (total_gap + POINTS_PER_GIFT - 1) // POINTS_PER_GIFT
return {
"scope": scope,
"commodity_id": commodity_id,
"score": int(score),
"zero_topup": zero_topup,
"after_topup": eligible,
"leased": leased,
"blocked": {
f"{scope}_not_opened": not_opened,
"redeemed_within_cooldown": redeemed,
},
"cost": {"est_gifts": est_gifts, "est_yuan": est_gifts},
"cooldown_days": EXCHANGE_COOLDOWN_DAYS,
}
def active_lease_for_account(db: Session, account_id: int) -> AccountLease | None:
return (
db.query(AccountLease)
.filter(
AccountLease.account_id == account_id,
AccountLease.status == "active",
)
.first()
)
def leased_account_ids(
db: Session, *, holder_user_id: int, include_expired: bool = False
) -> list[int]:
"""当前用户持有的有效租约对应的账号 ID。"""
query = db.query(AccountLease.account_id).filter(
AccountLease.holder_user_id == holder_user_id,
AccountLease.status == "active",
)
if not include_expired:
query = query.filter(AccountLease.expires_at > _now())
return [int(row[0]) for row in query.all()]
def lease_accounts(
db: Session,
*,
holder_user_id: int,
candidates: Sequence[Candidate],
scope: str = SCOPE_ELITE,
commodity_id: str = "",
batch_id: str = "",
quantity: int | None = None,
ttl_minutes: int = DEFAULT_LEASE_TTL_MINUTES,
now: datetime | None = None,
) -> list[AccountLease]:
"""按顺序领取租约,返回**真正领到**的那些。
并发抢号由 `uq_lease_active_account` 裁决:每个候选用一个 SAVEPOINT 包住,
撞唯一键就跳过它继续下一个 —— 已领到的不受影响(`rollback()` 会连坐,
所以必须用 `begin_nested()`)。
**本函数会 commit**:租约必须先落库,后续建任务才有依据。
"""
moment = _now(now)
expires_at = moment + timedelta(minutes=max(1, int(ttl_minutes)))
leased: list[AccountLease] = []
for candidate in candidates:
if quantity is not None and len(leased) >= int(quantity):
break
lease = AccountLease(
account_id=candidate.account_id,
holder_user_id=holder_user_id,
scope=scope,
purpose="exchange",
commodity_id=commodity_id,
batch_id=batch_id,
status="active",
leased_at=moment,
expires_at=expires_at,
release_reason="",
created_at=moment,
)
try:
with db.begin_nested():
db.add(lease)
db.flush()
except IntegrityError:
# 撞唯一键 = 别人刚抢走这个账号。这是**预期内**的并发结果,不是故障。
# SAVEPOINT 已经把这一条回滚掉了,直接换下一个候选。
if lease in db:
db.expunge(lease)
continue
leased.append(lease)
db.commit()
return leased
def bind_leases_to_batch(
db: Session, leases: Sequence[AccountLease], batch_id: str
) -> None:
"""把租约与任务批次关联起来(任务创建后才拿得到 batch_id)。"""
if not leases or not batch_id:
return
for lease in leases:
lease.batch_id = batch_id
db.commit()
def release_lease(
db: Session,
lease: AccountLease,
*,
reason: str,
now: datetime | None = None,
) -> bool:
"""释放单个租约;已经是终态则返回 False(幂等)。"""
if lease.status != "active":
return False
lease.status = "released"
lease.released_at = _now(now)
lease.release_reason = str(reason or "")[:64]
db.commit()
return True
def release_for_task(
db: Session,
task: DouyuTask,
*,
reason: str,
now: datetime | None = None,
) -> bool:
"""任务终态时释放它对应账号的租约(文档 §3.9 的即时释放)。
按 `(batch_id, account_id)` 定位:一个批次里每个账号各有一条租约,
不能因为某一条任务结束就把整批的租约都释放掉。
"""
if not task.batch_id:
return False
lease = (
db.query(AccountLease)
.filter(
AccountLease.batch_id == task.batch_id,
AccountLease.account_id == task.account_id,
AccountLease.status == "active",
)
.first()
)
if lease is None:
return False
return release_lease(db, lease, reason=reason, now=now)
def release_many(
db: Session,
leases: Sequence[AccountLease],
*,
reason: str,
now: datetime | None = None,
status: str = "released",
) -> int:
"""批量释放(补位失败、客服放弃、超管强制释放)。
`status` 只允许 `released`(人/流程主动放)与 `expired`(TTL 或孤儿批次兜底)——
两者都会让生成列变 NULL、从而释放唯一索引,区别只在于事后怎么看这件事。
"""
moment = _now(now)
count = 0
for lease in leases:
if lease.status != "active":
continue
lease.status = status
lease.released_at = moment
lease.release_reason = str(reason or "")[:64]
count += 1
if count:
db.commit()
return count
def release_batch(
db: Session, batch_id: str, *, reason: str, now: datetime | None = None
) -> int:
"""释放一个任务批次下的全部有效租约。"""
leases = (
db.query(AccountLease)
.filter(AccountLease.batch_id == batch_id, AccountLease.status == "active")
.all()
)
return release_many(db, leases, reason=reason, now=now)
def reap_expired(
db: Session, *, now: datetime | None = None, batch_size: int = 500
) -> int:
"""把过期租约置为 expired(兜底作业调用)。
只动**已过期**的:正在跑的任务即使超了 TTL 也先留着,
避免把还在执行中的账号放给第二个人 —— TTL 到期的第一批由客服/运营处理,
真正兜底的是「批次已不在内存」那条路径(见 reaper 作业)。
"""
moment = _now(now)
leases = (
db.query(AccountLease)
.filter(AccountLease.status == "active", AccountLease.expires_at <= moment)
.order_by(AccountLease.id.asc())
.limit(max(1, int(batch_size)))
.all()
)
return release_many(db, leases, reason="ttl_expired", now=moment, status="expired")
def orphan_leases(db: Session, *, active_batch_ids: set[str], limit: int = 500):
"""批次已不在内存注册表(进程重启)的有效租约 —— 交给 reaper 释放。"""
query = db.query(AccountLease).filter(AccountLease.status == "active")
if active_batch_ids:
query = query.filter(AccountLease.batch_id.notin_(list(active_batch_ids)))
return query.order_by(AccountLease.id.asc()).limit(max(1, int(limit))).all()
#: 任务还没跑完的状态(与 `douyu_service.DOUYU_ACTIVE_TASK_STATUSES` 同义)。
#: 这里写一份而不是 import,是为了不让纯函数模块反向依赖整条 runner 依赖链。
_ACTIVE_TASK_STATUSES = ("planned", "pending", "running")
def reap_missed_releases(db: Session, *, limit: int = 500) -> int:
"""回收「任务已经终态、租约却还挂着」的租约。
`_mark_task` 的即时释放是主路径,但进程被杀、任务行被保留策略删掉、
释放时抛异常,都会留下悬空租约。判据是**该批次已经没有未完成任务**:
- 批次里还有 `planned/pending/running` → 说明还在跑,不动它;
- `batch_id` 为空 → 租约刚建、任务还没建出来,不动(由 TTL 兜底)。
只动「没有在跑的任务」的租约,所以绝不会把正在执行的账号交给第二个人。
"""
active_batches = select(DouyuTask.batch_id).where(
DouyuTask.status.in_(_ACTIVE_TASK_STATUSES)
)
leases = (
db.query(AccountLease)
.filter(
AccountLease.status == "active",
AccountLease.batch_id != "",
AccountLease.batch_id.notin_(active_batches),
)
.order_by(AccountLease.id.asc())
.limit(max(1, int(limit)))
.all()
)
return release_many(db, leases, reason="task_finished_missed")
def recharge_amounts_for(gold_needed: int) -> list[int]:
"""把一笔充值需求拆成每笔不超过 800 元的订单(文档 §3.4.2)。
返回**空列表**表示不需要充值。单账号一件 1280 分的套装只需要 128 元,
正常不会拆单;一次补很多分才会走到这里。
>>> recharge_amounts_for(0)
[]
>>> recharge_amounts_for(98)
[98]
>>> recharge_amounts_for(1700)
[800, 800, 100]
"""
remaining = max(0, int(gold_needed))
orders: list[int] = []
while remaining > 0:
chunk = min(remaining, MAX_RECHARGE_AMOUNT)
orders.append(chunk)
remaining -= chunk
return orders
@dataclass(frozen=True)
class SupplyStep:
"""补给链里的一步:任务类型 + 该任务自己的载荷。"""
task_type: str
payload: dict
def build_supply_chain(
candidate: Candidate,
*,
commodity_id: str,
exchange_task_type: str = "exchange_goods",
) -> list[SupplyStep]:
"""给一个 B 梯队账号排出「充值 → 赠令 → 兑换」的执行链(纯函数)。
设计要点(都是被现实逼出来的):
1. **先查余额再决定要不要花钱**。链首固定放 `query_gold_balance` /`query_points`:
充值步骤带 `_skip_when` 闸门,依据的是**执行那一刻**账号上的鱼翅/积分;
不先刷新一次,闸门读的是旧值 —— 旧值偏低就会**多充真金白银**。
2. **缺多少充多少**,超过 800 元自动拆单(服务端另有硬校验)。
3. **赠令带 `points >= score` 闸门**:积分已经够了就别再送礼(送出去是钱)。
4. A 梯队(积分已够)**不生成补给步骤**,链上只有兑换一步。
执行顺序由「同一批次 + 并发 1」保证(`DouyuBatchRunner` 按任务 id 顺序取行)。
"""
if not candidate.needs_topup:
return [
SupplyStep(
exchange_task_type,
{"commodity_id": commodity_id, "num": 1},
)
]
# 检查手册开通状态与最新积分
status_step = (
SupplyStep("query_elite_status", {"require_opened": True})
if exchange_task_type == "exchange_goods"
else SupplyStep("query_points", {})
)
steps: list[SupplyStep] = [
status_step,
SupplyStep("query_gold_balance", {}),
]
# 充值:余额够就跳过(闸门在执行时求值,读的是刚刷新过的账号字段)
for amount in recharge_amounts_for(candidate.topup_gifts):
steps.append(
SupplyStep(
"create_gold_qr",
{
"amount": amount,
"_skip_when": {
"column": "gold_balance",
"op": ">=",
"value": candidate.topup_gifts,
},
},
)
)
steps.append(
SupplyStep(
"donate_elite_gift",
{
"gift_count": candidate.topup_gifts,
"_skip_when": {
"column": "points",
"op": ">=",
"value": candidate.score,
},
},
)
)
steps.append(
SupplyStep(exchange_task_type, {"commodity_id": commodity_id, "num": 1})
)
return steps
def validate_recharge_amount(amount: int | None) -> int:
"""校验单次充值金额(元)。
只做**上限**校验,不做下限 —— 下限由调用方按业务给默认值。
"""
value = int(amount or 0)
if value <= 0:
raise ValueError("充值金额必须大于 0")
if value > MAX_RECHARGE_AMOUNT:
raise ValueError(
f"单次充值不得超过 {MAX_RECHARGE_AMOUNT} 元,本次请求 {value} 元"
)
return value
def lease_status_summary(db: Session, *, holder_user_id: int) -> dict[str, int]:
"""给池工作台用的租约概览。"""
moment = _now()
base = db.query(AccountLease).filter(
AccountLease.holder_user_id == holder_user_id,
AccountLease.status == "active",
)
return {
"active": base.count(),
"expiring_soon": base.filter(
AccountLease.expires_at <= moment + timedelta(minutes=10)
).count(),
}
__all__ = [
"DEFAULT_LEASE_TTL_MINUTES",
"EXCHANGE_COOLDOWN_DAYS",
"MAX_RECHARGE_AMOUNT",
"POINTS_PER_GIFT",
"SUPPORTED_POOL_SCOPES",
"Candidate",
"SupplyStep",
"active_lease_for_account",
"bind_leases_to_batch",
"build_supply_chain",
"eligible_accounts",
"estimate_supply",
"lease_accounts",
"lease_status_summary",
"leased_account_ids",
"make_candidate",
"orphan_leases",
"rank_candidates",
"reap_expired",
"reap_missed_releases",
"recharge_amounts_for",
"release_batch",
"release_for_task",
"release_lease",
"release_many",
"topup_gifts_for",
"validate_recharge_amount",
]