From df8c63b829bb9bf968a1e802078b580a5aa4a969 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 16 Oct 2026 19:38:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(huya):=20=E5=A4=96=E9=83=A8=20CK=20?= =?UTF-8?q?=E5=8D=8F=E4=BD=9C=E5=8F=B0=E8=B4=A6=EF=BC=88S1=E2=80=93S6?= =?UTF-8?q?=EF=BC=89+=20=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 协作台账:新表 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)。 --- .env.example | 22 + docker-compose.yml | 5 +- docs/设计-虎牙外部CK协作台账.md | 526 ++++++ pyproject.toml | 3 + scripts/cleanup_orphan_file_assets.py | 190 +++ tests/test_cleanup_orphan_file_assets.py | 180 ++ tests/test_file_upload.py | 409 +++++ tests/test_huya_collab_orders.py | 1015 +++++++++++ tests/test_migrations.py | 2 +- uv.lock | 63 + web/backend/crypto_storage.py | 3 + web/backend/main.py | 2 + .../versions/20261016_0054_file_assets.py | 92 + .../20261016_0055_huya_external_orders.py | 147 ++ .../20261016_0056_huya_order_bind_snapshot.py | 70 + web/backend/models.py | 168 ++ web/backend/permissions.py | 18 + web/backend/routers/files.py | 165 ++ web/backend/routers/huya/__init__.py | 3 + web/backend/routers/huya/collab.py | 371 ++++ web/backend/schemas.py | 207 +++ web/backend/services/file_service.py | 378 +++++ web/backend/services/file_storage.py | 434 +++++ web/backend/services/huya_collab_service.py | 1512 +++++++++++++++++ web/backend/services/huya_service.py | 47 +- web/frontend/src/App.tsx | 2 + web/frontend/src/api/files.ts | 18 + web/frontend/src/api/huya.ts | 63 + web/frontend/src/api/modules.ts | 1 + web/frontend/src/api/types.ts | 191 +++ web/frontend/src/layouts/MainLayout.tsx | 8 +- web/frontend/src/pages/HuyaCollabPage.tsx | 1150 +++++++++++++ 32 files changed, 7449 insertions(+), 16 deletions(-) create mode 100644 docs/设计-虎牙外部CK协作台账.md create mode 100755 scripts/cleanup_orphan_file_assets.py create mode 100644 tests/test_cleanup_orphan_file_assets.py create mode 100644 tests/test_file_upload.py create mode 100644 tests/test_huya_collab_orders.py create mode 100644 web/backend/migrations/versions/20261016_0054_file_assets.py create mode 100644 web/backend/migrations/versions/20261016_0055_huya_external_orders.py create mode 100644 web/backend/migrations/versions/20261016_0056_huya_order_bind_snapshot.py create mode 100644 web/backend/routers/files.py create mode 100644 web/backend/routers/huya/collab.py create mode 100644 web/backend/services/file_service.py create mode 100644 web/backend/services/file_storage.py create mode 100644 web/backend/services/huya_collab_service.py create mode 100644 web/frontend/src/api/files.ts create mode 100644 web/frontend/src/pages/HuyaCollabPage.tsx diff --git a/.env.example b/.env.example index fddbd50..473a3eb 100644 --- a/.env.example +++ b/.env.example @@ -101,5 +101,27 @@ FISH_FIN_RECHARGE_DEBUG=false # Roundcube 邮件验证码读取服务地址(可选;不填则使用默认服务) # MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/ +# ---- 文件存储:在线协作的图片上传 ---- +# local = 存本地 ./data/file_storage(开发/测试默认,无需对象存储); +# oss = 阿里云 OSS 或任意 S3 兼容存储,生产用这个。 +OSS_MODE=local +# ECS 与桶同地域时必须用内网 endpoint:免公网流量费、延迟低。 +# 本机(非 ECS)调试要改成公网:https://oss-cn-hangzhou.aliyuncs.com +# OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com +# OSS_BUCKET=live-hub-assets +# OSS_ACCESS_KEY_ID= +# OSS_ACCESS_KEY_SECRET= +# 实测该 RAM 子账号的权限边界(2026-10-16,ossutil 2.1.2):桶内 ListObjects / +# PutObject / GetObject / DeleteObject 都通;ListBuckets、GetBucketStat、 +# GetObjectAcl、跨桶访问一律 403。所以代码**不调** HeadBucket/GetBucketStat +# (见 file_storage.S3ObjectStorage.ensure_ready),RAM 策略也不用放开这些。 +# region 默认从 endpoint 推导(oss-cn-hangzhou-internal → oss-cn-hangzhou),一般不用配。 +# OSS_REGION= +# 上传大小上限(MB,默认 10)。前端会在上传前把长边压到 1600 以内。 +# OSS_MAX_UPLOAD_SIZE_MB=10 +# local 模式的对象根目录(默认 ./data/file_storage,已随 data/ 一起忽略) +# OSS_LOCAL_ROOT= +# OSS_PREFIX 是 P2 资料收集(collect)用的前缀,本功能由 scene 决定命名空间。 + # Uvicorn reload(开发模式设为 true,生产环境保持 false) UVICORN_RELOAD=false diff --git a/docker-compose.yml b/docker-compose.yml index 1bbf1ad..2646e46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,12 +69,15 @@ services: - ORDER_SITE_DATABASE_URL=${ORDER_SITE_DATABASE_URL:-} - ORDER_SITE_LOOKUP_ENABLED=${ORDER_SITE_LOOKUP_ENABLED:-false} - ORDER_SITE_LOOKUP_TIMEOUT_SECONDS=${ORDER_SITE_LOOKUP_TIMEOUT_SECONDS:-3} - # 用户上传图片的 OSS(P2 资料收集用) + # 用户上传图片的桶(在线协作 / P2 资料收集共用;生产用内网 endpoint)。 + # OSS_MODE=oss 才走对象存储,否则落容器内 ./data/file_storage。 + - OSS_MODE=${OSS_MODE:-local} - OSS_ENDPOINT=${OSS_ENDPOINT:-} - OSS_BUCKET=${OSS_BUCKET:-} - OSS_ACCESS_KEY_ID=${OSS_ACCESS_KEY_ID:-} - OSS_ACCESS_KEY_SECRET=${OSS_ACCESS_KEY_SECRET:-} - OSS_PREFIX=${OSS_PREFIX:-collect} + - OSS_MAX_UPLOAD_SIZE_MB=${OSS_MAX_UPLOAD_SIZE_MB:-10} networks: - default - order-site-net diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md new file mode 100644 index 0000000..e905e30 --- /dev/null +++ b/docs/设计-虎牙外部CK协作台账.md @@ -0,0 +1,526 @@ +# 设计:虎牙外部 CK 协作台账(替代 Excel 多人协同表) + +状态:**已评审**(2026-10-16 决策见 §10;S1 开工) +日期:2026-10-16 +前置:图片上传能力已落地(`web/backend/services/file_service.py` + `routers/files.py`,对象落 OSS 桶 `live-hub-assets`) + +--- + +## 0. 一句话 + +把现在用 Excel 维护的「外部 CK 跟进表」搬进后台:**一行 = 一单 = 一个外部虎牙 CK**,多人共同维护, +CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重建 → 作废), +两张截图(绑定图 / 主页账号图)走已经接好的 OSS 上传。 + +--- + +## 1. 背景:Excel 现在承担什么 + +现有 Excel 的表头与系统现状对照: + +| Excel 列 | 现在的样子 | 本设计怎么落 | +| --- | --- | --- | +| 店铺 | ❌ 系统里没有 | `shop`,人工录入/批量导入 | +| 订单编号 | ❌ 没有(虎牙侧没有订单概念;`external_order_cache` 是斗鱼 P0 的) | `order_no`,唯一键 | +| 负责人 | ⚠️ 只有 `huya_accounts.assigned_to`(语义是"谁能操作这账号") | `owner_id` → `users.id` | +| 绑定斗鱼号 | ⚠️ 只在 `huya_tasks.result`(`bind_status`/`bind_phase`)里,账号上无字段 | `bind_douyu_account` + `bind_douyu_uid` | +| 绑定图 | ❌ 完全没有 | `bind_images`(OSS object_key 数组) | +| 主页账号图 | ❌ 完全没有 | `home_images`(同上) | +| 备注(实为礼包名) | ⚠️ 模型有 `remark`,但这列写的是商品 | `gift_name`(礼包名)+ `remark`(自由备注) | +| 状态(已充值/已上脚本) | ❌ 无。现有的是 `points`/`elite_opened` 这类**查询结果**,不是人工推进的**业务状态** | `status` + `recharged_at`/`scripted_at` | +| 账密 / 积分 / 宝典状态 | ✅ `huya_accounts` 有 `username`/`account_password`/`points`/`elite_opened` | 本表自己一份(见 §2 边界) | + +**Excel 撑不住的三个点**(本设计的动机): +1. **并发覆盖**:两个人同时改一行,后保存的把前一个覆盖掉,且没有任何痕迹; +2. **截图外挂**:图存在本地/群聊里,找不到、对不上号、换人接手就断; +3. **状态靠肉眼**:CK 什么时候失效、这单卡在哪一步,没有可查的历史。 + +--- + +## 2. 目标与非目标(边界,先划清) + +### 2.1 目标 + +1. 外部 CK 的 CRUD:新增(粘贴 CK)、编辑、删除,解析出 uid/昵称; +2. CK 全生命周期:`unverified → valid → invalid`,支持重贴、从 `raw_cookie` 重建、人工停用; +3. 业务全生命周期:`pending → bound → recharged → scripted → done`,旁路 `void`; +4. 多人在同一张表上协作:共享视图 + 负责人 + 字段级审计 + 并发保护; +5. 两个图片槽位(绑定图 / 主页账号图)走 OSS,行内可预览。 + +### 2.2 非目标(本次**不做**,避免范围失控) + +- ❌ **不碰 `huya_accounts`**:本表自存 CK,**不双写、不互查、不做外键**。 + 这是明确选择("完全独立一套表")。代价见 §2.3。 +- ❌ 不把 CK 灌进现有任务执行器。`huya_tasks` 的 runner 全部挂在 `HuyaAccount` 上, + 走它就必须建账号行,等于破坏上面的边界。 +- ❌ 不做订单自动拉取(预留:以后可接 `order_site` 只读适配层,按 `order_no` 回显商品)。 +- ❌ 不做执行类动作(生成支付码 / 充值 / 浏览器兑换)。**绑定与充值仍在现有工具里做, + 本台账只负责"记录结果"**(见 §10 待确认 A)。 + +### 2.3 独立存 CK 的代价(必须写下来,别以后当 bug) + +- 同一个虎牙号可能**同时**存在于 `huya_accounts` 和本表 → 两边 CK 会各自过期, + 没有机制保证一致。**约定:本表的 CK 只在"协作单"语境里有效**,需要拿去跑任务时, + 人工到工作台重新导入(不自动同步)。 +- 本表拿不到工作台的设备态补齐(`guid`/`_qimei_uuid42`)等能力, + CK 有效性验证只做**只读探针**(§5.2),不做重登。 + +--- + +## 3. 领域模型 + +### 3.1 表 `huya_external_orders` + +一行 = 一单 = 一个外部 CK。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | int PK | | +| `order_no` | varchar(64) **unique** | 订单编号(Excel B 列),唯一 | +| `shop` | varchar(64) index | 店铺(A 列) | +| `owner_id` | int FK `users.id` index | 负责人(C 列),可空 | +| `uid` | varchar(32) **unique, nullable** | 虎牙 uid(从 CK 解析;空 = 还没贴 CK) | +| `yyuid` | varchar(32) | 虎牙 yyuid | +| `hy_username` | varchar(128) | CK 里的 `udb_passport`(登录名) | +| `nickname` | varchar(128) | 昵称(探针查到后补全) | +| `cookie` | EncryptedText | 网页运行 CK | +| `raw_cookie` | EncryptedText | App 原始凭据(可用来重建网页 CK) | +| `ck_status` | varchar(16) index | `unverified` / `valid` / `invalid` / `disabled` | +| `ck_checked_at` | datetime | 最近一次探针时间 | +| `ck_check_message` | varchar(256) | 探针的可读结论(失败原因) | +| `bind_douyu_account` | varchar(128) | 绑定斗鱼号(D 列,**人工自由文本**;Excel 粘贴导入按它映射) | +| `bind_douyu_uid` | varchar(32) | 绑定对象的结构化 uid(能拿到就落) | +| `bind_game_account` | varchar(128) | **探针读到的绑定游戏账号**(昵称),每次验证覆盖写入 | +| `bind_game_role` | varchar(128) | **探针读到的绑定角色名**(2026-10-16 用户明确:这条重要) | +| `bind_game_channel` | varchar(128) | 平台 / 区服(如「手Q · 苹果(iOS)」) | +| `bind_snapshot_at` | datetime | 上面三个绑定快照的读取时间 | +| `bind_images` | JSON | 绑定图 object_key 数组(E 列) | +| `home_images` | JSON | 主页账号图 object_key 数组(F 列) | +| `gift_name` | varchar(128) | 礼包名(G 列,原表头写的"备注") | +| `remark` | varchar(512) | 自由备注 | +| `status` | varchar(16) index | 业务状态(§4.1) | +| `bound_at` | datetime | 绑定完成时间 | +| `recharged_at` | datetime | 充值完成时间 | +| `scripted_at` | datetime | 上脚本时间 | +| `done_at` | datetime | 归档完成时间 | +| `void_reason` | varchar(256) | 作废原因(`status=void` 时必填) | +| `points` | int nullable | 最近一次只读查询的积分快照 | +| `elite_opened` | bool nullable | 最近一次只读查询的宝典开通状态 | +| `snapshot_at` | datetime | 上面两个快照的查询时间 | +| `created_by` | int FK `users.id` | 创建人 | +| `updated_by` | int FK `users.id` | **最后修改人**(协作可见) | +| `version` | int | **乐观锁版本号**,每次写入 +1(见 §7.2 为什么不用 `updated_at`) | +| `created_at` / `updated_at` | datetime | `updated_at` 只用于显示与排序 | + +索引:`order_no`(unique)、`uid`(unique)、`shop`、`owner_id`、`status`、`ck_status`、`updated_at`。 + +### 3.2 两个 key 设计上的选择 + +- **`uid` 唯一但可空**:MySQL 的唯一索引允许多个 NULL, + 所以"先建单、后贴 CK"和"一个 CK 只能挂一单"同时成立(1 单 = 1 CK 已确认)。 + 贴 CK 时如果 `uid` 已被别的行占用 → 报冲突,提示"这个虎牙号已在第 N 行(订单 xxx)"。 +- **`order_no` 唯一**:Excel 里订单号是天然主键,重复导入要能识别。 + +### 3.3 不建的表 + +- 不建"操作历史表":直接用现有 `audit_logs`(§7.3),避免再造一套事件表。 +- 不建"图片表":图片本体在 OSS,元数据在 `file_assets`(已落地),本表只存 key。 + +--- + +## 4. 两套生命周期(本设计的核心) + +### 4.1 业务状态机(一行的进度) + +``` +pending ──绑定成功──▶ bound ──充值──▶ recharged ──上脚本──▶ scripted ──归档──▶ done + │ │ │ │ + └────────────────────┴────────────────┴───────────────────┴──────▶ void(作废) +``` + +| 状态 | 含义 | 进入条件 | 谁可触发 | +| --- | --- | --- | --- | +| `pending` | 待绑定 | 新建行(默认) | — | +| `bound` | 已绑定 | 填了 `bind_douyu_account` **且** 至少 1 张绑定图 | 所有人 | +| `recharged` | 已充值 | 从 `bound` 前进;写 `recharged_at` | 所有人 | +| `scripted` | 已上脚本 | 从 `recharged` 前进;写 `scripted_at` | 所有人 | +| `done` | 已完成 | 从 `scripted` 前进;写 `done_at` | 所有人 | +| `void` | 作废 | 任意状态可进;`void_reason` 必填 | 所有人 | + +**规则**: +1. 状态**只能向前**。要回退必须显式勾选"回退"并从下拉里选目标态, + 审计里记为 `status_rollback`(保留痕迹,不静默改)。 +2. 进入 `bound` 的前置校验在**接口层**做(缺绑定图/缺绑定斗鱼号 → 400, + 错误信息要说清缺什么),不在前端做——前端只是体验,不能当校验。 +3. **不用一个布尔列表达"充值/上脚本"**:`recharged_at` / `scripted_at` 是 + "什么时候做过"的证据,`status` 是"现在卡在哪"。两者都留, + 才能既显示"已充值/已上脚本"这种组合,也能显示"只充了没上脚本"的中间态。 + +### 4.2 CK 状态机(凭据本身) + +``` +unverified ──探针成功──▶ valid ──探针失败──▶ invalid ──重贴/重建──▶ valid + │ │ │ + └──────────────────────┴────────────────────┴────▶ disabled(人工停用) +``` + +| 状态 | 含义 | 进入方式 | +| --- | --- | --- | +| `unverified` | 刚贴进来,没验过 | 新建/换 CK 时的默认值 | +| `valid` | 探针通了 | "验证 CK"动作成功 | +| `invalid` | 探针失败(CK 过期/被踢) | "验证 CK"动作失败,`ck_check_message` 记原因 | +| `disabled` | 人工停用(不想再用这个号) | `POST /api/huya/external-orders/{id}/ck/status`(**只能**改成 `disabled`/`unverified`,`valid`/`invalid` 归探针) | + +**与业务状态解耦**:CK 失效**不自动**把业务行改成作废——已经绑定/充值的单子, +CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红色标签),由人决定怎么处理。 + +### 4.3 防漂移(学 `docs/设计-账号全生命周期.md` §3.4.1 的教训) + +- 状态**只有一处写入口**:`services/huya_collab_service.set_status()`, + 接口层不允许直接 UPDATE `status`; +- 每次状态变化必须同时写 `*_at` 时间戳 + `audit_logs`; +- 加一条测试:**任何**状态写入都必须带审计行(漏了就是 bug)。 + +--- + +## 5. CK 的 CRUD 与全生命周期 + +### 5.1 新增 / 导入 + +- 单条新增:表单里贴 CK(支持 `纯CK`、`账号----密码----CK` 两种形态, + 复用 `huya_service.parse_huya_cookie_line`,它是纯函数、不依赖账号表) + → 解析出 `uid/yyuid/hy_username` → 落表,`ck_status=unverified`。 +- 批量导入:Excel/文本粘贴,见 §9.3。逐行解析,**部分失败不整体失败**, + 返回 `{created, updated, skipped, errors[]}`(每条错误的行号 + 原因)。 +- 重复 uid:如果 uid 已在本表 → 视为"换 CK"(更新 cookie 并记审计), + 而不是报错;如果被别的 `order_no` 占用 → 报冲突。 + +### 5.2 有效性探针(怎么判 valid / invalid) + +**2026-10-16 真机定论:用 `check_user_bind_game_account`(只读), +判据 = 响应非 None 且 `status == 200`。** + +| 候选接口 | 有效 CK | 篡改/失效 | 能否当探针 | +| --- | --- | --- | --- | +| `get_redeem_need_goods`(积分中心) | 200 | 单改 `udb_cred`、甚至删光 `udb_` 字段仍 200 | ❌ 不校验凭据(tag7 是公共数据) | +| `get_grow_level_user_info`(能拿虎牙昵称) | 成功 | 单改 `udb_cred` 仍成功 | ❌ 不看凭据 | +| `query_user_score` / `get_act_user_task_detail` / `get_user_prize_records` | — | — | ❌ 接口已下线(603「活动已结束」/ 601「活动已下线」) | +| **`check_user_bind_game_account`** | `status=200` + 真实绑定数据 | **裸 cookie(无凭据)+ 真 uid → `None`;真 CK + 别人 uid → `None`** | ✅ **用它** | + +- 参数走 `web/backend/huya_defaults.py` 的默认活动: + `b_act_id=HUYA_DEFAULT_BIND_ACT_ID(9271)`、`is_use_outer_act_id=1`、 + `outer_act_id=HUYA_DEFAULT_OUTER_ACT_ID(17096)`。 +- 探针是**人工触发**(列表勾选 → "验证 CK"):**串行 + 间隔** + (`PROBE_BATCH_INTERVAL_SECONDS`,一次最多 `PROBE_BATCH_MAX` = 50 条), + 不做后台定时——避免账号被风控盯上。 +- 结果写 `ck_status` / `ck_checked_at` / `ck_check_message`,并记审计(`huya:collab:ck_status`)。 + `disabled` 是人工停用,不刷 `ck_checked_at`(不算"刚跑过探针")。 +- 成功时**覆盖写入绑定快照**:`bind_game_account` / `bind_game_role` / `bind_game_channel` + + `bind_snapshot_at`(这几列探针独占,就是"现在到底绑了哪个号、哪个角色")。 + 覆盖前把旧值记进 `huya:collab:probe_snapshot` 审计;人工填的列(含 `bind_douyu_account`)**一律不碰**。 +- **不写 `nickname`**:按 §3.1 那是"虎牙号昵称",这个探针给不出(能给出昵称的接口都不认凭据) + ——硬写就是假数据。 +- 一次批量里单条失败不影响其他条(AGENTS §2:单项失败不要整体失败,但要如实暴露)。 + 没有 CK 的行**保持 `unverified`**,不谎报成 `invalid`。 + +### 5.3 重建 / 重贴 + +- **重建**:复用 `huya_service.rebuild_web_cookie_from_raw` 的逻辑 + (从 `raw_cookie` 补齐网页设备字段),把结果写回 `cookie`,`ck_status → unverified`; + 没有 `raw_cookie` 时明确报错"该行没有 App 原始凭据"。 +- **重贴**:覆盖 `cookie` + `raw_cookie`,记审计(旧值脱敏后入审计,见 §7.3)。 + +### 5.4 删除 / 作废 + +- `void` 作废:保留行、留 `void_reason`,列表默认过滤掉(可切换显示)。 +- 物理删除:`huya:collab:manage` 才能做,且要**级联清理图片**(§6.4),并记审计。 + +--- + +## 6. 图片(复用已落地的上传能力) + +### 6.1 槽位 + +| 槽位 | 对应 Excel 列 | 字段 | 张数 | +| --- | --- | --- | --- | +| 绑定图 | E | `bind_images` | 多张(第一张作封面) | +| 主页账号图 | F | `home_images` | 多张(第一张作封面) | + +存的是 OSS `object_key` 数组;前端用 `/api/files/object?key=...` 直接 `` 预览。 +**显示时不在列表里下原图**——用 `thumbnail_url`(缩略图变体),点开才看原图。 + +### 6.2 链路 + +``` +前端 antd Upload(上传前 canvas 压到长边 1600) + → POST /api/files/upload(scene=collab,需 file:upload) + → 返回 {object_key, url, thumbnail_url, medium_url} + → 前端把 object_key 提交到 PUT /api/huya/external-orders/{id} + → 本表存 key(不存 URL,URL 由后端拼) +``` + +**为什么存 key 不存 URL**:URL 是可推导的(`/api/files/object?key=...`), +存 URL 会在以后换存储/改路径时全部失效(order_site 踩过"存签名链接过期后到处裂图")。 + +### 6.3 读取权限 + +图片走 `/api/files/object`(需 `file:view`)。本模块的用户默认都授 `file:view` + `file:upload` +(见 §10 待确认 C)。 + +### 6.4 孤儿图清理 + +- 换图时旧 key 从行里移除,但**不立即删对象**(可能有别人在引用/误删难恢复); +- 留一个 `scripts/cleanup_orphan_file_assets.py`:扫 `file_assets` 里 + `scene=collab` 且 **不被任何行引用** 的对象,超过 N 天(默认 30)才删; +- 先用 `--dry-run` 出清单,确认后再删(对齐仓库既有的回填脚本风格)。 + +--- + +## 7. 多人协同机制 + +### 7.1 共享视图 + 负责人 + +- 一张表,**所有登录用户看到同一份数据**(默认不按人过滤); +- 默认排序:`待处理优先,再看 updated_at 倒序`; +- "我的单"是一个筛选器(`owner_id = 我`),不是权限边界。 + +### 7.2 并发保护(解决 Excel 覆盖痛点) + +- `PUT /api/huya/external-orders/{id}` 必须带 `expected_version`; +- 不匹配 → **409**,响应里带"谁在什么时候改过 + 改了哪些字段", + 前端弹"这行刚被 XXX 改过,要看最新还是覆盖保存"; +- 说明:这是乐观锁,不锁行(协作场景冲突少,锁行会让列表变卡)。 +- **为什么用整数 `version` 而不是 `updated_at`**(实现时发现的问题): + 本项目的 `UTCDateTime` 落库是 MySQL `DATETIME`,**只到秒**。同一秒内的两次改动会 + 得到完全相同的 `updated_at`,锁会失效——Excel 那种"后保存的覆盖前一个"会原样复活。 + 整数版本号没有精度问题,且 `version` 与 `updated_at` 各司其职(一个是并发控制, + 一个是显示排序)。 + +### 7.3 审计留痕(字段级 diff) + +复用 `services/audit_service.record_audit`: + +| action | 触发 | detail | +| --- | --- | --- | +| `huya:collab:create` | 新建行 | `{order_no, shop, uid, ck_status}` | +| `huya:collab:update` | 编辑 | **`{字段: {old, new}}`** 只记变化项 | +| `huya:collab:status` | 状态推进 | `{from, to, reason?}` | +| `huya:collab:ck` | 贴/换/重建 CK | `{uid, kind: paste/rebuild/refresh, ck_status}` | +| `huya:collab:image` | 增删图 | `{slot, added, removed}` | +| `huya:collab:delete` | 删除 | `{order_no, uid}` | + +> `record_audit` 已内置敏感字段脱敏(key 里含 `cookie`/`token`/`password` 等会变 `[已脱敏]`), +> 所以 diff 里带 cookie 字段是安全的——**但要断言这一点**(见 §11 S2 验收)。 + +### 7.4 列表上能看到的"协作信号" + +- `updated_by` 的头像/名字 + `updated_at`("最后改动"列); +- 详情抽屉里有时间线(从 `audit_logs` 按 target 查,不另建事件表)。 + +--- + +## 8. 接口设计 + +| 方法 | 路径 | 权限 | 说明 | +| --- | --- | --- | --- | +| GET | `/api/huya/external-orders` | `huya:collab:view` | 列表;筛选 `shop/owner_id/status/ck_status/keyword`,分页,默认排除 `void` | +| GET | `/api/huya/external-orders/summary` | `huya:collab:view` | 各状态计数(页头统计条) | +| POST | `/api/huya/external-orders` | `huya:collab:edit` | 新建(可带 CK 文本) | +| GET | `/api/huya/external-orders/{id}` | `huya:collab:view` | 详情(含图片 key、审计时间线) | +| PUT | `/api/huya/external-orders/{id}` | `huya:collab:edit` | 编辑(带 `expected_version`) | +| POST | `/api/huya/external-orders/{id}/status` | `huya:collab:edit` | 推进/回退状态 | +| POST | `/api/huya/external-orders/{id}/ck` | `huya:collab:edit` | 贴/换/重建 CK | +| POST | `/api/huya/external-orders/{id}/ck/status` | `huya:collab:edit` | 人工停用/恢复(只能 `disabled`/`unverified`) | +| POST | `/api/huya/external-orders/{id}/verify` | `huya:collab:edit` | 验证这一行的 CK(只读探针,§5.2) | +| POST | `/api/huya/external-orders/ck/verify` | `huya:collab:edit` | 批量验证 CK(探针,body `{ids: [...]}`,最多 50 条) | +| POST | `/api/huya/external-orders/import` | `huya:collab:edit` | 粘贴批量导入 | +| DELETE | `/api/huya/external-orders/{id}` | `huya:collab:manage` | 删除(级联清图) | +| GET | `/api/huya/external-orders/owners` | `huya:collab:view` | 可选负责人列表(给筛选/下拉用) | + +**注意**:图片上传不新增接口,直接用已有的 `POST /api/files/upload`。 + +--- + +## 9. 页面设计 + +新菜单:**虎牙 → 外部单协作**(`/huya/collab`),放在"精英宝典"之后。 + +### 9.1 列表 + +列(对齐 Excel,去掉它没有的信息): + +``` +店铺 | 订单编号 | 负责人 | 绑定账号/角色 | 绑定图·主页图 | 礼包/备注 | 状态 | 虎牙号(uid) | CK状态 | 最后改动 | 操作 +``` + +> 落地(2026-10-16):**「绑定斗鱼号」不再作为列表主列** —— 用户明确"绑定在该虎牙号上的 +> 游戏账号需要展示,绑定角色重要"。列表主展示探针读到的 `bind_game_account` + `bind_game_role` +> (附平台/区服);人工的 `bind_douyu_account` 只在详情抽屉里作为可编辑字段保留(Excel 导入列)。 + +- **内联编辑**:店铺 / 负责人 / 礼包名 / 备注(点开即改,失焦提交,走 §7.2 乐观锁); +- **图片列**:缩略图 + 张数,点击开大图(`Image.PreviewGroup`); +- **状态**:Tag 展示,行内"下一步"按钮推进(带二次确认); +- **CK 状态**:Tag + 一键"验证"; +- 筛选:店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、斗鱼号、uid); +- 页头统计条:`待处理 N / 已绑定 N / 已充值 N / 已上脚本 N / 已完成 N / CK失效 N`。 + +### 9.2 详情抽屉 + +左侧:图片(两个槽位,可上传/删除/换序); +右侧:字段编辑 + 操作按钮(验证 CK / 重建 CK / 推进状态 / 作废 / 删除); +底部:审计时间线(谁在什么时候改了什么)。 + +### 9.3 批量导入弹窗 + +- 大文本框贴 Excel 区域(TSV,`\t` 分隔,直接 Ctrl+V 即可); +- 自动识别表头(支持中英列名,也支持"无表头按固定列序"); +- 预览解析结果(识别出 N 行,其中 M 行缺订单号/重复)→ 确认后提交; +- 返回 `{created, updated, skipped, errors[]}`,错误按行号展示。 + +--- + +## 10. 已定决策(2026-10-16 评审通过) + +### 10.1 结构决策(用户拍板) + +| # | 问题 | 决定 | +| --- | --- | --- | +| 1 | 一行(一单)跟虎牙 CK 的基数关系 | **1 单 = 1 个 CK**(`uid` 在台账内唯一) | +| 2 | 台账与 `huya_accounts` 的关系 | **完全独立一套表,自己存 CK,不碰现有账号体系** | +| 3 | 店铺 / 订单编号来源 | **全部人工录入 + 支持 Excel 粘贴批量导入** | +| 4 | 协作范围与权限 | **所有登录用户共享同一张表,都能编辑**(靠审计留痕) | +| 5 | 权限点分配 | `view` + `edit` 给 operation 和 support;`manage`(删除)**只给 operation** | +| 6 | 图片槽位张数 | **允许多张,第一张作封面** | +| 7 | 绑定/充值/上脚本在哪执行 | **本台账只记录**;以后再加「一键送进工作台」(届时才需要建 `HuyaAccount`) | + +### 10.2 我按推荐值定的(未被反对即生效,随时可改) + +| # | 项 | 取的值 | +| --- | --- | --- | +| B | 列表默认是否隐藏 `void` | 隐藏,提供"显示已作废"开关 | +| E | `order_no` 唯一性 | 唯一;重复导入按"更新"处理 | +| F | 积分/宝典状态只读查询 | 需要,人工触发,写本表 `points`/`elite_opened` | +| G | 负责人来源 | 全部活跃用户(`users.is_active = 1`) | +| — | `uid` 可空 | 可空(唯一索引允许多 NULL)→ 支持"先建单、后贴 CK" | +| — | **绑定对象的展示口径**(2026-10-16 用户拍板) | 展示**探针读到的游戏账号 + 绑定角色**(角色最重要);人工的"绑定斗鱼号"不再作列表主列,只留详情抽屉(Excel 导入列) | +| — | 人工能否改 CK 状态 | 只能"停用"/"恢复(未验证)";`valid`/`invalid` 归探针 | + +> 决定 7 的后果:**本模块不 import 任何 `huya_tasks` / runner 代码**。 +> 以后要做「一键送进工作台」,那是一个**显式的**新动作(会创建 `HuyaAccount` 并写 +> 一条 `huya_workbench_accounts`),不会在本阶段悄悄发生。 + +### 10.3 待定:网页 CK 能不能「重建」 + +> **状态(2026-10-16)**:用户当天的关注点是"绑定账号/角色要展示"(已按 §10.2 落地), +> 本项**暂按 A 执行**:网页形态 CK 的行「重建」按钮置灰并提示"这行没有 App 原始凭据"。 +> 以后要放宽成 B,只需改 `services/huya_collab_service._apply_ck()` 一处(把两种形态都写进 +> `raw_cookie`)—— 但要注意:对一份正在正常使用的网页 CK 按「重建」会换掉设备指纹。 + +用一份真实外部 CK 验证后发现:**真机拿到的是网页形态 CK**(含 `guid` / `huya_ua` / +`_qimei_uuid42` 等网页设备字段,`udb_cred` 有 152 字符)。而当前实现沿用了 +`huya_accounts` 的语义 —— `raw_cookie` 只存 **App 原始凭据**(没有网页设备字段的那些), +所以: + +| 贴进来的形态 | `cookie` | `raw_cookie` | 「重建」可用 | +| --- | --- | --- | --- | +| App 原始凭据(无 guid) | ✅ | ✅ 同值 | ✅ | +| 网页 CK(有 guid,**真机实测就是这种**) | ✅ | ❌ 空 | ❌ 按钮不可用 | + +两种改法,选一个: + +- **A(当前实现,保守)**:维持现状。网页 CK 的行「重建」不可用,按钮置灰并提示 + "这行没有 App 原始凭据,请重新贴一份"。好处:语义与账号侧一致,不会拿一份好 CK + 去换设备态。坏处:对真机数据来说这个功能基本用不上。 +- **B(放宽语义)**:`raw_cookie` 改成"**导入时收到的 CK 原文**",`cookie` 是"当前运行态"。 + 贴任何形态都两者同写,「重建」= 把原文再过一遍 `fill_web_cookie_fields` 刷新设备态 + (这正是账号侧被风控后换设备指纹的做法)。好处:所有行都能重建。坏处:字段语义与 + `huya_accounts.raw_cookie` 不再一致;对一份**正在正常使用**的网页 CK 按一下重建, + 会把 guid 换成新的(虽然会置为"未验证",但仍算改动了可用凭据)。 + +--- + +## 11. 施工单(建议按阶段,每阶段可独立验收) + +| 阶段 | 内容 | 状态 | +| --- | --- | --- | +| **S1** | 模型 `HuyaExternalOrder` + 迁移 `20261016_0055` + `services/huya_collab_service.py`(CK 解析/去重/CRUD) | ✅ 2026-10-16 | +| **S2** | 两套状态机 + 字段级审计 + 乐观锁(`version`) | ✅ 2026-10-16 | +| **S3** | 图片槽位读写(存 `object_key`,URL 由后端拼) | ✅ 2026-10-16 | +| **S4** | 批量导入(TSV 表头识别 + SAVEPOINT 单行隔离 + 部分失败) | ✅ 2026-10-16 | +| **S5** | CK 探针验证(只读 `check_user_bind_game_account`,真机判据已定) | ✅ 2026-10-16 | +| **S6** | 前端页面(列表 + 内联编辑 + 图片 + 详情抽屉 + 导入弹窗) | ✅ 2026-10-16 | +| **S7** | 权限点接入(`huya:collab:view/edit/manage`) | ✅ 2026-10-16 | + +测试:`tests/test_huya_collab_orders.py`(68 例,其中探针 21 例,用假客户端离线跑)+ +`tests/test_cleanup_orphan_file_assets.py`(6 例,孤儿图清理脚本)。 +用例里的 CK 是**合成**的,形态对齐真机(分号后无空格、重复 `guid`、超长 `udb_biztoken`)—— +真实凭据绝不进仓库,一旦提交就永久留在 git 历史里。 + +孤儿图清理:`scripts/cleanup_orphan_file_assets.py`(默认 `--dry-run`,见 §6.4)。 + +### 11.1 S1–S4 落地时发现的、设计文档原稿没写到的点 + +1. **乐观锁从 `updated_at` 改成整数 `version`**(原因见 §7.2); +2. **向导批次导入必须用 SAVEPOINT**:`SessionLocal` 是 `autoflush=False`,且一次约束 + 冲突会把会话打成失败态(`PendingRollbackError`),后面每一行都会跟着报错—— + 不加 SAVEPOINT,"单行失败不整体失败"这条就是空话。反向验证已证实(去掉 + SAVEPOINT 后第二行直接挂掉); +3. **审计里不传 cookie 字段**:`record_audit` 虽有脱敏,但凭据不该多经一手 + (脱敏规则一改就是泄漏)。只记 `uid` 与 `credential_changed` 布尔; +4. **真机 CK 是「网页形态」**(含 `guid`/`huya_ua`/`_qimei_uuid42`),按当前实现 + `raw_cookie` 会留空 → 这些行的「重建」按钮不可用。是否要让网页 CK 也能重建, + 见 §10.3。 + +**先做 S1–S4(纯后端,可测),S5 需要真机,S6 前端单独一轮。** + +### 11.2 S5(探针)落地时发现的点 + +1. **探针判据必须是「非 None 且 `status == 200`」**:真机上「裸 cookie + 真 uid」与 + 「真 CK + 别人 uid」都返回 `None`,只有两者都对才拿到 200——这是唯一能区分有效/失效的 + 信号组合。反向验证(把判据改成「有数据就算成功」)会让 3 个用例变红。 +2. **探针能补的字段只有 `bind_douyu_account` / `bind_douyu_uid`**:返回的是**绑定在该虎牙号 + 上的游戏账号**(账号昵称 / 角色名 / 角色 id)。`nickname` 按 §3.1 是"虎牙号昵称", + 本探针给不出(能给出昵称的 `get_grow_level_user_info` 不校验凭据,当不了探针), + **不能拿游戏账号昵称冒充**。原稿 §5.2 写的"顺带补 nickname"据此修正。 +3. **绑定快照由探针独占(2026-10-16 当天修订)**:最早实现是"只补空值写 + `bind_douyu_account`",用户明确要求"展示绑定在该虎牙号上的游戏账号、**绑定角色重要**、 + 之前那套不要了"之后,改成探针**覆盖写入** `bind_game_account`/`bind_game_role`/ + `bind_game_channel`(+ `bind_snapshot_at`,见 §3.1 与迁移 `20261016_0056`), + 覆盖前把旧值记进 `huya:collab:probe_snapshot` 审计;人工列一个都不碰。 + 反向验证(去掉"只补空值"、判据改成"有数据就算成功")会让对应用例变红。 +4. **没有 CK 的行不能被标成 `invalid`**:那不是"凭据失效",是"还没有凭据",保持 + `unverified` 并在 `results[].message` 里说明;批量里这类行不发请求。 +5. **`/ck/verify` 的字面量路径要声明在 `/{order_id}` 之前**(同 §8 的注意点), + 否则可能被 `/{order_id}/timeline` 之类的模式先匹配。 + +### 11.3 S6(前端)落地的点 + +1. **页面**:`web/frontend/src/pages/HuyaCollabPage.tsx`(菜单「虎牙 → 外部单协作」, + 路由 `/huya/collab`,`MainLayout.tsx` 的 `huyaItems`);表格内联编辑、图片缩略图、 + 详情抽屉(字段表单 + 两个图片槽位 + 操作时间线 + 状态操作 + 贴/换/重建/停用 CK)、 + 粘贴导入弹窗(带本地预览)、页头统计条、筛选条、批量验证 CK。 +2. **菜单不受 `huya:task` 分支影响**:原布局里虎牙分组对客服只显示"浏览器兑换", + 而协作台账 `huya:collab:view` 是给客服的 —— 用**独立判断**追加菜单项,否则客服看不到这张表。 +3. **列表主列 = 探针读到的绑定账号 + 绑定角色**(§9.1 的落地说明);人工的 + `bind_douyu_account` 只留在详情抽屉,避免两处「绑定」互相矛盾的观感。 +4. **新增接口 `POST /{id}/ck/status`**:前端要能"停用这份 CK"(不想再用这个号)。 + 服务层 `set_manual_ck_status()` 只允许 `disabled` / `unverified` —— + 人工把 CK 标成"有效"等于绕过探针,必须挡住。 +5. **乐观锁 409 的处理**:前端把 `expected_version` 原样带回,冲突时提示并自动刷新列表, + 不做"覆盖保存"(那正是 Excel 的毛病)。 +6. **图片上传**:`web/frontend/src/api/files.ts` 的 `fileApi.upload(file, 'collab')` → + 拿 `object_key` 提交给 `/{id}/images`;图片一律走 `/api/files/object` 代理回读。 +7. 迁移 `20261016_0056` 只加列(`bind_game_*` + `bind_snapshot_at`),老列一动不动; + 应用启动时会自动 `upgrade head`,本地不用手动跑。 + +--- + +## 12. 风险 + +| 风险 | 应对 | +| --- | --- | +| 与 `huya_accounts` 出现"两份 CK 谁是准的" | §2.3 已写死:本表 CK 只在协作语境有效,不自动同步;需要跑任务时人工导入 | +| 探针接口被风控 | 只做人工触发、串行、带间隔;不做定时轮询 | +| 自由文本的"绑定斗鱼号"没法校验 | 先按文本存(Excel 里就是文本);能拿到结构化 uid 时补 `bind_douyu_uid` | +| 图片越攒越多 | §6.4 孤儿清理脚本 + `file_assets` 台账已有 `size_bytes` 可统计 | +| 分支 | 按 AGENTS §1,新功能应从 `main` 开 `feat/*`;开工前需确认当前分支与库迁移版本对齐 | diff --git a/pyproject.toml b/pyproject.toml index 6a0d5b7..902eedc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,9 @@ dependencies = [ "psycopg[binary]>=3.2,<4", "curl-cffi>=0.13", "segno>=1.6", + # 在线协作的图片上传:走 S3 兼容接口写阿里云 OSS(内网 endpoint)。 + # 只在 STORAGE_MODE=oss 时才导入,本地开发/测试用本地目录后端,不装也能跑。 + "boto3>=1.34", ] [build-system] diff --git a/scripts/cleanup_orphan_file_assets.py b/scripts/cleanup_orphan_file_assets.py new file mode 100755 index 0000000..6bad57b --- /dev/null +++ b/scripts/cleanup_orphan_file_assets.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python +"""清理协作台账的孤儿图(`scene=collab` 且已经没有任何一行在引用)。 + +## 为什么需要它 + +换图时旧 key 只是从行里移除,**不立即删对象**(可能有人正在看、误删不可恢复,设计文档 §6.4)。 +攒久了对象存储里全是没人引用的图,所以补一个**人工跑**的清理脚本: + +- 只扫 `file_assets.scene = 'collab'`(协作台账专用的 scene,不碰别的业务); +- 只删**不被任何 `huya_external_orders` 行引用**的对象(bind_images / home_images 两个槽位); +- 只删 `created_at` 早于 N 天(默认 30)的(给"刚上传还没提交"的图留足时间); +- **默认 `--dry-run`**,先出清单,确认后再 `--apply`。 + +## 用法 + +```bash +# 1) 先看会删什么(不碰库、不碰对象存储) +python scripts/cleanup_orphan_file_assets.py --dry-run + +# 2) 真删(务必先备份库;对象删了不可恢复) +python scripts/cleanup_orphan_file_assets.py --apply +python scripts/cleanup_orphan_file_assets.py --apply --days 60 +``` + +## 安全约束(别删掉正在用的图) + +1. **删对象成功才删台账行**:对象存储报错时保留 `file_assets` 行,下次还能再清一遍; + 反过来先删行再删对象,一旦对象没删掉就真的没人知道它存在了。 +2. 变体(thumb / medium)按上传时记录在台账里的 key 删,不靠猜扩展名。 +3. 引用判断读的是**当前库里**的行;脚本运行期间有人在编辑 → 可能误判,所以默认留 30 天, + 并要求先 `--dry-run`。 +""" + +from __future__ import annotations + +import argparse +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from sqlalchemy.orm import Session + +from web.backend.database import SessionLocal +from web.backend.models import FileAsset, HuyaExternalOrder + +# 协作台账的 scene(web/backend/services/file_service.normalize_scene 会把 scene 转小写)。 +COLLAB_SCENE = "collab" +DEFAULT_RETENTION_DAYS = 30 + + +def referenced_keys(db: Session) -> set[str]: + """当前被协作台账行引用的 object_key(两个图片槽位)。""" + keys: set[str] = set() + rows = db.query(HuyaExternalOrder.bind_images, HuyaExternalOrder.home_images).all() + for bind_images, home_images in rows: + for value in (bind_images, home_images): + if isinstance(value, list): + keys.update(str(item) for item in value if item) + return keys + + +def find_orphans( + db: Session, + *, + days: int = DEFAULT_RETENTION_DAYS, + scene: str = COLLAB_SCENE, + now: datetime | None = None, +) -> list[FileAsset]: + """找出「超过保留期 + 没人引用」的图(按最老的排前面)。""" + cutoff = (now or datetime.now(UTC)) - timedelta(days=max(0, int(days))) + referenced = referenced_keys(db) + candidates = ( + db.query(FileAsset) + .filter( + FileAsset.scene == scene, + FileAsset.status == "active", + FileAsset.created_at < cutoff, + ) + .order_by(FileAsset.created_at.asc(), FileAsset.id.asc()) + .all() + ) + return [asset for asset in candidates if asset.object_key not in referenced] + + +def _keys_of(asset: FileAsset) -> list[str]: + """原图 + 两个变体的 key(变体为空说明当时生成失败)。""" + return [ + key + for key in ( + asset.object_key, + asset.thumbnail_object_key, + asset.medium_object_key, + ) + if key + ] + + +def _format_size(size: int) -> str: + value = float(size or 0) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024 or unit == "GB": + return f"{value:.1f}{unit}" + value /= 1024 + return f"{value:.1f}GB" + + +def run(db: Session, *, apply: bool, days: int = DEFAULT_RETENTION_DAYS) -> dict: + """列出/清理孤儿图,返回统计(`--dry-run` 什么都不写)。""" + orphans = find_orphans(db, days=days) + storage = None + if apply and orphans: + from web.backend.services.file_storage import get_object_storage + + storage = get_object_storage() + + deleted = 0 + failed = 0 + freed = 0 + for asset in orphans: + created = ( + asset.created_at.strftime("%Y-%m-%d %H:%M") if asset.created_at else "-" + ) + prefix = "[DRY-RUN] " if not apply else "" + print( + f"{prefix}id={asset.id} {asset.object_key} " + f"{_format_size(asset.size_bytes)} {created} {asset.original_filename or '-'}" + ) + if not apply: + freed += int(asset.size_bytes or 0) + continue + try: + for key in _keys_of(asset): + storage.delete_object(key) + except Exception as exc: # noqa: BLE001 删对象失败就留台账行,下次再清 + failed += 1 + print(f" !! 删除对象失败,保留台账行:{type(exc).__name__}: {exc}") + continue + db.delete(asset) + deleted += 1 + freed += int(asset.size_bytes or 0) + + if apply: + db.commit() + + return { + "scene": COLLAB_SCENE, + "days": days, + "candidates": len(orphans), + "deleted": deleted, + "failed": failed, + "freed_bytes": freed, + "applied": apply, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="清理协作台账的孤儿图(scene=collab 且没人引用)" + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--dry-run", action="store_true", help="只列清单,不写库") + group.add_argument("--apply", action="store_true", help="真删(先备份库!)") + parser.add_argument( + "--days", + type=int, + default=DEFAULT_RETENTION_DAYS, + help=f"保留天数,超过才删(默认 {DEFAULT_RETENTION_DAYS})", + ) + args = parser.parse_args() + + db = SessionLocal() + try: + result = run(db, apply=args.apply, days=args.days) + prefix = "[DRY-RUN] " if not args.apply else "" + print( + f"{prefix}scene={result['scene']} 保留 {result['days']} 天:" + f"候选 {result['candidates']} 张 / 已删 {result['deleted']} 张" + f" / 失败 {result['failed']} 张 / 释放 {_format_size(result['freed_bytes'])}" + ) + if not args.apply and result["candidates"]: + print("[DRY-RUN] 确认无误后再跑 --apply(对象删了不可恢复)") + return 1 if result["failed"] else 0 + finally: + db.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cleanup_orphan_file_assets.py b/tests/test_cleanup_orphan_file_assets.py new file mode 100644 index 0000000..6d121a5 --- /dev/null +++ b/tests/test_cleanup_orphan_file_assets.py @@ -0,0 +1,180 @@ +"""协作台账孤儿图清理脚本:只删「过期 + 没人引用」的图,且删对象成功才删台账行。 + +脚本本体在 ``scripts/cleanup_orphan_file_assets.py``(一次性运维脚本,不入包), +所以这里用 importlib 按路径加载。 +""" + +from __future__ import annotations + +import importlib.util +from datetime import UTC, datetime, timedelta +from pathlib import Path +from types import ModuleType + +import pytest + +from web.backend.models import FileAsset, HuyaExternalOrder +from web.backend.services import file_storage + +_SCRIPT_PATH = ( + Path(__file__).resolve().parents[1] / "scripts" / "cleanup_orphan_file_assets.py" +) + + +def load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location( + "cleanup_orphan_file_assets", _SCRIPT_PATH + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def cleanup_script() -> ModuleType: + return load_script() + + +class FakeStorage: + """记录被删的 key;``fail_keys`` 里的 key 删除时抛错。""" + + def __init__(self, fail_keys: set[str] | None = None): + self.deleted: list[str] = [] + self.fail_keys = fail_keys or set() + + def delete_object(self, key: str) -> None: + if key in self.fail_keys: + raise RuntimeError(f"删不掉 {key}") + self.deleted.append(key) + + +def make_asset( + db, + key: str, + *, + scene: str = "collab", + days_old: int = 40, + size: int = 1024, + status: str = "active", + thumb: str = "", + medium: str = "", +) -> FileAsset: + asset = FileAsset( + object_key=key, + scene=scene, + size_bytes=size, + original_filename=key.rsplit("/", 1)[-1], + thumbnail_object_key=thumb, + medium_object_key=medium, + status=status, + created_at=datetime.now(UTC) - timedelta(days=days_old), + ) + db.add(asset) + db.commit() + return asset + + +def make_order(db, order_no: str, keys: list[str]) -> HuyaExternalOrder: + order = HuyaExternalOrder( + order_no=order_no, + shop="佳佳", + bind_images=list(keys), + home_images=[], + status="pending", + ck_status="unverified", + version=0, + ) + db.add(order) + db.commit() + return order + + +def test_find_orphans_skips_referenced_and_fresh_assets(db, cleanup_script): + referenced = make_asset( + db, "collab/2026/09/01/ref.png", thumb="collab/2026/09/01/ref.thumb.jpg" + ) + make_order(db, "A1", [referenced.object_key]) + make_asset(db, "collab/2026/09/02/fresh.png", days_old=3) + orphan = make_asset(db, "collab/2026/09/03/orphan.png") + + orphans = cleanup_script.find_orphans(db, days=30) + + assert [asset.object_key for asset in orphans] == [orphan.object_key] + + +def test_find_orphans_ignores_other_scenes_and_inactive_rows(db, cleanup_script): + make_asset(db, "misc/2026/09/01/other.png", scene="misc") + make_asset(db, "collab/2026/09/01/gone.png", status="deleted") + + assert cleanup_script.find_orphans(db, days=30) == [] + + +def test_dry_run_writes_nothing(db, cleanup_script): + orphan = make_asset(db, "collab/2026/09/01/orphan.png") + storage = FakeStorage() + + result = cleanup_script.run(db, apply=False, days=30) + + assert result["candidates"] == 1 + assert result["deleted"] == 0 + assert result["freed_bytes"] == 1024 + assert storage.deleted == [] + assert db.query(FileAsset).filter(FileAsset.id == orphan.id).count() == 1 + + +def test_apply_deletes_original_and_variants_then_the_row( + db, cleanup_script, monkeypatch +): + make_asset( + db, + "collab/2026/09/01/orphan.png", + thumb="collab/2026/09/01/orphan.thumb.webp", + medium="collab/2026/09/01/orphan.medium.jpg", + ) + storage = FakeStorage() + monkeypatch.setattr( + file_storage, "get_object_storage", lambda settings=None: storage + ) + + result = cleanup_script.run(db, apply=True, days=30) + + assert result["deleted"] == 1 + assert result["failed"] == 0 + assert storage.deleted == [ + "collab/2026/09/01/orphan.png", + "collab/2026/09/01/orphan.thumb.webp", + "collab/2026/09/01/orphan.medium.jpg", + ] + assert db.query(FileAsset).count() == 0 + + +def test_failed_object_delete_keeps_the_ledger_row(db, cleanup_script, monkeypatch): + """对象没删掉就绝不能删台账行 —— 否则再也没人知道这个对象存在。""" + orphan = make_asset(db, "collab/2026/09/01/orphan.png") + storage = FakeStorage(fail_keys={orphan.object_key}) + monkeypatch.setattr( + file_storage, "get_object_storage", lambda settings=None: storage + ) + + result = cleanup_script.run(db, apply=True, days=30) + + assert result["deleted"] == 0 + assert result["failed"] == 1 + assert db.query(FileAsset).filter(FileAsset.id == orphan.id).count() == 1 + + +def test_referenced_asset_survives_a_full_apply(db, cleanup_script, monkeypatch): + referenced = make_asset(db, "collab/2026/09/01/ref.png") + make_order(db, "A1", [referenced.object_key]) + make_asset(db, "collab/2026/09/01/orphan.png") + storage = FakeStorage() + monkeypatch.setattr( + file_storage, "get_object_storage", lambda settings=None: storage + ) + + cleanup_script.run(db, apply=True, days=30) + + remaining = [asset.object_key for asset in db.query(FileAsset).all()] + assert remaining == [referenced.object_key] + assert storage.deleted == ["collab/2026/09/01/orphan.png"] diff --git a/tests/test_file_upload.py b/tests/test_file_upload.py new file mode 100644 index 0000000..d2675ab --- /dev/null +++ b/tests/test_file_upload.py @@ -0,0 +1,409 @@ +"""在线协作图片上传:存储层 + 服务层的行为契约。 + +只跑本地目录后端(``OSS_MODE=local`` + 临时目录),不依赖 OSS。 +覆盖:魔数嗅探、scene 归一化、object key 布局、变体生成、落库、 +超限/类型拒绝、失败补偿删除、变体缺失回退原图、key 路径穿越。 +""" + +from __future__ import annotations + +import io +import re +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit + +import pytest +from PIL import Image + +from tests.support.db import make_test_session +from web.backend.models import FileAsset +from web.backend.services.file_service import ( + FileUploadError, + build_object_key, + generate_image_variants, + get_stored_object, + image_variant_fallback_keys, + image_variant_key, + normalize_scene, + resolve_content_type, + sniff_content_type, + upload_file_asset, +) +from web.backend.services.file_storage import ( + S3ObjectStorage, + StorageUnavailableError, + get_object_storage, + load_storage_settings, + reset_object_storage_cache, +) + + +@pytest.fixture +def local_storage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """把对象存储指到临时目录,并在用例结束后清掉单例缓存。""" + monkeypatch.setenv("OSS_MODE", "local") + monkeypatch.setenv("OSS_LOCAL_ROOT", str(tmp_path)) + monkeypatch.setenv("OSS_MAX_UPLOAD_SIZE_MB", "10") + reset_object_storage_cache() + yield tmp_path + reset_object_storage_cache() + + +def _png_bytes(width: int = 1200, height: int = 800) -> bytes: + buffer = io.BytesIO() + Image.new("RGB", (width, height), (30, 90, 200)).save(buffer, "PNG") + return buffer.getvalue() + + +def _jpeg_bytes(width: int = 1600, height: int = 900) -> bytes: + buffer = io.BytesIO() + Image.new("RGB", (width, height), (200, 90, 30)).save(buffer, "JPEG") + return buffer.getvalue() + + +class _FailingSession: + """commit 必炸的假会话,用来验证对象补偿删除。""" + + def __init__(self) -> None: + self.added: list[Any] = [] + self.rolled_back = False + + def add(self, obj: Any) -> None: + self.added.append(obj) + + def commit(self) -> None: + raise RuntimeError("database is down") + + def rollback(self) -> None: + self.rolled_back = True + + +class TestContentTypeAndScene: + def test_sniff_content_type_by_magic_bytes(self): + assert sniff_content_type(_png_bytes(4, 4)) == "image/png" + assert sniff_content_type(_jpeg_bytes(4, 4)) == "image/jpeg" + assert sniff_content_type(b"%PDF-1.7\n") == "application/pdf" + assert sniff_content_type(b"hello world") == "" + + def test_magic_bytes_win_over_lying_content_type(self): + """前端声称是 png、实际是 jpeg 时,以文件头为准。""" + assert resolve_content_type("image/png", _jpeg_bytes(4, 4)) == "image/jpeg" + # 识别不出来时才回退到调用方给的 Content-Type。 + assert resolve_content_type("application/pdf", b"not-an-image") == ( + "application/pdf" + ) + + def test_normalize_scene_keeps_only_safe_chars(self): + assert normalize_scene("Collab Image") == "collab-image" + assert normalize_scene("../../etc/passwd") == "etc-passwd" + assert normalize_scene("") == "misc" + assert normalize_scene("协作图片") == "misc" + + def test_object_key_layout_is_unpredictable_and_date_partitioned(self): + key = build_object_key("collab", "image/png", "screenshot.png") + assert re.fullmatch(r"collab/\d{4}/\d{2}/\d{2}/[0-9a-f]{24}\.png", key) + assert build_object_key("collab", "image/png", "a.png") != key + + +class TestImageVariants: + def test_png_source_keeps_alpha_and_outputs_webp(self): + variants = generate_image_variants(_png_bytes(), "image/png") + names = {variant.name: variant for variant in variants} + assert set(names) == {"thumb", "medium"} + for variant in names.values(): + assert variant.content_type == "image/webp" + with Image.open(io.BytesIO(variant.content)) as image: + assert max(image.size) <= (480 if variant.name == "thumb" else 1280) + + def test_jpeg_source_outputs_jpeg_and_shrinks_to_max_side(self): + variants = generate_image_variants(_jpeg_bytes(1600, 900), "image/jpeg") + thumb = next(item for item in variants if item.name == "thumb") + assert thumb.content_type == "image/jpeg" + with Image.open(io.BytesIO(thumb.content)) as image: + assert image.size == (480, 270) + + def test_pdf_has_no_variants_and_broken_image_is_skipped(self): + assert generate_image_variants(b"%PDF-1.7\n", "application/pdf") == [] + assert generate_image_variants(b"\x89PNG\r\n\x1a\nbroken", "image/png") == [] + + def test_variant_fallback_candidates_end_with_original(self): + candidates = image_variant_fallback_keys("collab/x.png", "thumb") + assert candidates == [ + "collab/x.thumb.webp", + "collab/x.thumb.jpg", + "collab/x.thumb.png", + "collab/x.png", + ] + # 无关的 variant 名不做候选展开。 + assert image_variant_fallback_keys("collab/x.png", "huge") == ["collab/x.png"] + assert image_variant_key("collab/x.png", "medium", ".jpg") == ( + "collab/x.medium.jpg" + ) + + +class TestUploadFileAsset: + def test_upload_writes_objects_and_asset_row(self, local_storage: Path): + session = make_test_session() + try: + result = upload_file_asset( + session, + data=_png_bytes(1200, 800), + filename="截图.png", + content_type="image/png", + scene="collab", + uploader_type="user", + uploader_id="7", + ) + finally: + session.close() + + assert result.content_type == "image/png" + assert result.object_key.startswith("collab/") + # key 里的斜杠按两个参考实现的做法转义成 %2F,回读时能原样还原。 + assert "collab%2F" in result.url + assert parse_qs(urlsplit(result.url).query)["key"][0] == result.object_key + # 与两个参考实现一致:变体 URL 直接指向变体对象,不再带 variant 参数。 + thumb_key = parse_qs(urlsplit(result.thumbnail_url).query)["key"][0] + medium_key = parse_qs(urlsplit(result.medium_url).query)["key"][0] + assert thumb_key == f"{result.object_key[:-4]}.thumb.webp" + assert medium_key == f"{result.object_key[:-4]}.medium.webp" + + # 原图 + 两个变体都真的落到对象存储里了。 + stored = sorted( + path.name for path in local_storage.rglob("*") if path.is_file() + ) + assert len(stored) == 3 + assert any(name.endswith(".thumb.webp") for name in stored) + assert any(name.endswith(".medium.webp") for name in stored) + + session = make_test_session() + try: + row = session.query(FileAsset).one() + assert row.object_key == result.object_key + assert row.scene == "collab" + assert row.uploader_id == "7" + assert row.size_bytes == len(_png_bytes(1200, 800)) + assert row.thumbnail_object_key.endswith(".thumb.webp") + assert row.medium_object_key.endswith(".medium.webp") + assert row.status == "active" + finally: + session.close() + + def test_pdf_upload_has_no_variants_and_falls_back_to_original( + self, local_storage: Path + ): + session = make_test_session() + try: + result = upload_file_asset( + session, + data=b"%PDF-1.7\nfake", + filename="材料.pdf", + content_type="application/pdf", + scene="collab", + ) + finally: + session.close() + + assert result.thumbnail_url == result.url + stored = [path.name for path in local_storage.rglob("*") if path.is_file()] + assert len(stored) == 1 + + # 请求 thumb 但变体不存在时,回读回退到原图。 + obj = get_stored_object(result.object_key, "thumb") + try: + assert obj.reader.read() == b"%PDF-1.7\nfake" + finally: + obj.reader.close() + + def test_upload_rejects_oversize_file(self, tmp_path: Path, monkeypatch): + monkeypatch.setenv("OSS_MODE", "local") + monkeypatch.setenv("OSS_LOCAL_ROOT", str(tmp_path)) + monkeypatch.setenv("OSS_MAX_UPLOAD_SIZE_MB", "1") + reset_object_storage_cache() + session = make_test_session() + try: + with pytest.raises(FileUploadError) as excinfo: + upload_file_asset( + session, data=b"x" * (1024 * 1024 + 1), filename="big.png" + ) + finally: + session.close() + reset_object_storage_cache() + + assert excinfo.value.status_code == 400 + assert excinfo.value.code == "file_too_large" + + def test_upload_rejects_unsupported_type(self, local_storage: Path): + session = make_test_session() + try: + with pytest.raises(FileUploadError) as excinfo: + upload_file_asset(session, data=b"#!/bin/sh\necho hi", filename="x.sh") + finally: + session.close() + assert excinfo.value.code == "file_type_invalid" + assert list(local_storage.rglob("*")) == [] + + def test_upload_rejects_empty_file(self, local_storage: Path): + session = make_test_session() + try: + with pytest.raises(FileUploadError) as excinfo: + upload_file_asset(session, data=b"", filename="empty.png") + finally: + session.close() + assert excinfo.value.code == "file_required" + + def test_upload_removes_objects_when_db_write_fails(self, local_storage: Path): + """写库失败必须把已写入的对象删掉,不能留下孤儿对象。""" + failing = _FailingSession() + with pytest.raises(RuntimeError, match="database is down"): + upload_file_asset( + failing, + data=_png_bytes(600, 400), + filename="shot.png", + content_type="image/png", + scene="collab", + ) + + assert failing.rolled_back is True + leftovers = [path for path in local_storage.rglob("*") if path.is_file()] + assert leftovers == [], f"补偿删除没清干净: {leftovers}" + + +class TestStorageSettings: + def test_file_asset_out_serializes_every_field(self): + """坑 #2:Pydantic 会静默丢字段 —— 断言序列化结果而不是属性。""" + from web.backend.schemas import FileAssetOut + + payload = FileAssetOut( + object_key="collab/2026/10/16/abc.png", + url="/api/files/object?key=collab%2F2026%2F10%2F16%2Fabc.png", + thumbnail_url="/api/files/object?key=collab%2F2026%2F10%2F16%2Fabc.thumb.webp", + medium_url="/api/files/object?key=collab%2F2026%2F10%2F16%2Fabc.medium.webp", + filename="截图.png", + content_type="image/png", + size=123, + ) + assert set(payload.model_dump()) == { + "object_key", + "url", + "thumbnail_url", + "medium_url", + "filename", + "content_type", + "size", + } + + def test_defaults_to_local_when_nothing_is_configured(self, monkeypatch): + for name in ( + "OSS_MODE", + "OSS_ENDPOINT", + "OSS_BUCKET", + "OSS_REGION", + "OSS_LOCAL_ROOT", + "OSS_MAX_UPLOAD_SIZE_MB", + ): + monkeypatch.delenv(name, raising=False) + settings = load_storage_settings() + assert settings.mode == "local" + assert settings.is_remote is False + assert settings.max_upload_size_mb == 10 + assert settings.local_root.name == "file_storage" + + def test_unknown_mode_falls_back_to_local(self, monkeypatch): + monkeypatch.setenv("OSS_MODE", "s3") + assert load_storage_settings().mode == "local" + + def test_region_is_derived_from_internal_endpoint(self, monkeypatch): + monkeypatch.setenv("OSS_MODE", "oss") + monkeypatch.setenv( + "OSS_ENDPOINT", "https://oss-cn-hangzhou-internal.aliyuncs.com" + ) + monkeypatch.delenv("OSS_REGION", raising=False) + settings = load_storage_settings() + assert settings.is_remote is True + # 生产 .env 只配 endpoint/bucket/AK,region 由端点推导出来。 + assert settings.region == "oss-cn-hangzhou" + + def test_explicit_region_wins_over_derived_one(self, monkeypatch): + monkeypatch.setenv("OSS_ENDPOINT", "oss-cn-hangzhou-internal.aliyuncs.com") + monkeypatch.setenv("OSS_REGION", "oss-cn-shanghai") + assert load_storage_settings().region == "oss-cn-shanghai" + + def test_oss_mode_without_endpoint_reports_actionable_error(self, monkeypatch): + monkeypatch.setenv("OSS_MODE", "oss") + monkeypatch.delenv("OSS_ENDPOINT", raising=False) + monkeypatch.delenv("OSS_BUCKET", raising=False) + storage = get_object_storage(load_storage_settings()) + with pytest.raises(StorageUnavailableError) as excinfo: + storage.ensure_ready() + assert "OSS_ENDPOINT" in str(excinfo.value) + + def test_aliyun_ensure_ready_never_calls_head_bucket(self, monkeypatch): + """阿里云桶由控制台创建,HeadBucket 在 RAM 收紧时会 403 甚至挂起,必须跳过。 + + 这个分支不构造 client,所以断言「不发任何请求也能返回」。 + """ + monkeypatch.setenv("OSS_MODE", "oss") + monkeypatch.setenv( + "OSS_ENDPOINT", "https://oss-cn-hangzhou-internal.aliyuncs.com" + ) + monkeypatch.setenv("OSS_BUCKET", "live-hub-assets") + storage = S3ObjectStorage(load_storage_settings()) + storage.ensure_ready() + assert storage._client is None + + def test_s3_client_uses_virtual_host_style_and_derived_region(self, monkeypatch): + """真造一个 boto3 client,确认内网端点/region/virtual-host 寻址都生效。""" + pytest.importorskip("boto3") + monkeypatch.setenv("OSS_MODE", "oss") + monkeypatch.setenv( + "OSS_ENDPOINT", "https://oss-cn-hangzhou-internal.aliyuncs.com" + ) + monkeypatch.setenv("OSS_BUCKET", "live-hub-assets") + monkeypatch.setenv("OSS_ACCESS_KEY_ID", "ak") + monkeypatch.setenv("OSS_ACCESS_KEY_SECRET", "sk") + storage = S3ObjectStorage(load_storage_settings()) + client = storage._get_client() + assert client.meta.endpoint_url == ( + "https://oss-cn-hangzhou-internal.aliyuncs.com" + ) + assert client.meta.region_name == "oss-cn-hangzhou" + # 阿里云 OSS 只认 virtual-host style。 + assert client.meta.config.s3["addressing_style"] == "virtual" + + +class TestStoredObjectRead: + def test_rejects_path_traversal_key(self, local_storage: Path): + with pytest.raises(FileUploadError) as excinfo: + get_stored_object("../../etc/passwd", "") + assert excinfo.value.code == "file_key_invalid" + + def test_missing_key_raises_not_found(self, local_storage: Path): + with pytest.raises(FileUploadError) as excinfo: + get_stored_object("collab/2026/10/16/deadbeef.png", "thumb") + assert excinfo.value.status_code == 404 + assert excinfo.value.code == "file_not_found" + + def test_thumbnail_variant_is_served_when_present(self, local_storage: Path): + session = make_test_session() + try: + result = upload_file_asset( + session, + data=_png_bytes(1200, 800), + filename="shot.png", + content_type="image/png", + scene="collab", + ) + finally: + session.close() + + thumbnail = get_stored_object(result.object_key, "thumb") + original = get_stored_object(result.object_key, "") + try: + assert thumbnail.content_type == "image/webp" + assert thumbnail.size < original.size + assert thumbnail.etag and original.etag + finally: + thumbnail.reader.close() + original.reader.close() diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py new file mode 100644 index 0000000..c496eed --- /dev/null +++ b/tests/test_huya_collab_orders.py @@ -0,0 +1,1015 @@ +"""虎牙外部 CK 协作台账:CRUD / 两套状态机 / 审计 / 图片 / 批量导入。 + +设计见 ``docs/设计-虎牙外部CK协作台账.md``。 + +用例里的 CK 都是**合成**的(形态对齐真机:分号后无空格、重复 ``guid``、 +超长 ``udb_biztoken``/``udb_cred``)——真实凭据绝不写进仓库, +一旦提交就永久留在 git 历史里。 +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from tests.support.db import get_test_engine, make_test_session +from web.backend.huya_defaults import ( + HUYA_DEFAULT_BIND_ACT_ID, + HUYA_DEFAULT_OUTER_ACT_ID, +) +from web.backend.models import ( + AuditLog, + HuyaAccount, + HuyaExternalOrder, + HuyaWorkbenchAccount, + User, +) +from web.backend.permissions import ROLE_PERMISSIONS +from web.backend.services import huya_collab_service as collab + + +def fake_ck(uid: str, *, web: bool = True) -> str: + """合成一份形态对齐真机的虎牙 CK。 + + ``web=True`` 模拟浏览器里导出的网页 CK(带 guid/_qimei_uuid42,且真机里 + ``guid`` 会重复出现);``web=False`` 模拟 App 原始凭据(没有网页设备字段)。 + """ + parts = [ + f"udb_uid={uid}", + f"udb_passport=hy_{uid}", + "udb_version=1.0", + "huya_ua=webh5&0.0.1&websocket&&h5_/30596253", + f"hdid={'a' * 40}", + f"username=hy_{uid}", + f"yyuid={uid}", + "udb_passdata=3", + ] + if web: + # 真机 CK 里 guid 确实重复出现两次,归一化后应只剩一个。 + parts += [f"guid={'b' * 32}", f"guid={'b' * 32}"] + parts.append("_qimei_uuid42=qc_123456") + parts += [f"udb_biztoken={'T' * 200}", f"udb_cred={'C' * 152}", "udb_status=1"] + return ";".join(parts) # 注意:真机是分号后无空格 + + +class TestCollabBase: + def setup_method(self): + self.engine = get_test_engine() + self.db = make_test_session(self.engine) + self.user = User(username="collab_op", password_hash="h", role="operation") + self.other = User(username="collab_support", password_hash="h", role="support") + self.db.add_all([self.user, self.other]) + self.db.commit() + + def teardown_method(self): + self.db.close() + + def _create( + self, order_no: str = "2627501604891357", **kwargs + ) -> HuyaExternalOrder: + return collab.create_order(self.db, self.user, order_no=order_no, **kwargs) + + def _audits(self, order: HuyaExternalOrder) -> list[AuditLog]: + return ( + self.db.query(AuditLog) + .filter(AuditLog.target == f"huya_external_order:{order.id}") + .order_by(AuditLog.id.asc()) + .all() + ) + + def _bind_ready(self, order: HuyaExternalOrder) -> HuyaExternalOrder: + """把一行推到「可以标记已绑定」的状态。""" + collab.set_ck(self.db, self.user, order, fake_ck("1199647239697")) + collab.update_order( + self.db, self.user, order, {"bind_douyu_account": "用户4241534208"} + ) + collab.set_images( + self.db, + self.user, + order, + "bind_images", + ["collab/2026/10/16/bind01.png"], + ) + return order + + +class TestCollabCrud(TestCollabBase): + def test_create_parses_ck_and_writes_audit(self): + order = self._create(ck_text=fake_ck("1199647239697"), shop="佳佳") + + assert order.uid == "1199647239697" + assert order.yyuid == "1199647239697" + assert order.hy_username == "hy_1199647239697" + assert order.status == "pending" + assert order.ck_status == "unverified" + assert order.version == 0 + assert order.created_by == self.user.id + + audits = self._audits(order) + assert [row.action for row in audits] == ["huya:collab:create"] + + def test_create_without_ck_leaves_uid_empty(self): + order = self._create(ck_text="") + assert order.uid is None + + def test_multiple_rows_without_ck_are_allowed(self): + """uid 唯一但可空:多个 NULL 不冲突,所以「先建单、后贴 CK」走得通。""" + self._create(order_no="A1") + self._create(order_no="A2") + assert self.db.query(HuyaExternalOrder).count() == 2 + + def test_create_rejects_duplicate_order_no(self): + self._create(order_no="DUP1") + with pytest.raises(collab.CollabError) as excinfo: + self._create(order_no="DUP1") + assert excinfo.value.status_code == 409 + assert excinfo.value.code == "order_no_conflict" + + def test_create_rejects_uid_already_used_by_another_order(self): + self._create(order_no="A1", ck_text=fake_ck("1001")) + with pytest.raises(collab.CollabError) as excinfo: + self._create(order_no="A2", ck_text=fake_ck("1001")) + assert excinfo.value.status_code == 409 + assert excinfo.value.code == "uid_conflict" + # 消息里要说清被谁占了,不然客服不知道该找哪一行 + assert "A1" in excinfo.value.detail + + def test_web_ck_does_not_fill_raw_cookie(self): + """网页 CK(有 guid)不塞进 raw_cookie —— 它是 App 原始凭据的专用位。""" + order = self._create(ck_text=fake_ck("1001", web=True)) + assert order.cookie + assert order.raw_cookie == "" + + def test_app_ck_also_kept_as_raw_cookie_for_rebuild(self): + order = self._create(ck_text=fake_ck("1002", web=False)) + assert order.cookie + assert order.raw_cookie == order.cookie + + def test_update_returns_field_diff_and_bumps_version(self): + order = self._create(shop="佳佳") + changes = collab.update_order( + self.db, self.user, order, {"shop": "小鱼儿", "remark": "熟人单"} + ) + + assert set(changes) == {"shop", "remark"} + assert changes["shop"] == {"old": "佳佳", "new": "小鱼儿"} + assert order.version == 1 + assert order.updated_by == self.user.id + + actions = [row.action for row in self._audits(order)] + assert actions[-1] == "huya:collab:update" + + def test_update_only_touches_submitted_fields(self): + order = self._create(shop="佳佳", gift_name="不朽伊人大礼包") + collab.update_order(self.db, self.user, order, {"remark": "只改备注"}) + assert order.shop == "佳佳" + assert order.gift_name == "不朽伊人大礼包" + + def test_update_rejects_stale_version(self): + """乐观锁:别人先改过就 409,绝不静默覆盖(这正是 Excel 最痛的点)。""" + order = self._create(shop="佳佳") + collab.update_order(self.db, self.user, order, {"shop": "小鱼儿"}) + + with pytest.raises(collab.CollabError) as excinfo: + collab.update_order( + self.db, + self.other, + order, + {"remark": "并发写"}, + expected_version=0, + ) + assert excinfo.value.status_code == 409 + assert excinfo.value.code == "version_conflict" + assert order.remark == "" + + def test_update_accepts_matching_version(self): + order = self._create(shop="佳佳") + collab.update_order(self.db, self.user, order, {"shop": "小鱼儿"}) + collab.update_order( + self.db, self.other, order, {"remark": "跟版本走"}, expected_version=1 + ) + assert order.remark == "跟版本走" + assert order.version == 2 + + def test_update_rejects_order_no_taken_by_another_row(self): + self._create(order_no="A1") + order = self._create(order_no="A2") + with pytest.raises(collab.CollabError) as excinfo: + collab.update_order(self.db, self.user, order, {"order_no": "A1"}) + assert excinfo.value.code == "order_no_conflict" + + def test_rejects_overlong_field_instead_of_silently_truncating(self): + with pytest.raises(collab.CollabError) as excinfo: + self._create(order_no="A1", remark="x" * 600) + assert excinfo.value.code == "field_too_long" + + def test_delete_keeps_audit_row(self): + order = self._create(order_no="DEL1") + order_id = order.id + snapshot = collab.delete_order(self.db, self.user, order) + + assert snapshot["order_no"] == "DEL1" + assert self.db.query(HuyaExternalOrder).count() == 0 + actions = [ + row.action + for row in self.db.query(AuditLog) + .filter(AuditLog.target == f"huya_external_order:{order_id}") + .all() + ] + assert "huya:collab:delete" in actions + + +class TestCollabStatusMachine(TestCollabBase): + def test_cannot_skip_forward_without_explicit_rollback(self): + """pending 只能走到 bound,直接跳到 recharged 要被挡下。""" + order = self._create() + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status(self.db, self.user, order, "recharged") + assert excinfo.value.code == "status_not_forward" + assert order.status == "pending" + + def test_bound_requires_ck_bind_account_and_image(self): + order = self._create() + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status(self.db, self.user, order, "bound") + message = excinfo.value.detail + # 错误信息必须说清缺什么,否则客服只能猜 + assert "CK" in message + assert "绑定斗鱼号" in message + assert "绑定图" in message + + def test_forward_flow_sets_timestamps(self): + order = self._bind_ready(self._create()) + + collab.set_status(self.db, self.user, order, "bound") + assert order.status == "bound" + assert order.bound_at is not None + + collab.set_status(self.db, self.user, order, "recharged") + collab.set_status(self.db, self.user, order, "scripted") + collab.set_status(self.db, self.user, order, "done") + + assert order.status == "done" + assert order.recharged_at is not None + assert order.scripted_at is not None + assert order.done_at is not None + + def test_every_status_change_writes_audit(self): + """设计文档 §4.3 防漂移:状态写入必须留痕,漏一条就是 bug。""" + order = self._bind_ready(self._create()) + before = len(self._audits(order)) + for target in ("bound", "recharged", "scripted", "done"): + collab.set_status(self.db, self.user, order, target) + after = self._audits(order) + assert len(after) - before == 4 + status_audits = [row for row in after if row.action == "huya:collab:status"] + assert len(status_audits) == 4 + + def test_rollback_requires_flag_and_clears_later_timestamps(self): + order = self._bind_ready(self._create()) + for target in ("bound", "recharged", "scripted", "done"): + collab.set_status(self.db, self.user, order, target) + assert order.scripted_at is not None + + # 不给 rollback 标记 → 拒 + with pytest.raises(collab.CollabError): + collab.set_status(self.db, self.user, order, "recharged") + + collab.set_status(self.db, self.user, order, "recharged", rollback=True) + assert order.status == "recharged" + # 回退后后面阶段的时间戳必须清掉,否则状态与时间戳自相矛盾 + assert order.scripted_at is None + assert order.done_at is None + assert order.recharged_at is not None + + def test_void_requires_reason_and_blocks_returning(self): + order = self._create() + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status(self.db, self.user, order, "void") + assert excinfo.value.code == "field_required" + + collab.set_status(self.db, self.user, order, "void", reason="客户退款") + assert order.status == "void" + assert order.void_reason == "客户退款" + + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status(self.db, self.user, order, "pending", rollback=True) + assert excinfo.value.code == "status_from_void" + + def test_same_status_is_noop(self): + order = self._bind_ready(self._create()) + before = len(self._audits(order)) + assert collab.set_status(self.db, self.user, order, "pending") == {} + # 幂等点击不该刷出审计噪声 + assert len(self._audits(order)) == before + + def test_done_has_no_next_status(self): + order = self._create() + collab.set_status(self.db, self.user, order, "void", reason="x") + assert collab.NEXT_STATUS.get("done") is None + + +class TestCollabCkLifecycle(TestCollabBase): + def test_valid_sets_checked_at_but_disabled_does_not(self): + order = self._create(ck_text=fake_ck("1001")) + + collab.set_ck_status(self.db, self.user, order, "valid") + assert order.ck_status == "valid" + assert order.ck_checked_at is not None + checked_at = order.ck_checked_at + assert order.ck_check_message == "" + + collab.set_ck_status(self.db, self.user, order, "disabled", message="不再使用") + assert order.ck_status == "disabled" + # 人工停用不是「刚跑过探针」,时间戳不该被刷 + assert order.ck_checked_at == checked_at + + def test_invalid_records_reason(self): + order = self._create(ck_text=fake_ck("1001")) + collab.set_ck_status( + self.db, self.user, order, "invalid", message="登录态已过期" + ) + assert order.ck_status == "invalid" + assert order.ck_check_message == "登录态已过期" + + def test_ck_status_rejects_unknown_value(self): + order = self._create() + with pytest.raises(collab.CollabError) as excinfo: + collab.set_ck_status(self.db, self.user, order, "whatever") + assert excinfo.value.code == "ck_status_unknown" + + def test_pasting_new_ck_resets_verification(self): + order = self._create(ck_text=fake_ck("1001")) + collab.set_ck_status(self.db, self.user, order, "valid") + collab.set_ck(self.db, self.user, order, fake_ck("1002")) + assert order.uid == "1002" + assert order.ck_status == "unverified" + assert order.ck_checked_at is None + + def test_ck_audit_never_leaks_the_credential(self): + """审计 detail 里绝不能出现凭据本身(脱敏规则一旦变动就是泄漏)。""" + ck = fake_ck("1001") + order = self._create(ck_text=ck) + + create_detail = self._audits(order)[0].detail + assert "udb_cred" not in create_detail + assert ck not in create_detail + + # 「换 CK」这条最有风险:逐条审计都查一遍 + collab.set_ck(self.db, self.user, order, fake_ck("1003")) + for entry in self._audits(order): + assert "udb_cred" not in entry.detail + assert "cookie" not in entry.detail + assert ck not in entry.detail + + paste_entry = next( + entry for entry in self._audits(order) if entry.action == "huya:collab:ck" + ) + # uid 与「内容变了」要留痕,否则查不出换过 CK + assert "1003" in paste_entry.detail + assert "credential_changed" in paste_entry.detail + + def test_rebuild_without_raw_cookie_is_rejected(self): + order = self._create(ck_text=fake_ck("1001", web=True)) + with pytest.raises(collab.CollabError) as excinfo: + collab.rebuild_ck(self.db, self.user, order) + assert excinfo.value.code == "no_raw_cookie" + + def test_rebuild_from_raw_cookie_replaces_running_cookie(self): + order = self._create(ck_text=fake_ck("1002", web=False)) + raw = order.raw_cookie + collab.rebuild_ck(self.db, self.user, order) + assert order.cookie # 重建后的运行态 + assert order.raw_cookie == raw # 原始凭据要原样留着,可反复重建 + assert order.ck_status == "unverified" + + def test_ck_parse_failure_is_readable(self): + with pytest.raises(collab.CollabError) as excinfo: + self._create(ck_text="这不是一份 CK") + assert excinfo.value.code == "ck_invalid" + + +class TestCollabImages(TestCollabBase): + def test_set_images_normalizes_and_dedupes(self): + order = self._create() + result = collab.set_images( + self.db, + self.user, + order, + "bind_images", + ["collab/2026/10/16/a.png", "collab/2026/10/16/a.png", " "], + ) + assert result["images"] == ["collab/2026/10/16/a.png"] + + def test_images_reject_path_traversal(self): + order = self._create() + with pytest.raises(collab.CollabError) as excinfo: + collab.set_images( + self.db, self.user, order, "bind_images", ["../etc/passwd"] + ) + assert excinfo.value.code == "image_key_invalid" + + def test_images_reject_unknown_slot_and_too_many(self): + order = self._create() + with pytest.raises(collab.CollabError) as excinfo: + collab.set_images(self.db, self.user, order, "other_images", []) + assert excinfo.value.code == "image_slot_unknown" + + too_many = [f"collab/2026/10/16/{index}.png" for index in range(21)] + with pytest.raises(collab.CollabError) as excinfo: + collab.set_images(self.db, self.user, order, "bind_images", too_many) + assert excinfo.value.code == "images_too_many" + + def test_image_change_is_audited_with_added_and_removed(self): + order = self._create() + collab.set_images( + self.db, self.user, order, "home_images", ["collab/2026/10/16/a.png"] + ) + collab.set_images( + self.db, self.user, order, "home_images", ["collab/2026/10/16/b.png"] + ) + entry = self._audits(order)[-1] + assert entry.action == "huya:collab:image" + assert "b.png" in entry.detail and "a.png" in entry.detail + + def test_serialize_builds_urls_from_keys_not_stored_urls(self): + order = self._create() + collab.set_images( + self.db, self.user, order, "bind_images", ["collab/2026/10/16/a.png"] + ) + payload = collab.serialize_order(order) + + assert payload["bind_images"] == ["collab/2026/10/16/a.png"] + assert payload["bind_image_urls"] == [ + "/api/files/object?key=collab%2F2026%2F10%2F16%2Fa.png" + ] + assert payload["bind_image_thumb_urls"] == [ + "/api/files/object?key=collab%2F2026%2F10%2F16%2Fa.png&variant=thumb" + ] + + def test_serialize_never_exposes_cookie(self): + order = self._create(ck_text=fake_ck("1001")) + payload = collab.serialize_order(order) + assert payload["has_cookie"] is True + assert "cookie" not in payload + assert "raw_cookie" not in payload + + +class TestCollabImport(TestCollabBase): + def test_import_with_header_creates_rows(self): + rows = [ + "店铺\t订单编号\t负责人\t绑定斗鱼号\t礼包\t备注", + "佳佳\t2627501604891357\tcollab_op\t用户4241534208\t不朽伊人大礼包\t熟人", + "小鱼儿\t2627501761915541\tcollab_support\t愚人节记得来骗我756\t激萌罗小黑\t", + ] + text = "\n".join(rows) + result = collab.import_orders( + self.db, + self.user, + text, + owner_by_name={"collab_op": self.user.id, "collab_support": self.other.id}, + ) + + assert result["created"] == 2 + assert result["errors"] == [] + orders = ( + self.db.query(HuyaExternalOrder) + .filter(HuyaExternalOrder.order_no == "2627501604891357") + .all() + ) + assert orders[0].shop == "佳佳" + assert orders[0].owner_id == self.user.id + assert orders[0].gift_name == "不朽伊人大礼包" + + def test_import_without_header_uses_fixed_column_order(self): + text = "佳佳\t2627501604891357\tcollab_op\t用户4241534208\t礼包A\t备注B" + result = collab.import_orders( + self.db, self.user, text, owner_by_name={"collab_op": self.user.id} + ) + assert result["created"] == 1 + order = self.db.query(HuyaExternalOrder).one() + assert order.shop == "佳佳" + assert order.bind_douyu_account == "用户4241534208" + assert order.gift_name == "礼包A" + + def test_import_unknown_owner_only_fails_that_row(self): + """单行失败不能带崩整批(AGENTS §2:单项失败要如实暴露,但不整体失败)。""" + rows = [ + "佳佳\tA1\t不认识的人\t用户1\t礼包\t", + "佳佳\tA2\tcollab_op\t用户2\t礼包\t", + ] + text = "\n".join(rows) + result = collab.import_orders( + self.db, self.user, text, owner_by_name={"collab_op": self.user.id} + ) + assert result["created"] == 1 + assert len(result["errors"]) == 1 + assert result["errors"][0]["line"] == 1 + assert "不认识的人" in result["errors"][0]["message"] + assert self.db.query(HuyaExternalOrder).one().order_no == "A2" + + def test_import_single_bad_row_does_not_poison_the_batch(self): + """超长字段会让那一行报错;用 SAVEPOINT 隔离后,后面几行必须照常写入。""" + rows = [ + f"佳佳\tA1\tcollab_op\t用户1\t{'礼包' * 200}\t", + "佳佳\tA2\tcollab_op\t用户2\t礼包\t", + "佳佳\tA3\tcollab_op\t用户3\t礼包\t", + ] + text = "\n".join(rows) + result = collab.import_orders( + self.db, self.user, text, owner_by_name={"collab_op": self.user.id} + ) + assert result["created"] == 2 + assert len(result["errors"]) == 1 + order_nos = {row.order_no for row in self.db.query(HuyaExternalOrder).all()} + assert order_nos == {"A2", "A3"} + + def test_import_survives_a_row_that_fails_at_the_database_level(self): + """「写库时才炸」的错误(这里用不存在的 owner_id 触发外键失败)必须被 + SAVEPOINT 隔离:不加 SAVEPOINT 时,第一次约束冲突会把会话打成失败态, + 后面每一行都会跟着报错 —— 单行失败就带崩了整批。 + """ + rows = [ + "佳佳\tA1\tghost_owner\t用户1\t礼包\t", + "佳佳\tA2\tcollab_op\t用户2\t礼包\t", + ] + text = "\n".join(rows) + result = collab.import_orders( + self.db, + self.user, + text, + owner_by_name={"ghost_owner": 999999, "collab_op": self.user.id}, + ) + assert result["created"] == 1 + assert len(result["errors"]) == 1 + assert self.db.query(HuyaExternalOrder).one().order_no == "A2" + + def test_import_duplicate_in_same_paste_keeps_first_only(self): + rows = [ + "佳佳\tA1\tcollab_op\t用户1\t礼包\t", + "小鱼儿\tA1\tcollab_op\t用户2\t礼包\t", + ] + text = "\n".join(rows) + result = collab.import_orders( + self.db, self.user, text, owner_by_name={"collab_op": self.user.id} + ) + assert result["created"] == 1 + assert len(result["errors"]) == 1 + assert self.db.query(HuyaExternalOrder).one().shop == "佳佳" + + def test_import_updates_existing_row(self): + order = self._create(order_no="A1", shop="佳佳") + result = collab.import_orders( + self.db, + self.user, + "小鱼儿\tA1\tcollab_op\t用户9\t礼包\t", + owner_by_name={"collab_op": self.user.id}, + ) + assert result["updated"] == 1 + self.db.refresh(order) + assert order.shop == "小鱼儿" + assert order.bind_douyu_account == "用户9" + + def test_import_skips_rows_without_order_no(self): + result = collab.import_orders( + self.db, self.user, "佳佳\t\tcollab_op\t用户1\t\t" + ) + assert result["skipped"] == 1 + assert result["created"] == 0 + + +class FakeBindResp: + """合成一份 ``check_user_bind_game_account`` 响应(字段名对齐真机响应结构)。""" + + def __init__( + self, + *, + status: int = 200, + msg: str = "", + nick: str = "", + role: str = "", + role_id: str = "", + open_id: str = "", + plat: str = "", + area: str = "", + game_name: str = "和平精英", + ): + self.status = status + self.msg = msg + self.gameName = game_name + self.accountData = SimpleNamespace( + gameAccount=SimpleNamespace(nick=nick, openId=open_id), + gameRole=SimpleNamespace( + roleName=role, roleId=role_id, platName=plat, areaName=area + ), + ) + + +class FakeProbeClient: + """离线探针客户端:按脚本依次给出 ``None`` / 异常 / 响应,并记录调用参数。""" + + def __init__(self, *outcomes): + self.outcomes = list(outcomes) + self.calls: list[dict] = [] + + def check_user_bind_game_account(self, **kwargs): + self.calls.append(kwargs) + if not self.outcomes: + raise AssertionError("探针被调用的次数超出用例预期") + outcome = self.outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + +class TestCollabCkProbe(TestCollabBase): + """S5:只读探针。判据(真机对照 2026-10-16)= 响应非 None 且 status == 200。""" + + def _row_with_ck(self, order_no: str = "P1", uid: str = "1199647239697"): + return self._create(order_no=order_no, ck_text=fake_ck(uid)) + + def test_verify_success_marks_valid_and_writes_audit(self): + order = self._row_with_ck() + client = FakeProbeClient( + FakeBindResp( + nick="随遇而安", + role="吃鸡199706", + role_id="1234567", + plat="手Q", + area="苹果(iOS)", + ) + ) + + result = collab.verify_ck(self.db, self.user, order, client=client) + + assert result["ok"] is True + assert order.ck_status == "valid" + assert order.ck_checked_at is not None + assert "随遇而安" in order.ck_check_message + assert "吃鸡199706" in order.ck_check_message + assert order.version == 1 + assert "huya:collab:ck_status" in [row.action for row in self._audits(order)] + + def test_verify_uses_this_rows_ck_and_the_default_activity(self): + """探针必须拿**本行的** CK 去问默认活动,不能借用别的行/别的活动。""" + order = self._row_with_ck(uid="1199647239697") + client = FakeProbeClient(FakeBindResp(nick="随遇而安")) + + collab.verify_ck(self.db, self.user, order, client=client) + + call = client.calls[0] + assert call["uid"] == 1199647239697 + assert isinstance(call["uid"], int) + assert call["cookie"] == order.cookie + assert ( + call["b_act_id"] + == collab.PROBE_BIND_ACT_ID + == int(HUYA_DEFAULT_BIND_ACT_ID) + ) + assert call["is_use_outer_act_id"] == 1 + assert ( + call["outer_act_id"] + == collab.PROBE_OUTER_ACT_ID + == HUYA_DEFAULT_OUTER_ACT_ID + ) + + def test_verify_marks_invalid_when_status_is_not_200(self): + order = self._row_with_ck() + client = FakeProbeClient(FakeBindResp(status=401, msg="登录态失效")) + + result = collab.verify_ck(self.db, self.user, order, client=client) + + assert result["ok"] is False + assert order.ck_status == "invalid" + assert order.ck_checked_at is not None + assert "401" in order.ck_check_message + assert "登录态失效" in order.ck_check_message + + def test_verify_treats_empty_response_as_invalid(self): + """裸 cookie / 别人的 uid 在真机上都返回 None —— 必须是「失效」。""" + order = self._row_with_ck() + + result = collab.verify_ck( + self.db, self.user, order, client=FakeProbeClient(None) + ) + + assert result["ok"] is False + assert order.ck_status == "invalid" + assert "没有返回数据" in order.ck_check_message + + def test_verify_does_not_accept_payload_without_200(self): + """别把判据改成「有数据就算成功」:status=0 却带数据也必须判失效。""" + order = self._row_with_ck() + client = FakeProbeClient( + FakeBindResp(status=0, nick="随遇而安", role="吃鸡199706") + ) + + result = collab.verify_ck(self.db, self.user, order, client=client) + + assert result["ok"] is False + assert order.ck_status == "invalid" + # 失效响应里的字段一个都不许落到台账上(绑定快照也不许被清空/覆盖) + assert order.bind_game_account == "" + assert order.bind_game_role == "" + assert order.bind_game_channel == "" + assert order.bind_snapshot_at is None + + def test_verify_survives_probe_exception(self): + order = self._row_with_ck() + + result = collab.verify_ck( + self.db, self.user, order, client=FakeProbeClient(RuntimeError("boom")) + ) + + assert result["ok"] is False + assert order.ck_status == "invalid" + assert "RuntimeError" in order.ck_check_message + + def test_verify_writes_binding_snapshot(self): + """绑定账号 + **绑定角色**(用户强调这条重要)+ 平台区服都落到快照列。""" + order = self._row_with_ck() + client = FakeProbeClient( + FakeBindResp( + nick="随遇而安", + role="吃鸡199706", + plat="手Q", + area="苹果(iOS)", + ) + ) + + result = collab.verify_ck(self.db, self.user, order, client=client) + + assert order.bind_game_account == "随遇而安" + assert order.bind_game_role == "吃鸡199706" + assert order.bind_game_channel == "手Q · 苹果(iOS)" + assert order.bind_snapshot_at is not None + assert result["binding"] == { + "bind_game_account": "随遇而安", + "bind_game_role": "吃鸡199706", + "bind_game_channel": "手Q · 苹果(iOS)", + } + # 人工填的列一个都不碰;nickname 是「虎牙号昵称」,这个探针给不出 + assert order.bind_douyu_account == "" + assert order.nickname == "" + assert "huya:collab:probe_snapshot" in [ + row.action for row in self._audits(order) + ] + + def test_verify_overwrites_previous_binding_snapshot(self): + """快照列是探针独占的「现在绑了谁」,重验要覆盖,并把旧值记进审计。""" + order = self._row_with_ck() + collab.verify_ck( + self.db, + self.user, + order, + client=FakeProbeClient(FakeBindResp(nick="旧账号", role="旧角色")), + ) + + collab.verify_ck( + self.db, + self.user, + order, + client=FakeProbeClient(FakeBindResp(nick="新账号", role="新角色")), + ) + + assert order.bind_game_account == "新账号" + assert order.bind_game_role == "新角色" + snapshots = [ + row + for row in self._audits(order) + if row.action == "huya:collab:probe_snapshot" + ] + # 两次验证各记一条(都在「快照变了」时才记),最后一条带 before/after + assert len(snapshots) == 2 + detail = json.loads(snapshots[-1].detail) + assert detail["before"]["bind_game_role"] == "旧角色" + assert detail["after"]["bind_game_role"] == "新角色" + + def test_binding_role_from_probe_satisfies_the_bound_precondition(self): + """只验证过 CK、读到绑定角色的行,不该再被「绑定斗鱼号」挡在 bound 外面。""" + order = self._row_with_ck() + collab.set_images( + self.db, + self.user, + order, + "bind_images", + ["collab/2026/10/16/bind01.png"], + ) + collab.verify_ck( + self.db, + self.user, + order, + client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706")), + ) + assert order.bind_douyu_account == "" + + collab.set_status(self.db, self.user, order, "bound") + + assert order.status == "bound" + assert order.bound_at is not None + + def test_verify_never_touches_manually_entered_columns(self): + order = self._row_with_ck() + collab.update_order( + self.db, + self.user, + order, + {"bind_douyu_account": "人工填的号", "bind_douyu_uid": "999"}, + ) + + collab.verify_ck( + self.db, + self.user, + order, + client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706")), + ) + + assert order.bind_douyu_account == "人工填的号" + assert order.bind_douyu_uid == "999" + assert order.bind_game_account == "随遇而安" + assert order.bind_game_role == "吃鸡199706" + + def test_verify_without_cookie_raises_instead_of_marking_invalid(self): + order = self._create(order_no="P-NOCK") + + with pytest.raises(collab.CollabError) as excinfo: + collab.verify_ck(self.db, self.user, order) + + assert excinfo.value.code == "no_cookie" + assert order.ck_status == "unverified" + + def test_verify_refuses_void_row(self): + order = self._row_with_ck() + collab.set_status(self.db, self.user, order, "void", reason="客户取消") + + with pytest.raises(collab.CollabError) as excinfo: + collab.verify_ck(self.db, self.user, order, client=FakeProbeClient(None)) + + assert excinfo.value.code == "void_order" + + def test_manual_ck_status_only_allows_disable_and_reset(self): + """人工只能停用/恢复;「有效/失效」必须由探针给(人工标有效=绕过验证)。""" + order = self._row_with_ck() + + collab.set_manual_ck_status( + self.db, self.user, order, "disabled", message="这个号不打算再用" + ) + assert order.ck_status == "disabled" + # 人工停用不算「刚跑过探针」,不刷 ck_checked_at + assert order.ck_checked_at is None + + collab.set_manual_ck_status(self.db, self.user, order, "unverified") + assert order.ck_status == "unverified" + + with pytest.raises(collab.CollabError) as excinfo: + collab.set_manual_ck_status(self.db, self.user, order, "valid") + assert excinfo.value.code == "ck_status_manual_only" + assert order.ck_status == "unverified" + + def test_verify_never_touches_business_status(self): + """CK 失效不改业务状态:已绑定的单子业务上可能仍然有效(设计文档 §4.2)。""" + order = self._row_with_ck() + self._bind_ready(order) + collab.set_status(self.db, self.user, order, "bound") + + collab.verify_ck( + self.db, self.user, order, client=FakeProbeClient(FakeBindResp(status=401)) + ) + + assert order.ck_status == "invalid" + assert order.status == "bound" + assert order.bound_at is not None + + def test_verify_never_creates_huya_account(self): + """边界(设计文档 §2.3):探针只读,不建 HuyaAccount、不写工作台。""" + order = self._row_with_ck() + + collab.verify_ck( + self.db, + self.user, + order, + client=FakeProbeClient(FakeBindResp(nick="随遇而安")), + ) + + assert self.db.query(HuyaAccount).count() == 0 + assert self.db.query(HuyaWorkbenchAccount).count() == 0 + + def test_verify_audit_never_carries_the_cookie(self): + order = self._row_with_ck() + collab.verify_ck( + self.db, + self.user, + order, + client=FakeProbeClient(FakeBindResp(nick="随遇而安")), + ) + + payload = json.dumps( + [row.detail for row in self._audits(order)], ensure_ascii=False + ) + assert "udb_cred" not in payload + assert "udb_biztoken" not in payload + assert order.cookie not in payload + + def test_batch_isolates_single_row_failure(self): + rows = [ + self._row_with_ck(order_no="B1", uid="1001"), + self._row_with_ck(order_no="B2", uid="1002"), + self._row_with_ck(order_no="B3", uid="1003"), + ] + client = FakeProbeClient( + FakeBindResp(nick="A"), + RuntimeError("boom"), + FakeBindResp(status=500, msg="服务端异常"), + ) + + result = collab.verify_ck_batch( + self.db, self.user, rows, client=client, interval_seconds=0 + ) + + assert result["total"] == 3 + assert result["valid"] == 1 + assert result["failed"] == 2 + assert [row.ck_status for row in rows] == ["valid", "invalid", "invalid"] + assert "RuntimeError" in result["results"][1]["message"] + + def test_batch_reports_rows_without_ck_without_lying(self): + with_ck = self._row_with_ck(order_no="B1", uid="1001") + without_ck = self._create(order_no="B2") + client = FakeProbeClient(FakeBindResp(nick="A")) + + result = collab.verify_ck_batch( + self.db, self.user, [with_ck, without_ck], client=client, interval_seconds=0 + ) + + assert result["valid"] == 1 + assert result["failed"] == 1 + # 没 CK 的行保持「未验证」,不能谎报成「已失效」 + assert without_ck.ck_status == "unverified" + assert "CK" in result["results"][1]["message"] + assert len(client.calls) == 1 # 没 CK 的行不发请求 + + def test_batch_keeps_interval_between_rows(self, monkeypatch): + """人工触发也要串行 + 间隔,别把账号送进风控名单。""" + rows = [ + self._row_with_ck(order_no="B1", uid="1001"), + self._row_with_ck(order_no="B2", uid="1002"), + self._row_with_ck(order_no="B3", uid="1003"), + ] + client = FakeProbeClient(*[FakeBindResp(nick="A") for _ in rows]) + slept: list[float] = [] + monkeypatch.setattr( + collab, "time", SimpleNamespace(sleep=lambda seconds: slept.append(seconds)) + ) + + collab.verify_ck_batch( + self.db, self.user, rows, client=client, interval_seconds=0.5 + ) + + assert slept == [0.5, 0.5] # 3 条之间睡 2 次,第一条前不睡 + + def test_batch_rejects_too_many_rows(self): + with pytest.raises(collab.CollabError) as excinfo: + collab.verify_ck_batch( + self.db, self.user, [None] * (collab.PROBE_BATCH_MAX + 1) + ) + + assert excinfo.value.code == "verify_batch_too_large" + + def test_list_orders_by_ids_keeps_request_order_and_ignores_missing(self): + first = self._row_with_ck(order_no="B1", uid="1001") + second = self._row_with_ck(order_no="B2", uid="1002") + + rows = collab.list_orders_by_ids(self.db, [second.id, first.id, 99999999]) + + assert [row.id for row in rows] == [second.id, first.id] + with pytest.raises(collab.CollabError) as excinfo: + collab.list_orders_by_ids(self.db, []) + assert excinfo.value.code == "verify_empty" + + +class TestCollabPermissions: + def test_role_defaults_match_the_agreed_matrix(self): + """运营能看能改能删;客服能看能改但不能删。""" + for role in ("operation", "support"): + assert "huya:collab:view" in ROLE_PERMISSIONS[role] + assert "huya:collab:edit" in ROLE_PERMISSIONS[role] + assert "huya:collab:manage" in ROLE_PERMISSIONS["operation"] + assert "huya:collab:manage" not in ROLE_PERMISSIONS["support"] + + def test_upload_permissions_needed_by_the_page(self): + """页面要上传图 + 预览图,这两个权限必须给到协作的角色。""" + for role in ("operation", "support"): + assert "file:view" in ROLE_PERMISSIONS[role] + assert "file:upload" in ROLE_PERMISSIONS[role] + + def test_collab_module_does_not_touch_huya_accounts(self): + """边界(设计文档 §2.3):本模块不建 HuyaAccount、不写工作台。""" + assert collab.HuyaExternalOrder.__tablename__ == "huya_external_orders" + # 协作台账模型里没有任何指向 huya_accounts 的外键 + targets = { + fk.target_fullname for fk in collab.HuyaExternalOrder.__table__.foreign_keys + } + assert targets == {"users.id"} + assert HuyaWorkbenchAccount.__tablename__ == "huya_workbench_accounts" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index e63337d..f571bc9 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261015_0053" +HEAD_REVISION = "20261016_0056" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/uv.lock b/uv.lock index e0e6b2c..3f13d38 100644 --- a/uv.lock +++ b/uv.lock @@ -85,6 +85,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, ] +[[package]] +name = "boto3" +version = "1.43.114" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/8c/f6f884dc947789317e73ed6fce85e18580d22e9f90e48d67c2367b02667e/boto3-1.43.114.tar.gz", hash = "sha256:be704857751564a5cf69c5bbaadbfa01c22806409815c73563db42fbffe583a2", size = 112653, upload-time = "2026-10-14T19:24:22.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f8/0799a101e6f65c8b687f50c218654cef1e44658e946c7d33d362e2572621/boto3-1.43.114-py3-none-any.whl", hash = "sha256:d9cac2eb921ce674970cef1c9ad750f85ee3a846aedcf188d18368fb9eb6da23", size = 140043, upload-time = "2026-10-14T19:24:21.038Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.114" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/c8/b508359d1f3846a918c06807a9ae27eee063f904559269e42ccde9de09ea/botocore-1.43.114.tar.gz", hash = "sha256:f366fa4db518775632ad1eb128cd8203ca46396cecf37209d904f0bbc049ce90", size = 16369844, upload-time = "2026-10-14T19:24:17.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/41/7c6fa7ac5fcfd5ea3c6f32aab001942da32b184a210f39042778cb1ad8ed/botocore-1.43.114-py3-none-any.whl", hash = "sha256:d1c441a22e93e158de5b1e026205f5d6d67a4545d10540c5090c62dccb3a9eca", size = 16067885, upload-time = "2026-10-14T19:24:14.629Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -318,6 +346,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "live-hub-py" version = "0.4.0" @@ -325,6 +362,7 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "bcrypt" }, + { name = "boto3" }, { name = "curl-cffi" }, { name = "fastapi" }, { name = "loguru" }, @@ -358,6 +396,7 @@ dev = [ requires-dist = [ { name = "alembic", specifier = ">=1.18.4" }, { name = "bcrypt", specifier = ">=4.0.0" }, + { name = "boto3", specifier = ">=1.34" }, { name = "curl-cffi", specifier = ">=0.13" }, { name = "fastapi", specifier = ">=0.110.0" }, { name = "loguru", specifier = ">=0.7.0" }, @@ -730,6 +769,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -842,6 +893,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, ] +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, +] + [[package]] name = "scipy" version = "1.18.0" diff --git a/web/backend/crypto_storage.py b/web/backend/crypto_storage.py index b620a96..3e1a26b 100644 --- a/web/backend/crypto_storage.py +++ b/web/backend/crypto_storage.py @@ -161,6 +161,9 @@ _SENSITIVE_COLUMNS: tuple[tuple[str, str], ...] = ( ("huya_register_success_logs", "password"), ("yyb_recharge_tasks", "login_qr_data"), ("yyb_recharge_tasks", "payment_qr_data"), + # 虎牙外部 CK 协作台账:CK 独立于 huya_accounts,同样必须加密落库。 + ("huya_external_orders", "cookie"), + ("huya_external_orders", "raw_cookie"), ("proxy_config", "api_url"), ("proxy_config", "http"), ("proxy_config", "https"), diff --git a/web/backend/main.py b/web/backend/main.py index 2933a47..2ef9de5 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -24,6 +24,7 @@ from .routers import ( cookies, dashboard, douyu, + files, huya, login, proxy, @@ -155,6 +156,7 @@ app.include_router(douyu.router) app.include_router(yyb.router) app.include_router(audit.router) app.include_router(automation.router) +app.include_router(files.router) @app.get("/api/health") diff --git a/web/backend/migrations/versions/20261016_0054_file_assets.py b/web/backend/migrations/versions/20261016_0054_file_assets.py new file mode 100644 index 0000000..1f873e8 --- /dev/null +++ b/web/backend/migrations/versions/20261016_0054_file_assets.py @@ -0,0 +1,92 @@ +"""上传文件台账表 `file_assets`(在线协作的图片落库)。 + +协作里用户上传的图片本体放对象存储(ECS 与桶同地域时走内网 endpoint, +``oss--internal.aliyuncs.com``),库里只留 key 与元数据: + +- 图片一律经 ``/api/files/object?key=...`` 由后端代理回读,**不下发 OSS 直链**。 + 参考 order_site 踩过的坑:曾经发过「15 分钟有效的签名链接」,结果入库时把当时的 + 链接原样存了下来,之后任何不重签的地方过一会儿必然裂图; +- ``object_key`` 里带 12 字节随机段(``scene/yyyy/mm/dd/<24位hex>``), + 不可枚举,所以普通链接就够用; +- ``thumbnail_object_key`` / ``medium_object_key`` 为空表示缩略图生成失败, + 回读接口按 `.thumb.webp → .thumb.jpg → .thumb.png → 原图` 逐个候选回退。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261016_0054" +down_revision: str | None = "20261015_0053" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _tables(bind) -> set[str]: + return set(sa.inspect(bind).get_table_names()) + + +def upgrade() -> None: + bind = op.get_bind() + if "file_assets" in _tables(bind): + return + + op.create_table( + "file_assets", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + # 对象存储里的 key:scene/yyyy/mm/dd/<24位随机hex> + sa.Column("object_key", sa.String(length=512), nullable=False), + # 业务场景前缀(collab/… 等),同时决定公开还是私有 + sa.Column("scene", sa.String(length=32), nullable=False, server_default="misc"), + sa.Column( + "original_filename", + sa.String(length=255), + nullable=False, + server_default="", + ), + sa.Column( + "content_type", sa.String(length=64), nullable=False, server_default="" + ), + sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column( + "thumbnail_object_key", + sa.String(length=512), + nullable=False, + server_default="", + ), + sa.Column( + "medium_object_key", + sa.String(length=512), + nullable=False, + server_default="", + ), + sa.Column( + "uploader_type", sa.String(length=32), nullable=False, server_default="user" + ), + sa.Column( + "uploader_id", sa.String(length=64), nullable=False, server_default="" + ), + sa.Column( + "status", sa.String(length=16), nullable=False, server_default="active" + ), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + # 回读接口按 key 精确定位,重复上传同一个 key 也要挡在唯一约束上。 + op.create_index( + "ix_file_assets_object_key", "file_assets", ["object_key"], unique=True + ) + op.create_index("ix_file_assets_scene", "file_assets", ["scene"]) + op.create_index("ix_file_assets_uploader_id", "file_assets", ["uploader_id"]) + + +def downgrade() -> None: + bind = op.get_bind() + if "file_assets" not in _tables(bind): + return + # MySQL 删表会连同索引一起删,不必逐个 drop_index。 + op.drop_table("file_assets") diff --git a/web/backend/migrations/versions/20261016_0055_huya_external_orders.py b/web/backend/migrations/versions/20261016_0055_huya_external_orders.py new file mode 100644 index 0000000..ef89f60 --- /dev/null +++ b/web/backend/migrations/versions/20261016_0055_huya_external_orders.py @@ -0,0 +1,147 @@ +"""虎牙外部 CK 协作台账表 `huya_external_orders`(替代 Excel 多人协同表)。 + +**一行 = 一单 = 一个外部 CK**,设计见 ``docs/设计-虎牙外部CK协作台账.md``。 + +三个不能改错的地方: + +1. **CK 独立存本表**(`cookie` / `raw_cookie`),刻意不写 `huya_accounts`、不做外键。 + 同一个虎牙号可能同时存在于两处、各自过期,那是设计上接受的代价(§2.3), + 不是 bug;需要拿去跑任务时人工到工作台重新导入。 +2. **`uid` 唯一但可空**:MySQL 的唯一索引允许多个 NULL,于是「先建单、后贴 CK」 + 与「一个虎牙号只能挂一单」能同时成立。别把它改成 NOT NULL —— 会让 + 「先建单」这条路直接写不进去。 +3. **两套状态各管一摊**:`status`(业务进度)与 `ck_status`(凭据有效性)。 + CK 失效不自动改业务状态,所以这两个列不要合并。 + +`cookie` / `raw_cookie` 同时登记进 `crypto_storage._SENSITIVE_COLUMNS`, +启动时的历史明文回填与落盘加密都靠那份清单。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261016_0055" +down_revision: str | None = "20261016_0054" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _tables(bind) -> set[str]: + return set(sa.inspect(bind).get_table_names()) + + +def upgrade() -> None: + bind = op.get_bind() + if "huya_external_orders" in _tables(bind): + return + + op.create_table( + "huya_external_orders", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + # Excel B 列:订单编号,台账内唯一。 + sa.Column("order_no", sa.String(length=64), nullable=False), + # Excel A 列:店铺。 + sa.Column("shop", sa.String(length=64), nullable=False, server_default=""), + # Excel C 列:负责人。 + sa.Column("owner_id", sa.Integer(), nullable=True), + # 外部 CK:uid 唯一可空(空 = 还没贴 CK)。 + sa.Column("uid", sa.String(length=32), nullable=True), + sa.Column("yyuid", sa.String(length=32), nullable=False, server_default=""), + sa.Column( + "hy_username", sa.String(length=128), nullable=False, server_default="" + ), + sa.Column("nickname", sa.String(length=128), nullable=False, server_default=""), + # 加密列(EncryptedText 在应用层加解密,DDL 是普通文本列)。 + sa.Column("cookie", sa.Text(), nullable=False), + sa.Column("raw_cookie", sa.Text(), nullable=False), + sa.Column( + "ck_status", + sa.String(length=16), + nullable=False, + server_default="unverified", + ), + sa.Column("ck_checked_at", sa.DateTime(), nullable=True), + sa.Column( + "ck_check_message", sa.String(length=256), nullable=False, server_default="" + ), + # Excel D 列:绑定斗鱼号。 + sa.Column( + "bind_douyu_account", + sa.String(length=128), + nullable=False, + server_default="", + ), + sa.Column( + "bind_douyu_uid", sa.String(length=32), nullable=False, server_default="" + ), + # Excel E/F 列:截图(OSS object_key 数组;MySQL JSON 列不能带 DEFAULT)。 + sa.Column("bind_images", sa.JSON(), nullable=True), + sa.Column("home_images", sa.JSON(), nullable=True), + # Excel G 列(表头写「备注」,实为礼包名)+ 自由备注。 + sa.Column( + "gift_name", sa.String(length=128), nullable=False, server_default="" + ), + sa.Column("remark", sa.String(length=512), nullable=False, server_default=""), + # 业务进度 + 各节点时间戳(时间戳是「做过」的证据,状态是「卡在哪」)。 + sa.Column( + "status", sa.String(length=16), nullable=False, server_default="pending" + ), + sa.Column("bound_at", sa.DateTime(), nullable=True), + sa.Column("recharged_at", sa.DateTime(), nullable=True), + sa.Column("scripted_at", sa.DateTime(), nullable=True), + sa.Column("done_at", sa.DateTime(), nullable=True), + sa.Column( + "void_reason", sa.String(length=256), nullable=False, server_default="" + ), + # 只读查询快照(不写 huya_accounts)。 + sa.Column("points", sa.Integer(), nullable=True), + sa.Column("elite_opened", sa.Boolean(), nullable=True), + sa.Column("snapshot_at", sa.DateTime(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("updated_by", sa.Integer(), nullable=True), + # 乐观锁版本号(每次写入 +1)。不用 updated_at 当锁:DATETIME 只到秒, + # 同一秒内的两次改动会撞成同一个值,锁会失效。 + sa.Column("version", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + # 订单号 / uid 唯一(uid 可空 → 多个 NULL 不冲突)。 + op.create_index( + "ix_huya_external_orders_order_no", + "huya_external_orders", + ["order_no"], + unique=True, + ) + op.create_index( + "ix_huya_external_orders_uid", "huya_external_orders", ["uid"], unique=True + ) + # 列表筛选维度。 + op.create_index("ix_huya_external_orders_shop", "huya_external_orders", ["shop"]) + op.create_index( + "ix_huya_external_orders_owner_id", "huya_external_orders", ["owner_id"] + ) + op.create_index( + "ix_huya_external_orders_status", "huya_external_orders", ["status"] + ) + op.create_index( + "ix_huya_external_orders_ck_status", "huya_external_orders", ["ck_status"] + ) + op.create_index( + "ix_huya_external_orders_created_by", "huya_external_orders", ["created_by"] + ) + # updated_at 兼作乐观锁版本号,也是默认排序键。 + op.create_index( + "ix_huya_external_orders_updated_at", "huya_external_orders", ["updated_at"] + ) + + +def downgrade() -> None: + bind = op.get_bind() + if "huya_external_orders" not in _tables(bind): + return + # MySQL 删表会连同索引一起删,不必逐个 drop_index。 + op.drop_table("huya_external_orders") diff --git a/web/backend/migrations/versions/20261016_0056_huya_order_bind_snapshot.py b/web/backend/migrations/versions/20261016_0056_huya_order_bind_snapshot.py new file mode 100644 index 0000000..0b5bb35 --- /dev/null +++ b/web/backend/migrations/versions/20261016_0056_huya_order_bind_snapshot.py @@ -0,0 +1,70 @@ +"""`huya_external_orders` 增加「探针读到的绑定对象」快照列。 + +背景(2026-10-16 真机确认):CK 探针 `check_user_bind_game_account` 返回的是**绑定在该 +虎牙号上的游戏账号**(账号昵称 / 角色名 / 平台·区服)。"这单到底绑了哪个号、哪个角色" +只有这里可信 —— 原来的 `bind_douyu_account` 是人工填的自由文本(Excel D 列)。 + +四个新列都由**探针独占**:每次验证覆盖写入,`bind_snapshot_at` 记读取时间; +人工不直接编辑(所以不在 EDITABLE_FIELDS 里)。 +老列 `bind_douyu_account` / `bind_douyu_uid` **保留不动**:Excel 粘贴导入还按它映射, +已填的历史数据也不能丢。 + +可重复执行:按列判断,缺哪列补哪列。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261016_0056" +down_revision: str | None = "20261016_0055" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "huya_external_orders" + +# (列名, DDL 类型) —— 顺序即 DDL 顺序,方便人工核对。 +_NEW_COLUMNS: tuple[tuple[str, sa.types.TypeEngine], ...] = ( + ("bind_game_account", sa.String(length=128)), + ("bind_game_role", sa.String(length=128)), + ("bind_game_channel", sa.String(length=128)), + ("bind_snapshot_at", sa.DateTime()), +) + + +def _has_table(bind) -> bool: + return TABLE in set(sa.inspect(bind).get_table_names()) + + +def _columns(bind) -> set[str]: + return {column["name"] for column in sa.inspect(bind).get_columns(TABLE)} + + +def upgrade() -> None: + bind = op.get_bind() + if not _has_table(bind): + return + existing = _columns(bind) + for name, column_type in _NEW_COLUMNS: + if name in existing: + continue + if name == "bind_snapshot_at": + op.add_column(TABLE, sa.Column(name, column_type, nullable=True)) + else: + # 文本快照列必须有 server_default:NOT NULL 且无默认值会让已有行写不进去。 + op.add_column( + TABLE, sa.Column(name, column_type, nullable=False, server_default="") + ) + + +def downgrade() -> None: + bind = op.get_bind() + if not _has_table(bind): + return + existing = _columns(bind) + for name, _column_type in reversed(_NEW_COLUMNS): + if name in existing: + op.drop_column(TABLE, name) diff --git a/web/backend/models.py b/web/backend/models.py index 7a5ed7d..ce5afa8 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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 + ) diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 7c991bd..2121d58 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -49,6 +49,10 @@ PERMISSIONS = { "huya:bind": "虎牙绑定操作", "huya:recharge": "虎牙充值操作", "huya:config": "虎牙配置管理", + # 外部 CK 协作台账(共享表,替代 Excel;见 docs/设计-虎牙外部CK协作台账.md) + "huya:collab:view": "查看外部 CK 协作台账", + "huya:collab:edit": "编辑外部 CK 协作台账", + "huya:collab:manage": "删除外部 CK 协作台账", # 代理 & 白名单 "proxy:manage": "代理配置管理", "whitelist:manage": "白名单配置管理", @@ -56,6 +60,9 @@ PERMISSIONS = { # 自动化 "automation:view": "查看自动化作业与运行历史", "automation:manage": "修改自动化配置、手动触发作业", + # 文件/附件(在线协作上传图片) + "file:view": "查看上传的文件", + "file:upload": "上传文件", # 系统 "system:settings": "系统设置", "audit:view": "查看审计日志", @@ -94,10 +101,16 @@ ROLE_PERMISSIONS = { "huya:bind", "huya:recharge", "huya:config", + # 协作台账是共享表:运营既能改也能删。 + "huya:collab:view", + "huya:collab:edit", + "huya:collab:manage", "proxy:manage", "whitelist:manage", "whitelist:test", "automation:view", + "file:view", + "file:upload", ], "support": [ "account:view_assigned", @@ -109,6 +122,11 @@ ROLE_PERMISSIONS = { "huya:view_assigned", "huya:tag", "huya:task", + # 客服能看能改(共享表,靠审计留痕),但删除只给运营。 + "huya:collab:view", + "huya:collab:edit", + "file:view", + "file:upload", ], } diff --git a/web/backend/routers/files.py b/web/backend/routers/files.py new file mode 100644 index 0000000..086af47 --- /dev/null +++ b/web/backend/routers/files.py @@ -0,0 +1,165 @@ +"""文件上传与回读(在线协作里的图片)。 + +- ``POST /api/files/upload`` multipart 上传,需要登录 + ``file:upload`` 权限; +- ``GET /api/files/object`` 由后端代理对象存储回读,需要登录 + ``file:view`` 权限。 + +两个接口都在 ``def``(同步)里实现:FastAPI 会把它们丢到线程池,图片解码与对象 +存储 IO 不会占住事件循环。 + +图片只用本服务的代理地址回读(``/api/files/object?key=...``),**不下发 OSS 直链**。 +前端 ```` 是同源请求,浏览器会自动带上登录 cookie,权限照样生效。 +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Iterator +from typing import Any + +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Query, + Request, + UploadFile, +) +from fastapi.responses import Response, StreamingResponse +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import require_permission +from ..models import User +from ..schemas import FileAssetOut +from ..services.audit_service import record_audit +from ..services.file_service import ( + FileUploadError, + get_stored_object, + upload_file_asset, +) +from ..services.file_storage import StorageUnavailableError, max_upload_size_bytes + +router = APIRouter(prefix="/api/files", tags=["files"]) + +_READ_CHUNK_SIZE = 64 * 1024 +# 私有文件缓存在浏览器侧只留 5 分钟:协作场景里图片可能被替换或下线。 +_PRIVATE_CACHE_CONTROL = "private, max-age=300" + + +@router.post("/upload", response_model=FileAssetOut) +def upload_file( + file: UploadFile = File(...), + scene: str = Form("misc"), + db: Session = Depends(get_db), + current: User = Depends(require_permission("file:upload")), +) -> FileAssetOut: + """上传一张图片(也接受 PDF)。 + + 先按上限多读 1 字节再判断,超限的请求不会把整个文件灌进内存。 + """ + limit = max_upload_size_bytes() + data = file.file.read(limit + 1) + if len(data) > limit: + raise HTTPException( + status_code=413, detail=f"文件不能超过 {limit // (1024 * 1024)}MB" + ) + + filename = (file.filename or "").strip() + try: + result = upload_file_asset( + db, + data=data, + filename=filename, + content_type=file.content_type or "", + scene=scene, + uploader_type="user", + uploader_id=str(current.id), + ) + except FileUploadError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + except StorageUnavailableError as exc: + raise HTTPException(status_code=503, detail="文件存储暂时不可用") from exc + + record_audit( + db, + current, + action="file:upload", + target=result.object_key, + detail={ + "scene": scene, + "filename": filename, + "content_type": result.content_type, + "size": result.size, + }, + ) + db.commit() + + return FileAssetOut( + object_key=result.object_key, + url=result.url, + thumbnail_url=result.thumbnail_url, + medium_url=result.medium_url, + filename=result.filename, + content_type=result.content_type, + size=result.size, + ) + + +@router.get("/object") +def read_file_object( + request: Request, + key: str = Query(..., min_length=1, max_length=512, description="对象 key"), + variant: str = Query("", description="thumb / medium;留空取原图"), + _current: User = Depends(require_permission("file:view")), +) -> Response: + """回读对象(变体缺失时自动回退原图),支持 ETag 304。""" + try: + stored = get_stored_object(key, variant) + except FileUploadError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + except StorageUnavailableError as exc: + raise HTTPException(status_code=503, detail="文件存储暂时不可用") from exc + + if stored.etag and request.headers.get("if-none-match") == stored.etag: + _close_quietly(stored.reader) + return Response(status_code=304, headers={"ETag": stored.etag}) + + headers: dict[str, str] = { + "Cache-Control": _PRIVATE_CACHE_CONTROL, + "X-Content-Type-Options": "nosniff", + # 图片/PDF 本身已压缩,GZip 中间件再压一遍只是白烧 CPU, + # 这里显式声明未编码,让中间件跳过(starlette 见到 Content-Encoding 就不压)。 + "Content-Encoding": "identity", + } + if stored.etag: + headers["ETag"] = stored.etag + if stored.size > 0: + headers["Content-Length"] = str(stored.size) + + return StreamingResponse( + _iter_reader(stored.reader), + media_type=stored.content_type or "application/octet-stream", + headers=headers, + ) + + +def _iter_reader(reader: Any) -> Iterator[bytes]: + """把存储侧的可读对象按固定块大小吐给客户端,结束时确保关闭。""" + try: + while True: + chunk = reader.read(_READ_CHUNK_SIZE) + if not chunk: + break + yield chunk + finally: + _close_quietly(reader) + + +def _close_quietly(reader: Any) -> None: + close = getattr(reader, "close", None) + if callable(close): + # 关闭失败不影响已经发出的响应(本地 BytesIO / S3 StreamingBody 都可能抛)。 + with contextlib.suppress(Exception): + close() diff --git a/web/backend/routers/huya/__init__.py b/web/backend/routers/huya/__init__.py index c3d8acf..8d0b6dd 100644 --- a/web/backend/routers/huya/__init__.py +++ b/web/backend/routers/huya/__init__.py @@ -19,6 +19,7 @@ from .accounts import ( set_account_tag, ) from .accounts import router as accounts_router +from .collab import router as collab_router from .cookies import router as cookies_router from .devices import router as devices_router from .login import ( @@ -63,6 +64,8 @@ router.include_router(cookies_router) router.include_router(workbench_router) router.include_router(devices_router) router.include_router(pay_router) +# 外部 CK 协作台账(共享表,替代 Excel) +router.include_router(collab_router) __all__ = [ "app_password_login_account", diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py new file mode 100644 index 0000000..4e7b7fe --- /dev/null +++ b/web/backend/routers/huya/collab.py @@ -0,0 +1,371 @@ +"""虎牙外部 CK 协作台账接口。 + +设计见 ``docs/设计-虎牙外部CK协作台账.md``。这一层只做三件事: +参数校验、权限、异常翻译;业务规则全在 ``services/huya_collab_service.py``。 + +权限: +- 查看 = ``huya:collab:view``(运营 + 客服) +- 编辑 = ``huya:collab:edit``(运营 + 客服;共享表,改动靠审计留痕) +- 删除 = ``huya:collab:manage``(**只给运营**) + +图片上传不在这里:前端直接调 ``POST /api/files/upload``,把返回的 object_key +提交给本模块的 images 接口。 + +CK 验证是**只读探针**(``POST .../verify`` 单条、``POST .../ck/verify`` 批量): +人工触发、串行带间隔,不建 ``HuyaAccount``、不改业务状态(设计文档 §5.2)。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from ...database import get_db +from ...deps import require_permission +from ...models import AuditLog, HuyaExternalOrder, User +from ...schemas import ( + HuyaExternalOrderAuditOut, + HuyaExternalOrderCkRequest, + HuyaExternalOrderCkStatusRequest, + HuyaExternalOrderCreate, + HuyaExternalOrderImagesRequest, + HuyaExternalOrderImportRequest, + HuyaExternalOrderListOut, + HuyaExternalOrderOut, + HuyaExternalOrderOwnerOut, + HuyaExternalOrderStatusRequest, + HuyaExternalOrderSummaryOut, + HuyaExternalOrderUpdate, + HuyaExternalOrderVerifyOut, + HuyaExternalOrderVerifyRequest, +) +from ...services import huya_collab_service as collab + +router = APIRouter() + +_AUDIT_TARGET_PREFIX = "huya_external_order:" +_AUDIT_TIMELINE_LIMIT = 100 + + +def _http_error(exc: collab.CollabError) -> HTTPException: + return HTTPException(status_code=exc.status_code, detail=exc.detail) + + +def _serialize_one(db: Session, order: HuyaExternalOrder) -> dict: + return collab.serialize_order(order, users=collab.users_by_id(db, [order])) + + +# -------------------------------------------------------------------------- +# 字面量路径必须声明在 /{order_id} 之前,否则 "summary" 会被当成 id +# -------------------------------------------------------------------------- + + +@router.get("/external-orders/summary", response_model=HuyaExternalOrderSummaryOut) +def external_orders_summary( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("huya:collab:view")), +): + """页头统计条:各业务状态计数 + CK 失效数。""" + return collab.summary(db) + + +@router.get("/external-orders/owners", response_model=list[HuyaExternalOrderOwnerOut]) +def external_orders_owners( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("huya:collab:view")), +): + """负责人候选:全部活跃用户(不是权限边界,只是筛选/指派用)。""" + rows = ( + db.query(User.id, User.username) + .filter(User.is_active.is_(True)) + .order_by(User.username) + .all() + ) + return [{"id": int(row[0]), "username": str(row[1])} for row in rows] + + +@router.get("/external-orders/shops", response_model=list[str]) +def external_orders_shops( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("huya:collab:view")), +): + """已用过的店铺列表(筛选下拉)。""" + return collab.list_shops(db) + + +@router.get("/external-orders", response_model=HuyaExternalOrderListOut) +def list_external_orders( + shop: str = Query("", max_length=64), + owner_id: int | None = Query(None), + status: str = Query("", max_length=16), + ck_status: str = Query("", max_length=16), + keyword: str = Query("", max_length=128), + include_void: bool = Query(False, description="是否包含已作废的行(默认隐藏)"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + _current: User = Depends(require_permission("huya:collab:view")), +): + """共享视图:默认不按人过滤(负责人只是筛选维度,不是权限边界)。""" + rows, total = collab.list_orders( + db, + shop=shop, + owner_id=owner_id, + status=status, + ck_status=ck_status, + keyword=keyword, + include_void=include_void, + page=page, + page_size=page_size, + ) + names = collab.users_by_id(db, rows) + return { + "items": [collab.serialize_order(row, users=names) for row in rows], + "total": total, + "page": page, + "page_size": page_size, + } + + +@router.post("/external-orders", response_model=HuyaExternalOrderOut) +def create_external_order( + req: HuyaExternalOrderCreate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """新建一行(可同时贴一份外部 CK)。订单号唯一,重复会 409。""" + try: + order = collab.create_order( + db, + current, + order_no=req.order_no, + shop=req.shop, + owner_id=req.owner_id, + ck_text=req.ck_text, + bind_douyu_account=req.bind_douyu_account, + gift_name=req.gift_name, + remark=req.remark, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + return _serialize_one(db, order) + + +@router.post("/external-orders/import") +def import_external_orders( + req: HuyaExternalOrderImportRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """Excel 粘贴批量导入。单行失败只记在 ``errors`` 里,不影响其他行。""" + owners = { + str(name): int(user_id) + for user_id, name in db.query(User.id, User.username) + .filter(User.is_active.is_(True)) + .all() + } + return collab.import_orders(db, current, req.text, owner_by_name=owners) + + +@router.post("/external-orders/ck/verify", response_model=HuyaExternalOrderVerifyOut) +def verify_external_orders_ck( + req: HuyaExternalOrderVerifyRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """批量验证 CK(只读探针):人工触发、串行、带间隔,不做定时轮询。 + + 单条失败只记在 ``results`` 里,不影响其他条(AGENTS §2)。探针只读, + **不会**建 ``HuyaAccount``、也不会改业务状态。 + """ + try: + orders = collab.list_orders_by_ids(db, req.ids) + return collab.verify_ck_batch(db, current, orders) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +# -------------------------------------------------------------------------- +# 单行操作 +# -------------------------------------------------------------------------- + + +@router.get("/external-orders/{order_id}", response_model=HuyaExternalOrderOut) +def get_external_order( + order_id: int, + db: Session = Depends(get_db), + _current: User = Depends(require_permission("huya:collab:view")), +): + try: + order = collab.get_order(db, order_id) + except collab.CollabError as exc: + raise _http_error(exc) from exc + return _serialize_one(db, order) + + +@router.get( + "/external-orders/{order_id}/timeline", + response_model=list[HuyaExternalOrderAuditOut], +) +def external_order_timeline( + order_id: int, + db: Session = Depends(get_db), + _current: User = Depends(require_permission("huya:collab:view")), +): + """操作时间线:直接读 ``audit_logs``,不另建事件表。""" + rows = ( + db.query(AuditLog) + .filter(AuditLog.target == f"{_AUDIT_TARGET_PREFIX}{order_id}") + .order_by(AuditLog.id.desc()) + .limit(_AUDIT_TIMELINE_LIMIT) + .all() + ) + return [ + { + "id": row.id, + "action": row.action, + "username": row.username or "", + "detail": row.detail or "", + "success": row.success, + "created_at": row.created_at, + } + for row in rows + ] + + +@router.put("/external-orders/{order_id}", response_model=HuyaExternalOrderOut) +def update_external_order( + order_id: int, + req: HuyaExternalOrderUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """编辑(部分字段)。带 ``expected_version`` 做乐观锁,冲突返回 409。""" + try: + order = collab.get_order(db, order_id) + payload = req.model_dump(exclude_unset=True) + expected_version = payload.pop("expected_version", None) + collab.update_order( + db, current, order, payload, expected_version=expected_version + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + return _serialize_one(db, order) + + +@router.post("/external-orders/{order_id}/status", response_model=HuyaExternalOrderOut) +def update_external_order_status( + order_id: int, + req: HuyaExternalOrderStatusRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """推进/回退业务状态。回退必须显式带 ``rollback=true``。""" + try: + order = collab.get_order(db, order_id) + collab.assert_version(order, req.expected_version) + collab.set_status( + db, + current, + order, + req.status, + reason=req.reason, + rollback=req.rollback, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + return _serialize_one(db, order) + + +@router.post("/external-orders/{order_id}/ck", response_model=HuyaExternalOrderOut) +def update_external_order_ck( + order_id: int, + req: HuyaExternalOrderCkRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """贴入/换 CK(``action=paste``)或从 raw_cookie 重建(``action=rebuild``)。""" + try: + order = collab.get_order(db, order_id) + collab.assert_version(order, req.expected_version) + if req.action == "rebuild": + collab.rebuild_ck(db, current, order) + else: + collab.set_ck(db, current, order, req.ck_text) + except collab.CollabError as exc: + raise _http_error(exc) from exc + return _serialize_one(db, order) + + +@router.post( + "/external-orders/{order_id}/ck/status", response_model=HuyaExternalOrderOut +) +def update_external_order_ck_status( + order_id: int, + req: HuyaExternalOrderCkStatusRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """人工停用 / 恢复这份 CK(``disabled`` / ``unverified``)。 + + ``valid`` / ``invalid`` **只能**由只读探针给(``POST .../verify``): + 人工标「有效」等于绕过验证(设计文档 §4.2)。 + """ + try: + order = collab.get_order(db, order_id) + collab.assert_version(order, req.expected_version) + collab.set_manual_ck_status(db, current, order, req.status, message=req.message) + except collab.CollabError as exc: + raise _http_error(exc) from exc + return _serialize_one(db, order) + + +@router.post("/external-orders/{order_id}/verify", response_model=HuyaExternalOrderOut) +def verify_external_order_ck( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """验证 CK(只读探针):写 ``ck_status`` / ``ck_checked_at`` / ``ck_check_message``。 + + 成功时顺手补 ``bind_douyu_account`` / ``bind_douyu_uid``(**只补空值**)。 + CK 失效**不会**自动改业务状态,列表上只做显式提示。 + """ + try: + order = collab.get_order(db, order_id) + collab.verify_ck(db, current, order) + except collab.CollabError as exc: + raise _http_error(exc) from exc + return _serialize_one(db, order) + + +@router.post("/external-orders/{order_id}/images", response_model=HuyaExternalOrderOut) +def update_external_order_images( + order_id: int, + req: HuyaExternalOrderImagesRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """整槽替换图片(提交 ``/api/files/upload`` 返回的 object_key 数组)。""" + try: + order = collab.get_order(db, order_id) + collab.assert_version(order, req.expected_version) + collab.set_images(db, current, order, req.slot, req.images) + except collab.CollabError as exc: + raise _http_error(exc) from exc + return _serialize_one(db, order) + + +@router.delete("/external-orders/{order_id}") +def delete_external_order( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:manage")), +): + """删除一行(**只给运营**)。只删行,不删 OSS 里的图(交给孤儿清理脚本)。""" + try: + order = collab.get_order(db, order_id) + snapshot = collab.delete_order(db, current, order) + except collab.CollabError as exc: + raise _http_error(exc) from exc + return {"success": True, "order_no": snapshot["order_no"]} diff --git a/web/backend/schemas.py b/web/backend/schemas.py index d488da8..e8d0dac 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1064,3 +1064,210 @@ class AppInfo(BaseModel): 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 + + +# ---- 虎牙外部 CK 协作台账(见 docs/设计-虎牙外部CK协作台账.md) ---- +class HuyaExternalOrderCreate(BaseModel): + """新建一行(可同时贴一份外部 CK)。""" + + order_no: str = Field(..., min_length=1, max_length=64) + shop: str = Field("", max_length=64) + owner_id: int | None = None + ck_text: str = "" + bind_douyu_account: str = Field("", max_length=128) + gift_name: str = Field("", max_length=128) + 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) + 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 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 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 = "" + remark: str = "" + + status: str = "pending" + 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 + void_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 + + +class HuyaExternalOrderListOut(BaseModel): + """列表响应:共享视图,带上总数供分页使用。""" + + items: list[HuyaExternalOrderOut] + total: int + page: int + page_size: int + + +class HuyaExternalOrderSummaryOut(BaseModel): + """页头统计条。""" + + total: int + by_status: dict[str, int] + ck_invalid: int + + +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 diff --git a/web/backend/services/file_service.py b/web/backend/services/file_service.py new file mode 100644 index 0000000..4128407 --- /dev/null +++ b/web/backend/services/file_service.py @@ -0,0 +1,378 @@ +"""上传服务:校验 → 生成对象 key → 落对象存储 → 生成缩略图变体 → 落台账。 + +流程与两个参考实现对齐(order_site 的 ``file-storage-service.ts``、 +hfb_sys 的 ``modules/file/service.go``): + +1. 内存里收下整个文件(上限 10MB),先判大小再**按魔数嗅探真实类型**(不信 + Content-Type); +2. ``objectKey = scene/yyyy/mm/dd/<24位随机hex>`` —— 随机段保证对象不可枚举; +3. 原图入库后再生成长边 480/1280 的 ``thumb`` / ``medium`` 变体,**变体失败不阻断 + 上传**(回读接口会自动回退原图); +4. 任何一步失败都把已经写进对象存储的 key 全部删掉,避免留下孤儿对象; +5. 元数据落 ``file_assets`` 台账,便于溯源与后续清理。 +""" + +from __future__ import annotations + +import io +import re +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any +from urllib.parse import quote + +from loguru import logger +from PIL import Image, ImageOps, features +from sqlalchemy.orm import Session + +from ..models import FileAsset +from .file_storage import ( + ObjectStorage, + StorageSettings, + StoredObject, + get_object_storage, + max_upload_size_bytes, + normalize_object_key, +) + +ALLOWED_CONTENT_TYPES = frozenset( + {"image/jpeg", "image/png", "image/webp", "application/pdf"} +) +IMAGE_CONTENT_TYPES = frozenset({"image/jpeg", "image/png", "image/webp"}) + +VARIANT_THUMB = "thumb" +VARIANT_MEDIUM = "medium" +_VARIANT_NAMES = (VARIANT_THUMB, VARIANT_MEDIUM) +_VARIANT_MAX_SIDE = {VARIANT_THUMB: 480, VARIANT_MEDIUM: 1280} +_VARIANT_QUALITY = {VARIANT_THUMB: 76, VARIANT_MEDIUM: 82} + +_EXTENSION_BY_CONTENT_TYPE = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "application/pdf": ".pdf", +} + +_SCENE_PATTERN = re.compile(r"[^a-z0-9_-]+") +_SAFE_EXT_PATTERN = re.compile(r"^\.[a-z0-9]{1,8}$") +_MAX_SCENE_LENGTH = 32 + + +class FileUploadError(RuntimeError): + """上传/读取的业务错误,路由层据此转成对应 HTTP 状态码。""" + + def __init__(self, detail: str, *, status_code: int = 400, code: str = ""): + super().__init__(detail) + self.detail = detail + self.status_code = status_code + self.code = code + + +@dataclass(frozen=True) +class ImageVariant: + """一张缩略图变体(内容保存在内存里,随原图一起写入对象存储)。""" + + name: str + content: bytes + content_type: str + + +@dataclass(frozen=True) +class UploadedFile: + """上传结果,同时作为接口响应体。""" + + object_key: str + url: str + thumbnail_url: str + medium_url: str + filename: str + content_type: str + size: int + + +def normalize_scene(raw_scene: Any) -> str: + """scene 归一化:只留小写字母/数字/下划线/连字符,缺省 misc。""" + scene = _SCENE_PATTERN.sub("-", str(raw_scene or "").strip().lower()).strip("-") + return (scene or "misc")[:_MAX_SCENE_LENGTH] + + +def sniff_content_type(data: bytes) -> str: + """按文件头魔数判断类型;识别不出返回空串。""" + if data[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return "image/png" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp" + if data[:4] == b"%PDF": + return "application/pdf" + return "" + + +def resolve_content_type(hint: Any, data: bytes) -> str: + """先信魔数,再信调用方给的 Content-Type。""" + detected = sniff_content_type(data) + if detected: + return detected + normalized = str(hint or "").strip().lower().split(";")[0] + return normalized or "application/octet-stream" + + +def build_object_key(scene: str, content_type: str, filename: str = "") -> str: + """``scene/yyyy/mm/dd/<24位随机hex>``。""" + now = datetime.now(UTC) + token = secrets.token_hex(12) + return f"{scene}/{now.year:04d}/{now.month:02d}/{now.day:02d}/{token}{_pick_extension(content_type, filename)}" + + +def image_variant_key(object_key: str, variant: str, extension: str) -> str: + """变体 key:在扩展名前插入 ``.``。""" + base = object_key + for known in _EXTENSION_BY_CONTENT_TYPE.values(): + if object_key.lower().endswith(known): + base = object_key[: -len(known)] + break + return f"{base}.{variant}{extension}" + + +def image_variant_fallback_keys(object_key: str, variant: str) -> list[str]: + """变体 key 的候选顺序:webp → jpg → png → 原图。 + + 变体的扩展名取决于源图是否有透明通道(png/webp 保 alpha 出 webp, + 其余出 jpg),而回读接口只知道基础 key,所以这里按候选逐个试。 + """ + if variant not in _VARIANT_NAMES: + return [object_key] + candidates = [ + image_variant_key(object_key, variant, ext) for ext in (".webp", ".jpg", ".png") + ] + candidates.append(object_key) + return list(dict.fromkeys(candidates)) + + +def build_file_url(object_key: str, variant: str = "") -> str: + """文件访问地址(走后端代理,不暴露对象存储直链)。""" + url = f"/api/files/object?key={quote(normalize_object_key(object_key), safe='')}" + if variant: + url = f"{url}&variant={quote(variant, safe='')}" + return url + + +def _webp_available() -> bool: + return bool(features.check("webp")) + + +def _pick_extension(content_type: str, filename: str) -> str: + known = _EXTENSION_BY_CONTENT_TYPE.get(content_type) + if known: + return known + candidate = "" + if "." in filename: + candidate = filename[filename.rfind(".") :].lower() + return candidate if _SAFE_EXT_PATTERN.match(candidate) else ".bin" + + +def _load_image(data: bytes) -> Image.Image | None: + try: + image = Image.open(io.BytesIO(data)) + image.load() + except Exception: # noqa: BLE001 坏图不该让整次上传失败 + return None + try: + # 手机截图常带 EXIF 方向,先摆正再缩放(对齐 sharp 的 .rotate())。 + return ImageOps.exif_transpose(image) + except Exception: # noqa: BLE001 + return image + + +def _encode_variant( + image: Image.Image, *, preserve_alpha: bool, quality: int +) -> tuple[bytes, str]: + buffer = io.BytesIO() + if preserve_alpha: + prepared = image.convert("RGBA") + if _webp_available(): + prepared.save(buffer, "WEBP", quality=quality, method=4) + return buffer.getvalue(), "image/webp" + prepared.save(buffer, "PNG", optimize=True) + return buffer.getvalue(), "image/png" + # JPEG 没有透明通道;源图也只会是无 alpha 的 JPEG,直接转 RGB 即可。 + image.convert("RGB").save(buffer, "JPEG", quality=quality, optimize=True) + return buffer.getvalue(), "image/jpeg" + + +def generate_image_variants(data: bytes, content_type: str) -> list[ImageVariant]: + """生成 thumb/medium 变体;不是图片或解码失败时返回空列表。""" + if content_type not in IMAGE_CONTENT_TYPES: + return [] + source = _load_image(data) + if source is None: + logger.warning(f"图片解码失败,跳过变体生成 content_type={content_type}") + return [] + + # 与两个参考实现一致:png/webp 视为可能带透明通道,保留 alpha 输出 webp。 + preserve_alpha = content_type in ("image/png", "image/webp") + variants: list[ImageVariant] = [] + try: + for name in _VARIANT_NAMES: + max_side = _VARIANT_MAX_SIDE[name] + try: + frame = source.copy() + frame.thumbnail((max_side, max_side), Image.Resampling.LANCZOS) + content, variant_type = _encode_variant( + frame, preserve_alpha=preserve_alpha, quality=_VARIANT_QUALITY[name] + ) + frame.close() + except Exception as exc: # noqa: BLE001 单张变体失败不影响其余变体 + logger.warning(f"生成图片变体失败 variant={name}: {exc}") + continue + if content: + variants.append( + ImageVariant(name=name, content=content, content_type=variant_type) + ) + finally: + source.close() + return variants + + +def upload_file_asset( + db: Session, + *, + data: bytes, + filename: str = "", + content_type: str = "", + scene: str = "misc", + uploader_type: str = "user", + uploader_id: str = "", + settings: StorageSettings | None = None, +) -> UploadedFile: + """校验并写入一份上传文件。 + + ``settings`` 显式传入时可覆盖环境配置(测试用非默认本地目录)。 + """ + if not data: + raise FileUploadError( + "请选择要上传的文件", status_code=400, code="file_required" + ) + + limit = max_upload_size_bytes(settings) + if len(data) > limit: + raise FileUploadError( + f"文件不能超过 {limit // (1024 * 1024)}MB", + status_code=400, + code="file_too_large", + ) + + resolved_type = resolve_content_type(content_type, data) + if resolved_type not in ALLOWED_CONTENT_TYPES: + raise FileUploadError( + "文件不符合规则,仅支持 JPG、PNG、WebP 或 PDF", + status_code=400, + code="file_type_invalid", + ) + + resolved_scene = normalize_scene(scene) + object_key = build_object_key(resolved_scene, resolved_type, filename) + storage: ObjectStorage = get_object_storage(settings) + + uploaded_keys: list[str] = [] + try: + uploaded_keys.append(object_key) + storage.put_object( + key=object_key, + data=data, + content_type=resolved_type, + metadata={"original-filename": filename, "scene": resolved_scene}, + ) + + thumbnail_key = "" + medium_key = "" + for variant in generate_image_variants(data, resolved_type): + variant_key = image_variant_key( + object_key, variant.name, _pick_extension(variant.content_type, "") + ) + try: + storage.put_object( + key=variant_key, + data=variant.content, + content_type=variant.content_type, + metadata={"source-object": object_key}, + ) + except Exception as exc: # noqa: BLE001 变体失败不阻断上传 + logger.warning(f"变体写入失败 key={variant_key}: {exc}") + continue + uploaded_keys.append(variant_key) + if variant.name == VARIANT_THUMB: + thumbnail_key = variant_key + elif variant.name == VARIANT_MEDIUM: + medium_key = variant_key + + row = FileAsset( + object_key=object_key, + scene=resolved_scene, + original_filename=filename[:255], + content_type=resolved_type, + size_bytes=len(data), + thumbnail_object_key=thumbnail_key, + medium_object_key=medium_key, + uploader_type=str(uploader_type or "")[:32], + uploader_id=str(uploader_id or "")[:64], + status="active", + metadata_json={ + "originalContentType": content_type or "", + "scene": resolved_scene, + }, + ) + db.add(row) + db.commit() + except FileUploadError: + raise + except Exception as exc: + db.rollback() + _cleanup_objects(storage, uploaded_keys) + logger.error(f"文件上传失败,已回滚对象: {exc}") + raise + + return UploadedFile( + object_key=object_key, + url=build_file_url(object_key), + thumbnail_url=build_file_url(thumbnail_key or object_key), + medium_url=build_file_url(medium_key or object_key), + filename=filename, + content_type=resolved_type, + size=len(data), + ) + + +def get_stored_object(object_key: Any, variant: Any = "") -> StoredObject: + """读取对象(变体缺失自动回退原图)。""" + try: + key = normalize_object_key(object_key) + except ValueError as exc: + raise FileUploadError( + "文件 key 不正确", status_code=400, code="file_key_invalid" + ) from exc + + variant_name = str(variant or "").strip().lower() + candidates = ( + image_variant_fallback_keys(key, variant_name) if variant_name else [key] + ) + storage = get_object_storage() + for candidate in candidates: + try: + return storage.get_object(candidate) + except FileNotFoundError: + continue + raise FileUploadError( + "文件不存在或暂不可访问", status_code=404, code="file_not_found" + ) + + +def _cleanup_objects(storage: ObjectStorage, keys: list[str]) -> None: + """补偿删除:删不干净只告警,不再抛异常掩盖原始错误。""" + for key in keys: + try: + storage.delete_object(key) + except Exception as exc: # noqa: BLE001 + logger.warning(f"上传失败后的对象清理未成功 key={key}: {exc}") diff --git a/web/backend/services/file_storage.py b/web/backend/services/file_storage.py new file mode 100644 index 0000000..f7a8a36 --- /dev/null +++ b/web/backend/services/file_storage.py @@ -0,0 +1,434 @@ +"""对象存储适配层:本地目录后端 + S3 兼容对象存储(阿里云 OSS / MinIO)。 + +约定对齐两个参考实现(order_site 的 ``apps/backend/src/services/file-storage/*``、 +hfb_sys 的 ``backend/internal/modules/file/storage.go``): + +- **服务端代理读写**:对外只暴露 ``/api/files/object?key=...``,不下发 OSS 直链, + 也不发签名链接 —— 签名链接会被业务方原样存进库里,过期后到处裂图; +- **内网 endpoint**:ECS 与桶同地域时用 ``oss--internal.aliyuncs.com``, + 免公网流量费且延迟低; +- **寻址风格**:阿里云 OSS 必须 virtual-host style,自建 MinIO 走 path-style; +- **S3 校验头**:OSS 的 S3 兼容层不认 botocore 默认的 CRC32 请求校验头,需要关掉。 + +boto3 只在真正走 OSS 时才导入:本地开发/测试用 ``local`` 后端,没装 boto3 也能跑。 +""" + +from __future__ import annotations + +import hashlib +import io +import os +import threading +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, BinaryIO, Protocol +from urllib.parse import quote, urlsplit + +from loguru import logger + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +DEFAULT_LOCAL_ROOT = PROJECT_ROOT / "data" / "file_storage" + +MAX_UPLOAD_SIZE_MB_DEFAULT = 10 + + +class FileStorageError(RuntimeError): + """对象存储不可用(配置缺失、依赖缺失或远端拒绝)。""" + + +class StorageUnavailableError(FileStorageError): + """存储后端没有配置好或连不上,属于「暂时不可用」而非「文件不存在」。""" + + +@dataclass(frozen=True) +class StorageSettings: + """从环境变量读到的存储配置。""" + + mode: str + endpoint: str + bucket: str + access_key_id: str + secret_access_key: str + region: str + local_root: Path + max_upload_size_mb: int + + @property + def is_remote(self) -> bool: + return self.mode == "oss" + + +@dataclass(frozen=True) +class StoredObject: + """读取到的对象:调用方负责关闭 ``reader``。""" + + reader: BinaryIO + content_type: str + size: int + etag: str = "" + last_modified: datetime | None = None + + +class ObjectStorage(Protocol): + """存储后端接口。缺 key 一律抛 ``FileNotFoundError``。""" + + def put_object( + self, + *, + key: str, + data: bytes, + content_type: str, + metadata: dict[str, str] | None = None, + ) -> None: ... + + def get_object(self, key: str) -> StoredObject: ... + + def delete_object(self, key: str) -> None: ... + + def ensure_ready(self) -> None: ... + + +def _env_int(name: str, default: int) -> int: + """读取正整数环境变量,非法值回退到默认值。""" + try: + value = int(os.getenv(name, str(default))) + except ValueError: + return default + return value if value > 0 else default + + +def load_storage_settings() -> StorageSettings: + """读取 ``OSS_*`` 环境变量。未知 mode 一律回退到 local。 + + 变量名沿用仓库既有约定(compose 早已透传、生产 ``.env`` 里也已写入): + ``OSS_ENDPOINT`` 用内网端点,``OSS_BUCKET`` 是本项目自己的桶。 + """ + raw_mode = (os.getenv("OSS_MODE", "") or "").strip().lower() + mode = raw_mode if raw_mode in ("local", "oss") else "local" + endpoint = (os.getenv("OSS_ENDPOINT", "") or "").strip() + local_root = (os.getenv("OSS_LOCAL_ROOT", "") or "").strip() + region = (os.getenv("OSS_REGION", "") or "").strip() or _derive_region(endpoint) + return StorageSettings( + mode=mode, + endpoint=endpoint, + bucket=(os.getenv("OSS_BUCKET", "") or "").strip(), + access_key_id=(os.getenv("OSS_ACCESS_KEY_ID", "") or "").strip(), + secret_access_key=(os.getenv("OSS_ACCESS_KEY_SECRET", "") or "").strip(), + region=region, + local_root=Path(local_root) if local_root else DEFAULT_LOCAL_ROOT, + max_upload_size_mb=_env_int( + "OSS_MAX_UPLOAD_SIZE_MB", MAX_UPLOAD_SIZE_MB_DEFAULT + ), + ) + + +def _derive_region(endpoint: str) -> str: + """从 endpoint 反推 region。 + + ``oss-cn-hangzhou-internal.aliyuncs.com`` → ``oss-cn-hangzhou``。 + 生产 ``.env`` 只要求配 endpoint/bucket/AK 三项,但 sigv4 需要 region, + 所以没显式配 ``OSS_REGION`` 时按端点推导,省一个必填项、也避免填错。 + """ + if not endpoint: + return "" + candidate = endpoint if "//" in endpoint else f"//{endpoint}" + host = urlsplit(candidate).hostname or "" + label = host.split(".")[0] + if label.startswith("oss-") and label.endswith("-internal"): + return label[: -len("-internal")] + return label if label.startswith("oss-") else "" + + +def max_upload_size_bytes(settings: StorageSettings | None = None) -> int: + """上传大小上限(字节)。""" + resolved = settings or load_storage_settings() + return max(1, resolved.max_upload_size_mb) * 1024 * 1024 + + +def normalize_object_key(raw_key: Any) -> str: + """归一化对象 key 并挡住路径穿越。 + + 与 order_site 的 ``normalizeObjectKey`` 同一套判据:去掉前导斜杠, + 出现 ``..`` 或反斜杠直接拒绝。 + """ + key = str(raw_key or "").strip().lstrip("/") + if not key or ".." in key or "\\" in key: + raise ValueError("文件 key 不正确") + return key + + +class LocalObjectStorage: + """本地目录后端:仅用于开发与测试,生产走 OSS。 + + 内容类型按扩展名推断(本地模式下原图与变体的扩展名都由上传流程按 MIME 生成, + 因此推断是可靠的);etag 用内容 md5,便于回读接口做 304。 + """ + + def __init__(self, root: Path): + self._root = Path(root) + + @property + def root(self) -> Path: + return self._root + + def _path_for(self, key: str) -> Path: + return self._root / normalize_object_key(key) + + def ensure_ready(self) -> None: + self._root.mkdir(parents=True, exist_ok=True) + + def put_object( + self, + *, + key: str, + data: bytes, + content_type: str, + metadata: dict[str, str] | None = None, + ) -> None: + del content_type, metadata # 本地后端不保存对象元数据 + path = self._path_for(key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + def get_object(self, key: str) -> StoredObject: + path = self._path_for(key) + try: + data = path.read_bytes() + except FileNotFoundError as exc: + raise FileNotFoundError(key) from exc + return StoredObject( + reader=io.BytesIO(data), + content_type=_guess_content_type(path.name), + size=len(data), + etag=f'"{hashlib.md5(data).hexdigest()}"', + last_modified=datetime.fromtimestamp(path.stat().st_mtime, UTC), + ) + + def delete_object(self, key: str) -> None: + path = self._path_for(key) + try: + path.unlink() + except FileNotFoundError: + return + + +class S3ObjectStorage: + """S3 兼容后端(阿里云 OSS / MinIO)。boto3 延迟导入。""" + + def __init__(self, settings: StorageSettings): + self._settings = settings + self._client: Any = None + self._bucket_ready = False + self._lock = threading.Lock() + + @property + def is_aliyun(self) -> bool: + return "aliyuncs.com" in self._settings.endpoint.lower() + + def _build_client(self) -> Any: + settings = self._settings + if not (settings.endpoint and settings.bucket): + raise StorageUnavailableError( + "对象存储未配置:需要同时设置 OSS_ENDPOINT 与 OSS_BUCKET" + ) + try: + import boto3 + except ImportError as exc: # pragma: no cover - 取决于部署环境 + raise StorageUnavailableError( + "缺少 boto3 依赖,无法使用 OSS 存储(执行 uv sync 安装后重启服务)" + ) from exc + + from botocore.config import Config as BotoConfig + + addons: dict[str, Any] = {} + try: + # 新版 botocore 才支持这两个开关;旧版直接省略。 + addons = { + "request_checksum_calculation": "when_required", + "response_checksum_validation": "when_required", + } + config = BotoConfig( + signature_version="s3v4", + s3={"addressing_style": "virtual" if self.is_aliyun else "path"}, + retries={"max_attempts": 3, "mode": "standard"}, + connect_timeout=_env_int("OSS_CONNECT_TIMEOUT", 5), + read_timeout=_env_int("OSS_READ_TIMEOUT", 30), + max_pool_connections=_env_int("OSS_MAX_POOL_CONNECTIONS", 20), + **addons, + ) + except TypeError: # pragma: no cover - 仅旧版 botocore 走到 + config = BotoConfig( + signature_version="s3v4", + s3={"addressing_style": "virtual" if self.is_aliyun else "path"}, + ) + + return boto3.client( + "s3", + endpoint_url=_normalize_endpoint_url(settings.endpoint, self.is_aliyun), + aws_access_key_id=settings.access_key_id or None, + aws_secret_access_key=settings.secret_access_key or None, + region_name=settings.region or None, + config=config, + ) + + def _get_client(self) -> Any: + if self._client is None: + with self._lock: + if self._client is None: + self._client = self._build_client() + return self._client + + def ensure_ready(self) -> None: + """确保桶可用。 + + 阿里云 OSS 的桶由控制台预先创建 —— HeadBucket 在 RAM 权限收紧时会 403 + 甚至挂起,所以直接跳过;自建 MinIO 才做「不存在就建」。 + """ + if self._bucket_ready or self.is_aliyun: + self._bucket_ready = True + return + client = self._get_client() + with self._lock: + if self._bucket_ready: + return + try: + client.head_bucket(Bucket=self._settings.bucket) + except Exception as exc: # 兼容层错误码不规范,只能按形状兜底 + if not _is_not_found(exc): + raise + client.create_bucket(Bucket=self._settings.bucket) + self._bucket_ready = True + + def put_object( + self, + *, + key: str, + data: bytes, + content_type: str, + metadata: dict[str, str] | None = None, + ) -> None: + self.ensure_ready() + try: + self._get_client().put_object( + Bucket=self._settings.bucket, + Key=normalize_object_key(key), + Body=data, + ContentType=content_type or "application/octet-stream", + **({"Metadata": _sanitize_metadata(metadata)} if metadata else {}), + ) + except Exception as exc: # 边界统一转成存储异常 + raise StorageUnavailableError(f"对象写入失败: {exc}") from exc + + def get_object(self, key: str) -> StoredObject: + self.ensure_ready() + try: + output = self._get_client().get_object( + Bucket=self._settings.bucket, Key=normalize_object_key(key) + ) + except Exception as exc: # 边界统一转成存储异常 + if _is_not_found(exc): + raise FileNotFoundError(key) from exc + raise StorageUnavailableError(f"对象读取失败: {exc}") from exc + + body = output.get("Body") + if body is None: + raise FileNotFoundError(key) + return StoredObject( + reader=body, + content_type=str(output.get("ContentType") or "application/octet-stream"), + size=int(output.get("ContentLength") or 0), + etag=str(output.get("ETag") or ""), + last_modified=output.get("LastModified"), + ) + + def delete_object(self, key: str) -> None: + self.ensure_ready() + try: + self._get_client().delete_object( + Bucket=self._settings.bucket, Key=normalize_object_key(key) + ) + except Exception as exc: # noqa: BLE001 删除失败不该打断业务异常链 + logger.warning(f"对象删除失败 key={key}: {exc}") + + +_storage: ObjectStorage | None = None +_storage_lock = threading.Lock() + + +def _build_storage(settings: StorageSettings) -> ObjectStorage: + """按配置构造后端:oss → S3 兼容,其余 → 本地目录。""" + if settings.is_remote: + return S3ObjectStorage(settings) + return LocalObjectStorage(settings.local_root) + + +def get_object_storage(settings: StorageSettings | None = None) -> ObjectStorage: + """返回当前配置对应的存储后端;显式传 settings 时不走单例(测试用)。""" + global _storage + if settings is not None: + return _build_storage(settings) + if _storage is None: + with _storage_lock: + if _storage is None: + _storage = _build_storage(load_storage_settings()) + return _storage + + +def reset_object_storage_cache() -> None: + """清空单例 —— 测试里切换 ``STORAGE_*`` 环境变量后调用。""" + global _storage + with _storage_lock: + _storage = None + + +def _guess_content_type(filename: str) -> str: + lowered = filename.lower() + if lowered.endswith((".jpg", ".jpeg")): + return "image/jpeg" + if lowered.endswith(".png"): + return "image/png" + if lowered.endswith(".webp"): + return "image/webp" + if lowered.endswith(".pdf"): + return "application/pdf" + return "application/octet-stream" + + +def _normalize_endpoint_url(endpoint: str, is_aliyun: bool) -> str: + """补全 scheme:阿里云走 https,自建 MinIO 走 http。""" + candidate = endpoint.strip() + if not candidate: + raise StorageUnavailableError("对象存储 endpoint 未配置") + if not candidate.startswith(("http://", "https://")): + candidate = f"{'https' if is_aliyun else 'http'}://{candidate}" + parsed = urlsplit(candidate) + if not parsed.hostname: + raise StorageUnavailableError(f"对象存储 endpoint 不合法: {endpoint}") + return candidate.rstrip("/") + + +def _sanitize_metadata(metadata: dict[str, str] | None) -> dict[str, str]: + """元数据会作为 HTTP header 下发,值统一转义为 ASCII(中文文件名否则会 400)。""" + if not metadata: + return {} + sanitized: dict[str, str] = {} + for raw_key, raw_value in metadata.items(): + key = str(raw_key or "").strip() + if not key: + continue + sanitized[key] = quote(str(raw_value or ""), safe="") + return sanitized + + +def _is_not_found(error: Exception) -> bool: + """把各兼容层形状不一的不存在错误统一识别出来。""" + code = getattr(error, "response", None) + if isinstance(code, dict): + error_code = str((code.get("Error") or {}).get("Code") or "") + status = (code.get("ResponseMetadata") or {}).get("HTTPStatusCode") + if error_code in ("404", "NoSuchKey", "NoSuchBucket", "NotFound"): + return True + if status == 404: + return True + return getattr(error, "name", "") in ("NotFound", "NoSuchKey") diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py new file mode 100644 index 0000000..74ea2bd --- /dev/null +++ b/web/backend/services/huya_collab_service.py @@ -0,0 +1,1512 @@ +"""虎牙外部 CK 协作台账的服务层(模型:``models.HuyaExternalOrder``)。 + +设计见 ``docs/设计-虎牙外部CK协作台账.md``。改这里之前先读文档 §2.3 与 §10.2, +两条边界不是 bug: + +1. **不写 ``huya_accounts``、不做外键**:CK 独立存本表; +2. **不 import 任何任务执行器**(``huya_runner*`` / ``huya_tasks``): + 绑定/充值/上脚本都在别的工具里做,本表只记录结果。 + S5 的探针只 import ``core.huya.http_client``(只读 RPC),同样不碰 runner。 + +本模块是**两套状态机的唯一写入口**(设计文档 §4.3 防漂移): + +- ``set_status()`` —— 业务进度 ``status`` +- ``set_ck_status()`` / ``set_ck()`` / ``rebuild_ck()`` —— 凭据状态 ``ck_status`` + +接口层不允许直接改这两个列,否则状态与时间戳会各自漂移。 +""" + +from __future__ import annotations + +import dataclasses +import re +import time +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from typing import Any + +from loguru import logger +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from ..huya_defaults import HUYA_DEFAULT_BIND_ACT_ID, HUYA_DEFAULT_OUTER_ACT_ID +from ..models import HuyaExternalOrder, User +from .audit_service import record_audit +from .file_service import build_file_url +from .file_storage import normalize_object_key +from .huya_service import build_web_cookie_from_raw, parse_huya_cookie_line + +# -------------------------------------------------------------------------- +# 业务状态机(一单卡在哪一步) +# -------------------------------------------------------------------------- + +STATUS_PENDING = "pending" +STATUS_BOUND = "bound" +STATUS_RECHARGED = "recharged" +STATUS_SCRIPTED = "scripted" +STATUS_DONE = "done" +STATUS_VOID = "void" + +ORDER_STATUSES: tuple[str, ...] = ( + STATUS_PENDING, + STATUS_BOUND, + STATUS_RECHARGED, + STATUS_SCRIPTED, + STATUS_DONE, + STATUS_VOID, +) + +ORDER_STATUS_LABELS = { + STATUS_PENDING: "待处理", + STATUS_BOUND: "已绑定", + STATUS_RECHARGED: "已充值", + STATUS_SCRIPTED: "已上脚本", + STATUS_DONE: "已完成", + STATUS_VOID: "已作废", +} + +# 「向前一步」的下一站;done 是终点,void 是任意态可达的旁路,都不在这条链上。 +NEXT_STATUS = { + STATUS_PENDING: STATUS_BOUND, + STATUS_BOUND: STATUS_RECHARGED, + STATUS_RECHARGED: STATUS_SCRIPTED, + STATUS_SCRIPTED: STATUS_DONE, +} + +# 阶段序号:用来同步时间戳(回退时要清掉后面阶段的时间戳,否则状态与时间戳互相矛盾)。 +STAGE_INDEX = { + STATUS_PENDING: 0, + STATUS_BOUND: 1, + STATUS_RECHARGED: 2, + STATUS_SCRIPTED: 3, + STATUS_DONE: 4, +} + +STATUS_TIMESTAMP_FIELDS = { + STATUS_BOUND: "bound_at", + STATUS_RECHARGED: "recharged_at", + STATUS_SCRIPTED: "scripted_at", + STATUS_DONE: "done_at", +} + +# -------------------------------------------------------------------------- +# CK 状态机(凭据本身) +# -------------------------------------------------------------------------- + +CK_UNVERIFIED = "unverified" +CK_VALID = "valid" +CK_INVALID = "invalid" +CK_DISABLED = "disabled" + +CK_STATUSES: tuple[str, ...] = (CK_UNVERIFIED, CK_VALID, CK_INVALID, CK_DISABLED) + +CK_STATUS_LABELS = { + CK_UNVERIFIED: "未验证", + CK_VALID: "有效", + CK_INVALID: "已失效", + CK_DISABLED: "已停用", +} + +# 只有这两种状态代表「刚跑过探针」,需要刷新 ck_checked_at。 +_CK_CHECKED_STATUSES = (CK_VALID, CK_INVALID) + +# -------------------------------------------------------------------------- +# 字段与图片槽位 +# -------------------------------------------------------------------------- + +EDITABLE_FIELDS: tuple[str, ...] = ( + "order_no", + "shop", + "owner_id", + "bind_douyu_account", + "bind_douyu_uid", + "gift_name", + "remark", +) + +FIELD_LIMITS = { + "order_no": 64, + "shop": 64, + "bind_douyu_account": 128, + "bind_douyu_uid": 32, + "gift_name": 128, + "remark": 512, + "void_reason": 256, +} + +FIELD_LABELS = { + "order_no": "订单编号", + "shop": "店铺", + "owner_id": "负责人", + "bind_douyu_account": "绑定斗鱼号", + "bind_douyu_uid": "绑定斗鱼号 uid", + "gift_name": "礼包名", + "remark": "备注", + "void_reason": "作废原因", +} + +IMAGE_SLOTS: tuple[str, ...] = ("bind_images", "home_images") + +IMAGE_SLOT_LABELS = { + "bind_images": "绑定图", + "home_images": "主页账号图", +} + +MAX_IMAGES_PER_SLOT = 20 + +# 网页运行 CK 才有这两类字段;没有 → 粘贴进来的其实是 App 原始凭据, +# 顺手存进 raw_cookie,以后就能用「重建」续命(不用重新找人要 CK)。 +_WEB_COOKIE_HINTS = ("guid=", "_qimei_uuid42") + + +class CollabError(RuntimeError): + """协作台账的业务错误,路由层据此转成对应 HTTP 状态码。""" + + def __init__(self, detail: str, *, status_code: int = 400, code: str = ""): + super().__init__(detail) + self.detail = detail + self.status_code = status_code + self.code = code + + +# -------------------------------------------------------------------------- +# 内部工具 +# -------------------------------------------------------------------------- + + +def _now() -> datetime: + return datetime.now(UTC) + + +def clean_text( + value: Any, *, field: str, required: bool = False, max_length: int | None = None +) -> str: + """统一收口字段清洗:去空白、限长、必填校验。 + + **不静默截断**:超长直接报错,否则用户填了 100 字备注、库里只剩 512 字节 + 之前的片段,事后没人知道被切过。 + """ + text = str(value if value is not None else "").strip() + limit = max_length or FIELD_LIMITS.get(field, 255) + if len(text) > limit: + raise CollabError( + f"{FIELD_LABELS.get(field, field)}不能超过 {limit} 个字符", + code="field_too_long", + ) + if required and not text: + raise CollabError( + f"{FIELD_LABELS.get(field, field)}不能为空", code="field_required" + ) + return text + + +def normalize_images(value: Any, *, field: str) -> list[str]: + """把图片字段统一成 object_key 数组(去空、去重、挡路径穿越、限张数)。""" + if value is None: + return [] + if isinstance(value, str): + items: Sequence[Any] = [part for part in re.split(r"[,\s]+", value) if part] + elif isinstance(value, (list, tuple)): + items = list(value) + else: + raise CollabError( + f"{IMAGE_SLOT_LABELS.get(field, field)}格式不正确", code="images_invalid" + ) + + result: list[str] = [] + for item in items: + text = str(item or "").strip() + if not text: + continue + try: + key = normalize_object_key(text) + except ValueError as exc: + raise CollabError( + f"{IMAGE_SLOT_LABELS.get(field, field)}的文件 key 不正确", + code="image_key_invalid", + ) from exc + if key not in result: + result.append(key) + + if len(result) > MAX_IMAGES_PER_SLOT: + raise CollabError( + f"{IMAGE_SLOT_LABELS.get(field, field)}最多 {MAX_IMAGES_PER_SLOT} 张", + code="images_too_many", + ) + return result + + +def images_of(order: HuyaExternalOrder, slot: str) -> list[str]: + """安全读取图片数组(JSON 列历史上可能是 None)。""" + value = getattr(order, slot, None) + return [str(item) for item in value] if isinstance(value, list) else [] + + +def look_like_web_cookie(cookie: str) -> bool: + """判断一份 CK 是否已是「网页运行态」(含 guid/_qimei_uuid42 设备字段)。""" + text = cookie or "" + return any(hint in text for hint in _WEB_COOKIE_HINTS) + + +def parse_ck(text: str) -> dict: + """解析粘贴进来的 CK(复用 huya_service 的纯函数,不依赖账号表)。""" + parsed = parse_huya_cookie_line(text or "") + if not parsed: + raise CollabError( + "CK 解析失败:没识别到虎牙 uid(支持「纯 CK」或「账号----密码----CK」)", + code="ck_invalid", + ) + return parsed + + +def _touch(order: HuyaExternalOrder, current: User | None) -> None: + """每次写入统一推进版本号与最后修改人(乐观锁靠 version,不用 updated_at)。""" + order.updated_by = current.id if current else None + order.version = int(order.version or 0) + 1 + order.updated_at = _now() + + +def _audit_target(order: HuyaExternalOrder) -> str: + return f"huya_external_order:{order.id}" + + +def assert_version(order: HuyaExternalOrder, expected_version: int | None) -> None: + """乐观锁:客户端提交时带的版本必须与库里一致,否则说明别人先改了。""" + if expected_version is None: + return + if int(expected_version) != int(order.version or 0): + raise CollabError( + f"这行已被 {_last_editor_name(order) or '其他人'} 改过(版本 {order.version})," + "请刷新后重试;直接覆盖会丢掉对方的修改", + status_code=409, + code="version_conflict", + ) + + +def _last_editor_name(order: HuyaExternalOrder) -> str: + editor = getattr(order, "updated_by_user", None) + return str(getattr(editor, "username", "") or "") + + +def _assert_order_no_available( + db: Session, order_no: str, *, exclude_id: int | None = None +) -> None: + query = db.query(HuyaExternalOrder).filter(HuyaExternalOrder.order_no == order_no) + if exclude_id is not None: + query = query.filter(HuyaExternalOrder.id != exclude_id) + other = query.first() + if other is not None: + raise CollabError( + f"订单编号 {order_no} 已被第 {other.id} 行占用", + status_code=409, + code="order_no_conflict", + ) + + +def _assert_uid_available( + db: Session, uid: str | None, *, exclude_id: int | None = None +) -> None: + """一个虎牙号只能挂一单(已确认 1 单 = 1 CK)。""" + text = str(uid or "").strip() + if not text: + return + query = db.query(HuyaExternalOrder).filter(HuyaExternalOrder.uid == text) + if exclude_id is not None: + query = query.filter(HuyaExternalOrder.id != exclude_id) + other = query.first() + if other is not None: + raise CollabError( + f"虎牙号 {text} 已经挂在订单 {other.order_no}(第 {other.id} 行)", + status_code=409, + code="uid_conflict", + ) + + +def _apply_ck(order: HuyaExternalOrder, cookie_text: str) -> dict: + """把一份 CK 写进行对象(不含冲突检查与审计,两层调用方共用)。""" + parsed = parse_ck(cookie_text) + cookie = str(parsed.get("cookie") or "") + order.uid = str(parsed.get("uid") or "") + order.yyuid = str(parsed.get("yyuid") or "") + order.hy_username = str(parsed.get("username") or "")[:128] + order.cookie = cookie + # 粘贴的若是 App 原始凭据(没有网页设备字段),顺手留一份 raw_cookie, + # 以后被风控标记时能直接「重建」而不必重新要 CK。 + if not look_like_web_cookie(cookie): + order.raw_cookie = cookie + order.ck_status = CK_UNVERIFIED + order.ck_checked_at = None + order.ck_check_message = "" + return { + "uid": order.uid, + "cookie_kind": "web" if look_like_web_cookie(cookie) else "raw", + } + + +# -------------------------------------------------------------------------- +# 业务状态机 +# -------------------------------------------------------------------------- + + +def _assert_can_enter_bound(order: HuyaExternalOrder) -> None: + """进入「已绑定」的前置校验(在服务层做,前端校验只是体验)。 + + 绑定对象有两条来源:人工填的 `bind_douyu_account`(Excel D 列),或**探针读到的 + `bind_game_role`**(2026-10-16 起探针把绑定角色写进快照列)。有任意一条就算"绑上了", + 否则只验证过 CK、没读过绑定角色的行会被挡在 `bound` 外面。 + """ + missing: list[str] = [] + if not str(order.cookie or "").strip(): + missing.append("CK") + has_binding = bool( + str(order.bind_douyu_account or "").strip() + or str(order.bind_game_role or "").strip() + ) + if not has_binding: + missing.append("绑定账号(人工填绑定斗鱼号,或先「验证 CK」读到绑定角色)") + if not images_of(order, "bind_images"): + missing.append("绑定图") + if missing: + raise CollabError( + f"还不能标记为「已绑定」,缺少:{'、'.join(missing)}", + code="bound_precondition", + ) + + +def _sync_stage_timestamps( + order: HuyaExternalOrder, target: str, now: datetime +) -> None: + """让各阶段时间戳与目标状态保持一致。 + + - 目标状态**之前**(含自身)缺失的时间戳补上:状态说「已上脚本」, + 时间戳就不能是空的,否则前端那格显示空白、没人知道什么时候做的; + - 目标状态**之后**的时间戳清空:回退之后还留着「已上脚本时间」, + 就是状态与时间戳互相矛盾。 + """ + if target == STATUS_VOID: + return + target_index = STAGE_INDEX[target] + for stage, field in STATUS_TIMESTAMP_FIELDS.items(): + if STAGE_INDEX[stage] <= target_index: + if getattr(order, field) is None: + setattr(order, field, now) + else: + setattr(order, field, None) + + +def set_status( + db: Session, + current: User | None, + order: HuyaExternalOrder, + target: str, + *, + reason: str = "", + rollback: bool = False, + commit: bool = True, +) -> dict: + """推进/回退业务状态(本模块唯一的 ``status`` 写入口)。 + + Args: + target: 目标状态,必须在 ``ORDER_STATUSES`` 里。 + reason: 作废原因(``target=void`` 时必填)。 + rollback: 目标不是「向前一步」时,必须显式置 True 才允许改。 + """ + if target not in ORDER_STATUSES: + raise CollabError(f"未知状态:{target}", code="status_unknown") + if target == order.status: + return {} + + from_status = order.status + if target != STATUS_VOID and target != NEXT_STATUS.get(from_status): + if not rollback: + next_status = NEXT_STATUS.get(from_status) + hint = ( + f"只能推进到「{ORDER_STATUS_LABELS[next_status]}」" + if next_status + else "这单已经走到终点,只能作废" + ) + raise CollabError( + f"状态只能向前推进({hint});要回退请显式勾选「回退」", + code="status_not_forward", + ) + if from_status == STATUS_VOID: + raise CollabError( + "已作废的单子不能改回流程内状态,请编辑或新建", + code="status_from_void", + ) + + if target == STATUS_BOUND: + _assert_can_enter_bound(order) + + now = _now() + if target == STATUS_VOID: + order.void_reason = clean_text(reason, field="void_reason", required=True) + else: + # 从作废改回流程内状态时,作废原因要清掉,不然列表上会自相矛盾。 + order.void_reason = "" + + order.status = target + _sync_stage_timestamps(order, target, now) + _touch(order, current) + + record_audit( + db, + current, + action="huya:collab:status", + target=_audit_target(order), + detail={ + "from": from_status, + "to": target, + "rollback": bool(rollback and target != NEXT_STATUS.get(from_status)), + "reason": order.void_reason, + "order_no": order.order_no, + }, + ) + if commit: + db.commit() + db.refresh(order) + return {"from": from_status, "to": target} + + +# -------------------------------------------------------------------------- +# CK 写入口 +# -------------------------------------------------------------------------- + + +def set_ck( + db: Session, + current: User | None, + order: HuyaExternalOrder, + cookie_text: str, + *, + commit: bool = True, +) -> dict: + """贴入 / 换一份 CK(uid 冲突会挡住),状态回到「未验证」。""" + before = str(order.cookie or "") + detail = _apply_ck(order, cookie_text) + _assert_uid_available(db, order.uid, exclude_id=order.id) + _touch(order, current) + record_audit( + db, + current, + action="huya:collab:ck", + target=_audit_target(order), + detail={ + "kind": "paste", + "uid": order.uid, + "order_no": order.order_no, + # 刻意**不**把 cookie 交给审计层:即便 record_audit 会脱敏, + # 凭据也不该多经一手(脱敏规则一改就是凭据泄漏)。 + # 只留一个「内容是否变了」的布尔,够用来追溯换没换过。 + "credential_changed": before != str(order.cookie or ""), + }, + ) + if commit: + db.commit() + db.refresh(order) + return detail + + +def set_ck_status( + db: Session, + current: User | None, + order: HuyaExternalOrder, + status: str, + *, + message: str = "", + commit: bool = True, +) -> dict: + """改写凭据状态(本模块唯一的 ``ck_status`` 写入口)。 + + ``valid`` / ``invalid`` 视为「刚跑过探针」,会刷新 ``ck_checked_at``; + ``disabled`` 是人工停用,不动探针时间。 + """ + if status not in CK_STATUSES: + raise CollabError(f"未知 CK 状态:{status}", code="ck_status_unknown") + previous = order.ck_status + order.ck_status = status + order.ck_check_message = clean_text( + message, field="ck_check_message", max_length=256 + ) + if status in _CK_CHECKED_STATUSES: + order.ck_checked_at = _now() + _touch(order, current) + record_audit( + db, + current, + action="huya:collab:ck_status", + target=_audit_target(order), + detail={ + "from": previous, + "to": status, + "message": order.ck_check_message, + "order_no": order.order_no, + }, + ) + if commit: + db.commit() + db.refresh(order) + return {"from": previous, "to": status} + + +def rebuild_ck( + db: Session, current: User | None, order: HuyaExternalOrder, *, commit: bool = True +) -> dict: + """用 ``raw_cookie`` 重建网页运行 CK(复用 huya_service 的纯函数)。""" + if not str(order.raw_cookie or "").strip(): + raise CollabError( + "这行没有 App 原始凭据,无法重建网页 CK(请重新贴一份)", + code="no_raw_cookie", + ) + try: + rebuilt = build_web_cookie_from_raw( + uid=str(order.uid or ""), + yyuid=str(order.yyuid or ""), + username=str(order.hy_username or ""), + raw_cookie=str(order.raw_cookie or ""), + ) + except ValueError as exc: + raise CollabError(f"重建失败:{exc}", code="rebuild_failed") from exc + + order.cookie = rebuilt + order.ck_status = CK_UNVERIFIED + order.ck_checked_at = None + order.ck_check_message = "已从 App 原始凭据重建,待验证" + _touch(order, current) + record_audit( + db, + current, + action="huya:collab:ck", + target=_audit_target(order), + detail={ + "kind": "rebuild", + "uid": order.uid, + "order_no": order.order_no, + "credential_changed": True, + }, + ) + if commit: + db.commit() + db.refresh(order) + return {"uid": order.uid, "cookie_kind": "web"} + + +# 人工能改的 CK 状态:`valid` / `invalid` 只能由探针给(人工标「有效」等于绕过验证)。 +MANUAL_CK_STATUSES: tuple[str, ...] = (CK_UNVERIFIED, CK_DISABLED) + + +def set_manual_ck_status( + db: Session, + current: User | None, + order: HuyaExternalOrder, + status: str, + *, + message: str = "", + commit: bool = True, +) -> dict: + """人工改凭据状态(只允许「已停用」/「未验证」),仍然走 :func:`set_ck_status` 落库。""" + if status not in MANUAL_CK_STATUSES: + raise CollabError( + "人工只能把 CK 标成「已停用」或「未验证」;有效/失效由探针判定", + code="ck_status_manual_only", + ) + return set_ck_status(db, current, order, status, message=message, commit=commit) + + +def set_images( + db: Session, + current: User | None, + order: HuyaExternalOrder, + slot: str, + value: Any, + *, + commit: bool = True, +) -> dict: + """整槽替换图片(前端上传完拿到 object_key 后一次性提交)。""" + if slot not in IMAGE_SLOTS: + raise CollabError(f"未知图片槽位:{slot}", code="image_slot_unknown") + before = images_of(order, slot) + after = normalize_images(value, field=slot) + if before == after: + return {"slot": slot, "images": after} + setattr(order, slot, after) + _touch(order, current) + record_audit( + db, + current, + action="huya:collab:image", + target=_audit_target(order), + detail={ + "slot": slot, + "slot_label": IMAGE_SLOT_LABELS[slot], + "added": [key for key in after if key not in before], + "removed": [key for key in before if key not in after], + "order_no": order.order_no, + }, + ) + if commit: + db.commit() + db.refresh(order) + return {"slot": slot, "images": after} + + +# -------------------------------------------------------------------------- +# CK 探针(S5,只读验证) +# -------------------------------------------------------------------------- + +# 探针参数与 web/backend/huya_defaults.py 的默认活动一致(2026-10-16 真机实测)。 +PROBE_BIND_ACT_ID = int(HUYA_DEFAULT_BIND_ACT_ID) +PROBE_OUTER_ACT_ID = HUYA_DEFAULT_OUTER_ACT_ID +PROBE_TIMEOUT_SECONDS = 15.0 + +# 探针独占的绑定快照列(每次验证**覆盖写入**,人工不编辑): +# 「这单现在到底绑了哪个游戏账号、哪个角色」只认这几列。 +PROBE_SNAPSHOT_FIELDS: tuple[str, ...] = ( + "bind_game_account", + "bind_game_role", + "bind_game_channel", +) + +# 批量验证**串行 + 间隔**:不做定时轮询,一次也别勾太多,避免把账号送进风控名单。 +PROBE_BATCH_INTERVAL_SECONDS = 1.0 +PROBE_BATCH_MAX = 50 + +_CK_MESSAGE_LIMIT = 256 # ck_check_message 列宽 +_PROBE_PIECE_LIMIT = 40 # 拼消息时单段文本的上限 + + +def _clip(value: Any, limit: int = _PROBE_PIECE_LIMIT) -> str: + """把探针文本裁到可读长度(``ck_check_message`` 只有 256 字符)。""" + text = str(value if value is not None else "").strip() + return text if len(text) <= limit else text[: limit - 3] + "..." + + +@dataclasses.dataclass(frozen=True) +class CkProbeResult: + """一次只读探针的结论(不落库;落库由 :func:`verify_ck` 负责)。""" + + ok: bool + message: str + snapshot: dict[str, str] = dataclasses.field(default_factory=dict) + + +def _build_probe_client() -> Any: + """探针客户端(HTTP cdnws 只读通道)。 + + 只做只读 RPC:**不建 ``HuyaAccount``、不 import 任何 runner**(设计文档 §2.3)。 + 测试替换这个工厂就能离线跑,不真连虎牙。 + """ + from core.huya.http_client import HuyaHttpClient + + return HuyaHttpClient( + logger=lambda message: logger.debug(f"[huya-collab] {message}") + ) + + +def _probe_snapshot(resp: Any) -> dict[str, str]: + """探针读到的**绑定对象**:绑定在该虎牙号上的游戏账号 + 角色。 + + 2026-10-16 真机确认:``check_user_bind_game_account`` 返回的是**游戏账号** + (账号昵称 / 角色名 / 平台·区服),**不是虎牙号自己的昵称** —— 虎牙昵称要 + `get_grow_level_user_info`,可那个接口不认凭据、当不了探针,所以 ``nickname`` + 一个字段都不碰(那是「虎牙号昵称」,硬写就是假数据)。 + + 这几个列由探针**独占并覆盖写入**(``bind_snapshot_at`` 记读取时间): + "这单现在到底绑了哪个号、哪个角色"只有这里可信(人工填的是自由文本)。 + """ + account_data = getattr(resp, "accountData", None) + game_account = getattr(account_data, "gameAccount", None) + game_role = getattr(account_data, "gameRole", None) + + snapshot: dict[str, str] = dict.fromkeys(PROBE_SNAPSHOT_FIELDS, "") + account_name = _clip(getattr(game_account, "nick", "")) or _clip( + getattr(game_role, "roleName", "") + ) + if account_name: + snapshot["bind_game_account"] = account_name + role_name = _clip(getattr(game_role, "roleName", "")) + if role_name: + snapshot["bind_game_role"] = role_name + channel = " · ".join( + piece + for piece in ( + _clip(getattr(game_role, "platName", "")), + _clip(getattr(game_role, "areaName", "")), + ) + if piece + ) + if channel: + snapshot["bind_game_channel"] = channel + return snapshot + + +def _probe_success_message(resp: Any) -> str: + """成功时的可读结论(列表上直接能看到探针读到的绑定对象)。""" + account_data = getattr(resp, "accountData", None) + game_account = getattr(account_data, "gameAccount", None) + game_role = getattr(account_data, "gameRole", None) + + parts: list[str] = [] + nick = _clip(getattr(game_account, "nick", "")) + role_name = _clip(getattr(game_role, "roleName", "")) + if nick: + parts.append(f"账号 {nick}") + if role_name: + parts.append(f"角色 {role_name}") + channel = " · ".join( + piece + for piece in ( + _clip(getattr(game_role, "platName", "")), + _clip(getattr(game_role, "areaName", "")), + ) + if piece + ) + if channel: + parts.append(channel) + detail = " / ".join(parts) + return (f"有效:{detail}" if detail else "有效")[:_CK_MESSAGE_LIMIT] + + +def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeResult: + """只读探针:这份 CK 还能不能过(不写库、不发敏感动作)。 + + 判据(2026-10-16 真机对照确定,**别改成「有数据就算成功」**): + 响应**非 None 且 ``status == 200``**。 + + 实测支持这个判据:裸 cookie(无任何凭据)+ 真 uid → 返回 ``None``; + 真 CK + 别人的 uid → 也返回 ``None``;只有「真 CK + 真 uid」才拿到 200 + 真实数据。 + """ + uid_text = str(order.uid or "").strip() + cookie = str(order.cookie or "").strip() + if not uid_text or not cookie: + return CkProbeResult(False, "这行还没有可验证的 CK(uid 或凭据为空)") + try: + uid = int(uid_text) + except ValueError: + return CkProbeResult(False, f"CK 里的 uid 不是数字:{_clip(uid_text)}") + + try: + probe_client = client or _build_probe_client() + resp = probe_client.check_user_bind_game_account( + uid=uid, + cookie=cookie, + b_act_id=PROBE_BIND_ACT_ID, + is_use_outer_act_id=1, + outer_act_id=PROBE_OUTER_ACT_ID, + timeout=PROBE_TIMEOUT_SECONDS, + ) + except Exception as exc: # noqa: BLE001 探针异常=验证不通过,不能让接口 500 + logger.debug(f"[huya-collab] CK 探针异常:{type(exc).__name__}: {exc}") + return CkProbeResult(False, f"探针调用失败:{type(exc).__name__}: {_clip(exc)}") + + if resp is None: + return CkProbeResult(False, "探针没有返回数据(凭据无效或已被风控)") + status = int(getattr(resp, "status", 0) or 0) + if status != 200: + msg = _clip(getattr(resp, "msg", "")) + suffix = f":{msg}" if msg else "" + return CkProbeResult(False, f"探针返回 status={status}{suffix}") + return CkProbeResult(True, _probe_success_message(resp), _probe_snapshot(resp)) + + +def verify_ck( + db: Session, + current: User | None, + order: HuyaExternalOrder, + *, + client: Any | None = None, + commit: bool = True, +) -> dict: + """跑一次只读探针并把结论落库。 + + - 结论**只**通过 :func:`set_ck_status` 写(``ck_status`` 的唯一写入口,审计不会丢); + - 成功时**覆盖写入**绑定快照(``bind_game_account`` / ``bind_game_role`` / + ``bind_game_channel`` + ``bind_snapshot_at``):这几列是探针独占的"现在绑了谁", + 覆盖前会把旧值记进 ``huya:collab:probe_snapshot`` 审计,出问题能查回来; + - 人工填的那些列(含 ``bind_douyu_account``)**一律不碰**; + - CK 失效**不改业务状态**(设计文档 §4.2:已绑定/已充值的单子业务上可能仍然有效)。 + """ + if not str(order.cookie or "").strip(): + raise CollabError("这行还没有 CK,先贴一份再验证", code="no_cookie") + if order.status == STATUS_VOID: + raise CollabError( + "已作废的行不验证 CK(要重新用请先改回流程内状态)", code="void_order" + ) + + result = probe_ck(order, client=client) + binding: dict[str, str] = { + field_name: str(getattr(order, field_name) or "") + for field_name in PROBE_SNAPSHOT_FIELDS + } + if result.ok: + before = dict(binding) + for field_name in PROBE_SNAPSHOT_FIELDS: + setattr(order, field_name, result.snapshot.get(field_name, "")) + order.bind_snapshot_at = _now() + binding = { + field_name: str(getattr(order, field_name) or "") + for field_name in PROBE_SNAPSHOT_FIELDS + } + if binding != before: + # 快照变了(或第一次读到)才记一条,别每次验证都刷两行审计。 + record_audit( + db, + current, + action="huya:collab:probe_snapshot", + target=_audit_target(order), + detail={ + "order_no": order.order_no, + "before": before, + "after": binding, + }, + ) + + target = CK_VALID if result.ok else CK_INVALID + set_ck_status(db, current, order, target, message=result.message, commit=False) + if commit: + db.commit() + db.refresh(order) + return { + "id": order.id, + "order_no": order.order_no, + "ok": result.ok, + "ck_status": target, + "message": result.message, + "binding": binding, + } + + +def _probe_row_error(order: HuyaExternalOrder, message: str) -> dict: + """批量验证里「这一行没跑成」的统一形状(状态保持原样,不谎报 invalid)。""" + return { + "id": order.id, + "order_no": order.order_no, + "ok": False, + "ck_status": order.ck_status, + "message": _clip(message, _CK_MESSAGE_LIMIT), + "binding": { + field_name: str(getattr(order, field_name) or "") + for field_name in PROBE_SNAPSHOT_FIELDS + }, + } + + +def verify_ck_batch( + db: Session, + current: User | None, + orders: Sequence[HuyaExternalOrder], + *, + client: Any | None = None, + interval_seconds: float | None = None, + commit: bool = True, +) -> dict: + """批量验证:**串行 + 间隔**(人工触发,不做定时轮询),单条失败不影响其他条。 + + Returns: + ``{total, valid, failed, results: [{id, order_no, ok, ck_status, message, binding}]}`` + """ + rows = list(orders) + if len(rows) > PROBE_BATCH_MAX: + raise CollabError( + f"一次最多验证 {PROBE_BATCH_MAX} 条,请分批勾选", + code="verify_batch_too_large", + ) + interval = ( + PROBE_BATCH_INTERVAL_SECONDS + if interval_seconds is None + else max(0.0, float(interval_seconds)) + ) + + results: list[dict] = [] + for index, order in enumerate(rows): + if index and interval: + time.sleep(interval) + try: + # 每行一个 SAVEPOINT:某行在写库阶段炸了也只脏这一行(同 import_orders)。 + with db.begin_nested(): + results.append( + verify_ck(db, current, order, client=client, commit=False) + ) + db.flush() + except CollabError as exc: + results.append(_probe_row_error(order, exc.detail)) + except Exception as exc: # noqa: BLE001 单条失败不该带崩整批 + logger.debug(f"[huya-collab] 批量验证单行失败:{type(exc).__name__}: {exc}") + results.append(_probe_row_error(order, f"{type(exc).__name__}: {exc}")) + + if commit: + db.commit() + valid = sum(1 for row in results if row["ok"]) + return { + "total": len(results), + "valid": valid, + "failed": len(results) - valid, + "results": results, + } + + +def list_orders_by_ids(db: Session, ids: Sequence[int]) -> list[HuyaExternalOrder]: + """按 id 取行(保持勾选顺序;已被别人删掉的 id 直接忽略)。""" + wanted = list(dict.fromkeys(int(value) for value in ids)) + if not wanted: + raise CollabError("请先选择要验证的行", code="verify_empty") + if len(wanted) > PROBE_BATCH_MAX: + raise CollabError( + f"一次最多验证 {PROBE_BATCH_MAX} 条,请分批勾选", + code="verify_batch_too_large", + ) + rows = db.query(HuyaExternalOrder).filter(HuyaExternalOrder.id.in_(wanted)).all() + by_id = {int(row.id): row for row in rows} + return [by_id[value] for value in wanted if value in by_id] + + +# -------------------------------------------------------------------------- +# CRUD +# -------------------------------------------------------------------------- + + +def create_order( + db: Session, + current: User, + *, + order_no: str, + shop: str = "", + owner_id: int | None = None, + ck_text: str = "", + bind_douyu_account: str = "", + gift_name: str = "", + remark: str = "", + commit: bool = True, +) -> HuyaExternalOrder: + """新建一行(可同时贴 CK)。订单号唯一,重复直接报冲突。""" + order_no = clean_text(order_no, field="order_no", required=True) + _assert_order_no_available(db, order_no) + + order = HuyaExternalOrder( + order_no=order_no, + shop=clean_text(shop, field="shop"), + owner_id=_clean_owner_id(owner_id), + bind_douyu_account=clean_text(bind_douyu_account, field="bind_douyu_account"), + gift_name=clean_text(gift_name, field="gift_name"), + remark=clean_text(remark, field="remark"), + bind_images=[], + home_images=[], + status=STATUS_PENDING, + ck_status=CK_UNVERIFIED, + created_by=current.id, + updated_by=current.id, + version=0, + ) + if str(ck_text or "").strip(): + _apply_ck(order, ck_text) + _assert_uid_available(db, order.uid) + + db.add(order) + db.flush() # 先拿到 id,审计里要带 target + record_audit( + db, + current, + action="huya:collab:create", + target=_audit_target(order), + detail={ + "order_no": order.order_no, + "shop": order.shop, + "uid": order.uid or "", + "ck_status": order.ck_status, + }, + ) + if commit: + db.commit() + db.refresh(order) + return order + + +def update_order( + db: Session, + current: User, + order: HuyaExternalOrder, + payload: Mapping[str, Any], + *, + expected_version: int | None = None, + commit: bool = True, +) -> dict: + """编辑可改字段,返回**字段级 diff**(``{字段: {old, new}}``)。 + + 乐观锁:``expected_version`` 与库里不一致 → 409,绝不静默覆盖 + (这正是 Excel 协同最痛的地方)。 + """ + assert_version(order, expected_version) + + changes: dict[str, dict[str, Any]] = {} + for field in EDITABLE_FIELDS: + if field not in payload: + continue + if field == "owner_id": + new_value: Any = _clean_owner_id(payload[field]) + else: + new_value = clean_text( + payload[field], + field=field, + required=(field == "order_no"), + ) + old_value = getattr(order, field) + if old_value == new_value: + continue + if field == "order_no": + _assert_order_no_available(db, new_value, exclude_id=order.id) + changes[field] = {"old": old_value, "new": new_value} + setattr(order, field, new_value) + + if not changes: + return {} + + _touch(order, current) + record_audit( + db, + current, + action="huya:collab:update", + target=_audit_target(order), + detail={"order_no": order.order_no, "changes": changes}, + ) + if commit: + db.commit() + db.refresh(order) + return changes + + +def delete_order( + db: Session, current: User, order: HuyaExternalOrder, *, commit: bool = True +) -> dict: + """物理删除(需 ``huya:collab:manage``)。 + + 只删行,**不删对象存储里的图**:撤图不可逆,交给孤儿清理脚本按保留期处理 + (设计文档 §6.4)。 + """ + snapshot = { + "order_no": order.order_no, + "uid": order.uid or "", + "images": images_of(order, "bind_images") + images_of(order, "home_images"), + } + record_audit( + db, + current, + action="huya:collab:delete", + target=_audit_target(order), + detail={ + "order_no": snapshot["order_no"], + "uid": snapshot["uid"], + "image_count": len(snapshot["images"]), + }, + ) + db.delete(order) + if commit: + db.commit() + return snapshot + + +def _clean_owner_id(value: Any) -> int | None: + if value is None or value == "": + return None + try: + owner_id = int(value) + except (TypeError, ValueError) as exc: + raise CollabError("负责人必须是用户 ID", code="owner_invalid") from exc + if owner_id <= 0: + return None + return owner_id + + +# -------------------------------------------------------------------------- +# 查询 +# -------------------------------------------------------------------------- + + +def get_order(db: Session, order_id: int) -> HuyaExternalOrder: + order = db.query(HuyaExternalOrder).filter(HuyaExternalOrder.id == order_id).first() + if order is None: + raise CollabError( + "这一行不存在(可能已被别人删除)", status_code=404, code="order_not_found" + ) + return order + + +def list_orders( + db: Session, + *, + shop: str = "", + owner_id: int | None = None, + status: str = "", + ck_status: str = "", + keyword: str = "", + include_void: bool = False, + page: int = 1, + page_size: int = 50, +) -> tuple[list[HuyaExternalOrder], int]: + """共享视图:默认**不按人过滤**(负责人只是筛选维度,不是权限边界)。""" + query = db.query(HuyaExternalOrder) + if shop: + query = query.filter(HuyaExternalOrder.shop == shop) + if owner_id: + query = query.filter(HuyaExternalOrder.owner_id == owner_id) + if status: + query = query.filter(HuyaExternalOrder.status == status) + if ck_status: + query = query.filter(HuyaExternalOrder.ck_status == ck_status) + if not include_void: + query = query.filter(HuyaExternalOrder.status != STATUS_VOID) + text = (keyword or "").strip() + if text: + like = f"%{text}%" + query = query.filter( + or_( + HuyaExternalOrder.order_no.like(like), + HuyaExternalOrder.bind_douyu_account.like(like), + HuyaExternalOrder.bind_game_account.like(like), + HuyaExternalOrder.bind_game_role.like(like), + HuyaExternalOrder.uid.like(like), + HuyaExternalOrder.nickname.like(like), + HuyaExternalOrder.hy_username.like(like), + HuyaExternalOrder.gift_name.like(like), + HuyaExternalOrder.remark.like(like), + ) + ) + + total = query.count() + page = max(1, int(page or 1)) + page_size = min(200, max(1, int(page_size or 50))) + # 排序:业务状态(作废排最后)+ 最近改动在前,让「刚被谁动过」的浮上来。 + stage = func.field(HuyaExternalOrder.status, *ORDER_STATUSES) + rows = ( + query.order_by(stage.asc(), HuyaExternalOrder.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return rows, total + + +def summary(db: Session) -> dict: + """页头统计条:各状态计数 + CK 失效数。""" + status_rows = ( + db.query(HuyaExternalOrder.status, func.count(HuyaExternalOrder.id)) + .group_by(HuyaExternalOrder.status) + .all() + ) + by_status = {str(status): int(count) for status, count in status_rows} + ck_invalid = ( + db.query(func.count(HuyaExternalOrder.id)) + .filter(HuyaExternalOrder.ck_status == CK_INVALID) + .scalar() + ) + return { + "total": sum(by_status.values()), + "by_status": {code: by_status.get(code, 0) for code in ORDER_STATUSES}, + "ck_invalid": int(ck_invalid or 0), + } + + +def list_shops(db: Session) -> list[str]: + rows = ( + db.query(HuyaExternalOrder.shop) + .filter(HuyaExternalOrder.shop != "") + .distinct() + .order_by(HuyaExternalOrder.shop) + .all() + ) + return [str(row[0]) for row in rows] + + +def users_by_id(db: Session, orders: Sequence[HuyaExternalOrder]) -> dict[int, str]: + """一次性把涉及的用户名查出来,避免每行都懒加载(N+1)。""" + ids = { + int(value) + for order in orders + for value in (order.owner_id, order.created_by, order.updated_by) + if value + } + if not ids: + return {} + rows = db.query(User.id, User.username).filter(User.id.in_(ids)).all() + return {int(row[0]): str(row[1]) for row in rows} + + +def serialize_order( + order: HuyaExternalOrder, *, users: Mapping[int, str] | None = None +) -> dict: + """把行对象转成接口输出。 + + 图片存的是 object_key,URL 在这里现拼 —— 库里存 URL 会在换存储/改路径时 + 全部失效(order_site 踩过「存下来的签名链接过期后到处裂图」)。 + """ + names = users or {} + bind_images = images_of(order, "bind_images") + home_images = images_of(order, "home_images") + + def urls(keys: list[str], variant: str = "") -> list[str]: + return [build_file_url(key, variant) for key in keys] + + def name_of(value: int | None) -> str: + return names.get(int(value), "") if value else "" + + return { + "id": order.id, + "order_no": order.order_no, + "shop": order.shop or "", + "owner_id": order.owner_id, + "owner_name": name_of(order.owner_id), + "uid": order.uid or "", + "yyuid": order.yyuid or "", + "hy_username": order.hy_username or "", + "nickname": order.nickname or "", + # 只暴露「有没有」,CK 本体绝不出接口。 + "has_cookie": bool(order.cookie), + "has_raw_cookie": bool(order.raw_cookie), + "ck_status": order.ck_status, + "ck_status_label": CK_STATUS_LABELS.get(order.ck_status, order.ck_status), + "ck_checked_at": order.ck_checked_at, + "ck_check_message": order.ck_check_message or "", + "bind_douyu_account": order.bind_douyu_account or "", + "bind_douyu_uid": order.bind_douyu_uid or "", + # 探针读到的绑定对象(只读快照,人工不可编辑) + "bind_game_account": order.bind_game_account or "", + "bind_game_role": order.bind_game_role or "", + "bind_game_channel": order.bind_game_channel or "", + "bind_snapshot_at": order.bind_snapshot_at, + "bind_images": bind_images, + "home_images": home_images, + "bind_image_urls": urls(bind_images), + "home_image_urls": urls(home_images), + "bind_image_thumb_urls": urls(bind_images, "thumb"), + "home_image_thumb_urls": urls(home_images, "thumb"), + "gift_name": order.gift_name or "", + "remark": order.remark or "", + "status": order.status, + "status_label": ORDER_STATUS_LABELS.get(order.status, order.status), + "next_status": NEXT_STATUS.get(order.status), + "bound_at": order.bound_at, + "recharged_at": order.recharged_at, + "scripted_at": order.scripted_at, + "done_at": order.done_at, + "void_reason": order.void_reason or "", + "points": order.points, + "elite_opened": order.elite_opened, + "snapshot_at": order.snapshot_at, + "created_by": order.created_by, + "created_by_name": name_of(order.created_by), + "updated_by": order.updated_by, + "updated_by_name": name_of(order.updated_by), + "version": int(order.version or 0), + "created_at": order.created_at, + "updated_at": order.updated_at, + } + + +# -------------------------------------------------------------------------- +# 批量导入(Excel 粘贴) +# -------------------------------------------------------------------------- + +IMPORT_COLUMNS: tuple[str, ...] = ( + "shop", + "order_no", + "owner", + "bind_douyu_account", + "gift_name", + "remark", +) + +# 表头识别:中文(Excel 原表头)+ 英文别名。按位置兜底要求 6 列顺序一致。 +_HEADER_ALIASES: dict[str, str] = { + "店铺": "shop", + "shop": "shop", + "订单编号": "order_no", + "订单号": "order_no", + "order_no": "order_no", + "orderno": "order_no", + "负责人": "owner", + "owner": "owner", + "绑定斗鱼号": "bind_douyu_account", + "绑定斗鱼": "bind_douyu_account", + "斗鱼号": "bind_douyu_account", + "bind_douyu_account": "bind_douyu_account", + "礼包": "gift_name", + "礼包名": "gift_name", + "商品": "gift_name", + "gift_name": "gift_name", + "备注": "remark", + "remark": "remark", +} + + +def parse_import_text(text: str) -> tuple[list[str], list[tuple[int, list[str]]]]: + """把粘贴的文本切成 (列顺序, [(行号, 单元格)])。 + + 支持 Excel 直接复制出来的 TSV(``\\t``),也兼容逗号/分号分隔。 + 第一行若能被识别成表头,则按表头映射列;否则按固定列序 + ``店铺 / 订单编号 / 负责人 / 绑定斗鱼号 / 礼包 / 备注``。 + """ + rows: list[tuple[int, list[str]]] = [] + for index, raw_line in enumerate((text or "").splitlines(), start=1): + line = raw_line.rstrip("\r\n") + if not line.strip(): + continue + if "\t" in line: + cells = line.split("\t") + elif ";" in line: + cells = line.split(";") + elif "," in line: + cells = line.split(",") + else: + cells = [line] + rows.append((index, [cell.strip() for cell in cells])) + + if not rows: + return list(IMPORT_COLUMNS), [] + + first_index, first_cells = rows[0] + mapped: list[str] = [] + for cell in first_cells: + key = _HEADER_ALIASES.get(cell.strip().lower()) or _HEADER_ALIASES.get( + cell.strip() + ) + mapped.append(key or "") + if mapped.count("order_no") == 1 and len(mapped) >= 2: + logger.debug(f"协作台账导入:识别到表头(行 {first_index}){mapped}") + return mapped, rows[1:] + return list(IMPORT_COLUMNS), rows + + +def import_orders( + db: Session, + current: User, + text: str, + *, + owner_by_name: Mapping[str, int] | None = None, + commit: bool = True, +) -> dict: + """批量导入:逐行处理,**单行失败不影响其他行**(AGENTS §2),错误按行号返回。 + + Returns: + ``{created, updated, skipped, errors: [{line, order_no, message}]}`` + """ + columns, rows = parse_import_text(text) + owners = { + str(name).strip(): int(uid) for name, uid in (owner_by_name or {}).items() + } + created = 0 + updated = 0 + skipped = 0 + errors: list[dict[str, Any]] = [] + + seen_order_nos: set[str] = set() + + for line_no, cells in rows: + record = { + column: (cells[index] if index < len(cells) else "") + for index, column in enumerate(columns) + if column + } + order_no = str(record.get("order_no") or "").strip() + if not order_no: + skipped += 1 + continue + if order_no in seen_order_nos: + # 同一批粘贴里重复出现:只处理第一条,否则后一条会把刚建的覆盖掉。 + errors.append( + { + "line": line_no, + "order_no": order_no, + "message": "本次粘贴里重复出现,只处理第一条", + } + ) + continue + seen_order_nos.add(order_no) + + owner_name = str(record.get("owner") or "").strip() + payload = { + "shop": record.get("shop", ""), + "bind_douyu_account": record.get("bind_douyu_account", ""), + "gift_name": record.get("gift_name", ""), + "remark": record.get("remark", ""), + } + if owner_name: + if owner_name not in owners: + errors.append( + { + "line": line_no, + "order_no": order_no, + "message": f"负责人「{owner_name}」不存在或不是活跃用户", + } + ) + continue + payload["owner_id"] = owners[owner_name] + + outcome = "" + try: + # 每行套一个 SAVEPOINT:这一行炸了只回滚这一行,整批继续。 + # 若直接往同一个事务里写,一次约束冲突就会把会话打成失败态, + # 后面每一行都跟着报错 —— 那就成了「单行失败带崩整批」。 + with db.begin_nested(): + existing = ( + db.query(HuyaExternalOrder) + .filter(HuyaExternalOrder.order_no == order_no) + .first() + ) + if existing is None: + create_order( + db, + current, + order_no=order_no, + shop=payload.get("shop", ""), + owner_id=payload.get("owner_id"), + bind_douyu_account=payload.get("bind_douyu_account", ""), + gift_name=payload.get("gift_name", ""), + remark=payload.get("remark", ""), + commit=False, + ) + outcome = "created" + else: + # 已存在按「更新」处理(设计文档 §10.2 E);导入不带 + # expected_version,视为「明确要覆盖」。 + changes = update_order( + db, + current, + existing, + payload, + expected_version=None, + commit=False, + ) + outcome = "updated" if changes else "skipped" + # SessionLocal 是 autoflush=False:显式 flush,让后面几行的 + # 重复检查能看到刚写入的这行。 + db.flush() + except CollabError as exc: + errors.append( + {"line": line_no, "order_no": order_no, "message": exc.detail} + ) + continue + except Exception as exc: # noqa: BLE001 单行失败不该带崩整批导入 + errors.append( + { + "line": line_no, + "order_no": order_no, + "message": f"{type(exc).__name__}: {exc}", + } + ) + continue + + if outcome == "created": + created += 1 + elif outcome == "updated": + updated += 1 + else: + skipped += 1 + + if commit: + db.commit() + return { + "created": created, + "updated": updated, + "skipped": skipped, + "errors": errors, + "parsed_columns": columns, + } diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index 447f885..3fc890d 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -414,11 +414,22 @@ def persist_huya_session_cookie( return True -def rebuild_web_cookie_from_raw(db: Session, account: HuyaAccount) -> str: - """用 App 原始凭据重建网页运行 Cookie,不发起登录或支付。""" - raw = normalize_huya_cookie(account.raw_cookie or "") +def build_web_cookie_from_raw( + *, + uid: str = "", + yyuid: str = "", + username: str = "", + raw_cookie: str = "", +) -> str: + """用 App 原始凭据生成网页运行 Cookie(**纯函数,不碰数据库**)。 + + 从 ``rebuild_web_cookie_from_raw`` 抽出来,供不依赖 ``huya_accounts`` 的调用方 + 复用(例如虎牙外部 CK 协作台账)—— 与其把这段设备态补齐逻辑复制一份, + 不如让两边共用一处。原函数的行为一行未改,只是改为调用这里。 + """ + raw = normalize_huya_cookie(raw_cookie or "") if not raw: - raise ValueError("该账号没有 App 原始凭据,无法重建网页 Cookie") + raise ValueError("没有 App 原始凭据,无法重建网页 Cookie") from core.huya.cookie_utils import cookie_pairs from core.huya.device_profile import _load_db, canonical_account_key @@ -427,27 +438,37 @@ def rebuild_web_cookie_from_raw(db: Session, account: HuyaAccount) -> str: qimei_feature_from_device_profile, ) - uid = 0 - for candidate in (account.uid, account.yyuid, cookie_value(raw, "udb_uid")): + uid_value = 0 + for candidate in (uid, yyuid, cookie_value(raw, "udb_uid")): text = str(candidate or "").strip() if text.isdigit(): - uid = int(text) + uid_value = int(text) break feature = None - username = (account.username or "").strip() - if username: + account_name = (username or "").strip() + if account_name: profiles = _load_db() - profile = profiles.get(canonical_account_key(username)) or profiles.get( - username + profile = profiles.get(canonical_account_key(account_name)) or profiles.get( + account_name ) if profile: feature = qimei_feature_from_device_profile(profile) - filled = fill_web_cookie_fields(raw, guid="", uid=uid, qimei_feature=feature) + filled = fill_web_cookie_fields(raw, guid="", uid=uid_value, qimei_feature=feature) pairs = dict(cookie_pairs(filled)) pairs["udb_appid"] = "5002" - rebuilt = "; ".join(f"{key}={value}" for key, value in pairs.items()) + return "; ".join(f"{key}={value}" for key, value in pairs.items()) + + +def rebuild_web_cookie_from_raw(db: Session, account: HuyaAccount) -> str: + """用 App 原始凭据重建网页运行 Cookie,不发起登录或支付。""" + rebuilt = build_web_cookie_from_raw( + uid=account.uid, + yyuid=account.yyuid, + username=account.username, + raw_cookie=account.raw_cookie, + ) account.cookie = rebuilt account.status = "login_success" account.updated_at = datetime.now(UTC) diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 38e9686..5d61d72 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -28,6 +28,7 @@ const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage')); const HuyaDeviceBindingsPage = lazy(() => import('./pages/HuyaDeviceBindingsPage')); const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage')); const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage')); +const HuyaCollabPage = lazy(() => import('./pages/HuyaCollabPage')); const HuyaBrowserExchangePage = lazy(() => import('./pages/HuyaBrowserExchangePage')); const YybRechargePage = lazy(() => import('./pages/YybRechargePage')); const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage')); @@ -100,6 +101,7 @@ function AppContent() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/files.ts b/web/frontend/src/api/files.ts new file mode 100644 index 0000000..bc1483f --- /dev/null +++ b/web/frontend/src/api/files.ts @@ -0,0 +1,18 @@ +import api from './client'; +import type { FileUploadResult } from './types'; + +/** + * 文件上传/回读(后端 /api/files)。 + * + * 图片一律经后端代理地址回读(/api/files/object?key=...),不下发对象存储直链, + * 也不在前端存签名 URL(签名会过期,过期后到处裂图)。 + */ +export const fileApi = { + /** 上传一张图片,返回 object_key(协作台账图片槽位提交的就是它) */ + upload: (file: File, scene = 'collab') => { + const form = new FormData(); + form.append('file', file); + form.append('scene', scene); + return api.post('/files/upload', form); + }, +}; diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 580e9c8..bf2c07e 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -31,6 +31,20 @@ import type { HuyaTaskBatchResult, HuyaTaskItem, HuyaBrowserExchangeKit, + HuyaCollabAuditItem, + HuyaCollabCkRequest, + HuyaCollabCkStatusRequest, + HuyaCollabImagesRequest, + HuyaCollabImportResult, + HuyaCollabOrder, + HuyaCollabOrderCreate, + HuyaCollabOrderList, + HuyaCollabOrderQuery, + HuyaCollabOrderUpdate, + HuyaCollabOwner, + HuyaCollabStatusRequest, + HuyaCollabSummary, + HuyaCollabVerifyResult, HuyaExchangeTargetKey, HuyaExternalCkImportRequest, HuyaExternalCkImportResult, @@ -172,4 +186,53 @@ export const huyaApi = { /** 本机浏览器兑换: 获取目标页与登录脚本, 由用户在自己浏览器的控制台执行 (target 不传 = elite) */ createBrowserExchange: (accountId: number, target?: HuyaExchangeTargetKey) => api.post(`/huya/accounts/${accountId}/browser-exchange`, { target }), + + // ---- 虎牙外部 CK 协作台账(共享表,替代 Excel;业务规则见 docs/设计-虎牙外部CK协作台账.md)---- + /** 列表:共享视图,默认不按人过滤(负责人只是筛选维度) */ + listCollabOrders: (params: HuyaCollabOrderQuery = {}) => + api.get('/huya/external-orders', { params }), + /** 页头统计条:各业务状态计数 + CK 失效数 */ + collabOrdersSummary: () => + api.get('/huya/external-orders/summary'), + /** 负责人候选(全部活跃用户) */ + collabOrderOwners: () => + api.get('/huya/external-orders/owners'), + /** 已用过的店铺(筛选下拉) */ + collabOrderShops: () => api.get('/huya/external-orders/shops'), + getCollabOrder: (id: number) => + api.get(`/huya/external-orders/${id}`), + createCollabOrder: (data: HuyaCollabOrderCreate) => + api.post('/huya/external-orders', data), + /** 编辑:必须带 expected_version(别人先改过会 409) */ + updateCollabOrder: (id: number, data: HuyaCollabOrderUpdate) => + api.put(`/huya/external-orders/${id}`, data), + /** 推进/回退业务状态(回退必须显式 rollback) */ + updateCollabOrderStatus: (id: number, data: HuyaCollabStatusRequest) => + api.post(`/huya/external-orders/${id}/status`, data), + /** 贴/换 CK(action=paste)或从 raw_cookie 重建(action=rebuild) */ + updateCollabOrderCk: (id: number, data: HuyaCollabCkRequest) => + api.post(`/huya/external-orders/${id}/ck`, data), + /** 人工停用 / 恢复这份 CK(valid / invalid 只能由探针给) */ + updateCollabOrderCkStatus: (id: number, data: HuyaCollabCkStatusRequest) => + api.post(`/huya/external-orders/${id}/ck/status`, data), + /** 整槽替换图片(提交 /api/files/upload 返回的 object_key 数组) */ + updateCollabOrderImages: (id: number, data: HuyaCollabImagesRequest) => + api.post(`/huya/external-orders/${id}/images`, data), + /** 验证 CK(只读探针):写 ck_status,并刷新绑定账号/角色快照 */ + verifyCollabOrder: (id: number) => + api.post(`/huya/external-orders/${id}/verify`), + /** 批量验证 CK:人工触发、串行带间隔,一次最多 50 条 */ + verifyCollabOrders: (ids: number[]) => + api.post('/huya/external-orders/ck/verify', { ids }), + /** Excel/文本粘贴批量导入(TSV,首行可为表头) */ + importCollabOrders: (text: string) => + api.post('/huya/external-orders/import', { text }), + /** 操作时间线(直接读 audit_logs) */ + collabOrderTimeline: (id: number) => + api.get(`/huya/external-orders/${id}/timeline`), + /** 删除一行(只给运营:huya:collab:manage) */ + deleteCollabOrder: (id: number) => + api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>( + `/huya/external-orders/${id}`, + ), }; diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index 4fce821..e3b5102 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -8,6 +8,7 @@ export { automationApi } from './automation'; export { cookieApi } from './cookies'; export { dashboardApi } from './dashboard'; export { douyuApi } from './douyu'; +export { fileApi } from './files'; export { huyaApi } from './huya'; export { loginApi } from './login'; export { proxyApi } from './proxy'; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index e90cf5b..825ba73 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1195,3 +1195,194 @@ export interface AccountExchangeRecords { total: number; queried_at: string | null; } + +// ---- 虎牙外部 CK 协作台账(/api/huya/external-orders,见 docs/设计-虎牙外部CK协作台账.md)---- + +/** 业务状态:一单卡在哪一步(void 是旁路) */ +export type HuyaCollabStatus = 'pending' | 'bound' | 'recharged' | 'scripted' | 'done' | 'void'; + +/** 凭据状态:CK 本身还有没有效 */ +export type HuyaCollabCkStatus = 'unverified' | 'valid' | 'invalid' | 'disabled'; + +export type HuyaCollabImageSlot = 'bind_images' | 'home_images'; + +/** 一行 = 一单 = 一个外部虎牙 CK */ +export interface HuyaCollabOrder { + id: number; + order_no: string; + shop: string; + owner_id: number | null; + owner_name: string; + uid: string; + yyuid: string; + hy_username: string; + /** 虎牙号昵称(探针给不出,暂时为空) */ + nickname: string; + has_cookie: boolean; + has_raw_cookie: boolean; + ck_status: HuyaCollabCkStatus; + ck_status_label: string; + ck_checked_at: string | null; + ck_check_message: string; + /** 人工填的绑定斗鱼号(Excel D 列,贴表导入用) */ + bind_douyu_account: string; + bind_douyu_uid: string; + /** 探针读到的绑定对象(只读快照):游戏账号 / 角色 / 平台·区服 */ + bind_game_account: string; + bind_game_role: string; + bind_game_channel: string; + bind_snapshot_at: string | null; + bind_images: string[]; + home_images: string[]; + bind_image_urls: string[]; + home_image_urls: string[]; + bind_image_thumb_urls: string[]; + home_image_thumb_urls: string[]; + gift_name: string; + remark: string; + status: HuyaCollabStatus; + status_label: string; + next_status: HuyaCollabStatus | null; + bound_at: string | null; + recharged_at: string | null; + scripted_at: string | null; + done_at: string | null; + void_reason: string; + points: number | null; + elite_opened: boolean | null; + snapshot_at: string | null; + created_by: number | null; + created_by_name: string; + updated_by: number | null; + updated_by_name: string; + /** 乐观锁版本号:提交时必须原样带回,别人先改过会 409 */ + version: number; + created_at: string | null; + updated_at: string | null; +} + +export interface HuyaCollabOrderList { + items: HuyaCollabOrder[]; + total: number; + page: number; + page_size: number; +} + +export interface HuyaCollabSummary { + total: number; + by_status: Record; + ck_invalid: number; +} + +export interface HuyaCollabOwner { + id: number; + username: string; +} + +export interface HuyaCollabAuditItem { + id: number; + action: string; + username: string; + detail: string; + success: boolean | null; + created_at: string | null; +} + +export interface HuyaCollabOrderQuery { + shop?: string; + owner_id?: number | null; + status?: string; + ck_status?: string; + keyword?: string; + include_void?: boolean; + page?: number; + page_size?: number; +} + +export interface HuyaCollabOrderCreate { + order_no: string; + shop?: string; + owner_id?: number | null; + ck_text?: string; + bind_douyu_account?: string; + gift_name?: string; + remark?: string; +} + +export interface HuyaCollabOrderUpdate { + order_no?: string; + shop?: string; + owner_id?: number | null; + bind_douyu_account?: string; + bind_douyu_uid?: string; + gift_name?: string; + remark?: string; + expected_version?: number; +} + +export interface HuyaCollabStatusRequest { + status: HuyaCollabStatus; + reason?: string; + rollback?: boolean; + expected_version?: number; +} + +export interface HuyaCollabCkRequest { + ck_text: string; + action: 'paste' | 'rebuild'; + expected_version?: number; +} + +/** 人工只能把 CK 标成「已停用」/「未验证」;valid/invalid 由探针给 */ +export interface HuyaCollabCkStatusRequest { + status: 'unverified' | 'disabled'; + message?: string; + expected_version?: number; +} + +export interface HuyaCollabImagesRequest { + slot: HuyaCollabImageSlot; + images: string[]; + expected_version?: number; +} + +export interface HuyaCollabImportError { + line: number; + order_no: string; + message: string; +} + +export interface HuyaCollabImportResult { + created: number; + updated: number; + skipped: number; + errors: HuyaCollabImportError[]; + parsed_columns: string[]; +} + +export interface HuyaCollabVerifyItem { + id: number; + order_no: string; + ok: boolean; + ck_status: string; + message: string; + binding: Record; +} + +export interface HuyaCollabVerifyResult { + total: number; + valid: number; + failed: number; + results: HuyaCollabVerifyItem[]; +} + +/** 上传接口返回(object_key 是后续提交给协作台账图片槽位的东西) */ +export interface FileUploadResult { + object_key: string; + url: string; + thumbnail_url: string; + medium_url: string; + filename: string; + content_type: string; + size: number; +} diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index dc1aaab..54e5db3 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -7,7 +7,7 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined, SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined, BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined, - SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined, + SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined, FileExcelOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { getUser, clearAuth, type AuthUser } from '../store/auth'; @@ -113,6 +113,12 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { douyuItems.push({ key: '/yyb/recharge', label: '应用宝充值', icon: }); } + // 外部单协作台账:权限独立(huya:collab:view/edit 给运营和客服), + // 不受「虎牙任务」那套分支影响 —— 客服没有 huya:task 也要能看到这张共享表。 + if (can('huya:collab:view')) { + huyaItems.push({ key: '/huya/collab', label: '外部单协作', icon: }); + } + if (douyuItems.length > 0) { menuItems.push({ key: 'group-douyu', type: 'group', label: '斗鱼', children: douyuItems }); } diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx new file mode 100644 index 0000000..f26914b --- /dev/null +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -0,0 +1,1150 @@ +/** + * 虎牙外部单协作台账(`/huya/collab`)— 替代 Excel 多人协同表。 + * + * 设计:`docs/设计-虎牙外部CK协作台账.md`。三条要点: + * 1. **一行 = 一单 = 一个外部虎牙 CK**,所有人共享同一张表,改动靠审计留痕; + * 2. **绑定账号 / 绑定角色**来自「验证 CK」的只读探针快照(人工不可编辑,每次验证覆盖); + * 3. **内联编辑必须带 `expected_version`**:别人先改过会 409,绝不静默覆盖(Excel 最痛的点)。 + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + App, Button, Card, Col, Descriptions, Divider, Drawer, Dropdown, Empty, Form, Image, + Input, Modal, Popconfirm, Progress, Row, Select, Space, Statistic, Switch, Table, Tag, + Timeline, Tooltip, Typography, Upload, +} from 'antd'; +import type { TableProps } from 'antd'; +import { + CheckCircleOutlined, CloudUploadOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined, + LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, StopOutlined, + SwapOutlined, UndoOutlined, +} from '@ant-design/icons'; +import { + fileApi, + huyaApi, + type HuyaCollabAuditItem, + type HuyaCollabImageSlot, + type HuyaCollabOrder, + type HuyaCollabOrderQuery, + type HuyaCollabOrderUpdate, + type HuyaCollabOwner, + type HuyaCollabStatus, + type HuyaCollabSummary, +} from '../api/modules'; +import { message } from '../utils/antdMessage'; +import { getErrorMessage } from '../utils/error'; +import { usePermissions } from '../hooks/usePermissions'; + +const { Text, Paragraph } = Typography; + +const STATUS_META: Record = { + pending: { label: '待处理', color: 'default' }, + bound: { label: '已绑定', color: 'blue' }, + recharged: { label: '已充值', color: 'geekblue' }, + scripted: { label: '已上脚本', color: 'purple' }, + done: { label: '已完成', color: 'success' }, + void: { label: '已作废', color: 'error' }, +}; + +const CK_META: Record = { + unverified: { label: '未验证', color: 'default' }, + valid: { label: '有效', color: 'success' }, + invalid: { label: '已失效', color: 'error' }, + disabled: { label: '已停用', color: 'warning' }, +}; + +const ACTION_LABELS: Record = { + 'huya:collab:create': '新建', + 'huya:collab:update': '编辑', + 'huya:collab:status': '状态变更', + 'huya:collab:ck': 'CK 变更', + 'huya:collab:ck_status': 'CK 验证', + 'huya:collab:probe_snapshot': '绑定快照刷新', + 'huya:collab:image': '图片变更', + 'huya:collab:delete': '删除', +}; + +const SLOT_META: Record = { + bind_images: { label: '绑定图', keysKey: 'bind_images' }, + home_images: { label: '主页账号图', keysKey: 'home_images' }, +}; + +const IMPORT_PREVIEW_ROWS = 20; +const MAX_IMAGES_PER_SLOT = 20; + +function formatTime(value: string | null | undefined): string { + if (!value) return '-'; + const time = new Date(value); + return Number.isNaN(time.getTime()) ? String(value) : time.toLocaleString(); +} + +/** 点开即改、失焦提交(回车=失焦提交,Esc=放弃) */ +function EditableText({ + value, disabled, placeholder, onCommit, +}: { + value: string; + disabled?: boolean; + placeholder?: string; + onCommit: (next: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + setEditing(false); + }, [value]); + + if (!editing || disabled) { + return ( +
{ if (!disabled) setEditing(true); }} + style={{ cursor: disabled ? 'default' : 'text', minHeight: 22 }} + title={disabled ? undefined : '点击编辑'} + > + {value || {placeholder || '—'}} +
+ ); + } + + return ( + setDraft(event.target.value)} + onBlur={() => { + setEditing(false); + if (draft !== value) onCommit(draft); + }} + onPressEnter={(event) => event.currentTarget.blur()} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setDraft(value); + setEditing(false); + event.currentTarget.blur(); + } + }} + /> + ); +} + +/** 两个图片槽位(object_key 数组,第一张是封面) */ +function ImageSlotEditor({ + order, slot, canEdit, busy, onUpload, onRemove, onSetCover, +}: { + order: HuyaCollabOrder; + slot: HuyaCollabImageSlot; + canEdit: boolean; + busy: boolean; + onUpload: (slot: HuyaCollabImageSlot, file: File) => void; + onRemove: (slot: HuyaCollabImageSlot, key: string) => void; + onSetCover: (slot: HuyaCollabImageSlot, key: string) => void; +}) { + const keys = slot === 'bind_images' ? order.bind_images : order.home_images; + const urls = slot === 'bind_images' ? order.bind_image_urls : order.home_image_urls; + const thumbs = slot === 'bind_images' ? order.bind_image_thumb_urls : order.home_image_thumb_urls; + const full = keys.length >= MAX_IMAGES_PER_SLOT; + + return ( + + + + {keys.length === 0 && 还没有图} + {keys.length > 0 && ( + + {keys.map((key, index) => ( + +
+ {`${SLOT_META[slot].label}${index + {index === 0 && ( + + 封面 + + )} +
+ {canEdit && ( + + {index !== 0 && ( + + + + )} + +
+ ); +} + +export default function HuyaCollabPage() { + const { modal } = App.useApp(); + const { can } = usePermissions(); + const canEdit = can('huya:collab:edit'); + const canManage = can('huya:collab:manage'); + + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [summary, setSummary] = useState(null); + const [owners, setOwners] = useState([]); + const [shops, setShops] = useState([]); + const [filters, setFilters] = useState({ include_void: false }); + const [selectedIds, setSelectedIds] = useState([]); + + const [detail, setDetail] = useState(null); + const [timeline, setTimeline] = useState([]); + const [timelineLoading, setTimelineLoading] = useState(false); + const [detailForm] = Form.useForm(); + + const [createOpen, setCreateOpen] = useState(false); + const [createForm] = Form.useForm>(); + const [importOpen, setImportOpen] = useState(false); + const [importText, setImportText] = useState(''); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState<{ + created: number; updated: number; skipped: number; + errors: { line: number; order_no: string; message: string }[]; + } | null>(null); + + const [ckTarget, setCkTarget] = useState<{ order: HuyaCollabOrder; text: string } | null>(null); + const [statusAction, setStatusAction] = useState<{ + order: HuyaCollabOrder; mode: 'void' | 'rollback'; target?: HuyaCollabStatus; + } | null>(null); + const [statusReason, setStatusReason] = useState(''); + const [rollbackTarget, setRollbackTarget] = useState('pending'); + + const load = useCallback(async () => { + setLoading(true); + try { + const [list, nextSummary, nextOwners, nextShops] = await Promise.all([ + huyaApi.listCollabOrders({ ...filters, page, page_size: pageSize }), + huyaApi.collabOrdersSummary(), + huyaApi.collabOrderOwners(), + huyaApi.collabOrderShops(), + ]); + setRows(list.items); + setTotal(list.total); + setSummary(nextSummary); + setOwners(nextOwners); + setShops(nextShops); + // 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。 + setSelectedIds((ids) => ids.filter((id) => list.items.some((row) => row.id === id))); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setLoading(false); + } + }, [filters, page, pageSize]); + + useEffect(() => { void load(); }, [load]); + + const applyUpdated = useCallback((updated: HuyaCollabOrder) => { + setRows((list) => list.map((row) => (row.id === updated.id ? updated : row))); + setDetail((current) => (current && current.id === updated.id ? updated : current)); + }, []); + + const patchOrder = async (row: HuyaCollabOrder, payload: HuyaCollabOrderUpdate) => { + try { + const updated = await huyaApi.updateCollabOrder(row.id, { ...payload, expected_version: row.version }); + applyUpdated(updated); + message.success('已保存'); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + void load(); + } + }; + + const openDetail = async (row: HuyaCollabOrder) => { + setDetail(row); + detailForm.setFieldsValue({ + order_no: row.order_no, + shop: row.shop, + owner_id: row.owner_id, + bind_douyu_account: row.bind_douyu_account, + gift_name: row.gift_name, + remark: row.remark, + }); + setTimeline([]); + setTimelineLoading(true); + try { + setTimeline(await huyaApi.collabOrderTimeline(row.id)); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setTimelineLoading(false); + } + }; + + const verifyOne = async (row: HuyaCollabOrder) => { + setBusy(true); + try { + const updated = await huyaApi.verifyCollabOrder(row.id); + applyUpdated(updated); + if (updated.ck_status === 'valid') { + message.success( + updated.bind_game_role + ? `CK 有效:绑定角色 ${updated.bind_game_role}` + : 'CK 有效', + ); + } else { + message.warning(updated.ck_check_message || 'CK 验证未通过'); + } + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setBusy(false); + } + }; + + const verifySelected = async () => { + if (selectedIds.length === 0) return; + setBusy(true); + try { + const result = await huyaApi.verifyCollabOrders(selectedIds); + const failures = result.results.filter((item) => !item.ok); + if (failures.length === 0) { + message.success(`验证完成:${result.valid} 条全部有效`); + } else { + modal.info({ + title: `验证完成:有效 ${result.valid} / 未通过 ${result.failed}`, + width: 640, + content: ( + + {failures.map((item) => ( + + {item.order_no}:{item.message} + + ))} + + ), + }); + } + setSelectedIds([]); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setBusy(false); + } + }; + + const advance = async (row: HuyaCollabOrder) => { + if (!row.next_status) return; + setBusy(true); + try { + applyUpdated(await huyaApi.updateCollabOrderStatus(row.id, { + status: row.next_status, + expected_version: row.version, + })); + message.success(`已标记为「${STATUS_META[row.next_status].label}」`); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + void load(); + } finally { + setBusy(false); + } + }; + + const submitStatusAction = async () => { + if (!statusAction) return; + const { order, mode, target } = statusAction; + if (mode === 'void' && !statusReason.trim()) { + message.warning('作废必须填原因'); + return; + } + setBusy(true); + try { + applyUpdated(await huyaApi.updateCollabOrderStatus(order.id, { + status: mode === 'void' ? 'void' : (target ?? rollbackTarget), + reason: statusReason, + rollback: mode === 'rollback', + expected_version: order.version, + })); + message.success(mode === 'void' ? '已作废' : '已回退'); + setStatusAction(null); + setStatusReason(''); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setBusy(false); + } + }; + + const submitCk = async (action: 'paste' | 'rebuild') => { + if (!ckTarget) return; + if (action === 'paste' && !ckTarget.text.trim()) { + message.warning('请先贴一份 CK'); + return; + } + setBusy(true); + try { + const updated = await huyaApi.updateCollabOrderCk(ckTarget.order.id, { + ck_text: ckTarget.text, + action, + expected_version: ckTarget.order.version, + }); + applyUpdated(updated); + message.success(action === 'rebuild' ? '已从 App 原始凭据重建' : 'CK 已保存,待验证'); + setCkTarget(null); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setBusy(false); + } + }; + + const toggleCkDisabled = async (row: HuyaCollabOrder) => { + const next = row.ck_status === 'disabled' ? 'unverified' : 'disabled'; + setBusy(true); + try { + const updated = await huyaApi.updateCollabOrderCkStatus(row.id, { + status: next, + message: next === 'disabled' ? '人工停用' : '人工恢复,待验证', + expected_version: row.version, + }); + applyUpdated(updated); + message.success(next === 'disabled' ? '已停用这份 CK' : '已恢复(待验证)'); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setBusy(false); + } + }; + + const removeOrder = async (row: HuyaCollabOrder) => { + setBusy(true); + try { + await huyaApi.deleteCollabOrder(row.id); + message.success(`已删除订单 ${row.order_no}`); + if (detail?.id === row.id) setDetail(null); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setBusy(false); + } + }; + + const commitSlot = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, keys: string[]) => { + const updated = await huyaApi.updateCollabOrderImages(row.id, { + slot, images: keys, expected_version: row.version, + }); + applyUpdated(updated); + }; + + const uploadImage = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, file: File) => { + setBusy(true); + try { + const uploaded = await fileApi.upload(file, 'collab'); + const current = slot === 'bind_images' ? row.bind_images : row.home_images; + await commitSlot(row, slot, [...current, uploaded.object_key]); + message.success('图片已上传'); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + void load(); + } finally { + setBusy(false); + } + }; + + const removeImage = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, key: string) => { + setBusy(true); + try { + const current = slot === 'bind_images' ? row.bind_images : row.home_images; + await commitSlot(row, slot, current.filter((item) => item !== key)); + message.success('已从这一行移除'); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setBusy(false); + } + }; + + const setCoverImage = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, key: string) => { + setBusy(true); + try { + const current = slot === 'bind_images' ? row.bind_images : row.home_images; + await commitSlot(row, slot, [key, ...current.filter((item) => item !== key)]); + message.success('已设为封面'); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setBusy(false); + } + }; + + const submitCreate = async () => { + try { + const values = await createForm.validateFields(); + const created = await huyaApi.createCollabOrder({ + order_no: String(values.order_no ?? '').trim(), + shop: String(values.shop ?? ''), + owner_id: (values.owner_id as number | undefined) ?? null, + ck_text: String(values.ck_text ?? ''), + bind_douyu_account: String(values.bind_douyu_account ?? ''), + gift_name: String(values.gift_name ?? ''), + remark: String(values.remark ?? ''), + }); + message.success(`已新建 ${created.order_no}`); + setCreateOpen(false); + createForm.resetFields(); + void load(); + } catch (e: unknown) { + if (e instanceof Error) message.error(getErrorMessage(e)); + } + }; + + const submitImport = async () => { + if (!importText.trim()) { + message.warning('请先粘贴表格内容'); + return; + } + setImporting(true); + try { + const result = await huyaApi.importCollabOrders(importText); + setImportResult(result); + message.success(`导入完成:新建 ${result.created} / 更新 ${result.updated} / 跳过 ${result.skipped}`); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setImporting(false); + } + }; + + /** 导入弹窗里的本地预览(后端还会再解析一次,这里只是让人确认列没串) */ + const importPreview = useMemo(() => { + const lines = importText.split('\n').map((line) => line.replace(/\r$/, '')).filter((line) => line.trim()); + const cells = lines.map((line) => (line.includes('\t') ? line.split('\t') : line.split(','))); + return { + header: cells[0] ?? [], + rows: cells.slice(1, IMPORT_PREVIEW_ROWS + 1), + total: Math.max(0, cells.length - 1), + }; + }, [importText]); + + const ownerOptions = owners.map((owner) => ({ value: owner.id, label: owner.username })); + const shopOptions = shops.map((shop) => ({ value: shop, label: shop })); + const finished = summary ? (summary.by_status.done ?? 0) : 0; + + const columns: TableProps['columns'] = [ + { + title: '订单编号', dataIndex: 'order_no', width: 170, fixed: 'left', + render: (value: string, row) => ( + + {value} + {row.status === 'void' && 已作废:{row.void_reason || '-'}} + + ), + }, + { + title: '店铺', dataIndex: 'shop', width: 110, + render: (value: string, row) => ( + void patchOrder(row, { shop: next })} /> + ), + }, + { + title: '负责人', dataIndex: 'owner_id', width: 130, + render: (value: number | null, row) => ( + { setPage(1); setFilters((prev) => ({ ...prev, shop: value })); }} /> + ({ value: key, label: meta.label }))} + value={filters.status || undefined} + onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, status: value })); }} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + 截图(第一张是封面) + {(['bind_images', 'home_images'] as HuyaCollabImageSlot[]).map((slot) => ( + void uploadImage(detail, targetSlot, file)} + onRemove={(targetSlot, key) => void removeImage(detail, targetSlot, key)} + onSetCover={(targetSlot, key) => void setCoverImage(detail, targetSlot, key)} + /> + ))} + + {canEdit && ( + + {detail.next_status && ( + + )} + + {detail.status !== 'void' && ( + + )} + + )} + + 操作时间线 + {timeline.length === 0 && !timelineLoading && } + ({ + color: item.success === false ? 'red' : 'blue', + children: ( + + + {ACTION_LABELS[item.action] || item.action} + · {item.username || '-'} + + {formatTime(item.created_at)} + {item.detail && ( + + {item.detail} + + )} + + ), + }))} + /> + + )} + + + setCreateOpen(false)} + onOk={() => void submitCreate()} + confirmLoading={busy} + okText="新建" + destroyOnHidden + > +
+ + + + + + + + + + + +
+
+ + setImportOpen(false)} + onOk={() => void submitImport()} + confirmLoading={importing} + okText="导入" + destroyOnHidden + > + + + 列顺序:店铺 / 订单编号 / 负责人 / 绑定斗鱼号 / 礼包 / 备注(首行表头可留)。 + 已存在的订单号按「更新」处理;单行出错不影响其他行。 + + setImportText(event.target.value)} + placeholder={'佳佳\t2627501604891357\t张三\t用户123\t不朽伊人大礼包\t熟人'} /> + {importPreview.rows.length > 0 && ( + <> + + 预览(前 {importPreview.rows.length} 行,共 {importPreview.total} 行) + {importPreview.header.length > 1 ? '' : ':未识别到表头,按固定列序解析'} + + String(index)} + pagination={false} + scroll={{ x: 700, y: 200 }} + dataSource={importPreview.rows.map((cells, index) => ({ index, cells }))} + columns={['店铺', '订单编号', '负责人', '绑定斗鱼号', '礼包', '备注'].map((title, columnIndex) => ({ + title, dataIndex: ['cells', columnIndex], width: 120, ellipsis: true, + render: (value: string | undefined) => value || -, + }))} + /> + + )} + {importResult && ( + + + 结果:新建 {importResult.created} / 更新 {importResult.updated} / 跳过 {importResult.skipped} + + {importResult.errors.map((error) => ( + + 第 {error.line} 行({error.order_no || '无订单号'}):{error.message} + + ))} + + )} + + + + setCkTarget(null)} + footer={[ + , + , + , + ]} + destroyOnHidden + > + + + 贴一份新的外部 CK:支持「纯 CK」或「账号----密码----CK」。保存后状态回到「未验证」, + 点「验证 CK」用只读探针确认(探针通不通由服务端说了算)。 + + setCkTarget((current) => (current ? { ...current, text: event.target.value } : current))} + placeholder="udb_uid=...;udb_passport=...;udb_cred=..." /> + + + + setStatusAction(null)} + onOk={() => void submitStatusAction()} + confirmLoading={busy} + okButtonProps={{ danger: statusAction?.mode === 'void' }} + okText={statusAction?.mode === 'void' ? '作废' : '回退'} + destroyOnHidden + > + + {statusAction?.mode === 'rollback' && ( +