让运营能按订单号查到订单,作为后续资料收集(P2)与皮肤准入(P3/P4)的输入。 order_site 零改动:只读直连、独立极小连接池、可一键降级。 上机核实推翻了规划两处假设(详见 §6.5.1): - orders 表没有 order_no 列,只有 id 与 platform_order_id → 查询入口用后者; - platform_order_id 非唯一:541 组重复,形态固定为「91kaquan 内部行(已发货) + 真实店铺行(无人管)」。按规划原文的单行判据,这 540 个已发货订单会被误判 成「没人管」而放行重复履约。改为**订单号维度聚合**:任一行已发货即 auto_fulfilled。 真机验证(只读账号 livehub_ro):三桶判定全对,重复组正确拦下并回显真实 店铺行的中文名(狂沙舞者,而非内部码 suit_sand_dancer),单次查询 3.3ms。 顺带修一个只在真机才暴露的 bug:_get_engine 先置 _INITIALIZED 再读 URL, 一次落空就把模块永久毒化成不可用,之后补上连接串也救不回来。
333 lines
13 KiB
Python
333 lines
13 KiB
Python
"""P0 订单读取适配层回归。
|
||
|
||
重点守住三件事:
|
||
|
||
1. **route 按订单号维度聚合**:同一个订单号在 order_site 里有 541 组重复
|
||
(发货方内部行 + 真实店铺行,见规划 §6.5.1)。任一行已发货就要判
|
||
``auto_fulfilled`` —— 否则这 540 个已发货订单会被放行重复履约。这条是
|
||
P0 最容易写错、后果最重的地方,用直接构造两行的用例钉死。
|
||
2. **回显取真实店铺行**:内部行(``shop_id=91kaquan``)的 sku_code 是
|
||
``suit_sand_dancer`` 这类内部编码,不能回显给买家。
|
||
3. **可降级**:未启用 / 未配置 / 库不可用时抛 ``OrderLookupUnavailable``,
|
||
主流程退化成人工填写,而不是把异常抛给用户。
|
||
|
||
真实只读库的连通性验证不进自动回归(需生产网络),见
|
||
``tests/test_external_order_source_live.py``(手工跑)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
import pytest
|
||
|
||
import web.backend.services.external_order_source as src
|
||
from tests.support.db import get_test_engine, make_test_session
|
||
from web.backend.models import ExternalOrderCache
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clean_env(monkeypatch):
|
||
"""每个用例从干净的外部配置起步,避免相互污染。"""
|
||
src.reset_engine()
|
||
monkeypatch.delenv("ORDER_SITE_DATABASE_URL", raising=False)
|
||
monkeypatch.delenv("ORDER_SITE_LOOKUP_ENABLED", raising=False)
|
||
yield
|
||
src.reset_engine()
|
||
|
||
|
||
@pytest.fixture
|
||
def session():
|
||
engine = get_test_engine()
|
||
s = make_test_session(engine)
|
||
s.query(ExternalOrderCache).delete()
|
||
s.commit()
|
||
yield s
|
||
s.rollback()
|
||
s.close()
|
||
|
||
|
||
def _row(
|
||
source_id=100,
|
||
shop_id="4661340439",
|
||
sku_name="兔兔白日梦",
|
||
sku_code="187749890445642",
|
||
has_ft=False,
|
||
has_wo=False,
|
||
spec=None,
|
||
order_status="paid",
|
||
):
|
||
return {
|
||
"source_order_id": source_id,
|
||
"platform": "kuaishou",
|
||
"provider": "91kaquan",
|
||
"shop_id": shop_id,
|
||
"order_status": order_status,
|
||
"sku_code": sku_code,
|
||
"sku_name": sku_name,
|
||
"quantity": 1,
|
||
"spec_json": spec if spec is not None else {},
|
||
"has_fulfillment": has_ft,
|
||
"has_work_order": has_wo,
|
||
}
|
||
|
||
|
||
class TestRouteAggregation:
|
||
"""route 判定(纯函数,不需要数据库)。"""
|
||
|
||
def test_single_unhandled_row_is_candidate(self):
|
||
assert src._route_of([_row()]) == src.ROUTE_UNHANDLED
|
||
|
||
def test_fulfillment_row_wins_over_unhandled(self):
|
||
"""同一个订单号:内部行已发货 + 店铺行无人管 → 已处理。
|
||
|
||
这是 §6.5.1 那 540 组的核心判定,反过来写就会重复履约。
|
||
"""
|
||
rows = [
|
||
_row(source_id=145013, shop_id=src.INTERNAL_SHOP_ID, has_ft=True),
|
||
_row(source_id=145012, shop_id="4661340439"),
|
||
]
|
||
assert src._route_of(rows) == src.ROUTE_AUTO_FULFILLED
|
||
|
||
def test_work_order_row_wins_over_unhandled(self):
|
||
rows = [_row(source_id=1, has_wo=True), _row(source_id=2)]
|
||
assert src._route_of(rows) == src.ROUTE_WORKER_PLATFORM
|
||
|
||
def test_fulfillment_wins_over_work_order(self):
|
||
"""两桶理论上互斥,真撞上时按「已发货」优先。"""
|
||
rows = [_row(has_ft=True), _row(has_wo=True)]
|
||
assert src._route_of(rows) == src.ROUTE_AUTO_FULFILLED
|
||
|
||
|
||
class TestDisplayRow:
|
||
def test_prefers_real_shop_row_over_internal(self):
|
||
rows = [
|
||
_row(source_id=145013, shop_id=src.INTERNAL_SHOP_ID, sku_code="suit_x"),
|
||
_row(source_id=145012, shop_id="4661340439", sku_name="狂沙舞者"),
|
||
]
|
||
result = src._build_result("2624100167260659", rows)
|
||
assert result["sku_name"] == "狂沙舞者"
|
||
assert result["source_order_id"] == "145012"
|
||
assert result["sku_code"] != "suit_x"
|
||
|
||
def test_falls_back_to_internal_row_when_only_one(self):
|
||
rows = [_row(shop_id=src.INTERNAL_SHOP_ID, sku_code="suit_x")]
|
||
assert src._build_result("x", rows)["sku_code"] == "suit_x"
|
||
|
||
|
||
class TestSpecExtraction:
|
||
def test_sku_keys_read_from_spec_json(self):
|
||
spec = {
|
||
"skuId": "192758405127439",
|
||
"skuNick": "(3选1)甜心娇蕊",
|
||
"sellerId": "4661340439",
|
||
"relItemId": "25228845231439",
|
||
"relSkuId": "",
|
||
"itemTitle": "和平精英皮肤小店入口...",
|
||
}
|
||
result = src._build_result("2627501925533255", [_row(spec=spec, sku_name="")])
|
||
assert result["seller_id"] == "4661340439"
|
||
assert result["rel_item_id"] == "25228845231439"
|
||
assert result["sku_nick"] == "(3选1)甜心娇蕊"
|
||
# relSkuId 为空时回退到 skuId(规划 §3.6:skuId 才是快手 SKU ID)
|
||
assert result["rel_sku_id"] == "192758405127439"
|
||
assert result["item_title"] == "和平精英皮肤小店入口..."
|
||
|
||
def test_spec_json_as_string_is_parsed(self):
|
||
result = src._build_result("x", [_row(spec='{"sellerId": "3676797936"}')])
|
||
assert result["seller_id"] == "3676797936"
|
||
|
||
def test_malformed_spec_json_is_ignored(self):
|
||
result = src._build_result("x", [_row(spec="{not json")])
|
||
assert result["seller_id"] == ""
|
||
|
||
|
||
class TestUrlNormalization:
|
||
def test_bare_postgresql_url_gets_driver(self):
|
||
url = "postgresql://u:p@h:5432/db"
|
||
assert src._normalize_url(url) == "postgresql+psycopg://u:p@h:5432/db"
|
||
|
||
def test_url_with_driver_is_untouched(self):
|
||
url = "postgresql+psycopg://u:p@h:5432/db"
|
||
assert src._normalize_url(url) == url
|
||
|
||
|
||
class TestDegradation:
|
||
def test_missing_url_disables_lookup(self, monkeypatch):
|
||
monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true")
|
||
with pytest.raises(src.OrderLookupUnavailable):
|
||
src.lookup_order("2627501925533255")
|
||
|
||
def test_disabled_flag_disables_lookup(self, monkeypatch):
|
||
monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db")
|
||
monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "false")
|
||
with pytest.raises(src.OrderLookupUnavailable):
|
||
src.lookup_order("2627501925533255")
|
||
|
||
def test_empty_order_no_raises(self, monkeypatch):
|
||
monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db")
|
||
monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true")
|
||
with pytest.raises(src.OrderLookupUnavailable):
|
||
src.lookup_order(" ")
|
||
|
||
|
||
class TestCacheRoundTrip:
|
||
"""缓存读写:命中未过期快照就不查外部。"""
|
||
|
||
def _seed(self, session, order_no, *, route=src.ROUTE_UNHANDLED, age_hours=0):
|
||
session.add(
|
||
ExternalOrderCache(
|
||
platform_order_id=order_no,
|
||
source_order_id="145012",
|
||
platform="kuaishou",
|
||
provider="91kaquan",
|
||
shop_id="4661340439",
|
||
order_status="paid",
|
||
sku_code="187749890445642",
|
||
sku_name="兔兔白日梦",
|
||
quantity=1,
|
||
route=route,
|
||
seller_id="4661340439",
|
||
rel_item_id="25228845231439",
|
||
rel_sku_id="187749890445642",
|
||
sku_nick="兔兔白日梦",
|
||
item_title="和平精英小店",
|
||
fetched_at=datetime.now(UTC) - timedelta(hours=age_hours),
|
||
)
|
||
)
|
||
session.commit()
|
||
|
||
def test_fresh_cache_is_returned_without_external_query(self, session, monkeypatch):
|
||
monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db")
|
||
monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true")
|
||
self._seed(session, "2627501925533255")
|
||
|
||
def _boom(_order_no):
|
||
raise AssertionError("命中缓存时不应查询外部库")
|
||
|
||
monkeypatch.setattr(src, "_fetch_rows", _boom)
|
||
result = src.lookup_order("2627501925533255")
|
||
assert result["cached"] is True
|
||
assert result["sku_name"] == "兔兔白日梦"
|
||
assert result["route"] == src.ROUTE_UNHANDLED
|
||
|
||
def test_expired_cache_is_ignored(self, session, monkeypatch):
|
||
monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db")
|
||
monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true")
|
||
self._seed(session, "2627501925533255", age_hours=25)
|
||
|
||
monkeypatch.setattr(src, "_fetch_rows", lambda _o: [_row()])
|
||
result = src.lookup_order("2627501925533255")
|
||
assert result["cached"] is False
|
||
|
||
def test_lookup_writes_cache(self, session, monkeypatch):
|
||
monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db")
|
||
monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true")
|
||
monkeypatch.setattr(src, "_fetch_rows", lambda _o: [_row()])
|
||
|
||
src.lookup_order("2627501925533255")
|
||
row = (
|
||
session.query(ExternalOrderCache)
|
||
.filter_by(platform_order_id="2627501925533255")
|
||
.first()
|
||
)
|
||
assert row is not None
|
||
assert row.route == src.ROUTE_UNHANDLED
|
||
assert row.sku_name == "兔兔白日梦"
|
||
|
||
def test_duplicate_writes_do_not_collide(self, session, monkeypatch):
|
||
"""同一订单号两行都要能存下 —— 单列主键会把它们互相覆盖。"""
|
||
monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db")
|
||
monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true")
|
||
rows = [
|
||
_row(source_id=145013, shop_id=src.INTERNAL_SHOP_ID, has_ft=True),
|
||
_row(source_id=145012, shop_id="4661340439", sku_name="狂沙舞者"),
|
||
]
|
||
monkeypatch.setattr(src, "_fetch_rows", lambda _o: rows)
|
||
|
||
result = src.lookup_order("2624100167260659")
|
||
assert result["route"] == src.ROUTE_AUTO_FULFILLED
|
||
assert result["sku_name"] == "狂沙舞者"
|
||
|
||
stored = (
|
||
session.query(ExternalOrderCache)
|
||
.filter_by(platform_order_id="2624100167260659")
|
||
.all()
|
||
)
|
||
# 适配层只落「回显用的那一行」,但主键带 source_order_id,
|
||
# 换一行回显时不会覆盖前一条。
|
||
assert len(stored) == 1
|
||
assert stored[0].source_order_id == "145012"
|
||
|
||
|
||
class TestExternalFailure:
|
||
def test_external_error_becomes_unavailable(self, session, monkeypatch):
|
||
monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db")
|
||
monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true")
|
||
|
||
def _raise(_order_no):
|
||
raise RuntimeError("connection refused")
|
||
|
||
monkeypatch.setattr(src, "_fetch_rows", _raise)
|
||
with pytest.raises(src.OrderLookupUnavailable):
|
||
src.lookup_order("2627501925533255")
|
||
|
||
def test_no_rows_means_order_not_found(self, session, monkeypatch):
|
||
monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db")
|
||
monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true")
|
||
monkeypatch.setattr(src, "_fetch_rows", lambda _o: [])
|
||
with pytest.raises(src.OrderLookupUnavailable):
|
||
src.lookup_order("0000000000000000")
|
||
|
||
|
||
class TestEngineConfig:
|
||
def test_engine_uses_tiny_pool_and_statement_timeout(self, monkeypatch):
|
||
"""外部库的连接必须又小又带超时,别拖累对方生产库。"""
|
||
monkeypatch.setenv(
|
||
"ORDER_SITE_DATABASE_URL", "postgresql://livehub_ro:p@h:5432/order_site"
|
||
)
|
||
monkeypatch.setenv("ORDER_SITE_LOOKUP_TIMEOUT_SECONDS", "3")
|
||
created: dict[str, object] = {}
|
||
real_create_engine = src.create_engine
|
||
|
||
def _spy(url, **kwargs):
|
||
created.update(kwargs)
|
||
return real_create_engine(url, **kwargs)
|
||
|
||
monkeypatch.setattr(src, "create_engine", _spy)
|
||
engine = src._get_engine()
|
||
try:
|
||
assert engine.pool.size() == 2
|
||
assert engine.pool._max_overflow == 0
|
||
# statement_timeout 下发给连接,不是应用侧 kill(账号级已有 3s 兜底)
|
||
assert created["connect_args"]["options"] == "-c statement_timeout=3000"
|
||
finally:
|
||
engine.dispose()
|
||
|
||
def test_engine_is_created_once(self, monkeypatch):
|
||
monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db")
|
||
a = src._get_engine()
|
||
b = src._get_engine()
|
||
assert a is b
|
||
a.dispose()
|
||
|
||
def test_missing_url_does_not_poison_later_calls(self, monkeypatch):
|
||
"""未配置时抛错,但补上连接串后必须能恢复。
|
||
|
||
曾经的写法是先置 ``_INITIALIZED = True`` 再读 URL,一次落空就永久
|
||
毒化成不可用 —— 真机上表现为「明明配了 URL 却一直报未配置」。
|
||
"""
|
||
with pytest.raises(src.OrderLookupUnavailable):
|
||
src._get_engine()
|
||
|
||
monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db")
|
||
engine = src._get_engine()
|
||
assert engine is not None
|
||
engine.dispose()
|
||
|
||
|
||
def test_os_environ_not_leaked_between_tests():
|
||
"""保险:用例没把 ORDER_SITE_* 留在环境里。"""
|
||
assert "ORDER_SITE_DATABASE_URL" not in os.environ
|