Files
live-hub-py/web/backend/schemas.py
T

2053 lines
63 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 SmsCodeRequest(BaseModel):
username: str
password: str
class SmsCodeResponse(BaseModel):
sent: bool = True
phone_masked: str = Field(..., serialization_alias="phoneMasked")
expires_in_seconds: int = Field(..., serialization_alias="expiresInSeconds")
provider: str = "mock"
class LoginPrepareRequest(BaseModel):
username: str
password: str
sms_code: str = Field(..., alias="smsCode")
api_base: str | None = Field(None, alias="apiBase")
model_config = ConfigDict(populate_by_name=True)
class LoginPrepareResponse(BaseModel):
challenge_id: str = Field(..., serialization_alias="challengeId")
challenge: str
expires_at: str = Field(..., serialization_alias="expiresAt")
poll_token: str = Field(..., serialization_alias="pollToken")
launch_url: str = Field(..., serialization_alias="launchUrl")
phone_masked: str = Field(..., serialization_alias="phoneMasked")
app: str = "live_hub"
class LoginCompleteRequest(BaseModel):
challenge_id: str = Field(..., alias="challengeId")
poll_token: str = Field(..., alias="pollToken")
device_ticket: str = Field(..., alias="deviceTicket")
model_config = ConfigDict(populate_by_name=True)
class DeviceAuthorizeRequest(BaseModel):
app: str
challenge_id: str = Field(..., alias="challengeId")
challenge: str
device_id: str = Field(..., alias="deviceId")
public_key: str = Field(..., alias="publicKey")
signature: str
timestamp: int | str
client_version: str = Field(..., alias="clientVersion")
model_config = ConfigDict(populate_by_name=True)
class UserPhoneBindRequest(BaseModel):
phone: 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
phone: str = ""
phone_masked: str = ""
phone_bound_at: datetime | None = None
device_id: str = ""
device_id_masked: str = ""
device_bound_at: datetime | None = None
device_last_used_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,
"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,
"phone": self.phone,
"phone_masked": self.phone_masked,
"phone_bound_at": _isoformat(self.phone_bound_at),
"device_id": self.device_id,
"device_id_masked": self.device_id_masked,
"device_bound_at": _isoformat(self.device_bound_at),
"device_last_used_at": _isoformat(self.device_last_used_at),
}
# ---- 用户管理 ----
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 AccountBulkSelection(BaseModel):
"""批量操作选择范围:显式 ID 或当前筛选条件下全部账号。"""
account_ids: list[int] = Field(default_factory=list)
all_matching: bool = False
search: str = ""
tag: str = ""
#: 多行斗鱼账号名(每行一个),与其它筛选条件叠加
account_names: str = ""
assigned_only: bool = False
#: 归属筛选:""=全部 / unassigned=未分配 / assigned=已分配
assign_status: str = ""
#: 只看某个客服名下的账号(与 assign_status 可叠加)
assigned_user_id: int | None = None
#: 分配池口径:持 CK 且 CK 未失效(排除停用/归档)
assignable_only: bool = False
has_cookie: bool = False
status: str = ""
ck_status: str = ""
failure_kind: str = ""
bind_status: str = ""
high_value_filter: str = ""
min_points: int | None = None
max_points: int | None = None
min_gold: float | None = None
max_gold: float | None = None
elite_opened: bool | None = None
esports_opened: bool | None = None
role_bind_status: str = ""
class AccountExportRequest(AccountBulkSelection):
"""账号导出请求:支持纯账号名单(txt)或结构化明细(csv)。"""
format: str = Field("txt", pattern="^(txt|csv)$")
class BatchAssign(AccountBulkSelection):
"""批量分配 / 取消分配。
⚠️ `assigned_to` 是**目标**客服(None=取消分配),与父类的
`assigned_user_id`(筛选「谁名下的账号」)是两件事,别混。
`all_matching=True` 时按父类的筛选条件解析目标账号。
"""
assigned_to: int | None = None
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
# 登录失败分型(2026-10-07):客服排查重登/登录异常
relogin_failure_kind: str = ""
relogin_failure_label: str = ""
relogin_last_error: str = ""
# 列表页的「关键列」:全部直接来自 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'`,
# 没声明的字段会被**静默丢掉** —— 路由明明返回了,接口却看不到。
# 为 None 表示未查询;为 [] 表示已查询且两套均未兑换。
high_value_owned: list[str] | None = None
# 「有没有做过一键查询」靠它判断(列表不返回整份快照)
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,
"relogin_failure_kind": self.relogin_failure_kind,
"relogin_failure_label": self.relogin_failure_label,
"relogin_last_error": self.relogin_last_error,
"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
# 登录接口策略,与登录任务同一套取值:wgapi(默认,保持原行为)或 iframe。
# 有些人只有其中一套能收到邮箱验证码,所以要能选。
api_strategy: str = "wgapi"
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
api_strategy: str = "wgapi"
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 = ""
# 精英宝典开通状态(NULL = 未查询);有效期接口没给时为空
elite_opened: bool | None = None
elite_open_time: int | None = None
elite_expire_time: int | None = None
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,
"elite_opened": self.elite_opened,
"elite_open_time": self.elite_open_time,
"elite_expire_time": self.elite_expire_time,
"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 HuyaWorkbenchCookieImport(BaseModel):
"""工作台「导入外部 CK」:把外部 Cookie 入库并直接加入自己的工作台。"""
text: str = Field(..., min_length=1)
tag: str = ""
handbook_scope: str = Field("elite", pattern="^elite$|^browser_exchange$")
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"
peace_enabled: bool = False
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,
"peace_enabled": self.peace_enabled,
"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
peace_enabled: bool | 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
# ---- 文件上传(在线协作图片) ----
class FileAssetOut(BaseModel):
"""上传结果。url 一律是本服务的代理地址,不下发对象存储直链。"""
object_key: str
url: str
thumbnail_url: str
medium_url: str
filename: str
content_type: str
size: int
class HuyaCollabShopCreate(BaseModel):
"""新增店铺(字典),新建协作单时就能点选。"""
name: str = Field(..., min_length=1, max_length=64)
sort_order: int = 0
remark: str = Field("", max_length=255)
class HuyaCollabShopUpdate(BaseModel):
"""改店铺:名字 / 排序 / 停用启用 / 备注(只提交要改的字段)。"""
name: str | None = Field(None, min_length=1, max_length=64)
sort_order: int | None = None
is_active: bool | None = None
remark: str | None = Field(None, max_length=255)
# 改名时是否把已有订单里的旧名字一起改掉(默认改,避免筛选里堆一堆改名前的老值)
rename_orders: bool = True
class HuyaCollabShopOut(BaseModel):
"""店铺字典项。"""
id: int
name: str
sort_order: int = 0
is_active: bool = True
remark: str = ""
created_by_name: str = ""
updated_by_name: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
# ---- 虎牙外部 CK 协作台账(见 docs/设计-虎牙外部CK协作台账.md) ----
class HuyaCollabGiftOut(BaseModel):
"""活动礼包(用一份有效 CK 现拉,不落库)。"""
product_id: str = ""
name: str = ""
price: int | None = None
remain_text: str = ""
stock: int | None = None
score: int | None = None
category_name: str = ""
icon: str = ""
class HuyaCollabCkPreviewRequest(BaseModel):
"""粘贴 CK 后的即时预览(只读:探针 + 顺带拉礼包)。"""
ck_text: str = Field(..., min_length=1)
with_gifts: bool = True
class HuyaCollabBuyerOrderOut(BaseModel):
"""「同一买家的其它单」里的一条(够列表/详情里一眼看清是哪单)。"""
id: int
order_no: str
shop: str = ""
gift_name: str = ""
uid: str = ""
status: str = "incomplete"
status_label: str = ""
created_at: datetime | None = None
class HuyaCollabCkPreviewOut(BaseModel):
"""预览结论:``ok`` 且 ``has_role`` 才允许提交新建。"""
ok: bool = False
message: str = ""
uid: str = ""
yyuid: str = ""
hy_username: str = ""
ck_status: str = "unverified"
bind_game_account: str = ""
bind_game_role: str = ""
bind_game_channel: str = ""
has_role: bool = False
gifts: list[HuyaCollabGiftOut] = Field(default_factory=list)
gifts_message: str = ""
# 这个虎牙号已经挂在哪些单上(放开 uid 唯一性后只是提示,不拦提交)
uid_used_by: list[HuyaCollabBuyerOrderOut] = Field(default_factory=list)
class HuyaExternalOrderCreate(BaseModel):
"""新建一行(可同时贴一份外部 CK)。
``require_verified``(默认开):人工新建表单的严格模式 —— 订单编号 / 店铺 / CK /
礼包名 / **主页截图**必填,且 CK 必须探针通并读到绑定角色;粘贴导入不走这条路。
``draft``(默认关):先建单后补资料 —— 只登记订单号 + 店铺,状态进「待补资料」,
CK / 礼包名 / 主页截图都可以稍后在详情里补(2026-10-17 用户需求)。
"""
order_no: str = Field(..., min_length=1, max_length=64)
shop: str = Field("", max_length=64)
require_verified: bool = True
draft: bool = False
owner_id: int | None = None
ck_text: str = ""
bind_douyu_account: str = Field("", max_length=128)
gift_name: str = Field("", max_length=128)
required_points: int | None = Field(None, ge=0)
# 主页截图(object_key 数组,第一张作封面)。绑定图不再收:绑定关系由探针自动读,
# 人工截图没有意义(2026-10-16 用户拍板)。
home_images: list[str] = Field(default_factory=list)
remark: str = Field("", max_length=512)
class HuyaExternalOrderUpdate(BaseModel):
"""部分更新:只提交要改的字段(服务端用 ``exclude_unset`` 识别)。"""
order_no: str | None = Field(None, max_length=64)
shop: str | None = Field(None, max_length=64)
owner_id: int | None = None
bind_douyu_account: str | None = Field(None, max_length=128)
bind_douyu_uid: str | None = Field(None, max_length=32)
gift_name: str | None = Field(None, max_length=128)
required_points: int | None = Field(None, ge=0)
remark: str | None = Field(None, max_length=512)
# 乐观锁:客户端拿到的版本号。不一致说明别人先改了 → 409。
expected_version: int | None = None
class HuyaExternalOrderStatusRequest(BaseModel):
"""推进/回退业务状态。"""
status: str = Field(..., min_length=1, max_length=16)
reason: str = Field("", max_length=256)
# 目标不是「向前一步」时必须显式置 True,避免误点把状态改回去。
rollback: bool = False
expected_version: int | None = None
class HuyaExternalOrderArchiveRequest(BaseModel):
"""归档抢购成功的订单(单条/勾选/按当前筛选)。"""
ids: list[int] = Field(default_factory=list)
reason: str = Field("", max_length=256)
# true 时忽略 ids,按下面的筛选条件归档所有匹配的「抢购成功」活跃行。
archive_filtered: bool = False
expected_count: int | None = Field(None, ge=0)
shop: str = Field("", max_length=64)
owner_id: int | None = None
status: str = Field("", max_length=16)
ck_status: str = Field("", max_length=16)
gift_name: str = Field("", max_length=128)
keyword: str = Field("", max_length=128)
include_void: bool = False
class HuyaExternalOrderUnarchiveRequest(BaseModel):
"""取消归档(恢复回主台账)。"""
ids: list[int] = Field(default_factory=list)
class HuyaExternalOrderCkRequest(BaseModel):
"""贴入/换 CK,或从 raw_cookie 重建。"""
ck_text: str = ""
action: str = Field("paste", pattern="^(paste|rebuild)$")
expected_version: int | None = None
class HuyaExternalOrderCkStatusRequest(BaseModel):
"""人工改 CK 状态:**只能**改成「已停用」或「未验证」。
``valid`` / ``invalid`` 由只读探针给(``POST .../verify``)——人工标「有效」等于绕过验证。
"""
status: str = Field(..., pattern="^(unverified|disabled)$")
message: str = Field("", max_length=256)
expected_version: int | None = None
class HuyaExternalOrderImagesRequest(BaseModel):
"""整槽替换某个图片槽位(提交 /api/files/upload 返回的 object_key)。"""
slot: str = Field(..., pattern="^(bind_images|home_images)$")
images: list[str] = Field(default_factory=list)
expected_version: int | None = None
class HuyaExternalOrderImportRequest(BaseModel):
"""Excel 粘贴批量导入(TSV,首行可为表头)。"""
text: str
class HuyaExternalOrderVerifyRequest(BaseModel):
"""批量验证 CK(探针):人工勾选的行 id(一次最多 50 条)。"""
ids: list[int] = Field(default_factory=list)
class HuyaExternalOrderVerifyItemOut(BaseModel):
"""批量验证里的一行结论(``ok=False`` 时看 ``message``)。"""
id: int
order_no: str = ""
ok: bool = False
ck_status: str = ""
message: str = ""
binding: dict[str, str] = Field(default_factory=dict)
class HuyaExternalOrderVerifyOut(BaseModel):
"""批量验证汇总:``valid + failed = total``。"""
total: int
valid: int
failed: int
results: list[HuyaExternalOrderVerifyItemOut] = Field(default_factory=list)
class HuyaExternalOrderStatusBatchRequest(BaseModel):
"""勾选多行,一次切到同一个业务状态(单个也走这里,ids 只放一个)。"""
ids: list[int] = Field(default_factory=list)
status: str = Field(..., min_length=1, max_length=16)
reason: str = Field("", max_length=256)
# 目标不是「向前」时必须显式置 True(和单行接口一致,避免误点)
rollback: bool = False
class HuyaExternalOrderStatusBatchItemOut(BaseModel):
"""批量改状态里的一行结论(``ok=False`` 时看 ``message``,不影响其它行)。"""
id: int
order_no: str = ""
ok: bool = False
message: str = ""
class HuyaExternalOrderStatusBatchOut(BaseModel):
"""批量改状态汇总:``updated + failed = total``。"""
total: int
updated: int
failed: int
target: str = ""
target_label: str = ""
results: list[HuyaExternalOrderStatusBatchItemOut] = Field(default_factory=list)
class HuyaExternalOrderRechargeRequest(BaseModel):
"""充值核账请求:按绑定账号整组核账,阈值默认 100。"""
ids: list[int] = Field(default_factory=list)
threshold: int = Field(100, ge=0, le=1_000_000)
class HuyaExternalOrderRechargeOrderOut(BaseModel):
id: int
order_no: str = ""
status: str = ""
status_label: str = ""
class HuyaExternalOrderRechargeGroupOut(BaseModel):
group_key: str = ""
status: str = ""
status_label: str = ""
balance: int | None = None
required_total: int | None = None
surplus: int | None = None
threshold: int = 100
reason: str = ""
message: str = ""
order_ids: list[int] = Field(default_factory=list)
orders: list[HuyaExternalOrderRechargeOrderOut] = Field(default_factory=list)
class HuyaExternalOrderRechargeFailureOut(BaseModel):
id: int
order_no: str = ""
message: str = ""
class HuyaExternalOrderRechargeOut(BaseModel):
total: int
updated: int
failed: int
threshold: int = 100
groups: list[HuyaExternalOrderRechargeGroupOut] = Field(default_factory=list)
failures: list[HuyaExternalOrderRechargeFailureOut] = Field(default_factory=list)
class HuyaExternalOrderArchiveItemOut(BaseModel):
"""归档/取消归档里的一行结论。"""
id: int
order_no: str = ""
ok: bool = False
message: str = ""
class HuyaExternalOrderArchiveOut(BaseModel):
"""归档/取消归档汇总:``archived`` 或 ``unarchived`` + skipped = total。"""
total: int
archived: int = 0
unarchived: int = 0
skipped: int = 0
results: list[HuyaExternalOrderArchiveItemOut] = Field(default_factory=list)
class HuyaExternalOrderActivityQueryRequest(BaseModel):
"""勾选多行查积分 / 兑换记录(一次最多 50 条,串行带间隔)。
``scope``(2026-10-17 用户要求把批量按钮拆成两个):
- ``both``(默认,保持原行为):两个接口都查;
- ``score``:只查积分 —— 没有兑换记录就没有判定,**不会**自动改业务状态;
- ``records``:只查兑换记录 —— 积分不更新,判定与自动改状态照旧。
"""
ids: list[int] = Field(default_factory=list)
scope: str = "both"
class HuyaExternalOrderCkExportRequest(BaseModel):
"""勾选多行,导出这些行的 CK 明文(前端写剪贴板;一次最多 50 条)。"""
ids: list[int] = Field(default_factory=list)
class HuyaExternalOrderCkExportItemOut(BaseModel):
"""导出结果里的一行:``ck_text`` 是**明文凭据**,只走这个接口、不留日志。"""
id: int
order_no: str = ""
uid: str = ""
nickname: str = ""
status: str = ""
status_label: str = ""
ck_status: str = ""
ck_status_label: str = ""
ck_text: str = ""
class HuyaExternalOrderCkExportSkippedOut(BaseModel):
"""没导出的行(例如这行还没贴 CK)——把原因显式回给前端,别静默丢。"""
id: int
order_no: str = ""
reason: str = ""
class HuyaExternalOrderCkExportOut(BaseModel):
"""导出 CK 的结果:``exported + len(skipped) = total``。"""
total: int
exported: int
skipped: list[HuyaExternalOrderCkExportSkippedOut] = Field(default_factory=list)
items: list[HuyaExternalOrderCkExportItemOut] = Field(default_factory=list)
class HuyaCollabMetricMatchedOut(BaseModel):
"""匹配到的那条兑换记录(只留界面要用的几个字段)。"""
prize_name: str = ""
score: int | None = None
status_label: str = ""
order_id: str = ""
exchange_time: int | None = None
class HuyaCollabMetricOut(BaseModel):
"""一条「账号全生命周期」快照(积分 / 兑换记录 + 判定)。"""
id: int
order_id: int
order_no: str = ""
uid: str = ""
sid: int | None = None
# 这条快照查了哪一半(both / score / records):前端据此决定显示哪几段
scope: str = "both"
score: int | None = None
gained_score: int | None = None
used_score: int | None = None
records_total: int = 0
records_matched: int = 0
# 空串 = 本次没查兑换记录(只查积分),与 unknown(查了判不了)不同
verdict: str = ""
verdict_label: str = ""
message: str = ""
source: str = "manual"
matched_preview: list[HuyaCollabMetricMatchedOut] = Field(default_factory=list)
created_by: int | None = None
created_by_name: str = ""
created_at: datetime | None = None
class HuyaCollabFrequentGiftOut(BaseModel):
"""「常用礼包」:台账里出现过的礼包名 + 用过几次(新建/补资料时的快捷按钮)。
只有 ``name`` 与 ``used`` —— 当前活动还剩多少是礼包列表那边的事,
两边混在一起会让人以为这个数字是库存(见设计文档 §11.4 的 SID 教训)。
"""
name: str
used: int = 0
class HuyaExternalOrderActivityItemOut(BaseModel):
"""批量查询里的一行结论。"""
ok: bool = False
order_id: int
order_no: str = ""
# 本次查的是哪一半(``both`` / ``score`` / ``records``)
scope: str = ""
scope_label: str = ""
# 只查积分时为**空串**(未判定),不是 ``unknown``(查了但判不了)
verdict: str = "unknown"
verdict_label: str = ""
score: int | None = None
gained_score: int | None = None
used_score: int | None = None
records_total: int = 0
records_matched: int = 0
message: str = ""
# 查询后是否自动改了状态(空 = 没改,见服务层:只有「已上脚本」的单会自动改)
auto_status: str = ""
auto_status_label: str = ""
status: str = ""
status_label: str = ""
class HuyaExternalOrderActivityQueryOut(BaseModel):
"""批量查询汇总:``success + failed + unknown + not_judged = total``。"""
total: int
success: int
failed: int
unknown: int = 0
# 本次没查兑换记录、因此没有判定结论的行数(只查积分时全落这里)
not_judged: int = 0
auto_changed: int = 0
scope: str = ""
scope_label: str = ""
results: list[HuyaExternalOrderActivityItemOut] = Field(default_factory=list)
class HuyaCollabBuyerClusterOut(BaseModel):
"""同一买家的多单(软识别,只读派生)。
``key`` 是买家指纹的可读文本(``绑定账号 · 绑定角色``);``method`` 说明识别口径,
免得以后有人以为这是人工"软绑定"关系。
"""
key: str = ""
method: str = ""
total: int = 0
others: list[HuyaCollabBuyerOrderOut] = Field(default_factory=list)
class HuyaExternalOrderOut(BaseModel):
"""一行的完整视图。
``*_image_urls`` 由后端拼(``/api/files/object?key=...``),
``*_image_thumb_urls`` 走缩略图变体(变体缺失自动回退原图)。
"""
id: int
order_no: str
shop: str
owner_id: int | None = None
owner_name: str = ""
uid: str = ""
yyuid: str = ""
hy_username: str = ""
nickname: str = ""
has_cookie: bool = False
has_raw_cookie: bool = False
ck_status: str = "unverified"
ck_status_label: str = ""
ck_checked_at: datetime | None = None
ck_check_message: str = ""
bind_douyu_account: str = ""
bind_douyu_uid: str = ""
# 探针读到的绑定对象(只读快照:账号 / 角色 / 平台·区服 + 读取时间)
bind_game_account: str = ""
bind_game_role: str = ""
bind_game_channel: str = ""
bind_snapshot_at: datetime | None = None
bind_images: list[str] = Field(default_factory=list)
home_images: list[str] = Field(default_factory=list)
bind_image_urls: list[str] = Field(default_factory=list)
home_image_urls: list[str] = Field(default_factory=list)
bind_image_thumb_urls: list[str] = Field(default_factory=list)
home_image_thumb_urls: list[str] = Field(default_factory=list)
gift_name: str = ""
required_points: int | None = None
remark: str = ""
status: str = "incomplete"
status_label: str = ""
next_status: str | None = None
bound_at: datetime | None = None
recharged_at: datetime | None = None
scripted_at: datetime | None = None
done_at: datetime | None = None
points_checked_at: datetime | None = None
points_balance: int | None = None
points_required_total: int | None = None
points_surplus: int | None = None
points_threshold: int | None = None
points_check_reason: str = ""
points_check_message: str = ""
points_check_detail: dict[str, Any] | None = None
void_reason: str = ""
# 归档是可见性状态,不影响上面的业务状态。
is_archived: bool = False
archived_at: datetime | None = None
archived_by: int | None = None
archived_by_name: str = ""
archive_reason: str = ""
points: int | None = None
elite_opened: bool | None = None
snapshot_at: datetime | None = None
created_by: int | None = None
created_by_name: str = ""
updated_by: int | None = None
updated_by_name: str = ""
version: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
# 同一买家的其它单(软识别:绑定账号 + 绑定角色相同;只读派生,没识别到就是 null)
buyer_cluster: HuyaCollabBuyerClusterOut | None = None
# 最近一次「积分 / 兑换记录」快照(完整时间线走 GET /{id}/metrics)
# 积分与判定是**分开**取最近一条查到过它的:只查积分的那次不产生判定,反之亦然
# (2026-10-17 拆分查询入口,见 services/huya_collab_service.order_metric_views)
last_score: int | None = None
last_metric_at: datetime | None = None
last_metric_message: str = ""
last_verdict: str = ""
last_verdict_label: str = ""
last_verdict_at: datetime | None = None
last_verdict_message: str = ""
class HuyaExternalOrderListOut(BaseModel):
"""列表响应:共享视图,带上总数供分页使用。"""
items: list[HuyaExternalOrderOut]
total: int
page: int
page_size: int
class HuyaExternalOrderSummaryOut(BaseModel):
"""页头统计条(口径与当前 archive_scope / include_void 一致)。"""
total: int
by_status: dict[str, int]
ck_invalid: int
archived_total: int = 0
class HuyaExternalOrderOwnerOut(BaseModel):
"""负责人下拉项(活跃用户)。"""
id: int
username: str
class HuyaExternalOrderAuditOut(BaseModel):
"""详情页的时间线条目(直接来自 audit_logs)。"""
id: int
action: str
username: str = ""
detail: str = ""
success: bool | None = None
created_at: datetime | None = None
# ==================== 斗鱼外部单协作 ====================
class DouyuCollabShopCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
remark: str = Field("", max_length=255)
class DouyuCollabShopUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=64)
sort_order: int | None = None
is_active: bool | None = None
remark: str | None = Field(None, max_length=255)
class DouyuCollabShopOut(BaseModel):
id: int
name: str
sort_order: int = 0
is_active: bool = True
remark: str = ""
created_by: int | None = None
updated_by: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class DouyuCollabGoodsOut(BaseModel):
commodity_id: str = ""
name: str = ""
score: int | None = None
status: str = ""
open_status: int | None = None
start_time: int | None = None
end_time: int | None = None
mark: str = ""
count: int | None = None
elite_limited: int | None = None
is_limited: bool = False
raw: dict[str, Any] | None = None
class DouyuCollabAccountLookupRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=128)
with_goods: bool = True
class DouyuCollabAccountLookupOut(BaseModel):
found: bool = False
ok: bool = False
message: str = ""
errors: list[str] = Field(default_factory=list)
account_id: int | None = None
account_username: str = ""
account_uid: str = ""
account_nickname: str = ""
ck_status: str = ""
ck_status_label: str = ""
manual_type: int | None = None
manual_score: int | None = None
expire_time: int | None = None
manual_opened: bool = False
bind_game_account: str = ""
bind_game_role: str = ""
bind_game_channel: str = ""
fish_fin_balance: int | None = None
goods: list[DouyuCollabGoodsOut] = Field(default_factory=list)
goods_count: int = 0
orders_using_account: list[dict[str, Any]] = Field(default_factory=list)
class DouyuCollabOrderCreate(BaseModel):
order_no: str = Field(..., min_length=1, max_length=64)
shop: str = Field("", max_length=64)
owner_id: int | None = None
account_username: str = Field("", max_length=128)
gift_name: str = Field("", max_length=128)
commodity_id: str = Field("", max_length=64)
required_points: int | None = Field(None, ge=0)
home_images: list[str] = Field(default_factory=list)
remark: str = Field("", max_length=512)
draft: bool = False
require_verified: bool = True
class DouyuCollabOrderUpdate(BaseModel):
order_no: str | None = Field(None, max_length=64)
shop: str | None = Field(None, max_length=64)
owner_id: int | None = None
account_username: str | None = Field(None, max_length=128)
gift_name: str | None = Field(None, max_length=128)
commodity_id: str | None = Field(None, max_length=64)
required_points: int | None = Field(None, ge=0)
home_images: list[str] | None = None
remark: str | None = Field(None, max_length=512)
expected_version: int | None = None
class DouyuCollabStatusRequest(BaseModel):
status: str = Field(..., min_length=1, max_length=16)
reason: str = Field("", max_length=256)
rollback: bool = False
expected_version: int | None = None
class DouyuCollabStatusBatchRequest(BaseModel):
ids: list[int] = Field(default_factory=list)
status: str = Field(..., min_length=1, max_length=16)
reason: str = Field("", max_length=256)
rollback: bool = False
class DouyuCollabAccountRequest(BaseModel):
account_username: str = Field(..., min_length=1, max_length=128)
expected_version: int | None = None
class DouyuCollabImagesRequest(BaseModel):
home_images: list[str] = Field(default_factory=list)
expected_version: int | None = None
class DouyuCollabArchiveRequest(BaseModel):
ids: list[int] = Field(default_factory=list)
reason: str = Field("", max_length=256)
class DouyuCollabCkExportRequest(BaseModel):
"""勾选多行,导出关联内部账号的 CK 明文(一次最多 50 条)。"""
ids: list[int] = Field(default_factory=list)
class DouyuCollabCkExportItemOut(BaseModel):
"""导出结果里的一行:``ck_text`` 是明文凭据,只走这个接口、不留日志。"""
id: int
order_no: str = ""
account_id: int | None = None
account_username: str = ""
account_nickname: str = ""
status: str = ""
status_label: str = ""
ck_status: str = ""
ck_status_label: str = ""
ck_text: str = ""
class DouyuCollabCkExportSkippedOut(BaseModel):
id: int
order_no: str = ""
reason: str = ""
class DouyuCollabCkExportOut(BaseModel):
"""导出 CK 的结果:``exported + len(skipped) = total``。"""
total: int
exported: int
skipped: list[DouyuCollabCkExportSkippedOut] = Field(default_factory=list)
items: list[DouyuCollabCkExportItemOut] = Field(default_factory=list)
class DouyuCollabUsernameExportRequest(BaseModel):
"""勾选多行,导出关联的斗鱼用户名(一次最多 500 条)。"""
ids: list[int] = Field(default_factory=list)
class DouyuCollabUsernameExportItemOut(BaseModel):
id: int
order_no: str = ""
account_username: str = ""
class DouyuCollabUsernameExportSkippedOut(BaseModel):
id: int
order_no: str = ""
reason: str = ""
class DouyuCollabUsernameExportOut(BaseModel):
"""``exported`` 是按单计数,``unique`` 是去重后的用户名个数。"""
total: int
exported: int
unique: int
skipped: list[DouyuCollabUsernameExportSkippedOut] = Field(default_factory=list)
items: list[DouyuCollabUsernameExportItemOut] = Field(default_factory=list)
usernames: list[str] = Field(default_factory=list)
class DouyuCollabOrderOut(BaseModel):
id: int
order_no: str
shop: str = ""
owner_id: int | None = None
owner_name: str = ""
account_id: int | None = None
account_username: str = ""
account_uid: str = ""
account_nickname: str = ""
account_exists: bool = False
has_cookie: bool = False
account_ck_status: str = ""
account_status: str = ""
bind_game_account: str = ""
bind_game_role: str = ""
bind_game_channel: str = ""
bind_snapshot_at: datetime | None = None
home_images: list[str] = Field(default_factory=list)
home_image_urls: list[str] = Field(default_factory=list)
home_image_thumb_urls: list[str] = Field(default_factory=list)
gift_name: str = ""
commodity_id: str = ""
required_points: int | None = None
remark: str = ""
status: str = "incomplete"
status_label: str = ""
next_status: str | None = None
missing_fields: list[str] = Field(default_factory=list)
fish_fin_balance: int | None = None
points_balance: int | None = None
points_required_total: int | None = None
points_surplus: int | None = None
points_threshold: int | None = None
points_checked_at: datetime | None = None
points_check_reason: str = ""
points_check_message: str = ""
points_check_detail: dict[str, Any] | None = None
recharged_at: datetime | None = None
scripted_at: datetime | None = None
done_at: datetime | None = None
void_reason: str = ""
is_archived: bool = False
archived_at: datetime | None = None
archived_by: int | None = None
archived_by_name: str = ""
archive_reason: str = ""
created_by: int | None = None
created_by_name: str = ""
updated_by: int | None = None
updated_by_name: str = ""
version: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
account_cluster: dict[str, Any] | None = None
last_metric_at: datetime | None = None
last_metric_message: str = ""
last_verdict: str = ""
last_verdict_label: str = ""
last_records_total: int = 0
last_records_matched: int = 0
class DouyuCollabOrderListOut(BaseModel):
items: list[DouyuCollabOrderOut]
total: int
page: int
page_size: int
class DouyuCollabSummaryOut(BaseModel):
total: int
by_status: dict[str, int]
account_missing: int = 0
ck_invalid: int = 0
archived_total: int = 0
class DouyuCollabOwnerOut(BaseModel):
id: int
username: str
class DouyuCollabMetricOut(BaseModel):
id: int
order_id: int
order_no: str = ""
account_username: str = ""
fish_fin_balance: int | None = None
points_balance: int | None = None
points_required_total: int | None = None
points_surplus: int | None = None
records_total: int = 0
records_matched: int = 0
verdict: str = ""
verdict_label: str = ""
message: str = ""
source: str = ""
created_at: datetime | None = None
class DouyuCollabTimelineOut(BaseModel):
id: int
action: str
username: str = ""
detail: str = ""
success: bool | None = None
created_at: datetime | None = None