From a73309ac078a4175898a3d444ade8a9a1b8793ce Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 31 Jul 2026 21:31:24 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(compare/coupon):=20trace=5Fid=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=94=B1=E5=90=8E=E7=AB=AF=E7=AD=BE=E5=8F=91?= =?UTF-8?q?=EF=BC=8C=E5=89=8D=E7=AB=AF=E4=B8=8D=E5=86=8D=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一次比价/领券的 trace_id 改由后端签发,让前端 SLS 运行日志(trace_id 索引列)、 app-server 比价记录与领券流水、pricebot trace 目录/run.log/trace_url 全链共用同一个 id 查到底(此前前端各业务自己 randomUUID,虽同链但非后端签发、也无单一签发点)。 - POST /api/v1/compare/start(预占额度,任务第一个请求,签发与建 running 行合一): 请求 trace_id 改可选,缺省时服务端签发 uuid;响应新增 trace_id 字段返回(签发的或 回显客户端带来的)。客户端带值则沿用——老客户端兼容 + 同 trace 重试幂等。 - POST /api/v1/coupon/session:started 帧缺 trace_id 时签发并随响应返回(签发不依赖 写库成功);非 started 帧缺 trace_id 不签发、不写库(收尾没有 id 只能是异常调用, 签发新 id 只会造出查不到发起信息的孤儿行)。新增 CouponSessionOut 响应模型——原 dict[str,bool] 注解无法承载字符串 trace_id,FastAPI 响应校验会炸。 - 测试:更新 2 处旧断言(响应体多出 trace_id 字段),新增 compare 不带 id 签发用例 + coupon started 签发/回显、终尾缺 id 跳过写库 3 个用例。全量 30 passed + ruff clean。 配合 shaguabijia-app-android 同名分支 feat-unify-trace-id-backend-issued 的前端换源改动。 Co-Authored-By: Claude Fable 5 --- app/api/v1/compare_record.py | 8 +++- app/api/v1/coupon.py | 30 +++++++++++--- app/schemas/compare_record.py | 11 ++++- app/schemas/coupon_state.py | 19 ++++++++- tests/test_compare_daily_limit.py | 31 +++++++++++++- tests/test_coupon_session_issue.py | 65 ++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 tests/test_coupon_session_issue.py 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..307423e 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, ) @@ -204,15 +206,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 +249,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_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 -- 2.52.0 From 93bf991fe1b44e343bfca53de6ff4cb986a29c3e Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 31 Jul 2026 23:08:43 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(coupon):=20/coupon/step=20=E6=AF=8F?= =?UTF-8?q?=E5=B8=A7=E5=93=8D=E5=BA=94=E9=A1=B6=E5=B1=82=E5=9B=9E=E6=98=BE?= =?UTF-8?q?=20trace=5Fid=EF=BC=8C=E8=A1=A5=E9=BD=90=E9=A2=86=E5=88=B8?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 compare 链路(_forward 的 setdefault 注入,每步响应都带 trace_id):coupon_step 透传响应在 resp.json() 后 setdefault 注入本次任务 trace_id,客户端任一帧都能从响应 拿到全链 id。**只回显请求里带的、不 mint**——step 是循环接口,每帧签新 id 会把一次 任务打散;领券 trace_id 的唯一签发点保持在 /coupon/session (status=started)。带 isinstance dict + 非空 trace_id 双防御(pricebot 响应顶层本无 trace_id 字段,setdefault 不会覆盖任何上游值)。 顺手修复 test_coupon_proxy.py 一个 pre-existing 红测试:coupon_step 转发早已改为 content=raw 原始字节透传,但 fake_post 仍只捕获 json= 参数 → captured["json"] 恒 None、body 转发断言一直失败(git stash 验证不带本轮改动同样红)。fake 改捕 content、 断言按字节 json.loads 后对比;transparently-passes-through 断言更新为"透传 + 顶层 回显 trace_id"新契约。 领券相关测试 22 passed。 Co-Authored-By: Claude Fable 5 --- app/api/v1/coupon.py | 6 ++++++ tests/test_coupon_proxy.py | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 307423e..a3179d8 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -177,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 先不去重)。写库失败不影响返回。 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") -- 2.52.0