mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
feat(net): 分时/日K批量响应可选 gzip 压缩与网络设置
- _gzip_payload: 偏好开启 + Accept-Encoding 协商 + 超 1KB 才压 (level 6); datetime 序列化与非压缩路径一致, 保证 since 增量 字符串字典序合并不受影响 - minute-batch / daily-batch 端点各自独立偏好键, 逐请求即时生效; 实测分时 9.1MB→1.28MB (86%), 日K 736KB→156KB (79%) - 超时设置 tab 更名网络设置, 卡内新增压缩区: 大开关 OR 联动 (任一子开即亮, 全关才灭, 点击全开/全关) + 分时/日K两个子开关 - preferences.save() 加线程锁: 修复并行 PUT read-modify-write 互相覆盖 (总开关批量关不齐的根因), 存量偏好写入一并受益 - 测试: 双端点压缩/关闭直通/无协商头直通/偏好默认 + 并行 save 竞态
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
"""K 线 / 同步 API。"""
|
"""K 线 / 同步 API。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
@@ -9,7 +11,7 @@ from zoneinfo import ZoneInfo
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request, Response
|
||||||
|
|
||||||
from app.indicators.pipeline import compute_enriched, compute_enriched_single
|
from app.indicators.pipeline import compute_enriched, compute_enriched_single
|
||||||
from app.market_time import cn_now, cn_today, in_continuous_session
|
from app.market_time import cn_now, cn_today, in_continuous_session
|
||||||
@@ -22,6 +24,41 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/kline", tags=["kline"])
|
router = APIRouter(prefix="/api/kline", tags=["kline"])
|
||||||
|
|
||||||
|
|
||||||
|
def _gzip_payload(request: Request, payload: dict, *, pref_key: str) -> dict | Response:
|
||||||
|
"""大 JSON 响应的传输压缩: 偏好开启 + 客户端接受 gzip + 响应超阈值才压。
|
||||||
|
|
||||||
|
分时/日K批量各自独立偏好键 (网络设置里大开关批量、子开关单独控制)。
|
||||||
|
level 6 实测 13MB ≈ 290ms CPU 压掉 87%; level 9 要 2.5s 不可用。
|
||||||
|
datetime → isoformat, 与 FastAPI jsonable_encoder 输出一致
|
||||||
|
(前端 since 增量按字符串字典序比较, 格式必须与非压缩路径相同)。
|
||||||
|
"""
|
||||||
|
from app.services import preferences as _prefs
|
||||||
|
_getters = {
|
||||||
|
"minute_batch_compress": _prefs.get_minute_batch_compress,
|
||||||
|
"daily_batch_compress": _prefs.get_daily_batch_compress,
|
||||||
|
}
|
||||||
|
getter = _getters.get(pref_key)
|
||||||
|
compress_on = False
|
||||||
|
if getter is not None:
|
||||||
|
try:
|
||||||
|
compress_on = bool(getter())
|
||||||
|
except Exception: # 偏好读取异常按不压缩返回原样
|
||||||
|
compress_on = False
|
||||||
|
headers = getattr(request, "headers", None) or {}
|
||||||
|
if compress_on and "gzip" in (headers.get("accept-encoding") or ""):
|
||||||
|
raw = json.dumps(
|
||||||
|
payload, ensure_ascii=False, separators=(",", ":"), allow_nan=True,
|
||||||
|
default=lambda o: o.isoformat() if hasattr(o, "isoformat") else str(o),
|
||||||
|
).encode()
|
||||||
|
if len(raw) > 1024:
|
||||||
|
return Response(
|
||||||
|
content=gzip.compress(raw, 6),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding"},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _minute_allowed(capset) -> bool:
|
def _minute_allowed(capset) -> bool:
|
||||||
"""是否有分钟K权限 (TickFlow Pro+ 或 custom minute 源)。"""
|
"""是否有分钟K权限 (TickFlow Pro+ 或 custom minute 源)。"""
|
||||||
from app.tickflow.capabilities import Cap
|
from app.tickflow.capabilities import Cap
|
||||||
@@ -567,7 +604,8 @@ def get_daily_batch(request: Request, body: dict):
|
|||||||
if not sub.is_empty():
|
if not sub.is_empty():
|
||||||
result[sub["symbol"][0]] = sub.to_dicts()
|
result[sub["symbol"][0]] = sub.to_dicts()
|
||||||
|
|
||||||
return {"data": result}
|
# 日K批量同为大响应端点 (千只自选 MB 级), 与分时各自独立压缩开关
|
||||||
|
return _gzip_payload(request, {"data": result}, pref_key="daily_batch_compress")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/minute-batch")
|
@router.post("/minute-batch")
|
||||||
@@ -779,12 +817,15 @@ def get_minute_batch(request: Request, body: dict):
|
|||||||
}
|
}
|
||||||
result = {sym: rows for sym, rows in result.items() if rows}
|
result = {sym: rows for sym, rows in result.items() if rows}
|
||||||
|
|
||||||
# full_minute_local: 本轮 prefer_local 生效 (本地分区由全量分钟服务供给, 股票未做补拉)
|
return _gzip_payload(
|
||||||
return {
|
request,
|
||||||
"data": result,
|
{
|
||||||
"full_minute_local": full_minute_healthy,
|
"data": result,
|
||||||
"incremental": since_dt is not None,
|
"full_minute_local": full_minute_healthy,
|
||||||
}
|
"incremental": since_dt is not None,
|
||||||
|
},
|
||||||
|
pref_key="minute_batch_compress",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/minute-range")
|
@router.get("/minute-range")
|
||||||
|
|||||||
@@ -427,6 +427,14 @@ class DataSourceJobTimeoutPrefs(BaseModel):
|
|||||||
data_source_long_job_timeout_s: int = Field(ge=60)
|
data_source_long_job_timeout_s: int = Field(ge=60)
|
||||||
|
|
||||||
|
|
||||||
|
class MinuteBatchCompressPrefs(BaseModel):
|
||||||
|
minute_batch_compress: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DailyBatchCompressPrefs(BaseModel):
|
||||||
|
daily_batch_compress: bool
|
||||||
|
|
||||||
|
|
||||||
class DatasetFieldMapItem(BaseModel):
|
class DatasetFieldMapItem(BaseModel):
|
||||||
source: str
|
source: str
|
||||||
target: str
|
target: str
|
||||||
@@ -504,6 +512,8 @@ def get_preferences() -> dict:
|
|||||||
"financial_data_provider": preferences.get_financial_provider(),
|
"financial_data_provider": preferences.get_financial_provider(),
|
||||||
"data_source_job_timeout_s": preferences.get_data_source_job_timeout_s(),
|
"data_source_job_timeout_s": preferences.get_data_source_job_timeout_s(),
|
||||||
"data_source_long_job_timeout_s": preferences.get_data_source_long_job_timeout_s(),
|
"data_source_long_job_timeout_s": preferences.get_data_source_long_job_timeout_s(),
|
||||||
|
"minute_batch_compress": preferences.get_minute_batch_compress(),
|
||||||
|
"daily_batch_compress": preferences.get_daily_batch_compress(),
|
||||||
**preferences.get_realtime_quote_scope(),
|
**preferences.get_realtime_quote_scope(),
|
||||||
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
|
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
|
||||||
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
|
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
|
||||||
@@ -790,6 +800,22 @@ def update_data_source_job_timeouts(req: DataSourceJobTimeoutPrefs) -> dict:
|
|||||||
return req.model_dump()
|
return req.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences/minute-batch-compress")
|
||||||
|
def update_minute_batch_compress(req: MinuteBatchCompressPrefs) -> dict:
|
||||||
|
"""保存分时批量响应的 gzip 传输压缩开关。逐请求即时读取, 保存后立即生效。"""
|
||||||
|
from app.services import preferences
|
||||||
|
preferences.save({"minute_batch_compress": req.minute_batch_compress})
|
||||||
|
return {"minute_batch_compress": preferences.get_minute_batch_compress()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences/daily-batch-compress")
|
||||||
|
def update_daily_batch_compress(req: DailyBatchCompressPrefs) -> dict:
|
||||||
|
"""保存日K批量响应的 gzip 传输压缩开关 (与分时独立)。逐请求即时读取。"""
|
||||||
|
from app.services import preferences
|
||||||
|
preferences.save({"daily_batch_compress": req.daily_batch_compress})
|
||||||
|
return {"daily_batch_compress": preferences.get_daily_batch_compress()}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/mining-schedule")
|
@router.put("/preferences/mining-schedule")
|
||||||
def update_mining_schedule(req: MiningSchedulePrefs) -> dict:
|
def update_mining_schedule(req: MiningSchedulePrefs) -> dict:
|
||||||
"""一次更新周度自动 mining 配置。"""
|
"""一次更新周度自动 mining 配置。"""
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -54,14 +55,22 @@ def load() -> dict:
|
|||||||
return copy.deepcopy(_cache)
|
return copy.deepcopy(_cache)
|
||||||
|
|
||||||
|
|
||||||
|
_SAVE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def save(updates: dict) -> dict:
|
def save(updates: dict) -> dict:
|
||||||
"""合并写入。返回新内容。"""
|
"""合并写入。返回新内容。
|
||||||
current = load()
|
|
||||||
current.update(updates)
|
锁内 read-modify-write: FastAPI 同步端点跑线程池, 并行 PUT 各自基于旧快照
|
||||||
_path().write_text(
|
写盘会互相覆盖 (实测: 压缩总开关并行写分时/日K两键, 后写者把先写者覆盖)。
|
||||||
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
|
"""
|
||||||
)
|
with _SAVE_LOCK:
|
||||||
_invalidate_cache()
|
current = load()
|
||||||
|
current.update(updates)
|
||||||
|
_path().write_text(
|
||||||
|
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||||
|
)
|
||||||
|
_invalidate_cache()
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +247,20 @@ def get_data_source_long_job_timeout_s() -> int:
|
|||||||
return max(DATA_SOURCE_JOB_TIMEOUT_MIN_S, timeout_s)
|
return max(DATA_SOURCE_JOB_TIMEOUT_MIN_S, timeout_s)
|
||||||
|
|
||||||
|
|
||||||
|
def get_minute_batch_compress() -> bool:
|
||||||
|
"""分时批量响应是否启用 gzip 传输压缩。默认开启 (公网部署传输是大头);
|
||||||
|
本机/内网可关闭省服务端 CPU。每次请求即时读取, 开关保存后立即生效。
|
||||||
|
"""
|
||||||
|
raw = load().get("minute_batch_compress", True)
|
||||||
|
return bool(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def get_daily_batch_compress() -> bool:
|
||||||
|
"""日K批量响应是否启用 gzip 传输压缩 (与分时各自独立配置)。默认开启。"""
|
||||||
|
raw = load().get("daily_batch_compress", True)
|
||||||
|
return bool(raw)
|
||||||
|
|
||||||
|
|
||||||
def _allowed_data_providers() -> set[str]:
|
def _allowed_data_providers() -> set[str]:
|
||||||
try:
|
try:
|
||||||
from app.data_providers import custom as custom_sources
|
from app.data_providers import custom as custom_sources
|
||||||
|
|||||||
@@ -1011,3 +1011,173 @@ def test_minute_refresh_is_healthy_requires_recent_round(monkeypatch):
|
|||||||
monkeypatch.setattr(mr.preferences, "get_minute_refresh_enabled", lambda: False)
|
monkeypatch.setattr(mr.preferences, "get_minute_refresh_enabled", lambda: False)
|
||||||
svc._state.last_round_at = time_mod.time() - 10
|
svc._state.last_round_at = time_mod.time() - 10
|
||||||
assert svc.is_healthy() is False
|
assert svc.is_healthy() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 测试: 分时批量传输压缩 (网络设置开关) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_minute_batch_compress_preference_default_and_toggle(monkeypatch):
|
||||||
|
"""偏好默认开启; 关闭后 getter 立即反映 (逐请求读取, 无缓存)。"""
|
||||||
|
from app.services import preferences as prefs
|
||||||
|
|
||||||
|
monkeypatch.setattr(prefs, "load", lambda: {})
|
||||||
|
assert prefs.get_minute_batch_compress() is True
|
||||||
|
monkeypatch.setattr(prefs, "load", lambda: {"minute_batch_compress": False})
|
||||||
|
assert prefs.get_minute_batch_compress() is False
|
||||||
|
|
||||||
|
|
||||||
|
def _compress_mock_env(monkeypatch, *, compress_on, accept="gzip, deflate"):
|
||||||
|
"""构造 get_minute_batch 压缩路径的最小 mock 环境, 返回 (mock_request, sync_spy)。"""
|
||||||
|
from app.api import kline as kline_api
|
||||||
|
from app.services import preferences as prefs
|
||||||
|
|
||||||
|
monkeypatch.setattr(prefs, "get_minute_batch_compress", lambda: compress_on)
|
||||||
|
|
||||||
|
sync_spy = MagicMock(return_value=_mock_minute_df())
|
||||||
|
monkeypatch.setattr(kline_api.kline_sync, "sync_minute_batch", sync_spy)
|
||||||
|
|
||||||
|
mock_repo = MagicMock()
|
||||||
|
mock_repo.get_etf_symbol_set.return_value = set()
|
||||||
|
mock_repo.get_minute_batch.return_value = _mock_minute_rows("600519.SH", 100)
|
||||||
|
|
||||||
|
mock_capset = MagicMock()
|
||||||
|
mock_capset.has.return_value = True
|
||||||
|
mock_capset.limits.return_value = None
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.app.state.repo = mock_repo
|
||||||
|
mock_request.app.state.capabilities = mock_capset
|
||||||
|
mock_request.headers = {"accept-encoding": accept} if accept else {}
|
||||||
|
return mock_request, sync_spy
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_minute_batch_gzip_response_when_enabled(monkeypatch):
|
||||||
|
"""开关开 + 客户端接受 gzip + 响应超阈值 → 返回 gzip Response, 解压后 JSON 完整。"""
|
||||||
|
import gzip as gzip_mod
|
||||||
|
from app.api import kline as kline_api
|
||||||
|
from fastapi import Response
|
||||||
|
|
||||||
|
mock_request, _ = _compress_mock_env(monkeypatch, compress_on=True)
|
||||||
|
result = kline_api.get_minute_batch(
|
||||||
|
mock_request, {"symbols": ["600519.SH"], "date": "2026-01-15"}
|
||||||
|
)
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert result.headers["content-encoding"] == "gzip"
|
||||||
|
import json as json_mod
|
||||||
|
payload = json_mod.loads(gzip_mod.decompress(result.body))
|
||||||
|
assert payload["full_minute_local"] is False
|
||||||
|
assert len(payload["data"]["600519.SH"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_minute_batch_plain_when_disabled(monkeypatch):
|
||||||
|
"""开关关 → 恒返回普通 dict, 不做压缩。"""
|
||||||
|
from app.api import kline as kline_api
|
||||||
|
|
||||||
|
mock_request, _ = _compress_mock_env(monkeypatch, compress_on=False)
|
||||||
|
result = kline_api.get_minute_batch(
|
||||||
|
mock_request, {"symbols": ["600519.SH"], "date": "2026-01-15"}
|
||||||
|
)
|
||||||
|
assert isinstance(result, dict) and "600519.SH" in result["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_minute_batch_plain_without_accept_encoding(monkeypatch):
|
||||||
|
"""开关开但客户端未声明 gzip (如裸 curl) → 尊重协商, 原样返回。"""
|
||||||
|
from app.api import kline as kline_api
|
||||||
|
|
||||||
|
mock_request, _ = _compress_mock_env(monkeypatch, compress_on=True, accept=None)
|
||||||
|
result = kline_api.get_minute_batch(
|
||||||
|
mock_request, {"symbols": ["600519.SH"], "date": "2026-01-15"}
|
||||||
|
)
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 测试: 日K批量传输压缩 (与分时独立开关) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_mock_env(monkeypatch, *, compress_on, accept="gzip, deflate"):
|
||||||
|
"""构造 get_daily_batch 压缩路径的最小 mock。"""
|
||||||
|
from app.api import kline as kline_api
|
||||||
|
from app.services import preferences as prefs
|
||||||
|
|
||||||
|
monkeypatch.setattr(prefs, "get_daily_batch_compress", lambda: compress_on)
|
||||||
|
|
||||||
|
# 20 根日K (date 列), 足以过 1KB 阈值
|
||||||
|
n = 20
|
||||||
|
daily_df = pl.DataFrame({
|
||||||
|
"symbol": ["600519.SH"] * n,
|
||||||
|
"date": [date(2026, 1, 1) + timedelta(days=i) for i in range(n)],
|
||||||
|
"open": [100.0] * n, "high": [101.0] * n,
|
||||||
|
"low": [99.0] * n, "close": [100.5] * n, "volume": [1000.0] * n,
|
||||||
|
})
|
||||||
|
mock_repo = MagicMock()
|
||||||
|
mock_repo.resolve_asset_type.return_value = "stock"
|
||||||
|
mock_repo.get_daily_batch.return_value = daily_df
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.app.state.repo = mock_repo
|
||||||
|
mock_request.headers = {"accept-encoding": accept} if accept else {}
|
||||||
|
return mock_request
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_batch_gzip_when_enabled(monkeypatch):
|
||||||
|
"""日K压缩开 + 接受 gzip → 压缩 Response, 解压 JSON 完整。"""
|
||||||
|
import gzip as gzip_mod
|
||||||
|
import json as json_mod
|
||||||
|
from app.api import kline as kline_api
|
||||||
|
from fastapi import Response
|
||||||
|
|
||||||
|
req = _daily_mock_env(monkeypatch, compress_on=True)
|
||||||
|
result = kline_api.get_daily_batch(req, {"symbols": ["600519.SH"], "days": 20})
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert result.headers["content-encoding"] == "gzip"
|
||||||
|
payload = json_mod.loads(gzip_mod.decompress(result.body))
|
||||||
|
assert len(payload["data"]["600519.SH"]) == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_batch_plain_when_disabled(monkeypatch):
|
||||||
|
from app.api import kline as kline_api
|
||||||
|
|
||||||
|
req = _daily_mock_env(monkeypatch, compress_on=False)
|
||||||
|
result = kline_api.get_daily_batch(req, {"symbols": ["600519.SH"], "days": 20})
|
||||||
|
assert isinstance(result, dict) and "600519.SH" in result["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_batch_independent_from_minute_switch(monkeypatch):
|
||||||
|
"""日K与分时独立: 分时关、日K开 → 日K仍压缩 (helper 按 pref_key 走各自 getter)。"""
|
||||||
|
import gzip as gzip_mod
|
||||||
|
from app.api import kline as kline_api
|
||||||
|
from app.services import preferences as prefs
|
||||||
|
from fastapi import Response
|
||||||
|
|
||||||
|
monkeypatch.setattr(prefs, "get_minute_batch_compress", lambda: False)
|
||||||
|
req = _daily_mock_env(monkeypatch, compress_on=True)
|
||||||
|
result = kline_api.get_daily_batch(req, {"symbols": ["600519.SH"], "days": 20})
|
||||||
|
assert isinstance(result, Response) and gzip_mod.decompress(result.body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preferences_parallel_saves_do_not_lose_each_other(tmp_path, monkeypatch):
|
||||||
|
"""回归: 并行 save 不同键不得互相覆盖 (压缩总开关并行 PUT 两键的竞态)。
|
||||||
|
|
||||||
|
save 是 read-modify-write, 无锁时两线程同时基于旧快照写盘,
|
||||||
|
后写者会把先写者的更新覆盖掉。
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
from app.services import preferences as prefs
|
||||||
|
|
||||||
|
monkeypatch.setattr(prefs, "_path", lambda: tmp_path / "preferences.json")
|
||||||
|
prefs._invalidate_cache()
|
||||||
|
prefs.save({"minute_batch_compress": True})
|
||||||
|
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
|
||||||
|
def write_key(key: str) -> None:
|
||||||
|
barrier.wait() # 尽量同时进入 save
|
||||||
|
prefs.save({key: False})
|
||||||
|
|
||||||
|
t1 = threading.Thread(target=write_key, args=("minute_batch_compress",))
|
||||||
|
t2 = threading.Thread(target=write_key, args=("daily_batch_compress",))
|
||||||
|
t1.start(); t2.start(); t1.join(); t2.join()
|
||||||
|
|
||||||
|
final = prefs.load()
|
||||||
|
assert final["minute_batch_compress"] is False
|
||||||
|
assert final["daily_batch_compress"] is False
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function ActiveJobCard({ job }: { job: PipelineJob }) {
|
|||||||
{job.error.includes('超时自动取消') && (
|
{job.error.includes('超时自动取消') && (
|
||||||
<div className="mt-1 text-[10px] text-muted">
|
<div className="mt-1 text-[10px] text-muted">
|
||||||
判定依据是「无进度」而非总时长, 任务只要仍在推进就不会被中断;
|
判定依据是「无进度」而非总时长, 任务只要仍在推进就不会被中断;
|
||||||
若网络环境较慢可在 设置 → 超时设置 中调大停滞阈值。
|
若网络环境较慢可在 设置 → 网络设置 中调大停滞阈值。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1611,6 +1611,8 @@ export interface Preferences {
|
|||||||
financial_data_provider?: string
|
financial_data_provider?: string
|
||||||
data_source_job_timeout_s: number
|
data_source_job_timeout_s: number
|
||||||
data_source_long_job_timeout_s: number
|
data_source_long_job_timeout_s: number
|
||||||
|
minute_batch_compress: boolean
|
||||||
|
daily_batch_compress: boolean
|
||||||
realtime_pull_stock?: boolean
|
realtime_pull_stock?: boolean
|
||||||
realtime_pull_etf?: boolean
|
realtime_pull_etf?: boolean
|
||||||
pipeline_pull_a_share: boolean
|
pipeline_pull_a_share: boolean
|
||||||
@@ -1785,8 +1787,23 @@ export const api = {
|
|||||||
data_source_job_timeout_s: dataSourceJobTimeoutS,
|
data_source_job_timeout_s: dataSourceJobTimeoutS,
|
||||||
data_source_long_job_timeout_s: dataSourceLongJobTimeoutS,
|
data_source_long_job_timeout_s: dataSourceLongJobTimeoutS,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/** 分时批量响应 gzip 压缩开关 (网络设置) — 逐请求即时生效 */
|
||||||
|
updateMinuteBatchCompress: (enabled: boolean) =>
|
||||||
|
request<Pick<Preferences, 'minute_batch_compress'>>('/api/settings/preferences/minute-batch-compress', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ minute_batch_compress: enabled }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** 日K批量响应 gzip 压缩开关 (与分时独立) — 逐请求即时生效 */
|
||||||
|
updateDailyBatchCompress: (enabled: boolean) =>
|
||||||
|
request<Pick<Preferences, 'daily_batch_compress'>>('/api/settings/preferences/daily-batch-compress', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ daily_batch_compress: enabled }),
|
||||||
|
}),
|
||||||
updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number) =>
|
updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number) =>
|
||||||
request<Preferences>('/api/settings/preferences/minute-sync', {
|
request<Preferences>('/api/settings/preferences/minute-sync', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const TABS: readonly TabDef[] = [
|
|||||||
{ key: 'monitoring', label: '实时监控', icon: Radio, panel: SettingsMonitoringPanel },
|
{ key: 'monitoring', label: '实时监控', icon: Radio, panel: SettingsMonitoringPanel },
|
||||||
{ key: 'ext-pages', label: '扩展页面', icon: BarChart3, panel: SettingsExtPagesPanel },
|
{ key: 'ext-pages', label: '扩展页面', icon: BarChart3, panel: SettingsExtPagesPanel },
|
||||||
{ key: 'signals', label: '信号库', icon: Zap, panel: SettingsCustomSignalsPanel },
|
{ key: 'signals', label: '信号库', icon: Zap, panel: SettingsCustomSignalsPanel },
|
||||||
{ key: 'timeout', label: '超时设置', icon: Clock3, panel: SettingsTimeoutPanel },
|
{ key: 'timeout', label: '网络设置', icon: Clock3, panel: SettingsTimeoutPanel },
|
||||||
{ key: 'menus', label: '菜单设置', icon: SlidersHorizontal, panel: SettingsMenuSettingsPanel },
|
{ key: 'menus', label: '菜单设置', icon: SlidersHorizontal, panel: SettingsMenuSettingsPanel },
|
||||||
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
|
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 数据任务超时配置卡片 — 从 DataSources 抽出, 放在系统设置页。
|
* 网络设置面板内容 — 任务停滞超时配置 + 分时批量传输压缩开关。
|
||||||
|
* 菜单/Tab 名为「网络设置」(Settings.tsx), 卡片内超时区块标题保持「超时设置」。
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -54,6 +55,47 @@ export function JobTimeoutCard() {
|
|||||||
const timeoutValuesChanged = regularTimeout !== currentRegularTimeout
|
const timeoutValuesChanged = regularTimeout !== currentRegularTimeout
|
||||||
|| longTimeout !== currentLongTimeout
|
|| longTimeout !== currentLongTimeout
|
||||||
|
|
||||||
|
const minuteBatchCompress = prefs.data?.minute_batch_compress ?? true
|
||||||
|
const dailyBatchCompress = prefs.data?.daily_batch_compress ?? true
|
||||||
|
// 总开关显示: 任一子开即亮, 全关才灭; 点击 = 全开/全关 (批量写两个子项)
|
||||||
|
const compressAnyOn = minuteBatchCompress || dailyBatchCompress
|
||||||
|
const toggleCompress = useMutation({
|
||||||
|
mutationFn: (enabled: boolean) => api.updateMinuteBatchCompress(enabled),
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
qc.setQueryData<Preferences>(QK.preferences, current => (
|
||||||
|
current ? { ...current, ...saved } : current
|
||||||
|
))
|
||||||
|
toast(saved.minute_batch_compress ? '分时压缩已开启' : '分时压缩已关闭', 'success')
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast(`保存失败: ${e.message}`, 'error'),
|
||||||
|
})
|
||||||
|
const toggleDailyCompress = useMutation({
|
||||||
|
mutationFn: (enabled: boolean) => api.updateDailyBatchCompress(enabled),
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
qc.setQueryData<Preferences>(QK.preferences, current => (
|
||||||
|
current ? { ...current, ...saved } : current
|
||||||
|
))
|
||||||
|
toast(saved.daily_batch_compress ? '日K压缩已开启' : '日K压缩已关闭', 'success')
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast(`保存失败: ${e.message}`, 'error'),
|
||||||
|
})
|
||||||
|
const toggleAllCompress = useMutation({
|
||||||
|
mutationFn: async (enabled: boolean) => {
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
api.updateMinuteBatchCompress(enabled),
|
||||||
|
api.updateDailyBatchCompress(enabled),
|
||||||
|
])
|
||||||
|
return { ...a, ...b }
|
||||||
|
},
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
qc.setQueryData<Preferences>(QK.preferences, current => (
|
||||||
|
current ? { ...current, ...saved } : current
|
||||||
|
))
|
||||||
|
toast(saved.minute_batch_compress ? '传输压缩已全部开启' : '传输压缩已全部关闭', 'success')
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast(`保存失败: ${e.message}`, 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
const saveJobTimeouts = useMutation({
|
const saveJobTimeouts = useMutation({
|
||||||
mutationFn: () => api.updateDataSourceJobTimeouts(regularTimeout, longTimeout),
|
mutationFn: () => api.updateDataSourceJobTimeouts(regularTimeout, longTimeout),
|
||||||
onSuccess: (saved) => {
|
onSuccess: (saved) => {
|
||||||
@@ -152,6 +194,74 @@ export function JobTimeoutCard() {
|
|||||||
<span className="block text-[10px] text-muted/60 mt-1.5">默认 30 分钟无进度,最小 1 分钟</span>
|
<span className="block text-[10px] text-muted/60 mt-1.5">默认 30 分钟无进度,最小 1 分钟</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-border space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block text-xs font-medium text-foreground">数据传输压缩</span>
|
||||||
|
<span className="block text-[10px] text-muted mt-0.5 leading-relaxed">
|
||||||
|
大数据接口(分时、日K)启用 gzip 压缩,响应可缩至约 1/8,公网访问明显更快;本机或内网可关闭以节省服务端 CPU。任一子项开启时总开关为开,点击总开关一键全开/全关,子项可单独微调,立即生效。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleAllCompress.mutate(!compressAnyOn)}
|
||||||
|
disabled={toggleAllCompress.isPending}
|
||||||
|
className={`shrink-0 relative h-5 w-9 rounded-full transition-colors disabled:opacity-40 ${
|
||||||
|
compressAnyOn ? 'bg-accent' : 'bg-elevated'
|
||||||
|
}`}
|
||||||
|
title={compressAnyOn ? '全部关闭' : '全部开启'}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
||||||
|
compressAnyOn ? 'left-[1.125rem]' : 'left-0.5'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ml-1 space-y-2 border-l-2 border-border/60 pl-3">
|
||||||
|
<CompressToggleRow
|
||||||
|
label="分时数据压缩"
|
||||||
|
desc="分时批量接口(自选/策略分时图,千只标的 MB 级响应)"
|
||||||
|
enabled={minuteBatchCompress}
|
||||||
|
pending={toggleCompress.isPending}
|
||||||
|
onToggle={() => toggleCompress.mutate(!minuteBatchCompress)}
|
||||||
|
/>
|
||||||
|
<CompressToggleRow
|
||||||
|
label="日K数据压缩"
|
||||||
|
desc="日K批量接口(自选/策略日K列,千只标的 MB 级响应)"
|
||||||
|
enabled={dailyBatchCompress}
|
||||||
|
pending={toggleDailyCompress.isPending}
|
||||||
|
onToggle={() => toggleDailyCompress.mutate(!dailyBatchCompress)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CompressToggleRow({ label, desc, enabled, pending, onToggle }: {
|
||||||
|
label: string
|
||||||
|
desc: string
|
||||||
|
enabled: boolean
|
||||||
|
pending: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block text-[11px] font-medium text-secondary">{label}</span>
|
||||||
|
<span className="block text-[10px] text-muted/80 mt-0.5 leading-relaxed">{desc}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={pending}
|
||||||
|
className={`shrink-0 relative h-4 w-7 rounded-full transition-colors disabled:opacity-40 ${
|
||||||
|
enabled ? 'bg-accent' : 'bg-elevated'
|
||||||
|
}`}
|
||||||
|
title={enabled ? '点击关闭' : '点击开启'}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-3 w-3 rounded-full bg-white shadow transition-all ${
|
||||||
|
enabled ? 'left-[0.875rem]' : 'left-0.5'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user