diff --git a/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md b/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md index b47051e..95cfd44 100644 --- a/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md +++ b/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md @@ -704,6 +704,7 @@ data.totalCount 6. 兑换接口是 GET 状态变更,必须设置本地防重锁并禁止自动重试。 7. 支付二维码从 `createorder.data.url` 对应的收银台 HTML 中解析,不要重新计算 `qr.weibo.cn` 的 `sign`。 8. 不记录或回显完整 Cookie、支付 URL、二维码短链、uid、vuid、昵称和订单号。 +9. 已导入 CK 的单行复制走 `POST /api/weibo/accounts/{account_id}/ck/copy`,由独立权限 `weibo:account:ck_export` 控制;列表/详情不返回 CK 明文,复制动作写 `weibo:account:ck_export` 审计,但审计 detail 只记账号 ID 与 UID,不写 CK 本体。 ## 10. 鉴权与错误处理 diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index 0f8a191..2534c5d 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -18,9 +18,10 @@ from core.weibo import ( WeiboRechargeError, WeiboRechargeOrder, ) -from web.backend.models import User, WeiboAccount +from web.backend.models import AuditLog, User, WeiboAccount from web.backend.routers import weibo as weibo_router from web.backend.routers.weibo import ( + copy_account_ck, create_bind_qrcodes, disable_account, enable_account, @@ -221,6 +222,57 @@ def test_refresh_marks_payment_login_expiry_as_invalid(db, current_user): assert "6102" in account.ck_check_message +def test_copy_account_ck_returns_plaintext_and_audits_without_ck(db, current_user): + account_out = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=b", tag=""), + db=db, + current=current_user, + ).accounts[0] + + result = copy_account_ck(account_out.id, db=db, current=current_user) + + dumped = result.model_dump() + assert dumped["account_id"] == account_out.id + assert dumped["ck_text"] == "SCF=a; SUB=b" + audit = ( + db.query(AuditLog).filter(AuditLog.action == "weibo:account:ck_export").one() + ) + assert dumped["uid"] in audit.detail + assert "SUB=b" not in audit.detail + + +def test_copy_account_ck_uses_export_permission_defaults(db, current_user): + account_out = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=b", tag=""), + db=db, + current=current_user, + ).accounts[0] + support = User( + username="weibo-copy-support", + password_hash="hash", + role="support", + is_active=True, + ) + db.add(support) + db.commit() + + with pytest.raises(HTTPException) as exc_info: + copy_account_ck(account_out.id, db=db, current=support) + + assert exc_info.value.status_code == 403 + + operator = User( + username="weibo-copy-operator", + password_hash="hash", + role="operation", + is_active=True, + ) + db.add(operator) + db.commit() + result = copy_account_ck(account_out.id, db=db, current=operator) + assert result.ck_text == "SCF=a; SUB=b" + + def test_list_accounts_returns_parallel_page_shape(db, current_user): assert ( import_cookies( diff --git a/web/backend/permissions.py b/web/backend/permissions.py index c81dbcc..25ee4ad 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -45,6 +45,7 @@ PERMISSIONS = { "weibo:view": "查看微博账号", "weibo:import": "导入/刷新微博 CK", "weibo:manage": "编辑、停用和启用微博账号", + "weibo:account:ck_export": "复制微博外部 CK 账号的 CK(写剪贴板)", "weibo:bind": "生成微博绑定二维码", "weibo:recharge": "创建微博助威充值订单", "weibo:exchange": "执行微博微豆兑换", @@ -115,6 +116,7 @@ ROLE_PERMISSIONS = { "weibo:view", "weibo:import", "weibo:manage", + "weibo:account:ck_export", "weibo:bind", "weibo:recharge", "weibo:exchange", diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index 52fe404..83c4750 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -16,6 +16,7 @@ from ..deps import get_current_user from ..models import User, WeiboAccount from ..permissions import user_has_permission from ..schemas import ( + WeiboAccountCkOut, WeiboAccountDisableRequest, WeiboAccountOut, WeiboAccountUpdate, @@ -347,6 +348,33 @@ def refresh_account( return _account_out(account) +@router.post("/accounts/{account_id}/ck/copy", response_model=WeiboAccountCkOut) +def copy_account_ck( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """复制已导入的微博 CK 明文;只走单独权限并保留审计。""" + + _require_weibo_perm(current, "weibo:account:ck_export") + account = _get_account(db, account_id) + ck_text = (account.cookie or "").strip() + if not ck_text: + raise HTTPException(status_code=409, detail="该账号没有保存 CK") + _record_audit_safely( + db, + current, + action="weibo:account:ck_export", + target=f"weibo_account:{account.id}", + detail={"account_id": account.id, "uid": account.uid}, + ) + return WeiboAccountCkOut( + account_id=account.id, + uid=account.uid, + ck_text=ck_text, + ) + + @router.post("/accounts/{account_id}/bind-qrcodes", response_model=WeiboBindQrOut) def create_bind_qrcodes( account_id: int, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index fce26b5..2a583fa 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -509,6 +509,12 @@ class WeiboAccountDisableRequest(BaseModel): reason: str = Field("", max_length=256) +class WeiboAccountCkOut(BaseModel): + account_id: int + uid: str + ck_text: str + + class WeiboImportResult(BaseModel): success: bool message: str diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index ad38c41..b0392d6 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -801,6 +801,12 @@ export interface WeiboBindQrResult { schemes: WeiboBindScheme[]; } +export interface WeiboAccountCkResult { + account_id: number; + uid: string; + ck_text: string; +} + export interface WeiboGoodsItem { id: number; goods_name: string; diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts index c958f2f..795ab3f 100644 --- a/web/frontend/src/api/weibo.ts +++ b/web/frontend/src/api/weibo.ts @@ -3,6 +3,7 @@ import type { MessageResponse, PaginatedResponse, WeiboAccountItem, + WeiboAccountCkResult, WeiboBindQrResult, WeiboExchangeRecordsResult, WeiboExchangeResult, @@ -55,6 +56,10 @@ export const weiboApi = { api.post( `/weibo/accounts/${accountId}/refresh`, ), + copyAccountCk: (accountId: number) => + api.post( + `/weibo/accounts/${accountId}/ck/copy`, + ), bindQrcodes: (accountId: number, channel: 'wx' | 'qq') => api.post( `/weibo/accounts/${accountId}/bind-qrcodes`, diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index c546442..6925f60 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -86,6 +86,7 @@ export default function WeiboAccountsPage() { const { can } = usePermissions(); const canImport = can('weibo:import'); const canManage = can('weibo:manage'); + const canExportCk = can('weibo:account:ck_export'); const canBind = can('weibo:bind'); const canRecharge = can('weibo:recharge'); const canExchange = can('weibo:exchange'); @@ -100,6 +101,7 @@ export default function WeiboAccountsPage() { const [enabledFilter, setEnabledFilter] = useState(''); const [loading, setLoading] = useState(false); const [refreshingId, setRefreshingId] = useState(null); + const [copyingId, setCopyingId] = useState(null); const [mutatingId, setMutatingId] = useState(null); const [selectedIds, setSelectedIds] = useState([]); const [batchBusy, setBatchBusy] = useState(false); @@ -216,6 +218,19 @@ export default function WeiboAccountsPage() { } }; + const handleCopyCk = async (account: WeiboAccountItem) => { + setCopyingId(account.id); + try { + const result = await weiboApi.copyAccountCk(account.id); + await copyTextToClipboard(result.ck_text); + message.success(`${account.nickname || account.uid} 的 CK 已复制`); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setCopyingId(null); + } + }; + const openQr = (account: WeiboAccountItem) => { if (!account.is_enabled) { message.warning('账号已停用,请先启用'); @@ -699,6 +714,16 @@ export default function WeiboAccountsPage() { > 功能 + {canExportCk && ( + + )} {canManage && (