fix(model):修复模型导入问题
This commit is contained in:
1 parent
2793a6467c
commit
d39cc70fee
4 files changed
+85
-11
No files matched your search
@@ -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 值,兼容已有的脏导出文件。
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user