Files
qian 67514a44a3 feat(billing):新增MiniMax特殊计费功能
此功能可以作为示例,后续需要特殊计费模式可参考此功能实现自行二开
2026-09-13 22:14:21 +08:00

307 lines
11 KiB
Go

package admin
import (
"context"
"encoding/json"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
v1 "github.com/qianfree/team-api/api/admin/v1"
"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"
relay "github.com/qianfree/team-api/internal/logic/relay"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/shopspring/decimal"
)
// ListTenantModels 列出租户已分配的模型
func (s *sAdmin) ListTenantModels(ctx context.Context, req *v1.TenantModelListReq) (*v1.TenantModelListRes, error) {
var results []struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
ModelID int64 `json:"model_id"`
Enabled bool `json:"enabled"`
BillingMode *string `json:"billing_mode"`
PerRequestPrice *float64 `json:"per_request_price"`
DiscountRatio *float64 `json:"discount_ratio"`
MaxConcurrency *int `json:"max_concurrency"`
ChannelScope string `json:"channel_scope"`
CustomInputPrice *float64 `json:"custom_input_price"`
CustomOutputPrice *float64 `json:"custom_output_price"`
CustomCacheReadPrice *float64 `json:"custom_cache_read_price"`
CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price"`
CustomPricingTiers string `json:"custom_pricing_tiers"`
Multiplier float64 `json:"multiplier"`
ModelCode string `json:"model_code"`
ModelName string `json:"model_name"`
Category string `json:"category"`
}
err := dao.MdlTenantModels.Ctx(ctx).
LeftJoin("mdl_models ON mdl_tenant_models.model_id = mdl_models.id").
Where("mdl_tenant_models.tenant_id", req.TenantID).
Fields("mdl_tenant_models.id, mdl_tenant_models.tenant_id, mdl_tenant_models.model_id, mdl_tenant_models.enabled, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, mdl_tenant_models.channel_scope, 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, mdl_tenant_models.multiplier, mdl_models.model_id as model_code, mdl_models.model_name, mdl_models.category").
OrderAsc("mdl_models.category").
OrderAsc("mdl_models.model_id").
Scan(&results)
if err != nil {
return nil, err
}
list := make([]v1.TenantModelItem, 0, len(results))
for _, r := range results {
item := v1.TenantModelItem{
ID: r.ID,
TenantID: r.TenantID,
ModelID: r.ModelID,
ModelCode: r.ModelCode,
ModelName: r.ModelName,
Category: r.Category,
Enabled: r.Enabled,
BillingMode: r.BillingMode,
PerRequestPrice: r.PerRequestPrice,
DiscountRatio: r.DiscountRatio,
MaxConcurrency: r.MaxConcurrency,
ChannelScope: r.ChannelScope,
CustomInputPrice: r.CustomInputPrice,
CustomOutputPrice: r.CustomOutputPrice,
CustomCacheReadPrice: r.CustomCacheReadPrice,
CustomCacheCreationPrice: r.CustomCacheCreationPrice,
Multiplier: r.Multiplier,
}
if r.CustomPricingTiers != "" && r.CustomPricingTiers != "null" && r.CustomPricingTiers != "[]" {
var tiers []*v1.PricingTier
if err := json.Unmarshal([]byte(r.CustomPricingTiers), &tiers); err == nil {
item.CustomPricingTiers = tiers
}
}
list = append(list, item)
}
return &v1.TenantModelListRes{List: list}, nil
}
// BatchAssignModels 批量分配模型给租户
func (s *sAdmin) BatchAssignModels(ctx context.Context, req *v1.TenantModelBatchAssignReq) (*v1.TenantModelBatchAssignRes, error) {
assigned := 0
for _, a := range req.Assignments {
g.Log().Debugf(ctx, "BatchAssignModels: tenant_id=%d, model_id=%d, enabled=%v", req.TenantID, a.ModelID, a.Enabled)
var model *struct {
ID int64 `json:"id"`
Status string `json:"status"`
}
err := dao.MdlModels.Ctx(ctx).
Where("id", a.ModelID).
Scan(&model)
if err != nil || model == nil || model.Status != "active" {
g.Log().Warningf(ctx, "BatchAssignModels: skip model_id=%d, err=%v, nil=%v", a.ModelID, err, model == nil)
continue
}
count, countErr := dao.MdlTenantModels.Ctx(ctx).
Where("tenant_id", req.TenantID).
Where("model_id", a.ModelID).
Count()
g.Log().Debugf(ctx, "BatchAssignModels: existing count=%d, countErr=%v", count, countErr)
if count > 0 {
g.Log().Debugf(ctx, "BatchAssignModels: skip already assigned, model_id=%d", a.ModelID)
continue
}
maxConc := a.MaxConcurrency
if maxConc == nil {
concurrency := 5
maxConc = &concurrency
}
var tiersJSON any
if len(a.CustomPricingTiers) > 0 {
b, _ := json.Marshal(a.CustomPricingTiers)
tiersJSON = string(b)
}
// API 边界 *float64 → *decimal.Decimal 转换
var perRequestPriceDecimal, discountRatioDecimal, customInputPriceDecimal, customOutputPriceDecimal, customCacheReadPriceDecimal, customCacheCreationPriceDecimal *decimal.Decimal
if a.PerRequestPrice != nil {
d := billing.NewFromFloat(*a.PerRequestPrice)
perRequestPriceDecimal = &d
}
if a.DiscountRatio != nil {
d := billing.NewFromFloat(*a.DiscountRatio)
discountRatioDecimal = &d
}
if a.CustomInputPrice != nil {
d := billing.NewFromFloat(*a.CustomInputPrice)
customInputPriceDecimal = &d
}
if a.CustomOutputPrice != nil {
d := billing.NewFromFloat(*a.CustomOutputPrice)
customOutputPriceDecimal = &d
}
if a.CustomCacheReadPrice != nil {
d := billing.NewFromFloat(*a.CustomCacheReadPrice)
customCacheReadPriceDecimal = &d
}
if a.CustomCacheCreationPrice != nil {
d := billing.NewFromFloat(*a.CustomCacheCreationPrice)
customCacheCreationPriceDecimal = &d
}
insertData := do.MdlTenantModels{
TenantId: req.TenantID,
ModelId: a.ModelID,
Enabled: a.Enabled,
BillingMode: a.BillingMode,
PerRequestPrice: perRequestPriceDecimal,
DiscountRatio: discountRatioDecimal,
MaxConcurrency: maxConc,
ChannelScope: a.ChannelScope,
CustomInputPrice: customInputPriceDecimal,
CustomOutputPrice: customOutputPriceDecimal,
CustomCacheReadPrice: customCacheReadPriceDecimal,
CustomCacheCreationPrice: customCacheCreationPriceDecimal,
CustomPricingTiers: tiersJSON,
Multiplier: 1.0,
}
g.Log().Debugf(ctx, "BatchAssignModels: inserting data tenant_id=%v, model_id=%v, enabled=%v, max_concurrency=%v", insertData.TenantId, insertData.ModelId, insertData.Enabled, *maxConc)
_, err = dao.MdlTenantModels.Ctx(ctx).Insert(insertData)
if err != nil {
g.Log().Errorf(ctx, "BatchAssignModels: insert failed, model_id=%d, err=%v", a.ModelID, err)
} else {
assigned++
g.Log().Debugf(ctx, "BatchAssignModels: insert success, model_id=%d, assigned=%d", a.ModelID, assigned)
}
}
billing.ClearTenantPriceCache(ctx, req.TenantID)
relay.ClearTenantModelAccessCache(ctx, req.TenantID)
return &v1.TenantModelBatchAssignRes{Assigned: assigned}, nil
}
// UpdateTenantModel 更新租户模型配置
func (s *sAdmin) UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdateReq) (*v1.TenantModelUpdateRes, error) {
data := do.MdlTenantModels{}
if req.Enabled != nil {
data.Enabled = *req.Enabled
}
if req.BillingMode != nil {
// 特殊计费方案模型不允许租户级 billing_mode 覆盖:计费模式由平台方案决定(special),
// 租户改模式只会造成日志标签与计费口径错位(读取侧 GetModelPriceAt 同口径忽略覆盖)。
// 置空(恢复平台默认)不受限
if *req.BillingMode != "" {
var pricingRow struct {
Pricing string `json:"pricing"`
}
if err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ModelID).Fields("pricing").Scan(&pricingRow); err != nil {
return nil, gerror.Wrap(err, "查询模型定价")
}
if blob := billing.ParsePricingBlob(pricingRow.Pricing); blob != nil && blob.Scheme != "" {
return nil, gerror.New("该模型使用特殊计费方案,计费模式由平台方案决定,不允许租户级覆盖")
}
}
data.BillingMode = *req.BillingMode
}
if req.PerRequestPrice != nil {
d := billing.NewFromFloat(**req.PerRequestPrice)
data.PerRequestPrice = &d
}
if req.DiscountRatio != nil {
d := billing.NewFromFloat(**req.DiscountRatio)
data.DiscountRatio = &d
}
if req.MaxConcurrency != nil {
data.MaxConcurrency = *req.MaxConcurrency
}
if req.ChannelScope != nil {
data.ChannelScope = *req.ChannelScope
}
if req.CustomInputPrice != nil {
d := billing.NewFromFloat(**req.CustomInputPrice)
data.CustomInputPrice = &d
}
if req.CustomCacheReadPrice != nil {
d := billing.NewFromFloat(**req.CustomCacheReadPrice)
data.CustomCacheReadPrice = &d
}
if req.CustomCacheCreationPrice != nil {
d := billing.NewFromFloat(**req.CustomCacheCreationPrice)
data.CustomCacheCreationPrice = &d
}
if req.CustomOutputPrice != nil {
d := billing.NewFromFloat(**req.CustomOutputPrice)
data.CustomOutputPrice = &d
}
if req.CustomPricingTiers != nil {
if len(*req.CustomPricingTiers) > 0 {
b, _ := json.Marshal(*req.CustomPricingTiers)
data.CustomPricingTiers = string(b)
} else {
data.CustomPricingTiers = "[]"
}
}
_, err := dao.MdlTenantModels.Ctx(ctx).
Where("tenant_id", req.TenantID).
Where("model_id", req.ModelID).
Data(data).
Update()
if err != nil {
return nil, err
}
billing.ClearTenantPriceCache(ctx, req.TenantID)
relay.ClearTenantModelAccessCache(ctx, req.TenantID)
return nil, nil
}
// DeleteTenantModel 移除租户模型分配
func (s *sAdmin) DeleteTenantModel(ctx context.Context, req *v1.TenantModelDeleteReq) (*v1.TenantModelDeleteRes, error) {
result, err := dao.MdlTenantModels.Ctx(ctx).
Where("tenant_id", req.TenantID).
Where("model_id", req.ModelID).
Delete()
if err != nil {
return nil, err
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return nil, common.NewNotFoundError("模型分配")
}
billing.ClearTenantPriceCache(ctx, req.TenantID)
relay.ClearTenantModelAccessCache(ctx, req.TenantID)
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
}