From 8fe58f4a4251de0aa25f007f3919b6891f798376 Mon Sep 17 00:00:00 2001 From: no_gen_mu Date: Mon, 25 May 2026 19:46:14 +0800 Subject: [PATCH] feat(coupon): add /api/v1/coupon/step proxy to pricebot - new POST /api/v1/coupon/step, JWT-authenticated, async httpx forwards body to pricebot - new PRICEBOT_BASE_URL / PRICEBOT_REQUEST_TIMEOUT_SEC settings (default localhost:8000) - error handling: pricebot unreachable / 5xx -> 502 with friendly message - tests: auth, passthrough, 5xx, unreachable, invalid json (5 cases, all pass) Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 7 ++ app/api/v1/coupon.py | 72 +++++++++++++++++++ app/core/config.py | 6 ++ app/main.py | 2 + tests/test_coupon_proxy.py | 143 +++++++++++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 app/api/v1/coupon.py create mode 100644 tests/test_coupon_proxy.py diff --git a/.env.example b/.env.example index 83a6cf8..2ac45d7 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,13 @@ SMS_MOCK=true SMS_CODE_TTL_SEC=300 SMS_SEND_INTERVAL_SEC=60 +# ===== Pricebot 上游 (领券业务透传目标) ===== +# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend 的 /api/coupon/step。 +# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。 +PRICEBOT_BASE_URL=http://localhost:8000 +# 领券单帧最多 wait 6s,加网络往返,30s 兜底 +PRICEBOT_REQUEST_TIMEOUT_SEC=30 + # ===== CORS ===== # 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类 CORS_ALLOW_ORIGINS= diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py new file mode 100644 index 0000000..f91327f --- /dev/null +++ b/app/api/v1/coupon.py @@ -0,0 +1,72 @@ +"""领券业务透传端点。 + +把客户端的 POST /api/v1/coupon/step 请求原样转发给 pricebot-backend +的 /api/coupon/step,只在外层加 JWT 鉴权。 + +pricebot 协议文档: + pricebot-backend/docs/projects/领券-客户端对接协议.md +""" +from __future__ import annotations + +import logging +from typing import Any + +import httpx +from fastapi import APIRouter, HTTPException, Request, status + +from app.api.deps import CurrentUser +from app.core.config import settings + +logger = logging.getLogger("shagua.coupon") + +router = APIRouter(prefix="/api/v1/coupon", tags=["coupon"]) + + +@router.post("/step", summary="领券任务步进 (透传到 pricebot)") +async def coupon_step( + request: Request, + user: CurrentUser, +) -> dict[str, Any]: + """把请求体原样转发给 pricebot-backend 的 /api/coupon/step。 + + - 鉴权: Bearer access_token (get_current_user 隐式校验) + - 透传: 不做 schema 校验,pricebot 自己校验 + - 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message + """ + 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('/')}/api/coupon/step" + timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC + + logger.info( + "coupon_step user_id=%d trace_id=%s step=%s", + user.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() diff --git a/app/core/config.py b/app/core/config.py index 0cd99e4..e93710b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -46,6 +46,12 @@ class Settings(BaseSettings): SMS_CODE_TTL_SEC: int = 300 SMS_SEND_INTERVAL_SEC: int = 60 + # ===== Pricebot 上游 (领券业务透传目标) ===== + # pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step + PRICEBOT_BASE_URL: str = "http://localhost:8000" + # 领券一帧最多 wait 6s,加网络往返,timeout 给 30s 比较稳 + PRICEBOT_REQUEST_TIMEOUT_SEC: int = 30 + # ===== CORS ===== CORS_ALLOW_ORIGINS: str = "" diff --git a/app/main.py b/app/main.py index 2a6c7ff..5fd793a 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.coupon import router as coupon_router from app.core.config import settings from app.core.logging import setup_logging @@ -57,3 +58,4 @@ def health() -> dict[str, str]: app.include_router(auth_router) +app.include_router(coupon_router) diff --git a/tests/test_coupon_proxy.py b/tests/test_coupon_proxy.py new file mode 100644 index 0000000..41033e9 --- /dev/null +++ b/tests/test_coupon_proxy.py @@ -0,0 +1,143 @@ +"""/api/v1/coupon/step 透传端点测试。 + +mock 掉对 pricebot 的 httpx 调用,验证: + 1. 无 token → 401 + 2. 带 token + pricebot 200 → 响应原样透传,请求 body 原样转发 + 3. pricebot 5xx → 502 + 4. pricebot 网络错误 → 502 +""" +from __future__ import annotations + +import time +from unittest.mock import MagicMock, patch + +import httpx +import pytest + + +def _stub_screen_state() -> dict: + """最简的 screen_state(领券业务用,跟外卖/电商同结构)。""" + return { + "screen": {"width": 1080, "height": 2340, "density": 3.0}, + "foreground": {"package": "x", "activity": ""}, + "windows": [], + } + + +def _stub_request_body() -> dict: + return { + "device_id": "test-device", + "trace_id": "test-trace-1", + "step": 0, + "screen_state": _stub_screen_state(), + } + + +@pytest.fixture() +def access_token(client) -> str: + """sms 登录拿一个 access_token。每次新 phone 避免冷却 429。""" + phone = f"139{int(time.time() * 1000) % 100000000:08d}" + r = client.post("/api/v1/auth/sms/send", json={"phone": phone}) + assert r.status_code == 200, r.text + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + 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_passes_body_through(client, access_token) -> None: + """带 token + pricebot 200 → 响应原样透传,请求 body 原样转发到 /api/coupon/step。""" + fake_pricebot_resp = { + "success": True, + "action": { + "command": "launch", + "params": {"app": "美团", "deeplink": "imeituan://test"}, + }, + "continue": True, + "status": { + "platform": "coupon", + "progress": { + "current": 1, + "total": 2, + "current_coupon_id": "mt_banjia", + "current_coupon_name": "美团半价周末", + }, + }, + } + + 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_pricebot_resp + return mock_resp + + with patch.object(httpx.AsyncClient, "post", fake_post): + r = client.post( + "/api/v1/coupon/step", + headers={"Authorization": f"Bearer {access_token}"}, + json=_stub_request_body(), + ) + + assert r.status_code == 200, r.text + assert r.json() == fake_pricebot_resp + # 验证请求被原样转发(body 不动 + URL 指向 pricebot) + assert captured["json"] == _stub_request_body() + assert captured["url"].endswith("/api/coupon/step") + + +def test_coupon_step_pricebot_5xx_returns_502(client, access_token) -> None: + """pricebot 返 503 → 我们返 502。""" + 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( + "/api/v1/coupon/step", + headers={"Authorization": f"Bearer {access_token}"}, + json=_stub_request_body(), + ) + + assert r.status_code == 502 + assert "pricebot upstream returned 503" in r.json()["detail"] + + +def test_coupon_step_pricebot_unreachable_returns_502(client, access_token) -> None: + """pricebot 网络不可达 → 502。""" + 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( + "/api/v1/coupon/step", + headers={"Authorization": f"Bearer {access_token}"}, + json=_stub_request_body(), + ) + + assert r.status_code == 502 + assert "pricebot upstream unreachable" in r.json()["detail"] + + +def test_coupon_step_invalid_json_body(client, access_token) -> None: + """非 JSON body → 400(在转发前就拒绝)。""" + r = client.post( + "/api/v1/coupon/step", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + content=b"not-json", + ) + assert r.status_code == 400 + assert "invalid json body" in r.json()["detail"]