feat(weibo): 增加外部 CK 单行复制

This commit is contained in:
yml2213
2026-11-08 14:13:01 +08:00
parent 5b42577b0b
commit 66b4f72994
8 changed files with 126 additions and 1 deletions
@@ -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. 鉴权与错误处理
+53 -1
View File
@@ -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(
+2
View File
@@ -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",
+28
View File
@@ -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,
+6
View File
@@ -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
+6
View File
@@ -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;
+5
View File
@@ -3,6 +3,7 @@ import type {
MessageResponse,
PaginatedResponse,
WeiboAccountItem,
WeiboAccountCkResult,
WeiboBindQrResult,
WeiboExchangeRecordsResult,
WeiboExchangeResult,
@@ -55,6 +56,10 @@ export const weiboApi = {
api.post<WeiboAccountItem, WeiboAccountItem>(
`/weibo/accounts/${accountId}/refresh`,
),
copyAccountCk: (accountId: number) =>
api.post<WeiboAccountCkResult, WeiboAccountCkResult>(
`/weibo/accounts/${accountId}/ck/copy`,
),
bindQrcodes: (accountId: number, channel: 'wx' | 'qq') =>
api.post<WeiboBindQrResult, WeiboBindQrResult>(
`/weibo/accounts/${accountId}/bind-qrcodes`,
@@ -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<number | null>(null);
const [copyingId, setCopyingId] = useState<number | null>(null);
const [mutatingId, setMutatingId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
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() {
>
功能
</Button>
{canExportCk && (
<Button
size="small"
icon={<CopyOutlined />}
loading={copyingId === account.id}
onClick={() => void handleCopyCk(account)}
>
复制CK
</Button>
)}
{canManage && (
<Button
size="small"