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
This commit is contained in:
yml2213
2026-10-07 12:29:08 +08:00
parent 281769b440
commit 18bc7be4b1
38 changed files with 5143 additions and 35 deletions
+96 -1
View File
@@ -3,10 +3,11 @@
from __future__ import annotations
import asyncio
import operator
import threading
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, ClassVar
from loguru import logger
from sqlalchemy.orm import Session
@@ -24,6 +25,7 @@ from ..models import (
from ..models import (
ProxyConfig as ProxyConfigModel,
)
from .account_pool import release_for_task
from .douyu_service import (
DOUYU_CONFIG_FIELDS,
douyu_config_value,
@@ -36,6 +38,9 @@ if TYPE_CHECKING:
# 支付/到账轮询(手册与充值共用)
DOUYU_PAYMENT_POLL_SECONDS = 600
#: 支付等待超时后的状态:**转人工**,不再自动轮询,租约也先不还。
MANUAL_REQUIRED_STATUS = "manual_required"
DOUYU_PAYMENT_POLL_INTERVAL = 5
# 走代理的任务类型:写操作/消耗类(兑换、锁单、支付、开通、充值、送礼)。
@@ -219,8 +224,98 @@ class DouyuBatchRunnerCore:
task.result = result
task.finished_at = datetime.now(UTC)
db.commit()
# 账号池:任务走到终态就**把租约还回去**(设计 §3.9 的即时释放)。
# 这一步覆盖 99% 的情况;漏掉的(进程被杀、任务行被清)由
# `account_lease_reaper` 兜底。释放失败绝不能影响任务结果本身。
self._release_pool_lease(db, task)
self._push_task_event(task)
def _release_pool_lease(self, db: Session, task: DouyuTask) -> None:
"""释放该任务对应账号的租约(不是整批 —— 同批每个账号各有一条)。"""
try:
if not task.batch_id:
return
# 转人工的任务**先不还号**:客服还在等支付/处理,账号已经挂在这单上。
# 同理,同一批次里只要还有 `manual_required` 的任务(补给链后半段会被
# 前置闸门标成 skipped),整条链的号都先留着。
# 账号不会被永久占住 —— 租约到期由 `account_lease_reaper` 兜底收回。
if task.status == MANUAL_REQUIRED_STATUS:
return
waiting_manual = (
db.query(DouyuTask.id)
.filter(
DouyuTask.batch_id == task.batch_id,
DouyuTask.status == MANUAL_REQUIRED_STATUS,
)
.first()
)
if waiting_manual:
return
release_for_task(db, task, reason=f"task_{task.status}")
except Exception as exc: # noqa: BLE001 旁路动作,绝不能带崩任务
logger.warning(f"释放账号租约失败 task={task.id}: {exc}")
# ── 补给链的两个运行期闸门(见 account_pool.build_supply_chain)──────────
#: `_skip_when` 支持的账号字段与比较符。
_SKIP_COLUMNS: ClassVar[tuple[str, ...]] = (
"points",
"gold_balance",
"exchange_balance",
"esports_points",
)
_SKIP_OPS: ClassVar[dict[str, Any]] = {
">=": operator.ge,
">": operator.gt,
"<=": operator.le,
"<": operator.lt,
"==": operator.eq,
}
def _skip_reason(self, account: Account, task: DouyuTask) -> str:
"""`_skip_when` 闸门:满足条件就跳过这一步。
补给链用它实现「余额已经够了就别再充钱」。**只在能确定时跳过**:
字段缺失、比较符不认识、值解析失败一律不跳(宁可多跑一步,
也不要因为解析不出来就静默把钱省掉/花掉)。
"""
rule = self._task_payload(task).get("_skip_when")
if not isinstance(rule, dict):
return ""
column = str(rule.get("column") or "")
if column not in self._SKIP_COLUMNS:
return ""
compare = self._SKIP_OPS.get(str(rule.get("op") or ""))
if compare is None:
return ""
current = getattr(account, column, None)
if current is None:
return ""
try:
target = int(rule.get("value"))
except (TypeError, ValueError):
return ""
if compare(int(current), target):
return f"跳过:{column}={int(current)} {rule.get('op')} {target} 已满足"
return ""
def _dependency_reason(self, db: Session, task: DouyuTask) -> str:
"""`_depends_on` 闸门:前置任务没成功就不必再跑(省得连着一串失败)。"""
raw = self._task_payload(task).get("_depends_on")
if not raw:
return ""
try:
depends_on = int(raw)
except (TypeError, ValueError):
return ""
previous = db.query(DouyuTask).filter(DouyuTask.id == depends_on).first()
if previous is None:
return ""
# 前置「成功」或「被跳过」都算通过:跳过意味着那一步本来就不需要做。
if previous.status in ("success", "skipped"):
return ""
return f"前置任务 #{depends_on} 未成功({previous.status}),跳过"
def _update_task_progress(
self,
db: Session,