"""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 = "" ck_check_status: str = "" ck_checked_at: datetime | None = None 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