diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index b381d25..7856323 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -434,3 +434,15 @@ dao.XxxTable.Ctx(ctx).Where("id", req.Id).Data(data).Update() 6. 冗余 `updated_at` 手动设置(见"自动时间维护") 额外修复:16 处 Scan 改指针后遗漏 `nil` 检查导致 panic(member.go、organization.go、order.go、notification.go、permission.go、channel.go、help_center.go、feature_flag.go)。 + +### 2026-05-30:MiddlewareHandlerResponse 追加标准响应到文件导出 + +**问题描述**:模型导出功能下载的 JSON 文件末尾被追加了 `{"code":0,"message":"ok","data":null}`,导致重新导入时报错 `invalid character '{' after top-level value`。 + +**原因**:Go 的 nil interface 陷阱。`ExportModelsJson` 返回 `(nil, nil)`,但 Controller 将 `(*ModelExportJsonRes)(nil)` 存入 `interface{}` 后,GoFrame 的 `r.GetHandlerResponse()` 判断 `res != nil` 为 true(typed nil pointer ≠ nil),中间件误调用 `response.Success()` 追加了标准响应体。 + +**修复方式**: +1. **中间件**(`handler_response.go`):在检查 `GetHandlerResponse()` 前增加两道防线: + - 检测 `Content-Disposition` 头(handler 直接写入下载文件时设置) + - 检测 `BufferLength() > 0 && GetHandlerResponse() == nil`(handler 已写响应体且返回值确实为 nil) +2. **导入端**(`model_import_export.go`):`json.Unmarshal` 改为 `json.NewDecoder(...).Decode()`,只解析第一个完整 JSON 值,兼容已有的脏导出文件。 diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index 47c658d..66a2d42 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -1,6 +1,7 @@ package admin import ( + "bytes" "context" "encoding/json" "io" @@ -11,6 +12,7 @@ import ( "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" "github.com/gogf/gf/v2/os/gtime" "github.com/qianfree/team-api/api/admin/v1" @@ -149,47 +151,74 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe func (s *sAdmin) ImportModelsPreview(ctx context.Context, req *v1.ModelImportPreviewReq) (*v1.ModelImportPreviewRes, error) { var data []byte + r := g.RequestFromCtx(ctx) + glog.Info(ctx, "[ModelImport] 收到预览请求, Method:", r.Method, "Content-Type:", r.Header.Get("Content-Type")) + // 优先通过标准文件上传读取 - file := g.RequestFromCtx(ctx).GetUploadFile("file") + file := r.GetUploadFile("file") if file != nil { + glog.Info(ctx, "[ModelImport] GetUploadFile 获取成功, Filename:", file.Filename, "Size:", file.Size) f, err := file.Open() if err != nil { + glog.Error(ctx, "[ModelImport] file.Open 失败:", err) return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) } defer f.Close() data, err = io.ReadAll(f) if err != nil { + glog.Error(ctx, "[ModelImport] io.ReadAll 失败:", err) return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) } + glog.Info(ctx, "[ModelImport] 读取文件内容长度:", len(data)) + } else { + glog.Warning(ctx, "[ModelImport] GetUploadFile 返回 nil,尝试兜底方式") } // 兜底:从已解析的 MultipartForm.File 中读取(文件在 File 而非 Value 中) if len(data) == 0 { - mf := g.RequestFromCtx(ctx).GetMultipartForm() + mf := r.GetMultipartForm() if mf != nil { + glog.Info(ctx, "[ModelImport] MultipartForm 获取成功, File字段数:", len(mf.File), "Value字段数:", len(mf.Value)) if files := mf.File["file"]; len(files) > 0 { + glog.Info(ctx, "[ModelImport] MultipartForm.File[file] 找到, 文件数:", len(files), "文件名:", files[0].Filename) f, err := files[0].Open() if err == nil { defer f.Close() data, _ = io.ReadAll(f) } + } else { + glog.Warning(ctx, "[ModelImport] MultipartForm.File 中无 file 字段,可用字段:") + for k := range mf.File { + glog.Info(ctx, " - File field:", k) + } } + } else { + glog.Warning(ctx, "[ModelImport] GetMultipartForm 返回 nil") } } if len(data) == 0 { + glog.Error(ctx, "[ModelImport] 文件内容为空,无法解析") return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) } + glog.Info(ctx, "[ModelImport] 文件内容长度:", len(data), "前100字节:", string(data[:min(100, len(data))])) + var exportData struct { Version string `json:"version"` Models []v1.ModelImportPreviewItem `json:"models"` } - if err := json.Unmarshal(data, &exportData); err != nil { + // 使用 json.Decoder 而非 json.Unmarshal,只解析第一个完整 JSON 值, + // 兼容旧版本导出文件末尾可能被中间件追加的标准响应体。 + if err := json.NewDecoder(bytes.NewReader(data)).Decode(&exportData); err != nil { + glog.Error(ctx, "[ModelImport] JSON 解析失败:", err) return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) } + glog.Info(ctx, "[ModelImport] JSON 解析成功, Version:", exportData.Version, "模型数:", len(exportData.Models)) + if exportData.Version != "1.0" { + glog.Error(ctx, "[ModelImport] 版本不兼容:", exportData.Version) return nil, gerror.NewCode(gcode.New(consts.CodeModelImportBadVersion, consts.MsgModelImportBadVersion, nil), consts.MsgModelImportBadVersion) } @@ -200,6 +229,7 @@ func (s *sAdmin) ImportModelsPreview(ctx context.Context, req *v1.ModelImportPre } } + glog.Info(ctx, "[ModelImport] 预览完成, 模型数:", len(exportData.Models)) return &v1.ModelImportPreviewRes{Models: exportData.Models}, nil } diff --git a/internal/middleware/handler_response.go b/internal/middleware/handler_response.go index 1ff47c0..fd5487a 100644 --- a/internal/middleware/handler_response.go +++ b/internal/middleware/handler_response.go @@ -45,6 +45,21 @@ func MiddlewareHandlerResponse(r *ghttp.Request) { } } + // Skip JSON wrapping when handler has already written directly to the response + // (e.g., model export sets Content-Disposition: attachment). + // This also works around Go's nil-interface trap: a typed nil pointer (*T)(nil) + // stored in interface{} is != nil, causing GetHandlerResponse() to appear non-nil + // even though the handler returned nil. + if r.Response.Header().Get("Content-Disposition") != "" { + return + } + + // If the handler already wrote body content and returned a nil-ish result, + // don't append standard response wrapper on top of it. + if r.Response.BufferLength() > 0 && r.GetHandlerResponse() == nil { + return + } + // The controller method returns (res, error); GoFrame stores the first // return value in r.handlerResponse, accessible via GetHandlerResponse(). res := r.GetHandlerResponse() diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index c3153f6..27b2d62 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -410,7 +410,7 @@ const importColumns: TableColumnData[] = [ title: '冲突处理', dataIndex: 'conflict_action', width: 120, - render({ record }, { emit }: any) { + render({ record }: any) { if (record.conflict !== 'exists') return null return h('select', { value: importConflictActions.value[record.model_id] || 'skip', @@ -433,12 +433,22 @@ function openImportModal() { showImportModal.value = true } -async function handleImportUpload(data: { file: any; fileList: any[] }) { - // Naive UI @change 回调参数: { file: UploadFileInfo, fileList: UploadFileInfo[] } - // UploadFileInfo.file 才是浏览器原生 File 对象 - const file = data.file?.file - if (!file) return +async function handleImportUpload(fileItemList: any[], fileItem: any) { + // Arco Design Vue @change 回调参数: (fileItemList: FileItem[], fileItem: FileItem) + // fileItem.file 才是浏览器原生 File 对象 + console.log('[ModelImport] @change triggered', { + fileListLen: fileItemList?.length, + fileItem, + rawFile: fileItem?.file, + }) + const file = fileItem?.file + if (!file) { + console.warn('[ModelImport] 未获取到原生 File 对象,fileItem =', fileItem) + return + } + + console.log('[ModelImport] 开始上传预览,文件:', file.name, file.size, 'bytes') importPreviewing.value = true try { const formData = new FormData() @@ -447,7 +457,9 @@ async function handleImportUpload(data: { file: any; fileList: any[] }) { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 30000, }) + console.log('[ModelImport] 预览响应:', res) const models = res.data?.data?.models || res.data?.models || [] + console.log('[ModelImport] 解析到模型数:', models.length) importPreviewData.value = models // 默认全选 importSelectedKeys.value = models.map((m: any) => m.model_id) @@ -457,7 +469,8 @@ async function handleImportUpload(data: { file: any; fileList: any[] }) { importConflictActions.value[m.model_id] = 'skip' } } - } catch { + } catch (err) { + console.error('[ModelImport] 预览失败:', err) // error handled by interceptor } finally { importPreviewing.value = false @@ -474,6 +487,8 @@ async function confirmImport() { : 'skip', })) + console.log('[ModelImport] 确认导入, 已选模型数:', selectedModels.length, selectedModels.map((m: any) => m.model_id)) + if (selectedModels.length === 0) { Message.warning('请至少选择一个模型') return @@ -482,6 +497,7 @@ async function confirmImport() { importing.value = true try { const res: any = await request.post('/admin/models/import', { models: selectedModels }) + console.log('[ModelImport] 导入响应:', res) const result = res.data?.data || res.data Message.success(`导入完成:成功 ${result.imported} 个,跳过 ${result.skipped} 个`) if (result.errors?.length) { @@ -489,7 +505,8 @@ async function confirmImport() { } showImportModal.value = false fetchData() - } catch { + } catch (err) { + console.error('[ModelImport] 导入失败:', err) // error handled by interceptor } finally { importing.value = false