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

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

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

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

验证:pytest 880 passed / 6 skipped;ruff format 全绿、ruff check 仅剩既有告警;
前端 tsc -b / eslint / vite build 通过;接口端到端冒烟 29 项全绿(TestClient,探针用假客户端);
探针真机冒烟 3 例(有效 CK=True、别人 uid=False、裸 cookie=False)。
This commit is contained in:
yml2213
2026-10-16 19:38:59 +08:00
parent 99e8643d83
commit df8c63b829
32 changed files with 7449 additions and 16 deletions
+2
View File
@@ -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() {
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
<Route path="huya/elite" element={lazyRoute(<HuyaElitePage />)} />
<Route path="huya/collab" element={lazyRoute(<HuyaCollabPage />)} />
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
+18
View File
@@ -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<FileUploadResult, FileUploadResult>('/files/upload', form);
},
};
+63
View File
@@ -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<HuyaBrowserExchangeKit, HuyaBrowserExchangeKit>(`/huya/accounts/${accountId}/browser-exchange`, { target }),
// ---- 虎牙外部 CK 协作台账(共享表,替代 Excel;业务规则见 docs/设计-虎牙外部CK协作台账.md)----
/** 列表:共享视图,默认不按人过滤(负责人只是筛选维度) */
listCollabOrders: (params: HuyaCollabOrderQuery = {}) =>
api.get<HuyaCollabOrderList, HuyaCollabOrderList>('/huya/external-orders', { params }),
/** 页头统计条:各业务状态计数 + CK 失效数 */
collabOrdersSummary: () =>
api.get<HuyaCollabSummary, HuyaCollabSummary>('/huya/external-orders/summary'),
/** 负责人候选(全部活跃用户) */
collabOrderOwners: () =>
api.get<HuyaCollabOwner[], HuyaCollabOwner[]>('/huya/external-orders/owners'),
/** 已用过的店铺(筛选下拉) */
collabOrderShops: () => api.get<string[], string[]>('/huya/external-orders/shops'),
getCollabOrder: (id: number) =>
api.get<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}`),
createCollabOrder: (data: HuyaCollabOrderCreate) =>
api.post<HuyaCollabOrder, HuyaCollabOrder>('/huya/external-orders', data),
/** 编辑:必须带 expected_version(别人先改过会 409) */
updateCollabOrder: (id: number, data: HuyaCollabOrderUpdate) =>
api.put<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}`, data),
/** 推进/回退业务状态(回退必须显式 rollback) */
updateCollabOrderStatus: (id: number, data: HuyaCollabStatusRequest) =>
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/status`, data),
/** 贴/换 CK(action=paste)或从 raw_cookie 重建(action=rebuild) */
updateCollabOrderCk: (id: number, data: HuyaCollabCkRequest) =>
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/ck`, data),
/** 人工停用 / 恢复这份 CK(valid / invalid 只能由探针给) */
updateCollabOrderCkStatus: (id: number, data: HuyaCollabCkStatusRequest) =>
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/ck/status`, data),
/** 整槽替换图片(提交 /api/files/upload 返回的 object_key 数组) */
updateCollabOrderImages: (id: number, data: HuyaCollabImagesRequest) =>
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/images`, data),
/** 验证 CK(只读探针):写 ck_status,并刷新绑定账号/角色快照 */
verifyCollabOrder: (id: number) =>
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/verify`),
/** 批量验证 CK:人工触发、串行带间隔,一次最多 50 条 */
verifyCollabOrders: (ids: number[]) =>
api.post<HuyaCollabVerifyResult, HuyaCollabVerifyResult>('/huya/external-orders/ck/verify', { ids }),
/** Excel/文本粘贴批量导入(TSV,首行可为表头) */
importCollabOrders: (text: string) =>
api.post<HuyaCollabImportResult, HuyaCollabImportResult>('/huya/external-orders/import', { text }),
/** 操作时间线(直接读 audit_logs) */
collabOrderTimeline: (id: number) =>
api.get<HuyaCollabAuditItem[], HuyaCollabAuditItem[]>(`/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}`,
),
};
+1
View File
@@ -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';
+191
View File
@@ -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<string, number>;
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<string, string>;
}
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;
}
+7 -1
View File
@@ -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: <ShoppingCartOutlined /> });
}
// 外部单协作台账:权限独立(huya:collab:view/edit 给运营和客服),
// 不受「虎牙任务」那套分支影响 —— 客服没有 huya:task 也要能看到这张共享表。
if (can('huya:collab:view')) {
huyaItems.push({ key: '/huya/collab', label: '外部单协作', icon: <FileExcelOutlined /> });
}
if (douyuItems.length > 0) {
menuItems.push({ key: 'group-douyu', type: 'group', label: '斗鱼', children: douyuItems });
}
File diff suppressed because it is too large Load Diff