feat(weibo): 增加协作 CK 批量复制与商品图片代理
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user