feat(weibo): 增加协作 CK 批量复制与商品图片代理
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
+28
-1
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) == {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"<html>error</html>"),
|
||||
)
|
||||
|
||||
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"
|
||||
@@ -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()
|
||||
|
||||
@@ -22,6 +22,7 @@ export type {
|
||||
WeiboCollabArchiveResult,
|
||||
WeiboCollabArchiveScope,
|
||||
WeiboCollabBalanceCheckResult,
|
||||
WeiboCollabCkExportResult,
|
||||
WeiboCollabExchangeCheckResult,
|
||||
WeiboCollabGoods,
|
||||
WeiboCollabLiveGoods,
|
||||
|
||||
@@ -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<string, number>;
|
||||
@@ -371,6 +388,11 @@ export const weiboCollabApi = {
|
||||
'/weibo/external-orders/unarchive',
|
||||
ids,
|
||||
),
|
||||
exportCks: (ids: number[]) =>
|
||||
api.post<WeiboCollabCkExportResult, WeiboCollabCkExportResult>(
|
||||
'/weibo/external-orders/ck/export',
|
||||
{ ids },
|
||||
),
|
||||
deleteOrder: (id: number) => api.delete(`/weibo/external-orders/${id}`),
|
||||
metrics: (id: number) =>
|
||||
api.get<WeiboCollabMetric[], WeiboCollabMetric[]>(
|
||||
|
||||
@@ -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({
|
||||
<Space align="start">
|
||||
{goods.goods_image && (
|
||||
<img
|
||||
src={goods.goods_image}
|
||||
src={normalizeImageURL(goods.goods_image)}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ width: 42, height: 42, objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<WeiboCollabOrder[]>([]);
|
||||
const [summary, setSummary] = useState<WeiboCollabSummary | null>(null);
|
||||
@@ -145,6 +146,7 @@ export default function WeiboCollabPage() {
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
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<WeiboCollabOrder>['columns'] = [
|
||||
{
|
||||
title: '序号',
|
||||
@@ -997,16 +1024,19 @@ export default function WeiboCollabPage() {
|
||||
<>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => void copySelectedAccountValues('username')}
|
||||
onClick={() => void copySelectedUsernames()}
|
||||
>
|
||||
复制所选用户名
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => void copySelectedAccountValues('uid')}
|
||||
>
|
||||
复制所选 UID
|
||||
</Button>
|
||||
{canExportCk && (
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingCk}
|
||||
onClick={() => void copySelectedCks()}
|
||||
>
|
||||
复制所选 CK
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canEdit && selectedIds.length > 0 && archiveScope !== 'archived' && (
|
||||
|
||||
Reference in New Issue
Block a user