Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
chengqian committed 2026-05-29 21:55:48 +08:00
commit 6cc69dac32
52 files changed
+2039 -517

No files matched your search

+4
View File
@@ -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)
+66
View File
@@ -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"`
}
+21
View File
@@ -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"
}
+7
View File
@@ -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
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
+2 -2
View File
@@ -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
+11
View File
@@ -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,
+16 -25
View File
@@ -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
}
+3 -1
View File
@@ -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
+7 -8
View File
@@ -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).
+1 -2
View File
@@ -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))
+325
View File
@@ -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, ",")
}
+1 -1
View File
@@ -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
}
+24
View File
@@ -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
}
+28
View File
@@ -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
}
+9
View File
@@ -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 全局折扣字段。
+138
View File
@@ -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
}
+4 -1
View File
@@ -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
}
+24 -4
View File
@@ -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
}
+100 -3
View File
@@ -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)
+25 -45
View File
@@ -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
})
}
+237 -177
View File
@@ -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
+8
View File
@@ -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 获取工单详情(管理后台,含回复)
+4 -4
View File
@@ -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;
+8 -1
View File
@@ -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 验证结果
+3 -2
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+7
View File
@@ -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 {
+1 -1
View File
@@ -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,
+57 -44
View File
@@ -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
}
}
+6 -2
View File
@@ -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 ---
+2 -2
View File
@@ -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)
}
}
+36 -22
View File
@@ -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)
}
}
}
+4 -1
View File
@@ -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)
}
+29 -5
View File
@@ -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 ""
}
@@ -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)
}
+3 -5
View File
@@ -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)
}
}
+76 -112
View File
@@ -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
}
}
+18 -26
View File
@@ -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)
@@ -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"})
+4 -2
View File
@@ -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)
+6 -2
View File
@@ -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"}).
+12 -1
View File
@@ -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
}
+27 -4
View File
@@ -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 ""
}
@@ -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)
}
@@ -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)
}
}
+281 -1
View File
@@ -311,6 +311,194 @@ const { exporting, exportFile } = useExport({
search: filterSearch.value,
}),
})
// ===== 模型导入导出 =====
const selectedRowKeys = ref<number[]>([])
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<any[]>([])
const importPreviewing = ref(false)
const importing = ref(false)
const importSelectedKeys = ref<string[]>([])
const importConflictActions = ref<Record<string, string>>({})
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 = {}
}
</script>
<template>
@@ -318,8 +506,17 @@ const { exporting, exportFile } = useExport({
<PageHeader title="模型管理" description="管理 AI 模型配置">
<template #actions>
<AButton type="primary" @click="openCreate">创建模型</AButton>
<AButton
type="outline"
:disabled="selectedRowKeys.length === 0"
:loading="exportingJson"
@click="exportJson"
>
导出配置{{ selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : '' }}
</AButton>
<AButton type="outline" @click="openImportModal">导入配置</AButton>
<ADropdown trigger="hover">
<AButton :loading="exporting">导出</AButton>
<AButton :loading="exporting">导出报表</AButton>
<template #content>
<ADoption @click="exportFile('csv')">导出 CSV</ADoption>
<ADoption @click="exportFile('xlsx')">导出 Excel</ADoption>
@@ -370,6 +567,9 @@ const { exporting, exportFile } = useExport({
:stripe="true"
:pagination="false"
row-key="id"
:row-selection="{ type: 'checkbox', showCheckedAll: true }"
:selected-keys="selectedRowKeys"
@selection-change="handleSelectionChange"
/>
<div class="table-footer">
<APagination
@@ -451,6 +651,75 @@ const { exporting, exportFile } = useExport({
</AFormItem>
</AForm>
</AModal>
<!-- Import Modal -->
<AModal
v-model:visible="showImportModal"
title="导入模型配置"
:width="850"
:footer="false"
:mask-closable="false"
@close="resetImport"
>
<!-- Upload area -->
<div v-if="importPreviewData.length === 0">
<AUpload
draggable
accept=".json"
:auto-upload="false"
:limit="1"
@change="handleImportUpload"
>
<template #upload-button>
<div class="import-upload-area">
<div style="color: var(--color-text-3); font-size: 14px;">
拖拽 JSON 文件到此处,或点击选择文件
</div>
<div style="color: var(--color-text-4); font-size: 12px; margin-top: 4px;">
支持由"导出配置"生成的 JSON 文件
</div>
</div>
</template>
</AUpload>
<div v-if="importPreviewing" style="text-align: center; padding: 20px;">
<ASpin /> 解析中...
</div>
</div>
<!-- Preview table -->
<div v-else>
<div style="margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--color-text-2); font-size: 13px;">
共 {{ importPreviewData.length }} 个模型,已选择 {{ importSelectedKeys.length }} 个
</span>
<ASpace>
<AButton size="small" @click="resetImport">重新选择文件</AButton>
</ASpace>
</div>
<ATable
:columns="importColumns"
:data="importPreviewData"
:pagination="false"
:bordered="false"
:stripe="true"
row-key="model_id"
:row-selection="{ type: 'checkbox', showCheckedAll: true }"
v-model:selectedKeys="importSelectedKeys"
:scroll="{ y: 400 }"
/>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px;">
<AButton @click="showImportModal = false">取消</AButton>
<AButton
type="primary"
:loading="importing"
:disabled="importSelectedKeys.length === 0"
@click="confirmImport"
>
确认导入 ({{ importSelectedKeys.length }})
</AButton>
</div>
</div>
</AModal>
</div>
</template>
@@ -468,4 +737,15 @@ const { exporting, exportFile } = useExport({
gap: 8px 0;
width: 100%;
}
.import-upload-area {
border: 1px dashed var(--color-border-2);
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s;
}
.import-upload-area:hover {
border-color: rgb(var(--primary-6));
}
</style>
@@ -384,6 +384,46 @@ async function handleSaveGroups(done: () => void) {
}
}
// === Available Models Preview ===
const showPreviewModal = ref(false)
const previewLoading = ref(false)
const previewData = ref<any[]>([])
const previewColumns: TableColumnData[] = [
{ title: '模型标识', dataIndex: 'model_id', width: 180, ellipsis: true },
{ title: '显示名', dataIndex: 'model_name', width: 150, ellipsis: true },
{ title: '分类', dataIndex: 'category', width: 80 },
{ title: '上下文', dataIndex: 'max_context_tokens', width: 100,
render({ record }) { return record.max_context_tokens ? record.max_context_tokens.toLocaleString() : '-' },
},
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100,
render({ record }) { return record.max_output_tokens ? record.max_output_tokens.toLocaleString() : '-' },
},
{ title: '来源', dataIndex: 'source', width: 80,
render({ record }) {
const color = record.source === 'explicit' ? 'arcoblue' : 'green'
const label = record.source === 'explicit' ? '独立分配' : '分组'
return h(Tag, { color, size: 'small' }, () => label)
},
},
]
async function fetchAvailableModels() {
previewLoading.value = true
try {
const res: any = await request.get(`/admin/tenants/${tenantId}/available-models`)
previewData.value = res.data?.data?.list || res.data?.list || []
} catch {
} finally {
previewLoading.value = false
}
}
function openPreviewModal() {
showPreviewModal.value = true
fetchAvailableModels()
}
// Load data when switching to models tab
function onTabChange(key: string) {
if (key === 'models') {
@@ -562,6 +602,9 @@ onMounted(() => {
<ASpin :loading="loading" class="w-full">
<template v-if="detail">
<ATabs v-model:active-key="activeTab" @change="onTabChange">
<template #extra>
<AButton v-if="activeTab === 'models'" type="outline" size="small" @click="openPreviewModal">查看可用模型</AButton>
</template>
<!-- Tab 1: Basic Info -->
<ATabPane key="info" title="基本信息">
<ACard :bordered="false" class="mb-4" title="租户信息">
@@ -764,6 +807,23 @@ onMounted(() => {
/>
</AModal>
<!-- Available Models Preview Modal -->
<AModal v-model:visible="showPreviewModal" title="租户可用模型" :width="800" :footer="false">
<div class="mb-3 text-sm" style="color: var(--ta-text-tertiary)">
共 {{ previewData.length }} 个模型
</div>
<ATable
:columns="previewColumns"
:data="previewData"
:loading="previewLoading"
:bordered="false"
:stripe="true"
:pagination="false"
row-key="model_id"
size="small"
/>
</AModal>
<!-- Edit Model Config Drawer -->
<ADrawer
v-model:visible="showEditModelModal"
@@ -100,14 +100,12 @@ async function handleSubmit() {
try {
if (editingId.value) {
await request.put(`/admin/tenant-level-configs/${editingId.value}`, {
update: {
name: formData.name,
cumulative_recharge_threshold: formData.cumulative_recharge_threshold,
max_members: formData.max_members,
max_concurrency: formData.max_concurrency,
price_multiplier: formData.price_multiplier,
sort_order: formData.sort_order,
},
name: formData.name,
cumulative_recharge_threshold: formData.cumulative_recharge_threshold,
max_members: formData.max_members,
max_concurrency: formData.max_concurrency,
price_multiplier: formData.price_multiplier,
sort_order: formData.sort_order,
})
Message.success('更新成功')
} else {