ab2de6ec79
## 变更说明 - below_minimum 归入成功,保留原始业务结局 - store_closed/store_not_found/items_not_found/no_delivery/unsupported 归入失败 - running 保持进行中生命周期状态 - 历史细分状态迁移为三态终态,迁移可逆 - 后台成功/失败筛选及汇总兼容迁移前历史值 ## 验证 - 相关回归:48 passed - Ruff:通过 - Alembic:单一 head --------- Co-authored-by: guke <guke@wonderable.ai> Co-authored-by: linkeyu <798648091@qq.com> Co-authored-by: unknown <798648091@qq.com> Reviewed-on: #209 Co-authored-by: linkeyu <linkeyu@wonderable.ai> Co-committed-by: linkeyu <linkeyu@wonderable.ai>
494 lines
22 KiB
Python
494 lines
22 KiB
Python
"""后端 harvest 落库测试(比价记录改后端 harvest 后新增)。
|
||
|
||
覆盖:
|
||
repo 层:harvest_running(建行/幂等回填)、harvest_done(派生+newly_success 幂等)、
|
||
harvest_abort(夭折 / **不降级 success**)、upsert_record 不降级。
|
||
端点层:price/step 首帧 mint trace_id + 回传 + 建 running 行;done 帧落 success;
|
||
trace/finalize 落 cancelled;软鉴权(无 token 也放行、user_id 暂空)。
|
||
pricebot 用 httpx mock,不真连(同 test_compare_proxy)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import httpx
|
||
import pytest
|
||
from sqlalchemy import select
|
||
|
||
from app.db.session import SessionLocal
|
||
from app.models.comparison import ComparisonRecord
|
||
from app.repositories import comparison as crud
|
||
from app.schemas.compare_record import ComparisonRecordIn
|
||
|
||
|
||
def _tid() -> str:
|
||
return uuid.uuid4().hex
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw_status", "record_status"),
|
||
[
|
||
("success", "success"),
|
||
("below_minimum", "success"),
|
||
("failed", "failed"),
|
||
("store_closed", "failed"),
|
||
("store_not_found", "failed"),
|
||
("items_not_found", "failed"),
|
||
("no_delivery", "failed"),
|
||
("unsupported", "failed"),
|
||
("cancelled", "cancelled"),
|
||
("running", "running"),
|
||
(None, None),
|
||
],
|
||
)
|
||
def test_record_status_normalization(raw_status, record_status) -> None:
|
||
assert crud._normalize_record_status(raw_status) == record_status
|
||
|
||
|
||
def _done_params() -> dict:
|
||
"""一份典型 done 帧 params:美团 25 元 vs 源淘宝闪购 30 元 → 省 5 元、success。"""
|
||
return {
|
||
"comparison_results": [
|
||
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
|
||
"price": 25.0, "is_source": False, "rank": 1, "items": []},
|
||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购",
|
||
"package": "com.taobao.taobao", "price": 30.0, "is_source": True, "rank": 2,
|
||
"store_name": "测试店", "items": [{"name": "肥牛饭", "qty": 1}]},
|
||
],
|
||
"information": "美团更便宜",
|
||
"trace_url": "https://price.shaguabijia.com/traces/done/",
|
||
}
|
||
|
||
|
||
def _get(db, trace_id: str) -> ComparisonRecord | None:
|
||
return db.execute(
|
||
select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id)
|
||
).scalar_one_or_none()
|
||
|
||
|
||
# ============================================================
|
||
# repo 层
|
||
# ============================================================
|
||
|
||
|
||
def test_harvest_running_creates_row(client) -> None:
|
||
tid = _tid()
|
||
with SessionLocal() as db:
|
||
crud.harvest_running(
|
||
db, trace_id=tid, user_id=None, device_id="dev-1",
|
||
device_info={"brand": "vivo", "model": "V2309A", "android_version": "14",
|
||
"rom_version": "14"},
|
||
trace_url="https://price.shaguabijia.com/traces/run/",
|
||
)
|
||
rec = _get(db, tid)
|
||
assert rec is not None
|
||
assert rec.status == "running"
|
||
assert rec.user_id is None
|
||
assert rec.device_id == "dev-1"
|
||
assert rec.device_model == "V2309A"
|
||
assert rec.device_manufacturer == "vivo"
|
||
assert rec.rom_version == 14
|
||
assert rec.trace_url.endswith("/run/")
|
||
|
||
|
||
def test_harvest_running_idempotent_backfills(client) -> None:
|
||
tid = _tid()
|
||
with SessionLocal() as db:
|
||
crud.harvest_running(db, trace_id=tid, user_id=None, trace_url=None)
|
||
# 第二帧带上 trace_url + user_id → 回填空缺,不新建、不改 status
|
||
crud.harvest_running(db, trace_id=tid, user_id=None,
|
||
trace_url="https://price.shaguabijia.com/traces/late/")
|
||
rows = db.execute(
|
||
select(ComparisonRecord).where(ComparisonRecord.trace_id == tid)
|
||
).scalars().all()
|
||
assert len(rows) == 1 # 幂等:仍一行
|
||
assert rows[0].status == "running"
|
||
assert rows[0].trace_url.endswith("/late/") # 空缺被回填
|
||
|
||
|
||
def test_harvest_done_derives_and_newly_success_once(client) -> None:
|
||
tid = _tid()
|
||
with SessionLocal() as db:
|
||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||
rec, newly = crud.harvest_done(db, trace_id=tid, user_id=None,
|
||
done_params=_done_params())
|
||
assert newly is True # running → success 是"新落成"
|
||
assert rec.status == "success"
|
||
assert rec.source_platform_id == "taobao_flash"
|
||
assert rec.source_price_cents == 3000
|
||
assert rec.best_platform_id == "meituan"
|
||
assert rec.best_price_cents == 2500
|
||
assert rec.saved_amount_cents == 500 # 30 - 25
|
||
assert rec.is_source_best is False
|
||
assert rec.store_name == "测试店"
|
||
assert rec.information == "美团更便宜"
|
||
assert rec.fail_reason is None # 成功记录不派生失败原因
|
||
assert rec.items == [{"name": "肥牛饭", "qty": 1}]
|
||
assert rec.trace_url.endswith("/done/")
|
||
# 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发)
|
||
_rec2, newly2 = crud.harvest_done(db, trace_id=tid, user_id=None,
|
||
done_params=_done_params())
|
||
assert newly2 is False
|
||
|
||
|
||
def test_harvest_done_below_minimum_counts_as_completed_success(client) -> None:
|
||
"""未达起送是可信业务结论:主状态/完成奖励归 success,细分结局仍留在 raw_payload。"""
|
||
tid = _tid()
|
||
done_below_minimum = {
|
||
"record_status": "below_minimum",
|
||
"comparison_results": [
|
||
{
|
||
"platform_id": "meituan",
|
||
"platform_name": "美团",
|
||
"package": "com.sankuai.meituan",
|
||
"price": 59.0,
|
||
"is_source": True,
|
||
"rank": 1,
|
||
"store_name": "测试店",
|
||
"items": [{"name": "红乌苏", "qty": 1}],
|
||
},
|
||
],
|
||
"platform_results": {
|
||
"meituan": {"is_source": True, "status": "source", "price": 59.0},
|
||
"taobao_flash": {
|
||
"is_source": False,
|
||
"status": "below_minimum",
|
||
"reason": "购物车未达起送门槛(差 ¥25.2)",
|
||
},
|
||
},
|
||
"information": "淘宝未达起送门槛,可加菜凑单后下单",
|
||
}
|
||
with SessionLocal() as db:
|
||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||
rec, newly = crud.harvest_done(
|
||
db, trace_id=tid, user_id=None, done_params=done_below_minimum
|
||
)
|
||
|
||
assert rec.status == "success"
|
||
assert newly is True
|
||
assert rec.fail_reason is None
|
||
assert rec.raw_payload["record_status"] == "below_minimum"
|
||
assert (
|
||
rec.raw_payload["platform_results"]["taobao_flash"]["status"]
|
||
== "below_minimum"
|
||
)
|
||
|
||
|
||
def test_harvest_done_platforms_below_minimum_counts_as_success(client) -> None:
|
||
"""新 platforms 单源派生路径也必须执行同一 below_minimum → success 归一化。"""
|
||
tid = _tid()
|
||
with SessionLocal() as db:
|
||
rec, newly = crud.harvest_done(
|
||
db,
|
||
trace_id=tid,
|
||
user_id=None,
|
||
done_params={
|
||
"record_status": "below_minimum",
|
||
"platforms": [
|
||
{
|
||
"role": "source",
|
||
"platform_id": "meituan",
|
||
"platform_name": "美团",
|
||
"price": 59.0,
|
||
"store_name": "测试店",
|
||
"items": [{"name": "红乌苏", "qty": 1}],
|
||
},
|
||
{
|
||
"role": "target",
|
||
"platform_id": "taobao_flash",
|
||
"platform_name": "淘宝",
|
||
"status": "below_minimum",
|
||
"price": None,
|
||
},
|
||
],
|
||
"platform_results": {
|
||
"taobao_flash": {
|
||
"is_source": False,
|
||
"status": "below_minimum",
|
||
"reason": "购物车未达起送门槛",
|
||
}
|
||
},
|
||
},
|
||
)
|
||
|
||
assert rec.status == "success"
|
||
assert newly is True
|
||
assert rec.raw_payload["record_status"] == "below_minimum"
|
||
|
||
|
||
def test_legacy_upsert_below_minimum_counts_as_success(client) -> None:
|
||
"""灰度期客户端直报路径无论走 status 还是 record_status 都不能落第四种主状态。"""
|
||
tid = _tid()
|
||
payload = ComparisonRecordIn(
|
||
trace_id=tid,
|
||
business_type="food",
|
||
status="below_minimum",
|
||
comparison_results=[],
|
||
platform_results={
|
||
"taobao_flash": {
|
||
"is_source": False,
|
||
"status": "below_minimum",
|
||
"reason": "购物车未达起送门槛",
|
||
}
|
||
},
|
||
information="淘宝未达起送门槛,可加菜凑单后下单",
|
||
)
|
||
with SessionLocal() as db:
|
||
rec = crud.upsert_record(db, user_id=987654, payload=payload)
|
||
|
||
assert rec.status == "success"
|
||
assert rec.fail_reason is None
|
||
assert rec.raw_payload["status"] == "below_minimum"
|
||
|
||
|
||
def test_harvest_done_failed_derives_fail_reason(client) -> None:
|
||
"""failed 记录:记录级 information 笼统,但 fail_reason 从 platform_results 救出具体原因
|
||
(id 3030 型:美团系统失败 + 京东 items_not_found → 展示京东那条)。"""
|
||
tid = _tid()
|
||
done_failed = {
|
||
"comparison_results": [
|
||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购",
|
||
"package": "com.taobao.taobao", "price": 23.04, "is_source": True, "rank": 1,
|
||
"items": [{"name": "肥牛饭", "qty": 1}]},
|
||
],
|
||
"platform_results": {
|
||
"taobao_flash": {"is_source": True, "status": "source", "price": 23.04},
|
||
"meituan_waimai": {"is_source": False, "status": "failed",
|
||
"reason": "搜索店铺失败, 无法跳转到搜索页"},
|
||
"jd_waimai_standalone": {"is_source": False, "status": "items_not_found",
|
||
"reason": "京东外卖此店内未找到这些菜品"},
|
||
},
|
||
"information": "比价过程出错,请稍后重试",
|
||
}
|
||
with SessionLocal() as db:
|
||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||
rec, newly = crud.harvest_done(db, trace_id=tid, user_id=None,
|
||
done_params=done_failed)
|
||
assert newly is False # 没落成 success
|
||
assert rec.status == "failed"
|
||
assert rec.fail_reason == "京东外卖此店内未找到这些菜品"
|
||
assert rec.information == "比价过程出错,请稍后重试" # 原文案仍留存
|
||
|
||
|
||
def _done_params_platforms_no_isbest() -> dict:
|
||
"""id 3304 型:done 帧 platforms 全平台「相似替换/仅供参考」(has_dish_diff),pricebot
|
||
一个 is_best 都没标,但有有价目标(美团 57.8 < 源淘宝闪购 60.8)。"""
|
||
return {
|
||
"platforms": [
|
||
{"role": "source", "platform_id": "eleme", "platform_name": "淘宝闪购",
|
||
"package": "me.ele", "price": 60.8, "is_best": False, "has_dish_diff": False,
|
||
"store_name": "窑鸡王", "items": [{"name": "招牌窑鸡 整只-香辣", "qty": 1}]},
|
||
{"role": "target", "platform_id": "meituan_waimai", "platform_name": "美团外卖",
|
||
"package": "com.sankuai.meituan.takeoutnew", "price": 57.8, "is_best": False,
|
||
"has_dish_diff": True},
|
||
{"role": "target", "platform_id": "jd_waimai_standalone", "platform_name": "京东外卖",
|
||
"package": "com.jd.waimai", "price": 74.9, "is_best": False, "has_dish_diff": True},
|
||
],
|
||
"information": "美团更便宜(含相似商品替换)",
|
||
}
|
||
|
||
|
||
def test_harvest_done_platforms_no_isbest_falls_back_to_cheapest_target(client) -> None:
|
||
"""回归(id 3304):platforms 全无 is_best(全平台相似替换)但有有价目标 → best 应兜底取
|
||
有价目标里最低那家,不能让 best_*/saved 整条落 NULL(否则首页价 0.00 / 记录页无最低红框 /
|
||
省额丢失)。"""
|
||
tid = _tid()
|
||
with SessionLocal() as db:
|
||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||
rec, _ = crud.harvest_done(db, trace_id=tid, user_id=None,
|
||
done_params=_done_params_platforms_no_isbest())
|
||
assert rec.status == "success"
|
||
assert rec.best_platform_id == "meituan_waimai" # 有价目标里最低
|
||
assert rec.best_price_cents == 5780 # 57.8 元
|
||
assert rec.source_price_cents == 6080 # 源淘宝闪购 60.8
|
||
assert rec.saved_amount_cents == 300 # 60.8 - 57.8
|
||
assert rec.is_source_best is False # 兜底选的是目标,非源
|
||
|
||
|
||
def _done_params_no_isbest_source_cheapest() -> dict:
|
||
"""无 is_best 且源本身最便宜:源淘宝闪购 50.0 < 全部 dish-diff 目标(美团 57.8 / 京东 74.9)。
|
||
此时不能强选更贵的目标当 best(否则 saved 变负、污染「累计发现可省」),应回落源。"""
|
||
return {
|
||
"platforms": [
|
||
{"role": "source", "platform_id": "eleme", "platform_name": "淘宝闪购",
|
||
"package": "me.ele", "price": 50.0, "is_best": False, "has_dish_diff": False,
|
||
"store_name": "窑鸡王", "items": [{"name": "招牌窑鸡 整只-香辣", "qty": 1}]},
|
||
{"role": "target", "platform_id": "meituan_waimai", "platform_name": "美团外卖",
|
||
"package": "com.sankuai.meituan.takeoutnew", "price": 57.8, "is_best": False,
|
||
"has_dish_diff": True},
|
||
{"role": "target", "platform_id": "jd_waimai_standalone", "platform_name": "京东外卖",
|
||
"package": "com.jd.waimai", "price": 74.9, "is_best": False, "has_dish_diff": True},
|
||
],
|
||
"information": "源平台已是最低(其余为相似替换)",
|
||
}
|
||
|
||
|
||
def test_harvest_done_no_isbest_source_cheapest_falls_back_to_source(client) -> None:
|
||
"""回归:无 is_best 且源最便宜 → best 回落源(与 _derive「全目标缺菜回落源、不虚报省」同语义),
|
||
saved=0、is_source_best=True,绝不因强选更贵目标而让 saved 变负、倒扣「累计发现可省」。"""
|
||
tid = _tid()
|
||
with SessionLocal() as db:
|
||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||
rec, _ = crud.harvest_done(db, trace_id=tid, user_id=None,
|
||
done_params=_done_params_no_isbest_source_cheapest())
|
||
assert rec.status == "success"
|
||
assert rec.best_platform_id == "eleme" # 回落到源(源最便宜)
|
||
assert rec.best_price_cents == 5000 # 源 50.0
|
||
assert rec.source_price_cents == 5000
|
||
assert rec.saved_amount_cents == 0 # 没省到,绝不为负
|
||
assert rec.is_source_best is True # 源就是最便宜
|
||
|
||
|
||
def test_harvest_abort_cancels_running(client) -> None:
|
||
tid = _tid()
|
||
with SessionLocal() as db:
|
||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||
rec = crud.harvest_abort(db, trace_id=tid, status="cancelled",
|
||
reason="用户终止比价",
|
||
trace_url="https://price.shaguabijia.com/traces/ab/")
|
||
assert rec is not None
|
||
assert rec.status == "cancelled"
|
||
assert rec.information == "用户终止比价"
|
||
assert rec.trace_url.endswith("/ab/")
|
||
|
||
|
||
def test_harvest_abort_no_downgrade_success(client) -> None:
|
||
"""已 success 的行,finalize 后到(收尾取消)→ 只 refresh trace_url,status 不降级。"""
|
||
tid = _tid()
|
||
with SessionLocal() as db:
|
||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||
crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params())
|
||
rec = crud.harvest_abort(db, trace_id=tid, status="cancelled", reason="收尾误触")
|
||
assert rec.status == "success" # 不降级
|
||
assert rec.information == "美团更便宜" # 不被 abort 的 reason 覆盖
|
||
|
||
|
||
def test_harvest_abort_missing_row_returns_none(client) -> None:
|
||
with SessionLocal() as db:
|
||
assert crud.harvest_abort(db, trace_id=_tid(), status="cancelled",
|
||
reason=None) is None
|
||
|
||
|
||
def test_upsert_record_no_downgrade_after_harvest_success(client) -> None:
|
||
"""harvest 落 success 后,老客户端 fromFailure 的 cancelled 上报不许把它盖回去。"""
|
||
tid = _tid()
|
||
with SessionLocal() as db:
|
||
crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params())
|
||
payload = ComparisonRecordIn(
|
||
trace_id=tid, business_type="food", status="cancelled",
|
||
information="用户终止", comparison_results=[],
|
||
)
|
||
# 用一个不会与顺序自增用户撞的合成 id(SQLite 测试库 FK 不强制;别用小整数,
|
||
# 否则会撞上别的测试 login 出来的真实 user_id → 记录混进那个用户的列表)。
|
||
rec = crud.upsert_record(db, user_id=987654, payload=payload)
|
||
assert rec.status == "success" # 不降级
|
||
assert rec.user_id == 987654 # 但补上了 user_id(原为 None)
|
||
|
||
|
||
# ============================================================
|
||
# 端点层(mock pricebot)
|
||
# ============================================================
|
||
|
||
|
||
def _mock_pricebot(resp_json: dict):
|
||
"""patch httpx.AsyncClient.post 返回给定响应;捕获转发的 content。"""
|
||
captured: dict = {}
|
||
|
||
async def fake_post(self, url, content=None, **kw):
|
||
captured["url"] = url
|
||
captured["content"] = content
|
||
m = MagicMock()
|
||
m.status_code = 200
|
||
m.json = lambda: dict(resp_json)
|
||
return m
|
||
|
||
return patch.object(httpx.AsyncClient, "post", fake_post), captured
|
||
|
||
|
||
def _stub_body(**over) -> dict:
|
||
body = {
|
||
"device_id": "dev-x",
|
||
"step": 0,
|
||
"query": "海底捞",
|
||
"device_info": {"brand": "OPPO", "model": "PEXM00", "android_version": "13",
|
||
"rom_version": "13"},
|
||
"screen_state": {"screen": {"width": 1080, "height": 2340, "density": 3.0},
|
||
"foreground": {"package": "com.taobao.taobao", "activity": ""},
|
||
"windows": []},
|
||
}
|
||
body.update(over)
|
||
return body
|
||
|
||
|
||
def test_price_step_mints_trace_id_and_creates_running(client) -> None:
|
||
"""首帧不带 trace_id → app-server 签发 + 回传;并建 running 行(带机型/设备)。"""
|
||
wait_frame = {"success": True, "action": {"command": "wait", "params": {"duration_ms": 500}},
|
||
"continue": True, "trace_url": "https://price.shaguabijia.com/traces/mint/"}
|
||
p, _cap = _mock_pricebot(wait_frame)
|
||
with p:
|
||
r = client.post("/api/v1/price/step", json=_stub_body()) # 无 trace_id、无 token
|
||
assert r.status_code == 200, r.text
|
||
tid = r.json().get("trace_id")
|
||
assert tid and len(tid) >= 16 # 回传了签发的 trace_id
|
||
with SessionLocal() as db:
|
||
rec = _get(db, tid)
|
||
assert rec is not None and rec.status == "running"
|
||
assert rec.user_id is None # 无 token → 软鉴权放行、user_id 暂空
|
||
assert rec.device_model == "PEXM00"
|
||
assert rec.trace_url.endswith("/mint/")
|
||
|
||
|
||
def test_price_step_done_harvests_success(client) -> None:
|
||
tid = _tid()
|
||
done_frame = {"success": True, "continue": False,
|
||
"action": {"command": "done", "params": _done_params()},
|
||
"trace_url": "https://price.shaguabijia.com/traces/done2/"}
|
||
p, _cap = _mock_pricebot(done_frame)
|
||
with p, patch(
|
||
"app.api.v1.compare.backfill_comparison_llm_cost"
|
||
) as backfill:
|
||
r = client.post("/api/v1/price/step", json=_stub_body(trace_id=tid, step=8))
|
||
assert r.status_code == 200
|
||
with SessionLocal() as db:
|
||
rec = _get(db, tid)
|
||
assert rec is not None and rec.status == "success"
|
||
assert rec.best_platform_id == "meituan"
|
||
assert rec.saved_amount_cents == 500
|
||
backfill.assert_called_once_with(rec.id, tid)
|
||
|
||
|
||
def test_trace_finalize_harvests_abort(client) -> None:
|
||
tid = _tid()
|
||
with SessionLocal() as db: # 先有 running 行(帧0建的)
|
||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||
p, _cap = _mock_pricebot({"trace_url": "https://price.shaguabijia.com/traces/fin/"})
|
||
with p, patch(
|
||
"app.api.v1.compare.backfill_comparison_llm_cost"
|
||
) as backfill:
|
||
r = client.post("/api/v1/trace/finalize",
|
||
json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"})
|
||
assert r.status_code == 200
|
||
with SessionLocal() as db:
|
||
rec = _get(db, tid)
|
||
assert rec is not None and rec.status == "cancelled"
|
||
assert rec.trace_url.endswith("/fin/")
|
||
backfill.assert_called_once_with(rec.id, tid)
|
||
|
||
|
||
def test_price_step_binds_user_when_authed(client) -> None:
|
||
"""带 JWT 的首帧 → running 行绑上 user_id。"""
|
||
client.post("/api/v1/auth/sms/send", json={"phone": "13800009001"})
|
||
token = client.post("/api/v1/auth/sms/login",
|
||
json={"phone": "13800009001", "code": "123456"}).json()["access_token"]
|
||
wait_frame = {"success": True, "action": {"command": "wait", "params": {}},
|
||
"continue": True, "trace_url": "https://price.shaguabijia.com/traces/auth/"}
|
||
p, _cap = _mock_pricebot(wait_frame)
|
||
with p:
|
||
r = client.post("/api/v1/price/step", json=_stub_body(),
|
||
headers={"Authorization": f"Bearer {token}"})
|
||
tid = r.json()["trace_id"]
|
||
with SessionLocal() as db:
|
||
rec = _get(db, tid)
|
||
assert rec is not None and rec.user_id is not None # 绑上了登录用户
|