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) <noreply@anthropic.com>
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user