fix(web): 修复图片上传、自动保存竞态、登录竞态并统一失效公共缓存

FormData 不强设 Content-Type(P1-2),补 api 客户端回归测试; 自动保存成功仅失效列表与公共键,防详情 refetch 回滚输入(P1-4); login/logout 以请求序号丢弃迟到 /api/me 响应,401 统一回调跳登录(P2-15); 首页不硬编码 page_size,写操作后统一 invalidatePublic,TagPage 补 error 分支(P2-15)。
This commit is contained in:
2026-09-08 17:32:57 +08:00
parent 3105b0415c
commit b94e9b1721
10 changed files with 136 additions and 10 deletions
+12 -2
View File
@@ -25,6 +25,13 @@ export function getCsrfToken(): string | null {
return csrfToken
}
// 401 统一回调:由 AuthProvider 注册(置未登录态 → RequireAuth 跳 /admin/login)
let unauthorizedHandler: (() => void) | null = null
export function setUnauthorizedHandler(fn: (() => void) | null) {
unauthorizedHandler = fn
}
interface Envelope<T> {
data?: T
error?: { code: string; message: string; field?: string }
@@ -36,7 +43,9 @@ export async function api<T>(
): Promise<T> {
const method = (options.method ?? 'GET').toUpperCase()
const headers = new Headers(options.headers)
if (options.body && !headers.has('Content-Type')) {
// FormData 由浏览器自动生成含 boundary 的 multipart Content-Type,不可覆写
// (曾因此破坏图片上传,评审 round2 P1-2)
if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (method !== 'GET' && method !== 'HEAD' && csrfToken) {
@@ -54,8 +63,9 @@ export async function api<T>(
if (!res.ok) {
const err = body?.error
if (res.status === 401) {
// 登录态失效:清内存态,统一由 AuthContext 跳登录页
// 登录态失效:清内存态并通知全局回调(AuthProvider 跳登录页)
setCsrfToken(null)
unauthorizedHandler?.()
}
throw new ApiError(
res.status,
+20 -3
View File
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { api, setCsrfToken } from './api'
import { api, setCsrfToken, setUnauthorizedHandler } from './api'
interface Me {
authenticated: boolean
@@ -22,10 +22,15 @@ const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true)
const [authenticated, setAuthenticated] = useState(false)
// 请求序号:login/logout 递增,使竞态中的迟到 /api/me 响应被丢弃
// (防登录成功后被先前的匿名响应覆盖,评审 round2 P2-15)
const seqRef = useRef(0)
const refresh = useCallback(async () => {
const seq = ++seqRef.current
try {
const me = await api<Me>('/api/me')
if (seqRef.current !== seq) return // 状态已被 login/logout 改变,丢弃迟到响应
setAuthenticated(me.authenticated)
if (me.authenticated && me.csrf_token) {
setCsrfToken(me.csrf_token)
@@ -33,10 +38,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setCsrfToken(null)
}
} catch {
if (seqRef.current !== seq) return
setAuthenticated(false)
setCsrfToken(null)
} finally {
setLoading(false)
if (seqRef.current === seq) setLoading(false)
}
}, [])
@@ -44,12 +50,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void refresh()
}, [refresh])
// api 层 401 统一回调:置未登录态,RequireAuth 随即跳转 /admin/login
useEffect(() => {
setUnauthorizedHandler(() => {
setAuthenticated(false)
setCsrfToken(null)
})
return () => setUnauthorizedHandler(null)
}, [])
const login = useCallback(
async (password: string) => {
const res = await api<{ csrf_token: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
seqRef.current++ // 使在途的匿名 /api/me 响应失效
setCsrfToken(res.csrf_token)
setAuthenticated(true)
},
@@ -58,6 +74,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logout = useCallback(async () => {
await api('/api/auth/logout', { method: 'POST' })
seqRef.current++
setCsrfToken(null)
setAuthenticated(false)
}, [])
+14
View File
@@ -0,0 +1,14 @@
import type { QueryClient } from '@tanstack/react-query'
/**
* 写操作成功后失效公共数据缓存(列表/标签页/详情/标签云)。
* 公共查询 staleTime=30s 且不随窗口聚焦刷新,管理端改动若不失效,
* 返回公共页最长 30s 展示旧内容(评审 round2 P2-15)。
* 前缀匹配:['notes'] → ['notes',page,tag],['note'] → ['note',slug]。
*/
export function invalidatePublic(qc: QueryClient) {
void qc.invalidateQueries({ queryKey: ['notes'] })
void qc.invalidateQueries({ queryKey: ['tag'] })
void qc.invalidateQueries({ queryKey: ['note'] })
void qc.invalidateQueries({ queryKey: ['tags'] })
}
+5 -1
View File
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Bold, Italic, Link2, Code, Table, Eye, EyeOff } from 'lucide-react'
import { api, ApiError } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import MarkdownViewer from '../components/MarkdownViewer'
import { slugPreview } from '../lib/utils'
@@ -110,7 +111,10 @@ export default function AdminEdit() {
setSlugFieldError(null)
setErrorMsg(null)
dirtyRef.current = false
void qc.invalidateQueries({ queryKey: ['admin'] })
// 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]),
// 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4)
void qc.invalidateQueries({ queryKey: ['admin', 'notes'] })
invalidatePublic(qc)
if (isNew) {
navigate(`/admin/notes/${saved.id}/edit`, { replace: true })
}
+6 -1
View File
@@ -4,6 +4,7 @@ import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import { useAuth } from '../lib/auth'
import { formatDateTime } from '../lib/utils'
@@ -26,6 +27,7 @@ export default function AdminList() {
onSuccess: () => {
setConfirmId(null)
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
})
@@ -46,7 +48,10 @@ export default function AdminList() {
}),
})
},
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin'] }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
})
return (
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api, ApiError } from '../lib/api'
import type { SiteSettings } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
export default function AdminSettings() {
const qc = useQueryClient()
@@ -32,6 +33,7 @@ export default function AdminSettings() {
onSuccess: () => {
setMsg('已保存')
void qc.invalidateQueries({ queryKey: ['site'] })
invalidatePublic(qc) // page_size 等设置影响公共列表
},
onError: (e) => setMsg(e instanceof ApiError ? e.message : '保存失败'),
})
+5 -1
View File
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { RotateCcw } from 'lucide-react'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import { formatDateTime } from '../lib/utils'
export default function AdminTrash() {
@@ -13,7 +14,10 @@ export default function AdminTrash() {
const restore = useMutation({
mutationFn: (id: number) => api(`/api/admin/trash/${id}/restore`, { method: 'POST' }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin'] }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
})
return (
+2 -1
View File
@@ -7,7 +7,8 @@ import type { NoteItem, NoteList, TagCount } from '../lib/api'
import { formatDate } from '../lib/utils'
function useNotes(page: number, tag?: string) {
const qs = new URLSearchParams({ page: String(page), page_size: '10' })
// 不传 page_size:服务端默认值即站点设置(管理员可改),首页随设置生效
const qs = new URLSearchParams({ page: String(page) })
if (tag) qs.set('tag', tag)
return useQuery({
queryKey: ['notes', page, tag ?? ''],
+3 -1
View File
@@ -6,7 +6,7 @@ import { formatDate } from '../lib/utils'
export default function TagPage() {
const { tag = '' } = useParams()
const { data, isLoading } = useQuery({
const { data, isLoading, error } = useQuery({
queryKey: ['tag', tag],
queryFn: () => api<NoteList>(`/api/notes?tag=${encodeURIComponent(tag)}&page=1&page_size=100`),
})
@@ -24,6 +24,8 @@ export default function TagPage() {
</header>
{isLoading ? (
<p className="py-12 text-center text-zinc-400">加载中…</p>
) : error ? (
<p className="py-12 text-center text-red-500">加载失败,请稍后重试</p>
) : !data || data.items.length === 0 ? (
<p className="py-12 text-center text-zinc-400">该标签下暂无公开笔记</p>
) : (
+67
View File
@@ -0,0 +1,67 @@
// api 客户端单测(评审 round2 P1-2 / P2-15 的回归守护):
// 1. FormData 请求不得强设 Content-Type(multipart boundary 由浏览器生成);
// 2. 401 触发全局未授权回调(AuthProvider 据此跳登录页)。
import { describe, it, expect, vi, afterEach } from 'vitest'
import { api, ApiError, setUnauthorizedHandler } from '../src/lib/api'
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
describe('api 客户端', () => {
afterEach(() => {
vi.unstubAllGlobals()
setUnauthorizedHandler(null)
})
it('FormData 请求不强设 Content-Type(保留浏览器 multipart boundary)', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(201, { data: { id: 1, url: '/api/images/1' } }))
vi.stubGlobal('fetch', fetchMock)
const form = new FormData()
form.append('file', new Blob(['x'], { type: 'image/png' }), 'a.png')
await api<{ id: number }>('/api/admin/images', { method: 'POST', body: form })
expect(fetchMock).toHaveBeenCalledOnce()
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const headers = init.headers as Headers
expect(headers.has('Content-Type')).toBe(false)
})
it('JSON 请求默认补 application/json 并携带 CSRF 头', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { data: { ok: true } }))
vi.stubGlobal('fetch', fetchMock)
const { setCsrfToken } = await import('../src/lib/api')
setCsrfToken('token-abc')
await api('/api/admin/notes/1', { method: 'PUT', body: JSON.stringify({ title: 'x' }) })
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const headers = init.headers as Headers
expect(headers.get('Content-Type')).toBe('application/json')
expect(headers.get('X-CSRF-Token')).toBe('token-abc')
setCsrfToken(null)
})
it('401 触发全局未授权回调并抛出 ApiError', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse(401, { error: { code: 'unauthorized', message: '未登录或会话已过期' } }),
)
vi.stubGlobal('fetch', fetchMock)
const handler = vi.fn()
setUnauthorizedHandler(handler)
try {
await api('/api/admin/notes', { method: 'DELETE' })
expect.unreachable('应抛出 ApiError')
} catch (e) {
expect(e).toBeInstanceOf(ApiError)
expect((e as ApiError).status).toBe(401)
expect((e as ApiError).code).toBe('unauthorized')
}
expect(handler).toHaveBeenCalledOnce()
})
})