From be799abfa032b527acbcb3442836e888d38edd56 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Mon, 31 Aug 2026 22:01:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(intraday):=20since=20=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E6=97=B6=E5=8C=BA=E5=B4=A9=E6=BA=83=20=E2=80=94=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E7=9B=B4=E4=BC=A0=E5=8E=9F=E5=A7=8B=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E4=B8=B2,=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=BD=92?= =?UTF-8?q?=E4=B8=80=20aware=20=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户端 toISOString 生成带 Z 的 UTC, Python 3.12 解析为 aware datetime, 与行内 naive 北京时间比较 TypeError (第二轮轮询 500), 且换算差 8 小时。 - 客户端: since 直接回传最旧最后一根的原始 datetime 字符串 (同格式同时区, 字典序即时间序), 消灭一切换算 - 服务端: fromisoformat 后 aware 输入先转北京墙钟再去 tzinfo, 防御旧客户端 --- backend/app/api/kline.py | 5 +++++ backend/tests/test_minute_routing.py | 8 ++++++++ frontend/src/lib/minuteBatchIncremental.ts | 15 +++++++-------- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 9a5564e..0379a6b 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -5,6 +5,7 @@ import logging import math from datetime import date, timedelta from pathlib import Path +from zoneinfo import ZoneInfo from functools import lru_cache from typing import Optional @@ -590,6 +591,10 @@ def get_minute_batch(request: Request, body: dict): if since_str: try: since_dt = datetime.fromisoformat(str(since_str)) + # 防御: 带 Z/偏移的 aware 输入 (如 toISOString) → 转北京墙钟再去 tz, + # 否则与行里的 naive 北京时间比较会 TypeError 且差 8 小时 + if since_dt.tzinfo is not None: + since_dt = since_dt.astimezone(ZoneInfo("Asia/Shanghai")).replace(tzinfo=None) except ValueError: since_dt = None # 自选分时本地优先标志: 全量分钟服务健康时, 股票缺口不再批量补拉 diff --git a/backend/tests/test_minute_routing.py b/backend/tests/test_minute_routing.py index e5d635d..5e4f399 100644 --- a/backend/tests/test_minute_routing.py +++ b/backend/tests/test_minute_routing.py @@ -440,6 +440,14 @@ def test_minute_batch_since_returns_only_new_bars(monkeypatch): assert plain["incremental"] is False assert len(plain["data"]["600519.SH"]) == 240 + # 防御: 带 Z 的 UTC aware 输入 (toISOString 客户端) → 归一为北京 naive 再比, + # 不抛 TypeError 且语义正确 (15:00 北京 = 07:00 UTC) + utc = kline_api.get_minute_batch(req, { + "symbols": ["600519.SH"], "date": "2026-01-15", + "since": "2026-01-15T07:00:00Z", + }) + assert [r["datetime"] for r in utc["data"]["600519.SH"]] == [datetime(2026, 1, 15, 15, 0)] + # ---------- 测试 10: sync_minute_batch 自定义源成功时调 on_segment (Issue 1) ---------- diff --git a/frontend/src/lib/minuteBatchIncremental.ts b/frontend/src/lib/minuteBatchIncremental.ts index f7330d4..1c4bfb7 100644 --- a/frontend/src/lib/minuteBatchIncremental.ts +++ b/frontend/src/lib/minuteBatchIncremental.ts @@ -10,17 +10,17 @@ import { api, type MinuteKlineRow } from '@/lib/api' type MinuteBatchData = Record -function lastBarTs(data: MinuteBatchData, symbols?: string[]): number | null { - // since 只按本轮请求的 symbol 取最旧最后一根: 视口感知下不可见 symbol 可能 - // 落后很多分钟, 把它们计入会把整个批量窗口拉大重拉 +function lastBarTs(data: MinuteBatchData, symbols?: string[]): string | null { + // 直接取"最旧最后一根"的原始 datetime 字符串 (与服务端行同格式, 同为北京墙钟): + // 不做任何 Date/ISO 转换 — toISOString 会变成带 Z 的 UTC, 服务端 naive 比较 + // 会 TypeError, 且换算差 8 小时。固定格式字符串的字典序即时间序。 const scope = symbols ? new Set(symbols) : null - let min: number | null = null + let min: string | null = null for (const [sym, rows] of Object.entries(data)) { if (scope && !scope.has(sym)) continue const last = rows[rows.length - 1] if (!last) continue - const t = new Date(last.datetime).getTime() - if (Number.isFinite(t) && (min === null || t < min)) min = t + if (min === null || last.datetime < min) min = last.datetime } return min } @@ -45,8 +45,7 @@ export async function fetchMinuteBatchIncremental( preferLocal?: boolean, ): Promise<{ data: MinuteBatchData }> { const prev = qc.getQueryData<{ data: MinuteBatchData }>(cacheKey)?.data - const minTs = prev ? lastBarTs(prev, symbols) : null - const since = minTs !== null ? new Date(minTs).toISOString() : undefined + const since = prev ? lastBarTs(prev, symbols) ?? undefined : undefined const resp = await api.klineMinuteBatch(symbols, undefined, preferLocal, since) if (!since || !resp.incremental) return { data: resp.data ?? {} } return { data: mergeInto(prev ?? {}, resp.data ?? {}) }