通知
This commit is contained in:
+2
-2
@@ -38,8 +38,8 @@ SURUGAYA_PROXY_USERNAME=
|
|||||||
# 代理密码(如代理需要认证则填写)
|
# 代理密码(如代理需要认证则填写)
|
||||||
SURUGAYA_PROXY_PASSWORD=
|
SURUGAYA_PROXY_PASSWORD=
|
||||||
|
|
||||||
# Redis(可选;用于跨进程/跨实例复用会话与限流状态)
|
# Redis(可选;用于跨进程/跨实例复用会话与限流状态;并为 /api/news 读取 worker 抓取的通知)
|
||||||
# 留空表示不使用 Redis(退回进程内存)
|
# 留空表示不使用 Redis(退回进程内存,且 /api/news 不可用)
|
||||||
SURUGAYA_REDIS_HOST=
|
SURUGAYA_REDIS_HOST=
|
||||||
SURUGAYA_REDIS_PORT=6379
|
SURUGAYA_REDIS_PORT=6379
|
||||||
SURUGAYA_REDIS_PASSWORD=
|
SURUGAYA_REDIS_PASSWORD=
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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.health import router as health_router
|
||||||
from app.api.routes.scrape import router as scrape_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.config import get_settings
|
||||||
from app.core.container import ServiceContainer
|
from app.core.container import ServiceContainer
|
||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
@@ -90,6 +91,7 @@ def create_app() -> FastAPI:
|
|||||||
app = FastAPI(title="Surugaya Scraper Service", lifespan=lifespan)
|
app = FastAPI(title="Surugaya Scraper Service", lifespan=lifespan)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(scrape_router)
|
app.include_router(scrape_router)
|
||||||
|
app.include_router(news_router)
|
||||||
|
|
||||||
@app.exception_handler(AppError)
|
@app.exception_handler(AppError)
|
||||||
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
||||||
|
|||||||
@@ -207,3 +207,17 @@ class CheckTaskCallbackUrlData(BaseModel):
|
|||||||
"""测试回调地址响应数据"""
|
"""测试回调地址响应数据"""
|
||||||
response_status: int
|
response_status: int
|
||||||
response_body: Any
|
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 hash : jp_surugaya_trade
|
||||||
trade pending dedup : jp_surugaya_trade_pending:{trade_code}
|
trade pending dedup : jp_surugaya_trade_pending:{trade_code}
|
||||||
trade fetch stream : jp_surugaya_trade_fetch
|
trade fetch stream : jp_surugaya_trade_fetch
|
||||||
|
news hash : jp_surugaya_news[_dev]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,6 +22,7 @@ TRADE_DELAY_ZSET: str = "jp_surugaya_trade_delay"
|
|||||||
TRADE_HASH: str = "jp_surugaya_trade"
|
TRADE_HASH: str = "jp_surugaya_trade"
|
||||||
TRADE_PENDING_PREFIX: str = "jp_surugaya_trade_pending"
|
TRADE_PENDING_PREFIX: str = "jp_surugaya_trade_pending"
|
||||||
TRADE_FETCH_STREAM: str = "jp_surugaya_trade_fetch"
|
TRADE_FETCH_STREAM: str = "jp_surugaya_trade_fetch"
|
||||||
|
NEWS_HASH: str = "jp_surugaya_news"
|
||||||
|
|
||||||
# Suffix appended to dev-mode keys.
|
# Suffix appended to dev-mode keys.
|
||||||
_DEV_SUFFIX: str = "_dev"
|
_DEV_SUFFIX: str = "_dev"
|
||||||
@@ -78,3 +80,14 @@ def trade_pending_key(trade_code: object) -> str:
|
|||||||
def trade_fetch_stream() -> str:
|
def trade_fetch_stream() -> str:
|
||||||
"""Redis Stream key for trade fetch tasks."""
|
"""Redis Stream key for trade fetch tasks."""
|
||||||
return TRADE_FETCH_STREAM
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user