Files
live-hub-py/web/backend/schemas.py
T
yml2213 a64380fbcc feat(account): S6 bind_status 三列拆解 —— 四值枚举、唯一写入口、前端改读账号
`accounts.bind_status` 是个**语义垃圾场**:一个自由文本列,6 个文件、**29 处写入**、
20 多种取值,混了三种东西 —— 真状态(`bind_confirmed`)、动作痕迹(`goods_locked`)、
纯查询痕迹(`points_queried`)。它是**覆盖式**的,所以 `bind_confirmed` 会被之后
任意一次查询盖掉 —— 「这个账号到底绑没绑」根本查不出来。
`esports_bind_status` / `xpd_bind_status` 是同一个病。

## 收敛成四值 + 三列一义

`unknown` / `unbound` / `pending` / `bound`,每个作用域一列:

| 列 | 作用域 |
| --- | --- |
| `accounts.role_bind_status`(**新增**) | 精英宝典角色 |
| `accounts.esports_bind_status`(收敛) | 电竞手册角色 |
| `accounts.xpd_bind_status`(收敛) | 和平小店角色 |

- 新增 `services/account_binding.py`:唯一写入口 `set_bind_status()`,
  传旧自由文本直接 `ValueError`(否则垃圾会被写回去);`map_legacy_bind_status()` 负责旧值映射。
- **29 处自由文本写入全部删除或改走枚举**:真状态走枚举,动作/查询痕迹**不再写状态列**
  (动作已由 S5 的直发事件记录,查询痕迹本来就不该是状态)。
- `accounts.bind_status` 保留原值便于比对,**已无任何写入方**,待 S8 drop。
- 一条**源码扫描用例**禁止任何地方再出现 `account.bind_status = "..."` 这类写入。

## 回填不猜(设计文档 §5.5)

只映射**明确陈述了绑定关系**的取值(`bind_confirmed`/`esports_bound`/`xpd_bound` → bound,
`*_qr_*`/`*_ready` → pending,`game_not_bound`/`xpd_not_bound` → unbound),
**其余一律 unknown**。因为列是覆盖式的,「最后一件事是查询」并不代表没绑定。
迁移 SQL 与 Python 映射有一致性用例:**解析迁移源码里的 CASE 逐条比对**
(已反向验证:故意改错一条映射,用例立刻红)。

## ⚠️ 顺带救回一个差点被我自己丢掉的状态

「**电竞手册是否已开通**」以前**只寄生在 `esports_bind_status` 的 `esports_opened`
取值里**(`elite_opened` 有独立布尔列,电竞没有)。归一化会把那个取值抹成 `unknown`
→ **静默丢状态**。所以:
- 新增 `accounts.esports_opened` 布尔列(与 `elite_opened` 对称),开通任务成功时置位;
- 迁移里**必须先 `save_esports_opened()` 再归一化**,顺序用例钉住;
- 详情页「电竞手册」开通状态改读 `esports_opened`。

## 前端不再从任务日志反推账号状态

工作台原来从**当前加载的那一页任务**里挑每个账号最新的一条来推绑定状态
(`latestConfirmBindTaskByAccount` 等)。两个问题:① 支撑任务一旦翻出窗口,
**显示的状态就是错的**;② 这让任务日志变成「账号状态库」,于是它永远不敢清理。

现在状态读账号字段,任务日志只补「扫码流程中的瞬时信息」(如确认前的
`pending_role_name`)。删掉了两个不再需要的派生与对应的 `clearPending` 绕行逻辑。

## 踩到并修掉的东西

- **模型与迁移不一致**:我把模型收窄成 `String(16)`,迁移却留着 `String(32)` ——
  测试库按模型建、生产按迁移建,两边列宽不同。已让迁移也收窄,且**收窄必须在
  归一化之后**(旧取值最长 19 字符,先收窄会截断成废值)。downgrade 也补齐了反向操作,
  已实测 `downgrade -1 && upgrade head` 干净。
- **parity 用例第一版是抄了一份 SQL**(迁移改了用例也不会红,等于没测)——
  改成解析迁移源码;后来又因为列已收窄、19 字符的旧值塞不进测试库,
  干脆改成源码级解析比对(不造数据)。

## 验证

- `TZ=Asia/Shanghai`:**490 passed / 6 skipped**(S5 后 450 → 净增 40)
- `TZ=UTC`(相关文件)106 passed;迁移链 3 passed;ruff 全绿
- 前端 `tsc -b && vite build` 通过、`npm run lint` **0 errors**
- 真实开发库:迁移重放后三列均为 `varchar(16) NOT NULL DEFAULT 'unknown'`;
  12 个账号的旧值 `elite_status_queried`(纯查询痕迹)→ **unknown**(没有猜成 bound)

未做(后续):S6 的**账号页重构**(列表关键列 / 生命周期主页)保留;
`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」现在技术上可下探到 7 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
2026-10-04 16:28:06 +08:00

949 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pydantic 请求/响应模型"""
from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_serializer
from .huya_defaults import (
HUYA_DEFAULT_BIND_ACT_ID,
HUYA_DEFAULT_OUTER_ACT_ID,
HUYA_DEFAULT_PAY_CHANNEL,
HUYA_DEFAULT_ROOM_PID,
HUYA_DEFAULT_SID,
)
class YybTaskCreateRequest(BaseModel):
"""创建 YYB 任务;登录方式在下一步选择。"""
class YybLoginRequest(BaseModel):
provider: str = Field(..., pattern="^(qq|wechat)$")
timeout: int = Field(600, ge=60, le=1800)
class YybSelectionRequest(BaseModel):
platform: str = Field(..., pattern="^(android|ios)$")
points: int = Field(..., gt=0)
product_id: str = Field(..., min_length=1, max_length=128)
zone_id: str = Field(..., min_length=1, max_length=64)
zone_name: str = Field("", max_length=128)
role_id: str = Field(..., min_length=1, max_length=64)
role_name: str = Field(..., min_length=1, max_length=128)
def _ensure_tz(dt: datetime | None) -> datetime | None:
"""确保 datetime 带有 UTC 时区信息,无时区的视为 UTC。"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
def _isoformat(dt: datetime | None) -> str | None:
normalized = _ensure_tz(dt)
return normalized.isoformat() if normalized is not None else None
# ---- 认证 ----
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
role: str
username: str
permissions: list[str]
class UserInfo(BaseModel):
id: int
username: str
role: str
is_active: bool
remark: str = ""
created_at: datetime | None = None
permissions: list[str] = []
custom_permissions: list[str] | None = None
model_config = ConfigDict(from_attributes=True)
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"id": self.id,
"username": self.username,
"role": self.role,
"is_active": self.is_active,
"remark": self.remark,
"created_at": _isoformat(self.created_at),
"permissions": self.permissions,
"custom_permissions": self.custom_permissions,
}
# ---- 用户管理 ----
class UserCreate(BaseModel):
username: str = Field(..., min_length=2, max_length=64)
password: str = Field(..., min_length=6, max_length=128)
role: str = Field("support", pattern="^(super_admin|operation|support)$")
remark: str = ""
class UserUpdate(BaseModel):
password: str | None = None
role: str | None = None
is_active: bool | None = None
remark: str | None = None
custom_permissions: list[str] | None = None
class UserRename(BaseModel):
username: str = Field(..., min_length=2, max_length=64)
# ---- 账号 ----
class AccountImport(BaseModel):
"""批量导入,文本格式:用户名|密码|邮箱|邮箱密码|标签(可选)
tag 为本次导入的统一标签兜底:某行未单独写标签时使用该值,
行内第5列标签优先。
"""
text: str
tag: str = ""
class AccountAssign(BaseModel):
assigned_to: int | None = None
class BatchAssign(BaseModel):
account_ids: list[int]
assigned_to: int | None = None # None=取消分配
class AccountBulkSelection(BaseModel):
"""批量操作选择范围:显式 ID 或当前筛选条件下全部账号。"""
account_ids: list[int] = Field(default_factory=list)
all_matching: bool = False
search: str = ""
tag: str = ""
assigned_only: bool = False
has_cookie: bool = False
class AccountBulkTag(AccountBulkSelection):
tag_value: str = ""
class AccountTag(BaseModel):
tag: str | None = None
account_ids: list[int] | None = None
class AccountRemark(BaseModel):
remark: str | None = None
class AccountLifecycleUpdate(BaseModel):
"""人工设定生命周期状态。
`status` 取 `disabled`(停用)/ `archived`(归档)/ `auto`(恢复自动推导)。
"""
status: str = Field(..., pattern="^(disabled|archived|auto)$")
reason: str = ""
class AccountAutomationUpdate(BaseModel):
"""自动化开关:false = 账号可用,但自动化不碰它(人工维护的特殊账号)。"""
auto_managed: bool
class AccountOut(BaseModel):
id: int
username: str
# 敏感字段根据角色决定是否返回
password: str | None = None
email: str | None = None
email_password: str | None = None
tag: str = ""
assigned_to: int | None = None
assigned_username: str | None = None
remark: str = ""
created_at: datetime | None = None
# 生命周期状态(2026-10-04 S3):客服最关心的是「哪些要人工」
status: str = ""
status_label: str = ""
status_reason: str = ""
auto_managed: bool = True
model_config = ConfigDict(from_attributes=True)
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"id": self.id,
"username": self.username,
"password": self.password,
"email": self.email,
"email_password": self.email_password,
"tag": self.tag,
"assigned_to": self.assigned_to,
"assigned_username": self.assigned_username,
"remark": self.remark,
"created_at": _isoformat(self.created_at),
"status": self.status,
"status_label": self.status_label,
"status_reason": self.status_reason,
"auto_managed": self.auto_managed,
}
# ---- 登录任务 ----
class LoginBatchRequest(BaseModel):
account_ids: list[int]
max_login_retries: int = 0 # 登录整体重试次数,0=无限重试直到成功(后端兜底 20 次)
max_total_time: float = 300 # 单账号登录总时长上限(秒),0=不限制
concurrency: int = 3 # 并发数,1-10
api_strategy: str = "wgapi" # 接口策略: wgapi(新版)或 iframe(旧版备选)
mode: str = Field(
"login", pattern="^(login|check)$"
) # login=登录取CK,check=账号状态检测
class CookieReloginRequest(BaseModel):
ids: list[int] = [] # 需要重新登录的**账号** ID(2026-10-04 CK 解耦前是记录 ID)
class LoginTaskOut(BaseModel):
id: int
batch_id: str
account_id: int
account_username: str = ""
status: str
cookie: str = ""
message: str = ""
created_by: int
created_at: datetime | None = None
finished_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"id": self.id,
"batch_id": self.batch_id,
"account_id": self.account_id,
"account_username": self.account_username,
"status": self.status,
"cookie": self.cookie,
"message": self.message,
"created_by": self.created_by,
"created_at": _isoformat(self.created_at),
"finished_at": _isoformat(self.finished_at),
}
# ---- 账号检测 ----
class AccountCheckBatchRequest(BaseModel):
text: str = Field(..., min_length=1)
concurrency: int = Field(3, ge=1, le=10)
max_login_retries: int = Field(0, ge=0, le=50)
max_total_time: float = Field(300, ge=0, le=3600)
use_proxy: bool = False
class AccountCheckItemOut(BaseModel):
line: int
username: str
email: str
status: str
message: str
started_at: datetime | None = None
finished_at: datetime | None = None
class AccountCheckBatchOut(BaseModel):
batch_id: str
status: str
message: str
created_by: int
concurrency: int
max_login_retries: int
max_total_time: float
use_proxy: bool
total: int
finished_count: int
running_count: int
status_counts: dict[str, int]
created_at: datetime | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
items: list[AccountCheckItemOut]
# ---- 虎牙 ----
class HuyaCookieImport(BaseModel):
"""批量导入虎牙 Cookie。支持纯 CK 或 账号----密码----CK。"""
text: str
tag: str = ""
class HuyaPasswordLoginRequest(BaseModel):
"""虎牙账号 Web 密码登录并保存 Cookie(旧版)。"""
username: str = Field(..., min_length=1, max_length=128)
password: str = Field(..., min_length=1, max_length=128)
tag: str = ""
cookie: str = ""
class HuyaAppPasswordLoginRequest(BaseModel):
"""虎牙账号 App 协议密码登录并保存 Cookie(推荐)。"""
username: str = Field(..., min_length=1, max_length=128)
password: str = Field(..., min_length=1, max_length=128)
tag: str = ""
force_new_device: bool = False
class HuyaSmsCodeRequest(BaseModel):
"""发送虎牙短信验证码。"""
phone: str = Field(..., min_length=5, max_length=32)
cookie: str = ""
class HuyaSmsLoginRequest(BaseModel):
"""提交虎牙短信验证码并保存 Cookie。"""
authcode: str = Field(..., min_length=4, max_length=8)
state: str = Field(..., min_length=1)
phone: str = ""
tag: str = ""
class HuyaTestSmsUrlRequest(BaseModel):
"""在线测试从接码链接提取验证码。"""
url: str = Field(..., min_length=1)
phone: str = ""
class HuyaAutoRegisterRequest(BaseModel):
"""虎牙自动注册批次。每行格式:手机号----短信查询URL。"""
text: str = Field(..., min_length=1)
tag: str = ""
concurrency: int = Field(1, ge=1, le=5)
wait_seconds: float = Field(180, ge=15, le=600)
poll_interval: float = Field(5, ge=1, le=30)
password_prefix: str = Field("hy", max_length=8)
fixed_password: str = Field("", max_length=64)
use_proxy: bool = False
class HuyaAutoRegisterRetryRequest(BaseModel):
"""继续批次。默认 continue=从停止处往下跑(不重试已失败)。
mode:
- continue: 只跑 pending/stopped/中断中(默认)
- retry_failed: 只重试 error
- all_unfinished: 失败+未完成都跑
"""
mode: str = Field("continue", max_length=32)
concurrency: int | None = Field(None, ge=1, le=5)
wait_seconds: float | None = Field(None, ge=15, le=600)
poll_interval: float | None = Field(None, ge=1, le=30)
password_prefix: str | None = Field(None, max_length=8)
fixed_password: str | None = Field(None, max_length=64)
use_proxy: bool | None = None
class HuyaAutoRegisterItemOut(BaseModel):
line: int
phone: str
provider: str
sms_url: str = ""
status: str
message: str
code: str = ""
change_code: str = ""
attempts: int = 0
change_attempts: int = 0
account_id: int | None = None
username: str = ""
uid: str = ""
password: str = ""
password_changed: bool = False
cookie: str = ""
cookie_preview: str = ""
started_at: datetime | None = None
finished_at: datetime | None = None
class HuyaAutoRegisterBatchOut(BaseModel):
batch_id: str
status: str
message: str
tag: str = ""
created_by: int
concurrency: int
wait_seconds: float
poll_interval: float
password_prefix: str = "hy"
use_proxy: bool = False
total: int
success_count: int
failed_count: int
stopped_count: int
running_count: int
created_at: datetime | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
items: list[HuyaAutoRegisterItemOut] = []
class HuyaRegisterSuccessLogOut(BaseModel):
id: int
batch_id: str = ""
item_id: int | None = None
account_id: int | None = None
phone: str = ""
username: str = ""
uid: str = ""
password: str = ""
sms_url: str = ""
tag: str = ""
provider: str = ""
created_by: int | None = None
created_at: datetime | None = None
export_line: str = ""
class HuyaPasswordAccountImport(BaseModel):
"""导入虎牙账号密码,稍后再选择登录。"""
text: str = Field(..., min_length=1)
tag: str = ""
class HuyaPasswordLoginSelectedRequest(BaseModel):
"""选择已导入的虎牙账号执行密码登录。"""
account_ids: list[int] = Field(default_factory=list)
all_matching: bool = False
search: str = ""
tag: str = ""
assigned_only: bool = False
has_cookie: bool = False
force_new_device: bool = False
max_attempts: int = Field(2, ge=1, le=3)
interval_seconds: float = Field(8, ge=0, le=60)
retry_delay_seconds: float = Field(3, ge=0, le=30)
class HuyaDeviceBindingsBatchDeleteRequest(BaseModel):
"""批量解绑设备:删除账号画像与 hydevice 指纹状态。"""
accounts: list[str] = Field(..., min_length=1)
class HuyaAccountOut(BaseModel):
id: int
uid: str = ""
yyuid: str = ""
username: str = ""
has_password: bool = False
has_cookie: bool = False
nickname: str = ""
cookie: str = ""
cookie_preview: str = ""
tag: str = ""
remark: str = ""
status: str = ""
# 上次成功登录渠道: web / app / sms (空 = 仅导入 Cookie, 从未协议登录)
login_channel: str = ""
points: int | None = None
game_name: str = ""
game_channel: str = ""
game_phone: str = ""
sms_url: str = ""
assigned_to: int | None = None
assigned_username: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"id": self.id,
"uid": self.uid,
"yyuid": self.yyuid,
"username": self.username,
"has_password": self.has_password,
"has_cookie": self.has_cookie,
"nickname": self.nickname,
"cookie": self.cookie,
"cookie_preview": self.cookie_preview,
"tag": self.tag,
"remark": self.remark,
"status": self.status,
"login_channel": self.login_channel,
"points": self.points,
"game_name": self.game_name,
"game_channel": self.game_channel,
"game_phone": self.game_phone,
"sms_url": self.sms_url,
"assigned_to": self.assigned_to,
"assigned_username": self.assigned_username,
"created_at": _isoformat(self.created_at),
"updated_at": _isoformat(self.updated_at),
}
class HuyaConfigOut(BaseModel):
room_pid: str = HUYA_DEFAULT_ROOM_PID
sid: str = HUYA_DEFAULT_SID
outer_act_id: str = HUYA_DEFAULT_OUTER_ACT_ID
bind_act_id: str = HUYA_DEFAULT_BIND_ACT_ID
pay_channel: str = HUYA_DEFAULT_PAY_CHANNEL
updated_at: datetime | None = None
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"room_pid": self.room_pid,
"sid": self.sid,
"outer_act_id": self.outer_act_id,
"bind_act_id": self.bind_act_id,
"pay_channel": self.pay_channel,
"updated_at": _isoformat(self.updated_at),
}
class HuyaConfigUpdate(BaseModel):
room_pid: str | None = None
sid: str | None = None
outer_act_id: str | None = None
bind_act_id: str | None = None
pay_channel: str | None = None
class HuyaTaskBatchRequest(BaseModel):
account_ids: list[int]
task_type: str
handbook_scope: str = Field("legacy", pattern="^(legacy|elite)$")
concurrency: int = 3
payload: dict[str, Any] = Field(default_factory=dict)
class HuyaWorkbenchAccountsUpdate(BaseModel):
handbook_scope: str = Field(..., pattern="^elite$|^browser_exchange$")
account_ids: list[int] = Field(default_factory=list)
class HuyaBrowserExchangeRequest(BaseModel):
"""浏览器兑换的目标页选择(常量见 services/huya_exchange_targets.py)。"""
target: str | None = Field(None, pattern="^(live_room|elite|esports)$")
class HuyaTaskOut(BaseModel):
id: int
batch_id: str
account_id: int
account_uid: str = ""
account_nickname: str = ""
task_type: str
handbook_scope: str = "legacy"
status: str
message: str = ""
result: dict[str, Any] | None = None
created_by: int
created_at: datetime | None = None
finished_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"id": self.id,
"batch_id": self.batch_id,
"account_id": self.account_id,
"account_uid": self.account_uid,
"account_nickname": self.account_nickname,
"task_type": self.task_type,
"handbook_scope": self.handbook_scope,
"status": self.status,
"message": self.message,
"result": self.result,
"created_by": self.created_by,
"created_at": _isoformat(self.created_at),
"finished_at": _isoformat(self.finished_at),
}
class HuyaGoodsOut(BaseModel):
id: int
account_id: int | None = None
sid: int = 0
product_id: str
name: str = ""
price: int | None = None
remain_text: str = ""
raw: dict[str, Any] | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"id": self.id,
"account_id": self.account_id,
"sid": self.sid,
"product_id": self.product_id,
"name": self.name,
"price": self.price,
"remain_text": self.remain_text,
"raw": self.raw,
"updated_at": _isoformat(self.updated_at),
}
class HuyaRechargeGoodsOut(BaseModel):
id: int
account_id: int | None = None
sid: int = 0
spu_id: str
sku_id: str = ""
name: str = ""
price: int | None = None
stock: int | None = None
buy_limit: int | None = None
icon: str = ""
description: str = ""
task_id: str = ""
task_name: str = ""
raw: dict[str, Any] | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"id": self.id,
"account_id": self.account_id,
"sid": self.sid,
"spu_id": self.spu_id,
"sku_id": self.sku_id,
"name": self.name,
"price": self.price,
"stock": self.stock,
"buy_limit": self.buy_limit,
"icon": self.icon,
"description": self.description,
"task_id": self.task_id,
"task_name": self.task_name,
"raw": self.raw,
"updated_at": _isoformat(self.updated_at),
}
# ---- 斗鱼活动任务 ----
class DouyuConfigOut(BaseModel):
manual_id: str = "G4KA4Qnz4LDp7"
rid: str = "9263298"
bind_act_alias: str = "20260120QYOOB"
confirm_act_alias: str = "20260120QYOOB"
legacy_act_alias: str = "cjm"
room_id: str = "9263298"
elite_amount: int = 3000
esports_manual_id: str = "1W0yGkOdDbAXb"
esports_act_alias: str = "20260226ZMGDG"
esports_amount: int = 3000
esports_chicken_gift_id: str = "24766"
esports_chicken_skin_id: str = "0"
esports_firework_gift_id: str = "24767"
esports_firework_skin_id: str = "3850"
xpd_act_alias: str = "20260623KDQFH"
xpd_act_id: str = "46195"
xpd_rid: str = "9263298"
gold_pay_type: int = 1
gold_recharge_channel: str = "wechat_qr"
gold_api_product_id: str = "111570"
gold_api_account_template_name: str = "斗鱼昵称"
gift_id: str = "23643"
skin_id: str = "2942"
updated_at: datetime | None = None
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"manual_id": self.manual_id,
"rid": self.rid,
"bind_act_alias": self.bind_act_alias,
"confirm_act_alias": self.confirm_act_alias,
"legacy_act_alias": self.legacy_act_alias,
"room_id": self.room_id,
"elite_amount": self.elite_amount,
"esports_manual_id": self.esports_manual_id,
"esports_act_alias": self.esports_act_alias,
"esports_amount": self.esports_amount,
"esports_chicken_gift_id": self.esports_chicken_gift_id,
"esports_chicken_skin_id": self.esports_chicken_skin_id,
"esports_firework_gift_id": self.esports_firework_gift_id,
"esports_firework_skin_id": self.esports_firework_skin_id,
"xpd_act_alias": self.xpd_act_alias,
"xpd_act_id": self.xpd_act_id,
"xpd_rid": self.xpd_rid,
"gold_pay_type": self.gold_pay_type,
"gold_recharge_channel": self.gold_recharge_channel,
"gold_api_product_id": self.gold_api_product_id,
"gold_api_account_template_name": self.gold_api_account_template_name,
"gift_id": self.gift_id,
"skin_id": self.skin_id,
"updated_at": _isoformat(self.updated_at),
}
class DouyuConfigUpdate(BaseModel):
manual_id: str | None = None
rid: str | None = None
bind_act_alias: str | None = None
confirm_act_alias: str | None = None
legacy_act_alias: str | None = None
room_id: str | None = None
elite_amount: int | None = Field(None, ge=1)
esports_manual_id: str | None = None
esports_act_alias: str | None = None
esports_amount: int | None = Field(None, ge=1)
esports_chicken_gift_id: str | None = None
esports_chicken_skin_id: str | None = None
esports_firework_gift_id: str | None = None
esports_firework_skin_id: str | None = None
xpd_act_alias: str | None = None
xpd_act_id: str | None = None
xpd_rid: str | None = None
gold_pay_type: int | None = Field(None, ge=1, le=9)
gold_recharge_channel: str | None = Field(
None, pattern="^(wechat_qr|supplier_api)$"
)
gold_api_product_id: str | None = Field(None, max_length=128)
gold_api_account_template_name: str | None = Field(None, max_length=64)
gift_id: str | None = None
skin_id: str | None = None
class DouyuTaskBatchRequest(BaseModel):
account_ids: list[int]
task_type: str
handbook_scope: str = Field(..., pattern="^(elite|esports|peace)$")
concurrency: int = Field(3, ge=1, le=10)
payload: dict[str, Any] = Field(default_factory=dict)
class DouyuWorkbenchAccountsUpdate(BaseModel):
handbook_scope: str = Field(..., pattern="^(elite|esports|peace)$")
account_ids: list[int] = Field(default_factory=list)
class DouyuTaskOut(BaseModel):
id: int
batch_id: str
account_id: int
account_username: str = ""
account_uid: str = ""
account_nickname: str = ""
task_type: str
handbook_scope: str = "legacy"
status: str
message: str = ""
result: dict[str, Any] | None = None
created_by: int
created_at: datetime | None = None
finished_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"id": self.id,
"batch_id": self.batch_id,
"account_id": self.account_id,
"account_username": self.account_username,
"account_uid": self.account_uid,
"account_nickname": self.account_nickname,
"task_type": self.task_type,
"handbook_scope": self.handbook_scope,
"status": self.status,
"message": self.message,
"result": self.result,
"created_by": self.created_by,
"created_at": _isoformat(self.created_at),
"finished_at": _isoformat(self.finished_at),
}
class DouyuGoodsOut(BaseModel):
id: int
commodity_id: str
name: str = ""
score: int | None = None
status: str = ""
raw: dict[str, Any] | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"id": self.id,
"commodity_id": self.commodity_id,
"name": self.name,
"score": self.score,
"status": self.status,
"raw": self.raw,
"updated_at": _isoformat(self.updated_at),
}
class DouyuTaskAccountOut(BaseModel):
id: int
username: str
uid: str = ""
nickname: str = ""
tag: str = ""
points: int | None = None
game_name: str = ""
game_channel: str = ""
gold_balance: int | None = None
exchange_balance: int | None = None
# 绑定状态:三列各管一个作用域,取值 unknown/unbound/pending/bound
# (见 services/account_binding)。**`bind_status` 已废弃**,不再返回 ——
# 它是「最后发生的动作」式复用列,前端拿它判断绑定必然出错。
role_bind_status: str = "unknown"
role_bind_label: str = ""
change_role_wait_time: int | None = None
elite_opened: bool | None = None
elite_expire_time: int | None = None
esports_points: int | None = None
esports_game_name: str = ""
esports_game_channel: str = ""
esports_bind_status: str = "unknown"
esports_bind_label: str = ""
esports_change_role_wait_time: int | None = None
esports_can_change_time: int | None = None
xpd_game_name: str = ""
xpd_openid: str = ""
xpd_role_id: str = ""
xpd_plat_id: int | None = None
xpd_area_id: int | None = None
xpd_balance: int | None = None
xpd_fragments: int | None = None
xpd_bind_status: str = "unknown"
xpd_bind_label: str = ""
assigned_to: int | None = None
assigned_username: str | None = None
class DouyuXpdGoodsOut(BaseModel):
id: int
commodity_id: str
name: str = ""
price: int | None = None
org_price: int | None = None
category: str = ""
goods_left: int | None = None
raw: dict[str, Any] | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
@model_serializer
def _serialize(self) -> dict[str, Any]:
return {
"id": self.id,
"commodity_id": self.commodity_id,
"name": self.name,
"price": self.price,
"org_price": self.org_price,
"category": self.category,
"goods_left": self.goods_left,
"raw": self.raw,
"updated_at": _isoformat(self.updated_at),
}
# ---- 代理配置 ----
class ProxyConfigOut(BaseModel):
enabled: bool = False
api_url: str = ""
http: str = ""
https: str = ""
whitelist_enabled: bool = False
whitelist_platform: str = "xiequ"
whitelist_credentials: dict | None = None
# 旧字段保留(向后兼容)
whitelist_uid: str = ""
whitelist_ukey: str = ""
class ProxyConfigUpdate(BaseModel):
enabled: bool | None = None
api_url: str | None = None
http: str | None = None
https: str | None = None
whitelist_enabled: bool | None = None
whitelist_platform: str | None = "xiequ"
whitelist_credentials: dict | None = None
# 旧字段保留(向后兼容)
whitelist_uid: str | None = None
whitelist_ukey: str | None = None
# ---- 代理平台元信息 ----
class PlatformFieldDef(BaseModel):
"""平台凭据字段定义。"""
key: str
label: str
placeholder: str = ""
class PlatformInfo(BaseModel):
"""平台元信息。"""
name: str
label: str
credential_fields: list[PlatformFieldDef]
# ---- 通用 ----
class AppInfo(BaseModel):
version: str
class MessageResponse(BaseModel):
message: str
success: bool = True