Init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API routing package."""
|
||||
@@ -0,0 +1,41 @@
|
||||
"""FastAPI 依赖注入:提供容器获取和鉴权校验"""
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from app.core.container import ServiceContainer
|
||||
from app.core.errors import AuthenticationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_container(request: Request) -> ServiceContainer:
|
||||
"""从请求中获取服务容器"""
|
||||
return request.app.state.container
|
||||
|
||||
|
||||
def require_bearer_token(
|
||||
request: Request,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
) -> None:
|
||||
"""Bearer Token 鉴权依赖
|
||||
|
||||
从请求头 Authorization 中提取 Bearer Token,
|
||||
与服务端配置的 token 做安全比较(使用 secrets.compare_digest 防止时序攻击)。
|
||||
"""
|
||||
authorization = request.headers.get("Authorization")
|
||||
if not authorization:
|
||||
logger.warning("鉴权失败:缺少 Authorization 请求头")
|
||||
raise AuthenticationError("Missing Authorization header")
|
||||
|
||||
token = authorization.replace("Bearer ", "", 1).strip()
|
||||
if token == authorization:
|
||||
logger.warning("鉴权失败:Authorization scheme 非 Bearer")
|
||||
raise AuthenticationError("Invalid Authorization scheme")
|
||||
if not token:
|
||||
logger.warning("鉴权失败:Bearer token 为空")
|
||||
raise AuthenticationError("Invalid token")
|
||||
|
||||
if not secrets.compare_digest(token, container.settings.bearer_token):
|
||||
logger.warning("鉴权失败:token 不匹配")
|
||||
raise AuthenticationError("Invalid token")
|
||||
@@ -0,0 +1 @@
|
||||
"""API route modules."""
|
||||
@@ -0,0 +1,31 @@
|
||||
"""健康检查接口:返回服务状态和浏览器池信息"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.dependencies import get_container, require_bearer_token
|
||||
from app.core.container import ServiceContainer
|
||||
from app.models.scrape import ApiResponse, HealthData
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=ApiResponse[HealthData], dependencies=[Depends(require_bearer_token)])
|
||||
async def health(container: ServiceContainer = Depends(get_container)) -> ApiResponse[HealthData]:
|
||||
"""健康检查接口
|
||||
|
||||
返回浏览器池状态、Redis 连接状态和当前活跃会话等信息。
|
||||
"""
|
||||
session = await container.session_store.get_session("www.suruga-ya.jp:search")
|
||||
data = HealthData(
|
||||
status="ok" if container.browser_pool.ready else "degraded",
|
||||
browser_enabled=container.settings.browser_enabled,
|
||||
browser_ready=container.browser_pool.ready,
|
||||
browser_startup_error=container.browser_pool.startup_error,
|
||||
redis_enabled=container.session_store.redis_enabled,
|
||||
active_session_id=session.session_id if session else None,
|
||||
)
|
||||
return ApiResponse[HealthData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=data,
|
||||
code=0,
|
||||
)
|
||||
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.dependencies import get_container, require_bearer_token
|
||||
from app.core.container import ServiceContainer
|
||||
from app.models.scrape import (
|
||||
ApiResponse,
|
||||
CategoryData,
|
||||
CheckTaskCallbackUrlData,
|
||||
CheckTaskCallbackUrlRequest,
|
||||
DetailRequest,
|
||||
OrderShippingFeeData,
|
||||
OrderShippingFeeRequest,
|
||||
ProductDetailData,
|
||||
PurchaseTaskRequest,
|
||||
SearchRequest,
|
||||
SearchResultData, TradeMonitorRequest,
|
||||
)
|
||||
from app.utils.http_utils import generate_signed_headers
|
||||
from app.utils.parse_util import get_handle_fee, get_shipping_fee
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["scrape"])
|
||||
|
||||
|
||||
def _parse_callback_response_body(content_type: str, body_bytes: bytes) -> Any:
|
||||
if not body_bytes:
|
||||
return None
|
||||
|
||||
body_text = body_bytes.decode("utf-8", errors="replace")
|
||||
if "application/json" not in content_type.lower():
|
||||
return body_text
|
||||
|
||||
try:
|
||||
return json.loads(body_text)
|
||||
except json.JSONDecodeError:
|
||||
return body_text
|
||||
|
||||
|
||||
def _post_callback_request(
|
||||
callback_url: str,
|
||||
request_body: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
) -> tuple[int, dict[str, str], Any]:
|
||||
body_bytes = json.dumps(request_body, ensure_ascii=False).encode("utf-8")
|
||||
http_request = request.Request(
|
||||
url=callback_url,
|
||||
data=body_bytes,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with request.urlopen(http_request, timeout=15) as response:
|
||||
response_body = response.read()
|
||||
response_headers = dict(response.headers.items())
|
||||
content_type = response_headers.get("Content-Type", "")
|
||||
return response.status, response_headers, _parse_callback_response_body(content_type, response_body)
|
||||
except error.HTTPError as exc:
|
||||
response_body = exc.read()
|
||||
response_headers = dict(exc.headers.items()) if exc.headers is not None else {}
|
||||
content_type = response_headers.get("Content-Type", "")
|
||||
return exc.code, response_headers, _parse_callback_response_body(content_type, response_body)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/search",
|
||||
response_model=ApiResponse[SearchResultData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def search(
|
||||
payload: SearchRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
) -> ApiResponse[SearchResultData]:
|
||||
"""搜索商品列表 Authorization Bearer NilsO8Il8yoT1wazJFs2eeOmlPRfWuSx
|
||||
|
||||
根据关键词和页码抓取骏河屋搜索结果,返回商品列表、总数和分页信息。
|
||||
"""
|
||||
data = await container.surugaya_client.search(payload)
|
||||
return ApiResponse[SearchResultData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=data,
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item_detail",
|
||||
response_model=ApiResponse[ProductDetailData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def item_detail(
|
||||
payload: DetailRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
) -> ApiResponse[ProductDetailData]:
|
||||
"""获取商品详情
|
||||
|
||||
根据商品 ID 或 URL 抓取骏河屋商品详情页,返回名称、价格、图片等信息。
|
||||
"""
|
||||
data = await container.surugaya_client.fetch_detail(payload)
|
||||
return ApiResponse[ProductDetailData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=data,
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/categories",
|
||||
response_model=ApiResponse[list[CategoryData]],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def categories(
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
) -> ApiResponse[list[CategoryData]]:
|
||||
"""获取商品分类
|
||||
|
||||
获取骏河屋商品分类列表,包含分类名称和分类 ID。数据带有缓存。
|
||||
"""
|
||||
data = await container.surugaya_client.fetch_categories()
|
||||
return ApiResponse[list[CategoryData]](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=data,
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/order/get_shipping_fee",
|
||||
response_model=ApiResponse[OrderShippingFeeData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def order_get_shipping_fee(payload: OrderShippingFeeRequest) -> ApiResponse[OrderShippingFeeData]:
|
||||
goods_amount = sum(item.goods_number * item.goods_price for item in payload.item_list)
|
||||
shipping_fee = get_shipping_fee(goods_amount)
|
||||
handle_amount = get_handle_fee(goods_amount)
|
||||
order_amount = goods_amount + shipping_fee + handle_amount
|
||||
|
||||
return ApiResponse[OrderShippingFeeData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=OrderShippingFeeData(
|
||||
goods_amount=goods_amount,
|
||||
shipping_fee=shipping_fee,
|
||||
order_amount=order_amount,
|
||||
handle_amount=handle_amount,
|
||||
),
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/order/add_purchase_task",
|
||||
response_model=ApiResponse[None],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def order_add_purchase_task(
|
||||
payload: PurchaseTaskRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
) -> ApiResponse[None]:
|
||||
"""添加采购任务
|
||||
|
||||
将采购任务加入 Redis Stream 队列。
|
||||
"""
|
||||
if container.session_store._redis is None:
|
||||
return ApiResponse[None](
|
||||
success=False,
|
||||
msg="Redis 未配置",
|
||||
data=None,
|
||||
code=500,
|
||||
)
|
||||
|
||||
stream_name = "jp_surugaya_purchase_task_dev" if payload.is_dev == "1" else "jp_surugaya_purchase_task"
|
||||
params_str = payload.model_dump_json(by_alias=True)
|
||||
|
||||
await container.session_store._redis.xadd(
|
||||
name=stream_name,
|
||||
fields={"payload": params_str},
|
||||
maxlen=100,
|
||||
approximate=True
|
||||
)
|
||||
|
||||
return ApiResponse[None](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=None,
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/order/add_trade_monitor",
|
||||
response_model=ApiResponse[None],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def order_add_trade_monitor(
|
||||
payload: TradeMonitorRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
) -> ApiResponse[None]:
|
||||
"""添加采购监控
|
||||
|
||||
将采购监控加入 Redis Stream 队列。
|
||||
"""
|
||||
if container.session_store._redis is None:
|
||||
return ApiResponse[None](
|
||||
success=False,
|
||||
msg="Redis 未配置",
|
||||
data=None,
|
||||
code=500,
|
||||
)
|
||||
|
||||
node_id = payload.node_id
|
||||
if node_id is None:
|
||||
return ApiResponse[None](
|
||||
success=False,
|
||||
msg="node_id 不能为空",
|
||||
data=None,
|
||||
code=400,
|
||||
)
|
||||
|
||||
trade_code = payload.payload.get("trade_code")
|
||||
if not trade_code or trade_code == "":
|
||||
return ApiResponse[None](
|
||||
success=False,
|
||||
msg="trade_code 不能为空",
|
||||
data=None,
|
||||
code=400,
|
||||
)
|
||||
|
||||
queue_name = "jp_surugaya_trade_delay_dev" if payload.is_dev == "1" else "jp_surugaya_trade_delay"
|
||||
queue_name += f":{node_id}"
|
||||
|
||||
score = int(time.time()) + 3
|
||||
member = json.dumps(payload.payload)
|
||||
await container.session_store._redis.zadd(
|
||||
name=queue_name,
|
||||
mapping={member: score}
|
||||
)
|
||||
|
||||
return ApiResponse[None](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=None,
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/trade",
|
||||
response_model=ApiResponse[dict[str, Any]],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def trade(
|
||||
trade_code: str,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
) -> ApiResponse[dict[str, Any]]:
|
||||
"""
|
||||
获取订单详情
|
||||
"""
|
||||
if not trade_code or trade_code.strip() == "":
|
||||
return ApiResponse[dict[str, Any]](
|
||||
success=False,
|
||||
msg="trade_code 不能为空",
|
||||
data=None,
|
||||
code=400,
|
||||
)
|
||||
|
||||
if container.session_store._redis is None:
|
||||
return ApiResponse[dict[str, Any]](
|
||||
success=False,
|
||||
msg="Redis 未配置",
|
||||
data=None,
|
||||
code=500,
|
||||
)
|
||||
|
||||
trade_data = await container.session_store._redis.hget(
|
||||
name="jp_surugaya_trade",
|
||||
key=trade_code,
|
||||
)
|
||||
|
||||
if not trade_data:
|
||||
return ApiResponse[dict[str, Any]](
|
||||
success=False,
|
||||
msg="未找到该交易",
|
||||
data=None,
|
||||
code=404,
|
||||
)
|
||||
|
||||
return ApiResponse[dict[str, Any]](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=json.loads(trade_data),
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post(
|
||||
"/test/check_task_callback_url",
|
||||
response_model=ApiResponse[CheckTaskCallbackUrlData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def check_task_callback_url(
|
||||
payload: CheckTaskCallbackUrlRequest,
|
||||
) -> ApiResponse[CheckTaskCallbackUrlData]:
|
||||
"""测试回调地址可用性并返回回调响应。"""
|
||||
request_body = payload.data
|
||||
headers = generate_signed_headers(
|
||||
app_id=payload.app_id,
|
||||
app_secret=payload.app_secret,
|
||||
data=request_body,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
response_status, response_headers, response_body = await asyncio.to_thread(
|
||||
_post_callback_request,
|
||||
str(payload.callback_url),
|
||||
request_body,
|
||||
headers,
|
||||
)
|
||||
except error.URLError as exc:
|
||||
return ApiResponse[CheckTaskCallbackUrlData](
|
||||
success=False,
|
||||
msg=f"回调请求失败: {exc.reason}",
|
||||
data=None,
|
||||
code=500,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ApiResponse[CheckTaskCallbackUrlData](
|
||||
success=False,
|
||||
msg=f"回调请求异常: {exc}",
|
||||
data=None,
|
||||
code=500,
|
||||
)
|
||||
|
||||
success = 200 <= response_status < 300
|
||||
return ApiResponse[CheckTaskCallbackUrlData](
|
||||
success=success,
|
||||
msg="调用正常" if success else "回调请求有异常",
|
||||
data=CheckTaskCallbackUrlData(
|
||||
response_status=response_status,
|
||||
response_body=response_body,
|
||||
),
|
||||
code=0 if success else response_status,
|
||||
)
|
||||
Reference in New Issue
Block a user