feat(weibo): 增加协作 CK 批量复制与商品图片代理
This commit is contained in:
@@ -1290,6 +1290,7 @@ def list_mall_goods(
|
||||
db,
|
||||
account,
|
||||
activity=WEIBO_DEFAULT_ACTIVITY,
|
||||
embed_images=True,
|
||||
)
|
||||
except (WeiboError, ValueError) as exc:
|
||||
raise _manual_http_error(exc) from exc
|
||||
|
||||
@@ -26,6 +26,8 @@ from ..schemas import (
|
||||
WeiboCollabAccountRequest,
|
||||
WeiboCollabArchiveRequest,
|
||||
WeiboCollabBalanceCheckRequest,
|
||||
WeiboCollabCkExportOut,
|
||||
WeiboCollabCkExportRequest,
|
||||
WeiboCollabExchangeCheckRequest,
|
||||
WeiboCollabGoodsCreate,
|
||||
WeiboCollabGoodsOut,
|
||||
@@ -370,6 +372,22 @@ def create_external_order(
|
||||
return _one(db, order)
|
||||
|
||||
|
||||
@router.post("/external-orders/ck/export", response_model=WeiboCollabCkExportOut)
|
||||
def export_external_orders_ck(
|
||||
req: WeiboCollabCkExportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("weibo:account:ck_export")),
|
||||
):
|
||||
"""按所选协作单导出关联微博账号的 CK 明文,并逐单写审计。"""
|
||||
|
||||
if "weibo:collab:view" not in get_user_permissions(current):
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
try:
|
||||
return collab.export_cks(db, current, req.ids)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/external-orders/verify/batch")
|
||||
def verify_external_orders_batch(
|
||||
ids: list[int],
|
||||
|
||||
@@ -848,6 +848,37 @@ class WeiboCollabStatusBatchRequest(BaseModel):
|
||||
rollback: bool = False
|
||||
|
||||
|
||||
class WeiboCollabCkExportRequest(BaseModel):
|
||||
"""勾选多单,导出关联微博账号的 CK 明文(一次最多 50 单)。"""
|
||||
|
||||
ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WeiboCollabCkExportItemOut(BaseModel):
|
||||
"""导出结果的一行;``ck_text`` 是明文凭据,只由这个接口返回。"""
|
||||
|
||||
id: int
|
||||
order_no: str = ""
|
||||
account_id: int
|
||||
uid: str = ""
|
||||
nickname: str = ""
|
||||
ck_status: str = ""
|
||||
ck_text: str = ""
|
||||
|
||||
|
||||
class WeiboCollabCkExportSkippedOut(BaseModel):
|
||||
id: int
|
||||
order_no: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class WeiboCollabCkExportOut(BaseModel):
|
||||
total: int
|
||||
exported: int
|
||||
skipped: list[WeiboCollabCkExportSkippedOut] = Field(default_factory=list)
|
||||
items: list[WeiboCollabCkExportItemOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WeiboCollabBalanceCheckRequest(BaseModel):
|
||||
ids: list[int] = Field(default_factory=list)
|
||||
threshold: int = Field(100, ge=0, le=1_000_000)
|
||||
|
||||
@@ -96,6 +96,7 @@ ARCHIVABLE_STATUSES = (STATUS_DONE,)
|
||||
MAX_IMAGES = 20
|
||||
CHECK_BATCH_MAX = 50
|
||||
ARCHIVE_BATCH_MAX = 5000
|
||||
CK_EXPORT_ACTION = "weibo:account:ck_export"
|
||||
DEFAULT_WEIDOU_THRESHOLD = 100
|
||||
|
||||
|
||||
@@ -217,6 +218,109 @@ def list_orders_by_ids(db: Session, ids: Sequence[int]) -> list[WeiboExternalOrd
|
||||
)
|
||||
|
||||
|
||||
def export_cks(
|
||||
db: Session,
|
||||
current: User | None,
|
||||
order_ids: Sequence[int],
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""导出所选协作单关联账号的 CK 明文,供前端写入剪贴板。"""
|
||||
|
||||
requested = list(dict.fromkeys(int(item) for item in order_ids if int(item) > 0))
|
||||
if not requested:
|
||||
raise CollabError("请先勾选要导出的行", code="ck_export_empty")
|
||||
if len(requested) > CHECK_BATCH_MAX:
|
||||
raise CollabError(
|
||||
f"一次最多导出 {CHECK_BATCH_MAX} 行,请分批操作",
|
||||
code="ck_export_too_large",
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(WeiboExternalOrder).filter(WeiboExternalOrder.id.in_(requested)).all()
|
||||
)
|
||||
by_id = {int(order.id): order for order in rows}
|
||||
accounts = _accounts_by_id(
|
||||
db, {int(order.account_id) for order in rows if order.account_id}
|
||||
)
|
||||
items: list[dict[str, Any]] = []
|
||||
skipped: list[dict[str, Any]] = []
|
||||
for order_id in requested:
|
||||
order = by_id.get(order_id)
|
||||
if order is None:
|
||||
skipped.append(
|
||||
{
|
||||
"id": order_id,
|
||||
"order_no": "",
|
||||
"reason": "协作单不存在或已删除",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not order.account_id:
|
||||
skipped.append(
|
||||
{
|
||||
"id": int(order.id),
|
||||
"order_no": order.order_no,
|
||||
"reason": "这单还没关联微博账号",
|
||||
}
|
||||
)
|
||||
continue
|
||||
account = accounts.get(int(order.account_id))
|
||||
if account is None:
|
||||
skipped.append(
|
||||
{
|
||||
"id": int(order.id),
|
||||
"order_no": order.order_no,
|
||||
"reason": "关联的微博账号不存在或已删除",
|
||||
}
|
||||
)
|
||||
continue
|
||||
ck_text = str(account.cookie or "").strip()
|
||||
if not ck_text:
|
||||
skipped.append(
|
||||
{
|
||||
"id": int(order.id),
|
||||
"order_no": order.order_no,
|
||||
"reason": "关联的微博账号还没有 CK",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
items.append(
|
||||
{
|
||||
"id": int(order.id),
|
||||
"order_no": order.order_no,
|
||||
"account_id": int(account.id),
|
||||
"uid": account.uid or order.account_uid or "",
|
||||
"nickname": account.nickname or order.account_nickname or "",
|
||||
"ck_status": account.ck_check_status or "",
|
||||
"ck_text": ck_text,
|
||||
}
|
||||
)
|
||||
record_audit(
|
||||
db,
|
||||
current,
|
||||
action=CK_EXPORT_ACTION,
|
||||
target=f"weibo_account:{account.id}",
|
||||
detail={
|
||||
"account_id": int(account.id),
|
||||
"uid": account.uid or order.account_uid or "",
|
||||
"order_id": int(order.id),
|
||||
"order_no": order.order_no,
|
||||
"ck_status": account.ck_check_status or "",
|
||||
"batch_size": len(requested),
|
||||
},
|
||||
)
|
||||
if commit:
|
||||
db.commit()
|
||||
return {
|
||||
"total": len(requested),
|
||||
"exported": len(items),
|
||||
"skipped": skipped,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def _missing_for_recharge(order: WeiboExternalOrder) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not order.shop:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""微博商品图片下载与内嵌服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from core.weibo import normalize_weibo_image_url
|
||||
|
||||
from .file_service import sniff_content_type
|
||||
|
||||
IMAGE_CONCURRENCY = 6
|
||||
IMAGE_TIMEOUT_SECONDS = 3.0
|
||||
MAX_IMAGE_BYTES = 5 << 20
|
||||
_IMAGE_CONTENT_TYPES = frozenset({"image/jpeg", "image/png", "image/webp"})
|
||||
_IMAGE_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/156.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Referer": "https://vipclub.e.weibo.com/",
|
||||
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WeiboImageLoadResult:
|
||||
total: int
|
||||
loaded: int
|
||||
|
||||
@property
|
||||
def failed(self) -> int:
|
||||
return max(0, self.total - self.loaded)
|
||||
|
||||
|
||||
def fetch_weibo_image_data_url(raw_url: str) -> str:
|
||||
"""下载一张微博图片,校验后转成浏览器可直接显示的 data URL。"""
|
||||
|
||||
image_url = normalize_weibo_image_url(raw_url)
|
||||
parsed = urlsplit(image_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ValueError("图片地址必须是 HTTP(S) URL")
|
||||
|
||||
with requests.get(
|
||||
image_url,
|
||||
headers=_IMAGE_HEADERS,
|
||||
timeout=IMAGE_TIMEOUT_SECONDS,
|
||||
stream=True,
|
||||
) as response:
|
||||
if not 200 <= response.status_code < 300:
|
||||
raise ValueError(f"图片 HTTP 状态异常: {response.status_code}")
|
||||
declared_size = response.headers.get("Content-Length", "").strip()
|
||||
if declared_size.isdigit() and int(declared_size) > MAX_IMAGE_BYTES:
|
||||
raise ValueError("图片超过 5 MiB")
|
||||
|
||||
data = bytearray()
|
||||
for chunk in response.iter_content(chunk_size=64 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
data.extend(chunk)
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise ValueError("图片超过 5 MiB")
|
||||
|
||||
if not data:
|
||||
raise ValueError("图片内容为空")
|
||||
content_type = sniff_content_type(bytes(data))
|
||||
if content_type not in _IMAGE_CONTENT_TYPES:
|
||||
raise ValueError(f"响应不是支持的图片类型: {content_type or 'unknown'}")
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
return f"data:{content_type};base64,{encoded}"
|
||||
|
||||
|
||||
def embed_weibo_goods_images(
|
||||
goods: list[dict[str, Any]],
|
||||
*,
|
||||
account_id: int | None = None,
|
||||
) -> WeiboImageLoadResult:
|
||||
"""并发把商品列表图片转成 data URL;单张失败时保留 HTTPS 兜底地址。"""
|
||||
|
||||
total = len(goods)
|
||||
if total == 0:
|
||||
logger.info("微博商品图片加载完成:图片 0/0")
|
||||
return WeiboImageLoadResult(total=0, loaded=0)
|
||||
|
||||
loaded = 0
|
||||
jobs: dict[Any, int] = {}
|
||||
workers = min(IMAGE_CONCURRENCY, total)
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
for index, item in enumerate(goods):
|
||||
image_url = normalize_weibo_image_url(item.get("goods_image"))
|
||||
item["goods_image"] = image_url
|
||||
if not image_url:
|
||||
continue
|
||||
if image_url.startswith("data:image/"):
|
||||
loaded += 1
|
||||
continue
|
||||
jobs[executor.submit(fetch_weibo_image_data_url, image_url)] = index
|
||||
|
||||
for future in as_completed(jobs):
|
||||
index = jobs[future]
|
||||
try:
|
||||
goods[index]["goods_image"] = future.result()
|
||||
except Exception as exc: # noqa: BLE001 - 单张图片失败不能影响商品列表
|
||||
logger.debug(f"微博商品图片下载失败:{type(exc).__name__}: {exc}")
|
||||
else:
|
||||
loaded += 1
|
||||
|
||||
result = WeiboImageLoadResult(total=total, loaded=loaded)
|
||||
context = f"(account_id={account_id})" if account_id is not None else ""
|
||||
logger.info(f"微博商品图片加载完成:图片 {result.loaded}/{result.total}{context}")
|
||||
return result
|
||||
@@ -19,6 +19,7 @@ from core.weibo import (
|
||||
)
|
||||
|
||||
from ..models import WeiboAccount, WeiboWorkbenchAccount
|
||||
from .weibo_image_service import embed_weibo_goods_images
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -348,6 +349,7 @@ def query_weibo_mall(
|
||||
account: WeiboAccount,
|
||||
*,
|
||||
activity: str = "",
|
||||
embed_images: bool = False,
|
||||
) -> dict:
|
||||
try:
|
||||
data = WeiboClient(account.cookie or "").list_mall(
|
||||
@@ -356,6 +358,12 @@ def query_weibo_mall(
|
||||
except (WeiboError, ValueError) as exc:
|
||||
_mark_manual_failure(db, account, exc)
|
||||
raise
|
||||
goods = data.get("list")
|
||||
if embed_images and isinstance(goods, list):
|
||||
embed_weibo_goods_images(
|
||||
[item for item in goods if isinstance(item, dict)],
|
||||
account_id=account.id,
|
||||
)
|
||||
if isinstance(data.get("weidou_balance"), int):
|
||||
account.weidou_balance = data["weidou_balance"]
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user