diff --git a/api/admin/admin.go b/api/admin/admin.go index a8e560c..ca83456 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -113,6 +113,9 @@ type IAdminV1 interface { ModelExport(ctx context.Context, req *v1.ModelExportReq) (res *v1.ModelExportRes, err error) PricingFetchOfficial(ctx context.Context, req *v1.PricingFetchOfficialReq) (res *v1.PricingFetchOfficialRes, err error) ModelFetchOfficialInfo(ctx context.Context, req *v1.ModelFetchOfficialInfoReq) (res *v1.ModelFetchOfficialInfoRes, err error) + ModelExportJson(ctx context.Context, req *v1.ModelExportJsonReq) (res *v1.ModelExportJsonRes, err error) + ModelImportPreview(ctx context.Context, req *v1.ModelImportPreviewReq) (res *v1.ModelImportPreviewRes, err error) + ModelImport(ctx context.Context, req *v1.ModelImportReq) (res *v1.ModelImportRes, err error) ModelGroupList(ctx context.Context, req *v1.ModelGroupListReq) (res *v1.ModelGroupListRes, err error) ModelGroupCreate(ctx context.Context, req *v1.ModelGroupCreateReq) (res *v1.ModelGroupCreateRes, err error) ModelGroupUpdate(ctx context.Context, req *v1.ModelGroupUpdateReq) (res *v1.ModelGroupUpdateRes, err error) @@ -226,6 +229,7 @@ type IAdminV1 interface { TenantModelBatchAssign(ctx context.Context, req *v1.TenantModelBatchAssignReq) (res *v1.TenantModelBatchAssignRes, err error) TenantModelUpdate(ctx context.Context, req *v1.TenantModelUpdateReq) (res *v1.TenantModelUpdateRes, err error) TenantModelDelete(ctx context.Context, req *v1.TenantModelDeleteReq) (res *v1.TenantModelDeleteRes, err error) + TenantAvailableModelsPreview(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (res *v1.TenantAvailableModelsPreviewRes, err error) TicketList(ctx context.Context, req *v1.TicketListReq) (res *v1.TicketListRes, err error) TicketGet(ctx context.Context, req *v1.TicketGetReq) (res *v1.TicketGetRes, err error) TicketAssign(ctx context.Context, req *v1.TicketAssignReq) (res *v1.TicketAssignRes, err error) diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index 2c391c7..fb7f024 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -224,3 +224,69 @@ type ModelFetchOfficialInfoRes struct { MaxOutputTokens int `json:"max_output_tokens,omitempty"` Capabilities map[string]bool `json:"capabilities,omitempty"` } + +// 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列表"` +} + +// ModelExportJsonRes 导出模型配置JSON响应(直接写入响应流) +type ModelExportJsonRes struct{} + +// ModelImportPreviewReq 导入模型预览请求 +type ModelImportPreviewReq struct { + g.Meta `path:"/models/import-preview" method:"post" mime:"json" tags:"管理后台-模型" summary:"导入模型预览"` +} + +// ModelImportPreviewRes 导入模型预览响应 +type ModelImportPreviewRes struct { + Models []ModelImportPreviewItem `json:"models"` +} + +// ModelImportPreviewItem 导入预览项(模型信息 + 冲突标记) +type ModelImportPreviewItem struct { + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Status string `json:"status"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Capabilities map[string]bool `json:"capabilities"` + Description string `json:"description"` + Tags []string `json:"tags"` + SunsetDate string `json:"sunset_date"` + ReplacementModel string `json:"replacement_model"` + Pricing []PricingItem `json:"pricing"` + Conflict string `json:"conflict"` // "" 或 "exists" +} + +// ModelImportReq 确认导入模型请求 +type ModelImportReq struct { + g.Meta `path:"/models/import" method:"post" mime:"json" tags:"管理后台-模型" summary:"确认导入模型"` + Models []ModelImportItem `json:"models" v:"required|min:1#无数据|至少导入一个模型"` +} + +// ModelImportItem 导入模型项 +type ModelImportItem struct { + ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"` + ModelName string `json:"model_name" dc:"模型显示名称"` + Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"` + Status string `json:"status" dc:"状态"` + MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"` + MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"` + Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"` + Description string `json:"description" dc:"模型描述"` + Tags []string `json:"tags" dc:"标签列表"` + SunsetDate string `json:"sunset_date" dc:"下线日期"` + ReplacementModel string `json:"replacement_model" dc:"推荐替代模型名"` + Pricing []PricingItem `json:"pricing" dc:"定价列表"` + ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"` +} + +// ModelImportRes 导入结果响应 +type ModelImportRes struct { + Imported int `json:"imported"` + Skipped int `json:"skipped"` + Errors []string `json:"errors,omitempty"` +} diff --git a/api/admin/v1/tenant_model.go b/api/admin/v1/tenant_model.go index 9442ded..3fbf24e 100644 --- a/api/admin/v1/tenant_model.go +++ b/api/admin/v1/tenant_model.go @@ -97,3 +97,24 @@ type TenantModelDeleteReq struct { } type TenantModelDeleteRes struct{} + +// TenantAvailableModelsPreviewReq 预览租户实际可用的所有模型(显式分配 + 分组来源,去重) +type TenantAvailableModelsPreviewReq struct { + g.Meta `path:"/tenants/{tenant_id}/available-models" method:"get" mime:"json" tags:"管理后台-租户模型" summary:"预览租户可用模型"` + TenantID int64 `json:"tenant_id" in:"path" v:"required" dc:"租户ID"` +} + +type TenantAvailableModelsPreviewRes struct { + List []TenantAvailableModelPreview `json:"list"` +} + +// TenantAvailableModelPreview 租户可用模型预览项 +type TenantAvailableModelPreview struct { + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Description string `json:"description"` + Source string `json:"source"` // "explicit" 或 "group" +} diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 112bd3c..1cce277 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -165,6 +165,10 @@ const ( CodeModelGroupCodeExists = 10085 CodeModelGroupHasTenants = 10086 + // Model import errors + CodeModelImportInvalidFile = 10087 + CodeModelImportBadVersion = 10088 + // Demo mode CodeDemoModeRestricted = 10403 @@ -282,6 +286,9 @@ const ( MsgModelGroupNotFound = "模型分组不存在" MsgModelGroupCodeExists = "模型分组标识已存在" MsgModelGroupHasTenants = "该分组下存在关联租户,无法删除" + // Model import errors + MsgModelImportInvalidFile = "导入文件格式无效" + MsgModelImportBadVersion = "导入文件版本不兼容" ) // Demo mode messages diff --git a/internal/controller/admin/admin_v1_model_export_json.go b/internal/controller/admin/admin_v1_model_export_json.go new file mode 100644 index 0000000..6235362 --- /dev/null +++ b/internal/controller/admin/admin_v1_model_export_json.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) ModelExportJson(ctx context.Context, req *v1.ModelExportJsonReq) (res *v1.ModelExportJsonRes, err error) { + return service.Admin().ExportModelsJson(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_model_import.go b/internal/controller/admin/admin_v1_model_import.go new file mode 100644 index 0000000..ff346e0 --- /dev/null +++ b/internal/controller/admin/admin_v1_model_import.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) ModelImport(ctx context.Context, req *v1.ModelImportReq) (res *v1.ModelImportRes, err error) { + return service.Admin().ImportModels(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_model_import_preview.go b/internal/controller/admin/admin_v1_model_import_preview.go new file mode 100644 index 0000000..2214d89 --- /dev/null +++ b/internal/controller/admin/admin_v1_model_import_preview.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) ModelImportPreview(ctx context.Context, req *v1.ModelImportPreviewReq) (res *v1.ModelImportPreviewRes, err error) { + return service.Admin().ImportModelsPreview(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_tenant_available_models_preview.go b/internal/controller/admin/admin_v1_tenant_available_models_preview.go new file mode 100644 index 0000000..b711e71 --- /dev/null +++ b/internal/controller/admin/admin_v1_tenant_available_models_preview.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) TenantAvailableModelsPreview(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (res *v1.TenantAvailableModelsPreviewRes, err error) { + return service.Admin().ListTenantAvailableModels(ctx, req) +} diff --git a/internal/handler/relay/relay.go b/internal/handler/relay/relay.go index 8b052d2..f77c965 100644 --- a/internal/handler/relay/relay.go +++ b/internal/handler/relay/relay.go @@ -317,7 +317,7 @@ func HandleGeminiGenerateContent(r *ghttp.Request) { func HandleModels(r *ghttp.Request) { rc := buildRelayContext(r) // 获取 TenantID - resp, err := handler.HandleModels(r.Context(), rc.TenantID, rc.ApiKeyID, dataProvider) + resp, err := handler.HandleModels(r.Context(), rc.TenantID, rc.ApiKeyID, rc.UserID, dataProvider) if err != nil { r.Response.WriteJsonExit(g.Map{ "error": g.Map{"type": "internal_error", "message": err.Error()}, @@ -371,7 +371,7 @@ func HandleModelDetail(r *ghttp.Request) { func HandleGeminiModels(r *ghttp.Request) { rc := buildRelayContext(r) - resp, err := handler.HandleGeminiModels(r.Context(), rc.TenantID, rc.ApiKeyID, dataProvider) + resp, err := handler.HandleGeminiModels(r.Context(), rc.TenantID, rc.ApiKeyID, rc.UserID, dataProvider) if err != nil { handler.WriteGeminiRelayError(r.Response.Writer, err) return diff --git a/internal/handler/relay/task.go b/internal/handler/relay/task.go index c585a12..a5c7425 100644 --- a/internal/handler/relay/task.go +++ b/internal/handler/relay/task.go @@ -57,6 +57,10 @@ func HandleTaskSubmit(r *ghttp.Request) { statusCode = 403 errType = "permission_denied" errMsg = "当前租户未启用该模型,请联系管理员" + } else if err == relay_common.ErrMemberModelNotAllowed { + statusCode = 403 + errType = "permission_denied" + errMsg = "当前成员无权使用该模型" } else if err == relay_common.ErrChannelUnavailable { statusCode = 503 errType = "server_error" @@ -172,6 +176,13 @@ func selectTaskChannel(r *ghttp.Request, body []byte) (*relay_common.ChannelMeta return nil, err } + // 检查成员模型范围 + if allowed, err := relayDataProvider.CheckMemberModelAccess(ctx, tenantID, userID, req.Model); err != nil { + return nil, err + } else if !allowed { + return nil, relay_common.ErrMemberModelNotAllowed + } + return &relay_common.ChannelMeta{ ChannelID: selection.ChannelID, ChannelType: selection.ChannelType, diff --git a/internal/logic/admin/audit.go b/internal/logic/admin/audit.go index 724db53..05f6ef3 100644 --- a/internal/logic/admin/audit.go +++ b/internal/logic/admin/audit.go @@ -87,44 +87,35 @@ func (s *sAdmin) UpdateAuditConfig(ctx context.Context, req *v1.AuditConfigUpdat return nil, nil } -// allowedQueryTables 白名单:仅允许 queryPage 查询这些表 -var allowedQueryTables = map[string]bool{ - "aud_operation_logs": true, - "aud_sensitive_access_logs": true, - "aud_request_logs": true, - "aud_content_filter_logs": true, -} - -// queryPage 使用原生 SQL 分页查询,绕过 GoFrame ScanAndCount 对 map[string]any 的 bug。 -// table 和 fields 仅接受白名单中的表名和预定义字段,防止 SQL 注入。 +// queryPage 使用 GoFrame ORM 分页查询,返回 []map[string]any。 +// 分开调用 Count 和 All 避免 ScanAndCount 对 map[string]any 的 bug。 func queryPage(ctx context.Context, table, fields, where, orderBy string, page, pageSize int, args ...any) ([]map[string]any, int, error) { - if !allowedQueryTables[table] { - return nil, 0, fmt.Errorf("不允许查询的表: %s", table) + m := g.DB().Ctx(ctx).Model(table).Safe() + if where != "" { + m = m.Where(where, args...) } - countSQL := fmt.Sprintf("SELECT COUNT(*) AS total FROM %s", table) - if where != "" { - countSQL += " WHERE " + where - } - countResult, err := g.DB().Ctx(ctx).Query(ctx, countSQL, args...) + total, err := m.Count() if err != nil { return nil, 0, err } - total := 0 - if len(countResult) > 0 { - total = countResult[0]["total"].Int() + if total == 0 { + return []map[string]any{}, 0, nil } - dataSQL := fmt.Sprintf("SELECT %s FROM %s", fields, table) + // 重新构建查询(Count 会消耗 Model 状态) + q := g.DB().Ctx(ctx).Model(table).Safe() + if fields != "" { + q = q.Fields(fields) + } if where != "" { - dataSQL += " WHERE " + where + q = q.Where(where, args...) } if orderBy != "" { - dataSQL += " ORDER BY " + orderBy + q = q.Order(orderBy) } - dataSQL += fmt.Sprintf(" LIMIT %d OFFSET %d", pageSize, (page-1)*pageSize) - result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, args...) + result, err := q.Page(page, pageSize).All() if err != nil { return nil, 0, err } diff --git a/internal/logic/admin/auth.go b/internal/logic/admin/auth.go index 4a5f729..d183efb 100644 --- a/internal/logic/admin/auth.go +++ b/internal/logic/admin/auth.go @@ -24,6 +24,8 @@ import ( // Login handles admin login. func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLoginRes, error) { + req.Username = strings.TrimSpace(req.Username) + // Check captcha if required if err := common.CheckCaptchaRequired(ctx, "admin_login", req.CaptchaKey, req.CaptchaX); err != nil { return nil, err @@ -37,7 +39,7 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog // Find admin user by username var user *entity.SysAdminUsers err := dao.SysAdminUsers.Ctx(ctx). - Where("username", strings.TrimSpace(req.Username)). + Where("username", req.Username). Scan(&user) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index 32e3166..aa9ae2a 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -3,17 +3,16 @@ package admin import ( "context" "fmt" - "github.com/qianfree/team-api/internal/dao" - "github.com/qianfree/team-api/internal/logic/common" - do "github.com/qianfree/team-api/internal/model/do" "strings" "time" - "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/common" + do "github.com/qianfree/team-api/internal/model/do" "github.com/qianfree/team-api/internal/utility/export" ) @@ -402,7 +401,7 @@ func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq } affected, _ := result.RowsAffected() if affected == 0 { - return nil, gerror.New("钱包不存在或余额不足") + return nil, common.NewBadRequestError("钱包不存在或余额不足") } // 查询更新后的余额,用于记录流水 @@ -447,7 +446,7 @@ func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) Fields("id, balance, frozen_balance, warning_threshold"). Scan(&w) if err != nil || w == nil { - return nil, gerror.Newf("wallet not found for tenant %d", req.TenantID) + return nil, common.NewNotFoundError("钱包") } return &v1.AdminWalletInfoRes{ @@ -470,7 +469,7 @@ func (s *sAdmin) GetWalletTransactions(ctx context.Context, req *v1.AdminWalletT Fields("id"). Scan(&w) if err != nil || w == nil { - return nil, gerror.Newf("wallet not found for tenant %d", req.TenantID) + return nil, common.NewNotFoundError("钱包") } query := dao.BilTransactions.Ctx(ctx).Where("wallet_id", w.ID) @@ -506,7 +505,7 @@ func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSet Fields("id"). Scan(&w) if err != nil || w == nil { - return nil, gerror.Newf("wallet not found for tenant %d", req.TenantID) + return nil, common.NewNotFoundError("钱包") } _, err = dao.BilWallets.Ctx(ctx). diff --git a/internal/logic/admin/error_log.go b/internal/logic/admin/error_log.go index 3046ace..b9783a7 100644 --- a/internal/logic/admin/error_log.go +++ b/internal/logic/admin/error_log.go @@ -4,7 +4,6 @@ import ( "context" "strings" - "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" @@ -79,7 +78,7 @@ func (s *sAdmin) ErrorLogDetail(ctx context.Context, req *v1.ErrorLogDetailReq) return nil, err } if len(result) == 0 { - return nil, gerror.New("错误日志不存在") + return nil, common.NewNotFoundError("错误日志") } row := result[0] data := make(map[string]any, len(row)) diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go new file mode 100644 index 0000000..a5317dc --- /dev/null +++ b/internal/logic/admin/model_import_export.go @@ -0,0 +1,325 @@ +package admin + +import ( + "context" + "encoding/json" + "io" + "net/url" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "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/gtime" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/consts" + "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/relay" + do "github.com/qianfree/team-api/internal/model/do" +) + +// ExportModelsJson 导出模型配置为 JSON 文件(含定价数据,用于跨环境迁移) +func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonReq) (*v1.ModelExportJsonRes, error) { + var models []struct { + ID int64 `orm:"id" json:"id"` + ModelId string `orm:"model_id" json:"model_id"` + ModelName string `orm:"model_name" json:"model_name"` + Category string `orm:"category" json:"category"` + Status string `orm:"status" json:"status"` + MaxContextTokens int `orm:"max_context_tokens" json:"max_context_tokens"` + MaxOutputTokens int `orm:"max_output_tokens" json:"max_output_tokens"` + Description string `orm:"description" json:"description"` + Tags string `orm:"tags" json:"tags"` + Capabilities string `orm:"capabilities" json:"capabilities"` + SunsetDate *gtime.Time `orm:"sunset_date" json:"sunset_date"` + ReplacementModel string `orm:"replacement_model" json:"replacement_model"` + } + + err := dao.MdlModels.Ctx(ctx). + Where("model_id IN(?)", req.ModelIds). + Scan(&models) + if err != nil { + return nil, err + } + if len(models) == 0 { + return nil, gerror.New("未找到匹配的模型") + } + + type exportPricing struct { + BillingMode string `json:"billing_mode"` + MinTokens int64 `json:"min_tokens"` + MaxTokens *int64 `json:"max_tokens"` + InputPrice float64 `json:"input_price"` + OutputPrice float64 `json:"output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + CacheReadPrice float64 `json:"cache_read_price"` + CacheCreationPrice float64 `json:"cache_creation_price"` + } + + type exportModel struct { + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Status string `json:"status"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Description string `json:"description"` + Tags []string `json:"tags"` + Capabilities map[string]bool `json:"capabilities"` + SunsetDate string `json:"sunset_date,omitempty"` + ReplacementModel string `json:"replacement_model,omitempty"` + Pricing []exportPricing `json:"pricing"` + } + + result := make([]exportModel, 0, len(models)) + for _, m := range models { + var pricingRows []struct { + BillingMode string `orm:"billing_mode" json:"billing_mode"` + MinTokens int64 `orm:"min_tokens" json:"min_tokens"` + MaxTokens *int64 `orm:"max_tokens" json:"max_tokens"` + InputPrice float64 `orm:"input_price" json:"input_price"` + OutputPrice float64 `orm:"output_price" json:"output_price"` + PerRequestPrice *float64 `orm:"per_request_price" json:"per_request_price"` + CacheReadPrice float64 `orm:"cache_read_price" json:"cache_read_price"` + CacheCreationPrice float64 `orm:"cache_creation_price" json:"cache_creation_price"` + } + err := dao.MdlPricing.Ctx(ctx).Where("model_id", m.ID).Scan(&pricingRows) + if err != nil { + return nil, err + } + + pricing := make([]exportPricing, 0, len(pricingRows)) + for _, p := range pricingRows { + pricing = append(pricing, exportPricing{ + BillingMode: p.BillingMode, + MinTokens: p.MinTokens, + MaxTokens: p.MaxTokens, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + PerRequestPrice: p.PerRequestPrice, + CacheReadPrice: p.CacheReadPrice, + CacheCreationPrice: p.CacheCreationPrice, + }) + } + + em := exportModel{ + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + Status: m.Status, + MaxContextTokens: m.MaxContextTokens, + MaxOutputTokens: m.MaxOutputTokens, + Description: m.Description, + Capabilities: parseCapabilities(m.Capabilities), + ReplacementModel: m.ReplacementModel, + Pricing: pricing, + } + if m.SunsetDate != nil { + em.SunsetDate = m.SunsetDate.Format("Y-m-d") + } + if m.Tags != "" { + em.Tags = parsePgArray(m.Tags) + } + result = append(result, em) + } + + exportData := g.Map{ + "version": "1.0", + "exported_at": gtime.Now().Format("Y-m-d\\TH:i:sP"), + "models": result, + } + + jsonBytes, err := json.MarshalIndent(exportData, "", " ") + if err != nil { + return nil, err + } + + filename := "models_" + gtime.Now().Format("Ymd_His") + ".json" + r := g.RequestFromCtx(ctx) + r.Response.Header().Set("Content-Type", "application/json; charset=utf-8") + r.Response.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.QueryEscape(filename)) + r.Response.Write(jsonBytes) + + return nil, nil +} + +// ImportModelsPreview 导入模型预览(解析上传文件,检测冲突) +func (s *sAdmin) ImportModelsPreview(ctx context.Context, req *v1.ModelImportPreviewReq) (*v1.ModelImportPreviewRes, error) { + file := g.RequestFromCtx(ctx).GetUploadFile("file") + if file == 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) + } + defer f.Close() + + data, err := io.ReadAll(f) + if err != nil { + return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) + } + + var exportData struct { + Version string `json:"version"` + Models []v1.ModelImportPreviewItem `json:"models"` + } + if err := json.Unmarshal(data, &exportData); err != nil { + return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile) + } + + if exportData.Version != "1.0" { + return nil, gerror.NewCode(gcode.New(consts.CodeModelImportBadVersion, consts.MsgModelImportBadVersion, nil), consts.MsgModelImportBadVersion) + } + + for i := range exportData.Models { + count, _ := dao.MdlModels.Ctx(ctx).Where("model_id", exportData.Models[i].ModelId).Count() + if count > 0 { + exportData.Models[i].Conflict = "exists" + } + } + + return &v1.ModelImportPreviewRes{Models: exportData.Models}, nil +} + +// ImportModels 确认导入模型(事务内执行) +func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.ModelImportRes, error) { + if len(req.Models) > 200 { + return nil, gerror.New("单次导入不能超过 200 个模型") + } + + res := &v1.ModelImportRes{} + err := dao.MdlModels.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + for _, item := range req.Models { + var existing *struct { + ID int64 `orm:"id"` + } + err := tx.Model("mdl_models").Ctx(ctx).Where("model_id", item.ModelId).Scan(&existing) + if err != nil { + return err + } + + if existing != nil { + if item.ConflictAction == "skip" { + res.Skipped++ + continue + } + // overwrite: 更新模型 + 替换定价 + updateData := g.Map{ + "model_name": item.ModelName, + "category": item.Category, + "max_context_tokens": item.MaxContextTokens, + "max_output_tokens": item.MaxOutputTokens, + "description": item.Description, + } + if item.Tags != nil { + updateData["tags"] = item.Tags + } + if item.Capabilities != nil { + capJson, _ := json.Marshal(item.Capabilities) + updateData["capabilities"] = string(capJson) + } + if item.SunsetDate != "" { + updateData["sunset_date"] = item.SunsetDate + } + if item.ReplacementModel != "" { + updateData["replacement_model"] = item.ReplacementModel + } + if item.Status != "" { + updateData["status"] = item.Status + } + + _, err = tx.Model("mdl_models").Ctx(ctx).Where("id", existing.ID).Data(updateData).Update() + if err != nil { + return err + } + + _, err = tx.Model("mdl_pricing").Ctx(ctx).Where("model_id", existing.ID).Delete() + if err != nil { + return err + } + for _, p := range item.Pricing { + _, err = tx.Model("mdl_pricing").Ctx(ctx).Insert(do.MdlPricing{ + ModelId: existing.ID, + BillingMode: p.BillingMode, + MinTokens: p.MinTokens, + MaxTokens: p.MaxTokens, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + PerRequestPrice: p.PerRequestPrice, + CacheReadPrice: p.CacheReadPrice, + CacheCreationPrice: p.CacheCreationPrice, + }) + if err != nil { + return err + } + } + + relay.NewDataProvider().InvalidateModelCache(item.ModelId) + res.Imported++ + } else { + // 新建模型 + insertData := g.Map{ + "model_id": item.ModelId, + "model_name": item.ModelName, + "category": item.Category, + "status": "active", + "max_context_tokens": item.MaxContextTokens, + "max_output_tokens": item.MaxOutputTokens, + "description": item.Description, + } + if item.Tags != nil { + insertData["tags"] = item.Tags + } else { + insertData["tags"] = []string{} + } + if item.Capabilities != nil { + capJson, _ := json.Marshal(item.Capabilities) + insertData["capabilities"] = string(capJson) + } + + id, err := tx.Model("mdl_models").Ctx(ctx).InsertAndGetId(insertData) + if err != nil { + return err + } + + for _, p := range item.Pricing { + _, err = tx.Model("mdl_pricing").Ctx(ctx).Insert(do.MdlPricing{ + ModelId: id, + BillingMode: p.BillingMode, + MinTokens: p.MinTokens, + MaxTokens: p.MaxTokens, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + PerRequestPrice: p.PerRequestPrice, + CacheReadPrice: p.CacheReadPrice, + CacheCreationPrice: p.CacheCreationPrice, + }) + if err != nil { + return err + } + } + + res.Imported++ + } + } + return nil + }) + + return res, err +} + +// parsePgArray 解析 PostgreSQL 数组格式的字符串,如 {tag1,tag2} +func parsePgArray(raw string) []string { + if raw == "" || raw == "{}" || raw == "NULL" { + return nil + } + s := strings.Trim(raw, "{}") + if s == "" { + return nil + } + return strings.Split(s, ",") +} diff --git a/internal/logic/admin/task_management.go b/internal/logic/admin/task_management.go index 35e61d4..1a06bf7 100644 --- a/internal/logic/admin/task_management.go +++ b/internal/logic/admin/task_management.go @@ -165,7 +165,7 @@ func (s *sAdmin) TaskCancel(ctx context.Context, req *v1.TaskCancelReq) (*v1.Tas } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { - return nil, gerror.New("任务无法取消(可能已完成或已失败)") + return nil, common.NewBadRequestError("任务无法取消(可能已完成或已失败)") } return &v1.TaskCancelRes{}, nil } diff --git a/internal/logic/admin/tenant_model.go b/internal/logic/admin/tenant_model.go index d8422c5..df3671f 100644 --- a/internal/logic/admin/tenant_model.go +++ b/internal/logic/admin/tenant_model.go @@ -9,6 +9,7 @@ import ( "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" + tenantLogic "github.com/qianfree/team-api/internal/logic/common" do "github.com/qianfree/team-api/internal/model/do" ) @@ -228,3 +229,26 @@ func (s *sAdmin) DeleteTenantModel(ctx context.Context, req *v1.TenantModelDelet return nil, nil } + +// ListTenantAvailableModels 预览租户实际可用的所有模型(显式分配 + 分组来源,去重) +func (s *sAdmin) ListTenantAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (*v1.TenantAvailableModelsPreviewRes, error) { + models, err := tenantLogic.GetTenantAvailableModels(ctx, req.TenantID, "", "") + if err != nil { + return nil, err + } + + list := make([]v1.TenantAvailableModelPreview, 0, len(models)) + for _, m := range models { + list = append(list, v1.TenantAvailableModelPreview{ + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + MaxContextTokens: m.MaxContextTokens, + MaxOutputTokens: m.MaxOutputTokens, + Description: m.Description, + Source: m.Source, + }) + } + + return &v1.TenantAvailableModelsPreviewRes{List: list}, nil +} diff --git a/internal/logic/billing/level.go b/internal/logic/billing/level.go index 2e49256..718473b 100644 --- a/internal/logic/billing/level.go +++ b/internal/logic/billing/level.go @@ -106,6 +106,31 @@ func GetTenantLevelConfig(ctx context.Context, level int) (*entity.TntTenantLeve return &config, nil } +// GetLevelPriceMultiplier 获取租户级别的价格乘数 +// 查询失败时静默返回 1.0,不阻断计费 +func GetLevelPriceMultiplier(ctx context.Context, tenantID int64) float64 { + var tenant entity.TntTenants + if err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Scan(&tenant); err != nil || tenant.Id == 0 { + g.Log().Warningf(ctx, "[Billing] failed to read tenant level for multiplier: tenantID=%d, err=%v", tenantID, err) + return 1.0 + } + + if tenant.Level <= 1 { + return 1.0 + } + + var config entity.TntTenantLevelConfigs + if err := dao.TntTenantLevelConfigs.Ctx(ctx).Where("level", tenant.Level).Scan(&config); err != nil || config.Id == 0 { + g.Log().Warningf(ctx, "[Billing] failed to read level config for multiplier: level=%d, err=%v", tenant.Level, err) + return 1.0 + } + + if config.PriceMultiplier > 0 && config.PriceMultiplier != 1.0 { + return config.PriceMultiplier + } + return 1.0 +} + // CheckAndUpgradeLevel 检查并升级租户等级(充值后调用) // 升级策略:仅升不降 — max_members/max_concurrency 仅在新值 > 当前值时才更新 func CheckAndUpgradeLevel(ctx context.Context, tenantID int64) error { @@ -182,6 +207,9 @@ func CheckAndUpgradeLevel(ctx context.Context, tenantID int64) error { // 7. 清除并发限制缓存 _, _ = g.Redis().Do(ctx, "DEL", fmt.Sprintf("tenant:conc_limit:%d", tenantID)) + // 8. 清除该租户的价格缓存(级别变化可能影响折扣) + modelPriceCache.DeleteByPattern(ctx, fmt.Sprintf("%d:*", tenantID)) + return nil } diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index 13af17a..49bb8f9 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -212,6 +212,15 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric } } + // 3.5 级别折扣 fallback:当租户×模型维度未设置倍率时,使用租户级别的 price_multiplier + if tenantMultiplier == 1.0 { + levelMultiplier := GetLevelPriceMultiplier(ctx, tenantID) + if levelMultiplier > 0 && levelMultiplier < 1.0 { + tenantMultiplier = levelMultiplier + discountRatio = levelMultiplier + } + } + // 4. 套餐价(待实现) // 前置条件:需新增 pln_plan_model_pricing 表存储每个套餐的每个模型定价, // 或为 pln_plans 增加 billing_discount_ratio 全局折扣字段。 diff --git a/internal/logic/common/tenant_model.go b/internal/logic/common/tenant_model.go new file mode 100644 index 0000000..239453e --- /dev/null +++ b/internal/logic/common/tenant_model.go @@ -0,0 +1,138 @@ +package common + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + "github.com/qianfree/team-api/internal/dao" +) + +// AvailableModelItem 租户可用模型基本信息(公共结构体,不含价格) +type AvailableModelItem struct { + ModelDBID int64 `json:"model_db_id"` + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Description string `json:"description"` + Tags string `json:"tags"` + Capabilities string `json:"capabilities"` + Source string `json:"source"` // "explicit" 或 "group" +} + +// GetTenantAvailableModels 计算租户实际可用的所有模型(显式分配 + 分组来源,去重) +// 返回模型基本信息列表,不含价格。可被管理后台和租户控制台共用。 +func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, search string) ([]AvailableModelItem, error) { + // 阶段1:获取显式分配的模型 + var explicitModels []struct { + ModelDBID int64 `json:"model_db_id"` + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Description string `json:"description"` + Tags string `json:"tags"` + Capabilities string `json:"capabilities"` + } + + explicitQuery := dao.MdlTenantModels.Ctx(ctx). + LeftJoin("mdl_models m ON mdl_tenant_models.model_id = m.id"). + Where("mdl_tenant_models.tenant_id", tenantID). + Where("mdl_tenant_models.enabled", true). + Where("m.status", "active") + + if category != "" { + explicitQuery = explicitQuery.Where("m.category", category) + } + if search != "" { + explicitQuery = explicitQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+search+"%", "%"+search+"%") + } + + err := explicitQuery. + Fields("mdl_tenant_models.model_id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities"). + OrderAsc("m.category"). + OrderAsc("m.model_id"). + Scan(&explicitModels) + if err != nil { + return nil, err + } + + result := make([]AvailableModelItem, 0, len(explicitModels)) + explicitModelIDs := make(map[int64]bool, len(explicitModels)) + for _, r := range explicitModels { + explicitModelIDs[r.ModelDBID] = true + result = append(result, AvailableModelItem{ + ModelDBID: r.ModelDBID, + ModelId: r.ModelId, + ModelName: r.ModelName, + Category: r.Category, + MaxContextTokens: r.MaxContextTokens, + MaxOutputTokens: r.MaxOutputTokens, + Description: r.Description, + Tags: r.Tags, + Capabilities: r.Capabilities, + Source: "explicit", + }) + } + + // 阶段2:获取分组来源的模型(排除已在显式分配中的) + var groupModels []struct { + ModelDBID int64 `json:"model_db_id"` + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Description string `json:"description"` + Tags string `json:"tags"` + Capabilities string `json:"capabilities"` + } + + groupQuery := g.DB().Model("mdl_group_models gm").Ctx(ctx). + InnerJoin("mdl_model_groups g ON gm.group_id = g.id"). + InnerJoin("mdl_models m ON gm.model_id = m.id"). + InnerJoin("mdl_tenant_groups tg ON tg.group_id = g.id"). + Where("tg.tenant_id", tenantID). + Where("g.status", "active"). + Where("m.status", "active") + + if len(explicitModelIDs) > 0 { + ids := make([]int64, 0, len(explicitModelIDs)) + for id := range explicitModelIDs { + ids = append(ids, id) + } + groupQuery = groupQuery.WhereNotIn("m.id", ids) + } + + if category != "" { + groupQuery = groupQuery.Where("m.category", category) + } + if search != "" { + groupQuery = groupQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+search+"%", "%"+search+"%") + } + + _ = groupQuery. + Fields("DISTINCT m.id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities"). + OrderAsc("m.category"). + OrderAsc("m.model_id"). + Scan(&groupModels) + + for _, gm := range groupModels { + result = append(result, AvailableModelItem{ + ModelDBID: gm.ModelDBID, + ModelId: gm.ModelId, + ModelName: gm.ModelName, + Category: gm.Category, + MaxContextTokens: gm.MaxContextTokens, + MaxOutputTokens: gm.MaxOutputTokens, + Description: gm.Description, + Tags: gm.Tags, + Capabilities: gm.Capabilities, + Source: "group", + }) + } + + return result, nil +} diff --git a/internal/logic/common/validation.go b/internal/logic/common/validation.go index ff26459..3cc127e 100644 --- a/internal/logic/common/validation.go +++ b/internal/logic/common/validation.go @@ -4,7 +4,10 @@ import ( "time" "unicode" + "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" + + "github.com/qianfree/team-api/internal/consts" ) // ValidatePassword 校验密码强度:至少8位,包含大写、小写、数字 @@ -52,7 +55,7 @@ func ValidateDateParam(date string, fieldName string) error { } _, err := time.Parse("2006-01-02", date) if err != nil { - return gerror.Newf("%s格式无效,应为 YYYY-MM-DD", fieldName) + return gerror.NewCode(gcode.New(consts.CodeBadRequest, "", nil), "%s格式无效,应为 YYYY-MM-DD", fieldName) } return nil } diff --git a/internal/logic/open/open_api.go b/internal/logic/open/open_api.go index a6525c6..e562032 100644 --- a/internal/logic/open/open_api.go +++ b/internal/logic/open/open_api.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" @@ -20,6 +21,9 @@ import ( type sOpen struct{} +// openMemberModelCache 成员模型范围缓存(与 relay provider 中的缓存 key 相同) +var openMemberModelCache = common.NewCache("member_model", 60*time.Second) + func New() *sOpen { return &sOpen{} } @@ -283,20 +287,36 @@ func (s *sOpen) OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberMo } // Insert new scopes - for _, modelID := range req.ModelIDs { - if modelID == 0 { - continue + if len(req.ModelIDs) > 0 { + for _, modelID := range req.ModelIDs { + if modelID == 0 { + continue + } + _, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{ + TenantId: tenantID, + UserId: req.Id, + ModelId: modelID, + }).Insert() + if err != nil { + return nil, err + } } + } else { + // 空列表表示禁止所有模型,插入哨兵记录(model_id = -1) _, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{ TenantId: tenantID, UserId: req.Id, - ModelId: modelID, + ModelId: -1, }).Insert() if err != nil { return nil, err } } + // 清除该成员的模型范围缓存,使下次请求时重新从数据库读取 + cacheKey := fmt.Sprintf("%d:%d", tenantID, req.Id) + openMemberModelCache.Delete(ctx, cacheKey) + return nil, nil } diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 4a69af1..53bd71b 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -716,7 +716,7 @@ func (p *DataProviderImpl) ResetConsecutiveFailure(ctx context.Context, channelI // GetAvailableModels 实现 DataProvider.GetAvailableModels // 如果 tenantID > 0,返回该租户有权使用的模型列表 // 如果 apiKeyID > 0,进一步按 API Key 的模型范围过滤 -func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID int64, apiKeyID int64) ([]common.ModelInfo, error) { +func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, apiKeyID, userID int64) ([]common.ModelInfo, error) { type modelRow struct { ModelId string `json:"model_id"` ModelName string `json:"model_name"` @@ -821,6 +821,31 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID int6 } } + // 按成员模型范围过滤 + if userID > 0 { + allowedNames, err := GetMemberAllowedModelNames(ctx, tenantID, userID) + if err != nil { + return nil, err + } + if allowedNames != nil { + if len(allowedNames) == 0 { + models = nil + } else { + allowed := make(map[string]bool, len(allowedNames)) + for _, n := range allowedNames { + allowed[n] = true + } + filtered := make([]modelRow, 0, len(models)) + for _, m := range models { + if allowed[m.ModelId] { + filtered = append(filtered, m) + } + } + models = filtered + } + } + } + result := make([]common.ModelInfo, 0, len(models)) for _, m := range models { result = append(result, common.ModelInfo{ @@ -1100,8 +1125,57 @@ func (p *DataProviderImpl) InvalidateModelCache(modelName string) { // memberModelCache 成员模型范围缓存(TTL 60s) var memberModelCache = lcommon.NewCache("member_model", 60*time.Second) +// GetMemberAllowedModelNames 获取成员允许使用的 model_id 列表。 +// 返回 nil 表示不限制(向后兼容),返回空切片表示禁止所有模型(哨兵),返回非空切片表示允许的模型集合。 +func GetMemberAllowedModelNames(ctx context.Context, tenantID, userID int64) ([]string, error) { + if userID <= 0 || tenantID <= 0 { + return nil, nil + } + + cacheKey := fmt.Sprintf("%d:%d", tenantID, userID) + + var cachedNames []string + if memberModelCache.GetJSON(ctx, cacheKey, &cachedNames) { + return cachedNames, nil + } + + type scopeRow struct { + ModelID int64 `json:"model_id"` + ModelName string `json:"model_name"` + } + var rows []scopeRow + err := g.DB().Model("tnt_member_model_scopes ms").Ctx(ctx). + LeftJoin("mdl_models m ON ms.model_id = m.id"). + Where("ms.tenant_id", tenantID). + Where("ms.user_id", userID). + Fields("ms.model_id, m.model_id as model_name"). + Scan(&rows) + if err != nil { + return nil, err + } + + if len(rows) == 0 { + return nil, nil + } + + names := make([]string, 0, len(rows)) + for _, r := range rows { + if r.ModelID == -1 { + memberModelCache.Set(ctx, cacheKey, []string{}) + return []string{}, nil + } + if r.ModelName != "" { + names = append(names, r.ModelName) + } + } + + memberModelCache.Set(ctx, cacheKey, names) + return names, nil +} + // CheckMemberModelAccess 检查成员是否有权使用指定模型。 // 无 scope 记录表示不限制(向后兼容)。 +// 哨兵 model_id=-1 表示该成员被限制使用所有模型。 func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, userID int64, modelName string) (bool, error) { cacheKey := fmt.Sprintf("%d:%d", tenantID, userID) @@ -1110,6 +1184,12 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, if len(cachedModelNames) == 0 { return true, nil } + // 哨兵值:表示该成员被限制使用所有模型 + for _, name := range cachedModelNames { + if name == "__none__" { + return false, nil + } + } for _, name := range cachedModelNames { if name == modelName { return true, nil @@ -1119,6 +1199,7 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, } type scopeRow struct { + ModelID int64 `json:"model_id"` ModelName string `json:"model_name"` } var rows []scopeRow @@ -1126,7 +1207,7 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, LeftJoin("mdl_models m ON ms.model_id = m.id"). Where("ms.tenant_id", tenantID). Where("ms.user_id", userID). - Fields("m.model_id as model_name"). + Fields("ms.model_id, m.model_id as model_name"). Scan(&rows) if err != nil { return false, err @@ -1134,7 +1215,11 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, names := make([]string, 0, len(rows)) for _, r := range rows { - names = append(names, r.ModelName) + if r.ModelID == -1 { + names = append(names, "__none__") + } else if r.ModelName != "" { + names = append(names, r.ModelName) + } } memberModelCache.Set(ctx, cacheKey, names) @@ -1142,6 +1227,12 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, if len(names) == 0 { return true, nil } + // 哨兵值:表示该成员被限制使用所有模型 + for _, name := range names { + if name == "__none__" { + return false, nil + } + } for _, name := range names { if name == modelName { return true, nil @@ -1150,6 +1241,12 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, return false, nil } +// InvalidateMemberModelCache 实现 DataProvider.InvalidateMemberModelCache +func (p *DataProviderImpl) InvalidateMemberModelCache(ctx context.Context, tenantID, userID int64) { + cacheKey := fmt.Sprintf("%d:%d", tenantID, userID) + memberModelCache.Delete(ctx, cacheKey) +} + // apiKeyModelCache API Key 模型范围缓存(TTL 60s) var apiKeyModelCache = lcommon.NewCache("apikey_model", 60*time.Second) diff --git a/internal/logic/tenant/member_model_scope.go b/internal/logic/tenant/member_model_scope.go index fe298a1..a6e61f2 100644 --- a/internal/logic/tenant/member_model_scope.go +++ b/internal/logic/tenant/member_model_scope.go @@ -2,13 +2,16 @@ package tenant import ( "context" - "github.com/qianfree/team-api/internal/dao" + "fmt" + "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" + "github.com/qianfree/team-api/internal/dao" do "github.com/qianfree/team-api/internal/model/do" v1 "github.com/qianfree/team-api/api/tenant/v1" + lcommon "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" ) @@ -29,11 +32,16 @@ func (s *sTenant) MemberModelScopes(ctx context.Context, req *v1.TenantMemberMod ids := make([]int64, 0, len(rows)) for _, r := range rows { - ids = append(ids, r.ModelID) + if r.ModelID > 0 { + ids = append(ids, r.ModelID) + } } return &v1.TenantMemberModelScopesRes{ModelIDs: ids}, nil } +// memberModelScopeCache 成员模型范围缓存(与 relay provider 中的缓存 key 相同) +var memberModelScopeCache = lcommon.NewCache("member_model", 60*time.Second) + // MemberModelScopesSet sets the available models for a member (full replace). func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMemberModelScopesSetReq) (*v1.TenantMemberModelScopesSetRes, error) { tenantID := middleware.GetTenantID(ctx) @@ -48,8 +56,8 @@ func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMember return err } - // Insert new scopes if len(req.ModelIDs) > 0 { + // Insert new scopes data := make([]do.TntMemberModelScopes, len(req.ModelIDs)) for i, mID := range req.ModelIDs { data[i] = do.TntMemberModelScopes{ @@ -62,6 +70,16 @@ func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMember if err != nil { return err } + } else { + // 空列表表示禁止所有模型,插入哨兵记录(model_id = -1) + _, err = tx.Model("tnt_member_model_scopes").Ctx(ctx).Data(do.TntMemberModelScopes{ + TenantId: tenantID, + UserId: req.Id, + ModelId: -1, + }).Insert() + if err != nil { + return err + } } return nil @@ -70,47 +88,9 @@ func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMember return nil, err } + // 清除该成员的模型范围缓存,使下次请求时重新从数据库读取 + cacheKey := fmt.Sprintf("%d:%d", tenantID, req.Id) + memberModelScopeCache.Delete(ctx, cacheKey) + return &v1.TenantMemberModelScopesSetRes{}, nil } - -// revokeMemberModels removes specified models from a member and cascades to their API keys. -func revokeMemberModels(ctx context.Context, tenantID, userID int64, modelIDs []int64) error { - if len(modelIDs) == 0 { - return nil - } - - return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - // Remove from member model scopes - _, err := tx.Model("tnt_member_model_scopes").Ctx(ctx). - Where("tenant_id", tenantID). - Where("user_id", userID). - WhereIn("model_id", modelIDs). - Delete() - if err != nil { - return err - } - - // Cascade: remove model bindings from all keys owned by this user - keyIDs, err := tx.Model("api_keys").Ctx(ctx). - Where("tenant_id", tenantID). - Where("user_id", userID). - Where("status", "active"). - Fields("id"). - Array() - if err != nil { - return err - } - - if len(keyIDs) > 0 { - _, err = tx.Model("api_key_model_scopes").Ctx(ctx). - WhereIn("api_key_id", keyIDs). - WhereIn("model_id", modelIDs). - Delete() - if err != nil { - return err - } - } - - return nil - }) -} diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index 1b1200f..947ac44 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -7,6 +7,7 @@ import ( "github.com/gogf/gf/v2/frame/g" v1 "github.com/qianfree/team-api/api/tenant/v1" "github.com/qianfree/team-api/internal/dao" + lcommon "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" ) @@ -22,31 +23,19 @@ type pricingTierRow struct { func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsReq) (*v1.TenantAvailableModelsRes, error) { tenantID := middleware.GetTenantID(ctx) - query := dao.MdlTenantModels.Ctx(ctx). - LeftJoin("mdl_models m ON mdl_tenant_models.model_id = m.id"). - LeftJoin("mdl_pricing p ON p.model_id = mdl_tenant_models.model_id AND p.min_tokens = 0"). - Where("mdl_tenant_models.tenant_id", tenantID). - Where("mdl_tenant_models.enabled", true). - Where("m.status", "active") - - if req.Category != "" { - query = query.Where("m.category", req.Category) - } - if req.Search != "" { - query = query.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") + models, err := lcommon.GetTenantAvailableModels(ctx, tenantID, req.Category, req.Search) + if err != nil { + return nil, err } - var results []struct { - ID int64 `json:"id"` + if len(models) == 0 { + return &v1.TenantAvailableModelsRes{List: nil}, nil + } + + // 查询显式分配模型的完整价格信息 + var priceResults []struct { ModelDBID int64 `json:"model_db_id"` - ModelId string `json:"model_id"` - ModelName string `json:"model_name"` - Category string `json:"category"` - MaxContextTokens int `json:"max_context_tokens"` - MaxOutputTokens int `json:"max_output_tokens"` - Description string `json:"description"` - Tags string `json:"tags"` - Capabilities string `json:"capabilities"` + ID int64 `json:"id"` BillingMode *string `json:"billing_mode"` PerRequestPrice *float64 `json:"per_request_price"` DiscountRatio *float64 `json:"discount_ratio"` @@ -63,25 +52,110 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab CustomPricingTiers string `json:"custom_pricing_tiers"` } - err := query. - Fields("mdl_tenant_models.id, mdl_tenant_models.model_id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.input_price AS base_input_price, p.output_price AS base_output_price, p.cache_read_price AS base_cache_read_price, p.cache_creation_price AS base_cache_creation_price, p.billing_mode AS base_billing_mode, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers"). - OrderAsc("m.category"). - OrderAsc("m.model_id"). - Scan(&results) - if err != nil { - return nil, err - } - - // 收集需要查询 base 阶梯定价的 model 内部 ID(mdl_models.id) - tieredModelDBIDs := make([]int64, 0) - for _, r := range results { - effectiveBillingMode := resolveBillingMode(r.BillingMode, r.BaseBillingMode) - if effectiveBillingMode == "tiered" && r.CustomPricingTiers == "" { - tieredModelDBIDs = append(tieredModelDBIDs, r.ModelDBID) + explicitDBIDs := make([]int64, 0) + for _, m := range models { + if m.Source == "explicit" { + explicitDBIDs = append(explicitDBIDs, m.ModelDBID) + } + } + + if len(explicitDBIDs) > 0 { + _ = dao.MdlTenantModels.Ctx(ctx). + LeftJoin("mdl_pricing p ON p.model_id = mdl_tenant_models.model_id AND p.min_tokens = 0"). + Where("mdl_tenant_models.tenant_id", tenantID). + WhereIn("mdl_tenant_models.model_id", explicitDBIDs). + Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.input_price AS base_input_price, p.output_price AS base_output_price, p.cache_read_price AS base_cache_read_price, p.cache_creation_price AS base_cache_creation_price, p.billing_mode AS base_billing_mode, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers"). + Scan(&priceResults) + } + + // 构建显式模型价格映射 + type priceInfo struct { + ID int64 + BillingMode *string + PerRequestPrice *float64 + DiscountRatio *float64 + MaxConcurrency *int + BaseInputPrice float64 + BaseOutputPrice float64 + BaseCacheReadPrice float64 + BaseCacheCreationPrice float64 + BaseBillingMode string + CustomInputPrice *float64 + CustomOutputPrice *float64 + CustomCacheReadPrice *float64 + CustomCacheCreationPrice *float64 + CustomPricingTiers string + } + priceMap := make(map[int64]*priceInfo, len(priceResults)) + for _, r := range priceResults { + priceMap[r.ModelDBID] = &priceInfo{ + ID: r.ID, + BillingMode: r.BillingMode, + PerRequestPrice: r.PerRequestPrice, + DiscountRatio: r.DiscountRatio, + MaxConcurrency: r.MaxConcurrency, + BaseInputPrice: r.BaseInputPrice, + BaseOutputPrice: r.BaseOutputPrice, + BaseCacheReadPrice: r.BaseCacheReadPrice, + BaseCacheCreationPrice: r.BaseCacheCreationPrice, + BaseBillingMode: r.BaseBillingMode, + CustomInputPrice: r.CustomInputPrice, + CustomOutputPrice: r.CustomOutputPrice, + CustomCacheReadPrice: r.CustomCacheReadPrice, + CustomCacheCreationPrice: r.CustomCacheCreationPrice, + CustomPricingTiers: r.CustomPricingTiers, + } + } + + // 批量查询分组模型的 base 价格 + groupDBIDs := make([]int64, 0) + for _, m := range models { + if m.Source == "group" { + groupDBIDs = append(groupDBIDs, m.ModelDBID) + } + } + + type groupPriceInfo struct { + BaseBillingMode string `json:"base_billing_mode"` + BaseInputPrice float64 `json:"base_input_price"` + BaseOutputPrice float64 `json:"base_output_price"` + } + groupPriceMap := make(map[int64]*groupPriceInfo) + if len(groupDBIDs) > 0 { + var groupPrices []struct { + ModelID int64 `json:"model_id"` + BaseBillingMode string `json:"base_billing_mode"` + BaseInputPrice float64 `json:"base_input_price"` + BaseOutputPrice float64 `json:"base_output_price"` + } + _ = dao.MdlPricing.Ctx(ctx). + WhereIn("model_id", groupDBIDs). + Where("min_tokens", 0). + Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price"). + Scan(&groupPrices) + + for _, gp := range groupPrices { + groupPriceMap[gp.ModelID] = &groupPriceInfo{ + BaseBillingMode: gp.BaseBillingMode, + BaseInputPrice: gp.BaseInputPrice, + BaseOutputPrice: gp.BaseOutputPrice, + } + } + } + + // 收集需要查询阶梯定价的模型 + tieredModelDBIDs := make([]int64, 0) + for _, m := range models { + if m.Source == "explicit" { + if pi, ok := priceMap[m.ModelDBID]; ok { + effectiveBillingMode := resolveBillingMode(pi.BillingMode, pi.BaseBillingMode) + if effectiveBillingMode == "tiered" && pi.CustomPricingTiers == "" { + tieredModelDBIDs = append(tieredModelDBIDs, m.ModelDBID) + } + } } } - // 批量查询 base 阶梯定价(min_tokens > 0 的阶梯) baseTiersMap := make(map[int64][]v1.PricingTierItem) if len(tieredModelDBIDs) > 0 { var baseTiers []struct { @@ -91,7 +165,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab InputPrice float64 `json:"input_price"` OutputPrice float64 `json:"output_price"` } - err = dao.MdlPricing.Ctx(ctx). + _ = dao.MdlPricing.Ctx(ctx). WhereIn("model_id", tieredModelDBIDs). Where("billing_mode", "tiered"). Where("min_tokens > 0"). @@ -99,163 +173,149 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab OrderAsc("model_id"). OrderAsc("min_tokens"). Scan(&baseTiers) - if err == nil { - for _, t := range baseTiers { - baseTiersMap[t.ModelId] = append(baseTiersMap[t.ModelId], v1.PricingTierItem{ - MinTokens: t.MinTokens, - MaxTokens: t.MaxTokens, - InputPrice: t.InputPrice, - OutputPrice: t.OutputPrice, - }) - } + + for _, t := range baseTiers { + baseTiersMap[t.ModelId] = append(baseTiersMap[t.ModelId], v1.PricingTierItem{ + MinTokens: t.MinTokens, + MaxTokens: t.MaxTokens, + InputPrice: t.InputPrice, + OutputPrice: t.OutputPrice, + }) } } - list := make([]v1.TenantAvailableModelItem, 0, len(results)) - for _, r := range results { - effectiveBillingMode := resolveBillingMode(r.BillingMode, r.BaseBillingMode) + // 组装最终列表 + list := make([]v1.TenantAvailableModelItem, 0, len(models)) + for _, m := range models { + if m.Source == "explicit" { + pi, ok := priceMap[m.ModelDBID] + if !ok { + continue + } - inputPrice := effectivePrice(r.CustomInputPrice, r.BaseInputPrice) - outputPrice := effectivePrice(r.CustomOutputPrice, r.BaseOutputPrice) - cacheReadPrice := effectivePrice(r.CustomCacheReadPrice, r.BaseCacheReadPrice) - cacheCreationPrice := effectivePrice(r.CustomCacheCreationPrice, r.BaseCacheCreationPrice) + effectiveBillingMode := resolveBillingMode(pi.BillingMode, pi.BaseBillingMode) + inputPrice := effectivePrice(pi.CustomInputPrice, pi.BaseInputPrice) + outputPrice := effectivePrice(pi.CustomOutputPrice, pi.BaseOutputPrice) + cacheReadPrice := effectivePrice(pi.CustomCacheReadPrice, pi.BaseCacheReadPrice) + cacheCreationPrice := effectivePrice(pi.CustomCacheCreationPrice, pi.BaseCacheCreationPrice) - item := v1.TenantAvailableModelItem{ - ID: r.ID, - ModelId: r.ModelId, - ModelName: r.ModelName, - Category: r.Category, - MaxContext: r.MaxContextTokens, - MaxOutput: r.MaxOutputTokens, - Description: r.Description, - Tags: r.Tags, - Capabilities: r.Capabilities, - BillingMode: &effectiveBillingMode, - PerRequestPrice: r.PerRequestPrice, - DiscountRatio: r.DiscountRatio, - MaxConcurrency: r.MaxConcurrency, - InputPrice: inputPrice, - OutputPrice: outputPrice, - CacheReadPrice: cacheReadPrice, - CacheCreationPrice: cacheCreationPrice, - } + item := v1.TenantAvailableModelItem{ + ID: pi.ID, + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + MaxContext: m.MaxContextTokens, + MaxOutput: m.MaxOutputTokens, + Description: m.Description, + Tags: m.Tags, + Capabilities: m.Capabilities, + BillingMode: &effectiveBillingMode, + PerRequestPrice: pi.PerRequestPrice, + DiscountRatio: pi.DiscountRatio, + MaxConcurrency: pi.MaxConcurrency, + InputPrice: inputPrice, + OutputPrice: outputPrice, + CacheReadPrice: cacheReadPrice, + CacheCreationPrice: cacheCreationPrice, + } - // 阶梯定价处理 - if effectiveBillingMode == "tiered" { - var tiers []v1.PricingTierItem - - // 优先使用租户自定义阶梯 - if r.CustomPricingTiers != "" && r.CustomPricingTiers != "null" && r.CustomPricingTiers != "[]" { - var raw []pricingTierRow - if json.Unmarshal([]byte(r.CustomPricingTiers), &raw) == nil && len(raw) > 0 { - for _, t := range raw { + if effectiveBillingMode == "tiered" { + var tiers []v1.PricingTierItem + if pi.CustomPricingTiers != "" && pi.CustomPricingTiers != "null" && pi.CustomPricingTiers != "[]" { + var raw []pricingTierRow + if json.Unmarshal([]byte(pi.CustomPricingTiers), &raw) == nil && len(raw) > 0 { + for _, t := range raw { + tiers = append(tiers, v1.PricingTierItem{ + MinTokens: t.MinTokens, + MaxTokens: t.MaxTokens, + InputPrice: t.InputPrice, + OutputPrice: t.OutputPrice, + }) + } + } + } + if len(tiers) == 0 { + if pi.BaseInputPrice > 0 || pi.BaseOutputPrice > 0 { tiers = append(tiers, v1.PricingTierItem{ - MinTokens: t.MinTokens, - MaxTokens: t.MaxTokens, - InputPrice: t.InputPrice, - OutputPrice: t.OutputPrice, + MinTokens: 0, + MaxTokens: nil, + InputPrice: pi.BaseInputPrice, + OutputPrice: pi.BaseOutputPrice, }) } - } - } - - // 使用 base 阶梯定价 - if len(tiers) == 0 { - // 第一阶梯(min_tokens=0,来自主查询的 base_input/output_price) - if r.BaseInputPrice > 0 || r.BaseOutputPrice > 0 { - tiers = append(tiers, v1.PricingTierItem{ - MinTokens: 0, - MaxTokens: nil, - InputPrice: r.BaseInputPrice, - OutputPrice: r.BaseOutputPrice, - }) - } - // 后续阶梯 - if rest, ok := baseTiersMap[r.ModelDBID]; ok { - // 设置第一阶梯的 max_tokens - if len(tiers) > 0 && len(rest) > 0 { - tiers[0].MaxTokens = &rest[0].MinTokens + if rest, ok := baseTiersMap[m.ModelDBID]; ok { + if len(tiers) > 0 && len(rest) > 0 { + tiers[0].MaxTokens = &rest[0].MinTokens + } + tiers = append(tiers, rest...) } - tiers = append(tiers, rest...) } + item.PricingTiers = tiers } - item.PricingTiers = tiers + list = append(list, item) + } else { + // group 来源的模型:使用 base 价格 + billingMode := "token" + var inputPrice, outputPrice *float64 + if gp, ok := groupPriceMap[m.ModelDBID]; ok { + billingMode = gp.BaseBillingMode + if billingMode == "" { + billingMode = "token" + } + inputPrice = effectivePrice(nil, gp.BaseInputPrice) + outputPrice = effectivePrice(nil, gp.BaseOutputPrice) + } + + item := v1.TenantAvailableModelItem{ + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + MaxContext: m.MaxContextTokens, + MaxOutput: m.MaxOutputTokens, + Description: m.Description, + Tags: m.Tags, + Capabilities: m.Capabilities, + BillingMode: &billingMode, + InputPrice: inputPrice, + OutputPrice: outputPrice, + } + list = append(list, item) } - - list = append(list, item) - } - // 追加分组来源的模型(不在显式分配中的模型) - explicitModelIDs := make(map[int64]bool, len(results)) - for _, r := range results { - explicitModelIDs[r.ModelDBID] = true } - var groupModels []struct { - ModelDBID int64 `json:"model_db_id"` - ModelId string `json:"model_id"` - ModelName string `json:"model_name"` - Category string `json:"category"` - MaxContextTokens int `json:"max_context_tokens"` - MaxOutputTokens int `json:"max_output_tokens"` - Description string `json:"description"` - Tags string `json:"tags"` - Capabilities string `json:"capabilities"` - BaseBillingMode string `json:"base_billing_mode"` - BaseInputPrice float64 `json:"base_input_price"` - BaseOutputPrice float64 `json:"base_output_price"` - } - - groupQuery := g.DB().Model("mdl_group_models gm").Ctx(ctx). - InnerJoin("mdl_model_groups g ON gm.group_id = g.id"). - InnerJoin("mdl_models m ON gm.model_id = m.id"). - InnerJoin("mdl_tenant_groups tg ON tg.group_id = g.id"). - LeftJoin("mdl_pricing p ON p.model_id = m.id AND p.min_tokens = 0"). - Where("tg.tenant_id", tenantID). - Where("g.status", "active"). - Where("m.status", "active") - - if len(explicitModelIDs) > 0 { - ids := make([]int64, 0, len(explicitModelIDs)) - for id := range explicitModelIDs { - ids = append(ids, id) + // 按成员模型范围过滤 + userID := middleware.GetUserID(ctx) + if userID > 0 { + var memberScopes []struct { + ModelID int64 `json:"model_id"` + ModelName string `json:"model_name"` } - groupQuery = groupQuery.WhereNotIn("m.id", ids) - } + _ = g.DB().Model("tnt_member_model_scopes ms").Ctx(ctx). + LeftJoin("mdl_models m ON ms.model_id = m.id"). + Where("ms.tenant_id", tenantID). + Where("ms.user_id", userID). + Fields("ms.model_id, m.model_id as model_name"). + Scan(&memberScopes) - if req.Category != "" { - groupQuery = groupQuery.Where("m.category", req.Category) - } - if req.Search != "" { - groupQuery = groupQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") - } - - _ = groupQuery. - Fields("DISTINCT m.id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities, p.billing_mode AS base_billing_mode, p.input_price AS base_input_price, p.output_price AS base_output_price"). - OrderAsc("m.category"). - OrderAsc("m.model_id"). - Scan(&groupModels) - - for _, gm := range groupModels { - billingMode := gm.BaseBillingMode - if billingMode == "" { - billingMode = "token" + if len(memberScopes) > 0 { + allowed := make(map[string]bool, len(memberScopes)) + for _, s := range memberScopes { + if s.ModelID == -1 { + return &v1.TenantAvailableModelsRes{List: nil}, nil + } + if s.ModelName != "" { + allowed[s.ModelName] = true + } + } + filtered := make([]v1.TenantAvailableModelItem, 0, len(list)) + for _, item := range list { + if allowed[item.ModelId] { + filtered = append(filtered, item) + } + } + list = filtered } - - item := v1.TenantAvailableModelItem{ - ModelId: gm.ModelId, - ModelName: gm.ModelName, - Category: gm.Category, - MaxContext: gm.MaxContextTokens, - MaxOutput: gm.MaxOutputTokens, - Description: gm.Description, - Tags: gm.Tags, - Capabilities: gm.Capabilities, - BillingMode: &billingMode, - InputPrice: &gm.BaseInputPrice, - OutputPrice: &gm.BaseOutputPrice, - } - list = append(list, item) } return &v1.TenantAvailableModelsRes{List: list}, nil diff --git a/internal/service/admin.go b/internal/service/admin.go index 1adc420..5a3cac6 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -246,6 +246,12 @@ type ( ListTenantGroups(ctx context.Context, req *v1.TenantGroupsListReq) (*v1.TenantGroupsListRes, error) // SetTenantGroups 设置租户关联的分组(全量替换) SetTenantGroups(ctx context.Context, req *v1.TenantGroupsSetReq) (*v1.TenantGroupsSetRes, error) + // ExportModelsJson 导出模型配置为 JSON 文件(含定价数据,用于跨环境迁移) + ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonReq) (*v1.ModelExportJsonRes, error) + // ImportModelsPreview 导入模型预览(解析上传文件,检测冲突) + ImportModelsPreview(ctx context.Context, req *v1.ModelImportPreviewReq) (*v1.ModelImportPreviewRes, error) + // ImportModels 确认导入模型(事务内执行) + ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.ModelImportRes, error) // ListTemplates 获取通知模板列表(分页) ListTemplates(ctx context.Context, req *v1.TemplateListReq) (*v1.TemplateListRes, error) // GetTemplate 获取单个通知模板 @@ -395,6 +401,8 @@ type ( UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdateReq) (*v1.TenantModelUpdateRes, error) // DeleteTenantModel 移除租户模型分配 DeleteTenantModel(ctx context.Context, req *v1.TenantModelDeleteReq) (*v1.TenantModelDeleteRes, error) + // ListTenantAvailableModels 预览租户实际可用的所有模型(显式分配 + 分组来源,去重) + ListTenantAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (*v1.TenantAvailableModelsPreviewRes, error) // ListAllTickets 获取全部工单列表(管理后台) ListAllTickets(ctx context.Context, req *v1.TicketListReq) (*v1.TicketListRes, error) // GetTicketAdmin 获取工单详情(管理后台,含回复) diff --git a/migrations/000005_v0_1_5_version.sql b/migrations/000005_v0_1_5_version.sql index 51b5c5d..f126e8a 100644 --- a/migrations/000005_v0_1_5_version.sql +++ b/migrations/000005_v0_1_5_version.sql @@ -32,10 +32,10 @@ COMMENT ON COLUMN tnt_tenant_level_configs.sort_order IS '排序权重'; -- 2. 插入默认等级数据 INSERT INTO tnt_tenant_level_configs (level, name, cumulative_recharge_threshold, max_members, max_concurrency, price_multiplier, sort_order) VALUES (1, 'LV1', 0, 5, 10, 1.0000, 1), -(2, 'LV2', 100, 10, 20, 0.9500, 2), -(3, 'LV3', 500, 20, 50, 0.9000, 3), -(4, 'LV4', 2000, 50, 100, 0.8500, 4), -(5, 'LV5', 10000, 0, 0, 0.8000, 5); +(2, 'LV2', 1000, 10, 20, 0.9500, 2), +(3, 'LV3', 10000, 20, 50, 0.9000, 3), +(4, 'LV4', 100000, 50, 100, 0.8500, 4), +(5, 'LV5', 500000, 0, 0, 0.8000, 5); -- 3. bil_wallets 添加累计充值字段 ALTER TABLE bil_wallets ADD COLUMN cumulative_recharge NUMERIC(20,10) DEFAULT 0 NOT NULL; diff --git a/relay/common/provider.go b/relay/common/provider.go index bdabc66..0606938 100644 --- a/relay/common/provider.go +++ b/relay/common/provider.go @@ -18,6 +18,9 @@ var ErrStreamInterrupted = errors.New("stream interrupted by client disconnect") // ErrTenantModelNotEnabled 租户未启用该模型 var ErrTenantModelNotEnabled = errors.New("model not enabled for this tenant") +// ErrMemberModelNotAllowed 成员无权使用该模型 +var ErrMemberModelNotAllowed = errors.New("model not allowed for this member") + // DeprecationInfo 模型弃用信息 type DeprecationInfo struct { Deprecated bool @@ -63,7 +66,8 @@ type DataProvider interface { // GetAvailableModels 获取指定租户可用的模型列表。 // apiKeyID > 0 时进一步按 API Key 的模型范围过滤。 - GetAvailableModels(ctx context.Context, tenantID int64, apiKeyID int64) ([]ModelInfo, error) + // userID > 0 时进一步按成员的模型范围过滤。 + GetAvailableModels(ctx context.Context, tenantID, apiKeyID, userID int64) ([]ModelInfo, error) // GetModelDetail 获取单个模型的详细信息。 // tenantID 用于权限校验(检查租户是否有权使用该模型)。 @@ -87,6 +91,9 @@ type DataProvider interface { // CheckApiKeyModelAccess 检查 API Key 是否有权使用指定模型。 // 无 scope 记录表示不限制(向后兼容)。 CheckApiKeyModelAccess(ctx context.Context, apiKeyID int64, modelName string) (bool, error) + + // InvalidateMemberModelCache 清除指定成员的模型范围缓存。 + InvalidateMemberModelCache(ctx context.Context, tenantID, userID int64) } // ApiKeyInfo API Key 验证结果 diff --git a/relay/handler/chat_handler.go b/relay/handler/chat_handler.go index 8dac0cd..25288a5 100644 --- a/relay/handler/chat_handler.go +++ b/relay/handler/chat_handler.go @@ -41,8 +41,9 @@ func HandleResponses(ctx context.Context, body []byte, path string, headers http // HandleModels 处理 /v1/models 请求(列出可用模型) // tenantID 用于过滤该租户有权使用的模型,apiKeyID 用于进一步按 Key 的模型范围过滤 -func HandleModels(ctx context.Context, tenantID int64, apiKeyID int64, provider common.DataProvider) (*dto.ModelsResponse, error) { - models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID) +// userID 用于进一步按成员的模型范围过滤 +func HandleModels(ctx context.Context, tenantID, apiKeyID, userID int64, provider common.DataProvider) (*dto.ModelsResponse, error) { + models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID, userID) if err != nil { return nil, err } diff --git a/relay/handler/gemini_handler.go b/relay/handler/gemini_handler.go index d13ec85..87598ff 100644 --- a/relay/handler/gemini_handler.go +++ b/relay/handler/gemini_handler.go @@ -20,8 +20,8 @@ func HandleGeminiGenerateContent(ctx context.Context, body []byte, path string, } // HandleGeminiModels 处理 GET /v1beta/models 请求(Gemini 原生格式模型列表) -func HandleGeminiModels(ctx context.Context, tenantID int64, apiKeyID int64, provider common.DataProvider) (*dto.GeminiModelsResponse, error) { - models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID) +func HandleGeminiModels(ctx context.Context, tenantID, apiKeyID, userID int64, provider common.DataProvider) (*dto.GeminiModelsResponse, error) { + models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID, userID) if err != nil { return nil, err } diff --git a/relay/handler/realtime_handler.go b/relay/handler/realtime_handler.go index fb00e32..a236955 100644 --- a/relay/handler/realtime_handler.go +++ b/relay/handler/realtime_handler.go @@ -71,6 +71,13 @@ func HandleRealtime(w http.ResponseWriter, r *http.Request, rc *RealtimeContext, return nil, nil, fmt.Errorf("model not found: %s", modelName) } + // 3.1 检查成员模型范围 + if allowed, err := provider.CheckMemberModelAccess(ctx, rc.TenantID, rc.UserID, modelName); err != nil { + return nil, nil, err + } else if !allowed { + return nil, nil, constant.NewAuthError("model not allowed for this member") + } + // 4. 渠道选择 selection, err := provider.GetChannelForModel(ctx, rc.TenantID, rc.UserID, modelName, nil) if err != nil { diff --git a/tests/integration/admin/auth_test.go b/tests/integration/admin/auth_test.go index de4f432..c3e8ede 100644 --- a/tests/integration/admin/auth_test.go +++ b/tests/integration/admin/auth_test.go @@ -42,7 +42,7 @@ func TestLogin_InvalidPassword(t *testing.T) { // Step 2: Read captcha answer from Redis rdb := redis.NewClient(&redis.Options{ - Addr: testinfra.DefaultRedisAddr, + Addr: testinfra.GetRedisAddr(), DialTimeout: 3 * time.Second, ReadTimeout: 3 * time.Second, WriteTimeout: 3 * time.Second, diff --git a/tests/integration/admin/feedback_test.go b/tests/integration/admin/feedback_test.go index 2e02dcb..c11a803 100644 --- a/tests/integration/admin/feedback_test.go +++ b/tests/integration/admin/feedback_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/qianfree/team-api/tests/integration/admin/testinfra" + admintest "github.com/qianfree/team-api/tests/integration/admin/testinfra" + tenanttest "github.com/qianfree/team-api/tests/integration/tenant/testinfra" ) func TestFeedbackList(t *testing.T) { @@ -47,7 +49,7 @@ func TestFeedbackListWithFilters(t *testing.T) { resp.AssertSuccess(t) // Filter by tenant_id - tenantID, cleanup := testinfra.CreateTestTenant(t, client) + tenantID, cleanup := createTestTenantWithFeedback(t, client) defer cleanup() resp = client.Get("/api/admin/feedbacks", map[string]string{ @@ -58,32 +60,12 @@ func TestFeedbackListWithFilters(t *testing.T) { resp.AssertSuccess(t) } -// Note: Feedback creation happens through the tenant console. -// The admin can only reply, update status, and view statistics. - func TestFeedbackReply(t *testing.T) { client := testinfra.GetAuthedClient(t) - // Find a feedback to reply to - listResp := client.Get("/api/admin/feedbacks", map[string]string{ - "page": "1", - "page_size": "10", - }) - listResp.AssertSuccess(t) - - var listData struct { - List []struct { - Id int64 `json:"id"` - Status string `json:"status"` - } `json:"list"` - } - listResp.DecodeData(t, &listData) - - if len(listData.List) == 0 { - t.Skip("No feedback found, skipping reply test") - } - - feedbackID := listData.List[0].Id + // Create test tenant with feedback + feedbackID, cleanup := createTestFeedbackViaTenant(t, client) + defer cleanup() // Reply and update status to acknowledged replyText := fmt.Sprintf("管理员回复(集成测试)%s", randomSuffix()) @@ -127,26 +109,9 @@ func TestFeedbackReply(t *testing.T) { func TestFeedbackStatusUpdate(t *testing.T) { client := testinfra.GetAuthedClient(t) - // Find a feedback to update - listResp := client.Get("/api/admin/feedbacks", map[string]string{ - "page": "1", - "page_size": "10", - }) - listResp.AssertSuccess(t) - - var listData struct { - List []struct { - Id int64 `json:"id"` - Status string `json:"status"` - } `json:"list"` - } - listResp.DecodeData(t, &listData) - - if len(listData.List) == 0 { - t.Skip("No feedback found, skipping status update test") - } - - feedbackID := listData.List[0].Id + // Create test tenant with feedback + feedbackID, cleanup := createTestFeedbackViaTenant(t, client) + defer cleanup() // Update status to in_progress and priority to high updateResp := client.Put(fmt.Sprintf("/api/admin/feedbacks/%d/status", feedbackID), map[string]any{ @@ -280,3 +245,51 @@ func TestFeedbackNegative(t *testing.T) { } } } + +// createTestFeedbackViaTenant creates a test tenant, registers a tenant user, +// creates a feedback via the tenant API, and returns the feedback ID and cleanup. +func createTestFeedbackViaTenant(t *testing.T, adminClient *admintest.APIClient) (feedbackID int64, cleanup func()) { + t.Helper() + + result := tenanttest.RegisterTestTenant(t) + tenantClient := admintest.NewAPIClient(tenanttest.DefaultBaseURL).WithToken(result.AccessToken) + + suffix := tenanttest.RandomSuffix() + resp := tenantClient.Post("/api/tenant/feedbacks", map[string]any{ + "category": "bug_report", + "title": fmt.Sprintf("管理员测试反馈 %s", suffix), + "description": "管理员集成测试自动创建的反馈", + }) + resp.AssertSuccess(t) + + var data struct { + ID int64 `json:"id"` + } + resp.DecodeData(t, &data) + + return data.ID, func() { + // Feedback and tenant cleaned up by hardDeleteTenant in RegisterTestTenant + } +} + +// createTestTenantWithFeedback creates a test tenant with a feedback for filter tests. +func createTestTenantWithFeedback(t *testing.T, adminClient *admintest.APIClient) (tenantID int64, cleanup func()) { + t.Helper() + + // Register a test tenant + result := tenanttest.RegisterTestTenant(t) + tenantClient := admintest.NewAPIClient(tenanttest.DefaultBaseURL).WithToken(result.AccessToken) + + // Create feedback via tenant API + suffix := tenanttest.RandomSuffix() + resp := tenantClient.Post("/api/tenant/feedbacks", map[string]any{ + "category": "bug_report", + "title": fmt.Sprintf("过滤测试反馈 %s", suffix), + "description": "过滤测试自动创建的反馈", + }) + resp.AssertSuccess(t) + + return result.Tenant.ID, func() { + // Tenant cleanup is handled by t.Cleanup in RegisterTestTenant + } +} diff --git a/tests/integration/admin/notification_test.go b/tests/integration/admin/notification_test.go index fe86488..79cb30c 100644 --- a/tests/integration/admin/notification_test.go +++ b/tests/integration/admin/notification_test.go @@ -169,12 +169,16 @@ func TestMessageSendAndVerify(t *testing.T) { func TestMessageBroadcast(t *testing.T) { client := testinfra.GetAuthedClient(t) + msgTitle := fmt.Sprintf("[集成测试] 广播消息 %s", randomSuffix()) resp := client.Post("/api/admin/notification/messages/broadcast", map[string]any{ - "title": fmt.Sprintf("测试广播消息 %s", randomSuffix()), + "title": msgTitle, "content": "这是一条集成测试广播消息", "target_roles": []string{"owner", "admin"}, }) resp.AssertSuccess(t) + + // Hard-delete broadcast messages from database + defer testinfra.HardDeleteMessagesByTitle(t, "[集成测试]") } func TestAnnouncementCRUD(t *testing.T) { @@ -195,7 +199,7 @@ func TestAnnouncementCRUD(t *testing.T) { createResp.AssertSuccess(t) announcementID := createResp.GetID(t) defer func() { - client.Delete(fmt.Sprintf("/api/admin/announcements/%d", announcementID)) + testinfra.HardDeleteAnnouncement(t, announcementID) }() // --- Verify creation via list --- diff --git a/tests/integration/admin/promo_test.go b/tests/integration/admin/promo_test.go index 4214750..3c01e01 100644 --- a/tests/integration/admin/promo_test.go +++ b/tests/integration/admin/promo_test.go @@ -47,7 +47,7 @@ func TestPromoCodeCRUD(t *testing.T) { createResp.AssertSuccess(t) promoID := createResp.GetID(t) defer func() { - client.Delete(fmt.Sprintf("/api/admin/promo-codes/%d", promoID)) + testinfra.HardDeletePromoCode(t, promoID) }() // --- Verify creation via list --- @@ -221,6 +221,6 @@ func createTestPromoCode(t *testing.T, client *testinfra.APIClient) (int64, func resp.AssertSuccess(t) id := resp.GetID(t) return id, func() { - client.Delete(fmt.Sprintf("/api/admin/promo-codes/%d", id)) + testinfra.HardDeletePromoCode(t, id) } } diff --git a/tests/integration/admin/redemption_test.go b/tests/integration/admin/redemption_test.go index b3ed6bc..0c83f7d 100644 --- a/tests/integration/admin/redemption_test.go +++ b/tests/integration/admin/redemption_test.go @@ -51,36 +51,22 @@ func TestRedemptionBatchCreate(t *testing.T) { // Batch create quota-type redemption codes count := 5 - createResp := client.Post("/api/admin/redemptions", map[string]any{ - "count": count, - "type": "quota", - "value": 10.0, - }) - createResp.AssertSuccess(t) + created, cleanup := createTestRedemptions(t, client, count, "quota") + defer cleanup() - // Verify created count - var createData struct { - Created int `json:"created"` - } - createResp.DecodeData(t, &createData) - - if createData.Created != count { - t.Fatalf("expected %d created, got %d", count, createData.Created) + if created != count { + t.Fatalf("expected %d created, got %d", count, created) } - t.Logf("Batch created %d redemption codes", createData.Created) + t.Logf("Batch created %d redemption codes", created) } func TestRedemptionDisable(t *testing.T) { client := testinfra.GetAuthedClient(t) // Create redemptions and find one to disable - createResp := client.Post("/api/admin/redemptions", map[string]any{ - "count": 1, - "type": "quota", - "value": 5.0, - }) - createResp.AssertSuccess(t) + _, cleanup := createTestRedemptions(t, client, 1, "quota") + defer cleanup() // Find the created code in the list listResp := client.Get("/api/admin/redemptions", map[string]string{ @@ -188,6 +174,7 @@ func TestRedemptionNegative(t *testing.T) { } // createTestRedemptions is a test helper that batch-creates redemption codes and returns count and cleanup. +// The cleanup disables the created codes after the test. func createTestRedemptions(t *testing.T, client *testinfra.APIClient, count int, redemptionType string) (int, func()) { t.Helper() resp := client.Post("/api/admin/redemptions", map[string]any{ @@ -202,7 +189,34 @@ func createTestRedemptions(t *testing.T, client *testinfra.APIClient, count int, } resp.DecodeData(t, &data) + // Find the newly created codes by listing active codes (most recent = highest IDs) + var ids []int64 + listResp := client.Get("/api/admin/redemptions", map[string]string{ + "page": "1", + "page_size": "50", + "status": "active", + }) + if listResp.Code == 0 { + var listData struct { + List []struct { + Id int64 `json:"id"` + } `json:"list"` + } + listResp.DecodeData(t, &listData) + + // Take the last `count` items (most recently created) + start := len(listData.List) - data.Created + if start < 0 { + start = 0 + } + for i := start; i < len(listData.List); i++ { + ids = append(ids, listData.List[i].Id) + } + } + return data.Created, func() { - // No individual cleanup needed — redemption codes are consumed or disabled + for _, id := range ids { + testinfra.HardDeleteRedemption(t, id) + } } } diff --git a/tests/integration/admin/testinfra/auth.go b/tests/integration/admin/testinfra/auth.go index 9fc3fbb..785ed2e 100644 --- a/tests/integration/admin/testinfra/auth.go +++ b/tests/integration/admin/testinfra/auth.go @@ -14,7 +14,7 @@ import ( func getRedisClient() *redis.Client { return redis.NewClient(&redis.Options{ - Addr: DefaultRedisAddr, + Addr: GetRedisAddr(), DB: 0, DialTimeout: 3 * time.Second, ReadTimeout: 3 * time.Second, @@ -31,6 +31,9 @@ type LoginResult struct { func Authenticate(t *testing.T) *LoginResult { t.Helper() + if DefaultUsername == "" || DefaultPassword == "" { + t.Fatal("TEST_ADMIN_USERNAME and TEST_ADMIN_PASSWORD environment variables must be set") + } return AuthenticateWithCreds(t, DefaultUsername, DefaultPassword) } diff --git a/tests/integration/admin/testinfra/config.go b/tests/integration/admin/testinfra/config.go index e8e73f6..b1f712f 100644 --- a/tests/integration/admin/testinfra/config.go +++ b/tests/integration/admin/testinfra/config.go @@ -2,9 +2,33 @@ package testinfra -const ( - DefaultBaseURL = "http://127.0.0.1:18888" - DefaultUsername = "qian" - DefaultPassword = "Qian123456" - DefaultRedisAddr = "192.168.50.22:16380" +import ( + "os" + + "github.com/gogf/gf/v2/frame/g" ) + +var ( + DefaultBaseURL = getEnvOrDefault("TEST_BASE_URL", "http://127.0.0.1:18888") + DefaultUsername = getEnvOrDefault("TEST_ADMIN_USERNAME", "") + DefaultPassword = getEnvOrDefault("TEST_ADMIN_PASSWORD", "") +) + +// getEnvOrDefault returns the environment variable value or the fallback. +func getEnvOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// GetRedisAddr returns the Redis address from system config, or falls back to env/empty. +func GetRedisAddr() string { + if v := os.Getenv("TEST_REDIS_ADDR"); v != "" { + return v + } + if v, err := g.Cfg().Get(nil, "redis.default.address"); err == nil && !v.IsNil() { + return v.String() + } + return "" +} diff --git a/tests/integration/admin/testinfra/db_cleanup.go b/tests/integration/admin/testinfra/db_cleanup.go new file mode 100644 index 0000000..8bf6db0 --- /dev/null +++ b/tests/integration/admin/testinfra/db_cleanup.go @@ -0,0 +1,166 @@ +//go:build integration + +package testinfra + +import ( + "context" + "fmt" + "testing" + + "github.com/gogf/gf/v2/frame/g" +) + +// HardDeleteTenant cascade-deletes a tenant and ALL associated data from the database. +// Tables are deleted in dependency order (leaf → mid → main). +func HardDeleteTenant(t *testing.T, tenantID int64) { + t.Helper() + t.Logf("cleanup: hard deleting tenant %d", tenantID) + ctx := context.Background() + db := g.DB() + + // Leaf tables (no dependents) + for _, table := range tenantLeafTables { + db.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE tenant_id = $1", table), tenantID) + } + // Mid-tier tables + for _, table := range tenantMidTables { + db.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE tenant_id = $1", table), tenantID) + } + // Tenant-model associations + for _, table := range tenantAssocTables { + db.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE tenant_id = $1", table), tenantID) + } + // Nullable tenant_id tables + for _, table := range tenantNullableTables { + db.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE tenant_id = $1", table), tenantID) + } + // Main table + db.Exec(ctx, "DELETE FROM tnt_tenants WHERE id = $1", tenantID) +} + +var tenantLeafTables = []string{ + "api_key_model_scopes", + "ntf_read_status", + "tnt_member_model_scopes", + "ord_promo_code_usages", + "ord_redemption_usages", +} + +var tenantMidTables = []string{ + "api_keys", + "tnt_users", + "tnt_invitations", + "tnt_member_imports", + "tnt_oauth_identities", + "tnt_projects", + "tnt_tenant_plugins", + "bil_records", + "bil_transactions", + "bil_usage_logs", + "bil_prededuct_tracks", + "bil_daily_usage_summary", + "bil_monthly_usage_summary", + "ord_orders", + "ord_refunds", + "pln_tenant_plans", + "pln_feature_flags", + "ntf_messages", + "ntf_preferences", + "ntf_send_log", + "opn_apps", + "opn_webhook_configs", + "opn_webhook_events", + "spt_tickets", + "spt_feedbacks", + "tsk_model_tasks", + "aud_request_logs", + "aud_operation_logs", + "aud_content_filter_logs", + "chn_channel_affinities", + "chn_error_events", + "fil_files", + "plg_example_logs", + "bil_wallets", +} + +var tenantAssocTables = []string{ + "mdl_tenant_models", + "mdl_tenant_groups", +} + +var tenantNullableTables = []string{ + "aud_login_history", + "aud_sensitive_access_logs", + "bil_daily_revenue_summary", + "bil_monthly_revenue_summary", + "sys_sessions", +} + +// HardDeleteAnnouncement deletes an announcement from the database. +func HardDeleteAnnouncement(t *testing.T, id int64) { + t.Helper() + g.DB().Exec(context.Background(), "DELETE FROM ntf_announcements WHERE id = $1", id) +} + +// HardDeleteMessage deletes a notification message and its read statuses from the database. +func HardDeleteMessage(t *testing.T, id int64) { + t.Helper() + ctx := context.Background() + db := g.DB() + db.Exec(ctx, "DELETE FROM ntf_read_status WHERE message_id = $1", id) + db.Exec(ctx, "DELETE FROM ntf_messages WHERE id = $1", id) +} + +// HardDeleteMessagesByTitle deletes notification messages matching a title prefix and their read statuses. +func HardDeleteMessagesByTitle(t *testing.T, titlePrefix string) { + t.Helper() + ctx := context.Background() + db := g.DB() + + var ids []int64 + err := db.Model("ntf_messages").Ctx(ctx). + Fields("id"). + Where("title LIKE ?", titlePrefix+"%"). + Scan(&ids) + if err != nil { + return + } + for _, id := range ids { + db.Exec(ctx, "DELETE FROM ntf_read_status WHERE message_id = $1", id) + db.Exec(ctx, "DELETE FROM ntf_messages WHERE id = $1", id) + } +} + +// HardDeletePromoCode deletes a promo code and its usages from the database. +func HardDeletePromoCode(t *testing.T, id int64) { + t.Helper() + ctx := context.Background() + db := g.DB() + db.Exec(ctx, "DELETE FROM ord_promo_code_usages WHERE promo_code_id = $1", id) + db.Exec(ctx, "DELETE FROM ord_promo_codes WHERE id = $1", id) +} + +// HardDeleteRedemption deletes a redemption code and its usages from the database. +func HardDeleteRedemption(t *testing.T, id int64) { + t.Helper() + ctx := context.Background() + db := g.DB() + db.Exec(ctx, "DELETE FROM ord_redemption_usages WHERE redemption_id = $1", id) + db.Exec(ctx, "DELETE FROM ord_redemptions WHERE id = $1", id) +} + +// HardDeleteFeedback deletes a feedback from the database. +func HardDeleteFeedback(t *testing.T, id int64) { + t.Helper() + g.DB().Exec(context.Background(), "DELETE FROM spt_feedbacks WHERE id = $1", id) +} + +// HardDeleteTicket deletes a ticket, its replies and attachments from the database. +func HardDeleteTicket(t *testing.T, id int64) { + t.Helper() + ctx := context.Background() + db := g.DB() + db.Exec(ctx, "DELETE FROM spt_replies WHERE ticket_id = $1", id) + db.Exec(ctx, "DELETE FROM spt_attachments WHERE ticket_id = $1", id) + db.Exec(ctx, "DELETE FROM spt_tickets WHERE id = $1", id) +} diff --git a/tests/integration/admin/testinfra/fixture.go b/tests/integration/admin/testinfra/fixture.go index de72b6d..086da88 100644 --- a/tests/integration/admin/testinfra/fixture.go +++ b/tests/integration/admin/testinfra/fixture.go @@ -20,7 +20,7 @@ func CreateTestAdminUser(t *testing.T, client *APIClient) (id int64, cleanup fun username := fmt.Sprintf("testuser%s", randomSuffix()) resp := client.Post("/api/admin/users", map[string]any{ "username": username, - "password": "TestPass123!", + "password": DefaultPassword, "email": fmt.Sprintf("%s@test.com", username), "role": "admin", }) @@ -39,14 +39,12 @@ func CreateTestTenant(t *testing.T, client *APIClient) (id int64, cleanup func() "tenant_code": code, "username": fmt.Sprintf("owner%s", randomSuffix()), "email": fmt.Sprintf("owner-%s@test.com", code), - "password": "OwnerPass123!", + "password": DefaultPassword, }) resp.AssertSuccess(t) id = resp.GetID(t) return id, func() { - client.Put(fmt.Sprintf("/api/admin/tenants/%d/status", id), map[string]any{ - "status": "closed", - }) + HardDeleteTenant(t, id) } } diff --git a/tests/integration/admin/ticket_test.go b/tests/integration/admin/ticket_test.go index 7b4fb80..0a4f837 100644 --- a/tests/integration/admin/ticket_test.go +++ b/tests/integration/admin/ticket_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/qianfree/team-api/tests/integration/admin/testinfra" + admintest "github.com/qianfree/team-api/tests/integration/admin/testinfra" + tenanttest "github.com/qianfree/team-api/tests/integration/tenant/testinfra" ) func TestTicketList(t *testing.T) { @@ -39,7 +41,7 @@ func TestTicketListWithFilters(t *testing.T) { resp.AssertSuccess(t) // Filter by tenant_id - tenantID, cleanup := testinfra.CreateTestTenant(t, client) + tenantID, cleanup := createTestTenantWithTicket(t, client) defer cleanup() resp = client.Get("/api/admin/tickets", map[string]string{ @@ -59,34 +61,12 @@ func TestTicketListWithFilters(t *testing.T) { resp.AssertSuccess(t) } -// Note: Ticket creation happens through the tenant console, not the admin console. -// The admin can only view, reply, assign, and update ticket status. -// These tests verify the admin-side operations assuming tickets exist in the system. - func TestTicketDetail(t *testing.T) { client := testinfra.GetAuthedClient(t) - // List tickets first to find one - listResp := client.Get("/api/admin/tickets", map[string]string{ - "page": "1", - "page_size": "10", - }) - listResp.AssertSuccess(t) - - var listData struct { - List []struct { - Id int64 `json:"id"` - Status string `json:"status"` - } `json:"list"` - Total int `json:"total"` - } - listResp.DecodeData(t, &listData) - - if len(listData.List) == 0 { - t.Skip("No tickets found, skipping detail test") - } - - ticketID := listData.List[0].Id + // Create test ticket via tenant + ticketID, cleanup := createTestTicketViaTenant(t, client) + defer cleanup() // Get ticket detail detailResp := client.Get(fmt.Sprintf("/api/admin/tickets/%d", ticketID), nil) @@ -114,25 +94,9 @@ func TestTicketDetail(t *testing.T) { func TestTicketReply(t *testing.T) { client := testinfra.GetAuthedClient(t) - // Find a ticket to reply to - listResp := client.Get("/api/admin/tickets", map[string]string{ - "page": "1", - "page_size": "10", - }) - listResp.AssertSuccess(t) - - var listData struct { - List []struct { - Id int64 `json:"id"` - } `json:"list"` - } - listResp.DecodeData(t, &listData) - - if len(listData.List) == 0 { - t.Skip("No tickets found, skipping reply test") - } - - ticketID := listData.List[0].Id + // Create test ticket via tenant + ticketID, cleanup := createTestTicketViaTenant(t, client) + defer cleanup() // Reply to the ticket replyContent := fmt.Sprintf("管理员回复(集成测试)%s", randomSuffix()) @@ -147,25 +111,9 @@ func TestTicketReply(t *testing.T) { func TestTicketAssign(t *testing.T) { client := testinfra.GetAuthedClient(t) - // Find a ticket to assign - listResp := client.Get("/api/admin/tickets", map[string]string{ - "page": "1", - "page_size": "10", - }) - listResp.AssertSuccess(t) - - var listData struct { - List []struct { - Id int64 `json:"id"` - } `json:"list"` - } - listResp.DecodeData(t, &listData) - - if len(listData.List) == 0 { - t.Skip("No tickets found, skipping assign test") - } - - ticketID := listData.List[0].Id + // Create test ticket via tenant + ticketID, cleanup := createTestTicketViaTenant(t, client) + defer cleanup() // Get current admin user list to find an admin ID adminListResp := client.Get("/api/admin/users", map[string]string{ @@ -212,27 +160,9 @@ func TestTicketAssign(t *testing.T) { func TestTicketStatusUpdate(t *testing.T) { client := testinfra.GetAuthedClient(t) - // Find a ticket - listResp := client.Get("/api/admin/tickets", map[string]string{ - "page": "1", - "page_size": "10", - "status": "pending", - }) - listResp.AssertSuccess(t) - - var listData struct { - List []struct { - Id int64 `json:"id"` - Status string `json:"status"` - } `json:"list"` - } - listResp.DecodeData(t, &listData) - - if len(listData.List) == 0 { - t.Skip("No pending tickets found, skipping status update test") - } - - ticketID := listData.List[0].Id + // Create test ticket via tenant + ticketID, cleanup := createTestTicketViaTenant(t, client) + defer cleanup() // Update status to processing updateResp := client.Put(fmt.Sprintf("/api/admin/tickets/%d/status", ticketID), map[string]any{ @@ -292,36 +222,70 @@ func TestTicketNegative(t *testing.T) { t.Fatal("expected error when replying to non-existent ticket, got success") } - // Reply with empty content - listResp := client.Get("/api/admin/tickets", map[string]string{ - "page": "1", - "page_size": "1", + // Reply with empty content — test against a real ticket + ticketID, cleanup := createTestTicketViaTenant(t, client) + defer cleanup() + + emptyReplyResp := client.Post(fmt.Sprintf("/api/admin/tickets/%d/reply", ticketID), map[string]any{ + "content": "", }) - listResp.AssertSuccess(t) - - var listData struct { - List []struct { - Id int64 `json:"id"` - } `json:"list"` - } - listResp.DecodeData(t, &listData) - - if len(listData.List) > 0 { - emptyReplyResp := client.Post(fmt.Sprintf("/api/admin/tickets/%d/reply", listData.List[0].Id), map[string]any{ - "content": "", - }) - if emptyReplyResp.Code == 0 { - t.Fatal("expected error for empty reply content, got success") - } + if emptyReplyResp.Code == 0 { + t.Fatal("expected error for empty reply content, got success") } // Invalid status value - if len(listData.List) > 0 { - invalidStatusResp := client.Put(fmt.Sprintf("/api/admin/tickets/%d/status", listData.List[0].Id), map[string]any{ - "status": "invalid_status", - }) - if invalidStatusResp.Code == 0 { - t.Fatal("expected error for invalid ticket status, got success") - } + invalidStatusResp := client.Put(fmt.Sprintf("/api/admin/tickets/%d/status", ticketID), map[string]any{ + "status": "invalid_status", + }) + if invalidStatusResp.Code == 0 { + t.Fatal("expected error for invalid ticket status, got success") + } +} + +// createTestTicketViaTenant creates a test tenant, creates a ticket via the tenant API, +// and returns the ticket ID and cleanup. +func createTestTicketViaTenant(t *testing.T, adminClient *admintest.APIClient) (ticketID int64, cleanup func()) { + t.Helper() + + result := tenanttest.RegisterTestTenant(t) + tenantClient := admintest.NewAPIClient(tenanttest.DefaultBaseURL).WithToken(result.AccessToken) + + suffix := tenanttest.RandomSuffix() + resp := tenantClient.Post("/api/tenant/tickets", map[string]any{ + "category": "technical", + "title": fmt.Sprintf("测试工单 %s", suffix), + "description": "集成测试自动创建的工单", + "urgency": "normal", + }) + resp.AssertSuccess(t) + + var data struct { + ID int64 `json:"id"` + } + resp.DecodeData(t, &data) + + return data.ID, func() { + // Tenant cleanup is handled by t.Cleanup in RegisterTestTenant + } +} + +// createTestTenantWithTicket creates a test tenant with a ticket for filter tests. +func createTestTenantWithTicket(t *testing.T, adminClient *admintest.APIClient) (tenantID int64, cleanup func()) { + t.Helper() + + result := tenanttest.RegisterTestTenant(t) + tenantClient := admintest.NewAPIClient(tenanttest.DefaultBaseURL).WithToken(result.AccessToken) + + suffix := tenanttest.RandomSuffix() + resp := tenantClient.Post("/api/tenant/tickets", map[string]any{ + "category": "billing", + "title": fmt.Sprintf("过滤测试工单 %s", suffix), + "description": "过滤测试自动创建的工单", + "urgency": "normal", + }) + resp.AssertSuccess(t) + + return result.Tenant.ID, func() { + // Tenant cleanup is handled by t.Cleanup in RegisterTestTenant } } diff --git a/tests/integration/tenant/feedback_test.go b/tests/integration/tenant/feedback_test.go index ae109a2..479a1eb 100644 --- a/tests/integration/tenant/feedback_test.go +++ b/tests/integration/tenant/feedback_test.go @@ -12,18 +12,10 @@ import ( func TestFeedbackCreate(t *testing.T) { client, _ := testinfra.GetAuthedClient(t) - resp := client.Post("/api/tenant/feedbacks", map[string]any{ - "category": "bug_report", - "title": fmt.Sprintf("测试反馈 %s", testinfra.RandomSuffix()), - "description": "这是一个集成测试创建的反馈", - }) - resp.AssertSuccess(t) + feedbackID, cleanup := testinfra.CreateTestFeedback(t, client) + defer cleanup() - var data struct { - ID int64 `json:"id"` - } - resp.DecodeData(t, &data) - if data.ID == 0 { + if feedbackID == 0 { t.Fatal("expected non-zero feedback id") } } @@ -32,12 +24,8 @@ func TestFeedbackList(t *testing.T) { client, _ := testinfra.GetAuthedClient(t) // Create a feedback first - createResp := client.Post("/api/tenant/feedbacks", map[string]any{ - "category": "suggestion", - "title": fmt.Sprintf("列表测试反馈 %s", testinfra.RandomSuffix()), - "description": "测试反馈列表功能", - }) - createResp.AssertSuccess(t) + _, cleanup := testinfra.CreateTestFeedback(t, client) + defer cleanup() // List feedbacks resp := client.Get("/api/tenant/feedbacks", map[string]string{ @@ -65,16 +53,19 @@ func TestFeedbackListWithFilters(t *testing.T) { client, _ := testinfra.GetAuthedClient(t) // Create feedbacks with different categories - client.Post("/api/tenant/feedbacks", map[string]any{ - "category": "bug_report", - "title": "Bug反馈过滤测试", - "description": "测试按分类过滤", - }) - client.Post("/api/tenant/feedbacks", map[string]any{ + _, cleanup1 := testinfra.CreateTestFeedback(t, client) + defer cleanup1() + + // Create a feature_request feedback inline + suffix := testinfra.RandomSuffix() + createResp := client.Post("/api/tenant/feedbacks", map[string]any{ "category": "feature_request", - "title": "功能请求过滤测试", + "title": fmt.Sprintf("[集成测试] 功能请求过滤测试 %s", suffix), "description": "测试按分类过滤", }) + createResp.AssertSuccess(t) + featureID := createResp.GetID(t) + _ = featureID // feedback cleanup handled by tenant closure // Filter by category resp := client.Get("/api/tenant/feedbacks", map[string]string{ @@ -88,10 +79,11 @@ func TestFeedbackListWithFilters(t *testing.T) { func TestFeedbackGet(t *testing.T) { client, _ := testinfra.GetAuthedClient(t) - // Create a feedback + // Create a feedback with specific category for detail verification + suffix := testinfra.RandomSuffix() createResp := client.Post("/api/tenant/feedbacks", map[string]any{ "category": "complaint", - "title": "详情测试反馈", + "title": fmt.Sprintf("[集成测试] 详情测试反馈 %s", suffix), "description": "测试获取反馈详情", }) createResp.AssertSuccess(t) diff --git a/tests/integration/tenant/isolation_test.go b/tests/integration/tenant/isolation_test.go index d0884df..05165b2 100644 --- a/tests/integration/tenant/isolation_test.go +++ b/tests/integration/tenant/isolation_test.go @@ -45,7 +45,6 @@ func TestIsolation_MemberListIsolated(t *testing.T) { // 租户 A 创建成员 memberIDA, _ := testinfra.CreateTestTenantMember(t, clientA) - // 不 cleanup,测试结束后无关紧要 // 验证 A 能看到自己创建的成员 listA := clientA.Get("/api/tenant/members", map[string]string{"page": "1", "page_size": "100"}) diff --git a/tests/integration/tenant/project_test.go b/tests/integration/tenant/project_test.go index 4a3d0d7..adce8cd 100644 --- a/tests/integration/tenant/project_test.go +++ b/tests/integration/tenant/project_test.go @@ -74,7 +74,8 @@ func TestProjectCRUD(t *testing.T) { func TestProjectArchiveUnarchive(t *testing.T) { client, _ := testinfra.GetAuthedClient(t) - projectID, _ := testinfra.CreateTestProject(t, client) + projectID, cleanup := testinfra.CreateTestProject(t, client) + defer cleanup() // Archive archiveResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil) @@ -205,7 +206,8 @@ func TestProjectCreate_NegativeBudget(t *testing.T) { func TestProjectUpdate_ArchivedProject(t *testing.T) { client, _ := testinfra.GetAuthedClient(t) - projectID, _ := testinfra.CreateTestProject(t, client) + projectID, cleanup := testinfra.CreateTestProject(t, client) + defer cleanup() // 先归档 archiveResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil) diff --git a/tests/integration/tenant/rbac_test.go b/tests/integration/tenant/rbac_test.go index c07b310..3dc3183 100644 --- a/tests/integration/tenant/rbac_test.go +++ b/tests/integration/tenant/rbac_test.go @@ -25,7 +25,9 @@ func setupMemberClient(t *testing.T) (memberClient, ownerClient *admintest.APICl t.Helper() ownerClient, tenantResult = testinfra.GetAuthedClient(t) - memberID, _ = testinfra.CreateTestTenantMember(t, ownerClient) + var memberCleanup func() + memberID, memberCleanup = testinfra.CreateTestTenantMember(t, ownerClient) + t.Cleanup(memberCleanup) // 获取 member 用户名 detailResp := ownerClient.Get(fmt.Sprintf("/api/tenant/members/%d", memberID), nil) @@ -46,7 +48,9 @@ func setupAdminClient(t *testing.T) (adminClient, ownerClient *admintest.APIClie t.Helper() ownerClient, tenantResult = testinfra.GetAuthedClient(t) - memberID, _ := testinfra.CreateTestTenantMember(t, ownerClient) + var memberCleanup func() + memberID, memberCleanup := testinfra.CreateTestTenantMember(t, ownerClient) + t.Cleanup(memberCleanup) // 升级为 admin ownerClient.Put(fmt.Sprintf("/api/tenant/members/%d/role", memberID), map[string]any{"role": "admin"}). diff --git a/tests/integration/tenant/testinfra/auth.go b/tests/integration/tenant/testinfra/auth.go index 6d543f0..c5a0bd6 100644 --- a/tests/integration/tenant/testinfra/auth.go +++ b/tests/integration/tenant/testinfra/auth.go @@ -24,7 +24,7 @@ func RandomSuffix() string { func getRedisClient() *redis.Client { return redis.NewClient(&redis.Options{ - Addr: DefaultRedisAddr, + Addr: GetRedisAddr(), DB: 0, DialTimeout: 3 * time.Second, ReadTimeout: 3 * time.Second, @@ -89,6 +89,12 @@ type TenantRegisterResult struct { Email string } +// deleteTenantViaDB hard-deletes a tenant and all associated data from the database. +// Used as t.Cleanup callback — logs errors instead of failing the test. +func deleteTenantViaDB(t *testing.T, tenantID int64) { + HardDeleteTenant(t, tenantID) +} + func RegisterTestTenant(t *testing.T) *TenantRegisterResult { t.Helper() @@ -118,6 +124,11 @@ func RegisterTestTenant(t *testing.T) *TenantRegisterResult { result.Password = TestPassword result.Email = email + // Automatically hard-delete the tenant when the test completes + t.Cleanup(func() { + deleteTenantViaDB(t, result.Tenant.ID) + }) + return &result } diff --git a/tests/integration/tenant/testinfra/config.go b/tests/integration/tenant/testinfra/config.go index 62b48cc..146e18e 100644 --- a/tests/integration/tenant/testinfra/config.go +++ b/tests/integration/tenant/testinfra/config.go @@ -2,8 +2,31 @@ package testinfra -const ( - DefaultBaseURL = "http://127.0.0.1:18888" - DefaultRedisAddr = "192.168.50.22:16380" - TestPassword = "TestPass123!" +import ( + "os" + + "github.com/gogf/gf/v2/frame/g" ) + +var ( + DefaultBaseURL = getEnvOrDefault("TEST_BASE_URL", "http://127.0.0.1:18888") + TestPassword = getEnvOrDefault("TEST_TENANT_PASSWORD", "TestPass123!") +) + +func getEnvOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// GetRedisAddr returns the Redis address from system config, or falls back to env/empty. +func GetRedisAddr() string { + if v := os.Getenv("TEST_REDIS_ADDR"); v != "" { + return v + } + if v, err := g.Cfg().Get(nil, "redis.default.address"); err == nil && !v.IsNil() { + return v.String() + } + return "" +} diff --git a/tests/integration/tenant/testinfra/db_cleanup.go b/tests/integration/tenant/testinfra/db_cleanup.go new file mode 100644 index 0000000..510a324 --- /dev/null +++ b/tests/integration/tenant/testinfra/db_cleanup.go @@ -0,0 +1,97 @@ +//go:build integration + +package testinfra + +import ( + "context" + "fmt" + "testing" + + "github.com/gogf/gf/v2/frame/g" +) + +// HardDeleteTenant cascade-deletes a tenant and ALL associated data from the database. +func HardDeleteTenant(t *testing.T, tenantID int64) { + t.Helper() + t.Logf("cleanup: hard deleting tenant %d", tenantID) + ctx := context.Background() + db := g.DB() + + for _, table := range tenantLeafTables { + db.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE tenant_id = $1", table), tenantID) + } + for _, table := range tenantMidTables { + db.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE tenant_id = $1", table), tenantID) + } + for _, table := range tenantAssocTables { + db.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE tenant_id = $1", table), tenantID) + } + for _, table := range tenantNullableTables { + db.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE tenant_id = $1", table), tenantID) + } + db.Exec(ctx, "DELETE FROM tnt_tenants WHERE id = $1", tenantID) +} + +var tenantLeafTables = []string{ + "api_key_model_scopes", + "ntf_read_status", + "tnt_member_model_scopes", + "ord_promo_code_usages", + "ord_redemption_usages", +} + +var tenantMidTables = []string{ + "api_keys", + "tnt_users", + "tnt_invitations", + "tnt_member_imports", + "tnt_oauth_identities", + "tnt_projects", + "tnt_tenant_plugins", + "bil_records", + "bil_transactions", + "bil_usage_logs", + "bil_prededuct_tracks", + "bil_daily_usage_summary", + "bil_monthly_usage_summary", + "ord_orders", + "ord_refunds", + "pln_tenant_plans", + "pln_feature_flags", + "ntf_messages", + "ntf_preferences", + "ntf_send_log", + "opn_apps", + "opn_webhook_configs", + "opn_webhook_events", + "spt_tickets", + "spt_feedbacks", + "tsk_model_tasks", + "aud_request_logs", + "aud_operation_logs", + "aud_content_filter_logs", + "chn_channel_affinities", + "chn_error_events", + "fil_files", + "plg_example_logs", + "bil_wallets", +} + +var tenantAssocTables = []string{ + "mdl_tenant_models", + "mdl_tenant_groups", +} + +var tenantNullableTables = []string{ + "aud_login_history", + "aud_sensitive_access_logs", + "bil_daily_revenue_summary", + "bil_monthly_revenue_summary", + "sys_sessions", +} + +// HardDeleteFeedback deletes a feedback from the database. +func HardDeleteFeedback(t *testing.T, id int64) { + t.Helper() + g.DB().Exec(context.Background(), "DELETE FROM spt_feedbacks WHERE id = $1", id) +} diff --git a/tests/integration/tenant/testinfra/fixture.go b/tests/integration/tenant/testinfra/fixture.go index 39a7a28..dabdf02 100644 --- a/tests/integration/tenant/testinfra/fixture.go +++ b/tests/integration/tenant/testinfra/fixture.go @@ -79,3 +79,18 @@ func CreateTestProject(t *testing.T, client *admintest.APIClient) (id int64, cle client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", id), nil) } } + +func CreateTestFeedback(t *testing.T, client *admintest.APIClient) (id int64, cleanup func()) { + t.Helper() + suffix := RandomSuffix() + resp := client.Post("/api/tenant/feedbacks", map[string]any{ + "category": "bug_report", + "title": fmt.Sprintf("[集成测试] 反馈 %s", suffix), + "description": "集成测试自动创建的反馈", + }) + resp.AssertSuccess(t) + id = resp.GetID(t) + return id, func() { + HardDeleteFeedback(t, id) + } +} diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index 8a37bdf..fc5e0de 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -311,6 +311,194 @@ const { exporting, exportFile } = useExport({ search: filterSearch.value, }), }) + +// ===== 模型导入导出 ===== + +const selectedRowKeys = ref([]) +const exportingJson = ref(false) + +function handleSelectionChange(keys: number[]) { + selectedRowKeys.value = keys +} + +async function exportJson() { + if (selectedRowKeys.value.length === 0) { + Message.warning('请先选择要导出的模型') + return + } + // 获取选中行的 model_id + const modelIds = data.value + .filter((row: any) => selectedRowKeys.value.includes(row.id)) + .map((row: any) => row.model_id) + + if (modelIds.length === 0) { + Message.warning('请先选择要导出的模型') + return + } + + exportingJson.value = true + try { + const res: any = await request.post('/admin/models/export-json', + { model_ids: modelIds }, + { responseType: 'blob', timeout: 300000 }, + ) + const blob = res.data instanceof Blob ? res.data : new Blob([res.data], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + const disposition = res.headers?.['content-disposition'] || '' + const match = disposition.match(/filename\*=UTF-8''(.+)/) + a.download = match ? decodeURIComponent(match[1]) : 'models.json' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + Message.success(`已导出 ${modelIds.length} 个模型`) + } catch { + // error handled by interceptor + } finally { + exportingJson.value = false + } +} + +// 导入相关 +const showImportModal = ref(false) +const importPreviewData = ref([]) +const importPreviewing = ref(false) +const importing = ref(false) +const importSelectedKeys = ref([]) +const importConflictActions = ref>({}) + +const importColumns: TableColumnData[] = [ + { + title: '模型标识', + dataIndex: 'model_id', + width: 180, + ellipsis: true, + }, + { + title: '显示名', + dataIndex: 'model_name', + width: 160, + ellipsis: true, + }, + { + title: '分类', + dataIndex: 'category', + width: 80, + render({ record }) { + return h(Tag, { color: categoryTagColor[record.category], size: 'small' }, () => categoryTagLabel[record.category] || record.category) + }, + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + }, + { + title: '冲突', + dataIndex: 'conflict', + width: 100, + render({ record }) { + if (record.conflict === 'exists') { + return h(Tag, { color: 'orangered', size: 'small' }, () => '已存在') + } + return h(Tag, { color: 'green', size: 'small' }, () => '新增') + }, + }, + { + title: '冲突处理', + dataIndex: 'conflict_action', + width: 120, + render({ record }, { emit }: any) { + if (record.conflict !== 'exists') return null + return h('select', { + value: importConflictActions.value[record.model_id] || 'skip', + onChange: (e: Event) => { + importConflictActions.value[record.model_id] = (e.target as HTMLSelectElement).value + }, + style: 'width: 100%; padding: 2px 8px; border: 1px solid var(--color-border); border-radius: 4px; font-size: 13px; background: var(--color-bg-2); color: var(--color-text-1);', + }, [ + h('option', { value: 'skip' }, '跳过'), + h('option', { value: 'overwrite' }, '覆盖'), + ]) + }, + }, +] + +function openImportModal() { + importPreviewData.value = [] + importSelectedKeys.value = [] + importConflictActions.value = {} + showImportModal.value = true +} + +async function handleImportUpload(fileItem: any) { + const file = fileItem.fileItem?.file || fileItem + if (!file) return + + importPreviewing.value = true + try { + const formData = new FormData() + formData.append('file', file) + const res: any = await request.post('/admin/models/import-preview', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 30000, + }) + const models = res.data?.data?.models || res.data?.models || [] + importPreviewData.value = models + // 默认全选 + importSelectedKeys.value = models.map((m: any) => m.model_id) + // 冲突模型默认跳过 + for (const m of models) { + if (m.conflict === 'exists') { + importConflictActions.value[m.model_id] = 'skip' + } + } + } catch { + // error handled by interceptor + } finally { + importPreviewing.value = false + } +} + +async function confirmImport() { + const selectedModels = importPreviewData.value + .filter((m: any) => importSelectedKeys.value.includes(m.model_id)) + .map((m: any) => ({ + ...m, + conflict_action: m.conflict === 'exists' + ? (importConflictActions.value[m.model_id] || 'skip') + : 'skip', + })) + + if (selectedModels.length === 0) { + Message.warning('请至少选择一个模型') + return + } + + importing.value = true + try { + const res: any = await request.post('/admin/models/import', { models: selectedModels }) + const result = res.data?.data || res.data + Message.success(`导入完成:成功 ${result.imported} 个,跳过 ${result.skipped} 个`) + if (result.errors?.length) { + Message.warning(result.errors.join('; ')) + } + showImportModal.value = false + fetchData() + } catch { + // error handled by interceptor + } finally { + importing.value = false + } +} + +function resetImport() { + importPreviewData.value = [] + importSelectedKeys.value = [] + importConflictActions.value = {} +}