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:
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user