From 4df2506a74478c7f972f0e4d8f1d82144b8b7d5e Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:56:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20access=20token=20=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=20localStorage=EF=BC=9B401=20=E5=88=B7=E6=96=B0=20single-fligh?= =?UTF-8?q?t=20=E5=B9=B6=E8=B7=B3=E7=99=BB=E5=BD=95=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - access token 只存内存:XSS 拿不到持久凭据;刷新页面由 bootstrap 用 HttpOnly refresh cookie 静默换新;bootstrap 并发去重 - 并发 401 共享一次刷新(refresh 已是轮换式,各自刷新会互相踩);会话失效自动回登录页 - 防主题闪烁内联脚本挪到外部 theme-init.js(配合 CSP script-src 'self') - 修 console 用量页与管理用户搜索的查询参数未编码;极小金额不再显示科学计数法;渠道表单数字输入清空后省略字段而非提交 0(权重 0 的渠道永远选不中) - 改密成功后主动清会话回登录页(后端已递增 token_version,当前会话已作废) --- web/index.html | 14 ++--------- web/public/theme-init.js | 11 ++++++++ web/src/api/client.ts | 35 ++++++++++++++++++-------- web/src/lib/format.ts | 4 ++- web/src/stores/auth.ts | 34 ++++++++++++++++--------- web/src/views/admin/ChannelsView.vue | 15 ++++++++--- web/src/views/admin/UsersView.vue | 2 +- web/src/views/console/SettingsView.vue | 8 +++++- web/src/views/console/UsageView.vue | 2 +- 9 files changed, 83 insertions(+), 42 deletions(-) create mode 100644 web/public/theme-init.js diff --git a/web/index.html b/web/index.html index ba17d3d..562d187 100644 --- a/web/index.html +++ b/web/index.html @@ -4,18 +4,8 @@ - + + openteam · LLM API 中转站 diff --git a/web/public/theme-init.js b/web/public/theme-init.js new file mode 100644 index 0000000..c9c403d --- /dev/null +++ b/web/public/theme-init.js @@ -0,0 +1,11 @@ +// 防止主题闪烁:CSS 加载前按 localStorage/系统偏好设置 data-theme。 +// 独立外部文件而非内联脚本:CSP script-src 'self' 下内联会被拦掉。 +;(function () { + try { + var m = localStorage.getItem('ot_theme') || 'system' + var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') + } catch (e) { + document.documentElement.setAttribute('data-theme', 'dark') + } +})() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 82ed4a0..b9bfeb1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,22 +15,37 @@ http.interceptors.request.use((config) => { return config }) +// single-flight:并发 401 共享同一次刷新。refresh 已是轮换式(每次换发新 cookie), +// 两个请求各自刷新会让第二个带着已被轮换的会话状态,容易随机登出。 +let refreshInflight: Promise | null = null + +async function redirectToLogin() { + if (window.location.pathname.startsWith('/login')) return + const { router } = await import('@/router') + await router.push({ name: 'login', query: { redirect: window.location.pathname + window.location.search } }) +} + http.interceptors.response.use( (resp) => resp, async (error) => { - if (error.response?.status === 401) { + const status = error.response?.status + const cfg = error.config ?? {} + const isRefreshCall = String(cfg.url ?? '').includes('/auth/refresh') + if (status === 401 && !cfg._retried && !isRefreshCall) { const auth = useAuthStore() - // 尝试用 refresh cookie 换新 token 后再试一次 - if (auth.accessToken && !error.config?._retried) { - error.config._retried = true - try { - await auth.refresh() - return http.request(error.config) - } catch { - auth.clear() + cfg._retried = true + try { + if (!refreshInflight) { + refreshInflight = auth.refresh().finally(() => { + refreshInflight = null + }) } - } else { + await refreshInflight + return http.request(cfg) + } catch { + // 会话已失效:清状态并回登录页,否则用户停在受保护页面每个操作都报错 auth.clear() + await redirectToLogin() } } return Promise.reject(error) diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 31a43ce..6138bb6 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -15,6 +15,8 @@ export function fmtTime(s?: string | null): string { export function fmtCost(v: number): string { if (v === 0) return '$0' - if (v < 0.01) return '$' + v.toExponential(2) + // toLocaleString 对 <0.01 会出科学计数法($5e-3):定点 6 位并去尾零 + if (v < 1e-6) return '<$0.000001' + if (v < 0.01) return '$' + v.toFixed(6).replace(/0+$/, '').replace(/\.$/, '') return fmtMoney(v) } diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 4ef5428..ee85510 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -2,10 +2,16 @@ import { defineStore } from 'pinia' import { http } from '@/api/client' import type { User } from '@/types' +// App.vue 的 onMounted 与路由守卫都可能触发 bootstrap:并发时共享同一次刷新, +// 避免重复打 /auth/refresh +let bootstrapInflight: Promise | null = null + export const useAuthStore = defineStore('auth', { state: () => ({ user: null as User | null, - accessToken: localStorage.getItem('ot_access') ?? '', + // access token 只存内存:XSS 即使发生也拿不到持久凭据。 + // 刷新页面后由 bootstrap 用 HttpOnly refresh cookie 静默换新 token。 + accessToken: '', ready: false, }), getters: { @@ -14,15 +20,22 @@ export const useAuthStore = defineStore('auth', { }, actions: { async bootstrap() { - try { - if (!this.accessToken) { - await this.refresh() - } - await this.fetchMe() - } catch { - this.clear() + if (this.ready) return + if (!bootstrapInflight) { + bootstrapInflight = (async () => { + try { + await this.refresh() + await this.fetchMe() + } catch { + this.clear() + } finally { + this.ready = true + } + })().finally(() => { + bootstrapInflight = null + }) } - this.ready = true + await bootstrapInflight }, async register(username: string, email: string, password: string) { await http.post('/auth/register', { username, email, password }) @@ -34,12 +47,10 @@ export const useAuthStore = defineStore('auth', { setSession(d: { access_token: string; user: User }) { this.accessToken = d.access_token this.user = d.user - localStorage.setItem('ot_access', d.access_token) }, async refresh() { const { data } = await http.post('/auth/refresh') this.accessToken = data.data.access_token as string - localStorage.setItem('ot_access', this.accessToken) }, async fetchMe() { const { data } = await http.get('/auth/me') @@ -56,7 +67,6 @@ export const useAuthStore = defineStore('auth', { clear() { this.accessToken = '' this.user = null - localStorage.removeItem('ot_access') }, }, }) diff --git a/web/src/views/admin/ChannelsView.vue b/web/src/views/admin/ChannelsView.vue index b2a8cf5..14e00ad 100644 --- a/web/src/views/admin/ChannelsView.vue +++ b/web/src/views/admin/ChannelsView.vue @@ -122,12 +122,19 @@ async function save() { return } saving.value = true + // 数字输入被清空时 v-model 是 ''(Number('')==0):直接提交会把权重写成 0(永远选不中)。 + // 省略该字段:创建走后端默认值,更新端点是指针字段不动原值。 + const numOrOmit = (v: unknown) => { + if (v === '' || v === null || v === undefined) return undefined + const n = Number(v) + return Number.isNaN(n) ? undefined : n + } const payload = { ...form, - weight: Number(form.weight), - priority: Number(form.priority), - timeout_ms: Number(form.timeout_ms), - max_concurrency: Number(form.max_concurrency), + weight: numOrOmit(form.weight), + priority: numOrOmit(form.priority), + timeout_ms: numOrOmit(form.timeout_ms), + max_concurrency: numOrOmit(form.max_concurrency), } try { if (editing.value) { diff --git a/web/src/views/admin/UsersView.vue b/web/src/views/admin/UsersView.vue index 2ca73fb..4f59893 100644 --- a/web/src/views/admin/UsersView.vue +++ b/web/src/views/admin/UsersView.vue @@ -42,7 +42,7 @@ const balanceForm = reactive({ amount: 0, remark: '' }) async function load() { try { - const { data } = await http.get(`/admin/users?page=${page.value}&page_size=${pageSize}${q.value ? '&q=' + q.value : ''}`) + const { data } = await http.get(`/admin/users?page=${page.value}&page_size=${pageSize}${q.value ? '&q=' + encodeURIComponent(q.value) : ''}`) users.value = data.data.items total.value = data.data.total } catch (e) { diff --git a/web/src/views/console/SettingsView.vue b/web/src/views/console/SettingsView.vue index e0213bd..0b595ab 100644 --- a/web/src/views/console/SettingsView.vue +++ b/web/src/views/console/SettingsView.vue @@ -95,8 +95,14 @@ async function changePassword() { saving.value = true try { await http.post('/auth/password', { old_password: oldPwd.value, new_password: newPwd.value }) - toast.ok('密码已更新') + toast.ok('密码已更新,请重新登录') oldPwd.value = newPwd.value = confirmPwd.value = '' + // 后端改密即递增会话版本:当前 access/refresh 已全部作废,主动回登录页 + const { useAuthStore } = await import('@/stores/auth') + const auth = useAuthStore() + auth.clear() + const { router } = await import('@/router') + await router.push({ name: 'login' }) } catch (e) { toast.err(errMsg(e)) } finally { diff --git a/web/src/views/console/UsageView.vue b/web/src/views/console/UsageView.vue index 645d028..0951836 100644 --- a/web/src/views/console/UsageView.vue +++ b/web/src/views/console/UsageView.vue @@ -23,7 +23,7 @@ async function load() { const [s, st, l] = await Promise.all([ http.get('/usage/summary'), http.get(`/usage/stats?group=${group.value}`), - http.get(`/usage/logs?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`), + http.get(`/usage/logs?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + encodeURIComponent(modelFilter.value) : ''}`), ]) summary.value = s.data.data const items = st.data.data.items as { date?: string; model?: string; cost: number; requests: number }[]