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
+734
View File
@@ -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,
}
+55 -1
View File
@@ -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,
)
+12
View File
@@ -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:
+4 -1
View File
@@ -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,
+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,
+5 -1
View File
@@ -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="赠送精英令",
+21 -5
View File
@@ -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 ""),
+4 -1
View File
@@ -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,
+10 -3
View File
@@ -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]
+2 -1
View File
@@ -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,
+70 -2
View File
@@ -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",