feat(C-01):路由/代理前缀冲突修复

SPA冲突路由改名+旧路径Navigate重定向(/api-docs→/open-api-docs、/api-detail→/open-api-detail、/article→/post、/user→/user-home);vite代理宽前缀改精确正则白名单;nginx.conf同步同口径;新增86路由回归脚本+Playwright 3项(86/86全绿)。
This commit is contained in:
chunyu
2026-09-15 15:25:59 +08:00
parent 26feb3d277
commit 24f200bfa6
18 changed files with 539 additions and 137 deletions
+152 -75
View File
@@ -1,7 +1,156 @@
import { defineConfig } from 'vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path';
// C-01: vite 代理精确收窄 —— 只代理后端真实存在的 API 路径,SPA 路由一律放行。
// 后端真实挂载(chunyu_project/chunyu_project/urls.py + 各 app urls.py):
// /api/*(api.urls:getIpData/baiduFanyi/tool/weather/air-quality/currency/captcha/shorturl/...)
// /api/apidirectory/*、/user/*、/bug/reports/*、/article/articles|my-articles|comments/*
// /learn/*、/history/records/*、/message/*、/chat/*、/tool/*、/search/*、/app/*、
// /logs/api/*、/shorturl/*、/s/<code>/(短链跳转)、/admin/、/swagger*、/redoc/、/i18n/、/media/
// SPA 路由(/open-api*、/post/*、/user-home*、/api-docs 旧重定向等)不在此列,
// 由 bypass 精确放行,避免前缀误伤(如 /api 劫持 /open-api?不——新 SPA 已迁走,
// 但 /api-docs 旧重定向、/article 旧重定向等仍需放行到前端)。
const BACKEND = 'http://127.0.0.1:8002'
// 纯 SPA 前缀:命中即放行(返回 undefined → 走 vite dev 中间件/前端路由)
const SPA_PREFIXES = [
'/open-api',
'/post/',
'/user-home',
'/articles',
'/utility',
'/learn-manage',
'/learn-editor',
'/article-manage',
'/article-editor',
'/bug-detail',
'/tool-detail',
'/course-learn',
'/shorturl-detail',
'/currency-detail',
'/ip-location',
'/baidu-translate',
'/qrcode-generator',
'/color-picker',
'/changelog',
'/favorites',
'/console',
'/profile',
'/settings',
'/history',
'/favorites',
'/use',
'/document',
'/manual',
'/privacy',
'/agreement',
'/messages',
'/chat',
'/search',
'/bug',
'/learn',
'/wallet',
'/task-center',
'/invite',
'/forgot-password',
'/register',
]
// 精确 SPA 路由(无尾部、与后端前缀撞名的几个)
const SPA_EXACT = new Set([
'/api-docs', '/api-directory', '/api',
'/user', '/article-manage', '/article-editor', '/bug-detail',
])
// C-01 旧分享链接对应的 SPA 页面(已迁到 /open-api*,但旧 URL 仍由前端 <Navigate> 承接)
const LEGACY_API_PAGES = new Set([
'/api/air-quality-details', '/api/weather-details', '/api/qrcode-generator',
'/api/ip-location', '/api/password-generator',
])
function isSpaPath(url: string | undefined): boolean {
if (!url) return false
const p = url.split('?')[0]
if (SPA_EXACT.has(p)) return true
if (LEGACY_API_PAGES.has(p)) return true
// 旧重定向路由也属 SPA
if (p.startsWith('/api-detail') || p.startsWith('/api-docs') || p.startsWith('/api-directory')) return true
if (p.startsWith('/article/') || p === '/article') return true
if (p.startsWith('/user/') || p === '/user') return true
return SPA_PREFIXES.some((pre) => p === pre || p.startsWith(pre + '/') || (pre.endsWith('/') ? p.startsWith(pre) : p.startsWith(pre + '/') || p === pre))
}
function spaBypass(req: { url?: string }) {
if (isSpaPath(req.url)) return req.url // 放行到前端
}
// 后端真实 API 上下文 → 全部走精确前缀(每个都以 / 结尾或带参数段,
// 且各自的 bypass 先放行 SPA,避免 /message 误伤 /messages、/s 误伤 /settings)
const proxy: Record<string, object> = {
'/ws': {
target: 'ws://127.0.0.1:8002',
ws: true,
rewrite: (path: string) => path.replace(/^\/ws/, ''),
},
'/media/': { target: BACKEND, changeOrigin: true },
// /api/*:后端真实 API(含 /api/apidirectory/*、/api/shorturl/* 等)
'^/api/': { target: BACKEND, changeOrigin: true, bypass: spaBypass },
// /user/*:后端 user.urls 全是 /user/<action>/;SPA 已迁到 /user-home*,
// 仅放行旧分享链接 /user/<数字id>(LegacyUserHomeRedirect),其余一律代理
'^/user/': {
target: BACKEND,
changeOrigin: true,
bypass: (req: { url?: string }) => {
const p = (req.url || '').split('?')[0]
if (/^\/user\/\d+\/?$/.test(p)) return req.url // 旧用户主页分享链接 → SPA 重定向
},
},
// /bug/reports/*:后端仅 reports/ 三条;SPA 的 /bug 与 /bug-detail 放行
'^/bug/reports/': { target: BACKEND, changeOrigin: true },
// /article/articles|my-articles|comments/*:后端仅这几段;SPA /post/*、/articles、/article-manage 等放行
'^/article/(articles|my-articles|comments)/': { target: BACKEND, changeOrigin: true },
// /learn/*:后端 learn.urls;SPA 无 /learn/<id> 冲突(/learn 是列表页、/learn-manage 等已迁出判断)
// 但 /learn 本体是 SPA 列表页 → 仅代理带子段的后端路径,根 /learn 放行
'^/learn/(courses|chapters|my-courses|my-progress|materials|favorites|cdn)/': { target: BACKEND, changeOrigin: true },
// /history/records/*:后端仅 records/;SPA /history 是列表页
'^/history/records/': { target: BACKEND, changeOrigin: true },
// /message/*:后端 message.urls;SPA /messages(多 s)天然不命中
'^/message/': { target: BACKEND, changeOrigin: true },
// /chat/*:后端 chat.urls;SPA /chat 本体是页面 → 仅代理后端子段
'^/chat/(friend-requests|friends|users|conversations|messages|upload|favorite-stickers)/': { target: BACKEND, changeOrigin: true },
// /tool/*:后端 tool.urls;SPA /tool-detail 放行(不以 /tool/ 开头,天然安全)
'^/tool/': { target: BACKEND, changeOrigin: true },
// /search/*:后端 search.urls(GET /search/ 本体 + suggestions/hot-keywords)
// SPA /search 本体是搜索页 → 无查询串的裸 /search 放行到前端
'^/search/': {
target: BACKEND,
changeOrigin: true,
bypass: (req: { url?: string }) => {
const raw = req.url || ''
const p = raw.split('?')[0]
if (p === '/search/' && !raw.includes('?')) return req.url
},
},
// /app/*:后端 changelog;SPA 无 /app 冲突
'^/app/': { target: BACKEND, changeOrigin: true },
// /logs/api/*:后端日志;SPA 无冲突
'^/logs/api/': { target: BACKEND, changeOrigin: true },
// /shorturl/*:后端 shorturl.urls;SPA /shorturl-detail 放行
'^/shorturl/': { target: BACKEND, changeOrigin: true },
// /s/<code>/:短链跳转(后端 ShortUrlRedirectView);SPA /settings、/shorturl-detail 必须放行
'/s': {
target: BACKEND,
changeOrigin: true,
bypass: (req: { url?: string }) => {
const p = (req.url || '').split('?')[0]
if (p === '/s' || p.startsWith('/settings') || p.startsWith('/shorturl-detail') || p.startsWith('/src') || p.includes('.')) return req.url
if (!/^\/s\/[^/]+\/?$/.test(p)) return req.url // 非短码形态一律放行
},
},
'/i18n': { target: BACKEND, changeOrigin: true, bypass: spaBypass },
}
// https://vite.dev/config/
export default defineConfig({
server: {
@@ -13,79 +162,7 @@ export default defineConfig({
'127.0.0.1',
'192.168.1.4'
],
proxy: {
'/ws': {
target: 'ws://127.0.0.1:8002',
ws: true,
rewrite: (path) => path.replace(/^\/ws/, ''),
},
'/media/': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/api': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/user': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
bypass: (req) => {
if (req.url?.includes('.')) return req.url;
},
},
'/bug': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/article': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/learn': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/history': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/message': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/chat': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/search': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/app': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/tool': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/logs': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
'/s': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
bypass: (req) => {
if (req.url?.startsWith('/src') || req.url?.includes('.')) return req.url;
},
},
'/i18n': {
target: 'http://127.0.0.1:8002',
changeOrigin: true,
},
},
proxy,
},
plugins: [react()],
resolve: {
@@ -93,4 +170,4 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
})
})