feat(compare): 比价记录列表支持「已下单」筛选与店名/菜名搜索
GET /api/v1/compare/records 新增 ordered / keyword 两个查询参数,过滤全部下推到 SQL。 不能分页之后再由客户端 filter —— 一页里可能一条都不命中,列表看着就是空的, 得翻很多页才蹦出一条。 顺带修掉这条链路上几处随数据量线性变慢的地方: - 列表查询 defer raw_payload / llm_calls / llm_price_snapshot 三个重型 JSON 列。 出参 ComparisonRecordOut 根本不读,却是每页几百 KB~几 MB 的白读 + 白反序列化, 是「比价记录/全部记录」页慢的主要来源;详情接口不 defer,raw_payload 照常返回。 - 「已下单」标记改为只按本页店名(≤ limit 条)反查 savings,不再把该用户全部下单 店名捞进内存跟 50 条记录取交集。 - 新增 (user_id, created_at, id) 复合索引:反向扫恰好等于列表的 ORDER BY created_at DESC, id DESC,PG 免排序直接取前 n 条。 迁移走 CREATE INDEX CONCURRENTLY,不阻塞线上 harvest 写入。 - keyword 转义 LIKE 通配符后再匹配,避免搜一个「%」把整表拉回来。 - nginx 对 application/json 开 gzip:此前 gzip off + gzip_types 只含 text/html + gzip_proxied off 三个默认值凑一起,等于所有接口都在裸奔;记录列表这种 字段名和中文店名高度重复的 JSON 压缩比稳定 8~10 倍。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -222,6 +222,210 @@ def test_stats_compare_count_and_saved(client) -> None:
|
||||
assert s2["compare_count"] == 2 # 仍 2(failed 不计)
|
||||
|
||||
|
||||
def test_records_ordered_flag(client) -> None:
|
||||
"""「已下单」店级标记:店名命中该用户 source='compare' 的下单记录才 True。
|
||||
|
||||
覆盖 list_records 只按**本页店名**反查 savings 的写法(原来是把该用户全部下单店名捞回内存
|
||||
再取交集,随下单量线性变慢)——两种写法结果必须一致,故这里按店名逐条断言。
|
||||
"""
|
||||
token = _login(client, "13800002010")
|
||||
|
||||
# 两条比价记录:一条海底捞(稍后会有对应下单),一条没下过单的店
|
||||
client.post("/api/v1/compare/record", json=_food_payload("ord-1"), headers=_auth(token))
|
||||
other = _food_payload("ord-2")
|
||||
other["store_name"] = "没下过单的店"
|
||||
client.post("/api/v1/compare/record", json=other, headers=_auth(token))
|
||||
|
||||
# 下单前:两条都不该带「已下单」
|
||||
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
|
||||
assert {it["store_name"]: it["ordered"] for it in items} == {
|
||||
"海底捞(朝阳店)": False,
|
||||
"没下过单的店": False,
|
||||
}
|
||||
|
||||
# 对海底捞真实下单一笔(order/report 写 source='compare' 的 savings_record)
|
||||
r = client.post(
|
||||
"/api/v1/order/report",
|
||||
json={
|
||||
"client_event_id": "evt-ordered-flag",
|
||||
"platform": "美团",
|
||||
"platform_package": "com.sankuai.meituan",
|
||||
"pay_channel": "wechat",
|
||||
"compared_price_cents": 12350,
|
||||
"paid_amount_cents": 12350,
|
||||
"shop_name": "海底捞(朝阳店)",
|
||||
"original_price_cents": 12850,
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# 下单后:只有同店名那条翻成 True,另一条不受影响
|
||||
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
|
||||
assert {it["store_name"]: it["ordered"] for it in items} == {
|
||||
"海底捞(朝阳店)": True,
|
||||
"没下过单的店": False,
|
||||
}
|
||||
|
||||
# 别人的下单不该影响本人标记(_ordered_shop_names 按 user_id 过滤)
|
||||
token_b = _login(client, "13800002011")
|
||||
client.post("/api/v1/compare/record", json=_food_payload("ord-b"), headers=_auth(token_b))
|
||||
items_b = client.get("/api/v1/compare/records", headers=_auth(token_b)).json()["items"]
|
||||
assert [it["ordered"] for it in items_b] == [False]
|
||||
|
||||
|
||||
def test_records_list_omits_raw_payload(client) -> None:
|
||||
"""列表出参不含 raw_payload(仓库层 defer 掉了重型 JSON 列);详情接口照常返回。
|
||||
|
||||
defer 的列一旦被 ORM 实例读到会触发逐行懒加载(N+1),而列表 schema 本就不该带 raw_payload
|
||||
—— 这条同时守住「列表不泄露上报体全量」和「没人不小心把它加回出参」。
|
||||
"""
|
||||
token = _login(client, "13800002012")
|
||||
rid = client.post(
|
||||
"/api/v1/compare/record", json=_food_payload("no-raw"), headers=_auth(token)
|
||||
).json()["id"]
|
||||
|
||||
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
|
||||
assert len(items) == 1
|
||||
assert "raw_payload" not in items[0]
|
||||
# 概要字段照常齐全(defer 没误伤列表要用的列)
|
||||
assert items[0]["store_name"] == "海底捞(朝阳店)"
|
||||
assert items[0]["best_platform_id"] == "meituan"
|
||||
assert items[0]["comparison_results"] and items[0]["items"]
|
||||
|
||||
# 详情不 defer:raw_payload 全量还在
|
||||
d = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token)).json()
|
||||
assert d["raw_payload"]["trace_id"] == "no-raw"
|
||||
|
||||
|
||||
def test_records_ordered_filter(client) -> None:
|
||||
"""ordered=true 只出「已下单」的记录,且过滤结果自身能翻页。
|
||||
|
||||
这个筛选必须在服务端做:客户端早先是对「已经拉回来的那一页」做 filter,分页之后一页里
|
||||
很可能一条已下单都没有 ——「已下单」tab 就会看着像空的,得手动翻很多页才蹦出一条。
|
||||
"""
|
||||
token = _login(client, "13800002013")
|
||||
|
||||
# 3 条「下过单的店」+ 2 条没下过单的店,交错写入,确保过滤不是靠顺序碰巧对上
|
||||
for i in range(3):
|
||||
p = _food_payload(f"of-ordered-{i}")
|
||||
p["store_name"] = "下过单的店"
|
||||
client.post("/api/v1/compare/record", json=p, headers=_auth(token))
|
||||
if i < 2:
|
||||
q = _food_payload(f"of-plain-{i}")
|
||||
q["store_name"] = "没下过单的店"
|
||||
client.post("/api/v1/compare/record", json=q, headers=_auth(token))
|
||||
|
||||
client.post(
|
||||
"/api/v1/order/report",
|
||||
json={
|
||||
"client_event_id": "evt-ordered-filter",
|
||||
"platform": "美团",
|
||||
"platform_package": "com.sankuai.meituan",
|
||||
"pay_channel": "wechat",
|
||||
"compared_price_cents": 12350,
|
||||
"paid_amount_cents": 12350,
|
||||
"shop_name": "下过单的店",
|
||||
"original_price_cents": 12850,
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
# 不传 ordered:5 条全出(「全部记录」tab 口径不变)
|
||||
assert len(client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]) == 5
|
||||
|
||||
# ordered=true:只出那 3 条,且每条都自带 ordered=True
|
||||
page = client.get("/api/v1/compare/records?ordered=true", headers=_auth(token)).json()
|
||||
assert [it["store_name"] for it in page["items"]] == ["下过单的店"] * 3
|
||||
assert all(it["ordered"] for it in page["items"])
|
||||
assert page["next_cursor"] is None
|
||||
|
||||
# 游标只在「已下单」集合内走 —— 不会把没下单的记录算进一页的 limit 里
|
||||
p1 = client.get(
|
||||
"/api/v1/compare/records?ordered=true&limit=2", headers=_auth(token)
|
||||
).json()
|
||||
assert len(p1["items"]) == 2
|
||||
assert p1["next_cursor"] is not None
|
||||
p2 = client.get(
|
||||
f"/api/v1/compare/records?ordered=true&limit=2&cursor={p1['next_cursor']}",
|
||||
headers=_auth(token),
|
||||
).json()
|
||||
assert [it["store_name"] for it in p2["items"]] == ["下过单的店"]
|
||||
# 两页不重叠,合起来正好 3 条
|
||||
assert len({it["id"] for it in p1["items"] + p2["items"]}) == 3
|
||||
|
||||
# 别人的下单不该让本人记录进「已下单」
|
||||
token_b = _login(client, "13800002014")
|
||||
pb = _food_payload("of-b")
|
||||
pb["store_name"] = "下过单的店"
|
||||
client.post("/api/v1/compare/record", json=pb, headers=_auth(token_b))
|
||||
assert client.get(
|
||||
"/api/v1/compare/records?ordered=true", headers=_auth(token_b)
|
||||
).json()["items"] == []
|
||||
|
||||
|
||||
def test_records_keyword_search(client) -> None:
|
||||
"""keyword 按店名 / 菜名模糊搜(忽略大小写),LIKE 通配符只当字面量;搜索结果也能翻页。
|
||||
|
||||
菜名走写路径派生的 product_names 文本列 —— items 是 JSON,SQLite 下中文被 ensure_ascii
|
||||
转义,直接 LIKE 搜不到。
|
||||
"""
|
||||
token = _login(client, "13800002015")
|
||||
|
||||
b = _food_payload("kw-b")
|
||||
b["store_name"] = "Pizza Hut"
|
||||
b["items"] = [{"name": "榴莲比萨", "qty": 1}]
|
||||
client.post("/api/v1/compare/record", json=b, headers=_auth(token))
|
||||
|
||||
c = _food_payload("kw-c")
|
||||
c["store_name"] = "100%纯牛肉汉堡"
|
||||
c["items"] = [{"name": "双层牛肉堡", "qty": 1}]
|
||||
client.post("/api/v1/compare/record", json=c, headers=_auth(token))
|
||||
|
||||
# 默认 payload 的店名是「海底捞(朝阳店)」
|
||||
client.post("/api/v1/compare/record", json=_food_payload("kw-a"), headers=_auth(token))
|
||||
|
||||
def _search(kw: str, **extra) -> list[str]:
|
||||
r = client.get(
|
||||
"/api/v1/compare/records",
|
||||
params={"keyword": kw, **extra},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return [it["store_name"] for it in r.json()["items"]]
|
||||
|
||||
assert _search("海底捞") == ["海底捞(朝阳店)"] # 店名命中
|
||||
assert _search("榴莲") == ["Pizza Hut"] # 菜名命中(product_names)
|
||||
assert _search("pizza") == ["Pizza Hut"] # 忽略大小写
|
||||
assert _search("PIZZA") == ["Pizza Hut"]
|
||||
assert _search("不存在的店") == [] # 没命中就是空
|
||||
# 通配符只当普通字符:搜 % 不该把整表拉回来,搜 _ 也不该匹配任意单字符
|
||||
assert _search("%") == ["100%纯牛肉汉堡"]
|
||||
assert _search("_") == []
|
||||
# 纯空白等同不传 → 不过滤
|
||||
assert len(_search(" ")) == 3
|
||||
|
||||
# 搜索结果自身可翻页
|
||||
for i in range(3):
|
||||
p = _food_payload(f"kw-page-{i}")
|
||||
p["store_name"] = "连锁烤鱼店"
|
||||
client.post("/api/v1/compare/record", json=p, headers=_auth(token))
|
||||
p1 = client.get(
|
||||
"/api/v1/compare/records",
|
||||
params={"keyword": "烤鱼", "limit": 2},
|
||||
headers=_auth(token),
|
||||
).json()
|
||||
assert len(p1["items"]) == 2
|
||||
assert p1["next_cursor"] is not None
|
||||
p2 = client.get(
|
||||
"/api/v1/compare/records",
|
||||
params={"keyword": "烤鱼", "limit": 2, "cursor": p1["next_cursor"]},
|
||||
headers=_auth(token),
|
||||
).json()
|
||||
assert [it["store_name"] for it in p2["items"]] == ["连锁烤鱼店"]
|
||||
assert len({it["id"] for it in p1["items"] + p2["items"]}) == 3
|
||||
|
||||
|
||||
def test_requires_auth(client) -> None:
|
||||
"""不带 token 统一 401。"""
|
||||
assert client.post("/api/v1/compare/record", json={"trace_id": "t"}).status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user