From e177f21d1c8704ee2dccbedd9cffcec4dc255b0b Mon Sep 17 00:00:00 2001 From: leefer Date: Wed, 29 Jul 2026 20:16:21 +0800 Subject: [PATCH] refactor: establish frontend request and state boundaries --- docs/governance/architecture-inventory.json | 8 +- .../stage-14-frontend-boundaries.md | 38 ++ static/app.js | 430 ++++++++---------- static/index.html | 4 +- static/shared/api.js | 102 +++++ static/shared/state.js | 44 ++ tests/test_frontend_boundaries.py | 37 ++ 7 files changed, 421 insertions(+), 242 deletions(-) create mode 100644 docs/governance/stage-14-frontend-boundaries.md create mode 100644 static/shared/api.js create mode 100644 static/shared/state.js create mode 100644 tests/test_frontend_boundaries.py diff --git a/docs/governance/architecture-inventory.json b/docs/governance/architecture-inventory.json index b6851bd..cf550ca 100644 --- a/docs/governance/architecture-inventory.json +++ b/docs/governance/architecture-inventory.json @@ -254,8 +254,8 @@ "code_hotspots": [ { "path": "static/app.js", - "bytes": 449457, - "lines": 9447 + "bytes": 447568, + "lines": 9403 }, { "path": "static/styles.css", @@ -274,8 +274,8 @@ }, { "path": "static/index.html", - "bytes": 133569, - "lines": 1871 + "bytes": 133691, + "lines": 1873 }, { "path": "database.py", diff --git a/docs/governance/stage-14-frontend-boundaries.md b/docs/governance/stage-14-frontend-boundaries.md new file mode 100644 index 0000000..f584a25 --- /dev/null +++ b/docs/governance/stage-14-frontend-boundaries.md @@ -0,0 +1,38 @@ +# Stage 14: Frontend Request and State Boundaries + +## Request Boundary + +`static/shared/api.js` is now the only application file allowed to call `fetch`. It owns: + +- JSON serialization and response parsing; +- CSRF attachment for mutating requests; +- expired-session notification; +- abort signals; +- NDJSON stream decoding and normalized stream errors. + +The mentor and review-assistant streams use the same client as ordinary API requests. Existing +function signatures and page interactions remain unchanged. + +## State Boundary + +`static/shared/state.js` stores mutable state in explicit domains: session, market, entity +details, review, screener, mentor, and heaven. A compatibility proxy retains the existing flat +access syntax while rejecting unregistered fields. New page modules can request their owned +domain without depending on another page's data. + +This stage establishes a migration boundary rather than splitting the build-free monolith in a +single high-risk edit. Page extraction can now proceed domain by domain with no contract change. + +## Enforcement + +Automated checks require that: + +- only `shared/api.js` contains browser `fetch` calls; +- shared state and API scripts load before `app.js`; +- application state is created through the shared state boundary. + +## Residual Risk + +`static/app.js` still contains page renderers and event handlers in one file. The state domains +make ownership explicit, but those functions should move into page modules only in later, +independently verified stages. diff --git a/static/app.js b/static/app.js index b81e1d9..1629ef4 100644 --- a/static/app.js +++ b/static/app.js @@ -21,166 +21,185 @@ const THEME_STORAGE_KEY = "xiaobaiTheme"; let activeThemeTransition = null; let themeSwitchSequence = 0; -const state = { - user: null, - csrfToken: "", - authMode: "login", - started: false, - dashboard: null, - filter: "all", - query: "", - sortKey: "streak", - sortDirection: "desc", - brokenQuery: "", - brokenSortKey: "", - brokenSortDirection: "desc", - downQuery: "", - downSortKey: "", - downSortDirection: "asc", - yesterdayFilter: "all", - yesterdayQuery: "", - yesterdaySortKey: "", - yesterdaySortDirection: "desc", - activeView: "sentimentCycleView", - dragonTiger: null, - dragonViewMode: "daily", - dragonFilter: "all", - dragonQuery: "", - selectedDragonTraderId: "", - hotMoneyProfiles: null, - hotMoneyProfileQuery: "", - selectedHotMoneyProfileId: "", - rotationHistory: null, - rotationHistoryKey: "", - rotationSelectedSector: "", - rotationSelectedDate: "", - rotationMembers: null, - rotationMembersKey: "", - rotationMembersLoading: false, - rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest", - rotationLoading: false, - auctionData: null, - auctionDataset: "focus", - auctionFilter: "all", - auctionQuery: "", - auctionSortKey: "attention_score", - auctionSortDirection: "desc", - auctionLoading: false, - auctionTimer: null, - themeLibrary: null, - themeQuery: "", - selectedThemeCode: "", - themeDetail: null, - themeLoading: false, - popularityData: null, - popularitySource: "combined", - popularityQuery: "", - popularityLoading: false, - expandedLadderLevels: new Set(), - ladderSortMode: "time", - stockDetail: null, - activeStock: null, - stockDetailChartMode: "daily", - stockDetailIntraday: null, - stockDetailRequestSequence: 0, - entityDetailItem: null, - entityDetailPayload: null, - entityDetailChartMode: "daily", - entityDetailIntraday: null, - entityDetailRequestSequence: 0, - stockPreviewCode: "", - stockPreviewType: "stock", - stockPreviewItem: null, - stockPreviewPayload: null, - stockPreviewChart: "daily", - stockPreviewFallback: null, - watchlist: [], - watchlistSelection: null, - watchlistSearchResults: [], - watchlistSearchRequestSequence: 0, - editingDailyNoteId: 0, - notes: [], - tradeEntries: [], - tradeSummary: {}, - editingTradeId: 0, - initialStockOpened: false, - screenerSetup: null, - screenerSetupKey: "", - screenerSetupRequestKey: "", - screenerSetupPromise: null, - selectedRegime: "", - selectedStrategy: null, - customStrategyDraft: null, - screenerRunning: false, - screenerRunningMode: "", - screenerResults: { smart: null, curated: null, quant: null }, - screenerResultContexts: { smart: null, curated: null, quant: null }, - screenerResultStore: {}, - screenerTracking: null, - screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode")) - ? localStorage.getItem("xiaobaiScreenerMode") - : "smart", - curatedCategory: "全部", - curatedSchool: "全部", - curatedQuery: "", - curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list", - selectedCuratedStrategyId: 0, - quantFilters: [], - quantScores: [], - alerts: [], - alertFilter: "all", - alertUnreadCount: 0, - assistantMessages: [], - assistantLoading: false, - assistantController: null, - screenerMobileView: "strategy", - sentimentHistory: null, - sentimentRange: 20, - sentimentHistoryKey: "", - sentimentLoading: false, - mentorSetup: null, - selectedMentorId: "", - mentorMessages: [], - mentorLoading: false, - mentorQuery: "", - mentorGrade: "all", - mentorDirectoryOpen: false, - mentorSortMode: false, - mentorSavingPreferences: false, - mentorController: null, - heavenSetup: null, - heavenManualData: null, - personalField: null, - heavenPanel: "trend", - heavenInterpretations: { trend: "", fortune: "", heart: "" }, - heavenReadingMode: "trend", - heavenReadingTab: "current", - heavenReadingHistory: { trend: [], fortune: [], heart: [] }, - heavenReadingSelectedId: 0, - heavenReadingLoading: false, - heavenReadingError: "", - heartStage: "intro", - heartTimer: null, - heartSeconds: HEART_BREATH_TOTAL_MS / 1000, - heartBreathingEndsAt: 0, - heartLines: [], - heartThrows: [], - heartHexagram: null, - heartCurtainTimer: null, - heartStageToken: 0, - heartRevealToken: 0, - heavenPerformanceKey: "", - heavenPerformancePanels: new Set(), - heavenPerformanceActive: "", - dashboardLoading: false, - dashboardRequestSequence: 0, - heavenRequestSequence: 0, - dashboardRequestDate: "", - adminModels: [], - globalSearchResults: [], - globalSearchActiveIndex: -1, - globalSearchRequestSequence: 0, -}; +const state = window.XiaobaiState.create({ + session: { + user: null, + csrfToken: "", + authMode: "login", + started: false, + activeView: "sentimentCycleView", + dashboardLoading: false, + dashboardRequestSequence: 0, + dashboardRequestDate: "", + adminModels: [], + globalSearchResults: [], + globalSearchActiveIndex: -1, + globalSearchRequestSequence: 0, + }, + market: { + dashboard: null, + filter: "all", + query: "", + sortKey: "streak", + sortDirection: "desc", + brokenQuery: "", + brokenSortKey: "", + brokenSortDirection: "desc", + downQuery: "", + downSortKey: "", + downSortDirection: "asc", + yesterdayFilter: "all", + yesterdayQuery: "", + yesterdaySortKey: "", + yesterdaySortDirection: "desc", + dragonTiger: null, + dragonViewMode: "daily", + dragonFilter: "all", + dragonQuery: "", + selectedDragonTraderId: "", + hotMoneyProfiles: null, + hotMoneyProfileQuery: "", + selectedHotMoneyProfileId: "", + rotationHistory: null, + rotationHistoryKey: "", + rotationSelectedSector: "", + rotationSelectedDate: "", + rotationMembers: null, + rotationMembersKey: "", + rotationMembersLoading: false, + rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest", + rotationLoading: false, + auctionData: null, + auctionDataset: "focus", + auctionFilter: "all", + auctionQuery: "", + auctionSortKey: "attention_score", + auctionSortDirection: "desc", + auctionLoading: false, + auctionTimer: null, + themeLibrary: null, + themeQuery: "", + selectedThemeCode: "", + themeDetail: null, + themeLoading: false, + popularityData: null, + popularitySource: "combined", + popularityQuery: "", + popularityLoading: false, + expandedLadderLevels: new Set(), + ladderSortMode: "time", + sentimentHistory: null, + sentimentRange: 20, + sentimentHistoryKey: "", + sentimentLoading: false, + }, + details: { + stockDetail: null, + activeStock: null, + stockDetailChartMode: "daily", + stockDetailIntraday: null, + stockDetailRequestSequence: 0, + entityDetailItem: null, + entityDetailPayload: null, + entityDetailChartMode: "daily", + entityDetailIntraday: null, + entityDetailRequestSequence: 0, + stockPreviewCode: "", + stockPreviewType: "stock", + stockPreviewItem: null, + stockPreviewPayload: null, + stockPreviewChart: "daily", + stockPreviewFallback: null, + initialStockOpened: false, + }, + review: { + watchlist: [], + watchlistSelection: null, + watchlistSearchResults: [], + watchlistSearchRequestSequence: 0, + editingDailyNoteId: 0, + notes: [], + tradeEntries: [], + tradeSummary: {}, + editingTradeId: 0, + alerts: [], + alertFilter: "all", + alertUnreadCount: 0, + assistantMessages: [], + assistantLoading: false, + assistantController: null, + }, + screener: { + screenerSetup: null, + screenerSetupKey: "", + screenerSetupRequestKey: "", + screenerSetupPromise: null, + selectedRegime: "", + selectedStrategy: null, + customStrategyDraft: null, + screenerRunning: false, + screenerRunningMode: "", + screenerResults: { smart: null, curated: null, quant: null }, + screenerResultContexts: { smart: null, curated: null, quant: null }, + screenerResultStore: {}, + screenerTracking: null, + screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode")) + ? localStorage.getItem("xiaobaiScreenerMode") + : "smart", + curatedCategory: "全部", + curatedSchool: "全部", + curatedQuery: "", + curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list", + selectedCuratedStrategyId: 0, + quantFilters: [], + quantScores: [], + screenerMobileView: "strategy", + }, + mentor: { + mentorSetup: null, + selectedMentorId: "", + mentorMessages: [], + mentorLoading: false, + mentorQuery: "", + mentorGrade: "all", + mentorDirectoryOpen: false, + mentorSortMode: false, + mentorSavingPreferences: false, + mentorController: null, + }, + heaven: { + heavenSetup: null, + heavenManualData: null, + personalField: null, + heavenPanel: "trend", + heavenInterpretations: { trend: "", fortune: "", heart: "" }, + heavenReadingMode: "trend", + heavenReadingTab: "current", + heavenReadingHistory: { trend: [], fortune: [], heart: [] }, + heavenReadingSelectedId: 0, + heavenReadingLoading: false, + heavenReadingError: "", + heartStage: "intro", + heartTimer: null, + heartSeconds: HEART_BREATH_TOTAL_MS / 1000, + heartBreathingEndsAt: 0, + heartLines: [], + heartThrows: [], + heartHexagram: null, + heartCurtainTimer: null, + heartStageToken: 0, + heartRevealToken: 0, + heavenPerformanceKey: "", + heavenPerformancePanels: new Set(), + heavenPerformanceActive: "", + heavenRequestSequence: 0, + }, +}); + +window.XiaobaiAPI.configure({ + csrfToken: () => state.csrfToken, + onUnauthorized: () => showAuthGate("登录状态已失效,请重新登录。"), +}); const elements = { tradeDate: document.querySelector("#tradeDate"), @@ -4698,39 +4717,16 @@ function scheduleMentorRender() { } async function streamMentorRequest(body, signal, onDelta, onMeta) { - const response = await fetch("/api/mentors/chat", { + await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", { method: "POST", - headers: { - "Content-Type": "application/json", - ...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}), - }, - body: JSON.stringify(body), + body, signal, + errorMessage: "问师暂不可用", + onEvent: (event) => { + if (event.type === "delta") onDelta(String(event.content || "")); + if (event.type === "meta") onMeta(event); + }, }); - if (!response.ok) { - const payload = await response.json().catch(() => ({})); - throw new Error(payload.error || "问师暂不可用"); - } - if (!response.body) throw new Error("当前浏览器不支持流式回答"); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - const consume = (line) => { - if (!line.trim()) return; - const event = JSON.parse(line); - if (event.type === "delta") onDelta(String(event.content || "")); - if (event.type === "meta") onMeta(event); - if (event.type === "error") throw new Error(event.error || "问师回答失败"); - }; - while (true) { - const { value, done } = await reader.read(); - buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - lines.forEach(consume); - if (done) break; - } - if (buffer.trim()) consume(buffer); } function useMentorQuickPrompt(prompt) { @@ -7907,41 +7903,15 @@ async function sendAssistantQuestion(event) { } async function streamAssistantRequest(question, signal, onDelta) { - const response = await fetch("/api/assistant/chat", { + await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", { method: "POST", - headers: { - "Content-Type": "application/json", - ...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}), - }, - body: JSON.stringify({ question, trade_date: elements.tradeDate.value }), + body: { question, trade_date: elements.tradeDate.value }, signal, - }); - if (!response.ok) { - const payload = await response.json().catch(() => ({})); - throw new Error(payload.error || "复盘助手暂不可用"); - } - if (!response.body) throw new Error("当前浏览器不支持流式回答"); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { value, done } = await reader.read(); - buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) { - if (!line.trim()) continue; - const event = JSON.parse(line); + errorMessage: "复盘助手暂不可用", + onEvent: (event) => { if (event.type === "delta") onDelta(String(event.content || "")); - if (event.type === "error") throw new Error(event.error || "复盘助手回答失败"); - } - if (done) break; - } - if (buffer.trim()) { - const event = JSON.parse(buffer); - if (event.type === "delta") onDelta(String(event.content || "")); - if (event.type === "error") throw new Error(event.error || "复盘助手回答失败"); - } + }, + }); } function stopAssistantResponse() { @@ -9167,21 +9137,7 @@ function formatMoneyMillion(value) { } async function apiRequest(url, method = "GET", body = null, requestOptions = {}) { - const options = { method, headers: {}, signal: requestOptions.signal }; - if (method !== "GET" && state.csrfToken) { - options.headers["X-CSRF-Token"] = state.csrfToken; - } - if (body !== null) { - options.headers["Content-Type"] = "application/json"; - options.body = JSON.stringify(body); - } - const response = await fetch(url, options); - const payload = await response.json(); - if (response.status === 401 && !url.startsWith("/api/auth/")) { - showAuthGate("登录状态已失效,请重新登录。"); - } - if (!response.ok || payload.error) throw new Error(payload.error || "请求失败"); - return payload; + return window.XiaobaiAPI.request(url, method, body, requestOptions); } function setLoading(loading, text = "正在加载复盘数据", context = "default") { diff --git a/static/index.html b/static/index.html index 4912074..516d6b8 100644 --- a/static/index.html +++ b/static/index.html @@ -1865,7 +1865,9 @@ + + - + diff --git a/static/shared/api.js b/static/shared/api.js new file mode 100644 index 0000000..262a41d --- /dev/null +++ b/static/shared/api.js @@ -0,0 +1,102 @@ +(function exposeApiClient(global) { + "use strict"; + + let csrfTokenSupplier = () => ""; + let unauthorizedHandler = () => {}; + + class ApiError extends Error { + constructor(message, status = 0, payload = null) { + super(message); + this.name = "ApiError"; + this.status = status; + this.payload = payload; + } + } + + function configure(options = {}) { + if (typeof options.csrfToken === "function") csrfTokenSupplier = options.csrfToken; + if (typeof options.onUnauthorized === "function") unauthorizedHandler = options.onUnauthorized; + } + + function requestOptions(method, body, signal) { + const normalizedMethod = String(method || "GET").toUpperCase(); + const options = { method: normalizedMethod, headers: {}, signal }; + const csrfToken = csrfTokenSupplier(); + if (!["GET", "HEAD", "OPTIONS"].includes(normalizedMethod) && csrfToken) { + options.headers["X-CSRF-Token"] = csrfToken; + } + if (body !== null && body !== undefined) { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + return options; + } + + async function parseJson(response) { + try { + return await response.json(); + } catch (_error) { + return {}; + } + } + + function handleUnauthorized(response, url) { + if (response.status === 401 && !String(url).startsWith("/api/auth/")) { + unauthorizedHandler({ response, url }); + } + } + + async function request(url, method = "GET", body = null, options = {}) { + const response = await fetch(url, requestOptions(method, body, options.signal)); + const payload = await parseJson(response); + handleUnauthorized(response, url); + if (!response.ok || payload.error) { + throw new ApiError(payload.error || "请求失败", response.status, payload); + } + return payload; + } + + async function streamNdjson(url, options = {}) { + const response = await fetch( + url, + requestOptions(options.method || "POST", options.body, options.signal), + ); + if (!response.ok) { + const payload = await parseJson(response); + handleUnauthorized(response, url); + throw new ApiError( + payload.error || options.errorMessage || "流式请求暂不可用", + response.status, + payload, + ); + } + if (!response.body) throw new ApiError("当前浏览器不支持流式回答", response.status); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const consume = (line) => { + if (!line.trim()) return; + let event; + try { + event = JSON.parse(line); + } catch (_error) { + throw new ApiError("流式响应格式错误", response.status); + } + if (event.type === "error") { + throw new ApiError(event.error || options.errorMessage || "流式请求失败", response.status, event); + } + options.onEvent?.(event); + }; + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + lines.forEach(consume); + if (done) break; + } + if (buffer.trim()) consume(buffer); + } + + global.XiaobaiAPI = Object.freeze({ ApiError, configure, request, streamNdjson }); +})(window); diff --git a/static/shared/state.js b/static/shared/state.js new file mode 100644 index 0000000..9224a9e --- /dev/null +++ b/static/shared/state.js @@ -0,0 +1,44 @@ +(function exposeStateStore(global) { + "use strict"; + + function create(domains) { + const owners = new Map(); + const stores = {}; + Object.entries(domains).forEach(([domain, values]) => { + stores[domain] = { ...values }; + Object.keys(values).forEach((key) => { + if (owners.has(key)) throw new Error(`Duplicate state field: ${key}`); + owners.set(key, domain); + }); + }); + + const proxy = new Proxy({}, { + get(_target, key) { + if (key === "domain") return (name) => stores[name]; + if (key === "domains") return Object.freeze({ ...stores }); + if (typeof key !== "string" || !owners.has(key)) return undefined; + return stores[owners.get(key)][key]; + }, + set(_target, key, value) { + if (typeof key !== "string" || !owners.has(key)) { + throw new Error(`Unregistered application state field: ${String(key)}`); + } + stores[owners.get(key)][key] = value; + return true; + }, + has(_target, key) { + return key === "domain" || key === "domains" || owners.has(key); + }, + ownKeys() { + return [...owners.keys()]; + }, + getOwnPropertyDescriptor(_target, key) { + if (!owners.has(key)) return undefined; + return { enumerable: true, configurable: true }; + }, + }); + return proxy; + } + + global.XiaobaiState = Object.freeze({ create }); +})(window); diff --git a/tests/test_frontend_boundaries.py b/tests/test_frontend_boundaries.py new file mode 100644 index 0000000..d185acb --- /dev/null +++ b/tests/test_frontend_boundaries.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / "static" + + +class FrontendBoundaryTests(unittest.TestCase): + def test_shared_api_is_the_only_application_fetch_exit(self) -> None: + fetch_files = [] + for path in STATIC.rglob("*.js"): + if "vendor" in path.parts: + continue + if re.search(r"\bfetch\s*\(", path.read_text(encoding="utf-8")): + fetch_files.append(path.relative_to(STATIC).as_posix()) + self.assertEqual(fetch_files, ["shared/api.js"]) + + def test_shared_dependencies_load_before_application(self) -> None: + html = (STATIC / "index.html").read_text(encoding="utf-8") + state_position = html.index('/shared/state.js') + api_position = html.index('/shared/api.js') + app_position = html.index('/app.js') + self.assertLess(state_position, api_position) + self.assertLess(api_position, app_position) + + def test_application_state_is_created_through_shared_boundary(self) -> None: + app = (STATIC / "app.js").read_text(encoding="utf-8") + self.assertIn("const state = window.XiaobaiState.create({", app) + self.assertNotIn("const state = {", app) + + +if __name__ == "__main__": + unittest.main()