diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index 4e38f8b..6e9a204 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -580,6 +580,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid | POST | `/api/huya/external-orders/activity/query` | `huya:collab:edit` | 勾选多行查积分 + 兑换记录(串行带间隔,每行写一条生命周期快照;§5.7) | | POST | `/api/huya/external-orders/{id}/activity/query` | `huya:collab:edit` | 查这一行的积分 + 兑换记录(单行「查询」按钮) | | GET | `/api/huya/external-orders/{id}/metrics` | `huya:collab:view` | 账号全生命周期时间线(每次查询的快照,新→旧;§3.5) | +| POST | `/api/huya/external-orders/{id}/ck/copy` | **`huya:collab:ck_export`** | **复制单行 CK 明文**(前端写剪贴板;逐行写审计;§11.8) | | POST | `/api/huya/external-orders/ck/export` | **`huya:collab:ck_export`** | **导出勾选行的 CK 明文**(前端写剪贴板;逐行写审计;最多 50 条;§11.8) | | POST | `/api/huya/external-orders/import` | `huya:collab:edit` | 粘贴批量导入 | | GET | `/api/huya/collab-shops` | `huya:collab:view` | 店铺字典(含停用的,管理弹窗用) | @@ -927,9 +928,9 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid | 面 | 落地 | | --- | --- | -| 权限 | 新增权限点 **`huya:collab:ck_export`**("导出外部 CK 协作台账的 CK");**默认只给运营**,客服默认没有(贴 CK 是"录入",导出是"把凭据带走",两件事不共用一个权限;口径同 `huya:cookie:export`) | +| 权限 | 权限点 **`huya:collab:ck_export`**("复制/导出外部 CK 协作台账的 CK");单行复制和勾选批量导出共用;**默认运营和客服都有**,权限页的自定义权限仍可覆盖 | | 服务层 | `export_cks(db, current, orders)`:只读 + 逐行写审计;返回 `{total, exported, skipped[], items[]}`,`items[].ck_text` 是明文(**只有这个接口会返回**);上限 `PROBE_BATCH_MAX=50` | -| 接口 | `POST /api/huya/external-orders/ck/export`(字面量路径,排在 `/{order_id}` 之前),body `{ids: [...]}` | +| 接口 | 单行:`POST /api/huya/external-orders/{id}/ck/copy`;批量:`POST /api/huya/external-orders/ck/export`(字面量路径,排在 `/{order_id}` 之前),body `{ids: [...]}`;两者共用同一个权限依赖 | | 审计 | 逐行写 `huya:collab:ck_export`,`target=huya_external_order:`,detail 只记 `order_no / uid / ck_status / batch_size` —— **绝不写 CK 本体**(测试里用 `assert "C"*152 not in detail` 钉死) | | 前端 | 工具栏「导出 CK(N)」→ 拉明文 → 弹窗预览 + 「复制到剪贴板」(走 `utils/clipboard.ts`,兼容 http 远程访问的 `execCommand` 回退) | @@ -943,7 +944,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid 界面上点名"其中 N 条已失效/已停用" —— 不替用户做隐含决策(服务层只管"有没有凭据"); 6. **上限 50 行**(和批量验证/查询一致),一次别点太多,人看不过来也难追溯。 -**待跟进**:导出动作目前只写审计;如果以后要做"谁在什么时候导出了什么"的专门报表, +**待跟进**:单行复制和批量导出目前都只写审计;如果以后要做"谁在什么时候导出了什么"的专门报表, `audit_logs.action = 'huya:collab:ck_export'` 已经可以直接筛(不用加表)。 ### 11.9 2026-10-17 第六轮:批量查询拆成「查积分」+「查兑换记录」两个按钮 @@ -1083,7 +1084,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid | --- | --- | | 与 `huya_accounts` 出现"两份 CK 谁是准的" | §2.3 已写死:本表 CK 只在协作语境有效,不自动同步;需要跑任务时人工导入 | | 探针接口被风控 | 只做人工触发、串行、带间隔;不做定时轮询 | -| CK 被导出后外传(导出到剪贴板 = 凭据出门) | 独立权限点 `huya:collab:ck_export`(默认只给运营)+ 逐行审计(不含 CK 本体)+ 界面常驻警告;明文只在弹窗 state 里,关掉即清(§11.8) | +| CK 被导出后外传(导出到剪贴板 = 凭据出门) | 单行复制与批量导出统一走 `huya:collab:ck_export`(默认运营和客服可用,权限页可覆盖)+ 逐行审计(不含 CK 本体)+ 界面常驻警告;明文只在弹窗 state 里,关掉即清(§11.8) | | 积分 / 兑换记录查询被风控 | 同上:只手动触发(单行 + 勾选批量,串行带间隔),不做定时轮询(§5.7) | | 自动改状态误判(接口抖动 → 判成"抢购失败") | 三态判定:取不到数据一律 `unknown` 且不动状态;只有 `scripted` 的单才自动改;每次查询留快照,人工可回退(`rollback`) | | 积分中心 `queryAllocation` 拉不到 / 返回空 → 礼包列表空白 | 只影响「拉礼包」,不影响 CK 有效性;提示用户稍后点「强制刷新礼包」。旧 `getActPrizeList` 的 SID 过期问题只保留给精英宝典工作台(§11.4) | diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index f8e5218..e0c3c09 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -16,6 +16,7 @@ import pytest from core.huya.taf_protocol import TafOutputStream, TafType from tests.support.db import get_test_engine, make_test_session +from web.backend.deps import require_permission from web.backend.huya_defaults import ( HUYA_DEFAULT_BIND_ACT_ID, HUYA_DEFAULT_OUTER_ACT_ID, @@ -3090,22 +3091,33 @@ class TestCollabCkExport(TestCollabBase): collab.export_cks(self.db, self.user, [None] * (collab.PROBE_BATCH_MAX + 1)) assert excinfo.value.code == "ck_export_too_large" - def test_single_row_copy_is_super_admin_only(self): - """操作列「复制 CK」只给管理员;运营/客服即使有导出权限也不能走这个入口。""" + def test_single_row_copy_uses_the_same_export_permission(self): + """单行复制与批量导出共用权限点,运营和客服默认都可用。""" order = self._create(order_no="EXP-ADMIN-1", ck_text=fake_ck("1199647000099")) - with pytest.raises(collab_api.HTTPException) as excinfo: - collab_api.copy_external_order_ck(order.id, self.db, self.user) - assert excinfo.value.status_code == 403 + checker = require_permission("huya:collab:ck_export") + assert checker(self.user) == self.user + assert checker(self.other) == self.other - admin = User(username="collab_admin", password_hash="h", role="super_admin") - self.db.add(admin) - self.db.commit() - result = collab_api.copy_external_order_ck(order.id, self.db, admin) + result = collab_api.copy_external_order_ck(order.id, self.db, self.other) assert result["exported"] == 1 assert result["items"][0]["order_no"] == "EXP-ADMIN-1" assert result["items"][0]["ck_text"] == order.cookie - assert self._audits(order)[-1].username == "collab_admin" + assert self._audits(order)[-1].username == self.other.username + + def test_single_row_copy_rejects_user_without_export_permission(self): + checker = require_permission("huya:collab:ck_export") + denied = User( + username="collab_denied", + password_hash="h", + role="support", + custom_permissions=[], + is_active=True, + ) + + with pytest.raises(collab_api.HTTPException) as excinfo: + checker(denied) + assert excinfo.value.status_code == 403 class TestProbeCache(TestCollabBase): @@ -3162,10 +3174,10 @@ class TestCollabPermissions: assert "file:view" in ROLE_PERMISSIONS[role] assert "file:upload" in ROLE_PERMISSIONS[role] - def test_ck_export_is_operation_only_by_default(self): - """导出 CK = 把凭据带出系统:默认只给运营(要发给客服就在权限页单独勾)。""" - assert "huya:collab:ck_export" in ROLE_PERMISSIONS["operation"] - assert "huya:collab:ck_export" not in ROLE_PERMISSIONS["support"] + def test_ck_export_is_available_to_operation_and_support_by_default(self): + """单行复制与批量导出共用权限,默认运营、客服都可用。""" + for role in ("operation", "support"): + assert "huya:collab:ck_export" in ROLE_PERMISSIONS[role] assert "huya:collab:ck_export" in ROLE_PERMISSIONS["super_admin"] def test_collab_module_does_not_touch_huya_accounts(self): diff --git a/web/backend/permissions.py b/web/backend/permissions.py index d256d31..c81dbcc 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -70,8 +70,8 @@ PERMISSIONS = { "huya:collab:view": "查看外部 CK 协作台账", "huya:collab:edit": "编辑外部 CK 协作台账", "huya:collab:manage": "删除外部 CK 协作台账", - # CK 是凭据:导到剪贴板 = 把凭据带出系统,单独一个点(同 cookie:export 的口径),默认只给运营 - "huya:collab:ck_export": "导出外部 CK 协作台账的 CK(写剪贴板)", + # CK 是凭据:单行复制与批量导出共用这个权限点,默认运营和客服都有。 + "huya:collab:ck_export": "复制/导出外部 CK 协作台账的 CK(写剪贴板)", # 代理 & 白名单 "proxy:manage": "代理配置管理", "whitelist:manage": "白名单配置管理", @@ -163,8 +163,8 @@ ROLE_PERMISSIONS = { # 客服能看能改(共享表,靠审计留痕),但删除只给运营。 "huya:collab:view", "huya:collab:edit", - # 注意:**不给** huya:collab:ck_export —— 贴 CK 是「录入」,导出是「把凭据带走」, - # 需要时在权限页单独勾(口径同 huya:cookie:export,见设计文档 §11.8)。 + # 单行复制与批量导出共用权限点,客服默认也可用,仍逐行写审计。 + "huya:collab:ck_export", "file:view", "file:upload", ], diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index 33e0424..7f6d9d4 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -30,7 +30,7 @@ from fastapi import ( from sqlalchemy.orm import Session from ...database import get_db -from ...deps import authenticate_websocket, get_current_user, require_permission +from ...deps import authenticate_websocket, require_permission from ...models import AuditLog, HuyaExternalOrder, User from ...permissions import get_user_permissions from ...schemas import ( @@ -625,9 +625,9 @@ def export_external_orders_ck( ): """勾选多行导出 **CK 明文**(前端写进剪贴板)。 - 单独一个权限点 ``huya:collab:ck_export``(默认只给运营):贴 CK 是「录入」, - 导出是「把凭据带走」。逐行写审计(``huya:collab:ck_export``),但**只记单号**, - 不把 CK 本体写进审计;没 CK 的行在 ``skipped`` 里显式回原因。 + 与单行复制共用权限点 ``huya:collab:ck_export``,默认运营和客服都有。 + 逐行写审计,但**只记单号**,不把 CK 本体写进审计; + 没 CK 的行在 ``skipped`` 里显式回原因。 """ try: orders = collab.list_orders_by_ids(db, req.ids) @@ -643,16 +643,12 @@ def export_external_orders_ck( def copy_external_order_ck( order_id: int, db: Session = Depends(get_db), - current: User = Depends(get_current_user), + current: User = Depends(require_permission("huya:collab:ck_export")), ): - """复制单行 CK 明文(**仅超级管理员**;前端直接写剪贴板)。 + """复制单行 CK 明文(由 ``huya:collab:ck_export`` 权限控制)。 - 这个入口比勾选导出更敏感:它把「单行凭据」做成一键复制,因此只给管理员。 - 仍然复用 :func:`collab.export_cks`:逐行写 ``huya:collab:ck_export`` 审计, - 但审计里只记单号,不写 CK 本体。 + 与勾选导出共用同一个权限点和审计动作,审计只记单号,不写 CK 本体。 """ - if current.role != "super_admin": - raise HTTPException(status_code=403, detail="仅超级管理员可复制 CK") try: order = collab.get_order(db, order_id) return collab.export_cks(db, current, [order]) diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 93af418..a214ef0 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -2506,8 +2506,8 @@ def export_cks( 这是本模块唯一一个把凭据**往外送**的读接口,所以三条约束写死在这里: - 1. **权限单独一个点**(``huya:collab:ck_export``,默认只给运营)—— 贴 CK 是「录入」, - 导出是「把凭据带走」,两件事不该共用一个权限(口径同 ``huya:cookie:export``); + 1. **单行复制与批量导出共用一个权限点** ``huya:collab:ck_export``, + 默认给运营和客服;权限页的自定义权限仍可覆盖; 2. **逐行写审计**(``huya:collab:ck_export``):只记「谁在什么时候导出了哪一单」, **绝不把 CK 本体写进审计/日志**(列表接口也照旧不返回 cookie); 3. **没 CK 的行显式跳过并回原因**,不静默丢;也不替用户猜 —— diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 7a05c11..06908d1 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -345,7 +345,7 @@ export const huyaApi = { '/huya/external-orders/ck/export', { ids }, ), - /** 单行复制 CK(仅超级管理员;后端再次校验角色) */ + /** 单行复制 CK(与批量导出共用 huya:collab:ck_export 权限) */ copyCollabOrderCk: (id: number) => api.post( `/huya/external-orders/${id}/ck/copy`, diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index fef403d..8fd6105 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -501,10 +501,8 @@ export default function HuyaCollabPage() { const { can, user } = usePermissions(); const canEdit = can('huya:collab:edit'); const canManage = can('huya:collab:manage'); - // 导出 CK 是把凭据带出系统,单独一个权限点(默认只给运营,见 permissions.py) + // 单行复制与批量导出共用这个权限点(默认运营和客服都有) const canExportCk = can('huya:collab:ck_export'); - // 「复制 CK」是单行一键带走凭据,按用户要求只给超级管理员(后端也会再校验一次) - const isAdmin = user?.role === 'super_admin'; const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); @@ -1399,7 +1397,7 @@ export default function HuyaCollabPage() { } }; - /** 单行「复制 CK」:只有超级管理员能看到按钮,后端还会再校验一次角色。 */ + /** 单行「复制 CK」与批量导出共用权限点,后端会再次校验。 */ const copyOneCk = async (row: HuyaCollabOrder) => { setBusy(true); try { @@ -2249,8 +2247,8 @@ export default function HuyaCollabPage() { 补资料 )} - {isAdmin && !row.is_archived && ( - + {canExportCk && !row.is_archived && ( +