From 0e29717c9734d4468d172ebbdb500c20488debe1 Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 12 Nov 2026 14:12:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E5=A2=9E=E5=8A=A0=E5=8D=8F?= =?UTF-8?q?=E4=BD=9C=20CK=20=E6=89=B9=E9=87=8F=E5=A4=8D=E5=88=B6=E4=B8=8E?= =?UTF-8?q?=E5=95=86=E5=93=81=E5=9B=BE=E7=89=87=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/weibo/__init__.py | 2 + core/weibo/client.py | 29 ++++- tests/test_huya_collab_orders.py | 22 ++++ tests/test_migrations.py | 128 ------------------- tests/test_weibo_accounts.py | 2 + tests/test_weibo_client.py | 17 ++- tests/test_weibo_collab.py | 60 ++++++++- tests/test_weibo_image_service.py | 111 ++++++++++++++++ web/backend/routers/weibo.py | 1 + web/backend/routers/weibo_collab.py | 18 +++ web/backend/schemas.py | 31 +++++ web/backend/services/weibo_collab_service.py | 104 +++++++++++++++ web/backend/services/weibo_image_service.py | 118 +++++++++++++++++ web/backend/services/weibo_service.py | 8 ++ web/frontend/src/api/modules.ts | 1 + web/frontend/src/api/weiboCollab.ts | 22 ++++ web/frontend/src/pages/WeiboAccountsPage.tsx | 11 +- web/frontend/src/pages/WeiboCollabPage.tsx | 72 ++++++++--- 18 files changed, 604 insertions(+), 153 deletions(-) create mode 100644 tests/test_weibo_image_service.py create mode 100644 web/backend/services/weibo_image_service.py diff --git a/core/weibo/__init__.py b/core/weibo/__init__.py index 4589bdd..c120402 100644 --- a/core/weibo/__init__.py +++ b/core/weibo/__init__.py @@ -18,6 +18,7 @@ from .client import ( WeiboTransportError, extract_bind_raw_url, normalize_weibo_cookie, + normalize_weibo_image_url, ) __all__ = [ @@ -38,4 +39,5 @@ __all__ = [ "WeiboTransportError", "extract_bind_raw_url", "normalize_weibo_cookie", + "normalize_weibo_image_url", ] diff --git a/core/weibo/client.py b/core/weibo/client.py index 77d90bf..5b3613f 100644 --- a/core/weibo/client.py +++ b/core/weibo/client.py @@ -221,6 +221,17 @@ def normalize_weibo_cookie(raw: str) -> str: return "; ".join(pairs) +def normalize_weibo_image_url(value: Any) -> str: + """把微博返回的图片地址升级为 HTTPS,避免浏览器拦截混合内容。""" + + text = unescape(str(value or "")).strip() + if text.startswith("//"): + return f"https:{text}" + if text.lower().startswith("http://"): + return f"https://{text[7:]}" + return text + + def _parse_cookie_pairs(cookie: str) -> dict[str, str]: pairs: dict[str, str] = {} for item in cookie.split(";"): @@ -491,7 +502,23 @@ class WeiboClient: "/mall/list", params={"F": activity, "_t": int(time.time() * 1000)}, ) - return payload.get("data") or {} + data = payload.get("data") or {} + if not isinstance(data, dict): + return {} + goods = data.get("list") + if not isinstance(goods, list): + return data + normalized_goods = [] + for item in goods: + if not isinstance(item, dict): + normalized_goods.append(item) + continue + normalized = dict(item) + normalized["goods_image"] = normalize_weibo_image_url( + item.get("goods_image") + ) + normalized_goods.append(normalized) + return {**data, "list": normalized_goods} def exchange_goods( self, diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 7f9e8a6..814c95b 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -2724,6 +2724,28 @@ class TestCollabRecharge(TestCollabBase): assert order.status == collab.STATUS_TO_RECHARGE assert order.points_checked_at is None + def test_verify_ck_invalid_after_recharged_keeps_recharged_status(self): + order = self._ready("RC-9B", "1008", gift_name="礼包A", required_points=200) + self._check( + [order], + FakeRechargeClient( + FakeBindResp(nick=self.BIND, role=self.ROLE), + score=self._score(200), + ), + ) + assert order.status == collab.STATUS_RECHARGED + + result = collab.verify_ck( + self.db, + self.user, + order, + client=FakeProbeClient(FakeBindResp(status=401, msg="登录态过期")), + ) + + assert result["ok"] is False + assert order.ck_status == "invalid" + assert order.status == collab.STATUS_RECHARGED + def test_points_query_failure_keeps_group_status_unchanged(self): order = self._ready("RC-10", "1008", gift_name="礼包A", required_points=200) client = FakeRechargeClient( diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 36af6a2..4e53b01 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -267,131 +267,3 @@ class TestMigrationSmoke: mig_engine.dispose() with admin_engine.begin() as conn: conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`;")) - - -class TestRollbackBackfillScript: - """回滚前置脚本:把账号侧 CK 搬回 `login_tasks`。 - - 为什么必须有它:S2 之后 CK **只写 `accounts.cookie`、不双写** `login_tasks`, - 而降级会 drop 掉 `accounts.cookie` —— 不先搬回去, - **部署期间新拿到的 CK 会永久丢失**(本项目唯一不能承受的损失)。 - - 演练验证过:跑了它再降级,部署期间新拿到的 CK 完整幸存。 - """ - - def test_moves_account_cookie_back_to_login_tasks(self): - import importlib.util - from pathlib import Path - - import web.backend.database as db_module - from tests.support.db import make_test_session - from web.backend.models import Account, LoginTask, User - - path = Path(PROJECT_ROOT) / "scripts" / "backfill_login_tasks_from_accounts.py" - spec = importlib.util.spec_from_file_location("rollback_backfill", path) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - engine = get_test_engine() - db_module.SessionLocal.configure(bind=engine) - db = make_test_session(engine) - try: - db.query(LoginTask).filter(LoginTask.batch_id == "rb-test").delete() - db.commit() - user = User(username="rb-user", password_hash="h", role="super_admin") - db.add(user) - db.commit() - account = Account( - username="rb-acc", - password="p", - email="e@e.com", - email_password="ep", - ) - db.add(account) - db.commit() - # 账号侧有新 CK;登录记录侧还是旧值(模拟「部署期间新拿到」) - account.cookie = "PHPSESSID=NEW_DEPLOY_WINDOW_CK" - db.add( - LoginTask( - batch_id="rb-test", - account_id=account.id, - status="success", - message="旧行", - cookie="PHPSESSID=OLD", - created_by=user.id, - ) - ) - db.commit() - - result = module.run(db, apply=True) - assert result["updated"] >= 1 - - db.expire_all() - tasks = ( - db.query(LoginTask) - .filter( - LoginTask.account_id == account.id, - LoginTask.cookie.isnot(None), - LoginTask.cookie != "", - ) - .all() - ) - assert any(t.cookie == "PHPSESSID=NEW_DEPLOY_WINDOW_CK" for t in tasks), ( - "部署期间的新 CK 没有搬回登录记录 —— 降级会丢" - ) - finally: - db.close() - - def test_creates_synthetic_row_when_no_history(self): - """新代码跑期间新导入、没有任何登录记录的账号,也要能回填。 - - 旧代码靠 `login_tasks` 找 CK,没有行就等于「这账号没 CK」。 - """ - import importlib.util - from pathlib import Path - - import web.backend.database as db_module - from tests.support.db import make_test_session - from web.backend.models import Account, LoginTask, User - - path = Path(PROJECT_ROOT) / "scripts" / "backfill_login_tasks_from_accounts.py" - spec = importlib.util.spec_from_file_location("rollback_backfill2", path) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - engine = get_test_engine() - db_module.SessionLocal.configure(bind=engine) - db = make_test_session(engine) - try: - user = User(username="rb-user2", password_hash="h", role="super_admin") - db.add(user) - db.commit() - account = Account( - username="rb-orphan", - password="p", - email="e@e.com", - email_password="ep", - cookie="PHPSESSID=ORPHAN_CK", - ) - db.add(account) - db.commit() - assert ( - db.query(LoginTask).filter(LoginTask.account_id == account.id).count() - == 0 - ) - - module.run(db, apply=True) - db.expire_all() - row = ( - db.query(LoginTask) - .filter(LoginTask.account_id == account.id) - .order_by(LoginTask.id.desc()) - .first() - ) - assert row is not None, "没有历史行时必须补一条合成行" - assert row.cookie == "PHPSESSID=ORPHAN_CK" - assert row.batch_id == module.BACKFILL_BATCH - finally: - db.close() diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index d8c03c0..f396785 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -1309,6 +1309,7 @@ def test_manual_mall_exchange_and_point_routes_serialize(monkeypatch, db, curren { "id": 37, "goods_name": "会员1天卡", + "goods_image": "data:image/png;base64,aGVsbG8=", "price": 100, "remain_count": 99, "is_sold_out": False, @@ -1369,6 +1370,7 @@ def test_manual_mall_exchange_and_point_routes_serialize(monkeypatch, db, curren ).model_dump() assert mall["goods"][0]["goods_name"] == "会员1天卡" + assert mall["goods"][0]["goods_image"] == "data:image/png;base64,aGVsbG8=" assert mall["weidou_balance"] == 120 exchange_item = exchange["items"][0] assert set(exchange_item) == { diff --git a/tests/test_weibo_client.py b/tests/test_weibo_client.py index 5392d8b..521f039 100644 --- a/tests/test_weibo_client.py +++ b/tests/test_weibo_client.py @@ -274,7 +274,20 @@ def test_mall_exchange_and_point_queries(): { "code": 100000, "data": { - "list": [{"id": 37, "goods_name": "会员1天卡", "price": 100}], + "list": [ + { + "id": 37, + "goods_name": "会员1天卡", + "goods_image": "http://wx3.sinaimg.cn/large/demo.jpg", + "price": 100, + }, + { + "id": 38, + "goods_name": "会员3天卡", + "goods_image": "//wx4.sinaimg.cn/large/demo-2.jpg", + "price": 200, + }, + ], "weidou_balance": 100, }, }, @@ -305,6 +318,8 @@ def test_mall_exchange_and_point_queries(): points = client.list_point_history("bowen_tjg") assert mall["weidou_balance"] == 100 + assert mall["list"][0]["goods_image"] == "https://wx3.sinaimg.cn/large/demo.jpg" + assert mall["list"][1]["goods_image"] == "https://wx4.sinaimg.cn/large/demo-2.jpg" assert records["totalCount"] == 0 assert points["total"] == 1 assert session.calls[0][2]["params"]["F"] == "bowen_tjg" diff --git a/tests/test_weibo_collab.py b/tests/test_weibo_collab.py index c1b8c53..20dbda4 100644 --- a/tests/test_weibo_collab.py +++ b/tests/test_weibo_collab.py @@ -4,7 +4,8 @@ from __future__ import annotations from sqlalchemy.orm import Session -from web.backend.models import User, WeiboAccount, WeiboExternalOrderMetric +from web.backend.models import AuditLog, User, WeiboAccount, WeiboExternalOrderMetric +from web.backend.schemas import WeiboCollabCkExportOut from web.backend.services import weibo_collab_service as collab @@ -166,3 +167,60 @@ def test_exchange_record_unique_match_and_archive(monkeypatch, db): timeline = collab.order_timeline(db, first.id) assert timeline assert any(item["action"] == "weibo:collab:archive" for item in timeline) + + +def test_export_cks_returns_plaintext_and_audits_without_ck(db): + user = make_user(db) + account = make_account(db) + order = collab.create_order( + db, + user, + order_no="WB-CK-001", + account_id=account.id, + goods_name="商品A", + required_weidou=100, + require_verified=False, + ) + + result = collab.export_cks(db, user, [order.id]) + + dumped = WeiboCollabCkExportOut.model_validate(result).model_dump() + assert dumped["total"] == 1 + assert dumped["exported"] == 1 + assert dumped["items"][0]["account_id"] == account.id + assert dumped["items"][0]["ck_text"] == "SCF=a; SUB=b" + audit = ( + db.query(AuditLog).filter(AuditLog.action == "weibo:account:ck_export").one() + ) + assert f'"order_id":{order.id}' in audit.detail + assert "SCF=a; SUB=b" not in audit.detail + + +def test_export_cks_exposes_skipped_orders(db): + user = make_user(db) + account = make_account(db) + valid = collab.create_order( + db, + user, + order_no="WB-CK-101", + account_id=account.id, + goods_name="商品A", + required_weidou=100, + require_verified=False, + ) + unbound = collab.create_order( + db, + user, + order_no="WB-CK-102", + goods_name="商品B", + required_weidou=100, + require_verified=False, + ) + + result = collab.export_cks(db, user, [valid.id, unbound.id, 999999]) + + dumped = WeiboCollabCkExportOut.model_validate(result).model_dump() + assert dumped["total"] == 3 + assert dumped["exported"] == 1 + assert {item["id"] for item in dumped["skipped"]} == {unbound.id, 999999} + assert any("还没关联微博账号" in item["reason"] for item in dumped["skipped"]) diff --git a/tests/test_weibo_image_service.py b/tests/test_weibo_image_service.py new file mode 100644 index 0000000..edd68fb --- /dev/null +++ b/tests/test_weibo_image_service.py @@ -0,0 +1,111 @@ +"""微博商品图片后端下载与内嵌测试。""" + +from __future__ import annotations + +import base64 + +import pytest + +from web.backend.services import weibo_image_service + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"test-image-data" + + +class FakeResponse: + def __init__( + self, + *, + body: bytes = PNG_BYTES, + status_code: int = 200, + content_length: str | None = None, + ): + self.status_code = status_code + self.headers = ( + {"Content-Length": content_length} if content_length is not None else {} + ) + self.body = body + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def iter_content(self, chunk_size: int): + del chunk_size + yield self.body + + +def test_fetch_image_url_returns_embedded_png(monkeypatch): + captured: dict = {} + + def fake_get(url, **kwargs): + captured["url"] = url + captured["headers"] = kwargs["headers"] + return FakeResponse() + + monkeypatch.setattr(weibo_image_service.requests, "get", fake_get) + + result = weibo_image_service.fetch_weibo_image_data_url( + "http://wx3.sinaimg.cn/large/demo.png" + ) + + assert captured["url"] == "https://wx3.sinaimg.cn/large/demo.png" + assert captured["headers"]["Referer"] == "https://vipclub.e.weibo.com/" + assert result == ( + "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode("ascii") + ) + + +def test_fetch_image_url_rejects_non_image(monkeypatch): + monkeypatch.setattr( + weibo_image_service.requests, + "get", + lambda *_args, **_kwargs: FakeResponse(body=b"error"), + ) + + with pytest.raises(ValueError, match="不是支持的图片类型"): + weibo_image_service.fetch_weibo_image_data_url( + "https://wx3.sinaimg.cn/large/error.jpg" + ) + + +def test_fetch_image_url_rejects_oversized_content(monkeypatch): + monkeypatch.setattr( + weibo_image_service.requests, + "get", + lambda *_args, **_kwargs: FakeResponse( + content_length=str(weibo_image_service.MAX_IMAGE_BYTES + 1) + ), + ) + + with pytest.raises(ValueError, match="超过 5 MiB"): + weibo_image_service.fetch_weibo_image_data_url( + "https://wx3.sinaimg.cn/large/large.png" + ) + + +def test_embed_goods_images_falls_back_to_normalized_https(monkeypatch): + goods = [ + {"id": 1, "goods_image": "http://wx3.sinaimg.cn/large/ok.png"}, + {"id": 2, "goods_image": "//wx4.sinaimg.cn/large/fail.png"}, + ] + + def fake_fetch(url: str) -> str: + if url.endswith("ok.png"): + return "data:image/png;base64,b2s=" + raise ValueError("download failed") + + monkeypatch.setattr( + weibo_image_service, + "fetch_weibo_image_data_url", + fake_fetch, + ) + + result = weibo_image_service.embed_weibo_goods_images(goods, account_id=1) + + assert result.total == 2 + assert result.loaded == 1 + assert result.failed == 1 + assert goods[0]["goods_image"] == "data:image/png;base64,b2s=" + assert goods[1]["goods_image"] == "https://wx4.sinaimg.cn/large/fail.png" diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index d638f86..e86467e 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -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 diff --git a/web/backend/routers/weibo_collab.py b/web/backend/routers/weibo_collab.py index 73d3690..19f04c0 100644 --- a/web/backend/routers/weibo_collab.py +++ b/web/backend/routers/weibo_collab.py @@ -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], diff --git a/web/backend/schemas.py b/web/backend/schemas.py index f92954f..d98f40e 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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) diff --git a/web/backend/services/weibo_collab_service.py b/web/backend/services/weibo_collab_service.py index a714f85..7842d15 100644 --- a/web/backend/services/weibo_collab_service.py +++ b/web/backend/services/weibo_collab_service.py @@ -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: diff --git a/web/backend/services/weibo_image_service.py b/web/backend/services/weibo_image_service.py new file mode 100644 index 0000000..20fad99 --- /dev/null +++ b/web/backend/services/weibo_image_service.py @@ -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 diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py index 06c6319..4739f28 100644 --- a/web/backend/services/weibo_service.py +++ b/web/backend/services/weibo_service.py @@ -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() diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index 27a9d7a..1bb52aa 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -22,6 +22,7 @@ export type { WeiboCollabArchiveResult, WeiboCollabArchiveScope, WeiboCollabBalanceCheckResult, + WeiboCollabCkExportResult, WeiboCollabExchangeCheckResult, WeiboCollabGoods, WeiboCollabLiveGoods, diff --git a/web/frontend/src/api/weiboCollab.ts b/web/frontend/src/api/weiboCollab.ts index ad80571..ced9712 100644 --- a/web/frontend/src/api/weiboCollab.ts +++ b/web/frontend/src/api/weiboCollab.ts @@ -155,6 +155,23 @@ export interface WeiboCollabOrderList { page_size: number; } +export interface WeiboCollabCkExportItem { + id: number; + order_no: string; + account_id: number; + uid: string; + nickname: string; + ck_status: string; + ck_text: string; +} + +export interface WeiboCollabCkExportResult { + total: number; + exported: number; + skipped: Array<{ id: number; order_no: string; reason: string }>; + items: WeiboCollabCkExportItem[]; +} + export interface WeiboCollabSummary { total: number; by_status: Record; @@ -371,6 +388,11 @@ export const weiboCollabApi = { '/weibo/external-orders/unarchive', ids, ), + exportCks: (ids: number[]) => + api.post( + '/weibo/external-orders/ck/export', + { ids }, + ), deleteOrder: (id: number) => api.delete(`/weibo/external-orders/${id}`), metrics: (id: number) => api.get( diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index d571682..7fa2de3 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -97,6 +97,14 @@ const WEIBO_BATCH_RECHARGE_MAX = 20; const WEIBO_BATCH_RECHARGE_CONCURRENCY = 3; const WEIBO_BATCH_RECHARGE_POLL_INTERVAL_MS = 8_000; +function normalizeImageURL(value: string): string { + const imageURL = value.trim(); + if (!imageURL || imageURL.startsWith('data:image/')) return imageURL; + if (imageURL.startsWith('//')) return `https:${imageURL}`; + if (/^http:\/\//i.test(imageURL)) return imageURL.replace(/^http:/i, 'https:'); + return imageURL; +} + type BatchRechargePhase = 'queued' | 'creating' | 'ready' | 'error'; type BatchCreditPhase = '' | 'checking' | 'pending' | 'credited' | 'error'; @@ -1492,8 +1500,9 @@ export default function WeiboAccountsPage({ {goods.goods_image && ( )} diff --git a/web/frontend/src/pages/WeiboCollabPage.tsx b/web/frontend/src/pages/WeiboCollabPage.tsx index 6d4904f..431ddc5 100644 --- a/web/frontend/src/pages/WeiboCollabPage.tsx +++ b/web/frontend/src/pages/WeiboCollabPage.tsx @@ -133,6 +133,7 @@ export default function WeiboCollabPage() { const navigate = useNavigate(); const canEdit = can('weibo:collab:edit'); const canManage = can('weibo:collab:manage'); + const canExportCk = can('weibo:account:ck_export'); const [orders, setOrders] = useState([]); const [summary, setSummary] = useState(null); @@ -145,6 +146,7 @@ export default function WeiboCollabPage() { const [pageSize, setPageSize] = useState(20); const [loading, setLoading] = useState(false); const [selectedIds, setSelectedIds] = useState([]); + const [copyingCk, setCopyingCk] = useState(false); const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState(''); const [shop, setShop] = useState(''); @@ -615,38 +617,63 @@ export default function WeiboCollabPage() { [summary], ); - const copySelectedAccountValues = async (kind: 'username' | 'uid') => { + const copySelectedUsernames = async () => { const selectedOrders = orders.filter((row) => selectedIds.includes(row.id)); const values = Array.from( new Set( selectedOrders - .map((row) => - kind === 'uid' - ? row.account_uid - : row.account_nickname || row.account_uid, - ) + .map((row) => row.account_nickname || row.account_uid) .map((value) => String(value || '').trim()) .filter(Boolean), ), ); if (values.length === 0) { - message.warning( - kind === 'uid' ? '所选订单没有微博 UID' : '所选订单没有微博用户名', - ); + message.warning('所选订单没有微博用户名'); return; } try { await copyTextToClipboard(values.join('\n')); - message.success( - `已复制 ${values.length} 个${ - kind === 'uid' ? ' UID' : '用户名' - },可直接粘贴到工作台导入搜索框`, - ); + message.success(`已复制 ${values.length} 个用户名`); } catch (error) { message.error(getErrorMessage(error)); } }; + const copySelectedCks = async () => { + setCopyingCk(true); + try { + const result = await weiboCollabApi.exportCks(selectedIds); + const items = Array.from( + new Map( + result.items.map((item) => [item.account_id, item]), + ).values(), + ); + const failures = result.skipped.map( + (item) => `${item.order_no || `单 ${item.id}`}:${item.reason}`, + ); + if (items.length > 0) { + await copyTextToClipboard( + items.map((item) => item.ck_text.trim()).join('\n'), + ); + if (failures.length > 0) { + message.warning( + `已复制 ${items.length} 份 CK;${failures.length} 单未能导出:${failures + .slice(0, 3) + .join(';')}`, + ); + } else { + message.success(`已复制 ${items.length} 份 CK`); + } + } else { + message.error(`没有可复制的 CK:${failures.join(';')}`); + } + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setCopyingCk(false); + } + }; + const columns: TableProps['columns'] = [ { title: '序号', @@ -997,16 +1024,19 @@ export default function WeiboCollabPage() { <> - + {canExportCk && ( + + )} )} {canEdit && selectedIds.length > 0 && archiveScope !== 'archived' && (