Files
live-hub-py/web/backend/schemas.py
T
yml2213 62d9053e8a feat(account): 官方兑换记录补成账号事件(三作用域)+ 一键查询结果落到账号
## 官方兑换记录 → 真正的账号事件

关键发现:斗鱼官方兑换记录**带真实兑换时间**(`time` 字段),所以可以直接
补成正常的历史事件,时间线因此变完整(这就是「账号全生命周期」该有的样子),
列表的兑换聚合也随之准确 —— 上线前的兑换不再是一片空白。

- **三个作用域都拉**:精英宝典 / 电竞手册(共用 `exchange_records`,只有 manual_id
  不同)/ 和平小店(`xpd_embed_query` → `xpd_purchase_records`)
- 幂等键含作用域:`official_exchange:{scope}:{account}:{commodity}:{time}`
- `detail.scope` 标作用域;`source=official_sync` 与"当场记的"分得开
- 任一作用域失败只记进 `errors`,不影响其余(小店未开通会报
  `Livelink登录态校验失败`,属正常)

## 一键查询结果落到账号(不再只留在任务里)

- 新增 `accounts.high_value_owned`(迁移 0048):**已兑换过的高价值套装名**,
  供列表展示。单开一列而不是让列表读 `overview_snapshot` —— 那份 JSON 有 1~2KB,
  15000 个账号各带一份会把响应放大到几十 MB。
- 迁移**可安全重跑**(启动时会自动跑):加列前查 `information_schema`。
  ⚠️ 回填里**不要** `op.get_bind().commit()` —— MySQL 的 DDL 自动提交而 commit
  会打乱版本记账,表现为"列加上了、版本号没更新",下次启动重复 ADD COLUMN
  直接把服务起不来(生产上就是启动即挂)。
- 迁移里从旧快照**回填** `high_value_owned`,已查过的账号不必重查。

## 两个 bug

1. **「最近一次兑换」按插入 id 排**(应为事件时间):补录的历史事件 id 最大、
   时间却最老,会把几个月前的老兑换显示成"最近一次"。改为 `occurred_at DESC, id DESC`。
2. **手写的 `@model_serializer` 没列新字段** → 属性有值、JSON 里没有。
   这是本项目第三次踩 Pydantic 静默丢字段;已补,并把断言改为看 `model_dump()`。

## 列表「兑换记录」口径

只聚合**官方**记录(`source=official_sync`):用户要求列表看官方的权威口径,
我们当场记的那些留给详情页时间线。另加 `GET /accounts/{id}/exchange-records`
供列表点击预览(按需拉取,不进列表响应)。
2026-10-04 20:03:42 +08:00

1009 lines
31 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
# 列表页的「关键列」:全部直接来自 accounts 行,不需要任何 JOIN 或聚合
# (客服一眼要能看出「这账号能不能用、要不要我处理」)。
has_cookie: bool = False
ck_check_status: str = ""
ck_checked_at: datetime | None = None
points: int | None = None
gold_balance: int | None = None
role_bind_status: str = "unknown"
role_bind_label: str = ""
esports_bind_status: str = "unknown"
esports_bind_label: str = ""
xpd_bind_status: str = "unknown"
xpd_bind_label: str = ""
# 兑换记录(来自 account_events 的 exchange 事件,每页一条聚合查询)
exchange_count: int = 0
last_exchange_at: datetime | None = None
last_exchange_name: str = ""
# 一键查询得到的(账号表里本来没有的)信息,供列表展示。
# ⚠️ 必须在这里声明:Pydantic 默认 `extra='ignore'`,
# 没声明的字段会被**静默丢掉** —— 路由明明返回了,接口却看不到。
high_value_owned: list[str] = Field(default_factory=list)
# 「有没有做过一键查询」靠它判断(列表不返回整份快照)
overview_queried_at: datetime | None = None
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,
"has_cookie": self.has_cookie,
"ck_check_status": self.ck_check_status,
"ck_checked_at": _isoformat(self.ck_checked_at),
"points": self.points,
"gold_balance": self.gold_balance,
"role_bind_status": self.role_bind_status,
"role_bind_label": self.role_bind_label,
"esports_bind_status": self.esports_bind_status,
"esports_bind_label": self.esports_bind_label,
"xpd_bind_status": self.xpd_bind_status,
"xpd_bind_label": self.xpd_bind_label,
"exchange_count": self.exchange_count,
"last_exchange_at": _isoformat(self.last_exchange_at),
"last_exchange_name": self.last_exchange_name,
# ⚠️ 这个 `_serialize` 是**手写**的:字段只声明在模型上**不够**,
# 不在这里列出来就会被丢掉。踩过 —— 路由返回的对象属性明明有值,
# 前端拿到的 JSON 里却没有(属性访问能过、序列化后没有,
# 只断言属性的测试因此假绿)。
"high_value_owned": self.high_value_owned,
"overview_queried_at": _isoformat(self.overview_queried_at),
}
# ---- 登录任务 ----
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`:手动粘贴(旧模式)—— 条目没有 account_id,结果只在批次里与 zip 里,
不写账号时间线(保持旧行为);
- `account_ids`:按账号选择(新模式)—— 凭据从库里取,结果写回账号时间线。
"""
text: str = ""
# 「选择账号」的两种选法(与账号列表的批量操作保持同一套字段):
# ① 手动挑几个 → account_ids;② 按筛选全选 → select_all_matching + 各筛选条件
account_ids: list[int] = Field(default_factory=list)
select_all_matching: bool = False
search: str = ""
tag: str = ""
status: str = ""
ck_status: str = ""
bind_status: str = ""
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
account_id: int | None = None
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