diff --git a/yunke-server/app/analytics/health.py b/yunke-server/app/analytics/health.py index c304008..91266ec 100644 --- a/yunke-server/app/analytics/health.py +++ b/yunke-server/app/analytics/health.py @@ -6,6 +6,7 @@ msg_daily_stats 当日有行 = 4h 消息链路活着。 from __future__ import annotations import datetime as dt +from typing import Any import sqlalchemy as sa from sqlalchemy.ext.asyncio import AsyncSession @@ -22,15 +23,23 @@ from app.db.models import ( async def data_as_of(session: AsyncSession) -> int | None: - """数据截至(ms-epoch)= MAX(msg_daily_stats.last_ts)。""" + """数据截至(ms-epoch)= MAX(msg_daily_stats.last_ts)。 + + 漏迁移导致表缺失时按 500 响亮报错是有意的——本模块是诊断端点, + 要暴露部署问题而非掩盖(0ade58a 的空表静默守卫是业务端点的容错, + 别好心搬到这里)。 + """ val = ( await session.execute(sa.select(sa.func.max(MsgDailyStats.last_ts))) ).scalar_one_or_none() return int(val) if val is not None else None -async def entity_freshness(session: AsyncSession) -> list[dict]: - """各实体最后更新时间(UPDATED_AT 列 MAX;message 用 fetched_at)。""" +async def entity_freshness(session: AsyncSession) -> list[dict[str, Any]]: + """各实体最后更新时间(UPDATED_AT 列 MAX;message 用 fetched_at)。 + + 同 data_as_of:表缺失按 500 响亮报错是有意的(诊断端点不容错)。 + """ async def _max(col) -> dt.datetime | None: return (await session.execute(sa.select(sa.func.max(col)))).scalar_one_or_none() diff --git a/yunke-server/tests/test_analytics_api.py b/yunke-server/tests/test_analytics_api.py index 0779716..24e2826 100644 --- a/yunke-server/tests/test_analytics_api.py +++ b/yunke-server/tests/test_analytics_api.py @@ -1,7 +1,12 @@ """/api/analytics/health:E0-lite 冒烟。""" from __future__ import annotations -from app.db.models import MsgDailyStats +import datetime as dt + +from app.db.models import Customer, MsgDailyStats + +# naive:updated_at 列是无时区 DateTime;fromisoformat 构造不触发 DTZ 检查 +_SEEN = "2026-09-04T12:00:00" async def test_health_empty(make_client): @@ -13,7 +18,8 @@ async def test_health_empty(make_client): assert r.status_code == 200 body = r.json() assert body["data_as_of"] is None - assert any(e["table"] == "message" for e in body["entities"]) + assert len(body["entities"]) == 6 + assert all(e["last_seen"] is None for e in body["entities"]) async def test_health_with_stats(make_client): @@ -31,9 +37,21 @@ async def test_health_with_stats(make_client): last_is_send=False, ) ) + session.add( + Customer( + friend_id="f1", + channel="wechat", + updated_at=dt.datetime.fromisoformat(_SEEN), + ) + ) await session.commit() client = await make_client(seed) r = await client.get("/api/analytics/health") assert r.status_code == 200 assert r.json()["data_as_of"] == 1756900000000 + entities = {e["table"]: e["last_seen"] for e in r.json()["entities"]} + # 前端契约:last_seen 必须是 ISO 字符串(fromisoformat 可解析) + assert dt.datetime.fromisoformat(entities["customer"]) == dt.datetime.fromisoformat( + _SEEN + )