fix(model):修复模型导入问题

This commit is contained in:
qian committed 2026-05-30 09:49:22 +08:00
1 parent 2793a6467c
commit d39cc70fee
4 files changed
+85 -11

No files matched your search

+12
View File
@@ -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 值,兼容已有的脏导出文件。
+33 -3
View File
@@ -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
}
+15
View File
@@ -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()
+25 -8
View File
@@ -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