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:
@@ -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)
|
||||
@@ -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):
|
||||
"""自动化作业定义。状态落库,进程重启不丢调度进度。"""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
"""账号池:候选筛选、两梯队排序、供给预估、租约领取与释放。
|
||||
|
||||
配套文档:`docs/设计-账号自动分配与回收.md`(§3.3 候选与排序、§3.4 补给链、
|
||||
§3.5 原子领取、§3.9 自动收回)。
|
||||
|
||||
## 为什么要拆「纯函数」与「SQL」
|
||||
|
||||
排序与充值量计算是**业务规则的集中地**(先花快过期的、先用小余额、
|
||||
缺多少补多少),也是最容易被改错、必须能被反向验证的部分 ——
|
||||
所以它们是不碰数据库的纯函数,用例可以脱离 MySQL 直接喂数据。
|
||||
|
||||
SQL 只负责**筛选**(谁的 CK 有效、谁已开通、谁 365 天内没兑过、谁没被租走)。
|
||||
|
||||
## 两个必须记住的约束
|
||||
|
||||
1. **`elite_opened = 1` 是硬门槛**:开通精英宝典要人工扫码
|
||||
(`create_elite_qr` 生成支付码),系统不能自动开通 —— 未开通的账号进池也白进。
|
||||
2. **并发抢号靠数据库**:`account_leases.active_account_id` 上的唯一索引裁决胜负,
|
||||
撞键就换下一个候选(SAVEPOINT 包住,不影响已经领到的那些)。
|
||||
不要退回「先查再插」——那在并发下必然双发。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, not_, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Account, AccountEvent, AccountLease, DouyuTask, User
|
||||
from .account_binding import SCOPE_ELITE, SCOPE_ESPORTS
|
||||
|
||||
#: 租约默认有效期(分钟)。到点由 `account_lease_reaper` 兜底回收。
|
||||
DEFAULT_LEASE_TTL_MINUTES = 60
|
||||
|
||||
#: 同一件皮肤多久内不允许在同一账号再兑(滚动天数)。
|
||||
EXCHANGE_COOLDOWN_DAYS = 365
|
||||
|
||||
#: 1 个精英令 = 10 精英积分 = 1 鱼翅 = 1 元(2026-10-07 生产实测)。
|
||||
POINTS_PER_GIFT = 10
|
||||
|
||||
#: 单次鱼翅充值上限(元)。见设计 §3.4.2。
|
||||
MAX_RECHARGE_AMOUNT = 800
|
||||
|
||||
#: 池成员 = 未分配 ∪ 管理员/运营名下;客服名下账号不进池(不跨归属)。
|
||||
_POOL_OWNER_ROLES = ("super_admin", "operation")
|
||||
|
||||
#: 作用域 → (积分列名,开通标记列名)。和平小店没有「开通」概念。
|
||||
_SCOPE_COLUMNS: dict[str, tuple[str, str | None]] = {
|
||||
SCOPE_ELITE: ("points", "elite_opened"),
|
||||
SCOPE_ESPORTS: ("esports_points", "esports_opened"),
|
||||
}
|
||||
|
||||
SUPPORTED_POOL_SCOPES = tuple(_SCOPE_COLUMNS)
|
||||
|
||||
|
||||
def _now(now: datetime | None = None) -> datetime:
|
||||
return now or datetime.now(UTC)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Candidate:
|
||||
"""一个可分配账号在**本次兑换**下的画像。"""
|
||||
|
||||
account_id: int
|
||||
username: str
|
||||
points: int
|
||||
score: int
|
||||
tier: str # "A" = 积分已够(免充值);"B" = 需要充值
|
||||
topup_gifts: int
|
||||
points_expire_at: datetime | None = None
|
||||
role_change_available_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def topup_yuan(self) -> int:
|
||||
"""需要充多少元(1 元 = 1 鱼翅 = 1 精英令)。"""
|
||||
return self.topup_gifts
|
||||
|
||||
@property
|
||||
def needs_topup(self) -> bool:
|
||||
return self.topup_gifts > 0
|
||||
|
||||
|
||||
def topup_gifts_for(points: int | None, score: int) -> int:
|
||||
"""凑够 `score` 还差几个精英令(向下取整的缺口向上取整到整数个令)。
|
||||
|
||||
>>> topup_gifts_for(300, 1280)
|
||||
98
|
||||
>>> topup_gifts_for(1280, 1280)
|
||||
0
|
||||
>>> topup_gifts_for(None, 20)
|
||||
2
|
||||
"""
|
||||
current = int(points or 0)
|
||||
missing = max(0, int(score) - current)
|
||||
return (missing + POINTS_PER_GIFT - 1) // POINTS_PER_GIFT
|
||||
|
||||
|
||||
def _sort_key(candidate: Candidate):
|
||||
"""两梯队排序键(文档 §3.3)。
|
||||
|
||||
A 梯队(免充值):先花**快过期**的 → 再用**小余额**(把大额账号留给贵商品)。
|
||||
B 梯队(需充值):先选**充值最少**的 → 再用**大余额**(留存量的先用掉)。
|
||||
|
||||
`points_expire_at` 为空排最后;最后统一用 `account_id` 兜稳定顺序,
|
||||
保证同样的输入永远得到同样的顺序(否则用例无法断言)。
|
||||
"""
|
||||
if candidate.tier == "A":
|
||||
expire_missing = candidate.points_expire_at is None
|
||||
expire_key = candidate.points_expire_at or datetime.max.replace(tzinfo=UTC)
|
||||
return (0, expire_missing, expire_key, candidate.points, candidate.account_id)
|
||||
return (
|
||||
1,
|
||||
candidate.topup_gifts,
|
||||
-candidate.points,
|
||||
candidate.account_id,
|
||||
)
|
||||
|
||||
|
||||
def make_candidate(
|
||||
*,
|
||||
account_id: int,
|
||||
username: str,
|
||||
points: int | None,
|
||||
score: int,
|
||||
points_expire_at: datetime | None = None,
|
||||
role_change_available_at: datetime | None = None,
|
||||
) -> Candidate:
|
||||
"""由账号行构造候选(纯函数,便于用例直接喂数据)。"""
|
||||
current = int(points or 0)
|
||||
gifts = topup_gifts_for(current, score)
|
||||
return Candidate(
|
||||
account_id=account_id,
|
||||
username=username,
|
||||
points=current,
|
||||
score=int(score),
|
||||
tier="A" if gifts == 0 else "B",
|
||||
topup_gifts=gifts,
|
||||
points_expire_at=points_expire_at,
|
||||
role_change_available_at=role_change_available_at,
|
||||
)
|
||||
|
||||
|
||||
def rank_candidates(candidates: Iterable[Candidate]) -> list[Candidate]:
|
||||
"""按两梯队规则排序(纯函数)。"""
|
||||
return sorted(candidates, key=_sort_key)
|
||||
|
||||
|
||||
def latest_exchange_exists_clause(commodity_id: str, since: datetime):
|
||||
"""「该账号在 `since` 之后兑过这件商品」的子查询条件。"""
|
||||
return (
|
||||
select(AccountEvent.id)
|
||||
.where(
|
||||
AccountEvent.account_id == Account.id,
|
||||
AccountEvent.event_type == "exchange",
|
||||
# 只按 subject_key 判:D1 回填后我方任务与官方补录都是商品 ID。
|
||||
# 不额外约束 subject_type —— 部分回填的历史行 subject_type 仍是 goods,
|
||||
# 按它过滤会漏判(这正是 D1 要修的东西)。
|
||||
AccountEvent.subject_key == commodity_id,
|
||||
AccountEvent.occurred_at >= since,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def active_lease_exists_clause():
|
||||
"""「该账号存在有效租约」的子查询条件。"""
|
||||
return (
|
||||
select(AccountLease.id)
|
||||
.where(AccountLease.active_account_id == Account.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def pool_membership_clause():
|
||||
"""池成员:未分配 ∪ 管理员/运营名下(客服名下账号不进池)。"""
|
||||
owner_ids = select(User.id).where(User.role.in_(_POOL_OWNER_ROLES))
|
||||
return or_(Account.assigned_to.is_(None), Account.assigned_to.in_(owner_ids))
|
||||
|
||||
|
||||
def candidate_query(
|
||||
*,
|
||||
scope: str,
|
||||
score: int,
|
||||
commodity_id: str,
|
||||
now: datetime | None = None,
|
||||
):
|
||||
"""候选账号的基础查询(**未排序、未限量**,供计数与取行共用)。"""
|
||||
if scope not in _SCOPE_COLUMNS:
|
||||
raise ValueError(f"暂不支持的作用域: {scope}")
|
||||
points_column_name, opened_column_name = _SCOPE_COLUMNS[scope]
|
||||
points_column = getattr(Account, points_column_name)
|
||||
since = _now(now) - timedelta(days=EXCHANGE_COOLDOWN_DAYS)
|
||||
|
||||
conditions: list[Any] = [
|
||||
Account.status == "active",
|
||||
Account.ck_check_status == "valid",
|
||||
Account.cookie.isnot(None),
|
||||
Account.cookie != "",
|
||||
Account.auto_managed.is_(True),
|
||||
pool_membership_clause(),
|
||||
not_(active_lease_exists_clause()),
|
||||
not_(latest_exchange_exists_clause(commodity_id, since)),
|
||||
]
|
||||
if opened_column_name:
|
||||
# 未开通的账号系统开不了(要人工扫码),直接排除。
|
||||
# `NULL` = 没查过,同样排除 —— 不能把「不知道」当「已开通」放行。
|
||||
conditions.append(getattr(Account, opened_column_name).is_(True))
|
||||
# 积分未知(没查过)时按 0 处理会把充值量算错,先排除。
|
||||
conditions.append(points_column.isnot(None))
|
||||
return select(Account).where(and_(*conditions)), points_column
|
||||
|
||||
|
||||
def eligible_accounts(
|
||||
db: Session,
|
||||
*,
|
||||
scope: str = SCOPE_ELITE,
|
||||
score: int,
|
||||
commodity_id: str,
|
||||
now: datetime | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[Candidate]:
|
||||
"""筛出可分配账号并按两梯队排序。
|
||||
|
||||
`limit` 是为了「要 5 个」时不用把整个池拉出来 —— 但**排序键里有 Python 侧
|
||||
的表达式**(到期时间、充值量),所以 SQL 只做粗筛 + 有界取行,再在 Python 里排。
|
||||
取值上限给足余量(默认 `limit*5`,至少 200),否则「积分够的账号排在后面」
|
||||
会被截断掉。
|
||||
"""
|
||||
query, points_column = candidate_query(
|
||||
scope=scope, score=score, commodity_id=commodity_id, now=now
|
||||
)
|
||||
query = query.order_by(points_column.desc(), Account.id.asc())
|
||||
if limit is not None:
|
||||
query = query.limit(max(200, int(limit) * 5))
|
||||
rows = db.execute(query).scalars().all()
|
||||
candidates = [
|
||||
make_candidate(
|
||||
account_id=row.id,
|
||||
username=row.username,
|
||||
points=getattr(row, _SCOPE_COLUMNS[scope][0]),
|
||||
score=score,
|
||||
points_expire_at=row.points_expire_at,
|
||||
role_change_available_at=row.role_change_available_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return rank_candidates(candidates)
|
||||
|
||||
|
||||
def estimate_supply(
|
||||
db: Session,
|
||||
*,
|
||||
scope: str = SCOPE_ELITE,
|
||||
score: int,
|
||||
commodity_id: str,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""不做任何锁定,预估「现在能分出几个、要花多少钱」(文档 §3.8)。
|
||||
|
||||
返回的四个数在写死的口径下**互斥且可加**:
|
||||
`zero_topup + (after_topup - zero_topup) + blocked 三项 = 池外无关账号` 不计。
|
||||
"""
|
||||
if scope not in _SCOPE_COLUMNS:
|
||||
raise ValueError(f"暂不支持的作用域: {scope}")
|
||||
points_column_name, opened_column_name = _SCOPE_COLUMNS[scope]
|
||||
points_column = getattr(Account, points_column_name)
|
||||
opened_column = getattr(Account, opened_column_name) if opened_column_name else None
|
||||
since = _now(now) - timedelta(days=EXCHANGE_COOLDOWN_DAYS)
|
||||
|
||||
valid_base = [
|
||||
Account.status == "active",
|
||||
Account.ck_check_status == "valid",
|
||||
Account.cookie.isnot(None),
|
||||
Account.cookie != "",
|
||||
Account.auto_managed.is_(True),
|
||||
pool_membership_clause(),
|
||||
]
|
||||
not_redeemed = not_(latest_exchange_exists_clause(commodity_id, since))
|
||||
|
||||
def count(*extra) -> int:
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.count(Account.id)).where(and_(*valid_base, *extra))
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
opened_ok = (
|
||||
[opened_column.is_(True), points_column.isnot(None)]
|
||||
if opened_column is not None
|
||||
else [points_column.isnot(None)]
|
||||
)
|
||||
eligible = count(*opened_ok, not_redeemed, not_(active_lease_exists_clause()))
|
||||
zero_topup = count(
|
||||
*opened_ok,
|
||||
not_redeemed,
|
||||
not_(active_lease_exists_clause()),
|
||||
points_column >= score,
|
||||
)
|
||||
# 已兑过(含仍在冷却期)的账号里,只看「手册已开通」的那部分 ——
|
||||
# 未开通的账号无论如何都用不了,混进这个数会让人误以为是"冷却挡住了"。
|
||||
redeemed = count(*opened_ok, latest_exchange_exists_clause(commodity_id, since))
|
||||
not_opened = (
|
||||
count(points_column.isnot(None), not_(opened_column.is_(True)))
|
||||
if opened_column is not None
|
||||
else 0
|
||||
)
|
||||
leased = count(*opened_ok, not_redeemed, active_lease_exists_clause())
|
||||
|
||||
gap_expr = func.greatest(0, score - func.coalesce(points_column, 0))
|
||||
total_gap = int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(gap_expr), 0)).where(
|
||||
and_(
|
||||
*valid_base,
|
||||
*opened_ok,
|
||||
not_redeemed,
|
||||
not_(active_lease_exists_clause()),
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
est_gifts = (total_gap + POINTS_PER_GIFT - 1) // POINTS_PER_GIFT
|
||||
|
||||
return {
|
||||
"scope": scope,
|
||||
"commodity_id": commodity_id,
|
||||
"score": int(score),
|
||||
"zero_topup": zero_topup,
|
||||
"after_topup": eligible,
|
||||
"leased": leased,
|
||||
"blocked": {
|
||||
f"{scope}_not_opened": not_opened,
|
||||
"redeemed_within_cooldown": redeemed,
|
||||
},
|
||||
"cost": {"est_gifts": est_gifts, "est_yuan": est_gifts},
|
||||
"cooldown_days": EXCHANGE_COOLDOWN_DAYS,
|
||||
}
|
||||
|
||||
|
||||
def active_lease_for_account(db: Session, account_id: int) -> AccountLease | None:
|
||||
return (
|
||||
db.query(AccountLease)
|
||||
.filter(
|
||||
AccountLease.account_id == account_id,
|
||||
AccountLease.status == "active",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def leased_account_ids(
|
||||
db: Session, *, holder_user_id: int, include_expired: bool = False
|
||||
) -> list[int]:
|
||||
"""当前用户持有的有效租约对应的账号 ID。"""
|
||||
query = db.query(AccountLease.account_id).filter(
|
||||
AccountLease.holder_user_id == holder_user_id,
|
||||
AccountLease.status == "active",
|
||||
)
|
||||
if not include_expired:
|
||||
query = query.filter(AccountLease.expires_at > _now())
|
||||
return [int(row[0]) for row in query.all()]
|
||||
|
||||
|
||||
def lease_accounts(
|
||||
db: Session,
|
||||
*,
|
||||
holder_user_id: int,
|
||||
candidates: Sequence[Candidate],
|
||||
scope: str = SCOPE_ELITE,
|
||||
commodity_id: str = "",
|
||||
batch_id: str = "",
|
||||
quantity: int | None = None,
|
||||
ttl_minutes: int = DEFAULT_LEASE_TTL_MINUTES,
|
||||
now: datetime | None = None,
|
||||
) -> list[AccountLease]:
|
||||
"""按顺序领取租约,返回**真正领到**的那些。
|
||||
|
||||
并发抢号由 `uq_lease_active_account` 裁决:每个候选用一个 SAVEPOINT 包住,
|
||||
撞唯一键就跳过它继续下一个 —— 已领到的不受影响(`rollback()` 会连坐,
|
||||
所以必须用 `begin_nested()`)。
|
||||
|
||||
**本函数会 commit**:租约必须先落库,后续建任务才有依据。
|
||||
"""
|
||||
moment = _now(now)
|
||||
expires_at = moment + timedelta(minutes=max(1, int(ttl_minutes)))
|
||||
leased: list[AccountLease] = []
|
||||
for candidate in candidates:
|
||||
if quantity is not None and len(leased) >= int(quantity):
|
||||
break
|
||||
lease = AccountLease(
|
||||
account_id=candidate.account_id,
|
||||
holder_user_id=holder_user_id,
|
||||
scope=scope,
|
||||
purpose="exchange",
|
||||
commodity_id=commodity_id,
|
||||
batch_id=batch_id,
|
||||
status="active",
|
||||
leased_at=moment,
|
||||
expires_at=expires_at,
|
||||
release_reason="",
|
||||
created_at=moment,
|
||||
)
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.add(lease)
|
||||
db.flush()
|
||||
except IntegrityError:
|
||||
# 撞唯一键 = 别人刚抢走这个账号。这是**预期内**的并发结果,不是故障。
|
||||
# SAVEPOINT 已经把这一条回滚掉了,直接换下一个候选。
|
||||
if lease in db:
|
||||
db.expunge(lease)
|
||||
continue
|
||||
leased.append(lease)
|
||||
db.commit()
|
||||
return leased
|
||||
|
||||
|
||||
def bind_leases_to_batch(
|
||||
db: Session, leases: Sequence[AccountLease], batch_id: str
|
||||
) -> None:
|
||||
"""把租约与任务批次关联起来(任务创建后才拿得到 batch_id)。"""
|
||||
if not leases or not batch_id:
|
||||
return
|
||||
for lease in leases:
|
||||
lease.batch_id = batch_id
|
||||
db.commit()
|
||||
|
||||
|
||||
def release_lease(
|
||||
db: Session,
|
||||
lease: AccountLease,
|
||||
*,
|
||||
reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> bool:
|
||||
"""释放单个租约;已经是终态则返回 False(幂等)。"""
|
||||
if lease.status != "active":
|
||||
return False
|
||||
lease.status = "released"
|
||||
lease.released_at = _now(now)
|
||||
lease.release_reason = str(reason or "")[:64]
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def release_for_task(
|
||||
db: Session,
|
||||
task: DouyuTask,
|
||||
*,
|
||||
reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> bool:
|
||||
"""任务终态时释放它对应账号的租约(文档 §3.9 的即时释放)。
|
||||
|
||||
按 `(batch_id, account_id)` 定位:一个批次里每个账号各有一条租约,
|
||||
不能因为某一条任务结束就把整批的租约都释放掉。
|
||||
"""
|
||||
if not task.batch_id:
|
||||
return False
|
||||
lease = (
|
||||
db.query(AccountLease)
|
||||
.filter(
|
||||
AccountLease.batch_id == task.batch_id,
|
||||
AccountLease.account_id == task.account_id,
|
||||
AccountLease.status == "active",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if lease is None:
|
||||
return False
|
||||
return release_lease(db, lease, reason=reason, now=now)
|
||||
|
||||
|
||||
def release_many(
|
||||
db: Session,
|
||||
leases: Sequence[AccountLease],
|
||||
*,
|
||||
reason: str,
|
||||
now: datetime | None = None,
|
||||
status: str = "released",
|
||||
) -> int:
|
||||
"""批量释放(补位失败、客服放弃、超管强制释放)。
|
||||
|
||||
`status` 只允许 `released`(人/流程主动放)与 `expired`(TTL 或孤儿批次兜底)——
|
||||
两者都会让生成列变 NULL、从而释放唯一索引,区别只在于事后怎么看这件事。
|
||||
"""
|
||||
moment = _now(now)
|
||||
count = 0
|
||||
for lease in leases:
|
||||
if lease.status != "active":
|
||||
continue
|
||||
lease.status = status
|
||||
lease.released_at = moment
|
||||
lease.release_reason = str(reason or "")[:64]
|
||||
count += 1
|
||||
if count:
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
|
||||
def release_batch(
|
||||
db: Session, batch_id: str, *, reason: str, now: datetime | None = None
|
||||
) -> int:
|
||||
"""释放一个任务批次下的全部有效租约。"""
|
||||
leases = (
|
||||
db.query(AccountLease)
|
||||
.filter(AccountLease.batch_id == batch_id, AccountLease.status == "active")
|
||||
.all()
|
||||
)
|
||||
return release_many(db, leases, reason=reason, now=now)
|
||||
|
||||
|
||||
def reap_expired(
|
||||
db: Session, *, now: datetime | None = None, batch_size: int = 500
|
||||
) -> int:
|
||||
"""把过期租约置为 expired(兜底作业调用)。
|
||||
|
||||
只动**已过期**的:正在跑的任务即使超了 TTL 也先留着,
|
||||
避免把还在执行中的账号放给第二个人 —— TTL 到期的第一批由客服/运营处理,
|
||||
真正兜底的是「批次已不在内存」那条路径(见 reaper 作业)。
|
||||
"""
|
||||
moment = _now(now)
|
||||
leases = (
|
||||
db.query(AccountLease)
|
||||
.filter(AccountLease.status == "active", AccountLease.expires_at <= moment)
|
||||
.order_by(AccountLease.id.asc())
|
||||
.limit(max(1, int(batch_size)))
|
||||
.all()
|
||||
)
|
||||
return release_many(db, leases, reason="ttl_expired", now=moment, status="expired")
|
||||
|
||||
|
||||
def orphan_leases(db: Session, *, active_batch_ids: set[str], limit: int = 500):
|
||||
"""批次已不在内存注册表(进程重启)的有效租约 —— 交给 reaper 释放。"""
|
||||
query = db.query(AccountLease).filter(AccountLease.status == "active")
|
||||
if active_batch_ids:
|
||||
query = query.filter(AccountLease.batch_id.notin_(list(active_batch_ids)))
|
||||
return query.order_by(AccountLease.id.asc()).limit(max(1, int(limit))).all()
|
||||
|
||||
|
||||
#: 任务还没跑完的状态(与 `douyu_service.DOUYU_ACTIVE_TASK_STATUSES` 同义)。
|
||||
#: 这里写一份而不是 import,是为了不让纯函数模块反向依赖整条 runner 依赖链。
|
||||
_ACTIVE_TASK_STATUSES = ("planned", "pending", "running")
|
||||
|
||||
|
||||
def reap_missed_releases(db: Session, *, limit: int = 500) -> int:
|
||||
"""回收「任务已经终态、租约却还挂着」的租约。
|
||||
|
||||
`_mark_task` 的即时释放是主路径,但进程被杀、任务行被保留策略删掉、
|
||||
释放时抛异常,都会留下悬空租约。判据是**该批次已经没有未完成任务**:
|
||||
|
||||
- 批次里还有 `planned/pending/running` → 说明还在跑,不动它;
|
||||
- `batch_id` 为空 → 租约刚建、任务还没建出来,不动(由 TTL 兜底)。
|
||||
|
||||
只动「没有在跑的任务」的租约,所以绝不会把正在执行的账号交给第二个人。
|
||||
"""
|
||||
active_batches = select(DouyuTask.batch_id).where(
|
||||
DouyuTask.status.in_(_ACTIVE_TASK_STATUSES)
|
||||
)
|
||||
leases = (
|
||||
db.query(AccountLease)
|
||||
.filter(
|
||||
AccountLease.status == "active",
|
||||
AccountLease.batch_id != "",
|
||||
AccountLease.batch_id.notin_(active_batches),
|
||||
)
|
||||
.order_by(AccountLease.id.asc())
|
||||
.limit(max(1, int(limit)))
|
||||
.all()
|
||||
)
|
||||
return release_many(db, leases, reason="task_finished_missed")
|
||||
|
||||
|
||||
def recharge_amounts_for(gold_needed: int) -> list[int]:
|
||||
"""把一笔充值需求拆成每笔不超过 800 元的订单(文档 §3.4.2)。
|
||||
|
||||
返回**空列表**表示不需要充值。单账号一件 1280 分的套装只需要 128 元,
|
||||
正常不会拆单;一次补很多分才会走到这里。
|
||||
|
||||
>>> recharge_amounts_for(0)
|
||||
[]
|
||||
>>> recharge_amounts_for(98)
|
||||
[98]
|
||||
>>> recharge_amounts_for(1700)
|
||||
[800, 800, 100]
|
||||
"""
|
||||
remaining = max(0, int(gold_needed))
|
||||
orders: list[int] = []
|
||||
while remaining > 0:
|
||||
chunk = min(remaining, MAX_RECHARGE_AMOUNT)
|
||||
orders.append(chunk)
|
||||
remaining -= chunk
|
||||
return orders
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupplyStep:
|
||||
"""补给链里的一步:任务类型 + 该任务自己的载荷。"""
|
||||
|
||||
task_type: str
|
||||
payload: dict
|
||||
|
||||
|
||||
def build_supply_chain(
|
||||
candidate: Candidate,
|
||||
*,
|
||||
commodity_id: str,
|
||||
exchange_task_type: str = "exchange_goods",
|
||||
) -> list[SupplyStep]:
|
||||
"""给一个 B 梯队账号排出「充值 → 赠令 → 兑换」的执行链(纯函数)。
|
||||
|
||||
设计要点(都是被现实逼出来的):
|
||||
|
||||
1. **先查余额再决定要不要花钱**。链首固定放 `query_gold_balance` /`query_points`:
|
||||
充值步骤带 `_skip_when` 闸门,依据的是**执行那一刻**账号上的鱼翅/积分;
|
||||
不先刷新一次,闸门读的是旧值 —— 旧值偏低就会**多充真金白银**。
|
||||
2. **缺多少充多少**,超过 800 元自动拆单(服务端另有硬校验)。
|
||||
3. **赠令带 `points >= score` 闸门**:积分已经够了就别再送礼(送出去是钱)。
|
||||
4. A 梯队(积分已够)**不生成补给步骤**,链上只有兑换一步。
|
||||
|
||||
执行顺序由「同一批次 + 并发 1」保证(`DouyuBatchRunner` 按任务 id 顺序取行)。
|
||||
"""
|
||||
if not candidate.needs_topup:
|
||||
return [
|
||||
SupplyStep(
|
||||
exchange_task_type,
|
||||
{"commodity_id": commodity_id, "num": 1},
|
||||
)
|
||||
]
|
||||
|
||||
steps: list[SupplyStep] = [
|
||||
SupplyStep("query_gold_balance", {}),
|
||||
SupplyStep("query_points", {}),
|
||||
]
|
||||
# 充值:余额够就跳过(闸门在执行时求值,读的是刚刷新过的账号字段)
|
||||
for amount in recharge_amounts_for(candidate.topup_gifts):
|
||||
steps.append(
|
||||
SupplyStep(
|
||||
"create_gold_qr",
|
||||
{
|
||||
"amount": amount,
|
||||
"_skip_when": {
|
||||
"column": "gold_balance",
|
||||
"op": ">=",
|
||||
"value": candidate.topup_gifts,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
steps.append(
|
||||
SupplyStep(
|
||||
"donate_elite_gift",
|
||||
{
|
||||
"gift_count": candidate.topup_gifts,
|
||||
"_skip_when": {
|
||||
"column": "points",
|
||||
"op": ">=",
|
||||
"value": candidate.score,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
steps.append(
|
||||
SupplyStep(exchange_task_type, {"commodity_id": commodity_id, "num": 1})
|
||||
)
|
||||
return steps
|
||||
|
||||
|
||||
def validate_recharge_amount(amount: int | None) -> int:
|
||||
"""校验单次充值金额(元)。
|
||||
|
||||
只做**上限**校验,不做下限 —— 下限由调用方按业务给默认值。
|
||||
"""
|
||||
value = int(amount or 0)
|
||||
if value <= 0:
|
||||
raise ValueError("充值金额必须大于 0")
|
||||
if value > MAX_RECHARGE_AMOUNT:
|
||||
raise ValueError(
|
||||
f"单次充值不得超过 {MAX_RECHARGE_AMOUNT} 元,本次请求 {value} 元"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def lease_status_summary(db: Session, *, holder_user_id: int) -> dict[str, int]:
|
||||
"""给池工作台用的租约概览。"""
|
||||
moment = _now()
|
||||
base = db.query(AccountLease).filter(
|
||||
AccountLease.holder_user_id == holder_user_id,
|
||||
AccountLease.status == "active",
|
||||
)
|
||||
return {
|
||||
"active": base.count(),
|
||||
"expiring_soon": base.filter(
|
||||
AccountLease.expires_at <= moment + timedelta(minutes=10)
|
||||
).count(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_LEASE_TTL_MINUTES",
|
||||
"EXCHANGE_COOLDOWN_DAYS",
|
||||
"MAX_RECHARGE_AMOUNT",
|
||||
"POINTS_PER_GIFT",
|
||||
"SUPPORTED_POOL_SCOPES",
|
||||
"Candidate",
|
||||
"SupplyStep",
|
||||
"active_lease_for_account",
|
||||
"bind_leases_to_batch",
|
||||
"build_supply_chain",
|
||||
"eligible_accounts",
|
||||
"estimate_supply",
|
||||
"lease_accounts",
|
||||
"lease_status_summary",
|
||||
"leased_account_ids",
|
||||
"make_candidate",
|
||||
"orphan_leases",
|
||||
"rank_candidates",
|
||||
"reap_expired",
|
||||
"reap_missed_releases",
|
||||
"recharge_amounts_for",
|
||||
"release_batch",
|
||||
"release_for_task",
|
||||
"release_lease",
|
||||
"release_many",
|
||||
"topup_gifts_for",
|
||||
"validate_recharge_amount",
|
||||
]
|
||||
@@ -6,6 +6,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .jobs.account_lease_reaper import (
|
||||
JOB_KEY as ACCOUNT_LEASE_REAPER_KEY,
|
||||
)
|
||||
from .jobs.account_lease_reaper import (
|
||||
run_account_lease_reaper,
|
||||
)
|
||||
from .jobs.account_overview_refresh import (
|
||||
JOB_KEY as ACCOUNT_OVERVIEW_REFRESH_KEY,
|
||||
)
|
||||
@@ -39,6 +45,7 @@ from .scheduler import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ACCOUNT_LEASE_REAPER_KEY",
|
||||
"ACCOUNT_OVERVIEW_REFRESH_KEY",
|
||||
"CK_HEALTH_KEY",
|
||||
"CK_RELOGIN_KEY",
|
||||
@@ -51,6 +58,7 @@ __all__ = [
|
||||
"register_job",
|
||||
"registered_jobs",
|
||||
"reset_registry",
|
||||
"run_account_lease_reaper",
|
||||
"run_account_overview_refresh",
|
||||
"run_ck_health",
|
||||
"run_ck_relogin",
|
||||
@@ -140,3 +148,20 @@ def register_default_jobs() -> None:
|
||||
run=run_ck_health,
|
||||
)
|
||||
)
|
||||
register_job(
|
||||
JobSpec(
|
||||
key=ACCOUNT_LEASE_REAPER_KEY,
|
||||
title="账号租约回收",
|
||||
# 租约的主释放路径是任务终态钩子;本作业只是**保险丝**,
|
||||
# 兜住「进程重启 / 释放抛异常 / 任务行被清」三种漏网情况。
|
||||
# 跑得勤一点(1 分钟)成本极低:只扫带索引的 status+expires_at,
|
||||
# 而且悬空租约会让账号白白锁着 —— 早收早能用。
|
||||
# `interval_seconds` 对它是无意义的(本作业自己按三类规则选行),
|
||||
# 但 JobSpec 要求必填,给一个与 run_interval 同量级的值。
|
||||
interval_seconds=60,
|
||||
run_interval_seconds=60,
|
||||
batch_size=500,
|
||||
concurrency=1,
|
||||
run=run_account_lease_reaper,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""作业:兜底回收账号租约(账号池的「自动收回」保险丝)。
|
||||
|
||||
## 为什么必须有它
|
||||
|
||||
租约的主释放路径是 `_mark_task` 写终态时的即时钩子(`release_for_task`),
|
||||
覆盖 99% 的情况。但下面三种情况会留下**悬空租约**,而这个账号会一直显示
|
||||
"被占用"、谁也领不走:
|
||||
|
||||
1. **进程被杀 / 重启**:执行器线程没了,任务永远停在 `pending/running`;
|
||||
2. **释放时抛异常**:`_mark_task` 里那一步是旁路,失败只记 warning;
|
||||
3. **任务行被保留策略删掉**:批次看着"没有未完成任务",但钩子再也不会跑。
|
||||
|
||||
本作业按三条规则收:
|
||||
|
||||
| 规则 | 判据 | 置为 |
|
||||
| --- | --- | --- |
|
||||
| TTL 到期 | `expires_at <= now` | `expired` |
|
||||
| 漏释放 | 批次**已无**未完成任务(`planned/pending/running`) | `released` |
|
||||
| 孤儿批次 | 批次不在**本进程**的执行器注册表里 | `expired` |
|
||||
|
||||
## ⚠️ 部署前提
|
||||
|
||||
「孤儿批次」这条规则依赖**单进程部署**:注册表 `douyu_batch_registry` 在内存里,
|
||||
只有跑这个批次的那个进程知道它。生产是 `uvicorn --workers 1`(见
|
||||
`docker-entrypoint.sh`),所以成立。
|
||||
|
||||
**如果将来改成多 worker,这条规则会误杀别的 worker 正在跑的批次** ——
|
||||
到那时必须换成落库的批次心跳(或在 `douyu_tasks` 上记执行者标识),
|
||||
不能只靠内存注册表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ....models import AutomationJob
|
||||
from ...account_pool import (
|
||||
orphan_leases,
|
||||
reap_expired,
|
||||
reap_missed_releases,
|
||||
release_many,
|
||||
)
|
||||
|
||||
JOB_KEY = "account_lease_reaper"
|
||||
|
||||
#: 一轮最多处理多少条租约(三类各用这个上限)。
|
||||
DEFAULT_BATCH = 500
|
||||
|
||||
|
||||
def _positive_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed if parsed > 0 else default
|
||||
|
||||
|
||||
def run_account_lease_reaper(
|
||||
db: Session, job: AutomationJob, *, now: datetime | None = None
|
||||
) -> dict[str, Any]:
|
||||
batch = _positive_int(job.batch_size, DEFAULT_BATCH)
|
||||
moment = now or datetime.now(UTC)
|
||||
|
||||
# 延迟导入:作业模块在应用启动时被加载,顶层导入 runner 会把
|
||||
# 「路由 → runner → account_pool」整条链在启动期拉起来,容易出循环导入。
|
||||
from ....services.douyu_runner import douyu_batch_registry
|
||||
|
||||
# ① TTL 到期
|
||||
expired = reap_expired(db, now=moment, batch_size=batch)
|
||||
# ② 任务已终态但租约没释放
|
||||
missed = reap_missed_releases(db, limit=batch)
|
||||
# ③ 批次已不在本进程注册表(进程重启)
|
||||
active_batch_ids = douyu_batch_registry.active_ids()
|
||||
orphans = orphan_leases(db, active_batch_ids=active_batch_ids, limit=batch)
|
||||
orphaned = release_many(db, orphans, reason="orphan_batch", status="expired")
|
||||
|
||||
total = expired + missed + orphaned
|
||||
message = (
|
||||
f"租约回收 {total} 条"
|
||||
f"(TTL 到期 {expired} / 漏释放 {missed} / 孤儿批次 {orphaned})"
|
||||
)
|
||||
if total:
|
||||
logger.info(message)
|
||||
return {
|
||||
"scanned": total,
|
||||
"expired": expired,
|
||||
"missed_release": missed,
|
||||
"orphan_batch": orphaned,
|
||||
"active_batches": len(active_batch_ids),
|
||||
"message": message,
|
||||
}
|
||||
@@ -64,6 +64,23 @@ def task_ref(task_id: int, kind: str = "") -> str:
|
||||
return f"{base}:{kind}" if kind else base
|
||||
|
||||
|
||||
def task_actor(task: Any) -> dict[str, Any]:
|
||||
"""业务事件的「谁做的」—— 发起这条任务的用户。
|
||||
|
||||
为什么用 `task.created_by`:任务由客服在界面上发起(池路径下也就是**租用该账号
|
||||
的客服**),所以它就是操作人。以前所有业务事件(充值/兑换/开通/积分)都不带这个
|
||||
字段,生产 34,462 条事件里有 24,502 条(71%)查不出责任人 —— 池化之后
|
||||
「客服租用期间做了什么」是必须能回答的,所以补齐。
|
||||
|
||||
调用处一律用 `**task_actor(task)`,默认值仍是「无操作人」,
|
||||
因此老调用方一行不改也不会变。
|
||||
"""
|
||||
created_by = getattr(task, "created_by", None)
|
||||
if not created_by:
|
||||
return {"actor_type": "system", "actor_id": None}
|
||||
return {"actor_type": "user", "actor_id": int(created_by)}
|
||||
|
||||
|
||||
def record_recharge(
|
||||
db: Session,
|
||||
account: Account,
|
||||
@@ -77,6 +94,8 @@ def record_recharge(
|
||||
douyu_uid: str = "",
|
||||
balance_after: int | None = None,
|
||||
confirmed: bool = True,
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""充值鱼翅到账。
|
||||
|
||||
@@ -110,6 +129,8 @@ def record_recharge(
|
||||
subject_type="recharge",
|
||||
subject_key=order_id or supplier_order_id or "",
|
||||
source_ref=task_ref(task_id),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +149,8 @@ def record_exchange(
|
||||
game_channel: str = "",
|
||||
points_after: int | None = None,
|
||||
scope: str = "elite",
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""商品/皮肤兑换成功 —— **只在真的消耗了积分并兑换成功时调用**。
|
||||
|
||||
@@ -160,6 +183,8 @@ def record_exchange(
|
||||
subject_type="commodity",
|
||||
subject_key=commodity_id or name or "",
|
||||
source_ref=task_ref(task_id),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -174,6 +199,8 @@ def record_gold_spent(
|
||||
source_label: str = "",
|
||||
balance_after: int | None = None,
|
||||
confirmed: bool = True,
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""鱼翅消耗(送礼等)。
|
||||
|
||||
@@ -203,6 +230,8 @@ def record_gold_spent(
|
||||
subject_type="gold",
|
||||
# 副事件:同一个任务还会发「积分到账」,所以带后缀避免撞幂等键
|
||||
source_ref=task_ref(task_id, "gold"),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -214,6 +243,8 @@ def record_points_expire(
|
||||
occurred_at: datetime | None = None,
|
||||
expires_at: Any = None,
|
||||
confirmed: bool = False,
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""积分到期(资产减少)。
|
||||
|
||||
@@ -236,6 +267,8 @@ def record_points_expire(
|
||||
detail={"confirmed": confirmed, "expires_at": expires_at},
|
||||
source="points_expire",
|
||||
subject_type="points",
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -250,6 +283,8 @@ def record_points_credit(
|
||||
expire_at: Any = None,
|
||||
confirmed: bool = True,
|
||||
source_label: str = "精英令",
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""积分到账(送礼/任务发放)。
|
||||
|
||||
@@ -281,6 +316,8 @@ def record_points_credit(
|
||||
source="douyu_task",
|
||||
subject_type="points",
|
||||
source_ref=task_ref(task_id),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -295,6 +332,8 @@ def record_bind(
|
||||
act_alias: str = "",
|
||||
rebind: bool = False,
|
||||
weak: bool = False,
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""角色绑定 / 换绑**真的发生了**。
|
||||
|
||||
@@ -332,6 +371,8 @@ def record_bind(
|
||||
subject_type=f"{scope}_role",
|
||||
subject_key=role_name or "",
|
||||
source_ref=task_ref(task_id),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -346,6 +387,8 @@ def record_official_exchange(
|
||||
show_name: str = "",
|
||||
exchange_num: int | None = None,
|
||||
scope: str = "elite",
|
||||
actor_type: str = "",
|
||||
actor_id: int | None = None,
|
||||
) -> bool:
|
||||
"""把斗鱼官方兑换记录写成账号事件。
|
||||
|
||||
@@ -386,10 +429,21 @@ def record_official_exchange(
|
||||
"scope": scope,
|
||||
},
|
||||
source=OFFICIAL_SYNC_SOURCE,
|
||||
subject_type="goods",
|
||||
# 台账检索键必须是**斗鱼商品 ID**:365 天规则与 `ix_account_events_ledger`
|
||||
# 都按 `subject_key` 判「该账号该商品最近一次兑换」。
|
||||
#
|
||||
# 2026-10-07 修:这里以前漏传 `subject_key`(只设了 subject_type="goods"),
|
||||
# 于是 10,612 条官方补录事件的主键全是空串 —— 用 subject_key 查历史会漏掉
|
||||
# 87% 的记录,365 天规则形同虚设。`subject_type` 同时统一为 commodity,
|
||||
# 与 `record_exchange`(我方任务直发)保持一致。
|
||||
# 幂等靠下面的 `source_ref`,与 subject_key 无关,改这里不会造成重复。
|
||||
subject_type="commodity",
|
||||
subject_key=commodity_id,
|
||||
source_ref=(
|
||||
f"official_exchange:{scope}:{account.id}:{commodity_id}:{_stamp(occurred_at)}"
|
||||
),
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -59,6 +59,18 @@ class DouyuBatchRunner(
|
||||
self._push_log(
|
||||
"info", f"[{current}/{total}] 开始: {self._account_name(account)}"
|
||||
)
|
||||
# 补给链的两个运行期闸门(见 `account_pool.build_supply_chain`):
|
||||
# `_depends_on` 前置没成功就别跑,`_skip_when` 条件已满足就别做(省的是钱)。
|
||||
# 放在 CK 检测**之前** —— 被跳过的任务不该因为 CK 失效再报一次失败。
|
||||
skip_reason = self._dependency_reason(worker_db, task) or self._skip_reason(
|
||||
account, task
|
||||
)
|
||||
if skip_reason:
|
||||
self._mark_task(worker_db, task, "skipped", skip_reason)
|
||||
self._push_log(
|
||||
"info", f"[{current}] {self._account_name(account)} {skip_reason}"
|
||||
)
|
||||
return
|
||||
# CK 与健康状态都挂在账号上(2026-10-04 CK 解耦)。
|
||||
cookie = account.cookie or ""
|
||||
if not cookie:
|
||||
|
||||
@@ -19,7 +19,7 @@ from .account_binding import (
|
||||
SCOPE_ESPORTS,
|
||||
set_bind_status,
|
||||
)
|
||||
from .business_events import record_bind
|
||||
from .business_events import record_bind, task_actor
|
||||
|
||||
DOUYU_LEGACY_BIND_ACT_ALIAS = "20250213NQCYX"
|
||||
DOUYU_BIND_ROLE_POLL_SECONDS = 65
|
||||
@@ -932,6 +932,7 @@ class BindMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
scope="elite",
|
||||
role_name=role_name,
|
||||
act_alias=use_confirm_alias,
|
||||
@@ -968,6 +969,7 @@ class BindMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
scope="elite",
|
||||
role_name=final_role_name,
|
||||
act_alias=use_confirm_alias,
|
||||
@@ -1125,6 +1127,7 @@ class BindMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
scope="esports",
|
||||
role_name=role_text,
|
||||
act_alias=act_alias,
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import operator
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -24,6 +25,7 @@ from ..models import (
|
||||
from ..models import (
|
||||
ProxyConfig as ProxyConfigModel,
|
||||
)
|
||||
from .account_pool import release_for_task
|
||||
from .douyu_service import (
|
||||
DOUYU_CONFIG_FIELDS,
|
||||
douyu_config_value,
|
||||
@@ -36,6 +38,9 @@ if TYPE_CHECKING:
|
||||
|
||||
# 支付/到账轮询(手册与充值共用)
|
||||
DOUYU_PAYMENT_POLL_SECONDS = 600
|
||||
|
||||
#: 支付等待超时后的状态:**转人工**,不再自动轮询,租约也先不还。
|
||||
MANUAL_REQUIRED_STATUS = "manual_required"
|
||||
DOUYU_PAYMENT_POLL_INTERVAL = 5
|
||||
|
||||
# 走代理的任务类型:写操作/消耗类(兑换、锁单、支付、开通、充值、送礼)。
|
||||
@@ -219,8 +224,98 @@ class DouyuBatchRunnerCore:
|
||||
task.result = result
|
||||
task.finished_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
# 账号池:任务走到终态就**把租约还回去**(设计 §3.9 的即时释放)。
|
||||
# 这一步覆盖 99% 的情况;漏掉的(进程被杀、任务行被清)由
|
||||
# `account_lease_reaper` 兜底。释放失败绝不能影响任务结果本身。
|
||||
self._release_pool_lease(db, task)
|
||||
self._push_task_event(task)
|
||||
|
||||
def _release_pool_lease(self, db: Session, task: DouyuTask) -> None:
|
||||
"""释放该任务对应账号的租约(不是整批 —— 同批每个账号各有一条)。"""
|
||||
try:
|
||||
if not task.batch_id:
|
||||
return
|
||||
# 转人工的任务**先不还号**:客服还在等支付/处理,账号已经挂在这单上。
|
||||
# 同理,同一批次里只要还有 `manual_required` 的任务(补给链后半段会被
|
||||
# 前置闸门标成 skipped),整条链的号都先留着。
|
||||
# 账号不会被永久占住 —— 租约到期由 `account_lease_reaper` 兜底收回。
|
||||
if task.status == MANUAL_REQUIRED_STATUS:
|
||||
return
|
||||
waiting_manual = (
|
||||
db.query(DouyuTask.id)
|
||||
.filter(
|
||||
DouyuTask.batch_id == task.batch_id,
|
||||
DouyuTask.status == MANUAL_REQUIRED_STATUS,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if waiting_manual:
|
||||
return
|
||||
release_for_task(db, task, reason=f"task_{task.status}")
|
||||
except Exception as exc: # noqa: BLE001 旁路动作,绝不能带崩任务
|
||||
logger.warning(f"释放账号租约失败 task={task.id}: {exc}")
|
||||
|
||||
# ── 补给链的两个运行期闸门(见 account_pool.build_supply_chain)──────────
|
||||
|
||||
#: `_skip_when` 支持的账号字段与比较符。
|
||||
_SKIP_COLUMNS: ClassVar[tuple[str, ...]] = (
|
||||
"points",
|
||||
"gold_balance",
|
||||
"exchange_balance",
|
||||
"esports_points",
|
||||
)
|
||||
_SKIP_OPS: ClassVar[dict[str, Any]] = {
|
||||
">=": operator.ge,
|
||||
">": operator.gt,
|
||||
"<=": operator.le,
|
||||
"<": operator.lt,
|
||||
"==": operator.eq,
|
||||
}
|
||||
|
||||
def _skip_reason(self, account: Account, task: DouyuTask) -> str:
|
||||
"""`_skip_when` 闸门:满足条件就跳过这一步。
|
||||
|
||||
补给链用它实现「余额已经够了就别再充钱」。**只在能确定时跳过**:
|
||||
字段缺失、比较符不认识、值解析失败一律不跳(宁可多跑一步,
|
||||
也不要因为解析不出来就静默把钱省掉/花掉)。
|
||||
"""
|
||||
rule = self._task_payload(task).get("_skip_when")
|
||||
if not isinstance(rule, dict):
|
||||
return ""
|
||||
column = str(rule.get("column") or "")
|
||||
if column not in self._SKIP_COLUMNS:
|
||||
return ""
|
||||
compare = self._SKIP_OPS.get(str(rule.get("op") or ""))
|
||||
if compare is None:
|
||||
return ""
|
||||
current = getattr(account, column, None)
|
||||
if current is None:
|
||||
return ""
|
||||
try:
|
||||
target = int(rule.get("value"))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if compare(int(current), target):
|
||||
return f"跳过:{column}={int(current)} {rule.get('op')} {target} 已满足"
|
||||
return ""
|
||||
|
||||
def _dependency_reason(self, db: Session, task: DouyuTask) -> str:
|
||||
"""`_depends_on` 闸门:前置任务没成功就不必再跑(省得连着一串失败)。"""
|
||||
raw = self._task_payload(task).get("_depends_on")
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
depends_on = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
previous = db.query(DouyuTask).filter(DouyuTask.id == depends_on).first()
|
||||
if previous is None:
|
||||
return ""
|
||||
# 前置「成功」或「被跳过」都算通过:跳过意味着那一步本来就不需要做。
|
||||
if previous.status in ("success", "skipped"):
|
||||
return ""
|
||||
return f"前置任务 #{depends_on} 未成功({previous.status}),跳过"
|
||||
|
||||
def _update_task_progress(
|
||||
self,
|
||||
db: Session,
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
||||
from core.douyu import DouyuActivityClient
|
||||
|
||||
from ..models import Account, DouyuTask
|
||||
from .business_events import record_gold_spent, record_points_credit
|
||||
from .business_events import record_gold_spent, record_points_credit, task_actor
|
||||
|
||||
DOUYU_GIFT_POINTS_REFRESH_TIMES = 3
|
||||
DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2
|
||||
@@ -197,6 +197,7 @@ class DonateMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
gain=_esports_gift_points(result, baseline_points),
|
||||
points_after=account.esports_points,
|
||||
confirmed=confirmed,
|
||||
@@ -206,6 +207,7 @@ class DonateMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
amount=_gold_spent(baseline_gold, account.gold_balance),
|
||||
reason=f"赠送{gift_name} x{gift_count}",
|
||||
source_label=f"赠送{gift_name}",
|
||||
@@ -332,6 +334,7 @@ class DonateMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
gain=max(0, gift_count) * 10,
|
||||
points_after=account.points,
|
||||
expire_at=expire_at,
|
||||
@@ -344,6 +347,7 @@ class DonateMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
amount=_gold_spent(baseline_gold, account.gold_balance),
|
||||
reason=f"赠送精英令 x{gift_count}",
|
||||
source_label="赠送精英令",
|
||||
|
||||
@@ -18,8 +18,13 @@ from core.douyu import (
|
||||
)
|
||||
|
||||
from ..models import Account, DouyuTask
|
||||
from .business_events import record_recharge
|
||||
from .douyu_runner_core import DOUYU_PAYMENT_POLL_INTERVAL, DOUYU_PAYMENT_POLL_SECONDS
|
||||
from .account_pool import validate_recharge_amount
|
||||
from .business_events import record_recharge, task_actor
|
||||
from .douyu_runner_core import (
|
||||
DOUYU_PAYMENT_POLL_INTERVAL,
|
||||
DOUYU_PAYMENT_POLL_SECONDS,
|
||||
MANUAL_REQUIRED_STATUS,
|
||||
)
|
||||
from .douyu_service import update_account_profile_from_cookie
|
||||
|
||||
|
||||
@@ -247,7 +252,15 @@ class GoldMixin:
|
||||
self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict
|
||||
):
|
||||
payload = self._task_payload(task)
|
||||
amount = int(payload.get("amount") or payload.get("gold_amount") or 1)
|
||||
# 单次充值上限(元)。**必须服务端校验**:前端限制只是体验,
|
||||
# 任务可以绕过界面直接建(`POST /api/douyu/tasks/batch` 的 payload 是自由字典)。
|
||||
try:
|
||||
amount = validate_recharge_amount(
|
||||
int(payload.get("amount") or payload.get("gold_amount") or 1)
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._mark_task(db, task, "failed", str(exc))
|
||||
return
|
||||
channel = str(config.get("gold_recharge_channel") or "wechat_qr")
|
||||
if channel == "supplier_api":
|
||||
try:
|
||||
@@ -289,6 +302,7 @@ class GoldMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
amount=amount,
|
||||
channel=channel,
|
||||
balance_after=account.gold_balance,
|
||||
@@ -305,8 +319,9 @@ class GoldMixin:
|
||||
self._mark_task(
|
||||
db,
|
||||
task,
|
||||
"failed",
|
||||
f"未检测到鱼翅到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}",
|
||||
MANUAL_REQUIRED_STATUS,
|
||||
f"等待 {DOUYU_PAYMENT_POLL_SECONDS // 60} 分钟未检测到鱼翅到账,已转人工处理"
|
||||
f"(当前余额: {account.gold_balance if account.gold_balance is not None else '-'})",
|
||||
result,
|
||||
)
|
||||
|
||||
@@ -446,6 +461,7 @@ class GoldMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
amount=amount,
|
||||
order_id=str(result.get("out_order_id") or ""),
|
||||
supplier_order_id=str(result.get("order_id") or ""),
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
|
||||
from core.douyu import DouyuActivityClient, DouyuActivityError
|
||||
|
||||
from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask
|
||||
from .business_events import record_exchange
|
||||
from .business_events import record_exchange, task_actor
|
||||
|
||||
# 兑换节奏与重试(对齐 8.30 浏览器抓包:锁单->支付间隔约 2.2~4.2s;火爆类错误要长退避而不是秒级连打)
|
||||
DOUYU_EXCHANGE_PRE_CREATE_JITTER = (0.3, 1.2)
|
||||
@@ -214,6 +214,7 @@ class GoodsMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
name=(goods.name if goods else "") or commodity_id or order_id,
|
||||
score=score,
|
||||
commodity_id=commodity_id,
|
||||
@@ -520,6 +521,7 @@ class GoodsMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
name=name,
|
||||
score=locked.get("score"),
|
||||
commodity_id=commodity_id,
|
||||
@@ -635,6 +637,7 @@ class GoodsMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
name=name,
|
||||
score=spent if spent and spent > 0 else None,
|
||||
commodity_id=commodity_id,
|
||||
|
||||
@@ -34,7 +34,7 @@ from core.douyu import DouyuActivityClient
|
||||
|
||||
from ..models import Account, DouyuTask
|
||||
from .account_binding import derive_from_bind_info, set_bind_status
|
||||
from .business_events import record_official_exchange
|
||||
from .business_events import record_official_exchange, task_actor
|
||||
from .douyu_runner_donate import _parse_expire_at, _points_expire_at
|
||||
|
||||
#: 默认关注的高价值商品。按**商品名包含**匹配 —— 官方列表里的名字带前缀
|
||||
@@ -151,12 +151,18 @@ def _group_by_scope(
|
||||
|
||||
|
||||
def _record_official_exchanges(
|
||||
db: Session, account: Account, pairs: list[tuple[str, dict[str, Any]]]
|
||||
db: Session,
|
||||
account: Account,
|
||||
pairs: list[tuple[str, dict[str, Any]]],
|
||||
task: Any = None,
|
||||
) -> int:
|
||||
"""把官方兑换记录补成账号事件,返回**新写入**的条数。
|
||||
|
||||
幂等由 `record_official_exchange` 的 `source_ref` 保证(含作用域),
|
||||
所以一键查询反复执行不会重复落事件。
|
||||
|
||||
`task` 只用来取**操作人**(`task.created_by`)—— 谁点的这次一键查询,
|
||||
这条兑换历史就记在谁名下。不传则退化为「无操作人」,与老行为一致。
|
||||
"""
|
||||
added = 0
|
||||
for scope, rec in pairs:
|
||||
@@ -181,6 +187,7 @@ def _record_official_exchanges(
|
||||
show_name=str(rec.get("showName") or rec.get("show_name") or ""),
|
||||
exchange_num=rec.get("exchangeNum") or rec.get("count"),
|
||||
scope=scope,
|
||||
**task_actor(task),
|
||||
):
|
||||
added += 1
|
||||
return added
|
||||
@@ -511,7 +518,7 @@ class OverviewMixin:
|
||||
overview["exchange_record_count"] = len(all_records)
|
||||
# 把官方记录**补成账号事件**(带真实兑换时间、按作用域去重)。
|
||||
overview["exchange_events_added"] = _record_official_exchanges(
|
||||
db, account, all_records
|
||||
db, account, all_records, task
|
||||
)
|
||||
overview["exchange_records"] = {
|
||||
scope: rows[:EXCHANGE_RECORD_LIMIT]
|
||||
|
||||
@@ -18,7 +18,7 @@ from .account_binding import (
|
||||
SCOPE_PEACE,
|
||||
set_bind_status,
|
||||
)
|
||||
from .business_events import record_exchange
|
||||
from .business_events import record_exchange, task_actor
|
||||
|
||||
DOUYU_XPD_BIND_POLL_SECONDS = 300
|
||||
DOUYU_XPD_BIND_POLL_INTERVAL = 5
|
||||
@@ -591,6 +591,7 @@ class XpdMixin:
|
||||
db,
|
||||
account,
|
||||
task_id=task.id,
|
||||
**task_actor(task),
|
||||
name=goods.name or commodity_id,
|
||||
score=price,
|
||||
commodity_id=commodity_id,
|
||||
|
||||
@@ -265,6 +265,58 @@ def account_uid(account: Account, cookie: str) -> str:
|
||||
return account.uid or cookie_value(cookie, "acf_uid")
|
||||
|
||||
|
||||
def create_douyu_task(
|
||||
db: Session,
|
||||
account: Account,
|
||||
task_type: str,
|
||||
handbook_scope: str,
|
||||
created_by: int,
|
||||
payload: dict | None = None,
|
||||
*,
|
||||
batch_id: str | None = None,
|
||||
account_assigned_to: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> DouyuTask:
|
||||
"""建**一条**斗鱼任务(单账号、单类型、载荷可各账号不同)。
|
||||
|
||||
存在的理由:`create_douyu_planned_tasks` 给一批账号**共用一个** `payload`,
|
||||
而账号池的补给链每个账号的充值额/赠令数都不一样,共用载荷根本表达不了。
|
||||
|
||||
`batch_id` 传值即并入该批次 —— 执行器按批取任务、按任务自己的类型分发,
|
||||
所以**一个批次里可以放不同类型的任务**(这正是补给链的实现方式)。
|
||||
顺序由「同一批次 + 并发 1」保证(见 `routers/douyu.py::start_douyu_batch`)。
|
||||
"""
|
||||
if task_type not in SUPPORTED_DOUYU_TASK_TYPES:
|
||||
raise ValueError("不支持的任务类型")
|
||||
if handbook_scope not in DOUYU_HANDBOOK_SCOPES:
|
||||
raise ValueError("无效的工作台")
|
||||
if task_type not in DOUYU_HANDBOOK_TASK_TYPES[handbook_scope]:
|
||||
raise ValueError("该任务不属于当前工作台")
|
||||
|
||||
task = DouyuTask(
|
||||
batch_id=batch_id or uuid.uuid4().hex[:12],
|
||||
account_id=account.id,
|
||||
account_assigned_to=(
|
||||
account_assigned_to
|
||||
if account_assigned_to is not None
|
||||
else account.assigned_to
|
||||
),
|
||||
task_type=task_type,
|
||||
handbook_scope=handbook_scope,
|
||||
status="planned",
|
||||
message="任务已创建,等待执行",
|
||||
result={"payload": payload} if payload else None,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(task)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
else:
|
||||
db.flush()
|
||||
return task
|
||||
|
||||
|
||||
def create_douyu_planned_tasks(
|
||||
db: Session,
|
||||
account_ids: list[int],
|
||||
@@ -272,8 +324,20 @@ def create_douyu_planned_tasks(
|
||||
handbook_scope: str,
|
||||
created_by: int,
|
||||
payload: dict | None = None,
|
||||
*,
|
||||
account_assigned_to_override: int | None = None,
|
||||
) -> tuple[str, int]:
|
||||
"""创建斗鱼任务记录,等待后台执行器消费。"""
|
||||
"""创建斗鱼任务记录,等待后台执行器消费。
|
||||
|
||||
`account_assigned_to_override` **只给账号池路径用**:池化后账号归属是
|
||||
管理员/运营,若仍按归属写,租用该账号的客服在任务列表里**看不到自己提交的任务**
|
||||
—— 列表对客服的过滤正是 `DouyuTask.account_assigned_to == 我`
|
||||
(见 `routers/douyu.py` 的 `_visible_tasks_query`)。
|
||||
|
||||
这一列本来就是「账号归属客服冗余字段,配合复合索引消除任务列表全表 filesort」,
|
||||
实际作用是「谁能在列表里看到这条任务」。池路径写**租用人**既保住索引命中,
|
||||
又让客服看得到自己的任务。**默认不传 = 保持旧行为**,旧路径完全不受影响。
|
||||
"""
|
||||
if task_type not in SUPPORTED_DOUYU_TASK_TYPES:
|
||||
raise ValueError("不支持的任务类型")
|
||||
if handbook_scope not in DOUYU_HANDBOOK_SCOPES:
|
||||
@@ -296,7 +360,11 @@ def create_douyu_planned_tasks(
|
||||
DouyuTask(
|
||||
batch_id=batch_id,
|
||||
account_id=account.id,
|
||||
account_assigned_to=account.assigned_to,
|
||||
account_assigned_to=(
|
||||
account_assigned_to_override
|
||||
if account_assigned_to_override is not None
|
||||
else account.assigned_to
|
||||
),
|
||||
task_type=task_type,
|
||||
handbook_scope=handbook_scope,
|
||||
status="planned",
|
||||
|
||||
@@ -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" />)} />
|
||||
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user