feat(compare): 比价记录落库 + 战绩里程碑(真发金币)

- comparison_record 表 + 上报(幂等 user+trace)/列表/详情 接口;服务端派生
  best/saved/is_source_best/status + information(done 帧文案/失败具体原因)
- 战绩里程碑:comparison_milestone_claim 表 + 进度/领取接口,按成功比价次数
  解锁,复用 grant_coins 真发金币(RECORD_MILESTONES 120/180/300/500/800/1200)
- 迁移 record/information/milestone 三个,单 head;rewards.py 集中档位配置
- 测试 test_compare_record(8)+test_compare_milestone(7) 全绿;docs/api 12a-12e + 索引

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
OuYingJun1024
2026-05-31 19:56:47 +08:00
parent eb5f0dc460
commit 15dcfa604b
23 changed files with 1400 additions and 4 deletions
+136
View File
@@ -0,0 +1,136 @@
"""比价战绩里程碑测试:进度随成功比价数解锁 / 逐档领奖发金币 / 幂等 / 边界。
复用比价记录上报接口造成功记录(每条 trace 一次成功比价),再验里程碑。
"""
from __future__ import annotations
from app.core.rewards import RECORD_MILESTONES
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _report_success(client, token: str, trace_id: str) -> None:
"""上报一条成功比价(有非源有效价 → status 派生 success)。"""
payload = {
"trace_id": trace_id,
"business_type": "food",
"store_name": "测试店",
"source_platform_id": "taobao_flash",
"source_price": 30.0,
"comparison_results": [
{"platform_id": "meituan", "platform_name": "美团", "price": 25.0,
"is_source": False, "rank": 1},
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "price": 30.0,
"is_source": True, "rank": 2},
],
}
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
def _report_failed(client, token: str, trace_id: str) -> None:
"""上报一条失败比价(只有源 → status 派生 failed,不计入解锁)。"""
payload = {
"trace_id": trace_id,
"business_type": "food",
"comparison_results": [
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "price": 30.0,
"is_source": True, "rank": 1},
],
}
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
def test_status_empty(client) -> None:
"""没比价过:成功数 0,所有档 locked,无可领。"""
token = _login(client, "13800003001")
r = client.get("/api/v1/compare/milestones", headers=_auth(token))
assert r.status_code == 200, r.text
d = r.json()
assert d["success_count"] == 0
assert d["claimable_count"] == 0
assert len(d["milestones"]) == len(RECORD_MILESTONES)
assert all(m["state"] == "locked" for m in d["milestones"])
# 第 1 档金币对齐配置
assert d["milestones"][0]["coin"] == RECORD_MILESTONES[0]
assert d["milestones"][0]["milestone"] == 1
def test_unlock_progresses_with_success(client) -> None:
"""成功比价 2 次:前 2 档 active 可领,其余 locked。失败记录不计入。"""
token = _login(client, "13800003002")
_report_success(client, token, "s-1")
_report_success(client, token, "s-2")
_report_failed(client, token, "f-1") # 失败不计
d = client.get("/api/v1/compare/milestones", headers=_auth(token)).json()
assert d["success_count"] == 2
assert d["claimable_count"] == 2
states = [m["state"] for m in d["milestones"]]
assert states[0] == "active"
assert states[1] == "active"
assert states[2] == "locked"
def test_claim_grants_coin_and_becomes_claimed(client) -> None:
"""领第 1 档:发对应金币,进金币余额,该档变 claimed。"""
token = _login(client, "13800003003")
_report_success(client, token, "s-1")
bal_before = client.get("/api/v1/wallet/account", headers=_auth(token)).json()["coin_balance"]
r = client.post("/api/v1/compare/milestones/1/claim", headers=_auth(token))
assert r.status_code == 200, r.text
res = r.json()
assert res["milestone"] == 1
assert res["coin_awarded"] == RECORD_MILESTONES[0]
assert res["coin_balance"] == bal_before + RECORD_MILESTONES[0]
d = client.get("/api/v1/compare/milestones", headers=_auth(token)).json()
assert d["milestones"][0]["state"] == "claimed"
assert d["claimable_count"] == 0 # 第 1 档领掉了,成功数 1 没解锁第 2 档
def test_claim_idempotent_409(client) -> None:
"""同档重复领 → 409,且不重复发金币。"""
token = _login(client, "13800003004")
_report_success(client, token, "s-1")
assert client.post("/api/v1/compare/milestones/1/claim", headers=_auth(token)).status_code == 200
bal = client.get("/api/v1/wallet/account", headers=_auth(token)).json()["coin_balance"]
r = client.post("/api/v1/compare/milestones/1/claim", headers=_auth(token))
assert r.status_code == 409
bal2 = client.get("/api/v1/wallet/account", headers=_auth(token)).json()["coin_balance"]
assert bal2 == bal # 没重复发
def test_claim_locked_409(client) -> None:
"""领还没解锁的档(成功数不够)→ 409 locked。"""
token = _login(client, "13800003005")
_report_success(client, token, "s-1") # 只解锁第 1 档
r = client.post("/api/v1/compare/milestones/2/claim", headers=_auth(token))
assert r.status_code == 409
def test_claim_unknown_milestone_404(client) -> None:
"""档位越界 → 404。"""
token = _login(client, "13800003006")
assert client.post("/api/v1/compare/milestones/0/claim", headers=_auth(token)).status_code == 404
big = len(RECORD_MILESTONES) + 1
assert client.post(f"/api/v1/compare/milestones/{big}/claim", headers=_auth(token)).status_code == 404
def test_requires_auth(client) -> None:
"""不带 token → 401。"""
assert client.get("/api/v1/compare/milestones").status_code == 401
assert client.post("/api/v1/compare/milestones/1/claim").status_code == 401
+192
View File
@@ -0,0 +1,192 @@
"""比价记录测试:上报派生 / 幂等 / 分页 / 详情 / 鉴权 / 失败帧。
用 sms mock 登录拿 token,再跑各接口闭环。
"""
from __future__ import annotations
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _food_payload(trace_id: str) -> dict:
"""一次淘宝闪购→美团/京东 的成功比价(美团最便宜)。"""
return {
"trace_id": trace_id,
"business_type": "food",
"device_id": "dev-abc",
"store_name": "海底捞(朝阳店)",
"source_platform_id": "taobao_flash",
"source_platform_name": "淘宝闪购",
"source_package": "com.taobao.taobao",
"source_price": 128.50,
"items": [
{"name": "麻辣午餐肉", "qty": 2},
{"name": "黑牛肉卷", "qty": 1, "specs": ["大份"]},
],
"comparison_results": [
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
"price": 123.50, "is_source": False, "rank": 1},
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
"price": 128.50, "is_source": True, "rank": 2},
{"platform_id": "jd_waimai", "platform_name": "京东外卖", "package": "com.jingdong.app.mall",
"price": 130.00, "is_source": False, "rank": 3},
],
"skipped_dish_count": 1,
"skipped_dish_names": ["黑牛肉卷"],
"total_dish_count": 3,
"information": "在美团找到同店,到手价 ¥123.50",
}
def test_report_and_derive(client) -> None:
"""上报成功比价:服务端派生 best/saved/is_source_best/分,详情可读回。"""
token = _login(client, "13800002001")
r = client.post("/api/v1/compare/record", json=_food_payload("trace-1"), headers=_auth(token))
assert r.status_code == 200, r.text
rec_id = r.json()["id"]
assert isinstance(rec_id, int)
r = client.get(f"/api/v1/compare/records/{rec_id}", headers=_auth(token))
assert r.status_code == 200, r.text
d = r.json()
# 金额转分
assert d["source_price_cents"] == 12850
assert d["best_price_cents"] == 12350
# 省 = 源价 - 最优价 = 5.00 元
assert d["saved_amount_cents"] == 500
# 最优是美团(rank=1),非源平台 → 没"源平台最便宜"
assert d["best_platform_id"] == "meituan"
assert d["is_source_best"] is False
assert d["status"] == "success"
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
assert d["store_name"] == "海底捞(朝阳店)"
assert d["total_dish_count"] == 3
assert d["skipped_dish_count"] == 1
assert d["skipped_dish_names"] == ["黑牛肉卷"]
assert len(d["comparison_results"]) == 3
assert len(d["items"]) == 2
# 详情带 raw_payload 全量
assert d["raw_payload"]["trace_id"] == "trace-1"
def test_source_is_cheapest_no_saving(client) -> None:
"""源平台本来就最便宜:saved<=0,is_source_best=True,仍记一条 success。"""
token = _login(client, "13800002002")
payload = _food_payload("trace-2")
# 把源平台价改到最低 + rank=1
payload["source_price"] = 100.00
payload["comparison_results"] = [
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
"price": 100.00, "is_source": True, "rank": 1},
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
"price": 123.50, "is_source": False, "rank": 2},
]
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
rec_id = r.json()["id"]
d = client.get(f"/api/v1/compare/records/{rec_id}", headers=_auth(token)).json()
assert d["best_platform_id"] == "taobao_flash"
assert d["is_source_best"] is True
assert d["saved_amount_cents"] == 0 # 100 - 100
assert d["status"] == "success" # 有非源有效价(美团)
def test_failed_comparison_recorded(client) -> None:
"""比价失败(只有源、无目标有效价):status=failed 也落一条。"""
token = _login(client, "13800002003")
payload = _food_payload("trace-3")
payload["comparison_results"] = [
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
"price": 128.50, "is_source": True, "rank": 1},
]
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
rec_id = r.json()["id"]
d = client.get(f"/api/v1/compare/records/{rec_id}", headers=_auth(token)).json()
assert d["status"] == "failed"
assert d["best_platform_id"] == "taobao_flash" # 唯一一条
# 源价有、最优价=源价 → saved=0(没采到更便宜目标)
assert d["saved_amount_cents"] == 0
def test_idempotent_same_trace(client) -> None:
"""同 trace_id 重复上报:返回同一 id,只一条记录,后报覆盖。"""
token = _login(client, "13800002004")
p = _food_payload("trace-dup")
r1 = client.post("/api/v1/compare/record", json=p, headers=_auth(token))
id1 = r1.json()["id"]
# 改个值再报一次(同 trace)
p["store_name"] = "改名后的店"
r2 = client.post("/api/v1/compare/record", json=p, headers=_auth(token))
id2 = r2.json()["id"]
assert id1 == id2 # 同一条
# 列表里只有一条
page = client.get("/api/v1/compare/records", headers=_auth(token)).json()
assert len(page["items"]) == 1
# 覆盖生效
assert page["items"][0]["store_name"] == "改名后的店"
def test_records_pagination_and_isolation(client) -> None:
"""分页 id 倒序 + 游标;只看得到自己的记录。"""
token_a = _login(client, "13800002005")
for i in range(5):
client.post("/api/v1/compare/record", json=_food_payload(f"a-{i}"), headers=_auth(token_a))
# 另一个用户的记录不应混入
token_b = _login(client, "13800002006")
client.post("/api/v1/compare/record", json=_food_payload("b-0"), headers=_auth(token_b))
r = client.get("/api/v1/compare/records?limit=3", headers=_auth(token_a))
page1 = r.json()
assert len(page1["items"]) == 3
assert page1["next_cursor"] is not None
ids = [it["id"] for it in page1["items"]]
assert ids == sorted(ids, reverse=True)
r = client.get(
f"/api/v1/compare/records?limit=3&cursor={page1['next_cursor']}",
headers=_auth(token_a),
)
page2 = r.json()
assert len(page2["items"]) == 2 # A 共 5 条
assert max(it["id"] for it in page2["items"]) < min(ids)
# B 只看到自己那一条
pb = client.get("/api/v1/compare/records", headers=_auth(token_b)).json()
assert len(pb["items"]) == 1
def test_detail_cross_user_404(client) -> None:
"""读他人记录详情 → 404(限本人)。"""
token_a = _login(client, "13800002007")
rid = client.post(
"/api/v1/compare/record", json=_food_payload("x-0"), headers=_auth(token_a)
).json()["id"]
token_b = _login(client, "13800002008")
r = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token_b))
assert r.status_code == 404
def test_requires_auth(client) -> None:
"""不带 token 统一 401。"""
assert client.post("/api/v1/compare/record", json={"trace_id": "t"}).status_code == 401
assert client.get("/api/v1/compare/records").status_code == 401
assert client.get("/api/v1/compare/records/1").status_code == 401
def test_trace_id_required(client) -> None:
"""缺 trace_id → 422(pydantic 校验)。"""
token = _login(client, "13800002009")
r = client.post("/api/v1/compare/record", json={"business_type": "food"}, headers=_auth(token))
assert r.status_code == 422