Files
yml2213 18bc7be4b1 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
2026-10-07 12:29:08 +08:00

453 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""业务事实的**命名构造**:充值 / 兑换 / 积分到账 / 绑定。
## 为什么单独一个模块
`emit()` 只负责「往时间线写一条」,它不知道「一次兑换」到底该长什么样。
以前这件事由收集器从 `douyu_tasks.result` 的 JSON 里**反推**(猜字段路径),
结果是:
- 新增业务类型没人想起改 allowlist → **静默丢数据**
(电竞兑换、和平小店兑换、锁单支付、电竞礼物积分从来没进过时间线);
- 同一件事的字段口径散在 runner 与收集器两处,容易分叉;
- `success` 不等于「业务真的发生了」时,把假成功也记成事实。
现在反过来:**业务代码在动作成功的那一刻,用这里的构造函数声明事实**。
每类事实的字段、标题、金额符号只定义一次。
## 两条硬规则
1. **必须紧贴 `_mark_task(..., "success", ...)` 之前调用。**
`_mark_task` 内部 `db.commit()`,而 `emit()` 用 `db.begin_nested()` 只 RELEASE
SAVEPOINT、不提交外层事务;`douyu_runner` 的 `finally` 只 `close()`(未提交即回滚)。
在 `_mark_task` **之后**发事件会被静默回滚 —— 而且 `emit()` 返回 True、无任何日志。
放在之前还有个好处:事件与任务状态**同一次提交**,原子。
2. **幂等键用 `task_ref(task.id)`**,与旧收集器的 key 完全一致。
于是迁移窗口内「直发」与「仍在跑的收集器」会自动去重,可以安全并存后再删作业。
⚠️ 不要用秒级时间戳当幂等键:撞唯一键会让 `emit()` 静默吞掉事件
(见 `account_lifecycle` 里那段复盘)。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy.orm import Session
from ..models import Account
from .account_events_service import (
CATEGORY_BUSINESS,
EVENT_BIND_OK,
EVENT_EXCHANGE,
EVENT_GOLD_SPENT,
EVENT_POINTS_CREDIT,
EVENT_POINTS_EXPIRE,
EVENT_RECHARGE,
OFFICIAL_SYNC_SOURCE,
emit,
to_naive_utc,
)
def task_ref(task_id: int, kind: str = "") -> str:
"""业务事件的幂等键:一条任务行对应一条事实。
与旧收集器(`f"douyu_task:{row['id']}"`)**完全一致**,所以直发与收集器
同时观测到同一笔业务时只会写一条 —— 这让「先直发、观察一段、再删收集器」
成为安全的迁移路径。
"""
base = f"douyu_task:{int(task_id)}"
# 同一个任务可能产生**多条**事件(例如送礼:既「积分到账」又「消耗鱼翅」)。
# 它们共用 task_ref 会撞唯一键、后写的那条被静默丢弃 —— 所以副事件带一个后缀。
# 不带后缀的形态与旧收集器一致,主事件继续沿用它。
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,
*,
task_id: int,
amount: int | None,
occurred_at: datetime | None = None,
order_id: str = "",
supplier_order_id: str = "",
channel: str = "",
douyu_uid: str = "",
balance_after: int | None = None,
confirmed: bool = True,
actor_type: str = "",
actor_id: int | None = None,
) -> bool:
"""充值鱼翅到账。
`amount` 用**本地已知的充值金额**,不要从 `result` 里找 —— 二维码链路的
`result` 里根本没有金额(`create_gold_qr` 只返回 `pay_url/token/raw`),
旧收集器因此在二维码充值上恒写 `amount=None`,导致 `accounts.recharge_total`
系统性少计。
`confirmed=False` 表示只有供应商的终态回执、没有核对斗鱼余额变化。
"""
title = f"充值 {amount}" if amount is not None else "充值到账"
if not confirmed:
title += "(供应商回执)"
return emit(
db,
account_id=account.id,
event_type=EVENT_RECHARGE,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=amount,
balance_after=balance_after,
detail={
"order_id": order_id,
"supplier_order_id": supplier_order_id,
"channel": channel,
"douyu_uid": douyu_uid,
"confirmed": confirmed,
},
source="douyu_task",
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,
)
def record_exchange(
db: Session,
account: Account,
*,
task_id: int,
name: str,
score: int | None,
occurred_at: datetime | None = None,
commodity_id: str = "",
order_id: str = "",
exchange_id: str = "",
game_name: str = "",
game_channel: str = "",
points_after: int | None = None,
scope: str = "elite",
actor_type: str = "",
actor_id: int | None = None,
) -> bool:
"""商品/皮肤兑换成功 —— **只在真的消耗了积分并兑换成功时调用**。
注意「预约到货 / 已预兑 / 预兑确认」这三类也把任务标成 `success`,但它们
**不消耗积分、也没兑换到东西**,绝不能走这里(旧收集器只按 `status=="success"`
过滤,于是每预约一次就生成一条金额为空的 `exchange`,把 `exchange_count` 灌大)。
`score` 为负写入 `amount`(消耗),`points_after` 是兑换后的积分余额。
"""
return emit(
db,
account_id=account.id,
event_type=EVENT_EXCHANGE,
title=f"兑换 {name}" if name else "兑换",
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=-int(score) if isinstance(score, (int, float)) else None,
balance_after=points_after,
detail={
"commodity_id": commodity_id,
"commodity_name": name,
"order_id": order_id,
"exchange_id": exchange_id,
"game_name": game_name,
"game_channel": game_channel,
"score": score,
"scope": scope,
},
source="douyu_task",
subject_type="commodity",
subject_key=commodity_id or name or "",
source_ref=task_ref(task_id),
actor_type=actor_type,
actor_id=actor_id,
)
def record_gold_spent(
db: Session,
account: Account,
*,
task_id: int,
amount: int | None,
occurred_at: datetime | None = None,
reason: str = "",
source_label: str = "",
balance_after: int | None = None,
confirmed: bool = True,
actor_type: str = "",
actor_id: int | None = None,
) -> bool:
"""鱼翅消耗(送礼等)。
`amount` 传**正数**,这里写成负号(与 `exchange` 一样,出项用负数)。
金额取**实测的余额差**(送礼前后各刷一次鱼翅),不是按礼物单价推算 ——
单价会变、活动会打折,只有观测到的差是真的。
余额差拿不到(或没有减少)时**不发事件**:宁可少一条,也不要记一笔没发生的消耗。
"""
if not amount or int(amount) <= 0:
return False
spent = int(amount)
title = f"{source_label}消耗鱼翅 {spent}" if source_label else f"消耗鱼翅 {spent}"
if not confirmed:
title += "(未确认扣减)"
return emit(
db,
account_id=account.id,
event_type=EVENT_GOLD_SPENT,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=-spent,
balance_after=balance_after,
detail={"reason": reason, "source": source_label, "confirmed": confirmed},
source="douyu_task",
subject_type="gold",
# 副事件:同一个任务还会发「积分到账」,所以带后缀避免撞幂等键
source_ref=task_ref(task_id, "gold"),
actor_type=actor_type,
actor_id=actor_id,
)
def record_points_expire(
db: Session,
account: Account,
*,
amount: int | None,
occurred_at: datetime | None = None,
expires_at: Any = None,
confirmed: bool = False,
actor_type: str = "",
actor_id: int | None = None,
) -> bool:
"""积分到期(资产减少)。
⚠️ **默认 `confirmed=False`**:斗鱼只在积分查询里给一个 `willExpireTime`
(到期时刻),到期**实际扣了多少分**我们并没有亲眼看到。
所以默认只记「预计到期」,并在 detail 里标清楚;要记成已确认,
必须由重新查询后真正观测到余额下降的调用方显式传 `confirmed=True`。
"""
title = f"积分到期 -{amount}" if amount else "积分到期"
if not confirmed:
title += "(预计)"
return emit(
db,
account_id=account.id,
event_type=EVENT_POINTS_EXPIRE,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=-int(amount) if amount else None,
detail={"confirmed": confirmed, "expires_at": expires_at},
source="points_expire",
subject_type="points",
actor_type=actor_type,
actor_id=actor_id,
)
def record_points_credit(
db: Session,
account: Account,
*,
task_id: int,
gain: int | None,
occurred_at: datetime | None = None,
points_after: int | None = None,
expire_at: Any = None,
confirmed: bool = True,
source_label: str = "精英令",
actor_type: str = "",
actor_id: int | None = None,
) -> bool:
"""积分到账(送礼/任务发放)。
`confirmed` 必须如实反映「有没有真的看到积分涨上去」。送礼任务在**没确认**
到账时也会把任务标成 `success`(消息里加一句「(未确认涨到 X)」),旧收集器
照样把 `gift_points_expected_gain` 当成事实写进台账 —— 把「预期」记成了「已到账」。
这里保留事件(延迟到账也是真实发生的发放),但**把 `confirmed` 明确落进
detail**:宁可标清楚是未确认,也不假装它是确认过的数字。
"""
title = f"积分到账 +{gain}" if gain is not None else "积分到账"
title = f"{source_label}{title}"
if not confirmed:
title += "(未确认)"
return emit(
db,
account_id=account.id,
event_type=EVENT_POINTS_CREDIT,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=int(gain) if isinstance(gain, (int, float)) else None,
balance_after=points_after,
detail={
"confirmed": confirmed,
"expire_at": expire_at,
"source": source_label,
},
source="douyu_task",
subject_type="points",
source_ref=task_ref(task_id),
actor_type=actor_type,
actor_id=actor_id,
)
def record_bind(
db: Session,
account: Account,
*,
task_id: int,
scope: str,
role_name: str = "",
occurred_at: datetime | None = None,
act_alias: str = "",
rebind: bool = False,
weak: bool = False,
actor_type: str = "",
actor_id: int | None = None,
) -> bool:
"""角色绑定 / 换绑**真的发生了**。
⚠️ 只在「确认接口成功」时调用。`confirm_bind` 里还有一条分支是
「检测到账号**本来就已经绑定**」——它什么都没做也把任务标成 `success`,
旧收集器把每次重复点「确认绑定」都记成一条 `bind_ok`。
`weak=True` 表示确认接口成功、但回查拿不到结果(没能亲眼确认生效),
仍然记,但标出来。
"""
label = "换绑" if rebind else "绑定"
scope_label = {"elite": "精英宝典", "esports": "电竞手册", "peace": "和平小店"}.get(
scope, scope
)
title = f"{scope_label}{label}"
if role_name:
title += f":{role_name}"
if weak:
title += "(未回查确认)"
return emit(
db,
account_id=account.id,
event_type=EVENT_BIND_OK,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
detail={
"scope": scope,
"role_name": role_name,
"act_alias": act_alias,
"rebind": rebind,
"confirmed": not weak,
},
source="douyu_task",
subject_type=f"{scope}_role",
subject_key=role_name or "",
source_ref=task_ref(task_id),
actor_type=actor_type,
actor_id=actor_id,
)
def record_official_exchange(
db: Session,
account: Account,
*,
commodity_id: str,
commodity_name: str,
score: int | None,
occurred_at: datetime | None,
show_name: str = "",
exchange_num: int | None = None,
scope: str = "elite",
actor_type: str = "",
actor_id: int | None = None,
) -> bool:
"""把斗鱼官方兑换记录写成账号事件。
## 为什么要写进事件流
列表的「兑换记录」列一直来自我们自己的 `account_events`,而事件是**上线后**
才开始记的 —— 上线前的兑换在界面上就是空白,看着像"从没兑换过"
(用户报过:账号明明有官方兑换记录,列表却显示「未兑换」)。
官方记录**带真实兑换时间**(`time` 字段),所以可以直接补成正常的历史事件,
时间线因此变完整(这就是"账号全生命周期"该有的样子),
列表的聚合也随之自动准确 —— 不需要再单独维护一套"官方条数"的旁路。
## 幂等
`source_ref` 用 `官方:账号:商品:时刻`,同一条记录查多少次都只落一条。
这也是必须的:一键查询会被反复执行。
"""
if not commodity_id:
return False
return emit(
db,
account_id=account.id,
event_type=EVENT_EXCHANGE,
title=f"兑换 {commodity_name}" if commodity_name else "兑换",
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=-int(score) if score else None,
detail={
"commodity_id": commodity_id,
"commodity_name": commodity_name,
"score": score,
"exchange_num": exchange_num,
"show_name": show_name,
# 标明来源:这条是**从官方记录补录**的,不是我们亲眼看着兑换的
"source": OFFICIAL_SYNC_SOURCE,
# 哪个作用域的官方记录(精英宝典 / 电竞手册 / 和平小店)
"scope": scope,
},
source=OFFICIAL_SYNC_SOURCE,
# 台账检索键必须是**斗鱼商品 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,
)
def _stamp(value: datetime | None) -> str:
"""幂等键里的时间部分;没有时间就用固定标记(同一商品仍只落一条)。"""
return to_naive_utc(value).strftime("%Y%m%d%H%M%S") if value else "unknown"