This commit is contained in:
2026-06-13 20:00:45 +08:00
parent 09f12c6c47
commit 2bcaab1626
6 changed files with 167 additions and 2 deletions
+2 -2
View File
@@ -38,8 +38,8 @@ SURUGAYA_PROXY_USERNAME=
# 代理密码(如代理需要认证则填写)
SURUGAYA_PROXY_PASSWORD=
# Redis(可选;用于跨进程/跨实例复用会话与限流状态)
# 留空表示不使用 Redis(退回进程内存)
# Redis(可选;用于跨进程/跨实例复用会话与限流状态;并为 /api/news 读取 worker 抓取的通知
# 留空表示不使用 Redis(退回进程内存,且 /api/news 不可用
SURUGAYA_REDIS_HOST=
SURUGAYA_REDIS_PORT=6379
SURUGAYA_REDIS_PASSWORD=
+72
View File
@@ -0,0 +1,72 @@
"""通知(お知らせ)路由:从 Redis 读取 worker 抓取的账号通知。
worker(pruchase_jhw)定时抓取骏河屋 action_news 页面,把每条通知按 data_id
作为 field 写入 Redis Hash(key 见 redis_keys.news_hash)。本路由读取该 Hash,
按 data_id 倒序(新→旧)返回,只读不删除。Redis 读取沿用 session_store 复用的连接,
与 scrape 路由保持一致。
"""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, Query
from app.api.dependencies import get_container, require_bearer_token
from app.core.container import ServiceContainer
from app.models.scrape import ApiResponse, NewsItemData
from surugaya_common import redis_keys
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["news"])
def _news_sort_key(item: dict[str, Any]) -> tuple[int, int]:
"""排序键:优先按 data_id 数值倒序,非数值退化到最小,保证排序稳定。"""
raw = str(item.get("data_id", "")).strip()
if raw.isdigit():
return (1, int(raw))
return (0, 0)
@router.get(
"/news",
response_model=ApiResponse[list[NewsItemData]],
dependencies=[Depends(require_bearer_token)],
)
async def list_news(
limit: int | None = Query(default=None, ge=1, le=500, description="返回条数上限,省略则返回全部"),
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[list[NewsItemData]]:
"""读取通知列表(按 data_id 倒序,新→旧),只读不删除。"""
if container.session_store._redis is None:
return ApiResponse[list[NewsItemData]](
success=False,
msg="Redis 未配置",
data=None,
code=500,
)
# dev/prod 由本服务 app_env 推导,需与 worker 写入端(APP_ENV)保持一致。
is_dev = "1" if container.settings.app_env == "dev" else "0"
raw_map = await container.session_store._redis.hgetall(redis_keys.news_hash(is_dev))
items: list[dict[str, Any]] = []
for data_id, raw_value in raw_map.items():
try:
items.append(json.loads(raw_value))
except (json.JSONDecodeError, TypeError):
logger.warning("通知 data_id=%s 的 JSON 解析失败,已跳过。", data_id)
items.sort(key=_news_sort_key, reverse=True)
if limit is not None and limit > 0:
items = items[:limit]
return ApiResponse[list[NewsItemData]](
success=True,
msg="success",
data=[NewsItemData(**item) for item in items],
code=0,
)
+2
View File
@@ -18,6 +18,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from app.api.routes.health import router as health_router
from app.api.routes.scrape import router as scrape_router
from app.api.routes.news import router as news_router
from app.core.config import get_settings
from app.core.container import ServiceContainer
from app.core.errors import AppError
@@ -90,6 +91,7 @@ def create_app() -> FastAPI:
app = FastAPI(title="Surugaya Scraper Service", lifespan=lifespan)
app.include_router(health_router)
app.include_router(scrape_router)
app.include_router(news_router)
@app.exception_handler(AppError)
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
+14
View File
@@ -207,3 +207,17 @@ class CheckTaskCallbackUrlData(BaseModel):
"""测试回调地址响应数据"""
response_status: int
response_body: Any
class NewsItemData(BaseModel):
"""单条「お知らせ」通知,由 worker 解析 action_news 页面后写入 Redis。"""
model_config = ConfigDict(extra="allow")
data_id: str # 站点侧通知唯一标识(单调递增)
date: str = "" # 通知日期,如 2026/06/12
message: str = "" # 通知文案,如 【販売】(取引番号:S2606115133)発送準備中です。
data_type: str = "" # 站点 data-type
data_url: str | None = None # 站点 data-url
trade_code: str | None = None # 从 data-url 解析的交易单号(非交易通知为 null)
account_id: str = "" # 该通知所属抓取账号
fetched_at: int = 0 # worker 抓取时间(epoch 秒)
@@ -10,6 +10,7 @@ Current literals (as of this extraction):
trade hash : jp_surugaya_trade
trade pending dedup : jp_surugaya_trade_pending:{trade_code}
trade fetch stream : jp_surugaya_trade_fetch
news hash : jp_surugaya_news[_dev]
"""
from __future__ import annotations
@@ -21,6 +22,7 @@ TRADE_DELAY_ZSET: str = "jp_surugaya_trade_delay"
TRADE_HASH: str = "jp_surugaya_trade"
TRADE_PENDING_PREFIX: str = "jp_surugaya_trade_pending"
TRADE_FETCH_STREAM: str = "jp_surugaya_trade_fetch"
NEWS_HASH: str = "jp_surugaya_news"
# Suffix appended to dev-mode keys.
_DEV_SUFFIX: str = "_dev"
@@ -78,3 +80,14 @@ def trade_pending_key(trade_code: object) -> str:
def trade_fetch_stream() -> str:
"""Redis Stream key for trade fetch tasks."""
return TRADE_FETCH_STREAM
def news_hash(is_dev: object = None) -> str:
"""Redis Hash key storing account notifications keyed by data_id.
Reproduces:
"jp_surugaya_news_dev" if is_dev == "1" else "jp_surugaya_news"
"""
if _is_dev(is_dev):
return f"{NEWS_HASH}{_DEV_SUFFIX}"
return NEWS_HASH
+64
View File
@@ -0,0 +1,64 @@
"""通知(お知らせ)读取链路测试:redis_keys 契约 + /api/news 路由排序/截断/降级。"""
from __future__ import annotations
import json
from types import SimpleNamespace
from app.api.routes.news import list_news
from app.core.config import Settings
from surugaya_common import redis_keys
class FakeRedis:
"""最小化异步 Redis 替身,仅实现 hgetall(值已 decode 为 str)。"""
def __init__(self, mapping: dict[str, str]):
self._mapping = mapping
async def hgetall(self, key: str) -> dict[str, str]:
return dict(self._mapping)
def _container(redis_obj, app_env: str = "prod") -> SimpleNamespace:
settings = Settings(_env_file=None, app_env=app_env)
session_store = SimpleNamespace(_redis=redis_obj)
return SimpleNamespace(settings=settings, session_store=session_store)
def _news_value(data_id: str, **extra) -> str:
return json.dumps({"data_id": data_id, "message": f"msg-{data_id}", **extra}, ensure_ascii=False)
class TestNewsHashKey:
def test_literals(self):
assert redis_keys.NEWS_HASH == "jp_surugaya_news"
assert redis_keys.news_hash() == "jp_surugaya_news"
assert redis_keys.news_hash("0") == "jp_surugaya_news"
assert redis_keys.news_hash("1") == "jp_surugaya_news_dev"
async def test_list_news_sorted_desc_by_data_id():
redis_obj = FakeRedis(
{"100": _news_value("100"), "300": _news_value("300", trade_code="S300"), "200": _news_value("200")}
)
resp = await list_news(limit=None, container=_container(redis_obj))
assert resp.success and resp.code == 0
assert [i.data_id for i in resp.data] == ["300", "200", "100"]
assert resp.data[0].trade_code == "S300"
async def test_list_news_limit():
redis_obj = FakeRedis({str(i): _news_value(str(i)) for i in range(1, 11)})
resp = await list_news(limit=3, container=_container(redis_obj))
assert [i.data_id for i in resp.data] == ["10", "9", "8"]
async def test_list_news_skips_bad_json():
redis_obj = FakeRedis({"1": _news_value("1"), "2": "not-json"})
resp = await list_news(limit=None, container=_container(redis_obj))
assert [i.data_id for i in resp.data] == ["1"]
async def test_list_news_redis_not_configured():
resp = await list_news(limit=None, container=_container(None))
assert not resp.success and resp.code == 500 and resp.data is None