diff --git a/backend/plugins/domain/auth/repository.go b/backend/plugins/domain/auth/repository.go index c245e924..5f1adf00 100644 --- a/backend/plugins/domain/auth/repository.go +++ b/backend/plugins/domain/auth/repository.go @@ -34,10 +34,8 @@ func setCacheService(s contracts.CacheService) { } func getDB(ctx context.Context) *gorm.DB { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { - return s.DB(ctx) - } + if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { + return s.DB(ctx) } dbMu.RLock() s := dbSvc @@ -49,10 +47,8 @@ func getDB(ctx context.Context) *gorm.DB { } func getCache(ctx context.Context) contracts.CacheService { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil { - return s - } + if s, err := core.InjectFrom[contracts.CacheService](ctx); err == nil && s != nil { + return s } cacheMu.RLock() s := cacheSvc diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts index 7b2113e4..c859a960 100644 --- a/frontend/lib/services/core/api-client.ts +++ b/frontend/lib/services/core/api-client.ts @@ -119,6 +119,26 @@ function isPublicAuthRequest(url?: string): boolean { ); } +function initiateLogin(currentPath: string): Promise { + if ( + currentPath.startsWith('/login') || + currentPath.startsWith('/register') || + currentPath.startsWith('/callback') || + currentPath.startsWith('/403') + ) { + return Promise.reject(new UnauthorizedError()); + } + + if (typeof window !== 'undefined') { + sessionStorage.setItem('redirect_after_login', currentPath); + const loginUrl = new URL('/login', window.location.origin); + loginUrl.searchParams.set('callbackUrl', currentPath); + window.location.href = loginUrl.toString(); + } + + return new Promise(() => {}); +} + /** * 响应拦截器 * 处理 API 响应和统一错误处理 @@ -152,13 +172,13 @@ apiClient.interceptors.response.use( return Promise.reject(cancelError); } - /* 401:未登录。公开认证接口只把错误交给页面;已登录态下的误报不得清 cookie 跳登录页。 */ + /* 401:未登录 → 登录页。登录/注册/人机校验接口把错误交给表单。 */ if (error.response?.status === 401) { const message = error.response.data?.error_msg || '未登录'; - if (!isPublicAuthRequest(error.config?.url)) { - toast.error(message, { id: 'unauthorized-error' }); + if (isPublicAuthRequest(error.config?.url)) { + return Promise.reject(new UnauthorizedError(message)); } - return Promise.reject(new UnauthorizedError(message)); + return initiateLogin(window.location.pathname + window.location.search); } /* 403:已登录但权限不足,进入独立 403 页,不清 cookie。 */ diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 4b0e189e..62ad2454 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -73,9 +73,94 @@ if (typeof setInterval !== 'undefined') { }, 60000); } +function backendOrigin(): string { + return process.env.WAVELET_BACKEND_URL || 'http://localhost:8000'; +} + +function isMultipart(request: NextRequest): boolean { + return (request.headers.get('content-type') || '').includes( + 'multipart/form-data', + ); +} + +function isDocumentNavigation(request: NextRequest): boolean { + return request.headers.get('sec-fetch-dest') === 'document'; +} + +async function proxyApiToBackend(request: NextRequest): Promise { + const { pathname, search } = request.nextUrl; + const url = `${backendOrigin()}${pathname}${search}`; + const headers = new Headers(); + request.headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'host' || + lower === 'connection' || + lower === 'content-length' || + lower === 'transfer-encoding' + ) { + return; + } + headers.set(key, value); + }); + const host = request.headers.get('host'); + if (host) { + headers.set('x-forwarded-host', host); + } + headers.set( + 'x-forwarded-proto', + request.nextUrl.protocol.replace(':', '') || 'http', + ); + + const method = request.method.toUpperCase(); + const init: RequestInit = { + method, + headers, + redirect: 'manual', + }; + if (method !== 'GET' && method !== 'HEAD') { + init.body = request.body; + Object.assign(init, { duplex: 'half' }); + } + + const upstream = await fetch(url, init); + + if (isDocumentNavigation(request)) { + if (upstream.status === 401) { + const loginUrl = new URL('/login', request.url); + loginUrl.searchParams.set('callbackUrl', '/home'); + return NextResponse.redirect(loginUrl); + } + if (upstream.status === 403) { + return NextResponse.redirect(new URL('/403', request.url)); + } + } + + const responseHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if (lower === 'set-cookie' || lower === 'transfer-encoding') { + return; + } + responseHeaders.set(key, value); + }); + const out = new NextResponse(upstream.body, { + status: upstream.status, + headers: responseHeaders, + }); + const cookies = + typeof upstream.headers.getSetCookie === 'function' + ? upstream.headers.getSetCookie() + : []; + for (const cookie of cookies) { + out.headers.append('set-cookie', cookie); + } + return out; +} + /* ==================== 代理主函数 ==================== */ -export function proxy(request: NextRequest) { +export async function proxy(request: NextRequest) { const { pathname, search } = request.nextUrl; const sessionCookieName = process.env.WAVELET_SESSION_COOKIE_NAME || 'wavelet_session_id'; @@ -86,7 +171,7 @@ export function proxy(request: NextRequest) { return NextResponse.next(); } - /* API 请求:速率限制后放行 */ + /* API 请求:速率限制后反代,并原样回写 Set-Cookie(rewrites 会丢登录 Cookie) */ if (pathname.startsWith('/api/')) { const rateLimitEnabled = process.env.WAVELET_RATE_LIMIT_ENABLED === 'true'; @@ -108,7 +193,17 @@ export function proxy(request: NextRequest) { ); } } - return NextResponse.next(); + if (isMultipart(request)) { + return NextResponse.next(); + } + try { + return await proxyApiToBackend(request); + } catch { + return NextResponse.json( + { error_msg: '无法连接到服务器', data: null }, + { status: 502 }, + ); + } } /* 页面请求:公共路由放行 */