diff --git a/README.md b/README.md index f8e40a3..41c81f3 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ compose.yaml .env.example 环境变量模板(复制为 .env 后填写) ``` -管理员的配置入口在数据中枢(页面右上角「数据中枢」按钮,固定指向 `http://192.168.200.11:8766/admin/`):数据源凭证、模型池、会员与邀请码都在那里维护,数据仍存在主站同一份 SQLite 里。主站自身只保留「行情管理」面板(Tushare Token、后台刷新开关、手动刷新与补数)。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。 +管理员的配置入口在数据中枢(页面右上角「数据中枢」按钮,固定指向 `http://192.168.200.11:8766/admin/`):数据源凭证、模型池、会员与邀请码都在那里维护,主站行情任务(后台刷新开关、手动刷新与历史回补)在数据中枢「数据源配置」页的行情任务卡维护,数据仍存在主站同一份 SQLite 里,主站不再保留单独的「行情管理」入口。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。 数据中枢用主站的管理员账号进入,没有独立账号;桥接令牌 `HUB_ADMIN_TOKEN` 需在主站与中枢两侧 `.env` 填成同一个值。详见 [xiaobai-datahub/README.md](xiaobai-datahub/README.md)。 diff --git a/backend/http/dispatch.py b/backend/http/dispatch.py index 3da4fd7..4526d7f 100644 --- a/backend/http/dispatch.py +++ b/backend/http/dispatch.py @@ -22,6 +22,8 @@ HUB_SERVICE_HANDLERS = { "/api/hub-admin/settings/save": "hub_save_settings", "/api/hub-admin/settings/test": "hub_test_model", "/api/hub-admin/models/fetch": "hub_fetch_models", + "/api/hub-admin/market/refresh": "hub_market_refresh", + "/api/hub-admin/market/backfill": "hub_market_backfill", "/api/hub-admin/members": "hub_members", "/api/hub-admin/membership/save": "hub_save_membership", "/api/hub-admin/invites": "hub_invites", diff --git a/backend/http/hubadmin.py b/backend/http/hubadmin.py index 7b3d64d..28dbf2c 100644 --- a/backend/http/hubadmin.py +++ b/backend/http/hubadmin.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from datetime import date from http import HTTPStatus from backend.features.accounts.security import token_hash, verify_password @@ -111,6 +112,44 @@ class HubAdminHttpMixin: except (ValueError, json.JSONDecodeError) as exc: self._hub_failure(exc) + def hub_market_refresh(self) -> None: + """HEL-566: manual snapshot refresh, submitted as a tracked job.""" + try: + body = self._hub_body() + trade_date = str(body.get("trade_date") or "") or date.today().isoformat() + refresh = self.application_service.request_background_sync(trade_date) + started = bool(refresh.get("started")) + self.send_json( + { + "ok": True, + "started": started, + "job_key": str(refresh.get("job_key") or ""), + "message": "后台刷新已开始" if started else "已有后台刷新任务正在运行", + } + ) + except (ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) + + def hub_market_backfill(self) -> None: + """HEL-566: historical snapshot backfill, submitted as a tracked job.""" + try: + body = self._hub_body() + result = self.application_service.request_market_backfill( + str(body.get("start_date") or ""), + str(body.get("end_date") or ""), + ) + started = bool(result.get("started")) + self.send_json( + { + "ok": True, + "started": started, + "job_key": str(result.get("job_key") or ""), + "message": "历史回补任务已开始" if started else "已有回补任务正在运行", + } + ) + except (ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) + def hub_members(self) -> None: service = self.application_service self.send_json( diff --git a/backend/jobs/service.py b/backend/jobs/service.py index 6be63cb..dae86f7 100644 --- a/backend/jobs/service.py +++ b/backend/jobs/service.py @@ -32,6 +32,23 @@ class JobServiceMixin: ) return {"started": started, "job_key": key if started else ""} + def request_market_backfill(self, start_date: str, end_date: str) -> dict[str, object]: + """HEL-566: submit the admin snapshot backfill as a tracked job. + + The data hub console triggers this over the service bridge; the work + itself can run for minutes, so it must not block the bridge call. + """ + normalized_start = normalize_date(start_date) + normalized_end = normalize_date(end_date) + key = f"backfill:{normalized_start}:{normalized_end}:{time.time_ns()}" + started = self.jobs.submit( + "market.backfill", + key, + lambda: self.backfill(normalized_start, normalized_end), + {"start_date": normalized_start, "end_date": normalized_end, "trigger": "administrator"}, + ) + return {"started": started, "job_key": key if started else ""} + def _background_refresh_tick(self) -> None: if not ( self.configured diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 2867a3f..dc0d670 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -479,8 +479,8 @@ }, { "path": "frontend/index.html", - "bytes": 46900, - "lines": 638 + "bytes": 44025, + "lines": 608 }, { "path": "backend/data/providers/tushare_industries.py", @@ -547,11 +547,6 @@ "bytes": 15743, "lines": 401 }, - { - "path": "frontend/shared/dashboard.js", - "bytes": 15063, - "lines": 321 - }, { "path": "frontend/pages/pools/page.html", "bytes": 14942, @@ -567,10 +562,15 @@ "bytes": 14467, "lines": 356 }, + { + "path": "frontend/shared/dashboard.js", + "bytes": 13708, + "lines": 304 + }, { "path": "frontend/shared/session.js", - "bytes": 13633, - "lines": 296 + "bytes": 13567, + "lines": 295 }, { "path": "backend/features/market/insights_auction_data.py", @@ -642,16 +642,16 @@ "bytes": 6739, "lines": 156 }, - { - "path": "frontend/shared/context.js", - "bytes": 6547, - "lines": 220 - }, { "path": "backend/application.py", "bytes": 6500, "lines": 164 }, + { + "path": "frontend/shared/context.js", + "bytes": 6492, + "lines": 219 + }, { "path": "frontend/pages/sentiment/page.html", "bytes": 6488, @@ -682,6 +682,11 @@ "bytes": 5592, "lines": 123 }, + { + "path": "backend/http/dispatch.py", + "bytes": 5401, + "lines": 141 + }, { "path": "frontend/pages.config.js", "bytes": 5385, @@ -697,11 +702,6 @@ "bytes": 5350, "lines": 74 }, - { - "path": "backend/http/dispatch.py", - "bytes": 5281, - "lines": 139 - }, { "path": "frontend/shared/feedback.js", "bytes": 5157, @@ -717,11 +717,6 @@ "bytes": 4712, "lines": 106 }, - { - "path": "frontend/shared/admin.js", - "bytes": 4500, - "lines": 109 - }, { "path": "backend/data/providers/tushare_helpers.py", "bytes": 4406, @@ -757,11 +752,6 @@ "bytes": 3369, "lines": 81 }, - { - "path": "frontend/app.js", - "bytes": 3337, - "lines": 95 - }, { "path": "frontend/pages/themes/page.html", "bytes": 3316, @@ -772,11 +762,21 @@ "bytes": 3299, "lines": 57 }, + { + "path": "backend/jobs/service.py", + "bytes": 3186, + "lines": 76 + }, { "path": "backend/features/market/insights_context.py", "bytes": 3175, "lines": 84 }, + { + "path": "frontend/app.js", + "bytes": 3081, + "lines": 91 + }, { "path": "frontend/pages/market/bindings.js", "bytes": 2663, @@ -787,11 +787,6 @@ "bytes": 2597, "lines": 69 }, - { - "path": "backend/jobs/service.py", - "bytes": 2337, - "lines": 59 - }, { "path": "backend/features/mentor/routes.py", "bytes": 2299, @@ -887,6 +882,11 @@ "bytes": 817, "lines": 23 }, + { + "path": "frontend/shared/admin.js", + "bytes": 800, + "lines": 18 + }, { "path": "backend/features/sentiment/routes.py", "bytes": 724, diff --git a/config/jobs.config.json b/config/jobs.config.json index 9d1cbf5..1ba7dde 100644 --- a/config/jobs.config.json +++ b/config/jobs.config.json @@ -21,6 +21,16 @@ "max_attempts": 1, "output_version": "screener-library-v8" }, + { + "id": "market.backfill", + "schedule": "administrator request from the data hub console", + "input_date_policy": "requested start and end trade dates", + "dependencies": ["market provider", "trade calendar", "database"], + "lock_key": "market-backfill", + "timeout_seconds": 1800, + "max_attempts": 1, + "output_version": "snapshot-backfill-v1" + }, { "id": "market.ifind-event-enrichment", "schedule": "on demand after market close", diff --git a/frontend/app.js b/frontend/app.js index 90fbb7f..4f18651 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -13,10 +13,6 @@ async function initialize() { document.querySelector("#journalDate").max = todayString(); document.querySelector("#tradeLogDate").value = elements.tradeDate.value; document.querySelector("#tradeLogDate").max = todayString(); - document.querySelector("#backfillStart").value = todayString(); - document.querySelector("#backfillEnd").value = todayString(); - document.querySelector("#backfillStart").max = todayString(); - document.querySelector("#backfillEnd").max = todayString(); document.querySelector("#qiObservationDate").value = elements.tradeDate.value; document.querySelector("#qiObservationDate").max = todayString(); document.querySelector("#accountBirthDate").max = todayString(); diff --git a/frontend/index.html b/frontend/index.html index e46599a..2d1621b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -157,7 +157,6 @@ -
@@ -592,35 +591,6 @@ - -
-
管理员

行情管理

- -
-
正在读取系统状态
-

模型池、会员与邀请码已统一在数据中枢管理,点击顶栏「数据中枢」进入。

-
-
-

公共行情

待检查
- - - -

所有用户读取同一份后台快照,页面不会随后台任务自动重绘。

-
数据中枢线路待检查
-
尚未手动刷新
-
-
-
-

历史数据回补

管理员任务
-
- - -
-
-
-
-
- ` + vendorFetchHtml(group, draft) + @@ -1098,6 +1249,13 @@ function vendorCardHtml(group) { `
${body}
`; } +/* HEL-566: 列表展开时按钮变「收起列表」,收起/未拉取时回到拉取文案, + 点击重新拉取会再次展开,交互沿用既有按钮,不新增视觉元素。 */ +function vendFetchLabel(group, draft) { + if (draft.fetched && !draft.collapsed && !draft.fetching) return '收起列表'; + return group.models.length ? '重新拉取模型列表' : '获取模型列表'; +} + function modelsHtml() { const data = state.models; if (!data) return panelHtml('模型池', '', '

正在从主站读取模型池…

'); @@ -1267,7 +1425,11 @@ function renderPage(p) { mainEl.dataset.page = p; document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p)); if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); } - else if (p === 'sources') { mainEl.innerHTML = sourcesHtml(); paintSparks(); } + else if (p === 'sources') { + mainEl.innerHTML = sourcesHtml(); + paintSparks(); + if (!state.market.status) loadMarketStatus(); + } else if (p === 'models') { mainEl.innerHTML = modelsHtml(); restoreVendorDrafts(); @@ -1325,7 +1487,7 @@ function updatePhaseTag() { /* ---------- 模型池 / 会员页的事件(沿用 mainEl 的委托,不另加监听) ---------- */ function handleModelClick(e) { const fetchBtn = e.target.closest('[data-vend-fetch]'); - if (fetchBtn) { fetchVendorModels(fetchBtn.dataset.vendFetch); return true; } + if (fetchBtn) { toggleVendorList(fetchBtn.dataset.vendFetch); return true; } const saveVendor = e.target.closest('[data-vend-save]'); if (saveVendor) { saveVendorProfile(saveVendor.dataset.vendSave); return true; } const manual = e.target.closest('[data-vend-manual]'); @@ -1373,6 +1535,7 @@ async function fetchVendorModels(baseUrl) { if (!key && !vendorGroup(baseUrl).configured) { toast('请先填写 API Key,再拉取模型列表', 'err'); return; } draft.fetching = true; draft.error = ''; + draft.collapsed = false; renderPage('models'); try { const result = await api('/admin/api/models/fetch', { method: 'POST', body: JSON.stringify({ base_url: url, api_key: key }) }); @@ -1387,6 +1550,17 @@ async function fetchVendorModels(baseUrl) { renderPage('models'); } +/* HEL-566: 拉出的清单可收起可展开——展开时收起,收起/未拉取时重新拉取。 */ +function toggleVendorList(baseUrl) { + const draft = vendorDraft(baseUrl); + if (!draft.fetching && draft.fetched && !draft.collapsed) { + draft.collapsed = true; + renderPage('models'); + return; + } + fetchVendorModels(baseUrl); +} + function nextModelId(model) { // 主站要求模型 ID 匹配 [A-Za-z0-9_-]{3,80},这里把模型名压成合法且唯一的 ID。 const used = new Set(state.models.models.map((m) => m.id)); @@ -1444,6 +1618,7 @@ async function addModels(baseUrl, names) { if (!added.length) { toast('这些模型已在池中', 'err'); return; } const draft = vendorDraft(baseUrl); draft.fetched = null; + draft.collapsed = false; draft.manual = false; draft.key = ''; const extra = {}; @@ -1592,6 +1767,12 @@ async function revokeInvite(codeId) { } mainEl.addEventListener('click', (e) => { + const mt = e.target.closest('[data-market-toggle]'); + if (mt) { toggleMarketRefresh(); return; } + const mr = e.target.closest('[data-market-refresh]'); + if (mr) { startMarketRefresh(); return; } + const mb = e.target.closest('[data-market-backfill]'); + if (mb) { startMarketBackfill(); return; } const pb = e.target.closest('[data-probe]'); if (pb) { doProbe(pb.dataset.probe); return; } const tg = e.target.closest('[data-toggle]'); diff --git a/xiaobai-datahub/datahub/httpapp.py b/xiaobai-datahub/datahub/httpapp.py index 3f59700..5d0ca18 100644 --- a/xiaobai-datahub/datahub/httpapp.py +++ b/xiaobai-datahub/datahub/httpapp.py @@ -238,6 +238,18 @@ class HubRequestHandler(BaseHTTPRequestHandler): self._json(console.test_model(self._read_json()), HTTPStatus.OK) elif path == "/admin/api/models/fetch" and method == "POST": self._json(console.fetch_models(self._read_json()), HTTPStatus.OK) + elif path == "/admin/api/market/status" and method == "GET": + self._json(console.market_status(), HTTPStatus.OK) + elif path == "/admin/api/market/settings" and method == "POST": + self._json(console.save_market_settings(self._read_json()), HTTPStatus.OK) + elif path == "/admin/api/market/refresh" and method == "POST": + payload = console.market_refresh(self._read_json(allow_empty=True)) + self.hub.pipeline.audit(user["username"], "market_refresh", str(payload.get("job_key") or ""), "") + self._json(payload, HTTPStatus.OK) + elif path == "/admin/api/market/backfill" and method == "POST": + payload = console.market_backfill(self._read_json()) + self.hub.pipeline.audit(user["username"], "market_backfill", str(payload.get("job_key") or ""), "") + self._json(payload, HTTPStatus.OK) elif path == "/admin/api/members" and method == "GET": self._json(console.members(), HTTPStatus.OK) elif path == "/admin/api/members/save" and method == "POST": diff --git a/xiaobai-datahub/datahub/siteconsole.py b/xiaobai-datahub/datahub/siteconsole.py index 99c2564..a6964ce 100644 --- a/xiaobai-datahub/datahub/siteconsole.py +++ b/xiaobai-datahub/datahub/siteconsole.py @@ -79,6 +79,54 @@ class SiteConsole: ) return {"models": payload.get("models") or []} + # ---------------------------------------------------------------- market + # HEL-566: 行情管理整体并入数据中枢。后台刷新开关、手动刷新与历史回补 + # 仍由主站执行(数据与调度都归主站),控制台只是经桥接代为操作, + # 任务状态也从主站 /api/hub-admin/status 的 jobs 里读。 + def market_status(self) -> dict[str, Any]: + payload = self._call("/api/hub-admin/status") + data = payload.get("data") or {} + return { + "background_refresh_enabled": bool(data.get("background_refresh_enabled")), + "configured": bool(data.get("configured")), + "snapshot_dates": data.get("snapshot_dates") or 0, + "jobs": data.get("jobs") or [], + } + + def save_market_settings(self, body: dict[str, Any]) -> dict[str, Any]: + if "background_refresh_enabled" not in body: + raise ApiError("INVALID_ARGUMENT", "没有需要保存的行情设置") + self._call( + "/api/hub-admin/settings/save", + {"background_refresh_enabled": bool(body.get("background_refresh_enabled"))}, + ) + return self.market_status() + + def market_refresh(self, body: dict[str, Any]) -> dict[str, Any]: + payload = self._call( + "/api/hub-admin/market/refresh", + {"trade_date": str(body.get("trade_date") or "")}, + ) + return { + "started": bool(payload.get("started")), + "job_key": str(payload.get("job_key") or ""), + "message": str(payload.get("message") or ""), + } + + def market_backfill(self, body: dict[str, Any]) -> dict[str, Any]: + payload = self._call( + "/api/hub-admin/market/backfill", + { + "start_date": str(body.get("start_date") or ""), + "end_date": str(body.get("end_date") or ""), + }, + ) + return { + "started": bool(payload.get("started")), + "job_key": str(payload.get("job_key") or ""), + "message": str(payload.get("message") or ""), + } + # --------------------------------------------------------------- members def members(self) -> dict[str, Any]: payload = self._call("/api/hub-admin/members") diff --git a/xiaobai-datahub/tests/test_site_console.py b/xiaobai-datahub/tests/test_site_console.py index ebf564c..0da9050 100644 --- a/xiaobai-datahub/tests/test_site_console.py +++ b/xiaobai-datahub/tests/test_site_console.py @@ -122,6 +122,60 @@ class MemberAndQuotaTests(unittest.TestCase): 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(