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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user