Files
live-hub-py/web/backend/schemas.py
T
yml2213 5ae5d92978 feat(account): 兑换记录列 + 修掉 source_ref NOT NULL 导致的事件静默丢失
三件事合成一次提交(按反馈不再一改一提交)。

## ① 账号列表加「兑换记录」列

次数 + 最近一次(商品名 + 日期),填上列表右侧的空档。

数据源是**账号自己的事件流**(`account_events` 的 `exchange`),不是 `douyu_tasks`
的 JSON —— 后者是执行日志,S5 起业务事实已经在事件里了。

性能上刻意**每页只跑两条聚合查询**(不是每行一条 N+1):
一条分组算 `count` + `max(occurred_at)`(走 `(account_id, event_type, occurred_at)`
复合索引),一条用窗口函数 `ROW_NUMBER()` 取每个账号最新那条的商品名。
实测 6 个账号 3 条兑换事件,聚合与「最近」都正确。

「兑换 」前缀在前端剥掉(列头已经叫「兑换记录」,再带前缀是重复)。

## ② 修掉一个静默丢事件的 bug(排查中发现的)

**现象**:开发库上「状态变迁 / 分配 / 标签 / 备注 / CK 清除」这些事件**一条都没有**,
而带幂等键的事件(业务事实、`ck_invalid`、`relogin_*`)照常写入 ——
时间线看起来有数据,只是恰好缺了某一类,**极难发现**。

**根因**:`source_ref` 是**可选**幂等键(模型与迁移文件都是 `nullable=True`,
`emit()` 对没有天然幂等键的事件就传 `None`)。但早期那版迁移
(重编号成 `0041` 之前的 `0040`)建的是 **NOT NULL**,而我后来只改了
**迁移文件**、没管**已建好的库** —— Alembic 只记版本号,不会重放已应用的脚本。
`__pycache__` 里那个 `20261002_0040_*.pyc` 就是旧版留下的痕迹。

**为什么静默**:`emit()` 按设计吞掉任何异常(事件是旁路,不能影响主流程),
只记一条 warning,调用方也从不检查返回值。已实测复现:
`emit(...)` 返回 `False` + warning,实际落库 `0` 条。

**修法**:迁移 `20261004_0045` 改回可空(`MODIFY`,**唯一索引保留**,幂等,
生产若跑过旧版也一并修好)。修复后实测:`emit` 返回 `True`、落库 1 条。

## ③ 补一条能防「未来」同类问题的守卫

`tests/test_migrations.py` 原本只比对「表/列/索引在不在」,**不比对可空性**。
已加:迁移建出的列**不允许比模型更严**(模型说可空、库里 NOT NULL)。

只查这个**危险方向**:反方向(模型 NOT NULL、库里允许 NULL)是无害的宽松,
历史遗留里有 28 处,一并报出来只会制造噪声、逼人修没收益的东西。

> 这条守卫**有边界**,已在代码注释与设计文档 §3.5.1 写明:
> 它在**全新库**上跑整链,只能防「未来的迁移建错」,**防不了已有库的历史漂移**。
> 已验证:临时禁用 `0045`,该用例仍通过(全新链本来就是对的)。

## 验证

- `TZ=Asia/Shanghai`:**503 passed / 6 skipped**(+3 条兑换列用例)
- `TZ=UTC`(迁移 + 列表相关)45 passed;ruff 全绿;前端 build 通过、lint 0 errors
- 真实开发库:迁移已应用(head `20261004_0045`),
  `source_ref` 现在 `IS_NULLABLE=YES` 且唯一索引仍在;
  不带幂等键的事件确实能落库;兑换聚合返回值正确

注:验证时在开发库那个有 CK 的账号上留了 3 条 `exchange` 演示事件
(兔兔白日梦 / 冠军鸡腿 / 烟花),是为了让列有数据可看,随时可删。
2026-10-04 17:25:40 +08:00

980 lines
29 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 = ""
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,
}
# ---- 登录任务 ----
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