refactor: establish frontend request and state boundaries

This commit is contained in:
leefer
2026-07-29 20:16:21 +08:00
parent a368174a75
commit e177f21d1c
7 changed files with 421 additions and 242 deletions
+4 -4
View File
@@ -254,8 +254,8 @@
"code_hotspots": [ "code_hotspots": [
{ {
"path": "static/app.js", "path": "static/app.js",
"bytes": 449457, "bytes": 447568,
"lines": 9447 "lines": 9403
}, },
{ {
"path": "static/styles.css", "path": "static/styles.css",
@@ -274,8 +274,8 @@
}, },
{ {
"path": "static/index.html", "path": "static/index.html",
"bytes": 133569, "bytes": 133691,
"lines": 1871 "lines": 1873
}, },
{ {
"path": "database.py", "path": "database.py",
@@ -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.
+193 -237
View File
@@ -21,166 +21,185 @@ const THEME_STORAGE_KEY = "xiaobaiTheme";
let activeThemeTransition = null; let activeThemeTransition = null;
let themeSwitchSequence = 0; let themeSwitchSequence = 0;
const state = { const state = window.XiaobaiState.create({
user: null, session: {
csrfToken: "", user: null,
authMode: "login", csrfToken: "",
started: false, authMode: "login",
dashboard: null, started: false,
filter: "all", activeView: "sentimentCycleView",
query: "", dashboardLoading: false,
sortKey: "streak", dashboardRequestSequence: 0,
sortDirection: "desc", dashboardRequestDate: "",
brokenQuery: "", adminModels: [],
brokenSortKey: "", globalSearchResults: [],
brokenSortDirection: "desc", globalSearchActiveIndex: -1,
downQuery: "", globalSearchRequestSequence: 0,
downSortKey: "", },
downSortDirection: "asc", market: {
yesterdayFilter: "all", dashboard: null,
yesterdayQuery: "", filter: "all",
yesterdaySortKey: "", query: "",
yesterdaySortDirection: "desc", sortKey: "streak",
activeView: "sentimentCycleView", sortDirection: "desc",
dragonTiger: null, brokenQuery: "",
dragonViewMode: "daily", brokenSortKey: "",
dragonFilter: "all", brokenSortDirection: "desc",
dragonQuery: "", downQuery: "",
selectedDragonTraderId: "", downSortKey: "",
hotMoneyProfiles: null, downSortDirection: "asc",
hotMoneyProfileQuery: "", yesterdayFilter: "all",
selectedHotMoneyProfileId: "", yesterdayQuery: "",
rotationHistory: null, yesterdaySortKey: "",
rotationHistoryKey: "", yesterdaySortDirection: "desc",
rotationSelectedSector: "", dragonTiger: null,
rotationSelectedDate: "", dragonViewMode: "daily",
rotationMembers: null, dragonFilter: "all",
rotationMembersKey: "", dragonQuery: "",
rotationMembersLoading: false, selectedDragonTraderId: "",
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest", hotMoneyProfiles: null,
rotationLoading: false, hotMoneyProfileQuery: "",
auctionData: null, selectedHotMoneyProfileId: "",
auctionDataset: "focus", rotationHistory: null,
auctionFilter: "all", rotationHistoryKey: "",
auctionQuery: "", rotationSelectedSector: "",
auctionSortKey: "attention_score", rotationSelectedDate: "",
auctionSortDirection: "desc", rotationMembers: null,
auctionLoading: false, rotationMembersKey: "",
auctionTimer: null, rotationMembersLoading: false,
themeLibrary: null, rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
themeQuery: "", rotationLoading: false,
selectedThemeCode: "", auctionData: null,
themeDetail: null, auctionDataset: "focus",
themeLoading: false, auctionFilter: "all",
popularityData: null, auctionQuery: "",
popularitySource: "combined", auctionSortKey: "attention_score",
popularityQuery: "", auctionSortDirection: "desc",
popularityLoading: false, auctionLoading: false,
expandedLadderLevels: new Set(), auctionTimer: null,
ladderSortMode: "time", themeLibrary: null,
stockDetail: null, themeQuery: "",
activeStock: null, selectedThemeCode: "",
stockDetailChartMode: "daily", themeDetail: null,
stockDetailIntraday: null, themeLoading: false,
stockDetailRequestSequence: 0, popularityData: null,
entityDetailItem: null, popularitySource: "combined",
entityDetailPayload: null, popularityQuery: "",
entityDetailChartMode: "daily", popularityLoading: false,
entityDetailIntraday: null, expandedLadderLevels: new Set(),
entityDetailRequestSequence: 0, ladderSortMode: "time",
stockPreviewCode: "", sentimentHistory: null,
stockPreviewType: "stock", sentimentRange: 20,
stockPreviewItem: null, sentimentHistoryKey: "",
stockPreviewPayload: null, sentimentLoading: false,
stockPreviewChart: "daily", },
stockPreviewFallback: null, details: {
watchlist: [], stockDetail: null,
watchlistSelection: null, activeStock: null,
watchlistSearchResults: [], stockDetailChartMode: "daily",
watchlistSearchRequestSequence: 0, stockDetailIntraday: null,
editingDailyNoteId: 0, stockDetailRequestSequence: 0,
notes: [], entityDetailItem: null,
tradeEntries: [], entityDetailPayload: null,
tradeSummary: {}, entityDetailChartMode: "daily",
editingTradeId: 0, entityDetailIntraday: null,
initialStockOpened: false, entityDetailRequestSequence: 0,
screenerSetup: null, stockPreviewCode: "",
screenerSetupKey: "", stockPreviewType: "stock",
screenerSetupRequestKey: "", stockPreviewItem: null,
screenerSetupPromise: null, stockPreviewPayload: null,
selectedRegime: "", stockPreviewChart: "daily",
selectedStrategy: null, stockPreviewFallback: null,
customStrategyDraft: null, initialStockOpened: false,
screenerRunning: false, },
screenerRunningMode: "", review: {
screenerResults: { smart: null, curated: null, quant: null }, watchlist: [],
screenerResultContexts: { smart: null, curated: null, quant: null }, watchlistSelection: null,
screenerResultStore: {}, watchlistSearchResults: [],
screenerTracking: null, watchlistSearchRequestSequence: 0,
screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode")) editingDailyNoteId: 0,
? localStorage.getItem("xiaobaiScreenerMode") notes: [],
: "smart", tradeEntries: [],
curatedCategory: "全部", tradeSummary: {},
curatedSchool: "全部", editingTradeId: 0,
curatedQuery: "", alerts: [],
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list", alertFilter: "all",
selectedCuratedStrategyId: 0, alertUnreadCount: 0,
quantFilters: [], assistantMessages: [],
quantScores: [], assistantLoading: false,
alerts: [], assistantController: null,
alertFilter: "all", },
alertUnreadCount: 0, screener: {
assistantMessages: [], screenerSetup: null,
assistantLoading: false, screenerSetupKey: "",
assistantController: null, screenerSetupRequestKey: "",
screenerMobileView: "strategy", screenerSetupPromise: null,
sentimentHistory: null, selectedRegime: "",
sentimentRange: 20, selectedStrategy: null,
sentimentHistoryKey: "", customStrategyDraft: null,
sentimentLoading: false, screenerRunning: false,
mentorSetup: null, screenerRunningMode: "",
selectedMentorId: "", screenerResults: { smart: null, curated: null, quant: null },
mentorMessages: [], screenerResultContexts: { smart: null, curated: null, quant: null },
mentorLoading: false, screenerResultStore: {},
mentorQuery: "", screenerTracking: null,
mentorGrade: "all", screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
mentorDirectoryOpen: false, ? localStorage.getItem("xiaobaiScreenerMode")
mentorSortMode: false, : "smart",
mentorSavingPreferences: false, curatedCategory: "全部",
mentorController: null, curatedSchool: "全部",
heavenSetup: null, curatedQuery: "",
heavenManualData: null, curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
personalField: null, selectedCuratedStrategyId: 0,
heavenPanel: "trend", quantFilters: [],
heavenInterpretations: { trend: "", fortune: "", heart: "" }, quantScores: [],
heavenReadingMode: "trend", screenerMobileView: "strategy",
heavenReadingTab: "current", },
heavenReadingHistory: { trend: [], fortune: [], heart: [] }, mentor: {
heavenReadingSelectedId: 0, mentorSetup: null,
heavenReadingLoading: false, selectedMentorId: "",
heavenReadingError: "", mentorMessages: [],
heartStage: "intro", mentorLoading: false,
heartTimer: null, mentorQuery: "",
heartSeconds: HEART_BREATH_TOTAL_MS / 1000, mentorGrade: "all",
heartBreathingEndsAt: 0, mentorDirectoryOpen: false,
heartLines: [], mentorSortMode: false,
heartThrows: [], mentorSavingPreferences: false,
heartHexagram: null, mentorController: null,
heartCurtainTimer: null, },
heartStageToken: 0, heaven: {
heartRevealToken: 0, heavenSetup: null,
heavenPerformanceKey: "", heavenManualData: null,
heavenPerformancePanels: new Set(), personalField: null,
heavenPerformanceActive: "", heavenPanel: "trend",
dashboardLoading: false, heavenInterpretations: { trend: "", fortune: "", heart: "" },
dashboardRequestSequence: 0, heavenReadingMode: "trend",
heavenRequestSequence: 0, heavenReadingTab: "current",
dashboardRequestDate: "", heavenReadingHistory: { trend: [], fortune: [], heart: [] },
adminModels: [], heavenReadingSelectedId: 0,
globalSearchResults: [], heavenReadingLoading: false,
globalSearchActiveIndex: -1, heavenReadingError: "",
globalSearchRequestSequence: 0, 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 = { const elements = {
tradeDate: document.querySelector("#tradeDate"), tradeDate: document.querySelector("#tradeDate"),
@@ -4698,39 +4717,16 @@ function scheduleMentorRender() {
} }
async function streamMentorRequest(body, signal, onDelta, onMeta) { async function streamMentorRequest(body, signal, onDelta, onMeta) {
const response = await fetch("/api/mentors/chat", { await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
method: "POST", method: "POST",
headers: { body,
"Content-Type": "application/json",
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
},
body: JSON.stringify(body),
signal, 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) { function useMentorQuickPrompt(prompt) {
@@ -7907,41 +7903,15 @@ async function sendAssistantQuestion(event) {
} }
async function streamAssistantRequest(question, signal, onDelta) { async function streamAssistantRequest(question, signal, onDelta) {
const response = await fetch("/api/assistant/chat", { await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", {
method: "POST", method: "POST",
headers: { body: { question, trade_date: elements.tradeDate.value },
"Content-Type": "application/json",
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
},
body: JSON.stringify({ question, trade_date: elements.tradeDate.value }),
signal, signal,
}); errorMessage: "复盘助手暂不可用",
if (!response.ok) { onEvent: (event) => {
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);
if (event.type === "delta") onDelta(String(event.content || "")); 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() { function stopAssistantResponse() {
@@ -9167,21 +9137,7 @@ function formatMoneyMillion(value) {
} }
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) { async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
const options = { method, headers: {}, signal: requestOptions.signal }; return window.XiaobaiAPI.request(url, method, body, requestOptions);
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;
} }
function setLoading(loading, text = "正在加载复盘数据", context = "default") { function setLoading(loading, text = "正在加载复盘数据", context = "default") {
+3 -1
View File
@@ -1865,7 +1865,9 @@
<script src="/vendor/lucide.min.js" defer></script> <script src="/vendor/lucide.min.js" defer></script>
<script src="/ui-core.js" defer></script> <script src="/ui-core.js" defer></script>
<script src="/shared/state.js?v=20260729-1" defer></script>
<script src="/shared/api.js?v=20260729-1" defer></script>
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script> <script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
<script src="/app.js?v=20260728-3" defer></script> <script src="/app.js?v=20260729-4" defer></script>
</body> </body>
</html> </html>
+102
View File
@@ -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);
+44
View File
@@ -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);
+37
View File
@@ -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()