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:
@@ -0,0 +1,734 @@
|
||||
"""账号池:候选筛选、两梯队排序、供给预估、租约领取与释放。
|
||||
|
||||
配套文档:`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
|
||||
|
||||
#: 租约默认有效期(分钟)。到点由 `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"),
|
||||
}
|
||||
|
||||
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},
|
||||
)
|
||||
]
|
||||
|
||||
steps: list[SupplyStep] = [
|
||||
SupplyStep("query_gold_balance", {}),
|
||||
SupplyStep("query_points", {}),
|
||||
]
|
||||
# 充值:余额够就跳过(闸门在执行时求值,读的是刚刷新过的账号字段)
|
||||
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",
|
||||
]
|
||||
@@ -6,6 +6,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .jobs.account_lease_reaper import (
|
||||
JOB_KEY as ACCOUNT_LEASE_REAPER_KEY,
|
||||
)
|
||||
from .jobs.account_lease_reaper import (
|
||||
run_account_lease_reaper,
|
||||
)
|
||||
from .jobs.account_overview_refresh import (
|
||||
JOB_KEY as ACCOUNT_OVERVIEW_REFRESH_KEY,
|
||||
)
|
||||
@@ -39,6 +45,7 @@ from .scheduler import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ACCOUNT_LEASE_REAPER_KEY",
|
||||
"ACCOUNT_OVERVIEW_REFRESH_KEY",
|
||||
"CK_HEALTH_KEY",
|
||||
"CK_RELOGIN_KEY",
|
||||
@@ -51,6 +58,7 @@ __all__ = [
|
||||
"register_job",
|
||||
"registered_jobs",
|
||||
"reset_registry",
|
||||
"run_account_lease_reaper",
|
||||
"run_account_overview_refresh",
|
||||
"run_ck_health",
|
||||
"run_ck_relogin",
|
||||
@@ -140,3 +148,20 @@ def register_default_jobs() -> None:
|
||||
run=run_ck_health,
|
||||
)
|
||||
)
|
||||
register_job(
|
||||
JobSpec(
|
||||
key=ACCOUNT_LEASE_REAPER_KEY,
|
||||
title="账号租约回收",
|
||||
# 租约的主释放路径是任务终态钩子;本作业只是**保险丝**,
|
||||
# 兜住「进程重启 / 释放抛异常 / 任务行被清」三种漏网情况。
|
||||
# 跑得勤一点(1 分钟)成本极低:只扫带索引的 status+expires_at,
|
||||
# 而且悬空租约会让账号白白锁着 —— 早收早能用。
|
||||
# `interval_seconds` 对它是无意义的(本作业自己按三类规则选行),
|
||||
# 但 JobSpec 要求必填,给一个与 run_interval 同量级的值。
|
||||
interval_seconds=60,
|
||||
run_interval_seconds=60,
|
||||
batch_size=500,
|
||||
concurrency=1,
|
||||
run=run_account_lease_reaper,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""作业:兜底回收账号租约(账号池的「自动收回」保险丝)。
|
||||
|
||||
## 为什么必须有它
|
||||
|
||||
租约的主释放路径是 `_mark_task` 写终态时的即时钩子(`release_for_task`),
|
||||
覆盖 99% 的情况。但下面三种情况会留下**悬空租约**,而这个账号会一直显示
|
||||
"被占用"、谁也领不走:
|
||||
|
||||
1. **进程被杀 / 重启**:执行器线程没了,任务永远停在 `pending/running`;
|
||||
2. **释放时抛异常**:`_mark_task` 里那一步是旁路,失败只记 warning;
|
||||
3. **任务行被保留策略删掉**:批次看着"没有未完成任务",但钩子再也不会跑。
|
||||
|
||||
本作业按三条规则收:
|
||||
|
||||
| 规则 | 判据 | 置为 |
|
||||
| --- | --- | --- |
|
||||
| TTL 到期 | `expires_at <= now` | `expired` |
|
||||
| 漏释放 | 批次**已无**未完成任务(`planned/pending/running`) | `released` |
|
||||
| 孤儿批次 | 批次不在**本进程**的执行器注册表里 | `expired` |
|
||||
|
||||
## ⚠️ 部署前提
|
||||
|
||||
「孤儿批次」这条规则依赖**单进程部署**:注册表 `douyu_batch_registry` 在内存里,
|
||||
只有跑这个批次的那个进程知道它。生产是 `uvicorn --workers 1`(见
|
||||
`docker-entrypoint.sh`),所以成立。
|
||||
|
||||
**如果将来改成多 worker,这条规则会误杀别的 worker 正在跑的批次** ——
|
||||
到那时必须换成落库的批次心跳(或在 `douyu_tasks` 上记执行者标识),
|
||||
不能只靠内存注册表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ....models import AutomationJob
|
||||
from ...account_pool import (
|
||||
orphan_leases,
|
||||
reap_expired,
|
||||
reap_missed_releases,
|
||||
release_many,
|
||||
)
|
||||
|
||||
JOB_KEY = "account_lease_reaper"
|
||||
|
||||
#: 一轮最多处理多少条租约(三类各用这个上限)。
|
||||
DEFAULT_BATCH = 500
|
||||
|
||||
|
||||
def _positive_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed if parsed > 0 else default
|
||||
|
||||
|
||||
def run_account_lease_reaper(
|
||||
db: Session, job: AutomationJob, *, now: datetime | None = None
|
||||
) -> dict[str, Any]:
|
||||
batch = _positive_int(job.batch_size, DEFAULT_BATCH)
|
||||
moment = now or datetime.now(UTC)
|
||||
|
||||
# 延迟导入:作业模块在应用启动时被加载,顶层导入 runner 会把
|
||||
# 「路由 → runner → account_pool」整条链在启动期拉起来,容易出循环导入。
|
||||
from ....services.douyu_runner import douyu_batch_registry
|
||||
|
||||
# ① TTL 到期
|
||||
expired = reap_expired(db, now=moment, batch_size=batch)
|
||||
# ② 任务已终态但租约没释放
|
||||
missed = reap_missed_releases(db, limit=batch)
|
||||
# ③ 批次已不在本进程注册表(进程重启)
|
||||
active_batch_ids = douyu_batch_registry.active_ids()
|
||||
orphans = orphan_leases(db, active_batch_ids=active_batch_ids, limit=batch)
|
||||
orphaned = release_many(db, orphans, reason="orphan_batch", status="expired")
|
||||
|
||||
total = expired + missed + orphaned
|
||||
message = (
|
||||
f"租约回收 {total} 条"
|
||||
f"(TTL 到期 {expired} / 漏释放 {missed} / 孤儿批次 {orphaned})"
|
||||
)
|
||||
if total:
|
||||
logger.info(message)
|
||||
return {
|
||||
"scanned": total,
|
||||
"expired": expired,
|
||||
"missed_release": missed,
|
||||
"orphan_batch": orphaned,
|
||||
"active_batches": len(active_batch_ids),
|
||||
"message": message,
|
||||
}
|
||||
@@ -64,6 +64,23 @@ def task_ref(task_id: int, kind: str = "") -> str:
|
||||
return f"{base}:{kind}" if kind else base
|
||||
|
||||
|
||||
def task_actor(task: Any) -> dict[str, Any]:
|
||||
"""业务事件的「谁做的」—— 发起这条任务的用户。
|
||||
|
||||
为什么用 `task.created_by`:任务由客服在界面上发起(池路径下也就是**租用该账号
|
||||
的客服**),所以它就是操作人。以前所有业务事件(充值/兑换/开通/积分)都不带这个
|
||||
字段,生产 34,462 条事件里有 24,502 条(71%)查不出责任人 —— 池化之后
|
||||
「客服租用期间做了什么」是必须能回答的,所以补齐。
|
||||
|
||||
调用处一律用 `**task_actor(task)`,默认值仍是「无操作人」,
|
||||
因此老调用方一行不改也不会变。
|
||||
"""
|
||||
created_by = getattr(task, "created_by", None)
|
||||
if not created_by:
|
||||
return {"actor_type": "system", "actor_id": None}
|
||||
return {"actor_type": "user", "actor_id": int(created_by)}
|
||||
|
||||
|
||||
def record_recharge(
|
||||
db: Session,
|
||||
account: Account,
|
||||
@@ -77,6 +94,8 @@ def record_recharge(
|
||||
douyu_uid: str = "",
|
||||
balance_after: int | None = None,
|
||||
confirmed: bool = True,
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""充值鱼翅到账。
|
||||
|
||||
@@ -110,6 +129,8 @@ def record_recharge(
|
||||
subject_type="recharge",
|
||||
subject_key=order_id or supplier_order_id or "",
|
||||
source_ref=task_ref(task_id),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +149,8 @@ def record_exchange(
|
||||
game_channel: str = "",
|
||||
points_after: int | None = None,
|
||||
scope: str = "elite",
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""商品/皮肤兑换成功 —— **只在真的消耗了积分并兑换成功时调用**。
|
||||
|
||||
@@ -160,6 +183,8 @@ def record_exchange(
|
||||
subject_type="commodity",
|
||||
subject_key=commodity_id or name or "",
|
||||
source_ref=task_ref(task_id),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -174,6 +199,8 @@ def record_gold_spent(
|
||||
source_label: str = "",
|
||||
balance_after: int | None = None,
|
||||
confirmed: bool = True,
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""鱼翅消耗(送礼等)。
|
||||
|
||||
@@ -203,6 +230,8 @@ def record_gold_spent(
|
||||
subject_type="gold",
|
||||
# 副事件:同一个任务还会发「积分到账」,所以带后缀避免撞幂等键
|
||||
source_ref=task_ref(task_id, "gold"),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -214,6 +243,8 @@ def record_points_expire(
|
||||
occurred_at: datetime | None = None,
|
||||
expires_at: Any = None,
|
||||
confirmed: bool = False,
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""积分到期(资产减少)。
|
||||
|
||||
@@ -236,6 +267,8 @@ def record_points_expire(
|
||||
detail={"confirmed": confirmed, "expires_at": expires_at},
|
||||
source="points_expire",
|
||||
subject_type="points",
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -250,6 +283,8 @@ def record_points_credit(
|
||||
expire_at: Any = None,
|
||||
confirmed: bool = True,
|
||||
source_label: str = "精英令",
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""积分到账(送礼/任务发放)。
|
||||
|
||||
@@ -281,6 +316,8 @@ def record_points_credit(
|
||||
source="douyu_task",
|
||||
subject_type="points",
|
||||
source_ref=task_ref(task_id),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -295,6 +332,8 @@ def record_bind(
|
||||
act_alias: str = "",
|
||||
rebind: bool = False,
|
||||
weak: bool = False,
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""角色绑定 / 换绑**真的发生了**。
|
||||
|
||||
@@ -332,6 +371,8 @@ def record_bind(
|
||||
subject_type=f"{scope}_role",
|
||||
subject_key=role_name or "",
|
||||
source_ref=task_ref(task_id),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -346,6 +387,8 @@ def record_official_exchange(
|
||||
show_name: str = "",
|
||||
exchange_num: int | None = None,
|
||||
scope: str = "elite",
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""把斗鱼官方兑换记录写成账号事件。
|
||||
|
||||
@@ -386,10 +429,21 @@ def record_official_exchange(
|
||||
"scope": scope,
|
||||
},
|
||||
source=OFFICIAL_SYNC_SOURCE,
|
||||
subject_type="goods",
|
||||
# 台账检索键必须是**斗鱼商品 ID**:365 天规则与 `ix_account_events_ledger`
|
||||
# 都按 `subject_key` 判「该账号该商品最近一次兑换」。
|
||||
#
|
||||
# 2026-10-07 修:这里以前漏传 `subject_key`(只设了 subject_type="goods"),
|
||||
# 于是 10,612 条官方补录事件的主键全是空串 —— 用 subject_key 查历史会漏掉
|
||||
# 87% 的记录,365 天规则形同虚设。`subject_type` 同时统一为 commodity,
|
||||
# 与 `record_exchange`(我方任务直发)保持一致。
|
||||
# 幂等靠下面的 `source_ref`,与 subject_key 无关,改这里不会造成重复。
|
||||
subject_type="commodity",
|
||||
subject_key=commodity_id,
|
||||
source_ref=(
|
||||
f"official_exchange:{scope}:{account.id}:{commodity_id}:{_stamp(occurred_at)}"
|
||||
),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -59,6 +59,18 @@ class DouyuBatchRunner(
|
||||
self._push_log(
|
||||
"info", f"[{current}/{total}] 开始: {self._account_name(account)}"
|
||||
)
|
||||
# 补给链的两个运行期闸门(见 `account_pool.build_supply_chain`):
|
||||
# `_depends_on` 前置没成功就别跑,`_skip_when` 条件已满足就别做(省的是钱)。
|
||||
# 放在 CK 检测**之前** —— 被跳过的任务不该因为 CK 失效再报一次失败。
|
||||
skip_reason = self._dependency_reason(worker_db, task) or self._skip_reason(
|
||||
account, task
|
||||
)
|
||||
if skip_reason:
|
||||
self._mark_task(worker_db, task, "skipped", skip_reason)
|
||||
self._push_log(
|
||||
"info", f"[{current}] {self._account_name(account)} {skip_reason}"
|
||||
)
|
||||
return
|
||||
# CK 与健康状态都挂在账号上(2026-10-04 CK 解耦)。
|
||||
cookie = account.cookie or ""
|
||||
if not cookie:
|
||||
|
||||
@@ -19,7 +19,7 @@ from .account_binding import (
|
||||
SCOPE_ESPORTS,
|
||||
set_bind_status,
|
||||
)
|
||||
from .business_events import record_bind
|
||||
from .business_events import record_bind, task_actor
|
||||
|
||||
DOUYU_LEGACY_BIND_ACT_ALIAS = "20250213NQCYX"
|
||||
DOUYU_BIND_ROLE_POLL_SECONDS = 65
|
||||
@@ -932,6 +932,7 @@ class BindMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
scope="elite",
|
||||
role_name=role_name,
|
||||
act_alias=use_confirm_alias,
|
||||
@@ -968,6 +969,7 @@ class BindMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
scope="elite",
|
||||
role_name=final_role_name,
|
||||
act_alias=use_confirm_alias,
|
||||
@@ -1125,6 +1127,7 @@ class BindMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
scope="esports",
|
||||
role_name=role_text,
|
||||
act_alias=act_alias,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
||||
from core.douyu import DouyuActivityClient
|
||||
|
||||
from ..models import Account, DouyuTask
|
||||
from .business_events import record_gold_spent, record_points_credit
|
||||
from .business_events import record_gold_spent, record_points_credit, task_actor
|
||||
|
||||
DOUYU_GIFT_POINTS_REFRESH_TIMES = 3
|
||||
DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2
|
||||
@@ -197,6 +197,7 @@ class DonateMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
gain=_esports_gift_points(result, baseline_points),
|
||||
points_after=account.esports_points,
|
||||
confirmed=confirmed,
|
||||
@@ -206,6 +207,7 @@ class DonateMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
amount=_gold_spent(baseline_gold, account.gold_balance),
|
||||
reason=f"赠送{gift_name} x{gift_count}",
|
||||
source_label=f"赠送{gift_name}",
|
||||
@@ -332,6 +334,7 @@ class DonateMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
gain=max(0, gift_count) * 10,
|
||||
points_after=account.points,
|
||||
expire_at=expire_at,
|
||||
@@ -344,6 +347,7 @@ class DonateMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
amount=_gold_spent(baseline_gold, account.gold_balance),
|
||||
reason=f"赠送精英令 x{gift_count}",
|
||||
source_label="赠送精英令",
|
||||
|
||||
@@ -18,8 +18,13 @@ from core.douyu import (
|
||||
)
|
||||
|
||||
from ..models import Account, DouyuTask
|
||||
from .business_events import record_recharge
|
||||
from .douyu_runner_core import DOUYU_PAYMENT_POLL_INTERVAL, DOUYU_PAYMENT_POLL_SECONDS
|
||||
from .account_pool import validate_recharge_amount
|
||||
from .business_events import record_recharge, task_actor
|
||||
from .douyu_runner_core import (
|
||||
DOUYU_PAYMENT_POLL_INTERVAL,
|
||||
DOUYU_PAYMENT_POLL_SECONDS,
|
||||
MANUAL_REQUIRED_STATUS,
|
||||
)
|
||||
from .douyu_service import update_account_profile_from_cookie
|
||||
|
||||
|
||||
@@ -247,7 +252,15 @@ class GoldMixin:
|
||||
self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict
|
||||
):
|
||||
payload = self._task_payload(task)
|
||||
amount = int(payload.get("amount") or payload.get("gold_amount") or 1)
|
||||
# 单次充值上限(元)。**必须服务端校验**:前端限制只是体验,
|
||||
# 任务可以绕过界面直接建(`POST /api/douyu/tasks/batch` 的 payload 是自由字典)。
|
||||
try:
|
||||
amount = validate_recharge_amount(
|
||||
int(payload.get("amount") or payload.get("gold_amount") or 1)
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._mark_task(db, task, "failed", str(exc))
|
||||
return
|
||||
channel = str(config.get("gold_recharge_channel") or "wechat_qr")
|
||||
if channel == "supplier_api":
|
||||
try:
|
||||
@@ -289,6 +302,7 @@ class GoldMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
amount=amount,
|
||||
channel=channel,
|
||||
balance_after=account.gold_balance,
|
||||
@@ -305,8 +319,9 @@ class GoldMixin:
|
||||
self._mark_task(
|
||||
db,
|
||||
task,
|
||||
"failed",
|
||||
f"未检测到鱼翅到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}",
|
||||
MANUAL_REQUIRED_STATUS,
|
||||
f"等待 {DOUYU_PAYMENT_POLL_SECONDS // 60} 分钟未检测到鱼翅到账,已转人工处理"
|
||||
f"(当前余额: {account.gold_balance if account.gold_balance is not None else '-'})",
|
||||
result,
|
||||
)
|
||||
|
||||
@@ -446,6 +461,7 @@ class GoldMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
amount=amount,
|
||||
order_id=str(result.get("out_order_id") or ""),
|
||||
supplier_order_id=str(result.get("order_id") or ""),
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
|
||||
from core.douyu import DouyuActivityClient, DouyuActivityError
|
||||
|
||||
from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask
|
||||
from .business_events import record_exchange
|
||||
from .business_events import record_exchange, task_actor
|
||||
|
||||
# 兑换节奏与重试(对齐 8.30 浏览器抓包:锁单->支付间隔约 2.2~4.2s;火爆类错误要长退避而不是秒级连打)
|
||||
DOUYU_EXCHANGE_PRE_CREATE_JITTER = (0.3, 1.2)
|
||||
@@ -214,6 +214,7 @@ class GoodsMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
name=(goods.name if goods else "") or commodity_id or order_id,
|
||||
score=score,
|
||||
commodity_id=commodity_id,
|
||||
@@ -520,6 +521,7 @@ class GoodsMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
name=name,
|
||||
score=locked.get("score"),
|
||||
commodity_id=commodity_id,
|
||||
@@ -635,6 +637,7 @@ class GoodsMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
name=name,
|
||||
score=spent if spent and spent > 0 else None,
|
||||
commodity_id=commodity_id,
|
||||
|
||||
@@ -34,7 +34,7 @@ from core.douyu import DouyuActivityClient
|
||||
|
||||
from ..models import Account, DouyuTask
|
||||
from .account_binding import derive_from_bind_info, set_bind_status
|
||||
from .business_events import record_official_exchange
|
||||
from .business_events import record_official_exchange, task_actor
|
||||
from .douyu_runner_donate import _parse_expire_at, _points_expire_at
|
||||
|
||||
#: 默认关注的高价值商品。按**商品名包含**匹配 —— 官方列表里的名字带前缀
|
||||
@@ -151,12 +151,18 @@ def _group_by_scope(
|
||||
|
||||
|
||||
def _record_official_exchanges(
|
||||
db: Session, account: Account, pairs: list[tuple[str, dict[str, Any]]]
|
||||
db: Session,
|
||||
account: Account,
|
||||
pairs: list[tuple[str, dict[str, Any]]],
|
||||
task: Any = None,
|
||||
) -> int:
|
||||
"""把官方兑换记录补成账号事件,返回**新写入**的条数。
|
||||
|
||||
幂等由 `record_official_exchange` 的 `source_ref` 保证(含作用域),
|
||||
所以一键查询反复执行不会重复落事件。
|
||||
|
||||
`task` 只用来取**操作人**(`task.created_by`)—— 谁点的这次一键查询,
|
||||
这条兑换历史就记在谁名下。不传则退化为「无操作人」,与老行为一致。
|
||||
"""
|
||||
added = 0
|
||||
for scope, rec in pairs:
|
||||
@@ -181,6 +187,7 @@ def _record_official_exchanges(
|
||||
show_name=str(rec.get("showName") or rec.get("show_name") or ""),
|
||||
exchange_num=rec.get("exchangeNum") or rec.get("count"),
|
||||
scope=scope,
|
||||
**task_actor(task),
|
||||
):
|
||||
added += 1
|
||||
return added
|
||||
@@ -511,7 +518,7 @@ class OverviewMixin:
|
||||
overview["exchange_record_count"] = len(all_records)
|
||||
# 把官方记录**补成账号事件**(带真实兑换时间、按作用域去重)。
|
||||
overview["exchange_events_added"] = _record_official_exchanges(
|
||||
db, account, all_records
|
||||
db, account, all_records, task
|
||||
)
|
||||
overview["exchange_records"] = {
|
||||
scope: rows[:EXCHANGE_RECORD_LIMIT]
|
||||
|
||||
@@ -18,7 +18,7 @@ from .account_binding import (
|
||||
SCOPE_PEACE,
|
||||
set_bind_status,
|
||||
)
|
||||
from .business_events import record_exchange
|
||||
from .business_events import record_exchange, task_actor
|
||||
|
||||
DOUYU_XPD_BIND_POLL_SECONDS = 300
|
||||
DOUYU_XPD_BIND_POLL_INTERVAL = 5
|
||||
@@ -591,6 +591,7 @@ class XpdMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
name=goods.name or commodity_id,
|
||||
score=price,
|
||||
commodity_id=commodity_id,
|
||||
|
||||
@@ -265,6 +265,58 @@ def account_uid(account: Account, cookie: str) -> str:
|
||||
return account.uid or cookie_value(cookie, "acf_uid")
|
||||
|
||||
|
||||
def create_douyu_task(
|
||||
db: Session,
|
||||
account: Account,
|
||||
task_type: str,
|
||||
handbook_scope: str,
|
||||
created_by: int,
|
||||
payload: dict | None = None,
|
||||
*,
|
||||
batch_id: str | None = None,
|
||||
account_assigned_to: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> DouyuTask:
|
||||
"""建**一条**斗鱼任务(单账号、单类型、载荷可各账号不同)。
|
||||
|
||||
存在的理由:`create_douyu_planned_tasks` 给一批账号**共用一个** `payload`,
|
||||
而账号池的补给链每个账号的充值额/赠令数都不一样,共用载荷根本表达不了。
|
||||
|
||||
`batch_id` 传值即并入该批次 —— 执行器按批取任务、按任务自己的类型分发,
|
||||
所以**一个批次里可以放不同类型的任务**(这正是补给链的实现方式)。
|
||||
顺序由「同一批次 + 并发 1」保证(见 `routers/douyu.py::start_douyu_batch`)。
|
||||
"""
|
||||
if task_type not in SUPPORTED_DOUYU_TASK_TYPES:
|
||||
raise ValueError("不支持的任务类型")
|
||||
if handbook_scope not in DOUYU_HANDBOOK_SCOPES:
|
||||
raise ValueError("无效的工作台")
|
||||
if task_type not in DOUYU_HANDBOOK_TASK_TYPES[handbook_scope]:
|
||||
raise ValueError("该任务不属于当前工作台")
|
||||
|
||||
task = DouyuTask(
|
||||
batch_id=batch_id or uuid.uuid4().hex[:12],
|
||||
account_id=account.id,
|
||||
account_assigned_to=(
|
||||
account_assigned_to
|
||||
if account_assigned_to is not None
|
||||
else account.assigned_to
|
||||
),
|
||||
task_type=task_type,
|
||||
handbook_scope=handbook_scope,
|
||||
status="planned",
|
||||
message="任务已创建,等待执行",
|
||||
result={"payload": payload} if payload else None,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(task)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
else:
|
||||
db.flush()
|
||||
return task
|
||||
|
||||
|
||||
def create_douyu_planned_tasks(
|
||||
db: Session,
|
||||
account_ids: list[int],
|
||||
@@ -272,8 +324,20 @@ def create_douyu_planned_tasks(
|
||||
handbook_scope: str,
|
||||
created_by: int,
|
||||
payload: dict | None = None,
|
||||
*,
|
||||
account_assigned_to_override: int | None = None,
|
||||
) -> tuple[str, int]:
|
||||
"""创建斗鱼任务记录,等待后台执行器消费。"""
|
||||
"""创建斗鱼任务记录,等待后台执行器消费。
|
||||
|
||||
`account_assigned_to_override` **只给账号池路径用**:池化后账号归属是
|
||||
管理员/运营,若仍按归属写,租用该账号的客服在任务列表里**看不到自己提交的任务**
|
||||
—— 列表对客服的过滤正是 `DouyuTask.account_assigned_to == 我`
|
||||
(见 `routers/douyu.py` 的 `_visible_tasks_query`)。
|
||||
|
||||
这一列本来就是「账号归属客服冗余字段,配合复合索引消除任务列表全表 filesort」,
|
||||
实际作用是「谁能在列表里看到这条任务」。池路径写**租用人**既保住索引命中,
|
||||
又让客服看得到自己的任务。**默认不传 = 保持旧行为**,旧路径完全不受影响。
|
||||
"""
|
||||
if task_type not in SUPPORTED_DOUYU_TASK_TYPES:
|
||||
raise ValueError("不支持的任务类型")
|
||||
if handbook_scope not in DOUYU_HANDBOOK_SCOPES:
|
||||
@@ -296,7 +360,11 @@ def create_douyu_planned_tasks(
|
||||
DouyuTask(
|
||||
batch_id=batch_id,
|
||||
account_id=account.id,
|
||||
account_assigned_to=account.assigned_to,
|
||||
account_assigned_to=(
|
||||
account_assigned_to_override
|
||||
if account_assigned_to_override is not None
|
||||
else account.assigned_to
|
||||
),
|
||||
task_type=task_type,
|
||||
handbook_scope=handbook_scope,
|
||||
status="planned",
|
||||
|
||||
Reference in New Issue
Block a user