Merge pull request 'test(coupon-proxy): 修正已过时的 401 鉴权断言' (#3) from feature/coupon-claim-integration into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-05-28 10:53:58 +08:00
11 changed files with 321 additions and 50 deletions
+88
View File
@@ -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")
+3
View File
@@ -102,6 +102,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 = ""
+2
View File
@@ -13,6 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.ad import router as ad_router
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.api.v1.savings import router as savings_router
@@ -65,6 +66,7 @@ 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)
app.include_router(wallet_router)
app.include_router(signin_router)
+26 -22
View File
@@ -19,38 +19,42 @@
| 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) |
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) |
| **钱包 / 我的资产**(前缀 `/api/v1/wallet` |||
| 12 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
| 13 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
| 14 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet-cash-transactions.md) |
| 15 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet-exchange-info.md) |
| 16 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet-exchange.md) |
| 17 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet-bind-wechat.md) |
| 18 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet-unbind-wechat.md) |
| 19 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet-withdraw-info.md) |
| 20 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet-withdraw.md) |
| 21 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet-withdraw-status.md) |
| 22 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet-withdraw-orders.md) |
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet-cash-transactions.md) |
| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet-exchange-info.md) |
| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet-exchange.md) |
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet-bind-wechat.md) |
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet-unbind-wechat.md) |
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet-withdraw-info.md) |
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet-withdraw.md) |
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet-withdraw-status.md) |
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet-withdraw-orders.md) |
| **签到**(前缀 `/api/v1/signin` |||
| 23 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) |
| 24 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) |
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) |
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) |
| **任务**(前缀 `/api/v1/tasks` |||
| 25 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) |
| 26 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) |
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) |
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) |
| **省钱**(前缀 `/api/v1/savings` |||
| 27 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings-summary.md) |
| 28 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings-battle.md) |
| 29 | `GET /api/v1/savings/records` | Bearer | [详情](./savings-records.md) |
| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings-summary.md) |
| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings-battle.md) |
| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings-records.md) |
| **看广告发奖**(前缀 `/api/v1/ad` |||
| 30 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
| 31 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
| 32 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
> 金额字段一律以**分**为单位(`*_cents`)。
+25
View File
@@ -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 长)
+23
View File
@@ -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
+6 -4
View File
@@ -1,9 +1,9 @@
# 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` 用于日志。
任意 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
⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer <access_token>` 头(契约层 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`
+11 -9
View File
@@ -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` |
+18 -11
View File
@@ -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 MVP2026-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(`<service>` 不单独设 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 项(无障碍/悬浮窗/录屏<API30/电池/自启动)真实检测 + 点击跳转 + 自启动无法检测态 + onResume 自动刷新 + 刷新按钮;逻辑抽到 `PermissionHelper`(HomeScreen 领券前检查复用)。
-**无障碍身份统一傻瓜比价**:服务名/图标继承 app;`accessibility_service_description`/前台通知/`GuideCopy`/引导 Activity 里的 "PriceBot" 全改「傻瓜比价」;app 图标换成完整 `logo1`(adaptive 居中+黄边,不裁切)。
-**GuideOverlayService 组件名 bug**:批量 sed 改包名误把无障碍服务 ComponentName 的 packageName 改成 `…shaguabijia.agent`(应是 applicationId `…shaguabijia`)→ 无障碍开启检测永远 false;改用 `PermissionHelper.isAccessibilityEnabled` 动态构造。
-**BuildConfig 包名**:`com.pricebot.app.BuildConfig` import 改本 app 包名。
+104
View File
@@ -0,0 +1,104 @@
"""/api/v1/intent/recognize 和 /api/v1/price/step 透传端点测试。
mock 掉对 pricebot httpx 调用,验证(两个端点参数化同跑):
1. token 也能用(MVP 不鉴权, coupon/step)
2. pricebot 200 响应原样透传,请求 body 原样转发到对应上游路径(去掉 /v1)
3. pricebot 5xx 502
4. pricebot 网络错误 502
5. JSON body 400
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import httpx
import pytest
# (客户端端点, 期望转发到的 pricebot 上游路径)
ENDPOINTS = [
("/api/v1/intent/recognize", "/api/intent/recognize"),
("/api/v1/price/step", "/api/price/step"),
]
def _stub_screen_state() -> 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"]
+15 -4
View File
@@ -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: