This commit is contained in:
2026-06-08 16:09:13 +08:00
commit 518c6b3c7a
32 changed files with 4156 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""API routing package."""
+41
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""API route modules."""
+31
View File
@@ -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,
)
+354
View File
@@ -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,
)