`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 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
949 lines
28 KiB
Python
949 lines
28 KiB
Python
"""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
|