diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 174a087..af1f88d 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -12,6 +12,7 @@ from __future__ import annotations import logging +import uuid from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status @@ -53,6 +54,10 @@ def reserve_compare_start( scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, ): raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用") + # trace_id 统一由服务端签发(客户端不带时):预占额度本就是任务的第一个请求, + # 签发与建 running 行合一,此后 Phase1/Phase2/记录/前端日志全链用同一个 id。 + # 客户端带了则沿用——老客户端兼容 + 同 trace 重试幂等(reserve_daily_start 按 trace_id 去重)。 + trace_id = payload.trace_id or str(uuid.uuid4()) try: policy = limit_policy.resolve( db, @@ -63,7 +68,7 @@ def reserve_compare_start( rec, used = crud_compare.reserve_daily_start( db, user_id=user.id, - trace_id=payload.trace_id, + trace_id=trace_id, business_type=payload.business_type, device_id=payload.device_id, limit=policy.limit, @@ -94,6 +99,7 @@ def reserve_compare_start( limit=policy.limit, used=used, remaining=max(policy.limit - used, 0) if policy.limit is not None else None, + trace_id=trace_id, ) diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index ecd40a8..a3179d8 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -12,6 +12,7 @@ from __future__ import annotations import json import logging +import uuid from typing import Any import httpx @@ -30,6 +31,7 @@ from app.schemas.coupon_state import ( CouponPromptShouldShowOut, CouponPromptShownIn, CouponSessionIn, + CouponSessionOut, CouponStatsOut, ) @@ -175,6 +177,12 @@ async def coupon_step( ) resp_json = resp.json() + # 每帧响应顶层回传本次任务 trace_id(对齐 compare _forward 的 setdefault):客户端任一帧 + # 都能从响应拿到全链 id。**只回显请求里带的、不 mint**——step 是循环接口,每帧签新 id + # 会把一次任务打散;领券 trace_id 的唯一签发点在 /coupon/session (status=started)。 + # pricebot 响应顶层本无 trace_id(只有 trace_url),setdefault 不会覆盖任何上游值。 + if isinstance(resp_json, dict) and trace_id: + resp_json.setdefault("trace_id", trace_id) # 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。 # 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。 @@ -204,15 +212,33 @@ async def coupon_step( return resp_json -@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)") -def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]: +@router.post( + "/session", + response_model=CouponSessionOut, + summary="领券任务流水上报(admin 领券数据看板数据源;started 兼签发本轮 trace_id)", +) +def coupon_session(payload: CouponSessionIn, db: DbSession) -> CouponSessionOut: """客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert 到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算 - 发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。""" + 发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。 + + trace_id 统一由后端签发:started 不带 trace_id → 签发 uuid 并随响应返回,客户端全程用它 + (领券 step 循环 / 收尾上报 / 前端运行日志)。签发不依赖写库成功——写库失败照样返回 trace_id, + 后续收尾上报 upsert 会补建行。非 started 缺 trace_id 不签发(收尾没有 id 只能是异常调用, + 签发新 id 只会造出一行查不到发起信息的孤儿),不写库、trace_id=null 返回。 + """ + trace_id = payload.trace_id or ( + str(uuid.uuid4()) if payload.status == "started" else None + ) + if trace_id is None: + logger.warning( + "coupon session missing trace_id for status=%s (skip write)", payload.status + ) + return CouponSessionOut(ok=True, trace_id=None) try: coupon_repo.upsert_coupon_session( db, - trace_id=payload.trace_id, + trace_id=trace_id, device_id=payload.device_id, status=payload.status, started_at_ms=payload.started_at_ms, @@ -229,7 +255,7 @@ def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]: ) except Exception as e: # noqa: BLE001 logger.warning("coupon session write failed: %s", e) - return {"ok": True} + return CouponSessionOut(ok=True, trace_id=trace_id) @router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)") diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 4a6b617..74fde79 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -214,9 +214,13 @@ class ComparisonRecordCreatedOut(BaseModel): class CompareStartReserveIn(BaseModel): - """Reserve one authenticated comparison start before the agent begins.""" + """Reserve one authenticated comparison start before the agent begins. - trace_id: str = Field(..., min_length=1, max_length=64) + trace_id 可选:不带 = 请服务端签发(统一 trace_id 由后端下发,前端/SLS 日志/ + pricebot 全链用同一个 id);带 = 沿用客户端值(老客户端兼容 + 网络重试幂等)。 + """ + + trace_id: str | None = Field(default=None, min_length=1, max_length=64) business_type: str = Field(default="food", min_length=1, max_length=16) device_id: str | None = Field(default=None, max_length=64) @@ -225,6 +229,9 @@ class CompareStartReserveOut(BaseModel): limit: int | None used: int remaining: int | None + # 本次比价全链 trace_id(服务端签发的,或回显客户端带来的)。客户端必须以它为准, + # 贯穿 Phase1/Phase2 step、比价记录、trace 收尾与前端运行日志上报。 + trace_id: str class CompareStatsOut(BaseModel): diff --git a/app/schemas/coupon_state.py b/app/schemas/coupon_state.py index 35d2cca..0b7ae25 100644 --- a/app/schemas/coupon_state.py +++ b/app/schemas/coupon_state.py @@ -58,9 +58,13 @@ class CouponSessionIn(BaseModel): - 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)。 - 收尾(completed/failed/abandoned):带 elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count。 不鉴权(同领券循环 MVP,按 device_id/trace_id),user_id 登录态带上做留痕(可空)。 + + trace_id 可选:started 不带 = 请服务端签发本轮领券 trace_id(统一 trace_id 由后端下发, + 响应 CouponSessionOut.trace_id 返回,客户端全程用它);带 = 沿用客户端值(老客户端兼容)。 + 非 started 缺 trace_id 不签发(防孤儿行),返回 trace_id=null 且不写库。 """ - trace_id: str + trace_id: str | None = None device_id: str status: str # started / completed / failed / abandoned started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis) @@ -74,3 +78,16 @@ class CouponSessionIn(BaseModel): platform_elapsed: dict[str, int] | None = None claimed_count: int | None = None trace_url: str | None = None + + +class CouponSessionOut(BaseModel): + """POST /api/v1/coupon/session 响应。 + + trace_id = 本轮领券全链 id(服务端签发的,或回显客户端带来的);客户端以它为准贯穿 + /coupon/step 循环、收尾上报与前端运行日志。⚠️ 不能沿用旧的 dict[str, bool] 返回注解—— + FastAPI 会按注解校验响应,字符串 trace_id 过 bool 校验必炸,故显式建模。 + 非 started 且缺 trace_id 时为 null(不签发防孤儿行)。 + """ + + ok: bool = True + trace_id: str | None = None diff --git a/tests/test_compare_daily_limit.py b/tests/test_compare_daily_limit.py index a10afec..1de1a56 100644 --- a/tests/test_compare_daily_limit.py +++ b/tests/test_compare_daily_limit.py @@ -48,7 +48,10 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None: retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token)) assert first.status_code == 200, first.text - assert first.json() == {"limit": 100, "used": 1, "remaining": 99} + # trace_id 回显客户端带来的值(老协议幂等路径) + assert first.json() == { + "limit": 100, "used": 1, "remaining": 99, "trace_id": payload["trace_id"], + } assert retry.status_code == 200, retry.text assert retry.json() == first.json() with SessionLocal() as db: @@ -69,6 +72,28 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None: assert record.device_id == "quota-device" +def test_compare_start_issues_trace_id_when_absent(client) -> None: + """新客户端不带 trace_id → 服务端签发并随响应返回,running 行以签发 id 建。""" + token, user_id = _login(client) + response = client.post( + "/api/v1/compare/start", + json={"business_type": "food", "device_id": "quota-device-issue"}, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + body = response.json() + issued = body["trace_id"] + assert issued # 非空签发 + assert body["used"] == 1 + with SessionLocal() as db: + record = db.execute( + select(ComparisonRecord).where(ComparisonRecord.trace_id == issued) + ).scalar_one() + assert record.user_id == user_id + assert record.status == "running" + assert record.device_id == "quota-device-issue" + + def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None: token, user_id = _login(client) now = datetime.now(CN_TZ).replace(tzinfo=None) @@ -101,7 +126,9 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None: headers=_headers(token), ) assert allowed.status_code == 200, allowed.text - assert allowed.json() == {"limit": 100, "used": 100, "remaining": 0} + assert allowed.json() == { + "limit": 100, "used": 100, "remaining": 0, "trace_id": final_allowed_trace, + } rejected_trace = f"quota-rejected-{user_id}" response = client.post( diff --git a/tests/test_coupon_proxy.py b/tests/test_coupon_proxy.py index c993181..5543cd3 100644 --- a/tests/test_coupon_proxy.py +++ b/tests/test_coupon_proxy.py @@ -8,6 +8,7 @@ mock 掉对 pricebot 的 httpx 调用,验证: """ from __future__ import annotations +import json import time from unittest.mock import MagicMock, patch @@ -62,7 +63,8 @@ def test_coupon_step_no_auth_required(client) -> None: def test_coupon_step_passes_body_through(client, access_token) -> None: - """带 token + pricebot 200 → 响应原样透传,请求 body 原样转发到 /api/coupon/step。""" + """带 token + pricebot 200 → 请求 body 原样转发到 /api/coupon/step; + 响应在透传基础上顶层回显本次任务 trace_id(setdefault 注入,其余字段原样)。""" fake_pricebot_resp = { "success": True, "action": { @@ -83,9 +85,12 @@ def test_coupon_step_passes_body_through(client, access_token) -> None: captured: dict = {} - async def fake_post(self, url, json=None, **kw): + # ⚠️ coupon_step 转发用 content=raw(原始字节透传,不重新 dumps),不是 json= —— + # fake 必须捕 content。旧 fake 只捕 json= 导致 captured["json"] 恒 None,本测试 + # 自 content=raw 优化后一直红着(pre-existing),本次顺手修正。 + async def fake_post(self, url, content=None, **kw): captured["url"] = url - captured["json"] = json + captured["content"] = content mock_resp = MagicMock() mock_resp.status_code = 200 mock_resp.json = lambda: fake_pricebot_resp @@ -99,9 +104,10 @@ def test_coupon_step_passes_body_through(client, access_token) -> None: ) assert r.status_code == 200, r.text - assert r.json() == fake_pricebot_resp - # 验证请求被原样转发(body 不动 + URL 指向 pricebot) - assert captured["json"] == _stub_request_body() + # 顶层多出 trace_id 回显(值=请求带的;不 mint,签发点唯一在 /coupon/session started) + assert r.json() == {**fake_pricebot_resp, "trace_id": "test-trace-1"} + # 验证请求被原样转发(body 字节不动 + URL 指向 pricebot) + assert json.loads(captured["content"]) == _stub_request_body() assert captured["url"].endswith("/api/coupon/step") diff --git a/tests/test_coupon_session_issue.py b/tests/test_coupon_session_issue.py new file mode 100644 index 0000000..b9459fc --- /dev/null +++ b/tests/test_coupon_session_issue.py @@ -0,0 +1,65 @@ +"""POST /api/v1/coupon/session 的 trace_id 签发行为(统一 trace_id 由后端下发)。 + +- started 不带 trace_id → 服务端签发并返回,行以签发 id 建; +- started 带 trace_id → 回显沿用(老客户端兼容); +- 非 started 缺 trace_id → 不签发不写库,trace_id=null(防孤儿行)。 +""" +from __future__ import annotations + +from sqlalchemy import func, select + +from app.db.session import SessionLocal +from app.models.coupon_state import CouponSession + + +def _base_payload(**overrides) -> dict: + payload = { + "device_id": "cs-issue-device", + "status": "started", + "started_at_ms": 1_722_000_000_000, + "platforms": ["meituan-waimai"], + "app_env": "dev", + } + payload.update(overrides) + return payload + + +def test_session_started_issues_trace_id_when_absent(client) -> None: + response = client.post("/api/v1/coupon/session", json=_base_payload()) + assert response.status_code == 200, response.text + body = response.json() + assert body["ok"] is True + issued = body["trace_id"] + assert issued + with SessionLocal() as db: + row = db.execute( + select(CouponSession).where(CouponSession.trace_id == issued) + ).scalar_one() + assert row.device_id == "cs-issue-device" + assert row.status == "started" + + +def test_session_started_echoes_client_trace_id(client) -> None: + response = client.post( + "/api/v1/coupon/session", json=_base_payload(trace_id="cs-legacy-1") + ) + assert response.status_code == 200, response.text + assert response.json()["trace_id"] == "cs-legacy-1" + with SessionLocal() as db: + count = db.scalar( + select(func.count(CouponSession.id)).where( + CouponSession.trace_id == "cs-legacy-1" + ) + ) + assert count == 1 + + +def test_session_terminal_without_trace_id_skips_write(client) -> None: + response = client.post( + "/api/v1/coupon/session", + json=_base_payload(status="completed", elapsed_ms=1234, claimed_count=2), + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["ok"] is True + assert body["trace_id"] is None