From d1774a390296f97c0b53b008e21fd532cbc75b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Tue, 29 Sep 2026 22:47:01 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(handlers):=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E9=94=99=E8=AF=AF=E5=93=8D=E5=BA=94=E4=B8=BA=E5=AE=89?= =?UTF-8?q?=E5=85=A8=20JSON=20=E5=B9=B6=E8=A1=A5=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 respondError:JSON 安全编码,客户端错误返回 400,服务端错误返回 500 - 收编 book/content/media/category 中 13 处 fmt.Fprintf 内联 JSON,修复错误信息含引号/换行时产出非法 JSON 的问题 - search/detail/catalog/multi-detail/content/multi-content/item-summary/audio/video 补必需参数校验,缺参返回 400 - search 的 offset/tab_type 非数字时返回 400,不再静默置 0 - 补齐 multi-detail/multi-content 的请求统计计数 - 新增 error_response_test.go:3 个测试锁定错误响应行为 - openapi.yaml 新增 ServerError 响应组件,15 个业务接口补 400/500 响应定义 BREAKING CHANGE: 业务接口错误不再返回 HTTP 200,缺参返回 400,上游失败返回 500 --- internal/handlers/book.go | 65 ++++++++++++++-------- internal/handlers/category.go | 4 +- internal/handlers/common.go | 9 ++++ internal/handlers/content.go | 38 ++++++++----- internal/handlers/error_response_test.go | 42 +++++++++++++++ internal/handlers/media.go | 35 +++++++----- openapi.yaml | 68 ++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 52 deletions(-) create mode 100644 internal/handlers/error_response_test.go diff --git a/internal/handlers/book.go b/internal/handlers/book.go index dbe19b6..7d84829 100644 --- a/internal/handlers/book.go +++ b/internal/handlers/book.go @@ -4,7 +4,6 @@ import ( "fanqie/internal/fqapi" "fmt" "net/http" - "strconv" ) // HandleSearch 搜索处理 @@ -16,22 +15,29 @@ func HandleSearch(w http.ResponseWriter, r *http.Request) { if query == "" { query = r.URL.Query().Get("key") } - - offsetStr := r.URL.Query().Get("offset") - offset := 0 - if offsetStr != "" { - offset, _ = strconv.Atoi(offsetStr) + if query == "" { + respondError(w, "缺少 query 参数", true) + return } - tabTypeStr := r.URL.Query().Get("tab_type") - tabType := 3 - if tabTypeStr != "" { - tabType, _ = strconv.Atoi(tabTypeStr) + offset, err := parseIntParam(r.URL.Query().Get("offset"), "offset") + if err != nil { + respondError(w, err.Error(), true) + return + } + + tabType, err := parseIntParam(r.URL.Query().Get("tab_type"), "tab_type") + if err != nil { + respondError(w, err.Error(), true) + return + } + if tabType == 0 { + tabType = 3 } result, err := fqapi.HandleBookSearch(query, offset, tabType) if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "搜索失败: %s"}`, err.Error()) + respondError(w, "搜索失败: "+err.Error(), false) return } @@ -43,11 +49,15 @@ func HandleDetail(w http.ResponseWriter, r *http.Request) { incrementStat("total", "detail") w.Header().Set("Content-Type", "application/json; charset=utf-8") - bookID := r.URL.Query().Get("book_id") - - result, err := fqapi.HandleGetDetail(bookID) + params, err := validateParams(r, "book_id") if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "获取详情失败: %s"}`, err.Error()) + respondError(w, err.Error(), true) + return + } + + result, err := fqapi.HandleGetDetail(params["book_id"]) + if err != nil { + respondError(w, "获取详情失败: "+err.Error(), false) return } @@ -59,11 +69,15 @@ func HandleCatalog(w http.ResponseWriter, r *http.Request) { incrementStat("total", "catalog") w.Header().Set("Content-Type", "application/json; charset=utf-8") - bookID := r.URL.Query().Get("book_id") - - result, err := fqapi.HandleGetCatalog(bookID) + params, err := validateParams(r, "book_id") if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "获取目录失败: %s"}`, err.Error()) + respondError(w, err.Error(), true) + return + } + + result, err := fqapi.HandleGetCatalog(params["book_id"]) + if err != nil { + respondError(w, "获取目录失败: "+err.Error(), false) return } @@ -72,13 +86,18 @@ func HandleCatalog(w http.ResponseWriter, r *http.Request) { // HandleMultiDetail 批量详情处理 func HandleMultiDetail(w http.ResponseWriter, r *http.Request) { + incrementStat("total", "detail") w.Header().Set("Content-Type", "application/json; charset=utf-8") - bookID := r.URL.Query().Get("book_id") - - result, err := fqapi.HandleGetMultiDetail(bookID) + params, err := validateParams(r, "book_id") if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "批量获取详情失败: %s"}`, err.Error()) + respondError(w, err.Error(), true) + return + } + + result, err := fqapi.HandleGetMultiDetail(params["book_id"]) + if err != nil { + respondError(w, "批量获取详情失败: "+err.Error(), false) return } diff --git a/internal/handlers/category.go b/internal/handlers/category.go index 312a0c6..3549d05 100644 --- a/internal/handlers/category.go +++ b/internal/handlers/category.go @@ -15,7 +15,7 @@ func HandleCategoryFront(w http.ResponseWriter, r *http.Request) { result, err := fqapi.HandleCategoryFront(tab) if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "获取分类列表失败: %s"}`, err.Error()) + respondError(w, "获取分类列表失败: "+err.Error(), false) return } @@ -35,7 +35,7 @@ func HandleCategoryLanding(w http.ResponseWriter, r *http.Request) { result, err := fqapi.HandleCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy) if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "获取分类详情失败: %s"}`, err.Error()) + respondError(w, "获取分类详情失败: "+err.Error(), false) return } diff --git a/internal/handlers/common.go b/internal/handlers/common.go index f82c7f3..d06f07b 100644 --- a/internal/handlers/common.go +++ b/internal/handlers/common.go @@ -96,6 +96,15 @@ func StopStatisticsSaver() { } } +// respondError 统一错误响应(JSON 安全编码;isClientErr 为 true 时返回 400,否则 500) +func respondError(w http.ResponseWriter, msg string, isClientErr bool) { + status := http.StatusInternalServerError + if isClientErr { + status = http.StatusBadRequest + } + respondJSON(w, status, map[string]interface{}{"code": -1, "msg": msg}) +} + // RequireAccessKey 验证访问密钥中间件 func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handlers/content.go b/internal/handlers/content.go index c76e249..5cd6d23 100644 --- a/internal/handlers/content.go +++ b/internal/handlers/content.go @@ -12,9 +12,13 @@ func HandleContent(w http.ResponseWriter, r *http.Request) { incrementStat("total", "content") w.Header().Set("Content-Type", "application/json; charset=utf-8") - // 从查询参数获取item_id - itemID := r.URL.Query().Get("item_id") - + params, err := validateParams(r, "item_id") + if err != nil { + respondError(w, err.Error(), true) + return + } + itemID := params["item_id"] + // 获取URL参数(支持动态覆盖配置) textMode := r.URL.Query().Get("text_mode") // 1=纯文本模式 imageMode := r.URL.Query().Get("image_mode") // 0=移除图片, 1=保留图片 @@ -22,7 +26,7 @@ func HandleContent(w http.ResponseWriter, r *http.Request) { result, err := fqapi.HandleGetContent(itemID, textMode, imageMode) if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "获取内容失败: %s"}`, err.Error()) + respondError(w, "获取内容失败: "+err.Error(), false) return } @@ -51,17 +55,22 @@ func HandleContent(w http.ResponseWriter, r *http.Request) { // HandleMultiContent 批量内容处理 func HandleMultiContent(w http.ResponseWriter, r *http.Request) { + incrementStat("total", "content") w.Header().Set("Content-Type", "application/json; charset=utf-8") - bookID := r.URL.Query().Get("book_id") - itemIDs := r.URL.Query().Get("item_ids") + params, err := validateParams(r, "book_id", "item_ids") + if err != nil { + respondError(w, err.Error(), true) + return + } + textMode := r.URL.Query().Get("text_mode") imageMode := r.URL.Query().Get("image_mode") ignoreFailed := r.URL.Query().Get("ignore_failed") - result, err := fqapi.HandleMultiContent(itemIDs, bookID, textMode, imageMode, ignoreFailed) + result, err := fqapi.HandleMultiContent(params["item_ids"], params["book_id"], textMode, imageMode, ignoreFailed) if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "批量获取内容失败: %s"}`, err.Error()) + respondError(w, "批量获取内容失败: "+err.Error(), false) return } @@ -72,12 +81,15 @@ func HandleMultiContent(w http.ResponseWriter, r *http.Request) { func HandleItemSummary(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") - bookID := r.URL.Query().Get("book_id") - itemIDs := r.URL.Query().Get("item_ids") - - result, err := fqapi.HandleItemSummary(itemIDs, bookID) + params, err := validateParams(r, "book_id", "item_ids") if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "获取章节摘要失败: %s"}`, err.Error()) + respondError(w, err.Error(), true) + return + } + + result, err := fqapi.HandleItemSummary(params["item_ids"], params["book_id"]) + if err != nil { + respondError(w, "获取章节摘要失败: "+err.Error(), false) return } diff --git a/internal/handlers/error_response_test.go b/internal/handlers/error_response_test.go new file mode 100644 index 0000000..31aeade --- /dev/null +++ b/internal/handlers/error_response_test.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +// TestRespondErrorProducesValidJSON 错误信息含引号/换行时输出仍须是合法 JSON +func TestRespondErrorProducesValidJSON(t *testing.T) { + w := httptest.NewRecorder() + respondError(w, "上游返回了 \"引号\" 和\n换行", false) + + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("非法 JSON: %v, body: %s", err, w.Body.String()) + } + if body["code"].(float64) != -1 { + t.Errorf("code 应为 -1, got %v", body["code"]) + } + if w.Code != 500 { + t.Errorf("服务端错误应返回 500, got %d", w.Code) + } +} + +// TestRespondErrorClientErr 客户端错误应返回 400 +func TestRespondErrorClientErr(t *testing.T) { + w := httptest.NewRecorder() + respondError(w, "缺少 book_id 参数", true) + if w.Code != 400 { + t.Errorf("客户端错误应返回 400, got %d", w.Code) + } +} + +// TestHandleDetailMissingParam 缺少必需参数返回 400 而非透传到上游 +func TestHandleDetailMissingParam(t *testing.T) { + w := httptest.NewRecorder() + HandleDetail(w, httptest.NewRequest("GET", "/detail", nil)) + if w.Code != 400 { + t.Errorf("缺参应返回 400, got %d, body: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 532ab8a..c2568c1 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -13,13 +13,18 @@ func HandleAudio(w http.ResponseWriter, r *http.Request) { incrementStat("total", "audio") w.Header().Set("Content-Type", "application/json; charset=utf-8") - itemIDs := r.URL.Query().Get("item_ids") + params, err := validateParams(r, "item_ids") + if err != nil { + respondError(w, err.Error(), true) + return + } + toneID := r.URL.Query().Get("tone_id") output := r.URL.Query().Get("output") - result, err := fqapi.HandleAudio(itemIDs, toneID, output) + result, err := fqapi.HandleAudio(params["item_ids"], toneID, output) if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "获取音频失败: %s"}`, err.Error()) + respondError(w, "获取音频失败: "+err.Error(), false) return } @@ -62,11 +67,15 @@ func HandleVideo(w http.ResponseWriter, r *http.Request) { incrementStat("total", "video") w.Header().Set("Content-Type", "application/json; charset=utf-8") - itemIDs := r.URL.Query().Get("item_ids") - - result, err := fqapi.HandleVideo(itemIDs) + params, err := validateParams(r, "item_ids") if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "获取视频失败: %s"}`, err.Error()) + respondError(w, err.Error(), true) + return + } + + result, err := fqapi.HandleVideo(params["item_ids"]) + if err != nil { + respondError(w, "获取视频失败: "+err.Error(), false) return } @@ -76,14 +85,13 @@ func HandleVideo(w http.ResponseWriter, r *http.Request) { // HandleManga 漫画处理 func HandleManga(w http.ResponseWriter, r *http.Request) { incrementStat("total", "manga") + itemID := r.URL.Query().Get("item_ids") if itemID == "" { itemID = r.URL.Query().Get("item_id") } - if itemID == "" { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - fmt.Fprint(w, `{"code": -1, "msg": "缺少item_ids参数"}`) + respondError(w, "缺少 item_ids 参数", true) return } @@ -96,8 +104,7 @@ func HandleManga(w http.ResponseWriter, r *http.Request) { result, err := fqapi.HandleGetManga(itemID, showHTML, outputMode) if err != nil { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - fmt.Fprintf(w, `{"code": -1, "msg": "获取漫画失败: %s"}`, err.Error()) + respondError(w, "获取漫画失败: "+err.Error(), false) return } @@ -113,16 +120,16 @@ func HandleManga(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `
未找到漫画内容
`) } else { // 返回JSON格式 - w.Header().Set("Content-Type", "application/json; charset=utf-8") jsonData, err := json.Marshal(map[string]interface{}{ "code": 0, "msg": "success", "data": result, }) if err != nil { - fmt.Fprintf(w, `{"code": -1, "msg": "JSON编码失败: %s"}`, err.Error()) + respondError(w, "JSON编码失败: "+err.Error(), false) return } + w.Header().Set("Content-Type", "application/json; charset=utf-8") fmt.Fprint(w, string(jsonData)) } } diff --git a/openapi.yaml b/openapi.yaml index e798836..92d2c2d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -169,6 +169,10 @@ paths: $ref: "#/components/schemas/UpstreamResponse" example: { code: 0, msg: success, data: { search_book_data_list: [], has_more: 1 } } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /detail: get: tags: [书籍] @@ -183,6 +187,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /info: get: tags: [书籍] @@ -197,6 +205,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /catalog: get: tags: [书籍] @@ -211,6 +223,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /directory/all_items: get: tags: [书籍] @@ -225,6 +241,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /multi-detail: get: tags: [书籍] @@ -244,6 +264,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /content: get: tags: [内容] @@ -295,6 +319,10 @@ paths: properties: book_id: { type: string, description: 所属书籍 ID } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /multi-content: get: tags: [内容] @@ -327,6 +355,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /item-summary: get: tags: [内容] @@ -346,6 +378,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /chapter_review: get: tags: [评论] @@ -371,6 +407,8 @@ paths: "400": $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /review: get: tags: [评论] @@ -405,6 +443,8 @@ paths: "400": $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /review_html: get: tags: [评论] @@ -432,6 +472,8 @@ paths: "400": $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /audio: get: tags: [多媒体] @@ -459,6 +501,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /audio/proxy/{key}: get: tags: [多媒体] @@ -507,6 +553,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /manga: get: tags: [多媒体] @@ -547,6 +597,8 @@ paths: "400": $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /manga/image/{item_id}/{index}: get: tags: [多媒体] @@ -606,6 +658,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /category-landing: get: tags: [分类] @@ -633,6 +689,10 @@ paths: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/ServerError" /emoji/{file}: get: tags: [静态资源] @@ -660,6 +720,7 @@ paths: text/plain: schema: { type: string } + components: securitySchemes: accessKeyQuery: @@ -695,6 +756,13 @@ components: schema: $ref: "#/components/schemas/ErrorResponse" example: { code: -1, msg: "访问被拒绝:无效的访问密钥" } + ServerError: + description: 服务端错误(上游请求失败等) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: { code: -1, msg: "获取内容失败: 请求超时" } BadRequest: description: 参数缺失或格式错误 content: