feat(huya): 外部 CK 协作台账(S1–S6)+ 图片上传能力

协作台账:新表 huya_external_orders 替代 Excel 多人协同表(设计见
docs/设计-虎牙外部CK协作台账.md),一条 CK 一行,所有人共享、改动留痕。

- 前端 pages/HuyaCollabPage.tsx + 菜单「虎牙 → 外部单协作」/huya/collab:
  内联编辑(带乐观锁 version)、图片缩略图、详情抽屉(字段表单 + 两个图片槽位 +
  操作时间线)、粘贴导入弹窗(带 TSV 预览)、批量/单条验证 CK、页头统计条、筛选
- 两套状态机:业务 pending→bound→recharged→scripted→done(旁路 void)、
  凭据 unverified/valid/invalid/disabled;唯一写入口在 services/huya_collab_service.py,
  字段级审计 + 整数 version 乐观锁(不用 updated_at:DATETIME 只到秒)
- CK 只读探针 check_user_bind_game_account(真机判据:响应非 None 且 status == 200),
  成功即覆盖写绑定快照 bind_game_account/bind_game_role/bind_game_channel(迁移 0056),
  失败只改凭据状态、不动业务状态;人工只能停用/恢复(POST /{id}/ck/status)
- 贴/换/重建 CK、TSV 批量导入(SAVEPOINT 单行隔离,部分失败不整体失败)、
  权限 huya:collab:view/edit/manage(删除只给运营)
- 孤儿图清理脚本 scripts/cleanup_orphan_file_assets.py(默认 --dry-run)

图片上传(协作台账截图用):OSS/本地双后端 + 缩略图变体 + /api/files 代理回读,
权限 file:upload / file:view。

验证:pytest 880 passed / 6 skipped;ruff format 全绿、ruff check 仅剩既有告警;
前端 tsc -b / eslint / vite build 通过;接口端到端冒烟 29 项全绿(TestClient,探针用假客户端);
探针真机冒烟 3 例(有效 CK=True、别人 uid=False、裸 cookie=False)。
This commit is contained in:
yml2213
2026-10-16 19:38:59 +08:00
parent 99e8643d83
commit df8c63b829
32 changed files with 7449 additions and 16 deletions
+168
View File
@@ -985,3 +985,171 @@ class ExternalOrderCache(Base):
fetched_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, index=True
)
class FileAsset(Base):
"""上传文件台账(对齐 order_site 的 ``file_assets``)。
对象本体在对象存储里(内网 OSS),库中只留 key 与元数据 —— 图片一律经
``/api/files/object?key=...`` 由后端代理回读,不下发 OSS 直链、也不存签名 URL
(签名链接会在入库时被原样存下来,过期后到处裂图)。
变体 key 为空表示缩略图生成失败,回读接口会自动回退原图。
"""
__tablename__ = "file_assets"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
object_key: Mapped[str] = mapped_column(
String(512), unique=True, nullable=False, index=True
)
scene: Mapped[str] = mapped_column(
String(32), default="misc", server_default="misc", index=True
)
original_filename: Mapped[str] = mapped_column(
String(255), default="", server_default=""
)
content_type: Mapped[str] = mapped_column(String(64), default="", server_default="")
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
thumbnail_object_key: Mapped[str] = mapped_column(
String(512), default="", server_default=""
)
medium_object_key: Mapped[str] = mapped_column(
String(512), default="", server_default=""
)
uploader_type: Mapped[str] = mapped_column(
String(32), default="user", server_default="user"
)
uploader_id: Mapped[str] = mapped_column(
String(64), default="", server_default="", index=True
)
status: Mapped[str] = mapped_column(
String(16), default="active", server_default="active"
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class HuyaExternalOrder(Base):
"""虎牙外部 CK 协作台账(替代 Excel 多人协同表)。
**一行 = 一单 = 一个外部 CK**,设计见 ``docs/设计-虎牙外部CK协作台账.md``。
两条边界(改动前先读设计文档 §2.3,别当成 bug 修):
1. **CK 独立存本表,刻意不写 ``huya_accounts``、不做外键**。同一个虎牙号可能同时
存在于两处、各自过期,这是已知代价;本表的 CK 只在「协作单」语境里有效,
需要拿去跑任务时人工到工作台重新导入,不自动同步。
2. **不接任务执行器**:绑定/充值/上脚本都在别的工具里做,本表只记录结果。
「一键送进工作台」是以后显式加的新动作。
两套状态各管一摊,不要混:``status`` 是业务进度(一单卡在哪一步),
``ck_status`` 是凭据本身有没有失效。CK 失效**不会**自动改业务状态。
"""
__tablename__ = "huya_external_orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Excel B 列:订单编号,台账内唯一(重复导入按「更新」处理)。
order_no: Mapped[str] = mapped_column(
String(64), unique=True, nullable=False, index=True
)
# Excel A 列:店铺。
shop: Mapped[str] = mapped_column(
String(64), default="", server_default="", index=True
)
# Excel C 列:负责人(共享表里只是筛选维度,不是权限边界)。
owner_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
# ---- 外部 CK 本体(独立于 huya_accounts)----
# uid 唯一但可空:MySQL 唯一索引允许多个 NULL,于是「先建单、后贴 CK」与
# 「一个虎牙号只能挂一单」同时成立。
uid: Mapped[str | None] = mapped_column(
String(32), unique=True, nullable=True, index=True
)
yyuid: Mapped[str] = mapped_column(String(32), default="", server_default="")
hy_username: Mapped[str] = mapped_column(String(128), default="", server_default="")
nickname: Mapped[str] = mapped_column(String(128), default="", server_default="")
cookie: Mapped[str] = mapped_column(EncryptedText(), nullable=False, default="")
# App 原始凭据:网页运行 CK 被风控标记时可由它重建(见 rebuild_web_cookie_from_raw)。
raw_cookie: Mapped[str] = mapped_column(EncryptedText(), nullable=False, default="")
ck_status: Mapped[str] = mapped_column(
String(16), default="unverified", server_default="unverified", index=True
)
ck_checked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
ck_check_message: Mapped[str] = mapped_column(
String(256), default="", server_default=""
)
# ---- Excel D 列:绑定斗鱼号 ----
bind_douyu_account: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
bind_douyu_uid: Mapped[str] = mapped_column(
String(32), default="", server_default=""
)
# ---- 探针读到的绑定对象(游戏账号 / 角色,**只读快照**)----
# 2026-10-16 真机确认:`check_user_bind_game_account` 返回的是**绑定在该虎牙号上的
# 游戏账号**(账号昵称 / 角色名 / 平台·区服)。「这单绑了哪个号、哪个角色」只有这里
# 可信 —— 上面那个 `bind_douyu_account` 是人工填的自由文本,Excel 粘贴导入还在用它。
# 四个列**由探针独占**:每次验证覆盖写入,读的时间看 `bind_snapshot_at`;人工不编辑。
bind_game_account: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
bind_game_role: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
bind_game_channel: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
bind_snapshot_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
# ---- Excel E/F 列:两张截图(存 OSS object_key,不存 URL —— URL 可推导,
# 存 URL 会在换存储时全部失效)。数组,第一张作封面。
bind_images: Mapped[list | None] = mapped_column(JSON, nullable=True)
home_images: Mapped[list | None] = mapped_column(JSON, nullable=True)
# ---- Excel G 列(表头写的「备注」,实际是礼包名)+ 自由备注 ----
gift_name: Mapped[str] = mapped_column(String(128), default="", server_default="")
remark: Mapped[str] = mapped_column(String(512), default="", server_default="")
# ---- 业务进度 ----
status: Mapped[str] = mapped_column(
String(16), default="pending", server_default="pending", index=True
)
bound_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
recharged_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
scripted_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
done_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
void_reason: Mapped[str] = mapped_column(String(256), default="", server_default="")
# ---- 只读查询快照(不写 huya_accounts)----
points: Mapped[int | None] = mapped_column(Integer, nullable=True)
elite_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
snapshot_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
# 最后修改人:协作表上要能一眼看出「这行刚被谁动过」。
updated_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
# 乐观锁版本号:每次写入 +1。
# **刻意不用 updated_at 当锁**:本项目的 UTCDateTime 落库是 MySQL DATETIME
# (秒级精度),同一秒内的两次改动会得到完全相同的 updated_at,
# 锁就形同虚设 —— Excel 那种「后保存的覆盖前一个」会原样复活。
version: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
# 显示用(列表默认排序键);并发保护靠上面的 version。
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow, index=True
)