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
+2
View File
@@ -24,6 +24,7 @@ from .routers import (
cookies,
dashboard,
douyu,
douyu_pool,
huya,
login,
proxy,
@@ -152,6 +153,7 @@ app.include_router(proxy.router)
app.include_router(cookies.router)
app.include_router(huya.router)
app.include_router(douyu.router)
app.include_router(douyu_pool.router)
app.include_router(yyb.router)
app.include_router(audit.router)
app.include_router(automation.router)
@@ -0,0 +1,163 @@
"""账号租约 `account_leases` + `accounts.role_change_available_at` + 操作人索引
配套文档:`docs/设计-账号自动分配与回收.md`(§3.2 表结构、§4 换绑冷却、§3.14 流水)。
三件事:
1. **`account_leases`** —— 归属(`assigned_to`,管理员/运营的维护责任)与
使用权(租约,客服临时占用)分离。核心是生成列 `active_account_id`
加唯一索引 `uq_lease_active_account`:一个账号同时只能有一个**有效**租约,
并发抢号由数据库裁决(撞唯一键即换候选),不需要应用层加锁。
2. **`accounts.role_change_available_at`** —— `change_role_wait_time` 是接口原文的
「剩余秒数」,落库即过期;换绑判定需要的是**绝对时刻**。电竞手册复用既有的
`esports_can_change_time`(本来就是绝对时刻),不另开副本。
3. **`ix_account_events_actor_occurred`** —— 池化后客服不再持有账号,
「某客服这段时间做了什么」只能按 `actor_id` 查;此前没有可用索引。
**可重跑**:应用启动时会自动跑迁移,所以每个动作都带存在性守卫。
**回退**:drop 表、drop 列、drop 索引。租约是运行期状态,回退即丢失 ——
但账号与任务数据不受影响(`assigned_to`、`douyu_tasks` 一律没动)。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "20261007_0052"
down_revision: str | None = "20261004_0051"
branch_labels: str | None = None
depends_on: str | None = None
TABLE = "account_leases"
ACCOUNT_COLUMN = "role_change_available_at"
ACTOR_INDEX = "ix_account_events_actor_occurred"
#: 「一个账号同时只能有一个有效租约」的数据库级保证。
ACTIVE_ACCOUNT_EXPR = "CASE WHEN status = 'active' THEN account_id ELSE NULL END"
def _table_exists(name: str) -> bool:
conn = op.get_bind()
count = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema = DATABASE() AND table_name = :name"
),
{"name": name},
).scalar()
return bool(count)
def _column_exists(table: str, name: str) -> bool:
conn = op.get_bind()
count = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() "
"AND table_name = :table AND column_name = :name"
),
{"table": table, "name": name},
).scalar()
return bool(count)
def _index_exists(table: str, name: str) -> bool:
conn = op.get_bind()
count = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema = DATABASE() "
"AND table_name = :table AND index_name = :name"
),
{"table": table, "name": name},
).scalar()
return bool(count)
def upgrade() -> None:
if not _table_exists(TABLE):
op.create_table(
TABLE,
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.Column("holder_user_id", sa.Integer(), nullable=False),
sa.Column(
"scope", sa.String(length=16), nullable=False, server_default="elite"
),
sa.Column(
"purpose",
sa.String(length=24),
nullable=False,
server_default="exchange",
),
sa.Column(
"commodity_id", sa.String(length=64), nullable=False, server_default=""
),
sa.Column(
"batch_id", sa.String(length=64), nullable=False, server_default=""
),
sa.Column(
"status", sa.String(length=16), nullable=False, server_default="active"
),
sa.Column("leased_at", sa.DateTime(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("released_at", sa.DateTime(), nullable=True),
sa.Column(
"release_reason",
sa.String(length=64),
nullable=False,
server_default="",
),
# ⚠️ 生成列必须排在 account_id / status 之后:MySQL 只允许生成列
# 引用它前面已定义的列。
sa.Column(
"active_account_id",
sa.Integer(),
sa.Computed(ACTIVE_ACCOUNT_EXPR, persisted=True),
nullable=True,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(
["account_id"], ["accounts.id"], name="fk_lease_account"
),
sa.ForeignKeyConstraint(
["holder_user_id"], ["users.id"], name="fk_lease_holder"
),
)
if not _index_exists(TABLE, "uq_lease_active_account"):
op.create_index(
"uq_lease_active_account", TABLE, ["active_account_id"], unique=True
)
if not _index_exists(TABLE, "ix_leases_holder_status"):
op.create_index("ix_leases_holder_status", TABLE, ["holder_user_id", "status"])
if not _index_exists(TABLE, "ix_leases_status_expires"):
op.create_index("ix_leases_status_expires", TABLE, ["status", "expires_at"])
if not _index_exists(TABLE, "ix_account_leases_account_id"):
op.create_index("ix_account_leases_account_id", TABLE, ["account_id"])
if not _column_exists("accounts", ACCOUNT_COLUMN):
op.add_column(
"accounts",
sa.Column(
ACCOUNT_COLUMN,
sa.DateTime(),
nullable=True,
comment="精英宝典可换绑时刻(绝对时间);换绑冷却只记录不拦截",
),
)
if not _index_exists("account_events", ACTOR_INDEX):
op.create_index(ACTOR_INDEX, "account_events", ["actor_id", "occurred_at"])
def downgrade() -> None:
if _index_exists("account_events", ACTOR_INDEX):
op.drop_index(ACTOR_INDEX, table_name="account_events")
if _column_exists("accounts", ACCOUNT_COLUMN):
op.drop_column("accounts", ACCOUNT_COLUMN)
if _table_exists(TABLE):
op.drop_table(TABLE)
+88
View File
@@ -8,6 +8,7 @@ from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Computed,
ForeignKey,
Index,
Integer,
@@ -130,6 +131,14 @@ class Account(Base):
# S6 拆解时差点把这个信息丢掉,所以补成独立布尔列。
esports_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
change_role_wait_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 精英宝典**可换绑时刻**(绝对时间)。`change_role_wait_time` 是接口原文的
# 「剩余秒数」,落库那一刻就开始过期 —— 过几天再读它,既不知道是不是还在冷却,
# 也不知道什么时候能换绑。所以每次写入时同时记下 `now + wait_time`。
# 不同活动的换绑冷却不同,因此**按作用域分列**(电竞手册用既有的
# `esports_can_change_time`,它本来就是绝对时刻,不另开副本)。
role_change_available_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
# 精英宝典开通状态(getUserInfo 的 manualType / expireTime)。
# 光看积分区分不了「开通后积分花完」和「从未开通」,必须单独落库。
elite_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
@@ -885,9 +894,88 @@ class AccountEvent(Base):
"subject_key",
"occurred_at",
),
# 「某个客服这段时间做了什么」——池化后客服不再持有账号,
# 这条是管理侧追责与对账的主查询路径,此前没有任何索引可用。
Index("ix_account_events_actor_occurred", "actor_id", "occurred_at"),
)
class AccountLease(Base):
"""账号租约:谁在**此刻**可以使用这个账号。
## 为什么要有它
以前「能不能操作某账号」的唯一判据是 `accounts.assigned_to == 我`。
账号因此被**永久独占**:客服名下的账号积分花完了也不会流向别人,
管理员只能人工调拨。池化之后这层隐式互斥消失,必须有一个显式的
「临时使用权」——就是这张表。
## 归属与使用权分离
- `accounts.assigned_to` 收窄为**维护责任**(谁负责重登、开通、进货),
由管理员/运营承担,客服名下不再持有账号;
- `account_leases` 表示**此刻谁在用**,用完即释放。
## 一个账号同时只能有一个有效租约
靠生成列 `active_account_id` + 唯一索引在**数据库层**保证:非 active 时为 NULL,
MySQL 唯一索引允许多个 NULL,所以历史记录不受限。并发抢号由数据库裁决 ——
两个事务抢同一账号必有一个撞唯一键,捕获后换下一个候选即可,不需要应用层加锁。
⚠️ `active_account_id` 必须声明在 `account_id` 与 `status` **之后**:
MySQL 要求生成列只能引用它前面已定义的列。
"""
__tablename__ = "account_leases"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
account_id: Mapped[int] = mapped_column(
ForeignKey("accounts.id"), nullable=False, index=True
)
holder_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
# 作用域:elite / esports / peace(与 douyu_tasks.handbook_scope 同域)
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="elite")
purpose: Mapped[str] = mapped_column(
String(24), nullable=False, default="exchange", server_default="exchange"
)
commodity_id: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
batch_id: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
# active / released / expired
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="active", server_default="active"
)
leased_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
# TTL 兜底:进程重启、页面关掉都能靠它把租约收回来。
expires_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
released_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
release_reason: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
# 生成列:active 时等于 account_id,其余为 NULL。唯一索引因此只约束「有效租约」。
active_account_id: Mapped[int | None] = mapped_column(
Integer,
Computed(
"CASE WHEN status = 'active' THEN account_id ELSE NULL END",
persisted=True,
),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
__table_args__ = (
Index("uq_lease_active_account", "active_account_id", unique=True),
Index("ix_leases_holder_status", "holder_user_id", "status"),
Index("ix_leases_status_expires", "status", "expires_at"),
)
account: Mapped[Account] = relationship("Account")
holder: Mapped[User] = relationship("User")
class AutomationJob(Base):
"""自动化作业定义。状态落库,进程重启不丢调度进度。"""
+6
View File
@@ -20,6 +20,9 @@ PERMISSIONS = {
"account:assign": "分配账号给客服",
"account:delete": "删除账号",
"account:manage": "停用/归档账号、开关自动化",
# 账号池(自动分配与回收):归属仍是管理员/运营的维护责任,客服按需租用
"account:pool_use": "使用账号自动分配(领号执行并释放)",
"account:pool_admin": "查看全局账号池、强制释放他人租约",
# 登录任务
"login:batch": "发起批量登录",
"login:view_all": "查看所有登录任务",
@@ -72,6 +75,8 @@ ROLE_PERMISSIONS = {
"account:check",
"account:assign",
"account:manage",
"account:pool_use",
"account:pool_admin",
"login:batch",
"login:view_all",
"cookie:view",
@@ -103,6 +108,7 @@ ROLE_PERMISSIONS = {
"account:view_assigned",
"account:tag",
"account:remark",
"account:pool_use",
"douyu:task",
"yyb:session",
"yyb:history",
+40 -4
View File
@@ -11,6 +11,7 @@ from ..deps import get_current_user, require_permission
from ..models import (
Account,
AccountEvent,
AccountLease,
AuditLog,
DouyuTask,
DouyuWorkbenchAccount,
@@ -72,12 +73,23 @@ router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
def _visible_accounts_query(db: Session, current: User):
"""返回当前用户可见的斗鱼账号查询。"""
"""返回当前用户可见的斗鱼账号查询。
可见 = **我名下** ∪ **我当前持有有效租约**。
后半条是账号池加的:池化后客服名下不再挂账号(归属是管理员/运营的维护责任),
不把自己租到的号算进来,客服连刚做完的那单都看不到。
"""
query = db.query(Account)
if user_has_permission(current, "account:view_all"):
return query
if user_has_permission(current, "account:view_assigned"):
return query.filter(Account.assigned_to == current.id)
my_leases = select(AccountLease.account_id).where(
AccountLease.holder_user_id == current.id,
AccountLease.status == "active",
)
return query.filter(
or_(Account.assigned_to == current.id, Account.id.in_(my_leases))
)
raise HTTPException(status_code=403, detail="无权查看账号")
@@ -1428,15 +1440,21 @@ def account_events(
category: str = Query(
"", description="按粗分类筛选:lifecycle / auth / business / ops"
),
actor_id: int | None = Query(
None, description="按操作人筛选:谁做的这件事(池化后按客服查流水)"
),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""账号时间线(倒序分页)。
权限沿用账号可见性:客服只能看自己名下账号的事件。
权限沿用账号可见性:客服能看**自己名下 ∪ 自己租用中**的账号事件。
`category` 是给页面用的「大分类」过滤(状态变迁 / 登录与 CK / 业务 / 人工操作),
比逐一点 `event_type` 好用:客服最常看的是「人工操作」与「状态变迁」两条线。
`actor_id` 是池化后新增的:客服不再长期持有账号,所以"这个账号被谁动过、
动了什么"必须能按人查 —— 配合 `_task_actor` 落库的 `actor_id` 形成完整流水。
"""
acc = _visible_accounts_query(db, current).filter(Account.id == account_id).first()
if not acc:
@@ -1454,6 +1472,9 @@ def account_events(
if sql_category:
where += " AND category = :category"
params["category"] = sql_category
if isinstance(actor_id, int) and not isinstance(actor_id, bool):
where += " AND actor_id = :actor_id"
params["actor_id"] = int(actor_id)
total = (
db.execute(
@@ -1480,8 +1501,23 @@ def account_events(
.all()
)
items = [dict(row) for row in rows]
# 补上操作人的用户名:池化后「谁做的」是这一屏最重要的信息之一,
# 前端拿到 id 还要再查一次不值当。一次 IN 查询即可。
actor_ids = {item.get("actor_id") for item in items if item.get("actor_id")}
actor_names: dict[int, str] = {}
if actor_ids:
actor_names = {
user_id: name
for user_id, name in db.query(User.id, User.username)
.filter(User.id.in_(actor_ids))
.all()
}
for item in items:
item["actor_name"] = actor_names.get(item.get("actor_id"), "")
return {
"items": [dict(row) for row in rows],
"items": items,
"total": int(total),
"page": page,
"page_size": page_size,
+49 -13
View File
@@ -30,6 +30,7 @@ from ..database import SessionLocal, get_db
from ..deps import authenticate_websocket, get_current_user, require_permission
from ..models import (
Account,
AccountLease,
DouyuConfig,
DouyuEsportsGoodsSnapshot,
DouyuGoodsSnapshot,
@@ -240,13 +241,24 @@ def _visible_tasks_query(db: Session, current: User):
def _require_task_account_access(
db: Session, current: User, account_ids: list[int]
) -> None:
"""确保任务只会提交到当前用户可操作的账号。"""
"""确保任务只会提交到当前用户可操作的账号。
可操作 = **我名下** ∪ **我当前持有有效租约**。
后半条是账号池加的(归属是管理员/运营的维护责任,客服只按需租用)——
旧路径「名下账号」一字未改,这里只做**并集**。
"""
requested_ids = set(account_ids)
query = db.query(Account.id).filter(Account.id.in_(requested_ids))
if not _can_view_all(current):
if not user_has_permission(current, "account:view_assigned"):
raise HTTPException(status_code=403, detail="无权操作斗鱼账号")
query = query.filter(Account.assigned_to == current.id)
my_leases = select(AccountLease.account_id).where(
AccountLease.holder_user_id == current.id,
AccountLease.status == "active",
)
query = query.filter(
or_(Account.assigned_to == current.id, Account.id.in_(my_leases))
)
allowed_ids = {account_id for (account_id,) in query.all()}
if allowed_ids != requested_ids:
raise HTTPException(status_code=403, detail="包含无权操作的斗鱼账号")
@@ -265,6 +277,40 @@ def _require_batch_owner(db: Session, current: User, batch_id: str) -> None:
raise HTTPException(status_code=403, detail="无权操作该斗鱼任务批次")
def start_douyu_batch(
*,
batch_id: str,
task_type: str,
payload: dict,
concurrency: int,
) -> None:
"""在线程里启动一个批次的执行器(**必须在有事件循环的 async 路由里调用**)。
抽出来是因为账号池也要起批次:两条路径的执行方式必须完全一致 ——
复制一份的话,将来改了注册表或线程模型就会出现"老路径好了、池路径没好"。
⚠️ `DouyuBatchRunner` 按 `batch_id` 取**全部 planned 任务、不看 `task_type`**,
再按每条任务自己的类型分发。所以**一个批次里可以放不同类型的任务**;
但它们会被并发执行(`concurrency` 个 worker),顺序没有保证 ——
需要严格先后(充值 → 赠令 → 兑换)时只能把 `concurrency` 设成 1。
"""
log_queue = asyncio.Queue()
loop = asyncio.get_running_loop()
thread_db = SessionLocal()
runner = DouyuBatchRunner(
db=thread_db,
batch_id=batch_id,
task_type=task_type,
payload=payload,
log_queue=log_queue,
loop=loop,
concurrency=concurrency,
)
douyu_batch_registry.register(batch_id, log_queue, loop, runner)
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
def _parse_account_names(raw_names: str) -> list[str]:
"""解析前端粘贴的 Excel 账号名,每行一个并去重。"""
names: list[str] = []
@@ -715,22 +761,12 @@ async def create_task_batch(
)
db.commit()
log_queue = asyncio.Queue()
loop = asyncio.get_running_loop()
thread_db = SessionLocal()
runner = DouyuBatchRunner(
db=thread_db,
start_douyu_batch(
batch_id=batch_id,
task_type=req.task_type,
payload=req.payload,
log_queue=log_queue,
loop=loop,
concurrency=req.concurrency,
)
douyu_batch_registry.register(batch_id, log_queue, loop, runner)
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
return {"batch_id": batch_id, "count": count, "success": True}
+447
View File
@@ -0,0 +1,447 @@
"""账号池路由:自动分配账号 + 用完自动收回。
配套文档:`docs/设计-账号自动分配与回收.md`。
**这是新增的一条路,不是改造旧的**:
- 旧路径(管理员分配 → 客服导入工作台 → 手动选号)在 `routers/douyu.py` 里一字未动;
- 本模块只提供 `/api/douyu/pool/*`,前端对应**新建**的「账号分配台」页面。
与旧路径共享的东西只有三处,都是**扩展而非替换**:
1. `create_douyu_planned_tasks` 多了一个可选参数(默认行为不变);
2. `_require_task_account_access` 的可操作集合变成「我名下 ∪ 我持有的有效租约」;
3. `start_douyu_batch` 抽成函数,两条路径用同一套执行方式。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import require_permission
from ..models import (
Account,
AccountLease,
DouyuEsportsGoodsSnapshot,
DouyuGoodsSnapshot,
User,
)
from ..permissions import user_has_permission
from ..schemas import (
DouyuPoolAccountOut,
DouyuPoolExchangeRequest,
DouyuPoolExchangeResponse,
DouyuPoolLeaseOut,
)
from ..services import account_pool as pool
from ..services.audit_service import record_audit
from ..services.douyu_service import create_douyu_task, ensure_douyu_config
from .douyu import start_douyu_batch
router = APIRouter(prefix="/api/douyu/pool", tags=["账号池"])
#: 池路径一账号一件(一期)。批量商品的 `num` 以后再开。
ITEM_PER_ACCOUNT = 1
#: 池批次默认并发。**串行**是为了让「充值 → 赠令 → 兑换」的先后成立:
#: `DouyuBatchRunner` 按 batch_id 取全部 planned 任务并发执行,顺序没有保证。
POOL_BATCH_CONCURRENCY = 1
def _goods_snapshot(db: Session, scope: str, commodity_id: str):
model = (
DouyuGoodsSnapshot if scope == pool.SCOPE_ELITE else DouyuEsportsGoodsSnapshot
)
return (
db.query(model)
.filter(model.commodity_id == commodity_id)
.order_by(model.id.desc())
.first()
)
def _require_scope(scope: str) -> str:
if scope not in pool.SUPPORTED_POOL_SCOPES:
raise HTTPException(
status_code=400,
detail=f"该作用域暂不支持自动分配:{scope}",
)
return scope
def _goods_required(db: Session, scope: str, commodity_id: str) -> int:
goods = _goods_snapshot(db, scope, commodity_id)
if goods is None:
raise HTTPException(
status_code=404,
detail="找不到该商品,请先在对应工作台刷新商品列表",
)
score = int(goods.score or 0)
if score <= 0:
raise HTTPException(status_code=400, detail="该商品没有有效积分价格,无法分配")
return score
def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _is_pool_admin(current: User) -> bool:
return user_has_permission(current, "account:pool_admin")
@router.get("/supply")
def pool_supply(
scope: str = Query("elite", pattern="^(elite|esports)$"),
commodity_id: str = Query(..., min_length=1, max_length=64),
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:pool_use")),
) -> dict[str, Any]:
"""**不锁号**地预览「现在能分出几个、要花多少钱」。"""
scope = _require_scope(scope)
score = _goods_required(db, scope, commodity_id)
supply = pool.estimate_supply(
db, scope=scope, score=score, commodity_id=commodity_id
)
goods = _goods_snapshot(db, scope, commodity_id)
channel = str(douyu_config_value(db, "gold_recharge_channel") or "wechat_qr")
supply["commodity"] = {
"commodity_id": commodity_id,
"name": (goods.name if goods else "") or "",
"score": score,
}
supply["cooling"] = _cooling_summary(db)
supply["recharge_channel"] = channel
supply["manual_payment_required"] = channel != "supplier_api"
return supply
def douyu_config_value(db: Session, key: str) -> Any:
"""读斗鱼配置(**只读**)。
池路径**绝不写** `gold_recharge_channel` —— 那个开关由管理员在
`PUT /api/douyu/config` 手动切换(见设计 §3.4.1)。
"""
config = ensure_douyu_config(db)
return getattr(config, key, None)
def _cooling_summary(db: Session) -> dict[str, int]:
"""换绑冷却概览(**只记录不拦截**,给前端提示用)。
按第 3 条:不同活动的换绑冷却不同,一期只把「什么时候能换绑」记下来给客服看,
不作为候选过滤条件 —— 真卡这条,池子会从几千个掉到几百个。
"""
moment = _utcnow()
base = db.query(Account).filter(
Account.status == "active", Account.ck_check_status == "valid"
)
in_cooldown = base.filter(
Account.role_change_available_at.isnot(None),
Account.role_change_available_at > moment,
).count()
changeable = base.filter(
(Account.role_change_available_at.is_(None))
| (Account.role_change_available_at <= moment)
).count()
return {"role_change_in_cooldown": in_cooldown, "changeable_now": changeable}
def _utcnow() -> datetime:
from datetime import UTC
return datetime.now(UTC)
@router.post("/exchange", response_model=DouyuPoolExchangeResponse)
async def pool_exchange(
req: DouyuPoolExchangeRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:pool_use")),
) -> DouyuPoolExchangeResponse:
"""提交需求:分配账号 → 建任务 → 启动批次。用完由执行器自动释放租约。"""
scope = _require_scope(req.scope)
score = _goods_required(db, scope, req.commodity_id)
candidates = pool.eligible_accounts(
db,
scope=scope,
score=score,
commodity_id=req.commodity_id,
limit=max(req.quantity, 1) * 3,
)
reasons: list[str] = []
if not req.allow_topup:
before = len(candidates)
candidates = [c for c in candidates if not c.needs_topup]
if len(candidates) < before:
reasons.append(
f"已排除 {before - len(candidates)} 个需要充值积分的账号(本次未允许充值)"
)
if len(candidates) < req.quantity:
supply = pool.estimate_supply(
db, scope=scope, score=score, commodity_id=req.commodity_id
)
reasons.append(
f"可用账号不足:满足条件仅 {len(candidates)} 个"
f"(可直接兑 {supply['zero_topup']} 个,充值后可兑 {supply['after_topup']} 个)"
)
if not candidates:
return DouyuPoolExchangeResponse(
requested=req.quantity,
allocated=0,
shortfall=req.quantity,
reasons=reasons or ["没有可用账号"],
recharge_channel=str(douyu_config_value(db, "gold_recharge_channel") or ""),
manual_payment_required=(
str(douyu_config_value(db, "gold_recharge_channel") or "")
!= "supplier_api"
),
)
leases = pool.lease_accounts(
db,
holder_user_id=current.id,
candidates=candidates,
scope=scope,
commodity_id=req.commodity_id,
quantity=req.quantity,
)
if not leases:
return DouyuPoolExchangeResponse(
requested=req.quantity,
allocated=0,
shortfall=req.quantity,
reasons=[*reasons, "账号刚被其他客服领走,请重试"],
recharge_channel=str(douyu_config_value(db, "gold_recharge_channel") or ""),
)
account_ids = [lease.account_id for lease in leases]
task_type = (
"exchange_goods" if scope == pool.SCOPE_ELITE else "exchange_esports_goods"
)
by_id = {c.account_id: c for c in candidates}
# 补给链:A 梯队只有「兑换」一步;B 梯队是「查余额 → 充值 → 查积分 → 赠令 → 兑换」。
# 每个账号的充值额/赠令数都不一样,所以逐账号建任务(不能共用一份 payload)。
# 顺序由「同一批次 + 并发 1」保证;运行期另有两道闸门
# (`_depends_on` 前置失败就跳过、`_skip_when` 条件已满足就跳过,见 runner)。
batch_id = uuid.uuid4().hex[:12]
accounts_by_id = {
acc.id: acc
for acc in db.query(Account).filter(Account.id.in_(account_ids)).all()
}
created = 0
no_ck: list[AccountLease] = []
for lease in leases:
account = accounts_by_id.get(lease.account_id)
if account is None:
continue
if not (account.cookie or ""):
no_ck.append(lease)
continue
previous_id: int | None = None
for step in pool.build_supply_chain(
by_id[lease.account_id]
if lease.account_id in by_id
else pool.make_candidate(
account_id=account.id,
username=account.username,
points=account.points,
score=score,
),
commodity_id=req.commodity_id,
exchange_task_type=task_type,
):
payload = dict(step.payload)
if previous_id is not None:
payload["_depends_on"] = previous_id
task = create_douyu_task(
db,
account,
step.task_type,
scope,
current.id,
payload,
batch_id=batch_id,
# 池路径:任务列表对客服的过滤走这一列,写**租用人**客服才看得到自己的单子。
account_assigned_to=current.id,
)
previous_id = task.id
created += 1
if no_ck:
# 账号没有可用 CK:把刚领的租约立刻还回去,别占着。
pool.release_many(db, no_ck, reason="no_ck")
pool.bind_leases_to_batch(db, leases, batch_id)
if created == 0:
pool.release_many(db, leases, reason="no_ck")
raise HTTPException(
status_code=400,
detail="分配到的账号当前没有可用 Cookie,已释放,请稍后重试",
)
record_audit(
db,
current,
action="douyu:pool:exchange",
target=f"douyu_batch:{batch_id}",
detail={
"scope": scope,
"commodity_id": req.commodity_id,
"requested": req.quantity,
"allocated": len(leases),
"account_ids": account_ids,
"topup_gifts": sum(c.topup_gifts for c in candidates[: len(leases)]),
},
)
db.commit()
start_douyu_batch(
batch_id=batch_id,
task_type=task_type,
payload={"commodity_id": req.commodity_id, "num": ITEM_PER_ACCOUNT},
concurrency=POOL_BATCH_CONCURRENCY,
)
channel = str(douyu_config_value(db, "gold_recharge_channel") or "")
return DouyuPoolExchangeResponse(
batch_id=batch_id,
requested=req.quantity,
allocated=len(leases),
shortfall=max(0, req.quantity - len(leases)),
accounts=[
_account_out(db, lease, by_id.get(lease.account_id)) for lease in leases
],
reasons=reasons,
recharge_channel=channel,
manual_payment_required=channel != "supplier_api",
)
def _account_out(db: Session, lease: AccountLease, candidate) -> DouyuPoolAccountOut:
account = db.query(Account).filter(Account.id == lease.account_id).first()
topup = int(candidate.topup_gifts) if candidate else 0
return DouyuPoolAccountOut(
account_id=lease.account_id,
username=(account.username if account else ""),
tier=(candidate.tier if candidate else "A"),
points=int(candidate.points) if candidate else 0,
topup_gifts=topup,
topup_yuan=topup,
recharge_orders=pool.recharge_amounts_for(topup),
role_change_available_at=_iso(
account.role_change_available_at if account else None
),
)
@router.get("/leases", response_model=list[DouyuPoolLeaseOut])
def list_leases(
mine: bool = Query(True, description="只看我自己的(默认)"),
account_id: int | None = Query(
None, description="按账号查它的租赁记录(账号详情页的「租赁记录」用)"
),
include_finished: bool = Query(
False, description="是否带上已释放/已过期的历史记录"
),
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:pool_use")),
) -> list[DouyuPoolLeaseOut]:
query = db.query(AccountLease)
if not include_finished:
query = query.filter(AccountLease.status == "active")
if account_id is not None:
query = query.filter(AccountLease.account_id == int(account_id))
# 查某个账号的租赁记录时不再要求「必须是我租的」——
# 否则管理员复盘某账号时只会看到空白。能看到这个账号的人就能看它的租约史。
elif mine or not _is_pool_admin(current):
query = query.filter(AccountLease.holder_user_id == current.id)
rows = query.order_by(AccountLease.id.desc()).limit(500).all()
account_ids = [row.account_id for row in rows]
accounts: dict[int, Account] = {}
if account_ids:
accounts = {
acc.id: acc
for acc in db.query(Account).filter(Account.id.in_(account_ids)).all()
}
holder_ids = {row.holder_user_id for row in rows}
holders: dict[int, str] = {}
if holder_ids:
holders = {
user_id: name
for user_id, name in db.query(User.id, User.username)
.filter(User.id.in_(holder_ids))
.all()
}
return [
DouyuPoolLeaseOut(
id=row.id,
account_id=row.account_id,
username=(
accounts.get(row.account_id).username
if accounts.get(row.account_id)
else ""
),
holder_user_id=row.holder_user_id,
holder_name=holders.get(row.holder_user_id, ""),
scope=row.scope,
commodity_id=row.commodity_id,
batch_id=row.batch_id,
status=row.status,
leased_at=_iso(row.leased_at),
expires_at=_iso(row.expires_at),
role_change_available_at=_iso(
accounts[row.account_id].role_change_available_at
if row.account_id in accounts
else None
),
points=(
accounts[row.account_id].points if row.account_id in accounts else None
),
)
for row in rows
]
@router.post("/leases/{lease_id}/release")
def release_lease(
lease_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:pool_use")),
) -> dict[str, Any]:
"""手动释放租约(客服放弃、或用完提前还)。"""
lease = db.query(AccountLease).filter(AccountLease.id == lease_id).first()
if lease is None:
raise HTTPException(status_code=404, detail="租约不存在")
if lease.holder_user_id != current.id and not _is_pool_admin(current):
raise HTTPException(status_code=403, detail="无权释放他人的租约")
released = pool.release_lease(db, lease, reason="manual_release")
return {"success": True, "released": released}
@router.post("/leases/release-batch/{batch_id}")
def release_batch(
batch_id: str,
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:pool_admin")),
) -> dict[str, Any]:
"""超管强制释放一个批次的全部有效租约。"""
count = pool.release_batch(db, batch_id, reason="admin_release")
record_audit(
db,
current,
action="douyu:pool:release_batch",
target=f"douyu_batch:{batch_id}",
detail={"released": count},
)
db.commit()
return {"success": True, "released": count}
__all__ = ["router", "start_douyu_batch"]
+58
View File
@@ -850,6 +850,64 @@ class DouyuWorkbenchAccountsUpdate(BaseModel):
account_ids: list[int] = Field(default_factory=list)
class DouyuPoolExchangeRequest(BaseModel):
"""账号池:客服选皮肤 + 数量,由系统分配账号。
- `quantity` 是**账号个数**(一期一账号一件,`num=1`);
- `allow_topup=True` 时积分不足的账号也会入选,并按缺口自动充值积分;
- `allow_fallback=True` 时某个账号执行失败(已达上限/无库存等)会自动补位。
"""
scope: str = Field("elite", pattern="^(elite|esports)$")
commodity_id: str = Field(..., min_length=1, max_length=64)
quantity: int = Field(1, ge=1, le=50)
allow_topup: bool = True
allow_fallback: bool = False
class DouyuPoolAccountOut(BaseModel):
account_id: int
username: str = ""
# A = 积分已够(免充值);B = 需要充值
tier: str = "A"
points: int = 0
topup_gifts: int = 0
# 需要充多少元(1 元 = 1 鱼翅 = 1 精英令)
topup_yuan: int = 0
# 充值订单金额列表(每笔 ≤ 800 元);空 = 不需要充值
recharge_orders: list[int] = Field(default_factory=list)
# 该账号何时可以换绑游戏角色(只提示,不做候选过滤)
role_change_available_at: str | None = None
class DouyuPoolExchangeResponse(BaseModel):
batch_id: str = ""
requested: int = 0
allocated: int = 0
shortfall: int = 0
accounts: list[DouyuPoolAccountOut] = Field(default_factory=list)
reasons: list[str] = Field(default_factory=list)
# 当前鱼翅充值渠道(**只读回显**,让客服提交前就知道要不要去扫码)
recharge_channel: str = ""
manual_payment_required: bool = False
class DouyuPoolLeaseOut(BaseModel):
id: int
account_id: int
username: str = ""
holder_user_id: int = 0
holder_name: str = ""
scope: str = ""
commodity_id: str = ""
batch_id: str = ""
status: str = ""
leased_at: str | None = None
expires_at: str | None = None
role_change_available_at: str | None = None
points: int | None = None
class DouyuTaskOut(BaseModel):
id: int
batch_id: str
+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",
+2
View File
@@ -21,6 +21,7 @@ const UsersPage = lazy(() => import('./pages/UsersPage'));
const CookiePage = lazy(() => import('./pages/CookiePage'));
const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage'));
const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage'));
const DouyuPoolPage = lazy(() => import('./pages/DouyuPoolPage'));
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
@@ -90,6 +91,7 @@ function AppContent() {
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
<Route path="cookie-operations" element={lazyRoute(<CookieOperationsPage />)} />
<Route path="douyu/tasks" element={<Navigate to="/douyu/elite" replace />} />
<Route path="douyu/pool" element={lazyRoute(<DouyuPoolPage />)} />
<Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage key="elite" handbook="elite" />)} />
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage key="esports" handbook="esports" />)} />
<Route path="douyu/peace" element={lazyRoute(<DouyuTasksPage key="peace" handbook="peace" />)} />
+43
View File
@@ -0,0 +1,43 @@
import api from './client';
import type {
DouyuPoolExchangeRequest,
DouyuPoolExchangeResult,
DouyuPoolLease,
DouyuPoolSupply,
MessageResponse,
} from './types';
/**
* 账号池(自动分配与回收)。
*
* 单独一个模块而不是塞进 `douyuApi`:这是**新增的一条路**,
* 旧工作台(精英宝典/电竞手册)的接口一个都没动。
*/
export const douyuPoolApi = {
/** 不锁号地预览「能分出几个、要花多少钱」 */
supply: (scope: 'elite' | 'esports', commodityId: string) =>
api.get<DouyuPoolSupply, DouyuPoolSupply>('/douyu/pool/supply', {
params: { scope, commodity_id: commodityId },
}),
/** 提交需求:分配账号 + 建任务 + 启动批次 */
exchange: (data: DouyuPoolExchangeRequest) =>
api.post<DouyuPoolExchangeResult, DouyuPoolExchangeResult>('/douyu/pool/exchange', data),
leases: (mine = true) =>
api.get<DouyuPoolLease[], DouyuPoolLease[]>('/douyu/pool/leases', { params: { mine } }),
/** 某个账号的租赁记录(账号详情页用;`includeFinished` 带历史) */
leasesOfAccount: (accountId: number, includeFinished = true) =>
api.get<DouyuPoolLease[], DouyuPoolLease[]>('/douyu/pool/leases', {
params: { account_id: accountId, include_finished: includeFinished },
}),
releaseLease: (leaseId: number) =>
api.post<{ success: boolean; released: boolean }, { success: boolean; released: boolean }>(
`/douyu/pool/leases/${leaseId}/release`,
),
releaseBatch: (batchId: string) =>
api.post<{ success: boolean; released: number }, { success: boolean; released: number }>(
`/douyu/pool/leases/release-batch/${batchId}`,
),
/** 复用旧接口拿商品列表(皮肤下拉的数据源) */
goods: () => api.get<{ commodity_id: string; name: string; score: number | null }[], { commodity_id: string; name: string; score: number | null }[]>('/douyu/goods'),
stopBatch: (batchId: string) => api.post<MessageResponse, MessageResponse>(`/douyu/stop/${batchId}`),
};
+1
View File
@@ -8,6 +8,7 @@ export { automationApi } from './automation';
export { cookieApi } from './cookies';
export { dashboardApi } from './dashboard';
export { douyuApi } from './douyu';
export { douyuPoolApi } from './douyuPool';
export { huyaApi } from './huya';
export { loginApi } from './login';
export { proxyApi } from './proxy';
+72
View File
@@ -146,6 +146,8 @@ export interface AccountEventItem {
source: string;
actor_type?: string;
actor_id?: number | null;
/** 操作人用户名(后端联表补上;池化后「谁做的」是这一屏的关键信息) */
actor_name?: string;
}
export interface AccountEventPage {
@@ -1163,3 +1165,73 @@ export interface AccountExchangeRecords {
total: number;
queried_at: string | null;
}
/** 账号池:供给预览(不锁号) */
export interface DouyuPoolSupply {
scope: string;
commodity_id: string;
score: number;
/** 积分已够、可直接兑的账号数(0 充值) */
zero_topup: number;
/** 充值积分后可兑的账号数 */
after_topup: number;
/** 当前被别处租用中的数量 */
leased: number;
blocked: Record<string, number>;
cost: { est_gifts: number; est_yuan: number };
cooldown_days: number;
commodity: { commodity_id: string; name: string; score: number };
cooling: { role_change_in_cooldown: number; changeable_now: number };
/** 当前鱼翅充值渠道(只读回显) */
recharge_channel: string;
/** 是否需要在扫码渠道下人工支付 */
manual_payment_required: boolean;
}
export interface DouyuPoolAccount {
account_id: number;
username: string;
/** A = 积分已够;B = 需要充值 */
tier: string;
points: number;
topup_gifts: number;
topup_yuan: number;
/** 充值订单金额列表(每笔 ≤ 800 元) */
recharge_orders: number[];
role_change_available_at: string | null;
}
export interface DouyuPoolExchangeRequest {
scope: 'elite' | 'esports';
commodity_id: string;
quantity: number;
allow_topup: boolean;
allow_fallback: boolean;
}
export interface DouyuPoolExchangeResult {
batch_id: string;
requested: number;
allocated: number;
shortfall: number;
accounts: DouyuPoolAccount[];
reasons: string[];
recharge_channel: string;
manual_payment_required: boolean;
}
export interface DouyuPoolLease {
id: number;
account_id: number;
username: string;
holder_user_id: number;
holder_name: string;
scope: string;
commodity_id: string;
batch_id: string;
status: string;
leased_at: string | null;
expires_at: string | null;
role_change_available_at: string | null;
points: number | null;
}
+5
View File
@@ -8,6 +8,7 @@ import {
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined,
BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined,
SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, type AuthUser } from '../store/auth';
@@ -84,6 +85,10 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: <TrophyOutlined /> });
douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: <ShopOutlined /> });
}
// 账号分配台:新增的独立工作台(旧的三个工作台原样保留)
if (can('account:pool_use')) {
douyuItems.push({ key: '/douyu/pool', label: '账号分配台', icon: <ThunderboltOutlined /> });
}
// 虎牙:客服只保留浏览器兑换;管理员/运营显示完整入口
if (can('huya:task')) {
+75 -1
View File
@@ -7,7 +7,7 @@ import type { TableProps } from 'antd';
import {
ArrowLeftOutlined, EditOutlined, ReloadOutlined, ThunderboltOutlined,
} from '@ant-design/icons';
import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules';
import { accountApi, douyuApi, douyuPoolApi, userApi, type AccountEventItem, type AccountOverview, type DouyuPoolLease, type UserInfo } from '../api/modules';
import AccountOverviewPanel from './douyu-tasks/AccountOverviewPanel';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
@@ -100,6 +100,7 @@ export default function AccountDetailPage() {
const [eventType, setEventType] = useState('');
const [category, setCategory] = useState('');
const [eventsLoading, setEventsLoading] = useState(false);
const [leases, setLeases] = useState<DouyuPoolLease[]>([]);
const [tagDraft, setTagDraft] = useState('');
// 一键查询:向斗鱼实时查一次该账号的全部关键信息(异步任务 + 轮询结果)
const [ovLoading, setOvLoading] = useState(false);
@@ -209,6 +210,22 @@ export default function AccountDetailPage() {
void loadEvents();
}, [loadEvents]);
// 租赁记录:池化后「这个账号被谁租过、用完还回去没有」。
// 没有 `account:pool_use` 权限的账号会 403 —— 静默留空,不影响页面其它部分。
useEffect(() => {
if (!accountId) return;
let alive = true;
(async () => {
try {
const rows = await douyuPoolApi.leasesOfAccount(accountId);
if (alive) setLeases(rows);
} catch {
if (alive) setLeases([]);
}
})();
return () => { alive = false; };
}, [accountId]);
const columns: TableProps<AccountEventItem>['columns'] = [
{
title: '时间',
@@ -238,6 +255,28 @@ export default function AccountDetailPage() {
render: (val: number | null) =>
val === null || val === undefined ? <Text type="secondary">-</Text> : val,
},
{
// 池化后客服不再长期持有账号,「这件事是谁做的」必须一眼看得到。
// 历史事件(占 71%)没有操作人 —— 如实显示「-」,不要假装是系统做的。
title: '操作人 / 渠道',
width: 170,
render: (_val, record) => {
const channel = (record.detail?.channel as string | undefined) || '';
const channelLabel = channel === 'supplier_api'
? 'API 直充'
: channel === 'wechat_qr'
? '微信扫码'
: channel;
return (
<Space size={4} wrap>
{record.actor_name
? <Tag color="blue">{record.actor_name}</Tag>
: <Text type="secondary">-</Text>}
{channelLabel && <Tag>{channelLabel}</Tag>}
</Space>
);
},
},
];
if (!accountId) {
@@ -678,6 +717,41 @@ export default function AccountDetailPage() {
</Spin>
<Card
title="租赁记录"
extra={<Text type="secondary" style={{ fontSize: 12 }}>客服按需租用,用完自动收回</Text>}
>
<Table
rowKey="id"
size="small"
dataSource={leases}
pagination={false}
locale={{ emptyText: <Empty description="暂无租赁记录" /> }}
columns={[
{
title: '客服', dataIndex: 'holder_name', width: 120,
render: (value: string, row: DouyuPoolLease) => value || `#${row.holder_user_id}`,
},
{ title: '商品', dataIndex: 'commodity_id', width: 200 },
{ title: '批次', dataIndex: 'batch_id', width: 130 },
{
title: '状态', dataIndex: 'status', width: 100,
render: (value: string) => (
<Tag color={value === 'active' ? 'green' : 'default'}>{value}</Tag>
),
},
{
title: '租用时间', dataIndex: 'leased_at', width: 170,
render: (value: string | null) => (value ? formatTime(value) : '-'),
},
{
title: '释放时间', dataIndex: 'expires_at', width: 170,
render: (value: string | null) => (value ? formatTime(value) : '-'),
},
]}
/>
</Card>
<Card
title="账号时间线"
extra={
+282
View File
@@ -0,0 +1,282 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, Col, Descriptions, InputNumber, Row, Select, Space,
Statistic, Table, Tag, Typography, theme,
} from 'antd';
import { ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
import { douyuPoolApi } from '../api/modules';
import type {
DouyuPoolAccount, DouyuPoolExchangeResult, DouyuPoolLease, DouyuPoolSupply,
} from '../api/modules';
import type { ColumnsType } from 'antd/es/table';
const { Text, Paragraph } = Typography;
/**
* 账号分配台(**新建的工作台**,不是改造精英宝典页)。
*
* 客服在这里只做三件事:选皮肤 → 填数量 → 提交,剩下的(领号、补积分、兑换、
* 用完自动收回)由系统做。旧工作台的手动选号路径完整保留,两者互不影响。
*/
export default function DouyuPoolPage() {
const { token } = theme.useToken();
const [goods, setGoods] = useState<{ commodity_id: string; name: string; score: number | null }[]>([]);
const [commodityId, setCommodityId] = useState<string>('');
const [quantity, setQuantity] = useState(1);
const [allowTopup, setAllowTopup] = useState(true);
const [supply, setSupply] = useState<DouyuPoolSupply | null>(null);
const [result, setResult] = useState<DouyuPoolExchangeResult | null>(null);
const [leases, setLeases] = useState<DouyuPoolLease[]>([]);
const [loading, setLoading] = useState(false);
const [previewing, setPreviewing] = useState(false);
const loadGoods = useCallback(async () => {
try {
const rows = await douyuPoolApi.goods();
const usable = rows.filter((item) => (item.score ?? 0) > 0);
setGoods(usable);
if (usable.length > 0 && !commodityId) setCommodityId(usable[0].commodity_id);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
}, [commodityId]);
const loadLeases = useCallback(async () => {
try {
setLeases(await douyuPoolApi.leases(true));
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
}, []);
useEffect(() => { void loadGoods(); }, [loadGoods]);
useEffect(() => { void loadLeases(); }, [loadLeases]);
const loadSupply = useCallback(async () => {
if (!commodityId) return;
setPreviewing(true);
try {
setSupply(await douyuPoolApi.supply('elite', commodityId));
} catch (e: unknown) {
setSupply(null);
message.error(getErrorMessage(e));
} finally {
setPreviewing(false);
}
}, [commodityId]);
useEffect(() => { void loadSupply(); }, [loadSupply]);
const submit = async () => {
if (!commodityId) { message.warning('请先选择皮肤'); return; }
setLoading(true);
try {
const data = await douyuPoolApi.exchange({
scope: 'elite', commodity_id: commodityId, quantity,
allow_topup: allowTopup, allow_fallback: true,
});
setResult(data);
if (data.shortfall > 0) {
message.warning(`实配 ${data.allocated} / 需求 ${data.requested},缺 ${data.shortfall}`);
} else {
message.success(`已分配 ${data.allocated} 个账号,批次 ${data.batch_id}`);
}
await Promise.all([loadSupply(), loadLeases()]);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
};
const releaseLease = async (leaseId: number) => {
try {
await douyuPoolApi.releaseLease(leaseId);
message.success('已释放');
await Promise.all([loadSupply(), loadLeases()]);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const goodsOptions = useMemo(
() => goods.map((item) => ({
value: item.commodity_id,
label: `${item.name}(${item.score ?? 0} 分)`,
})),
[goods],
);
const accountColumns: ColumnsType<DouyuPoolAccount> = [
{ title: '账号ID', dataIndex: 'account_id', width: 90 },
{ title: '账号', dataIndex: 'username', ellipsis: true },
{
title: '梯队', dataIndex: 'tier', width: 90,
render: (tier: string) => (
<Tag color={tier === 'A' ? 'green' : 'orange'}>
{tier === 'A' ? '免充值' : '需充值'}
</Tag>
),
},
{ title: '积分', dataIndex: 'points', width: 90 },
{
title: '需充值', width: 160,
render: (_, row) => (row.topup_yuan > 0
? <Text type="warning">{row.topup_yuan} 元({row.recharge_orders.join(' + ')})</Text>
: <Text type="secondary">无需</Text>),
},
{
title: '可换绑时间', dataIndex: 'role_change_available_at', width: 180,
render: (value: string | null) => (value ? value.replace('T', ' ').slice(0, 19) : '—'),
},
];
const leaseColumns: ColumnsType<DouyuPoolLease> = [
{ title: '账号', dataIndex: 'username', ellipsis: true },
{ title: '商品', dataIndex: 'commodity_id', width: 200 },
{ title: '批次', dataIndex: 'batch_id', width: 130 },
{
title: '到期', dataIndex: 'expires_at', width: 170,
render: (value: string | null) => (value ? value.replace('T', ' ').slice(0, 19) : '—'),
},
{
title: '操作', width: 100,
render: (_, row) => (
<Button size="small" danger onClick={() => void releaseLease(row.id)}>释放</Button>
),
},
];
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card
title="发起兑换(系统自动分配账号)"
extra={(
<Button icon={<ReloadOutlined />} onClick={() => { void loadGoods(); void loadSupply(); void loadLeases(); }}>
刷新
</Button>
)}
>
<Row gutter={[16, 16]} align="bottom">
<Col xs={24} md={10}>
<Text type="secondary">皮肤</Text>
<Select
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
placeholder="选择要兑换的皮肤"
value={commodityId || undefined}
onChange={setCommodityId}
options={goodsOptions}
/>
</Col>
<Col xs={12} md={4}>
<Text type="secondary">需要几个账号</Text>
<InputNumber
min={1}
max={50}
style={{ width: '100%' }}
value={quantity}
onChange={(value) => setQuantity(Number(value) || 1)}
/>
</Col>
<Col xs={12} md={5}>
<Text type="secondary">积分不足时</Text>
<Select
style={{ width: '100%' }}
value={allowTopup ? 'topup' : 'skip'}
onChange={(value) => setAllowTopup(value === 'topup')}
options={[
{ value: 'topup', label: '自动充值积分' },
{ value: 'skip', label: '只用已有积分' },
]}
/>
</Col>
<Col xs={24} md={5}>
<Button
type="primary"
block
icon={<ThunderboltOutlined />}
loading={loading}
onClick={() => void submit()}
>
提交并自动分配
</Button>
</Col>
</Row>
{supply && (
<Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} md={6}>
<Statistic title="可直接兑(免充值)" value={supply.zero_topup} suffix="个" />
</Col>
<Col xs={12} md={6}>
<Statistic title="充值后可兑" value={supply.after_topup} suffix="个" />
</Col>
<Col xs={12} md={6}>
<Statistic
title="全部兑完预计花费"
value={supply.cost.est_yuan}
suffix="元"
loading={previewing}
/>
</Col>
<Col xs={12} md={6}>
<Statistic title="当前租用中" value={supply.leased} suffix="个" />
</Col>
</Row>
)}
{supply?.manual_payment_required && (
<Alert
style={{ marginTop: 12 }}
type="warning"
showIcon
message={`当前鱼翅渠道为「${supply.recharge_channel}」,需要人工扫码支付`}
description="系统会生成支付码并等待 10 分钟;超时后转入人工处理,需要你到「我的租约」里继续或放弃。"
/>
)}
</Card>
{result && (
<Card title={`本次分配结果(批次 ${result.batch_id || '—'})`}>
<Descriptions size="small" column={4} style={{ marginBottom: 12 }}>
<Descriptions.Item label="需求">{result.requested}</Descriptions.Item>
<Descriptions.Item label="实配">{result.allocated}</Descriptions.Item>
<Descriptions.Item label="缺口">
<Text type={result.shortfall > 0 ? 'danger' : undefined}>{result.shortfall}</Text>
</Descriptions.Item>
<Descriptions.Item label="充值渠道">{result.recharge_channel || '—'}</Descriptions.Item>
</Descriptions>
{result.reasons.length > 0 && (
<Paragraph type="warning" style={{ marginBottom: 12 }}>
{result.reasons.map((reason) => <div key={reason}>· {reason}</div>)}
</Paragraph>
)}
<Table
rowKey="account_id"
size="small"
pagination={false}
columns={accountColumns}
dataSource={result.accounts}
locale={{ emptyText: '本次没有分配账号' }}
/>
</Card>
)}
<Card title="我的租约(用完自动收回,也可手动释放)">
<Table
rowKey="id"
size="small"
columns={leaseColumns}
dataSource={leases}
pagination={{ pageSize: 10, showSizeChanger: false }}
locale={{ emptyText: '当前没有租用中的账号' }}
style={{ background: token.colorBgContainer }}
/>
</Card>
</Space>
);
}
@@ -15,6 +15,10 @@ export const TASK_STATUS_COLORS: Record<string, string> = {
failed: 'error',
error: 'error',
stopped: 'warning',
// 账号池补给链:条件已满足 / 前置未成功 → 跳过(不是失败,别标红)
skipped: 'default',
// 鱼翅等待超时转人工:需要有人去处理,用醒目但不等于失败的颜色
manual_required: 'warning',
};
export const TASK_STATUS_LABELS: Record<string, string> = {
@@ -25,6 +29,8 @@ export const TASK_STATUS_LABELS: Record<string, string> = {
failed: '失败',
error: '异常',
stopped: '已停止',
skipped: '已跳过',
manual_required: '转人工',
};
export const ELITE_QUICK_ACTIONS = [