From f68f9501061772766c8bbc1fcef4225f8ca5fe4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=98?= <4846a27a-ba87-49c9-939d-7275f63efcc7@agents.multica.local> Date: Thu, 27 Aug 2026 13:41:23 +0000 Subject: [PATCH 01/21] =?UTF-8?q?=E6=96=BD=E5=B7=A5(HEL-183):=20=E9=97=AE?= =?UTF-8?q?=E5=A4=A9=E5=B7=A5=E5=85=B7=E8=A1=8C=E5=8F=B3=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=B9=B6=E7=BB=9F=E4=B8=80=20logo/=E6=94=B6=E8=B5=B7/=E9=97=AE?= =?UTF-8?q?=E5=B8=88=E9=A1=B6=E8=B7=9D=E4=B8=8E=E9=A1=B5=E5=A4=B4=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复顶栏工具行 margin-left:auto,问天隐藏行情条后仍靠右;logo 区与收起按钮对齐顶栏/状态条高度;问师恢复页顶 14px;问天三签改用全站 segmented 页头。 Co-authored-by: Cursor Co-authored-by: multica-agent --- frontend/index.html | 8 +- frontend/pages/heaven/foundation.css | 246 +----------------------- frontend/pages/heaven/page.html | 19 +- frontend/pages/mentor/foundation.css | 2 +- frontend/shared/components/controls.css | 12 +- frontend/shared/shell.css | 14 +- tests/e2e/app-shell.spec.js | 96 +++++++++ 7 files changed, 137 insertions(+), 260 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 2d85bfa..94f4181 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -34,9 +34,9 @@ - + - + @@ -52,8 +52,8 @@ - - + + diff --git a/frontend/pages/heaven/foundation.css b/frontend/pages/heaven/foundation.css index 0bd40e6..f1f5002 100644 --- a/frontend/pages/heaven/foundation.css +++ b/frontend/pages/heaven/foundation.css @@ -93,28 +93,6 @@ body[data-active-view="heavenView"] .workspace-view { display: none; } -:where(#heavenView) .heaven-tabs { - display: flex; - - border-bottom: 1px solid var(--line); -} - -:where(#heavenView) .heaven-tab { - border-bottom: 3px solid transparent; - - background: transparent; - - cursor: pointer; -} - -:where(#heavenView) .heaven-tab.active { - border-bottom-color: var(--coral); -} - -:where(#heavenView) .heaven-tab:hover { - border-bottom-color: var(--coral); -} - :where(#heavenView) .heaven-panel { display: none; } @@ -1875,60 +1853,6 @@ body[data-active-view="heavenView"] .workspace-view { color: var(--heaven-ink-faint); } -#heavenView .heaven-tab { - font-family: var(--heaven-serif); - - height: 52px; - - position: relative; - - padding: 0px 2px; - - border: 0px; - - color: var(--heaven-ink-soft); - - font-size: 14px; - - font-weight: 600; -} - -#heavenView .heaven-tab::after { - content: ""; - - position: absolute; - - right: 0px; - - bottom: 0px; - - left: 0px; - - height: 2px; - - background: var(--heaven-cinnabar); - - opacity: 0; - - transform: scaleX(0.3); - - transition: opacity 220ms ease, transform 260ms var(--ease-out); -} - -#heavenView .heaven-tab.active { - color: var(--heaven-ink); -} - -#heavenView .heaven-tab:hover { - color: var(--heaven-ink); -} - -#heavenView .heaven-tab.active::after { - opacity: 1; - - transform: scaleX(1); -} - :where(#heavenView) .heaven-proverb { margin: 0px; @@ -1960,7 +1884,7 @@ body[data-active-view="heavenView"] .workspace-view { } #heavenView .button:focus-visible, -#heavenView .heaven-tab:focus-visible, +#heavenView .segment:focus-visible, #heavenView summary:focus-visible { outline: 2px solid var(--heaven-cinnabar); @@ -2623,12 +2547,6 @@ body[data-active-view="heavenView"] .workspace-view { padding: 0px 14px; } - #heavenView .heaven-tabs { - gap: 22px; - - padding: 0px 14px; - } - .heaven-proverb { padding: 8px 14px; @@ -3093,7 +3011,7 @@ body[data-active-view="heavenView"] .workspace-view { #heavenView.heaven-data-loading .heaven-panel, #heavenView.heaven-data-loading .heaven-proverb, -#heavenView.heaven-data-loading .heaven-tabs { +#heavenView.heaven-data-loading .heaven-page-head { opacity: 0.42; pointer-events: none; @@ -3695,7 +3613,7 @@ body[data-active-view="heavenView"] .workspace-view { @media (max-width: 900px) { #heavenView .heaven-panel > , #heavenView .heaven-proverb, -#heavenView .heaven-tabs, +#heavenView .heaven-page-head, #heavenView .heaven-toolbar { width: min(100% - 28px, 1280px); } @@ -3746,22 +3664,6 @@ body[data-active-view="heavenView"] .workspace-view { display: none; } - #heavenView .heaven-tabs { - display: grid; - - grid-template-columns: repeat(3, minmax(0px, 1fr)); - - gap: 0px; - - padding: 0px; - } - - #heavenView .heaven-tab { - width: 100%; - - min-width: 0px; - } - #heavenFortunePanel .fortune-heading { display: flex; @@ -3867,18 +3769,6 @@ body[data-active-view="heavenView"] .workspace-view { } @media (max-width: 520px) { - .heaven-tabs { - gap: 0px; - - padding: 0px 8px; - } - - .heaven-tab { - min-width: 0px; - - flex: 1 1 0%; - } - .heaven-controls { grid-template-columns: 1fr; @@ -6232,26 +6122,6 @@ body[data-active-view="heavenView"] .workspace-view { padding: 12px 20px; } -#heavenView .heaven-tabs { - align-items: stretch; - - gap: 30px; - - border-color: var(--heaven-rule); - - background: rgba(253, 252, 248, 0.96); - - width: min(100% - 40px, 1280px); - - margin-right: auto; - - margin-left: auto; - - min-height: 54px; - - padding: 0px 20px; -} - #heavenView .heaven-proverb { margin-right: auto; @@ -6297,12 +6167,6 @@ body[data-active-view="heavenView"] .workspace-view { padding: 10px 14px; } - #heavenView .heaven-tabs { - min-height: 52px; - - padding: 0px 14px; - } - #heavenView .heaven-proverb { padding: 9px 14px; } @@ -6668,18 +6532,6 @@ body[data-active-view="heavenView"] .workspace-view { color: var(--wt-faint); } -:root[data-theme="light"] #heavenView .wt-tabs .wt-tab { - color: var(--wt-muted); -} - -:root[data-theme="light"] #heavenView .wt-tabs .wt-tab small { - color: var(--wt-faint); -} - -:root[data-theme="light"] #heavenView .wt-tabs .wt-tab.on { - color: var(--wt-gold-bright); -} - :root[data-theme="light"] #heavenView .wt-empty { color: var(--wt-muted); } @@ -7128,88 +6980,6 @@ body[data-active-view="heavenView"] .workspace-view { letter-spacing: 4px; } - .wt-tabs { - display: flex; - - justify-content: center; - - gap: 34px; - - margin-top: 20px; - } - - .wt-tabs .wt-tab { - position: relative; - - padding: 8px 4px; - - border: 0px; - - background: transparent; - - color: rgba(216, 210, 189, 0.5); - - font-size: 15px; - - letter-spacing: 3px; - - transition: color 0.2s; - } - - .wt-tabs .wt-tab small { - display: block; - - margin-top: 3px; - - color: rgba(216, 210, 189, 0.3); - - font-family: inherit; - - font-size: 10px; - - letter-spacing: 1px; - } - - .wt-tabs .wt-tab::after { - content: ""; - - position: absolute; - - bottom: -2px; - - left: 50%; - - width: 0px; - - height: 1.5px; - - background: var(--wt-gold); - - transform: translateX(-50%); - - transition: 0.25s; - } - - .wt-tabs .wt-tab.on { - color: var(--wt-gold-bright); - } - - .wt-tabs .wt-tab.on::after { - width: 100%; - } - - .wt-tabs .wt-tab:disabled { - cursor: default; - } - - .wt-tabs .wt-tab:focus { - outline: none; - } - - .wt-tabs .wt-tab:focus-visible { - box-shadow: rgba(201, 165, 92, 0.45) 0px 2px 0px; - } - .heaven-proverb { margin: 8px 0px 0px; @@ -9072,16 +8842,6 @@ body[data-active-view="heavenView"] .workspace-view { gap: 5px; } - .wt-tabs { - gap: 16px; - } - - .wt-tabs .wt-tab { - font-size: 13px; - - letter-spacing: 2px; - } - .heaven-proverb { text-align: center; } diff --git a/frontend/pages/heaven/page.html b/frontend/pages/heaven/page.html index 6972818..335d317 100644 --- a/frontend/pages/heaven/page.html +++ b/frontend/pages/heaven/page.html @@ -1,16 +1,23 @@
+
+
+

问天

+ -- +
+
+
+ + + +
+
+

问 天

- --
观天之道 · 执天之行
-

遇事不决可问春风,春风不语即随本心

diff --git a/frontend/pages/mentor/foundation.css b/frontend/pages/mentor/foundation.css index e517a5e..355dcec 100644 --- a/frontend/pages/mentor/foundation.css +++ b/frontend/pages/mentor/foundation.css @@ -49,7 +49,7 @@ body[data-active-view="mentorView"] .app-page-context span { height: 100%; min-height: 0; flex-direction: column; - padding: 0; + padding: var(--page-pad-y) 0 0; color: var(--qp-text-1); font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; } diff --git a/frontend/shared/components/controls.css b/frontend/shared/components/controls.css index 6ccc5d0..fe44fe4 100644 --- a/frontend/shared/components/controls.css +++ b/frontend/shared/components/controls.css @@ -660,7 +660,9 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { text-align: left; - min-height: 38px; + height: var(--size-statusbar); + + min-height: var(--size-statusbar); display: flex; @@ -670,7 +672,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { margin: auto -8px -8px; - padding: 10px 16px; + padding: 0 16px; border-right: 0px; @@ -691,6 +693,12 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { font-size: 12px; } +.sidebar-collapse-button .lucide { + width: 14px; + + height: 14px; +} + body.sidebar-collapsed .sidebar-collapse-button span { display: none; } diff --git a/frontend/shared/shell.css b/frontend/shared/shell.css index 0de2241..d76bd79 100644 --- a/frontend/shared/shell.css +++ b/frontend/shared/shell.css @@ -1294,7 +1294,9 @@ body.sidebar-collapsed { } .sidebar-brand { - min-height: 55px; + height: var(--size-topbar); + + min-height: var(--size-topbar); display: flex; @@ -1304,7 +1306,7 @@ body.sidebar-collapsed { margin: 0px -8px 7px; - padding: 0px 16px; + padding: 0 16px; border-bottom: 1px solid var(--r2-line-soft); @@ -2146,13 +2148,17 @@ body.sidebar-collapsed .status-bar { } .module-nav .sidebar-brand { + height: var(--size-topbar); + + min-height: var(--size-topbar); + display: flex; align-items: center; gap: 8px; - padding: 14px 16px; + padding: 0 16px; border-bottom: 1px solid var(--line-soft); } @@ -3494,7 +3500,7 @@ body.sidebar-collapsed .status-bar { flex: 0 0 auto; align-items: center; gap: var(--header-action-gap); - margin-left: 0; + margin-left: auto; overflow: visible; } diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 9ceaa8b..49c927e 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -3735,3 +3735,99 @@ test("desktop header keeps commands in view and tape text unclipped across works await page.screenshot({ path: path.join(shotDir, "admin-390-night.png") }); fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`); }); + +test("HEL-183 heaven tools right-align and shell heights unify", async ({ page }) => { + const fs = require("node:fs"); + const path = require("node:path"); + const shotDir = path.join(__dirname, "../../runtime/hel183-shots"); + fs.mkdirSync(shotDir, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("admin", true)); + await page.goto("/index.html"); + + const measure = () => page.evaluate(() => { + const box = (node) => { + if (!node) return null; + const r = node.getBoundingClientRect(); + return { x: r.x, y: r.y, right: r.right, width: r.width, height: r.height }; + }; + const brand = document.querySelector(".module-nav .sidebar-brand") || document.querySelector(".sidebar-brand"); + const header = document.querySelector(".app-header"); + const actions = document.querySelector(".header-actions"); + const collapse = document.querySelector(".sidebar-collapse-button"); + const status = document.querySelector(".status-bar"); + const overview = document.querySelector(".overview-strip"); + const brandBox = box(brand); + const headerBox = box(header); + const actionsBox = box(actions); + const collapseBox = box(collapse); + const statusBox = box(status); + return { + brandHeight: brandBox ? Math.round(brandBox.height) : null, + headerHeight: headerBox ? Math.round(headerBox.height) : null, + brandBottom: brandBox ? Math.round(brandBox.y + brandBox.height) : null, + headerBottom: headerBox ? Math.round(headerBox.y + headerBox.height) : null, + collapseHeight: collapseBox ? Math.round(collapseBox.height) : null, + statusHeight: statusBox ? Math.round(statusBox.height) : null, + actionsNearRight: actionsBox && headerBox ? (headerBox.right - actionsBox.right) < 24 : false, + actionsMarginLeft: actions ? getComputedStyle(actions).marginLeft : null, + overviewDisplay: overview ? getComputedStyle(overview).display : null, + mentorPadTop: (() => { + const mentor = document.querySelector("#mentorView"); + return mentor ? getComputedStyle(mentor).paddingTop : null; + })(), + }; + }); + + await page.locator('[data-view="sentimentCycleView"]').first().click(); + await expect(page.locator("#sentimentCycleView")).toHaveClass(/active-view/); + let geo = await measure(); + expect(geo.brandHeight, "logo height").toBe(64); + expect(geo.headerHeight, "header height").toBe(64); + expect(geo.brandBottom, "logo/header bottom align").toBe(geo.headerBottom); + expect(geo.collapseHeight, "collapse height").toBe(28); + expect(geo.statusHeight, "status height").toBe(28); + expect(geo.actionsNearRight, "sentiment tools right").toBe(true); + await page.locator(".app-header").screenshot({ path: path.join(shotDir, "sentiment-header-day.png") }); + await page.screenshot({ path: path.join(shotDir, "sentiment-page-day.png") }); + + await page.locator('[data-view="heavenView"]').first().click(); + await expect(page.locator("#heavenView")).toHaveClass(/active-view/); + await expect(page.locator("#heavenView .heaven-page-head")).toBeVisible(); + await expect(page.locator("#heavenView .heaven-page-head .segment")).toHaveCount(3); + geo = await measure(); + expect(geo.overviewDisplay, "heaven hides overview").toBe("none"); + expect(geo.actionsMarginLeft, "tools margin-left resolved").not.toBe("0px"); + expect(Number.parseFloat(geo.actionsMarginLeft), "tools left auto gap").toBeGreaterThan(40); + expect(geo.actionsNearRight, "heaven tools right").toBe(true); + expect(geo.brandHeight).toBe(64); + expect(geo.collapseHeight).toBe(28); + await page.locator(".app-header").screenshot({ path: path.join(shotDir, "heaven-header-day.png") }); + await page.screenshot({ path: path.join(shotDir, "heaven-page-day.png") }); + + await page.locator('[data-heaven-panel="fortune"]').click(); + await expect(page.locator('[data-heaven-panel="fortune"]')).toHaveClass(/active/); + await expect(page.locator("#heavenFortunePanel")).toHaveClass(/active-heaven-panel/); + + await page.locator('[data-view="mentorView"]').first().click(); + await expect(page.locator("#mentorView")).toHaveClass(/active-view/); + geo = await measure(); + expect(geo.mentorPadTop, "mentor top padding").toBe("14px"); + await page.screenshot({ path: path.join(shotDir, "mentor-page-day.png") }); + + await page.locator("#themeToggle").click(); + await page.locator('[data-view="heavenView"]').first().click(); + await expect(page.locator("#heavenView")).toHaveClass(/active-view/); + geo = await measure(); + expect(geo.actionsNearRight, "heaven night tools right").toBe(true); + expect(geo.brandHeight).toBe(64); + await page.locator(".app-header").screenshot({ path: path.join(shotDir, "heaven-header-night.png") }); + await page.screenshot({ path: path.join(shotDir, "heaven-page-night.png") }); + + await page.locator('[data-view="sentimentCycleView"]').first().click(); + await page.locator(".app-header").screenshot({ path: path.join(shotDir, "sentiment-header-night.png") }); + await page.screenshot({ path: path.join(shotDir, "sentiment-page-night.png") }); + + await page.locator('[data-view="mentorView"]').first().click(); + await page.screenshot({ path: path.join(shotDir, "mentor-page-night.png") }); +}); From 94e6f618c896b553df3129b2d4c489c480ced7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=98?= <4846a27a-ba87-49c9-939d-7275f63efcc7@agents.multica.local> Date: Thu, 27 Aug 2026 14:05:18 +0000 Subject: [PATCH 02/21] =?UTF-8?q?=E6=96=BD=E5=B7=A5(HEL-188):=20=E5=88=B7?= =?UTF-8?q?=E6=96=B0=20architecture-inventory=20=E4=BB=A5=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20HEL-183=20=E8=BF=90=E8=A1=8C=E6=97=B6=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E6=8C=87=E7=BA=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor Co-authored-by: multica-agent --- config/architecture-inventory.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index e725ad6..9f2d5c0 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -372,9 +372,9 @@ "css_layers": [ "/shared/tokens.css?v=20260820-3", "/shared/base.css?v=20260806-1", - "/shared/shell.css?v=20260820-8", + "/shared/shell.css?v=20260827-hel183", "/shared/auth.css?v=20260820-5", - "/shared/components/controls.css?v=20260820-2", + "/shared/components/controls.css?v=20260827-hel183", "/shared/components/navigation.css?v=20260820-1", "/shared/components/cards.css?v=20260820-1", "/shared/components/tables.css?v=20260820-1", @@ -390,8 +390,8 @@ "/pages/popularity/foundation.css?v=20260820-1", "/pages/dragon-tiger/foundation.css?v=20260820-1", "/pages/screener/foundation.css?v=20260820-4", - "/pages/mentor/foundation.css?v=20260820-2", - "/pages/heaven/foundation.css?v=20260806-2", + "/pages/mentor/foundation.css?v=20260827-hel183", + "/pages/heaven/foundation.css?v=20260827-hel183", "/pages/review/foundation.css?v=20260820-4" ], "frontend_composition": { @@ -436,8 +436,8 @@ "code_hotspots": [ { "path": "frontend/pages/heaven/foundation.css", - "bytes": 185936, - "lines": 11734 + "bytes": 182616, + "lines": 11494 }, { "path": "frontend/pages/screener/foundation.css", @@ -451,8 +451,8 @@ }, { "path": "frontend/shared/shell.css", - "bytes": 63550, - "lines": 3757 + "bytes": 63659, + "lines": 3763 }, { "path": "backend/features/heaven/engine.py", @@ -461,7 +461,7 @@ }, { "path": "frontend/index.html", - "bytes": 47871, + "bytes": 47891, "lines": 661 }, { @@ -501,8 +501,8 @@ }, { "path": "frontend/pages/heaven/page.html", - "bytes": 19747, - "lines": 262 + "bytes": 19885, + "lines": 269 }, { "path": "frontend/pages/screener/page.html", From ef86b31f6b1fed4e237565ab6e8a4a358cb1f4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=98?= <4846a27a-ba87-49c9-939d-7275f63efcc7@agents.multica.local> Date: Thu, 27 Aug 2026 14:47:27 +0000 Subject: [PATCH 03/21] =?UTF-8?q?fix(HEL-191):=20=E8=A1=A5=E5=9B=9E?= =?UTF-8?q?=E9=97=AE=E5=A4=A9=E8=A7=A3=E5=8A=BF=E7=9F=A5=E8=AF=86=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=85=9C=E5=BA=95=E5=B9=B6=E6=B6=88=E9=99=A4=20Failed?= =?UTF-8?q?=20to=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宿主机 data 挂载会遮盖镜像内 heaven_knowledge.json;增加不受挂载影响的 seed, 并将缺文件/坏 JSON 转为结构化中文错误,前端展示可读提示而非 Failed to fetch。 Co-authored-by: Cursor Co-authored-by: multica-agent --- DOCKER_DEPLOY.md | 16 ++ Dockerfile | 5 +- .../heaven/assets/heaven_knowledge.json | 117 ++++++++++++++ backend/features/heaven/http.py | 17 +- backend/features/heaven/knowledge.py | 55 ++++++- config/architecture-inventory.json | 4 +- frontend/pages/heaven/page.js | 3 +- frontend/shared/api.js | 21 ++- tests/test_heaven_knowledge.py | 151 +++++++++++++++++- 9 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 backend/features/heaven/assets/heaven_knowledge.json diff --git a/DOCKER_DEPLOY.md b/DOCKER_DEPLOY.md index 378050d..7a503b9 100644 --- a/DOCKER_DEPLOY.md +++ b/DOCKER_DEPLOY.md @@ -14,13 +14,29 @@ v xiaobai-review 容器 :8765 |-- /app 只读应用代码 + | `-- backend/features/heaven/assets/heaven_knowledge.json + | 镜像内 seed(不受 data 挂载遮盖) `-- /app/data 宿主机 ./data 持久化挂载 + |-- review.db + |-- iching_zh.json + `-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed ``` 账号、加密后的公共数据 Token、平台模型 API Key、生辰资料、行情快照和复盘数据均在 `data/review.db`。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与 密钥必须成对备份,任意一个丢失都无法恢复账号内的加密资料。 +问天静态知识文件: + +- `data/iching_zh.json`、`data/heaven_knowledge.json` 纳入 Git 与镜像构建; + `.dockerignore` 不排除这两个文件(只排除 `data/*.db`、`data/cache/` 等运行时产物)。 +- Compose 把宿主机 `./data` 整目录挂到 `/app/data`,会遮盖镜像里同路径文件。 + 因此宿主机 `data/` 应保留上述两个 JSON;若只缺 `heaven_knowledge.json`, + 服务会回退读取镜像内 + `backend/features/heaven/assets/heaven_knowledge.json`,解势仍可用。 +- 持久化位置:正式环境以宿主机项目目录下的 `./data/heaven_knowledge.json` 为准; + 补文件后无需改代码,重启容器即可加载。 + 管理员私有的问师 Skill 保存在宿主机 `data/private-mentor-skills/`。该目录随 `data` 挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill 只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。 diff --git a/Dockerfile b/Dockerfile index d9dd589..6ce7869 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,10 @@ COPY requirements.txt ./ RUN python -m pip install --no-cache-dir -r requirements.txt COPY --chown=xiaobai:xiaobai . . -RUN mkdir -p /app/data && chown -R xiaobai:xiaobai /app/data +RUN mkdir -p /app/data && chown -R xiaobai:xiaobai /app/data \ + && test -f /app/data/heaven_knowledge.json \ + && test -f /app/data/iching_zh.json \ + && test -f /app/backend/features/heaven/assets/heaven_knowledge.json USER xiaobai diff --git a/backend/features/heaven/assets/heaven_knowledge.json b/backend/features/heaven/assets/heaven_knowledge.json new file mode 100644 index 0000000..402b390 --- /dev/null +++ b/backend/features/heaven/assets/heaven_knowledge.json @@ -0,0 +1,117 @@ +{ + "version": "2026.08.05-5", + "sources": { + "zhouyi": { + "title": "周易经文与十翼", + "scope": "卦辞、爻辞、彖传、象传", + "kind": "public_domain_primary", + "note": "观势与观心只引用本项目已校录的卦爻原文,不把现代网络释文当作原典。" + }, + "jingfang": { + "title": "京氏易传", + "scope": "八宫与纳甲体系来源", + "kind": "public_domain_traditional", + "note": "确定性程序采用京房纳甲、八宫世应的通行排法。" + }, + "huozhulin": { + "title": "火珠林", + "scope": "纳甲筮法、六亲与日月关系", + "kind": "public_domain_traditional", + "note": "用于观心规则脉络,不直接复制后世简化断语。" + }, + "zengshan": { + "title": "增删卜易", + "scope": "用神、世应、动变、日月旺衰", + "kind": "public_domain_traditional", + "note": "只采用可明确编码且有一致输入条件的规则;争议规则单独标记。" + }, + "neijing": { + "title": "黄帝内经·素问运气七篇", + "scope": "五运、司天在泉、主客气与运气关系", + "kind": "public_domain_primary", + "note": "观气将原典关系转成当日自我观察语言,不宣称对股价存在因果作用。" + } + }, + "trend": { + "method": "本卦说明当下结构,实际动爻说明变化关节,之卦说明所趋结构;多动爻全部保留,不以固定口诀删去用户实际得到的爻。", + "rules": { + "stable": "无动爻时以本卦整体、上下卦关系和大象为主,说明结构的延续条件,不把静止等同于永远不变。", + "single": "一爻动时以该爻的时位、爻辞和象辞为变化核心,并用之卦检查变化后的结构。", + "multiple": "多爻动时逐一保留相关爻义,先找共同方向与冲突,再结合之卦给出有条件的倾向;不得用固定套话把不同动爻压成同一结论。" + } + }, + "fortune": { + "principle": "先立中运与司天在泉的年纲,再察当前客气加临主气,最后以日辰说明当日触发;不使用产品权重推导传统结论。", + "movement": { + "太过": "太过表示该运之气偏于有余,解释时同时观察其本气表现与对所胜、所生关系的牵动,不直接等同于吉或凶。", + "不及": "不及表示该运之气偏于不足,解释时同时观察其所不胜来乘与所生受累的可能,不直接等同于弱势结论。" + }, + "qi": { + "厥阴风木": "厥阴取风木之动,侧重疏泄、升发、变化与不定;偏盛时可表现为动摇、急变或升散不收。", + "少阴君火": "少阴取君火之明与热,侧重显化、温煦和内在驱动;偏盛时容易躁热,受制时则显而不畅。", + "太阴湿土": "太阴取湿土之濡与承载,侧重黏滞、蓄积和转化;偏盛时容易困重迟缓,得化时则能承接。", + "少阳相火": "少阳取相火之行与枢转,侧重外达、加速和往来;偏盛时容易浮越躁动,受阻时表现为枢机不利。", + "阳明燥金": "阳明取燥金之收与清肃,侧重收敛、裁决和边界;偏盛时容易干急严峻,得润时则清明有序。", + "太阳寒水": "太阳取寒水之藏与凝,侧重潜藏、收引和下行;偏盛时容易凝滞退缩,得温时则蓄势有根。" + }, + "relations": { + "same": "客主同气表示同类气相并,重点看是否相得而彰,还是同气偏盛而亢;不能机械判为有利。", + "guest_generates_host": "客生主表示来气生助时令本气,气机较易衔接;仍需观察生助是否过度及年纲是否承接。", + "host_generates_guest": "主生客表示时令本气向来气流转,有相生也有外泄;不能只取相生而忽略主气受耗。", + "guest_controls_host": "客克主表示来气制约主气,传统称客胜为从;重点解释外来变化居上及原有节律受制。", + "host_controls_guest": "主克客表示主气制约来气,传统称主胜为逆;重点解释时令与来气相持而不把相克直接断凶。" + }, + "day_trigger": "日辰只说明当日关系如何被触发,不与中运、司天在泉或主客气并列重复计权。", + "industry_boundary": "五行对应行业只作传统取象:可以说明本次已经出现的五行之气对相应行业形成的象征性关注、节奏或约束,但不得读取或猜测行业实时行情,不得预测涨跌,也不得把取象写成投资推荐。", + "personal_boundary": "personal.natal_day_master才是用户本命日主;today_relative_to_natal_day_master中的pillars是当日历法,stem_relations只是当日年、月、日三柱天干相对本命日主的确定性关系标签。只能使用本次检索到的关系释义,不得自行重算十神、扩展五行生克、使用藏干、库气或支的燥湿属性,也不得把当日日柱称为用户命局,或由这些字段推断命局中某一十神偏重、身强身弱或喜用神。", + "personal_relations": { + "比肩": "比肩作为当日天干关系标签,只提示用户可能更在意自主判断、同类比较或坚持原有立场;不能据此判断命局强弱或现实事件。", + "劫财": "劫财作为当日天干关系标签,只提示用户留意精力、注意力或可支配资源在同类事项间的分流与竞争感;不等同于破财或他人争夺。", + "食神": "食神作为当日天干关系标签,只提示用户留意表达、输出、舒缓与完成感;不等同于收益或确定的轻松结果。", + "伤官": "伤官作为当日天干关系标签,只提示用户留意质疑规则、急于表达或追求自主空间的倾向;不等同于冲突或违规。", + "偏财": "偏财作为当日天干关系标签,只提示用户留意机会分配、灵活取舍与非固定资源的吸引力;不等同于意外获利。", + "正财": "正财作为当日天干关系标签,只提示用户更关注可核对的结果、资源边界和务实落地;不等同于必得收益或现金变化。", + "七杀": "七杀作为当日天干关系标签,只提示用户留意紧迫感、外部压力和快速决断冲动;不等同于危险必然发生。", + "正官": "正官作为当日天干关系标签,只提示用户更在意规则、责任、秩序和可交付标准;不等同于结果必然受控。", + "偏印": "偏印作为当日天干关系标签,只提示用户留意内省、非惯常信息和反复推敲的倾向;不等同于退缩、失眠或方向错误。", + "正印": "正印作为当日天干关系标签,只提示用户更在意依据、支持、学习和安全边界;不等同于必然获得帮助。" + } + }, + "heart": { + "presets": { + "trade": "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?", + "mind": "此刻影响我交易判断的情绪、执念或盲点是什么?", + "unthemed": "不设具体问题,只观此刻一念。" + }, + "focus": { + "trade": "以世爻、应爻、妻财爻及实际动变为主要检索对象,同时检查兄弟、官鬼和子孙的生克,不把任何单一六亲固定判吉凶。", + "mind": "以世爻和实际动爻为主,观察官鬼所示压力、子孙所示舒解及内外生克;不把心境问题强行翻译成价格方向。", + "unthemed": "不强选事项用神,以本卦、世爻、实际动爻和之卦作一般观照,不猜测用户没有提出的问题。", + "custom": "先依据用户明确写出的股票交易问题选择相关六亲;无法明确归类时退回世爻、动爻和卦变的一般解释,不擅自补全问题。" + }, + "evidence_order": [ + "用户问题与预设来源", + "本卦及卦宫", + "世应与所问相关六亲", + "月建日辰、旬空及冲合生克", + "实际动爻与变爻", + "之卦与整体卦义", + "六神辅助象义" + ], + "limits": "六神只作辅助象义;空亡、月破、日冲、合冲刑害均需结合用神、世应和动变,不得单项宣布结果。", + "semantics": { + "self_response": "世爻表示求测者当前立场与承受状态,应爻表示所问事项的外部一端或对照面。应爻不是固定的合作方、庄家或资金方;只有用户问题明确给出该角色时,才可作对应解释。", + "calendar": "月建与日辰用于判断爻在起卦时刻的承受、生扶和制约。旬空表示该爻所象征的条件当下可能未落实、难发挥或有名无实,但不能单凭旬空判失败,也不能用填实日期预测何时涨跌或行动。月破、日冲、六合、六冲、六害和相刑同样必须与世应、相关六亲及动变合看。", + "movement": "动爻说明关系正在变化;变爻说明变化后的承接方向。回头生、回头克和原变爻生克只描述力量关系,不自动对应现实中的借贷、融资、合作或某个具体人物。进神退神只说明同类地支变化的进退趋势,不直接宣布价格方向。", + "six_spirits": "六神只补充表达色彩,不单独定成败。青龙不必然有利,白虎不必然紧急或凶险,朱雀不必然等同口舌,玄武不必然等同欺骗,勾陈与螣蛇也不得脱离爻位、六亲和动变独断。", + "timing_boundary": "观心不作应期预测。可以说明某项条件在起卦时刻尚未落实或受制,但不得给出未来若干日、某干支日、出空或填实后必然发生什么。", + "relatives": { + "兄弟": "兄弟是与卦宫五行同类的关系。在股票交易问题中可作为竞争、同类力量或资源分流的候选象义,但不直接等同合作方、亏损或他人拿走资金。", + "子孙": "子孙是卦宫所生的关系,可作为舒缓、产出、执行后的释放或对压力的制衡候选象义,但不直接等同收益、资金提供方或确定的利好。", + "妻财": "妻财是卦宫所克的关系,在股票交易问题中可作为价值、收益预期、持仓利益或可支配资源的候选象义,但不直接等同现金、融资、自有资金或必得之财。", + "官鬼": "官鬼是克制卦宫的关系,可作为压力、风险、规则约束或担忧的候选象义,但不直接等同借贷、坏消息、疾病或必然损失。", + "父母": "父母是生助卦宫的关系,可作为信息、依据、计划、规则、凭据或保护条件的候选象义,但不直接等同政策、合同或某一条消息。" + } + } + } +} diff --git a/backend/features/heaven/http.py b/backend/features/heaven/http.py index 0defe12..1ccda62 100644 --- a/backend/features/heaven/http.py +++ b/backend/features/heaven/http.py @@ -3,15 +3,24 @@ from __future__ import annotations import json from http import HTTPStatus +from backend.features.heaven.knowledge import HeavenKnowledgeError + class HeavenHttpMixin: + def _send_heaven_client_error(self, exc: Exception) -> None: + payload: dict = {"error": str(exc)} + code = getattr(exc, "error_code", None) + if code: + payload["code"] = str(code) + self.send_json(payload, HTTPStatus.BAD_REQUEST) + def heaven_hexagram(self) -> None: try: body = self.read_json_body() result = self.application_service.heaven_hexagram(body.get("lines")) self.send_json({"ok": True, "hexagram": result}) except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + self._send_heaven_client_error(exc) def heaven_personal(self) -> None: try: @@ -19,12 +28,12 @@ class HeavenHttpMixin: result = self.application_service.heaven_personal(body) self.send_json({"ok": True, "personal": result}) except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + self._send_heaven_client_error(exc) def heaven_interpret(self) -> None: try: body = self.read_json_body() result = self.application_service.heaven_interpret(body) self.send_json({"ok": True, **result}) - except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + except (HeavenKnowledgeError, ValueError, json.JSONDecodeError) as exc: + self._send_heaven_client_error(exc) diff --git a/backend/features/heaven/knowledge.py b/backend/features/heaven/knowledge.py index be19dec..6d16e44 100644 --- a/backend/features/heaven/knowledge.py +++ b/backend/features/heaven/knowledge.py @@ -2,12 +2,23 @@ from __future__ import annotations import json from functools import lru_cache +from pathlib import Path from typing import Any from backend.bootstrap.config import APP_DIR KNOWLEDGE_FILE = APP_DIR / "data" / "heaven_knowledge.json" +# Baked into the image outside the ./data bind mount so volume overlay cannot hide it. +KNOWLEDGE_SEED_FILE = Path(__file__).resolve().parent / "assets" / "heaven_knowledge.json" + + +class HeavenKnowledgeError(ValueError): + """Structured knowledge-file failure surfaced to HTTP as Chinese API errors.""" + + def __init__(self, message: str, *, code: str) -> None: + super().__init__(message) + self.error_code = code def prepare_heaven_context(mode: str, calculation: dict[str, Any]) -> dict[str, Any]: @@ -379,9 +390,49 @@ def _line_record(line: dict[str, Any]) -> dict[str, Any]: } +def resolve_heaven_knowledge_path() -> Path: + """Prefer the persisted data-dir file; fall back to the image-baked seed.""" + if KNOWLEDGE_FILE.is_file(): + return KNOWLEDGE_FILE + if KNOWLEDGE_SEED_FILE.is_file(): + return KNOWLEDGE_SEED_FILE + raise HeavenKnowledgeError( + "问天知识文件缺失:未找到 heaven_knowledge.json。" + "请确认宿主机 data 目录或镜像内 seed 文件完整。", + code="heaven_knowledge_missing", + ) + + @lru_cache(maxsize=1) def _knowledge_catalog() -> dict[str, Any]: - payload = json.loads(KNOWLEDGE_FILE.read_text(encoding="utf-8")) + path = resolve_heaven_knowledge_path() + try: + raw = path.read_text(encoding="utf-8") + except OSError as exc: + raise HeavenKnowledgeError( + f"问天知识文件无法读取({path.name}):{exc.strerror or exc}", + code="heaven_knowledge_missing", + ) from exc + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise HeavenKnowledgeError( + f"问天知识文件 JSON 损坏({path.name}),无法解析:" + f"第 {exc.lineno} 行附近。", + code="heaven_knowledge_invalid", + ) from exc + if not isinstance(payload, dict): + raise HeavenKnowledgeError( + f"问天知识文件格式不正确({path.name}):根节点必须是对象。", + code="heaven_knowledge_invalid", + ) if not payload.get("version") or not isinstance(payload.get("sources"), dict): - raise ValueError("问天知识库格式不完整。") + raise HeavenKnowledgeError( + f"问天知识库格式不完整({path.name}):缺少 version 或 sources。", + code="heaven_knowledge_invalid", + ) return payload + + +def clear_heaven_knowledge_cache() -> None: + _knowledge_catalog.cache_clear() diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index e725ad6..e5d77ce 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -446,8 +446,8 @@ }, { "path": "frontend/pages/heaven/page.js", - "bytes": 97189, - "lines": 2069 + "bytes": 97268, + "lines": 2070 }, { "path": "frontend/shared/shell.css", diff --git a/frontend/pages/heaven/page.js b/frontend/pages/heaven/page.js index f3b41ef..ff108bb 100644 --- a/frontend/pages/heaven/page.js +++ b/frontend/pages/heaven/page.js @@ -1363,7 +1363,8 @@ async function interpretHeaven(mode) { } catch (error) { stopHeavenReadingAnimation(); state.heavenReadingLoading = false; - state.heavenReadingError = error.message || "问天解读失败"; + const detail = error?.payload?.message || error?.payload?.error || error.message; + state.heavenReadingError = detail || "问天解读失败"; renderHeavenReadingDialog(); showHeavenNotice(state.heavenReadingError); showToast(state.heavenReadingError); diff --git a/frontend/shared/api.js b/frontend/shared/api.js index 262a41d..1d68232 100644 --- a/frontend/shared/api.js +++ b/frontend/shared/api.js @@ -46,12 +46,29 @@ } } + function readableRequestError(error) { + const message = String(error?.message || ""); + if ( + error instanceof TypeError + || /failed to fetch|networkerror|load failed|network request failed/i.test(message) + ) { + return "网络请求失败,服务暂时不可用,请稍后重试。"; + } + return message || "请求失败"; + } + async function request(url, method = "GET", body = null, options = {}) { - const response = await fetch(url, requestOptions(method, body, options.signal)); + let response; + try { + response = await fetch(url, requestOptions(method, body, options.signal)); + } catch (error) { + throw new ApiError(readableRequestError(error), 0, null); + } const payload = await parseJson(response); handleUnauthorized(response, url); if (!response.ok || payload.error) { - throw new ApiError(payload.error || "请求失败", response.status, payload); + const message = payload.message || payload.error || "请求失败"; + throw new ApiError(message, response.status, payload); } return payload; } diff --git a/tests/test_heaven_knowledge.py b/tests/test_heaven_knowledge.py index 4264086..abf35ae 100644 --- a/tests/test_heaven_knowledge.py +++ b/tests/test_heaven_knowledge.py @@ -1,13 +1,162 @@ from __future__ import annotations +import json +import tempfile import unittest +from http import HTTPStatus +from pathlib import Path +from unittest.mock import patch from backend.features.heaven.engine import build_five_phase_field, hexagram_from_lines -from backend.features.heaven.knowledge import prepare_heaven_context +from backend.features.heaven.http import HeavenHttpMixin +from backend.features.heaven.knowledge import ( + HeavenKnowledgeError, + clear_heaven_knowledge_cache, + prepare_heaven_context, + resolve_heaven_knowledge_path, + _knowledge_catalog, +) from backend.features.heaven.six_yao import build_six_yao_chart class HeavenKnowledgeTests(unittest.TestCase): + def tearDown(self) -> None: + clear_heaven_knowledge_cache() + + def test_catalog_loads_from_trusted_repo_file(self): + clear_heaven_knowledge_cache() + path = resolve_heaven_knowledge_path() + catalog = _knowledge_catalog() + self.assertTrue(path.is_file()) + self.assertEqual(path.name, "heaven_knowledge.json") + self.assertTrue(str(catalog.get("version") or "").startswith("2026.")) + self.assertIn("zhouyi", catalog["sources"]) + self.assertIn("neijing", catalog["sources"]) + self.assertEqual(len(catalog["fortune"]["qi"]), 6) + self.assertEqual(len(catalog["fortune"]["personal_relations"]), 10) + + def test_missing_knowledge_file_raises_chinese_structured_error(self): + clear_heaven_knowledge_cache() + missing = Path(tempfile.mkdtemp()) / "missing-heaven_knowledge.json" + with patch( + "backend.features.heaven.knowledge.KNOWLEDGE_FILE", missing + ), patch( + "backend.features.heaven.knowledge.KNOWLEDGE_SEED_FILE", + missing.with_name("missing-seed.json"), + ): + with self.assertRaises(HeavenKnowledgeError) as raised: + _knowledge_catalog() + self.assertEqual(raised.exception.error_code, "heaven_knowledge_missing") + self.assertIn("缺失", str(raised.exception)) + + def test_corrupt_knowledge_json_raises_chinese_structured_error(self): + clear_heaven_knowledge_cache() + with tempfile.TemporaryDirectory() as temp_dir: + broken = Path(temp_dir) / "heaven_knowledge.json" + broken.write_text("{not-json", encoding="utf-8") + with patch( + "backend.features.heaven.knowledge.KNOWLEDGE_FILE", broken + ), patch( + "backend.features.heaven.knowledge.KNOWLEDGE_SEED_FILE", + Path(temp_dir) / "unused-seed.json", + ): + with self.assertRaises(HeavenKnowledgeError) as raised: + _knowledge_catalog() + self.assertEqual(raised.exception.error_code, "heaven_knowledge_invalid") + self.assertIn("损坏", str(raised.exception)) + + def test_interpret_http_returns_structured_chinese_error_for_missing_file(self): + class FakeHandler(HeavenHttpMixin): + def __init__(self) -> None: + self.payload = None + self.status = None + self.application_service = type( + "Svc", + (), + { + "heaven_interpret": staticmethod( + lambda _body: (_ for _ in ()).throw( + HeavenKnowledgeError( + "问天知识文件缺失:未找到 heaven_knowledge.json。", + code="heaven_knowledge_missing", + ) + ) + ) + }, + )() + + def read_json_body(self): + return {"mode": "trend", "trade_date": "2026-08-04"} + + def send_json(self, payload, status=HTTPStatus.OK, headers=None): + self.payload = payload + self.status = status + + handler = FakeHandler() + handler.heaven_interpret() + self.assertEqual(handler.status, HTTPStatus.BAD_REQUEST) + self.assertIn("缺失", handler.payload["error"]) + self.assertEqual(handler.payload["code"], "heaven_knowledge_missing") + + def test_interpret_http_returns_structured_chinese_error_for_corrupt_json(self): + class FakeHandler(HeavenHttpMixin): + def __init__(self) -> None: + self.payload = None + self.status = None + self.application_service = type( + "Svc", + (), + { + "heaven_interpret": staticmethod( + lambda _body: (_ for _ in ()).throw( + HeavenKnowledgeError( + "问天知识文件 JSON 损坏(heaven_knowledge.json),无法解析:第 1 行附近。", + code="heaven_knowledge_invalid", + ) + ) + ) + }, + )() + + def read_json_body(self): + return {"mode": "trend", "trade_date": "2026-08-04"} + + def send_json(self, payload, status=HTTPStatus.OK, headers=None): + self.payload = payload + self.status = status + + handler = FakeHandler() + handler.heaven_interpret() + self.assertEqual(handler.status, HTTPStatus.BAD_REQUEST) + self.assertIn("损坏", handler.payload["error"]) + self.assertEqual(handler.payload["code"], "heaven_knowledge_invalid") + + def test_seed_fallback_when_data_file_missing(self): + clear_heaven_knowledge_cache() + with tempfile.TemporaryDirectory() as temp_dir: + seed = Path(temp_dir) / "seed.json" + seed.write_text( + json.dumps( + { + "version": "test-seed", + "sources": {"zhouyi": {"title": "周易"}}, + "trend": {"method": "m", "rules": {"stable": "s", "single": "a", "multiple": "b"}}, + "fortune": {}, + "heart": {}, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + missing_data = Path(temp_dir) / "data-heaven_knowledge.json" + with patch( + "backend.features.heaven.knowledge.KNOWLEDGE_FILE", missing_data + ), patch( + "backend.features.heaven.knowledge.KNOWLEDGE_SEED_FILE", seed + ): + catalog = _knowledge_catalog() + self.assertEqual(catalog["version"], "test-seed") + def test_fortune_context_excludes_weighted_summary_and_adds_bounded_industry_symbols(self): field = build_five_phase_field("2026-08-04") prepared = prepare_heaven_context( From 7ad445bc9f5c7ad4103d5c4f70d7a1415a516e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=98?= <4846a27a-ba87-49c9-939d-7275f63efcc7@agents.multica.local> Date: Thu, 27 Aug 2026 14:50:32 +0000 Subject: [PATCH 04/21] =?UTF-8?q?fix(HEL-190):=20=E6=8C=89=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E4=BA=A4=E6=98=93=E6=97=A5=E5=8E=86=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=9C=80=E8=BF=9160=E6=97=A5=E5=BF=AB=E7=85=A7=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=96=AD=E6=A1=A3=E5=90=8E=E5=8F=AA=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=BD=93=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留连续性过滤,新增可审计补档工具与备份步骤;周末/节假日与真缺档分开处理,支持重复执行与部分失败续跑。 Co-authored-by: Cursor Co-authored-by: multica-agent --- backend/features/market/backfill_history.py | 202 +++++++++++++ backend/features/market/repository.py | 25 ++ backend/features/market/service.py | 250 +++++++++++++-- backend/features/system/routes.py | 10 +- config/architecture-inventory.json | 14 +- docs/maintenance/行情历史补档.md | 69 +++++ frontend/shared/admin.js | 9 +- tests/test_snapshot_backfill.py | 317 ++++++++++++++++++++ tools/README.md | 3 + tools/backfill_recent_snapshots.py | 106 +++++++ 10 files changed, 974 insertions(+), 31 deletions(-) create mode 100644 backend/features/market/backfill_history.py create mode 100644 docs/maintenance/行情历史补档.md create mode 100644 tests/test_snapshot_backfill.py create mode 100644 tools/backfill_recent_snapshots.py diff --git a/backend/features/market/backfill_history.py b/backend/features/market/backfill_history.py new file mode 100644 index 0000000..29f06b5 --- /dev/null +++ b/backend/features/market/backfill_history.py @@ -0,0 +1,202 @@ +"""Auditable recent-trading-day snapshot backfill helpers. + +Planning and backup stay free of provider imports so feature boundary tests remain green. +The service layer supplies open trading dates from the live calendar and executes sync. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any, Iterable + + +MAX_RANGE_TRADING_DAYS = 15 +MAX_RECENT_TRADING_DAYS = 60 +DEFAULT_RECENT_TRADING_DAYS = 60 + +# Tables touched by a successful historical dashboard sync. User / token / model +# tables must never appear here. +SNAPSHOT_BACKFILL_WRITE_TABLES = frozenset( + { + "dashboard_snapshots", + "data_snapshots", + "sync_runs", + } +) + + +def clamp_recent_lookback(lookback: int) -> int: + value = int(lookback) + if value < 1: + raise ValueError("回补交易日数量至少为 1。") + if value > MAX_RECENT_TRADING_DAYS: + raise ValueError(f"单次最多回补最近 {MAX_RECENT_TRADING_DAYS} 个交易日。") + return value + + +def calendar_window_start(end_date: str, lookback: int) -> str: + """Natural-day lower bound large enough to cover lookback open sessions.""" + end = datetime.strptime(end_date, "%Y%m%d").date() + span = max(40, int(lookback * 2) + 20) + return (end - timedelta(days=span)).strftime("%Y%m%d") + + +def select_open_trade_dates( + calendar_rows: Iterable[dict[str, Any]], + end_date: str, + lookback: int, +) -> list[str]: + """Pick the last ``lookback`` open SSE sessions on or before ``end_date``.""" + lookback = clamp_recent_lookback(lookback) + end = normalize_compact_date(end_date) + open_dates = sorted( + { + normalize_compact_date(str(row.get("cal_date") or "")) + for row in calendar_rows + if int(row.get("is_open") or 0) == 1 and row.get("cal_date") + } + ) + open_dates = [item for item in open_dates if item <= end] + if not open_dates: + raise ValueError("交易日历未返回可用交易日,请检查行情 Token。") + return open_dates[-lookback:] + + +def select_open_trade_dates_in_range( + calendar_rows: Iterable[dict[str, Any]], + start_date: str, + end_date: str, + *, + maximum: int = MAX_RANGE_TRADING_DAYS, +) -> tuple[list[str], list[str]]: + """Return (open_dates, skipped_non_trading_days) inside an inclusive range.""" + start = normalize_compact_date(start_date) + end = normalize_compact_date(end_date) + if start > end: + raise ValueError("开始日期不能晚于结束日期。") + open_set = { + normalize_compact_date(str(row.get("cal_date") or "")) + for row in calendar_rows + if int(row.get("is_open") or 0) == 1 and row.get("cal_date") + } + open_dates: list[str] = [] + skipped: list[str] = [] + cursor = datetime.strptime(start, "%Y%m%d").date() + last = datetime.strptime(end, "%Y%m%d").date() + while cursor <= last: + compact = cursor.strftime("%Y%m%d") + if compact in open_set: + open_dates.append(compact) + else: + skipped.append(compact) + cursor += timedelta(days=1) + if len(open_dates) > maximum: + raise ValueError(f"单次最多回补 {maximum} 个交易日。") + return open_dates, skipped + + +def classify_snapshot_coverage( + trade_dates: list[str], + existing_dates: Iterable[str], +) -> dict[str, Any]: + present_set = { + normalize_compact_date(item) + for item in existing_dates + if item + } + present = [item for item in trade_dates if item in present_set] + missing = [item for item in trade_dates if item not in present_set] + return { + "trade_dates": list(trade_dates), + "present": present, + "missing": missing, + "present_count": len(present), + "missing_count": len(missing), + } + + +def create_sqlite_backup( + source_path: Path, + backup_dir: Path, + *, + label: str = "pre-backfill", + stamped_at: datetime | None = None, +) -> Path: + """Create a timestamped SQLite backup via the native backup API.""" + source = Path(source_path) + if not source.exists(): + raise FileNotFoundError(f"数据库不存在:{source}") + stamp = (stamped_at or datetime.now().astimezone()).strftime("%Y%m%d-%H%M%S") + safe_label = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in label).strip("-") or "backup" + backup_dir = Path(backup_dir) + backup_dir.mkdir(parents=True, exist_ok=True) + target = backup_dir / f"review-{safe_label}-{stamp}.db" + source_conn = sqlite3.connect(f"file:{source}?mode=ro", uri=True) + try: + target_conn = sqlite3.connect(target) + try: + source_conn.backup(target_conn) + target_conn.commit() + finally: + target_conn.close() + finally: + source_conn.close() + return target + + +def display_date(compact: str) -> str: + value = normalize_compact_date(compact) + return f"{value[:4]}-{value[4:6]}-{value[6:8]}" + + +def normalize_compact_date(value: str) -> str: + compact = str(value or "").replace("-", "").strip() + if len(compact) != 8 or not compact.isdigit(): + raise ValueError("日期格式应为 YYYY-MM-DD。") + datetime.strptime(compact, "%Y%m%d") + return compact + + +def build_backfill_audit( + *, + mode: str, + end_date: str, + lookback: int | None, + coverage: dict[str, Any], + skipped_non_trading_days: list[str] | None = None, + backup_path: str | None = None, + dry_run: bool = False, + results: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + results = list(results or []) + succeeded = [row for row in results if row.get("status") == "success"] + skipped = [row for row in results if row.get("status") == "skipped"] + failed = [row for row in results if row.get("status") == "failed"] + return { + "ok": not failed, + "mode": mode, + "dry_run": dry_run, + "end_date": display_date(end_date), + "lookback": lookback, + "backup_path": backup_path, + "write_tables": sorted(SNAPSHOT_BACKFILL_WRITE_TABLES), + "trade_dates": [display_date(item) for item in coverage.get("trade_dates") or []], + "present": [display_date(item) for item in coverage.get("present") or []], + "missing": [display_date(item) for item in coverage.get("missing") or []], + "skipped_non_trading_days": [ + display_date(item) for item in (skipped_non_trading_days or []) + ], + "present_count": int(coverage.get("present_count") or 0), + "missing_count": int(coverage.get("missing_count") or 0), + "results": results, + "succeeded_count": len(succeeded), + "skipped_count": len(skipped), + "failed_count": len(failed), + "created_dates": [ + str(row.get("trade_date") or "") + for row in succeeded + if row.get("action") == "created" + ], + } diff --git a/backend/features/market/repository.py b/backend/features/market/repository.py index 7492e41..15c4828 100644 --- a/backend/features/market/repository.py +++ b/backend/features/market/repository.py @@ -227,6 +227,31 @@ class MarketRepositoryMixin: result.append(payload) return result + def list_snapshot_trade_dates( + self, + start_date: str = "", + end_date: str = "", + ) -> list[str]: + clauses: list[str] = [] + parameters: list[Any] = [] + if start_date: + clauses.append("trade_date >= ?") + parameters.append(start_date) + if end_date: + clauses.append("trade_date <= ?") + parameters.append(end_date) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT trade_date FROM dashboard_snapshots + {where} + ORDER BY trade_date + """, + parameters, + ).fetchall() + return [str(row["trade_date"]) for row in rows] + def start_sync(self, trade_date: str, source: str) -> int: started_at = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: diff --git a/backend/features/market/service.py b/backend/features/market/service.py index 935037e..545d867 100644 --- a/backend/features/market/service.py +++ b/backend/features/market/service.py @@ -3,9 +3,11 @@ from __future__ import annotations import copy import re from datetime import date, datetime, time as dt_time, timedelta +from pathlib import Path from typing import Any from backend.bootstrap.config import ( + DATA_DIR, normalize_date, tushare_code, validate_stock_code, @@ -13,6 +15,17 @@ from backend.bootstrap.config import ( ) from backend.data.providers.ifind_client import IfindError from backend.data.providers.tushare_client import TushareClient, TushareError +from backend.features.market.backfill_history import ( + DEFAULT_RECENT_TRADING_DAYS, + MAX_RANGE_TRADING_DAYS, + build_backfill_audit, + calendar_window_start, + classify_snapshot_coverage, + create_sqlite_backup, + display_date, + select_open_trade_dates, + select_open_trade_dates_in_range, +) from backend.features.market.charts import ChartDataError from backend.features.market.insights import MarketInsightsService from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION @@ -890,31 +903,226 @@ class MarketServiceMixin: "intraday": intraday_points, } - def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]: - start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date() - end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date() - if start > end: - raise ValueError("开始日期不能晚于结束日期。") - weekdays = [] - current = start - while current <= end: - if current.weekday() < 5: - weekdays.append(current) - current += timedelta(days=1) - if len(weekdays) > 15: - raise ValueError("单次最多回补 15 个工作日。") - results = [] - for day in weekdays: - dashboard = self.sync_dashboard(day.strftime("%Y%m%d")) + def backfill( + self, + start_date: str = "", + end_date: str = "", + *, + lookback: int | None = None, + dry_run: bool = False, + force: bool = False, + create_backup: bool = True, + ) -> dict[str, Any]: + """Backfill dashboard snapshots for real trading days only. + + - Date-range mode keeps the admin UI contract (max 15 open sessions). + - Recent mode fills the last N open sessions (default/max 60). + Weekends and holidays are reported as skipped non-trading days, not errors. + """ + if not self.configured: + raise ValueError("公共行情尚未配置,无法回补历史快照。") + normalized_end = normalize_date(end_date or date.today().isoformat()) + if lookback is not None or not (start_date and end_date): + target_lookback = ( + DEFAULT_RECENT_TRADING_DAYS if lookback is None else int(lookback) + ) + return self.backfill_recent_trading_days( + end_date=normalized_end, + lookback=target_lookback, + dry_run=dry_run, + force=force, + create_backup=create_backup, + ) + return self._backfill_date_range( + start_date=normalize_date(start_date), + end_date=normalized_end, + dry_run=dry_run, + force=force, + create_backup=create_backup, + ) + + def backfill_recent_trading_days( + self, + end_date: str = "", + lookback: int = DEFAULT_RECENT_TRADING_DAYS, + *, + dry_run: bool = False, + force: bool = False, + create_backup: bool = True, + ) -> dict[str, Any]: + normalized_end = normalize_date(end_date or date.today().isoformat()) + trade_dates = self._load_recent_open_trade_dates(normalized_end, lookback) + existing = self.database.list_snapshot_trade_dates( + trade_dates[0], trade_dates[-1] + ) + coverage = classify_snapshot_coverage(trade_dates, existing) + return self._execute_snapshot_backfill( + mode="recent", + end_date=normalized_end, + lookback=lookback, + coverage=coverage, + skipped_non_trading_days=[], + dry_run=dry_run, + force=force, + create_backup=create_backup, + ) + + def _backfill_date_range( + self, + start_date: str, + end_date: str, + *, + dry_run: bool = False, + force: bool = False, + create_backup: bool = True, + ) -> dict[str, Any]: + window_start = calendar_window_start(end_date, MAX_RANGE_TRADING_DAYS) + calendar_rows = self._tushare_client().query( + "trade_cal", + { + "exchange": "SSE", + "start_date": min(window_start, start_date), + "end_date": end_date, + }, + "cal_date,is_open,pretrade_date", + ) + trade_dates, skipped = select_open_trade_dates_in_range( + calendar_rows, + start_date, + end_date, + maximum=MAX_RANGE_TRADING_DAYS, + ) + if not trade_dates: + raise ValueError("选定区间内没有交易日,周末或节假日无需回补。") + existing = self.database.list_snapshot_trade_dates(trade_dates[0], trade_dates[-1]) + coverage = classify_snapshot_coverage(trade_dates, existing) + return self._execute_snapshot_backfill( + mode="range", + end_date=end_date, + lookback=None, + coverage=coverage, + skipped_non_trading_days=skipped, + dry_run=dry_run, + force=force, + create_backup=create_backup, + ) + + def _load_recent_open_trade_dates(self, end_date: str, lookback: int) -> list[str]: + start_date = calendar_window_start(end_date, lookback) + calendar_rows = self._tushare_client().query( + "trade_cal", + { + "exchange": "SSE", + "start_date": start_date, + "end_date": end_date, + }, + "cal_date,is_open,pretrade_date", + ) + return select_open_trade_dates(calendar_rows, end_date, lookback) + + def _execute_snapshot_backfill( + self, + *, + mode: str, + end_date: str, + lookback: int | None, + coverage: dict[str, Any], + skipped_non_trading_days: list[str], + dry_run: bool, + force: bool, + create_backup: bool, + ) -> dict[str, Any]: + targets = list(coverage["trade_dates"] if force else coverage["missing"]) + backup_path: str | None = None + if create_backup and not dry_run and targets: + backup = create_sqlite_backup( + Path(self.database.path), + DATA_DIR / "backups", + label=f"pre-{mode}-backfill", + ) + backup_path = str(backup) + + results: list[dict[str, Any]] = [] + if dry_run: + for trade_date in coverage["trade_dates"]: + exists = trade_date in coverage["present"] + if exists and not force: + status = "skipped" + action = "exists" + else: + status = "planned" + action = "refresh" if exists else "create" + results.append( + { + "requested_date": display_date(trade_date), + "trade_date": display_date(trade_date), + "status": status, + "action": action, + } + ) + return build_backfill_audit( + mode=mode, + end_date=end_date, + lookback=lookback, + coverage=coverage, + skipped_non_trading_days=skipped_non_trading_days, + backup_path=backup_path, + dry_run=True, + results=results, + ) + + present_before = set(coverage["present"]) + for trade_date in targets: + existed = trade_date in present_before + try: + dashboard = self.sync_dashboard(trade_date) + actual = normalize_date( + str(dashboard.get("meta", {}).get("trade_date") or trade_date) + ) + results.append( + { + "requested_date": display_date(trade_date), + "trade_date": display_date(actual), + "status": "success", + "action": "refreshed" if existed else "created", + "source": dashboard.get("meta", {}).get("source"), + "records": self._record_count(dashboard), + } + ) + except Exception as exc: + results.append( + { + "requested_date": display_date(trade_date), + "trade_date": display_date(trade_date), + "status": "failed", + "action": "refresh" if existed else "create", + "error": str(exc), + } + ) + + for trade_date in coverage["present"]: + if force: + continue results.append( { - "requested_date": day.isoformat(), - "trade_date": dashboard["meta"]["trade_date"], - "source": dashboard["meta"]["source"], - "records": self._record_count(dashboard), + "requested_date": display_date(trade_date), + "trade_date": display_date(trade_date), + "status": "skipped", + "action": "exists", } ) - return results + + results.sort(key=lambda row: str(row.get("requested_date") or "")) + return build_backfill_audit( + mode=mode, + end_date=end_date, + lookback=lookback, + coverage=coverage, + skipped_non_trading_days=skipped_non_trading_days, + backup_path=backup_path, + dry_run=False, + results=results, + ) def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]: snapshot = self.database.get_snapshot(trade_date) or {} diff --git a/backend/features/system/routes.py b/backend/features/system/routes.py index 5956cc9..1e0d840 100644 --- a/backend/features/system/routes.py +++ b/backend/features/system/routes.py @@ -29,11 +29,17 @@ class SystemRoutesMixin: def backfill_data(self) -> None: try: body = self.read_json_body() - results = self.application_service.backfill( + lookback_raw = body.get("lookback") + lookback = int(lookback_raw) if lookback_raw not in (None, "") else None + audit = self.application_service.backfill( str(body.get("start_date") or ""), str(body.get("end_date") or ""), + lookback=lookback, + dry_run=bool(body.get("dry_run")), + force=bool(body.get("force")), + create_backup=body.get("create_backup", True) is not False, ) - self.send_json({"ok": True, "results": results}) + self.send_json({"ok": True, **audit, "results": audit.get("results") or []}) except ValueError as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) except Exception as exc: diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index e725ad6..ba3cc5d 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -541,8 +541,8 @@ }, { "path": "frontend/shared/admin.js", - "bytes": 14145, - "lines": 261 + "bytes": 14410, + "lines": 268 }, { "path": "backend/features/heaven/market_context.py", @@ -799,6 +799,11 @@ "bytes": 1919, "lines": 45 }, + { + "path": "backend/features/system/routes.py", + "bytes": 1791, + "lines": 46 + }, { "path": "backend/jobs/service.py", "bytes": 1746, @@ -829,11 +834,6 @@ "bytes": 1455, "lines": 48 }, - { - "path": "backend/features/system/routes.py", - "bytes": 1423, - "lines": 40 - }, { "path": "backend/features/themes/routes.py", "bytes": 1337, diff --git a/docs/maintenance/行情历史补档.md b/docs/maintenance/行情历史补档.md new file mode 100644 index 0000000..5425c56 --- /dev/null +++ b/docs/maintenance/行情历史补档.md @@ -0,0 +1,69 @@ +# 行情历史补档(最近 60 个交易日) + +用于修复 `dashboard_snapshots` 断档导致情绪周期 / 主题轮动 / 智能选股只剩当天的问题。 +保留 `latest_contiguous_history` 连续性规则;通过真实交易日历回补缺失交易日快照。 + +## 适用场景 + +- 库中已有稀疏历史快照,但最近一个真实交易日缺失,接口 `available_days=1`。 +- 需要可重复执行、可审计、可回退的补档,而不是迁库或放宽算法。 + +## 前置 + +1. 使用与线上一致的代码分支。 +2. 管理员账号已配置可用的公共 Tushare Token。 +3. 只操作目标环境自己的 `data/review.db`;禁止 `.36` 与 `.11` 互拷。 + +## 上线步骤(总工执行) + +在应用根目录: + +```bash +# 1) 只读规划:区分已有、真正缺档;不会写入 +python tools/backfill_recent_snapshots.py --account <管理员账号> --lookback 60 --dry-run --json + +# 2) 正式补档:先走 SQLite backup API 写 data/backups/review-pre-recent-backfill-*.db +# 再对缺失交易日调用现有 sync_dashboard +python tools/backfill_recent_snapshots.py --account <管理员账号> --lookback 60 --json + +# 3) 验证 +# GET /api/sentiment/history?trade_date=YYYY-MM-DD&limit=60 +# 期望 available_days >= 20,且不再只有 1 天 +``` + +管理端日期区间回补(`/api/backfill`)已改为只处理交易日历中的开市日,周末/节假日会进入 +`skipped_non_trading_days`,不再当成错误;单次仍限制 15 个交易日。最近 60 日请用本工具。 + +## 写入边界 + +只会通过现有同步路径写入: + +- `dashboard_snapshots` +- 同步审计表 `sync_runs` +- 必要时的 `data_snapshots`(仅当请求日被解析到其他交易日) + +不得改动用户、Token、模型绑定或系统配置表。 + +## 回滚 + +1. 优先按审计结果的 `created_dates` 精确删除新增行: + +```sql +DELETE FROM dashboard_snapshots WHERE trade_date IN ('YYYYMMDD', ...); +``` + +2. 若需整库回退,停止写入后用补档前备份覆盖: + +```bash +# 示例:把 data/backups/review-pre-recent-backfill-YYYYMMDD-HHMMSS.db +# 复制回 data/review.db 后重启容器 +``` + +3. 代码回退:对该提交执行 Git revert 后重新部署镜像。 + +## 验收要点 + +- dry-run 与正式执行可重复跑;已有交易日默认跳过。 +- 周末、节假日出现在 `skipped_non_trading_days`,不计入失败。 +- 部分交易日同步失败时,其他日期仍会继续,并在审计结果中标 `failed`。 +- 情绪周期、主题轮动 9 列、智能选股置信度随连续交易日恢复。 diff --git a/frontend/shared/admin.js b/frontend/shared/admin.js index 50dbbd2..cebe715 100644 --- a/frontend/shared/admin.js +++ b/frontend/shared/admin.js @@ -7,7 +7,14 @@ async function backfillData() { start_date: document.querySelector("#backfillStart").value, end_date: document.querySelector("#backfillEnd").value, }); - showToast(`历史回补完成,共处理 ${payload.results.length} 个工作日`); + const failed = (payload.failed_count || 0); + const skipped = (payload.skipped_non_trading_days || []).length; + const suffix = failed + ? `,失败 ${failed} 个` + : skipped + ? `,跳过 ${skipped} 个非交易日` + : ""; + showToast(`历史回补完成,共处理 ${payload.results.length} 个交易日${suffix}`); state.sentimentHistory = null; state.sentimentHistoryKey = ""; if (state.activeView === "sentimentCycleView") { diff --git a/tests/test_snapshot_backfill.py b/tests/test_snapshot_backfill.py new file mode 100644 index 0000000..a0b0b0e --- /dev/null +++ b/tests/test_snapshot_backfill.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import json +import tempfile +import threading +import unittest +from datetime import datetime +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from backend.features.market.backfill_history import ( + build_backfill_audit, + classify_snapshot_coverage, + create_sqlite_backup, + select_open_trade_dates, + select_open_trade_dates_in_range, +) +from backend.features.market.service import MarketServiceMixin +from backend.features.sentiment.engine import ( + build_sentiment_history, + latest_contiguous_history, +) +from backend.features.sentiment.service import SentimentServiceMixin +from database import ReviewDatabase + + +def _snapshot(trade_date: str, previous_trade_date: str) -> dict[str, Any]: + display = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}" + previous_display = ( + f"{previous_trade_date[:4]}-{previous_trade_date[4:6]}-{previous_trade_date[6:8]}" + if previous_trade_date + else "" + ) + return { + "meta": { + "trade_date": display, + "previous_trade_date": previous_display, + "source": "tushare", + }, + "overview": { + "up_count": 2500, + "down_count": 2000, + "flat_count": 100, + "amount_billion": 12000, + "limit_up_count": 40, + "limit_down_count": 5, + "broken_count": 10, + "seal_rate": 70, + "max_height": 3, + "second_board_count": 8, + "three_plus_count": 4, + "previous_limit_count": 35, + "previous_positive_rate": 55, + "average_previous_change": 1.2, + "median_previous_change": 0.8, + "advance_rate": 20, + "severe_loss_rate": 5, + "previous_down_count": 3, + "ladder_completeness": 60, + "limit_amount_billion": 300, + }, + "limits": [{"code": "000001"}], + "broken": [], + "down_limits": [], + "yesterday_limits": [], + } + + +class _BackfillHarness(MarketServiceMixin, SentimentServiceMixin): + def __init__(self, database: ReviewDatabase) -> None: + self.database = database + self.sync_lock = threading.Lock() + self.configured = True + self.token = "test-token" + self.current_user_id = 1 + self._calendar_rows: list[dict[str, Any]] = [] + self._fail_dates: set[str] = set() + self.sync_calls: list[str] = [] + + def _tushare_client(self): # type: ignore[override] + harness = self + + class _Client: + def query(self, api_name, params, fields=""): + assert api_name == "trade_cal" + start = str(params["start_date"]) + end = str(params["end_date"]) + return [ + row + for row in harness._calendar_rows + if start <= str(row["cal_date"]) <= end + ] + + return _Client() + + def sync_dashboard(self, trade_date: str) -> dict[str, Any]: # type: ignore[override] + compact = trade_date.replace("-", "") + self.sync_calls.append(compact) + if compact in self._fail_dates: + raise ValueError(f"simulated failure for {compact}") + previous = "" + for row in self._calendar_rows: + if str(row["cal_date"]) == compact: + previous = str(row.get("pretrade_date") or "") + break + payload = _snapshot(compact, previous) + self.database.save_snapshot(compact, "tushare", payload) + return payload + + def _apply_reason_overrides(self, dashboard: dict[str, Any]) -> dict[str, Any]: + return dashboard + + def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]: + return dashboard + + +class BackfillHistoryHelperTests(unittest.TestCase): + def test_select_open_trade_dates_skips_weekends_and_holidays(self) -> None: + rows = [ + {"cal_date": "20260821", "is_open": 1, "pretrade_date": "20260820"}, + {"cal_date": "20260822", "is_open": 0, "pretrade_date": "20260821"}, # Sat + {"cal_date": "20260823", "is_open": 0, "pretrade_date": "20260821"}, # Sun + {"cal_date": "20260824", "is_open": 1, "pretrade_date": "20260821"}, + {"cal_date": "20260825", "is_open": 1, "pretrade_date": "20260824"}, + {"cal_date": "20260826", "is_open": 1, "pretrade_date": "20260825"}, + {"cal_date": "20260827", "is_open": 1, "pretrade_date": "20260826"}, + ] + selected = select_open_trade_dates(rows, "20260827", 4) + self.assertEqual(selected, ["20260824", "20260825", "20260826", "20260827"]) + + def test_range_mode_reports_non_trading_days_separately(self) -> None: + rows = [ + {"cal_date": "20260821", "is_open": 1}, + {"cal_date": "20260824", "is_open": 1}, + ] + open_dates, skipped = select_open_trade_dates_in_range( + rows, "20260821", "20260824" + ) + self.assertEqual(open_dates, ["20260821", "20260824"]) + self.assertEqual(skipped, ["20260822", "20260823"]) + + def test_classify_snapshot_coverage_finds_real_gaps(self) -> None: + coverage = classify_snapshot_coverage( + ["20260824", "20260825", "20260826", "20260827"], + ["20260824", "20260827"], + ) + self.assertEqual(coverage["missing"], ["20260825", "20260826"]) + self.assertEqual(coverage["present"], ["20260824", "20260827"]) + + +class ContiguousHistoryGapTests(unittest.TestCase): + def test_missing_previous_trade_day_collapses_to_today(self) -> None: + payloads = [ + _snapshot("20260824", "20260821"), + _snapshot("20260827", "20260826"), # gap: 20260826 missing + ] + series = latest_contiguous_history(build_sentiment_history(payloads)) + self.assertEqual([row["trade_date"] for row in series], ["20260827"]) + + def test_continuous_history_keeps_full_tail(self) -> None: + payloads = [ + _snapshot("20260825", "20260824"), + _snapshot("20260826", "20260825"), + _snapshot("20260827", "20260826"), + ] + series = latest_contiguous_history(build_sentiment_history(payloads)) + self.assertEqual( + [row["trade_date"] for row in series], + ["20260825", "20260826", "20260827"], + ) + + +class SnapshotBackfillServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.db_path = Path(self.temporary.name) / "review.db" + self.database = ReviewDatabase(self.db_path) + self.service = _BackfillHarness(self.database) + self.service._calendar_rows = [ + {"cal_date": "20260820", "is_open": 1, "pretrade_date": "20260819"}, + {"cal_date": "20260821", "is_open": 1, "pretrade_date": "20260820"}, + {"cal_date": "20260822", "is_open": 0, "pretrade_date": "20260821"}, + {"cal_date": "20260823", "is_open": 0, "pretrade_date": "20260821"}, + {"cal_date": "20260824", "is_open": 1, "pretrade_date": "20260821"}, + {"cal_date": "20260825", "is_open": 1, "pretrade_date": "20260824"}, + {"cal_date": "20260826", "is_open": 1, "pretrade_date": "20260825"}, + {"cal_date": "20260827", "is_open": 1, "pretrade_date": "20260826"}, + ] + # Sparse history mimicking .11: keep 0824 and today, miss 0825/0826. + self.database.save_snapshot("20260824", "tushare", _snapshot("20260824", "20260821")) + self.database.save_snapshot("20260827", "tushare", _snapshot("20260827", "20260826")) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_recent_backfill_fills_gap_and_restores_history(self) -> None: + before = self.service.sentiment_history("20260827", 20) + self.assertEqual(before["available_days"], 1) + + with patch( + "backend.features.market.service.create_sqlite_backup", + return_value=Path(self.temporary.name) / "fake-backup.db", + ) as backup: + audit = self.service.backfill_recent_trading_days( + end_date="20260827", + lookback=4, + dry_run=False, + create_backup=True, + ) + + backup.assert_called_once() + self.assertEqual(sorted(self.service.sync_calls), ["20260825", "20260826"]) + self.assertEqual(audit["missing"], ["2026-08-25", "2026-08-26"]) + self.assertEqual(sorted(audit["created_dates"]), ["2026-08-25", "2026-08-26"]) + after = self.service.sentiment_history("20260827", 20) + self.assertGreaterEqual(after["available_days"], 4) + self.assertEqual( + [row["trade_date"] for row in after["rows"]], + ["20260824", "20260825", "20260826", "20260827"], + ) + + def test_dry_run_does_not_write_snapshots(self) -> None: + audit = self.service.backfill_recent_trading_days( + end_date="20260827", + lookback=4, + dry_run=True, + create_backup=True, + ) + self.assertTrue(audit["dry_run"]) + self.assertEqual(self.service.sync_calls, []) + self.assertIsNone(audit["backup_path"]) + self.assertEqual( + self.database.list_snapshot_trade_dates("20260824", "20260827"), + ["20260824", "20260827"], + ) + + def test_repeat_execution_skips_existing_days(self) -> None: + with patch( + "backend.features.market.service.create_sqlite_backup", + return_value=Path(self.temporary.name) / "fake-backup.db", + ): + first = self.service.backfill_recent_trading_days( + end_date="20260827", lookback=4 + ) + self.service.sync_calls.clear() + second = self.service.backfill_recent_trading_days( + end_date="20260827", lookback=4 + ) + self.assertEqual(first["succeeded_count"], 2) + self.assertEqual(self.service.sync_calls, []) + self.assertEqual(second["missing_count"], 0) + self.assertEqual(second["skipped_count"], 4) + self.assertIsNone(second["backup_path"]) + + def test_partial_failure_continues_remaining_days(self) -> None: + self.service._fail_dates.add("20260825") + with patch( + "backend.features.market.service.create_sqlite_backup", + return_value=Path(self.temporary.name) / "fake-backup.db", + ): + audit = self.service.backfill_recent_trading_days( + end_date="20260827", lookback=4 + ) + self.assertFalse(audit["ok"]) + self.assertEqual(audit["failed_count"], 1) + self.assertEqual(audit["succeeded_count"], 1) + self.assertIn("20260826", self.database.list_snapshot_trade_dates()) + self.assertNotIn("20260825", self.database.list_snapshot_trade_dates()) + + def test_range_backfill_skips_weekend_without_treating_as_error(self) -> None: + with patch( + "backend.features.market.service.create_sqlite_backup", + return_value=Path(self.temporary.name) / "fake-backup.db", + ): + audit = self.service.backfill( + start_date="2026-08-21", + end_date="2026-08-24", + ) + self.assertEqual(audit["mode"], "range") + self.assertEqual(audit["skipped_non_trading_days"], ["2026-08-22", "2026-08-23"]) + self.assertEqual(sorted(self.service.sync_calls), ["20260821"]) + self.assertTrue(audit["ok"]) + + def test_sqlite_backup_api_creates_restorable_copy(self) -> None: + backup_dir = Path(self.temporary.name) / "backups" + backup = create_sqlite_backup( + self.db_path, + backup_dir, + label="pre-recent-backfill", + stamped_at=datetime(2026, 8, 27, 15, 30, 0), + ) + self.assertTrue(backup.exists()) + self.assertIn("pre-recent-backfill-20260827-153000", backup.name) + restored = ReviewDatabase(backup) + self.assertEqual( + restored.list_snapshot_trade_dates(), + ["20260824", "20260827"], + ) + + def test_audit_lists_only_snapshot_related_write_tables(self) -> None: + audit = build_backfill_audit( + mode="recent", + end_date="20260827", + lookback=60, + coverage={"trade_dates": [], "present": [], "missing": [], "present_count": 0, "missing_count": 0}, + ) + self.assertEqual( + audit["write_tables"], + ["dashboard_snapshots", "data_snapshots", "sync_runs"], + ) + self.assertNotIn("users", audit["write_tables"]) + self.assertNotIn("system_settings", audit["write_tables"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/README.md b/tools/README.md index 417e3e4..04bece1 100644 --- a/tools/README.md +++ b/tools/README.md @@ -17,6 +17,9 @@ registry, and verification tools. `backend/features/*/routes.py` owners. - `python tools/build_architecture_inventory.py [--check]`: generate or verify `config/architecture-inventory.json` from the current source tree. +- `python tools/backfill_recent_snapshots.py --account [--lookback 60] [--dry-run]`: + auditable recent trading-day dashboard snapshot backfill. See + `docs/maintenance/行情历史补档.md`. `verify_baseline.py` does not inspect a parent checkout or skip tests according to files outside this application. Historical comparison scripts were retired after final standalone acceptance; diff --git a/tools/backfill_recent_snapshots.py b/tools/backfill_recent_snapshots.py new file mode 100644 index 0000000..df1e48e --- /dev/null +++ b/tools/backfill_recent_snapshots.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Auditable recent trading-day dashboard snapshot backfill. + +Examples: + + python tools/backfill_recent_snapshots.py --account admin --dry-run + python tools/backfill_recent_snapshots.py --account admin --lookback 60 + python tools/backfill_recent_snapshots.py --account admin --end-date 2026-08-27 --force +""" + +from __future__ import annotations + +import argparse +import json +from datetime import date + +from backend.application import SERVICE +from backend.bootstrap.config import normalize_date +from backend.features.market.backfill_history import DEFAULT_RECENT_TRADING_DAYS + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Backfill the latest N real trading-day dashboard snapshots" + ) + parser.add_argument( + "--account", + required=True, + help="Account that can resolve the shared Tushare token", + ) + parser.add_argument( + "--end-date", + default=date.today().isoformat(), + help="Inclusive end date YYYY-MM-DD (default: today)", + ) + parser.add_argument( + "--lookback", + type=int, + default=DEFAULT_RECENT_TRADING_DAYS, + help=f"Number of open trading days to cover (default {DEFAULT_RECENT_TRADING_DAYS}, max 60)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Plan only: classify missing gaps without writing", + ) + parser.add_argument( + "--force", + action="store_true", + help="Re-sync days that already have snapshots", + ) + parser.add_argument( + "--no-backup", + action="store_true", + help="Skip the SQLite backup API step (not recommended)", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print the full audit payload as JSON", + ) + args = parser.parse_args() + + user = SERVICE.database.user_by_username(args.account.strip()) + if not user: + raise SystemExit("account not found") + SERVICE.bind_user(int(user["id"])) + + end_date = normalize_date(args.end_date) + audit = SERVICE.backfill_recent_trading_days( + end_date=end_date, + lookback=args.lookback, + dry_run=args.dry_run, + force=args.force, + create_backup=not args.no_backup, + ) + + if args.json: + print(json.dumps(audit, ensure_ascii=False, indent=2)) + raise SystemExit(0 if audit.get("ok") else 1) + + print( + f"mode={audit['mode']} end={audit['end_date']} lookback={audit['lookback']} " + f"dry_run={audit['dry_run']}" + ) + print( + f"present={audit['present_count']} missing={audit['missing_count']} " + f"succeeded={audit['succeeded_count']} skipped={audit['skipped_count']} " + f"failed={audit['failed_count']}" + ) + if audit.get("backup_path"): + print(f"backup={audit['backup_path']}") + if audit.get("missing"): + print("missing_dates=" + ",".join(audit["missing"])) + if audit.get("created_dates"): + print("created_dates=" + ",".join(audit["created_dates"])) + failed = [row for row in audit.get("results") or [] if row.get("status") == "failed"] + for row in failed: + print(f"failed {row.get('requested_date')}: {row.get('error')}") + if not audit.get("ok"): + raise SystemExit(1) + print("backfill complete") + + +if __name__ == "__main__": + main() From 8e94c7b4292ad1a2547caeaa91361b3f0d3e836a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Thu, 27 Aug 2026 15:12:42 +0000 Subject: [PATCH 05/21] =?UTF-8?q?fix(HEL-199):=20=E4=B8=BA=E8=A1=A5?= =?UTF-8?q?=E6=A1=A3=E5=B7=A5=E5=85=B7=E8=A1=A5=E4=B8=8A=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E6=A0=B9=20sys.path=20=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使 python3 tools/backfill_recent_snapshots.py --help 在干净环境下可直接运行,并同步文档运行示例为容器内执行。 Co-authored-by: Cursor Co-authored-by: multica-agent --- docs/maintenance/行情历史补档.md | 6 +++--- tools/backfill_recent_snapshots.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/maintenance/行情历史补档.md b/docs/maintenance/行情历史补档.md index 5425c56..c92d4c4 100644 --- a/docs/maintenance/行情历史补档.md +++ b/docs/maintenance/行情历史补档.md @@ -16,15 +16,15 @@ ## 上线步骤(总工执行) -在应用根目录: +在目标环境容器内执行(应用根目录;宿主机也可直接跑,脚本已自带仓库根 `sys.path` 引导): ```bash # 1) 只读规划:区分已有、真正缺档;不会写入 -python tools/backfill_recent_snapshots.py --account <管理员账号> --lookback 60 --dry-run --json +docker compose exec xiaobai-review python tools/backfill_recent_snapshots.py --account <管理员账号> --lookback 60 --dry-run --json # 2) 正式补档:先走 SQLite backup API 写 data/backups/review-pre-recent-backfill-*.db # 再对缺失交易日调用现有 sync_dashboard -python tools/backfill_recent_snapshots.py --account <管理员账号> --lookback 60 --json +docker compose exec xiaobai-review python tools/backfill_recent_snapshots.py --account <管理员账号> --lookback 60 --json # 3) 验证 # GET /api/sentiment/history?trade_date=YYYY-MM-DD&limit=60 diff --git a/tools/backfill_recent_snapshots.py b/tools/backfill_recent_snapshots.py index df1e48e..a9627ca 100644 --- a/tools/backfill_recent_snapshots.py +++ b/tools/backfill_recent_snapshots.py @@ -12,7 +12,13 @@ from __future__ import annotations import argparse import json +import sys from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) from backend.application import SERVICE from backend.bootstrap.config import normalize_date From cf206c7de9402830f0088f933d606f3420059487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Fri, 28 Aug 2026 08:00:08 +0000 Subject: [PATCH 06/21] =?UTF-8?q?fix(HEL-207):=20=E6=94=B6=E7=9B=98?= =?UTF-8?q?=E5=90=8E=E6=94=B9=E8=B5=B0=E6=97=A5=E7=BA=BF=EF=BC=8C=E7=A6=81?= =?UTF-8?q?=E6=AD=A2=E8=AF=AF=E8=B0=83=20rt=5Fk=EF=BC=8C=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=97=A7=E5=BF=AB=E7=85=A7=E8=AE=B0=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将实时窗口与调度窗口统一到 15:05;盘后优先日线并补关闭盘后同步;沿用旧快照时 sync/job 记 failed。 Co-authored-by: Cursor Co-authored-by: multica-agent --- backend/data/providers/tushare_dashboard.py | 16 +- backend/features/market/service.py | 34 ++- backend/jobs/service.py | 9 + tests/test_dashboard_refresh_window.py | 226 ++++++++++++++++++++ 4 files changed, 270 insertions(+), 15 deletions(-) create mode 100644 tests/test_dashboard_refresh_window.py diff --git a/backend/data/providers/tushare_dashboard.py b/backend/data/providers/tushare_dashboard.py index b7e98c1..ee87127 100644 --- a/backend/data/providers/tushare_dashboard.py +++ b/backend/data/providers/tushare_dashboard.py @@ -26,18 +26,8 @@ class DashboardMixin: ) daily = self._load_daily(trade_date) - if ( - not daily - and requested_date == datetime.now().astimezone().strftime("%Y%m%d") - and trade_date == requested_date - and datetime.now().astimezone().time().replace(tzinfo=None) >= dt_time(9, 15) - ): - return self._realtime_dashboard( - requested_date, - trade_date, - previous_trade_date, - ) if not daily: + # 15:05 后只走日线;日线未就绪时不得回退调用无权限的 rt_k。 raise TushareError(f"No daily data returned for {trade_date}") notices: list[str] = [] @@ -96,13 +86,13 @@ class DashboardMixin: @staticmethod def should_use_realtime(requested_date: str, trade_date: str) -> bool: - """Use rt_k for today's open market until end-of-day datasets settle.""" + """Use rt_k only inside the intraday window; 15:05+ must use daily bars.""" now = datetime.now().astimezone() today = now.strftime("%Y%m%d") return ( requested_date == today and trade_date == today - and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(16, 30) + and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(15, 5) ) def _realtime_dashboard( diff --git a/backend/features/market/service.py b/backend/features/market/service.py index 935037e..0a6dc34 100644 --- a/backend/features/market/service.py +++ b/backend/features/market/service.py @@ -175,6 +175,30 @@ class MarketServiceMixin: age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds() return age_seconds >= 8 + def _closing_snapshot_due( + self, + normalized_date: str, + snapshot: dict[str, Any], + ) -> bool: + """After 15:05, keep requesting daily bars until today's EOD snapshot exists.""" + if not self.configured or normalized_date != date.today().strftime("%Y%m%d"): + return False + now = datetime.now().astimezone() + if now.weekday() >= 5: + return False + local_time = now.time().replace(tzinfo=None) + if local_time < datetime.strptime("15:05", "%H:%M").time(): + return False + meta = snapshot.get("meta") or {} + snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "") + if ( + snapshot_trade_date == normalized_date + and not meta.get("realtime") + and not meta.get("carried_forward") + ): + return False + return True + def sync_dashboard(self, trade_date: str) -> dict[str, Any]: normalized_date = normalize_date(trade_date) source = "tushare" @@ -219,9 +243,15 @@ class MarketServiceMixin: fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}" ) self.database.finish_sync( - sync_id, "fallback", self._record_count(carried), str(exc), "tushare" + sync_id, "failed", self._record_count(carried), str(exc), "tushare" ) - return self._apply_reason_overrides(self._with_storage(carried, cached=True)) + result = self._apply_reason_overrides( + self._with_storage(carried, cached=True) + ) + # 页面仍可读到沿用快照;后台任务通过顶层 status=failed 记失败。 + result["status"] = "failed" + result["error"] = str(exc) + return result self.database.finish_sync(sync_id, "failed", message=str(exc)) raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc except Exception as exc: diff --git a/backend/jobs/service.py b/backend/jobs/service.py index 35bc31e..677fee6 100644 --- a/backend/jobs/service.py +++ b/backend/jobs/service.py @@ -46,4 +46,13 @@ class JobServiceMixin: lambda: self.sync_dashboard(today), {"trade_date": today, "trigger": "realtime-poll"}, ) + elif self._closing_snapshot_due(today, snapshot): + # 15:05 后改走日线生成当日快照;按分钟去重,避免日线未就绪时刷爆任务。 + bucket = int(time.time() // 60) + self.jobs.submit( + "market.refresh", + f"closing:{today}:{bucket}", + lambda: self.sync_dashboard(today), + {"trade_date": today, "trigger": "post-close"}, + ) self._schedule_automatic_screeners(today, snapshot) diff --git a/tests/test_dashboard_refresh_window.py b/tests/test_dashboard_refresh_window.py new file mode 100644 index 0000000..f654f16 --- /dev/null +++ b/tests/test_dashboard_refresh_window.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import threading +import unittest +from datetime import datetime +from unittest.mock import patch + +from backend.data.providers.tushare_client import TushareClient +from backend.data.providers.tushare_transport import TushareError +from server import DashboardService + + +class FixedDatetime(datetime): + fixed_now = datetime(2026, 8, 28, 15, 4).astimezone() + + @classmethod + def now(cls, tz=None): + return cls.fixed_now + + +class WindowClient(TushareClient): + def __init__(self, token: str = "test-token"): + super().__init__(token) + self.calls: list[str] = [] + self.daily_rows: list[dict] = [] + self.rt_k_error: Exception | None = None + + def query(self, api_name, params=None, fields=""): + self.calls.append(api_name) + params = params or {} + if api_name == "trade_cal": + return [ + { + "cal_date": "20260828", + "is_open": 1, + "pretrade_date": "20260827", + } + ] + if api_name == "daily": + return list(self.daily_rows) + if api_name == "rt_k": + if self.rt_k_error is not None: + raise self.rt_k_error + raise AssertionError("rt_k should not be called in this scenario") + if api_name in {"limit_list_d", "stock_basic", "stk_limit", "daily_basic"}: + return [] + raise AssertionError(f"Unexpected API call: {api_name} {params}") + + def resolve_trade_context(self, requested_date: str): + return requested_date, "20260827" + + +class SyncDatabaseStub: + def __init__(self, latest=None): + self.latest = latest + self.snapshots: dict[str, dict] = {} + self.sync_runs: list[dict] = [] + self._sync_id = 0 + + def start_sync(self, trade_date: str, source: str) -> int: + self._sync_id += 1 + self.sync_runs.append( + { + "id": self._sync_id, + "trade_date": trade_date, + "source": source, + "status": "running", + } + ) + return self._sync_id + + def finish_sync( + self, + sync_id: int, + status: str, + record_count: int = 0, + message: str = "", + source: str | None = None, + ) -> None: + for row in self.sync_runs: + if row["id"] == sync_id: + row.update( + { + "status": status, + "record_count": record_count, + "message": message, + "source": source or row["source"], + } + ) + return + raise AssertionError(f"unknown sync_id {sync_id}") + + def save_snapshot(self, trade_date: str, source: str, payload: dict) -> None: + self.snapshots[trade_date] = {"source": source, "payload": payload} + + def save_data_snapshot(self, kind: str, cache_key: str, source: str, payload: dict) -> None: + return None + + def get_latest_real_snapshot(self, _trade_date: str, strictly_before: bool = False): + return self.latest + + def reason_overrides(self, _trade_date: str): + return {} + + +class DashboardRefreshWindowTests(unittest.TestCase): + def setUp(self) -> None: + TushareClient._realtime_reference_cache.clear() + TushareClient._capital_cache.clear() + TushareClient._latest_realtime_market.clear() + TushareClient._stock_activity_cache.clear() + + def _service(self, client: WindowClient, latest=None) -> DashboardService: + service = object.__new__(DashboardService) + service._system_credentials = {"tushare_token": "test-token"} + service.sync_lock = threading.Lock() + service.database = SyncDatabaseStub(latest=latest) + service.data_gateway = None + service._tushare_client = lambda: client + service._enrich_dashboard_sentiment = lambda dashboard, _date: dashboard + service._apply_reason_overrides = lambda dashboard: dashboard + return service + + def test_should_use_realtime_at_1504(self) -> None: + FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 4).astimezone() + with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime): + self.assertTrue(TushareClient.should_use_realtime("20260828", "20260828")) + + def test_should_use_daily_at_1505(self) -> None: + FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 5).astimezone() + with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime): + self.assertFalse(TushareClient.should_use_realtime("20260828", "20260828")) + + def test_after_close_empty_daily_does_not_call_rt_k(self) -> None: + FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone() + client = WindowClient() + client.daily_rows = [] + with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime): + with self.assertRaises(TushareError): + client.dashboard("20260828") + self.assertIn("daily", client.calls) + self.assertNotIn("rt_k", client.calls) + + def test_after_close_uses_daily_when_ready(self) -> None: + FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone() + client = WindowClient() + client.daily_rows = [ + { + "ts_code": "000001.SZ", + "trade_date": "20260828", + "open": 10, + "high": 11, + "low": 9.5, + "close": 10.5, + "pre_close": 10, + "pct_chg": 5, + "vol": 1000, + "amount": 1_000_000, + } + ] + + def load_limit_lists(_trade_date): + return [] + + def load_limit_type(_trade_date, _limit_type): + return [] + + client._load_limit_lists = load_limit_lists # type: ignore[method-assign] + client._load_limit_type = load_limit_type # type: ignore[method-assign] + client._derive_limits = lambda *args, **kwargs: [] # type: ignore[method-assign] + + with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime): + dashboard = client.dashboard("20260828") + + self.assertIn("daily", client.calls) + self.assertNotIn("rt_k", client.calls) + self.assertFalse(dashboard["meta"].get("realtime")) + self.assertEqual(dashboard["meta"]["trade_date"], "2026-08-28") + + def test_fallback_old_snapshot_marks_sync_and_job_status_failed(self) -> None: + FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone() + client = WindowClient() + client.daily_rows = [] + latest = { + "meta": {"source": "tushare", "trade_date": "2026-08-27"}, + "overview": {}, + "limits": [], + "broken": [], + "down_limits": [], + "yesterday_limits": [], + } + service = self._service(client, latest=latest) + + with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime): + result = service.sync_dashboard("20260828") + + self.assertEqual(result["status"], "failed") + self.assertTrue(result["meta"]["carried_forward"]) + self.assertEqual(result["meta"]["trade_date"], "2026-08-27") + self.assertEqual(service.database.sync_runs[-1]["status"], "failed") + self.assertNotIn("rt_k", client.calls) + + def test_closing_snapshot_due_after_1505_when_today_missing(self) -> None: + FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone() + service = object.__new__(DashboardService) + service._system_credentials = {"tushare_token": "test-token"} + with patch("backend.features.market.service.datetime", FixedDatetime), patch( + "backend.features.market.service.date" + ) as fake_date: + fake_date.today.return_value = FixedDatetime.fixed_now.date() + self.assertTrue(service._closing_snapshot_due("20260828", {})) + self.assertFalse( + service._closing_snapshot_due( + "20260828", + { + "meta": { + "trade_date": "2026-08-28", + "realtime": False, + } + }, + ) + ) + + +if __name__ == "__main__": + unittest.main() From 6d7a839202668551ffa88211f5209b679adab1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Fri, 28 Aug 2026 08:09:49 +0000 Subject: [PATCH 07/21] =?UTF-8?q?chore(HEL-208):=20=E5=88=B7=E6=96=B0=20ar?= =?UTF-8?q?chitecture-inventory=20=E4=BB=A5=E5=AF=B9=E9=BD=90=20HEL-207=20?= =?UTF-8?q?=E8=A1=8C=E6=83=85=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- config/architecture-inventory.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 12368d2..32fe54c 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -486,8 +486,8 @@ }, { "path": "backend/data/providers/tushare_dashboard.py", - "bytes": 28051, - "lines": 644 + "bytes": 27730, + "lines": 634 }, { "path": "backend/data/providers/tushare_industries.py", @@ -774,6 +774,11 @@ "bytes": 2202, "lines": 53 }, + { + "path": "backend/jobs/service.py", + "bytes": 2201, + "lines": 58 + }, { "path": "backend/data/providers/tushare_client.py", "bytes": 2166, @@ -804,11 +809,6 @@ "bytes": 1791, "lines": 46 }, - { - "path": "backend/jobs/service.py", - "bytes": 1746, - "lines": 49 - }, { "path": "backend/features/alerts/routes.py", "bytes": 1687, From f27471238a4d7e6665911e8f1b65fd566b1f29cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E7=AE=A1?= Date: Fri, 28 Aug 2026 09:05:42 +0000 Subject: [PATCH 08/21] =?UTF-8?q?fix(HEL-164):=20=E6=92=A4=E5=9B=9E?= =?UTF-8?q?=E7=9B=98=E5=90=8E=E5=88=B7=E6=96=B0=E6=94=B9=E5=8A=A8=E5=B9=B6?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=8E=9F=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- backend/data/providers/tushare_dashboard.py | 16 +- backend/features/market/service.py | 35 +-- backend/jobs/service.py | 9 - config/architecture-inventory.json | 14 +- tests/test_dashboard_refresh_window.py | 226 -------------------- 5 files changed, 22 insertions(+), 278 deletions(-) delete mode 100644 tests/test_dashboard_refresh_window.py diff --git a/backend/data/providers/tushare_dashboard.py b/backend/data/providers/tushare_dashboard.py index ee87127..b7e98c1 100644 --- a/backend/data/providers/tushare_dashboard.py +++ b/backend/data/providers/tushare_dashboard.py @@ -26,8 +26,18 @@ class DashboardMixin: ) daily = self._load_daily(trade_date) + if ( + not daily + and requested_date == datetime.now().astimezone().strftime("%Y%m%d") + and trade_date == requested_date + and datetime.now().astimezone().time().replace(tzinfo=None) >= dt_time(9, 15) + ): + return self._realtime_dashboard( + requested_date, + trade_date, + previous_trade_date, + ) if not daily: - # 15:05 后只走日线;日线未就绪时不得回退调用无权限的 rt_k。 raise TushareError(f"No daily data returned for {trade_date}") notices: list[str] = [] @@ -86,13 +96,13 @@ class DashboardMixin: @staticmethod def should_use_realtime(requested_date: str, trade_date: str) -> bool: - """Use rt_k only inside the intraday window; 15:05+ must use daily bars.""" + """Use rt_k for today's open market until end-of-day datasets settle.""" now = datetime.now().astimezone() today = now.strftime("%Y%m%d") return ( requested_date == today and trade_date == today - and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(15, 5) + and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(16, 30) ) def _realtime_dashboard( diff --git a/backend/features/market/service.py b/backend/features/market/service.py index 34ccacc..e266057 100644 --- a/backend/features/market/service.py +++ b/backend/features/market/service.py @@ -188,30 +188,6 @@ class MarketServiceMixin: age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds() return age_seconds >= 8 - def _closing_snapshot_due( - self, - normalized_date: str, - snapshot: dict[str, Any], - ) -> bool: - """After 15:05, keep requesting daily bars until today's EOD snapshot exists.""" - if not self.configured or normalized_date != date.today().strftime("%Y%m%d"): - return False - now = datetime.now().astimezone() - if now.weekday() >= 5: - return False - local_time = now.time().replace(tzinfo=None) - if local_time < datetime.strptime("15:05", "%H:%M").time(): - return False - meta = snapshot.get("meta") or {} - snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "") - if ( - snapshot_trade_date == normalized_date - and not meta.get("realtime") - and not meta.get("carried_forward") - ): - return False - return True - def sync_dashboard(self, trade_date: str) -> dict[str, Any]: normalized_date = normalize_date(trade_date) source = "tushare" @@ -256,15 +232,9 @@ class MarketServiceMixin: fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}" ) self.database.finish_sync( - sync_id, "failed", self._record_count(carried), str(exc), "tushare" + sync_id, "fallback", self._record_count(carried), str(exc), "tushare" ) - result = self._apply_reason_overrides( - self._with_storage(carried, cached=True) - ) - # 页面仍可读到沿用快照;后台任务通过顶层 status=failed 记失败。 - result["status"] = "failed" - result["error"] = str(exc) - return result + return self._apply_reason_overrides(self._with_storage(carried, cached=True)) self.database.finish_sync(sync_id, "failed", message=str(exc)) raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc except Exception as exc: @@ -1193,4 +1163,3 @@ class MarketServiceMixin: len(dashboard.get(key) or []) for key in ("limits", "broken", "down_limits", "yesterday_limits") ) - diff --git a/backend/jobs/service.py b/backend/jobs/service.py index 677fee6..35bc31e 100644 --- a/backend/jobs/service.py +++ b/backend/jobs/service.py @@ -46,13 +46,4 @@ class JobServiceMixin: lambda: self.sync_dashboard(today), {"trade_date": today, "trigger": "realtime-poll"}, ) - elif self._closing_snapshot_due(today, snapshot): - # 15:05 后改走日线生成当日快照;按分钟去重,避免日线未就绪时刷爆任务。 - bucket = int(time.time() // 60) - self.jobs.submit( - "market.refresh", - f"closing:{today}:{bucket}", - lambda: self.sync_dashboard(today), - {"trade_date": today, "trigger": "post-close"}, - ) self._schedule_automatic_screeners(today, snapshot) diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 32fe54c..12368d2 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -486,8 +486,8 @@ }, { "path": "backend/data/providers/tushare_dashboard.py", - "bytes": 27730, - "lines": 634 + "bytes": 28051, + "lines": 644 }, { "path": "backend/data/providers/tushare_industries.py", @@ -774,11 +774,6 @@ "bytes": 2202, "lines": 53 }, - { - "path": "backend/jobs/service.py", - "bytes": 2201, - "lines": 58 - }, { "path": "backend/data/providers/tushare_client.py", "bytes": 2166, @@ -809,6 +804,11 @@ "bytes": 1791, "lines": 46 }, + { + "path": "backend/jobs/service.py", + "bytes": 1746, + "lines": 49 + }, { "path": "backend/features/alerts/routes.py", "bytes": 1687, diff --git a/tests/test_dashboard_refresh_window.py b/tests/test_dashboard_refresh_window.py deleted file mode 100644 index f654f16..0000000 --- a/tests/test_dashboard_refresh_window.py +++ /dev/null @@ -1,226 +0,0 @@ -from __future__ import annotations - -import threading -import unittest -from datetime import datetime -from unittest.mock import patch - -from backend.data.providers.tushare_client import TushareClient -from backend.data.providers.tushare_transport import TushareError -from server import DashboardService - - -class FixedDatetime(datetime): - fixed_now = datetime(2026, 8, 28, 15, 4).astimezone() - - @classmethod - def now(cls, tz=None): - return cls.fixed_now - - -class WindowClient(TushareClient): - def __init__(self, token: str = "test-token"): - super().__init__(token) - self.calls: list[str] = [] - self.daily_rows: list[dict] = [] - self.rt_k_error: Exception | None = None - - def query(self, api_name, params=None, fields=""): - self.calls.append(api_name) - params = params or {} - if api_name == "trade_cal": - return [ - { - "cal_date": "20260828", - "is_open": 1, - "pretrade_date": "20260827", - } - ] - if api_name == "daily": - return list(self.daily_rows) - if api_name == "rt_k": - if self.rt_k_error is not None: - raise self.rt_k_error - raise AssertionError("rt_k should not be called in this scenario") - if api_name in {"limit_list_d", "stock_basic", "stk_limit", "daily_basic"}: - return [] - raise AssertionError(f"Unexpected API call: {api_name} {params}") - - def resolve_trade_context(self, requested_date: str): - return requested_date, "20260827" - - -class SyncDatabaseStub: - def __init__(self, latest=None): - self.latest = latest - self.snapshots: dict[str, dict] = {} - self.sync_runs: list[dict] = [] - self._sync_id = 0 - - def start_sync(self, trade_date: str, source: str) -> int: - self._sync_id += 1 - self.sync_runs.append( - { - "id": self._sync_id, - "trade_date": trade_date, - "source": source, - "status": "running", - } - ) - return self._sync_id - - def finish_sync( - self, - sync_id: int, - status: str, - record_count: int = 0, - message: str = "", - source: str | None = None, - ) -> None: - for row in self.sync_runs: - if row["id"] == sync_id: - row.update( - { - "status": status, - "record_count": record_count, - "message": message, - "source": source or row["source"], - } - ) - return - raise AssertionError(f"unknown sync_id {sync_id}") - - def save_snapshot(self, trade_date: str, source: str, payload: dict) -> None: - self.snapshots[trade_date] = {"source": source, "payload": payload} - - def save_data_snapshot(self, kind: str, cache_key: str, source: str, payload: dict) -> None: - return None - - def get_latest_real_snapshot(self, _trade_date: str, strictly_before: bool = False): - return self.latest - - def reason_overrides(self, _trade_date: str): - return {} - - -class DashboardRefreshWindowTests(unittest.TestCase): - def setUp(self) -> None: - TushareClient._realtime_reference_cache.clear() - TushareClient._capital_cache.clear() - TushareClient._latest_realtime_market.clear() - TushareClient._stock_activity_cache.clear() - - def _service(self, client: WindowClient, latest=None) -> DashboardService: - service = object.__new__(DashboardService) - service._system_credentials = {"tushare_token": "test-token"} - service.sync_lock = threading.Lock() - service.database = SyncDatabaseStub(latest=latest) - service.data_gateway = None - service._tushare_client = lambda: client - service._enrich_dashboard_sentiment = lambda dashboard, _date: dashboard - service._apply_reason_overrides = lambda dashboard: dashboard - return service - - def test_should_use_realtime_at_1504(self) -> None: - FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 4).astimezone() - with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime): - self.assertTrue(TushareClient.should_use_realtime("20260828", "20260828")) - - def test_should_use_daily_at_1505(self) -> None: - FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 5).astimezone() - with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime): - self.assertFalse(TushareClient.should_use_realtime("20260828", "20260828")) - - def test_after_close_empty_daily_does_not_call_rt_k(self) -> None: - FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone() - client = WindowClient() - client.daily_rows = [] - with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime): - with self.assertRaises(TushareError): - client.dashboard("20260828") - self.assertIn("daily", client.calls) - self.assertNotIn("rt_k", client.calls) - - def test_after_close_uses_daily_when_ready(self) -> None: - FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone() - client = WindowClient() - client.daily_rows = [ - { - "ts_code": "000001.SZ", - "trade_date": "20260828", - "open": 10, - "high": 11, - "low": 9.5, - "close": 10.5, - "pre_close": 10, - "pct_chg": 5, - "vol": 1000, - "amount": 1_000_000, - } - ] - - def load_limit_lists(_trade_date): - return [] - - def load_limit_type(_trade_date, _limit_type): - return [] - - client._load_limit_lists = load_limit_lists # type: ignore[method-assign] - client._load_limit_type = load_limit_type # type: ignore[method-assign] - client._derive_limits = lambda *args, **kwargs: [] # type: ignore[method-assign] - - with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime): - dashboard = client.dashboard("20260828") - - self.assertIn("daily", client.calls) - self.assertNotIn("rt_k", client.calls) - self.assertFalse(dashboard["meta"].get("realtime")) - self.assertEqual(dashboard["meta"]["trade_date"], "2026-08-28") - - def test_fallback_old_snapshot_marks_sync_and_job_status_failed(self) -> None: - FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone() - client = WindowClient() - client.daily_rows = [] - latest = { - "meta": {"source": "tushare", "trade_date": "2026-08-27"}, - "overview": {}, - "limits": [], - "broken": [], - "down_limits": [], - "yesterday_limits": [], - } - service = self._service(client, latest=latest) - - with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime): - result = service.sync_dashboard("20260828") - - self.assertEqual(result["status"], "failed") - self.assertTrue(result["meta"]["carried_forward"]) - self.assertEqual(result["meta"]["trade_date"], "2026-08-27") - self.assertEqual(service.database.sync_runs[-1]["status"], "failed") - self.assertNotIn("rt_k", client.calls) - - def test_closing_snapshot_due_after_1505_when_today_missing(self) -> None: - FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone() - service = object.__new__(DashboardService) - service._system_credentials = {"tushare_token": "test-token"} - with patch("backend.features.market.service.datetime", FixedDatetime), patch( - "backend.features.market.service.date" - ) as fake_date: - fake_date.today.return_value = FixedDatetime.fixed_now.date() - self.assertTrue(service._closing_snapshot_due("20260828", {})) - self.assertFalse( - service._closing_snapshot_due( - "20260828", - { - "meta": { - "trade_date": "2026-08-28", - "realtime": False, - } - }, - ) - ) - - -if __name__ == "__main__": - unittest.main() From 1cb2745867bd50eefd205fc5a5513f3376bb3701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E7=AE=A1?= Date: Fri, 28 Aug 2026 09:29:06 +0000 Subject: [PATCH 09/21] =?UTF-8?q?feat(HEL-164):=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E5=88=B7=E6=96=B0=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- backend/features/system/http.py | 4 +- backend/jobs/service.py | 5 ++- config/architecture-inventory.json | 24 ++++++------ frontend/index.html | 1 + frontend/shared/auth.css | 26 +++++++++++++ frontend/shared/dashboard.js | 62 ++++++++++++++++++++++++++++-- 6 files changed, 103 insertions(+), 19 deletions(-) diff --git a/backend/features/system/http.py b/backend/features/system/http.py index 531cceb..036bf39 100644 --- a/backend/features/system/http.py +++ b/backend/features/system/http.py @@ -26,13 +26,15 @@ class SystemHttpMixin: def start_background_refresh(self) -> None: try: body = self.read_json_body(allow_empty=True) - started = self.application_service.request_background_sync( + refresh = self.application_service.request_background_sync( str(body.get("trade_date") or date.today().isoformat()) ) + started = bool(refresh.get("started")) self.send_json( { "ok": True, "started": started, + "job_key": str(refresh.get("job_key") or ""), "message": "后台刷新已开始" if started else "已有后台刷新任务正在运行", }, HTTPStatus.ACCEPTED, diff --git a/backend/jobs/service.py b/backend/jobs/service.py index 35bc31e..216d22a 100644 --- a/backend/jobs/service.py +++ b/backend/jobs/service.py @@ -20,15 +20,16 @@ class JobServiceMixin: workers_stopped = self.jobs.wait_for_idle(timeout_seconds) return scheduler_stopped and workers_stopped - def request_background_sync(self, trade_date: str) -> bool: + def request_background_sync(self, trade_date: str) -> dict[str, object]: normalized = normalize_date(trade_date) key = f"manual:{normalized}:{time.time_ns()}" - return self.jobs.submit( + started = self.jobs.submit( "market.refresh", key, lambda: self.sync_dashboard(normalized), {"trade_date": normalized, "trigger": "administrator"}, ) + return {"started": started, "job_key": key if started else ""} def _background_refresh_tick(self) -> None: if not ( diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 12368d2..d06e930 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -461,8 +461,8 @@ }, { "path": "frontend/index.html", - "bytes": 47891, - "lines": 661 + "bytes": 48077, + "lines": 662 }, { "path": "backend/features/screener/catalog.py", @@ -554,6 +554,11 @@ "bytes": 13176, "lines": 293 }, + { + "path": "frontend/shared/dashboard.js", + "bytes": 12894, + "lines": 274 + }, { "path": "backend/features/market/insights_auction_data.py", "bytes": 12829, @@ -574,11 +579,6 @@ "bytes": 10539, "lines": 244 }, - { - "path": "frontend/shared/dashboard.js", - "bytes": 9993, - "lines": 220 - }, { "path": "backend/data/providers/tushare_sectors.py", "bytes": 9876, @@ -799,16 +799,16 @@ "bytes": 1919, "lines": 45 }, + { + "path": "backend/jobs/service.py", + "bytes": 1833, + "lines": 50 + }, { "path": "backend/features/system/routes.py", "bytes": 1791, "lines": 46 }, - { - "path": "backend/jobs/service.py", - "bytes": 1746, - "lines": 49 - }, { "path": "backend/features/alerts/routes.py", "bytes": 1687, diff --git a/frontend/index.html b/frontend/index.html index 94f4181..4f0ca64 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -609,6 +609,7 @@

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

+
尚未手动刷新
diff --git a/frontend/shared/auth.css b/frontend/shared/auth.css index 424d982..22d516c 100644 --- a/frontend/shared/auth.css +++ b/frontend/shared/auth.css @@ -471,6 +471,32 @@ line-height: 1.6; } +.admin-refresh-status { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-muted); + color: var(--text-muted); + font-size: 12px; + line-height: 1.55; +} + +.admin-refresh-status svg { + flex: 0 0 auto; + width: 15px; + height: 15px; + margin-top: 2px; +} + +.admin-refresh-status[data-tone="running"] { color: var(--primary); } +.admin-refresh-status[data-tone="success"] { color: var(--down); } +.admin-refresh-status[data-tone="warning"] { color: var(--warning); } +.admin-refresh-status[data-tone="failure"] { color: var(--up); } + .account-button > span { flex: 0 0 auto; diff --git a/frontend/shared/dashboard.js b/frontend/shared/dashboard.js index 41e9e57..54b612f 100644 --- a/frontend/shared/dashboard.js +++ b/frontend/shared/dashboard.js @@ -40,17 +40,71 @@ async function loadDashboard(force = false, background = false, showOverlay = tr async function startAdminRefresh() { const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean); buttons.forEach((button) => { button.disabled = true; }); + const requestedDate = elements.tradeDate.value; + setAdminRefreshStatus("running", `正在刷新 ${requestedDate} 的行情,请稍候…`, "loader-circle"); try { - const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value }); - showToast(payload.message || "后台刷新已提交"); - setStatus("后台刷新运行中,当前页面保持不变"); + const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: requestedDate }); + if (!payload.started || !payload.job_key) { + setAdminRefreshStatus("warning", "已有刷新任务正在运行,请稍后再试。", "clock-3"); + showToast(payload.message || "已有后台刷新任务正在运行"); + return; + } + setStatus(`正在刷新 ${requestedDate} 的行情`); + const job = await waitForAdminRefresh(payload.job_key); + if (job.status === "failed") { + const reason = job.message || job.error_code || "数据源未返回结果"; + setAdminRefreshStatus("failure", `刷新失败:${reason}`, "circle-x"); + setStatus("后台刷新失败"); + showToast("后台刷新失败"); + return; + } + const query = new URLSearchParams({ trade_date: requestedDate }); + const dashboard = await apiRequest(`/api/dashboard?${query}`); + applyDashboard(dashboard); + const meta = dashboard.meta || {}; + const actualDate = String(meta.trade_date || "").slice(0, 10); + const requestedCompact = requestedDate.replaceAll("-", ""); + const actualCompact = actualDate.replaceAll("-", ""); + const updated = formatTimestamp(meta.updated_at); + if (actualCompact !== requestedCompact || meta.carried_forward) { + const reason = meta.notice ? `;${meta.notice}` : ""; + setAdminRefreshStatus("warning", `刷新已完成,但没有获取到 ${requestedDate} 的最新行情;当前仍是 ${actualDate || "未知日期"}${reason}`, "triangle-alert"); + showToast("刷新完成,但未获取到所选日期的最新行情"); + } else if (meta.notice) { + setAdminRefreshStatus("warning", `已刷新到 ${actualDate}(${updated}),但数据源提示:${meta.notice}`, "triangle-alert"); + showToast(`已刷新到 ${actualDate},请留意数据源提示`); + } else { + setAdminRefreshStatus("success", `刷新成功:已获取 ${actualDate} 的最新行情,更新时间 ${updated}`, "circle-check"); + showToast(`刷新成功:已获取 ${actualDate} 的最新行情`); + } } catch (error) { - showToast(error.message || "后台刷新启动失败"); + const message = error.message || "后台刷新失败"; + setAdminRefreshStatus("failure", `刷新失败:${message}`, "circle-x"); + setStatus("后台刷新失败"); + showToast(message); } finally { buttons.forEach((button) => { button.disabled = false; }); } } +function setAdminRefreshStatus(tone, message, icon = "circle-dot") { + const status = document.querySelector("#adminRefreshStatus"); + if (!status) return; + status.dataset.tone = tone; + status.innerHTML = `${escapeHtml(message)}`; + refreshIcons(); +} + +async function waitForAdminRefresh(jobKey) { + for (let attempt = 0; attempt < 120; attempt += 1) { + const payload = await apiRequest("/api/admin/settings"); + const job = (payload.data?.jobs || []).find((item) => item.idempotency_key === jobKey); + if (job && ["success", "failed"].includes(job.status)) return job; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error("刷新等待超时,请稍后重试"); +} + function applyDashboard(payload, background = false) { state.dashboard = payload; const selectedDate = payload.meta.requested_date || payload.meta.trade_date; From d9ee725744a1a1684908d57f381ed4388b6288d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E7=AE=A1?= Date: Fri, 28 Aug 2026 09:44:51 +0000 Subject: [PATCH 10/21] =?UTF-8?q?fix(HEL-164):=20=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E4=B8=8D=E5=AE=8C=E6=95=B4=E6=B6=A8=E5=81=9C?= =?UTF-8?q?=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- backend/data/providers/tushare_dashboard.py | 4 ++++ backend/features/market/service.py | 5 +++++ backend/jobs/service.py | 12 ++++++++++- config/architecture-inventory.json | 14 ++++++------ tests/test_admin_refresh_status.py | 24 +++++++++++++++++++++ tests/test_realtime_dashboard.py | 19 ++++++++++++++++ 6 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 tests/test_admin_refresh_status.py diff --git a/backend/data/providers/tushare_dashboard.py b/backend/data/providers/tushare_dashboard.py index b7e98c1..c2fb4b4 100644 --- a/backend/data/providers/tushare_dashboard.py +++ b/backend/data/providers/tushare_dashboard.py @@ -41,13 +41,16 @@ class DashboardMixin: raise TushareError(f"No daily data returned for {trade_date}") notices: list[str] = [] + limit_data_source = "official" try: limit_rows = self._load_limit_lists(trade_date) previous_limit_rows = self._load_limit_type(previous_trade_date, "U") if not limit_rows: + limit_data_source = "derived" notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。") limit_rows = self._derive_limits(trade_date, daily) except TushareError as exc: + limit_data_source = "derived" notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}") limit_rows = self._derive_limits(trade_date, daily) previous_daily = self._load_daily(previous_trade_date) @@ -79,6 +82,7 @@ class DashboardMixin: "trade_date": _display_date(trade_date), "previous_trade_date": _display_date(previous_trade_date), "source": "tushare", + "limit_data_source": limit_data_source, "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "notice": ";".join(notices), }, diff --git a/backend/features/market/service.py b/backend/features/market/service.py index e266057..e372b8f 100644 --- a/backend/features/market/service.py +++ b/backend/features/market/service.py @@ -198,6 +198,11 @@ class MarketServiceMixin: raise TushareError("公共行情尚未配置") dashboard = self._tushare_client().dashboard(normalized_date) + if (dashboard.get("meta") or {}).get("limit_data_source") == "derived": + raise TushareError( + str((dashboard.get("meta") or {}).get("notice") or "官方涨跌停数据尚未返回") + ) + dashboard["meta"]["source"] = source dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date) dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date) diff --git a/backend/jobs/service.py b/backend/jobs/service.py index 216d22a..15dccc4 100644 --- a/backend/jobs/service.py +++ b/backend/jobs/service.py @@ -7,6 +7,16 @@ from datetime import date from backend.bootstrap.config import normalize_date +def _verified_dashboard_result(dashboard: dict[str, object]) -> dict[str, object]: + meta = dashboard.get("meta") or {} + if isinstance(meta, dict) and meta.get("carried_forward"): + return { + "status": "failed", + "error": str(meta.get("notice") or "未获取到所选日期的最新行情"), + } + return dashboard + + class JobServiceMixin: def start_background_jobs(self) -> threading.Thread: return self.jobs.start_scheduler( @@ -26,7 +36,7 @@ class JobServiceMixin: started = self.jobs.submit( "market.refresh", key, - lambda: self.sync_dashboard(normalized), + lambda: _verified_dashboard_result(self.sync_dashboard(normalized)), {"trade_date": normalized, "trigger": "administrator"}, ) return {"started": started, "job_key": key if started else ""} diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index d06e930..66e9883 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -486,8 +486,8 @@ }, { "path": "backend/data/providers/tushare_dashboard.py", - "bytes": 28051, - "lines": 644 + "bytes": 28234, + "lines": 648 }, { "path": "backend/data/providers/tushare_industries.py", @@ -769,6 +769,11 @@ "bytes": 2299, "lines": 57 }, + { + "path": "backend/jobs/service.py", + "bytes": 2219, + "lines": 60 + }, { "path": "backend/features/screener/regime.py", "bytes": 2202, @@ -799,11 +804,6 @@ "bytes": 1919, "lines": 45 }, - { - "path": "backend/jobs/service.py", - "bytes": 1833, - "lines": 50 - }, { "path": "backend/features/system/routes.py", "bytes": 1791, diff --git a/tests/test_admin_refresh_status.py b/tests/test_admin_refresh_status.py new file mode 100644 index 0000000..634f89e --- /dev/null +++ b/tests/test_admin_refresh_status.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import unittest + +from backend.jobs.service import _verified_dashboard_result + + +class AdminRefreshStatusTests(unittest.TestCase): + def test_carried_snapshot_is_reported_as_failed_job(self): + result = _verified_dashboard_result( + {"meta": {"carried_forward": True, "notice": "官方涨跌停数据尚未返回"}} + ) + + self.assertEqual(result["status"], "failed") + self.assertEqual(result["error"], "官方涨跌停数据尚未返回") + + def test_current_snapshot_is_reported_as_successful_job(self): + dashboard = {"meta": {"trade_date": "2026-08-28", "carried_forward": False}} + + self.assertIs(_verified_dashboard_result(dashboard), dashboard) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_realtime_dashboard.py b/tests/test_realtime_dashboard.py index 1114dc9..6977a4f 100644 --- a/tests/test_realtime_dashboard.py +++ b/tests/test_realtime_dashboard.py @@ -111,6 +111,25 @@ class RealtimeDashboardTests(unittest.TestCase): self.assertEqual(quote["amount_billion"], 3.0) self.assertAlmostEqual(quote["turnover_rate"], 0.01) + def test_close_dashboard_marks_official_limit_data(self): + dashboard = self.client.dashboard("20260720") + + self.assertEqual(dashboard["meta"]["limit_data_source"], "official") + + def test_close_dashboard_marks_derived_limit_data_as_incomplete(self): + original_query = self.client.query + + def query(api_name, params=None, fields=""): + if api_name == "limit_list_d": + return [] + return original_query(api_name, params, fields) + + self.client.query = query + dashboard = self.client.dashboard("20260720") + + self.assertEqual(dashboard["meta"]["limit_data_source"], "derived") + self.assertIn("日线数据推算", dashboard["meta"]["notice"]) + if __name__ == "__main__": unittest.main() From 89b8d33de7098d312a4e0f2a6f7fa35b5c1a1cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E7=AE=A1?= Date: Fri, 28 Aug 2026 11:32:21 +0000 Subject: [PATCH 11/21] =?UTF-8?q?fix(HEL-164):=20=E6=8E=92=E9=99=A4?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E4=B8=AD=E7=9A=84=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- .dockerignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.dockerignore b/.dockerignore index 58d5f1d..b08c538 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,7 @@ __pycache__/ *.log runtime/ data/cache/ +data/backups/ data/private-mentor-skills/ data/*.db data/*.db-shm From 213f735f6ad9d90b12a08af36e9a7d2f2ca1ff2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Fri, 28 Aug 2026 15:01:22 +0000 Subject: [PATCH 12/21] =?UTF-8?q?fix(HEL-221):=20=E6=8C=89=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=A0=B7=E5=9B=BE=E5=AE=9E=E7=8E=B0=E6=83=85=E7=BB=AA?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E5=B7=A6=E5=8F=B3=E7=AD=89=E9=AB=98=E4=B8=8E?= =?UTF-8?q?=E5=A4=9C=E9=97=B4=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面分析区固定 600px 并 stretch 对齐,评分构成在剩余高度内分配;夜间 tooltip 使用指定深色对比度,图表按容器尺寸重绘且不随视口拉满整页。 Co-authored-by: Cursor Co-authored-by: multica-agent --- frontend/pages/sentiment/foundation.css | 79 ++++++++++++-- frontend/pages/sentiment/page.js | 42 +++++++- frontend/shared/tokens.css | 5 + tests/e2e/app-shell.spec.js | 133 ++++++++++++++++++++++++ tests/test_frontend_contract.py | 16 +++ 5 files changed, 265 insertions(+), 10 deletions(-) diff --git a/frontend/pages/sentiment/foundation.css b/frontend/pages/sentiment/foundation.css index 6accf89..8300c5d 100644 --- a/frontend/pages/sentiment/foundation.css +++ b/frontend/pages/sentiment/foundation.css @@ -1011,9 +1011,9 @@ border-radius: 6px; - background: var(--r2-ink); + background: var(--sentiment-tooltip-bg); - color: var(--text-inverse); + color: var(--sentiment-tooltip-fg); font-size: 11px; @@ -1021,6 +1021,8 @@ } .sentiment-chart-tooltip b { + color: inherit; + font-weight: 700; } @@ -1568,10 +1570,6 @@ #sentimentCycleView .sentiment-component-item { padding: 4px 0px; } - - #sentimentCycleView .sentiment-component-item small { - display: none; - } } @media (min-width: 768px) { @@ -1764,3 +1762,72 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +@media (min-width: 1024px) { + #sentimentCycleView .redesigned-emotion-grid { + align-items: stretch; + + height: var(--sentiment-analysis-height); + + max-height: var(--sentiment-analysis-height); + + overflow: hidden; + } + + .redesigned-sentiment-view .sentiment-analysis-main, + .redesigned-sentiment-view .sentiment-analysis-rail { + align-self: stretch; + + height: 100%; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-trend-panel { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-chart-shell { + flex: 1 1 auto; + + height: auto; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-chart-shell canvas { + height: 100%; + } + + .redesigned-sentiment-view .sentiment-cycle-summary { + flex: 0 0 auto; + } + + .redesigned-sentiment-view .sentiment-components-panel { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-component-list { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + justify-content: space-evenly; + + min-height: 0px; + } +} diff --git a/frontend/pages/sentiment/page.js b/frontend/pages/sentiment/page.js index 135f5bb..d514b29 100644 --- a/frontend/pages/sentiment/page.js +++ b/frontend/pages/sentiment/page.js @@ -1,10 +1,39 @@ let sentimentChartAnimationFrame = null; +let sentimentChartResizeObserver = null; +let sentimentChartLastSize = ""; window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], { bind: bindSentimentEvents, enter: ["loadSentiment"], }); +function sentimentChartSizeKey(target) { + if (!target) return ""; + const rect = target.getBoundingClientRect(); + return `${Math.round(rect.width)}x${Math.round(rect.height)}x${window.devicePixelRatio || 1}`; +} + +function observeSentimentTrendChart() { + const shell = document.querySelector("#sentimentCycleView .sentiment-chart-shell"); + if (!shell) return; + if (!sentimentChartResizeObserver) { + sentimentChartResizeObserver = new ResizeObserver(() => { + if (sentimentChartAnimationFrame) return; + if (state.activeView !== "sentimentCycleView") return; + const rows = state.sentimentHistory?.rows; + if (!rows?.length) return; + const current = document.querySelector("#sentimentCycleView .sentiment-chart-shell"); + const nextKey = sentimentChartSizeKey(current); + if (!nextKey || nextKey === sentimentChartLastSize) return; + drawSentimentTrendChart(rows, 1); + }); + } else { + sentimentChartResizeObserver.disconnect(); + } + sentimentChartLastSize = sentimentChartSizeKey(shell); + sentimentChartResizeObserver.observe(shell); +} + async function loadSentimentHistory(force = false) { if (!state.dashboard || state.sentimentLoading) return; const key = `${elements.tradeDate.value}:${state.sentimentRange}`; @@ -126,6 +155,7 @@ function animateSentimentTrendChart(rows) { if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame); if (!motionEnabled()) { drawSentimentTrendChart(rows, 1); + observeSentimentTrendChart(); return; } const startedAt = performance.now(); @@ -135,7 +165,10 @@ function animateSentimentTrendChart(rows) { const progress = 1 - (1 - rawProgress) ** 3; drawSentimentTrendChart(rows, progress); if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame); - else sentimentChartAnimationFrame = null; + else { + sentimentChartAnimationFrame = null; + observeSentimentTrendChart(); + } }; sentimentChartAnimationFrame = requestAnimationFrame(frame); } @@ -144,9 +177,9 @@ function drawSentimentTrendChart(rows, progress = 1) { const canvas = document.querySelector("#sentimentTrendChart"); if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return; const rect = canvas.getBoundingClientRect(); - if (!rect.width) return; - const width = Math.max(320, rect.width); - const height = Math.max(220, rect.height); + if (rect.width < 8 || rect.height < 8) return; + const width = rect.width; + const height = rect.height; const ratio = window.devicePixelRatio || 1; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); @@ -258,6 +291,7 @@ function drawSentimentTrendChart(rows, progress = 1) { const dateText = displayCompactDate(row.trade_date).slice(5); context.fillText(dateText, x(index), height - padding.bottom + 10); }); + sentimentChartLastSize = sentimentChartSizeKey(canvas.closest(".sentiment-chart-shell")); } function bindSentimentChartTooltip(rows) { diff --git a/frontend/shared/tokens.css b/frontend/shared/tokens.css index 5391ed9..e7c2b1f 100644 --- a/frontend/shared/tokens.css +++ b/frontend/shared/tokens.css @@ -210,6 +210,9 @@ --pool-table-max-height: calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap)); --sentiment-history-max-height: 510px; --sentiment-history-min-height: 220px; + --sentiment-analysis-height: 600px; + --sentiment-tooltip-bg: var(--text-primary); + --sentiment-tooltip-fg: var(--text-inverse); --primary-share: 1.45fr; --secondary-share: .75fr; --mobile-nav-height: 64px; @@ -505,6 +508,8 @@ --chart-repair: #e2ad58; --chart-ma-10: #d39a45; --chart-ma-20: #9aa5af; + --sentiment-tooltip-bg: #26293e; + --sentiment-tooltip-fg: #e8eaed; --on-action: #101418; --warning-line: #6d5a38; --warning-line-strong: #66502d; diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 9ceaa8b..3f049c9 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -47,6 +47,72 @@ function session(role = "admin", subscribed = true) { }; } +function sentimentHistoryPayload(days = 20) { + const phases = ["冰点", "修复", "发酵", "高潮", "分化", "退潮"]; + const rows = Array.from({ length: days }, (_, index) => { + const score = 28 + ((index * 7) % 55); + return { + trade_date: `2026-08-${String(index + 1).padStart(2, "0")}`, + score, + label: "情绪观察", + phase: phases[index % phases.length], + direction: index % 2 ? "升温" : "降温", + day_change: index % 2 ? 3.2 : -2.1, + seal_rate: 71.5, + limit_up_count: 40 + index, + first_board_count: 18, + second_board_count: 8, + three_plus_count: 4, + max_height: 5, + broken_count: 12, + limit_down_count: 3, + previous_limit_count: 38, + previous_positive_count: 22, + previous_positive_rate: 57.9, + average_previous_change: 1.2, + normalization: "固定锚点", + components: { + breadth: { label: "市场宽度", score: 55.8, weight: 20, summary: "红盘家数回升" }, + limit: { label: "涨停连板", score: 79.6, weight: 25, summary: "连板生态改善" }, + profit: { label: "赚钱效应", score: 67.0, weight: 30, summary: "昨日反馈尚可" }, + ladder: { label: "涨幅结构", score: 81.5, weight: 15, summary: "高度仍在扩张" }, + amount: { label: "成交活跃度", score: 46.1, weight: 10, summary: "量能略低于均值" }, + }, + }; + }); + return { available_days: days, rows }; +} + +async function renderSentimentFixture(page, days = 20) { + await page.evaluate((payload) => { + state.sentimentHistory = payload; + renderSentimentHistory(); + }, sentimentHistoryPayload(days)); + await page.locator('[data-view="sentimentCycleView"]').first().click(); + await page.waitForTimeout(900); +} + +function readSentimentLayout() { + const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect(); + const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); + const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect(); + const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); + const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect(); + const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect(); + const table = document.querySelector(".sentiment-history-frame").getBoundingClientRect(); + const small = document.querySelector(".sentiment-component-item small"); + return { + topDelta: Math.abs(trend.top - summary.top), + bottomDelta: Math.abs(trend.bottom - components.bottom), + analysisHeight: analysis.height, + chartHeight: chart.height, + detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom) - 0.5, + tableVisible: table.top < window.innerHeight && table.bottom > detail.bottom, + smallVisible: small ? getComputedStyle(small).display !== "none" : false, + overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + }; +} + function waitForApplicationRuntime(page) { return expect(page.locator("body")).toHaveAttribute("data-runtime-ready", "true"); } @@ -721,6 +787,7 @@ test("collapsed overview and sentiment layout keep a single current reading", as await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0); await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。"); const alignment = await page.evaluate(() => { + const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect(); const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect(); @@ -732,6 +799,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text")); return { columnsAligned: Math.abs(trend.top - summary.top) < 1, + bottomsAligned: Math.abs(trend.bottom - components.bottom) < 1, + analysisHeight: analysis.height, railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom, detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom), chartHeight: chart.height, @@ -743,6 +812,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as }; }); expect(alignment.columnsAligned).toBe(true); + expect(alignment.bottomsAligned).toBe(true); + expect(Math.abs(alignment.analysisHeight - 600)).toBeLessThanOrEqual(1); expect(alignment.railAligned).toBe(true); expect(alignment.detailAfterAnalysis).toBe(true); expect(alignment.chartHeight).toBeGreaterThanOrEqual(340); @@ -780,6 +851,68 @@ test("collapsed overview and sentiment layout keep a single current reading", as } }); +test("sentiment cycle keeps 600px equal-height layout across zoom viewports", async ({ page }, testInfo) => { + const shotDir = testInfo.outputPath("hel-221-shots"); + await mockApplication(page, session("user", true)); + const viewports = [ + { name: "zoom-100", width: 2560, height: 1440 }, + { name: "zoom-110", width: 2327, height: 1309 }, + { name: "zoom-125", width: 2048, height: 1152 }, + ]; + + for (const viewport of viewports) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto("/index.html"); + await renderSentimentFixture(page, 20); + const layout = await page.evaluate(readSentimentLayout); + expect(layout.topDelta, viewport.name).toBeLessThanOrEqual(1); + expect(layout.bottomDelta, viewport.name).toBeLessThanOrEqual(1); + expect(Math.abs(layout.analysisHeight - 600), viewport.name).toBeLessThanOrEqual(1); + expect(layout.chartHeight, viewport.name).toBeGreaterThanOrEqual(450); + expect(layout.detailAfterAnalysis, viewport.name).toBe(true); + expect(layout.tableVisible, viewport.name).toBe(true); + expect(layout.smallVisible, viewport.name).toBe(true); + expect(layout.overflowX, viewport.name).toBe(false); + await page.screenshot({ + path: `${shotDir}/day-${viewport.name}.png`, + fullPage: true, + }); + } + + await page.setViewportSize({ width: 2560, height: 1440 }); + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.waitForTimeout(200); + const nightLayout = await page.evaluate(readSentimentLayout); + expect(nightLayout.topDelta).toBeLessThanOrEqual(1); + expect(nightLayout.bottomDelta).toBeLessThanOrEqual(1); + expect(Math.abs(nightLayout.analysisHeight - 600)).toBeLessThanOrEqual(1); + await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } }); + const tooltip = page.locator("#sentimentChartTooltip"); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText("温度"); + const tooltipStyle = await tooltip.evaluate((node) => { + const style = getComputedStyle(node); + const bold = getComputedStyle(node.querySelector("b") || node); + return { background: style.backgroundColor, color: style.color, bold: bold.color }; + }); + expect(tooltipStyle.background).toBe("rgb(38, 41, 62)"); + expect(tooltipStyle.color).toBe("rgb(232, 234, 237)"); + expect(tooltipStyle.bold).toBe("rgb(232, 234, 237)"); + await page.screenshot({ path: `${shotDir}/night-zoom-100.png`, fullPage: true }); + + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } }); + await expect(tooltip).toBeVisible(); + const lightTooltip = await tooltip.evaluate((node) => { + const style = getComputedStyle(node); + return { background: style.backgroundColor, color: style.color }; + }); + expect(lightTooltip.background).toBe("rgb(31, 35, 41)"); + expect(lightTooltip.color).toBe("rgb(255, 255, 255)"); +}); + test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py index ab8f0fd..e2657b7 100644 --- a/tests/test_frontend_contract.py +++ b/tests/test_frontend_contract.py @@ -344,6 +344,22 @@ class FrontendContractTests(unittest.TestCase): self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles) self.assertIn("overflow: auto;", self.sentiment_styles) + def test_sentiment_equal_height_and_tooltip_tokens(self): + self.assertIn("--sentiment-analysis-height: 600px;", self.tokens) + self.assertIn("--sentiment-tooltip-bg: var(--text-primary);", self.tokens) + self.assertIn("--sentiment-tooltip-fg: var(--text-inverse);", self.tokens) + self.assertIn("--sentiment-tooltip-bg: #26293e;", self.tokens) + self.assertIn("--sentiment-tooltip-fg: #e8eaed;", self.tokens) + self.assertIn("height: var(--sentiment-analysis-height);", self.sentiment_styles) + self.assertIn("max-height: var(--sentiment-analysis-height);", self.sentiment_styles) + self.assertIn("justify-content: space-evenly;", self.sentiment_styles) + self.assertIn("background: var(--sentiment-tooltip-bg);", self.sentiment_styles) + self.assertIn("color: var(--sentiment-tooltip-fg);", self.sentiment_styles) + self.assertIn("new ResizeObserver", self.script) + self.assertIn("#sentimentCycleView .redesigned-emotion-grid {", self.sentiment_styles) + self.assertNotIn("height: 100vh", self.sentiment_styles) + self.assertNotIn("min-height: 100%", self.sentiment_styles) + def test_mentor_final_visual_fix_contract(self): shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8") mentor_html = (STATIC_DIR / "pages" / "mentor" / "page.html").read_text(encoding="utf-8") From f0a1adf52f2cacd082c65ea2eddf9d368b6aee21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Sat, 29 Aug 2026 11:24:39 +0800 Subject: [PATCH 13/21] =?UTF-8?q?=E6=96=BD=E5=B7=A5(HEL-226):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=99=BB=E5=BD=95=E9=97=A8=E6=88=B7=E4=B8=8E=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=E5=85=8D=E5=AF=86=E5=88=87=E6=8D=A2=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用设备 Cookie 和授权表记住本机已验证账号,登录页按确认样图做成门户,不再把密码写进浏览器。 Co-authored-by: Cursor Co-authored-by: multica-agent --- backend/bootstrap/config.py | 2 + backend/database/migrations/__init__.py | 2 + .../migrations/m0005_account_switch_grants.py | 42 ++ backend/features/accounts/application.py | 8 +- backend/features/accounts/http.py | 116 ++++- backend/features/accounts/repository.py | 93 ++++ backend/features/accounts/routes.py | 3 + backend/features/accounts/service.py | 97 +++- backend/http/dispatch.py | 2 + backend/http/handler.py | 36 +- config/api.config.json | 21 + config/architecture-inventory.json | 46 +- frontend/app.js | 6 +- frontend/index.html | 10 +- frontend/login/index.html | 59 +++ frontend/login/page.js | 273 +++++++++++ frontend/m/css/shell.css | 23 + frontend/m/index.html | 4 +- frontend/m/js/boot.js | 7 +- frontend/m/js/router.js | 30 ++ frontend/m/js/session.js | 27 +- frontend/shared/auth.css | 442 ++++++++++++++++++ frontend/shared/session.js | 20 +- frontend/shared/tokens.css | 2 + tests/e2e/login-portal.spec.js | 151 ++++++ tests/test_account_switch_grants.py | 132 ++++++ tests/test_database_migrations.py | 7 +- tests/test_governance_registries.py | 3 + tools/build_api_registry.py | 8 +- 29 files changed, 1583 insertions(+), 89 deletions(-) create mode 100644 backend/database/migrations/m0005_account_switch_grants.py create mode 100644 frontend/login/index.html create mode 100644 frontend/login/page.js create mode 100644 tests/e2e/login-portal.spec.js create mode 100644 tests/test_account_switch_grants.py diff --git a/backend/bootstrap/config.py b/backend/bootstrap/config.py index 58813bd..3ed5980 100644 --- a/backend/bootstrap/config.py +++ b/backend/bootstrap/config.py @@ -18,6 +18,8 @@ TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,128}$") USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}$") SESSION_COOKIE = "xiaobai_session" SESSION_MAX_AGE = 30 * 24 * 60 * 60 +DEVICE_COOKIE = "xiaobai_device" +DEVICE_MAX_AGE = 180 * 24 * 60 * 60 def load_local_env() -> None: diff --git a/backend/database/migrations/__init__.py b/backend/database/migrations/__init__.py index 694558f..71755cf 100644 --- a/backend/database/migrations/__init__.py +++ b/backend/database/migrations/__init__.py @@ -2,6 +2,7 @@ from .m0001_adopt_legacy import MIGRATION as M0001_ADOPT_LEGACY from .m0002_job_runs import MIGRATION as M0002_JOB_RUNS from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES +from .m0005_account_switch_grants import MIGRATION as M0005_ACCOUNT_SWITCH_GRANTS from .runner import Migration, MigrationError, MigrationRunner MIGRATIONS = ( @@ -9,6 +10,7 @@ MIGRATIONS = ( M0002_JOB_RUNS, M0003_LLM_AUDIT, M0004_MENTOR_NOTES, + M0005_ACCOUNT_SWITCH_GRANTS, ) __all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"] diff --git a/backend/database/migrations/m0005_account_switch_grants.py b/backend/database/migrations/m0005_account_switch_grants.py new file mode 100644 index 0000000..e63f245 --- /dev/null +++ b/backend/database/migrations/m0005_account_switch_grants.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def create_account_switch_grants(connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS account_switch_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_hash TEXT NOT NULL, + user_id INTEGER NOT NULL, + granted_at TEXT NOT NULL, + last_used_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + UNIQUE (device_hash, user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_switch_grants_device + ON account_switch_grants(device_hash, last_used_at DESC) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_switch_grants_expiry + ON account_switch_grants(expires_at) + """ + ) + + +MIGRATION = Migration( + version="0005", + name="create_account_switch_grants", + action=create_account_switch_grants, + signature="account-switch-grants:v1:device,user,granted,used,expires,unique", +) diff --git a/backend/features/accounts/application.py b/backend/features/accounts/application.py index 6608e6b..1d227e9 100644 --- a/backend/features/accounts/application.py +++ b/backend/features/accounts/application.py @@ -28,11 +28,11 @@ class AccountApplicationMixin: def update_membership(self, payload: dict[str, Any]) -> None: self.accounts.update_membership(payload) - def register_account(self, username: str, password: str) -> dict[str, Any]: - return self.accounts.register(username, password) + def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: + return self.accounts.register(username, password, device_hash) - def login_account(self, username: str, password: str) -> dict[str, Any]: - return self.accounts.login(username, password) + def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: + return self.accounts.login(username, password, device_hash) def change_password(self, current_password: str, new_password: str) -> None: self.accounts.change_password(current_password, new_password) diff --git a/backend/features/accounts/http.py b/backend/features/accounts/http.py index 4bc282e..22aafeb 100644 --- a/backend/features/accounts/http.py +++ b/backend/features/accounts/http.py @@ -1,49 +1,108 @@ from __future__ import annotations import json +import secrets from http import HTTPStatus +from backend.features.accounts.security import token_hash + class AccountHttpMixin: + def _device_hash(self) -> str: + raw = self.device_token() + return token_hash(raw) if raw else "" + + def _ensure_device_token(self) -> str: + return self.device_token() or secrets.token_urlsafe(32) + + def _auth_success_headers(self, session_token: str, device_raw: str) -> list[tuple[str, str]]: + return [ + ("Set-Cookie", self.session_cookie(session_token)), + ("Set-Cookie", self.device_cookie(device_raw)), + ] + + def _send_authenticated_session(self, result: dict, status: HTTPStatus, device_raw: str) -> None: + self.send_json( + { + "ok": True, + "authenticated": True, + "user": result["user"], + "csrf_token": result["csrf_token"], + }, + status, + self._auth_success_headers(result["session_token"], device_raw), + ) + def auth_register(self) -> None: try: body = self.read_json_body() + device_raw = self._ensure_device_token() result = self.application_service.register_account( str(body.get("username") or ""), str(body.get("password") or ""), + token_hash(device_raw), ) - self.send_json( - { - "ok": True, - "authenticated": True, - "user": result["user"], - "csrf_token": result["csrf_token"], - }, - HTTPStatus.CREATED, - {"Set-Cookie": self.session_cookie(result["session_token"])}, - ) + self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw) except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) def auth_login(self) -> None: try: body = self.read_json_body() + device_raw = self._ensure_device_token() result = self.application_service.login_account( str(body.get("username") or ""), str(body.get("password") or ""), + token_hash(device_raw), ) - self.send_json( - { - "ok": True, - "authenticated": True, - "user": result["user"], - "csrf_token": result["csrf_token"], - }, - headers={"Set-Cookie": self.session_cookie(result["session_token"])}, - ) + self._send_authenticated_session(result, HTTPStatus.OK, device_raw) except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED) + def auth_accounts(self) -> None: + current_user_id = None + if self.require_auth(send_error=False): + current_user_id = int(self.auth_user["id"]) + payload = self.application_service.accounts.list_device_accounts( + self._device_hash(), + current_user_id, + ) + self.send_json({"ok": True, **payload}) + + def auth_switch(self) -> None: + try: + body = self.read_json_body() + try: + user_id = int(body.get("user_id")) + except (TypeError, ValueError): + user_id = 0 + device_raw = self.device_token() + result = self.application_service.accounts.switch_account( + token_hash(device_raw) if device_raw else "", + user_id, + ) + self._send_authenticated_session( + result, + HTTPStatus.OK, + device_raw or self._ensure_device_token(), + ) + except PermissionError as exc: + self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + + def auth_forget(self) -> None: + try: + body = self.read_json_body() + try: + user_id = int(body.get("user_id")) + except (TypeError, ValueError): + user_id = 0 + self.application_service.accounts.forget_account(self._device_hash(), user_id) + except (ValueError, json.JSONDecodeError): + pass + self.send_json({"ok": True}) + def auth_me(self) -> None: service = self.application_service if not self.require_auth(send_error=False): @@ -55,6 +114,15 @@ class AccountHttpMixin: } ) return + headers = None + if not self.device_token(): + device_raw = secrets.token_urlsafe(32) + service.accounts.remember_account( + token_hash(device_raw), + int(self.auth_user["id"]), + fresh=True, + ) + headers = [("Set-Cookie", self.device_cookie(device_raw))] self.send_json( { "ok": True, @@ -66,15 +134,19 @@ class AccountHttpMixin: "membership": service.membership(), }, "csrf_token": str(self.auth_user["csrf_token"]), - } + }, + headers=headers, ) def auth_logout(self) -> None: raw_token = self.session_token() + user_id = int(getattr(self, "auth_user", {}).get("id") or 0) if raw_token: - from backend.features.accounts.security import token_hash - self.application_service.database.delete_session(token_hash(raw_token)) + self.application_service.accounts.revoke_current_device_grant( + self._device_hash(), + user_id, + ) self.send_json( {"ok": True}, headers={"Set-Cookie": self.session_cookie("", clear=True)}, diff --git a/backend/features/accounts/repository.py b/backend/features/accounts/repository.py index 98cc534..04bb60c 100644 --- a/backend/features/accounts/repository.py +++ b/backend/features/accounts/repository.py @@ -234,3 +234,96 @@ class AccountRepositoryMixin: (user_id,), ) return cursor.rowcount > 0 + + def cleanup_expired_switch_grants(self, now: str) -> int: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM account_switch_grants WHERE expires_at <= ?", + (now,), + ) + return int(cursor.rowcount) + + def list_switch_grants(self, device_hash: str, now: str) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status, + u.membership_plan, u.membership_starts_at, u.membership_expires_at, + u.created_at, g.last_used_at, g.granted_at, g.expires_at + FROM account_switch_grants AS g + JOIN users AS u ON u.id = g.user_id + WHERE g.device_hash = ? AND g.expires_at > ? + ORDER BY g.last_used_at DESC, g.id DESC + """, + (device_hash, now), + ).fetchall() + return [dict(row) for row in rows] + + def get_switch_grant(self, device_hash: str, user_id: int) -> dict[str, Any] | None: + with self.connect() as connection: + row = connection.execute( + """ + SELECT device_hash, user_id, granted_at, last_used_at, expires_at + FROM account_switch_grants + WHERE device_hash = ? AND user_id = ? + """, + (device_hash, user_id), + ).fetchone() + return dict(row) if row else None + + def upsert_switch_grant( + self, + device_hash: str, + user_id: int, + granted_at: str, + last_used_at: str, + expires_at: str, + ) -> None: + with self.connect() as connection: + connection.execute( + """ + INSERT INTO account_switch_grants + (device_hash, user_id, granted_at, last_used_at, expires_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(device_hash, user_id) DO UPDATE SET + granted_at = excluded.granted_at, + last_used_at = excluded.last_used_at, + expires_at = excluded.expires_at + """, + (device_hash, user_id, granted_at, last_used_at, expires_at), + ) + + def prune_switch_grants(self, device_hash: str, keep: int) -> int: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT id FROM account_switch_grants + WHERE device_hash = ? + ORDER BY last_used_at DESC, id DESC + """, + (device_hash,), + ).fetchall() + extra = [int(row["id"]) for row in rows[keep:]] + if not extra: + return 0 + connection.execute( + f"DELETE FROM account_switch_grants WHERE id IN ({','.join('?' * len(extra))})", + extra, + ) + return len(extra) + + def delete_switch_grant(self, device_hash: str, user_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM account_switch_grants WHERE device_hash = ? AND user_id = ?", + (device_hash, user_id), + ) + return cursor.rowcount > 0 + + def delete_switch_grants_for_user(self, user_id: int) -> int: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM account_switch_grants WHERE user_id = ?", + (user_id,), + ) + return int(cursor.rowcount) diff --git a/backend/features/accounts/routes.py b/backend/features/accounts/routes.py index 89af383..a5921b9 100644 --- a/backend/features/accounts/routes.py +++ b/backend/features/accounts/routes.py @@ -6,6 +6,9 @@ class AccountRoutesMixin: if parsed.path == "/api/auth/me": self.auth_me() return True + if parsed.path == "/api/auth/accounts": + self.auth_accounts() + return True return False def _handle_accounts_get(self, parsed) -> bool: diff --git a/backend/features/accounts/service.py b/backend/features/accounts/service.py index aa6958c..47d5a75 100644 --- a/backend/features/accounts/service.py +++ b/backend/features/accounts/service.py @@ -77,15 +77,22 @@ class AccountService: access = self.access_supplier() or self.database.user_access(self.current_user_id) or {} return self.membership_for_access(access) - def register(self, username: str, password: str) -> dict[str, Any]: + GRANT_SLIDE_DAYS = 30 + GRANT_HARD_DAYS = 180 + MAX_GRANTS_PER_DEVICE = 5 + SWITCH_REAUTH_MESSAGE = "该账号需重新验证" + + def register(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: username = username.strip() self.validate_input(username, password) with self.auth_lock: salt, password_digest = hash_password(password) user = self.database.create_user(username, salt, password_digest) - return self.create_session(user) + result = self.create_session(user) + self.remember_account(device_hash, int(user["id"]), fresh=True) + return result - def login(self, username: str, password: str) -> dict[str, Any]: + def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: username = username.strip() if not username or not password: raise ValueError("账号名和密码不能为空。") @@ -96,7 +103,9 @@ class AccountService: str(user.get("password_hash") or ""), ): raise ValueError("账号名或密码不正确。") - return self.create_session(user) + result = self.create_session(user) + self.remember_account(device_hash, int(user["id"]), fresh=True) + return result def change_password(self, current_password: str, new_password: str) -> None: current_password = str(current_password or "") @@ -112,6 +121,86 @@ class AccountService: salt, digest = hash_password(new_password) if not self.database.update_user_password(self.current_user_id, salt, digest): raise ValueError("账号不存在。") + self.database.delete_switch_grants_for_user(self.current_user_id) + + @staticmethod + def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + @classmethod + def _iso(cls, value: datetime) -> str: + return value.isoformat(timespec="seconds") + + def remember_account(self, device_hash: str, user_id: int, *, fresh: bool = False) -> None: + if not device_hash or user_id <= 0: + return + now = self._utc_now() + now_text = self._iso(now) + self.database.cleanup_expired_switch_grants(now_text) + existing = None if fresh else self.database.get_switch_grant(device_hash, user_id) + granted_at = parse_iso_datetime(existing["granted_at"]) if existing else now + if granted_at is None: + granted_at = now + expires = min( + now + timedelta(days=self.GRANT_SLIDE_DAYS), + granted_at + timedelta(days=self.GRANT_HARD_DAYS), + ) + if not existing: + self.database.prune_switch_grants(device_hash, self.MAX_GRANTS_PER_DEVICE - 1) + self.database.upsert_switch_grant( + device_hash, + user_id, + self._iso(granted_at), + now_text, + self._iso(expires), + ) + + def list_device_accounts( + self, device_hash: str, current_user_id: int | None = None + ) -> dict[str, Any]: + if not device_hash: + return {"accounts": [], "current_user_id": current_user_id} + now_text = self._iso(self._utc_now()) + self.database.cleanup_expired_switch_grants(now_text) + accounts = [] + for row in self.database.list_switch_grants(device_hash, now_text): + accounts.append( + { + "user_id": int(row["id"]), + "username": str(row["username"]), + "role": str(row.get("role") or "user"), + "membership": self.membership_for_access(row), + "last_used_at": str(row.get("last_used_at") or ""), + } + ) + return {"accounts": accounts, "current_user_id": current_user_id} + + def switch_account(self, device_hash: str, user_id: int) -> dict[str, Any]: + if not device_hash or user_id <= 0: + raise PermissionError(self.SWITCH_REAUTH_MESSAGE) + now = self._utc_now() + now_text = self._iso(now) + self.database.cleanup_expired_switch_grants(now_text) + grant = self.database.get_switch_grant(device_hash, user_id) + expires = parse_iso_datetime(grant.get("expires_at")) if grant else None + if not grant or not expires or expires <= now: + if grant: + self.database.delete_switch_grant(device_hash, user_id) + raise PermissionError(self.SWITCH_REAUTH_MESSAGE) + user = self.database.user_access(user_id) + if not user: + raise PermissionError(self.SWITCH_REAUTH_MESSAGE) + result = self.create_session(user) + self.remember_account(device_hash, user_id) + return result + + def forget_account(self, device_hash: str, user_id: int) -> None: + if device_hash and user_id > 0: + self.database.delete_switch_grant(device_hash, user_id) + + def revoke_current_device_grant(self, device_hash: str, user_id: int) -> None: + if device_hash and user_id > 0: + self.database.delete_switch_grant(device_hash, user_id) def create_session(self, user: dict[str, Any]) -> dict[str, Any]: session_token = secrets.token_urlsafe(32) diff --git a/backend/http/dispatch.py b/backend/http/dispatch.py index 3b58526..3c31e5c 100644 --- a/backend/http/dispatch.py +++ b/backend/http/dispatch.py @@ -7,6 +7,8 @@ from urllib.parse import urlparse PUBLIC_POST_HANDLERS = { "/api/auth/register": "auth_register", "/api/auth/login": "auth_login", + "/api/auth/switch": "auth_switch", + "/api/auth/forget": "auth_forget", } AUTHENTICATED_POST_HANDLERS = { diff --git a/backend/http/handler.py b/backend/http/handler.py index c95e484..2a906fc 100644 --- a/backend/http/handler.py +++ b/backend/http/handler.py @@ -9,7 +9,13 @@ from http.cookies import SimpleCookie from typing import Any from urllib.parse import unquote -from backend.bootstrap.config import SESSION_COOKIE, SESSION_MAX_AGE, STATIC_DIR +from backend.bootstrap.config import ( + DEVICE_COOKIE, + DEVICE_MAX_AGE, + SESSION_COOKIE, + SESSION_MAX_AGE, + STATIC_DIR, +) from backend.features.accounts.security import token_hash from backend.http.context import correlation_id from backend.http.errors import normalize_error_payload @@ -21,15 +27,21 @@ class HttpTransportMixin: application_service: Any route_registry: Any - def session_token(self) -> str: + def cookie_value(self, name: str) -> str: cookie = SimpleCookie() try: cookie.load(self.headers.get("Cookie", "")) except Exception: return "" - morsel = cookie.get(SESSION_COOKIE) + morsel = cookie.get(name) return morsel.value if morsel else "" + def session_token(self) -> str: + return self.cookie_value(SESSION_COOKIE) + + def device_token(self) -> str: + return self.cookie_value(DEVICE_COOKIE) + def require_auth(self, send_error: bool = True) -> bool: raw_token = self.session_token() service = self.application_service @@ -79,15 +91,18 @@ class HttpTransportMixin: return self.require_member() return True - def session_cookie(self, value: str, clear: bool = False) -> str: - max_age = 0 if clear else SESSION_MAX_AGE - cookie = ( - f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}" - ) + def _cookie_header(self, name: str, value: str, max_age: int) -> str: + cookie = f"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}" if self.headers.get("X-Forwarded-Proto", "").lower() == "https": cookie += "; Secure" return cookie + def session_cookie(self, value: str, clear: bool = False) -> str: + return self._cookie_header(SESSION_COOKIE, value, 0 if clear else SESSION_MAX_AGE) + + def device_cookie(self, value: str, clear: bool = False) -> str: + return self._cookie_header(DEVICE_COOKIE, value, 0 if clear else DEVICE_MAX_AGE) + def read_json_body(self, allow_empty: bool = False) -> dict[str, Any]: length = int(self.headers.get("Content-Length", "0")) if length == 0 and allow_empty: @@ -132,7 +147,7 @@ class HttpTransportMixin: self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK, - headers: dict[str, str] | None = None, + headers: dict[str, str] | list[tuple[str, str]] | tuple[tuple[str, str], ...] | None = None, ) -> None: request_id = getattr(self, "_correlation_id", "") if not request_id: @@ -145,7 +160,8 @@ class HttpTransportMixin: self.send_header("Content-Length", str(len(content))) self.send_header("Cache-Control", "no-store") self.send_header("X-Request-ID", request_id) - for name, value in (headers or {}).items(): + header_items = headers.items() if isinstance(headers, dict) else (headers or ()) + for name, value in header_items: self.send_header(name, value) self.end_headers() self.wfile.write(content) diff --git a/config/api.config.json b/config/api.config.json index 02fd77f..dc88d77 100644 --- a/config/api.config.json +++ b/config/api.config.json @@ -128,6 +128,20 @@ "feature": "auction", "access": "authenticated" }, + { + "method": "GET", + "path": "/api/auth/accounts", + "match": "exact", + "feature": "auth", + "access": "public" + }, + { + "method": "POST", + "path": "/api/auth/forget", + "match": "exact", + "feature": "auth", + "access": "public" + }, { "method": "POST", "path": "/api/auth/login", @@ -156,6 +170,13 @@ "feature": "auth", "access": "public" }, + { + "method": "POST", + "path": "/api/auth/switch", + "match": "exact", + "feature": "auth", + "access": "public" + }, { "method": "POST", "path": "/api/backfill", diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index e725ad6..4d51c0b 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -10,10 +10,10 @@ }, "counts": { "primary_pages": 16, - "api_exact_paths": 53, + "api_exact_paths": 56, "api_prefixes": 0, "api_patterns": 11, - "database_tables": 36, + "database_tables": 37, "frontend_page_fragments": 12 }, "pages": [ @@ -96,10 +96,13 @@ "/api/assistant/chat", "/api/assistant/messages", "/api/auction", + "/api/auth/accounts", + "/api/auth/forget", "/api/auth/login", "/api/auth/logout", "/api/auth/me", "/api/auth/register", + "/api/auth/switch", "/api/backfill", "/api/chart/intraday", "/api/dashboard", @@ -189,6 +192,7 @@ "assistant_messages", "heaven_readings", "job_runs", + "account_switch_grants", "schema_migrations" ], "background_job_methods": [ @@ -370,10 +374,10 @@ } ], "css_layers": [ - "/shared/tokens.css?v=20260820-3", + "/shared/tokens.css?v=20260829-1", "/shared/base.css?v=20260806-1", "/shared/shell.css?v=20260820-8", - "/shared/auth.css?v=20260820-5", + "/shared/auth.css?v=20260829-1", "/shared/components/controls.css?v=20260820-2", "/shared/components/navigation.css?v=20260820-1", "/shared/components/cards.css?v=20260820-1", @@ -461,8 +465,8 @@ }, { "path": "frontend/index.html", - "bytes": 47871, - "lines": 661 + "bytes": 48037, + "lines": 663 }, { "path": "backend/features/screener/catalog.py", @@ -551,8 +555,8 @@ }, { "path": "frontend/shared/session.js", - "bytes": 13176, - "lines": 293 + "bytes": 12848, + "lines": 283 }, { "path": "backend/features/market/insights_auction_data.py", @@ -716,8 +720,8 @@ }, { "path": "backend/http/dispatch.py", - "bytes": 4118, - "lines": 115 + "bytes": 4196, + "lines": 117 }, { "path": "frontend/shared/table.js", @@ -734,16 +738,16 @@ "bytes": 3369, "lines": 81 }, + { + "path": "frontend/app.js", + "bytes": 3337, + "lines": 95 + }, { "path": "frontend/pages/themes/page.html", "bytes": 3316, "lines": 55 }, - { - "path": "frontend/app.js", - "bytes": 3201, - "lines": 93 - }, { "path": "backend/features/market/insights_context.py", "bytes": 3175, @@ -761,7 +765,7 @@ }, { "path": "backend/features/accounts/application.py", - "bytes": 2442, + "bytes": 2514, "lines": 63 }, { @@ -849,6 +853,11 @@ "bytes": 1143, "lines": 19 }, + { + "path": "backend/features/accounts/routes.py", + "bytes": 908, + "lines": 25 + }, { "path": "backend/features/popularity/routes.py", "bytes": 822, @@ -859,11 +868,6 @@ "bytes": 817, "lines": 23 }, - { - "path": "backend/features/accounts/routes.py", - "bytes": 803, - "lines": 22 - }, { "path": "backend/features/sentiment/routes.py", "bytes": 724, diff --git a/frontend/app.js b/frontend/app.js index 0fbfd37..90fbb7f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -25,8 +25,10 @@ async function initialize() { try { const session = await apiRequest("/api/auth/me"); if (!session.authenticated) { - if (session.registration_required) selectAuthMode("register"); - showAuthGate(); + const params = new URLSearchParams(); + if (session.registration_required) params.set("mode", "register"); + const query = params.toString(); + window.location.replace("/login/" + (query ? `?${query}` : "")); return; } await applyAuthenticatedSession(session); diff --git a/frontend/index.html b/frontend/index.html index 2d85bfa..ed00604 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,7 +24,9 @@ (() => { let theme = "light"; try { - theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light"; + const stored = localStorage.getItem("xiaobaiTheme"); + if (stored === "dark" || stored === "light") theme = stored; + else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) theme = "dark"; } catch (_error) { theme = "light"; } @@ -32,10 +34,10 @@ document.documentElement.style.colorScheme = theme; })(); - + - + @@ -57,7 +59,7 @@ -
+