主站行情管理并入数据中枢
- 数据中枢数据源配置页新增「主站行情任务」卡:交易时段后台刷新开关、
手动后台刷新、历史区间回补(任务提交 + 轮询主站 job 记录亮灯),
数据与调度仍归主站,控制台只经桥接代操作
- 新增桥接端点 /api/hub-admin/market/{refresh,backfill};回补改为
market.backfill 后台任务(jobs.config.json 注册,锁独立,超时 30 分钟),
桥接调用秒回,不再阻塞
- 主站桌面端删除顶栏「行情管理」按钮与整个行情管理对话框,清理随之
失效的 CSS;移动端删除 system/admin 页与入口,管理员专区只留数据中枢
- Tushare Token / iFinD 凭证编辑沿用数据源页既有凭证区,无功能缺失
模型池收起修复
- 拉出模型清单后按钮切换为「收起列表」,收起只留一行摘要;再次点击
重新拉取并展开;勾选添加完成后清单自动收起(原逻辑保留)
- 交互全部沿用 HEL-558 已确认样式的既有按钮与提示组件,未新增视觉
自测
- verify_baseline 通过;pytest 492 项通过;数据中枢 240 项通过
- verify_datahub_console 新增 [11b] 行情任务桥接端到端段;UI 自测新增
行情任务卡开关往返、模型清单展开/收起/再展开/添加自动收起,日夜主题
与 1030 窄屏复验通过
- Playwright e2e 102/103:唯一失败项在基线提交上同样失败(本机字体度量
导致的头部溢出,与本卡无关)
Co-authored-by: multica-agent <github@multica.ai>
268 lines
12 KiB
Python
268 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import re
|
||
import unittest
|
||
from http import HTTPStatus
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
REVIEW_ROOT = ROOT.parent
|
||
|
||
from datahub.serving import ApiError
|
||
from datahub.siteauth import SiteBridgeError
|
||
from datahub.siteauth import SiteBridgeError
|
||
from datahub.siteconsole import SiteConsole
|
||
|
||
|
||
class RecordingBridge:
|
||
"""Stands in for the review site so these tests exercise only the mapping."""
|
||
|
||
def __init__(self, replies: dict[str, dict[str, Any]] | None = None) -> None:
|
||
self.replies = replies or {}
|
||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||
self.fail_with = ""
|
||
|
||
def call(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
self.calls.append((path, dict(payload or {})))
|
||
if self.fail_with:
|
||
raise SiteBridgeError(self.fail_with)
|
||
return self.replies.get(path, {})
|
||
|
||
def paths(self) -> list[str]:
|
||
return [path for path, _ in self.calls]
|
||
|
||
|
||
STATUS = {
|
||
"llm": {
|
||
"primary_model_id": "gpt-main",
|
||
"fallback_model_id": "",
|
||
"models": [
|
||
{"id": "gpt-main", "name": "GPT 主力", "model": "gpt-4o", "base_url": "https://api.openai.com/v1", "configured": True, "api_key_last4": "7f21"},
|
||
{"id": "gpt-mini", "name": "GPT 轻量", "model": "gpt-4o-mini", "base_url": "https://api.openai.com/v1", "configured": True, "api_key_last4": "7f21"},
|
||
{"id": "self-host", "name": "自建 Qwen", "model": "qwen2.5", "base_url": "https://llm.intra.example.com/v1", "configured": False, "api_key_last4": ""},
|
||
],
|
||
}
|
||
}
|
||
|
||
|
||
class ModelPoolTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
self.bridge = RecordingBridge({"/api/hub-admin/status": STATUS})
|
||
self.console = SiteConsole(self.bridge)
|
||
|
||
def test_models_are_grouped_per_vendor_so_one_vendor_can_hold_many(self) -> None:
|
||
payload = self.console.models()
|
||
groups = {group["base_url"]: group for group in payload["groups"]}
|
||
self.assertEqual(2, len(groups))
|
||
openai = groups["https://api.openai.com/v1"]
|
||
self.assertEqual("OpenAI", openai["label"])
|
||
self.assertEqual(2, len(openai["models"]))
|
||
self.assertEqual("7f21", openai["key_last4"])
|
||
self.assertTrue(openai["configured"])
|
||
|
||
def test_unknown_vendors_fall_back_to_their_host_as_a_label(self) -> None:
|
||
groups = {group["base_url"]: group for group in self.console.models()["groups"]}
|
||
self.assertEqual("llm.intra.example.com", groups["https://llm.intra.example.com/v1"]["label"])
|
||
self.assertFalse(groups["https://llm.intra.example.com/v1"]["configured"])
|
||
|
||
def test_vendor_presets_are_offered_for_the_new_vendor_picker(self) -> None:
|
||
vendors = self.console.models()["vendors"]
|
||
self.assertIn("OpenAI", [vendor["label"] for vendor in vendors])
|
||
self.assertTrue(all(vendor["base_url"].startswith("http") for vendor in vendors))
|
||
|
||
def test_saving_only_forwards_the_keys_the_caller_actually_sent(self) -> None:
|
||
self.console.save_models({"primary_model_id": "gpt-mini"})
|
||
path, payload = self.bridge.calls[0]
|
||
self.assertEqual("/api/hub-admin/settings/save", path)
|
||
self.assertEqual({"primary_model_id": "gpt-mini"}, payload)
|
||
|
||
def test_saving_nothing_is_refused_rather_than_wiping_the_pool(self) -> None:
|
||
with self.assertRaises(ApiError):
|
||
self.console.save_models({})
|
||
self.assertEqual([], self.bridge.paths())
|
||
|
||
def test_fetching_a_model_list_needs_a_vendor_endpoint(self) -> None:
|
||
with self.assertRaises(ApiError):
|
||
self.console.fetch_models({"api_key": "sk-test"})
|
||
|
||
def test_fetch_passes_the_typed_key_through_for_first_time_vendors(self) -> None:
|
||
self.bridge.replies["/api/hub-admin/models/fetch"] = {"models": ["gpt-4o", "gpt-4o-mini"]}
|
||
result = self.console.fetch_models({"base_url": "https://api.openai.com/v1", "api_key": "sk-new"})
|
||
self.assertEqual(["gpt-4o", "gpt-4o-mini"], result["models"])
|
||
self.assertEqual({"base_url": "https://api.openai.com/v1", "api_key": "sk-new"}, self.bridge.calls[0][1])
|
||
|
||
def test_a_site_outage_becomes_a_console_error_not_a_traceback(self) -> None:
|
||
self.bridge.fail_with = "主站不可达"
|
||
with self.assertRaises(ApiError) as caught:
|
||
self.console.models()
|
||
self.assertIn("主站不可达", str(caught.exception))
|
||
|
||
|
||
class MemberAndQuotaTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
self.bridge = RecordingBridge({"/api/hub-admin/members": {"users": [{"id": 2}], "membership": {"member_daily_limit": 50}}})
|
||
self.console = SiteConsole(self.bridge)
|
||
|
||
def test_saving_a_member_reads_the_roster_back_so_the_page_shows_truth(self) -> None:
|
||
payload = self.console.save_member({"user_id": 2, "status": "active", "duration": "3_months"})
|
||
self.assertEqual(["/api/hub-admin/membership/save", "/api/hub-admin/members"], self.bridge.paths())
|
||
self.assertEqual([{"id": 2}], payload["users"])
|
||
|
||
def test_quota_is_a_system_setting_not_a_membership_row(self) -> None:
|
||
self.console.save_quota({"member_daily_limit": 80})
|
||
self.assertEqual("/api/hub-admin/settings/save", self.bridge.paths()[0])
|
||
self.assertEqual({"member_daily_limit": 80}, self.bridge.calls[0][1])
|
||
|
||
def test_quota_below_one_is_rejected_before_it_reaches_the_site(self) -> None:
|
||
for bad in (0, -5, "abc"):
|
||
with self.subTest(bad=bad):
|
||
with self.assertRaises(ApiError):
|
||
self.console.save_quota({"member_daily_limit": bad})
|
||
self.assertEqual([], self.bridge.paths())
|
||
|
||
|
||
class MarketTaskTests(unittest.TestCase):
|
||
"""HEL-566: 主站行情管理并入中枢后的桥接映射。"""
|
||
|
||
def setUp(self) -> None:
|
||
self.bridge = RecordingBridge(
|
||
{
|
||
"/api/hub-admin/status": {
|
||
"data": {
|
||
"background_refresh_enabled": True,
|
||
"configured": True,
|
||
"snapshot_dates": 12,
|
||
"jobs": [{"idempotency_key": "manual:20260916:1", "status": "success"}],
|
||
}
|
||
},
|
||
"/api/hub-admin/settings/save": {},
|
||
"/api/hub-admin/market/refresh": {"started": True, "job_key": "manual:20260916:1", "message": "后台刷新已开始"},
|
||
"/api/hub-admin/market/backfill": {"started": True, "job_key": "backfill:20260901:20260908:1", "message": "历史回补任务已开始"},
|
||
}
|
||
)
|
||
self.console = SiteConsole(self.bridge)
|
||
|
||
def test_market_status_projects_only_what_the_card_needs(self) -> None:
|
||
payload = self.console.market_status()
|
||
self.assertTrue(payload["background_refresh_enabled"])
|
||
self.assertEqual(1, len(payload["jobs"]))
|
||
self.assertNotIn("llm", payload)
|
||
self.assertNotIn("users", payload)
|
||
|
||
def test_toggling_background_refresh_saves_then_reads_back(self) -> None:
|
||
payload = self.console.save_market_settings({"background_refresh_enabled": False})
|
||
self.assertEqual(
|
||
["/api/hub-admin/settings/save", "/api/hub-admin/status"],
|
||
self.bridge.paths(),
|
||
)
|
||
self.assertEqual({"background_refresh_enabled": False}, self.bridge.calls[0][1])
|
||
self.assertTrue(payload["background_refresh_enabled"]) # 读回的是主站真实状态
|
||
|
||
def test_saving_without_the_toggle_is_refused(self) -> None:
|
||
with self.assertRaises(ApiError):
|
||
self.console.save_market_settings({})
|
||
self.assertEqual([], self.bridge.paths())
|
||
|
||
def test_refresh_and_backfill_forward_their_payloads(self) -> None:
|
||
refresh = self.console.market_refresh({"trade_date": "2026-09-16"})
|
||
self.assertTrue(refresh["started"])
|
||
self.assertEqual({"trade_date": "2026-09-16"}, self.bridge.calls[0][1])
|
||
backfill = self.console.market_backfill({"start_date": "2026-09-01", "end_date": "2026-09-08"})
|
||
self.assertEqual("backfill:20260901:20260908:1", backfill["job_key"])
|
||
self.assertEqual(
|
||
{"start_date": "2026-09-01", "end_date": "2026-09-08"},
|
||
self.bridge.calls[1][1],
|
||
)
|
||
|
||
|
||
class InviteTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
self.bridge = RecordingBridge(
|
||
{
|
||
"/api/hub-admin/invites": {"summary": {"unused": 1}, "codes": [{"code_id": "abc", "code_masked": "XB-9Q2F-••••"}]},
|
||
"/api/hub-admin/invites/create": {
|
||
"created": [{"code_id": "abc", "code": "XB-9Q2F-7K3M-2P8T"}],
|
||
"summary": {"unused": 1},
|
||
"codes": [{"code_id": "abc", "code_masked": "XB-9Q2F-••••"}],
|
||
},
|
||
}
|
||
)
|
||
self.console = SiteConsole(self.bridge)
|
||
|
||
def test_plaintext_codes_come_back_only_from_the_create_call(self) -> None:
|
||
created = self.console.create_invites({"count": 1}, created_by=1)
|
||
self.assertEqual("XB-9Q2F-7K3M-2P8T", created["created"][0]["code"])
|
||
# 列表里永远只有掩码,完整码不会再出现第二次
|
||
listed = self.console.invites()
|
||
self.assertEqual("XB-9Q2F-••••", listed["codes"][0]["code_masked"])
|
||
self.assertNotIn("code", listed["codes"][0])
|
||
|
||
def test_the_operator_is_recorded_as_the_issuer(self) -> None:
|
||
self.console.create_invites({"count": 3, "note": "给张总"}, created_by=7)
|
||
payload = self.bridge.calls[0][1]
|
||
self.assertEqual(7, payload["created_by"])
|
||
self.assertEqual(3, payload["count"])
|
||
self.assertEqual("给张总", payload["note"])
|
||
|
||
def test_revoking_uses_the_public_handle_never_the_raw_code(self) -> None:
|
||
self.console.revoke_invite({"code_id": "abc"})
|
||
self.assertEqual("/api/hub-admin/invites/revoke", self.bridge.paths()[0])
|
||
self.assertEqual({"code_id": "abc"}, self.bridge.calls[0][1])
|
||
|
||
def test_revoking_without_a_target_is_refused(self) -> None:
|
||
with self.assertRaises(ApiError):
|
||
self.console.revoke_invite({})
|
||
self.assertEqual([], self.bridge.paths())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
|
||
|
||
class BridgeContractTests(unittest.TestCase):
|
||
"""Both halves of the bridge must agree on the path spelling.
|
||
|
||
A typo here fails only at runtime with a confusing 401 (the review site
|
||
falls through to its browser-session guard), so it is worth a static check.
|
||
"""
|
||
|
||
def test_every_path_the_console_calls_is_registered_on_the_review_site(self) -> None:
|
||
console_paths = set()
|
||
for module in ("siteauth.py", "siteconsole.py"):
|
||
source = (ROOT / "datahub" / module).read_text(encoding="utf-8")
|
||
console_paths.update(re.findall(r'"(/api/hub-admin/[a-z/-]+)"', source))
|
||
self.assertTrue(console_paths)
|
||
registry = (REVIEW_ROOT / "backend" / "http" / "dispatch.py").read_text(encoding="utf-8")
|
||
registered = set(re.findall(r'"(/api/hub-admin/[a-z/-]+)":', registry))
|
||
self.assertEqual(
|
||
set(),
|
||
console_paths - registered,
|
||
"控制台在调、主站没注册的桥接路径会静默变成 401",
|
||
)
|
||
|
||
|
||
class BridgeErrorMappingTests(unittest.TestCase):
|
||
"""主站拒绝入参(Key 不对)不能在中枢这边冒成 500。"""
|
||
|
||
def _console(self, error: SiteBridgeError) -> SiteConsole:
|
||
class Failing:
|
||
def call(self, path, payload=None):
|
||
raise error
|
||
|
||
return SiteConsole(Failing())
|
||
|
||
def test_upstream_rejection_comes_back_as_a_bad_request(self) -> None:
|
||
console = self._console(SiteBridgeError("模型列表拉取失败(HTTP 401)", 400))
|
||
with self.assertRaises(ApiError) as caught:
|
||
console.fetch_models({"base_url": "https://api.openai.com/v1", "api_key": "sk-bad"})
|
||
self.assertEqual(caught.exception.code, "INVALID_ARGUMENT")
|
||
self.assertEqual(caught.exception.status, HTTPStatus.BAD_REQUEST)
|
||
|
||
def test_unreachable_site_comes_back_as_service_unavailable(self) -> None:
|
||
console = self._console(SiteBridgeError("主站不可达:connection refused"))
|
||
with self.assertRaises(ApiError) as caught:
|
||
console.models()
|
||
self.assertEqual(caught.exception.code, "SOURCE_UNAVAILABLE")
|
||
self.assertEqual(caught.exception.status, HTTPStatus.SERVICE_UNAVAILABLE)
|