Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
commit
6cc69dac32
52 files changed
+2039
-517
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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, ",")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 全局折扣字段。
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 获取工单详情(管理后台,含回复)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 验证结果
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user