From 24faaf9d473da039e4521dcd49aa0b17580641b7 Mon Sep 17 00:00:00 2001 From: marco Date: Thu, 28 May 2026 10:29:20 +0800 Subject: [PATCH 1/5] =?UTF-8?q?test(coupon-proxy):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=B7=B2=E8=BF=87=E6=97=B6=E7=9A=84=20401=20=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coupon/step MVP 阶段已去鉴权(见「待办与技术债.md」已解决),但 test_coupon_step_requires_auth 仍断言"无 token → 401",baseline 实际走到上游 拿 502,这条用例长期红灯。改为 mock httpx 验证不带 token 也能正常透传,与新加的 compare 端点 no-auth 测试一致。 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_coupon_proxy.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/test_coupon_proxy.py b/tests/test_coupon_proxy.py index 41033e9..c993181 100644 --- a/tests/test_coupon_proxy.py +++ b/tests/test_coupon_proxy.py @@ -44,10 +44,21 @@ def access_token(client) -> str: return r.json()["access_token"] -def test_coupon_step_requires_auth(client) -> None: - """无 token → 401(get_current_user 拦下)。""" - r = client.post("/api/v1/coupon/step", json=_stub_request_body()) - assert r.status_code == 401 +def test_coupon_step_no_auth_required(client) -> None: + """MVP 不鉴权:不带 token 也能转发(已去掉 CurrentUser,device_id 透传)。 + + 历史:本测试原断言"无 token → 401",但 coupon/step 已去鉴权(见 docs/待办与 + 技术债.md「已解决」),401 断言已过时,改为验证不带 token 也能正常透传。 + """ + async def fake_post(self, url, json=None, **kw): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json = lambda: {"success": True} + return mock_resp + + with patch.object(httpx.AsyncClient, "post", fake_post): + r = client.post("/api/v1/coupon/step", json=_stub_request_body()) + assert r.status_code == 200, r.text def test_coupon_step_passes_body_through(client, access_token) -> None: From aa34ad7d0bc3c9b804d38947481c3182935245b9 Mon Sep 17 00:00:00 2001 From: marco Date: Thu, 28 May 2026 10:29:20 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(compare):=20=E5=A4=96=E5=8D=96?= =?UTF-8?q?=E6=AF=94=E4=BB=B7=202=20=E7=AB=AF=E7=82=B9=E9=80=8F=E4=BC=A0?= =?UTF-8?q?=E5=88=B0=20pricebot-backend=20(food=20MVP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仿 coupon_step 在 app/api/v1/compare.py 加纯 body 透传 2 端点(MVP 不鉴权,只读 device_id/trace_id/step 打日志,不做 schema 校验)。client /api/v1/{intent/recognize, price/step} → backend 去掉 /v1 转发到 PRICEBOT_BASE_URL,接通外卖比价 client→server→pricebot-backend 链路。 - compare.py: _passthrough helper + intent/recognize + price/step - config.py: PRICEBOT_COMPARE_TIMEOUT_SEC=60(意图识别大上下文 LLM、price/step 每帧 LLM,比领券 30s 长;对齐客户端 agent ApiClient 的 60s 读超时) - main.py: 注册 compare_router - tests/test_compare_proxy.py: 2 端点参数化测试(透传/5xx→502/不可达→502/坏 JSON→400) - docs/api/{compare-intent-recognize,compare-price-step}.md + 更新 README 索引 - 待办与技术债.md P2 外卖 2 端点标 ✅(2026-05-27);电商 2 个待接(compare.py 加两行即可) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/v1/compare.py | 88 +++++++++++++++++++++++ app/core/config.py | 3 + app/main.py | 2 + docs/api/README.md | 6 +- docs/api/compare-intent-recognize.md | 25 +++++++ docs/api/compare-price-step.md | 23 ++++++ docs/待办与技术债.md | 29 +++++--- tests/test_compare_proxy.py | 104 +++++++++++++++++++++++++++ 8 files changed, 267 insertions(+), 13 deletions(-) create mode 100644 app/api/v1/compare.py create mode 100644 docs/api/compare-intent-recognize.md create mode 100644 docs/api/compare-price-step.md create mode 100644 tests/test_compare_proxy.py diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py new file mode 100644 index 0000000..1da32dd --- /dev/null +++ b/app/api/v1/compare.py @@ -0,0 +1,88 @@ +"""外卖比价业务透传端点。 + +把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给 +pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权** +(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT + +device_id↔user_id 绑定后才能做用户级画像)。 + +- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+ + 价格,一次性。 +- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。 + +真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。 +电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize +和 /ecom/step 两行即可。 + +pricebot 协议文档: + pricebot-backend/docs/main/02_api_protocol.md +""" +from __future__ import annotations + +import logging +from typing import Any + +import httpx +from fastapi import APIRouter, HTTPException, Request, status + +from app.core.config import settings + +logger = logging.getLogger("shagua.compare") + +router = APIRouter(prefix="/api/v1", tags=["compare"]) + + +async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]: + """把请求体原样转发给 pricebot-backend 的 {upstream_path}。 + + 跟 coupon_step 同款透传壳:不鉴权、不做 schema 校验,仅读 device_id/trace_id/step + 打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s, + 比领券的 30s 长)。 + """ + try: + body = await request.json() + except Exception as e: + raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e + + url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}{upstream_path}" + timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC + + logger.info( + "compare %s device_id=%s trace_id=%s step=%s", + upstream_path, + body.get("device_id"), + body.get("trace_id"), + body.get("step"), + ) + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, json=body) + except httpx.RequestError as e: + logger.error("[pricebot] request failed: %s", e) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"pricebot upstream unreachable: {e}", + ) from e + + if resp.status_code >= 500: + logger.error( + "[pricebot] 5xx status=%d body=%s", + resp.status_code, + resp.text[:500], + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"pricebot upstream returned {resp.status_code}", + ) + + return resp.json() + + +@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传到 pricebot)") +async def intent_recognize(request: Request) -> dict[str, Any]: + return await _passthrough(request, "/api/intent/recognize") + + +@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)") +async def price_step(request: Request) -> dict[str, Any]: + return await _passthrough(request, "/api/price/step") diff --git a/app/core/config.py b/app/core/config.py index 7709b6c..fbf9474 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -61,6 +61,9 @@ class Settings(BaseSettings): PRICEBOT_BASE_URL: str = "http://localhost:8000" # 领券一帧最多 wait 6s,加网络往返,timeout 给 30s 比较稳 PRICEBOT_REQUEST_TIMEOUT_SEC: int = 30 + # 比价(intent/recognize + price/step)透传超时:意图识别是大上下文 LLM、 + # price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。 + PRICEBOT_COMPARE_TIMEOUT_SEC: int = 60 # ===== CORS ===== CORS_ALLOW_ORIGINS: str = "" diff --git a/app/main.py b/app/main.py index 159fc08..26cd4e9 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.v1.auth import router as auth_router +from app.api.v1.compare import router as compare_router from app.api.v1.coupon import router as coupon_router from app.api.v1.meituan import router as meituan_router from app.core.config import settings @@ -60,4 +61,5 @@ def health() -> dict[str, str]: app.include_router(auth_router) app.include_router(coupon_router) +app.include_router(compare_router) app.include_router(meituan_router) diff --git a/docs/api/README.md b/docs/api/README.md index 89556a2..f2c03f8 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -18,13 +18,15 @@ | 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth-refresh.md) | | 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth-me.md) | | 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth-logout.md) | -| 8 | `POST /api/v1/coupon/step` | Bearer | [详情](./coupon-step.md) | +| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md) | | 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) | | 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) | | 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) | +| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) | +| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 -> `coupon/step` 是后端**唯一需要 JWT 鉴权的业务接口**。 +> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。当前**所有业务接口均无鉴权**,只有 `/me`、`/logout` 需 Bearer。 --- diff --git a/docs/api/compare-intent-recognize.md b/docs/api/compare-intent-recognize.md new file mode 100644 index 0000000..1b58626 --- /dev/null +++ b/docs/api/compare-intent-recognize.md @@ -0,0 +1,25 @@ +# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot) + +> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md) + +## 入参 +任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。 +客户端实际传源平台购物车页的无障碍树采集结果(pricebot 协议里的 `screens`:`cart_page_1` / `cart_page_2`)。 + +## 出参 +pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`。 + +## 错误码 +- `400` body 不是合法 JSON +- `502` pricebot 上游不可达(网络错误)或返回 5xx + +## 说明 +把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/intent/recognize`(去掉 `/v1`,async httpx)。**一次比价只调一次**。真正的识别逻辑(剪枝 + 索引 + LLM)在 **pricebot-backend(另一个 repo,GoalEngine)**,本接口只是"透传壳"。 + +外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。 + +⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../待办与技术债.md) P1。 + +**相关配置**: +- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`) +- `PRICEBOT_COMPARE_TIMEOUT_SEC`(默认 60s,意图识别是大上下文 LLM,比领券的 30s 长) diff --git a/docs/api/compare-price-step.md b/docs/api/compare-price-step.md new file mode 100644 index 0000000..4bd3f42 --- /dev/null +++ b/docs/api/compare-price-step.md @@ -0,0 +1,23 @@ +# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot) + +> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md) + +## 入参 +任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。 +客户端逐帧上报 `screen_state` + 上一步 `action_result`;`step=0` 还带 `query` + `calibration`(来自 Phase 1)。 + +## 出参 +pricebot-backend 的响应**原样返回**(JSON object)。含 `action`(tap / set_text / launch / wait / done…)、`continue`、`status`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。 + +## 错误码 +- `400` body 不是合法 JSON +- `502` pricebot 上游不可达(网络错误)或返回 5xx + +## 说明 +把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/price/step`(去掉 `/v1`,async httpx)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**,本接口只是"透传壳"。 + +⚠️ **MVP 阶段不鉴权**(同 `coupon/step`)。 + +**相关配置**: +- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`;生产部署应与 pricebot-backend 同内网——比价一单 30~80 步、逐帧多一跳,走公网延迟会累积) +- `PRICEBOT_COMPARE_TIMEOUT_SEC`(默认 60s,price/step 每帧都是 LLM) diff --git a/docs/待办与技术债.md b/docs/待办与技术债.md index 444554c..ae3b543 100644 --- a/docs/待办与技术债.md +++ b/docs/待办与技术债.md @@ -9,7 +9,7 @@ ## P1 · 鉴权与用户绑定(最该补) -- **现状**:agent 系列接口 MVP 阶段**全部不鉴权**——领券 `coupon/step` + 比价 4 个(`intent/recognize`、`price/step`、`ecom/intent/recognize`、`ecom/step`)。落地时现有 `coupon-step` 路由的 `CurrentUser` 依赖要先去掉。 +- **现状**:agent 系列接口 MVP 阶段**全部不鉴权**。领券 `coupon/step` **已落地不鉴权**(✅ 已去掉 `CurrentUser` 依赖);比价 4 个(`intent/recognize`、`price/step`、`ecom/intent/recognize`、`ecom/step`)端点尚未建(见 P2),建时同样先不加鉴权。 - **代价(为什么记这笔账)**:不验 JWT → 转发时 server 拿不到 `user_id` → agent 行为只能绑到 `device_id`(设备级),**采集不到"哪个用户领了/买了什么"的用户级画像**。而精准人群画像、私域分群运营是商业模式的核心资产,靠的就是这份用户级行为数据。`device_id` 仍照传(后端按设备串领券队列够用)。 - **待补**:① agent 接口加 JWT 鉴权;② 建立 `device_id ↔ user_id` 绑定(登录后上报一次即可);③ 领券/比价记录按 user 维度落库。 - **连带**:补鉴权后,客户端引擎 `ApiClient` 要接回 JWT(复用 app 现有 `AuthInterceptor` 思路:注入带鉴权头的 OkHttpClient)。 @@ -18,20 +18,21 @@ ## P2 · 引擎移植 · 后端转发层(本仓) -- **加 4 个比价透传端点**:`intent/recognize`、`price/step`、`ecom/intent/recognize`、`ecom/step`。仿 `coupon_step` **纯 body 透传、不加 Pydantic schema**(协议归 pricebot 校验,否则它一改字段本仓就得跟着改,白白耦合)。client `/api/v1/xxx` → 转发 backend `/api/xxx`(去掉 v1,同 `coupon_step`)。 -- **转发超时要调大**:`coupon_step` 现 `PRICEBOT_REQUEST_TIMEOUT_SEC=30`(领券单帧 wait 6s)。但意图识别是一次大上下文 LLM、`price/step` 每帧也是 LLM,可能 >30s;客户端 `ApiClient` 读超时是 60s。→ 转发超时对齐 60s,**待测 pricebot 各接口真实耗时**再定终值。 +- **比价透传端点**:仿 `coupon_step` **纯 body 透传、不加 Pydantic schema**(协议归 pricebot 校验,否则它一改字段本仓就得跟着改,白白耦合)。client `/api/v1/xxx` → 转发 backend `/api/xxx`(去掉 v1,同 `coupon_step`)。 + - ✅ **外卖 2 个已落地**(food MVP,2026-05-27):`intent/recognize` + `price/step`,在 `app/api/v1/compare.py`(不鉴权、超时 60s),测试 `tests/test_compare_proxy.py`,文档 `docs/api/compare-*.md`。 + - 电商 2 个待接:`ecom/intent/recognize` + `ecom/step`——放开电商 scene 时在 `compare.py` 加两行即可。 +- ✅ **转发超时已分离**(2026-05-27):比价用新增的 `PRICEBOT_COMPARE_TIMEOUT_SEC=60`(意图识别大上下文 LLM、price/step 每帧 LLM),与领券的 `PRICEBOT_REQUEST_TIMEOUT_SEC=30` 区分;真实耗时待真机实测再定终值。 - **生产 server 与 backend 同内网**:`PRICEBOT_BASE_URL` 部署时设内网地址。比价是 30~80 步循环,每步多一跳,走公网延迟会累积。 - **nginx body 上限**:比价 debug 包带 `screenshot`(webp base64,几百 KB)。若 debug 联调走线上域名经 nginx,注意 `client_max_body_size`(release 不带截图、debug 直连本地都无此问题,仅"debug 走域名"才撞)。 --- -## P2 · 引擎移植 · 客户端(android 仓,在此备忘) +## P2 · 引擎移植 · 客户端(android 仓) -- **无障碍身份统一为傻瓜比价**:服务名/图标自动继承 application(`` 不单独设 label/icon);需显式改的 3 处文案——`accessibility_service_description`、前台常驻通知文案("PriceBot 正在待命"等)、`GuideCopy` 里引导高亮的系统列表条目名("PriceBot"→"傻瓜比价")。 -- **BuildConfig 包名**:代码里 `com.pricebot.app.BuildConfig` 的 import 必须改成本 app 包名,否则编译不过。 -- **领券入口**:首页"一键领券"从 `ComparingScreen` 假动画 → 真 `startCouponClaim()`。 -- **比价悬浮球**:`SUPPORTED_BIZ_PACKAGES`(购物 App 前台才显示悬浮球)逻辑照搬。 -- **权限引导融入 Compose**:把 pricebot `MainActivity` 里申请无障碍/悬浮窗/电池优化的逻辑抽出,接到傻瓜比价的 Compose 页(权限引导 Activity 那几个可照搬,改包名+文案)。 +**领券链路相关已完成**(详见文末「已解决」):无障碍身份改名、BuildConfig 包名、领券入口、SettingsScreen 权限卡、ApiClient 改址。剩余: + +- ✅ **比价悬浮球(外卖)已接**(2026-05-27):`SUPPORTED_BIZ_PACKAGES` 收窄到 3 个外卖源(美团/淘宝闪购/京东外卖);scene 写死 food(`getSceneMode` 默认改「外卖比价」)。点球 → `startTask` → Phase1/2 → 比价结果悬浮窗,这条链客户端本就齐(随引擎移植搬入),本次只收窄入口 + 写死场景 + 接通 server。电商放开时再加回 PDD/抖音 + scene 选择。 +- **仿腾讯权限引导**:搬来的 `AccessibilityGuideActivity`/`PermissionGuide*`/`GuideOverlayService` 尚未接入口;当前是"跳系统设置页"最简版(SettingsScreen 权限卡 + 领券前检查已用)。体验后续可升级成仿腾讯浮层。 - **类名可选改名**:`PriceBotService` 等用户不可见,保留无妨;想整洁可改 `ShaguaAccessibilityService`(牵动引用,不急)。 --- @@ -48,9 +49,15 @@ - **FloatingButton 死代码**:Running 面板已换成 ComposeView 承载 `CouponProgressPanel`(=复用 AgentFloat)。旧原生面板字段(`line1~4View`/`elapsedView`/`progressBarView`/`historyContainerView` 等)+ `updateStatus(StepStatus)` 里保留的那段更新逻辑 + `appendActionLine`/`markCurrentActionDone`/`markPlatformDone`/`addCancelButton` 全部 no-op,待删。 - **ComparingScreen / CompResultScreen 弃用**:领券改走悬浮窗,不再走全屏假动画。`Routes.COMPARING` / `COMP_RESULT` 成死路由,连同两个 Screen 文件待删(`CouponPromptDialog` 已抽到 `ui/coupon/`,删 ComparingScreen 不影响它)。 - **ApiClient 常量 unused**:`getBackendUrl()` 改用 `BuildConfig.BASE_URL` 后,`DEFAULT_BACKEND_URL`/`KEY_BACKEND_URL` 常量 unused,待清。 -- **权限引导是最简版**:缺权限时直接跳系统设置页(`ACTION_ACCESSIBILITY_SETTINGS` / overlay 设置)。搬来的仿腾讯引导(`AccessibilityGuideActivity`/`PermissionGuide*`)尚未接入,体验后续可升级。 - **领券大数字语义**:悬浮窗左侧"已领券"大数字目前接 `progress.current`(正在领第几张),非"已成功数"。中间帧后端不回传已领明细,要精确需后端在 status.progress 加 `completed[]`(协议文档第四节已留扩展点)。 +- **正式版 versionCode 必须 > 占坑版线上最高**:占坑版(`shaguabijia-android`)与正式版同包名 `com.jishisongfu.shaguabijia` 原地覆盖。正式版当前 `versionCode=1`,真机占坑版是 5 → 同签名降级被 Android 拒装,**正式版无法覆盖更新线上占坑版用户**。上线前 versionCode 要提到 > 线上占坑版最高值。 ## 已解决 -(暂无) +- ✅ **领券链路接通**(阶段 1):首页「去领取」→ `CouponPromptDialog` → 权限检查 → `startCouponClaim` → 循环 `/api/v1/coupon/step`;Running 悬浮窗换皮(ComposeView 承载 `CouponProgressPanel`/AgentFloat,`OverlayLifecycleOwner` 撑 Compose)。 +- ✅ **coupon/step 去鉴权**:去掉 `CurrentUser`,MVP 不鉴权(device_id 透传)。 +- ✅ **ApiClient 改址**:`BuildConfig.BASE_URL` + `/api/v1/` 前缀。 +- ✅ **SettingsScreen 权限卡**:5 项(无障碍/悬浮窗/录屏 dict: + return { + "screen": {"width": 1080, "height": 2340, "density": 3.0}, + "foreground": {"package": "com.sankuai.meituan", "activity": ""}, + "windows": [], + } + + +def _stub_body() -> dict: + return { + "device_id": "test-device", + "trace_id": "test-trace-1", + "step": 0, + "query": "海底捞", + "screen_state": _stub_screen_state(), + } + + +@pytest.mark.parametrize("path,upstream", ENDPOINTS) +def test_passes_body_through(client, path, upstream) -> None: + """无 token + pricebot 200 → 响应原样透传,body 原样转发,URL 去掉 /v1。""" + fake_resp = { + "success": True, + "action": {"command": "wait", "params": {"duration_ms": 500}}, + "continue": True, + } + captured: dict = {} + + async def fake_post(self, url, json=None, **kw): + captured["url"] = url + captured["json"] = json + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json = lambda: fake_resp + return mock_resp + + with patch.object(httpx.AsyncClient, "post", fake_post): + r = client.post(path, json=_stub_body()) + + assert r.status_code == 200, r.text + assert r.json() == fake_resp + assert captured["json"] == _stub_body() # body 原样转发(不鉴权,无需 token) + assert captured["url"].endswith(upstream) # /api/v1/xxx → /api/xxx + + +@pytest.mark.parametrize("path,upstream", ENDPOINTS) +def test_pricebot_5xx_returns_502(client, path, upstream) -> None: + async def fake_post(self, url, json=None, **kw): + mock_resp = MagicMock() + mock_resp.status_code = 503 + mock_resp.text = "service unavailable" + return mock_resp + + with patch.object(httpx.AsyncClient, "post", fake_post): + r = client.post(path, json=_stub_body()) + + assert r.status_code == 502 + assert "pricebot upstream returned 503" in r.json()["detail"] + + +@pytest.mark.parametrize("path,upstream", ENDPOINTS) +def test_pricebot_unreachable_returns_502(client, path, upstream) -> None: + async def fake_post(self, url, json=None, **kw): + raise httpx.ConnectError("connection refused") + + with patch.object(httpx.AsyncClient, "post", fake_post): + r = client.post(path, json=_stub_body()) + + assert r.status_code == 502 + assert "pricebot upstream unreachable" in r.json()["detail"] + + +@pytest.mark.parametrize("path,upstream", ENDPOINTS) +def test_invalid_json_body(client, path, upstream) -> None: + r = client.post( + path, + headers={"Content-Type": "application/json"}, + content=b"not-json", + ) + assert r.status_code == 400 + assert "invalid json body" in r.json()["detail"] From 36f646597dec02708dab32963738d5e1b33c501e Mon Sep 17 00:00:00 2001 From: marco Date: Thu, 28 May 2026 10:43:53 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E9=A2=86?= =?UTF-8?q?=E5=88=B8=E5=8E=BB=E9=89=B4=E6=9D=83=20+=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=B7=B2=E6=8E=A5=E9=80=9A(=E6=94=B6=E5=B0=BE=20stash=20?= =?UTF-8?q?=E7=9A=84=E9=A2=84=20WIP=20+=20merge=20=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E8=A7=A3=E5=86=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 应用本次会话开始时 stash 的两份文档 WIP(主题一致:领券去鉴权后的文档同步): - docs/api/coupon-step.md: 鉴权 Bearer → 无(MVP);入参说明加 device_id;说明段去 "鉴权壳"措辞,加"前端已接通"链路 + MVP 不鉴权数据债说明(指向 P1)。 - docs/后端技术实现.md: 行 18 表格 / section 6 段 / 已知问题表三处 stash pop 已干净 apply;一句话总结(行 20)"鉴权 + 透传壳"→"透传壳"对齐;api/v1/ 文件树 冲突 — 采纳同事 8 行 router 清单(wallet/signin/tasks/savings/ad 新加),给 coupon.py 行末尾加"MVP 不鉴权",并补 compare.py 一行(本分支刚加的外卖比价 透传,同事 commit 那时还没有,补上保持文档跟代码一致)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/coupon-step.md | 10 ++++++---- docs/后端技术实现.md | 20 +++++++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/api/coupon-step.md b/docs/api/coupon-step.md index aa21345..f72672b 100644 --- a/docs/api/coupon-step.md +++ b/docs/api/coupon-step.md @@ -1,9 +1,9 @@ # POST /api/v1/coupon/step — 一键领券任务步进(透传到 pricebot) -> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md) +> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md) ## 入参 -任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `trace_id`、`step` 用于日志。 +任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。 ## 出参 pricebot-backend 的响应**原样返回**(JSON object)。 @@ -13,9 +13,11 @@ pricebot-backend 的响应**原样返回**(JSON object)。 - `502` pricebot 上游不可达(网络错误)或返回 5xx ## 说明 -本服务只加一层 JWT 鉴权,把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/coupon/step`(async httpx)。真正的领券逻辑在 **pricebot-backend(另一个 repo)**,本接口是"鉴权壳 + 透传"。是产品"一键领券"的接入点,前端目前尚未对接。 +把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/coupon/step`(async httpx)。真正的领券逻辑在 **pricebot-backend(另一个 repo,GoalEngine + 事件驱动)**,本接口只是"透传壳"。 -这是后端**唯一需要 JWT 鉴权的业务接口**。 +是产品"一键领券"的接入点,**前端已接通**:首页「去领取」→ `CouponPromptDialog` 确认 → 权限检查 → 无障碍引擎 `PriceBotService.startCouponClaim()` → 循环调本接口,后端逐张券下发 launch/wait/done。 + +⚠️ **MVP 阶段不鉴权**(已去掉 `CurrentUser` 依赖):`device_id` 透传给 pricebot 区分设备,但后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补 JWT 鉴权 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../待办与技术债.md) P1。 **相关配置**: - `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`) diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md index aef17ac..5a657fe 100644 --- a/docs/后端技术实现.md +++ b/docs/后端技术实现.md @@ -15,9 +15,9 @@ |---|---| | **账号与登录** | 极光一键登录 + 短信验证码登录(mock)→ 签发 JWT | | **美团 CPS 选品** | 透传美团联盟优惠券(外卖/到店)、点击换取推广链接(分佣) | -| **领券透传** | `/coupon/step` 加 JWT 鉴权后转发到 pricebot-backend(一键领券核心,前端待接入) | +| **领券透传** | `/coupon/step` 透传到 pricebot-backend(一键领券核心,MVP 不鉴权,前端已接通) | -**本服务自身没有"比价"实现**——名字叫比价,实质是:登录态 + 美团券聚合分佣 + 给真正的领券/比价核心(pricebot-backend,另一个 repo)做"鉴权 + 透传壳"。无爬虫、无 LLM、无积分/提现/钱包,数据模型仅一张 `user` 表(美团数据与领券请求均实时透传不落库)。 +**本服务自身没有"比价"实现**——名字叫比价,实质是:登录态 + 美团券聚合分佣 + 给真正的领券/比价核心(pricebot-backend,另一个 repo)做"透传壳"。无爬虫、无 LLM、无积分/提现/钱包,数据模型仅一张 `user` 表(美团数据与领券请求均实时透传不落库)。 --- @@ -50,7 +50,8 @@ app/ │ ├── deps.py # 共享依赖:get_current_user(鉴权)、get_db(注入 session) │ └── v1/ # 接口层(薄):解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码 │ ├── auth.py # 登录 6 端点(极光一键登录 / 短信 send+login / refresh / me / logout) -│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot) +│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,MVP 不鉴权) +│ ├── compare.py # 外卖比价透传 /intent/recognize + /price/step(转发 pricebot,MVP 不鉴权) │ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS) │ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单) │ ├── signin.py # 签到 2 端点(状态 / 执行签到) @@ -177,19 +178,20 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert ## 6. 领券透传(coupon/step) -产品"一键领券"的核心接入点,但**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。本服务只做"鉴权 + 透传壳"。 +产品"一键领券"的核心接入点,但**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。本服务只做"透传壳"。 ``` -客户端 → POST /api/v1/coupon/step (Bearer access_token + 任意 JSON body) - → coupon.py:get_current_user 校验 JWT(唯一需鉴权的业务接口) +客户端 → POST /api/v1/coupon/step (任意 JSON body,含 device_id/trace_id/step) + → coupon.py:不做 schema 校验,仅读 device_id/trace_id/step 打日志 → async httpx 把 body 原样 POST 到 PRICEBOT_BASE_URL/api/coupon/step → 把 pricebot 的响应原样返回 ``` -- **不做 schema 校验**:body 原样透传,pricebot 自己校验(后端仅读 `trace_id`/`step` 打日志)。 +- **MVP 不鉴权**:已去掉 `CurrentUser` 依赖。`device_id` 透传给 pricebot 区分设备,但后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补 JWT,见 `待办与技术债.md` P1。 +- **不做 schema 校验**:body 原样透传,pricebot 自己校验。 - **错误**:body 非合法 JSON → 400;pricebot 不可达或返回 5xx → 502。 - **配置**:`PRICEBOT_BASE_URL`(默认 `http://localhost:8000`)、`PRICEBOT_REQUEST_TIMEOUT_SEC`(默认 30s,因领券单帧最多 wait 6s)。 -- **现状**:接口已挂载可用,但**前端 ComparingScreen 仍是动画 demo、尚未对接此接口**;pricebot-backend 不在本目录。 +- **现状**:**前端已接通**——首页「去领取」→ `CouponPromptDialog` → 权限检查 → 无障碍引擎 `startCouponClaim` → 循环调本接口,逐张券下发 launch/wait/done。pricebot-backend 不在本目录。 --- @@ -256,7 +258,7 @@ conda activate price # 首次:pip install -e . | logout 无服务端失效 | 靠客户端清 token;后续加 jti 黑名单表 | | 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) | | feed 静默吞异常 | 建议给 `_fetch_topic` 加日志,避免无声失败 | -| 领券依赖 pricebot | `coupon/step` 仅透传,真正逻辑在 pricebot-backend;前端尚未对接 | +| 领券依赖 pricebot | `coupon/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路 | | SMS 为 mock | 上线接真实供应商 + `SMS_MOCK=false` | | 短信冷却存内存 | 扩 worker 前需迁移到 Redis | | SQLite | 流量上来切 PostgreSQL,只改 `DATABASE_URL` | From 3d1bb78afe6cd6cdc1018ba6cb192c840c4d621a Mon Sep 17 00:00:00 2001 From: no_gen_mu Date: Thu, 28 May 2026 10:44:19 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(api/coupon-step):=20=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E8=AF=B4=E6=B3=95=E7=B2=BE=E7=A1=AE=E5=8C=96=20=E2=80=94=20And?= =?UTF-8?q?roid=20=E5=B7=B2=E5=B8=A6=20Bearer=20/=20Server=20MVP=20?= =?UTF-8?q?=E4=B8=8D=E5=BC=BA=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 接通真机验证时 logcat 实证 Android 客户端通过 AuthInterceptor 自动带 Authorization: Bearer 头(见 shaguabijia-app-android 仓 data/net/ApiClient.kt:36)。原 docs 只写「鉴权:Bearer access_token」描述 契约目标,但没说明 server 端 MVP 不强校验的实现现状,容易让对接方误以为 缺 token 就 401。 L3 + L18 修订后清晰描述「客户端契约 vs Server 端 MVP 实现」两端联合状态, 不动 server 代码(coupon.py 仍 MVP 不接 Depends, P1 后启用)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/coupon-step.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/coupon-step.md b/docs/api/coupon-step.md index aa21345..e91aa30 100644 --- a/docs/api/coupon-step.md +++ b/docs/api/coupon-step.md @@ -1,6 +1,6 @@ # POST /api/v1/coupon/step — 一键领券任务步进(透传到 pricebot) -> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md) +> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:Bearer access_token(客户端契约;Server MVP 阶段不强校验) | [← 返回 API 索引](./README.md) ## 入参 任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `trace_id`、`step` 用于日志。 @@ -15,7 +15,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。 ## 说明 本服务只加一层 JWT 鉴权,把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/coupon/step`(async httpx)。真正的领券逻辑在 **pricebot-backend(另一个 repo)**,本接口是"鉴权壳 + 透传"。是产品"一键领券"的接入点,前端目前尚未对接。 -这是后端**唯一需要 JWT 鉴权的业务接口**。 +这是后端**唯一需要 JWT 鉴权**的业务接口。Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer ` 头;Server 端 MVP 阶段**暂未启用强校验**(见 [coupon.py](../../app/api/v1/coupon.py) 文件头 docstring 与 docs/待办与技术债.md P1),P1 完成后启用 `Depends(get_current_user)`。 **相关配置**: - `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`) From ba946a9d9a13e5ae886b61dd26bb2f899a2be719 Mon Sep 17 00:00:00 2001 From: marco Date: Thu, 28 May 2026 10:49:01 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(docs):=20=E7=A7=BB=E9=99=A4=203ff95db?= =?UTF-8?q?=20merge=20commit=20=E6=AE=8B=E7=95=99=E7=9A=84=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3ff95db 那次 merge 时, IDE/editor 已经修改文件但 my Edit 被 file-freshness guard 拒绝,后续 git add/commit/push 把仍带 <<<<<<< / =======/>>>>>>> 的 coupon-step.md 推上去了 —— 已部署文档破损。 按原合并方案 fix-forward:鉴权行用精确版「Bearer access_token(客户端契约;Server MVP 阶段不强校验)」,⚠️ 段保留「前端已接通」详述 + 用「客户端契约 vs Server 实现」措辞写鉴权状态 + device_id 用途 + P1 链接。 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/coupon-step.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/docs/api/coupon-step.md b/docs/api/coupon-step.md index 86080c4..333aae5 100644 --- a/docs/api/coupon-step.md +++ b/docs/api/coupon-step.md @@ -1,10 +1,6 @@ # POST /api/v1/coupon/step — 一键领券任务步进(透传到 pricebot) -<<<<<<< HEAD -> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md) -======= > 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:Bearer access_token(客户端契约;Server MVP 阶段不强校验) | [← 返回 API 索引](./README.md) ->>>>>>> origin/feature/coupon-claim-integration ## 入参 任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。 @@ -19,13 +15,9 @@ pricebot-backend 的响应**原样返回**(JSON object)。 ## 说明 把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/coupon/step`(async httpx)。真正的领券逻辑在 **pricebot-backend(另一个 repo,GoalEngine + 事件驱动)**,本接口只是"透传壳"。 -<<<<<<< HEAD 是产品"一键领券"的接入点,**前端已接通**:首页「去领取」→ `CouponPromptDialog` 确认 → 权限检查 → 无障碍引擎 `PriceBotService.startCouponClaim()` → 循环调本接口,后端逐张券下发 launch/wait/done。 -⚠️ **MVP 阶段不鉴权**(已去掉 `CurrentUser` 依赖):`device_id` 透传给 pricebot 区分设备,但后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补 JWT 鉴权 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../待办与技术债.md) P1。 -======= -这是后端**唯一需要 JWT 鉴权**的业务接口。Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer ` 头;Server 端 MVP 阶段**暂未启用强校验**(见 [coupon.py](../../app/api/v1/coupon.py) 文件头 docstring 与 docs/待办与技术债.md P1),P1 完成后启用 `Depends(get_current_user)`。 ->>>>>>> origin/feature/coupon-claim-integration +⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer ` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../待办与技术债.md) P1。 **相关配置**: - `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`)