From 499439d66bdff80f2b2375875dd857240ec6b23e Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Fri, 22 May 2026 14:15:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(handler):=20=E7=BB=9F=E4=B8=80=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=A4=84=E7=90=86=E5=B9=B6=E6=9B=BF=E6=8D=A2Next.js?= =?UTF-8?q?=E9=87=8D=E5=86=99=E4=B8=BA=E8=B7=AF=E7=94=B1=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将所有handler中的Fail函数调用替换为FailError以统一错误处理 - 添加FailError函数实现统一的错误日志记录和响应格式 - 移除Next.js配置中的rewrites并实现统一的API路由代理处理器 - 新增路由处理器支持长连接和大文件上传场景的代理转发 - 更新错误处理逻辑避免将内部错误详情暴露给前端用户 --- CHANGELOG.md | 2 ++ docs/pending-test.md | 2 ++ handler/admin.go | 8 ++--- handler/assets.go | 8 ++--- handler/auth.go | 12 +++---- handler/prompts.go | 2 +- handler/response.go | 6 ++++ handler/settings.go | 6 ++-- web/next.config.ts | 6 ---- web/src/app/api/[...path]/route.ts | 57 ++++++++++++++++++++++++++++++ 10 files changed, 85 insertions(+), 24 deletions(-) create mode 100644 web/src/app/api/[...path]/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bad75ae..8e7c50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [修复] 后端业务接口失败时统一返回通用错误文案,避免把数据库或内部错误细节暴露给前端。 ++ [修复] `/api/*` 后端请求改用统一 Next Route Handler 代理,避免长时间生图响应被 Next rewrites 断开。 + [修复] 图片节点生成会用扁平 metadata 字段记录提示词、生成类型、模型、尺寸、质量、数量和参考图引用,重试优先使用这些字段。 + [修复] 参考图丢失时会直接提示无法继续重试,避免误发空的 `images/edits` 请求。 + [修复] AI 代理失败统一返回通用错误,前端按业务 `code` 弹 `message.error`。 diff --git a/docs/pending-test.md b/docs/pending-test.md index 668d616..c09f71f 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,5 +1,6 @@ # 待测试 +- 后端业务接口遇到数据库或内部错误时,前端应只收到统一的“操作失败”文案,后端日志保留真实错误。 - 点击右上角版本号时,应弹出版本更新弹窗,并展示当前版本、最新版本、检查更新入口和可滚动的版本时间线。 - 设置弹窗中填写系统提示词后,生图、编辑图和画布助手文本请求应携带该提示词。 - Docker 镜像构建和运行时,前端应能正常读取 `CHANGELOG.md` 并完成版本信息注入。 @@ -10,6 +11,7 @@ - 管理后台私有配置页应提示:当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API,不建议公网部署,避免私有渠道额度被他人消耗。 - 用户侧配置弹窗应读取公共配置;未允许自定义渠道时固定使用云端渠道,允许时可切换云端渠道和本地直连。 - 用户侧云端模式应通过 `/api/v1/*` 代理路径请求后端模型接口,路径风格应与 OpenAI 保持一致。 +- 用户侧云端模式长时间生图时,统一 `/api/*` 代理应等待后端模型响应,不应再出现 Next rewrites 的 socket hang up。 - 根布局初始化组件应在页面刷新后统一加载当前用户信息和公共系统配置。 - 用户侧生图、画布节点生成和画布助手请求应按渠道模式分别走后端代理或浏览器本地直连。 - AI 代理请求上游失败时,后端日志应记录上游地址、状态或错误,前端只展示通用失败文案,不应暴露 EOF、socket hang up 等原始错误。 diff --git a/handler/admin.go b/handler/admin.go index 4f39d0a..950fb1d 100644 --- a/handler/admin.go +++ b/handler/admin.go @@ -20,7 +20,7 @@ func AdminPromptCategories(w http.ResponseWriter, r *http.Request) { func AdminPrompts(w http.ResponseWriter, r *http.Request) { result, err := service.ListPrompts(parseQuery(r)) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, result) @@ -31,7 +31,7 @@ func AdminSavePrompt(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&item) result, err := service.SavePrompt(item) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, result) @@ -39,7 +39,7 @@ func AdminSavePrompt(w http.ResponseWriter, r *http.Request) { func AdminDeletePrompt(w http.ResponseWriter, r *http.Request, id string) { if err := service.DeletePrompt(id); err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, true) @@ -52,7 +52,7 @@ func AdminSyncPromptCategories(w http.ResponseWriter, r *http.Request) { categories, err := service.SyncPromptCategory(request.Category) if err != nil { log.Printf("sync prompt category failed category=%s err=%v", request.Category, err) - Fail(w, err.Error()) + FailError(w, err) return } log.Printf("sync prompt category done category=%s", request.Category) diff --git a/handler/assets.go b/handler/assets.go index 71a748a..9cd87b6 100644 --- a/handler/assets.go +++ b/handler/assets.go @@ -11,7 +11,7 @@ import ( func Assets(w http.ResponseWriter, r *http.Request) { result, err := service.ListAssets(parseQuery(r)) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, result) @@ -20,7 +20,7 @@ func Assets(w http.ResponseWriter, r *http.Request) { func AdminAssets(w http.ResponseWriter, r *http.Request) { result, err := service.ListAssets(parseQuery(r)) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, result) @@ -31,7 +31,7 @@ func AdminSaveAsset(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&item) result, err := service.SaveAsset(item) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, result) @@ -39,7 +39,7 @@ func AdminSaveAsset(w http.ResponseWriter, r *http.Request) { func AdminDeleteAsset(w http.ResponseWriter, r *http.Request, id string) { if err := service.DeleteAsset(id); err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, true) diff --git a/handler/auth.go b/handler/auth.go index 7a0f1d7..209f715 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -30,7 +30,7 @@ func Register(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&request) session, err := service.Register(request.Username, request.Password) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, session) @@ -41,7 +41,7 @@ func Login(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&request) session, err := service.Login(request.Username, request.Password) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } if session.User.Role != model.UserRoleAdmin { @@ -56,7 +56,7 @@ func AdminLogin(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&request) session, err := service.Login(request.Username, request.Password) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } if session.User.Role != model.UserRoleAdmin { @@ -77,7 +77,7 @@ func CurrentUser(w http.ResponseWriter, r *http.Request) { func AdminUsers(w http.ResponseWriter, r *http.Request) { users, err := service.ListUsers(parseQuery(r)) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, users) @@ -92,7 +92,7 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) { Role: request.Role, }, request.Password) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, user) @@ -100,7 +100,7 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) { func AdminDeleteUser(w http.ResponseWriter, r *http.Request, id string) { if err := service.DeleteUser(id); err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, true) diff --git a/handler/prompts.go b/handler/prompts.go index e8b6c60..1595e75 100644 --- a/handler/prompts.go +++ b/handler/prompts.go @@ -9,7 +9,7 @@ import ( func Prompts(w http.ResponseWriter, r *http.Request) { result, err := service.ListPrompts(parseQuery(r)) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, result) diff --git a/handler/response.go b/handler/response.go index 5ab66e4..e1b6e10 100644 --- a/handler/response.go +++ b/handler/response.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "log" "net/http" "strconv" @@ -22,6 +23,11 @@ func Fail(w http.ResponseWriter, msg string) { writeJSON(w, response{Code: 1, Data: nil, Msg: msg}) } +func FailError(w http.ResponseWriter, err error) { + log.Printf("request failed: %v", err) + Fail(w, "操作失败") +} + func writeJSON(w http.ResponseWriter, value any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(value) diff --git a/handler/settings.go b/handler/settings.go index 1422a6c..ecdbc53 100644 --- a/handler/settings.go +++ b/handler/settings.go @@ -11,7 +11,7 @@ import ( func Settings(w http.ResponseWriter, r *http.Request) { settings, err := service.PublicSettings() if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, settings) @@ -20,7 +20,7 @@ func Settings(w http.ResponseWriter, r *http.Request) { func AdminSettings(w http.ResponseWriter, r *http.Request) { settings, err := service.AdminSettings() if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, settings) @@ -31,7 +31,7 @@ func AdminSaveSettings(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&settings) result, err := service.SaveSettings(settings) if err != nil { - Fail(w, err.Error()) + FailError(w, err) return } OK(w, result) diff --git a/web/next.config.ts b/web/next.config.ts index d303a54..de2b1df 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,6 +1,5 @@ import type { NextConfig } from "next"; import { PHASE_DEVELOPMENT_SERVER } from "next/constants"; -import { loadEnvConfig } from "@next/env"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; @@ -12,8 +11,6 @@ const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8"); export default function nextConfig(phase: string): NextConfig { const isDev = phase === PHASE_DEVELOPMENT_SERVER; - loadEnvConfig(resolve(webDir, ".."), isDev, undefined, true); - const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080"; const releases = parseChangelog(localChangelog); return { @@ -25,8 +22,5 @@ export default function nextConfig(phase: string): NextConfig { NEXT_PUBLIC_APP_VERSION: localVersion, NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases), }, - async rewrites() { - return [{ source: "/api/:path*", destination: `${apiBaseUrl}/api/:path*` }]; - }, }; } diff --git a/web/src/app/api/[...path]/route.ts b/web/src/app/api/[...path]/route.ts new file mode 100644 index 0000000..8d054da --- /dev/null +++ b/web/src/app/api/[...path]/route.ts @@ -0,0 +1,57 @@ +import type { NextRequest } from "next/server"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +type RouteContext = { + params: Promise<{ path: string[] }>; +}; + +function proxyHeaders(request: NextRequest) { + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("content-length"); + headers.delete("connection"); + return headers; +} + +function responseHeaders(response: Response) { + const headers = new Headers(response.headers); + headers.delete("content-length"); + headers.delete("content-encoding"); + headers.delete("transfer-encoding"); + return headers; +} + +async function proxy(request: NextRequest, context: RouteContext) { + const { path } = await context.params; + const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080"; + const target = `${apiBaseUrl.replace(/\/$/, "")}/api/${path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`; + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + + try { + const response = await fetch(target, { + method: request.method, + headers: proxyHeaders(request), + body: hasBody ? request.body : undefined, + duplex: hasBody ? "half" : undefined, + } as RequestInit & { duplex?: "half" }); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders(response), + }); + } catch (error) { + console.error("Failed to proxy", target, error); + return Response.json({ code: 1, data: null, msg: "接口连接失败,请确认后端服务已启动" }, { status: 502 }); + } +} + +export const GET = proxy; +export const HEAD = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; +export const OPTIONS = proxy;