diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index fb7f024..2b824f0 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -228,7 +228,7 @@ type ModelFetchOfficialInfoRes struct { // ModelExportJsonReq 导出模型配置(JSON,用于跨环境迁移) type ModelExportJsonReq struct { g.Meta `path:"/models/export-json" method:"post" mime:"json" tags:"管理后台-模型" summary:"导出模型配置JSON"` - ModelIds []string `json:"model_ids" v:"required|min:1#请选择模型|至少选择一个模型" dc:"要导出的model_id列表"` + ModelIds []string `json:"model_ids" v:"required#请至少选择一个模型" dc:"要导出的model_id列表"` } // ModelExportJsonRes 导出模型配置JSON响应(直接写入响应流) @@ -236,7 +236,7 @@ type ModelExportJsonRes struct{} // ModelImportPreviewReq 导入模型预览请求 type ModelImportPreviewReq struct { - g.Meta `path:"/models/import-preview" method:"post" mime:"json" tags:"管理后台-模型" summary:"导入模型预览"` + g.Meta `path:"/models/import-preview" method:"post" tags:"管理后台-模型" summary:"导入模型预览"` } // ModelImportPreviewRes 导入模型预览响应 @@ -264,7 +264,7 @@ type ModelImportPreviewItem struct { // ModelImportReq 确认导入模型请求 type ModelImportReq struct { g.Meta `path:"/models/import" method:"post" mime:"json" tags:"管理后台-模型" summary:"确认导入模型"` - Models []ModelImportItem `json:"models" v:"required|min:1#无数据|至少导入一个模型"` + Models []ModelImportItem `json:"models" v:"required#请至少导入一个模型"` } // ModelImportItem 导入模型项 diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index a5317dc..47c658d 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -147,19 +147,37 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe // ImportModelsPreview 导入模型预览(解析上传文件,检测冲突) func (s *sAdmin) ImportModelsPreview(ctx context.Context, req *v1.ModelImportPreviewReq) (*v1.ModelImportPreviewRes, error) { + var data []byte + + // 优先通过标准文件上传读取 file := g.RequestFromCtx(ctx).GetUploadFile("file") - if file == nil { - return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) + if file != nil { + f, err := file.Open() + if err != nil { + return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) + } + defer f.Close() + data, err = io.ReadAll(f) + if err != nil { + return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) + } } - f, err := file.Open() - if err != nil { - return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) + // 兜底:从已解析的 MultipartForm.File 中读取(文件在 File 而非 Value 中) + if len(data) == 0 { + mf := g.RequestFromCtx(ctx).GetMultipartForm() + if mf != nil { + if files := mf.File["file"]; len(files) > 0 { + f, err := files[0].Open() + if err == nil { + defer f.Close() + data, _ = io.ReadAll(f) + } + } + } } - defer f.Close() - data, err := io.ReadAll(f) - if err != nil { + if len(data) == 0 { return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) } diff --git a/internal/middleware/operation_log.go b/internal/middleware/operation_log.go index 98c94c4..130b531 100644 --- a/internal/middleware/operation_log.go +++ b/internal/middleware/operation_log.go @@ -70,8 +70,12 @@ func OperationLog(r *ghttp.Request) { return } - // Capture request body (before Next() reads it) - body := r.GetBodyString() + // Skip body capture for multipart/form-data (file uploads) + // r.GetBodyString() consumes the body stream and breaks multipart parsing + var body string + if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") { + body = r.GetBodyString() + } r.Middleware.Next() diff --git a/migrations/000005_v0_1_5_version.sql b/migrations/000005_v0_1_5_version.sql index f126e8a..9bb2684 100644 --- a/migrations/000005_v0_1_5_version.sql +++ b/migrations/000005_v0_1_5_version.sql @@ -121,7 +121,60 @@ CREATE INDEX idx_pln_tenant_plans_plan ON pln_tenant_plans (plan_id); CREATE INDEX idx_spt_feedbacks_user ON spt_feedbacks (tenant_id, user_id); CREATE INDEX idx_spt_replies_user ON spt_replies (user_id); +-- ============================================================ +-- 四、租户 max_members 和 max_concurrency 改为可 NULL +-- NULL 表示跟随等级配置(tnt_tenant_level_configs),非 NULL 表示自定义覆盖 +-- ============================================================ + +ALTER TABLE tnt_tenants ALTER COLUMN max_members DROP NOT NULL; +ALTER TABLE tnt_tenants ALTER COLUMN max_members SET DEFAULT NULL; +ALTER TABLE tnt_tenants ALTER COLUMN max_concurrency DROP NOT NULL; +ALTER TABLE tnt_tenants ALTER COLUMN max_concurrency SET DEFAULT NULL; + +COMMENT ON COLUMN tnt_tenants.max_members IS '最大成员数上限(NULL表示跟随等级配置)'; +COMMENT ON COLUMN tnt_tenants.max_concurrency IS '租户总并发上限(NULL表示跟随等级配置,0表示不限制)'; + +-- ============================================================ +-- 五、sys_sessions 添加 jti 列(JWT ID,用于会话吊销) +-- ============================================================ + +ALTER TABLE sys_sessions ADD COLUMN jti VARCHAR(36) NOT NULL DEFAULT ''; + +UPDATE sys_sessions SET jti = 'legacy-' || id WHERE jti = ''; + +ALTER TABLE sys_sessions ADD CONSTRAINT uk_sys_sessions_jti UNIQUE (jti); + +COMMENT ON COLUMN sys_sessions.jti IS 'JWT ID (jti),会话唯一标识符(UUID),用于 Redis 吊销缓存'; + -- +goose Down +-- 五、撤销 jti +ALTER TABLE sys_sessions DROP CONSTRAINT IF EXISTS uk_sys_sessions_jti; +ALTER TABLE sys_sessions DROP COLUMN IF EXISTS jti; + +-- 四、恢复 max_members / max_concurrency 为 NOT NULL +UPDATE tnt_tenants t +SET max_members = COALESCE( + (SELECT lc.max_members FROM tnt_tenant_level_configs lc WHERE lc.level = t.level), + 10 +) +WHERE t.max_members IS NULL; + +UPDATE tnt_tenants t +SET max_concurrency = COALESCE( + (SELECT lc.max_concurrency FROM tnt_tenant_level_configs lc WHERE lc.level = t.level), + 0 +) +WHERE t.max_concurrency IS NULL; + +ALTER TABLE tnt_tenants ALTER COLUMN max_members SET DEFAULT 10; +ALTER TABLE tnt_tenants ALTER COLUMN max_members SET NOT NULL; +ALTER TABLE tnt_tenants ALTER COLUMN max_concurrency SET DEFAULT 0; +ALTER TABLE tnt_tenants ALTER COLUMN max_concurrency SET NOT NULL; + +COMMENT ON COLUMN tnt_tenants.max_members IS '最大成员数上限'; +COMMENT ON COLUMN tnt_tenants.max_concurrency IS '租户总并发上限(0表示不限制)'; + +-- 三、删除索引 DROP INDEX IF EXISTS idx_spt_replies_user; DROP INDEX IF EXISTS idx_spt_feedbacks_user; DROP INDEX IF EXISTS idx_pln_tenant_plans_plan; @@ -131,10 +184,12 @@ DROP INDEX IF EXISTS idx_fil_files_user; DROP INDEX IF EXISTS idx_bil_records_channel; DROP INDEX IF EXISTS idx_api_keys_user; +-- 二、删除模型分组 DROP TABLE IF EXISTS mdl_tenant_groups; DROP TABLE IF EXISTS mdl_group_models; DROP TABLE IF EXISTS mdl_model_groups; +-- 一、删除租户等级 ALTER TABLE tnt_tenants DROP COLUMN IF EXISTS level; ALTER TABLE bil_wallets DROP COLUMN IF EXISTS cumulative_recharge; DROP TABLE IF EXISTS tnt_tenant_level_configs; diff --git a/migrations/000006_tenant_nullable_limits.sql b/migrations/000006_tenant_nullable_limits.sql deleted file mode 100644 index c3f432f..0000000 --- a/migrations/000006_tenant_nullable_limits.sql +++ /dev/null @@ -1,38 +0,0 @@ --- +goose Up --- 租户 max_members 和 max_concurrency 改为可 NULL --- NULL 表示跟随等级配置(tnt_tenant_level_configs),非 NULL 表示自定义覆盖 - -ALTER TABLE tnt_tenants ALTER COLUMN max_members DROP NOT NULL; -ALTER TABLE tnt_tenants ALTER COLUMN max_members SET DEFAULT NULL; -ALTER TABLE tnt_tenants ALTER COLUMN max_concurrency DROP NOT NULL; -ALTER TABLE tnt_tenants ALTER COLUMN max_concurrency SET DEFAULT NULL; - -COMMENT ON COLUMN tnt_tenants.max_members IS '最大成员数上限(NULL表示跟随等级配置)'; -COMMENT ON COLUMN tnt_tenants.max_concurrency IS '租户总并发上限(NULL表示跟随等级配置,0表示不限制)'; - --- 将现有记录的值保留(已有具体数值的不做变更,保持向后兼容) - --- +goose Down --- 恢复为 NOT NULL,将 NULL 值设为等级对应配置值,无等级配置的回退到默认值 - -UPDATE tnt_tenants t -SET max_members = COALESCE( - (SELECT lc.max_members FROM tnt_tenant_level_configs lc WHERE lc.level = t.level), - 10 -) -WHERE t.max_members IS NULL; - -UPDATE tnt_tenants t -SET max_concurrency = COALESCE( - (SELECT lc.max_concurrency FROM tnt_tenant_level_configs lc WHERE lc.level = t.level), - 0 -) -WHERE t.max_concurrency IS NULL; - -ALTER TABLE tnt_tenants ALTER COLUMN max_members SET DEFAULT 10; -ALTER TABLE tnt_tenants ALTER COLUMN max_members SET NOT NULL; -ALTER TABLE tnt_tenants ALTER COLUMN max_concurrency SET DEFAULT 0; -ALTER TABLE tnt_tenants ALTER COLUMN max_concurrency SET NOT NULL; - -COMMENT ON COLUMN tnt_tenants.max_members IS '最大成员数上限'; -COMMENT ON COLUMN tnt_tenants.max_concurrency IS '租户总并发上限(0表示不限制)'; diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index fc5e0de..c3153f6 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -433,8 +433,10 @@ function openImportModal() { showImportModal.value = true } -async function handleImportUpload(fileItem: any) { - const file = fileItem.fileItem?.file || fileItem +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 importPreviewing.value = true @@ -667,7 +669,7 @@ function resetImport() { draggable accept=".json" :auto-upload="false" - :limit="1" + :show-file-list="false" @change="handleImportUpload" >