diff --git a/backend/app/api/ext_data.py b/backend/app/api/ext_data.py index a076dd5..92c498d 100644 --- a/backend/app/api/ext_data.py +++ b/backend/app/api/ext_data.py @@ -16,6 +16,7 @@ import polars as pl from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile from pydantic import BaseModel, Field +from app.market_time import CN_TZ from app.services.ext_data import ( ExtConfig, ExtConfigStore, @@ -255,10 +256,14 @@ def _with_instrument_name(df: pl.DataFrame, data_dir: Path) -> pl.DataFrame: def _latest_sync_date(config: ExtConfig, data_dir: Path) -> str | None: - """扫描数据文件,返回该扩展配置的最新同步时间(含时分秒)。 + """扫描数据文件,返回该扩展配置的最新同步时间(北京墙钟, 含时分秒)。 - snapshot: 直接取 ext_data/{id}/part.parquet 的 mtime - timeseries: 扫描 ext_data/{id}/timeseries/date=xxx 分区目录 + + 用北京时间而非宿主机时钟: 前端 ExtDataStatCard 原样展示这串裸时间, + 容器默认 UTC 时会比同一页拉取面板里的 pull.last_run(带时区 ISO, + 浏览器按本地时区渲染)整整差一个时区。 """ from datetime import datetime @@ -266,7 +271,7 @@ def _latest_sync_date(config: ExtConfig, data_dir: Path) -> str | None: # 快照: part.parquet 与 config.json 同级 p = data_dir / "ext_data" / config.id / "part.parquet" if p.exists(): - ts = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") + ts = datetime.fromtimestamp(p.stat().st_mtime, tz=CN_TZ).strftime("%Y-%m-%d %H:%M:%S") return ts # 兼容旧路径 old = data_dir / "instruments_ext" @@ -285,7 +290,7 @@ def _latest_sync_date(config: ExtConfig, data_dir: Path) -> str | None: def _latest_sync_from_partitions(base: Path) -> str | None: - """从 date=xxx 分区目录中找到最新分区的修改时间。""" + """从 date=xxx 分区目录中找到最新分区的修改时间 (北京墙钟)。""" from datetime import datetime latest_ts: float = 0 latest_date: str | None = None @@ -297,7 +302,7 @@ def _latest_sync_from_partitions(base: Path) -> str | None: latest_ts = mtime latest_date = d.name[5:] if latest_date and latest_ts > 0: - ts = datetime.fromtimestamp(latest_ts).strftime("%H:%M:%S") + ts = datetime.fromtimestamp(latest_ts, tz=CN_TZ).strftime("%H:%M:%S") return f"{latest_date} {ts}" return latest_date diff --git a/backend/tests/test_ext_data_latest_sync_timezone.py b/backend/tests/test_ext_data_latest_sync_timezone.py new file mode 100644 index 0000000..3b37c7f --- /dev/null +++ b/backend/tests/test_ext_data_latest_sync_timezone.py @@ -0,0 +1,62 @@ +"""扩展数据卡片的「最新」时间必须是北京墙钟, 不随服务端时区漂移。 + +latest_sync_date 由 parquet 的 mtime 格式化而来, 前端 ExtDataStatCard 原样 +展示这串裸时间。用宿主机时钟格式化, 容器默认 UTC 时同一次同步在卡片上显示 +成 8 小时前, 而同一页拉取面板里的 pull.last_run(带时区的 ISO, 前端按浏览器 +时区渲染)显示的是正确时间 —— 一个页面两个时间对不上。 +""" +from __future__ import annotations + +import os +import types +from datetime import datetime + +import pytest + +from app.api.ext_data import _latest_sync_date +from app.market_time import CN_TZ + +# 固定 epoch 秒 = 北京时间 2026-09-04 22:13:20 +_MTIME = 1_788_531_200 + + +def _beijing(fmt: str) -> str: + return datetime.fromtimestamp(_MTIME, tz=CN_TZ).strftime(fmt) + + +def _touch(path, mtime: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"") + os.utime(path, (mtime, mtime)) + + +@pytest.fixture +def snapshot_config(): + return types.SimpleNamespace(id="ext_gn_ths", mode="snapshot") + + +@pytest.fixture +def timeseries_config(): + return types.SimpleNamespace(id="ext_ts_demo", mode="timeseries") + + +def test_snapshot_latest_sync_is_beijing_wall_clock(tmp_path, snapshot_config): + _touch(tmp_path / "ext_data" / snapshot_config.id / "part.parquet", _MTIME) + + got = _latest_sync_date(snapshot_config, tmp_path) + + assert got == _beijing("%Y-%m-%d %H:%M:%S") + + +def test_timeseries_latest_sync_is_beijing_wall_clock(tmp_path, timeseries_config): + base = tmp_path / "ext_data" / timeseries_config.id / "timeseries" + _touch(base / "date=2026-09-03" / "part.parquet", _MTIME - 86400) + _touch(base / "date=2026-09-04" / "part.parquet", _MTIME) + + got = _latest_sync_date(timeseries_config, tmp_path) + + assert got == f"2026-09-04 {_beijing('%H:%M:%S')}" + + +def test_latest_sync_is_none_without_any_data(tmp_path, snapshot_config): + assert _latest_sync_date(snapshot_config, tmp_path) is None