feat(orders): P0 订单读取适配层——连 order_site 只读库查订单(默认关闭)

让运营能按订单号查到订单,作为后续资料收集(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,
一次落空就把模块永久毒化成不可用,之后补上连接串也救不回来。
This commit is contained in:
yml2213
2026-10-02 22:50:20 +08:00
parent d6c6113112
commit afa5c815f6
9 changed files with 1001 additions and 3 deletions
+332
View File
@@ -0,0 +1,332 @@
"""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