feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit bb7968b612
2381 files changed
+551494

No files matched your search

+432
View File
@@ -0,0 +1,432 @@
package model
import (
"errors"
"fmt"
"sort"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/samber/lo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Ability struct {
Group string `json:"group" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
Model string `json:"model" gorm:"type:varchar(255);primaryKey;autoIncrement:false"`
ChannelId int `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"`
Enabled bool `json:"enabled"`
Priority *int64 `json:"priority" gorm:"bigint;default:0;index"`
Weight uint `json:"weight" gorm:"default:0;index"`
Tag *string `json:"tag" gorm:"index"`
}
type AbilityWithChannel struct {
Ability
ChannelType int `json:"channel_type"`
}
func GetAllEnableAbilityWithChannels() ([]AbilityWithChannel, error) {
var abilities []AbilityWithChannel
err := DB.Table("abilities").
Select("abilities.*, channels.type as channel_type").
Joins("left join channels on abilities.channel_id = channels.id").
Where("abilities.enabled = ?", true).
Scan(&abilities).Error
return abilities, err
}
func GetEnabledGroupSet() (map[string]struct{}, error) {
var groups []string
err := DB.Table("abilities").
Joins("JOIN channels ON channels.id = abilities.channel_id").
Where("abilities.enabled = ? AND channels.status = ?", true, common.ChannelStatusEnabled).
Distinct("abilities."+commonGroupCol).
Pluck("abilities."+commonGroupCol, &groups).Error
if err != nil {
return nil, err
}
enabledGroups := make(map[string]struct{}, len(groups))
for _, group := range groups {
group = strings.TrimSpace(group)
if group != "" {
enabledGroups[group] = struct{}{}
}
}
return enabledGroups, nil
}
func GetGroupEnabledModels(group string) []string {
var models []string
// Find distinct models
DB.Table("abilities").Where(commonGroupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models)
return models
}
func GetEnabledModels() []string {
var models []string
// Find distinct models
DB.Table("abilities").Where("enabled = ?", true).Distinct("model").Pluck("model", &models)
return models
}
func GetAllEnableAbilities() []Ability {
var abilities []Ability
DB.Find(&abilities, "enabled = ?", true)
return abilities
}
func getPriority(group string, model string, retry int) (int, error) {
var priorities []int
err := DB.Model(&Ability{}).
Select("DISTINCT(priority)").
Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true).
Order("priority DESC"). // 按优先级降序排序
Pluck("priority", &priorities).Error // Pluck用于将查询的结果直接扫描到一个切片中
if err != nil {
// 处理错误
return 0, err
}
if len(priorities) == 0 {
// 如果没有查询到优先级,则返回错误
return 0, errors.New("数据库一致性被破坏")
}
// 确定要使用的优先级
var priorityToUse int
if retry >= len(priorities) {
// 如果重试次数大于优先级数,则使用最小的优先级
priorityToUse = priorities[len(priorities)-1]
} else {
priorityToUse = priorities[retry]
}
return priorityToUse, nil
}
func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true)
channelQuery := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, true, maxPrioritySubQuery)
if retry != 0 {
priority, err := getPriority(group, model, retry)
if err != nil {
return nil, err
} else {
channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, true, priority)
}
}
return channelQuery, nil
}
func GetChannel(
group string,
model string,
retry int,
filters []dto.ChannelFilter,
) (*Channel, error) {
var abilities []Ability
err := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true).Order("priority DESC, weight DESC").Find(&abilities).Error
if err != nil {
return nil, err
}
abilities = filterAbilitiesByConstraints(abilities, model, filters)
if len(abilities) > 0 {
priorities := make([]int64, 0)
seen := make(map[int64]bool)
for _, ability := range abilities {
priority := int64(0)
if ability.Priority != nil {
priority = *ability.Priority
}
if !seen[priority] {
seen[priority] = true
priorities = append(priorities, priority)
}
}
sort.Slice(priorities, func(i, j int) bool { return priorities[i] > priorities[j] })
if retry >= len(priorities) {
retry = len(priorities) - 1
}
targetPriority := priorities[retry]
abilities = lo.Filter(abilities, func(ability Ability, _ int) bool {
return ability.Priority == nil && targetPriority == 0 || ability.Priority != nil && *ability.Priority == targetPriority
})
}
channel := Channel{}
if len(abilities) > 0 {
// Randomly choose one
weightSum := uint(0)
for _, ability_ := range abilities {
weightSum += ability_.Weight + 10
}
// Randomly choose one
weight := common.GetRandomInt(int(weightSum))
for _, ability_ := range abilities {
weight -= int(ability_.Weight) + 10
//log.Printf("weight: %d, ability weight: %d", weight, *ability_.Weight)
if weight <= 0 {
channel.Id = ability_.ChannelId
break
}
}
} else {
return nil, nil
}
err = DB.First(&channel, "id = ?", channel.Id).Error
return &channel, err
}
// filterAbilitiesByConstraints applies the same ChannelSatisfiesFilters
// predicate used by the memory-cache path. A failed channel lookup fails
// closed when a task-plugin identity is required and fails open otherwise.
func filterAbilitiesByConstraints(abilities []Ability, modelName string, filters []dto.ChannelFilter) []Ability {
if len(abilities) == 0 {
return nil
}
channelIds := make([]int, 0, len(abilities))
seen := make(map[int]struct{}, len(abilities))
for _, ability := range abilities {
if _, ok := seen[ability.ChannelId]; ok {
continue
}
seen[ability.ChannelId] = struct{}{}
channelIds = append(channelIds, ability.ChannelId)
}
var channels []*Channel
if err := DB.Where("id IN ?", channelIds).Find(&channels).Error; err != nil {
if identityFilterRequiresKey(filters) {
return nil
}
return abilities
}
channelsByID := make(map[int]*Channel, len(channels))
for _, channel := range channels {
channelsByID[channel.Id] = channel
}
filtered := make([]Ability, 0, len(abilities))
for _, ability := range abilities {
channel := channelsByID[ability.ChannelId]
if ok, _ := ChannelSatisfiesFilters(channel, modelName, filters); ok {
filtered = append(filtered, ability)
}
}
return filtered
}
func identityFilterRequiresKey(filters []dto.ChannelFilter) bool {
for _, filter := range filters {
if filter.Kind == dto.FilterTaskPluginIdentity && filter.TaskPluginKey != "" {
return true
}
}
return false
}
func (channel *Channel) AddAbilities(tx *gorm.DB) error {
models_ := strings.Split(channel.Models, ",")
groups_ := strings.Split(channel.Group, ",")
abilitySet := make(map[string]struct{})
abilities := make([]Ability, 0, len(models_))
for _, model := range models_ {
for _, group := range groups_ {
key := group + "|" + model
if _, exists := abilitySet[key]; exists {
continue
}
abilitySet[key] = struct{}{}
ability := Ability{
Group: group,
Model: model,
ChannelId: channel.Id,
Enabled: channel.Status == common.ChannelStatusEnabled,
Priority: channel.Priority,
Weight: uint(channel.GetWeight()),
Tag: channel.Tag,
}
abilities = append(abilities, ability)
}
}
if len(abilities) == 0 {
return nil
}
// choose DB or provided tx
useDB := DB
if tx != nil {
useDB = tx
}
for _, chunk := range lo.Chunk(abilities, 50) {
err := useDB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
if err != nil {
return err
}
}
return nil
}
func (channel *Channel) DeleteAbilities() error {
return DB.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error
}
// UpdateAbilities updates abilities of this channel.
// Make sure the channel is completed before calling this function.
func (channel *Channel) UpdateAbilities(tx *gorm.DB) error {
isNewTx := false
// 如果没有传入事务,创建新的事务
if tx == nil {
tx = DB.Begin()
if tx.Error != nil {
return tx.Error
}
isNewTx = true
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
}
// First delete all abilities of this channel
err := tx.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error
if err != nil {
if isNewTx {
tx.Rollback()
}
return err
}
// Then add new abilities
models_ := channel.GetModels()
groups_ := strings.Split(channel.Group, ",")
abilitySet := make(map[string]struct{})
abilities := make([]Ability, 0, len(models_))
for _, model := range models_ {
for _, group := range groups_ {
key := group + "|" + model
if _, exists := abilitySet[key]; exists {
continue
}
abilitySet[key] = struct{}{}
ability := Ability{
Group: group,
Model: model,
ChannelId: channel.Id,
Enabled: channel.Status == common.ChannelStatusEnabled,
Priority: channel.Priority,
Weight: uint(channel.GetWeight()),
Tag: channel.Tag,
}
abilities = append(abilities, ability)
}
}
if len(abilities) > 0 {
for _, chunk := range lo.Chunk(abilities, 50) {
err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
if err != nil {
if isNewTx {
tx.Rollback()
}
return err
}
}
}
// 如果是新创建的事务,需要提交
if isNewTx {
return tx.Commit().Error
}
return nil
}
func UpdateAbilityStatus(channelId int, status bool) error {
return DB.Model(&Ability{}).Where("channel_id = ?", channelId).Select("enabled").Update("enabled", status).Error
}
func UpdateAbilityStatusByTag(tag string, status bool) error {
return DB.Model(&Ability{}).Where("tag = ?", tag).Select("enabled").Update("enabled", status).Error
}
func UpdateAbilityByTag(tag string, newTag *string, priority *int64, weight *uint) error {
ability := Ability{}
if newTag != nil {
ability.Tag = newTag
}
if priority != nil {
ability.Priority = priority
}
if weight != nil {
ability.Weight = *weight
}
return DB.Model(&Ability{}).Where("tag = ?", tag).Updates(ability).Error
}
var fixLock = sync.Mutex{}
func FixAbility() (int, int, error) {
lock := fixLock.TryLock()
if !lock {
return 0, 0, errors.New("已经有一个修复任务在运行中,请稍后再试")
}
defer fixLock.Unlock()
// truncate abilities table
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
err := DB.Exec("DELETE FROM abilities").Error
if err != nil {
common.SysLog(fmt.Sprintf("Delete abilities failed: %s", err.Error()))
return 0, 0, err
}
} else {
err := DB.Exec("TRUNCATE TABLE abilities").Error
if err != nil {
common.SysLog(fmt.Sprintf("Truncate abilities failed: %s", err.Error()))
return 0, 0, err
}
}
var channels []*Channel
// Find all channels
err := DB.Model(&Channel{}).Find(&channels).Error
if err != nil {
return 0, 0, err
}
if len(channels) == 0 {
return 0, 0, nil
}
successCount := 0
failCount := 0
for _, chunk := range lo.Chunk(channels, 50) {
ids := lo.Map(chunk, func(c *Channel, _ int) int { return c.Id })
// Delete all abilities of this channel
err = DB.Where("channel_id IN ?", ids).Delete(&Ability{}).Error
if err != nil {
common.SysLog(fmt.Sprintf("Delete abilities failed: %s", err.Error()))
failCount += len(chunk)
continue
}
// Then add new abilities
for _, channel := range chunk {
err = channel.AddAbilities(nil)
if err != nil {
common.SysLog(fmt.Sprintf("Add abilities for channel %d failed: %s", channel.Id, err.Error()))
failCount++
} else {
successCount++
}
}
}
InitChannelCache()
return successCount, failCount, nil
}
+31
View File
@@ -0,0 +1,31 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetEnabledGroupSetRequiresEnabledAbilityAndChannel(t *testing.T) {
resetPricingEndpointTestTables(t)
require.NoError(t, DB.Create(&[]Channel{
{Id: 1, Type: constant.ChannelTypeOpenAI, Key: "active-key", Name: "active", Status: common.ChannelStatusEnabled},
{Id: 2, Type: constant.ChannelTypeOpenAI, Key: "manual-key", Name: "manual", Status: common.ChannelStatusManuallyDisabled},
{Id: 3, Type: constant.ChannelTypeOpenAI, Key: "auto-key", Name: "auto", Status: common.ChannelStatusAutoDisabled},
{Id: 4, Type: constant.ChannelTypeOpenAI, Key: "ability-key", Name: "ability", Status: common.ChannelStatusEnabled},
}).Error)
require.NoError(t, DB.Create(&[]Ability{
{Group: "active", Model: "gpt-active", ChannelId: 1, Enabled: true},
{Group: "manual-disabled", Model: "gpt-manual", ChannelId: 2, Enabled: true},
{Group: "auto-disabled", Model: "gpt-auto", ChannelId: 3, Enabled: true},
{Group: "ability-disabled", Model: "gpt-ability", ChannelId: 4, Enabled: false},
}).Error)
groups, err := GetEnabledGroupSet()
require.NoError(t, err)
assert.Equal(t, map[string]struct{}{"active": {}}, groups)
}
+151
View File
@@ -0,0 +1,151 @@
package model
import (
"errors"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
var (
ErrCurrentPasswordInvalid = errors.New("Current password is incorrect.")
ErrAccountPasswordState = errors.New("Password settings have changed. Reload the page and try again.")
ErrLastLoginMethod = errors.New("Add another login method before unlinking this account.")
ErrAccountBindingChanged = errors.New("Account bindings have changed. Start this operation again.")
ErrCannotDeleteRootUser = errors.New("The root account cannot be deleted.")
)
// ChangeUserPassword rechecks the authorized session, password state and current
// password under the same user lock as the credential update. Only self-profile
// fields are accepted; a stale snapshot cannot restore roles or account status.
func ChangeUserPassword(identity AuthSessionIdentity, update *User, firstPassword bool) error {
hash, err := common.HashAccountPassword(update.Password)
if err != nil {
return err
}
return DB.Transaction(func(tx *gorm.DB) error {
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var current User
if err := tx.First(&current, identity.UserID).Error; err != nil {
return err
}
if firstPassword != (current.Password == "") {
return ErrAccountPasswordState
}
if !firstPassword && !common.ValidatePasswordAndHash(update.OriginalPassword, current.Password) {
return ErrCurrentPasswordInvalid
}
if current.Password != "" && common.ValidatePasswordAndHash(update.Password, current.Password) {
return common.ErrAccountPasswordSame
}
changes := map[string]any{"password": hash}
if update.Username != "" {
changes["username"] = update.Username
}
if update.DisplayName != "" {
changes["display_name"] = update.DisplayName
}
if _, err := IncrementUserAuthVersionWithTx(tx, identity.UserID); err != nil {
return err
}
if err := tx.Model(&User{}).Where("id = ?", identity.UserID).Updates(changes).Error; err != nil {
return err
}
return tx.First(update, identity.UserID).Error
})
}
// AccountLoginMethods is a snapshot of administrator-enabled login mechanisms.
// Enrollment is checked inside the transaction, not trusted from this snapshot.
type AccountLoginMethods struct {
Password bool
Passkey bool
WeChat bool
OAuthColumns []string
CustomProviderIDs []int
}
func UnbindUserOAuthForSession(identity AuthSessionIdentity, providerID int, enabled AccountLoginMethods) error {
return DB.Transaction(func(tx *gorm.DB) error {
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var current User
if err := tx.First(&current, identity.UserID).Error; err != nil {
return err
}
var binding UserOAuthBinding
if err := tx.Where("user_id = ? AND provider_id = ?", identity.UserID, providerID).First(&binding).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAccountBindingChanged
}
return err
}
hasLogin := enabled.Password && current.Password != "" || enabled.WeChat && current.WeChatId != ""
columns := map[string]string{
"github_id": current.GitHubId, "discord_id": current.DiscordId,
"oidc_id": current.OidcId, "linux_do_id": current.LinuxDOId, "telegram_id": current.TelegramId,
}
for _, column := range enabled.OAuthColumns {
hasLogin = hasLogin || columns[column] != ""
}
if !hasLogin && enabled.Passkey {
var count int64
if err := tx.Model(&PasskeyCredential{}).Where("user_id = ?", identity.UserID).Count(&count).Error; err != nil {
return err
}
hasLogin = count > 0
}
if !hasLogin && len(enabled.CustomProviderIDs) > 0 {
var count int64
if err := tx.Model(&UserOAuthBinding{}).Where("user_id = ? AND provider_id <> ? AND provider_id IN ?", identity.UserID, providerID, enabled.CustomProviderIDs).Count(&count).Error; err != nil {
return err
}
hasLogin = count > 0
}
if !hasLogin {
return ErrLastLoginMethod
}
return tx.Delete(&binding).Error
})
}
func UpdateUserBindColumnForSessionWithTx(tx *gorm.DB, identity AuthSessionIdentity, column, value string) error {
if !userBindColumns[column] || value == "" {
return ErrAccountBindingChanged
}
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
// Preserve the existing ownership check within the transaction and update
// only the chosen binding column, never a complete user snapshot.
var count int64
if err := tx.Model(&User{}).Where(column+" = ? AND id <> ?", value, identity.UserID).Count(&count).Error; err != nil {
return err
}
if count != 0 {
return ErrExternalIdentityAlreadyClaimed
}
return tx.Model(&User{}).Where("id = ?", identity.UserID).Update(column, value).Error
}
func UpdateUserOAuthBindingForSessionWithTx(tx *gorm.DB, identity AuthSessionIdentity, providerID int, subject string) error {
if providerID <= 0 || subject == "" || len(subject) > 256 {
return ErrAccountBindingChanged
}
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var binding UserOAuthBinding
err := tx.Where("user_id = ? AND provider_id = ?", identity.UserID, providerID).First(&binding).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return CreateUserOAuthBindingWithTx(tx, &UserOAuthBinding{UserId: identity.UserID, ProviderId: providerID, ProviderUserId: subject})
}
if err != nil {
return err
}
// The existing unique provider/subject index rejects concurrent ownership.
return tx.Model(&binding).Update("provider_user_id", subject).Error
}
+269
View File
@@ -0,0 +1,269 @@
package model
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
AuditCategoryLogin = "login"
AuditCategorySecurity = "security"
AuditCategoryOperation = "operation"
AuditCategoryAccessToken = "access_token"
)
// AuditLog is retained independently of usage logs and their cleanup/TTL policy.
// TokenRef identifies a PAT generation without storing its bearer credential.
type AuditLog struct {
Id int `json:"id"`
EventId string `json:"event_id" gorm:"type:varchar(64);uniqueIndex"`
UserId int `json:"user_id" gorm:"index:idx_audit_user_time,priority:1"`
Username string `json:"username" gorm:"type:varchar(64);index"`
ActorRole int `json:"actor_role"` // Immutable role of the actor when the event began, not the log owner.
CreatedAt int64 `json:"created_at" gorm:"type:bigint;index:idx_audit_user_time,priority:2;index:idx_audit_token_time,priority:2;index"`
Category string `json:"category" gorm:"type:varchar(24);index"`
Action string `json:"action" gorm:"type:varchar(128)"`
TokenRef string `json:"token_ref" gorm:"type:varchar(64);index:idx_audit_token_time,priority:1"`
AuthMethod string `json:"auth_method" gorm:"type:varchar(24)"`
Ip string `json:"ip" gorm:"type:varchar(64)"`
UserAgent string `json:"user_agent" gorm:"type:varchar(512)"`
Method string `json:"method" gorm:"type:varchar(16)"`
Route string `json:"route" gorm:"type:varchar(255)"`
Status int `json:"status"`
Success bool `json:"success"`
RequestId string `json:"request_id" gorm:"type:varchar(64);index"`
Content string `json:"content" gorm:"type:text"`
Other AuditOther `json:"other" gorm:"type:json"`
}
type AuditLogFilter struct {
SelfView bool // Server-selected metadata projection, independent of the viewer's actual role.
UserId int
Username string
Category string
TokenRef string
ExcludeTokenRef string
RequestId string
StartTimestamp int64
EndTimestamp int64
Success *bool
}
func AccessTokenFingerprint(token string) string {
// PostgreSQL returns CHAR(32) tokens padded with spaces. Normalize that
// storage padding so persisted tokens and incoming credentials share a ref.
token = strings.TrimRight(token, " ")
if token == "" {
return ""
}
digest := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", digest)
}
// RecordAuditLog captures safe request metadata only; raw URLs, query strings,
// credentials and response/request bodies must never enter this table.
func RecordAuditLog(c *gin.Context, entry AuditLog) {
ctx := context.Background()
if c != nil && c.Request != nil {
ctx = c.Request.Context()
entry.RequestId = c.GetString(common.RequestIdKey)
entry.Ip = c.ClientIP()
entry.UserAgent = c.Request.UserAgent()
entry.Method = c.Request.Method
entry.Route = c.FullPath()
if entry.Status == 0 {
entry.Status = c.Writer.Status()
}
if entry.AuthMethod == "" {
entry.AuthMethod = "session"
if c.GetBool("use_access_token") {
entry.AuthMethod = "access_token"
}
}
}
if entry.CreatedAt == 0 {
entry.CreatedAt = common.GetTimestamp()
}
if entry.RequestId == "" {
entry.RequestId = common.NewRequestId()
}
if entry.EventId == "" {
entry.EventId = common.NewRequestId()
}
switch entry.ActorRole {
case common.RoleCommonUser, common.RoleAdminUser, common.RoleRootUser:
default:
logger.LogError(ctx, fmt.Sprintf("audit actor role unavailable (request_id=%s, actor_role=%d)", entry.RequestId, entry.ActorRole))
entry.ActorRole = 0 // Unknown actors remain visible to root only.
}
if entry.Username == "" {
entry.Username, _ = GetUsernameById(entry.UserId, false)
}
ua := []rune(entry.UserAgent)
if len(ua) > 512 {
entry.UserAgent = string(ua[:512])
}
if LOG_DB == nil {
logger.LogError(ctx, fmt.Sprintf("audit log write failed (request_id=%s): log database unavailable", entry.RequestId))
return
}
var row any = &entry
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
encoded, err := common.Marshal(entry.Other)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("audit log write failed (request_id=%s): %v", entry.RequestId, err))
return
}
// The ClickHouse GORM insert callback passes structs to the native
// driver without resolving their Valuer. Bind this column's JSON
// encoding while retaining AuditOther in the domain and API models.
row = &struct {
AuditLog `gorm:"embedded"`
EncodedOther string `gorm:"column:other;type:json"`
}{AuditLog: entry, EncodedOther: string(encoded)}
}
if err := LOG_DB.Table("audit_logs").Create(row).Error; err != nil {
logger.LogError(ctx, fmt.Sprintf("audit log write failed (request_id=%s): %v", entry.RequestId, err))
}
}
func GetAuditLogs(filter AuditLogFilter, start, limit, viewerRole int) ([]*AuditLog, int64, error) {
query := LOG_DB.Model(&AuditLog{})
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
// Decode native JSON through database/sql as text for AuditOther.Scan.
// Preserve numeric metadata instead of returning quoted Int64 values.
query = query.WithContext(clickhouse.Context(query.Statement.Context, clickhouse.WithSettings(clickhouse.Settings{
"output_format_native_write_json_as_string": 1,
"output_format_json_quote_64bit_integers": 0,
})))
}
if viewerRole < common.RoleRootUser {
query = query.Where("actor_role IN ?", []int{common.RoleCommonUser, common.RoleAdminUser})
}
if filter.UserId > 0 {
query = query.Where("user_id = ?", filter.UserId)
}
if filter.Username != "" {
query = query.Where("username = ?", filter.Username)
}
if filter.Category != "" {
query = query.Where("category = ?", filter.Category)
}
if filter.TokenRef != "" {
query = query.Where("token_ref = ?", filter.TokenRef)
}
if filter.ExcludeTokenRef != "" {
query = query.Where("token_ref <> ?", filter.ExcludeTokenRef)
}
if filter.RequestId != "" {
query = query.Where("request_id = ?", filter.RequestId)
}
if filter.StartTimestamp > 0 {
query = query.Where("created_at >= ?", filter.StartTimestamp)
}
if filter.EndTimestamp > 0 {
query = query.Where("created_at <= ?", filter.EndTimestamp)
}
if filter.Success != nil {
query = query.Where("success = ?", *filter.Success)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
logs := make([]*AuditLog, 0)
if err := query.Order("created_at DESC").Order("event_id DESC").Offset(start).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
visibility := logOtherVisibilityUser
if !filter.SelfView && viewerRole >= common.RoleRootUser {
visibility = logOtherVisibilityRoot
} else if !filter.SelfView && viewerRole >= common.RoleAdminUser {
visibility = logOtherVisibilityAdmin
}
for _, entry := range logs {
if visibility != logOtherVisibilityRoot {
entry.Other.RootInfo = nil
}
if visibility == logOtherVisibilityUser {
entry.Other.AdminInfo = nil
entry.Other.AuditInfo = nil
}
}
return logs, total, nil
}
type UserAccessTokenStatus struct {
Exists bool `json:"exists"`
TokenRef string `json:"token_ref"`
CreatedAt *int64 `json:"created_at"`
LastUsedAt *int64 `json:"last_used_at"`
LastUsedIp string `json:"last_used_ip"`
}
func GetUserAccessTokenStatus(userId int) (*UserAccessTokenStatus, error) {
var user User
if err := DB.Select("id", "role", "access_token", "access_token_created_at").First(&user, userId).Error; err != nil {
return nil, err
}
status := &UserAccessTokenStatus{Exists: user.GetAccessToken() != ""}
if !status.Exists {
return status, nil
}
status.TokenRef = AccessTokenFingerprint(user.GetAccessToken())
status.CreatedAt = user.AccessTokenCreatedAt
var latest AuditLog
query := LOG_DB.Select("created_at", "ip").Where("user_id = ? AND token_ref = ? AND category = ?", userId, status.TokenRef, AuditCategoryAccessToken)
if user.Role < common.RoleRootUser {
query = query.Where("actor_role IN ?", []int{common.RoleCommonUser, common.RoleAdminUser})
}
err := query.Order("created_at DESC").Order("event_id DESC").Take(&latest).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return status, nil
}
if err != nil {
return nil, err
}
status.LastUsedAt = &latest.CreatedAt
status.LastUsedIp = latest.Ip
return status, nil
}
// MigrateAuditLogs also supports independently configured ClickHouse log stores.
// No TTL clause or usage-log cleanup integration is intentional.
func MigrateAuditLogs() error {
if !common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
return LOG_DB.AutoMigrate(&AuditLog{})
}
return LOG_DB.Exec(`CREATE TABLE IF NOT EXISTS audit_logs (
id Int64 DEFAULT 0, event_id String, user_id Int64, username String, actor_role Int32,
created_at Int64, category String, action String, token_ref String,
auth_method String, ip String, user_agent String, method String, route String,
status Int32, success UInt8, request_id String, content String, other JSON
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(toDateTime(created_at))
ORDER BY (created_at, event_id)`).Error
}
func ValidAuditCategory(category string) bool {
return category == "" || category == AuditCategoryLogin || category == AuditCategorySecurity || category == AuditCategoryOperation || category == AuditCategoryAccessToken
}
func ValidTokenFingerprint(value string) bool {
if value == "" {
return true
}
if len(value) != 64 {
return false
}
return strings.Trim(value, "0123456789abcdef") == ""
}
+95
View File
@@ -0,0 +1,95 @@
package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
"github.com/QuantumNous/new-api/common"
)
// AuditOther is the structured metadata stored with an audit event. Privileged
// fields are separate so API projections can remove them without re-encoding JSON.
type AuditOther struct {
Op *AuditOperation `json:"op,omitempty"`
AdminInfo *AuditAdminInfo `json:"admin_info,omitempty"`
AuditInfo *AuditRequestInfo `json:"audit_info,omitempty"`
RootInfo AuditFields `json:"root_info,omitempty"`
LoginMethod string `json:"login_method,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
type AuditOperation struct {
Action string `json:"action"`
Params AuditFields `json:"params,omitempty"`
}
type AuditAdminInfo struct {
AdminID int `json:"admin_id,omitempty"`
AdminUsername string `json:"admin_username,omitempty"`
AdminRole int `json:"admin_role,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
}
type AuditRequestInfo struct {
Method string `json:"method"`
Route string `json:"route"`
Path string `json:"path"`
Status int `json:"status"`
Success bool `json:"success"`
Params map[string]string `json:"params,omitempty"`
}
// AuditFields holds action-specific parameters and root-only extensions.
// Retain their encoded values when reading so arbitrary nested integers do not
// round-trip through float64 and lose precision before the API returns them.
type AuditFields map[string]any
func (fields *AuditFields) UnmarshalJSON(data []byte) error {
var values map[string]json.RawMessage
if err := common.Unmarshal(data, &values); err != nil {
return err
}
if values == nil {
*fields = nil
return nil
}
decoded := make(AuditFields, len(values))
for key, value := range values {
decoded[key] = value
}
*fields = decoded
return nil
}
func (other AuditOther) Value() (driver.Value, error) {
data, err := common.Marshal(other)
if err != nil {
return nil, err
}
// PostgreSQL's simple protocol requires string, not a bytea parameter.
return string(data), nil
}
func (other *AuditOther) Scan(value any) error {
var data []byte
switch value := value.(type) {
case nil:
*other = AuditOther{}
return nil
case string:
data = []byte(value)
case []byte:
data = value
default:
return fmt.Errorf("unsupported audit metadata database type %T", value)
}
var decoded AuditOther
if len(data) > 0 {
if err := common.Unmarshal(data, &decoded); err != nil {
return err
}
}
*other = decoded
return nil
}
+288
View File
@@ -0,0 +1,288 @@
package model
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
AuthFlowPurposeOAuth = "oauth"
AuthFlowPurposeTwoFALogin = "2fa_login"
AuthFlowPurposeLoginVerification = "login_verification"
AuthFlowPurposeLoginPasskey = "login_passkey"
AuthFlowPurposePasskeyLogin = "passkey_login"
AuthFlowPurposePasskeyRegister = "passkey_register"
AuthFlowPurposePasskeyStepUp = "passkey_step_up"
AuthFlowPurposeTelegramBind = "telegram_bind"
AuthFlowPurposeTelegramAssertion = "telegram_assertion"
AuthFlowIntentLogin = "login"
AuthFlowIntentBind = "bind"
AuthFlowIntentVerify = "verify"
AuthFlowPurposeTwoFASetup = "2fa_setup"
AuthFlowPurposeSecurityProof = "security_proof"
AuthFlowPurposeEmailBinding = "email_binding"
AuthFlowTokenBytes = 32
AuthFlowDefaultCleanupRetention = 24 * time.Hour
)
var (
ErrAuthFlowInvalid = errors.New("auth flow is invalid")
ErrAuthFlowExpired = errors.New("auth flow has expired")
ErrAuthFlowConsumed = errors.New("auth flow has already been consumed")
)
// AuthFlow stores one-time, short-lived state for authentication ceremonies.
// TokenHash is an HMAC of the opaque token; the token itself is never persisted.
type AuthFlow struct {
Id int64 `json:"id" gorm:"primaryKey"`
TokenHash string `json:"-" gorm:"type:char(64);not null;uniqueIndex"`
Purpose string `json:"purpose" gorm:"type:varchar(32);not null;index:idx_auth_flow_purpose_expiry"`
Provider string `json:"provider,omitempty" gorm:"type:varchar(64)"`
Intent string `json:"intent,omitempty" gorm:"type:varchar(16)"`
UserId int `json:"user_id,omitempty" gorm:"index"`
SessionId string `json:"session_id,omitempty" gorm:"type:varchar(64);index"`
Payload string `json:"-" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index:idx_auth_flow_purpose_expiry"`
ConsumedAt *time.Time `json:"consumed_at,omitempty" gorm:"index"`
}
func (AuthFlow) TableName() string {
return "auth_flows"
}
type AuthFlowCreate struct {
Purpose string
Provider string
Intent string
UserId int
SessionId string
Payload string
ExpiresAt time.Time
}
type AuthFlowMatch struct {
Purpose string
Provider string
Intent string
UserId int
SessionId string
}
// AuthSessionIdentity binds an authentication flow to a specific session version.
type AuthSessionIdentity struct {
UserID int `json:"user_id"`
SessionID string `json:"session_id"`
UserAuthVersion int64 `json:"auth_version"`
SessionVersion int64 `json:"session_version"`
}
// AuthFlowAuthorization is server-owned state carried into a configuration flow
// after a proof has been consumed. ProofID is a database ID, never the proof token.
type AuthFlowAuthorization struct {
AuthSessionIdentity
ProofID int64 `json:"proof_id"`
Scope string `json:"scope"`
ContextHash string `json:"context_hash"`
Method string `json:"method"`
}
// ValidateAuthSessionWithTx rechecks the authoritative identity while holding the
// user/session locks until the caller's credential change or flow consumption commits.
func ValidateAuthSessionWithTx(tx *gorm.DB, identity AuthSessionIdentity) error {
if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 {
return ErrUserSessionInactive
}
var user User
if err := lockForUpdate(tx).First(&user, identity.UserID).Error; err != nil {
return err
}
if user.Status != common.UserStatusEnabled || user.AuthVersion != identity.UserAuthVersion {
return ErrUserSessionInactive
}
var session UserSession
if err := lockForUpdate(tx).Where("sid = ? AND user_id = ?", identity.SessionID, identity.UserID).First(&session).Error; err != nil {
return err
}
if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() || session.UserAuthVersion != identity.UserAuthVersion || session.Version != identity.SessionVersion {
return ErrUserSessionInactive
}
return nil
}
func applyAuthFlowMatch(query *gorm.DB, token string, match AuthFlowMatch) *gorm.DB {
query = query.Where("token_hash = ? AND purpose = ?", authFlowTokenHash(token), match.Purpose)
if match.Provider != "" {
query = query.Where("provider = ?", match.Provider)
}
if match.Intent != "" {
query = query.Where("intent = ?", match.Intent)
}
if match.UserId != 0 {
query = query.Where("user_id = ?", match.UserId)
}
if match.SessionId != "" {
query = query.Where("session_id = ?", match.SessionId)
}
return query
}
func authFlowTokenHash(token string) string {
return common.GenerateHMACWithKey([]byte("auth-flow-v1:"+common.SessionSecret), token)
}
func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) {
return createAuthFlowWithTx(DB, input)
}
func createAuthFlowWithTx(tx *gorm.DB, input AuthFlowCreate) (string, *AuthFlow, error) {
if strings.TrimSpace(input.Purpose) == "" || input.ExpiresAt.IsZero() || !input.ExpiresAt.After(time.Now()) {
return "", nil, ErrAuthFlowInvalid
}
random := make([]byte, AuthFlowTokenBytes)
if _, err := rand.Read(random); err != nil {
return "", nil, fmt.Errorf("generate auth flow token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(random)
flow := &AuthFlow{
TokenHash: authFlowTokenHash(token),
Purpose: input.Purpose,
Provider: input.Provider,
Intent: input.Intent,
UserId: input.UserId,
SessionId: input.SessionId,
Payload: input.Payload,
ExpiresAt: input.ExpiresAt,
}
if err := tx.Create(flow).Error; err != nil {
return "", nil, err
}
return token, flow, nil
}
// ClaimExternalAuthAssertion records a signed provider assertion as consumed.
// The assertion is HMACed before storage and the unique token_hash index makes
// replay rejection atomic on SQLite, MySQL and PostgreSQL.
func ClaimExternalAuthAssertion(purpose, assertion string, expiresAt time.Time) error {
return DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalAuthAssertionWithTx(tx, purpose, assertion, expiresAt)
})
}
// ClaimExternalAuthAssertionWithTx records a provider assertion in the
// caller's transaction so replay protection can commit atomically with the
// authentication flow and its resulting state change.
func ClaimExternalAuthAssertionWithTx(tx *gorm.DB, purpose, assertion string, expiresAt time.Time) error {
purpose = strings.TrimSpace(purpose)
assertion = strings.TrimSpace(assertion)
now := time.Now()
if tx == nil || purpose == "" || assertion == "" || !expiresAt.After(now) {
return ErrAuthFlowInvalid
}
flow := AuthFlow{
TokenHash: authFlowTokenHash("external:" + purpose + ":" + assertion),
Purpose: purpose,
ExpiresAt: expiresAt,
ConsumedAt: &now,
}
result := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "token_hash"}},
DoNothing: true,
}).Create(&flow)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrAuthFlowConsumed
}
return nil
}
// GetAuthFlow validates a flow without consuming it. Callers must still use
// ConsumeAuthFlow with all identity-bound fields before performing the action.
func GetAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) {
if token == "" || match.Purpose == "" {
return nil, ErrAuthFlowInvalid
}
var flow AuthFlow
if err := applyAuthFlowMatch(DB, token, match).First(&flow).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAuthFlowInvalid
}
return nil, err
}
if flow.ConsumedAt != nil {
return nil, ErrAuthFlowConsumed
}
if !flow.ExpiresAt.After(time.Now()) {
return nil, ErrAuthFlowExpired
}
return &flow, nil
}
// ConsumeAuthFlow atomically validates and consumes a flow. Optional match
// fields are enforced when non-zero so tokens cannot cross purposes or users.
func ConsumeAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) {
return ConsumeAuthFlowWithAction(token, match, nil)
}
// ConsumeAuthFlowWithAction consumes a flow and runs action in the same
// database transaction. An action failure rolls the consumption back.
func ConsumeAuthFlowWithAction(token string, match AuthFlowMatch, action func(tx *gorm.DB, flow *AuthFlow) error) (*AuthFlow, error) {
if token == "" || match.Purpose == "" {
return nil, ErrAuthFlowInvalid
}
var consumed AuthFlow
err := DB.Transaction(func(tx *gorm.DB) error {
// Claim with the first write, rather than upgrading a prior read lock.
// SQLite cannot reliably upgrade two concurrent deferred read transactions.
now := time.Now()
result := applyAuthFlowMatch(tx.Model(&AuthFlow{}), token, match).
Where("consumed_at IS NULL AND expires_at > ?", now).
Update("consumed_at", now)
if result.Error != nil {
return result.Error
}
query := applyAuthFlowMatch(tx, token, match)
if err := query.First(&consumed).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAuthFlowInvalid
}
return err
}
if result.RowsAffected != 1 && consumed.ConsumedAt != nil {
return ErrAuthFlowConsumed
}
if !consumed.ExpiresAt.After(time.Now()) {
return ErrAuthFlowExpired
}
if result.RowsAffected != 1 {
return ErrAuthFlowInvalid
}
if action != nil {
if err := action(tx, &consumed); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return &consumed, nil
}
func DeleteExpiredAuthFlows(now time.Time) error {
cutoff := now.Add(-AuthFlowDefaultCleanupRetention)
return DB.Where("expires_at < ? OR (consumed_at IS NOT NULL AND consumed_at < ?)", cutoff, cutoff).
Delete(&AuthFlow{}).Error
}
+109
View File
@@ -0,0 +1,109 @@
package model
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestAuthFlowIsBoundAndConsumedOnce(t *testing.T) {
truncateTables(t)
token, created, err := CreateAuthFlow(AuthFlowCreate{
Purpose: AuthFlowPurposeOAuth,
Provider: "github",
Intent: AuthFlowIntentBind,
UserId: 42,
SessionId: "session-a",
Payload: `{"affiliate_code":"invite"}`,
ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
require.NotEmpty(t, token)
assert.NotEqual(t, token, created.TokenHash)
_, err = ConsumeAuthFlow(token, AuthFlowMatch{
Purpose: AuthFlowPurposeOAuth,
Provider: "github",
Intent: AuthFlowIntentBind,
UserId: 99,
SessionId: "session-a",
})
assert.ErrorIs(t, err, ErrAuthFlowInvalid)
peeked, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth, Provider: "github"})
require.NoError(t, err)
assert.Nil(t, peeked.ConsumedAt)
consumed, err := ConsumeAuthFlow(token, AuthFlowMatch{
Purpose: AuthFlowPurposeOAuth,
Provider: "github",
Intent: AuthFlowIntentBind,
UserId: 42,
SessionId: "session-a",
})
require.NoError(t, err)
require.NotNil(t, consumed.ConsumedAt)
_, err = ConsumeAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, ErrAuthFlowConsumed)
}
func TestAuthFlowExpiryIsEnforced(t *testing.T) {
truncateTables(t)
token, flow, err := CreateAuthFlow(AuthFlowCreate{
Purpose: AuthFlowPurposeTwoFALogin,
UserId: 7,
ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
require.NoError(t, DB.Model(&AuthFlow{}).Where("id = ?", flow.Id).Update("expires_at", time.Now().Add(-time.Second)).Error)
_, err = GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTwoFALogin})
assert.True(t, errors.Is(err, ErrAuthFlowExpired))
_, err = ConsumeAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTwoFALogin})
assert.True(t, errors.Is(err, ErrAuthFlowExpired))
}
func TestExternalAuthAssertionCanOnlyBeClaimedOnce(t *testing.T) {
truncateTables(t)
expiresAt := time.Now().Add(time.Minute)
require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "signed-assertion", expiresAt))
err := ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "signed-assertion", expiresAt)
assert.ErrorIs(t, err, ErrAuthFlowConsumed)
require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "different-assertion", expiresAt))
}
func TestConsumeAuthFlowWithActionRollsBackTogether(t *testing.T) {
truncateTables(t)
token, _, err := CreateAuthFlow(AuthFlowCreate{
Purpose: AuthFlowPurposeTelegramBind,
UserId: 42,
SessionId: "session-a",
ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
actionErr := errors.New("binding failed")
_, err = ConsumeAuthFlowWithAction(token, AuthFlowMatch{
Purpose: AuthFlowPurposeTelegramBind, UserId: 42, SessionId: "session-a",
}, func(tx *gorm.DB, _ *AuthFlow) error {
if err := ClaimExternalAuthAssertionWithTx(tx, AuthFlowPurposeTelegramAssertion, "assertion-a", time.Now().Add(time.Minute)); err != nil {
return err
}
return actionErr
})
assert.ErrorIs(t, err, actionErr)
flow, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "assertion-a", time.Now().Add(time.Minute)))
}
+17
View File
@@ -0,0 +1,17 @@
package model
type AuthzRole struct {
Id uint `json:"id" gorm:"primaryKey;autoIncrement"`
Key string `json:"key" gorm:"size:64;uniqueIndex;not null"`
Name string `json:"name" gorm:"size:100;not null"`
Description string `json:"description" gorm:"type:text"`
BuiltIn bool `json:"built_in"`
Enabled bool `json:"enabled"`
Sort int `json:"sort"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"`
}
func (AuthzRole) TableName() string {
return "authz_roles"
}
+16
View File
@@ -0,0 +1,16 @@
package model
type CasbinRule struct {
Id uint `gorm:"primaryKey;autoIncrement"`
Ptype string `gorm:"size:100;index:idx_casbin_rule,priority:1;uniqueIndex:idx_casbin_rule_unique,priority:1"`
V0 string `gorm:"size:100;index:idx_casbin_rule,priority:2;uniqueIndex:idx_casbin_rule_unique,priority:2"`
V1 string `gorm:"size:100;index:idx_casbin_rule,priority:3;uniqueIndex:idx_casbin_rule_unique,priority:3"`
V2 string `gorm:"size:100;index:idx_casbin_rule,priority:4;uniqueIndex:idx_casbin_rule_unique,priority:4"`
V3 string `gorm:"size:100;index:idx_casbin_rule,priority:5;uniqueIndex:idx_casbin_rule_unique,priority:5"`
V4 string `gorm:"size:100;index:idx_casbin_rule,priority:6;uniqueIndex:idx_casbin_rule_unique,priority:6"`
V5 string `gorm:"size:100;index:idx_casbin_rule,priority:7;uniqueIndex:idx_casbin_rule_unique,priority:7"`
}
func (CasbinRule) TableName() string {
return "casbin_rule"
}
+1170
View File
File diff suppressed because it is too large. Load diff
+309
View File
@@ -0,0 +1,309 @@
package model
import (
"errors"
"fmt"
"math/rand"
"sort"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
kitdto "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/ratio_setting"
)
var group2model2channels map[string]map[string][]int // enabled channel
var channelsIDM map[int]*Channel // all channels include disabled
// channel2advancedCustomConfig caches parsed Advanced Custom (type 58) configs so
// path-aware selection avoids re-parsing JSON per request. Refreshed on full sync.
var channel2advancedCustomConfig map[int]*kitdto.AdvancedCustomConfig
var channelSyncLock sync.RWMutex
func InitChannelCache() {
if !common.MemoryCacheEnabled {
InvalidatePricingCache()
rebuildTaskAliasView()
return
}
newChannelId2channel := make(map[int]*Channel)
newChannel2advancedCustomConfig := make(map[int]*kitdto.AdvancedCustomConfig)
var channels []*Channel
DB.Find(&channels)
for _, channel := range channels {
newChannelId2channel[channel.Id] = channel
if channel.Type == constant.ChannelTypeAdvancedCustom {
if config := channel.GetOtherSettings().AdvancedCustom; config != nil {
newChannel2advancedCustomConfig[channel.Id] = config
}
}
}
var abilities []*Ability
DB.Find(&abilities)
groups := make(map[string]bool)
for _, ability := range abilities {
groups[ability.Group] = true
}
newGroup2model2channels := make(map[string]map[string][]int)
for group := range groups {
newGroup2model2channels[group] = make(map[string][]int)
}
for _, channel := range channels {
if channel.Status != common.ChannelStatusEnabled {
continue // skip disabled channels
}
groups := strings.SplitSeq(channel.Group, ",")
for group := range groups {
models := channel.GetModels()
for _, model := range models {
if _, ok := newGroup2model2channels[group][model]; !ok {
newGroup2model2channels[group][model] = make([]int, 0)
}
newGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel.Id)
}
}
}
// sort by priority
for group, model2channels := range newGroup2model2channels {
for model, channels := range model2channels {
sort.Slice(channels, func(i, j int) bool {
return newChannelId2channel[channels[i]].GetPriority() > newChannelId2channel[channels[j]].GetPriority()
})
newGroup2model2channels[group][model] = channels
}
}
channelSyncLock.Lock()
group2model2channels = newGroup2model2channels
//channelsIDM = newChannelId2channel
for i, channel := range newChannelId2channel {
if channel.ChannelInfo.IsMultiKey {
channel.Keys = channel.GetKeys()
if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling {
if oldChannel, ok := channelsIDM[i]; ok {
// 存在旧的渠道,如果是多key且轮询,保留轮询索引信息
if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling {
channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex
}
}
}
}
}
channelsIDM = newChannelId2channel
channel2advancedCustomConfig = newChannel2advancedCustomConfig
channelSyncLock.Unlock()
// Lock ordering: InvalidatePricingCache acquires updatePricingLock, and
// GetPricing (holding updatePricingLock) nests channelSyncLock.RLock via
// loadPricingAdvancedCustomConfigs. channelSyncLock MUST be released before
// invalidating the pricing cache, otherwise the reversed order deadlocks.
InvalidatePricingCache()
rebuildTaskAliasView()
common.SysLog("channels synced from database")
}
func SyncChannelCache(frequency int) {
for {
time.Sleep(time.Duration(frequency) * time.Second)
common.SysLog("syncing channels from database")
InitChannelCache()
}
}
func GetRandomSatisfiedChannel(
group string,
model string,
retry int,
filters []dto.ChannelFilter,
) (*Channel, error) {
// if memory cache is disabled, get channel directly from database
if !common.MemoryCacheEnabled {
return GetChannel(group, model, retry, filters)
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
// First, try to find channels with the exact model name.
channels, _ := filterCandidateIDs(group2model2channels[group][model], model, filters)
// If no channels found, try to find channels with the normalized model name.
if len(channels) == 0 {
normalizedModel := ratio_setting.RoutingMatchModelName(model)
channels, _ = filterCandidateIDs(group2model2channels[group][normalizedModel], model, filters)
}
if len(channels) == 0 {
return nil, nil
}
if len(channels) == 1 {
if channel, ok := channelsIDM[channels[0]]; ok {
return channel, nil
}
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0])
}
uniquePriorities := make(map[int]bool)
for _, channelId := range channels {
if channel, ok := channelsIDM[channelId]; ok {
uniquePriorities[int(channel.GetPriority())] = true
} else {
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
}
}
var sortedUniquePriorities []int
for priority := range uniquePriorities {
sortedUniquePriorities = append(sortedUniquePriorities, priority)
}
sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities)))
if retry >= len(uniquePriorities) {
retry = len(uniquePriorities) - 1
}
targetPriority := int64(sortedUniquePriorities[retry])
// get the priority for the given retry number
var sumWeight = 0
var targetChannels []*Channel
for _, channelId := range channels {
if channel, ok := channelsIDM[channelId]; ok {
if channel.GetPriority() == targetPriority {
sumWeight += channel.GetWeight()
targetChannels = append(targetChannels, channel)
}
} else {
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
}
}
if len(targetChannels) == 0 {
return nil, errors.New(fmt.Sprintf("no channel found, group: %s, model: %s, priority: %d", group, model, targetPriority))
}
// smoothing factor and adjustment
smoothingFactor := 1
smoothingAdjustment := 0
if sumWeight == 0 {
// when all channels have weight 0, set sumWeight to the number of channels and set smoothing adjustment to 100
// each channel's effective weight = 100
sumWeight = len(targetChannels) * 100
smoothingAdjustment = 100
} else if sumWeight/len(targetChannels) < 10 {
// when the average weight is less than 10, set smoothing factor to 100
smoothingFactor = 100
}
// Calculate the total weight of all channels up to endIdx
totalWeight := sumWeight * smoothingFactor
// Generate a random value in the range [0, totalWeight)
randomWeight := rand.Intn(totalWeight)
// Find a channel based on its weight
for _, channel := range targetChannels {
randomWeight -= channel.GetWeight()*smoothingFactor + smoothingAdjustment
if randomWeight < 0 {
return channel, nil
}
}
// return null if no channel is not found
return nil, errors.New("channel not found")
}
func CacheGetChannel(id int) (*Channel, error) {
if !common.MemoryCacheEnabled {
return GetChannelById(id, true)
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
c, ok := channelsIDM[id]
if !ok {
return nil, fmt.Errorf("渠道# %d,已不存在", id)
}
return c, nil
}
func CacheGetChannelInfo(id int) (*ChannelInfo, error) {
if !common.MemoryCacheEnabled {
channel, err := GetChannelById(id, true)
if err != nil {
return nil, err
}
return &channel.ChannelInfo, nil
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
c, ok := channelsIDM[id]
if !ok {
return nil, fmt.Errorf("渠道# %d,已不存在", id)
}
return &c.ChannelInfo, nil
}
func CacheUpdateChannelStatus(id int, status int) {
if !common.MemoryCacheEnabled {
return
}
channelSyncLock.Lock()
defer channelSyncLock.Unlock()
if channel, ok := channelsIDM[id]; ok {
channel.Status = status
}
if status != common.ChannelStatusEnabled {
// delete the channel from group2model2channels
for group, model2channels := range group2model2channels {
for model, channels := range model2channels {
for i, channelId := range channels {
if channelId == id {
// remove the channel from the slice
group2model2channels[group][model] = append(channels[:i], channels[i+1:]...)
break
}
}
}
}
}
}
func CacheUpdateChannel(channel *Channel) {
if !common.MemoryCacheEnabled {
return
}
channelSyncLock.Lock()
if channel == nil {
channelSyncLock.Unlock()
return
}
if channelsIDM == nil {
channelsIDM = make(map[int]*Channel)
}
if oldChannel, ok := channelsIDM[channel.Id]; ok {
logger.LogDebug(nil, "CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, oldChannel.ChannelInfo.MultiKeyPollingIndex)
}
channelsIDM[channel.Id] = channel
if channel2advancedCustomConfig == nil {
channel2advancedCustomConfig = make(map[int]*kitdto.AdvancedCustomConfig)
}
delete(channel2advancedCustomConfig, channel.Id)
if channel.Type == constant.ChannelTypeAdvancedCustom {
if config := channel.GetOtherSettings().AdvancedCustom; config != nil {
channel2advancedCustomConfig[channel.Id] = config
}
}
logger.LogDebug(nil, "CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
// Lock ordering: do NOT hold channelSyncLock while calling
// InvalidatePricingCache. GetPricing acquires updatePricingLock first and then
// channelSyncLock.RLock (via loadPricingAdvancedCustomConfigs); acquiring
// updatePricingLock while holding channelSyncLock would be an AB-BA deadlock.
channelSyncLock.Unlock()
InvalidatePricingCache()
}
+108
View File
@@ -0,0 +1,108 @@
package model
import (
"slices"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
)
var filterEvalOrder = []dto.ChannelFilterKind{
dto.FilterRequestPath,
dto.FilterTaskPluginIdentity,
}
// ChannelSatisfiesFilters reports whether ch passes every filter.
// On false, it returns the kind of the first violated filter (request_path
// then task_plugin_identity) for error attribution.
func ChannelSatisfiesFilters(ch *Channel, modelName string, filters []dto.ChannelFilter) (bool, dto.ChannelFilterKind) {
if ch == nil {
return false, ""
}
for _, kind := range filterEvalOrder {
for _, filter := range filters {
if filter.Kind != kind {
continue
}
if !channelMatchesFilter(ch, modelName, filter) {
return false, kind
}
}
}
return true, ""
}
// filterCandidateIDs applies filters to a cached candidate id list.
// Caller must hold channelSyncLock (read lock). The input slice is never mutated.
// A missing id in channelsIDM is kept for request_path (downstream consistency
// error) and dropped for task_plugin_identity, matching the previous filters.
func filterCandidateIDs(ids []int, modelName string, filters []dto.ChannelFilter) (kept []int, emptiedBy dto.ChannelFilterKind) {
if len(ids) == 0 {
return ids, ""
}
kept = ids
for _, kind := range filterEvalOrder {
kindFilters := filtersByKind(filters, kind)
if len(kindFilters) == 0 {
continue
}
next := make([]int, 0, len(kept))
for _, id := range kept {
channel, exists := channelsIDM[id]
if candidatePassesKindFilters(channel, exists, modelName, kind, kindFilters) {
next = append(next, id)
}
}
if len(kept) > 0 && len(next) == 0 {
return next, kind
}
kept = next
}
return kept, ""
}
func filtersByKind(filters []dto.ChannelFilter, kind dto.ChannelFilterKind) []dto.ChannelFilter {
var matched []dto.ChannelFilter
for _, filter := range filters {
if filter.Kind == kind {
matched = append(matched, filter)
}
}
return matched
}
func candidatePassesKindFilters(ch *Channel, exists bool, modelName string, kind dto.ChannelFilterKind, filters []dto.ChannelFilter) bool {
if kind == dto.FilterRequestPath && !exists {
return true
}
if !exists || ch == nil {
return false
}
for _, filter := range filters {
if !channelMatchesFilter(ch, modelName, filter) {
return false
}
}
return true
}
func channelMatchesFilter(ch *Channel, modelName string, filter dto.ChannelFilter) bool {
switch filter.Kind {
case dto.FilterRequestPath:
if filter.RequestPath == "" {
return true
}
if ch.Type != constant.ChannelTypeAdvancedCustom {
return true
}
config := ch.GetOtherSettings().AdvancedCustom
return config != nil && config.SupportsPathForModel(filter.RequestPath, modelName)
case dto.FilterTaskPluginIdentity:
if ch.Type == constant.ChannelTypeTaskPlugin {
return filter.TaskPluginKey != "" && ch.GetSetting().TaskPluginKey == filter.TaskPluginKey
}
return filter.TaskPluginKey == "" || slices.Contains(filter.TaskPluginChannelTypes, ch.Type)
default:
return true
}
}
+218
View File
@@ -0,0 +1,218 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
kitdto "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFilterCandidateIDs(t *testing.T) {
alphaSetting := `{"task_plugin_key":"alpha"}`
betaSetting := `{"task_plugin_key":"beta"}`
alpha := &Channel{Id: 900001, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Setting: &alphaSetting}
beta := &Channel{Id: 900002, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Setting: &betaSetting}
ordinary := &Channel{Id: 900003, Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusEnabled}
kling := &Channel{Id: 900004, Type: constant.ChannelTypeKling, Status: common.ChannelStatusEnabled}
jimeng := &Channel{Id: 900005, Type: constant.ChannelTypeJimeng, Status: common.ChannelStatusEnabled}
matchingCustom := &Channel{Id: 900010, Type: constant.ChannelTypeAdvancedCustom, Status: common.ChannelStatusEnabled}
matchingCustom.SetOtherSettings(kitdto.ChannelOtherSettings{
AdvancedCustom: &kitdto.AdvancedCustomConfig{
Routes: []kitdto.AdvancedCustomRoute{{
IncomingPath: "/v1/chat/completions",
Models: []string{"gpt-4"},
}},
},
})
otherCustom := &Channel{Id: 900011, Type: constant.ChannelTypeAdvancedCustom, Status: common.ChannelStatusEnabled}
otherCustom.SetOtherSettings(kitdto.ChannelOtherSettings{
AdvancedCustom: &kitdto.AdvancedCustomConfig{
Routes: []kitdto.AdvancedCustomRoute{{
IncomingPath: "/v1/responses",
Models: []string{"gpt-4"},
}},
},
})
pathFilter := dto.ChannelFilter{Kind: dto.FilterRequestPath, RequestPath: "/v1/chat/completions"}
emptyPathFilter := dto.ChannelFilter{Kind: dto.FilterRequestPath, RequestPath: ""}
tests := []struct {
name string
ids []int
modelName string
filters []dto.ChannelFilter
wantKept []int
wantEmpty dto.ChannelFilterKind
}{
{
name: "identity keeps matching type-59 key",
ids: []int{900001, 900002},
modelName: "shared",
filters: identityFilters("alpha", nil),
wantKept: []int{900001},
},
{
name: "identity empty key drops all type-59",
ids: []int{900001, 900002},
modelName: "shared",
filters: identityFilters("", nil),
wantKept: []int{},
wantEmpty: dto.FilterTaskPluginIdentity,
},
{
name: "identity empty key keeps ordinary channel",
ids: []int{900003},
modelName: "ordinary",
filters: identityFilters("", nil),
wantKept: []int{900003},
},
{
name: "identity keeps matching legacy type",
ids: []int{900004, 900005},
modelName: "legacy",
filters: identityFilters("legacy-alpha", []int{constant.ChannelTypeKling}),
wantKept: []int{900004},
},
{
name: "identity keeps all listed legacy types",
ids: []int{900004, 900005},
modelName: "legacy",
filters: identityFilters("legacy-alpha", []int{constant.ChannelTypeKling, constant.ChannelTypeJimeng}),
wantKept: []int{900004, 900005},
},
{
name: "identity keyed with no types drops legacy",
ids: []int{900004, 900005},
modelName: "legacy",
filters: identityFilters("legacy-alpha", nil),
wantKept: []int{},
wantEmpty: dto.FilterTaskPluginIdentity,
},
{
name: "identity drops missing cache entry",
ids: []int{900004, 999999},
modelName: "legacy",
filters: identityFilters("legacy-alpha", []int{constant.ChannelTypeKling}),
wantKept: []int{900004},
},
{
name: "empty request path is a passthrough including missing ids",
ids: []int{900003, 900010, 999999},
modelName: "gpt-4",
filters: []dto.ChannelFilter{emptyPathFilter},
wantKept: []int{900003, 900010, 999999},
},
{
name: "request path keeps missing cache entry for consistency",
ids: []int{900003, 999999},
modelName: "gpt-4",
filters: []dto.ChannelFilter{pathFilter},
wantKept: []int{900003, 999999},
},
{
name: "request path keeps matching type-58 and ordinary",
ids: []int{900003, 900010, 900011},
modelName: "gpt-4",
filters: []dto.ChannelFilter{pathFilter},
wantKept: []int{900003, 900010},
},
{
name: "request path empties when only unmatched type-58 remains",
ids: []int{900011},
modelName: "gpt-4",
filters: []dto.ChannelFilter{pathFilter},
wantKept: []int{},
wantEmpty: dto.FilterRequestPath,
},
{
name: "intersection attributes empty set to identity after path keeps candidates",
ids: []int{900001, 900010},
modelName: "gpt-4",
filters: []dto.ChannelFilter{pathFilter, identityFilters("missing", nil)[0]},
wantKept: []int{},
wantEmpty: dto.FilterTaskPluginIdentity,
},
{
name: "intersection attributes empty set to path when path runs first",
ids: []int{900011},
modelName: "gpt-4",
filters: []dto.ChannelFilter{identityFilters("", nil)[0], pathFilter},
wantKept: []int{},
wantEmpty: dto.FilterRequestPath,
},
}
channelSyncLock.Lock()
previous := channelsIDM
channelsIDM = map[int]*Channel{
900001: alpha,
900002: beta,
900003: ordinary,
900004: kling,
900005: jimeng,
900010: matchingCustom,
900011: otherCustom,
}
t.Cleanup(func() {
channelsIDM = previous
channelSyncLock.Unlock()
})
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
kept, emptiedBy := filterCandidateIDs(testCase.ids, testCase.modelName, testCase.filters)
if testCase.wantKept == nil {
assert.Nil(t, kept)
} else {
assert.Equal(t, testCase.wantKept, kept)
}
assert.Equal(t, testCase.wantEmpty, emptiedBy)
})
}
}
func TestChannelSatisfiesFilters(t *testing.T) {
alphaSetting := `{"task_plugin_key":"alpha"}`
alpha := &Channel{Id: 1, Type: constant.ChannelTypeTaskPlugin, Setting: &alphaSetting}
ordinary := &Channel{Id: 2, Type: constant.ChannelTypeOpenAI}
custom := &Channel{Id: 3, Type: constant.ChannelTypeAdvancedCustom}
custom.SetOtherSettings(kitdto.ChannelOtherSettings{
AdvancedCustom: &kitdto.AdvancedCustomConfig{
Routes: []kitdto.AdvancedCustomRoute{{
IncomingPath: "/v1/chat/completions",
Models: []string{"gpt-4"},
}},
},
})
ok, kind := ChannelSatisfiesFilters(nil, "gpt-4", nil)
assert.False(t, ok)
assert.Equal(t, dto.ChannelFilterKind(""), kind)
ok, kind = ChannelSatisfiesFilters(alpha, "shared", identityFilters("alpha", nil))
require.True(t, ok)
assert.Equal(t, dto.ChannelFilterKind(""), kind)
ok, kind = ChannelSatisfiesFilters(alpha, "shared", identityFilters("beta", nil))
assert.False(t, ok)
assert.Equal(t, dto.FilterTaskPluginIdentity, kind)
ok, kind = ChannelSatisfiesFilters(ordinary, "gpt-4", []dto.ChannelFilter{{
Kind: dto.FilterRequestPath,
RequestPath: "/v1/chat/completions",
}})
require.True(t, ok)
assert.Equal(t, dto.ChannelFilterKind(""), kind)
ok, kind = ChannelSatisfiesFilters(custom, "gpt-4", []dto.ChannelFilter{{
Kind: dto.FilterRequestPath,
RequestPath: "/v1/responses",
}})
assert.False(t, ok)
assert.Equal(t, dto.FilterRequestPath, kind)
}
+68
View File
@@ -0,0 +1,68 @@
package model
import (
"slices"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/ratio_setting"
)
func IsChannelEnabledForGroupModel(group string, modelName string, channelID int) bool {
if group == "" || modelName == "" || channelID <= 0 {
return false
}
if !common.MemoryCacheEnabled {
return isChannelEnabledForGroupModelDB(group, modelName, channelID)
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
if group2model2channels == nil {
return false
}
if isChannelIDInList(group2model2channels[group][modelName], channelID) {
return true
}
normalized := ratio_setting.RoutingMatchModelName(modelName)
if normalized != "" && normalized != modelName {
return isChannelIDInList(group2model2channels[group][normalized], channelID)
}
return false
}
func IsChannelEnabledForAnyGroupModel(groups []string, modelName string, channelID int) bool {
if len(groups) == 0 {
return false
}
for _, g := range groups {
if IsChannelEnabledForGroupModel(g, modelName, channelID) {
return true
}
}
return false
}
func isChannelEnabledForGroupModelDB(group string, modelName string, channelID int) bool {
var count int64
err := DB.Model(&Ability{}).
Where(commonGroupCol+" = ? and model = ? and channel_id = ? and enabled = ?", group, modelName, channelID, true).
Count(&count).Error
if err == nil && count > 0 {
return true
}
normalized := ratio_setting.RoutingMatchModelName(modelName)
if normalized == "" || normalized == modelName {
return false
}
count = 0
err = DB.Model(&Ability{}).
Where(commonGroupCol+" = ? and model = ? and channel_id = ? and enabled = ?", group, normalized, channelID, true).
Count(&count).Error
return err == nil && count > 0
}
func isChannelIDInList(list []int, channelID int) bool {
return slices.Contains(list, channelID)
}
+100
View File
@@ -0,0 +1,100 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChannelValidateSettingsRejectsInvalidHTTPTransport(t *testing.T) {
tests := []struct {
name string
setting dto.ChannelSettings
wantErr string
}{
{
name: "auto with shards is valid",
setting: dto.ChannelSettings{HTTPProtocol: "auto", HTTP2ConnectionShards: 4},
},
{
name: "http1 with shards greater than one rejected",
setting: dto.ChannelSettings{HTTPProtocol: "http1", HTTP2ConnectionShards: 2},
wantErr: "http2_connection_shards",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
channel := &Channel{}
channel.SetSetting(tt.setting)
err := channel.ValidateSettings()
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestAdvancedCustomChannelRequiresModelListRouteOnlyWhenUpdateChecksEnabled(t *testing.T) {
inferenceRoute := dto.AdvancedCustomRoute{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
Converter: "none",
}
tests := []struct {
name string
checksEnabled bool
routes []dto.AdvancedCustomRoute
wantErr string
}{
{
name: "legacy channel without discovery route remains valid",
routes: []dto.AdvancedCustomRoute{inferenceRoute},
},
{
name: "enabled checks require discovery route",
checksEnabled: true,
routes: []dto.AdvancedCustomRoute{inferenceRoute},
wantErr: dto.AdvancedCustomModelListPath,
},
{
name: "enabled checks accept discovery route",
checksEnabled: true,
routes: []dto.AdvancedCustomRoute{
inferenceRoute,
{
IncomingPath: dto.AdvancedCustomModelListPath,
UpstreamPath: dto.AdvancedCustomModelListPath,
Converter: "none",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
channel := &Channel{Type: constant.ChannelTypeAdvancedCustom}
channel.SetOtherSettings(dto.ChannelOtherSettings{
UpstreamModelUpdateCheckEnabled: tt.checksEnabled,
AdvancedCustom: &dto.AdvancedCustomConfig{
Routes: tt.routes,
},
})
err := channel.ValidateSettings()
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
+102
View File
@@ -0,0 +1,102 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupChannelStatusTest(t *testing.T) {
t.Helper()
truncateTables(t)
require.NoError(t, DB.Exec("DELETE FROM abilities").Error)
require.NoError(t, DB.Exec("DELETE FROM channels").Error)
memoryCacheEnabled := common.MemoryCacheEnabled
common.MemoryCacheEnabled = false
t.Cleanup(func() {
common.MemoryCacheEnabled = memoryCacheEnabled
})
}
func TestUpdateChannelStatusPersistsMultiKeyState(t *testing.T) {
setupChannelStatusTest(t)
channel := Channel{
Name: "multi-key-status",
Key: "key-a\nkey-b",
Status: common.ChannelStatusEnabled,
ChannelInfo: ChannelInfo{
IsMultiKey: true,
MultiKeySize: 2,
MultiKeyMode: constant.MultiKeyModePolling,
MultiKeyPollingIndex: 1,
},
}
require.NoError(t, DB.Create(&channel).Error)
changed := UpdateChannelStatus(channel.Id, "key-a", common.ChannelStatusAutoDisabled, "provider rejected key")
require.True(t, changed)
var stored Channel
require.NoError(t, DB.First(&stored, channel.Id).Error)
assert.Equal(t, common.ChannelStatusEnabled, stored.Status)
assert.Equal(t, common.ChannelStatusAutoDisabled, stored.ChannelInfo.MultiKeyStatusList[0])
assert.Equal(t, "provider rejected key", stored.ChannelInfo.MultiKeyDisabledReason[0])
assert.NotZero(t, stored.ChannelInfo.MultiKeyDisabledTime[0])
assert.Equal(t, 1, stored.ChannelInfo.MultiKeyPollingIndex)
}
func TestSaveStatusStateFromSingleKeySnapshotPreservesUnownedColumns(t *testing.T) {
setupChannelStatusTest(t)
channel := Channel{
Name: "single-key-status",
Key: "original-key",
Status: common.ChannelStatusEnabled,
Models: "original-model",
Group: "default",
UsedQuota: 100,
ChannelInfo: ChannelInfo{},
}
require.NoError(t, DB.Create(&channel).Error)
stale, err := GetChannelById(channel.Id, true)
require.NoError(t, err)
concurrentChannelInfo := ChannelInfo{
IsMultiKey: true,
MultiKeySize: 2,
MultiKeyMode: constant.MultiKeyModePolling,
MultiKeyPollingIndex: 1,
}
require.NoError(t, DB.Model(&Channel{}).Where("id = ?", channel.Id).Updates(map[string]any{
"key": "rotated-key",
"used_quota": gorm.Expr("used_quota + ?", 250),
"models": "concurrent-model",
"channel_info": concurrentChannelInfo,
}).Error)
stale.Status = common.ChannelStatusManuallyDisabled
stale.SetOtherInfo(map[string]any{
"status_reason": "manual operation",
"status_time": int64(1234),
})
require.NoError(t, stale.saveStatusState())
var stored Channel
require.NoError(t, DB.First(&stored, channel.Id).Error)
assert.Equal(t, common.ChannelStatusManuallyDisabled, stored.Status)
assert.Equal(t, "rotated-key", stored.Key)
assert.Equal(t, int64(350), stored.UsedQuota)
assert.Equal(t, "concurrent-model", stored.Models)
assert.Equal(t, concurrentChannelInfo, stored.ChannelInfo)
otherInfo := stored.GetOtherInfo()
assert.Equal(t, "manual operation", otherInfo["status_reason"])
assert.Equal(t, float64(1234), otherInfo["status_time"])
}
+179
View File
@@ -0,0 +1,179 @@
package model
import (
"errors"
"math/rand"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"gorm.io/gorm"
)
// Checkin 签到记录
type Checkin struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
UserId int `json:"user_id" gorm:"not null;uniqueIndex:idx_user_checkin_date"`
CheckinDate string `json:"checkin_date" gorm:"type:varchar(10);not null;uniqueIndex:idx_user_checkin_date"` // 格式: YYYY-MM-DD
QuotaAwarded int `json:"quota_awarded" gorm:"not null"`
CreatedAt int64 `json:"created_at" gorm:"bigint"`
}
// CheckinRecord 用于API返回的签到记录(不包含敏感字段)
type CheckinRecord struct {
CheckinDate string `json:"checkin_date"`
QuotaAwarded int `json:"quota_awarded"`
}
func (Checkin) TableName() string {
return "checkins"
}
// GetUserCheckinRecords 获取用户在指定日期范围内的签到记录
func GetUserCheckinRecords(userId int, startDate, endDate string) ([]Checkin, error) {
var records []Checkin
err := DB.Where("user_id = ? AND checkin_date >= ? AND checkin_date <= ?",
userId, startDate, endDate).
Order("checkin_date DESC").
Find(&records).Error
return records, err
}
// HasCheckedInToday 检查用户今天是否已签到
func HasCheckedInToday(userId int) (bool, error) {
today := time.Now().Format("2006-01-02")
var count int64
err := DB.Model(&Checkin{}).
Where("user_id = ? AND checkin_date = ?", userId, today).
Count(&count).Error
return count > 0, err
}
// UserCheckin 执行用户签到
// MySQL 和 PostgreSQL 使用事务保证原子性
// SQLite 不支持嵌套事务,使用顺序操作 + 手动回滚
func UserCheckin(userId int) (*Checkin, error) {
setting := operation_setting.GetCheckinSetting()
if !setting.Enabled {
return nil, errors.New("签到功能未启用")
}
// 检查今天是否已签到
hasChecked, err := HasCheckedInToday(userId)
if err != nil {
return nil, err
}
if hasChecked {
return nil, errors.New("今日已签到")
}
// 计算随机额度奖励
quotaAwarded := setting.MinQuota
if setting.MaxQuota > setting.MinQuota {
quotaAwarded = setting.MinQuota + rand.Intn(setting.MaxQuota-setting.MinQuota+1)
}
today := time.Now().Format("2006-01-02")
checkin := &Checkin{
UserId: userId,
CheckinDate: today,
QuotaAwarded: quotaAwarded,
CreatedAt: time.Now().Unix(),
}
// 根据数据库类型选择不同的策略
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
// SQLite 不支持嵌套事务,使用顺序操作 + 手动回滚
return userCheckinWithoutTransaction(checkin, userId, quotaAwarded)
}
// MySQL 和 PostgreSQL 支持事务,使用事务保证原子性
return userCheckinWithTransaction(checkin, userId, quotaAwarded)
}
// userCheckinWithTransaction 使用事务执行签到(适用于 MySQL 和 PostgreSQL)
func userCheckinWithTransaction(checkin *Checkin, userId int, quotaAwarded int) (*Checkin, error) {
err := DB.Transaction(func(tx *gorm.DB) error {
// 步骤1: 创建签到记录
// 数据库有唯一约束 (user_id, checkin_date),可以防止并发重复签到
if err := tx.Create(checkin).Error; err != nil {
return errors.New("签到失败,请稍后重试")
}
// 步骤2: 在事务中增加用户额度
if err := tx.Model(&User{}).Where("id = ?", userId).
Update("quota", gorm.Expr("quota + ?", quotaAwarded)).Error; err != nil {
return errors.New("签到失败:更新额度出错")
}
return nil
})
if err != nil {
return nil, err
}
// 事务成功后,异步更新缓存
go func() {
_ = cacheIncrUserQuota(userId, int64(quotaAwarded))
}()
return checkin, nil
}
// userCheckinWithoutTransaction 不使用事务执行签到(适用于 SQLite)
func userCheckinWithoutTransaction(checkin *Checkin, userId int, quotaAwarded int) (*Checkin, error) {
// 步骤1: 创建签到记录
// 数据库有唯一约束 (user_id, checkin_date),可以防止并发重复签到
if err := DB.Create(checkin).Error; err != nil {
return nil, errors.New("签到失败,请稍后重试")
}
// 步骤2: 增加用户额度
// 使用 db=true 强制直接写入数据库,不使用批量更新
if err := IncreaseUserQuota(userId, quotaAwarded, true); err != nil {
// 如果增加额度失败,需要回滚签到记录
DB.Delete(checkin)
return nil, errors.New("签到失败:更新额度出错")
}
return checkin, nil
}
// GetUserCheckinStats 获取用户签到统计信息
func GetUserCheckinStats(userId int, month string) (map[string]any, error) {
// 获取指定月份的所有签到记录
startDate := month + "-01"
endDate := month + "-31"
records, err := GetUserCheckinRecords(userId, startDate, endDate)
if err != nil {
return nil, err
}
// 转换为不包含敏感字段的记录
checkinRecords := make([]CheckinRecord, len(records))
for i, r := range records {
checkinRecords[i] = CheckinRecord{
CheckinDate: r.CheckinDate,
QuotaAwarded: r.QuotaAwarded,
}
}
// 检查今天是否已签到
hasCheckedToday, _ := HasCheckedInToday(userId)
// 获取用户所有时间的签到统计
var totalCheckins int64
var totalQuota int64
DB.Model(&Checkin{}).Where("user_id = ?", userId).Count(&totalCheckins)
DB.Model(&Checkin{}).Where("user_id = ?", userId).Select("COALESCE(SUM(quota_awarded), 0)").Scan(&totalQuota)
return map[string]any{
"total_quota": totalQuota, // 所有时间累计获得的额度
"total_checkins": totalCheckins, // 所有时间累计签到次数
"checkin_count": len(records), // 本月签到次数
"checked_in_today": hasCheckedToday, // 今天是否已签到
"records": checkinRecords, // 本月签到记录详情(不含id和user_id)
}, nil
}
+154
View File
@@ -0,0 +1,154 @@
package model
import (
"os"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsClickHouseDSN(t *testing.T) {
cases := []struct {
dsn string
want bool
}{
{"clickhouse://default:pass@localhost:9000/logs", true},
{"tcp://localhost:9000/logs", true},
{"http://localhost:8123/logs", true},
{"https://localhost:8443/logs", true},
{"postgres://root:pass@localhost:5432/db", false},
{"postgresql://root:pass@localhost:5432/db", false},
{"root:pass@tcp(localhost:3306)/db", false},
{"local", false},
{"", false},
}
for _, c := range cases {
assert.Equalf(t, c.want, isClickHouseDSN(c.dsn), "dsn=%q", c.dsn)
}
}
func TestNormalizeClickHouseDSN(t *testing.T) {
// https without secure gets secure=true appended
normalized := normalizeClickHouseDSN("https://default:pass@localhost:8443/logs")
assert.Contains(t, normalized, "secure=true")
assert.True(t, strings.HasPrefix(normalized, "https://"))
// https that already specifies secure is left untouched
assert.Equal(t,
"https://localhost:8443/logs?secure=false",
normalizeClickHouseDSN("https://localhost:8443/logs?secure=false"),
)
// non-https schemes are returned verbatim
assert.Equal(t, "clickhouse://localhost:9000/logs", normalizeClickHouseDSN("clickhouse://localhost:9000/logs"))
assert.Equal(t, "tcp://localhost:9000/logs", normalizeClickHouseDSN("tcp://localhost:9000/logs"))
}
func TestChooseDBRejectsClickHouseForMainDatabase(t *testing.T) {
original, had := os.LookupEnv("SQL_DSN")
t.Cleanup(func() {
if had {
require.NoError(t, os.Setenv("SQL_DSN", original))
} else {
require.NoError(t, os.Unsetenv("SQL_DSN"))
}
})
require.NoError(t, os.Setenv("SQL_DSN", "clickhouse://default:pass@localhost:9000/logs"))
db, dbType, err := chooseDB("SQL_DSN", false)
require.Error(t, err)
assert.Nil(t, db)
assert.Equal(t, common.DatabaseType(""), dbType)
assert.Contains(t, err.Error(), "does not support ClickHouse")
}
func TestClickHouseLogTTLExpression(t *testing.T) {
assert.Equal(t, "", clickHouseLogTTLExpression(0))
assert.Equal(t, "", clickHouseLogTTLExpression(-5))
assert.Equal(t, "toDateTime(created_at) + INTERVAL 30 DAY DELETE", clickHouseLogTTLExpression(30))
}
func TestClickHouseLogTTLClause(t *testing.T) {
assert.Equal(t, "", clickHouseLogTTLClause(0))
assert.Equal(t, "\nTTL toDateTime(created_at) + INTERVAL 7 DAY DELETE", clickHouseLogTTLClause(7))
}
func TestClickHouseLogCreateTableSQL(t *testing.T) {
withoutTTL := clickHouseLogCreateTableSQL(0)
assert.Contains(t, withoutTTL, "CREATE TABLE IF NOT EXISTS logs")
assert.Contains(t, withoutTTL, "ENGINE = MergeTree()")
assert.Contains(t, withoutTTL, "PARTITION BY toYYYYMM(toDateTime(created_at))")
assert.Contains(t, withoutTTL, "ORDER BY (created_at, request_id)")
assert.NotContains(t, withoutTTL, "TTL ")
withTTL := clickHouseLogCreateTableSQL(30)
assert.Contains(t, withTTL, "ORDER BY (created_at, request_id)")
assert.Contains(t, withTTL, "TTL toDateTime(created_at) + INTERVAL 30 DAY DELETE")
}
func TestClickHouseCreateTableHasTTL(t *testing.T) {
assert.True(t, clickHouseCreateTableHasTTL("CREATE TABLE logs (...)\nTTL toDateTime(created_at) + INTERVAL 30 DAY DELETE"))
assert.True(t, clickHouseCreateTableHasTTL("CREATE TABLE logs (...) TTL toDateTime(created_at)"))
assert.False(t, clickHouseCreateTableHasTTL("CREATE TABLE logs (...)\nORDER BY (created_at, request_id)"))
}
func TestClickHouseLogOrder(t *testing.T) {
assert.Equal(t, "created_at desc, request_id desc", clickHouseLogOrder(""))
assert.Equal(t, "logs.created_at desc, logs.request_id desc", clickHouseLogOrder("logs."))
}
func TestBuildLogLikeConditionUsesStandardEscape(t *testing.T) {
originalLogDatabaseType := common.LogDatabaseType()
t.Cleanup(func() {
common.SetLogDatabaseType(originalLogDatabaseType)
})
common.SetLogDatabaseType(common.DatabaseTypeSQLite)
condition, pattern, err := buildLogLikeCondition("logs.model_name", "gpt_4%")
require.NoError(t, err)
assert.Equal(t, "logs.model_name LIKE ? ESCAPE '!'", condition)
assert.Equal(t, "gpt!_4%", pattern)
}
func TestBuildLogLikeConditionUsesClickHouseEscaping(t *testing.T) {
originalLogDatabaseType := common.LogDatabaseType()
t.Cleanup(func() {
common.SetLogDatabaseType(originalLogDatabaseType)
})
common.SetLogDatabaseType(common.DatabaseTypeClickHouse)
condition, pattern, err := buildLogLikeCondition("logs.model_name", `gpt_4\mini%`)
require.NoError(t, err)
assert.Equal(t, "logs.model_name LIKE ?", condition)
assert.Equal(t, `gpt\_4\\mini%`, pattern)
}
func TestEnsureLogRequestId(t *testing.T) {
empty := &Log{}
ensureLogRequestId(empty)
assert.NotEmpty(t, empty.RequestId, "empty request id should be backfilled")
existing := &Log{RequestId: "fixed-request-id"}
ensureLogRequestId(existing)
assert.Equal(t, "fixed-request-id", existing.RequestId, "existing request id must be preserved")
assert.NotPanics(t, func() { ensureLogRequestId(nil) })
}
func TestAssignDisplayLogIds(t *testing.T) {
logs := []*Log{{}, {}, {}}
assignDisplayLogIds(logs, 0)
assert.Equal(t, []int{1, 2, 3}, []int{logs[0].Id, logs[1].Id, logs[2].Id})
assignDisplayLogIds(logs, 20)
assert.Equal(t, []int{21, 22, 23}, []int{logs[0].Id, logs[1].Id, logs[2].Id})
assert.NotPanics(t, func() { assignDisplayLogIds(nil, 0) })
}
+250
View File
@@ -0,0 +1,250 @@
package model
import (
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
)
type accessPolicyPayload struct {
Logic string `json:"logic"`
Conditions []accessConditionItem `json:"conditions"`
Groups []accessPolicyPayload `json:"groups"`
}
type accessConditionItem struct {
Field string `json:"field"`
Op string `json:"op"`
Value any `json:"value"`
}
var supportedAccessPolicyOps = map[string]struct{}{
"eq": {},
"ne": {},
"gt": {},
"gte": {},
"lt": {},
"lte": {},
"in": {},
"not_in": {},
"contains": {},
"not_contains": {},
"exists": {},
"not_exists": {},
}
// CustomOAuthProvider stores configuration for custom OAuth providers
type CustomOAuthProvider struct {
Id int `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"type:varchar(64);not null"` // Display name, e.g., "GitHub Enterprise"
Slug string `json:"slug" gorm:"type:varchar(64);uniqueIndex;not null"` // URL identifier, e.g., "github-enterprise"
Icon string `json:"icon" gorm:"type:varchar(128);default:''"` // Icon name from @lobehub/icons
Enabled bool `json:"enabled" gorm:"default:false"` // Whether this provider is enabled
ClientId string `json:"client_id" gorm:"type:varchar(256)"` // OAuth client ID
ClientSecret string `json:"-" gorm:"type:varchar(512)"` // OAuth client secret (not returned to frontend)
AuthorizationEndpoint string `json:"authorization_endpoint" gorm:"type:varchar(512)"` // Authorization URL
TokenEndpoint string `json:"token_endpoint" gorm:"type:varchar(512)"` // Token exchange URL
UserInfoEndpoint string `json:"user_info_endpoint" gorm:"type:varchar(512)"` // User info URL
Scopes string `json:"scopes" gorm:"type:varchar(256);default:'openid profile email'"` // OAuth scopes
// Field mapping configuration (supports JSONPath via gjson)
UserIdField string `json:"user_id_field" gorm:"type:varchar(128);default:'sub'"` // User ID field path, e.g., "sub", "id", "data.user.id"
UsernameField string `json:"username_field" gorm:"type:varchar(128);default:'preferred_username'"` // Username field path
DisplayNameField string `json:"display_name_field" gorm:"type:varchar(128);default:'name'"` // Display name field path
EmailField string `json:"email_field" gorm:"type:varchar(128);default:'email'"` // Email field path
// Advanced options
WellKnown string `json:"well_known" gorm:"type:varchar(512)"` // OIDC discovery endpoint (optional)
AuthStyle int `json:"auth_style" gorm:"default:0"` // 0=auto, 1=params, 2=header (Basic Auth)
AccessPolicy string `json:"access_policy" gorm:"type:text"` // JSON policy for access control based on user info
AccessDeniedMessage string `json:"access_denied_message" gorm:"type:varchar(512)"` // Custom error message template when access is denied
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (CustomOAuthProvider) TableName() string {
return "custom_oauth_providers"
}
// GetAllCustomOAuthProviders returns all custom OAuth providers
func GetAllCustomOAuthProviders() ([]*CustomOAuthProvider, error) {
var providers []*CustomOAuthProvider
err := DB.Order("id asc").Find(&providers).Error
return providers, err
}
// GetEnabledCustomOAuthProviders returns all enabled custom OAuth providers
func GetEnabledCustomOAuthProviders() ([]*CustomOAuthProvider, error) {
var providers []*CustomOAuthProvider
err := DB.Where("enabled = ?", true).Order("id asc").Find(&providers).Error
return providers, err
}
// GetCustomOAuthProviderById returns a custom OAuth provider by ID
func GetCustomOAuthProviderById(id int) (*CustomOAuthProvider, error) {
var provider CustomOAuthProvider
err := DB.First(&provider, id).Error
if err != nil {
return nil, err
}
return &provider, nil
}
// GetCustomOAuthProviderBySlug returns a custom OAuth provider by slug
func GetCustomOAuthProviderBySlug(slug string) (*CustomOAuthProvider, error) {
var provider CustomOAuthProvider
err := DB.Where("slug = ?", slug).First(&provider).Error
if err != nil {
return nil, err
}
return &provider, nil
}
// CreateCustomOAuthProvider creates a new custom OAuth provider
func CreateCustomOAuthProvider(provider *CustomOAuthProvider) error {
if err := validateCustomOAuthProvider(provider); err != nil {
return err
}
return DB.Create(provider).Error
}
// UpdateCustomOAuthProvider updates an existing custom OAuth provider
func UpdateCustomOAuthProvider(provider *CustomOAuthProvider) error {
if err := validateCustomOAuthProvider(provider); err != nil {
return err
}
return DB.Save(provider).Error
}
// DeleteCustomOAuthProvider deletes a custom OAuth provider by ID
func DeleteCustomOAuthProvider(id int) error {
// First, delete all user bindings for this provider
if err := DB.Where("provider_id = ?", id).Delete(&UserOAuthBinding{}).Error; err != nil {
return err
}
return DB.Delete(&CustomOAuthProvider{}, id).Error
}
// IsSlugTaken checks if a slug is already taken by another provider
// Returns true on DB errors (fail-closed) to prevent slug conflicts
func IsSlugTaken(slug string, excludeId int) bool {
var count int64
query := DB.Model(&CustomOAuthProvider{}).Where("slug = ?", slug)
if excludeId > 0 {
query = query.Where("id != ?", excludeId)
}
res := query.Count(&count)
if res.Error != nil {
// Fail-closed: treat DB errors as slug being taken to prevent conflicts
return true
}
return count > 0
}
// validateCustomOAuthProvider validates a custom OAuth provider configuration
func validateCustomOAuthProvider(provider *CustomOAuthProvider) error {
if provider.Name == "" {
return errors.New("provider name is required")
}
if provider.Slug == "" {
return errors.New("provider slug is required")
}
// Slug must be lowercase and contain only alphanumeric characters and hyphens
slug := strings.ToLower(provider.Slug)
for _, c := range slug {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
return errors.New("provider slug must contain only lowercase letters, numbers, and hyphens")
}
}
provider.Slug = slug
if slug == "telegram" {
return errors.New("the telegram slug is reserved for built-in Telegram OAuth; rename this custom provider")
}
if provider.ClientId == "" {
return errors.New("client ID is required")
}
if provider.AuthorizationEndpoint == "" {
return errors.New("authorization endpoint is required")
}
if provider.TokenEndpoint == "" {
return errors.New("token endpoint is required")
}
if provider.UserInfoEndpoint == "" {
return errors.New("user info endpoint is required")
}
// Set defaults for field mappings if empty
if provider.UserIdField == "" {
provider.UserIdField = "sub"
}
if provider.UsernameField == "" {
provider.UsernameField = "preferred_username"
}
if provider.DisplayNameField == "" {
provider.DisplayNameField = "name"
}
if provider.EmailField == "" {
provider.EmailField = "email"
}
if provider.Scopes == "" {
provider.Scopes = "openid profile email"
}
if strings.TrimSpace(provider.AccessPolicy) != "" {
var policy accessPolicyPayload
if err := common.UnmarshalJsonStr(provider.AccessPolicy, &policy); err != nil {
return errors.New("access_policy must be valid JSON")
}
if err := validateAccessPolicyPayload(&policy); err != nil {
return fmt.Errorf("access_policy is invalid: %w", err)
}
}
return nil
}
func validateAccessPolicyPayload(policy *accessPolicyPayload) error {
if policy == nil {
return errors.New("policy is nil")
}
logic := strings.ToLower(strings.TrimSpace(policy.Logic))
if logic == "" {
logic = "and"
}
if logic != "and" && logic != "or" {
return fmt.Errorf("unsupported logic: %s", logic)
}
if len(policy.Conditions) == 0 && len(policy.Groups) == 0 {
return errors.New("policy requires at least one condition or group")
}
for index, condition := range policy.Conditions {
field := strings.TrimSpace(condition.Field)
if field == "" {
return fmt.Errorf("condition[%d].field is required", index)
}
op := strings.ToLower(strings.TrimSpace(condition.Op))
if _, ok := supportedAccessPolicyOps[op]; !ok {
return fmt.Errorf("condition[%d].op is unsupported: %s", index, op)
}
if op == "in" || op == "not_in" {
if _, ok := condition.Value.([]any); !ok {
return fmt.Errorf("condition[%d].value must be an array for op %s", index, op)
}
}
}
for index := range policy.Groups {
if err := validateAccessPolicyPayload(&policy.Groups[index]); err != nil {
return fmt.Errorf("group[%d]: %w", index, err)
}
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package model
import "github.com/QuantumNous/new-api/common"
// GetDBTimestamp returns a UNIX timestamp from database time.
// Falls back to application time on error.
func GetDBTimestamp() int64 {
var ts int64
var err error
switch {
case common.UsingMainDatabase(common.DatabaseTypePostgreSQL):
err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())::bigint").Scan(&ts).Error
case common.UsingMainDatabase(common.DatabaseTypeSQLite):
err = DB.Raw("SELECT strftime('%s','now')").Scan(&ts).Error
default:
err = DB.Raw("SELECT UNIX_TIMESTAMP()").Scan(&ts).Error
}
if err != nil || ts <= 0 {
return common.GetTimestamp()
}
return ts
}
+205
View File
@@ -0,0 +1,205 @@
package model
import (
"errors"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
const (
EmailBindingTTL = 10 * time.Minute
EmailBindingResendDelay = time.Minute
EmailBindingMaxAttempts = 5
)
var (
ErrEmailBindingCodeInvalid = errors.New("Email verification code is incorrect.")
ErrEmailBindingLocked = errors.New("Too many incorrect codes. Start email verification again.")
ErrEmailBindingResendWait = errors.New("Please wait before requesting another verification code.")
)
// EmailBindingState is server-owned payload. It deliberately contains only
// salted code hashes and an already-consumed authorization, never usable codes
// or proof tokens. Resending must retain the deadline and failure counter.
type EmailBindingState struct {
Authorization *AuthFlowAuthorization `json:"authorization"`
CurrentEmail string `json:"current_email"`
Email string `json:"email"`
NewCodeHash string `json:"new_code_hash"`
OldCodeHash string `json:"old_code_hash,omitempty"`
FailedAttempts int `json:"failed_attempts"`
ResendAt int64 `json:"resend_at"`
}
func CreateEmailBinding(identity AuthSessionIdentity, state EmailBindingState) (string, *AuthFlow, error) {
var token string
var flow *AuthFlow
err := DB.Transaction(func(tx *gorm.DB) error {
if err := validateEmailBindingAccountWithTx(tx, identity, &state); err != nil {
return err
}
if err := ensureEmailAvailableWithTx(tx, state.Email, identity.UserID); err != nil {
return err
}
payload, err := common.Marshal(state)
if err != nil {
return err
}
token, flow, err = createAuthFlowWithTx(tx, AuthFlowCreate{
Purpose: AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID,
Payload: string(payload), ExpiresAt: time.Now().Add(EmailBindingTTL),
})
return err
})
return token, flow, err
}
func GetEmailBinding(identity AuthSessionIdentity, token string) (*AuthFlow, *EmailBindingState, error) {
flow, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
if err != nil {
return nil, nil, err
}
var state EmailBindingState
if err := common.UnmarshalJsonStr(flow.Payload, &state); err != nil {
return nil, nil, ErrAuthFlowInvalid
}
return flow, &state, nil
}
func ResendEmailBinding(identity AuthSessionIdentity, token, newCodeHash, oldCodeHash string) (*AuthFlow, *EmailBindingState, error) {
var flow *AuthFlow
var state *EmailBindingState
err := DB.Transaction(func(tx *gorm.DB) error {
var err error
flow, state, err = lockEmailBindingWithTx(tx, identity, token)
if err != nil {
return err
}
now := time.Now()
if state.ResendAt > now.Unix() {
return ErrEmailBindingResendWait
}
if newCodeHash == "" || (state.OldCodeHash != "") != (oldCodeHash != "") {
return ErrAuthFlowInvalid
}
state.NewCodeHash, state.OldCodeHash = newCodeHash, oldCodeHash
state.ResendAt = now.Add(EmailBindingResendDelay).Unix()
payload, err := common.Marshal(state)
if err != nil {
return err
}
return tx.Model(flow).Update("payload", string(payload)).Error
})
return flow, state, err
}
// CompleteEmailBinding commits failed attempts even though verification fails.
// Successful confirmation consumes the flow in the same transaction as the
// email update, preserving the existing cross-database email ownership lock.
func CompleteEmailBinding(identity AuthSessionIdentity, token, email, newCode, oldCode string) (*EmailBindingState, error) {
var state *EmailBindingState
var verificationError error
err := DB.Transaction(func(tx *gorm.DB) error {
// Acquire ownership protection before the first consistent read. Under
// MySQL REPEATABLE READ, a snapshot created before this lock could hide
// an address claimed by a concurrent transaction while we were waiting.
if email == "" || email != NormalizeEmail(email) {
return ErrAuthFlowInvalid
}
if err := lockNormalizedEmail(tx, email); err != nil {
return err
}
flow, lockedState, err := lockEmailBindingWithTx(tx, identity, token)
if err != nil {
return err
}
state = lockedState
if state.Email != email {
return ErrAuthFlowInvalid
}
newCode, newCodeError := common.ValidateNumericCode(newCode)
oldCode, oldCodeError := common.ValidateNumericCode(oldCode)
newValid := newCodeError == nil && common.ValidatePasswordAndHash(newCode, state.NewCodeHash)
oldValid := state.OldCodeHash == "" || oldCodeError == nil && common.ValidatePasswordAndHash(oldCode, state.OldCodeHash)
if !newValid || !oldValid {
state.FailedAttempts++
verificationError = ErrEmailBindingCodeInvalid
if state.FailedAttempts >= EmailBindingMaxAttempts {
verificationError = ErrEmailBindingLocked
}
payload, err := common.Marshal(state)
if err != nil {
return err
}
return tx.Model(flow).Update("payload", string(payload)).Error
}
if err := ensureEmailAvailableWithTx(tx, state.Email, identity.UserID); err != nil {
return err
}
if err := tx.Model(&User{}).Where("id = ?", identity.UserID).Update("email", state.Email).Error; err != nil {
return err
}
return tx.Model(flow).Update("consumed_at", time.Now()).Error
})
if err != nil {
return nil, err
}
return state, verificationError
}
func lockEmailBindingWithTx(tx *gorm.DB, identity AuthSessionIdentity, token string) (*AuthFlow, *EmailBindingState, error) {
match := AuthFlowMatch{Purpose: AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID}
// Make the first statement a write so SQLite does not have to upgrade a
// deferred read transaction. A no-op update also locks the row on MySQL and
// PostgreSQL; do not interpret dialect-dependent RowsAffected as success.
if err := applyAuthFlowMatch(tx.Model(&AuthFlow{}), token, match).
Where("consumed_at IS NULL AND expires_at > ?", time.Now()).
UpdateColumn("payload", gorm.Expr("payload")).Error; err != nil {
return nil, nil, err
}
var flow AuthFlow
if err := applyAuthFlowMatch(tx, token, match).First(&flow).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, ErrAuthFlowInvalid
}
return nil, nil, err
}
if flow.ConsumedAt != nil {
return nil, nil, ErrAuthFlowConsumed
}
if !flow.ExpiresAt.After(time.Now()) {
return nil, nil, ErrAuthFlowExpired
}
var state EmailBindingState
if err := common.UnmarshalJsonStr(flow.Payload, &state); err != nil {
return nil, nil, ErrAuthFlowInvalid
}
if err := validateEmailBindingAccountWithTx(tx, identity, &state); err != nil {
return nil, nil, err
}
if state.FailedAttempts >= EmailBindingMaxAttempts {
return nil, nil, ErrEmailBindingLocked
}
return &flow, &state, nil
}
func validateEmailBindingAccountWithTx(tx *gorm.DB, identity AuthSessionIdentity, state *EmailBindingState) error {
if state.Authorization == nil || state.Authorization.ProofID <= 0 || state.Authorization.AuthSessionIdentity != identity ||
state.Authorization.Scope != "account.binding.bind" || state.Authorization.ContextHash == "" ||
state.Email == "" || state.Email != NormalizeEmail(state.Email) || state.NewCodeHash == "" {
return ErrAuthFlowInvalid
}
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var user User
if err := tx.Select("email").First(&user, identity.UserID).Error; err != nil {
return err
}
if NormalizeEmail(user.Email) != state.CurrentEmail || state.CurrentEmail == state.Email {
return ErrAccountBindingChanged
}
return nil
}
+30
View File
@@ -0,0 +1,30 @@
package model
import "errors"
// Common errors
var (
ErrDatabase = errors.New("database error")
)
// User auth errors
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserEmptyCredentials = errors.New("empty credentials")
ErrEmailAlreadyTaken = errors.New("email already taken")
ErrEmailNotFound = errors.New("email not found")
ErrEmailAmbiguous = errors.New("email matches multiple users")
)
// Token auth errors
var (
ErrTokenNotProvided = errors.New("token not provided")
ErrTokenInvalid = errors.New("token invalid")
)
// Redemption errors
var ErrRedeemFailed = errors.New("redeem.failed")
// 2FA errors
var ErrTwoFANotEnabled = errors.New("2fa not enabled")
var ErrTwoFAAlreadyEnabled = errors.New("2fa already enabled")
+135
View File
@@ -0,0 +1,135 @@
package model
import (
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const ExternalIdentityProviderTelegram = "telegram"
var ErrExternalIdentityAlreadyClaimed = errors.New("external identity is already claimed")
// ExternalIdentityClaim is the durable ownership record for an identity issued
// by an external provider. The two unique indexes make both the provider
// subject and the user's provider slot single-owner without relying on a
// check-then-update sequence.
type ExternalIdentityClaim struct {
Id int64 `json:"id" gorm:"primaryKey"`
Provider string `json:"provider" gorm:"type:varchar(32);not null;uniqueIndex:idx_external_identity_subject,priority:1;uniqueIndex:idx_external_identity_user,priority:1"`
Subject string `json:"subject" gorm:"type:varchar(128);not null;uniqueIndex:idx_external_identity_subject,priority:2"`
UserId int `json:"user_id" gorm:"not null;index;uniqueIndex:idx_external_identity_user,priority:2"`
CreatedAt time.Time `json:"created_at"`
}
func (ExternalIdentityClaim) TableName() string {
return "external_identity_claims"
}
// ClaimExternalIdentityWithTx atomically claims a provider subject for one
// user. Repeating the exact mapping is idempotent; every competing subject or
// user is rejected. Ownership is read back instead of trusting RowsAffected,
// whose duplicate-key semantics differ between supported databases.
func ClaimExternalIdentityWithTx(tx *gorm.DB, provider, subject string, userId int) error {
provider = strings.TrimSpace(provider)
subject = strings.TrimSpace(subject)
if tx == nil || provider == "" || subject == "" || userId == 0 {
return errors.New("external identity claim is invalid")
}
claim := ExternalIdentityClaim{Provider: provider, Subject: subject, UserId: userId}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&claim)
if result.Error != nil {
return result.Error
}
var subjectOwner ExternalIdentityClaim
if err := tx.Where("provider = ? AND subject = ?", provider, subject).First(&subjectOwner).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrExternalIdentityAlreadyClaimed
}
return err
}
if subjectOwner.UserId != userId {
return ErrExternalIdentityAlreadyClaimed
}
var userClaim ExternalIdentityClaim
if err := tx.Where("provider = ? AND user_id = ?", provider, userId).First(&userClaim).Error; err != nil {
return err
}
if userClaim.Subject != subject {
return ErrExternalIdentityAlreadyClaimed
}
return nil
}
func ReleaseExternalIdentityWithTx(tx *gorm.DB, provider string, userId int) error {
provider = strings.TrimSpace(provider)
if tx == nil || provider == "" || userId == 0 {
return errors.New("external identity release is invalid")
}
return tx.Where("provider = ? AND user_id = ?", provider, userId).
Delete(&ExternalIdentityClaim{}).Error
}
func GetUserByTelegramID(telegramID string) (*User, error) {
var user User
err := DB.Where("telegram_id = ?", telegramID).First(&user).Error
return &user, err
}
// BindTelegramForSessionWithTx preserves single ownership and the session that
// started the binding. The caller consumes its OAuth flow in this transaction.
func BindTelegramForSessionWithTx(tx *gorm.DB, identity AuthSessionIdentity, telegramID string) error {
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var user User
if err := tx.Select("id", "telegram_id").First(&user, identity.UserID).Error; err != nil {
return err
}
if user.TelegramId != "" {
return ErrExternalIdentityAlreadyClaimed
}
if err := ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, telegramID, user.Id); err != nil {
return err
}
result := tx.Model(&User{}).Where("id = ? AND (telegram_id = ? OR telegram_id IS NULL)", user.Id, "").Update("telegram_id", telegramID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrExternalIdentityAlreadyClaimed
}
return nil
}
func releaseAllExternalIdentitiesWithTx(tx *gorm.DB, userId int) error {
if tx == nil || userId == 0 {
return errors.New("external identity release is invalid")
}
return tx.Where("user_id = ?", userId).Delete(&ExternalIdentityClaim{}).Error
}
// InitializeExternalIdentityClaims imports legacy Telegram bindings after the
// claim table is migrated. Existing duplicate ownership fails migration rather
// than preserving an ambiguous login identity.
func InitializeExternalIdentityClaims() error {
var users []User
if err := DB.Unscoped().Select("id", "telegram_id").
Where("telegram_id <> ?", "").Find(&users).Error; err != nil {
return err
}
return DB.Transaction(func(tx *gorm.DB) error {
for _, user := range users {
if err := ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id); err != nil {
return fmt.Errorf("backfill Telegram identity for user %d: %w", user.Id, err)
}
}
return nil
})
}
+91
View File
@@ -0,0 +1,91 @@
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestExternalIdentityClaimEnforcesSingleOwnerAtomically(t *testing.T) {
truncateTables(t)
first := User{Username: "telegram-owner-one", Password: "password", AffCode: "telegram-owner-one"}
second := User{Username: "telegram-owner-two", Password: "password", AffCode: "telegram-owner-two"}
require.NoError(t, DB.Create(&first).Error)
require.NoError(t, DB.Create(&second).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", first.Id)
}))
err := DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", second.Id)
})
assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed)
err = DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-456", first.Id)
})
assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed)
var claims []ExternalIdentityClaim
require.NoError(t, DB.Find(&claims).Error)
require.Len(t, claims, 1)
assert.Equal(t, first.Id, claims[0].UserId)
assert.Equal(t, "telegram-123", claims[0].Subject)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ReleaseExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, first.Id)
}))
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", second.Id)
}))
}
func TestClearTelegramBindingReleasesIdentityClaim(t *testing.T) {
truncateTables(t)
user := User{Username: "telegram-unbind", Password: "password", TelegramId: "telegram-unbind-id"}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id)
}))
require.NoError(t, user.ClearBinding(ExternalIdentityProviderTelegram))
assert.Empty(t, user.TelegramId)
var count int64
require.NoError(t, DB.Model(&ExternalIdentityClaim{}).Where("user_id = ?", user.Id).Count(&count).Error)
assert.Zero(t, count)
}
func TestInitializeExternalIdentityClaimsIsIdempotent(t *testing.T) {
truncateTables(t)
user := User{Username: "telegram-legacy", Password: "password", TelegramId: "telegram-legacy-id"}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, InitializeExternalIdentityClaims())
require.NoError(t, InitializeExternalIdentityClaims())
var claim ExternalIdentityClaim
require.NoError(t, DB.Where("provider = ? AND subject = ?", ExternalIdentityProviderTelegram, user.TelegramId).
First(&claim).Error)
assert.Equal(t, user.Id, claim.UserId)
}
func TestInitializeExternalIdentityClaimsRejectsAmbiguousLegacyBindings(t *testing.T) {
truncateTables(t)
first := User{Username: "telegram-legacy-one", Password: "password", TelegramId: "duplicate-telegram-id", AffCode: "telegram-legacy-one"}
second := User{Username: "telegram-legacy-two", Password: "password", TelegramId: "duplicate-telegram-id", AffCode: "telegram-legacy-two"}
require.NoError(t, DB.Create(&first).Error)
require.NoError(t, DB.Create(&second).Error)
err := InitializeExternalIdentityClaims()
assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed)
var count int64
require.NoError(t, DB.Model(&ExternalIdentityClaim{}).Count(&count).Error)
assert.Zero(t, count)
}
+239
View File
@@ -0,0 +1,239 @@
package model
import (
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/console_setting"
"gorm.io/gorm"
)
const retiredThemeOptionKey = "theme.frontend"
type legacyOptionTransform func(string) (string, error)
// MigrateRetiredFrontendOptions normalizes options that belonged to the
// removed dashboard frontend. Each legacy console setting is migrated in its
// own transaction so one malformed value cannot block the other settings.
func MigrateRetiredFrontendOptions() error {
if DB == nil {
return errors.New("database is not initialized")
}
var migrationErrors []error
if err := normalizeRetiredThemeOption(); err != nil {
migrationErrors = append(migrationErrors, fmt.Errorf("normalize %s: %w", retiredThemeOptionKey, err))
}
migrations := []struct {
source string
target string
transform legacyOptionTransform
}{
{source: "ApiInfo", target: "console_setting.api_info", transform: transformLegacyAPIInfo},
{source: "Announcements", target: "console_setting.announcements", transform: transformLegacyAnnouncements},
{source: "FAQ", target: "console_setting.faq", transform: transformLegacyFAQ},
}
for _, migration := range migrations {
if err := migrateLegacyOption(migration.source, migration.target, migration.transform); err != nil {
migrationErrors = append(migrationErrors, err)
}
}
if err := migrateLegacyUptimeOptions(); err != nil {
migrationErrors = append(migrationErrors, err)
}
return errors.Join(migrationErrors...)
}
func normalizeRetiredThemeOption() error {
return DB.Transaction(func(tx *gorm.DB) error {
var option Option
err := tx.Where(&Option{Key: retiredThemeOptionKey}).First(&option).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&Option{Key: retiredThemeOptionKey, Value: "default"}).Error
}
if err != nil {
return err
}
if option.Value == "default" {
return nil
}
return tx.Model(&option).Update("value", "default").Error
})
}
func migrateLegacyOption(sourceKey, targetKey string, transform legacyOptionTransform) error {
return DB.Transaction(func(tx *gorm.DB) error {
var source Option
if err := tx.Where(&Option{Key: sourceKey}).First(&source).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("read legacy option %s: %w", sourceKey, err)
}
var target Option
err := tx.Where(&Option{Key: targetKey}).First(&target).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("read target option %s: %w", targetKey, err)
}
if err == nil {
return tx.Delete(&source).Error
}
value, transformErr := transform(source.Value)
if transformErr != nil {
common.SysError(fmt.Sprintf("legacy option %s was not migrated: %v", sourceKey, transformErr))
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
target = Option{Key: targetKey}
}
target.Value = value
if err := tx.Save(&target).Error; err != nil {
return fmt.Errorf("write target option %s: %w", targetKey, err)
}
if err := tx.Delete(&source).Error; err != nil {
return fmt.Errorf("delete legacy option %s: %w", sourceKey, err)
}
return nil
})
}
func transformLegacyAPIInfo(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", errors.New("value is empty")
}
var items []map[string]any
if err := common.UnmarshalJsonStr(value, &items); err != nil {
return "", err
}
if len(items) > 50 {
items = items[:50]
}
encoded, err := common.Marshal(items)
if err != nil {
return "", err
}
result := string(encoded)
if err := console_setting.ValidateConsoleSettings(result, "ApiInfo"); err != nil {
return "", err
}
return result, nil
}
func transformLegacyAnnouncements(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", errors.New("value is empty")
}
if err := console_setting.ValidateConsoleSettings(value, "Announcements"); err != nil {
return "", err
}
return value, nil
}
func transformLegacyFAQ(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", errors.New("value is empty")
}
var legacyItems []map[string]any
if err := common.UnmarshalJsonStr(value, &legacyItems); err != nil {
return "", err
}
items := make([]map[string]any, 0, len(legacyItems))
for index, item := range legacyItems {
question, _ := item["question"].(string)
if strings.TrimSpace(question) == "" {
question, _ = item["title"].(string)
}
answer, _ := item["answer"].(string)
if strings.TrimSpace(answer) == "" {
answer, _ = item["content"].(string)
}
if strings.TrimSpace(question) == "" || strings.TrimSpace(answer) == "" {
return "", fmt.Errorf("FAQ entry %d is missing a question or answer", index)
}
items = append(items, map[string]any{"question": question, "answer": answer})
}
if len(items) > 50 {
items = items[:50]
}
encoded, err := common.Marshal(items)
if err != nil {
return "", err
}
result := string(encoded)
if err := console_setting.ValidateConsoleSettings(result, "FAQ"); err != nil {
return "", err
}
return result, nil
}
func migrateLegacyUptimeOptions() error {
return DB.Transaction(func(tx *gorm.DB) error {
var urlOption Option
urlErr := tx.Where(&Option{Key: "UptimeKumaUrl"}).First(&urlOption).Error
if urlErr != nil && !errors.Is(urlErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read legacy option UptimeKumaUrl: %w", urlErr)
}
var slugOption Option
slugErr := tx.Where(&Option{Key: "UptimeKumaSlug"}).First(&slugOption).Error
if slugErr != nil && !errors.Is(slugErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read legacy option UptimeKumaSlug: %w", slugErr)
}
if errors.Is(urlErr, gorm.ErrRecordNotFound) && errors.Is(slugErr, gorm.ErrRecordNotFound) {
return nil
}
var target Option
targetErr := tx.Where(&Option{Key: "console_setting.uptime_kuma_groups"}).First(&target).Error
if targetErr != nil && !errors.Is(targetErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read target option console_setting.uptime_kuma_groups: %w", targetErr)
}
if targetErr == nil {
if urlErr == nil {
if err := tx.Delete(&urlOption).Error; err != nil {
return err
}
}
if slugErr == nil {
return tx.Delete(&slugOption).Error
}
return nil
}
if urlErr != nil || slugErr != nil || strings.TrimSpace(urlOption.Value) == "" || strings.TrimSpace(slugOption.Value) == "" {
common.SysError("legacy Uptime Kuma options were not migrated: both URL and slug are required")
return nil
}
groups := []map[string]any{{
"id": 1,
"categoryName": "old",
"url": urlOption.Value,
"slug": slugOption.Value,
"description": "",
}}
encoded, err := common.Marshal(groups)
if err != nil {
return err
}
value := string(encoded)
if err := console_setting.ValidateConsoleSettings(value, "UptimeKumaGroups"); err != nil {
common.SysError(fmt.Sprintf("legacy Uptime Kuma options were not migrated: %v", err))
return nil
}
if errors.Is(targetErr, gorm.ErrRecordNotFound) {
target = Option{Key: "console_setting.uptime_kuma_groups"}
}
target.Value = value
if err := tx.Save(&target).Error; err != nil {
return fmt.Errorf("write target option console_setting.uptime_kuma_groups: %w", err)
}
if err := tx.Delete(&urlOption).Error; err != nil {
return err
}
return tx.Delete(&slugOption).Error
})
}
+180
View File
@@ -0,0 +1,180 @@
package model
import (
"fmt"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func useFrontendOptionMigrationDB(t *testing.T) *gorm.DB {
t.Helper()
previousDB := DB
previousType := common.MainDatabaseType()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&Option{}))
DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
t.Cleanup(func() {
DB = previousDB
common.SetMainDatabaseType(previousType)
})
return db
}
func requireOptionValue(t *testing.T, db *gorm.DB, key string) string {
t.Helper()
var option Option
require.NoError(t, db.Where(&Option{Key: key}).First(&option).Error)
return option.Value
}
func requireOptionMissing(t *testing.T, db *gorm.DB, key string) {
t.Helper()
var option Option
assert.ErrorIs(t, db.Where(&Option{Key: key}).First(&option).Error, gorm.ErrRecordNotFound)
}
func TestMigrateRetiredFrontendOptionsMigratesValidValuesIdempotently(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
legacy := []Option{
{Key: retiredThemeOptionKey, Value: "classic"},
{Key: "ApiInfo", Value: `[{"url":"https://api.example.com","route":"primary","description":"API","color":"blue"}]`},
{Key: "Announcements", Value: `[{"content":"maintenance","publishDate":"2026-07-20T00:00:00Z","type":"warning"}]`},
{Key: "FAQ", Value: `[{"title":"Question","content":"Answer"}]`},
{Key: "UptimeKumaUrl", Value: "https://status.example.com"},
{Key: "UptimeKumaSlug", Value: "status"},
}
require.NoError(t, db.Create(&legacy).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, "default", requireOptionValue(t, db, retiredThemeOptionKey))
assert.JSONEq(t, legacy[1].Value, requireOptionValue(t, db, "console_setting.api_info"))
assert.Equal(t, legacy[2].Value, requireOptionValue(t, db, "console_setting.announcements"))
assert.JSONEq(t, `[{"question":"Question","answer":"Answer"}]`, requireOptionValue(t, db, "console_setting.faq"))
assert.JSONEq(t, `[{
"id":1,"categoryName":"old","url":"https://status.example.com","slug":"status","description":""
}]`, requireOptionValue(t, db, "console_setting.uptime_kuma_groups"))
for _, key := range []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"} {
requireOptionMissing(t, db, key)
}
before, err := AllOption()
require.NoError(t, err)
require.NoError(t, MigrateRetiredFrontendOptions())
after, err := AllOption()
require.NoError(t, err)
assert.ElementsMatch(t, before, after)
}
func TestLegacyConsoleListMigrationCapsAPIInfoAndFAQ(t *testing.T) {
apiInfo := make([]map[string]any, 51)
faq := make([]map[string]any, 51)
for i := range apiInfo {
apiInfo[i] = map[string]any{
"url": fmt.Sprintf("https://api-%d.example.com", i),
"route": fmt.Sprintf("route-%d", i),
"description": "API",
"color": "blue",
}
faq[i] = map[string]any{"title": fmt.Sprintf("Question %d", i), "content": "Answer"}
}
apiBytes, err := common.Marshal(apiInfo)
require.NoError(t, err)
faqBytes, err := common.Marshal(faq)
require.NoError(t, err)
migratedAPI, err := transformLegacyAPIInfo(string(apiBytes))
require.NoError(t, err)
migratedFAQ, err := transformLegacyFAQ(string(faqBytes))
require.NoError(t, err)
var apiResult []map[string]any
require.NoError(t, common.UnmarshalJsonStr(migratedAPI, &apiResult))
var faqResult []map[string]any
require.NoError(t, common.UnmarshalJsonStr(migratedFAQ, &faqResult))
assert.Len(t, apiResult, 50)
assert.Len(t, faqResult, 50)
}
func TestMigrateRetiredFrontendOptionsPreservesMalformedValuesAndContinues(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
legacy := []Option{
{Key: "ApiInfo", Value: `{invalid`},
{Key: "FAQ", Value: `[{"question":"Question","answer":"Answer"}]`},
{Key: "UptimeKumaUrl", Value: "https://status.example.com"},
}
require.NoError(t, db.Create(&legacy).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, `{invalid`, requireOptionValue(t, db, "ApiInfo"))
requireOptionMissing(t, db, "console_setting.api_info")
requireOptionMissing(t, db, "FAQ")
assert.JSONEq(t, legacy[1].Value, requireOptionValue(t, db, "console_setting.faq"))
assert.Equal(t, "https://status.example.com", requireOptionValue(t, db, "UptimeKumaUrl"))
requireOptionMissing(t, db, "console_setting.uptime_kuma_groups")
}
func TestMigrateRetiredFrontendOptionsPreservesMixedInvalidFAQ(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
legacyFAQ := `[{"question":"Valid question","answer":"Valid answer"},{"question":"Missing answer"}]`
require.NoError(t, db.Create(&Option{Key: "FAQ", Value: legacyFAQ}).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, legacyFAQ, requireOptionValue(t, db, "FAQ"))
requireOptionMissing(t, db, "console_setting.faq")
}
func TestMigrateRetiredFrontendOptionsKeepsAuthoritativeTargets(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
options := []Option{
{Key: "ApiInfo", Value: `{invalid`},
{Key: "console_setting.api_info", Value: `[{"url":"https://new.example.com"}]`},
{Key: "UptimeKumaUrl", Value: "https://old.example.com"},
{Key: "UptimeKumaSlug", Value: "old"},
{Key: "console_setting.uptime_kuma_groups", Value: `[{"url":"https://new.example.com"}]`},
}
require.NoError(t, db.Create(&options).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, options[1].Value, requireOptionValue(t, db, "console_setting.api_info"))
assert.Equal(t, options[4].Value, requireOptionValue(t, db, "console_setting.uptime_kuma_groups"))
for _, key := range []string{"ApiInfo", "UptimeKumaUrl", "UptimeKumaSlug"} {
requireOptionMissing(t, db, key)
}
}
func TestMigrateRetiredFrontendOptionsKeepsEmptyAuthoritativeTargets(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
options := []Option{
{Key: "ApiInfo", Value: `[{"url":"https://old.example.com"}]`},
{Key: "console_setting.api_info", Value: ""},
{Key: "UptimeKumaUrl", Value: "https://old.example.com"},
{Key: "UptimeKumaSlug", Value: "old"},
{Key: "console_setting.uptime_kuma_groups", Value: ""},
}
require.NoError(t, db.Create(&options).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Empty(t, requireOptionValue(t, db, "console_setting.api_info"))
assert.Empty(t, requireOptionValue(t, db, "console_setting.uptime_kuma_groups"))
for _, key := range []string{"ApiInfo", "UptimeKumaUrl", "UptimeKumaSlug"} {
requireOptionMissing(t, db, key)
}
}
func TestRetiredThemeOptionIsPersistedButNotPublished(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
previousMap := common.OptionMap
t.Cleanup(func() { common.OptionMap = previousMap })
common.OptionMap = map[string]string{}
require.NoError(t, UpdateOption(retiredThemeOptionKey, "default"))
assert.Equal(t, "default", requireOptionValue(t, db, retiredThemeOptionKey))
_, published := common.OptionMap[retiredThemeOptionKey]
assert.False(t, published)
}
+91
View File
@@ -0,0 +1,91 @@
package model
import (
"errors"
"fmt"
"io"
"log"
"os"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/proto"
"github.com/QuantumNous/new-api/common"
sqlitedriver "github.com/glebarez/go-sqlite"
"github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
defaultSlowThresholdMs = 200
maxSlowThresholdMs = 60 * 60 * 1000
)
func newGormConfig(prepareStmt bool) *gorm.Config {
return &gorm.Config{
PrepareStmt: prepareStmt,
Logger: newGormLogger(os.Stdout),
}
}
func newGormLogger(w io.Writer) logger.Interface {
slowThresholdMs := common.GetEnvOrDefault("SQL_SLOW_THRESHOLD_MS", defaultSlowThresholdMs)
if slowThresholdMs < 0 || slowThresholdMs > maxSlowThresholdMs {
common.SysError(fmt.Sprintf("invalid SQL_SLOW_THRESHOLD_MS %d (allowed 0-%d, 0 disables slow query log), using default %d", slowThresholdMs, maxSlowThresholdMs, defaultSlowThresholdMs))
slowThresholdMs = defaultSlowThresholdMs
}
// 在 Writer 层脱敏而非包装 logger.Interface:后者会让 gorm 的 FileWithLineNum
// 把所有 SQL 日志的调用点归因到包装层自身,且需转发 ParamsFilter 类型断言。
return logger.New(&sanitizedLogWriter{delegate: log.New(w, "\r\n", log.LstdFlags)}, logger.Config{
SlowThreshold: time.Duration(slowThresholdMs) * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
ParameterizedQueries: !common.DebugEnabled,
Colorful: true,
})
}
// ParameterizedQueries 只过滤 SQL 字符串,驱动错误消息(如 MySQL 1062)同样会
// 内联数据值,在这里收敛为错误码;DEBUG=true 保留原文。
type sanitizedLogWriter struct {
delegate *log.Logger
}
func (s *sanitizedLogWriter) Printf(format string, args ...any) {
if !common.DebugEnabled {
for i, arg := range args {
if err, ok := arg.(error); ok {
args[i] = sanitizeDBError(err)
}
}
}
s.delegate.Printf(format, args...)
}
// 只收敛数据库服务端生成的驱动错误(消息可能内联数据值);网络/上下文等
// 其它错误不含查询数据,原样保留以便排障。
func sanitizeDBError(err error) error {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) {
return fmt.Errorf("mysql error %d", mysqlErr.Number)
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
// 08P01 是 PgBouncer 对同连接重名 Parse 的 FATAL,42P05 是原生 PostgreSQL 的
// duplicate_prepared_statement;都指向预处理语句与事务池代理不兼容。
if pgErr.Code == "08P01" || pgErr.Code == "42P05" {
return fmt.Errorf("postgres error SQLSTATE %s: prepared statement conflict with a transaction-pooling proxy (PgBouncer/Neon/Supabase); other clients sharing this database must disable prepared statements, or upgrade PgBouncer to >=1.21 with max_prepared_statements enabled", pgErr.Code)
}
return fmt.Errorf("postgres error SQLSTATE %s", pgErr.Code)
}
var chErr *proto.Exception
if errors.As(err, &chErr) {
return fmt.Errorf("clickhouse error %d", chErr.Code)
}
var sqliteErr *sqlitedriver.Error
if errors.As(err, &sqliteErr) {
return fmt.Errorf("sqlite error %d", sqliteErr.Code())
}
return err
}
+108
View File
@@ -0,0 +1,108 @@
package model
import (
"bytes"
"fmt"
"testing"
"github.com/ClickHouse/clickhouse-go/v2/lib/proto"
"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// 保护契约:数据库驱动错误消息可能内联数据值,非 DEBUG 下日志只保留错误码。
func TestSanitizeDBErrorStripsDriverMessage(t *testing.T) {
tests := []struct {
name string
err error
want string
leaked string
}{
{
name: "mysql duplicate entry",
err: &mysql.MySQLError{Number: 1062, Message: "Duplicate entry 'secret-value' for key 'users.idx'"},
want: "mysql error 1062",
leaked: "secret-value",
},
{
name: "postgres unique violation",
err: &pgconn.PgError{Code: "23505", Message: "duplicate key value", Detail: "Key (k)=(secret-value) already exists."},
want: "postgres error SQLSTATE 23505",
leaked: "secret-value",
},
{
name: "postgres pooler prepared statement conflict gets remediation hint",
err: &pgconn.PgError{Severity: "FATAL", Code: "08P01", Message: "prepared statement name is already in use: stmt_secret-value"},
want: "postgres error SQLSTATE 08P01: prepared statement conflict with a transaction-pooling proxy (PgBouncer/Neon/Supabase); other clients sharing this database must disable prepared statements, or upgrade PgBouncer to >=1.21 with max_prepared_statements enabled",
leaked: "secret-value",
},
{
name: "clickhouse exception",
err: &proto.Exception{Code: 241, Message: "Memory limit exceeded while processing 'secret-value'"},
want: "clickhouse error 241",
leaked: "secret-value",
},
{
name: "wrapped driver error",
err: fmt.Errorf("exec failed: %w", &mysql.MySQLError{Number: 1064, Message: "syntax error near 'secret-value'"}),
want: "mysql error 1064",
leaked: "secret-value",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeDBError(tc.err)
require.Error(t, got)
assert.Equal(t, tc.want, got.Error())
assert.NotContains(t, got.Error(), tc.leaked)
})
}
}
func TestSanitizeDBErrorSQLiteDriver(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
execErr := db.Exec("INSERT INTO missing_table (k) VALUES (?)", "secret-value").Error
require.Error(t, execErr)
got := sanitizeDBError(execErr)
assert.Regexp(t, `^sqlite error \d+$`, got.Error())
assert.NotContains(t, got.Error(), "secret-value")
}
func TestSanitizeDBErrorKeepsNonDriverErrors(t *testing.T) {
err := fmt.Errorf("dial tcp 127.0.0.1:3306: connect: connection refused")
assert.Equal(t, err, sanitizeDBError(err))
}
// 保护契约:经 gorm 真实链路,错误日志同时满足 SQL 参数化、驱动错误脱敏、
// 调用点归因到业务代码;DEBUG=true 恢复参数值与错误原文。
func TestGormLoggerEndToEndSanitizedOutput(t *testing.T) {
previousDebug := common.DebugEnabled
t.Cleanup(func() { common.DebugEnabled = previousDebug })
execQuery := func() string {
var buf bytes.Buffer
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: newGormLogger(&buf)})
require.NoError(t, err)
db.Exec("SELECT * FROM missing_table WHERE k = ?", "secret-value")
return buf.String()
}
common.DebugEnabled = false
out := execQuery()
assert.Contains(t, out, "k = ?")
assert.NotContains(t, out, "secret-value")
assert.Contains(t, out, "sqlite error")
assert.Contains(t, out, "gorm_logger_test.go")
common.DebugEnabled = true
debugOut := execQuery()
assert.Contains(t, debugOut, "secret-value")
assert.Contains(t, debugOut, "no such table")
}
+119
View File
@@ -0,0 +1,119 @@
package model
import (
"context"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
)
type StatusChannel struct {
Id int
Group string
Status int
}
type GroupCacheStats struct {
Requests int64 `json:"requests"`
CachedRequests int64 `json:"cached_requests"`
InputTokens int64 `json:"input_tokens"`
CachedTokens int64 `json:"cached_tokens"`
FirstResponseMs float64 `json:"first_response_ms,omitempty"`
}
func GetStatusChannels(ctx context.Context) ([]StatusChannel, error) {
var channels []StatusChannel
err := DB.WithContext(ctx).Model(&Channel{}).Select("id", "group", "status").
Where("status IN ?", []int{common.ChannelStatusEnabled, common.ChannelStatusAutoDisabled}).
Order("id").Find(&channels).Error
return channels, err
}
// Channels retain their identity when a public group is renamed.
func GetGroupCacheStats(ctx context.Context, channels []StatusChannel, start, end time.Time) (map[string]GroupCacheStats, error) {
stats := make(map[string]GroupCacheStats)
firstResponseTotals := make(map[string]struct {
total float64
count int64
})
channelGroups := make(map[int][]string)
ids := make([]int, 0, len(channels))
for _, channel := range channels {
ids = append(ids, channel.Id)
for _, group := range strings.Split(channel.Group, ",") {
group = strings.TrimSpace(group)
if group != "" && !common.StringsContains(channelGroups[channel.Id], group) {
channelGroups[channel.Id] = append(channelGroups[channel.Id], group)
stats[group] = GroupCacheStats{}
}
}
}
if len(ids) == 0 {
return stats, nil
}
rows, err := LOG_DB.WithContext(ctx).Model(&Log{}).Select("channel_id", "prompt_tokens", "other").
Where("type = ? AND token_id > 0 AND created_at >= ? AND created_at < ?", LogTypeConsume, start.Unix(), end.Unix()).
Where("channel_id IN ?", ids).Rows()
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var channelID int
var prompt int64
var raw string
if err := rows.Scan(&channelID, &prompt, &raw); err != nil {
return nil, err
}
var usage struct {
Cached int64 `json:"cache_tokens"`
Created int64 `json:"cache_creation_tokens"`
Created5m int64 `json:"cache_creation_tokens_5m"`
Created1h int64 `json:"cache_creation_tokens_1h"`
Written int64 `json:"cache_write_tokens"`
InputTotal int64 `json:"input_tokens_total"`
Semantic string `json:"usage_semantic"`
Claude bool `json:"claude"`
FirstResponseMs float64 `json:"frt"`
}
if strings.TrimSpace(raw) != "" && common.UnmarshalJsonStr(raw, &usage) != nil {
continue
}
input := max(prompt, 0)
cached := max(usage.Cached, 0)
if usage.InputTotal > 0 {
input = usage.InputTotal
} else if usage.Semantic == "anthropic" || (usage.Semantic == "" && usage.Claude) {
input += cached + max(usage.Written, usage.Created, max(usage.Created5m, 0)+max(usage.Created1h, 0), 0)
}
if input == 0 {
continue
}
cached = min(cached, input)
for _, group := range channelGroups[channelID] {
total := stats[group]
total.Requests++
if cached > 0 {
total.CachedRequests++
}
total.InputTokens += input
total.CachedTokens += cached
stats[group] = total
if usage.FirstResponseMs > 0 {
frt := firstResponseTotals[group]
frt.total += usage.FirstResponseMs
frt.count++
firstResponseTotals[group] = frt
}
}
}
for group, total := range firstResponseTotals {
if total.count > 0 {
groupStats := stats[group]
groupStats.FirstResponseMs = total.total / float64(total.count)
stats[group] = groupStats
}
}
return stats, rows.Err()
}
+92
View File
@@ -0,0 +1,92 @@
package model
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestGroupCacheStatsDatabaseMatrix(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
var driver gorm.Dialector
switch dialect {
case "sqlite":
driver = sqlite.Open(":memory:")
case "mysql":
dsn := os.Getenv("TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("TEST_MYSQL_DSN is not configured")
}
driver = mysql.Open(dsn)
case "postgres":
dsn := os.Getenv("TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("TEST_POSTGRES_DSN is not configured")
}
driver = postgres.Open(dsn)
}
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("gs_%d_", time.Now().UnixNano())}})
require.NoError(t, err)
originalDB, originalLogDB := DB, LOG_DB
DB, LOG_DB = db, db
t.Cleanup(func() { DB, LOG_DB = originalDB, originalLogDB })
channelTable := db.NamingStrategy.TableName("channels")
logTable := db.NamingStrategy.TableName("logs")
type sample struct {
Id int
ChannelId int
PromptTokens int64
Other string
Type int
TokenId int
CreatedAt int64
}
require.NoError(t, db.Table(channelTable).AutoMigrate(&StatusChannel{}))
require.NoError(t, db.Table(logTable).AutoMigrate(&sample{}))
t.Cleanup(func() {
require.NoError(t, db.Migrator().DropTable(channelTable, logTable))
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, sqlDB.Close())
})
require.NoError(t, db.Table(channelTable).Create(&[]StatusChannel{
{Id: 1, Group: "renamed", Status: 1}, {Id: 2, Group: "automatic-down", Status: 3},
{Id: 3, Group: "manual-off", Status: 2}, {Id: 4, Group: "unused", Status: 1},
}).Error)
require.NoError(t, db.Table(logTable).Create(&[]sample{
{ChannelId: 1, PromptTokens: 100, Other: `{"cache_tokens":80}`, Type: 2, TokenId: 1, CreatedAt: 150},
{ChannelId: 1, PromptTokens: 300, Other: `{"cache_tokens":0}`, Type: 2, TokenId: 1, CreatedAt: 150},
{ChannelId: 1, PromptTokens: 20, Other: `{"cache_tokens":50,"usage_semantic":"anthropic","cache_creation_tokens":30}`, Type: 2, TokenId: 1, CreatedAt: 150},
{ChannelId: 1, PromptTokens: 10, Other: `{"cache_tokens":40,"input_tokens_total":100,"claude":true}`, Type: 2, TokenId: 1, CreatedAt: 150},
{ChannelId: 2, PromptTokens: 50, Other: `{}`, Type: 2, TokenId: 1, CreatedAt: 150},
{ChannelId: 1, PromptTokens: 999, Other: `{}`, Type: 2, TokenId: 0, CreatedAt: 150},
{ChannelId: 1, PromptTokens: 999, Other: `{}`, Type: 5, TokenId: 1, CreatedAt: 150},
{ChannelId: 1, PromptTokens: 999, Other: `{}`, Type: 2, TokenId: 1, CreatedAt: 99},
{ChannelId: 1, PromptTokens: 999, Other: `{}`, Type: 2, TokenId: 1, CreatedAt: 200},
{ChannelId: 1, PromptTokens: 999, Other: `invalid`, Type: 2, TokenId: 1, CreatedAt: 150},
{ChannelId: 1, PromptTokens: 0, Other: `{}`, Type: 2, TokenId: 1, CreatedAt: 150},
{ChannelId: 3, PromptTokens: 999, Other: `{}`, Type: 2, TokenId: 1, CreatedAt: 150},
}).Error)
channels, err := GetStatusChannels(context.Background())
require.NoError(t, err)
require.Len(t, channels, 3)
stats, err := GetGroupCacheStats(context.Background(), channels, time.Unix(100, 0), time.Unix(200, 0))
require.NoError(t, err)
assert.Equal(t, GroupCacheStats{Requests: 4, CachedRequests: 3, InputTokens: 600, CachedTokens: 170}, stats["renamed"])
assert.Equal(t, GroupCacheStats{Requests: 1, InputTokens: 50}, stats["automatic-down"])
assert.Equal(t, GroupCacheStats{}, stats["unused"])
assert.NotContains(t, stats, "manual-off")
})
}
}
+94
View File
@@ -0,0 +1,94 @@
package model
import (
"database/sql/driver"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// 保护契约:PostgreSQL 走 simple protocol(PrepareStmt 关闭)时,driver.Valuer
// 返回 []byte 会被 pgx 按 bytea 十六进制字面量编码,写入 json 列触发
// SQLSTATE 22P02。所有 json 列的 Value() 必须返回 string(或 nil)。
func TestJSONColumnValuersReturnString(t *testing.T) {
testCases := []struct {
name string
valuer driver.Valuer
want string
}{
{
name: "ChannelInfo",
valuer: ChannelInfo{IsMultiKey: true, MultiKeySize: 2},
want: `{"is_multi_key":true,"multi_key_size":2,"multi_key_status_list":null,"multi_key_polling_index":0,"multi_key_mode":""}`,
},
{
name: "Properties",
valuer: Properties{Input: "hello"},
want: `{"input":"hello"}`,
},
{
name: "TaskPrivateData",
valuer: TaskPrivateData{Key: "k"},
want: `{"key":"k"}`,
},
{
name: "JSONValue",
valuer: JSONValue(`[{"k":"v"}]`),
want: `[{"k":"v"}]`,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
value, err := testCase.valuer.Value()
require.NoError(t, err)
str, ok := value.(string)
require.True(t, ok, "Value() must return string, got %T", value)
assert.JSONEq(t, testCase.want, str)
})
}
}
// 空值仍返回 nil,保持列的 NULL 语义。
func TestJSONColumnValuersZeroValueIsNil(t *testing.T) {
for name, valuer := range map[string]driver.Valuer{
"Properties": Properties{},
"TaskPrivateData": TaskPrivateData{},
"JSONValue": JSONValue(nil),
} {
t.Run(name, func(t *testing.T) {
value, err := valuer.Value()
require.NoError(t, err)
assert.Nil(t, value)
})
}
}
// 保护契约:json 列的 Scan 必须同时接受 []byte 与 string——不同驱动/协议
// 模式返回类型不同,静默丢弃 string 会把已有数据清零。
func TestJSONColumnScannersAcceptStringAndBytes(t *testing.T) {
toInput := func(kind string, payload string) any {
if kind == "bytes" {
return []byte(payload)
}
return payload
}
for _, kind := range []string{"bytes", "string"} {
t.Run(kind, func(t *testing.T) {
var info ChannelInfo
require.NoError(t, info.Scan(toInput(kind, `{"is_multi_key":true,"multi_key_size":2}`)))
assert.True(t, info.IsMultiKey)
assert.Equal(t, 2, info.MultiKeySize)
var props Properties
require.NoError(t, props.Scan(toInput(kind, `{"input":"hello"}`)))
assert.Equal(t, "hello", props.Input)
var private TaskPrivateData
require.NoError(t, private.Scan(toInput(kind, `{"key":"k"}`)))
assert.Equal(t, "k", private.Key)
})
}
}
+25
View File
@@ -0,0 +1,25 @@
package model
import (
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// lockForUpdate makes the next query emit SELECT ... FOR UPDATE so the matched
// rows stay locked until the surrounding transaction ends.
//
// GORM v2 silently ignores the legacy `Set("gorm:query_option", "FOR UPDATE")`
// from GORM v1, so that form does not lock anything. Always use this helper
// instead.
//
// SQLite has no FOR UPDATE syntax (the clause would be a syntax error), so it
// is skipped there; SQLite's single-writer model makes one of two conflicting
// transactions fail instead of both committing.
func lockForUpdate(tx *gorm.DB) *gorm.DB {
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
return tx
}
return tx.Clauses(clause.Locking{Strength: "UPDATE"})
}
+38
View File
@@ -0,0 +1,38 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/utils/tests"
)
// lockForUpdate must emit FOR UPDATE on databases that support it and skip
// it on SQLite, where the syntax does not exist.
//
// The dummy dialector is used because SQLite drivers strip locking clauses
// from the generated SQL, which would mask what the helper itself does.
func TestLockForUpdateEmitsRowLock(t *testing.T) {
dummyDB, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{DryRun: true})
require.NoError(t, err)
buildSQL := func() string {
var rows []Redemption
return lockForUpdate(dummyDB).Where("id = ?", 1).Find(&rows).Statement.SQL.String()
}
t.Cleanup(func() {
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
})
common.SetDatabaseTypes(common.DatabaseTypeMySQL, common.DatabaseTypeSQLite)
assert.Contains(t, buildSQL(), "FOR UPDATE")
common.SetDatabaseTypes(common.DatabaseTypePostgreSQL, common.DatabaseTypeSQLite)
assert.Contains(t, buildSQL(), "FOR UPDATE")
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
assert.NotContains(t, buildSQL(), "FOR UPDATE")
}
+739
View File
@@ -0,0 +1,739 @@
package model
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func applyExplicitLogTextFilter(tx *gorm.DB, column string, value string) (*gorm.DB, error) {
if value == "" {
return tx, nil
}
if strings.Contains(value, "%") {
condition, pattern, err := buildLogLikeCondition(column, value)
if err != nil {
return nil, err
}
return tx.Where(condition, pattern), nil
}
return tx.Where(column+" = ?", value), nil
}
func buildLogLikeCondition(column string, value string) (string, string, error) {
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
pattern, err := sanitizeClickHouseLikePattern(value)
if err != nil {
return "", "", err
}
return column + " LIKE ?", pattern, nil
}
pattern, err := sanitizeLikePattern(value)
if err != nil {
return "", "", err
}
return column + " LIKE ? ESCAPE '!'", pattern, nil
}
func sanitizeClickHouseLikePattern(input string) (string, error) {
input = strings.ReplaceAll(input, `\`, `\\`)
input = strings.ReplaceAll(input, `_`, `\_`)
if err := validateLikePattern(input); err != nil {
return "", err
}
return input, nil
}
type Log struct {
Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"`
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"`
Type int `json:"type" gorm:"index:idx_created_at_type"`
Content string `json:"content"`
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
TokenName string `json:"token_name" gorm:"index;default:''"`
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
Quota int `json:"quota" gorm:"default:0"`
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
UseTime int `json:"use_time" gorm:"default:0"`
IsStream bool `json:"is_stream"`
ChannelId int `json:"channel" gorm:"index"`
ChannelName string `json:"channel_name" gorm:"->"`
TokenId int `json:"token_id" gorm:"default:0;index"`
Group string `json:"group" gorm:"index"`
Ip string `json:"ip" gorm:"index;default:''"`
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
Other string `json:"other"`
}
// don't use iota, avoid change log type value
const (
LogTypeUnknown = 0
LogTypeTopup = 1
LogTypeConsume = 2
LogTypeManage = 3
LogTypeSystem = 4
LogTypeError = 5
LogTypeRefund = 6
LogTypeLogin = 7
)
func ensureLogRequestId(log *Log) {
if log != nil && log.RequestId == "" {
log.RequestId = common.NewRequestId()
}
}
func createLog(log *Log) error {
ensureLogRequestId(log)
return LOG_DB.Create(log).Error
}
func clickHouseLogOrder(prefix string) string {
return prefix + "created_at desc, " + prefix + "request_id desc"
}
func assignDisplayLogIds(logs []*Log, startIdx int) {
for i := range logs {
logs[i].Id = startIdx + i + 1
}
}
func formatUserLogs(logs []*Log, startIdx int) {
for i := range logs {
logs[i].ChannelName = ""
logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityUser)
}
assignDisplayLogIds(logs, startIdx)
}
// FormatAdminLogs removes root-only diagnostics while retaining operational
// admin_info. Root callers must not pass their results through this formatter.
func FormatAdminLogs(logs []*Log) {
for i := range logs {
logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityAdmin)
}
}
// FormatRootLogs normalizes legacy metadata into the current scoped shape
// without removing root-only diagnostics.
func FormatRootLogs(logs []*Log) {
for i := range logs {
logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityRoot)
}
}
func GetLogByTokenId(tokenId int) (logs []*Log, err error) {
order := "id desc"
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
order = clickHouseLogOrder("")
}
err = LOG_DB.Model(&Log{}).Where("token_id = ?", tokenId).Order(order).Limit(common.MaxRecentItems).Find(&logs).Error
formatUserLogs(logs, 0)
return logs, err
}
func RecordLog(userId int, logType int, content string) {
if logType == LogTypeConsume && !common.LogConsumeEnabled {
return
}
username, _ := GetUsernameById(userId, false)
log := &Log{
UserId: userId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: logType,
Content: content,
}
err := createLog(log)
if err != nil {
common.SysLog("failed to record log: " + err.Error())
}
}
// RecordLogWithAdminInfo stores operator metadata under other.admin_info and
// an optional, user-visible operation descriptor under other.op for localization.
func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo *AuditAdminInfo, operation *AuditOperation, request ...*gin.Context) {
if logType == LogTypeConsume && !common.LogConsumeEnabled {
return
}
username, _ := GetUsernameById(userId, false)
log := &Log{
UserId: userId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: logType,
Content: content,
}
if logType == LogTypeManage {
var c *gin.Context
if len(request) > 0 {
c = request[0]
}
actorRole := 0
if c != nil {
actorRole = c.GetInt("role")
}
RecordAuditLog(c, AuditLog{UserId: userId, Username: username, ActorRole: actorRole, Category: AuditCategoryOperation, Content: content, Other: AuditOther{AdminInfo: adminInfo, Op: operation}, Success: true})
return
}
if len(request) > 0 && request[0] != nil {
log.RequestId = request[0].GetString(common.RequestIdKey)
}
if adminInfo != nil || operation != nil {
data, err := common.Marshal(AuditOther{AdminInfo: adminInfo, Op: operation})
if err != nil {
common.SysError("failed to encode log admin info: " + err.Error())
return
}
log.Other = string(data)
}
if err := createLog(log); err != nil {
common.SysLog("failed to record log: " + err.Error())
}
}
// RecordLoginLog writes new login events to the independent audit table.
// username 由调用方传入(登录流程已持有用户对象),避免额外的数据库查询。
// content 为英文兜底文本(用于导出);action+params 供前端本地化渲染。
// other 包含 login_method、user_agent 等结构化信息。
func RecordLoginLog(userId, actorRole int, username string, content string, ip string, action string, params map[string]any, other AuditOther, request ...*gin.Context) {
other.Op = &AuditOperation{Action: action, Params: params}
var c *gin.Context
if len(request) > 0 {
c = request[0]
}
RecordAuditLog(c, AuditLog{UserId: userId, Username: username, ActorRole: actorRole, Category: AuditCategoryLogin, Action: action, Content: content, Ip: ip, Other: other, Success: true})
}
// RecordOperationAuditLog writes new operation/security events to the audit table.
// logUserId 为日志归属者,管理审计日志应归属实际操作者;目标资源/用户放入
// action params。username 内部按 logUserId 查询。content 为英文兜底文本(供导出使用)。
// action+params 写入 Other.op,供前端本地化渲染(普通用户可见,不含敏感信息)。
// adminInfo 存放操作者身份(写入 Other.admin_info,普通用户查询时剥离);
// auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。
func RecordOperationAuditLog(logUserId, actorRole int, content string, ip string, action string, params map[string]any, adminInfo *AuditAdminInfo, auditInfo *AuditRequestInfo, request ...*gin.Context) {
username, _ := GetUsernameById(logUserId, false)
other := AuditOther{
Op: &AuditOperation{Action: action, Params: params},
AdminInfo: adminInfo,
AuditInfo: auditInfo,
}
var c *gin.Context
if len(request) > 0 {
c = request[0]
}
category := AuditCategoryOperation
if adminInfo == nil {
category = AuditCategorySecurity
}
status, success := 200, true
if auditInfo != nil {
status = auditInfo.Status
success = auditInfo.Success
}
RecordAuditLog(c, AuditLog{UserId: logUserId, Username: username, ActorRole: actorRole, Category: category, Action: action, Content: content, Ip: ip, Status: status, Success: success, Other: other})
}
func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) {
username, _ := GetUsernameById(userId, false)
other := NewLogOther()
other.MergeAdmin(map[string]any{
"server_ip": common.GetIp(),
"node_name": common.NodeName,
"caller_ip": callerIp,
"payment_method": paymentMethod,
"callback_payment_method": callbackPaymentMethod,
"version": common.Version,
})
log := &Log{
UserId: userId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: LogTypeTopup,
Content: content,
Ip: callerIp,
Other: other.JSONString(),
}
err := createLog(log)
if err != nil {
common.SysLog("failed to record topup log: " + err.Error())
}
}
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
isStream bool, group string, other *LogOther) {
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content)))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
otherStr := other.JSONString()
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
if settingMap.RecordIpLog {
needRecordIp = true
}
}
log := &Log{
UserId: userId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: LogTypeError,
Content: content,
PromptTokens: 0,
CompletionTokens: 0,
TokenName: tokenName,
ModelName: modelName,
Quota: 0,
ChannelId: channelId,
TokenId: tokenId,
UseTime: useTimeSeconds,
IsStream: isStream,
Group: group,
Ip: func() string {
if needRecordIp {
return c.ClientIP()
}
return ""
}(),
RequestId: requestId,
UpstreamRequestId: upstreamRequestId,
Other: otherStr,
}
err := createLog(log)
if err != nil {
logger.LogError(c, "failed to record log: "+err.Error())
}
}
type RecordConsumeLogParams struct {
ChannelId int `json:"channel_id"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
ModelName string `json:"model_name"`
TokenName string `json:"token_name"`
Quota int `json:"quota"`
Content string `json:"content"`
TokenId int `json:"token_id"`
UseTimeSeconds int `json:"use_time_seconds"`
IsStream bool `json:"is_stream"`
Group string `json:"group"`
Other *LogOther `json:"other"`
}
func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) {
if !common.LogConsumeEnabled {
return
}
logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
createdAt := common.GetTimestamp()
otherStr := params.Other.JSONString()
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
if settingMap.RecordIpLog {
needRecordIp = true
}
}
log := &Log{
UserId: userId,
Username: username,
CreatedAt: createdAt,
Type: LogTypeConsume,
Content: params.Content,
PromptTokens: params.PromptTokens,
CompletionTokens: params.CompletionTokens,
TokenName: params.TokenName,
ModelName: params.ModelName,
Quota: params.Quota,
ChannelId: params.ChannelId,
TokenId: params.TokenId,
UseTime: params.UseTimeSeconds,
IsStream: params.IsStream,
Group: params.Group,
Ip: func() string {
if needRecordIp {
return c.ClientIP()
}
return ""
}(),
RequestId: requestId,
UpstreamRequestId: upstreamRequestId,
Other: otherStr,
}
err := createLog(log)
if err != nil {
logger.LogError(c, "failed to record log: "+err.Error())
}
if common.DataExportEnabled {
LogQuotaData(QuotaDataLogParams{
UserID: userId,
Username: username,
ModelName: params.ModelName,
Quota: params.Quota,
CreatedAt: createdAt,
TokenUsed: params.PromptTokens + params.CompletionTokens,
UseGroup: params.Group,
TokenID: params.TokenId,
ChannelID: params.ChannelId,
NodeName: common.NodeName,
})
}
}
type RecordTaskBillingLogParams struct {
UserId int
LogType int
Content string
ChannelId int
ModelName string
Quota int
TokenId int
Group string
Other *LogOther
NodeName string // 任务发起节点;为空时回退当前节点
}
func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
if params.LogType == LogTypeConsume && !common.LogConsumeEnabled {
return
}
username, _ := GetUsernameById(params.UserId, false)
tokenName := ""
if params.TokenId > 0 {
if token, err := GetTokenById(params.TokenId); err == nil {
tokenName = token.Name
}
}
createdAt := common.GetTimestamp()
log := &Log{
UserId: params.UserId,
Username: username,
CreatedAt: createdAt,
Type: params.LogType,
Content: params.Content,
TokenName: tokenName,
ModelName: params.ModelName,
Quota: params.Quota,
ChannelId: params.ChannelId,
TokenId: params.TokenId,
Group: params.Group,
Other: params.Other.JSONString(),
}
err := createLog(log)
if err != nil {
common.SysLog("failed to record task billing log: " + err.Error())
}
if params.LogType == LogTypeConsume && common.DataExportEnabled {
nodeName := params.NodeName
if nodeName == "" {
nodeName = common.NodeName
}
LogQuotaData(QuotaDataLogParams{
UserID: params.UserId,
Username: username,
ModelName: params.ModelName,
Quota: params.Quota,
CreatedAt: createdAt,
UseGroup: params.Group,
TokenID: params.TokenId,
ChannelID: params.ChannelId,
NodeName: nodeName,
})
}
}
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
tx = LOG_DB
} else {
tx = LOG_DB.Where("logs.type = ?", logType)
}
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
return nil, 0, err
}
if tx, err = applyExplicitLogTextFilter(tx, "logs.username", username); err != nil {
return nil, 0, err
}
if tokenName != "" {
tx = tx.Where("logs.token_name = ?", tokenName)
}
if requestId != "" {
tx = tx.Where("logs.request_id = ?", requestId)
}
if upstreamRequestId != "" {
tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
}
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("logs.created_at <= ?", endTimestamp)
}
if channel != 0 {
tx = tx.Where("logs.channel_id = ?", channel)
}
if group != "" {
tx = tx.Where("logs."+logGroupCol+" = ?", group)
}
err = tx.Model(&Log{}).Count(&total).Error
if err != nil {
return nil, 0, err
}
order := "logs.created_at desc, logs.id desc"
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
order = clickHouseLogOrder("logs.")
}
err = tx.Order(order).Limit(num).Offset(startIdx).Find(&logs).Error
if err != nil {
return nil, 0, err
}
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
assignDisplayLogIds(logs, startIdx)
}
channelIds := types.NewSet[int]()
for _, log := range logs {
if log.ChannelId != 0 {
channelIds.Add(log.ChannelId)
}
}
if channelIds.Len() > 0 {
var channels []struct {
Id int `gorm:"column:id"`
Name string `gorm:"column:name"`
}
if common.MemoryCacheEnabled {
// Cache get channel
for _, channelId := range channelIds.Items() {
if cacheChannel, err := CacheGetChannel(channelId); err == nil {
channels = append(channels, struct {
Id int `gorm:"column:id"`
Name string `gorm:"column:name"`
}{
Id: channelId,
Name: cacheChannel.Name,
})
}
}
} else {
// Bulk query channels from DB
if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds.Items()).Find(&channels).Error; err != nil {
return logs, total, err
}
}
channelMap := make(map[int]string, len(channels))
for _, channel := range channels {
channelMap[channel.Id] = channel.Name
}
for i := range logs {
logs[i].ChannelName = channelMap[logs[i].ChannelId]
}
}
return logs, total, err
}
const logSearchCountLimit = 10000
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
tx = LOG_DB.Where("logs.user_id = ?", userId)
} else {
tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
}
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
return nil, 0, err
}
if tokenName != "" {
tx = tx.Where("logs.token_name = ?", tokenName)
}
if requestId != "" {
tx = tx.Where("logs.request_id = ?", requestId)
}
if upstreamRequestId != "" {
tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
}
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("logs.created_at <= ?", endTimestamp)
}
if group != "" {
tx = tx.Where("logs."+logGroupCol+" = ?", group)
}
err = tx.Model(&Log{}).Limit(logSearchCountLimit).Count(&total).Error
if err != nil {
common.SysError("failed to count user logs: " + err.Error())
return nil, 0, errors.New("查询日志失败")
}
order := "logs.id desc"
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
order = clickHouseLogOrder("logs.")
}
err = tx.Order(order).Limit(num).Offset(startIdx).Find(&logs).Error
if err != nil {
common.SysError("failed to search user logs: " + err.Error())
return nil, 0, errors.New("查询日志失败")
}
formatUserLogs(logs, startIdx)
return logs, total, err
}
type Stat struct {
Quota int `json:"quota"`
Rpm int `json:"rpm"`
Tpm int `json:"tpm"`
}
func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat, err error) {
tx := LOG_DB.Table("logs").Select("COALESCE(sum(quota), 0) quota")
// 为rpm和tpm创建单独的查询
rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, COALESCE(sum(prompt_tokens), 0) + COALESCE(sum(completion_tokens), 0) tpm")
if tx, err = applyExplicitLogTextFilter(tx, "username", username); err != nil {
return stat, err
}
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "username", username); err != nil {
return stat, err
}
if tokenName != "" {
tx = tx.Where("token_name = ?", tokenName)
rpmTpmQuery = rpmTpmQuery.Where("token_name = ?", tokenName)
}
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
if tx, err = applyExplicitLogTextFilter(tx, "model_name", modelName); err != nil {
return stat, err
}
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "model_name", modelName); err != nil {
return stat, err
}
if channel != 0 {
tx = tx.Where("channel_id = ?", channel)
rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel)
}
if group != "" {
tx = tx.Where(logGroupCol+" = ?", group)
rpmTpmQuery = rpmTpmQuery.Where(logGroupCol+" = ?", group)
}
tx = tx.Where("type = ?", LogTypeConsume)
rpmTpmQuery = rpmTpmQuery.Where("type = ?", LogTypeConsume)
// 只统计最近60秒的rpm和tpm
rpmTpmQuery = rpmTpmQuery.Where("created_at >= ?", time.Now().Add(-60*time.Second).Unix())
// 执行查询
if err := tx.Scan(&stat).Error; err != nil {
common.SysError("failed to query log stat: " + err.Error())
return stat, errors.New("查询统计数据失败")
}
var rateStat struct {
Rpm int
Tpm int
}
if err := rpmTpmQuery.Scan(&rateStat).Error; err != nil {
common.SysError("failed to query rpm/tpm stat: " + err.Error())
return stat, errors.New("查询统计数据失败")
}
stat.Rpm = rateStat.Rpm
stat.Tpm = rateStat.Tpm
return stat, nil
}
func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) {
tx := LOG_DB.Table("logs").Select("COALESCE(sum(prompt_tokens), 0) + COALESCE(sum(completion_tokens), 0)")
if username != "" {
tx = tx.Where("username = ?", username)
}
if tokenName != "" {
tx = tx.Where("token_name = ?", tokenName)
}
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
if modelName != "" {
tx = tx.Where("model_name = ?", modelName)
}
tx.Where("type = ?", LogTypeConsume).Scan(&token)
return token
}
func CountOldLog(ctx context.Context, targetTimestamp int64) (int64, error) {
var total int64
if err := LOG_DB.WithContext(ctx).Model(&Log{}).Where("created_at < ?", targetTimestamp).Count(&total).Error; err != nil {
return 0, err
}
return total, nil
}
func DeleteOldLogBatch(ctx context.Context, targetTimestamp int64, limit int) (int64, error) {
if limit <= 0 {
limit = 100
}
if nil != ctx.Err() {
return 0, ctx.Err()
}
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
// ClickHouse DELETE is a heavy mutation that rewrites data parts, so
// per-batch mutations would be pathologically slow. Remove all matching
// rows in a single synchronous mutation regardless of limit; the reported
// count lets the caller's progress loop complete in one pass.
total, err := CountOldLog(ctx, targetTimestamp)
if err != nil {
return 0, err
}
if total == 0 {
return 0, nil
}
if err := LOG_DB.WithContext(ctx).Exec(
"ALTER TABLE logs DELETE WHERE created_at < ? SETTINGS mutations_sync = 1",
targetTimestamp,
).Error; err != nil {
return 0, err
}
return total, nil
}
result := LOG_DB.WithContext(ctx).Where("created_at < ?", targetTimestamp).Limit(limit).Delete(&Log{})
if nil != result.Error {
return 0, result.Error
}
return result.RowsAffected, nil
}
+244
View File
@@ -0,0 +1,244 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestFormatUserLogsStripsQuotaSaturation verifies the admin-only quota
// saturation marker (nested under other.admin_info) is removed for non-admin
// log views, since formatUserLogs strips the whole admin_info object.
func TestFormatUserLogsStripsQuotaSaturation(t *testing.T) {
other := common.MapToJsonStr(map[string]any{
"model_price": 0.004,
"admin_info": map[string]any{
"quota_saturation": map[string]any{
"op": "QuotaFromDecimal",
"kind": "overflow",
"clamped": common.MaxQuota,
},
},
})
logs := []*Log{{Other: other}}
formatUserLogs(logs, 0)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
_, hasAdminInfo := parsed["admin_info"]
require.False(t, hasAdminInfo, "admin_info (and nested quota_saturation) must be stripped for non-admin views")
// Non-admin billing fields remain visible.
require.Contains(t, parsed, "model_price")
}
func TestTaskPluginLogVisibilityIsRoleSeparated(t *testing.T) {
other := common.MapToJsonStr(map[string]any{
"model_price": 1.25,
"admin_info": map[string]any{
"task_plugin": map[string]any{
"key": "document-parser",
"name": "Document Parser",
"version": "1.2.3",
},
},
"root_info": map[string]any{
"upstream_task_id": "upstream-private",
"task_plugin": map[string]any{
"generation": 42,
},
},
})
t.Run("user", func(t *testing.T) {
logs := []*Log{{Other: other}}
formatUserLogs(logs, 0)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.NotContains(t, parsed, "admin_info")
assert.NotContains(t, parsed, "root_info")
assert.Equal(t, 1.25, parsed["model_price"])
})
t.Run("admin", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatAdminLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.Contains(t, parsed, "admin_info")
assert.NotContains(t, parsed, "root_info")
})
t.Run("root", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatRootLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.Contains(t, parsed, "admin_info")
assert.Contains(t, parsed, "root_info")
})
}
func TestLegacyLogOtherVisibilityIsRoleSeparated(t *testing.T) {
other := common.MapToJsonStr(map[string]any{
"request_path": "/v1/chat/completions",
"channel_id": 202,
"channel_name": "legacy-secret-channel",
"channel_type": 1,
"reject_reason": "legacy-policy-rejection",
"admin_info": map[string]any{
"existing_admin_field": "preserved",
},
"root_info": map[string]any{
"upstream_request_id": "upstream-private",
},
"audit_info": map[string]any{
"method": "POST",
},
})
t.Run("user", func(t *testing.T) {
logs := []*Log{{
Id: 99,
ChannelId: 77,
ChannelName: "resolved-secret-channel",
Other: other,
}}
formatUserLogs(logs, 10)
assert.Equal(t, 11, logs[0].Id)
assert.Equal(t, 77, logs[0].ChannelId)
assert.Empty(t, logs[0].ChannelName)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.Equal(t, "/v1/chat/completions", parsed["request_path"])
for _, key := range []string{
"channel_id",
"channel_name",
"channel_type",
"reject_reason",
"admin_info",
"root_info",
"audit_info",
} {
assert.NotContains(t, parsed, key)
}
})
t.Run("admin", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatAdminLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.Equal(t, "legacy-secret-channel", parsed["channel_name"])
assert.NotContains(t, parsed, "reject_reason")
assert.NotContains(t, parsed, "root_info")
assert.Contains(t, parsed, "audit_info")
adminInfo, ok := parsed["admin_info"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "preserved", adminInfo["existing_admin_field"])
assert.Equal(t, "legacy-policy-rejection", adminInfo["reject_reason"])
})
t.Run("root", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatRootLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.Equal(t, "legacy-secret-channel", parsed["channel_name"])
assert.NotContains(t, parsed, "reject_reason")
assert.Contains(t, parsed, "root_info")
assert.Contains(t, parsed, "audit_info")
adminInfo, ok := parsed["admin_info"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "preserved", adminInfo["existing_admin_field"])
assert.Equal(t, "legacy-policy-rejection", adminInfo["reject_reason"])
})
}
func TestLegacyRejectReasonDoesNotOverrideScopedValue(t *testing.T) {
other := common.MapToJsonStr(map[string]any{
"reject_reason": "legacy-value",
"admin_info": map[string]any{
"reject_reason": "scoped-value",
},
})
logs := []*Log{{Other: other}}
FormatRootLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.NotContains(t, parsed, "reject_reason")
adminInfo, ok := parsed["admin_info"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "scoped-value", adminInfo["reject_reason"])
}
func TestLegacyRejectReasonHandlesNullAdminInfo(t *testing.T) {
logs := []*Log{{Other: `{"reject_reason":"legacy-value","admin_info":null}`}}
FormatAdminLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.NotContains(t, parsed, "reject_reason")
adminInfo, ok := parsed["admin_info"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "legacy-value", adminInfo["reject_reason"])
}
func TestLogFormattingPreservesLargeIntegerLexemes(t *testing.T) {
const other = `{"public_id":9007199254740993,"admin_info":{"admin_id":9007199254740995},"root_info":{"generation":18446744073709551615}}`
t.Run("user", func(t *testing.T) {
logs := []*Log{{Other: other}}
formatUserLogs(logs, 0)
assert.Contains(t, logs[0].Other, `"public_id":9007199254740993`)
assert.NotContains(t, logs[0].Other, "admin_id")
assert.NotContains(t, logs[0].Other, "generation")
})
t.Run("admin", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatAdminLogs(logs)
assert.Contains(t, logs[0].Other, `"public_id":9007199254740993`)
assert.Contains(t, logs[0].Other, `"admin_id":9007199254740995`)
assert.NotContains(t, logs[0].Other, "generation")
})
t.Run("root", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatRootLogs(logs)
assert.Equal(t, other, logs[0].Other)
})
t.Run("unprivileged", func(t *testing.T) {
const unprivileged = `{"public_id":9007199254740993,"model_price":0.004}`
userLogs := []*Log{{Other: unprivileged}}
formatUserLogs(userLogs, 0)
assert.Equal(t, unprivileged, userLogs[0].Other)
adminLogs := []*Log{{Other: unprivileged}}
FormatAdminLogs(adminLogs)
assert.Equal(t, unprivileged, adminLogs[0].Other)
})
}
+263
View File
@@ -0,0 +1,263 @@
package model
import (
"encoding/json"
"maps"
"slices"
"github.com/QuantumNous/new-api/common"
)
const (
logOtherAdminInfoKey = "admin_info"
logOtherRootInfoKey = "root_info"
logOtherAuditInfoKey = "audit_info"
)
// legacySensitiveLogOtherKeys are historical top-level fields that must never
// be written via SetPublic and must be stripped from user-visible projections.
var legacySensitiveLogOtherKeys = []string{
"channel_id",
"channel_name",
"channel_type",
"reject_reason",
}
type logOtherVisibility int
const (
logOtherVisibilityUser logOtherVisibility = iota
logOtherVisibilityAdmin
logOtherVisibilityRoot
)
// LogOther separates usage-log metadata by the audience allowed to see it.
// Its maps stay private so callers cannot accidentally place privileged fields
// in the user-visible top level.
type LogOther struct {
public map[string]any
adminInfo map[string]any
rootInfo map[string]any
auditInfo map[string]any
}
func NewLogOther() *LogOther {
return &LogOther{}
}
func isReservedLogOtherKey(key string) bool {
switch key {
case logOtherAdminInfoKey, logOtherRootInfoKey, logOtherAuditInfoKey:
return true
default:
return slices.Contains(legacySensitiveLogOtherKeys, key)
}
}
// SetPublic records metadata that log owners may receive from self/token log APIs.
// It rejects role-scoped and legacy-sensitive keys so new writers cannot recreate
// the historical channel/reject-reason leak.
func (o *LogOther) SetPublic(key string, value any) bool {
if o == nil || key == "" || isReservedLogOtherKey(key) {
return false
}
if o.public == nil {
o.public = make(map[string]any)
}
o.public[key] = value
return true
}
func (o *LogOther) MergePublic(values map[string]any) {
for key, value := range values {
o.SetPublic(key, value)
}
}
func (o *LogOther) SetAdmin(key string, value any) bool {
if o == nil || key == "" {
return false
}
if o.adminInfo == nil {
o.adminInfo = make(map[string]any)
}
o.adminInfo[key] = value
return true
}
func (o *LogOther) MergeAdmin(values map[string]any) {
for key, value := range values {
o.SetAdmin(key, value)
}
}
func (o *LogOther) SetRoot(key string, value any) bool {
if o == nil || key == "" {
return false
}
if o.rootInfo == nil {
o.rootInfo = make(map[string]any)
}
o.rootInfo[key] = value
return true
}
func (o *LogOther) MergeRoot(values map[string]any) {
for key, value := range values {
o.SetRoot(key, value)
}
}
func (o *LogOther) SetAudit(key string, value any) bool {
if o == nil || key == "" {
return false
}
if o.auditInfo == nil {
o.auditInfo = make(map[string]any)
}
o.auditInfo[key] = value
return true
}
func (o *LogOther) MergeAudit(values map[string]any) {
for key, value := range values {
o.SetAudit(key, value)
}
}
func copyLogOtherMap(values map[string]any) map[string]any {
if len(values) == 0 {
return nil
}
copyValues := make(map[string]any, len(values))
maps.Copy(copyValues, values)
return copyValues
}
func (o *LogOther) toMap() map[string]any {
result := make(map[string]any)
if o == nil {
return result
}
maps.Copy(result, o.public)
if adminInfo := copyLogOtherMap(o.adminInfo); len(adminInfo) > 0 {
result[logOtherAdminInfoKey] = adminInfo
}
if auditInfo := copyLogOtherMap(o.auditInfo); len(auditInfo) > 0 {
result[logOtherAuditInfoKey] = auditInfo
}
if rootInfo := copyLogOtherMap(o.rootInfo); len(rootInfo) > 0 {
result[logOtherRootInfoKey] = rootInfo
}
return result
}
func (o *LogOther) jsonString() string {
if o == nil {
return ""
}
data, err := common.Marshal(o.toMap())
if err != nil {
common.SysError("failed to marshal log other: " + err.Error())
return ""
}
return string(data)
}
// JSONString returns the complete stored representation, including all
// privileged scopes. API projections must use the role-specific formatter.
func (o *LogOther) JSONString() string {
return o.jsonString()
}
// Snapshot returns a detached top-level view for tests and read-only
// inspection. Mutating it cannot add or replace fields in LogOther.
func (o *LogOther) Snapshot() map[string]any {
if o == nil {
return nil
}
return o.toMap()
}
func (o *LogOther) MarshalJSON() ([]byte, error) {
return common.Marshal(o.toMap())
}
func normalizeLegacyRejectReason(values map[string]json.RawMessage) bool {
rejectReason, ok := values["reject_reason"]
if !ok {
return false
}
adminInfo := make(map[string]json.RawMessage)
if rawAdminInfo, exists := values[logOtherAdminInfoKey]; exists {
_ = common.Unmarshal(rawAdminInfo, &adminInfo)
}
if adminInfo == nil {
adminInfo = make(map[string]json.RawMessage)
}
if _, exists := adminInfo["reject_reason"]; !exists {
adminInfo["reject_reason"] = rejectReason
}
encodedAdminInfo, err := common.Marshal(adminInfo)
if err != nil {
return false
}
values[logOtherAdminInfoKey] = encodedAdminInfo
delete(values, "reject_reason")
return true
}
// formatLogOtherJSON applies the role projection while keeping untouched JSON
// values as RawMessage. This preserves integers larger than JavaScript's safe
// range instead of round-tripping them through float64.
func formatLogOtherJSON(value string, visibility logOtherVisibility) string {
if value == "" {
return ""
}
var values map[string]json.RawMessage
if err := common.UnmarshalJsonStr(value, &values); err != nil {
if visibility == logOtherVisibilityRoot {
return value
}
return "{}"
}
changed := false
if visibility == logOtherVisibilityUser {
for _, key := range []string{logOtherAdminInfoKey, logOtherRootInfoKey, logOtherAuditInfoKey} {
if _, exists := values[key]; exists {
delete(values, key)
changed = true
}
}
for _, key := range legacySensitiveLogOtherKeys {
if _, exists := values[key]; exists {
delete(values, key)
changed = true
}
}
} else {
changed = normalizeLegacyRejectReason(values)
if visibility == logOtherVisibilityAdmin {
if _, exists := values[logOtherRootInfoKey]; exists {
delete(values, logOtherRootInfoKey)
changed = true
}
}
}
if !changed {
return value
}
formatted, err := common.Marshal(values)
if err != nil {
if visibility == logOtherVisibilityRoot {
return value
}
return "{}"
}
return string(formatted)
}
+84
View File
@@ -0,0 +1,84 @@
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLogOtherScopesAndMerges(t *testing.T) {
var other LogOther
assert.True(t, other.SetPublic("request_path", "/v1/chat/completions"))
other.MergePublic(map[string]any{
"zero": 0,
})
assert.True(t, other.SetAdmin("use_channel", []string{"channel-a"}))
other.MergeAdmin(map[string]any{
"rejected": false,
})
assert.True(t, other.SetRoot("upstream_request_id", "upstream-private"))
other.MergeRoot(map[string]any{
"generation": 0,
})
assert.True(t, other.SetAudit("method", "POST"))
other.MergeAudit(map[string]any{
"success": false,
})
require.JSONEq(t, `{
"request_path": "/v1/chat/completions",
"zero": 0,
"admin_info": {
"use_channel": ["channel-a"],
"rejected": false
},
"root_info": {
"upstream_request_id": "upstream-private",
"generation": 0
},
"audit_info": {
"method": "POST",
"success": false
}
}`, other.JSONString())
}
func TestLogOtherRejectsSensitivePublicFields(t *testing.T) {
other := NewLogOther()
for _, key := range []string{
"admin_info",
"root_info",
"audit_info",
"channel_id",
"channel_name",
"channel_type",
"reject_reason",
} {
assert.False(t, other.SetPublic(key, "must-not-leak"), key)
}
other.MergePublic(map[string]any{
"request_path": "/v1/responses",
"channel_name": "still-must-not-leak",
"admin_info": map[string]any{"secret": true},
})
require.JSONEq(t, `{"request_path":"/v1/responses"}`, other.JSONString())
require.JSONEq(t, `{}`, NewLogOther().JSONString())
}
func TestLogOtherJSONStringDoesNotMutateReceiver(t *testing.T) {
other := NewLogOther()
require.True(t, other.SetPublic("request_path", "/v1/chat/completions"))
require.True(t, other.SetAdmin("rejected", false))
before := other.Snapshot()
first := other.JSONString()
after := other.Snapshot()
second := other.JSONString()
require.Equal(t, before, after)
require.Equal(t, first, second)
}
+89
View File
@@ -0,0 +1,89 @@
package model
import (
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
// UserVerificationState is an authoritative, credential-free projection for
// choosing an authentication method. Loading it never fetches credential secrets.
type UserVerificationState struct {
UserID int
Status int
Role int
AuthVersion int64
HasPassword bool
HasTwoFA bool
TwoFALocked bool
HasPasskey bool
}
func GetUserVerificationState(userID int) (*UserVerificationState, error) {
return getUserVerificationState(DB, userID, false)
}
func getUserVerificationState(tx *gorm.DB, userID int, forUpdate bool) (*UserVerificationState, error) {
if userID <= 0 {
return nil, ErrUserSessionInvalid
}
var state UserVerificationState
query := tx.Model(&User{}).Select(
"id AS user_id, status, role, auth_version, CASE WHEN password <> '' THEN 1 ELSE 0 END AS has_password, "+
"EXISTS (?) AS has_two_fa, EXISTS (?) AS two_fa_locked, EXISTS (?) AS has_passkey",
tx.Model(&TwoFA{}).Select("1").Where("user_id = ? AND is_enabled = ?", userID, true),
tx.Model(&TwoFA{}).Select("1").Where("user_id = ? AND is_enabled = ? AND locked_until > ?", userID, true, time.Now()),
tx.Model(&PasskeyCredential{}).Select("1").Where("user_id = ?", userID),
).Where("id = ?", userID)
if forUpdate {
query = lockForUpdate(query)
}
if err := query.Take(&state).Error; err != nil {
return nil, err
}
return &state, nil
}
// CreateUserSessionFromLoginFlow commits the one-time login authorization and
// the resulting session together. The user lock serializes credential changes
// and session issuance, including the per-user session limits.
func CreateUserSessionFromLoginFlow(token string, session *UserSession, validate func(*AuthFlow, *UserVerificationState) error) error {
if session == nil || validate == nil {
return ErrUserSessionInvalid
}
cacheDeadline := userSessionCacheDeadline()
_, err := ConsumeAuthFlowWithAction(token, AuthFlowMatch{
Purpose: AuthFlowPurposeLoginVerification, UserId: session.UserID,
}, func(tx *gorm.DB, flow *AuthFlow) error {
state, err := getUserVerificationState(tx, session.UserID, true)
if err != nil {
return err
}
if state.Status != common.UserStatusEnabled || state.AuthVersion != session.UserAuthVersion {
return ErrUserSessionInactive
}
if err := validate(flow, state); err != nil {
return err
}
now := time.Now().Unix()
var activeCount, issuanceCount int64
if err := tx.Model(&UserSession{}).Where("user_id = ? AND status = ? AND expires_at > ?", session.UserID, UserSessionStatusActive, now).Count(&activeCount).Error; err != nil {
return err
}
if activeCount >= int64(common.UserSessionActiveLimit) {
return ErrUserSessionLimit
}
if err := tx.Model(&UserSession{}).Where("user_id = ? AND created_at > ?", session.UserID, now-common.UserSessionIssuanceWindowSeconds).Count(&issuanceCount).Error; err != nil {
return err
}
if issuanceCount >= int64(common.UserSessionIssuanceLimit) {
return ErrUserSessionIssuanceLimit
}
return createUserSessionWithTx(tx, session)
})
if err != nil {
return err
}
return publishCreatedUserSession(session, cacheDeadline)
}
+827
View File
@@ -0,0 +1,827 @@
package model
import (
"fmt"
"log"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/glebarez/sqlite"
"gorm.io/driver/clickhouse"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var commonGroupCol string
var commonKeyCol string
var commonTrueVal string
var commonFalseVal string
var logKeyCol string
var logGroupCol string
// jsonScanBytes 归一化 json 列的驱动返回值:不同驱动/协议模式下同一列可能
// 以 []byte 或 string 返回,静默丢弃 string 会导致字段被清零而不报错。
func jsonScanBytes(value any) []byte {
switch v := value.(type) {
case []byte:
return v
case string:
return []byte(v)
default:
return nil
}
}
func initCol() {
// init common column names
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
commonGroupCol = `"group"`
commonKeyCol = `"key"`
commonTrueVal = "true"
commonFalseVal = "false"
} else {
commonGroupCol = "`group`"
commonKeyCol = "`key`"
commonTrueVal = "1"
commonFalseVal = "0"
}
switch common.LogDatabaseType() {
case common.DatabaseTypePostgreSQL:
logGroupCol = `"group"`
logKeyCol = `"key"`
default:
logGroupCol = "`group`"
logKeyCol = "`key`"
}
}
var DB *gorm.DB
var LOG_DB *gorm.DB
func createRootAccountIfNeed() error {
var user User
//if user.Status != common.UserStatusEnabled {
if err := DB.First(&user).Error; err != nil {
common.SysLog("no user exists, create a root user for you: username is root, password is 123456")
hashedPassword, err := common.Password2Hash("123456")
if err != nil {
return err
}
rootUser := User{
Username: "root",
Password: hashedPassword,
Role: common.RoleRootUser,
Status: common.UserStatusEnabled,
DisplayName: "Root User",
AccessToken: nil,
Quota: 100000000,
}
DB.Create(&rootUser)
}
return nil
}
func CheckSetup() {
setup := GetSetup()
if setup == nil {
// No setup record exists, check if we have a root user
if RootUserExists() {
common.SysLog("system is not initialized, but root user exists")
// Create setup record
newSetup := Setup{
Version: common.Version,
InitializedAt: time.Now().Unix(),
}
err := DB.Create(&newSetup).Error
if err != nil {
common.SysLog("failed to create setup record: " + err.Error())
}
constant.Setup = true
} else {
common.SysLog("system is not initialized and no root user exists")
constant.Setup = false
}
} else {
// Setup record exists, system is initialized
common.SysLog("system is already initialized at: " + time.Unix(setup.InitializedAt, 0).String())
constant.Setup = true
}
}
func isClickHouseDSN(dsn string) bool {
return strings.HasPrefix(dsn, "clickhouse://") ||
strings.HasPrefix(dsn, "tcp://") ||
strings.HasPrefix(dsn, "http://") ||
strings.HasPrefix(dsn, "https://")
}
func normalizeClickHouseDSN(dsn string) string {
parsed, err := url.Parse(dsn)
if err != nil || parsed.Scheme != "https" {
return dsn
}
query := parsed.Query()
if _, ok := query["secure"]; !ok {
query.Set("secure", "true")
parsed.RawQuery = query.Encode()
}
return parsed.String()
}
func chooseDB(envName string, isLog bool) (*gorm.DB, common.DatabaseType, error) {
dsn := os.Getenv(envName)
if dsn != "" {
if isClickHouseDSN(dsn) {
if !isLog {
return nil, "", fmt.Errorf("%s does not support ClickHouse; use SQLite, MySQL, or PostgreSQL for the primary database and LOG_SQL_DSN for ClickHouse logs", envName)
}
common.SysLog("using ClickHouse as log database")
db, err := gorm.Open(clickhouse.Open(normalizeClickHouseDSN(dsn)), newGormConfig(false))
return db, common.DatabaseTypeClickHouse, err
}
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
// Use PostgreSQL
common.SysLog("using PostgreSQL as database")
// 同时关闭 pgx 隐式与 GORM 显式预处理语句:命名 prepared statement 与
// 事务池代理(PgBouncer/Neon/Supabase)不兼容,会触发 FATAL 08P01/42P05。
db, err := gorm.Open(postgresMigrationDialector{postgres.Dialector{Config: &postgres.Config{
DSN: dsn,
PreferSimpleProtocol: true,
}}}, newGormConfig(false))
return db, common.DatabaseTypePostgreSQL, err
}
if strings.HasPrefix(dsn, "local") {
common.SysLog("SQL_DSN not set, using SQLite as database")
db, err := gorm.Open(sqlite.Open(common.SQLitePath), newGormConfig(true))
return db, common.DatabaseTypeSQLite, err
}
// Use MySQL
common.SysLog("using MySQL as database")
// check parseTime
if !strings.Contains(dsn, "parseTime") {
if strings.Contains(dsn, "?") {
dsn += "&parseTime=true"
} else {
dsn += "?parseTime=true"
}
}
db, err := gorm.Open(mysqlMigrationDialector{mysql.Dialector{Config: &mysql.Config{DSN: dsn}}}, newGormConfig(true))
return db, common.DatabaseTypeMySQL, err
}
// Use SQLite
common.SysLog("SQL_DSN not set, using SQLite as database")
db, err := gorm.Open(sqlite.Open(common.SQLitePath), newGormConfig(true))
return db, common.DatabaseTypeSQLite, err
}
func InitDB() (err error) {
db, dbType, err := chooseDB("SQL_DSN", false)
if err == nil {
common.SetMainDatabaseType(dbType)
if os.Getenv("LOG_SQL_DSN") == "" {
common.SetLogDatabaseType(dbType)
}
initCol()
if common.DebugEnabled {
db = db.Debug()
}
DB = db
// MySQL charset/collation startup check: ensure Chinese-capable charset
if common.UsingMainDatabase(common.DatabaseTypeMySQL) {
if err := checkMySQLChineseSupport(DB); err != nil {
panic(err)
}
}
if err := ensureUserQuotaColumns(DB, common.MainDatabaseType()); err != nil {
return err
}
sqlDB, err := DB.DB()
if err != nil {
return err
}
sqlDB.SetMaxIdleConns(common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100))
sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000))
sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60)))
if !common.IsMasterNode {
return nil
}
if common.UsingMainDatabase(common.DatabaseTypeMySQL) {
//_, _ = sqlDB.Exec("ALTER TABLE channels MODIFY model_mapping TEXT;") // TODO: delete this line when most users have upgraded
}
common.SysLog("database migration started")
err = migrateDB()
return err
} else {
common.FatalLog(err)
}
return err
}
func InitLogDB() (err error) {
if os.Getenv("LOG_SQL_DSN") == "" {
LOG_DB = DB
common.SetLogDatabaseType(common.MainDatabaseType())
initCol()
if common.IsMasterNode {
return MigrateAuditLogs()
}
return
}
db, dbType, err := chooseDB("LOG_SQL_DSN", true)
if err == nil {
common.SetLogDatabaseType(dbType)
initCol()
if common.DebugEnabled {
db = db.Debug()
}
LOG_DB = db
// If log DB is MySQL, also ensure Chinese-capable charset
if common.UsingLogDatabase(common.DatabaseTypeMySQL) {
if err := checkMySQLChineseSupport(LOG_DB); err != nil {
panic(err)
}
}
sqlDB, err := LOG_DB.DB()
if err != nil {
return err
}
sqlDB.SetMaxIdleConns(common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100))
sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000))
sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60)))
if !common.IsMasterNode {
return nil
}
common.SysLog("database migration started")
err = migrateLOGDB()
return err
} else {
common.FatalLog(err)
}
return err
}
var userQuotaColumns = []string{"quota", "used_quota", "aff_quota", "aff_history"}
// ensureUserQuotaColumns rejects a legacy 32-bit wallet schema before any
// migrations run. The 64-bit-only build intentionally does not auto-upgrade
// an existing wallet; operators must migrate it explicitly before starting.
func ensureUserQuotaColumns(db *gorm.DB, dbType common.DatabaseType) error {
if common.GetEnvOrDefaultBool("SKIP_64BIT_QUOTA_SCHEMA_CHECK", false) {
common.SysLog("SKIP_64BIT_QUOTA_SCHEMA_CHECK=true; skipping user quota schema check")
return nil
}
if db == nil || dbType == common.DatabaseTypeSQLite {
return nil
}
if !db.Migrator().HasTable(&User{}) {
return nil
}
columnTypes, err := db.Migrator().ColumnTypes(&User{})
if err != nil {
return fmt.Errorf("failed to inspect users schema: %w", err)
}
for _, expected := range userQuotaColumns {
for _, actual := range columnTypes {
if !strings.EqualFold(actual.Name(), expected) {
continue
}
dataType := actual.DatabaseTypeName()
if !is64BitIntegerType(dbType, dataType) {
return fmt.Errorf("users.%s uses %s; 32-bit is not supported", expected, dataType)
}
}
}
return nil
}
func is64BitIntegerType(dbType common.DatabaseType, dataType string) bool {
normalized := strings.ToLower(strings.TrimSpace(dataType))
switch dbType {
case common.DatabaseTypeMySQL:
return normalized == "bigint" || normalized == "unsigned bigint" || normalized == "bigint unsigned"
case common.DatabaseTypePostgreSQL:
return normalized == "bigint" || normalized == "int8"
default:
return false
}
}
func migrateDB() error {
if err := migrateTokenKeyUniqueness(DB); err != nil {
return err
}
if err := migratePrefillGroupUniqueness(DB); err != nil {
return err
}
// Migrate price_amount column from float/double to decimal for existing tables
migrateSubscriptionPlanPriceAmount()
// Migrate model_limits column from varchar to text for existing tables
if err := migrateTokenModelLimitsToText(); err != nil {
return err
}
err := DB.AutoMigrate(
&Channel{},
&Token{},
&User{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
&PasskeyCredential{},
&Option{},
&LoginEncryptionKey{},
&Redemption{},
&Ability{},
&Log{},
&Midjourney{},
&TopUp{},
&QuotaData{},
&Task{},
&TaskPlugin{},
&Model{},
&Vendor{},
&PrefillGroup{},
&Setup{},
&TwoFA{},
&TwoFABackupCode{},
&Checkin{},
&SubscriptionOrder{},
&UserSubscription{},
&SubscriptionPreConsumeRecord{},
&CustomOAuthProvider{},
&UserOAuthBinding{},
&PerfMetric{},
&SystemInstance{},
&SystemTask{},
&SystemTaskLock{},
&CasbinRule{},
&AuthzRole{},
)
if err != nil {
return err
}
if err := InitializeUserAuthVersions(); err != nil {
return err
}
if err := InitializeExternalIdentityClaims(); err != nil {
return err
}
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
}
} else {
if err := DB.AutoMigrate(&SubscriptionPlan{}); err != nil {
return err
}
}
return nil
}
func migrateLOGDB() error {
if err := MigrateAuditLogs(); err != nil {
return err
}
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
return migrateClickHouseLogDB()
}
return LOG_DB.AutoMigrate(&Log{})
}
func migrateClickHouseLogDB() error {
ttlDays := clickHouseLogTTLDays()
if err := LOG_DB.Exec(clickHouseLogCreateTableSQL(ttlDays)).Error; err != nil {
return err
}
return syncClickHouseLogTTL(ttlDays)
}
func clickHouseLogTTLDays() int {
ttlDays := common.GetEnvOrDefault("LOG_SQL_CLICKHOUSE_TTL_DAYS", 0)
if ttlDays < 0 {
return 0
}
return ttlDays
}
func clickHouseLogTTLExpression(ttlDays int) string {
if ttlDays <= 0 {
return ""
}
return fmt.Sprintf("toDateTime(created_at) + INTERVAL %d DAY DELETE", ttlDays)
}
func clickHouseLogTTLClause(ttlDays int) string {
expression := clickHouseLogTTLExpression(ttlDays)
if expression == "" {
return ""
}
return "\nTTL " + expression
}
func clickHouseLogCreateTableSQL(ttlDays int) string {
return fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS logs (
id Int64 DEFAULT 0,
user_id Int32 DEFAULT 0,
created_at Int64 DEFAULT 0,
type Int32 DEFAULT 0,
content String DEFAULT '',
username String DEFAULT '',
token_name String DEFAULT '',
model_name String DEFAULT '',
quota Int32 DEFAULT 0,
prompt_tokens Int32 DEFAULT 0,
completion_tokens Int32 DEFAULT 0,
use_time Int32 DEFAULT 0,
is_stream UInt8 DEFAULT 0,
channel_id Int32 DEFAULT 0,
token_id Int32 DEFAULT 0,
`+"`group`"+` String DEFAULT '',
ip String DEFAULT '',
request_id String DEFAULT '',
upstream_request_id String DEFAULT '',
other String DEFAULT ''
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(toDateTime(created_at))
ORDER BY (created_at, request_id)%s`, clickHouseLogTTLClause(ttlDays))
}
func syncClickHouseLogTTL(ttlDays int) error {
expression := clickHouseLogTTLExpression(ttlDays)
if expression != "" {
return LOG_DB.Exec("ALTER TABLE logs MODIFY TTL " + expression).Error
}
hasTTL, err := clickHouseLogTableHasTTL()
if err != nil {
return err
}
if !hasTTL {
return nil
}
return LOG_DB.Exec("ALTER TABLE logs REMOVE TTL").Error
}
func clickHouseLogTableHasTTL() (bool, error) {
var createTableSQL string
if err := LOG_DB.Raw("SHOW CREATE TABLE logs").Scan(&createTableSQL).Error; err != nil {
return false, err
}
return clickHouseCreateTableHasTTL(createTableSQL), nil
}
func clickHouseCreateTableHasTTL(createTableSQL string) bool {
upperSQL := strings.ToUpper(createTableSQL)
return strings.Contains(upperSQL, "\nTTL ") || strings.Contains(upperSQL, " TTL ")
}
type sqliteColumnDef struct {
Name string
DDL string
}
func ensureSubscriptionPlanTableSQLite() error {
if !common.UsingMainDatabase(common.DatabaseTypeSQLite) {
return nil
}
tableName := "subscription_plans"
if !DB.Migrator().HasTable(tableName) {
createSQL := `CREATE TABLE ` + "`" + tableName + "`" + ` (
` + "`id`" + ` integer,
` + "`title`" + ` varchar(128) NOT NULL,
` + "`subtitle`" + ` varchar(255) DEFAULT '',
` + "`price_amount`" + ` decimal(10,6) NOT NULL,
` + "`currency`" + ` varchar(8) NOT NULL DEFAULT 'USD',
` + "`duration_unit`" + ` varchar(16) NOT NULL DEFAULT 'month',
` + "`duration_value`" + ` integer NOT NULL DEFAULT 1,
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
` + "`enabled`" + ` numeric DEFAULT 1,
` + "`sort_order`" + ` integer DEFAULT 0,
` + "`allow_balance_pay`" + ` numeric DEFAULT 1,
` + "`allow_wallet_overflow`" + ` numeric DEFAULT 1,
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '',
` + "`max_purchase_per_user`" + ` integer DEFAULT 0,
` + "`upgrade_group`" + ` varchar(64) DEFAULT '',
` + "`downgrade_group`" + ` varchar(64) DEFAULT '',
` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0,
` + "`quota_reset_period`" + ` varchar(16) DEFAULT 'never',
` + "`quota_reset_custom_seconds`" + ` bigint DEFAULT 0,
` + "`created_at`" + ` bigint,
` + "`updated_at`" + ` bigint,
PRIMARY KEY (` + "`id`" + `)
)`
return DB.Exec(createSQL).Error
}
var cols []struct {
Name string `gorm:"column:name"`
}
if err := DB.Raw("PRAGMA table_info(`" + tableName + "`)").Scan(&cols).Error; err != nil {
return err
}
existing := make(map[string]struct{}, len(cols))
for _, c := range cols {
existing[c.Name] = struct{}{}
}
required := []sqliteColumnDef{
{Name: "title", DDL: "`title` varchar(128) NOT NULL"},
{Name: "subtitle", DDL: "`subtitle` varchar(255) DEFAULT ''"},
{Name: "price_amount", DDL: "`price_amount` decimal(10,6) NOT NULL"},
{Name: "currency", DDL: "`currency` varchar(8) NOT NULL DEFAULT 'USD'"},
{Name: "duration_unit", DDL: "`duration_unit` varchar(16) NOT NULL DEFAULT 'month'"},
{Name: "duration_value", DDL: "`duration_value` integer NOT NULL DEFAULT 1"},
{Name: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"},
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
{Name: "allow_balance_pay", DDL: "`allow_balance_pay` numeric DEFAULT 1"},
{Name: "allow_wallet_overflow", DDL: "`allow_wallet_overflow` numeric DEFAULT 1"},
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
{Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"},
{Name: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"},
{Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"},
{Name: "downgrade_group", DDL: "`downgrade_group` varchar(64) DEFAULT ''"},
{Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"},
{Name: "quota_reset_period", DDL: "`quota_reset_period` varchar(16) DEFAULT 'never'"},
{Name: "quota_reset_custom_seconds", DDL: "`quota_reset_custom_seconds` bigint DEFAULT 0"},
{Name: "created_at", DDL: "`created_at` bigint"},
{Name: "updated_at", DDL: "`updated_at` bigint"},
}
for _, col := range required {
if _, ok := existing[col.Name]; ok {
continue
}
if err := DB.Exec("ALTER TABLE `" + tableName + "` ADD COLUMN " + col.DDL).Error; err != nil {
return err
}
}
return nil
}
// migrateTokenModelLimitsToText migrates model_limits column from varchar(1024) to text
// This is safe to run multiple times - it checks the column type first
func migrateTokenModelLimitsToText() error {
// SQLite uses type affinity, so TEXT and VARCHAR are effectively the same — no migration needed
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
return nil
}
tableName := "tokens"
columnName := "model_limits"
if !DB.Migrator().HasTable(tableName) {
return nil
}
if !DB.Migrator().HasColumn(&Token{}, columnName) {
return nil
}
var alterSQL string
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
var dataType string
if err := DB.Raw(`SELECT data_type FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`,
tableName, columnName).Scan(&dataType).Error; err != nil {
common.SysLog(fmt.Sprintf("Warning: failed to query metadata for %s.%s: %v", tableName, columnName, err))
} else if dataType == "text" {
return nil
}
alterSQL = fmt.Sprintf(`ALTER TABLE %s ALTER COLUMN %s TYPE text`, tableName, columnName)
} else if common.UsingMainDatabase(common.DatabaseTypeMySQL) {
var columnType string
if err := DB.Raw(`SELECT COLUMN_TYPE FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
tableName, columnName).Scan(&columnType).Error; err != nil {
common.SysLog(fmt.Sprintf("Warning: failed to query metadata for %s.%s: %v", tableName, columnName, err))
} else if strings.ToLower(columnType) == "text" {
return nil
}
alterSQL = fmt.Sprintf("ALTER TABLE %s MODIFY COLUMN %s text", tableName, columnName)
} else {
return nil
}
if alterSQL != "" {
if err := DB.Exec(alterSQL).Error; err != nil {
return fmt.Errorf("failed to migrate %s.%s to text: %w", tableName, columnName, err)
}
common.SysLog(fmt.Sprintf("Successfully migrated %s.%s to text", tableName, columnName))
}
return nil
}
// migrateSubscriptionPlanPriceAmount migrates price_amount column from float/double to decimal(10,6)
// This is safe to run multiple times - it checks the column type first
func migrateSubscriptionPlanPriceAmount() {
// SQLite doesn't support ALTER COLUMN, and its type affinity handles this automatically
// Skip early to avoid GORM parsing the existing table DDL which may cause issues
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
return
}
tableName := "subscription_plans"
columnName := "price_amount"
// Check if table exists first
if !DB.Migrator().HasTable(tableName) {
return
}
// Check if column exists
if !DB.Migrator().HasColumn(&SubscriptionPlan{}, columnName) {
return
}
var alterSQL string
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
// PostgreSQL: Check if already decimal/numeric
var dataType string
if err := DB.Raw(`SELECT data_type FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`,
tableName, columnName).Scan(&dataType).Error; err != nil {
common.SysLog(fmt.Sprintf("Warning: failed to query metadata for %s.%s: %v", tableName, columnName, err))
} else if dataType == "numeric" {
return // Already decimal/numeric
}
alterSQL = fmt.Sprintf(`ALTER TABLE %s ALTER COLUMN %s TYPE decimal(10,6) USING %s::decimal(10,6)`,
tableName, columnName, columnName)
} else if common.UsingMainDatabase(common.DatabaseTypeMySQL) {
// MySQL: Check if already decimal
var columnType string
if err := DB.Raw(`SELECT COLUMN_TYPE FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
tableName, columnName).Scan(&columnType).Error; err != nil {
common.SysLog(fmt.Sprintf("Warning: failed to query metadata for %s.%s: %v", tableName, columnName, err))
} else if strings.HasPrefix(strings.ToLower(columnType), "decimal") {
return // Already decimal
}
alterSQL = fmt.Sprintf("ALTER TABLE %s MODIFY COLUMN %s decimal(10,6) NOT NULL DEFAULT 0",
tableName, columnName)
} else {
return
}
if alterSQL != "" {
if err := DB.Exec(alterSQL).Error; err != nil {
common.SysLog(fmt.Sprintf("Warning: failed to migrate %s.%s to decimal: %v", tableName, columnName, err))
} else {
common.SysLog(fmt.Sprintf("Successfully migrated %s.%s to decimal(10,6)", tableName, columnName))
}
}
}
func closeDB(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
err = sqlDB.Close()
return err
}
func CloseDB() error {
if LOG_DB != DB {
err := closeDB(LOG_DB)
if err != nil {
return err
}
}
return closeDB(DB)
}
// checkMySQLChineseSupport ensures the MySQL connection and current schema
// default charset/collation can store Chinese characters. It allows common
// Chinese-capable charsets (utf8mb4, utf8, gbk, big5, gb18030) and panics otherwise.
func checkMySQLChineseSupport(db *gorm.DB) error {
// 仅检测:当前库默认字符集/排序规则 + 各表的排序规则(隐含字符集)
// Read current schema defaults
var schemaCharset, schemaCollation string
err := db.Raw("SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()").Row().Scan(&schemaCharset, &schemaCollation)
if err != nil {
return fmt.Errorf("读取当前库默认字符集/排序规则失败 / Failed to read schema default charset/collation: %v", err)
}
toLower := func(s string) string { return strings.ToLower(s) }
// Allowed charsets that can store Chinese text
allowedCharsets := map[string]string{
"utf8mb4": "utf8mb4_",
"utf8": "utf8_",
"gbk": "gbk_",
"big5": "big5_",
"gb18030": "gb18030_",
}
isChineseCapable := func(cs, cl string) bool {
csLower := toLower(cs)
clLower := toLower(cl)
if prefix, ok := allowedCharsets[csLower]; ok {
if clLower == "" {
return true
}
return strings.HasPrefix(clLower, prefix)
}
// 如果仅提供了排序规则,尝试按排序规则前缀判断
for _, prefix := range allowedCharsets {
if strings.HasPrefix(clLower, prefix) {
return true
}
}
return false
}
// 1) 当前库默认值必须支持中文
if !isChineseCapable(schemaCharset, schemaCollation) {
return fmt.Errorf("当前库默认字符集/排序规则不支持中文:schema(%s/%s)。请将库设置为 utf8mb4/utf8/gbk/big5/gb18030 / Schema default charset/collation is not Chinese-capable: schema(%s/%s). Please set to utf8mb4/utf8/gbk/big5/gb18030",
schemaCharset, schemaCollation, schemaCharset, schemaCollation)
}
// 2) 所有物理表的排序规则(隐含字符集)必须支持中文
type tableInfo struct {
Name string
Collation *string
}
var tables []tableInfo
if err := db.Raw("SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'").Scan(&tables).Error; err != nil {
return fmt.Errorf("读取表排序规则失败 / Failed to read table collations: %v", err)
}
var badTables []string
for _, t := range tables {
// NULL 或空表示继承库默认设置,已在上面校验库默认,视为通过
if t.Collation == nil || *t.Collation == "" {
continue
}
cl := *t.Collation
// 仅凭排序规则判断是否中文可用
ok := false
lower := strings.ToLower(cl)
for _, prefix := range allowedCharsets {
if strings.HasPrefix(lower, prefix) {
ok = true
break
}
}
if !ok {
badTables = append(badTables, fmt.Sprintf("%s(%s)", t.Name, cl))
}
}
if len(badTables) > 0 {
// 限制输出数量以避免日志过长
maxShow := 20
shown := badTables
if len(shown) > maxShow {
shown = shown[:maxShow]
}
return fmt.Errorf(
"存在不支持中文的表,请修复其排序规则/字符集。示例(最多展示 %d 项):%v / Found tables not Chinese-capable. Please fix their collation/charset. Examples (showing up to %d): %v",
maxShow, shown, maxShow, shown,
)
}
return nil
}
var (
lastPingTime time.Time
pingMutex sync.Mutex
)
func PingDB() error {
pingMutex.Lock()
defer pingMutex.Unlock()
if time.Since(lastPingTime) < time.Second*10 {
return nil
}
sqlDB, err := DB.DB()
if err != nil {
log.Printf("Error getting sql.DB from GORM: %v", err)
return err
}
err = sqlDB.Ping()
if err != nil {
log.Printf("Error pinging DB: %v", err)
return err
}
lastPingTime = time.Now()
common.SysLog("Database pinged successfully")
return nil
}
+249
View File
@@ -0,0 +1,249 @@
package model
type Midjourney struct {
Id int `json:"id"`
Code int `json:"code"`
UserId int `json:"user_id" gorm:"index"`
Action string `json:"action" gorm:"type:varchar(40);index"`
MjId string `json:"mj_id" gorm:"index"`
Prompt string `json:"prompt"`
PromptEn string `json:"prompt_en"`
Description string `json:"description"`
State string `json:"state"`
SubmitTime int64 `json:"submit_time" gorm:"index"`
StartTime int64 `json:"start_time" gorm:"index"`
FinishTime int64 `json:"finish_time" gorm:"index"`
ImageUrl string `json:"image_url"`
VideoUrl string `json:"video_url"`
VideoUrls string `json:"video_urls"`
Status string `json:"status" gorm:"type:varchar(20);index"`
Progress string `json:"progress" gorm:"type:varchar(30);index"`
FailReason string `json:"fail_reason"`
ChannelId int `json:"channel_id"`
Quota int `json:"quota"`
Buttons string `json:"buttons"`
Properties string `json:"properties"`
TokenId int `json:"-" gorm:"default:0"`
BillingChannelId int `json:"-" gorm:"default:0"`
}
// TaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段
type TaskQueryParams struct {
ChannelID string
MjID string
StartTimestamp string
EndTimestamp string
}
func GetAllUserTask(userId int, startIdx int, num int, queryParams TaskQueryParams) []*Midjourney {
var tasks []*Midjourney
var err error
// 初始化查询构建器
query := DB.Where("user_id = ?", userId)
if queryParams.MjID != "" {
query = query.Where("mj_id = ?", queryParams.MjID)
}
if queryParams.StartTimestamp != "" {
// 假设您已将前端传来的时间戳转换为数据库所需的时间格式,并处理了时间戳的验证和解析
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != "" {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
// 获取数据
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error
if err != nil {
return nil
}
return tasks
}
func GetAllTasks(startIdx int, num int, queryParams TaskQueryParams) []*Midjourney {
var tasks []*Midjourney
var err error
// 初始化查询构建器
query := DB
// 添加过滤条件
if queryParams.ChannelID != "" {
query = query.Where("channel_id = ?", queryParams.ChannelID)
}
if queryParams.MjID != "" {
query = query.Where("mj_id = ?", queryParams.MjID)
}
if queryParams.StartTimestamp != "" {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != "" {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
// 获取数据
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error
if err != nil {
return nil
}
return tasks
}
func GetAllUnFinishTasks() []*Midjourney {
var tasks []*Midjourney
var err error
// get all tasks progress is not 100%
err = DB.Where("progress != ?", "100%").Find(&tasks).Error
if err != nil {
return nil
}
return tasks
}
// HasUnfinishedMidjourneyTasks reports whether at least one Midjourney task is
// still in progress. It is a cheap existence check (LIMIT 1) used to decide
// whether the midjourney_poll system task needs to run; when no task is pending
// the scheduler skips creating a row entirely.
func HasUnfinishedMidjourneyTasks() bool {
var id int
err := DB.Model(&Midjourney{}).
Where("progress != ?", "100%").
Limit(1).
Pluck("id", &id).Error
return err == nil && id != 0
}
func GetByOnlyMJId(mjId string) *Midjourney {
var mj *Midjourney
var err error
err = DB.Where("mj_id = ?", mjId).First(&mj).Error
if err != nil {
return nil
}
return mj
}
func GetByMJId(userId int, mjId string) *Midjourney {
var mj *Midjourney
var err error
err = DB.Where("user_id = ? and mj_id = ?", userId, mjId).First(&mj).Error
if err != nil {
return nil
}
return mj
}
func GetByMJIds(userId int, mjIds []string) []*Midjourney {
var mj []*Midjourney
var err error
err = DB.Where("user_id = ? and mj_id in (?)", userId, mjIds).Find(&mj).Error
if err != nil {
return nil
}
return mj
}
func GetMjByuId(id int) *Midjourney {
var mj *Midjourney
var err error
err = DB.Where("id = ?", id).First(&mj).Error
if err != nil {
return nil
}
return mj
}
func UpdateProgress(id int, progress string) error {
return DB.Model(&Midjourney{}).Where("id = ?", id).Update("progress", progress).Error
}
func (midjourney *Midjourney) Insert() error {
var err error
err = DB.Create(midjourney).Error
return err
}
func (midjourney *Midjourney) Update() error {
var err error
err = DB.Save(midjourney).Error
return err
}
func (midjourney *Midjourney) UpdateBillingState() error {
return DB.Model(midjourney).
Select("quota", "token_id", "billing_channel_id").
Updates(midjourney).Error
}
func (midjourney *Midjourney) GetBillingChannelId() int {
if midjourney.BillingChannelId > 0 {
return midjourney.BillingChannelId
}
return midjourney.ChannelId
}
// UpdateWithStatus performs a conditional UPDATE guarded by fromStatus (CAS).
// Returns (true, nil) if this caller won the update, (false, nil) if
// another process already moved the task out of fromStatus.
// UpdateWithStatus performs a conditional UPDATE guarded by fromStatus (CAS).
// Uses Model().Select("*").Updates() to avoid GORM Save()'s INSERT fallback.
func (midjourney *Midjourney) UpdateWithStatus(fromStatus string) (bool, error) {
result := DB.Model(midjourney).Where("status = ?", fromStatus).Select("*").Updates(midjourney)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
func MjBulkUpdate(mjIds []string, params map[string]any) error {
return DB.Model(&Midjourney{}).
Where("mj_id in (?)", mjIds).
Updates(params).Error
}
func MjBulkUpdateByTaskIds(taskIDs []int, params map[string]any) error {
return DB.Model(&Midjourney{}).
Where("id in (?)", taskIDs).
Updates(params).Error
}
// CountAllTasks returns total midjourney tasks for admin query
func CountAllTasks(queryParams TaskQueryParams) int64 {
var total int64
query := DB.Model(&Midjourney{})
if queryParams.ChannelID != "" {
query = query.Where("channel_id = ?", queryParams.ChannelID)
}
if queryParams.MjID != "" {
query = query.Where("mj_id = ?", queryParams.MjID)
}
if queryParams.StartTimestamp != "" {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != "" {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
// CountAllUserTask returns total midjourney tasks for user
func CountAllUserTask(userId int, queryParams TaskQueryParams) int64 {
var total int64
query := DB.Model(&Midjourney{}).Where("user_id = ?", userId)
if queryParams.MjID != "" {
query = query.Where("mj_id = ?", queryParams.MjID)
}
if queryParams.StartTimestamp != "" {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != "" {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
+71
View File
@@ -0,0 +1,71 @@
package model
import (
"strings"
"github.com/shopspring/decimal"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
// Embed the concrete dialectors to retain their transaction/savepoint and other
// optional GORM interfaces. Only schema comparison needs normalization.
type mysqlMigrationDialector struct{ mysql.Dialector }
func (d mysqlMigrationDialector) Migrator(db *gorm.DB) gorm.Migrator {
return mysqlSchemaMigrator{d.Dialector.Migrator(db).(mysql.Migrator)}
}
type mysqlSchemaMigrator struct{ mysql.Migrator }
func (m mysqlSchemaMigrator) MigrateColumn(value any, field *schema.Field, column gorm.ColumnType) error {
if !field.HasDefaultValue || !strings.EqualFold(column.DatabaseTypeName(), "decimal") {
return m.Migrator.MigrateColumn(value, field, column)
}
stored, ok := column.DefaultValue()
if !ok {
return m.Migrator.MigrateColumn(value, field, column)
}
storedNumber, storedErr := decimal.NewFromString(stored)
modelNumber, modelErr := decimal.NewFromString(field.DefaultValue)
if storedErr != nil || modelErr != nil || !storedNumber.Equal(modelNumber) {
return m.Migrator.MigrateColumn(value, field, column)
}
// MySQL pads decimal defaults (0 -> 0.000000). Skip only the equivalent
// default comparison, retaining type/size/null checks. AlterColumn still
// reads the original model, including its default.
comparisonField := *field
comparisonField.HasDefaultValue = false
comparisonField.DefaultValue = ""
comparisonField.DefaultValueInterface = nil
return m.Migrator.MigrateColumn(value, &comparisonField, columnWithoutDefault{column})
}
type migrationColumnType interface{ gorm.ColumnType }
type columnWithoutDefault struct{ migrationColumnType }
func (columnWithoutDefault) DefaultValue() (string, bool) { return "", false }
type postgresMigrationDialector struct{ postgres.Dialector }
func (d postgresMigrationDialector) Migrator(db *gorm.DB) gorm.Migrator {
return postgresSchemaMigrator{d.Dialector.Migrator(db).(postgres.Migrator)}
}
type postgresSchemaMigrator struct{ postgres.Migrator }
func (m postgresSchemaMigrator) MigrateColumn(value any, field *schema.Field, column gorm.ColumnType) error {
if column.DatabaseTypeName() == "bpchar" && strings.HasPrefix(strings.ToLower(string(field.DataType)), "char(") {
// PostgreSQL reports CHAR(n) as bpchar. Normalize the name, retaining
// Length() so a real CHAR length change still triggers migration.
column = charColumnType{column}
}
return m.Migrator.MigrateColumn(value, field, column)
}
type charColumnType struct{ migrationColumnType }
func (charColumnType) DatabaseTypeName() string { return "char" }
+177
View File
@@ -0,0 +1,177 @@
package model
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type MigrationIdentityFields struct {
ID int `gorm:"primaryKey"`
Name string `gorm:"size:64;unique"`
Reference string `gorm:"size:64;uniqueIndex"`
Provider string `gorm:"size:32;uniqueIndex:,composite:provider_subject"`
Subject string `gorm:"size:64;uniqueIndex:,composite:provider_subject"`
}
type migrationIdentityV1 struct {
MigrationIdentityFields
Digest string `gorm:"type:char(32)"`
}
type migrationIdentityV2 struct {
MigrationIdentityFields
Digest string `gorm:"type:char(64)"`
Note string `gorm:"size:128"`
}
type migrationConstraintV1 struct {
ID int `gorm:"primaryKey"`
Name string `gorm:"size:64"`
}
type migrationConstraintV2 struct {
ID int `gorm:"primaryKey"`
Name string `gorm:"size:64;unique"`
}
type migrationDecimalV1 struct {
ID int `gorm:"primaryKey"`
Price float64 `gorm:"type:decimal(10,6);default:0"`
}
type migrationDecimalV2 struct {
ID int `gorm:"primaryKey"`
Price float64 `gorm:"type:decimal(12,6);not null;default:0"`
}
type migrationDecimalV3 struct {
ID int `gorm:"primaryKey"`
Price float64 `gorm:"type:decimal(12,6);not null;default:1.25"`
}
func TestMigrationSchemaStability(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
var dsn string
switch dialect {
case "sqlite":
dsn = "local"
previousPath := common.SQLitePath
common.SQLitePath = filepath.Join(t.TempDir(), "migration.db")
t.Cleanup(func() { common.SQLitePath = previousPath })
case "mysql":
dsn = os.Getenv("TEST_MYSQL_DSN")
case "postgres":
dsn = os.Getenv("TEST_POSTGRES_DSN")
}
if dsn == "" {
t.Skip("test database DSN is not configured")
}
t.Setenv("MIGRATION_TEST_DSN", dsn)
db, _, err := chooseDB("MIGRATION_TEST_DSN", false)
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
recorder := &migrationSQLRecorder{}
db = db.Session(&gorm.Session{Logger: recorder})
t.Run("identity_and_indexes", func(t *testing.T) {
const table = "migration_identity_test"
t.Cleanup(func() { _ = db.Migrator().DropTable(table) })
require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV1{}))
row := migrationIdentityV1{
MigrationIdentityFields: MigrationIdentityFields{ID: 1, Name: "root", Reference: "token-reference", Provider: "oidc", Subject: "subject"},
Digest: "old-digest",
}
require.NoError(t, db.Table(table).Create(&row).Error)
recorder.reset()
require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV1{}))
assert.Empty(t, recorder.schemaMutations())
require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV2{}))
columns, err := db.Table(table).Migrator().ColumnTypes(&migrationIdentityV2{})
require.NoError(t, err)
for _, column := range columns {
if column.Name() == "digest" {
length, ok := column.Length()
require.True(t, ok)
assert.EqualValues(t, 64, length)
}
}
assert.True(t, db.Table(table).Migrator().HasColumn(&migrationIdentityV2{}, "note"))
recorder.reset()
require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV2{}))
assert.Empty(t, recorder.schemaMutations())
var saved migrationIdentityV2
require.NoError(t, db.Table(table).First(&saved, 1).Error)
assert.Equal(t, row.MigrationIdentityFields, saved.MigrationIdentityFields)
expectedDigest := row.Digest
if dialect == "postgres" {
expectedDigest += strings.Repeat(" ", 64-len(row.Digest))
}
assert.Equal(t, expectedDigest, saved.Digest)
for _, duplicate := range []migrationIdentityV2{
{MigrationIdentityFields: MigrationIdentityFields{Name: "root", Reference: "other-1", Provider: "other", Subject: "1"}},
{MigrationIdentityFields: MigrationIdentityFields{Name: "other-2", Reference: "token-reference", Provider: "other", Subject: "2"}},
{MigrationIdentityFields: MigrationIdentityFields{Name: "other-3", Reference: "other-3", Provider: "oidc", Subject: "subject"}},
} {
assert.Error(t, db.Table(table).Create(&duplicate).Error)
}
})
t.Run("unique_constraint_changes", func(t *testing.T) {
const table = "migration_constraint_test"
t.Cleanup(func() { _ = db.Migrator().DropTable(table) })
require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV1{}))
require.NoError(t, db.Table(table).Create(&migrationConstraintV1{Name: "existing"}).Error)
require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV2{}))
assert.Error(t, db.Table(table).Create(&migrationConstraintV2{Name: "existing"}).Error)
recorder.reset()
require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV2{}))
assert.Empty(t, recorder.schemaMutations())
require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV1{}))
require.NoError(t, db.Table(table).Create(&migrationConstraintV1{Name: "existing"}).Error)
})
if dialect == "mysql" {
t.Run("decimal_default_and_real_changes", func(t *testing.T) {
const table = "migration_decimal_test"
t.Cleanup(func() { _ = db.Migrator().DropTable(table) })
require.NoError(t, db.Table(table).AutoMigrate(&migrationDecimalV1{}))
require.NoError(t, db.Table(table).Create(&migrationDecimalV1{ID: 1, Price: 12.345678}).Error)
for _, target := range []any{&migrationDecimalV1{}, &migrationDecimalV2{}, &migrationDecimalV3{}} {
require.NoError(t, db.Table(table).AutoMigrate(target))
recorder.reset()
require.NoError(t, db.Table(table).AutoMigrate(target))
assert.Empty(t, recorder.schemaMutations())
}
columns, err := db.Table(table).Migrator().ColumnTypes(&migrationDecimalV3{})
require.NoError(t, err)
for _, column := range columns {
if column.Name() == "price" {
precision, scale, ok := column.DecimalSize()
require.True(t, ok)
assert.EqualValues(t, 12, precision)
assert.EqualValues(t, 6, scale)
nullable, ok := column.Nullable()
require.True(t, ok)
assert.False(t, nullable)
}
}
require.NoError(t, db.Table(table).Create(&map[string]any{"id": 2}).Error)
var prices []float64
require.NoError(t, db.Table(table).Order("id").Pluck("price", &prices).Error)
assert.Equal(t, []float64{12.345678, 1.25}, prices)
})
}
})
}
}
+30
View File
@@ -0,0 +1,30 @@
package model
// GetMissingModels returns model names that are referenced in the system
func GetMissingModels() ([]string, error) {
// 1. 获取所有已启用模型(去重)
models := GetEnabledModels()
if len(models) == 0 {
return []string{}, nil
}
// 2. 查询已有的元数据模型名
var existing []string
if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil {
return nil, err
}
existingSet := make(map[string]struct{}, len(existing))
for _, e := range existing {
existingSet[e] = struct{}{}
}
// 3. 收集缺失模型
var missing []string
for _, name := range models {
if _, ok := existingSet[name]; !ok {
missing = append(missing, name)
}
}
return missing, nil
}
+31
View File
@@ -0,0 +1,31 @@
package model
func GetModelEnableGroups(modelName string) []string {
// 确保缓存最新
GetPricing()
if modelName == "" {
return make([]string, 0)
}
modelEnableGroupsLock.RLock()
groups, ok := modelEnableGroups[modelName]
modelEnableGroupsLock.RUnlock()
if !ok {
return make([]string, 0)
}
return groups
}
// GetModelQuotaTypes 返回指定模型的计费类型集合(来自缓存)
func GetModelQuotaTypes(modelName string) []int {
GetPricing()
modelEnableGroupsLock.RLock()
quota, ok := modelQuotaTypeMap[modelName]
modelEnableGroupsLock.RUnlock()
if !ok {
return []int{}
}
return []int{quota}
}
+547
View File
@@ -0,0 +1,547 @@
package model
import (
"errors"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
const (
NameRuleExact = iota
NameRulePrefix
NameRuleContains
NameRuleSuffix
)
type ModelSquareState string
const (
ModelSquareVisible ModelSquareState = "visible"
ModelSquareUnavailable ModelSquareState = "unavailable"
ModelSquareHidden ModelSquareState = "hidden"
ModelSquarePartial ModelSquareState = "partial"
)
type BoundChannel struct {
Name string `json:"name"`
Type int `json:"type"`
}
type Model struct {
Id int `json:"id"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
SupportedEndpoints []string `json:"supported_endpoints,omitempty" gorm:"-"`
Status int `json:"status" gorm:"default:1"`
SyncOfficial int `json:"sync_official" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
QuotaTypes []int `json:"quota_types,omitempty" gorm:"-"`
NameRule int `json:"name_rule" gorm:"default:0"`
MatchedModels []string `json:"matched_models,omitempty" gorm:"-"`
MatchedCount int `json:"matched_count,omitempty" gorm:"-"`
HasMetadata bool `json:"has_metadata" gorm:"-"`
ConfiguredChannelCount int `json:"configured_channel_count" gorm:"-"`
SquareState ModelSquareState `json:"square_state" gorm:"-"`
}
// MatchesName applies a metadata rule to a concrete channel model name.
func (mi *Model) MatchesName(name string) bool {
switch mi.NameRule {
case NameRulePrefix:
return strings.HasPrefix(name, mi.ModelName)
case NameRuleSuffix:
return strings.HasSuffix(name, mi.ModelName)
case NameRuleContains:
return strings.Contains(name, mi.ModelName)
default:
return name == mi.ModelName
}
}
// resolveModelMetadata preserves catalog precedence: exact, prefix, suffix,
// then contains. The first matching record within a rule type wins.
func resolveModelMetadata(records []Model, names []string) map[string]*Model {
resolved := make(map[string]*Model)
for i := range records {
if records[i].NameRule == NameRuleExact {
resolved[records[i].ModelName] = &records[i]
}
}
for _, rule := range []int{NameRulePrefix, NameRuleSuffix, NameRuleContains} {
for i := range records {
metadata := &records[i]
if metadata.NameRule != rule {
continue
}
for _, name := range names {
if _, exists := resolved[name]; !exists && metadata.MatchesName(name) {
resolved[name] = metadata
}
}
}
}
return resolved
}
// FillModelSquareStates applies the same metadata policy as the public catalog
// to live routes, then aggregates concrete models for metadata rule rows.
func FillModelSquareStates(rows []*Model, configured map[string][]int, connections []ModelConnection) error {
var metadata []Model
if err := DB.Find(&metadata).Error; err != nil {
return err
}
nameSet := make(map[string]struct{}, len(configured))
for name := range configured {
nameSet[name] = struct{}{}
}
for _, row := range rows {
if row != nil && row.NameRule == NameRuleExact {
nameSet[row.ModelName] = struct{}{}
}
}
names := make([]string, 0, len(nameSet))
for name := range nameSet {
names = append(names, name)
}
resolved := resolveModelMetadata(metadata, names)
available := make(map[string]bool)
for _, connection := range connections {
available[connection.Model] = true
}
states := make(map[string]ModelSquareState, len(names))
for _, name := range names {
state := ModelSquareUnavailable
if policy := resolved[name]; policy != nil && policy.Status != 1 {
state = ModelSquareHidden
} else if available[name] {
state = ModelSquareVisible
}
states[name] = state
}
for _, row := range rows {
if row == nil {
continue
}
if row.NameRule == NameRuleExact {
row.SquareState = states[row.ModelName]
continue
}
total, visible, hidden := 0, 0, 0
for name := range configured {
if !row.MatchesName(name) {
continue
}
total++
switch states[name] {
case ModelSquareVisible:
visible++
case ModelSquareHidden:
hidden++
}
}
row.SquareState = ModelSquareUnavailable
switch {
case total == 0:
if row.Status != 1 {
row.SquareState = ModelSquareHidden
}
case hidden == total:
row.SquareState = ModelSquareHidden
case visible == total:
row.SquareState = ModelSquareVisible
case visible > 0:
row.SquareState = ModelSquarePartial
}
}
return nil
}
// GetConfiguredModelChannels includes disabled channels and reads no credentials.
func GetConfiguredModelChannels() (map[string][]int, error) {
var channels []Channel
if err := DB.Select("id", "models").Find(&channels).Error; err != nil {
return nil, err
}
configured := make(map[string][]int)
for _, channel := range channels {
for _, name := range normalizeLookupValues(channel.GetModels()) {
configured[name] = append(configured[name], channel.Id)
}
}
return configured, nil
}
// SearchModelsWithChannels augments metadata with concrete configured names.
// Synthetic rows never persist and never affect the public pricing catalog.
func SearchModelsWithChannels(keyword, vendor, status, syncOfficial string, offset, limit int) ([]*Model, int64, error) {
records, _, err := SearchModels(keyword, vendor, status, syncOfficial, 0, -1)
if err != nil {
return nil, 0, err
}
_, filterStatus := parseModelStatusFilter(status)
_, filterSync := parseModelSyncFilter(syncOfficial)
if !filterStatus && !filterSync && (vendor == "" || vendor == "0") {
configured, err := GetConfiguredModelChannels()
if err != nil {
return nil, 0, err
}
var exactNames []string
if err := DB.Model(&Model{}).Where("name_rule = ?", NameRuleExact).Pluck("model_name", &exactNames).Error; err != nil {
return nil, 0, err
}
for _, name := range exactNames {
delete(configured, name)
}
names := make([]string, 0, len(configured))
for name := range configured {
if keyword == "" || strings.Contains(strings.ToLower(name), strings.ToLower(keyword)) {
names = append(names, name)
}
}
sort.Strings(names)
for _, name := range names {
records = append(records, &Model{ModelName: name, NameRule: NameRuleExact})
}
}
total := len(records)
if offset < 0 {
offset = 0
}
if offset >= total {
return []*Model{}, int64(total), nil
}
end := total
if limit >= 0 && limit < total-offset {
end = offset + limit
}
return records[offset:end], int64(total), nil
}
func (mi *Model) Insert() error {
return metadataTransaction(func(tx *gorm.DB) error {
if err := validateModelVendor(tx, mi.VendorID); err != nil {
return err
}
now := common.GetTimestamp()
mi.CreatedTime, mi.UpdatedTime = now, now
status, syncOfficial := mi.Status, mi.SyncOfficial
if err := tx.Create(mi).Error; err != nil {
return err
}
mi.Status, mi.SyncOfficial = status, syncOfficial
return tx.Model(&Model{}).Where("id = ?", mi.Id).Updates(map[string]any{"status": status, "sync_official": syncOfficial}).Error
})
}
func IsModelNameDuplicated(id int, name string) (bool, error) {
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
}
func (mi *Model) Update() error {
return metadataTransaction(func(tx *gorm.DB) error {
if err := validateModelVendor(tx, mi.VendorID); err != nil {
return err
}
mi.UpdatedTime = common.GetTimestamp()
return tx.Model(&Model{}).Where("id = ?", mi.Id).
Select("model_name", "description", "icon", "tags", "vendor_id", "endpoints", "status", "sync_official", "name_rule", "updated_time").Updates(mi).Error
})
}
func (mi *Model) Delete() error {
_, err := DeleteModelMetadata([]int{mi.Id}, false, false)
return err
}
type ModelDeleteResult struct {
DeletedCount int `json:"deleted_count"`
UpdatedChannels int `json:"updated_channels"`
}
// DeleteModelMetadata optionally removes exact model names from every channel.
// Channel removal requires exact-match metadata records. Pricing removal
// clears the selected names without expanding metadata matching rules.
func DeleteModelMetadata(ids []int, removeFromChannels, removePricing bool) (ModelDeleteResult, error) {
result := ModelDeleteResult{}
if len(ids) == 0 || len(ids) > 1000 {
return result, errors.New("select between 1 and 1000 models")
}
selected := make(map[int]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
return result, errors.New("invalid model ID")
}
selected[id] = struct{}{}
}
modelIDs := make([]int, 0, len(selected))
for id := range selected {
modelIDs = append(modelIDs, id)
}
sort.Ints(modelIDs)
names := make(map[string]struct{}, len(modelIDs))
deleteRecords := func(tx *gorm.DB) error {
var records []Model
if err := lockForUpdate(tx).Where("id IN ?", modelIDs).Order("id").Find(&records).Error; err != nil {
return err
}
if len(records) != len(modelIDs) {
return errors.New("selected models changed; reload before deleting")
}
for _, record := range records {
if removeFromChannels && record.NameRule != NameRuleExact {
return errors.New("only exact-match models can be removed from channels")
}
names[record.ModelName] = struct{}{}
}
if removeFromChannels {
var channels []Channel
// Read only routing fields. Lock channels in a consistent order, then
// update models and abilities in the same transaction as metadata.
if err := lockForUpdate(tx).Select("id", "models", "status", "group", "priority", "weight", "tag").Order("id").Find(&channels).Error; err != nil {
return err
}
for _, channel := range channels {
models := channel.GetModels()
remaining := make([]string, 0, len(models))
for _, name := range models {
if _, remove := names[strings.TrimSpace(name)]; !remove {
remaining = append(remaining, name)
}
}
if len(remaining) == len(models) {
continue
}
channel.Models = strings.Join(remaining, ",")
if err := tx.Model(&Channel{}).Where("id = ?", channel.Id).Update("models", channel.Models).Error; err != nil {
return err
}
if err := channel.UpdateAbilities(tx); err != nil {
return err
}
result.UpdatedChannels++
}
}
if err := tx.Where("id IN ?", modelIDs).Delete(&Model{}).Error; err != nil {
return err
}
result.DeletedCount = len(records)
return nil
}
var err error
if removePricing {
// Use the pricing mutation path so both option persistence and runtime
// publication stay serialized with ordinary pricing saves. All database
// writes share one transaction; runtime prices publish only after commit.
metadataMutationMu.Lock()
defer metadataMutationMu.Unlock()
err = mutateModelPricingOptions(func(tx *gorm.DB, values map[string]map[string]any) error {
if err := lockMetadataMutation(tx); err != nil {
return err
}
if err := deleteRecords(tx); err != nil {
return err
}
for _, entries := range values {
for name := range names {
delete(entries, name)
}
}
return nil
})
} else {
err = metadataTransaction(deleteRecords)
}
if err != nil {
return ModelDeleteResult{}, err
}
if result.UpdatedChannels > 0 {
InitChannelCache()
}
RefreshPricing()
return result, nil
}
func GetVendorModelCounts() (map[int64]int64, error) {
var stats []struct {
VendorID int64
Count int64
}
if err := DB.Model(&Model{}).
Select("vendor_id as vendor_id, count(*) as count").
Group("vendor_id").
Scan(&stats).Error; err != nil {
return nil, err
}
m := make(map[int64]int64, len(stats))
for _, s := range stats {
m[s.VendorID] = s.Count
}
return m, nil
}
func GetAllModels(offset int, limit int) ([]*Model, error) {
models, _, err := SearchModels("", "", "", "", offset, limit)
return models, err
}
// ModelConnection describes an enabled route independently of catalog visibility or price.
type ModelConnection struct {
AbilityWithChannel
ChannelName string `json:"channel_name"`
}
func GetModelConnections() ([]ModelConnection, error) {
var connections []ModelConnection
err := DB.Table("abilities").
Select("abilities.*, channels.type as channel_type, channels.name as channel_name").
Joins("JOIN channels ON abilities.channel_id = channels.id").
Where("abilities.enabled = ? AND channels.status = ?", true, common.ChannelStatusEnabled).
Order("abilities.model, abilities.channel_id").
Scan(&connections).Error
return connections, err
}
func normalizeLookupValues(values []string) []string {
seen := make(map[string]struct{}, len(values))
normalized := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
return normalized
}
func GetPreferredModelOwnerChannelTypes(modelNames []string, groups []string) (map[string]int, error) {
result := make(map[string]int)
modelNames = normalizeLookupValues(modelNames)
if len(modelNames) == 0 {
return result, nil
}
type row struct {
Model string
ChannelType int
}
var rows []row
query := DB.Table("abilities").
Select("abilities.model as model, channels.type as channel_type").
Joins("JOIN channels ON abilities.channel_id = channels.id").
Where("abilities.model IN ? AND abilities.enabled = ? AND channels.status = ?", modelNames, true, common.ChannelStatusEnabled).
Order("COALESCE(abilities.priority, 0) DESC").
Order("abilities.weight DESC").
Order("abilities.channel_id ASC")
groups = normalizeLookupValues(groups)
if len(groups) > 0 {
query = query.Where("abilities."+commonGroupCol+" IN ?", groups)
}
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
if _, ok := result[r.Model]; ok {
continue
}
result[r.Model] = r.ChannelType
}
return result, nil
}
func SearchModels(keyword string, vendor string, status string, syncOfficial string, offset int, limit int) ([]*Model, int64, error) {
var models []*Model
db := DB.Model(&Model{})
if keyword != "" {
like := "%" + keyword + "%"
db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like)
}
if vendor != "" {
if vid, err := strconv.Atoi(vendor); err == nil {
db = db.Where("models.vendor_id = ?", vid)
} else {
db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%")
}
}
if statusValue, ok := parseModelStatusFilter(status); ok {
db = db.Where("models.status = ?", statusValue)
}
if syncValue, ok := parseModelSyncFilter(syncOfficial); ok {
db = db.Where("models.sync_official = ?", syncValue)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("models.id DESC").Offset(offset).Limit(limit).Find(&models).Error; err != nil {
return nil, 0, err
}
return models, total, nil
}
// parseModelStatusFilter maps UI/API status values to the models.status column.
// Returns ok=false when no status filter should be applied.
func parseModelStatusFilter(status string) (value int, ok bool) {
switch strings.ToLower(strings.TrimSpace(status)) {
case "", "all":
return 0, false
case "enabled", "1":
return 1, true
case "disabled", "0":
return 0, true
default:
n, err := strconv.Atoi(status)
if err != nil {
return 0, false
}
return n, true
}
}
// parseModelSyncFilter maps UI/API sync values to the models.sync_official column.
// Returns ok=false when no sync filter should be applied.
func parseModelSyncFilter(syncOfficial string) (value int, ok bool) {
switch strings.ToLower(strings.TrimSpace(syncOfficial)) {
case "", "all":
return 0, false
case "yes", "1":
return 1, true
case "no", "0":
return 0, true
default:
n, err := strconv.Atoi(syncOfficial)
if err != nil {
return 0, false
}
return n, true
}
}
+278
View File
@@ -0,0 +1,278 @@
package model
import (
"crypto/sha256"
"errors"
"fmt"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var metadataMutationMu sync.Mutex
// metadataTransaction serializes changes to vendors and model ownership before
// acquiring model/vendor row locks. The option anchor also covers empty tables.
func metadataTransaction(change func(*gorm.DB) error) error {
metadataMutationMu.Lock()
defer metadataMutationMu.Unlock()
return DB.Transaction(func(tx *gorm.DB) error {
if err := lockMetadataMutation(tx); err != nil {
return err
}
return change(tx)
})
}
func lockMetadataMutation(tx *gorm.DB) error {
anchor := Option{Key: "metadata_sync_lock", Value: ""}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&anchor).Error; err != nil {
return err
}
return lockForUpdate(tx).Where(commonKeyCol+" = ?", anchor.Key).First(&anchor).Error
}
var ErrMetadataSyncConflict = errors.New("metadata changed; preview again before applying")
var MetadataSyncFields = []string{"description", "icon", "tags", "vendor", "endpoints", "name_rule", "status"}
type MetadataValues struct {
Description string `json:"description"`
Icon string `json:"icon"`
Tags string `json:"tags"`
Vendor string `json:"vendor"`
Endpoints string `json:"endpoints"`
NameRule int `json:"name_rule"`
Status int `json:"status"`
}
type MetadataSyncSelection struct {
ModelName string `json:"model_name"`
RecordVersion string `json:"record_version"`
Create bool `json:"create"`
Fields []string `json:"fields"`
}
type MetadataSyncUpdate struct {
MetadataSyncSelection
Values MetadataValues
}
type MetadataSyncResult struct {
CreatedModels []string `json:"created_models"`
UpdatedModels []MetadataSyncSelection `json:"updated_models"`
CreatedVendors []string `json:"created_vendors"`
}
func MetadataRecordVersion(local *Model, localVendor, upstreamVendor *Vendor) string {
encoded, _ := common.Marshal([]any{local, localVendor, upstreamVendor})
return fmt.Sprintf("%x", sha256.Sum256(encoded))
}
func GetMetadataSyncState(db *gorm.DB) (map[string]*Model, map[string]*Vendor, error) {
var models []*Model
var vendors []*Vendor
if err := db.Session(&gorm.Session{}).Order("id").Find(&models).Error; err != nil {
return nil, nil, err
}
if err := db.Session(&gorm.Session{}).Order("id").Find(&vendors).Error; err != nil {
return nil, nil, err
}
modelMap := make(map[string]*Model, len(models))
vendorMap := make(map[string]*Vendor, len(vendors))
for _, item := range models {
modelMap[item.ModelName] = item
}
for _, item := range vendors {
vendorMap[item.Name] = item
}
return modelMap, vendorMap, nil
}
func ValidateMetadataValues(values MetadataValues) error {
if values.NameRule < NameRuleExact || values.NameRule > NameRuleSuffix {
return errors.New("invalid metadata matching rule")
}
if values.Status != 0 && values.Status != 1 {
return errors.New("invalid catalog visibility")
}
return ValidateModelEndpoints(values.Endpoints)
}
// ValidateModelEndpoints accepts the existing map form (custom paths) and
// type-array form (declared protocols), but never arbitrary JSON scalars.
func ValidateModelEndpoints(raw string) error {
if strings.TrimSpace(raw) == "" {
return nil
}
var value any
if err := common.UnmarshalJsonStr(raw, &value); err != nil {
return fmt.Errorf("invalid endpoints: %w", err)
}
switch endpoints := value.(type) {
case []any:
for _, endpoint := range endpoints {
if text, ok := endpoint.(string); !ok || strings.TrimSpace(text) == "" {
return errors.New("endpoint types must be non-empty strings")
}
}
case map[string]any:
for key, endpoint := range endpoints {
if strings.TrimSpace(key) == "" {
return errors.New("endpoint type is required")
}
switch details := endpoint.(type) {
case string:
if !strings.HasPrefix(details, "/") {
return errors.New("endpoint paths must start with /")
}
case map[string]any:
path, _ := details["path"].(string)
if !strings.HasPrefix(path, "/") {
return errors.New("endpoint paths must start with /")
}
if method, exists := details["method"]; exists {
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS":
default:
return errors.New("invalid endpoint HTTP method")
}
}
default:
return errors.New("endpoint configuration must be a path or object")
}
}
default:
return errors.New("endpoints must be a JSON object or array")
}
return nil
}
func ApplyMetadataSync(updates []MetadataSyncUpdate, upstreamVendors map[string]Vendor) (*MetadataSyncResult, error) {
if len(updates) == 0 {
return nil, errors.New("select metadata changes before applying")
}
seen := make(map[string]bool)
for _, update := range updates {
if seen[update.ModelName] || strings.TrimSpace(update.ModelName) == "" {
return nil, errors.New("invalid or duplicate model selection")
}
seen[update.ModelName] = true
if update.RecordVersion == "" {
return nil, ErrMetadataSyncConflict
}
if err := ValidateMetadataValues(update.Values); err != nil {
return nil, err
}
if !update.Create && len(update.Fields) == 0 {
return nil, errors.New("select fields to update")
}
allowed := make(map[string]bool)
for _, field := range MetadataSyncFields {
allowed[field] = true
}
for _, field := range update.Fields {
if !allowed[field] {
return nil, fmt.Errorf("unsupported metadata field: %s", field)
}
}
}
result := &MetadataSyncResult{CreatedModels: []string{}, UpdatedModels: []MetadataSyncSelection{}, CreatedVendors: []string{}}
err := metadataTransaction(func(tx *gorm.DB) error {
locals, vendors, err := GetMetadataSyncState(lockForUpdate(tx))
if err != nil {
return err
}
vendorByID := make(map[int]*Vendor)
for _, vendor := range vendors {
vendorByID[vendor.Id] = vendor
}
// Verify the entire selection before any write, including shared vendors.
for _, update := range updates {
local := locals[update.ModelName]
if local != nil && local.SyncOfficial == 0 {
return fmt.Errorf("metadata sync is disabled for %s", update.ModelName)
}
var localVendor *Vendor
if local != nil {
localVendor = vendorByID[local.VendorID]
}
if MetadataRecordVersion(local, localVendor, FindMetadataVendor(vendors, update.Values.Vendor)) != update.RecordVersion {
return fmt.Errorf("%w: %s", ErrMetadataSyncConflict, update.ModelName)
}
if update.Create && local != nil || !update.Create && local == nil {
return ErrMetadataSyncConflict
}
}
for _, update := range updates {
fields := make(map[string]any)
selected := update.Fields
if update.Create {
selected = MetadataSyncFields
}
for _, field := range selected {
switch field {
case "description":
fields[field] = update.Values.Description
case "icon":
fields[field] = update.Values.Icon
case "tags":
fields[field] = update.Values.Tags
case "endpoints":
fields[field] = update.Values.Endpoints
case "name_rule":
fields[field] = update.Values.NameRule
case "status":
fields[field] = update.Values.Status
case "vendor":
vendorID := 0
name := update.Values.Vendor
if name != "" {
vendor := FindMetadataVendor(vendors, name)
if vendor == nil {
up, ok := upstreamVendors[name]
if !ok {
return fmt.Errorf("upstream vendor not found: %s", name)
}
vendor = &Vendor{Name: name, Description: up.Description, Icon: up.Icon, Status: 1, CreatedTime: common.GetTimestamp(), UpdatedTime: common.GetTimestamp()}
if err := validateVendorMetadata(tx, vendor); err != nil {
return err
}
if err := tx.Create(vendor).Error; err != nil {
return err
}
vendors[name] = vendor
result.CreatedVendors = append(result.CreatedVendors, name)
}
vendorID = vendor.Id
}
fields["vendor_id"] = vendorID
}
}
fields["updated_time"] = common.GetTimestamp()
if update.Create {
fields["model_name"] = update.ModelName
fields["sync_official"] = 1
fields["created_time"] = common.GetTimestamp()
if err := tx.Model(&Model{}).Create(fields).Error; err != nil {
return err
}
result.CreatedModels = append(result.CreatedModels, update.ModelName)
} else {
if err := tx.Model(&Model{}).Where("id = ?", locals[update.ModelName].Id).Updates(fields).Error; err != nil {
return err
}
result.UpdatedModels = append(result.UpdatedModels, update.MetadataSyncSelection)
}
}
return nil
})
if err != nil {
return nil, err
}
RefreshPricing()
return result, nil
}
+141
View File
@@ -0,0 +1,141 @@
package model
import (
"fmt"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/stretchr/testify/require"
)
func clearPreferredOwnerTables(t *testing.T) {
t.Helper()
require.NoError(t, DB.Exec("DELETE FROM abilities").Error)
require.NoError(t, DB.Exec("DELETE FROM channels").Error)
}
func insertPreferredOwnerCandidate(
t *testing.T,
channelID int,
modelName string,
group string,
channelType int,
priority int64,
weight uint,
channelStatus int,
abilityEnabled bool,
) {
t.Helper()
require.NoError(t, DB.Create(&Channel{
Id: channelID,
Type: channelType,
Key: fmt.Sprintf("key-%d", channelID),
Status: channelStatus,
Name: fmt.Sprintf("channel-%d", channelID),
}).Error)
require.NoError(t, DB.Create(&Ability{
Group: group,
Model: modelName,
ChannelId: channelID,
Enabled: abilityEnabled,
Priority: &priority,
Weight: weight,
}).Error)
}
func TestGetPreferredModelOwnerChannelTypes(t *testing.T) {
const modelName = "gpt-5.4"
tests := []struct {
name string
setup func(t *testing.T)
groups []string
expected int
found bool
}{
{
name: "openai only",
setup: func(t *testing.T) {
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 0, 0, common.ChannelStatusEnabled, true)
},
groups: []string{"default"},
expected: constant.ChannelTypeOpenAI,
found: true,
},
{
name: "codex only",
setup: func(t *testing.T) {
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeCodex, 0, 0, common.ChannelStatusEnabled, true)
},
groups: []string{"default"},
expected: constant.ChannelTypeCodex,
found: true,
},
{
name: "priority wins",
setup: func(t *testing.T) {
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 100, common.ChannelStatusEnabled, true)
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 2, 0, common.ChannelStatusEnabled, true)
},
groups: []string{"default"},
expected: constant.ChannelTypeCodex,
found: true,
},
{
name: "weight wins when priority is equal",
setup: func(t *testing.T) {
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 10, common.ChannelStatusEnabled, true)
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 1, 20, common.ChannelStatusEnabled, true)
},
groups: []string{"default"},
expected: constant.ChannelTypeCodex,
found: true,
},
{
name: "channel id stabilizes exact ties",
setup: func(t *testing.T) {
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 1, 10, common.ChannelStatusEnabled, true)
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 10, common.ChannelStatusEnabled, true)
},
groups: []string{"default"},
expected: constant.ChannelTypeOpenAI,
found: true,
},
{
name: "group filter excludes other groups",
setup: func(t *testing.T) {
insertPreferredOwnerCandidate(t, 1, modelName, "vip", constant.ChannelTypeCodex, 10, 100, common.ChannelStatusEnabled, true)
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeOpenAI, 1, 0, common.ChannelStatusEnabled, true)
},
groups: []string{"default"},
expected: constant.ChannelTypeOpenAI,
found: true,
},
{
name: "disabled candidates are ignored",
setup: func(t *testing.T) {
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeCodex, 10, 100, common.ChannelStatusEnabled, false)
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeOpenAI, 1, 0, common.ChannelStatusManuallyDisabled, true)
},
groups: []string{"default"},
found: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearPreferredOwnerTables(t)
tt.setup(t)
owners, err := GetPreferredModelOwnerChannelTypes([]string{modelName}, tt.groups)
require.NoError(t, err)
got, ok := owners[modelName]
require.Equal(t, tt.found, ok)
if tt.found {
require.Equal(t, tt.expected, got)
}
})
}
}
+379
View File
@@ -0,0 +1,379 @@
package model
import (
"crypto/sha256"
"errors"
"fmt"
"math"
"slices"
"sort"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PricingValues is one model's configuration, keyed by the existing option
// names. A missing key inherits the engine's default; an explicit zero is free.
type PricingValues map[string]any
type ModelPricingChange struct {
ModelName string `json:"model_name"`
ExpectedVersion string `json:"expected_version"`
Pricing PricingValues `json:"pricing"`
Reset bool `json:"reset,omitempty"`
}
type ModelPricingEntry struct {
ModelName string `json:"model_name"`
Version string `json:"version"`
Configured PricingValues `json:"configured"`
Effective PricingValues `json:"effective"`
UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usage_schema,omitempty"`
}
type ModelPricingSnapshot struct {
Entries []ModelPricingEntry `json:"entries"`
Options map[string]string `json:"options"`
EmptyVersion string `json:"empty_version"`
}
var ErrModelPricingConflict = errors.New("model pricing changed; reload before saving")
// Lock order is stable across instances. Creating missing option rows inside
// the transaction also serializes the first write to an unconfigured database.
var modelPricingOptionKeys = []string{
"AudioCompletionRatio", "AudioRatio", "CacheRatio", "CompletionRatio",
"CreateCacheRatio", "ImageRatio", "ModelPrice", "ModelRatio",
"billing_setting.billing_expr", "billing_setting.billing_mode",
}
var modelPricingMutationMu sync.Mutex
func IsModelPricingOption(key string) bool {
return slices.Contains(modelPricingOptionKeys, key)
}
func ModelPricingVersion(values PricingValues) string {
encoded, _ := common.Marshal(values)
return fmt.Sprintf("%x", sha256.Sum256(encoded))
}
func defaultPricingMaps() map[string]map[string]any {
result := make(map[string]map[string]any, len(modelPricingOptionKeys))
for _, key := range modelPricingOptionKeys {
result[key] = make(map[string]any)
}
for key, values := range ratio_setting.GetDefaultPricingMaps() {
for name, value := range values {
result[key][name] = value
}
}
return result
}
func readModelPricingMaps(db *gorm.DB) (map[string]map[string]any, error) {
var rows []Option
if err := db.Where(commonKeyCol+" IN ?", modelPricingOptionKeys).Find(&rows).Error; err != nil {
return nil, err
}
values := defaultPricingMaps()
for _, row := range rows {
var entries map[string]any
if err := common.UnmarshalJsonStr(row.Value, &entries); err != nil {
return nil, fmt.Errorf("%s: %w", row.Key, err)
}
if entries == nil {
return nil, fmt.Errorf("%s must be a JSON object", row.Key)
}
values[row.Key] = entries
}
return values, nil
}
func modelPricingValues(values map[string]map[string]any, name string) PricingValues {
result := make(PricingValues)
for _, key := range modelPricingOptionKeys {
if value, exists := values[key][name]; exists {
result[key] = value
}
}
return result
}
func effectiveModelPricing(values map[string]map[string]any, name string) PricingValues {
result := modelPricingValues(values, name)
// Legacy wildcard aliases are resolved by the same normalization as relay.
alias := ratio_setting.FormatMatchingModelName(name)
for _, key := range modelPricingOptionKeys[:8] {
if value, exists := values[key][alias]; exists {
result[key] = value
}
}
mode, _ := result["billing_setting.billing_mode"].(string)
if mode == "" {
_, hasPrice := result["ModelPrice"]
_, hasRatio := result["ModelRatio"]
if _, builtin := billing_setting.GetBuiltinBillingExpr(name); builtin && !hasPrice && !hasRatio {
mode = "tiered_expr"
}
}
if mode == "tiered_expr" {
result["billing_setting.billing_mode"] = mode
if _, exists := result["billing_setting.billing_expr"]; !exists {
if expression, ok := billing_setting.GetBuiltinBillingExpr(name); ok {
result["billing_setting.billing_expr"] = expression
}
}
return result
}
if _, exists := result["ModelPrice"]; exists {
return result
}
if _, exists := result["ModelRatio"]; !exists && operation_setting.SelfUseModeEnabled {
result["ModelRatio"] = float64(37.5)
}
// Completion ratios include engine-enforced model defaults. Expose their
// effective value without persisting them into the editable configuration.
completion := ratio_setting.GetCompletionRatioInfo(name)
if _, exists := result["CompletionRatio"]; !exists || completion.Locked {
result["CompletionRatio"] = completion.Ratio
}
return result
}
func GetModelPricingSnapshot(names []string) (*ModelPricingSnapshot, error) {
values, err := readModelPricingMaps(DB)
if err != nil {
return nil, err
}
if len(names) == 0 {
nameSet := make(map[string]bool)
for _, entries := range values {
for name := range entries {
nameSet[name] = true
}
}
for name := range billing_setting.GetBuiltinBillingExprCopy() {
nameSet[name] = true
}
for name := range nameSet {
names = append(names, name)
}
}
sort.Strings(names)
result := &ModelPricingSnapshot{Entries: make([]ModelPricingEntry, 0, len(names)), Options: make(map[string]string), EmptyVersion: ModelPricingVersion(PricingValues{})}
generation := jsplugin.DefaultRegistry.Generation()
for _, name := range names {
configured := modelPricingValues(values, name)
entry := ModelPricingEntry{ModelName: name, Version: ModelPricingVersion(configured), Configured: configured, Effective: effectiveModelPricing(values, name)}
if plugin, ok := generation.GetByModel(name); ok {
entry.UsageSchema = plugin.Meta.UsageSchema
} else if target, ok := ResolveTaskModelAlias(generation, name); ok {
if plugin, ok := generation.Get(target.PluginKey); ok {
entry.UsageSchema = plugin.Meta.UsageSchema
}
}
result.Entries = append(result.Entries, entry)
}
// Preserve the existing settings editor's full-map interface. Built-in
// expressions are display defaults only; per-model writes do not persist them.
for name, expression := range billing_setting.GetBuiltinBillingExprCopy() {
effective := effectiveModelPricing(values, name)
if effective["billing_setting.billing_mode"] != "tiered_expr" {
continue
}
if _, ok := values["billing_setting.billing_mode"][name]; !ok {
values["billing_setting.billing_mode"][name] = "tiered_expr"
}
if _, ok := values["billing_setting.billing_expr"][name]; !ok {
values["billing_setting.billing_expr"][name] = expression
}
}
for key, entries := range values {
encoded, err := common.Marshal(entries)
if err != nil {
return nil, err
}
result.Options[key] = string(encoded)
}
return result, nil
}
func ValidateModelPricing(name string, values PricingValues) error {
if strings.TrimSpace(name) == "" {
return errors.New("model name is required")
}
for key, value := range values {
if !IsModelPricingOption(key) {
return fmt.Errorf("unsupported pricing field: %s", key)
}
if key == "billing_setting.billing_mode" {
if value != "ratio" && value != "tiered_expr" {
return errors.New("invalid billing mode")
}
continue
}
if key == "billing_setting.billing_expr" {
expression, ok := value.(string)
if !ok || strings.TrimSpace(expression) == "" {
return errors.New("billing expression is required")
}
generation := jsplugin.DefaultRegistry.Generation()
var err error
if plugin, ok := generation.GetByModel(name); ok {
err = billing_setting.SmokeTestTaskExpr(expression, plugin.Meta.UsageSchema)
} else if target, resolved := ResolveTaskModelAlias(generation, name); resolved {
if plugin, ok := generation.Get(target.PluginKey); ok {
err = billing_setting.SmokeTestTaskExpr(expression, plugin.Meta.UsageSchema)
} else {
err = billing_setting.SmokeTestExpr(expression)
}
} else {
err = billing_setting.SmokeTestExpr(expression)
}
if err != nil {
return fmt.Errorf("model %s: %w", name, err)
}
continue
}
number, ok := value.(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
return fmt.Errorf("%s must be a finite, non-negative number", key)
}
}
if values["billing_setting.billing_mode"] == "tiered_expr" {
if _, exists := values["billing_setting.billing_expr"]; !exists {
if _, builtin := billing_setting.GetBuiltinBillingExpr(name); !builtin {
return errors.New("billing expression is required")
}
}
}
return nil
}
func UpdateModelPricing(changes []ModelPricingChange) error {
if len(changes) == 0 {
return errors.New("select model pricing changes before saving")
}
seen := make(map[string]bool)
for _, change := range changes {
if seen[change.ModelName] {
return errors.New("duplicate model pricing change")
}
seen[change.ModelName] = true
if change.ExpectedVersion == "" {
return ErrModelPricingConflict
}
if err := ValidateModelPricing(change.ModelName, change.Pricing); err != nil {
return err
}
}
return mutateModelPricingOptions(func(_ *gorm.DB, values map[string]map[string]any) error {
defaults := defaultPricingMaps()
for _, change := range changes {
if ModelPricingVersion(modelPricingValues(values, change.ModelName)) != change.ExpectedVersion {
return fmt.Errorf("%w: %s", ErrModelPricingConflict, change.ModelName)
}
pricing := change.Pricing
if change.Reset {
pricing = modelPricingValues(defaults, change.ModelName)
}
for _, key := range modelPricingOptionKeys {
delete(values[key], change.ModelName)
if value, exists := pricing[key]; exists {
values[key][change.ModelName] = value
}
}
}
return nil
})
}
// UpdateModelPricingOptions keeps legacy single-option callers on the same
// locking, validation and transaction path as the model-level API.
func UpdateModelPricingOptions(updates map[string]string) error {
return mutateModelPricingOptions(func(_ *gorm.DB, values map[string]map[string]any) error {
names := make(map[string]bool)
for key, raw := range updates {
if !IsModelPricingOption(key) {
return fmt.Errorf("unsupported pricing field: %s", key)
}
var entries map[string]any
if err := common.UnmarshalJsonStr(raw, &entries); err != nil {
return err
}
if entries == nil {
return fmt.Errorf("%s must be a JSON object", key)
}
for name := range values[key] {
names[name] = true
}
values[key] = entries
for name := range entries {
names[name] = true
}
}
for name := range names {
if err := ValidateModelPricing(name, modelPricingValues(values, name)); err != nil {
return err
}
}
return nil
})
}
func mutateModelPricingOptions(mutate func(*gorm.DB, map[string]map[string]any) error) error {
modelPricingMutationMu.Lock()
defer modelPricingMutationMu.Unlock()
var committed map[string]map[string]any
err := DB.Transaction(func(tx *gorm.DB) error {
defaults := defaultPricingMaps()
for _, key := range modelPricingOptionKeys {
encoded, err := common.Marshal(defaults[key])
if err != nil {
return err
}
row := Option{Key: key, Value: string(encoded)}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return err
}
}
values, err := readModelPricingMaps(lockForUpdate(tx))
if err != nil {
return err
}
if err := mutate(tx, values); err != nil {
return err
}
for _, key := range modelPricingOptionKeys {
encoded, err := common.Marshal(values[key])
if err != nil {
return err
}
if err := tx.Model(&Option{}).Where(commonKeyCol+" = ?", key).Update("value", string(encoded)).Error; err != nil {
return err
}
}
committed = values
return nil
})
if err != nil {
return err
}
for _, key := range modelPricingOptionKeys {
encoded, _ := common.Marshal(committed[key])
if err := updateOptionMap(key, string(encoded)); err != nil {
return err
}
}
RefreshPricing()
ratio_setting.InvalidateExposedDataCache()
return nil
}
+663
View File
@@ -0,0 +1,663 @@
package model
import (
"maps"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/performance_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"gorm.io/gorm"
)
type Option struct {
Key string `json:"key" gorm:"primaryKey"`
Value string `json:"value"`
}
func AllOption() ([]*Option, error) {
var options []*Option
var err error
err = DB.Find(&options).Error
return options, err
}
func InitOptionMap() {
common.OptionMapRWMutex.Lock()
common.OptionMap = make(map[string]string)
// 添加原有的系统配置
common.OptionMap["FileUploadPermission"] = strconv.Itoa(common.FileUploadPermission)
common.OptionMap["FileDownloadPermission"] = strconv.Itoa(common.FileDownloadPermission)
common.OptionMap["ImageUploadPermission"] = strconv.Itoa(common.ImageUploadPermission)
common.OptionMap["ImageDownloadPermission"] = strconv.Itoa(common.ImageDownloadPermission)
common.OptionMap["PasswordLoginEnabled"] = strconv.FormatBool(common.PasswordLoginEnabled)
common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled)
common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
common.OptionMap["LinuxDOOAuthEnabled"] = strconv.FormatBool(common.LinuxDOOAuthEnabled)
common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled)
common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled)
common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled)
common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled)
common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled)
common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled)
common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled)
common.OptionMap["TaskEnabled"] = strconv.FormatBool(common.TaskEnabled)
common.OptionMap["TaskPluginEnabled"] = strconv.FormatBool(constant.TaskPluginEnabled)
jsplugin.DefaultRegistry.SetEnabled(constant.TaskPluginEnabled)
common.OptionMap[setting.TaskPluginMarketplaceSourcesKey] = setting.TaskPluginMarketplaceSources2JsonString()
common.OptionMap[setting.TaskPluginDisabledFactoryKeysKey] = "[]"
jsplugin.DefaultRegistry.SetDisabledFactoryKeys(nil)
common.OptionMap["DataExportEnabled"] = strconv.FormatBool(common.DataExportEnabled)
common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64)
common.OptionMap["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled)
common.OptionMap["EmailAliasRestrictionEnabled"] = strconv.FormatBool(common.EmailAliasRestrictionEnabled)
common.OptionMap["EmailDomainWhitelist"] = strings.Join(common.EmailDomainWhitelist, ",")
common.OptionMap["SMTPServer"] = ""
common.OptionMap["SMTPFrom"] = ""
common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort)
common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = ""
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
common.OptionMap["SMTPStartTLSEnabled"] = strconv.FormatBool(common.SMTPStartTLSEnabled)
common.OptionMap["SMTPInsecureSkipVerify"] = strconv.FormatBool(common.SMTPInsecureSkipVerify)
common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
common.OptionMap["Notice"] = ""
common.OptionMap["About"] = ""
common.OptionMap["HomePageContent"] = ""
common.OptionMap["Footer"] = common.Footer
common.OptionMap["SystemName"] = common.SystemName
common.OptionMap["Logo"] = common.Logo
common.OptionMap["ServerAddress"] = ""
common.OptionMap["TaskPublicAddress"] = system_setting.TaskPublicAddress
common.OptionMap["WorkerUrl"] = system_setting.WorkerUrl
common.OptionMap["WorkerValidKey"] = system_setting.WorkerValidKey
common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(system_setting.WorkerAllowHttpImageRequestEnabled)
common.OptionMap["PayAddress"] = ""
common.OptionMap["CustomCallbackAddress"] = ""
common.OptionMap["EpayId"] = ""
common.OptionMap["EpayKey"] = ""
common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64)
common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64)
common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp)
common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp)
common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret
common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret
common.OptionMap["StripePriceId"] = setting.StripePriceId
common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled)
common.OptionMap["CreemApiKey"] = setting.CreemApiKey
common.OptionMap["CreemProducts"] = setting.CreemProducts
common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode)
common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret
common.OptionMap["WaffoEnabled"] = strconv.FormatBool(setting.WaffoEnabled)
common.OptionMap["WaffoApiKey"] = setting.WaffoApiKey
common.OptionMap["WaffoPrivateKey"] = setting.WaffoPrivateKey
common.OptionMap["WaffoPublicCert"] = setting.WaffoPublicCert
common.OptionMap["WaffoSandboxPublicCert"] = setting.WaffoSandboxPublicCert
common.OptionMap["WaffoSandboxApiKey"] = setting.WaffoSandboxApiKey
common.OptionMap["WaffoSandboxPrivateKey"] = setting.WaffoSandboxPrivateKey
common.OptionMap["WaffoSandbox"] = strconv.FormatBool(setting.WaffoSandbox)
common.OptionMap["WaffoMerchantId"] = setting.WaffoMerchantId
common.OptionMap["WaffoNotifyUrl"] = setting.WaffoNotifyUrl
common.OptionMap["WaffoReturnUrl"] = setting.WaffoReturnUrl
common.OptionMap["WaffoSubscriptionReturnUrl"] = setting.WaffoSubscriptionReturnUrl
common.OptionMap["WaffoCurrency"] = setting.WaffoCurrency
common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString()
common.OptionMap["WaffoPancakeMerchantID"] = setting.WaffoPancakeMerchantID
common.OptionMap["WaffoPancakePrivateKey"] = setting.WaffoPancakePrivateKey
common.OptionMap["WaffoPancakeReturnURL"] = setting.WaffoPancakeReturnURL
common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp)
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup)
common.OptionMap["MaxTokenAutoGroups"] = strconv.Itoa(setting.GetMaxTokenAutoGroups())
common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString()
common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = ""
common.OptionMap["TelegramBotToken"] = ""
common.OptionMap["TelegramBotName"] = ""
common.OptionMap["WeChatServerAddress"] = ""
common.OptionMap["WeChatServerToken"] = ""
common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
common.OptionMap["TurnstileSiteKey"] = ""
common.OptionMap["TurnstileSecretKey"] = ""
common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser)
common.OptionMap["QuotaForInviter"] = strconv.Itoa(common.QuotaForInviter)
common.OptionMap["QuotaForInvitee"] = strconv.Itoa(common.QuotaForInvitee)
common.OptionMap["QuotaRemindThreshold"] = strconv.Itoa(common.QuotaRemindThreshold)
common.OptionMap["PreConsumedQuota"] = strconv.Itoa(common.PreConsumedQuota)
common.OptionMap["ModelRequestRateLimitCount"] = strconv.Itoa(setting.ModelRequestRateLimitCount)
common.OptionMap["ModelRequestRateLimitDurationMinutes"] = strconv.Itoa(setting.ModelRequestRateLimitDurationMinutes)
common.OptionMap["ModelRequestRateLimitSuccessCount"] = strconv.Itoa(setting.ModelRequestRateLimitSuccessCount)
common.OptionMap["ModelRequestRateLimitGroup"] = setting.ModelRequestRateLimitGroup2JSONString()
common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString()
common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString()
common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString()
common.OptionMap["CreateCacheRatio"] = ratio_setting.CreateCacheRatio2JSONString()
common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString()
common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString()
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
//common.OptionMap["ChatLink"] = common.ChatLink
//common.OptionMap["ChatLink2"] = common.ChatLink2
common.OptionMap["QuotaPerUnit"] = strconv.FormatFloat(common.QuotaPerUnit, 'f', -1, 64)
common.OptionMap["RetryTimes"] = strconv.Itoa(common.RetryTimes)
common.OptionMap["DataExportInterval"] = strconv.Itoa(common.DataExportInterval)
common.OptionMap["DataExportDefaultTime"] = common.DataExportDefaultTime
common.OptionMap["DefaultCollapseSidebar"] = strconv.FormatBool(common.DefaultCollapseSidebar)
common.OptionMap["MjNotifyEnabled"] = strconv.FormatBool(setting.MjNotifyEnabled)
common.OptionMap["MjAccountFilterEnabled"] = strconv.FormatBool(setting.MjAccountFilterEnabled)
common.OptionMap["MjModeClearEnabled"] = strconv.FormatBool(setting.MjModeClearEnabled)
common.OptionMap["MjForwardUrlEnabled"] = strconv.FormatBool(setting.MjForwardUrlEnabled)
common.OptionMap["MjActionCheckSuccessEnabled"] = strconv.FormatBool(setting.MjActionCheckSuccessEnabled)
common.OptionMap["CheckSensitiveEnabled"] = strconv.FormatBool(setting.CheckSensitiveEnabled)
common.OptionMap["DemoSiteEnabled"] = strconv.FormatBool(operation_setting.DemoSiteEnabled)
common.OptionMap["SelfUseModeEnabled"] = strconv.FormatBool(operation_setting.SelfUseModeEnabled)
common.OptionMap["ModelRequestRateLimitEnabled"] = strconv.FormatBool(setting.ModelRequestRateLimitEnabled)
common.OptionMap["CheckSensitiveOnPromptEnabled"] = strconv.FormatBool(setting.CheckSensitiveOnPromptEnabled)
common.OptionMap["StopOnSensitiveEnabled"] = strconv.FormatBool(setting.StopOnSensitiveEnabled)
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString()
common.OptionMap["AutomaticRetryStatusCodes"] = operation_setting.AutomaticRetryStatusCodesToString()
common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled())
// 自动添加所有注册的模型配置
modelConfigs := config.GlobalConfig.ExportAllConfigs()
maps.Copy(common.OptionMap, modelConfigs)
common.OptionMapRWMutex.Unlock()
loadOptionsFromDatabase()
}
func loadOptionsFromDatabase() {
options, _ := AllOption()
for _, option := range options {
err := updateOptionMap(option.Key, option.Value)
if err != nil {
common.SysLog("failed to update option map: " + err.Error())
}
}
}
func SyncOptions(frequency int) {
for {
time.Sleep(time.Duration(frequency) * time.Second)
common.SysLog("syncing options from database")
loadOptionsFromDatabase()
}
}
func validateOptionValue(key string, value string) error {
if key == operation_setting.ToolPriceOptionKey {
return operation_setting.ValidateToolPricesJSON(value)
}
if key == operation_setting.ChannelTestConcurrencyOptionKey {
return operation_setting.ValidateChannelTestConcurrency(value)
}
if key == "MaxTokenAutoGroups" {
return setting.ValidateMaxTokenAutoGroups(value)
}
return nil
}
func UpdateOption(key string, value string) error {
if IsModelPricingOption(key) {
return UpdateModelPricingOptions(map[string]string{key: value})
}
if err := validateOptionValue(key, value); err != nil {
return err
}
// Save to database first
option := Option{
Key: key,
}
// https://gorm.io/docs/update.html#Save-All-Fields
DB.FirstOrCreate(&option, Option{Key: key})
option.Value = value
// Save is a combination function.
// If save value does not contain primary key, it will execute Create,
// otherwise it will execute Update (with all fields).
DB.Save(&option)
// Update OptionMap
return updateOptionMap(key, value)
}
// UpdateOptionsBulk persists multiple key/value pairs in a single database
// transaction, then dispatches them through updateOptionMap in one pass. If
// any DB write fails the whole transaction rolls back and no in-memory state
// is touched — safe for callers that must commit a set of related options
// atomically (e.g. payment gateway binding).
func UpdateOptionsBulk(values map[string]string) error {
if len(values) == 0 {
return nil
}
for key, value := range values {
if err := validateOptionValue(key, value); err != nil {
return err
}
}
err := DB.Transaction(func(tx *gorm.DB) error {
for k, v := range values {
option := Option{Key: k}
if err := tx.FirstOrCreate(&option, Option{Key: k}).Error; err != nil {
return err
}
option.Value = v
if err := tx.Save(&option).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
for k, v := range values {
if err := updateOptionMap(k, v); err != nil {
return err
}
}
return nil
}
func updateOptionMap(key string, value string) (err error) {
if key == retiredThemeOptionKey {
common.OptionMapRWMutex.Lock()
delete(common.OptionMap, key)
common.OptionMapRWMutex.Unlock()
return nil
}
common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock()
common.OptionMap[key] = value
// 检查是否是模型配置 - 使用更规范的方式处理
if handleConfigUpdate(key, value) {
return nil // 已由配置系统处理
}
// 处理传统配置项...
if strings.HasSuffix(key, "Permission") {
intValue, _ := strconv.Atoi(value)
switch key {
case "FileUploadPermission":
common.FileUploadPermission = intValue
case "FileDownloadPermission":
common.FileDownloadPermission = intValue
case "ImageUploadPermission":
common.ImageUploadPermission = intValue
case "ImageDownloadPermission":
common.ImageDownloadPermission = intValue
}
}
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" || key == "SMTPForceAuthLogin" || key == "SMTPInsecureSkipVerify" {
boolValue := value == "true"
switch key {
case "PasswordRegisterEnabled":
common.PasswordRegisterEnabled = boolValue
case "PasswordLoginEnabled":
common.PasswordLoginEnabled = boolValue
case "EmailVerificationEnabled":
common.EmailVerificationEnabled = boolValue
case "GitHubOAuthEnabled":
common.GitHubOAuthEnabled = boolValue
case "LinuxDOOAuthEnabled":
common.LinuxDOOAuthEnabled = boolValue
case "WeChatAuthEnabled":
common.WeChatAuthEnabled = boolValue
case "TelegramOAuthEnabled":
common.TelegramOAuthEnabled = boolValue
case "TurnstileCheckEnabled":
common.TurnstileCheckEnabled = boolValue
case "RegisterEnabled":
common.RegisterEnabled = boolValue
case "EmailDomainRestrictionEnabled":
common.EmailDomainRestrictionEnabled = boolValue
case "EmailAliasRestrictionEnabled":
common.EmailAliasRestrictionEnabled = boolValue
case "AutomaticDisableChannelEnabled":
common.AutomaticDisableChannelEnabled = boolValue
case "AutomaticEnableChannelEnabled":
common.AutomaticEnableChannelEnabled = boolValue
case "LogConsumeEnabled":
common.LogConsumeEnabled = boolValue
case "DisplayInCurrencyEnabled":
// 兼容旧字段:同步到新配置 general_setting.quota_display_type(运行时生效)
// true -> USD, false -> TOKENS
newVal := "USD"
if !boolValue {
newVal = "TOKENS"
}
if cfg := config.GlobalConfig.Get("general_setting"); cfg != nil {
_ = config.UpdateConfigFromMap(cfg, map[string]string{"quota_display_type": newVal})
}
case "DisplayTokenStatEnabled":
common.DisplayTokenStatEnabled = boolValue
case "DrawingEnabled":
common.DrawingEnabled = boolValue
case "TaskEnabled":
common.TaskEnabled = boolValue
case "TaskPluginEnabled":
constant.TaskPluginEnabled = boolValue
jsplugin.DefaultRegistry.SetEnabled(boolValue)
case "DataExportEnabled":
common.DataExportEnabled = boolValue
case "DefaultCollapseSidebar":
common.DefaultCollapseSidebar = boolValue
case "MjNotifyEnabled":
setting.MjNotifyEnabled = boolValue
case "MjAccountFilterEnabled":
setting.MjAccountFilterEnabled = boolValue
case "MjModeClearEnabled":
setting.MjModeClearEnabled = boolValue
case "MjForwardUrlEnabled":
setting.MjForwardUrlEnabled = boolValue
case "MjActionCheckSuccessEnabled":
setting.MjActionCheckSuccessEnabled = boolValue
case "CheckSensitiveEnabled":
setting.CheckSensitiveEnabled = boolValue
case "DemoSiteEnabled":
operation_setting.DemoSiteEnabled = boolValue
case "SelfUseModeEnabled":
operation_setting.SelfUseModeEnabled = boolValue
case "CheckSensitiveOnPromptEnabled":
setting.CheckSensitiveOnPromptEnabled = boolValue
case "ModelRequestRateLimitEnabled":
setting.ModelRequestRateLimitEnabled = boolValue
case "StopOnSensitiveEnabled":
setting.StopOnSensitiveEnabled = boolValue
case "SMTPSSLEnabled":
common.SMTPSSLEnabled = boolValue
case "SMTPStartTLSEnabled":
common.SMTPStartTLSEnabled = boolValue
case "SMTPInsecureSkipVerify":
common.SMTPInsecureSkipVerify = boolValue
case "SMTPForceAuthLogin":
common.SMTPForceAuthLogin = boolValue
case "WorkerAllowHttpImageRequestEnabled":
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
setting.DefaultUseAutoGroup = boolValue
case "ExposeRatioEnabled":
ratio_setting.SetExposeRatioEnabled(boolValue)
}
}
if key == setting.TaskPluginDisabledFactoryKeysKey {
jsplugin.DefaultRegistry.SetDisabledFactoryKeys(setting.ParseTaskPluginDisabledFactoryKeys(value))
}
switch key {
case "EmailDomainWhitelist":
common.EmailDomainWhitelist = strings.Split(value, ",")
case "SMTPServer":
common.SMTPServer = value
case "SMTPPort":
intValue, _ := strconv.Atoi(value)
common.SMTPPort = intValue
case "SMTPAccount":
common.SMTPAccount = value
case "SMTPFrom":
common.SMTPFrom = value
case "SMTPToken":
common.SMTPToken = value
case "ServerAddress":
system_setting.ServerAddress = value
case "TaskPublicAddress":
system_setting.TaskPublicAddress = value
case "WorkerUrl":
system_setting.WorkerUrl = value
case "WorkerValidKey":
system_setting.WorkerValidKey = value
case "PayAddress":
operation_setting.PayAddress = value
case "Chats":
err = setting.UpdateChatsByJsonString(value)
case "AutoGroups":
err = setting.UpdateAutoGroupsByJsonString(value)
case "MaxTokenAutoGroups":
err = setting.UpdateMaxTokenAutoGroups(value)
case "CustomCallbackAddress":
operation_setting.CustomCallbackAddress = value
case "EpayId":
operation_setting.EpayId = value
case "EpayKey":
operation_setting.EpayKey = value
case "Price":
operation_setting.Price, _ = strconv.ParseFloat(value, 64)
case "USDExchangeRate":
operation_setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
case "MinTopUp":
operation_setting.MinTopUp, _ = strconv.Atoi(value)
case "StripeApiSecret":
setting.StripeApiSecret = value
case "StripeWebhookSecret":
setting.StripeWebhookSecret = value
case "StripePriceId":
setting.StripePriceId = value
case "StripeUnitPrice":
setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64)
case "StripeMinTopUp":
setting.StripeMinTopUp, _ = strconv.Atoi(value)
case "StripePromotionCodesEnabled":
setting.StripePromotionCodesEnabled = value == "true"
case "CreemApiKey":
setting.CreemApiKey = value
case "CreemProducts":
setting.CreemProducts = value
case "CreemTestMode":
setting.CreemTestMode = value == "true"
case "CreemWebhookSecret":
setting.CreemWebhookSecret = value
case "WaffoEnabled":
setting.WaffoEnabled = value == "true"
case "WaffoApiKey":
setting.WaffoApiKey = value
case "WaffoPrivateKey":
setting.WaffoPrivateKey = value
case "WaffoPublicCert":
setting.WaffoPublicCert = value
case "WaffoSandboxPublicCert":
setting.WaffoSandboxPublicCert = value
case "WaffoSandboxApiKey":
setting.WaffoSandboxApiKey = value
case "WaffoSandboxPrivateKey":
setting.WaffoSandboxPrivateKey = value
case "WaffoSandbox":
setting.WaffoSandbox = value == "true"
case "WaffoMerchantId":
setting.WaffoMerchantId = value
case "WaffoNotifyUrl":
setting.WaffoNotifyUrl = value
case "WaffoReturnUrl":
setting.WaffoReturnUrl = value
case "WaffoSubscriptionReturnUrl":
setting.WaffoSubscriptionReturnUrl = value
case "WaffoCurrency":
setting.WaffoCurrency = value
case "WaffoUnitPrice":
setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoMinTopUp":
setting.WaffoMinTopUp, _ = strconv.Atoi(value)
case "WaffoPancakeMerchantID":
setting.WaffoPancakeMerchantID = value
case "WaffoPancakePrivateKey":
setting.WaffoPancakePrivateKey = value
case "WaffoPancakeReturnURL":
setting.WaffoPancakeReturnURL = value
case "WaffoPancakeStoreID":
setting.WaffoPancakeStoreID = value
case "WaffoPancakeProductID":
setting.WaffoPancakeProductID = value
case "WaffoPancakeUnitPrice":
setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoPancakeMinTopUp":
setting.WaffoPancakeMinTopUp, _ = strconv.Atoi(value)
case "TopupGroupRatio":
err = common.UpdateTopupGroupRatioByJSONString(value)
case "GitHubClientId":
common.GitHubClientId = value
case "GitHubClientSecret":
common.GitHubClientSecret = value
case "LinuxDOClientId":
common.LinuxDOClientId = value
case "LinuxDOClientSecret":
common.LinuxDOClientSecret = value
case "LinuxDOMinimumTrustLevel":
common.LinuxDOMinimumTrustLevel, _ = strconv.Atoi(value)
case "Footer":
common.Footer = value
case "SystemName":
common.SystemName = value
case "Logo":
common.Logo = value
case "WeChatServerAddress":
common.WeChatServerAddress = value
case "WeChatServerToken":
common.WeChatServerToken = value
case "WeChatAccountQRCodeImageURL":
common.WeChatAccountQRCodeImageURL = value
case "TelegramBotToken":
common.TelegramBotToken = value
case "TelegramBotName":
common.TelegramBotName = value
case "TurnstileSiteKey":
common.TurnstileSiteKey = value
case "TurnstileSecretKey":
common.TurnstileSecretKey = value
case "QuotaForNewUser":
common.QuotaForNewUser, _ = strconv.Atoi(value)
case "QuotaForInviter":
common.QuotaForInviter, _ = strconv.Atoi(value)
case "QuotaForInvitee":
common.QuotaForInvitee, _ = strconv.Atoi(value)
case "QuotaRemindThreshold":
common.QuotaRemindThreshold, _ = strconv.Atoi(value)
case "PreConsumedQuota":
common.PreConsumedQuota, _ = strconv.Atoi(value)
case "ModelRequestRateLimitCount":
setting.ModelRequestRateLimitCount, _ = strconv.Atoi(value)
case "ModelRequestRateLimitDurationMinutes":
setting.ModelRequestRateLimitDurationMinutes, _ = strconv.Atoi(value)
case "ModelRequestRateLimitSuccessCount":
setting.ModelRequestRateLimitSuccessCount, _ = strconv.Atoi(value)
case "ModelRequestRateLimitGroup":
err = setting.UpdateModelRequestRateLimitGroupByJSONString(value)
case "RetryTimes":
common.RetryTimes, _ = strconv.Atoi(value)
case "DataExportInterval":
common.DataExportInterval, _ = strconv.Atoi(value)
case "DataExportDefaultTime":
common.DataExportDefaultTime = value
case "ModelRatio":
err = ratio_setting.UpdateModelRatioByJSONString(value)
case "GroupRatio":
err = ratio_setting.UpdateGroupRatioByJSONString(value)
case "GroupGroupRatio":
err = ratio_setting.UpdateGroupGroupRatioByJSONString(value)
case "UserUsableGroups":
err = setting.UpdateUserUsableGroupsByJSONString(value)
case "CompletionRatio":
err = ratio_setting.UpdateCompletionRatioByJSONString(value)
case "ModelPrice":
err = ratio_setting.UpdateModelPriceByJSONString(value)
case "CacheRatio":
err = ratio_setting.UpdateCacheRatioByJSONString(value)
case "CreateCacheRatio":
err = ratio_setting.UpdateCreateCacheRatioByJSONString(value)
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(value)
case "AudioRatio":
err = ratio_setting.UpdateAudioRatioByJSONString(value)
case "AudioCompletionRatio":
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value)
case "TopUpLink":
common.TopUpLink = value
//case "ChatLink":
// common.ChatLink = value
//case "ChatLink2":
// common.ChatLink2 = value
case "ChannelDisableThreshold":
common.ChannelDisableThreshold, _ = strconv.ParseFloat(value, 64)
case "QuotaPerUnit":
common.QuotaPerUnit, _ = strconv.ParseFloat(value, 64)
case "SensitiveWords":
setting.SensitiveWordsFromString(value)
case "AutomaticDisableKeywords":
operation_setting.AutomaticDisableKeywordsFromString(value)
case "AutomaticDisableStatusCodes":
err = operation_setting.AutomaticDisableStatusCodesFromString(value)
case "AutomaticRetryStatusCodes":
err = operation_setting.AutomaticRetryStatusCodesFromString(value)
case "StreamCacheQueueLength":
setting.StreamCacheQueueLength, _ = strconv.Atoi(value)
case "PayMethods":
err = operation_setting.UpdatePayMethodsByJsonString(value)
case "WaffoPayMethods":
// WaffoPayMethods is read directly from OptionMap via setting.GetWaffoPayMethods().
// The value is already stored in OptionMap at the top of this function (line: common.OptionMap[key] = value).
// No additional in-memory variable to update.
}
return err
}
// handleConfigUpdate 处理分层配置更新,返回是否已处理
func handleConfigUpdate(key, value string) bool {
if key == operation_setting.ToolPriceOptionKey {
operation_setting.LoadToolPricesFromJSONString(value)
return true
}
parts := strings.SplitN(key, ".", 2)
if len(parts) != 2 {
return false // 不是分层配置
}
configName := parts[0]
configKey := parts[1]
// 获取配置对象
cfg := config.GlobalConfig.Get(configName)
if cfg == nil {
return false // 未注册的配置
}
// 更新配置
configMap := map[string]string{
configKey: value,
}
config.UpdateConfigFromMap(cfg, configMap)
// 特定配置的后处理
if configName == "performance_setting" {
performance_setting.UpdateAndSync()
} else if configName == "billing_setting" {
InvalidatePricingCache()
ratio_setting.InvalidateExposedDataCache()
}
return true // 已处理
}
+17
View File
@@ -0,0 +1,17 @@
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateOptionValueRejectsInvalidMaxTokenAutoGroups(t *testing.T) {
for _, value := range []string{"", "0", "-1", "1.5", "invalid"} {
t.Run(value, func(t *testing.T) {
assert.Error(t, validateOptionValue("MaxTokenAutoGroups", value))
})
}
require.NoError(t, validateOptionValue("MaxTokenAutoGroups", "999999"))
}
+76
View File
@@ -0,0 +1,76 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskPluginEnabledOptionUpdatesRegistry(t *testing.T) {
originalEnabled := constant.TaskPluginEnabled
originalMap := common.OptionMap
common.OptionMap = map[string]string{}
const key = "option-master-off"
source := `
export const meta = {apiVersion: 1, key: "option-master-off", name: "Option Master", version: "1.0.0", author: {name: "Test"}, models: ["option-master-model"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() {
constant.TaskPluginEnabled = originalEnabled
jsplugin.DefaultRegistry.SetEnabled(originalEnabled)
common.OptionMap = originalMap
})
_, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
require.NoError(t, updateOptionMap("TaskPluginEnabled", "false"))
assert.False(t, constant.TaskPluginEnabled)
assert.Equal(t, "false", common.OptionMap["TaskPluginEnabled"])
_, ok = jsplugin.DefaultRegistry.Get(key)
assert.False(t, ok)
require.NoError(t, updateOptionMap("TaskPluginEnabled", "true"))
_, ok = jsplugin.DefaultRegistry.Get(key)
assert.True(t, ok)
}
func TestTaskPluginDisabledFactoryKeysOptionUpdatesRegistry(t *testing.T) {
originalMap := common.OptionMap
common.OptionMap = map[string]string{}
const key = "option-factory-off"
source := `
export const meta = {apiVersion: 1, key: "option-factory-off", name: "Option Factory", version: "1.0.0", author: {name: "Test"}, models: ["option-factory-model"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() {
jsplugin.DefaultRegistry.SetDisabledFactoryKeys(nil)
common.OptionMap = originalMap
})
_, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
require.NoError(t, updateOptionMap(setting.TaskPluginDisabledFactoryKeysKey, `["option-factory-off"]`))
assert.Equal(t, `["option-factory-off"]`, common.OptionMap[setting.TaskPluginDisabledFactoryKeysKey])
_, ok = jsplugin.DefaultRegistry.Get(key)
assert.False(t, ok)
assert.Equal(t, []string{key}, jsplugin.DefaultRegistry.Snapshot().DisabledFactory)
}
+274
View File
@@ -0,0 +1,274 @@
package model
import (
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm"
)
var (
ErrPasskeyNotFound = errors.New("passkey credential not found")
ErrFriendlyPasskeyNotFound = errors.New("Passkey 验证失败,请重试或联系管理员")
)
type PasskeyCredential struct {
ID int `json:"id" gorm:"primaryKey"`
UserID int `json:"user_id" gorm:"uniqueIndex;not null"`
CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded
PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded
AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"`
AAGUID string `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded
SignCount uint32 `json:"sign_count" gorm:"default:0"`
CloneWarning bool `json:"clone_warning"`
UserPresent bool `json:"user_present"`
UserVerified bool `json:"user_verified"`
BackupEligible bool `json:"backup_eligible"`
BackupState bool `json:"backup_state"`
Transports string `json:"transports" gorm:"type:text"`
Attachment string `json:"attachment" gorm:"type:varchar(32)"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport {
if p == nil || strings.TrimSpace(p.Transports) == "" {
return nil
}
var transports []string
if err := common.Unmarshal([]byte(p.Transports), &transports); err != nil {
return nil
}
result := make([]protocol.AuthenticatorTransport, 0, len(transports))
for _, transport := range transports {
result = append(result, protocol.AuthenticatorTransport(transport))
}
return result
}
func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport) {
if len(list) == 0 {
p.Transports = ""
return
}
stringList := make([]string, len(list))
for i, transport := range list {
stringList[i] = string(transport)
}
encoded, err := common.Marshal(stringList)
if err != nil {
return
}
p.Transports = string(encoded)
}
func (p *PasskeyCredential) ToWebAuthnCredential() webauthn.Credential {
flags := webauthn.CredentialFlags{
UserPresent: p.UserPresent,
UserVerified: p.UserVerified,
BackupEligible: p.BackupEligible,
BackupState: p.BackupState,
}
credID, _ := base64.StdEncoding.DecodeString(p.CredentialID)
pubKey, _ := base64.StdEncoding.DecodeString(p.PublicKey)
aaguid, _ := base64.StdEncoding.DecodeString(p.AAGUID)
return webauthn.Credential{
ID: credID,
PublicKey: pubKey,
AttestationType: p.AttestationType,
Transport: p.TransportList(),
Flags: flags,
Authenticator: webauthn.Authenticator{
AAGUID: aaguid,
SignCount: p.SignCount,
CloneWarning: p.CloneWarning,
Attachment: protocol.AuthenticatorAttachment(p.Attachment),
},
}
}
func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credential) *PasskeyCredential {
if credential == nil {
return nil
}
passkey := &PasskeyCredential{
UserID: userID,
CredentialID: base64.StdEncoding.EncodeToString(credential.ID),
PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey),
AttestationType: credential.AttestationType,
AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID),
SignCount: credential.Authenticator.SignCount,
CloneWarning: credential.Authenticator.CloneWarning,
UserPresent: credential.Flags.UserPresent,
UserVerified: credential.Flags.UserVerified,
BackupEligible: credential.Flags.BackupEligible,
BackupState: credential.Flags.BackupState,
Attachment: string(credential.Authenticator.Attachment),
}
passkey.SetTransports(credential.Transport)
return passkey
}
func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) {
if userID == 0 {
common.SysLog("GetPasskeyByUserID: empty user ID")
return nil, ErrFriendlyPasskeyNotFound
}
var credential PasskeyCredential
if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志
return nil, ErrPasskeyNotFound
}
// 只有真正的数据库错误才记录日志
common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err))
return nil, ErrFriendlyPasskeyNotFound
}
return &credential, nil
}
func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) {
if len(credentialID) == 0 {
common.SysLog("GetPasskeyByCredentialID: empty credential ID")
return nil, ErrFriendlyPasskeyNotFound
}
credIDStr := base64.StdEncoding.EncodeToString(credentialID)
var credential PasskeyCredential
if err := DB.Where("credential_id = ?", credIDStr).First(&credential).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: passkey not found for credential ID length %d", len(credentialID)))
return nil, ErrFriendlyPasskeyNotFound
}
common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: database error for credential ID: %v", err))
return nil, ErrFriendlyPasskeyNotFound
}
return &credential, nil
}
// UpdatePasskeyAssertionState persists only fields produced by a successful
// assertion. Registration identity (credential ID, public key, AAGUID,
// transports and attestation metadata) is immutable on this path.
func UpdatePasskeyAssertionState(userID int, credential *webauthn.Credential, lastUsedAt time.Time) error {
if userID <= 0 || credential == nil || len(credential.ID) == 0 || lastUsedAt.IsZero() {
return fmt.Errorf("Passkey 保存失败,请重试")
}
credentialID := base64.StdEncoding.EncodeToString(credential.ID)
result := DB.Model(&PasskeyCredential{}).
Where("user_id = ? AND credential_id = ?", userID, credentialID).
Updates(map[string]any{
"sign_count": credential.Authenticator.SignCount,
"clone_warning": credential.Authenticator.CloneWarning,
"user_present": credential.Flags.UserPresent,
"user_verified": credential.Flags.UserVerified,
"backup_eligible": credential.Flags.BackupEligible,
"backup_state": credential.Flags.BackupState,
"last_used_at": lastUsedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrPasskeyNotFound
}
return nil
}
func upsertPasskeyCredentialWithTx(tx *gorm.DB, credential *PasskeyCredential) error {
if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
if err := tx.Create(credential).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
return nil
}
// UpsertPasskeyCredentialWithAuthVersion is reserved for enrollment changes;
// assertion sign-count updates must use UpdatePasskeyAssertionState.
func UpsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential) error {
return upsertPasskeyCredentialWithAuthVersion(credential, nil)
}
func RegisterPasskeyForSession(identity AuthSessionIdentity, credential *PasskeyCredential) error {
return upsertPasskeyCredentialWithAuthVersion(credential, &identity)
}
func upsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential, identity *AuthSessionIdentity) error {
if credential == nil || credential.UserID <= 0 {
return fmt.Errorf("Passkey 保存失败,请重试")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil {
if identity.UserID != credential.UserID {
return ErrUserSessionInactive
}
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
return err
}
}
if _, err := IncrementUserAuthVersionWithTx(tx, credential.UserID); err != nil {
return err
}
return upsertPasskeyCredentialWithTx(tx, credential)
}); err != nil {
return err
}
return PublishUserAuthCache(credential.UserID)
}
func DeletePasskeyByUserIDWithAuthVersion(userID int) error {
return deletePasskeyWithAuthVersion(userID, nil)
}
func DeletePasskeyForSession(identity AuthSessionIdentity) error {
return deletePasskeyWithAuthVersion(identity.UserID, &identity)
}
func deletePasskeyWithAuthVersion(userID int, identity *AuthSessionIdentity) error {
if userID == 0 {
return fmt.Errorf("删除失败,请重试")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil {
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
return err
}
}
var credential PasskeyCredential
if err := lockForUpdate(tx).Where("user_id = ?", userID).First(&credential).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPasskeyNotFound
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, userID); err != nil {
return err
}
result := tx.Unscoped().Delete(&credential)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrPasskeyNotFound
}
return nil
}); err != nil {
return err
}
return PublishUserAuthCache(userID)
}
+60
View File
@@ -0,0 +1,60 @@
package model
import (
"errors"
"fmt"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const activeLoginEncryptionKeySlot = "active"
// LoginEncryptionKey stores internal key material used by the browser login
// protocol. It is deliberately separate from administrator-facing options.
type LoginEncryptionKey struct {
ID uint `json:"-" gorm:"primaryKey"`
Slot string `json:"-" gorm:"type:varchar(32);not null;uniqueIndex"`
PrivateKeyPEM string `json:"-" gorm:"type:text;not null"`
}
// InitPasswordEncryption loads the shared login-encryption key from its
// dedicated store. Concurrent replicas converge through the unique slot.
func InitPasswordEncryption() error {
var stored LoginEncryptionKey
queryErr := DB.Where("slot = ?", activeLoginEncryptionKeySlot).First(&stored).Error
if queryErr == nil {
if err := common.LoadPasswordEncryptionPrivateKey(stored.PrivateKeyPEM); err != nil {
return fmt.Errorf("load persisted password encryption key: %w", err)
}
return nil
}
if !errors.Is(queryErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read password encryption key: %w", queryErr)
}
privateKeyPEM, err := common.GeneratePasswordEncryptionPrivateKey()
if err != nil {
return err
}
candidate := LoginEncryptionKey{
Slot: activeLoginEncryptionKeySlot,
PrivateKeyPEM: privateKeyPEM,
}
if err := DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "slot"}},
DoNothing: true,
}).Create(&candidate).Error; err != nil {
return fmt.Errorf("persist password encryption key: %w", err)
}
if err := DB.Where("slot = ?", activeLoginEncryptionKeySlot).First(&stored).Error; err != nil {
return fmt.Errorf("reload password encryption key: %w", err)
}
if err := common.LoadPasswordEncryptionPrivateKey(stored.PrivateKeyPEM); err != nil {
return fmt.Errorf("load persisted password encryption key: %w", err)
}
return nil
}
+355
View File
@@ -0,0 +1,355 @@
package model
import (
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func insertUserForPaymentGuardTest(t *testing.T, id int, quota int) *User {
t.Helper()
user := &User{
Id: id,
Username: "payment_guard_user",
Status: common.UserStatusEnabled,
Quota: quota,
}
require.NoError(t, DB.Create(user).Error)
return user
}
func insertSubscriptionPlanForPaymentGuardTest(t *testing.T, id int) *SubscriptionPlan {
t.Helper()
plan := &SubscriptionPlan{
Id: id,
Title: "Guard Plan",
PriceAmount: 9.99,
Currency: "USD",
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
Enabled: true,
TotalAmount: 1000,
}
require.NoError(t, DB.Create(plan).Error)
return plan
}
func insertSubscriptionOrderForPaymentGuardTest(t *testing.T, tradeNo string, userID int, planID int, paymentProvider string) {
t.Helper()
order := &SubscriptionOrder{
UserId: userID,
PlanId: planID,
Money: 9.99,
TradeNo: tradeNo,
PaymentMethod: paymentProvider,
PaymentProvider: paymentProvider,
Status: common.TopUpStatusPending,
CreateTime: time.Now().Unix(),
}
require.NoError(t, order.Insert())
}
func insertTopUpForPaymentGuardTest(t *testing.T, tradeNo string, userID int, paymentProvider string) {
t.Helper()
topUp := &TopUp{
UserId: userID,
Amount: 2,
Money: 9.99,
TradeNo: tradeNo,
PaymentMethod: paymentProvider,
PaymentProvider: paymentProvider,
Status: common.TopUpStatusPending,
CreateTime: time.Now().Unix(),
}
require.NoError(t, topUp.Insert())
}
func getTopUpStatusForPaymentGuardTest(t *testing.T, tradeNo string) string {
t.Helper()
topUp := GetTopUpByTradeNo(tradeNo)
require.NotNil(t, topUp)
return topUp.Status
}
func countUserSubscriptionsForPaymentGuardTest(t *testing.T, userID int) int64 {
t.Helper()
var count int64
require.NoError(t, DB.Model(&UserSubscription{}).Where("user_id = ?", userID).Count(&count).Error)
return count
}
func getUserQuotaForPaymentGuardTest(t *testing.T, userID int) int {
t.Helper()
var user User
require.NoError(t, DB.Select("quota").Where("id = ?", userID).First(&user).Error)
return user.Quota
}
func TestRechargeWaffoPancake_RejectsMismatchedPaymentMethod(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 101, 0)
insertTopUpForPaymentGuardTest(t, "waffo-pancake-guard", 101, PaymentProviderStripe)
err := RechargeWaffoPancake("waffo-pancake-guard")
require.Error(t, err)
topUp := GetTopUpByTradeNo("waffo-pancake-guard")
require.NotNil(t, topUp)
assert.Equal(t, common.TopUpStatusPending, topUp.Status)
assert.Equal(t, 0, getUserQuotaForPaymentGuardTest(t, 101))
}
func TestUpdatePendingTopUpStatus_RejectsMismatchedPaymentProvider(t *testing.T) {
testCases := []struct {
name string
tradeNo string
storedPaymentProvider string
expectedPaymentProvider string
targetStatus string
}{
{
name: "stripe expire",
tradeNo: "stripe-expire-guard",
storedPaymentProvider: PaymentProviderCreem,
expectedPaymentProvider: PaymentProviderStripe,
targetStatus: common.TopUpStatusExpired,
},
{
name: "waffo failed",
tradeNo: "waffo-failed-guard",
storedPaymentProvider: PaymentProviderStripe,
expectedPaymentProvider: PaymentProviderWaffo,
targetStatus: common.TopUpStatusFailed,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 150, 0)
insertTopUpForPaymentGuardTest(t, tc.tradeNo, 150, tc.storedPaymentProvider)
err := UpdatePendingTopUpStatus(tc.tradeNo, tc.expectedPaymentProvider, tc.targetStatus)
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
assert.Equal(t, common.TopUpStatusPending, getTopUpStatusForPaymentGuardTest(t, tc.tradeNo))
})
}
}
func TestCompleteSubscriptionOrder_RejectsMismatchedPaymentProvider(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 202, 0)
plan := insertSubscriptionPlanForPaymentGuardTest(t, 301)
insertSubscriptionOrderForPaymentGuardTest(t, "sub-guard-order", 202, plan.Id, PaymentProviderStripe)
err := CompleteSubscriptionOrder("sub-guard-order", `{"provider":"epay"}`, PaymentProviderEpay, "alipay")
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
order := GetSubscriptionOrderByTradeNo("sub-guard-order")
require.NotNil(t, order)
assert.Equal(t, common.TopUpStatusPending, order.Status)
assert.Zero(t, countUserSubscriptionsForPaymentGuardTest(t, 202))
topUp := GetTopUpByTradeNo("sub-guard-order")
assert.Nil(t, topUp)
}
func TestExpireSubscriptionOrder_RejectsMismatchedPaymentProvider(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 303, 0)
plan := insertSubscriptionPlanForPaymentGuardTest(t, 401)
insertSubscriptionOrderForPaymentGuardTest(t, "sub-expire-guard", 303, plan.Id, PaymentProviderStripe)
err := ExpireSubscriptionOrder("sub-expire-guard", PaymentProviderCreem)
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
order := GetSubscriptionOrderByTradeNo("sub-expire-guard")
require.NotNil(t, order)
assert.Equal(t, common.TopUpStatusPending, order.Status)
}
func createEpayTestOrder(t *testing.T, userId int, tradeNo string, provider string, status string) TopUp {
t.Helper()
topUp := TopUp{
UserId: userId,
Amount: 2,
Money: 10.0,
TradeNo: tradeNo,
PaymentMethod: "alipay",
PaymentProvider: provider,
CreateTime: common.GetTimestamp(),
Status: status,
}
require.NoError(t, DB.Create(&topUp).Error)
return topUp
}
func TestRechargeEpayCreditsQuotaExactlyOnce(t *testing.T) {
truncateTables(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 500000
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 501, 0)
order := createEpayTestOrder(t, user.Id, "EPAYTESTONCE", PaymentProviderEpay, common.TopUpStatusPending)
alreadyDone, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
require.NoError(t, err)
assert.False(t, alreadyDone)
assert.Equal(t, 2*500000, getUserQuotaForPaymentGuardTest(t, user.Id))
reloaded := GetTopUpByTradeNo(order.TradeNo)
require.NotNil(t, reloaded)
assert.Equal(t, common.TopUpStatusSuccess, reloaded.Status)
assert.NotZero(t, reloaded.CompleteTime)
alreadyDone, err = RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
require.NoError(t, err)
assert.True(t, alreadyDone)
assert.Equal(t, 2*500000, getUserQuotaForPaymentGuardTest(t, user.Id))
}
func TestRechargeEpayKeepsRedisAndDatabaseCreditInSync(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 5
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 502, 7)
require.NoError(t, populateUserCache(*user))
order := createEpayTestOrder(t, user.Id, "EPAYTESTREDISSYNC", PaymentProviderEpay, common.TopUpStatusPending)
alreadyDone, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
require.NoError(t, err)
assert.False(t, alreadyDone)
assert.Equal(t, 17, getUserQuotaForPaymentGuardTest(t, user.Id))
cached, err := cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, 17, cached.Quota)
alreadyDone, err = RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
require.NoError(t, err)
assert.True(t, alreadyDone)
cached, err = cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, 17, cached.Quota)
}
func TestRechargeEpayUpdatesPaymentMethodToActual(t *testing.T) {
truncateTables(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 500000
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 503, 0)
order := createEpayTestOrder(t, user.Id, "EPAYTESTMETHOD", PaymentProviderEpay, common.TopUpStatusPending)
alreadyDone, err := RechargeEpay(order.TradeNo, "wxpay", "127.0.0.1")
require.NoError(t, err)
assert.False(t, alreadyDone)
reloaded := GetTopUpByTradeNo(order.TradeNo)
require.NotNil(t, reloaded)
assert.Equal(t, "wxpay", reloaded.PaymentMethod)
assert.Equal(t, 2*500000, getUserQuotaForPaymentGuardTest(t, user.Id))
}
func TestRechargeEpayRejectsForeignAndNonPendingOrders(t *testing.T) {
truncateTables(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 500000
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 504, 7)
t.Run("order from another payment provider", func(t *testing.T) {
order := createEpayTestOrder(t, user.Id, "EPAYTESTSTRIPE", PaymentProviderStripe, common.TopUpStatusPending)
_, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
assert.ErrorIs(t, err, ErrPaymentMethodMismatch)
assert.Equal(t, 7, getUserQuotaForPaymentGuardTest(t, user.Id))
})
t.Run("order that is not pending", func(t *testing.T) {
order := createEpayTestOrder(t, user.Id, "EPAYTESTEXPIRED", PaymentProviderEpay, common.TopUpStatusExpired)
_, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
assert.ErrorIs(t, err, ErrTopUpStatusInvalid)
assert.Equal(t, 7, getUserQuotaForPaymentGuardTest(t, user.Id))
})
t.Run("missing order", func(t *testing.T) {
_, err := RechargeEpay("EPAYTESTMISSING", "alipay", "127.0.0.1")
assert.ErrorIs(t, err, ErrTopUpNotFound)
})
}
func TestRechargeEpayRejectsQuotaOverflowBeforeCompletingOrder(t *testing.T) {
truncateTables(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = float64(common.MaxWalletQuota + 1)
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 505, 3)
order := createEpayTestOrder(t, user.Id, "EPAYTESTOVERFLOW", PaymentProviderEpay, common.TopUpStatusPending)
_, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
require.Error(t, err)
assert.Equal(t, 3, getUserQuotaForPaymentGuardTest(t, user.Id))
assert.Equal(t, common.TopUpStatusPending, getTopUpStatusForPaymentGuardTest(t, order.TradeNo))
}
func TestRechargeEpayEnforcesFinalWalletQuotaLimit(t *testing.T) {
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 500000
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
testCases := []struct {
name string
currentQuota int
wantErr bool
wantQuota int
wantStatus string
}{
{
name: "allows exact highest representable wallet balance",
currentQuota: common.MaxWalletQuota - 1_000_000,
wantQuota: common.MaxWalletQuota,
wantStatus: common.TopUpStatusSuccess,
},
{
name: "rejects balance above wallet quota domain",
currentQuota: common.MaxWalletQuota - 999_999,
wantErr: true,
wantQuota: common.MaxWalletQuota - 999_999,
wantStatus: common.TopUpStatusPending,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
truncateTables(t)
user := insertUserForPaymentGuardTest(t, 506, tc.currentQuota)
order := createEpayTestOrder(t, user.Id, "EPAYTESTWALLETLIMIT", PaymentProviderEpay, common.TopUpStatusPending)
_, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
if tc.wantErr {
require.ErrorIs(t, err, ErrTopUpQuotaLimitExceeded)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.wantQuota, getUserQuotaForPaymentGuardTest(t, user.Id))
assert.Equal(t, tc.wantStatus, getTopUpStatusForPaymentGuardTest(t, order.TradeNo))
})
}
}
+130
View File
@@ -0,0 +1,130 @@
package model
import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PerfMetric stores aggregated relay performance metrics for the model square.
type PerfMetric struct {
Id int `json:"id" gorm:"primaryKey"`
ModelName string `json:"model_name" gorm:"size:128;uniqueIndex:idx_perf_model_group_bucket,priority:1"`
Group string `json:"group" gorm:"column:group;size:64;uniqueIndex:idx_perf_model_group_bucket,priority:2"`
BucketTs int64 `json:"bucket_ts" gorm:"uniqueIndex:idx_perf_model_group_bucket,priority:3;index:idx_perf_bucket_ts"`
RequestCount int64 `json:"-" gorm:"default:0"`
SuccessCount int64 `json:"-" gorm:"default:0"`
TotalLatencyMs int64 `json:"-" gorm:"default:0"`
TtftSumMs int64 `json:"-" gorm:"default:0"`
TtftCount int64 `json:"-" gorm:"default:0"`
OutputTokens int64 `json:"-" gorm:"default:0"`
GenerationMs int64 `json:"-" gorm:"default:0"`
}
func (PerfMetric) TableName() string {
return "perf_metrics"
}
func UpsertPerfMetric(metric *PerfMetric) error {
if metric == nil || metric.RequestCount == 0 {
return nil
}
return DB.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "model_name"},
{Name: "group"},
{Name: "bucket_ts"},
},
DoUpdates: clause.Assignments(map[string]any{
"request_count": gorm.Expr("perf_metrics.request_count + ?", metric.RequestCount),
"success_count": gorm.Expr("perf_metrics.success_count + ?", metric.SuccessCount),
"total_latency_ms": gorm.Expr("perf_metrics.total_latency_ms + ?", metric.TotalLatencyMs),
"ttft_sum_ms": gorm.Expr("perf_metrics.ttft_sum_ms + ?", metric.TtftSumMs),
"ttft_count": gorm.Expr("perf_metrics.ttft_count + ?", metric.TtftCount),
"output_tokens": gorm.Expr("perf_metrics.output_tokens + ?", metric.OutputTokens),
"generation_ms": gorm.Expr("perf_metrics.generation_ms + ?", metric.GenerationMs),
}),
}).Create(metric).Error
}
func GetPerfMetrics(modelName string, group string, startTs int64, endTs int64) ([]PerfMetric, error) {
var metrics []PerfMetric
query := DB.Model(&PerfMetric{}).
Where("model_name = ? AND bucket_ts >= ? AND bucket_ts <= ?", modelName, startTs, endTs)
if group != "" {
query = query.Where(commonGroupCol+" = ?", group)
}
err := query.Order("bucket_ts ASC").Find(&metrics).Error
return metrics, err
}
type PerfMetricSummary struct {
ModelName string `json:"model_name"`
RequestCount int64 `json:"request_count"`
SuccessCount int64 `json:"success_count"`
TotalLatencyMs int64 `json:"total_latency_ms"`
OutputTokens int64 `json:"output_tokens"`
GenerationMs int64 `json:"generation_ms"`
}
type PerfMetricSummaryBucket struct {
ModelName string `json:"model_name"`
BucketTs int64 `json:"bucket_ts"`
RequestCount int64 `json:"request_count"`
SuccessCount int64 `json:"success_count"`
TotalLatencyMs int64 `json:"total_latency_ms"`
OutputTokens int64 `json:"output_tokens"`
GenerationMs int64 `json:"generation_ms"`
}
func GetPerfMetricsSummaryAll(startTs int64, endTs int64, groups []string) ([]PerfMetricSummary, error) {
var summaries []PerfMetricSummary
query := DB.Model(&PerfMetric{}).
Select("model_name, SUM(request_count) as request_count, SUM(success_count) as success_count, SUM(total_latency_ms) as total_latency_ms, SUM(output_tokens) as output_tokens, SUM(generation_ms) as generation_ms").
Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs)
if groups != nil {
if len(groups) == 0 {
return summaries, nil
}
query = query.Where(commonGroupCol+" IN ?", groups)
}
err := query.
Group("model_name").
Having("SUM(request_count) > 0").
Find(&summaries).Error
return summaries, err
}
func GetPerfMetricsSummaryBucketsAll(startTs int64, endTs int64, groups []string) ([]PerfMetricSummaryBucket, error) {
var summaries []PerfMetricSummaryBucket
query := DB.Model(&PerfMetric{}).
Select("model_name, bucket_ts, SUM(request_count) as request_count, SUM(success_count) as success_count, SUM(total_latency_ms) as total_latency_ms, SUM(output_tokens) as output_tokens, SUM(generation_ms) as generation_ms").
Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs)
if groups != nil {
if len(groups) == 0 {
return summaries, nil
}
query = query.Where(commonGroupCol+" IN ?", groups)
}
err := query.
Group("model_name, bucket_ts").
Having("SUM(request_count) > 0").
Order("bucket_ts ASC").
Find(&summaries).Error
return summaries, err
}
func DeletePerfMetricsBefore(cutoffTs int64) error {
if cutoffTs <= 0 {
return nil
}
return DB.Where("bucket_ts < ?", cutoffTs).Delete(&PerfMetric{}).Error
}
func PerfMetricStartTime(hours int) int64 {
if hours <= 0 {
hours = 24
}
return time.Now().Add(-time.Duration(hours) * time.Hour).Unix()
}
+129
View File
@@ -0,0 +1,129 @@
package model
import (
"database/sql/driver"
"encoding/json"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
// PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。
// Name 字段保持唯一,用于在前端下拉框中展示。
// Type 字段用于区分组的类别,可选值如:model、tag、endpoint。
// Items 字段使用 JSON 数组保存对应类型的字符串集合,示例:
// ["gpt-4o", "gpt-3.5-turbo"]
// 设计遵循 3NF,避免冗余,提供灵活扩展能力。
// JSONValue 基于 json.RawMessage 实现,支持从数据库的 []byte 和 string 两种类型读取
type JSONValue json.RawMessage
// Value 实现 driver.Valuer 接口,用于数据库写入
// 必须返回 string 而非 []byte:PG simple protocol 下 []byte 按 bytea 编码,
// 写 json 列会触发 SQLSTATE 22P02。
func (j JSONValue) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
return string(j), nil
}
// Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型
func (j *JSONValue) Scan(value any) error {
switch v := value.(type) {
case nil:
*j = nil
return nil
case []byte:
// 拷贝底层字节,避免保留底层缓冲区
b := make([]byte, len(v))
copy(b, v)
*j = JSONValue(b)
return nil
case string:
*j = JSONValue([]byte(v))
return nil
default:
// 其他类型尝试序列化为 JSON
b, err := json.Marshal(v)
if err != nil {
return err
}
*j = JSONValue(b)
return nil
}
}
// MarshalJSON 确保在对外编码时与 json.RawMessage 行为一致
func (j JSONValue) MarshalJSON() ([]byte, error) {
if j == nil {
return []byte("null"), nil
}
return j, nil
}
// UnmarshalJSON 确保在对外解码时与 json.RawMessage 行为一致
func (j *JSONValue) UnmarshalJSON(data []byte) error {
if data == nil {
*j = nil
return nil
}
b := make([]byte, len(data))
copy(b, data)
*j = JSONValue(b)
return nil
}
type PrefillGroup struct {
Id int `json:"id"`
Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"`
Type string `json:"type" gorm:"size:32;index;not null"`
Items JSONValue `json:"items" gorm:"type:json"`
Description string `json:"description,omitempty" gorm:"type:varchar(255)"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// Insert 新建组
func (g *PrefillGroup) Insert() error {
now := common.GetTimestamp()
g.CreatedTime = now
g.UpdatedTime = now
return DB.Create(g).Error
}
// IsPrefillGroupNameDuplicated 检查组名称是否重复(排除自身 ID)
func IsPrefillGroupNameDuplicated(id int, name string) (bool, error) {
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
}
// Update 更新组
func (g *PrefillGroup) Update() error {
g.UpdatedTime = common.GetTimestamp()
return DB.Save(g).Error
}
// DeleteByID 根据 ID 删除组
func DeletePrefillGroupByID(id int) error {
return DB.Delete(&PrefillGroup{}, id).Error
}
// GetAllPrefillGroups 获取全部组,可按类型过滤(为空则返回全部)
func GetAllPrefillGroups(groupType string) ([]*PrefillGroup, error) {
var groups []*PrefillGroup
query := DB.Model(&PrefillGroup{})
if groupType != "" {
query = query.Where("type = ?", groupType)
}
if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil {
return nil, err
}
return groups, nil
}
+214
View File
@@ -0,0 +1,214 @@
package model
import (
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const prefillGroupNameIndex = "uk_prefill_name"
const legacyPrefillGroupNameUnique = "idx_prefill_groups_name"
type conflictingPrefillGroupUniqueness struct {
constraints []string
indexes []string
}
type prefillGroupNameIndexState struct {
exists bool
valid bool
}
func (conflicts conflictingPrefillGroupUniqueness) empty() bool {
return len(conflicts.constraints) == 0 && len(conflicts.indexes) == 0
}
func (conflicts conflictingPrefillGroupUniqueness) validateAutomaticMigrationScope() error {
unexpectedConstraints := make([]string, 0)
for _, name := range conflicts.constraints {
if name != legacyPrefillGroupNameUnique {
unexpectedConstraints = append(unexpectedConstraints, name)
}
}
unexpectedIndexes := make([]string, 0)
for _, name := range conflicts.indexes {
if name != legacyPrefillGroupNameUnique {
unexpectedIndexes = append(unexpectedIndexes, name)
}
}
if len(unexpectedConstraints) == 0 && len(unexpectedIndexes) == 0 {
return nil
}
return fmt.Errorf(
"prefill_groups.name has unsupported global unique constraints %q and indexes %q; only legacy object %q can be migrated automatically to partial uniqueness",
unexpectedConstraints,
unexpectedIndexes,
legacyPrefillGroupNameUnique,
)
}
func inspectConflictingPrefillGroupUniqueness(db *gorm.DB, tableName string) (conflictingPrefillGroupUniqueness, error) {
var conflicts conflictingPrefillGroupUniqueness
if err := db.Raw(`
SELECT constraint_meta.conname
FROM pg_catalog.pg_constraint AS constraint_meta
WHERE constraint_meta.conrelid = to_regclass(?)
AND constraint_meta.contype = 'u'
AND cardinality(constraint_meta.conkey) = 1
AND EXISTS (
SELECT 1
FROM pg_catalog.pg_attribute AS attribute_meta
WHERE attribute_meta.attrelid = constraint_meta.conrelid
AND attribute_meta.attnum = constraint_meta.conkey[1]
AND attribute_meta.attname = ?
)
ORDER BY constraint_meta.conname`, tableName, "name").Scan(&conflicts.constraints).Error; err != nil {
return conflicts, fmt.Errorf("inspect conflicting prefill group unique constraints: %w", err)
}
if err := db.Raw(`
SELECT index_class.relname
FROM pg_catalog.pg_index AS index_meta
JOIN pg_catalog.pg_class AS index_class
ON index_class.oid = index_meta.indexrelid
JOIN pg_catalog.pg_attribute AS attribute_meta
ON attribute_meta.attrelid = index_meta.indrelid
AND attribute_meta.attnum = index_meta.indkey[0]
WHERE index_meta.indrelid = to_regclass(?)
AND index_meta.indisunique
AND NOT index_meta.indisprimary
AND index_meta.indpred IS NULL
AND index_meta.indexprs IS NULL
AND index_meta.indnatts = 1
AND attribute_meta.attname = ?
AND NOT EXISTS (
SELECT 1
FROM pg_catalog.pg_constraint AS constraint_meta
WHERE constraint_meta.conindid = index_meta.indexrelid
)
ORDER BY index_class.relname`, tableName, "name").Scan(&conflicts.indexes).Error; err != nil {
return conflicts, fmt.Errorf("inspect conflicting prefill group unique indexes: %w", err)
}
return conflicts, nil
}
func inspectPrefillGroupNameIndex(db *gorm.DB, tableName string) (prefillGroupNameIndexState, error) {
var state struct {
Exists bool `gorm:"column:index_exists"`
Valid bool `gorm:"column:index_valid"`
}
if err := db.Raw(`
SELECT count(*) > 0 AS index_exists,
COALESCE(bool_or(
index_meta.indisunique
AND index_meta.indisvalid
AND index_meta.indisready
AND NOT index_meta.indisprimary
AND index_meta.indexprs IS NULL
AND index_meta.indnatts = 1
AND attribute_meta.attname = ?
AND pg_get_expr(index_meta.indpred, index_meta.indrelid) = '(deleted_at IS NULL)'
), false) AS index_valid
FROM pg_catalog.pg_index AS index_meta
JOIN pg_catalog.pg_class AS index_class
ON index_class.oid = index_meta.indexrelid
LEFT JOIN pg_catalog.pg_attribute AS attribute_meta
ON attribute_meta.attrelid = index_meta.indrelid
AND attribute_meta.attnum = index_meta.indkey[0]
WHERE index_meta.indrelid = to_regclass(?)
AND index_class.relname = ?`, "name", tableName, prefillGroupNameIndex).Scan(&state).Error; err != nil {
return prefillGroupNameIndexState{}, fmt.Errorf("inspect prefill group partial unique index: %w", err)
}
return prefillGroupNameIndexState{exists: state.Exists, valid: state.Valid}, nil
}
// migratePrefillGroupUniqueness replaces the known global PostgreSQL unique
// object left by older GORM versions before AutoMigrate inspects the column.
// Unknown conflicting objects are reported without being modified.
func migratePrefillGroupUniqueness(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("migrate prefill group uniqueness: database is nil")
}
if db.Dialector.Name() != "postgres" {
return nil
}
statement := &gorm.Statement{DB: db}
if err := statement.Parse(&PrefillGroup{}); err != nil {
return fmt.Errorf("parse prefill group schema: %w", err)
}
tableName := statement.Schema.Table
conflicts, err := inspectConflictingPrefillGroupUniqueness(db, tableName)
if err != nil {
return err
}
if conflicts.empty() {
return nil
}
if err := conflicts.validateAutomaticMigrationScope(); err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
migrator := tx.Migrator()
if !migrator.HasTable(&PrefillGroup{}) {
return nil
}
if err := tx.Exec(
"LOCK TABLE ? IN ACCESS EXCLUSIVE MODE",
clause.Table{Name: tableName},
).Error; err != nil {
return fmt.Errorf("lock prefill groups for uniqueness migration: %w", err)
}
conflicts, err := inspectConflictingPrefillGroupUniqueness(tx, tableName)
if err != nil {
return err
}
if conflicts.empty() {
return nil
}
if err := conflicts.validateAutomaticMigrationScope(); err != nil {
return err
}
if !migrator.HasColumn(&PrefillGroup{}, "DeletedAt") {
if err := migrator.AddColumn(&PrefillGroup{}, "DeletedAt"); err != nil {
return fmt.Errorf("add prefill groups deleted_at column: %w", err)
}
}
targetIndex, err := inspectPrefillGroupNameIndex(tx, tableName)
if err != nil {
return err
}
if !targetIndex.exists {
if err := migrator.CreateIndex(&PrefillGroup{}, prefillGroupNameIndex); err != nil {
return fmt.Errorf("create prefill group partial unique index: %w", err)
}
targetIndex, err = inspectPrefillGroupNameIndex(tx, tableName)
if err != nil {
return err
}
}
if !targetIndex.valid {
return fmt.Errorf("prefill group index %q has an unexpected definition", prefillGroupNameIndex)
}
for _, constraintName := range conflicts.constraints {
if err := migrator.DropConstraint(&PrefillGroup{}, constraintName); err != nil {
return fmt.Errorf("drop conflicting prefill group constraint %q: %w", constraintName, err)
}
}
for _, indexName := range conflicts.indexes {
if err := migrator.DropIndex(&PrefillGroup{}, indexName); err != nil {
return fmt.Errorf("drop conflicting prefill group index %q: %w", indexName, err)
}
}
return nil
})
}
+303
View File
@@ -0,0 +1,303 @@
package model
import (
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func testPrefillGroupMigrationNonPostgreSQL(t *testing.T, db *gorm.DB) {
t.Helper()
tableName := fmt.Sprintf("prefill_group_migration_%d", time.Now().UnixNano())
t.Cleanup(func() { _ = db.Migrator().DropTable(tableName) })
tableDB := db.Table(tableName)
require.NoError(t, tableDB.AutoMigrate(&PrefillGroup{}))
require.NoError(t, tableDB.Create(&PrefillGroup{
Name: "preserved-name",
Type: "model",
Items: JSONValue(`["gpt-test"]`),
Description: "preserve me",
}).Error)
for range 2 {
require.NoError(t, migratePrefillGroupUniqueness(db))
require.NoError(t, tableDB.AutoMigrate(&PrefillGroup{}))
}
var preserved PrefillGroup
require.NoError(t, tableDB.Where("name = ?", "preserved-name").First(&preserved).Error)
assert.Equal(t, "preserve me", preserved.Description)
assert.True(t, tableDB.Migrator().HasIndex(&PrefillGroup{}, prefillGroupNameIndex))
}
func TestMigratePrefillGroupUniquenessSQLite(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
testPrefillGroupMigrationNonPostgreSQL(t, db)
}
func TestMigratePrefillGroupUniquenessMySQL(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("TEST_MYSQL_DSN"))
if dsn == "" {
t.Skip("TEST_MYSQL_DSN is not configured")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
testPrefillGroupMigrationNonPostgreSQL(t, db)
}
func TestMigratePrefillGroupUniquenessPostgreSQL(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("TEST_POSTGRES_DSN"))
if dsn == "" {
t.Skip("TEST_POSTGRES_DSN is not configured")
}
db, err := gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
PreferSimpleProtocol: true,
}), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
tests := []struct {
name string
prepareOld func(*testing.T, *gorm.DB)
blockedConstraints []string
blockedIndexes []string
preservedIndexes []string
}{
{name: "fresh"},
{
name: "legacy_constraint",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
clause.Table{Name: "prefill_groups"},
clause.Column{Name: legacyPrefillGroupNameUnique},
clause.Column{Name: "name"},
).Error)
},
},
{
name: "legacy_standalone_index",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
require.NoError(t, tx.Migrator().DropIndex(&PrefillGroup{}, prefillGroupNameIndex))
require.NoError(t, tx.Exec(
"CREATE UNIQUE INDEX ? ON ? (?)",
clause.Column{Name: legacyPrefillGroupNameUnique},
clause.Table{Name: "prefill_groups"},
clause.Column{Name: "name"},
).Error)
},
},
{
name: "arbitrary_constraint_name",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
for _, constraintName := range []string{
legacyPrefillGroupNameUnique,
"prefill_groups_name_key",
} {
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
clause.Table{Name: "prefill_groups"},
clause.Column{Name: constraintName},
clause.Column{Name: "name"},
).Error)
}
},
blockedConstraints: []string{legacyPrefillGroupNameUnique, "prefill_groups_name_key"},
},
{
name: "arbitrary_index_name",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
for _, indexName := range []string{
legacyPrefillGroupNameUnique,
"prefill_groups_name_key",
} {
require.NoError(t, tx.Exec(
"CREATE UNIQUE INDEX ? ON ? (?)",
clause.Column{Name: indexName},
clause.Table{Name: "prefill_groups"},
clause.Column{Name: "name"},
).Error)
}
},
blockedIndexes: []string{legacyPrefillGroupNameUnique, "prefill_groups_name_key"},
},
{
name: "non_conflicting_indexes_are_preserved",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
clause.Table{Name: "prefill_groups"},
clause.Column{Name: legacyPrefillGroupNameUnique},
clause.Column{Name: "name"},
).Error)
require.NoError(t, tx.Exec(
"CREATE UNIQUE INDEX ? ON ? (?, ?)",
clause.Column{Name: "keep_prefill_name_deleted_at"},
clause.Table{Name: "prefill_groups"},
clause.Column{Name: "name"},
clause.Column{Name: "deleted_at"},
).Error)
require.NoError(t, tx.Exec(
"CREATE UNIQUE INDEX ? ON ? (lower(?)) WHERE deleted_at IS NULL",
clause.Column{Name: "keep_prefill_lower_name"},
clause.Table{Name: "prefill_groups"},
clause.Column{Name: "name"},
).Error)
require.NoError(t, tx.Exec(
"CREATE UNIQUE INDEX ? ON ? (?) WHERE deleted_at IS NOT NULL",
clause.Column{Name: "keep_prefill_deleted_name"},
clause.Table{Name: "prefill_groups"},
clause.Column{Name: "name"},
).Error)
},
preservedIndexes: []string{
"keep_prefill_name_deleted_at",
"keep_prefill_lower_name",
"keep_prefill_deleted_name",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
tx := db.Begin()
require.NoError(t, tx.Error)
t.Cleanup(func() { _ = tx.Rollback().Error })
schemaName := fmt.Sprintf("prefill_group_migration_%d", time.Now().UnixNano())
require.NoError(t, tx.Exec(
"CREATE SCHEMA ?",
clause.Table{Name: schemaName},
).Error)
require.NoError(t, tx.Exec(
"SET LOCAL search_path TO ?",
clause.Table{Name: schemaName},
).Error)
require.NoError(t, migratePrefillGroupUniqueness(tx))
require.NoError(t, tx.AutoMigrate(&PrefillGroup{}))
original := PrefillGroup{
Name: "shared-name",
Type: "model",
Items: JSONValue(`["gpt-test"]`),
Description: "preserve me",
}
require.NoError(t, tx.Create(&original).Error)
if test.prepareOld != nil {
test.prepareOld(t, tx)
}
if len(test.blockedConstraints) > 0 || len(test.blockedIndexes) > 0 {
err := migratePrefillGroupUniqueness(tx)
require.Error(t, err)
assert.Contains(t, err.Error(), "prefill_groups_name_key")
for _, constraintName := range test.blockedConstraints {
assert.True(t, tx.Migrator().HasConstraint(&PrefillGroup{}, constraintName))
}
for _, indexName := range test.blockedIndexes {
assert.True(t, tx.Migrator().HasIndex(&PrefillGroup{}, indexName))
}
return
}
for range 2 {
require.NoError(t, migratePrefillGroupUniqueness(tx))
require.NoError(t, tx.AutoMigrate(&PrefillGroup{}))
}
for _, indexName := range test.preservedIndexes {
assert.True(t, tx.Migrator().HasIndex(&PrefillGroup{}, indexName))
}
var preserved PrefillGroup
require.NoError(t, tx.First(&preserved, original.Id).Error)
assert.Equal(t, original.Name, preserved.Name)
assert.Equal(t, original.Description, preserved.Description)
var globalConstraintCount int64
require.NoError(t, tx.Raw(`
SELECT count(*)
FROM pg_catalog.pg_constraint AS constraint_meta
WHERE constraint_meta.conrelid = to_regclass('prefill_groups')
AND constraint_meta.contype = 'u'
AND cardinality(constraint_meta.conkey) = 1
AND EXISTS (
SELECT 1
FROM pg_catalog.pg_attribute AS attribute_meta
WHERE attribute_meta.attrelid = constraint_meta.conrelid
AND attribute_meta.attnum = constraint_meta.conkey[1]
AND attribute_meta.attname = 'name'
)`).Scan(&globalConstraintCount).Error)
assert.Zero(t, globalConstraintCount)
var globalIndexCount int64
require.NoError(t, tx.Raw(`
SELECT count(*)
FROM pg_catalog.pg_index AS index_meta
JOIN pg_catalog.pg_attribute AS attribute_meta
ON attribute_meta.attrelid = index_meta.indrelid
AND attribute_meta.attnum = index_meta.indkey[0]
WHERE index_meta.indrelid = to_regclass('prefill_groups')
AND index_meta.indisunique
AND NOT index_meta.indisprimary
AND index_meta.indpred IS NULL
AND index_meta.indexprs IS NULL
AND index_meta.indnatts = 1
AND attribute_meta.attname = 'name'`).Scan(&globalIndexCount).Error)
assert.Zero(t, globalIndexCount)
var targetIndexDefinition string
require.NoError(t, tx.Raw(`
SELECT indexdef
FROM pg_catalog.pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'prefill_groups'
AND indexname = ?`, prefillGroupNameIndex).Scan(&targetIndexDefinition).Error)
assert.Contains(t, strings.ToLower(targetIndexDefinition), "unique index")
assert.Contains(t, strings.ToLower(targetIndexDefinition), "where (deleted_at is null)")
duplicateError := tx.Transaction(func(duplicateTx *gorm.DB) error {
return duplicateTx.Create(&PrefillGroup{
Name: original.Name,
Type: "model",
Items: JSONValue(`[]`),
}).Error
})
require.Error(t, duplicateError)
require.NoError(t, tx.Delete(&original).Error)
require.NoError(t, tx.Create(&PrefillGroup{
Name: original.Name,
Type: "model",
Items: JSONValue(`[]`),
}).Error)
var totalRows int64
require.NoError(t, tx.Unscoped().Model(&PrefillGroup{}).Count(&totalRows).Error)
assert.EqualValues(t, 2, totalRows)
})
}
}
+435
View File
@@ -0,0 +1,435 @@
package model
import (
"fmt"
"maps"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
)
type Pricing struct {
ModelName string `json:"model_name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Tags string `json:"tags,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
OwnerBy string `json:"owner_by"`
CompletionRatio float64 `json:"completion_ratio"`
CacheRatio *float64 `json:"cache_ratio,omitempty"`
CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"`
ImageRatio *float64 `json:"image_ratio,omitempty"`
AudioRatio *float64 `json:"audio_ratio,omitempty"`
AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"`
EnableGroup []string `json:"enable_groups"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
BillingMode string `json:"billing_mode,omitempty"`
BillingExpr string `json:"billing_expr,omitempty"`
GroupBillingExpr map[string]string `json:"group_billing_expr,omitempty"`
BillingUsageSchema map[string]jsplugin.UsageFieldSchema `json:"billing_usage_schema,omitempty"`
BillingUsageExamples []jsplugin.UsageExample `json:"billing_usage_examples,omitempty"`
PricingVersion string `json:"pricing_version,omitempty"`
}
type PricingVendor struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
}
var (
pricingMap []Pricing
vendorsList []PricingVendor
supportedEndpointMap map[string]common.EndpointInfo
lastGetPricingTime time.Time
updatePricingLock sync.Mutex
// 缓存映射:模型名 -> 启用分组 / 计费类型
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
modelEnableGroupsLock = sync.RWMutex{}
)
var (
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
modelSupportEndpointsLock = sync.RWMutex{}
)
func GetPricing() []Pricing {
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
// Double check after acquiring the lock
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
modelSupportEndpointsLock.Lock()
defer modelSupportEndpointsLock.Unlock()
updatePricing()
}
}
return pricingMap
}
func InvalidatePricingCache() {
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
pricingMap = nil
vendorsList = nil
lastGetPricingTime = time.Time{}
}
// GetVendors 返回当前定价接口使用到的供应商信息
func GetVendors() []PricingVendor {
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
// 保证先刷新一次
GetPricing()
}
return vendorsList
}
func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
if model == "" {
return make([]constant.EndpointType, 0)
}
modelSupportEndpointsLock.RLock()
defer modelSupportEndpointsLock.RUnlock()
if endpoints, ok := modelSupportEndpointTypes[model]; ok {
return endpoints
}
return make([]constant.EndpointType, 0)
}
func getPricingEndpointTypesForAbility(ability AbilityWithChannel, advancedCustomConfigs map[int]*dto.AdvancedCustomConfig) []constant.EndpointType {
if ability.ChannelType != constant.ChannelTypeAdvancedCustom {
return common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
}
if config := advancedCustomConfigs[ability.ChannelId]; config != nil {
return config.SupportedEndpointTypesForModel(ability.Model)
}
return common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
}
// loadPricingAdvancedCustomConfigs runs inside updatePricing while
// updatePricingLock is held, and nests channelSyncLock.RLock. This defines the
// global lock order updatePricingLock -> channelSyncLock: any code path holding
// channelSyncLock must release it before touching the pricing cache (see
// InitChannelCache / CacheUpdateChannel), otherwise it deadlocks.
// The returned configs are pointers shared with the channel cache; they are
// replaced wholesale on update and never mutated in place, so reading them after
// RUnlock is safe.
func loadPricingAdvancedCustomConfigs(enableAbilities []AbilityWithChannel) map[int]*dto.AdvancedCustomConfig {
channelIDs := make([]int, 0)
seen := make(map[int]struct{})
for _, ability := range enableAbilities {
if ability.ChannelType != constant.ChannelTypeAdvancedCustom {
continue
}
if _, exists := seen[ability.ChannelId]; exists {
continue
}
seen[ability.ChannelId] = struct{}{}
channelIDs = append(channelIDs, ability.ChannelId)
}
if len(channelIDs) == 0 {
return nil
}
configs := make(map[int]*dto.AdvancedCustomConfig, len(channelIDs))
if common.MemoryCacheEnabled {
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
for _, channelID := range channelIDs {
if config := channel2advancedCustomConfig[channelID]; config != nil {
configs[channelID] = config
}
}
return configs
}
for _, channelID := range channelIDs {
channel, err := CacheGetChannel(channelID)
if err != nil {
common.SysLog(fmt.Sprintf("load advanced custom channel settings error: channel_id=%d, error=%v", channelID, err))
continue
}
if channel.Type != constant.ChannelTypeAdvancedCustom {
continue
}
if config := channel.GetOtherSettings().AdvancedCustom; config != nil {
configs[channelID] = config
}
}
return configs
}
func appendPricingEndpoint(endpoints []string, endpoint string) []string {
if endpoint == "" || common.StringsContains(endpoints, endpoint) {
return endpoints
}
return append(endpoints, endpoint)
}
func updatePricing() {
//modelRatios := common.GetModelRatios()
enableAbilities, err := GetAllEnableAbilityWithChannels()
if err != nil {
common.SysLog(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err))
return
}
// 预加载模型元数据与供应商一次,避免循环查询
var allMeta []Model
_ = DB.Find(&allMeta).Error
names := make([]string, 0, len(enableAbilities))
for _, ability := range enableAbilities {
names = append(names, ability.Model)
}
metaMap := resolveModelMetadata(allMeta, names)
// 预加载供应商
var vendors []Vendor
_ = DB.Find(&vendors).Error
vendorMap := make(map[int]*Vendor)
for i := range vendors {
vendorMap[vendors[i].Id] = &vendors[i]
}
// 初始化默认供应商映射
initDefaultVendorMapping(metaMap, vendorMap, enableAbilities)
// 构建对前端友好的供应商列表
vendorsList = make([]PricingVendor, 0, len(vendorMap))
for _, v := range vendorMap {
vendorsList = append(vendorsList, PricingVendor{
ID: v.Id,
Name: v.Name,
Description: v.Description,
Icon: v.Icon,
})
}
modelGroupsMap := make(map[string]*types.Set[string])
for _, ability := range enableAbilities {
groups, ok := modelGroupsMap[ability.Model]
if !ok {
groups = types.NewSet[string]()
modelGroupsMap[ability.Model] = groups
}
groups.Add(ability.Group)
}
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
modelSupportEndpointsStr := make(map[string][]string)
advancedCustomConfigs := loadPricingAdvancedCustomConfigs(enableAbilities)
// 先根据已有能力填充原生端点
for _, ability := range enableAbilities {
endpoints := modelSupportEndpointsStr[ability.Model]
channelTypes := getPricingEndpointTypesForAbility(ability, advancedCustomConfigs)
for _, channelType := range channelTypes {
if !common.StringsContains(endpoints, string(channelType)) {
endpoints = append(endpoints, string(channelType))
}
}
modelSupportEndpointsStr[ability.Model] = endpoints
}
// 再补充模型自定义端点:若配置有效则追加到已有推断,不再裁剪渠道真实能力
for modelName, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]any
if err := common.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
endpoints := modelSupportEndpointsStr[modelName]
for k, v := range raw {
switch v.(type) {
case string, map[string]any:
endpoints = appendPricingEndpoint(endpoints, k)
}
}
if len(endpoints) > 0 {
modelSupportEndpointsStr[modelName] = endpoints
}
}
}
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
for model, endpoints := range modelSupportEndpointsStr {
supportedEndpoints := make([]constant.EndpointType, 0)
for _, endpointStr := range endpoints {
endpointType := constant.EndpointType(endpointStr)
supportedEndpoints = append(supportedEndpoints, endpointType)
}
modelSupportEndpointTypes[model] = supportedEndpoints
}
// 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
supportedEndpointMap = make(map[string]common.EndpointInfo)
// 1. 默认端点
for _, endpoints := range modelSupportEndpointTypes {
for _, et := range endpoints {
if info, ok := common.GetDefaultEndpointInfo(et); ok {
if _, exists := supportedEndpointMap[string(et)]; !exists {
supportedEndpointMap[string(et)] = info
}
}
}
}
// 2. 自定义端点(models 表)覆盖默认
for _, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]any
if err := common.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
for k, v := range raw {
switch val := v.(type) {
case string:
supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"}
case map[string]any:
ep := common.EndpointInfo{Method: "POST"}
if p, ok := val["path"].(string); ok {
ep.Path = p
}
if m, ok := val["method"].(string); ok {
ep.Method = strings.ToUpper(m)
}
supportedEndpointMap[k] = ep
default:
// ignore unsupported types
}
}
}
}
pricingMap = make([]Pricing, 0)
pluginGeneration := jsplugin.DefaultRegistry.Generation()
for model, groups := range modelGroupsMap {
pricing := Pricing{
ModelName: model,
EnableGroup: groups.Items(),
SupportedEndpointTypes: modelSupportEndpointTypes[model],
}
// 补充模型元数据(描述、标签、供应商、状态)
if meta, ok := metaMap[model]; ok {
// 若模型被禁用(status!=1),则直接跳过,不返回给前端
if meta.Status != 1 {
continue
}
pricing.Description = meta.Description
pricing.Icon = meta.Icon
pricing.Tags = meta.Tags
pricing.VendorID = meta.VendorID
}
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
if findPrice {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
} else {
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0
}
if cacheRatio, ok := ratio_setting.GetCacheRatio(model); ok {
pricing.CacheRatio = &cacheRatio
}
if createCacheRatio, ok := ratio_setting.GetCreateCacheRatio(model); ok {
pricing.CreateCacheRatio = &createCacheRatio
}
if imageRatio, ok := ratio_setting.GetImageRatio(model); ok {
pricing.ImageRatio = &imageRatio
}
if ratio_setting.ContainsAudioRatio(model) {
audioRatio := ratio_setting.GetAudioRatio(model)
pricing.AudioRatio = &audioRatio
}
if ratio_setting.ContainsAudioCompletionRatio(model) {
audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(model)
pricing.AudioCompletionRatio = &audioCompletionRatio
}
if billingMode := billing_setting.GetBillingMode(model); billingMode == "tiered_expr" {
if expr, ok := billing_setting.GetBillingExpr(model); ok && strings.TrimSpace(expr) != "" {
pricing.BillingMode = billingMode
pricing.BillingExpr = expr
}
} else if target, resolved := ResolveTaskModelAlias(pluginGeneration, model); resolved && target.Declared != "" {
if tailMode := billing_setting.GetBillingMode(target.Declared); tailMode == "tiered_expr" {
if expr, ok := billing_setting.GetBillingExpr(target.Declared); ok && strings.TrimSpace(expr) != "" {
pricing.BillingMode = tailMode
pricing.BillingExpr = expr
}
}
}
plugin, ok := pluginGeneration.GetByModel(model)
if !ok {
if target, resolved := ResolveTaskModelAlias(pluginGeneration, model); resolved {
plugin, ok = pluginGeneration.Get(target.PluginKey)
}
}
if ok && plugin != nil && len(plugin.Meta.UsageSchema) > 0 {
pricing.BillingUsageSchema = make(map[string]jsplugin.UsageFieldSchema, len(plugin.Meta.UsageSchema))
for key, field := range plugin.Meta.UsageSchema {
field.Enum = append([]string(nil), field.Enum...)
field.Description = maps.Clone(field.Description)
if field.EnumLabels != nil {
labels := make(map[string]jsplugin.LocalizedText, len(field.EnumLabels))
for value, label := range field.EnumLabels {
labels[value] = maps.Clone(label)
}
field.EnumLabels = labels
}
pricing.BillingUsageSchema[key] = field
}
if len(plugin.Meta.UsageExamples) > 0 {
pricing.BillingUsageExamples = make([]jsplugin.UsageExample, len(plugin.Meta.UsageExamples))
for index, example := range plugin.Meta.UsageExamples {
facts := make(map[string]any, len(example.Facts))
maps.Copy(facts, example.Facts)
pricing.BillingUsageExamples[index] = jsplugin.UsageExample{
Label: example.Label,
Facts: facts,
}
}
}
}
pricingMap = append(pricingMap, pricing)
}
// 防止大更新后数据不通用
if len(pricingMap) > 0 {
pricingMap[0].PricingVersion = "5a90f2b86c08bd983a9a2e6d66c255f4eaef9c4bc934386d2b6ae84ef0ff1f1f"
}
// 刷新缓存映射,供高并发快速查询
modelEnableGroupsLock.Lock()
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
for _, p := range pricingMap {
modelEnableGroups[p.ModelName] = p.EnableGroup
modelQuotaTypeMap[p.ModelName] = p.QuotaType
}
modelEnableGroupsLock.Unlock()
lastGetPricingTime = time.Now()
}
// GetSupportedEndpointMap 返回全局端点到路径的映射
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
return supportedEndpointMap
}
+159
View File
@@ -0,0 +1,159 @@
package model
import (
"slices"
"strings"
)
// 简化的供应商映射规则
var defaultVendorRules = map[string]string{
"gpt": "OpenAI",
"dall-e": "OpenAI",
"whisper": "OpenAI",
"o1": "OpenAI",
"o3": "OpenAI",
"claude": "Anthropic",
"gemini": "Google",
"moonshot": "Moonshot",
"kimi": "Moonshot",
"chatglm": "智谱",
"glm-": "智谱",
"qwen": "阿里巴巴",
"deepseek": "DeepSeek",
"abab": "MiniMax",
"minimax": "MiniMax",
"ernie": "百度",
"spark": "讯飞",
"hunyuan": "腾讯",
"command": "Cohere",
"@cf/": "Cloudflare",
"360": "360",
"yi": "零一万物",
"jina": "Jina",
"mistral": "Mistral",
"grok": "xAI",
"llama": "Meta",
"doubao": "字节跳动",
"kling": "快手",
"jimeng": "即梦",
"vidu": "Vidu",
}
// 供应商默认图标映射
var defaultVendorIcons = map[string]string{
"OpenAI": "OpenAI",
"Anthropic": "Claude.Color",
"Google": "Gemini.Color",
"Moonshot": "Moonshot",
"智谱": "Zhipu.Color",
"阿里巴巴": "Qwen.Color",
"DeepSeek": "DeepSeek.Color",
"MiniMax": "Minimax.Color",
"百度": "Wenxin.Color",
"讯飞": "Spark.Color",
"腾讯": "Hunyuan.Color",
"Cohere": "Cohere.Color",
"Cloudflare": "Cloudflare.Color",
"360": "Ai360.Color",
"零一万物": "Yi.Color",
"Jina": "Jina",
"Mistral": "Mistral.Color",
"xAI": "XAI",
"Meta": "Ollama",
"字节跳动": "Doubao.Color",
"快手": "Kling.Color",
"即梦": "Jimeng.Color",
"Vidu": "Vidu",
"微软": "AzureAI",
"Microsoft": "AzureAI",
"Azure": "AzureAI",
}
// initDefaultVendorMapping 简化的默认供应商映射
func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vendor, enableAbilities []AbilityWithChannel) {
patterns := make([]string, 0, len(defaultVendorRules))
for pattern := range defaultVendorRules {
patterns = append(patterns, pattern)
}
slices.SortFunc(patterns, func(a, b string) int {
if len(a) != len(b) {
return len(b) - len(a)
}
return strings.Compare(a, b)
})
for _, ability := range enableAbilities {
modelName := ability.Model
if _, exists := metaMap[modelName]; exists {
continue
}
// 匹配供应商
vendorID := 0
modelLower := strings.ToLower(modelName)
for _, pattern := range patterns {
vendorName := defaultVendorRules[pattern]
if strings.Contains(modelLower, pattern) {
vendorID = getDisplayVendor(vendorName, vendorMap)
break
}
}
// 创建模型元数据
metaMap[modelName] = &Model{
ModelName: modelName,
VendorID: vendorID,
Status: 1,
NameRule: NameRuleExact,
}
}
}
// Default vendor entries are presentation data. Reading pricing must never
// recreate a deleted/merged database record.
var defaultVendorDisplayIDs = map[string]int{
"360": -1001,
"Anthropic": -1002,
"Cloudflare": -1003,
"Cohere": -1004,
"DeepSeek": -1005,
"Google": -1006,
"Jina": -1007,
"Meta": -1008,
"MiniMax": -1009,
"Mistral": -1010,
"Moonshot": -1011,
"OpenAI": -1012,
"Vidu": -1013,
"xAI": -1014,
"即梦": -1015,
"字节跳动": -1016,
"快手": -1017,
"智谱": -1018,
"百度": -1019,
"腾讯": -1020,
"讯飞": -1021,
"阿里巴巴": -1022,
"零一万物": -1023,
}
func getDisplayVendor(vendorName string, vendorMap map[int]*Vendor) int {
for id, vendor := range vendorMap {
if strings.EqualFold(vendor.Name, vendorName) {
return id
}
}
id := defaultVendorDisplayIDs[vendorName]
if id == 0 {
return 0
}
vendorMap[id] = &Vendor{Id: id, Name: vendorName, Status: 1, Icon: getDefaultVendorIcon(vendorName)}
return id
}
// 获取供应商默认图标
func getDefaultVendorIcon(vendorName string) string {
if icon, exists := defaultVendorIcons[vendorName]; exists {
return icon
}
return ""
}
+294
View File
@@ -0,0 +1,294 @@
package model
import (
"fmt"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func resetPricingEndpointTestTables(t *testing.T) {
t.Helper()
originalMemoryCacheEnabled := common.MemoryCacheEnabled
common.MemoryCacheEnabled = true
require.NoError(t, DB.AutoMigrate(&Channel{}, &Ability{}, &Model{}, &Vendor{}))
for _, table := range []string{"abilities", "channels", "models", "vendors"} {
require.NoError(t, DB.Exec("DELETE FROM "+table).Error)
}
InitChannelCache()
InvalidatePricingCache()
t.Cleanup(func() {
for _, table := range []string{"abilities", "channels", "models", "vendors"} {
require.NoError(t, DB.Exec("DELETE FROM "+table).Error)
}
InitChannelCache()
InvalidatePricingCache()
common.MemoryCacheEnabled = originalMemoryCacheEnabled
})
}
func insertPricingEndpointChannel(t *testing.T, channelID int, channelType int, settings dto.ChannelOtherSettings) {
t.Helper()
channel := &Channel{
Id: channelID,
Type: channelType,
Key: fmt.Sprintf("key-%d", channelID),
Status: common.ChannelStatusEnabled,
Name: fmt.Sprintf("channel-%d", channelID),
}
if settings.AdvancedCustom != nil {
channel.SetOtherSettings(settings)
}
require.NoError(t, DB.Create(channel).Error)
}
func insertPricingEndpointAbility(t *testing.T, channelID int, modelName string) {
t.Helper()
require.NoError(t, DB.Create(&Ability{
Group: "default",
Model: modelName,
ChannelId: channelID,
Enabled: true,
}).Error)
}
func pricingEndpointAdvancedCustomConfig(routes ...dto.AdvancedCustomRoute) dto.ChannelOtherSettings {
return dto.ChannelOtherSettings{
AdvancedCustom: &dto.AdvancedCustomConfig{
Routes: routes,
},
}
}
func pricingEndpointTypesByModel(t *testing.T) map[string][]constant.EndpointType {
t.Helper()
InitChannelCache()
return pricingEndpointTypesFromPricing(GetPricing())
}
func pricingEndpointTypesFromPricing(pricings []Pricing) map[string][]constant.EndpointType {
byModel := make(map[string][]constant.EndpointType)
for _, pricing := range pricings {
byModel[pricing.ModelName] = pricing.SupportedEndpointTypes
}
return byModel
}
func TestPricingAdvancedCustomUsesConfiguredEndpointTypes(t *testing.T) {
resetPricingEndpointTestTables(t)
insertPricingEndpointChannel(t, 101, constant.ChannelTypeAdvancedCustom, pricingEndpointAdvancedCustomConfig(
dto.AdvancedCustomRoute{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
},
dto.AdvancedCustomRoute{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: "openai_responses_to_gemini_generate_content",
Models: []string{"re:^gemini-"},
},
))
insertPricingEndpointAbility(t, 101, "gemini-2.5-flash")
insertPricingEndpointAbility(t, 101, "gpt-4o")
byModel := pricingEndpointTypesByModel(t)
assert.Equal(t, []constant.EndpointType{
constant.EndpointTypeOpenAI,
constant.EndpointTypeOpenAIResponse,
}, byModel["gemini-2.5-flash"])
assert.Equal(t, []constant.EndpointType{
constant.EndpointTypeOpenAI,
}, byModel["gpt-4o"])
}
func TestPricingModelMetadataEndpointsMergeWithAdvancedCustomInference(t *testing.T) {
resetPricingEndpointTestTables(t)
insertPricingEndpointChannel(t, 103, constant.ChannelTypeAdvancedCustom, pricingEndpointAdvancedCustomConfig(
dto.AdvancedCustomRoute{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: "openai_responses_to_gemini_generate_content",
Models: []string{"re:^gemini-"},
},
))
insertPricingEndpointAbility(t, 103, "gemini-2.5-flash")
require.NoError(t, DB.Create(&Model{
ModelName: "gemini-2.5-flash",
Endpoints: `{
"openai": "/v1/chat/completions"
}`,
Status: 1,
NameRule: NameRuleExact,
}).Error)
byModel := pricingEndpointTypesByModel(t)
assert.Equal(t, []constant.EndpointType{
constant.EndpointTypeOpenAIResponse,
constant.EndpointTypeOpenAI,
}, byModel["gemini-2.5-flash"])
}
func TestPricingModelMetadataEndpointsCanProvideEndpointWithoutChannelInference(t *testing.T) {
resetPricingEndpointTestTables(t)
insertPricingEndpointChannel(t, 104, constant.ChannelTypeAdvancedCustom, pricingEndpointAdvancedCustomConfig(
dto.AdvancedCustomRoute{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: "openai_responses_to_gemini_generate_content",
Models: []string{"re:^gemini-"},
},
))
insertPricingEndpointAbility(t, 104, "metadata-only-model")
require.NoError(t, DB.Create(&Model{
ModelName: "metadata-only-model",
Endpoints: `{
"openai": "/v1/chat/completions"
}`,
Status: 1,
NameRule: NameRuleExact,
}).Error)
byModel := pricingEndpointTypesByModel(t)
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, byModel["metadata-only-model"])
}
func TestPricingAdvancedCustomMissingConfigFallsBackToChannelType(t *testing.T) {
resetPricingEndpointTestTables(t)
insertPricingEndpointChannel(t, 102, constant.ChannelTypeAdvancedCustom, dto.ChannelOtherSettings{})
insertPricingEndpointAbility(t, 102, "gpt-4o")
byModel := pricingEndpointTypesByModel(t)
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, byModel["gpt-4o"])
}
func TestPricingNativeChannelEndpointTypesUnchanged(t *testing.T) {
resetPricingEndpointTestTables(t)
insertPricingEndpointChannel(t, 201, constant.ChannelTypeOpenAI, dto.ChannelOtherSettings{})
insertPricingEndpointChannel(t, 202, constant.ChannelTypeGemini, dto.ChannelOtherSettings{})
insertPricingEndpointChannel(t, 203, constant.ChannelTypeAnthropic, dto.ChannelOtherSettings{})
insertPricingEndpointAbility(t, 201, "gpt-4o")
insertPricingEndpointAbility(t, 202, "gemini-2.5-flash")
insertPricingEndpointAbility(t, 203, "claude-3-5-sonnet")
byModel := pricingEndpointTypesByModel(t)
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, byModel["gpt-4o"])
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI}, byModel["gemini-2.5-flash"])
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeAnthropic, constant.EndpointTypeOpenAI}, byModel["claude-3-5-sonnet"])
}
func TestInitChannelCacheInvalidatesPricingCache(t *testing.T) {
resetPricingEndpointTestTables(t)
insertPricingEndpointChannel(t, 301, constant.ChannelTypeAdvancedCustom, pricingEndpointAdvancedCustomConfig(
dto.AdvancedCustomRoute{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
},
))
insertPricingEndpointAbility(t, 301, "gemini-3.5-flash")
InitChannelCache()
initial := pricingEndpointTypesByModel(t)
require.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, initial["gemini-3.5-flash"])
var channel Channel
require.NoError(t, DB.First(&channel, "id = ?", 301).Error)
channel.SetOtherSettings(pricingEndpointAdvancedCustomConfig(
dto.AdvancedCustomRoute{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
},
dto.AdvancedCustomRoute{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: "openai_responses_to_gemini_generate_content",
Models: []string{"re:^gemini-"},
},
))
require.NoError(t, DB.Model(&Channel{}).Where("id = ?", 301).Update("settings", channel.OtherSettings).Error)
InitChannelCache()
updated := pricingEndpointTypesByModel(t)
assert.Equal(t, []constant.EndpointType{
constant.EndpointTypeOpenAI,
constant.EndpointTypeOpenAIResponse,
}, updated["gemini-3.5-flash"])
}
func TestInitChannelCacheInvalidatesStartupPricingBuiltBeforeChannelCache(t *testing.T) {
resetPricingEndpointTestTables(t)
insertPricingEndpointChannel(t, 302, constant.ChannelTypeAdvancedCustom, pricingEndpointAdvancedCustomConfig(
dto.AdvancedCustomRoute{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
},
dto.AdvancedCustomRoute{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: "openai_responses_to_gemini_generate_content",
Models: []string{"re:^gemini-"},
},
))
insertPricingEndpointAbility(t, 302, "gemini-3.5-flash")
staleByModel := pricingEndpointTypesFromPricing(GetPricing())
require.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, staleByModel["gemini-3.5-flash"])
InitChannelCache()
rebuiltByModel := pricingEndpointTypesFromPricing(GetPricing())
assert.Equal(t, []constant.EndpointType{
constant.EndpointTypeOpenAI,
constant.EndpointTypeOpenAIResponse,
}, rebuiltByModel["gemini-3.5-flash"])
}
func TestCacheUpdateChannelSyncsAdvancedCustomConfig(t *testing.T) {
resetPricingEndpointTestTables(t)
channel := &Channel{
Id: 401,
Type: constant.ChannelTypeAdvancedCustom,
Key: "key-401",
Status: common.ChannelStatusEnabled,
Name: "channel-401",
}
channel.SetOtherSettings(pricingEndpointAdvancedCustomConfig(dto.AdvancedCustomRoute{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: "openai_responses_to_gemini_generate_content",
}))
CacheUpdateChannel(channel)
require.NotNil(t, channel2advancedCustomConfig[401])
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAIResponse}, channel2advancedCustomConfig[401].SupportedEndpointTypesForModel("gemini-3.5-flash"))
channel.SetOtherSettings(pricingEndpointAdvancedCustomConfig(dto.AdvancedCustomRoute{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
}))
CacheUpdateChannel(channel)
require.NotNil(t, channel2advancedCustomConfig[401])
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, channel2advancedCustomConfig[401].SupportedEndpointTypesForModel("gemini-3.5-flash"))
channel.Type = constant.ChannelTypeOpenAI
CacheUpdateChannel(channel)
assert.Nil(t, channel2advancedCustomConfig[401])
}
+14
View File
@@ -0,0 +1,14 @@
package model
// RefreshPricing 强制立即重新计算与定价相关的缓存。
// 该方法用于需要最新数据的内部管理 API,
// 因此会绕过默认的 1 分钟延迟刷新。
func RefreshPricing() {
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
modelSupportEndpointsLock.Lock()
defer modelSupportEndpointsLock.Unlock()
updatePricing()
}
+141
View File
@@ -0,0 +1,141 @@
package model
import (
"fmt"
"testing"
"time"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func pricingUsagePluginSource(version, usageSchema string) string {
return fmt.Sprintf(`
export const meta = {
apiVersion: 1, key: "pricing-usage-probe", name: "Pricing Usage Probe", version: %q, author: {name: "Test"},
models: ["pricing-usage-model"], fetchMode: "per_task", usageSchema: %s
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, version, usageSchema)
}
func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testing.T) {
resetPricingEndpointTestTables(t)
const pluginKey = "pricing-usage-probe"
initialSource := pricingUsagePluginSource("1.0.0", `{
seconds: {type: "number", unit: "second", description: "Estimated duration."},
action: {enum:["video"],enumLabels:{video:{en:"Generate video",zh:"生成视频"}}}
}`)
_, err := jsplugin.DefaultRegistry.Register(initialSource, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(pluginKey) })
insertPricingEndpointChannel(t, 901, constant.ChannelTypeTaskPlugin, dto.ChannelOtherSettings{})
insertPricingEndpointAbility(t, 901, "pricing-usage-model")
insertPricingEndpointAbility(t, 901, "ordinary-model")
initialPricing := pricingByModel(GetPricing())
require.Contains(t, initialPricing, "pricing-usage-model")
require.Contains(t, initialPricing, "ordinary-model")
assert.Equal(t, "second", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Unit)
assert.Equal(t, "Estimated duration.", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Description["en"])
assert.Equal(t, "生成视频", initialPricing["pricing-usage-model"].BillingUsageSchema["action"].EnumLabels["video"]["zh"])
assert.Nil(t, initialPricing["ordinary-model"].BillingUsageSchema)
updatedSource := pricingUsagePluginSource("1.1.0", `{
seconds: {type: "number", unit: "second", description: "Measured duration."},
clips: {type: "number", unit: "count", description: "Generated clip count."}
}`)
_, err = jsplugin.DefaultRegistry.Register(updatedSource, jsplugin.Options{})
require.NoError(t, err)
lastGetPricingTime = time.Now().Add(-2 * time.Minute)
refreshedPricing := pricingByModel(GetPricing())
require.Len(t, refreshedPricing["pricing-usage-model"].BillingUsageSchema, 2)
assert.Equal(t, "Measured duration.", refreshedPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Description["en"])
assert.Equal(t, "count", refreshedPricing["pricing-usage-model"].BillingUsageSchema["clips"].Unit)
}
func TestPricingAliasCarriesPluginUsageSchemaAndTailExpr(t *testing.T) {
resetPricingEndpointTestTables(t)
const pluginKey = "pricing-usage-probe"
source := pricingUsagePluginSource("1.0.0", `{
seconds: {type: "number", unit: "second", description: "Estimated duration."}
}`)
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(pluginKey) })
mapping := `{"alias-model":"pricing-usage-model"}`
channel := &Channel{
Id: 910,
Type: constant.ChannelTypeTaskPlugin,
Key: "key-910",
Status: 1,
Name: "channel-910",
Models: "alias-model,pricing-usage-model",
ModelMapping: &mapping,
}
require.NoError(t, DB.Create(channel).Error)
insertPricingEndpointAbility(t, 910, "alias-model")
insertPricingEndpointAbility(t, 910, "pricing-usage-model")
InitChannelCache()
saved := map[string]string{}
require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
saved[key] = value
return nil
}))
t.Cleanup(func() {
require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
})
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
"billing_setting.billing_mode": `{"pricing-usage-model":"tiered_expr","alias-own-expr":"tiered_expr"}`,
"billing_setting.billing_expr": `{"pricing-usage-model":"u(\"seconds\")","alias-own-expr":"u(\"seconds\") * 2"}`,
}))
InvalidatePricingCache()
pricing := pricingByModel(GetPricing())
require.Contains(t, pricing, "alias-model")
require.Contains(t, pricing, "pricing-usage-model")
assert.Equal(t, "second", pricing["alias-model"].BillingUsageSchema["seconds"].Unit)
assert.Equal(t, "Estimated duration.", pricing["alias-model"].BillingUsageSchema["seconds"].Description["en"])
assert.Equal(t, "tiered_expr", pricing["alias-model"].BillingMode)
assert.Equal(t, `u("seconds")`, pricing["alias-model"].BillingExpr)
assert.Equal(t, "tiered_expr", pricing["pricing-usage-model"].BillingMode)
assert.Equal(t, `u("seconds")`, pricing["pricing-usage-model"].BillingExpr)
ownMapping := `{"alias-own-expr":"pricing-usage-model"}`
own := &Channel{
Id: 911,
Type: constant.ChannelTypeTaskPlugin,
Key: "key-911",
Status: 1,
Name: "channel-911",
Models: "alias-own-expr,pricing-usage-model",
ModelMapping: &ownMapping,
}
require.NoError(t, DB.Create(own).Error)
insertPricingEndpointAbility(t, 911, "alias-own-expr")
InitChannelCache()
InvalidatePricingCache()
refreshed := pricingByModel(GetPricing())
assert.Equal(t, `u("seconds") * 2`, refreshed["alias-own-expr"].BillingExpr)
assert.Equal(t, "second", refreshed["alias-own-expr"].BillingUsageSchema["seconds"].Unit)
}
func pricingByModel(pricings []Pricing) map[string]Pricing {
result := make(map[string]Pricing, len(pricings))
for _, pricing := range pricings {
result[pricing.ModelName] = pricing
}
return result
}
+240
View File
@@ -0,0 +1,240 @@
package model
import (
"context"
"errors"
"fmt"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
type cacheQuotaResult int
const (
cacheQuotaInsufficient cacheQuotaResult = iota
cacheQuotaOK
cacheQuotaMiss
)
const userQuotaReserveScript = `
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or tonumber(redis.call('HGET', KEYS[1], 'CacheSchema') or '0') ~= tonumber(ARGV[3])
or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
return -1
end
local quota = tonumber(redis.call('HGET', KEYS[1], 'Quota'))
if quota == nil or quota < tonumber(ARGV[1]) then
return 0
end
redis.call('HINCRBY', KEYS[1], 'Quota', -tonumber(ARGV[1]))
return 1`
const userQuotaDeltaScript = `
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or tonumber(redis.call('HGET', KEYS[1], 'CacheSchema') or '0') ~= tonumber(ARGV[3])
or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
return -1
end
redis.call('HINCRBY', KEYS[1], 'Quota', ARGV[1])
return 1`
const tokenQuotaReserveScript = `
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or redis.call('HEXISTS', KEYS[1], 'RemainQuota') == 0
or redis.call('HEXISTS', KEYS[1], 'UsedQuota') == 0 then
return -1
end
local remain = tonumber(redis.call('HGET', KEYS[1], 'RemainQuota'))
if remain == nil or remain < tonumber(ARGV[1]) then
return 0
end
redis.call('HINCRBY', KEYS[1], 'RemainQuota', -tonumber(ARGV[1]))
redis.call('HINCRBY', KEYS[1], 'UsedQuota', tonumber(ARGV[1]))
redis.call('HSET', KEYS[1], 'AccessedTime', ARGV[3])
return 1`
const tokenQuotaDeltaScript = `
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or redis.call('HEXISTS', KEYS[1], 'RemainQuota') == 0
or redis.call('HEXISTS', KEYS[1], 'UsedQuota') == 0 then
return -1
end
redis.call('HINCRBY', KEYS[1], 'RemainQuota', tonumber(ARGV[1]))
redis.call('HINCRBY', KEYS[1], 'UsedQuota', -tonumber(ARGV[1]))
redis.call('HSET', KEYS[1], 'AccessedTime', ARGV[3])
return 1`
func quotaResultFromLua(result int, err error) (cacheQuotaResult, error) {
if err != nil {
return cacheQuotaMiss, err
}
switch result {
case 1:
return cacheQuotaOK, nil
case 0:
return cacheQuotaInsufficient, nil
default:
return cacheQuotaMiss, nil
}
}
func cacheTryReserveUserQuota(userID int, amount int64) (cacheQuotaResult, error) {
result, err := common.RDB.Eval(context.Background(), userQuotaReserveScript,
[]string{getUserCacheKey(userID)}, amount, userID, userCacheSchemaVersion).Int()
return quotaResultFromLua(result, err)
}
func cacheApplyUserQuotaDelta(userID int, delta int64) (cacheQuotaResult, error) {
result, err := common.RDB.Eval(context.Background(), userQuotaDeltaScript,
[]string{getUserCacheKey(userID)}, delta, userID, userCacheSchemaVersion).Int()
return quotaResultFromLua(result, err)
}
func cacheTryReserveTokenQuota(id int, key string, amount int64) (cacheQuotaResult, error) {
result, err := common.RDB.Eval(context.Background(), tokenQuotaReserveScript,
[]string{getTokenCacheKey(key)}, amount, id, common.GetTimestamp()).Int()
return quotaResultFromLua(result, err)
}
func cacheApplyTokenQuotaDelta(id int, key string, delta int64) (cacheQuotaResult, error) {
result, err := common.RDB.Eval(context.Background(), tokenQuotaDeltaScript,
[]string{getTokenCacheKey(key)}, delta, id, common.GetTimestamp()).Int()
return quotaResultFromLua(result, err)
}
// persistUserQuotaDelta 把已在缓存侧预扣成功的增量落库;批量模式下入队,
// 直写模式下要求行存在(用户已删除时报错,交由调用方补偿缓存)。
func persistUserQuotaDelta(id int, delta int) error {
if common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeUserQuota, id, delta)
return nil
}
result := DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", delta))
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return nil
}
func persistTokenQuotaDelta(id int, delta int) error {
if common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeTokenQuota, id, delta)
return nil
}
result := DB.Model(&Token{}).Where("id = ?", id).Updates(
map[string]any{
"remain_quota": gorm.Expr("remain_quota + ?", delta),
"used_quota": gorm.Expr("used_quota - ?", delta),
"accessed_time": common.GetTimestamp(),
},
)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return nil
}
func reserveUserQuotaDB(id int, quota int) (bool, error) {
result := DB.Model(&User{}).
Where("id = ? AND quota >= ?", id, quota).
Update("quota", gorm.Expr("quota - ?", quota))
return result.RowsAffected == 1, result.Error
}
func reserveTokenQuotaDB(id int, quota int) (bool, error) {
result := DB.Model(&Token{}).
Where("id = ? AND remain_quota >= ?", id, quota).
Updates(map[string]any{
"remain_quota": gorm.Expr("remain_quota - ?", quota),
"used_quota": gorm.Expr("used_quota + ?", quota),
"accessed_time": common.GetTimestamp(),
})
return result.RowsAffected == 1, result.Error
}
// TryReserveUserQuota atomically checks and deducts a user's wallet quota.
// 缓存命中时以缓存余额为准(避免批量模式下过期的数据库余额放大并发超扣);
// Redis 异常或水合失败时降级为数据库条件更新,保证服务可用。
func TryReserveUserQuota(id int, quota int) (bool, error) {
if quota < 0 {
return false, errors.New("quota 不能为负数!")
}
if quota == 0 {
return true, nil
}
if !common.RedisEnabled {
return reserveUserQuotaDB(id, quota)
}
result, err := cacheTryReserveUserQuota(id, int64(quota))
if err == nil && result == cacheQuotaMiss {
if _, hydrateErr := GetUserCache(id); hydrateErr == nil {
result, err = cacheTryReserveUserQuota(id, int64(quota))
}
}
if err != nil || result == cacheQuotaMiss {
if err != nil {
common.SysLog("user quota cache reserve unavailable, falling back to database: " + err.Error())
}
return reserveUserQuotaDB(id, quota)
}
if result == cacheQuotaInsufficient {
return false, nil
}
if err = persistUserQuotaDelta(id, -quota); err != nil {
compensated, compensateErr := cacheApplyUserQuotaDelta(id, int64(quota))
if compensateErr != nil || compensated != cacheQuotaOK {
common.SysError(fmt.Sprintf("failed to compensate reserved user quota: result=%d error=%v", compensated, compensateErr))
}
return false, err
}
return true, nil
}
// TryReserveTokenQuota atomically checks and deducts a token quota. Unlimited
// tokens skip the balance check but still update remain/used accounting.
func TryReserveTokenQuota(id int, key string, quota int, unlimited bool) (bool, error) {
if quota < 0 {
return false, errors.New("quota 不能为负数!")
}
if quota == 0 {
return true, nil
}
if unlimited {
return true, DecreaseTokenQuota(id, key, quota)
}
if !common.RedisEnabled {
return reserveTokenQuotaDB(id, quota)
}
result, err := cacheTryReserveTokenQuota(id, key, int64(quota))
if err == nil && result == cacheQuotaMiss {
if _, hydrateErr := GetTokenByKey(key, true); hydrateErr == nil {
result, err = cacheTryReserveTokenQuota(id, key, int64(quota))
}
}
if err != nil || result == cacheQuotaMiss {
if err != nil {
common.SysLog("token quota cache reserve unavailable, falling back to database: " + err.Error())
}
return reserveTokenQuotaDB(id, quota)
}
if result == cacheQuotaInsufficient {
return false, nil
}
if err = persistTokenQuotaDelta(id, -quota); err != nil {
compensated, compensateErr := cacheApplyTokenQuotaDelta(id, key, int64(quota))
if compensateErr != nil || compensated != cacheQuotaOK {
common.SysError(fmt.Sprintf("failed to compensate reserved token quota: result=%d error=%v", compensated, compensateErr))
}
return false, err
}
return true, nil
}
+261
View File
@@ -0,0 +1,261 @@
package model
import (
"math"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func createReserveTestUser(t *testing.T, quota int) User {
t.Helper()
user := User{
Username: "reserve-user-" + common.GetRandomString(6),
Password: "unused-password-hash",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
Quota: quota,
AffCode: "reserve-aff-" + common.GetRandomString(8),
}
require.NoError(t, DB.Create(&user).Error)
return user
}
func createReserveTestToken(t *testing.T, remainQuota int) Token {
t.Helper()
token := Token{
UserId: 1,
Key: "reserve-token-" + common.GetRandomString(8),
Name: "reserve-test",
Status: common.TokenStatusEnabled,
ExpiredTime: -1,
RemainQuota: remainQuota,
}
require.NoError(t, token.Insert())
return token
}
func getUserQuotaFromDB(t *testing.T, id int) int {
t.Helper()
var user User
require.NoError(t, DB.Select("quota").First(&user, id).Error)
return user.Quota
}
func getTokenFromDB(t *testing.T, id int) Token {
t.Helper()
var token Token
require.NoError(t, DB.First(&token, id).Error)
return token
}
func resetBatchUpdateTestState(t *testing.T) {
t.Helper()
oldBatchEnabled := common.BatchUpdateEnabled
common.BatchUpdateEnabled = false
for i := range BatchUpdateTypeCount {
batchUpdateLocks[i].Lock()
batchUpdateStores[i] = make(map[int]int)
batchUpdateLocks[i].Unlock()
}
t.Cleanup(func() {
common.BatchUpdateEnabled = oldBatchEnabled
for i := range BatchUpdateTypeCount {
batchUpdateLocks[i].Lock()
batchUpdateStores[i] = make(map[int]int)
batchUpdateLocks[i].Unlock()
}
})
}
func TestTryReserveQuotaWithoutRedis(t *testing.T) {
truncateTables(t)
resetBatchUpdateTestState(t)
user := createReserveTestUser(t, 100)
reserved, err := TryReserveUserQuota(user.Id, 60)
require.NoError(t, err)
assert.True(t, reserved)
assert.Equal(t, 40, getUserQuotaFromDB(t, user.Id))
reserved, err = TryReserveUserQuota(user.Id, 41)
require.NoError(t, err)
assert.False(t, reserved)
assert.Equal(t, 40, getUserQuotaFromDB(t, user.Id))
token := createReserveTestToken(t, 80)
reserved, err = TryReserveTokenQuota(token.Id, token.Key, 25, false)
require.NoError(t, err)
assert.True(t, reserved)
reloaded := getTokenFromDB(t, token.Id)
assert.Equal(t, 55, reloaded.RemainQuota)
assert.Equal(t, 25, reloaded.UsedQuota)
reserved, err = TryReserveTokenQuota(token.Id, token.Key, 56, false)
require.NoError(t, err)
assert.False(t, reserved)
assert.Equal(t, 55, getTokenFromDB(t, token.Id).RemainQuota)
}
func TestRedisBatchReserveNeverFallsBackToStaleDatabaseBalance(t *testing.T) {
truncateTables(t)
resetBatchUpdateTestState(t)
useUserCacheMiniRedis(t)
common.BatchUpdateEnabled = true
user := createReserveTestUser(t, 10)
reserved, err := TryReserveUserQuota(user.Id, 8)
require.NoError(t, err)
assert.True(t, reserved)
assert.Equal(t, 10, getUserQuotaFromDB(t, user.Id), "batch delta is not flushed yet")
reserved, err = TryReserveUserQuota(user.Id, 3)
require.NoError(t, err)
assert.False(t, reserved, "stale DB balance must not authorize a second spend")
cachedUser, err := GetUserCache(user.Id)
require.NoError(t, err)
assert.Equal(t, 2, cachedUser.Quota)
token := createReserveTestToken(t, 9)
reserved, err = TryReserveTokenQuota(token.Id, token.Key, 7, false)
require.NoError(t, err)
assert.True(t, reserved)
reserved, err = TryReserveTokenQuota(token.Id, token.Key, 3, false)
require.NoError(t, err)
assert.False(t, reserved)
assert.Equal(t, 9, getTokenFromDB(t, token.Id).RemainQuota)
batchUpdate()
assert.Equal(t, 2, getUserQuotaFromDB(t, user.Id))
reloadedToken := getTokenFromDB(t, token.Id)
assert.Equal(t, 2, reloadedToken.RemainQuota)
assert.Equal(t, 7, reloadedToken.UsedQuota)
}
func TestBatchUpdateAccumulatesTwoMaximumRequestCharges(t *testing.T) {
truncateTables(t)
resetBatchUpdateTestState(t)
common.BatchUpdateEnabled = true
user := createReserveTestUser(t, common.MaxQuota*2+100)
require.NoError(t, DecreaseUserQuota(user.Id, common.MaxQuota, false))
require.NoError(t, DecreaseUserQuota(user.Id, common.MaxQuota, false))
batchUpdate()
assert.Equal(t, 100, getUserQuotaFromDB(t, user.Id))
}
func TestBatchUpdateAccumulatorSaturatesOverflow(t *testing.T) {
resetBatchUpdateTestState(t)
addNewRecord(BatchUpdateTypeUserQuota, 1, math.MaxInt)
addNewRecord(BatchUpdateTypeUserQuota, 1, 1)
batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
assert.Equal(t, math.MaxInt, batchUpdateStores[BatchUpdateTypeUserQuota][1])
batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
batchUpdateStores[BatchUpdateTypeUserQuota] = make(map[int]int)
batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
addNewRecord(BatchUpdateTypeUserQuota, 1, math.MinInt)
addNewRecord(BatchUpdateTypeUserQuota, 1, -1)
batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
assert.Equal(t, math.MinInt, batchUpdateStores[BatchUpdateTypeUserQuota][1])
batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
}
func TestReserveFallsBackToDatabaseWhenRedisIsUnavailable(t *testing.T) {
truncateTables(t)
resetBatchUpdateTestState(t)
server := useUserCacheMiniRedis(t)
user := createReserveTestUser(t, 20)
require.NoError(t, populateUserCache(user))
server.Close()
// Redis 故障时降级为数据库条件更新:服务保持可用且不会超扣。
reserved, err := TryReserveUserQuota(user.Id, 5)
require.NoError(t, err)
assert.True(t, reserved)
assert.Equal(t, 15, getUserQuotaFromDB(t, user.Id))
reserved, err = TryReserveUserQuota(user.Id, 16)
require.NoError(t, err)
assert.False(t, reserved)
assert.Equal(t, 15, getUserQuotaFromDB(t, user.Id))
}
func TestSynchronousReserveCompensatesCacheWhenPersistenceFails(t *testing.T) {
truncateTables(t)
resetBatchUpdateTestState(t)
useUserCacheMiniRedis(t)
user := createReserveTestUser(t, 10)
require.NoError(t, populateUserCache(user))
require.NoError(t, DB.Delete(&user).Error)
reserved, err := TryReserveUserQuota(user.Id, 6)
assert.False(t, reserved)
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
cached, cacheErr := cacheGetUserBase(user.Id)
require.NoError(t, cacheErr)
assert.Equal(t, 10, cached.Quota)
token := createReserveTestToken(t, 12)
_, err = GetTokenByKey(token.Key, true)
require.NoError(t, err)
require.NoError(t, DB.Delete(&token).Error)
reserved, err = TryReserveTokenQuota(token.Id, token.Key, 7, false)
assert.False(t, reserved)
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
cachedToken, cacheErr := cacheGetTokenByKey(token.Key)
require.NoError(t, cacheErr)
assert.Equal(t, 12, cachedToken.RemainQuota)
assert.Zero(t, cachedToken.UsedQuota)
}
func TestTokenCacheInitPreservesLiveQuotaAndFenceBlocksStaleSnapshot(t *testing.T) {
truncateTables(t)
resetBatchUpdateTestState(t)
server := useUserCacheMiniRedis(t)
token := createReserveTestToken(t, 100)
loaded, err := GetTokenByKey(token.Key, true)
require.NoError(t, err)
stale := *loaded
result, err := cacheApplyTokenQuotaDelta(token.Id, token.Key, -70)
require.NoError(t, err)
require.Equal(t, cacheQuotaOK, result)
// 已存在的哈希只刷新 TTL:数据库快照不得覆盖已被原子预扣的余额。
code, err := cacheInitToken(stale)
require.NoError(t, err)
assert.Equal(t, 2, code)
cached, err := cacheGetTokenByKey(token.Key)
require.NoError(t, err)
assert.Equal(t, 30, cached.RemainQuota)
// 变更期间:fence 删除缓存并拦截并发读者手中的过期快照。
require.NoError(t, invalidateTokenCacheForMutation(token.Key))
code, err = cacheInitToken(stale)
require.NoError(t, err)
assert.Zero(t, code, "the pre-mutation snapshot must not be published while fenced")
_, err = cacheGetTokenByKey(token.Key)
assert.Error(t, err)
// fence 过期后可重新从数据库水合。
server.FastForward(time.Duration(tokenCacheFenceSeconds+1) * time.Second)
fresh, err := GetTokenByKey(token.Key, false)
require.NoError(t, err)
assert.Equal(t, 100, fresh.RemainQuota)
cached, err = cacheGetTokenByKey(token.Key)
require.NoError(t, err)
assert.Equal(t, 100, cached.RemainQuota)
}
+255
View File
@@ -0,0 +1,255 @@
package model
import (
"errors"
"fmt"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"gorm.io/gorm"
)
type Redemption struct {
Id int `json:"id"`
UserId int `json:"user_id"`
Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index"`
Quota int `json:"quota" gorm:"default:100"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
Count int `json:"count" gorm:"-:all"` // only for api request
UsedUserId int `json:"used_user_id"`
DeletedAt gorm.DeletedAt `gorm:"index"`
ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期
}
func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
// 开始事务
tx := DB.Begin()
if tx.Error != nil {
return nil, 0, tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 获取总数
err = tx.Model(&Redemption{}).Count(&total).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
// 获取分页数据
err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
// 提交事务
if err = tx.Commit().Error; err != nil {
return nil, 0, err
}
return redemptions, total, nil
}
func SearchRedemptions(keyword string, status string, startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
tx := DB.Begin()
if tx.Error != nil {
return nil, 0, tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
query := tx.Model(&Redemption{})
if keyword != "" {
if id, err := strconv.Atoi(keyword); err == nil {
query = query.Where("id = ? OR name LIKE ?", id, keyword+"%")
} else {
query = query.Where("name LIKE ?", keyword+"%")
}
}
if status != "" {
now := common.GetTimestamp()
switch status {
case "expired":
query = query.Where(
"status = ? AND expired_time != 0 AND expired_time < ?",
common.RedemptionCodeStatusEnabled,
now,
)
case strconv.Itoa(common.RedemptionCodeStatusEnabled):
query = query.Where(
"status = ? AND (expired_time = 0 OR expired_time >= ?)",
common.RedemptionCodeStatusEnabled,
now,
)
case strconv.Itoa(common.RedemptionCodeStatusDisabled):
query = query.Where("status = ?", common.RedemptionCodeStatusDisabled)
case strconv.Itoa(common.RedemptionCodeStatusUsed):
query = query.Where("status = ?", common.RedemptionCodeStatusUsed)
}
}
// Get total count
err = query.Count(&total).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
// Get paginated data
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
if err = tx.Commit().Error; err != nil {
return nil, 0, err
}
return redemptions, total, nil
}
func GetRedemptionById(id int) (*Redemption, error) {
if id == 0 {
return nil, errors.New("id 为空!")
}
redemption := Redemption{Id: id}
var err error = nil
err = DB.First(&redemption, "id = ?", id).Error
return &redemption, err
}
func Redeem(key string, userId int) (quota int, err error) {
if key == "" {
return 0, errors.New("未提供兑换码")
}
if userId == 0 {
return 0, errors.New("无效的 user id")
}
redemption := &Redemption{}
keyCol := "`key`"
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
keyCol = `"key"`
}
common.RandomSleep()
err = DB.Transaction(func(tx *gorm.DB) error {
err := lockForUpdate(tx).Where(keyCol+" = ?", key).First(redemption).Error
if err != nil {
return errors.New("无效的兑换码")
}
if redemption.Status != common.RedemptionCodeStatusEnabled {
return errors.New("该兑换码已被使用")
}
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
return errors.New("该兑换码已过期")
}
// Compare-and-swap on status: only the transaction that flips
// enabled -> used may credit quota, so a concurrent redeem of the
// same code loses here even without a row lock (e.g. on SQLite).
result := tx.Model(&Redemption{}).
Where("id = ? AND status = ?", redemption.Id, common.RedemptionCodeStatusEnabled).
Updates(map[string]any{
"redeemed_time": common.GetTimestamp(),
"status": common.RedemptionCodeStatusUsed,
"used_user_id": userId,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("该兑换码已被使用")
}
return creditTopUpQuota(tx, userId, redemption.Quota, nil)
})
if err != nil {
common.SysError("redemption failed: " + err.Error())
return 0, ErrRedeemFailed
}
syncCreditUserQuotaCache(userId, redemption.Quota, "redemption")
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
return redemption.Quota, nil
}
func (redemption *Redemption) Insert() error {
if redemption.Quota <= 0 {
return errors.New("redemption quota must be positive")
}
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
return err
}
var err error
err = DB.Create(redemption).Error
return err
}
func (redemption *Redemption) SelectUpdate() error {
// This can update zero values
return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error
}
// Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error {
if redemption.Quota <= 0 {
return errors.New("redemption quota must be positive")
}
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
return err
}
var err error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
return err
}
func (redemption *Redemption) Delete() error {
var err error
err = DB.Delete(redemption).Error
return err
}
func DeleteRedemptionById(id int) (err error) {
if id == 0 {
return errors.New("id 为空!")
}
redemption := Redemption{Id: id}
err = DB.Where(redemption).First(&redemption).Error
if err != nil {
return err
}
return redemption.Delete()
}
func DeleteInvalidRedemptions() (int64, error) {
now := common.GetTimestamp()
result := DB.Where("status IN ? OR (status = ? AND expired_time != 0 AND expired_time < ?)", []int{common.RedemptionCodeStatusUsed, common.RedemptionCodeStatusDisabled}, common.RedemptionCodeStatusEnabled, now).Delete(&Redemption{})
return result.RowsAffected, result.Error
}
// BatchDeleteRedemptions soft-deletes the selected codes in one statement.
func BatchDeleteRedemptions(ids []int) (int64, error) {
if len(ids) == 0 || len(ids) > 1000 {
return 0, errors.New("select between 1 and 1000 redemption codes")
}
for _, id := range ids {
if id <= 0 {
return 0, errors.New("redemption IDs must be positive")
}
}
result := DB.Where("id IN ?", ids).Delete(&Redemption{})
return result.RowsAffected, result.Error
}
+210
View File
@@ -0,0 +1,210 @@
package model
import (
"sync"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestSearchRedemptionsFiltersAndPaginates(t *testing.T) {
require.NoError(t, DB.AutoMigrate(&Redemption{}))
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Redemption{}).Error)
t.Cleanup(func() {
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Redemption{}).Error)
})
now := common.GetTimestamp()
redemptions := []Redemption{
{Id: 1, Name: "alpha-active", Key: "00000000000000000000000000000001", Status: common.RedemptionCodeStatusEnabled, ExpiredTime: 0},
{Id: 2, Name: "alpha-future", Key: "00000000000000000000000000000002", Status: common.RedemptionCodeStatusEnabled, ExpiredTime: now + 3600},
{Id: 3, Name: "alpha-expired", Key: "00000000000000000000000000000003", Status: common.RedemptionCodeStatusEnabled, ExpiredTime: now - 10},
{Id: 4, Name: "beta-disabled", Key: "00000000000000000000000000000004", Status: common.RedemptionCodeStatusDisabled, ExpiredTime: 0},
{Id: 5, Name: "beta-used", Key: "00000000000000000000000000000005", Status: common.RedemptionCodeStatusUsed, ExpiredTime: 0},
}
require.NoError(t, DB.Create(&redemptions).Error)
tests := []struct {
name string
keyword string
status string
startIdx int
num int
wantTotal int64
wantIds []int
}{
{
name: "no filters returns all rows",
num: 10,
wantTotal: 5,
wantIds: []int{5, 4, 3, 2, 1},
},
{
name: "keyword filters by name prefix",
keyword: "alpha",
num: 10,
wantTotal: 3,
wantIds: []int{3, 2, 1},
},
{
name: "enabled status excludes expired rows",
status: "1",
num: 10,
wantTotal: 2,
wantIds: []int{2, 1},
},
{
name: "expired status returns enabled expired rows",
status: "expired",
num: 10,
wantTotal: 1,
wantIds: []int{3},
},
{
name: "disabled status",
status: "2",
num: 10,
wantTotal: 1,
wantIds: []int{4},
},
{
name: "used status",
status: "3",
num: 10,
wantTotal: 1,
wantIds: []int{5},
},
{
name: "pagination keeps unpaged total",
startIdx: 1,
num: 2,
wantTotal: 5,
wantIds: []int{4, 3},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rows, total, err := SearchRedemptions(tt.keyword, tt.status, tt.startIdx, tt.num)
require.NoError(t, err)
assert.Equal(t, tt.wantTotal, total)
gotIds := make([]int, 0, len(rows))
for _, row := range rows {
gotIds = append(gotIds, row.Id)
}
assert.Equal(t, tt.wantIds, gotIds)
})
}
}
func setupRedeemFixture(t *testing.T, quota int) (userId int, key string) {
t.Helper()
require.NoError(t, DB.AutoMigrate(&Redemption{}))
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Redemption{}).Error)
t.Cleanup(func() {
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Redemption{}).Error)
DB.Exec("DELETE FROM users")
DB.Exec("DELETE FROM logs")
})
user := &User{Username: "redeem-user", Password: "password", Status: common.UserStatusEnabled, Quota: 0}
require.NoError(t, DB.Create(user).Error)
key = "10000000000000000000000000000001"
redemption := &Redemption{
Name: "redeem-test",
Key: key,
Status: common.RedemptionCodeStatusEnabled,
Quota: quota,
CreatedTime: common.GetTimestamp(),
}
require.NoError(t, DB.Create(redemption).Error)
return user.Id, key
}
func TestRedeemCreditsQuotaExactlyOnce(t *testing.T) {
userId, key := setupRedeemFixture(t, 500)
quota, err := Redeem(key, userId)
require.NoError(t, err)
assert.Equal(t, 500, quota)
var user User
require.NoError(t, DB.First(&user, "id = ?", userId).Error)
assert.Equal(t, 500, user.Quota)
var redemption Redemption
require.NoError(t, DB.First(&redemption, "name = ?", "redeem-test").Error)
assert.Equal(t, common.RedemptionCodeStatusUsed, redemption.Status)
assert.Equal(t, userId, redemption.UsedUserId)
// Redeeming the same code again must fail and must not credit quota.
_, err = Redeem(key, userId)
require.Error(t, err)
require.NoError(t, DB.First(&user, "id = ?", userId).Error)
assert.Equal(t, 500, user.Quota)
}
func TestRedeemRejectsWalletOverflow(t *testing.T) {
userId, key := setupRedeemFixture(t, 11)
require.NoError(t, DB.Model(&User{}).Where("id = ?", userId).Update("quota", common.MaxWalletQuota-10).Error)
_, err := Redeem(key, userId)
require.ErrorIs(t, err, ErrRedeemFailed)
var user User
require.NoError(t, DB.First(&user, "id = ?", userId).Error)
assert.Equal(t, common.MaxWalletQuota-10, user.Quota)
var redemption Redemption
require.NoError(t, DB.First(&redemption, "key = ?", key).Error)
assert.Equal(t, common.RedemptionCodeStatusEnabled, redemption.Status)
}
func TestRedemptionQuotaRejectsWalletOverflow(t *testing.T) {
setupRedeemFixture(t, 500)
redemption := &Redemption{
Name: "overflow-redemption",
Key: "10000000000000000000000000000002",
Status: common.RedemptionCodeStatusEnabled,
Quota: common.MaxWalletQuota + 1,
CreatedTime: common.GetTimestamp(),
}
require.Error(t, redemption.Insert())
}
// Exactly one of several concurrent redeems of the same code may win, and
// quota must be credited exactly once.
func TestRedeemConcurrentSingleSuccess(t *testing.T) {
userId, key := setupRedeemFixture(t, 300)
const goroutines = 5
successes := make([]bool, goroutines)
var wg sync.WaitGroup
wg.Add(goroutines)
for i := range goroutines {
go func(idx int) {
defer wg.Done()
if _, err := Redeem(key, userId); err == nil {
successes[idx] = true
}
}(i)
}
wg.Wait()
successCount := 0
for _, ok := range successes {
if ok {
successCount++
}
}
assert.Equal(t, 1, successCount, "exactly one concurrent redeem should succeed")
var user User
require.NoError(t, DB.First(&user, "id = ?", userId).Error)
assert.Equal(t, 300, user.Quota, "quota must be credited exactly once")
}
+16
View File
@@ -0,0 +1,16 @@
package model
type Setup struct {
ID uint `json:"id" gorm:"primaryKey"`
Version string `json:"version" gorm:"type:varchar(50);not null"`
InitializedAt int64 `json:"initialized_at" gorm:"type:bigint;not null"`
}
func GetSetup() *Setup {
var setup Setup
err := DB.First(&setup).Error
if err != nil {
return nil
}
return &setup
}
File diff suppressed because it is too large. Load diff
+150
View File
@@ -0,0 +1,150 @@
package model
import (
"context"
"errors"
"fmt"
"net"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestSubscriptionGroupTransitionsPreserveAuthVersionAndSessions(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
now := time.Now().Unix()
user := User{
Username: "subscription-auth-user",
Password: "unused-password-hash",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, CreateUserSession(&UserSession{
SID: "subscription-auth-session",
UserID: user.Id,
Version: 1,
UserAuthVersion: 1,
Status: UserSessionStatusActive,
RefreshHash: "refresh-hash",
LoginMethod: "password",
LastActiveAt: now,
ExpiresAt: now + 3600,
}))
require.NoError(t, populateUserCache(user))
plan := &SubscriptionPlan{
Title: "Upgraded",
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 100,
UpgradeGroup: "pro",
Enabled: true,
}
require.NoError(t, DB.Create(plan).Error)
subscription, err := CreateUserSubscriptionFromPlanTx(DB, user.Id, plan, "test")
require.NoError(t, err)
require.Equal(t, "default", subscription.PrevUserGroup)
require.NoError(t, RefreshUserGroupCache(user.Id))
var updated User
require.NoError(t, DB.First(&updated, user.Id).Error)
assert.Equal(t, "pro", updated.Group)
assert.EqualValues(t, 1, updated.AuthVersion)
var session UserSession
require.NoError(t, DB.First(&session, "sid = ?", "subscription-auth-session").Error)
assert.Equal(t, UserSessionStatusActive, session.Status)
cached, err := GetUserCache(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
target, err := downgradeUserGroupForSubscriptionTx(tx, subscription, now+1)
assert.Equal(t, "default", target)
return err
}))
require.NoError(t, RefreshUserGroupCache(user.Id))
require.NoError(t, DB.First(&updated, user.Id).Error)
assert.Equal(t, "default", updated.Group)
assert.EqualValues(t, 1, updated.AuthVersion)
require.NoError(t, DB.First(&session, "sid = ?", "subscription-auth-session").Error)
assert.Equal(t, UserSessionStatusActive, session.Status)
cached, err = GetUserCache(user.Id)
require.NoError(t, err)
assert.Equal(t, "default", cached.Group)
}
func TestSubscriptionGroupCacheRefreshFailureDoesNotChangeCommittedResult(t *testing.T) {
previousDB, previousLogDB := DB, LOG_DB
previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType()
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
DB, LOG_DB = db, db
require.NoError(t, db.AutoMigrate(&User{}, &SubscriptionPlan{}, &UserSubscription{}))
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(4)
t.Cleanup(func() {
DB, LOG_DB = previousDB, previousLogDB
common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType)
_ = sqlDB.Close()
})
user := User{
Username: "subscription-cache-failure",
Password: "unused-password-hash",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
plan := &SubscriptionPlan{
Title: "Cache failure plan",
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 100,
UpgradeGroup: "pro",
Enabled: true,
}
require.NoError(t, DB.Create(plan).Error)
InvalidateSubscriptionPlanCache(plan.Id)
oldRedisEnabled, oldRDB := common.RedisEnabled, common.RDB
common.RedisEnabled = true
common.RDB = redis.NewClient(&redis.Options{
Dialer: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("forced redis failure")
},
MaxRetries: -1,
})
t.Cleanup(func() {
_ = common.RDB.Close()
common.RedisEnabled, common.RDB = oldRedisEnabled, oldRDB
})
message, err := AdminBindSubscription(user.Id, plan.Id, "test")
require.NoError(t, err)
assert.Contains(t, message, "pro")
var updated User
require.NoError(t, DB.First(&updated, user.Id).Error)
assert.Equal(t, "pro", updated.Group)
assert.EqualValues(t, 1, updated.AuthVersion)
var subscription UserSubscription
require.NoError(t, DB.Where("user_id = ?", user.Id).First(&subscription).Error)
assert.Equal(t, "active", subscription.Status)
}
+201
View File
@@ -0,0 +1,201 @@
package model
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func seedSubscriptionResetPlan(t *testing.T, plan *SubscriptionPlan) {
t.Helper()
require.NoError(t, DB.Create(plan).Error)
}
func seedSubscriptionResetSub(t *testing.T, sub *UserSubscription) {
t.Helper()
require.NoError(t, DB.Create(sub).Error)
}
func getSubscriptionResetSub(t *testing.T, id int) UserSubscription {
t.Helper()
var sub UserSubscription
require.NoError(t, DB.Where("id = ?", id).First(&sub).Error)
return sub
}
func TestAdminResetUserSubscriptionsByPlanResetsAllActiveMatchesAndAdvancesTime(t *testing.T) {
truncateTables(t)
now := GetDBTimestamp()
plan := &SubscriptionPlan{
Id: 9101,
Title: "Pro",
PriceAmount: 10,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 1000,
QuotaResetPeriod: SubscriptionResetDaily,
}
otherPlan := &SubscriptionPlan{
Id: 9102,
Title: "Basic",
PriceAmount: 1,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 100,
QuotaResetPeriod: SubscriptionResetDaily,
}
seedSubscriptionResetPlan(t, plan)
seedSubscriptionResetPlan(t, otherPlan)
activeEnd := now + 30*24*3600
expiredEnd := now - 1
seedSubscriptionResetSub(t, &UserSubscription{Id: 9201, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 300, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9202, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 500, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9203, UserId: 101, PlanId: otherPlan.Id, AmountTotal: 100, AmountUsed: 60, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9204, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 700, StartTime: now - 7200, EndTime: expiredEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now - 10})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9205, UserId: 102, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 800, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9206, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 900, StartTime: now - 3600, EndTime: activeEnd, Status: "cancelled", LastResetTime: now - 3600, NextResetTime: now + 120})
beforeReset := GetDBTimestamp()
result, err := AdminResetUserSubscriptionsByPlan(101, plan.Id, true)
afterReset := GetDBTimestamp()
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, plan.Id, result.PlanId)
assert.Equal(t, 2, result.MatchedCount)
assert.Equal(t, 2, result.ResetCount)
assert.Equal(t, 1, result.UserCount)
assert.Equal(t, []int{101}, result.AffectedUserIds)
assert.True(t, result.AdvanceResetTime)
for _, id := range []int{9201, 9202} {
sub := getSubscriptionResetSub(t, id)
assert.Zero(t, sub.AmountUsed)
assert.GreaterOrEqual(t, sub.LastResetTime, beforeReset)
assert.LessOrEqual(t, sub.LastResetTime, afterReset)
assert.Equal(t, calcNextResetTime(time.Unix(sub.LastResetTime, 0), plan, sub.EndTime), sub.NextResetTime)
}
assert.EqualValues(t, 60, getSubscriptionResetSub(t, 9203).AmountUsed)
assert.EqualValues(t, 700, getSubscriptionResetSub(t, 9204).AmountUsed)
assert.EqualValues(t, 800, getSubscriptionResetSub(t, 9205).AmountUsed)
assert.EqualValues(t, 900, getSubscriptionResetSub(t, 9206).AmountUsed)
}
func TestAdminResetUserSubscriptionsByPlanKeepsResetTimes(t *testing.T) {
truncateTables(t)
now := GetDBTimestamp()
plan := &SubscriptionPlan{
Id: 9301,
Title: "Team",
PriceAmount: 20,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 2000,
QuotaResetPeriod: SubscriptionResetMonthly,
}
seedSubscriptionResetPlan(t, plan)
lastReset := now - 86400
nextReset := now + 86400
seedSubscriptionResetSub(t, &UserSubscription{Id: 9302, UserId: 201, PlanId: plan.Id, AmountTotal: 2000, AmountUsed: 1200, StartTime: now - 172800, EndTime: now + 30*24*3600, Status: "active", LastResetTime: lastReset, NextResetTime: nextReset})
result, err := AdminResetUserSubscriptionsByPlan(201, plan.Id, false)
require.NoError(t, err)
assert.False(t, result.AdvanceResetTime)
sub := getSubscriptionResetSub(t, 9302)
assert.Zero(t, sub.AmountUsed)
assert.Equal(t, lastReset, sub.LastResetTime)
assert.Equal(t, nextReset, sub.NextResetTime)
}
func TestAdminResetUserSubscriptionsByPlanNoActiveMatchReturnsError(t *testing.T) {
truncateTables(t)
now := GetDBTimestamp()
plan := &SubscriptionPlan{
Id: 9401,
Title: "Expired",
PriceAmount: 10,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 1000,
}
seedSubscriptionResetPlan(t, plan)
seedSubscriptionResetSub(t, &UserSubscription{Id: 9402, UserId: 301, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 500, StartTime: now - 7200, EndTime: now - 1, Status: "active"})
result, err := AdminResetUserSubscriptionsByPlan(301, plan.Id, true)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, strings.Contains(err.Error(), "该用户没有有效的此套餐订阅"))
}
func TestAdminResetPlanSubscriptionsResetsAllActiveUsers(t *testing.T) {
truncateTables(t)
now := GetDBTimestamp()
plan := &SubscriptionPlan{
Id: 9501,
Title: "Business",
PriceAmount: 30,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 3000,
QuotaResetPeriod: SubscriptionResetNever,
}
seedSubscriptionResetPlan(t, plan)
activeEnd := now + 30*24*3600
seedSubscriptionResetSub(t, &UserSubscription{Id: 9502, UserId: 401, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1000, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9503, UserId: 401, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1100, StartTime: now - 3500, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9504, UserId: 402, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1200, StartTime: now - 3400, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9505, UserId: 403, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1300, StartTime: now - 7200, EndTime: now - 1, Status: "active", LastResetTime: now - 3600, NextResetTime: now - 10})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9506, UserId: 404, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1400, StartTime: now - 3600, EndTime: activeEnd, Status: "cancelled", LastResetTime: now - 3600, NextResetTime: now + 10})
result, err := AdminResetPlanSubscriptions(plan.Id, true)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, 3, result.MatchedCount)
assert.Equal(t, 3, result.ResetCount)
assert.Equal(t, 2, result.UserCount)
assert.Equal(t, []int{401, 402}, result.AffectedUserIds)
for _, id := range []int{9502, 9503, 9504} {
sub := getSubscriptionResetSub(t, id)
assert.Zero(t, sub.AmountUsed)
assert.Zero(t, sub.LastResetTime)
assert.Zero(t, sub.NextResetTime)
}
assert.EqualValues(t, 1300, getSubscriptionResetSub(t, 9505).AmountUsed)
assert.EqualValues(t, 1400, getSubscriptionResetSub(t, 9506).AmountUsed)
}
func TestAdminResetPlanSubscriptionsNoMatchSucceeds(t *testing.T) {
truncateTables(t)
plan := &SubscriptionPlan{
Id: 9601,
Title: "Empty",
PriceAmount: 10,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 1000,
}
seedSubscriptionResetPlan(t, plan)
result, err := AdminResetPlanSubscriptions(plan.Id, true)
require.NoError(t, err)
require.NotNil(t, result)
assert.Zero(t, result.MatchedCount)
assert.Zero(t, result.ResetCount)
assert.Zero(t, result.UserCount)
assert.Empty(t, result.AffectedUserIds)
}
+123
View File
@@ -0,0 +1,123 @@
package model
import (
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
SystemInstanceStatusOnline = "online"
SystemInstanceStatusStale = "stale"
SystemInstanceStaleAfterSeconds int64 = 90
)
type SystemInstance struct {
NodeName string `json:"node_name" gorm:"type:varchar(128);primaryKey"`
Info string `json:"info" gorm:"type:text"`
StartedAt int64 `json:"started_at" gorm:"bigint;index"`
LastSeenAt int64 `json:"last_seen_at" gorm:"bigint;index"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint;index"`
}
type SystemInstanceResponse struct {
NodeName string `json:"node_name"`
Status string `json:"status"`
StaleAfterSeconds int64 `json:"stale_after_seconds"`
StartedAt int64 `json:"started_at"`
LastSeenAt int64 `json:"last_seen_at"`
Info any `json:"info"`
}
func (instance *SystemInstance) BeforeCreate(_ *gorm.DB) error {
now := common.GetTimestamp()
if instance.CreatedAt == 0 {
instance.CreatedAt = now
}
if instance.UpdatedAt == 0 {
instance.UpdatedAt = now
}
return nil
}
func UpsertSystemInstance(nodeName string, info any, startedAt int64, lastSeenAt int64) error {
infoText, err := marshalSystemInstanceInfo(info)
if err != nil {
return err
}
if lastSeenAt == 0 {
lastSeenAt = common.GetTimestamp()
}
instance := &SystemInstance{
NodeName: nodeName,
Info: infoText,
StartedAt: startedAt,
LastSeenAt: lastSeenAt,
UpdatedAt: lastSeenAt,
}
return DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "node_name"}},
DoUpdates: clause.AssignmentColumns([]string{
"info",
"started_at",
"last_seen_at",
"updated_at",
}),
}).Create(instance).Error
}
func ListSystemInstances() ([]*SystemInstance, error) {
var instances []*SystemInstance
err := DB.Order("last_seen_at desc").Find(&instances).Error
return instances, err
}
func DeleteStaleSystemInstances(now int64) (int64, error) {
result := DB.Where("last_seen_at < ?", now-SystemInstanceStaleAfterSeconds).Delete(&SystemInstance{})
return result.RowsAffected, result.Error
}
func DeleteStaleSystemInstance(nodeName string, now int64) (bool, error) {
result := DB.Where("node_name = ? AND last_seen_at < ?", nodeName, now-SystemInstanceStaleAfterSeconds).Delete(&SystemInstance{})
return result.RowsAffected > 0, result.Error
}
func (instance *SystemInstance) ToResponse(now int64) SystemInstanceResponse {
status := SystemInstanceStatusOnline
if now-instance.LastSeenAt > SystemInstanceStaleAfterSeconds {
status = SystemInstanceStatusStale
}
return SystemInstanceResponse{
NodeName: instance.NodeName,
Status: status,
StaleAfterSeconds: SystemInstanceStaleAfterSeconds,
StartedAt: instance.StartedAt,
LastSeenAt: instance.LastSeenAt,
Info: decodeSystemInstanceInfo(instance.Info),
}
}
func marshalSystemInstanceInfo(v any) (string, error) {
if v == nil {
return "", nil
}
data, err := common.Marshal(v)
if err != nil {
return "", err
}
return string(data), nil
}
func decodeSystemInstanceInfo(data string) any {
if data == "" {
return nil
}
var value any
if err := common.UnmarshalJsonStr(data, &value); err != nil {
return data
}
return value
}
+480
View File
@@ -0,0 +1,480 @@
package model
import (
"errors"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
type SystemTaskStatus string
const (
SystemTaskStatusPending SystemTaskStatus = "pending"
SystemTaskStatusRunning SystemTaskStatus = "running"
SystemTaskStatusSucceeded SystemTaskStatus = "succeeded"
SystemTaskStatusFailed SystemTaskStatus = "failed"
SystemTaskTypeLogCleanup = "log_cleanup"
SystemTaskTypeChannelTest = "channel_test"
SystemTaskTypeModelUpdate = "model_update"
SystemTaskTypeMidjourneyPoll = "midjourney_poll"
SystemTaskTypeAsyncTaskPoll = "async_task_poll"
)
var ErrSystemTaskLockLost = errors.New("system task lock lost")
type SystemTask struct {
ID int64 `json:"id" gorm:"primary_key"`
TaskID string `json:"task_id" gorm:"type:varchar(64);uniqueIndex"`
Type string `json:"type" gorm:"type:varchar(64);index"`
Status SystemTaskStatus `json:"status" gorm:"type:varchar(32);index"`
ActiveKey *string `json:"active_key,omitempty" gorm:"type:varchar(64);uniqueIndex"`
Payload string `json:"payload" gorm:"type:text"`
State string `json:"state" gorm:"type:text"`
Result string `json:"result" gorm:"type:text"`
Error string `json:"error" gorm:"type:text"`
LockedBy string `json:"locked_by" gorm:"type:varchar(128);index"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint;index"`
}
type SystemTaskLock struct {
Type string `json:"type" gorm:"type:varchar(64);primaryKey"`
TaskID string `json:"task_id" gorm:"type:varchar(64);index"`
LockedBy string `json:"locked_by" gorm:"type:varchar(128);index"`
LockedUntil int64 `json:"locked_until" gorm:"bigint;index"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint;index"`
}
type SystemTaskResponse struct {
ID int64 `json:"id"`
TaskID string `json:"task_id"`
Type string `json:"type"`
Status SystemTaskStatus `json:"status"`
ActiveKey *string `json:"active_key,omitempty"`
Payload any `json:"payload"`
State any `json:"state"`
Result any `json:"result"`
Error string `json:"error"`
LockedBy string `json:"locked_by"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
func (task *SystemTask) BeforeCreate(_ *gorm.DB) error {
now := common.GetTimestamp()
if task.CreatedAt == 0 {
task.CreatedAt = now
}
if task.UpdatedAt == 0 {
task.UpdatedAt = now
}
return nil
}
func (lock *SystemTaskLock) BeforeCreate(_ *gorm.DB) error {
if lock.UpdatedAt == 0 {
lock.UpdatedAt = common.GetTimestamp()
}
return nil
}
func GenerateSystemTaskID() (string, error) {
key, err := common.GenerateRandomCharsKey(32)
if err != nil {
return "", err
}
return "systask_" + key, nil
}
func CreateSystemTask(taskType string, payload any, state any) (*SystemTask, error) {
taskID, err := GenerateSystemTaskID()
if err != nil {
return nil, err
}
payloadText, err := marshalSystemTaskJSON(payload)
if err != nil {
return nil, err
}
stateText, err := marshalSystemTaskJSON(state)
if err != nil {
return nil, err
}
task := &SystemTask{
TaskID: taskID,
Type: taskType,
Status: SystemTaskStatusPending,
ActiveKey: &taskType,
Payload: payloadText,
State: stateText,
}
if err := DB.Create(task).Error; err != nil {
return nil, err
}
return task, nil
}
func GetSystemTaskByTaskID(taskID string) (*SystemTask, error) {
var task SystemTask
if err := DB.Where("task_id = ?", taskID).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &task, nil
}
func GetActiveSystemTask(taskType string) (*SystemTask, error) {
var task SystemTask
err := DB.Where("type = ? AND status IN ?", taskType, activeSystemTaskStatuses()).
Order("id desc").
First(&task).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &task, nil
}
func FindPendingSystemTasks(taskType string, limit int) ([]*SystemTask, error) {
var tasks []*SystemTask
if limit <= 0 {
limit = 1
}
err := DB.Where("type = ? AND status = ?", taskType, SystemTaskStatusPending).
Order("id asc").
Limit(limit).
Find(&tasks).Error
return tasks, err
}
func FindEarliestPendingSystemTasks(taskTypes []string) (map[string]*SystemTask, error) {
tasksByType := map[string]*SystemTask{}
if len(taskTypes) == 0 {
return tasksByType, nil
}
subQuery := DB.Model(&SystemTask{}).
Select("MIN(id)").
Where("type IN ? AND status = ?", taskTypes, SystemTaskStatusPending).
Group("type")
var tasks []*SystemTask
if err := DB.Where("id IN (?)", subQuery).Find(&tasks).Error; err != nil {
return nil, err
}
for _, task := range tasks {
tasksByType[task.Type] = task
}
return tasksByType, nil
}
func ListSystemTasks(limit int) ([]*SystemTask, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
var tasks []*SystemTask
err := DB.Order("id desc").Limit(limit).Find(&tasks).Error
return tasks, err
}
// GetLatestSystemTask returns the most recent task row of the given type
// (any status) so the scheduler can decide whether enough time has elapsed
// since the last run. Returns (nil, nil) when no row exists.
func GetLatestSystemTask(taskType string) (*SystemTask, error) {
var task SystemTask
err := DB.Where("type = ?", taskType).Order("id desc").First(&task).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &task, nil
}
func GetLatestSystemTasks(taskTypes []string) (map[string]*SystemTask, error) {
tasksByType := map[string]*SystemTask{}
if len(taskTypes) == 0 {
return tasksByType, nil
}
subQuery := DB.Model(&SystemTask{}).
Select("MAX(id)").
Where("type IN ?", taskTypes).
Group("type")
var tasks []*SystemTask
if err := DB.Where("id IN (?)", subQuery).Find(&tasks).Error; err != nil {
return nil, err
}
for _, task := range tasks {
tasksByType[task.Type] = task
}
return tasksByType, nil
}
func ClaimSystemTask(id int64, taskType string, runnerID string, lockUntil int64) (*SystemTask, bool, error) {
now := common.GetTimestamp()
var task SystemTask
if err := DB.Where("id = ? AND type = ? AND status = ?", id, taskType, SystemTaskStatusPending).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, nil
}
return nil, false, err
}
acquired, expiredTaskID, err := acquireSystemTaskLock(taskType, task.TaskID, runnerID, now, lockUntil)
if err != nil || !acquired {
return nil, acquired, err
}
if expiredTaskID != "" && expiredTaskID != task.TaskID {
if err := MarkSystemTaskLeaseExpired(expiredTaskID); err != nil {
_ = ReleaseSystemTaskLock(task.TaskID, runnerID)
return nil, false, err
}
}
result := DB.Model(&SystemTask{}).
Where("id = ? AND type = ? AND status = ?", id, taskType, SystemTaskStatusPending).
Updates(map[string]any{
"status": SystemTaskStatusRunning,
"locked_by": runnerID,
"updated_at": now,
})
if result.Error != nil {
_ = ReleaseSystemTaskLock(task.TaskID, runnerID)
return nil, false, result.Error
}
if result.RowsAffected == 0 {
_ = ReleaseSystemTaskLock(task.TaskID, runnerID)
return nil, false, nil
}
if err := DB.Where("id = ?", id).First(&task).Error; err != nil {
return nil, false, err
}
return &task, true, nil
}
func acquireSystemTaskLock(taskType string, taskID string, lockedBy string, now int64, lockUntil int64) (bool, string, error) {
lock := &SystemTaskLock{
Type: taskType,
TaskID: taskID,
LockedBy: lockedBy,
LockedUntil: lockUntil,
UpdatedAt: now,
}
if err := DB.Create(lock).Error; err == nil {
return true, "", nil
}
var existing SystemTaskLock
err := DB.Where("type = ?", taskType).First(&existing).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, "", nil
}
return false, "", err
}
if existing.LockedUntil >= now {
return false, "", nil
}
result := DB.Model(&SystemTaskLock{}).
Where("type = ? AND locked_until < ?", taskType, now).
Updates(map[string]any{
"task_id": taskID,
"locked_by": lockedBy,
"locked_until": lockUntil,
"updated_at": now,
})
if result.Error != nil {
return false, "", result.Error
}
if result.RowsAffected == 0 {
return false, "", nil
}
return true, existing.TaskID, nil
}
func UpdateSystemTaskState(taskID string, lockedBy string, state any) error {
stateText, err := marshalSystemTaskJSON(state)
if err != nil {
return err
}
now := common.GetTimestamp()
result := DB.Model(&SystemTask{}).
Where("task_id = ? AND status = ? AND locked_by = ?", taskID, SystemTaskStatusRunning, lockedBy).
Where("EXISTS (SELECT 1 FROM system_task_locks WHERE system_task_locks.task_id = system_tasks.task_id AND system_task_locks.locked_by = ? AND system_task_locks.locked_until >= ?)", lockedBy, now).
Updates(map[string]any{
"state": stateText,
"updated_at": now,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
return nil
}
// MySQL counts changed rows, not matched rows. A no-op persist of the same
// state in the same second therefore returns RowsAffected == 0 even while
// the lease is still held. Confirm the lock before treating this as loss.
// Reuse `now` from the UPDATE so a clock tick cannot reintroduce false
// lock-loss; a lease that expires during the write is caught by the next heartbeat.
var held int64
err = DB.Model(&SystemTask{}).
Where("task_id = ? AND status = ? AND locked_by = ?", taskID, SystemTaskStatusRunning, lockedBy).
Where("EXISTS (SELECT 1 FROM system_task_locks WHERE system_task_locks.task_id = system_tasks.task_id AND system_task_locks.locked_by = ? AND system_task_locks.locked_until >= ?)", lockedBy, now).
Count(&held).Error
if err != nil {
return err
}
if held == 0 {
return ErrSystemTaskLockLost
}
return nil
}
func RenewSystemTaskLock(taskID string, lockedBy string, lockUntil int64) error {
now := common.GetTimestamp()
result := DB.Model(&SystemTaskLock{}).
Where("task_id = ? AND locked_by = ? AND locked_until >= ?", taskID, lockedBy, now).
Updates(map[string]any{
"locked_until": lockUntil,
"updated_at": now,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrSystemTaskLockLost
}
return nil
}
func MarkSystemTaskLeaseExpired(taskID string) error {
result := DB.Model(&SystemTask{}).
Where("task_id = ? AND status = ?", taskID, SystemTaskStatusRunning).
Updates(map[string]any{
"status": SystemTaskStatusFailed,
"active_key": nil,
"error": "task lease expired",
"updated_at": common.GetTimestamp(),
})
return result.Error
}
func ExpireStaleSystemTaskLocks(now int64) error {
var locks []*SystemTaskLock
if err := DB.Where("locked_until < ?", now).Find(&locks).Error; err != nil {
return err
}
for _, lock := range locks {
if err := MarkSystemTaskLeaseExpired(lock.TaskID); err != nil {
return err
}
result := DB.Where("type = ? AND task_id = ? AND locked_by = ? AND locked_until < ?", lock.Type, lock.TaskID, lock.LockedBy, now).
Delete(&SystemTaskLock{})
if result.Error != nil {
return result.Error
}
}
return nil
}
func ReleaseSystemTaskLock(taskID string, lockedBy string) error {
result := DB.Where("task_id = ? AND locked_by = ?", taskID, lockedBy).Delete(&SystemTaskLock{})
return result.Error
}
func FinishSystemTask(taskID string, lockedBy string, status SystemTaskStatus, resultPayload any, errorMessage string) error {
resultText, err := marshalSystemTaskJSON(resultPayload)
if err != nil {
return err
}
now := common.GetTimestamp()
result := DB.Model(&SystemTask{}).
Where("task_id = ? AND status = ? AND locked_by = ?", taskID, SystemTaskStatusRunning, lockedBy).
Where("EXISTS (SELECT 1 FROM system_task_locks WHERE system_task_locks.task_id = system_tasks.task_id AND system_task_locks.locked_by = ? AND system_task_locks.locked_until >= ?)", lockedBy, now).
Updates(map[string]any{
"status": status,
"active_key": nil,
"result": resultText,
"error": errorMessage,
"updated_at": now,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrSystemTaskLockLost
}
return ReleaseSystemTaskLock(taskID, lockedBy)
}
func (task *SystemTask) DecodePayload(v any) error {
return decodeSystemTaskJSONString(task.Payload, v)
}
func (task *SystemTask) DecodeState(v any) error {
return decodeSystemTaskJSONString(task.State, v)
}
func (task *SystemTask) ToResponse() SystemTaskResponse {
return SystemTaskResponse{
ID: task.ID,
TaskID: task.TaskID,
Type: task.Type,
Status: task.Status,
ActiveKey: task.ActiveKey,
Payload: decodeSystemTaskJSONValue(task.Payload),
State: decodeSystemTaskJSONValue(task.State),
Result: decodeSystemTaskJSONValue(task.Result),
Error: task.Error,
LockedBy: task.LockedBy,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
}
}
func activeSystemTaskStatuses() []string {
return []string{string(SystemTaskStatusPending), string(SystemTaskStatusRunning)}
}
func marshalSystemTaskJSON(v any) (string, error) {
if v == nil {
return "", nil
}
data, err := common.Marshal(v)
if err != nil {
return "", err
}
return string(data), nil
}
func decodeSystemTaskJSONString(data string, v any) error {
if data == "" {
return nil
}
return common.UnmarshalJsonStr(data, v)
}
func decodeSystemTaskJSONValue(data string) any {
if data == "" {
return nil
}
var value any
if err := common.UnmarshalJsonStr(data, &value); err != nil {
return data
}
return value
}
+383
View File
@@ -0,0 +1,383 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testSystemTaskPayload struct {
TargetTimestamp int64 `json:"target_timestamp"`
BatchSize int `json:"batch_size"`
}
type testSystemTaskState struct {
Total int64 `json:"total"`
Processed int64 `json:"processed"`
Progress int `json:"progress"`
Remaining int64 `json:"remaining"`
}
func createLegacyPendingSystemTask(t *testing.T, taskType string) *SystemTask {
t.Helper()
taskID, err := GenerateSystemTaskID()
require.NoError(t, err)
task := &SystemTask{
TaskID: taskID,
Type: taskType,
Status: SystemTaskStatusPending,
}
require.NoError(t, DB.Create(task).Error)
return task
}
func TestSystemTaskCreateAndActiveLifecycle(t *testing.T) {
truncateTables(t)
payload := testSystemTaskPayload{TargetTimestamp: 1000, BatchSize: 100}
state := testSystemTaskState{}
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, payload, state)
require.NoError(t, err)
require.NotNil(t, task.ActiveKey)
assert.Equal(t, SystemTaskTypeLogCleanup, *task.ActiveKey)
var decodedPayload testSystemTaskPayload
require.NoError(t, task.DecodePayload(&decodedPayload))
assert.Equal(t, payload, decodedPayload)
activeTask, err := GetActiveSystemTask(SystemTaskTypeLogCleanup)
require.NoError(t, err)
require.NotNil(t, activeTask)
assert.Equal(t, task.TaskID, activeTask.TaskID)
runnerID := "runner-a"
claimedTask, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
err = FinishSystemTask(claimedTask.TaskID, runnerID, SystemTaskStatusSucceeded, map[string]int64{"deleted_count": 0}, "")
require.NoError(t, err)
finishedTask, err := GetSystemTaskByTaskID(task.TaskID)
require.NoError(t, err)
require.NotNil(t, finishedTask)
assert.Nil(t, finishedTask.ActiveKey)
activeTask, err = GetActiveSystemTask(SystemTaskTypeLogCleanup)
require.NoError(t, err)
require.Nil(t, activeTask)
_, err = CreateSystemTask(SystemTaskTypeLogCleanup, payload, state)
require.NoError(t, err)
}
func TestSystemTaskActiveKeyPreventsDuplicateActiveRun(t *testing.T) {
truncateTables(t)
payload := testSystemTaskPayload{TargetTimestamp: 1000, BatchSize: 100}
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, payload, testSystemTaskState{})
require.NoError(t, err)
_, err = CreateSystemTask(SystemTaskTypeLogCleanup, payload, testSystemTaskState{})
require.Error(t, err)
activeTask, err := GetActiveSystemTask(SystemTaskTypeLogCleanup)
require.NoError(t, err)
require.NotNil(t, activeTask)
assert.Equal(t, task.TaskID, activeTask.TaskID)
}
func TestSystemTaskLockPreventsConcurrentClaim(t *testing.T) {
truncateTables(t)
payload := testSystemTaskPayload{TargetTimestamp: 1000, BatchSize: 100}
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, payload, testSystemTaskState{})
require.NoError(t, err)
secondTask := createLegacyPendingSystemTask(t, SystemTaskTypeLogCleanup)
claimedTask, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
_, claimed, err = ClaimSystemTask(secondTask.ID, SystemTaskTypeLogCleanup, "runner-b", common.GetTimestamp()+60)
require.NoError(t, err)
require.False(t, claimed)
assert.Equal(t, "runner-a", claimedTask.LockedBy)
reloadedSecond, err := GetSystemTaskByTaskID(secondTask.TaskID)
require.NoError(t, err)
require.NotNil(t, reloadedSecond)
assert.Equal(t, SystemTaskStatusPending, reloadedSecond.Status)
}
func TestExpiredSystemTaskLockFailsOldRunAndClaimsLegacyPendingRun(t *testing.T) {
truncateTables(t)
first, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
_, claimed, err := ClaimSystemTask(first.ID, SystemTaskTypeLogCleanup, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, DB.Model(&SystemTaskLock{}).
Where("task_id = ?", first.TaskID).
Update("locked_until", common.GetTimestamp()-1).Error)
second := createLegacyPendingSystemTask(t, SystemTaskTypeLogCleanup)
claimedTask, claimed, err := ClaimSystemTask(second.ID, SystemTaskTypeLogCleanup, "runner-b", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
assert.Equal(t, second.TaskID, claimedTask.TaskID)
assert.Equal(t, "runner-b", claimedTask.LockedBy)
reloadedFirst, err := GetSystemTaskByTaskID(first.TaskID)
require.NoError(t, err)
require.NotNil(t, reloadedFirst)
assert.Equal(t, SystemTaskStatusFailed, reloadedFirst.Status)
assert.Equal(t, "task lease expired", reloadedFirst.Error)
assert.Nil(t, reloadedFirst.ActiveKey)
}
func TestExpireStaleSystemTaskLockFailsOldRunAndAllowsNewRun(t *testing.T) {
truncateTables(t)
first, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
_, claimed, err := ClaimSystemTask(first.ID, SystemTaskTypeLogCleanup, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, DB.Model(&SystemTaskLock{}).
Where("task_id = ?", first.TaskID).
Update("locked_until", common.GetTimestamp()-1).Error)
require.NoError(t, ExpireStaleSystemTaskLocks(common.GetTimestamp()))
reloadedFirst, err := GetSystemTaskByTaskID(first.TaskID)
require.NoError(t, err)
require.NotNil(t, reloadedFirst)
assert.Equal(t, SystemTaskStatusFailed, reloadedFirst.Status)
assert.Equal(t, "task lease expired", reloadedFirst.Error)
assert.Nil(t, reloadedFirst.ActiveKey)
var lockCount int64
require.NoError(t, DB.Model(&SystemTaskLock{}).Where("task_id = ?", first.TaskID).Count(&lockCount).Error)
assert.Equal(t, int64(0), lockCount)
second, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
require.NotEqual(t, first.TaskID, second.TaskID)
}
func TestFindEarliestPendingSystemTasks(t *testing.T) {
truncateTables(t)
empty, err := FindEarliestPendingSystemTasks(nil)
require.NoError(t, err)
assert.Empty(t, empty)
firstA, err := CreateSystemTask("type_a", nil, nil)
require.NoError(t, err)
ignoredB, err := CreateSystemTask("type_b", nil, nil)
require.NoError(t, err)
_, claimed, err := ClaimSystemTask(ignoredB.ID, "type_b", "runner-b", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, FinishSystemTask(ignoredB.TaskID, "runner-b", SystemTaskStatusFailed, nil, "failed"))
firstB, err := CreateSystemTask("type_b", nil, nil)
require.NoError(t, err)
ignoredC, err := CreateSystemTask("type_c", nil, nil)
require.NoError(t, err)
_, claimed, err = ClaimSystemTask(ignoredC.ID, "type_c", "runner-c", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, FinishSystemTask(ignoredC.TaskID, "runner-c", SystemTaskStatusFailed, nil, "failed"))
tasks, err := FindEarliestPendingSystemTasks([]string{"type_a", "type_b", "type_c", "missing"})
require.NoError(t, err)
require.Len(t, tasks, 2)
assert.Equal(t, firstA.TaskID, tasks["type_a"].TaskID)
assert.Equal(t, firstB.TaskID, tasks["type_b"].TaskID)
assert.Nil(t, tasks["type_c"])
assert.Nil(t, tasks["missing"])
}
func TestGetLatestSystemTask(t *testing.T) {
truncateTables(t)
latest, err := GetLatestSystemTask(SystemTaskTypeChannelTest)
require.NoError(t, err)
require.Nil(t, latest)
first, err := CreateSystemTask(SystemTaskTypeChannelTest, nil, nil)
require.NoError(t, err)
runnerID := "runner-a"
_, claimed, err := ClaimSystemTask(first.ID, SystemTaskTypeChannelTest, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, FinishSystemTask(first.TaskID, runnerID, SystemTaskStatusSucceeded, nil, ""))
second, err := CreateSystemTask(SystemTaskTypeChannelTest, nil, nil)
require.NoError(t, err)
latest, err = GetLatestSystemTask(SystemTaskTypeChannelTest)
require.NoError(t, err)
require.NotNil(t, latest)
assert.Equal(t, second.TaskID, latest.TaskID)
}
func TestGetLatestSystemTasks(t *testing.T) {
truncateTables(t)
empty, err := GetLatestSystemTasks(nil)
require.NoError(t, err)
assert.Empty(t, empty)
firstA, err := CreateSystemTask("type_a", nil, nil)
require.NoError(t, err)
firstB, err := CreateSystemTask("type_b", nil, nil)
require.NoError(t, err)
_, claimed, err := ClaimSystemTask(firstA.ID, "type_a", "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, FinishSystemTask(firstA.TaskID, "runner-a", SystemTaskStatusSucceeded, nil, ""))
secondA, err := CreateSystemTask("type_a", nil, nil)
require.NoError(t, err)
tasks, err := GetLatestSystemTasks([]string{"type_a", "type_b", "missing"})
require.NoError(t, err)
require.Len(t, tasks, 2)
assert.NotEqual(t, firstA.TaskID, tasks["type_a"].TaskID)
assert.Equal(t, secondA.TaskID, tasks["type_a"].TaskID)
assert.Equal(t, firstB.TaskID, tasks["type_b"].TaskID)
assert.Nil(t, tasks["missing"])
}
func TestRenewSystemTaskLock(t *testing.T) {
truncateTables(t)
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
runnerID := "runner-a"
_, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
newLockUntil := common.GetTimestamp() + 600
require.NoError(t, RenewSystemTaskLock(task.TaskID, runnerID, newLockUntil))
var lock SystemTaskLock
require.NoError(t, DB.Where("task_id = ?", task.TaskID).First(&lock).Error)
assert.Equal(t, newLockUntil, lock.LockedUntil)
// A different runner cannot renew a lease it does not hold.
assert.ErrorIs(t, RenewSystemTaskLock(task.TaskID, "runner-b", common.GetTimestamp()+600), ErrSystemTaskLockLost)
// After the task finishes it is no longer running, so renew fails.
require.NoError(t, FinishSystemTask(task.TaskID, runnerID, SystemTaskStatusSucceeded, nil, ""))
assert.ErrorIs(t, RenewSystemTaskLock(task.TaskID, runnerID, common.GetTimestamp()+600), ErrSystemTaskLockLost)
}
func TestFinishSystemTaskRetainsExecutor(t *testing.T) {
truncateTables(t)
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
runnerID := "node-1-abc123"
_, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, FinishSystemTask(task.TaskID, runnerID, SystemTaskStatusSucceeded, nil, ""))
reloaded, err := GetSystemTaskByTaskID(task.TaskID)
require.NoError(t, err)
require.NotNil(t, reloaded)
assert.Equal(t, SystemTaskStatusSucceeded, reloaded.Status)
assert.Equal(t, runnerID, reloaded.LockedBy, "executor-of-record must be retained for history")
var lockCount int64
require.NoError(t, DB.Model(&SystemTaskLock{}).Where("task_id = ?", task.TaskID).Count(&lockCount).Error)
assert.Equal(t, int64(0), lockCount)
}
func TestSystemTaskUpdatesRequireCurrentLock(t *testing.T) {
truncateTables(t)
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
runnerID := "runner-a"
_, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, DB.Model(&SystemTaskLock{}).
Where("task_id = ?", task.TaskID).
Updates(map[string]any{"locked_by": "runner-b"}).Error)
assert.ErrorIs(t, UpdateSystemTaskState(task.TaskID, runnerID, testSystemTaskState{Progress: 10}), ErrSystemTaskLockLost)
assert.ErrorIs(t, FinishSystemTask(task.TaskID, runnerID, SystemTaskStatusSucceeded, nil, ""), ErrSystemTaskLockLost)
}
func TestSystemTaskUpdatesRequireUnexpiredLock(t *testing.T) {
truncateTables(t)
task, err := CreateSystemTask(SystemTaskTypeLogCleanup, nil, nil)
require.NoError(t, err)
runnerID := "runner-a"
_, claimed, err := ClaimSystemTask(task.ID, SystemTaskTypeLogCleanup, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, DB.Model(&SystemTaskLock{}).
Where("task_id = ?", task.TaskID).
Update("locked_until", common.GetTimestamp()-1).Error)
assert.ErrorIs(t, UpdateSystemTaskState(task.TaskID, runnerID, testSystemTaskState{Progress: 10}), ErrSystemTaskLockLost)
assert.ErrorIs(t, FinishSystemTask(task.TaskID, runnerID, SystemTaskStatusSucceeded, nil, ""), ErrSystemTaskLockLost)
reloaded, err := GetSystemTaskByTaskID(task.TaskID)
require.NoError(t, err)
require.NotNil(t, reloaded)
assert.Equal(t, SystemTaskStatusRunning, reloaded.Status)
assert.Empty(t, reloaded.State)
}
func TestUpdateSystemTaskStateIdenticalPayloadDoesNotLoseLock(t *testing.T) {
// SQLite reports matched rows for unchanged UPDATEs, so this case passed
// even before the fix. The MySQL regression is covered by
// TestUpdateSystemTaskStateIdenticalPayloadDoesNotLoseLockConfiguredDatabases.
truncateTables(t)
runUpdateSystemTaskStateIdenticalPayloadKeepsLock(t, SystemTaskTypeLogCleanup)
}
func runUpdateSystemTaskStateIdenticalPayloadKeepsLock(t *testing.T, taskType string) {
t.Helper()
// Two persists in the same second so MySQL's unchanged-row UPDATE returns 0.
task, err := CreateSystemTask(taskType, nil, nil)
require.NoError(t, err)
runnerID := "runner-a"
_, claimed, err := ClaimSystemTask(task.ID, taskType, runnerID, common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
state := testSystemTaskState{Total: 10, Processed: 10, Progress: 100, Remaining: 0}
require.NoError(t, UpdateSystemTaskState(task.TaskID, runnerID, state))
require.NoError(t, UpdateSystemTaskState(task.TaskID, runnerID, state), "identical state persist must not be treated as lock loss")
require.NoError(t, FinishSystemTask(task.TaskID, runnerID, SystemTaskStatusSucceeded, map[string]int64{"deleted_count": 10}, ""))
finished, err := GetSystemTaskByTaskID(task.TaskID)
require.NoError(t, err)
require.NotNil(t, finished)
assert.Equal(t, SystemTaskStatusSucceeded, finished.Status)
}
+635
View File
@@ -0,0 +1,635 @@
package model
import (
"bytes"
"context"
"database/sql/driver"
"encoding/json"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/billingexpr"
commonRelay "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
)
type TaskStatus string
func (t TaskStatus) ToVideoStatus() string {
var status string
switch t {
case TaskStatusNotStart, TaskStatusQueued, TaskStatusSubmitted:
status = dto.VideoStatusQueued
case TaskStatusInProgress:
status = dto.VideoStatusInProgress
case TaskStatusSuccess:
status = dto.VideoStatusCompleted
case TaskStatusFailure:
status = dto.VideoStatusFailed
default:
status = dto.VideoStatusUnknown // Default fallback
}
return status
}
const (
TaskStatusNotStart TaskStatus = "NOT_START"
TaskStatusSubmitted = "SUBMITTED"
TaskStatusQueued = "QUEUED"
TaskStatusInProgress = "IN_PROGRESS"
TaskStatusFailure = "FAILURE"
TaskStatusSuccess = "SUCCESS"
TaskStatusUnknown = "UNKNOWN"
)
// TaskRefundLegacyCutoff separates tasks created before timeout refunds were
// introduced. Those legacy tasks are failed without an automatic refund.
const TaskRefundLegacyCutoff int64 = 1771718400 // 2026-02-22 00:00:00 UTC
type Task struct {
ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"`
CreatedAt int64 `json:"created_at" gorm:"index"`
UpdatedAt int64 `json:"updated_at"`
TaskID string `json:"task_id" gorm:"type:varchar(191);index"` // 第三方id,不一定有/ song id\ Task id
Platform constant.TaskPlatform `json:"platform" gorm:"type:varchar(30);index"` // 平台
UserId int `json:"user_id" gorm:"index"`
Group string `json:"group" gorm:"type:varchar(50)"` // 修正计费用
ChannelId int `json:"channel_id" gorm:"index"`
Quota int `json:"quota"`
Action string `json:"action" gorm:"type:varchar(40);index"` // 任务类型, song, lyrics, description-mode
Status TaskStatus `json:"status" gorm:"type:varchar(20);index"` // 任务状态
FailReason string `json:"fail_reason"`
SubmitTime int64 `json:"submit_time" gorm:"index"`
StartTime int64 `json:"start_time" gorm:"index"`
FinishTime int64 `json:"finish_time" gorm:"index"`
Progress string `json:"progress" gorm:"type:varchar(20);index"`
Properties Properties `json:"properties" gorm:"type:json"`
Username string `json:"username,omitempty" gorm:"-"`
// 禁止返回给用户,内部可能包含key等隐私信息
PrivateData TaskPrivateData `json:"-" gorm:"column:private_data;type:json"`
Data json.RawMessage `json:"data" gorm:"type:json"`
}
func (t *Task) SetData(data any) {
b, _ := common.Marshal(data)
t.Data = json.RawMessage(b)
}
func (t *Task) GetData(v any) error {
return common.Unmarshal(t.Data, &v)
}
type Properties struct {
Input string `json:"input"`
UpstreamModelName string `json:"upstream_model_name,omitempty"`
OriginModelName string `json:"origin_model_name,omitempty"`
}
func (m *Properties) Scan(val any) error {
bytesValue := jsonScanBytes(val)
if len(bytesValue) == 0 {
*m = Properties{}
return nil
}
return common.Unmarshal(bytesValue, m)
}
func (m Properties) Value() (driver.Value, error) {
if m == (Properties{}) {
return nil, nil
}
// 必须返回 string 而非 []byte:PG simple protocol 下 []byte 按 bytea 编码,
// 写 json 列会触发 SQLSTATE 22P02。
b, err := common.Marshal(m)
if err != nil {
return nil, err
}
return string(b), nil
}
type TaskPrivateData struct {
Key string `json:"key,omitempty"`
UpstreamTaskID string `json:"upstream_task_id,omitempty"` // 上游真实 task ID
ResultURL string `json:"result_url,omitempty"` // 任务成功后的结果 URL(视频地址等)
// Execution records safe, immutable request provenance. It lives next to
// other private task state so public task DTOs cannot expose it by accident.
Execution *TaskExecutionSnapshot `json:"execution,omitempty"`
// 计费上下文:用于异步退款/差额结算(轮询阶段读取)
BillingSource string `json:"billing_source,omitempty"` // "wallet" 或 "subscription"
SubscriptionId int `json:"subscription_id,omitempty"` // 订阅 ID,用于订阅退款
TokenId int `json:"token_id,omitempty"` // 令牌 ID,用于令牌额度退款
NodeName string `json:"node_name,omitempty"` // 发起任务的节点名,轮询结算阶段据此归属日志而非最后查询节点
BillingContext *TaskBillingContext `json:"billing_context,omitempty"` // 计费参数快照(用于轮询阶段重新计算)
// ResponsesBackground records that the openai_responses create request
// asked for background:true. Every task is durable and survives client
// disconnect regardless; this only echoes the protocol-level request
// attribute back on retrieval snapshots.
ResponsesBackground bool `json:"responses_background,omitempty"`
// PluginState is plugin-owned cross-round data. Unlike Task.Data it is
// only replaced when a hook explicitly returns state.
PluginState json.RawMessage `json:"plugin_state,omitempty"`
// PollFailures counts consecutive unrecognized or transient poll outcomes.
PollFailures int `json:"poll_failures,omitempty"`
}
type TaskExecutionSnapshot struct {
RequestID string `json:"request_id,omitempty"`
RequestPath string `json:"request_path,omitempty"`
TaskPlugin *TaskPluginSnapshot `json:"task_plugin,omitempty"`
}
// TaskPluginSnapshot contains credential-free identity only. Plugin source,
// request/response payloads, and channel secrets must never be added here.
type TaskPluginSnapshot struct {
Key string `json:"key"`
Name string `json:"name"`
Version string `json:"version"`
Author *TaskPluginAuthorSnapshot `json:"author,omitempty"`
APIVersion int `json:"api_version"`
Generation uint64 `json:"generation"`
}
type TaskPluginAuthorSnapshot struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
}
// TaskBillingContext 记录任务提交时的计费参数,以便轮询阶段可以重新计算额度。
type TaskBillingContext struct {
ModelPrice float64 `json:"model_price,omitempty"` // 模型单价
GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率
ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率
OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等)
OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName
PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算
TieredSnapshot *billingexpr.BillingSnapshot `json:"tiered_snapshot,omitempty"`
}
// GetUpstreamTaskID 获取上游真实 task ID(用于与 provider 通信)
// 旧数据没有 UpstreamTaskID 时,TaskID 本身就是上游 ID
func (t *Task) GetUpstreamTaskID() string {
if t.PrivateData.UpstreamTaskID != "" {
return t.PrivateData.UpstreamTaskID
}
return t.TaskID
}
// GetResultURL 获取任务结果 URL(视频地址等)
// 新数据存在 PrivateData.ResultURL 中;旧数据回退到 FailReason(历史兼容)
func (t *Task) GetResultURL() string {
if t.PrivateData.ResultURL != "" {
return t.PrivateData.ResultURL
}
return t.FailReason
}
// GenerateTaskID 生成对外暴露的 task_xxxx 格式 ID
func GenerateTaskID() string {
key, _ := common.GenerateRandomCharsKey(32)
return "task_" + key
}
func (p *TaskPrivateData) Scan(val any) error {
bytesValue := jsonScanBytes(val)
if len(bytesValue) == 0 {
return nil
}
return common.Unmarshal(bytesValue, p)
}
func (p TaskPrivateData) Value() (driver.Value, error) {
if p.Key == "" && p.UpstreamTaskID == "" && p.ResultURL == "" &&
p.Execution == nil && p.BillingSource == "" && p.SubscriptionId == 0 &&
p.TokenId == 0 && p.NodeName == "" && p.BillingContext == nil &&
!p.ResponsesBackground && len(p.PluginState) == 0 && p.PollFailures == 0 {
return nil, nil
}
// 同 Properties.Value:string 避免 PG simple protocol 的 bytea 编码。
b, err := common.Marshal(p)
if err != nil {
return nil, err
}
return string(b), nil
}
// SyncTaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段
type SyncTaskQueryParams struct {
Platform constant.TaskPlatform
ChannelID string
TaskID string
UserID string
Action string
Status string
StartTimestamp int64
EndTimestamp int64
UserIDs []int
}
func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) *Task {
properties := Properties{}
privateData := TaskPrivateData{}
if relayInfo != nil && relayInfo.ChannelMeta != nil {
if relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeGemini ||
relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeVertexAi {
privateData.Key = relayInfo.ChannelMeta.ApiKey
}
if relayInfo.UpstreamModelName != "" {
properties.UpstreamModelName = relayInfo.UpstreamModelName
}
if relayInfo.OriginModelName != "" {
properties.OriginModelName = relayInfo.OriginModelName
}
}
// 使用预生成的公开 ID(如果有),否则新生成
taskID := ""
if relayInfo.TaskRelayInfo != nil && relayInfo.TaskRelayInfo.PublicTaskID != "" {
taskID = relayInfo.TaskRelayInfo.PublicTaskID
} else {
taskID = GenerateTaskID()
}
t := &Task{
TaskID: taskID,
UserId: relayInfo.UserId,
Group: relayInfo.UsingGroup,
SubmitTime: time.Now().Unix(),
Status: TaskStatusNotStart,
Progress: "0%",
ChannelId: relayInfo.ChannelId,
Platform: platform,
Properties: properties,
PrivateData: privateData,
}
return t
}
func TaskGetAllUserTask(userId int, startIdx int, num int, queryParams SyncTaskQueryParams) []*Task {
var tasks []*Task
var err error
// 初始化查询构建器
query := DB.Where("user_id = ?", userId)
if queryParams.TaskID != "" {
query = query.Where("task_id = ?", queryParams.TaskID)
}
if queryParams.Action != "" {
query = query.Where("action = ?", queryParams.Action)
}
if queryParams.Status != "" {
query = query.Where("status = ?", queryParams.Status)
}
if queryParams.Platform != "" {
query = query.Where("platform = ?", queryParams.Platform)
}
if queryParams.StartTimestamp != 0 {
// 假设您已将前端传来的时间戳转换为数据库所需的时间格式,并处理了时间戳的验证和解析
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != 0 {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
// 获取数据
err = query.Omit("channel_id").Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error
if err != nil {
return nil
}
return tasks
}
func TaskGetAllTasks(startIdx int, num int, queryParams SyncTaskQueryParams) []*Task {
var tasks []*Task
var err error
// 初始化查询构建器
query := DB
// 添加过滤条件
if queryParams.ChannelID != "" {
query = query.Where("channel_id = ?", queryParams.ChannelID)
}
if queryParams.Platform != "" {
query = query.Where("platform = ?", queryParams.Platform)
}
if queryParams.UserID != "" {
query = query.Where("user_id = ?", queryParams.UserID)
}
if len(queryParams.UserIDs) != 0 {
query = query.Where("user_id in (?)", queryParams.UserIDs)
}
if queryParams.TaskID != "" {
query = query.Where("task_id = ?", queryParams.TaskID)
}
if queryParams.Action != "" {
query = query.Where("action = ?", queryParams.Action)
}
if queryParams.Status != "" {
query = query.Where("status = ?", queryParams.Status)
}
if queryParams.StartTimestamp != 0 {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != 0 {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
// 获取数据
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error
if err != nil {
return nil
}
return tasks
}
func GetTimedOutUnfinishedTasks(cutoffUnix int64, limit int) []*Task {
var tasks []*Task
err := DB.Where("progress != ?", "100%").
Where("status NOT IN ?", []string{TaskStatusFailure, TaskStatusSuccess}).
Where("submit_time < ?", cutoffUnix).
Order("submit_time").
Limit(limit).
Find(&tasks).Error
if err != nil {
return nil
}
return tasks
}
func GetAllUnFinishSyncTasks(limit int) []*Task {
var tasks []*Task
var err error
// get all tasks progress is not 100%
err = DB.Where("progress != ?", "100%").Where("status != ?", TaskStatusFailure).Where("status != ?", TaskStatusSuccess).Limit(limit).Order("id").Find(&tasks).Error
if err != nil {
return nil
}
return tasks
}
// HasUnfinishedSyncTasks reports whether at least one async (Suno/video) task is
// still in progress. It is a cheap existence check (LIMIT 1) used to decide
// whether the async_task_poll system task needs to run; when no task is pending
// the scheduler skips creating a row entirely.
func HasUnfinishedSyncTasks() bool {
var id int64
err := DB.Model(&Task{}).
Where("progress != ?", "100%").
Where("status != ?", TaskStatusFailure).
Where("status != ?", TaskStatusSuccess).
Limit(1).
Pluck("id", &id).Error
return err == nil && id != 0
}
func GetByOnlyTaskId(taskId string) (*Task, bool, error) {
if taskId == "" {
return nil, false, nil
}
var task *Task
var err error
err = DB.Where("task_id = ?", taskId).First(&task).Error
exist, err := RecordExist(err)
if err != nil {
return nil, false, err
}
return task, exist, err
}
// GetUniqueByOnlyTaskId resolves a public task identifier only when exactly one
// row owns it. Historical task identifiers were not globally unique, so
// capability-based reads must fail closed instead of selecting an arbitrary
// tenant's row.
func GetUniqueByOnlyTaskId(taskId string) (*Task, bool, error) {
if taskId == "" {
return nil, false, nil
}
var tasks []*Task
if err := DB.Where("task_id = ?", taskId).Order("id").Limit(2).Find(&tasks).Error; err != nil {
return nil, false, err
}
if len(tasks) != 1 {
return nil, false, nil
}
return tasks[0], true, nil
}
func GetByTaskId(userId int, taskId string) (*Task, bool, error) {
if taskId == "" {
return nil, false, nil
}
var task *Task
var err error
err = DB.Where("user_id = ? and task_id = ?", userId, taskId).
First(&task).Error
exist, err := RecordExist(err)
if err != nil {
return nil, false, err
}
return task, exist, err
}
func GetByTaskIdsForPlatforms(userID int, platforms []constant.TaskPlatform, taskIDs []string) ([]*Task, error) {
if len(platforms) == 0 || len(taskIDs) == 0 {
return nil, nil
}
var tasks []*Task
err := DB.
Where("user_id = ? AND platform IN ? AND task_id IN ?", userID, platforms, taskIDs).
Find(&tasks).Error
if err != nil {
return nil, err
}
return tasks, nil
}
// GetTaskForProtocolObservation reloads one public task through the ownership
// boundary used by long-lived plugin protocol observers. A missing task,
// foreign user, and wrong plugin platform are deliberately indistinguishable.
func GetTaskForProtocolObservation(ctx context.Context, userID int, platform constant.TaskPlatform, taskID string) (*Task, bool, error) {
if taskID == "" {
return nil, false, nil
}
var task Task
err := DB.WithContext(ctx).
Where("user_id = ? AND platform = ? AND task_id = ?", userID, platform, taskID).
First(&task).Error
exists, err := RecordExist(err)
if err != nil || !exists {
return nil, exists, err
}
return &task, true, nil
}
func (Task *Task) Insert() error {
return Task.InsertWithContext(context.Background())
}
func (Task *Task) InsertWithContext(ctx context.Context) error {
return DB.WithContext(ctx).Create(Task).Error
}
type taskSnapshot struct {
Status TaskStatus
Progress string
StartTime int64
FinishTime int64
FailReason string
ResultURL string
Data json.RawMessage
PluginState json.RawMessage
PollFailures int
}
func (s taskSnapshot) Equal(other taskSnapshot) bool {
return s.Status == other.Status &&
s.Progress == other.Progress &&
s.StartTime == other.StartTime &&
s.FinishTime == other.FinishTime &&
s.FailReason == other.FailReason &&
s.ResultURL == other.ResultURL &&
bytes.Equal(s.Data, other.Data) &&
bytes.Equal(s.PluginState, other.PluginState) &&
s.PollFailures == other.PollFailures
}
func (t *Task) Snapshot() taskSnapshot {
return taskSnapshot{
Status: t.Status,
Progress: t.Progress,
StartTime: t.StartTime,
FinishTime: t.FinishTime,
FailReason: t.FailReason,
ResultURL: t.PrivateData.ResultURL,
Data: t.Data,
PluginState: t.PrivateData.PluginState,
PollFailures: t.PrivateData.PollFailures,
}
}
func (Task *Task) Update() error {
var err error
err = DB.Save(Task).Error
return err
}
func (t *Task) UpdateQuota() error {
return DB.Model(t).Update("quota", t.Quota).Error
}
// UpdateWithStatus performs a conditional UPDATE guarded by fromStatus (CAS).
// Returns (true, nil) if this caller won the update, (false, nil) if
// another process already moved the task out of fromStatus. MySQL commonly
// reports changed rows rather than matched rows, so a same-value no-op update
// can also return false even when the status predicate still matched.
//
// Uses Model().Select("*").Updates() instead of Save() because GORM's Save
// falls back to INSERT ON CONFLICT when the WHERE-guarded UPDATE matches
// zero rows, which silently bypasses the CAS guard.
func (t *Task) UpdateWithStatus(fromStatus TaskStatus) (bool, error) {
result := DB.Model(t).Where("status = ?", fromStatus).Select("*").Updates(t)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
// TaskBulkUpdateByID performs an unconditional bulk UPDATE by primary key IDs.
// WARNING: This function has NO CAS (Compare-And-Swap) guard — it will overwrite
// any concurrent status changes. DO NOT use in billing/quota lifecycle flows
// (e.g., timeout, success, failure transitions that trigger refunds or settlements).
// For status transitions that involve billing, use Task.UpdateWithStatus() instead.
func TaskBulkUpdateByID(ids []int64, params map[string]any) error {
if len(ids) == 0 {
return nil
}
return DB.Model(&Task{}).
Where("id in (?)", ids).
Updates(params).Error
}
type TaskQuotaUsage struct {
Mode string `json:"mode"`
Count float64 `json:"count"`
}
// TaskCountAllTasks returns total tasks that match the given query params (admin usage)
func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 {
var total int64
query := DB.Model(&Task{})
if queryParams.ChannelID != "" {
query = query.Where("channel_id = ?", queryParams.ChannelID)
}
if queryParams.Platform != "" {
query = query.Where("platform = ?", queryParams.Platform)
}
if queryParams.UserID != "" {
query = query.Where("user_id = ?", queryParams.UserID)
}
if len(queryParams.UserIDs) != 0 {
query = query.Where("user_id in (?)", queryParams.UserIDs)
}
if queryParams.TaskID != "" {
query = query.Where("task_id = ?", queryParams.TaskID)
}
if queryParams.Action != "" {
query = query.Where("action = ?", queryParams.Action)
}
if queryParams.Status != "" {
query = query.Where("status = ?", queryParams.Status)
}
if queryParams.StartTimestamp != 0 {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != 0 {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
// TaskCountAllUserTask returns total tasks for given user
func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 {
var total int64
query := DB.Model(&Task{}).Where("user_id = ?", userId)
if queryParams.TaskID != "" {
query = query.Where("task_id = ?", queryParams.TaskID)
}
if queryParams.Action != "" {
query = query.Where("action = ?", queryParams.Action)
}
if queryParams.Status != "" {
query = query.Where("status = ?", queryParams.Status)
}
if queryParams.Platform != "" {
query = query.Where("platform = ?", queryParams.Platform)
}
if queryParams.StartTimestamp != 0 {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != 0 {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
func (t *Task) ToOpenAIVideo() *dto.OpenAIVideo {
openAIVideo := dto.NewOpenAIVideo()
openAIVideo.ID = t.TaskID
openAIVideo.Status = t.Status.ToVideoStatus()
openAIVideo.Model = t.Properties.OriginModelName
openAIVideo.SetProgressStr(t.Progress)
openAIVideo.CreatedAt = t.CreatedAt
if t.Status == TaskStatusSuccess {
if t.FinishTime != 0 {
openAIVideo.CompletedAt = t.FinishTime
} else {
openAIVideo.CompletedAt = t.UpdatedAt
}
}
return openAIVideo
}
+348
View File
@@ -0,0 +1,348 @@
package model
import (
"context"
"encoding/json"
"os"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestMain(m *testing.M) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
panic("failed to open test db: " + err.Error())
}
DB = db
LOG_DB = db
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
common.RedisEnabled = false
common.BatchUpdateEnabled = false
common.LogConsumeEnabled = true
initCol()
sqlDB, err := db.DB()
if err != nil {
panic("failed to get sql.DB: " + err.Error())
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(
&Task{},
&User{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
&Token{},
&PasskeyCredential{},
&TwoFA{},
&TwoFABackupCode{},
&Log{},
&Channel{},
&QuotaData{},
&Ability{},
&TopUp{},
&SubscriptionPlan{},
&SubscriptionOrder{},
&UserSubscription{},
&UserOAuthBinding{},
&PerfMetric{},
&SystemInstance{},
&SystemTask{},
&SystemTaskLock{},
); err != nil {
panic("failed to migrate: " + err.Error())
}
os.Exit(m.Run())
}
func truncateTables(t *testing.T) {
t.Helper()
t.Cleanup(func() {
DB.Exec("DELETE FROM tasks")
DB.Exec("DELETE FROM auth_flows")
DB.Exec("DELETE FROM external_identity_claims")
DB.Exec("DELETE FROM user_sessions")
DB.Exec("DELETE FROM passkey_credentials")
DB.Exec("DELETE FROM two_fa_backup_codes")
DB.Exec("DELETE FROM two_fas")
DB.Exec("DELETE FROM tokens")
DB.Exec("DELETE FROM user_oauth_bindings")
DB.Exec("DELETE FROM users")
DB.Exec("DELETE FROM logs")
DB.Exec("DELETE FROM channels")
DB.Exec("DELETE FROM quota_data")
DB.Exec("DELETE FROM abilities")
DB.Exec("DELETE FROM top_ups")
DB.Exec("DELETE FROM subscription_orders")
DB.Exec("DELETE FROM subscription_plans")
DB.Exec("DELETE FROM user_subscriptions")
DB.Exec("DELETE FROM perf_metrics")
DB.Exec("DELETE FROM system_instances")
DB.Exec("DELETE FROM system_task_locks")
DB.Exec("DELETE FROM system_tasks")
})
}
func insertTask(t *testing.T, task *Task) {
t.Helper()
task.CreatedAt = time.Now().Unix()
task.UpdatedAt = time.Now().Unix()
require.NoError(t, DB.Create(task).Error)
}
func TestGetTaskForProtocolObservationScopesOwnerAndPlatform(t *testing.T) {
truncateTables(t)
task := &Task{
TaskID: "task_protocol_scope",
UserId: 7,
Platform: "plugin-a",
Status: TaskStatusInProgress,
}
insertTask(t, task)
got, exists, err := GetTaskForProtocolObservation(context.Background(), 7, "plugin-a", task.TaskID)
require.NoError(t, err)
require.True(t, exists)
assert.Equal(t, task.ID, got.ID)
for _, query := range []struct {
userID int
platform string
}{
{userID: 8, platform: "plugin-a"},
{userID: 7, platform: "plugin-b"},
} {
got, exists, err = GetTaskForProtocolObservation(context.Background(), query.userID, constant.TaskPlatform(query.platform), task.TaskID)
require.NoError(t, err)
assert.False(t, exists)
assert.Nil(t, got)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
_, _, err = GetTaskForProtocolObservation(cancelled, 7, "plugin-a", task.TaskID)
require.ErrorIs(t, err, context.Canceled)
}
// ---------------------------------------------------------------------------
// Snapshot / Equal — pure logic tests (no DB)
// ---------------------------------------------------------------------------
func TestSnapshotEqual_Same(t *testing.T) {
s := taskSnapshot{
Status: TaskStatusInProgress,
Progress: "50%",
StartTime: 1000,
FinishTime: 0,
FailReason: "",
ResultURL: "",
Data: json.RawMessage(`{"key":"value"}`),
}
assert.True(t, s.Equal(s))
}
func TestSnapshotEqual_DifferentStatus(t *testing.T) {
a := taskSnapshot{Status: TaskStatusInProgress, Data: json.RawMessage(`{}`)}
b := taskSnapshot{Status: TaskStatusSuccess, Data: json.RawMessage(`{}`)}
assert.False(t, a.Equal(b))
}
func TestSnapshotEqual_DifferentProgress(t *testing.T) {
a := taskSnapshot{Status: TaskStatusInProgress, Progress: "30%", Data: json.RawMessage(`{}`)}
b := taskSnapshot{Status: TaskStatusInProgress, Progress: "60%", Data: json.RawMessage(`{}`)}
assert.False(t, a.Equal(b))
}
func TestSnapshotEqual_DifferentData(t *testing.T) {
a := taskSnapshot{Status: TaskStatusInProgress, Data: json.RawMessage(`{"a":1}`)}
b := taskSnapshot{Status: TaskStatusInProgress, Data: json.RawMessage(`{"a":2}`)}
assert.False(t, a.Equal(b))
}
func TestSnapshotEqual_NilVsEmpty(t *testing.T) {
a := taskSnapshot{Status: TaskStatusInProgress, Data: nil}
b := taskSnapshot{Status: TaskStatusInProgress, Data: json.RawMessage{}}
// bytes.Equal(nil, []byte{}) == true
assert.True(t, a.Equal(b))
}
func TestSnapshotEqual_PluginStateAndPollFailures(t *testing.T) {
base := taskSnapshot{
Status: TaskStatusInProgress,
PluginState: json.RawMessage(`{"req_key":"a"}`),
PollFailures: 2,
}
assert.True(t, base.Equal(taskSnapshot{
Status: TaskStatusInProgress,
PluginState: json.RawMessage(`{"req_key":"a"}`),
PollFailures: 2,
}))
assert.False(t, base.Equal(taskSnapshot{
Status: TaskStatusInProgress,
PluginState: json.RawMessage(`{"req_key":"b"}`),
PollFailures: 2,
}))
assert.False(t, base.Equal(taskSnapshot{
Status: TaskStatusInProgress,
PluginState: json.RawMessage(`{"req_key":"a"}`),
PollFailures: 3,
}))
}
func TestSnapshot_Roundtrip(t *testing.T) {
task := &Task{
Status: TaskStatusInProgress,
Progress: "42%",
StartTime: 1234,
FinishTime: 5678,
FailReason: "timeout",
PrivateData: TaskPrivateData{
ResultURL: "https://example.com/result.mp4",
PluginState: json.RawMessage(`{"req_key":"keep"}`),
PollFailures: 3,
},
Data: json.RawMessage(`{"model":"test-model"}`),
}
snap := task.Snapshot()
assert.Equal(t, task.Status, snap.Status)
assert.Equal(t, task.Progress, snap.Progress)
assert.Equal(t, task.StartTime, snap.StartTime)
assert.Equal(t, task.FinishTime, snap.FinishTime)
assert.Equal(t, task.FailReason, snap.FailReason)
assert.Equal(t, task.PrivateData.ResultURL, snap.ResultURL)
assert.JSONEq(t, string(task.Data), string(snap.Data))
assert.Equal(t, task.PrivateData.PluginState, snap.PluginState)
assert.Equal(t, task.PrivateData.PollFailures, snap.PollFailures)
}
// ---------------------------------------------------------------------------
// UpdateWithStatus CAS — DB integration tests
// ---------------------------------------------------------------------------
func TestUpdateWithStatus_Win(t *testing.T) {
truncateTables(t)
task := &Task{
TaskID: "task_cas_win",
Status: TaskStatusInProgress,
Progress: "50%",
Data: json.RawMessage(`{}`),
}
insertTask(t, task)
task.Status = TaskStatusSuccess
task.Progress = "100%"
won, err := task.UpdateWithStatus(TaskStatusInProgress)
require.NoError(t, err)
assert.True(t, won)
var reloaded Task
require.NoError(t, DB.First(&reloaded, task.ID).Error)
assert.EqualValues(t, TaskStatusSuccess, reloaded.Status)
assert.Equal(t, "100%", reloaded.Progress)
}
func TestUpdateWithStatus_Lose(t *testing.T) {
truncateTables(t)
task := &Task{
TaskID: "task_cas_lose",
Status: TaskStatusFailure,
Data: json.RawMessage(`{}`),
}
insertTask(t, task)
task.Status = TaskStatusSuccess
won, err := task.UpdateWithStatus(TaskStatusInProgress) // wrong fromStatus
require.NoError(t, err)
assert.False(t, won)
var reloaded Task
require.NoError(t, DB.First(&reloaded, task.ID).Error)
assert.EqualValues(t, TaskStatusFailure, reloaded.Status) // unchanged
}
func TestUpdateWithStatus_ConcurrentWinner(t *testing.T) {
truncateTables(t)
task := &Task{
TaskID: "task_cas_race",
Status: TaskStatusInProgress,
Quota: 1000,
Data: json.RawMessage(`{}`),
}
insertTask(t, task)
const goroutines = 5
wins := make([]bool, goroutines)
var wg sync.WaitGroup
wg.Add(goroutines)
for i := range goroutines {
go func(idx int) {
defer wg.Done()
t := &Task{}
*t = Task{
ID: task.ID,
TaskID: task.TaskID,
Status: TaskStatusSuccess,
Progress: "100%",
Quota: task.Quota,
Data: json.RawMessage(`{}`),
}
t.CreatedAt = task.CreatedAt
t.UpdatedAt = time.Now().Unix()
won, err := t.UpdateWithStatus(TaskStatusInProgress)
if err == nil {
wins[idx] = won
}
}(i)
}
wg.Wait()
winCount := 0
for _, w := range wins {
if w {
winCount++
}
}
assert.Equal(t, 1, winCount, "exactly one goroutine should win the CAS")
}
func TestUpdateWithStatus_PersistsPluginStateAndPollFailures(t *testing.T) {
truncateTables(t)
task := &Task{
TaskID: "task_cas_plugin_state",
Status: TaskStatusInProgress,
Data: json.RawMessage(`{}`),
PrivateData: TaskPrivateData{
PluginState: json.RawMessage(`{"req_key":"old"}`),
PollFailures: 1,
},
}
insertTask(t, task)
task.PrivateData.PluginState = json.RawMessage(`{"req_key":"new"}`)
task.PrivateData.PollFailures = 4
won, err := task.UpdateWithStatus(TaskStatusInProgress)
require.NoError(t, err)
require.True(t, won)
var reloaded Task
require.NoError(t, DB.First(&reloaded, task.ID).Error)
assert.EqualValues(t, TaskStatusInProgress, reloaded.Status)
assert.JSONEq(t, `{"req_key":"new"}`, string(reloaded.PrivateData.PluginState))
assert.Equal(t, 4, reloaded.PrivateData.PollFailures)
}
+212
View File
@@ -0,0 +1,212 @@
package model
import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
)
// TaskAliasTarget is one mapping-derived alias after cross-channel aggregation.
// Declared is empty when the same plugin resolves the alias to more than one
// declared tail (display-only; pin is disabled).
type TaskAliasTarget struct {
Alias string
Declared string
PluginKey string
}
type taskAliasView struct {
generation uint64
expiresAt time.Time
byFold map[string]TaskAliasTarget
}
const taskAliasViewTTL = 60 * time.Second
var (
taskAliasViewPtr atomic.Pointer[taskAliasView]
taskAliasRebuildMu sync.Mutex
)
// ResolveTaskModelAlias returns the mapping-derived alias target for name
// (exact or ASCII-folded). g is the caller's routing generation: a Number
// mismatch or TTL expiry rebuilds the view against that generation.
func ResolveTaskModelAlias(g *jsplugin.RoutingGeneration, name string) (TaskAliasTarget, bool) {
if g == nil || name == "" {
return TaskAliasTarget{}, false
}
view := loadFreshTaskAliasView(g)
if view == nil {
return TaskAliasTarget{}, false
}
target, ok := view.byFold[jsplugin.ASCIIFold(name)]
return target, ok
}
func loadFreshTaskAliasView(g *jsplugin.RoutingGeneration) *taskAliasView {
view := taskAliasViewPtr.Load()
if taskAliasViewFresh(view, g.Number) {
return view
}
taskAliasRebuildMu.Lock()
defer taskAliasRebuildMu.Unlock()
view = taskAliasViewPtr.Load()
if taskAliasViewFresh(view, g.Number) {
return view
}
rebuilt := buildTaskAliasView(g)
taskAliasViewPtr.Store(rebuilt)
return rebuilt
}
func taskAliasViewFresh(view *taskAliasView, generation uint64) bool {
return view != nil && view.generation == generation && time.Now().Before(view.expiresAt)
}
func rebuildTaskAliasView() {
taskAliasRebuildMu.Lock()
defer taskAliasRebuildMu.Unlock()
taskAliasViewPtr.Store(buildTaskAliasView(jsplugin.DefaultRegistry.Generation()))
}
type taskAliasDraft struct {
spellings []string
byPlugin map[string]map[string]struct{}
}
func buildTaskAliasView(generation *jsplugin.RoutingGeneration) *taskAliasView {
genNum := uint64(0)
if generation != nil {
genNum = generation.Number
}
view := &taskAliasView{
generation: genNum,
expiresAt: time.Now().Add(taskAliasViewTTL),
byFold: make(map[string]TaskAliasTarget),
}
if DB == nil {
return view
}
var channels []Channel
err := DB.Select("id", "type", "models", "model_mapping").
Where("status = ?", common.ChannelStatusEnabled).
Find(&channels).Error
if err != nil {
common.SysError(fmt.Sprintf("rebuild task alias view: %s", err.Error()))
return view
}
drafts := make(map[string]*taskAliasDraft)
for i := range channels {
channel := &channels[i]
mappingJSON := channel.GetModelMapping()
if mappingJSON == "" || mappingJSON == "{}" {
continue
}
modelMap := make(map[string]string)
if err := common.UnmarshalJsonStr(mappingJSON, &modelMap); err != nil {
common.SysError(fmt.Sprintf("task alias view: channel %d model_mapping: %s", channel.Id, err.Error()))
continue
}
inModels := make(map[string]struct{})
for _, modelName := range channel.GetModels() {
inModels[modelName] = struct{}{}
}
for alias, mapped := range modelMap {
if mapped == "" {
continue
}
if _, exposed := inModels[alias]; !exposed {
continue
}
if _, declared := generation.CanonicalModel(alias); declared {
continue
}
tail, cyclic := followChannelModelMapping(modelMap, alias)
if cyclic {
common.SysError(fmt.Sprintf("task alias mapping cycle dropped: channel=%d key=%q", channel.Id, alias))
continue
}
declared, ok := generation.CanonicalModel(tail)
if !ok {
continue
}
plugin, ok := generation.GetByModel(declared)
if !ok {
continue
}
fold := jsplugin.ASCIIFold(alias)
draft := drafts[fold]
if draft == nil {
draft = &taskAliasDraft{byPlugin: make(map[string]map[string]struct{})}
drafts[fold] = draft
}
draft.spellings = append(draft.spellings, alias)
declareds := draft.byPlugin[plugin.Meta.Key]
if declareds == nil {
declareds = make(map[string]struct{})
draft.byPlugin[plugin.Meta.Key] = declareds
}
declareds[declared] = struct{}{}
}
}
for _, draft := range drafts {
alias := draft.spellings[0]
for _, spelling := range draft.spellings[1:] {
if spelling < alias {
alias = spelling
}
}
if len(draft.byPlugin) != 1 {
pluginKeys := make([]string, 0, len(draft.byPlugin))
for key := range draft.byPlugin {
pluginKeys = append(pluginKeys, key)
}
common.SysLog(fmt.Sprintf("task model alias %q dropped: maps to multiple plugins %v", alias, pluginKeys))
continue
}
var pluginKey, declared string
for key, declareds := range draft.byPlugin {
pluginKey = key
if len(declareds) == 1 {
for name := range declareds {
declared = name
}
}
}
view.byFold[jsplugin.ASCIIFold(alias)] = TaskAliasTarget{
Alias: alias,
Declared: declared,
PluginKey: pluginKey,
}
}
return view
}
// followChannelModelMapping walks one channel's mapping the same way
// ModelMappedHelper does: visited-set cycle detection, self-map stops at
// the current hop, a non-self cycle is reported to the caller.
func followChannelModelMapping(modelMap map[string]string, start string) (string, bool) {
current := start
visited := map[string]bool{current: true}
for {
mapped, exists := modelMap[current]
if !exists || mapped == "" {
return current, false
}
if visited[mapped] {
if mapped == current {
return current, false
}
return "", true
}
visited[mapped] = true
current = mapped
}
}
+93
View File
@@ -0,0 +1,93 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskToOpenAIVideoDoesNotExposeResultURL(t *testing.T) {
task := &Task{
TaskID: "task_public",
Status: TaskStatusSuccess,
Progress: "100%",
CreatedAt: 10,
UpdatedAt: 20,
Properties: Properties{
OriginModelName: "video-model",
},
PrivateData: TaskPrivateData{
ResultURL: "https://upstream.example/video.mp4?signature=secret",
},
}
video := task.ToOpenAIVideo()
assert.Equal(t, "task_public", video.ID)
assert.Equal(t, "video", video.Object)
assert.Equal(t, "completed", video.Status)
assert.Nil(t, video.Metadata)
encoded, err := common.Marshal(video)
require.NoError(t, err)
assert.NotContains(t, string(encoded), "upstream.example")
assert.NotContains(t, string(encoded), "signature")
}
func TestTaskToOpenAIVideoStatusAndCompletedAt(t *testing.T) {
tests := []struct {
name string
task Task
wantStatus string
wantCompletedAt int64
wantCompletedAtJSON bool
}{
{
name: "not start maps to queued",
task: Task{TaskID: "t1", Status: TaskStatusNotStart, CreatedAt: 10, UpdatedAt: 20},
wantStatus: "queued",
wantCompletedAt: 0,
},
{
name: "in progress omits completed at",
task: Task{TaskID: "t2", Status: TaskStatusInProgress, CreatedAt: 10, UpdatedAt: 20, FinishTime: 30},
wantStatus: "in_progress",
wantCompletedAt: 0,
},
{
name: "success uses finish time",
task: Task{TaskID: "t3", Status: TaskStatusSuccess, CreatedAt: 10, UpdatedAt: 20, FinishTime: 30},
wantStatus: "completed",
wantCompletedAt: 30,
wantCompletedAtJSON: true,
},
{
name: "success falls back to updated at",
task: Task{TaskID: "t4", Status: TaskStatusSuccess, CreatedAt: 10, UpdatedAt: 20},
wantStatus: "completed",
wantCompletedAt: 20,
wantCompletedAtJSON: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
video := tt.task.ToOpenAIVideo()
assert.Equal(t, tt.wantStatus, video.Status)
assert.Equal(t, tt.wantCompletedAt, video.CompletedAt)
encoded, err := common.Marshal(video)
require.NoError(t, err)
var fields map[string]any
require.NoError(t, common.Unmarshal(encoded, &fields))
if tt.wantCompletedAtJSON {
require.Contains(t, fields, "completed_at")
assert.Equal(t, float64(tt.wantCompletedAt), fields["completed_at"])
} else {
assert.NotContains(t, fields, "completed_at")
}
})
}
}
+251
View File
@@ -0,0 +1,251 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type TaskPluginChannelRef struct {
Id int `json:"id"`
Name string `json:"name"`
}
func GetTaskPluginUsage(key string) ([]TaskPluginChannelRef, int64, error) {
var channels []Channel
if err := DB.Where("type = ? AND status = ?", constant.ChannelTypeTaskPlugin, common.ChannelStatusEnabled).Find(&channels).Error; err != nil {
return nil, 0, err
}
refs := make([]TaskPluginChannelRef, 0)
for _, channel := range channels {
if channel.GetSetting().TaskPluginKey == key {
refs = append(refs, TaskPluginChannelRef{Id: channel.Id, Name: channel.Name})
}
}
var inFlight int64
err := DB.Model(&Task{}).Where("platform = ? AND status NOT IN ?", key, []TaskStatus{TaskStatusSuccess, TaskStatusFailure}).Count(&inFlight).Error
return refs, inFlight, err
}
type TaskPlugin struct {
Id int64 `json:"id"`
Key string `json:"key" gorm:"size:128;not null;uniqueIndex:uk_task_plugin_key_version,priority:1"`
APIVersion int `json:"api_version" gorm:"not null"`
Version string `json:"version" gorm:"size:64;not null;uniqueIndex:uk_task_plugin_key_version,priority:2"`
Source string `json:"source" gorm:"type:text;not null"`
SourceHash string `json:"source_hash" gorm:"size:64;not null"`
// Icon is the plugin logo shipped as a sidecar icon.svg / icon.png next to
// plugin.js, stored as a data URI so one column carries both the media
// type and the bytes. It never travels inside list or detail JSON; the UI
// loads it through GET /api/plugin/task/:key/icon. size matches the
// 512 KiB icon cap and makes GORM emit mediumtext on MySQL (a bare TEXT
// column there holds only 64 KiB), varchar(524288) on PostgreSQL, and text
// on SQLite.
Icon string `json:"-" gorm:"size:524288"`
Enabled bool `json:"enabled" gorm:"not null"`
Active bool `json:"active" gorm:"not null;index"`
CreatedAt int64 `json:"created_at" gorm:"not null"`
Remark string `json:"remark" gorm:"type:text"`
}
// HasIcon reports whether this version ships a logo.
func (plugin TaskPlugin) HasIcon() bool {
return plugin.Icon != ""
}
func SaveTaskPlugin(plugin *TaskPlugin) error {
return DB.Transaction(func(tx *gorm.DB) error {
var existing TaskPlugin
err := tx.Where(&TaskPlugin{Key: plugin.Key, Version: plugin.Version}).First(&existing).Error
if err == nil {
if existing.SourceHash != plugin.SourceHash {
return errors.New("plugin key and version already exist with different source")
}
updates := map[string]any{"enabled": plugin.Enabled, "remark": plugin.Remark}
if plugin.Icon != "" {
updates["icon"] = plugin.Icon
existing.Icon = plugin.Icon
}
if err = tx.Model(&existing).Updates(updates).Error; err != nil {
return err
}
existing.Enabled = plugin.Enabled
existing.Remark = plugin.Remark
*plugin = existing
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
plugin.CreatedAt = time.Now().Unix()
var count int64
if err = tx.Model(&TaskPlugin{}).Where(&TaskPlugin{Key: plugin.Key, Active: true}).Count(&count).Error; err != nil {
return err
}
plugin.Active = count == 0
return tx.Create(plugin).Error
})
}
func ListTaskPluginVersions(key string) ([]TaskPlugin, error) {
var plugins []TaskPlugin
err := DB.Where(&TaskPlugin{Key: key}).Order("created_at DESC, id DESC").Find(&plugins).Error
return plugins, err
}
func ListTaskPlugins() ([]TaskPlugin, error) {
var plugins []TaskPlugin
err := DB.
Order(clause.OrderByColumn{Column: clause.Column{Name: "key"}}).
Order(clause.OrderByColumn{Column: clause.Column{Name: "created_at"}, Desc: true}).
Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}, Desc: true}).
Find(&plugins).Error
return plugins, err
}
func GetTaskPluginVersion(key, version string) (*TaskPlugin, error) {
var plugin TaskPlugin
query := DB.Where(&TaskPlugin{Key: key})
if version == "" {
query = query.Where(&TaskPlugin{Active: true})
} else {
query = query.Where(&TaskPlugin{Version: version})
}
if err := query.First(&plugin).Error; err != nil {
return nil, err
}
return &plugin, nil
}
func ListActiveTaskPlugins() ([]TaskPlugin, error) {
snapshot, err := GetTaskPluginSyncSnapshot()
return snapshot.Plugins, err
}
type TaskPluginSyncSnapshot struct {
Plugins []TaskPlugin
Revision string
}
// GetTaskPluginSyncSnapshot returns the enabled override set together with a
// deterministic revision of every active database override. Nodes can compare
// the revision even though their local routing-generation counters differ.
func GetTaskPluginSyncSnapshot() (TaskPluginSyncSnapshot, error) {
var activePlugins []TaskPlugin
if err := DB.Where(&TaskPlugin{Active: true}).
Order(clause.OrderByColumn{Column: clause.Column{Name: "key"}}).
Order(clause.OrderByColumn{Column: clause.Column{Name: "version"}}).
Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}}).
Find(&activePlugins).Error; err != nil {
return TaskPluginSyncSnapshot{}, err
}
type revisionEntry struct {
Key string `json:"key"`
APIVersion int `json:"api_version"`
Version string `json:"version"`
SourceHash string `json:"source_hash"`
Enabled bool `json:"enabled"`
}
entries := make([]revisionEntry, 0, len(activePlugins))
enabledPlugins := make([]TaskPlugin, 0, len(activePlugins))
for _, plugin := range activePlugins {
entries = append(entries, revisionEntry{
Key: plugin.Key,
APIVersion: plugin.APIVersion,
Version: plugin.Version,
SourceHash: plugin.SourceHash,
Enabled: plugin.Enabled,
})
if plugin.Enabled {
enabledPlugins = append(enabledPlugins, plugin)
}
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Key != entries[j].Key {
return entries[i].Key < entries[j].Key
}
return entries[i].Version < entries[j].Version
})
payload, err := common.Marshal(entries)
if err != nil {
return TaskPluginSyncSnapshot{}, err
}
digest := sha256.Sum256(payload)
return TaskPluginSyncSnapshot{
Plugins: enabledPlugins,
Revision: hex.EncodeToString(digest[:]),
}, nil
}
func ActivateTaskPlugin(key, version string) error {
return DB.Transaction(func(tx *gorm.DB) error {
var target TaskPlugin
if err := tx.Where(&TaskPlugin{Key: key, Version: version}).First(&target).Error; err != nil {
return err
}
if err := tx.Model(&TaskPlugin{}).Where(&TaskPlugin{Key: key}).Update("active", false).Error; err != nil {
return err
}
return tx.Model(&target).Updates(map[string]any{"active": true, "enabled": true}).Error
})
}
func SetTaskPluginEnabled(key string, enabled bool) error {
result := DB.Model(&TaskPlugin{}).Where(&TaskPlugin{Key: key, Active: true}).Update("enabled", enabled)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
type TaskPluginDeleteResult struct {
DeletedActive bool
Promoted *TaskPlugin
}
func DeleteTaskPluginVersion(key, version string) (TaskPluginDeleteResult, error) {
result := TaskPluginDeleteResult{}
err := DB.Transaction(func(tx *gorm.DB) error {
var plugin TaskPlugin
if err := lockForUpdate(tx).Where(&TaskPlugin{Key: key, Version: version}).First(&plugin).Error; err != nil {
return err
}
result.DeletedActive = plugin.Active
if err := tx.Delete(&plugin).Error; err != nil {
return err
}
if !plugin.Active {
return nil
}
var promoted TaskPlugin
err := lockForUpdate(tx).
Where(&TaskPlugin{Key: key}).
Order("created_at DESC, id DESC").
First(&promoted).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if err = tx.Model(&promoted).Update("active", true).Error; err != nil {
return err
}
promoted.Active = true
result.Promoted = &promoted
return nil
})
return result, err
}
+58
View File
@@ -0,0 +1,58 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskPluginChannelSelectionFiltersBothCachePaths(t *testing.T) {
truncateTables(t)
priority := int64(0)
weight := uint(1)
baseURL := "https://example.com"
alphaSetting := `{"task_plugin_key":"alpha"}`
betaSetting := `{"task_plugin_key":"beta"}`
channels := []Channel{
{Id: 900001, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "alpha", Models: "shared", Group: "default", Priority: &priority, Weight: &weight, BaseURL: &baseURL, Setting: &alphaSetting},
{Id: 900002, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "beta", Models: "shared", Group: "default", Priority: &priority, Weight: &weight, BaseURL: &baseURL, Setting: &betaSetting},
{Id: 900003, Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusEnabled, Name: "ordinary", Models: "ordinary", Group: "default", Priority: &priority, Weight: &weight},
{Id: 900004, Type: constant.ChannelTypeKling, Status: common.ChannelStatusEnabled, Name: "legacy-alpha", Models: "legacy", Group: "default", Priority: &priority, Weight: &weight},
{Id: 900005, Type: constant.ChannelTypeJimeng, Status: common.ChannelStatusEnabled, Name: "legacy-beta", Models: "legacy", Group: "default", Priority: &priority, Weight: &weight},
}
for i := range channels {
require.NoError(t, channels[i].Insert())
}
selected, err := GetChannel("default", "shared", 0, identityFilters("alpha", nil))
require.NoError(t, err)
require.NotNil(t, selected)
assert.Equal(t, "alpha", selected.Name)
selected, err = GetChannel("default", "shared", 0, identityFilters("", nil))
require.NoError(t, err)
assert.Nil(t, selected)
selected, err = GetChannel("default", "ordinary", 0, identityFilters("", nil))
require.NoError(t, err)
require.NotNil(t, selected)
assert.Equal(t, "ordinary", selected.Name)
selected, err = GetChannel("default", "legacy", 0, identityFilters("legacy-alpha", []int{constant.ChannelTypeKling}))
require.NoError(t, err)
require.NotNil(t, selected)
assert.Equal(t, "legacy-alpha", selected.Name)
selected, err = GetChannel("default", "legacy", 0, identityFilters("legacy-alpha", []int{constant.ChannelTypeKling, constant.ChannelTypeJimeng}))
require.NoError(t, err)
require.NotNil(t, selected)
assert.Contains(t, []string{"legacy-alpha", "legacy-beta"}, selected.Name)
}
func identityFilters(key string, channelTypes []int) []dto.ChannelFilter {
return []dto.ChannelFilter{{
Kind: dto.FilterTaskPluginIdentity,
TaskPluginKey: key,
TaskPluginChannelTypes: channelTypes,
}}
}
+168
View File
@@ -0,0 +1,168 @@
package model
import (
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/utils/tests"
)
func setupTaskPluginModelTest(t *testing.T) {
t.Helper()
originalDB := DB
t.Cleanup(func() { DB = originalDB })
var err error
DB, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, DB.AutoMigrate(&TaskPlugin{}))
}
func TestTaskPluginVersionActivationAndSourceImmutability(t *testing.T) {
setupTaskPluginModelTest(t)
v1 := TaskPlugin{Key: "mock", APIVersion: 1, Version: "1.0.0", Source: "v1", SourceHash: "hash-v1", Enabled: true}
require.NoError(t, SaveTaskPlugin(&v1))
assert.True(t, v1.Active)
v2 := TaskPlugin{Key: "mock", APIVersion: 1, Version: "2.0.0", Source: "v2", SourceHash: "hash-v2", Enabled: true}
require.NoError(t, SaveTaskPlugin(&v2))
assert.False(t, v2.Active)
require.NoError(t, ActivateTaskPlugin("mock", "2.0.0"))
active, err := ListActiveTaskPlugins()
require.NoError(t, err)
require.Len(t, active, 1)
assert.Equal(t, "2.0.0", active[0].Version)
conflict := TaskPlugin{Key: "mock", APIVersion: 1, Version: "2.0.0", Source: "changed", SourceHash: "different", Enabled: true}
err = SaveTaskPlugin(&conflict)
require.Error(t, err)
assert.Contains(t, err.Error(), "different source")
require.NoError(t, SetTaskPluginEnabled("mock", false))
active, err = ListActiveTaskPlugins()
require.NoError(t, err)
assert.Empty(t, active)
all, err := ListTaskPlugins()
require.NoError(t, err)
assert.Len(t, all, 2)
deleteResult, err := DeleteTaskPluginVersion("mock", "2.0.0")
require.NoError(t, err)
assert.True(t, deleteResult.DeletedActive)
require.NotNil(t, deleteResult.Promoted)
assert.Equal(t, "1.0.0", deleteResult.Promoted.Version)
versions, err := ListTaskPluginVersions("mock")
require.NoError(t, err)
require.Len(t, versions, 1)
assert.Equal(t, "1.0.0", versions[0].Version)
assert.True(t, versions[0].Active)
}
func TestDeleteActiveTaskPluginPromotesNewestRemainingVersion(t *testing.T) {
setupTaskPluginModelTest(t)
plugins := []*TaskPlugin{
{Key: "promote", APIVersion: 1, Version: "1.0.0", Source: "v1", SourceHash: "hash-v1", Enabled: true},
{Key: "promote", APIVersion: 1, Version: "2.0.0", Source: "v2", SourceHash: "hash-v2", Enabled: false},
{Key: "promote", APIVersion: 1, Version: "3.0.0", Source: "v3", SourceHash: "hash-v3", Enabled: true},
{Key: "promote", APIVersion: 1, Version: "4.0.0", Source: "v4", SourceHash: "hash-v4", Enabled: true},
}
for _, plugin := range plugins {
require.NoError(t, SaveTaskPlugin(plugin))
}
require.NoError(t, DB.Model(plugins[1]).Update("created_at", 200).Error)
require.NoError(t, DB.Model(plugins[2]).Update("created_at", 100).Error)
require.NoError(t, DB.Model(plugins[3]).Update("created_at", 100).Error)
deleteResult, err := DeleteTaskPluginVersion("promote", "1.0.0")
require.NoError(t, err)
assert.True(t, deleteResult.DeletedActive)
require.NotNil(t, deleteResult.Promoted)
assert.Equal(t, "2.0.0", deleteResult.Promoted.Version)
assert.False(t, deleteResult.Promoted.Enabled)
deleteResult, err = DeleteTaskPluginVersion("promote", "2.0.0")
require.NoError(t, err)
assert.True(t, deleteResult.DeletedActive)
require.NotNil(t, deleteResult.Promoted)
assert.Equal(t, "4.0.0", deleteResult.Promoted.Version)
active, err := GetTaskPluginVersion("promote", "")
require.NoError(t, err)
assert.Equal(t, "4.0.0", active.Version)
}
func TestTaskPluginSyncSnapshotRevisionTracksDesiredRuntimeState(t *testing.T) {
setupTaskPluginModelTest(t)
empty, err := GetTaskPluginSyncSnapshot()
require.NoError(t, err)
assert.Empty(t, empty.Plugins)
require.NotEmpty(t, empty.Revision)
v1 := TaskPlugin{
Key: "revision-probe", APIVersion: 1, Version: "1.0.0",
Source: "v1", SourceHash: "hash-v1", Enabled: true,
}
require.NoError(t, SaveTaskPlugin(&v1))
v1Snapshot, err := GetTaskPluginSyncSnapshot()
require.NoError(t, err)
require.Len(t, v1Snapshot.Plugins, 1)
assert.NotEqual(t, empty.Revision, v1Snapshot.Revision)
v2 := TaskPlugin{
Key: "revision-probe", APIVersion: 1, Version: "2.0.0",
Source: "v2", SourceHash: "hash-v2", Enabled: true,
}
require.NoError(t, SaveTaskPlugin(&v2))
inactiveAdded, err := GetTaskPluginSyncSnapshot()
require.NoError(t, err)
assert.Equal(t, v1Snapshot.Revision, inactiveAdded.Revision)
require.NoError(t, DB.Model(&v1).Update("remark", "operator note").Error)
remarkChanged, err := GetTaskPluginSyncSnapshot()
require.NoError(t, err)
assert.Equal(t, v1Snapshot.Revision, remarkChanged.Revision)
require.NoError(t, ActivateTaskPlugin("revision-probe", "2.0.0"))
v2Snapshot, err := GetTaskPluginSyncSnapshot()
require.NoError(t, err)
require.Len(t, v2Snapshot.Plugins, 1)
assert.Equal(t, "2.0.0", v2Snapshot.Plugins[0].Version)
assert.NotEqual(t, v1Snapshot.Revision, v2Snapshot.Revision)
require.NoError(t, SetTaskPluginEnabled("revision-probe", false))
disabled, err := GetTaskPluginSyncSnapshot()
require.NoError(t, err)
assert.Empty(t, disabled.Plugins)
assert.NotEqual(t, v2Snapshot.Revision, disabled.Revision)
}
func TestTaskPluginOrderSQLQuotesMySQLKeyColumn(t *testing.T) {
db, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{DryRun: true})
require.NoError(t, err)
var sqls []string
require.NoError(t, db.Callback().Query().After("gorm:query").Register("test:capture_task_plugin_sql", func(tx *gorm.DB) {
sqls = append(sqls, tx.Statement.SQL.String())
}))
originalDB := DB
t.Cleanup(func() { DB = originalDB })
DB = db
_, err = ListTaskPlugins()
require.NoError(t, err)
_, err = GetTaskPluginSyncSnapshot()
require.NoError(t, err)
require.Len(t, sqls, 2)
for _, sql := range sqls {
assert.Contains(t, sql, "`key`")
assert.NotRegexp(t, `(?i)ORDER BY[[:space:]]+key([[:space:],]|$)`, sql)
}
}
+515
View File
@@ -0,0 +1,515 @@
package model
import (
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/bytedance/gopkg/util/gopool"
"gorm.io/gorm"
)
type Token struct {
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Key string `json:"key" gorm:"type:varchar(128);uniqueIndex"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index" `
CreatedTime int64 `json:"created_time" gorm:"bigint"`
AccessedTime int64 `json:"accessed_time" gorm:"bigint"`
ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired
RemainQuota int `json:"remain_quota" gorm:"default:0"`
UnlimitedQuota bool `json:"unlimited_quota"`
ModelLimitsEnabled bool `json:"model_limits_enabled"`
ModelLimits string `json:"model_limits" gorm:"type:text"`
AllowIps *string `json:"allow_ips" gorm:"default:''"`
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Group string `json:"group" gorm:"default:''"`
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
AutoGroups string `json:"-" gorm:"type:text"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (token *Token) GetAutoGroups() ([]string, error) {
if token.AutoGroups == "" {
return nil, nil
}
var groups []string
if err := common.UnmarshalJsonStr(token.AutoGroups, &groups); err != nil {
return nil, err
}
return groups, nil
}
func (token *Token) SetAutoGroups(groups []string) error {
if len(groups) == 0 {
token.AutoGroups = ""
return nil
}
data, err := common.Marshal(groups)
if err != nil {
return err
}
token.AutoGroups = string(data)
return nil
}
func (token *Token) Clean() {
token.Key = ""
}
func MaskTokenKey(key string) string {
if key == "" {
return ""
}
if len(key) <= 4 {
return strings.Repeat("*", len(key))
}
if len(key) <= 8 {
return key[:2] + "****" + key[len(key)-2:]
}
return key[:4] + "**********" + key[len(key)-4:]
}
func (token *Token) GetFullKey() string {
return token.Key
}
func (token *Token) GetMaskedKey() string {
return MaskTokenKey(token.Key)
}
func (token *Token) GetIpLimits() []string {
// delete empty spaces
//split with \n
ipLimits := make([]string, 0)
if token.AllowIps == nil {
return ipLimits
}
cleanIps := strings.ReplaceAll(*token.AllowIps, " ", "")
if cleanIps == "" {
return ipLimits
}
ips := strings.SplitSeq(cleanIps, "\n")
for ip := range ips {
ip = strings.TrimSpace(ip)
ip = strings.ReplaceAll(ip, ",", "")
if ip != "" {
ipLimits = append(ipLimits, ip)
}
}
return ipLimits
}
func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
var tokens []*Token
var err error
err = DB.Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&tokens).Error
return tokens, err
}
// sanitizeLikePattern 校验并清洗用户输入的 LIKE 搜索模式。
// 规则:
// 1. 转义 ! 和 _(使用 ! 作为 ESCAPE 字符,兼容 MySQL/PostgreSQL/SQLite)
// 2. 连续的 % 合并为单个 %
// 3. 最多允许 2 个 %
// 4. 含 % 时(模糊搜索),去掉 % 后关键词长度必须 >= 2
// 5. 不含 % 时按精确匹配
func sanitizeLikePattern(input string) (string, error) {
// 1. 先转义 ESCAPE 字符 ! 自身,再转义 _
// 使用 ! 而非 \ 作为 ESCAPE 字符,避免 MySQL 中反斜杠的字符串转义问题
input = strings.ReplaceAll(input, "!", "!!")
input = strings.ReplaceAll(input, `_`, `!_`)
if err := validateLikePattern(input); err != nil {
return "", err
}
// 5. 无 % 时,精确全匹配
return input, nil
}
func validateLikePattern(input string) error {
// 1. 连续的 % 直接拒绝
if strings.Contains(input, "%%") {
return errors.New("搜索模式中不允许包含连续的 % 通配符")
}
// 2. 统计 % 数量,不得超过 2
count := strings.Count(input, "%")
if count > 2 {
return errors.New("搜索模式中最多允许包含 2 个 % 通配符")
}
// 3. 含 % 时,去掉 % 后关键词长度必须 >= 2
if count > 0 {
stripped := strings.ReplaceAll(input, "%", "")
if len(stripped) < 2 {
return errors.New("使用模糊搜索时,关键词长度至少为 2 个字符")
}
}
return nil
}
const searchHardLimit = 100
func SearchUserTokens(userId int, keyword string, token string, offset int, limit int) (tokens []*Token, total int64, err error) {
// model 层强制截断
if limit <= 0 || limit > searchHardLimit {
limit = searchHardLimit
}
if offset < 0 {
offset = 0
}
if token != "" {
token = strings.TrimPrefix(token, "sk-")
}
// 超量用户(令牌数超过上限)只允许精确搜索,禁止模糊搜索
maxTokens := operation_setting.GetMaxUserTokens()
hasFuzzy := strings.Contains(keyword, "%") || strings.Contains(token, "%")
if hasFuzzy {
count, err := CountUserTokens(userId)
if err != nil {
common.SysLog("failed to count user tokens: " + err.Error())
return nil, 0, errors.New("获取令牌数量失败")
}
if int(count) > maxTokens {
return nil, 0, errors.New("令牌数量超过上限,仅允许精确搜索,请勿使用 % 通配符")
}
}
baseQuery := DB.Model(&Token{}).Where("user_id = ?", userId)
// 非空才加 LIKE 条件,空则跳过(不过滤该字段)
if keyword != "" {
keywordPattern, err := sanitizeLikePattern(keyword)
if err != nil {
return nil, 0, err
}
baseQuery = baseQuery.Where("name LIKE ? ESCAPE '!'", keywordPattern)
}
if token != "" {
tokenPattern, err := sanitizeLikePattern(token)
if err != nil {
return nil, 0, err
}
baseQuery = baseQuery.Where(commonKeyCol+" LIKE ? ESCAPE '!'", tokenPattern)
}
// 先查匹配总数(用于分页,受 maxTokens 上限保护,避免全表 COUNT)
err = baseQuery.Limit(maxTokens).Count(&total).Error
if err != nil {
common.SysError("failed to count search tokens: " + err.Error())
return nil, 0, errors.New("搜索令牌失败")
}
// 再分页查数据
err = baseQuery.Order("id desc").Offset(offset).Limit(limit).Find(&tokens).Error
if err != nil {
common.SysError("failed to search tokens: " + err.Error())
return nil, 0, errors.New("搜索令牌失败")
}
return tokens, total, nil
}
func ValidateUserToken(key string) (token *Token, err error) {
if key == "" {
return nil, ErrTokenNotProvided
}
token, err = GetTokenByKey(key, false)
if err == nil {
if token.Status == common.TokenStatusExhausted ||
token.Status == common.TokenStatusExpired ||
token.Status != common.TokenStatusEnabled {
return token, ErrTokenInvalid
}
if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() {
if !common.RedisEnabled {
token.Status = common.TokenStatusExpired
err := token.SelectUpdate()
if err != nil {
common.SysLog("failed to update token status" + err.Error())
}
}
return token, ErrTokenInvalid
}
if !token.UnlimitedQuota && token.RemainQuota <= 0 {
if !common.RedisEnabled {
token.Status = common.TokenStatusExhausted
err := token.SelectUpdate()
if err != nil {
common.SysLog("failed to update token status" + err.Error())
}
}
return token, ErrTokenInvalid
}
return token, nil
}
common.SysLog("ValidateUserToken: failed to get token: " + err.Error())
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTokenInvalid
}
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
}
func GetTokenByIds(id int, userId int) (*Token, error) {
if id == 0 || userId == 0 {
return nil, errors.New("id 或 userId 为空!")
}
token := Token{Id: id, UserId: userId}
var err error = nil
err = DB.First(&token, "id = ? and user_id = ?", id, userId).Error
return &token, err
}
func GetTokenById(id int) (*Token, error) {
if id == 0 {
return nil, errors.New("id 为空!")
}
token := Token{Id: id}
var err error = nil
err = DB.First(&token, "id = ?", id).Error
return &token, err
}
func GetTokenByKey(key string, fromDB bool) (token *Token, err error) {
if !fromDB && common.RedisEnabled {
// Try Redis first
token, err := cacheGetTokenByKey(key)
if err == nil {
return token, nil
}
// Don't return error - fall through to DB
}
token = &Token{}
if err = DB.Where(commonKeyCol+" = ?", key).First(token).Error; err != nil {
return nil, err
}
if common.RedisEnabled {
// 冷缓存时用数据库快照初始化;已存在的哈希只刷新 TTL,
// 避免快照覆盖 Redis 中已被原子预扣的余额。初始化失败不影响本次读取。
if _, cacheErr := cacheInitToken(*token); cacheErr != nil {
common.SysLog("failed to init token cache: " + cacheErr.Error())
}
}
return token, nil
}
func (token *Token) Insert() error {
var err error
err = DB.Create(token).Error
return err
}
// Update Make sure your token's fields is completed, because this will update non-zero values
func (token *Token) Update() (err error) {
// 写库前失效缓存并设置 fence,防止并发读者把过期快照重新写回缓存。
if cacheErr := invalidateTokenCacheForMutation(token.Key); cacheErr != nil {
common.SysLog("failed to invalidate token cache before update: " + cacheErr.Error())
}
return DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota",
"model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry", "auto_groups").Updates(token).Error
}
func (token *Token) SelectUpdate() (err error) {
if cacheErr := invalidateTokenCacheForMutation(token.Key); cacheErr != nil {
common.SysLog("failed to invalidate token cache before status update: " + cacheErr.Error())
}
// This can update zero values
return DB.Model(token).Select("accessed_time", "status").Updates(token).Error
}
func (token *Token) Delete() (err error) {
if cacheErr := invalidateTokenCacheForMutation(token.Key); cacheErr != nil {
common.SysLog("failed to invalidate token cache before delete: " + cacheErr.Error())
}
return DB.Delete(token).Error
}
func (token *Token) IsModelLimitsEnabled() bool {
return token.ModelLimitsEnabled
}
func (token *Token) GetModelLimits() []string {
if token.ModelLimits == "" {
return []string{}
}
return strings.Split(token.ModelLimits, ",")
}
func (token *Token) GetModelLimitsMap() map[string]bool {
limits := token.GetModelLimits()
limitsMap := make(map[string]bool)
for _, limit := range limits {
limitsMap[limit] = true
}
return limitsMap
}
func DisableModelLimits(tokenId int) error {
token, err := GetTokenById(tokenId)
if err != nil {
return err
}
token.ModelLimitsEnabled = false
token.ModelLimits = ""
return token.Update()
}
func DeleteTokenById(id int, userId int) (err error) {
// Why we need userId here? In case user want to delete other's token.
if id == 0 || userId == 0 {
return errors.New("id 或 userId 为空!")
}
token := Token{Id: id, UserId: userId}
err = DB.Where(token).First(&token).Error
if err != nil {
return err
}
return token.Delete()
}
func IncreaseTokenQuota(tokenId int, key string, quota int) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
if common.RedisEnabled {
gopool.Go(func() {
// 守卫式增量:哈希不存在时跳过,由下次读取从数据库水合,
// 绝不创建只有配额字段的残缺哈希。
if _, err := cacheApplyTokenQuotaDelta(tokenId, key, int64(quota)); err != nil {
common.SysLog("failed to increase token quota: " + err.Error())
}
})
}
if common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeTokenQuota, tokenId, quota)
return nil
}
return increaseTokenQuota(tokenId, quota)
}
func increaseTokenQuota(id int, quota int) (err error) {
err = DB.Model(&Token{}).Where("id = ?", id).Updates(
map[string]any{
"remain_quota": gorm.Expr("remain_quota + ?", quota),
"used_quota": gorm.Expr("used_quota - ?", quota),
"accessed_time": common.GetTimestamp(),
},
).Error
return err
}
func DecreaseTokenQuota(id int, key string, quota int) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
if common.RedisEnabled {
gopool.Go(func() {
if _, err := cacheApplyTokenQuotaDelta(id, key, int64(-quota)); err != nil {
common.SysLog("failed to decrease token quota: " + err.Error())
}
})
}
if common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeTokenQuota, id, -quota)
return nil
}
return decreaseTokenQuota(id, quota)
}
func decreaseTokenQuota(id int, quota int) (err error) {
err = DB.Model(&Token{}).Where("id = ?", id).Updates(
map[string]any{
"remain_quota": gorm.Expr("remain_quota - ?", quota),
"used_quota": gorm.Expr("used_quota + ?", quota),
"accessed_time": common.GetTimestamp(),
},
).Error
return err
}
// CountUserTokens returns total number of tokens for the given user, used for pagination
func CountUserTokens(userId int) (int64, error) {
var total int64
err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error
return total, err
}
// BatchDeleteTokens 删除指定用户的一组令牌,返回成功删除数量
func BatchDeleteTokens(ids []int, userId int) (int, error) {
if len(ids) == 0 {
return 0, errors.New("ids 不能为空!")
}
tx := DB.Begin()
var tokens []Token
if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Find(&tokens).Error; err != nil {
tx.Rollback()
return 0, err
}
if err := invalidateTokensCache(tokens); err != nil {
common.SysLog("failed to invalidate token cache before batch delete: " + err.Error())
}
if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Delete(&Token{}).Error; err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Commit().Error; err != nil {
return 0, err
}
return len(tokens), nil
}
func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) {
var tokens []Token
err := DB.Select("id", commonKeyCol).
Where("user_id = ? AND id IN (?)", userId, ids).
Find(&tokens).Error
return tokens, err
}
// InvalidateUserTokensCache 清理指定用户所有令牌在 Redis 中的缓存,
// 配合 InvalidateUserCache 使用,可在用户被禁用/删除时立即阻断其令牌的请求。
// 下一次请求将从数据库重新加载令牌及用户状态,从而立即识别出被禁用的用户。
func InvalidateUserTokensCache(userId int) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 {
return errors.New("userId 无效")
}
var tokens []Token
if err := DB.Unscoped().
Select("id", commonKeyCol).
Where("user_id = ?", userId).
Find(&tokens).Error; err != nil {
return err
}
return invalidateTokensCache(tokens)
}
func invalidateTokensCache(tokens []Token) error {
if !common.RedisEnabled {
return nil
}
var firstErr error
for _, t := range tokens {
if t.Key == "" {
continue
}
if err := invalidateTokenCacheForMutation(t.Key); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
+68
View File
@@ -0,0 +1,68 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTokenAutoGroupsRoundTripThroughRedisHashCache(t *testing.T) {
useUserCacheMiniRedis(t)
token := Token{
Id: 42,
UserId: 7,
Key: "token-auto-groups-cache-key",
Name: "auto-cache",
Group: "auto",
AutoGroups: `["vip","default"]`,
}
require.NoError(t, cacheSetTokenForTest(token))
cached, err := cacheGetTokenByKey(token.Key)
require.NoError(t, err)
assert.Equal(t, token.AutoGroups, cached.AutoGroups)
groups, err := cached.GetAutoGroups()
require.NoError(t, err)
assert.Equal(t, []string{"vip", "default"}, groups)
}
func TestTokenUpdateSynchronouslyNarrowsPreheatedAutoGroupsCache(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
token := Token{
UserId: 7,
Key: "token-auto-groups-update-cache-key",
Name: "auto-cache-update",
Status: common.TokenStatusEnabled,
ExpiredTime: -1,
UnlimitedQuota: true,
Group: "auto",
CrossGroupRetry: true,
AutoGroups: `["default","vip"]`,
}
require.NoError(t, token.Insert())
require.NoError(t, cacheSetTokenForTest(token))
preheated, err := cacheGetTokenByKey(token.Key)
require.NoError(t, err)
assert.JSONEq(t, `["default","vip"]`, preheated.AutoGroups)
require.NoError(t, token.SetAutoGroups([]string{"vip"}))
require.NoError(t, token.Update())
// Update 是限制性变更:写库前删除缓存并设置 fence。缓存不再提供旧的
// 宽分组值,下一次读取必须看到收紧后的分组。
_, cacheErr := cacheGetTokenByKey(token.Key)
require.Error(t, cacheErr, "the pre-update cache entry must be invalidated")
reloaded, err := GetTokenByKey(token.Key, false)
require.NoError(t, err)
assert.JSONEq(t, `["vip"]`, reloaded.AutoGroups)
}
// cacheSetTokenForTest 以测试身份写入完整 token 缓存(含额度字段),
// 模拟“已水合”的缓存状态。
func cacheSetTokenForTest(token Token) error {
_, err := cacheInitToken(token)
return err
}
+107
View File
@@ -0,0 +1,107 @@
package model
import (
"context"
"fmt"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
)
func getTokenCacheKey(key string) string {
return fmt.Sprintf("token:%s", common.GenerateHMAC(key))
}
func getTokenCacheFenceKey(key string) string {
return fmt.Sprintf("token:fence:%s", common.GenerateHMAC(key))
}
func tokenCacheTTLSeconds() int {
ttl := common.RedisKeyCacheSeconds()
if ttl <= 0 {
return 60
}
return ttl
}
// tokenCacheFenceSeconds must outlive a token mutation's database write plus
// any in-flight reader's DB-read-to-cache-init gap. The fence is not deleted
// after commit; it expires naturally so a reader holding a pre-mutation
// snapshot cannot publish it right after the mutation cleared the cache.
// While the fence exists readers simply serve the database without caching.
const tokenCacheFenceSeconds = 10
// invalidateTokenCacheForMutation is called before a token metadata mutation
// writes to the database: it raises the fence and drops the cached hash so no
// reader can act on (or re-publish) the pre-mutation state.
func invalidateTokenCacheForMutation(key string) error {
if !common.RedisEnabled || key == "" {
return nil
}
ctx := context.Background()
err := common.RDB.Set(ctx, getTokenCacheFenceKey(key), 1, time.Duration(tokenCacheFenceSeconds)*time.Second).Err()
if err != nil {
return err
}
return common.RDB.Del(ctx, getTokenCacheKey(key)).Err()
}
// cacheInitToken publishes a database snapshot only when no mutation fence is
// active and the hash is cold. An existing hash only gets its TTL refreshed:
// its RemainQuota may already be ahead of this snapshot because atomic
// pre-consume decrements Redis first, so a snapshot must never overwrite any
// field of a live hash.
// 返回值:0=被 fence 拦截,1=完成初始化,2=哈希已存在,仅刷新 TTL。
func cacheInitToken(token Token) (int, error) {
if !common.RedisEnabled {
return 0, nil
}
allowIps := ""
if token.AllowIps != nil {
allowIps = *token.AllowIps
}
const script = `
if redis.call('EXISTS', KEYS[2]) == 1 then
return 0
end
if redis.call('EXISTS', KEYS[1]) == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[17])
return 2
end
redis.call('HSET', KEYS[1],
'Id', ARGV[1], 'UserId', ARGV[2], 'Status', ARGV[3], 'Name', ARGV[4],
'CreatedTime', ARGV[5], 'AccessedTime', ARGV[6], 'ExpiredTime', ARGV[7],
'UnlimitedQuota', ARGV[8], 'ModelLimitsEnabled', ARGV[9], 'ModelLimits', ARGV[10],
'AllowIps', ARGV[11], 'Group', ARGV[12], 'CrossGroupRetry', ARGV[13],
'AutoGroups', ARGV[14], 'RemainQuota', ARGV[15], 'UsedQuota', ARGV[16])
redis.call('EXPIRE', KEYS[1], ARGV[17])
return 1`
return common.RDB.Eval(context.Background(), script, []string{
getTokenCacheKey(token.Key), getTokenCacheFenceKey(token.Key),
},
token.Id, token.UserId, token.Status, token.Name,
token.CreatedTime, token.AccessedTime, token.ExpiredTime,
strconv.FormatBool(token.UnlimitedQuota), strconv.FormatBool(token.ModelLimitsEnabled),
token.ModelLimits, allowIps, token.Group, strconv.FormatBool(token.CrossGroupRetry),
token.AutoGroups, token.RemainQuota, token.UsedQuota,
tokenCacheTTLSeconds(),
).Int()
}
// cacheGetTokenByKey 从缓存读取 token;不完整的哈希(如仅有配额字段)会被拒绝。
func cacheGetTokenByKey(key string) (*Token, error) {
if !common.RedisEnabled {
return nil, fmt.Errorf("redis is not enabled")
}
var token Token
if err := common.RedisHGetObj(getTokenCacheKey(key), &token); err != nil {
return nil, err
}
if token.Id <= 0 {
return nil, fmt.Errorf("token cache is incomplete")
}
token.Key = key
return &token, nil
}
+217
View File
@@ -0,0 +1,217 @@
package model
import (
"fmt"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
tokenKeyIndex = "idx_tokens_key"
postgresTokenKeyConstraint = "tokens_key_key"
gormTokenKeyConstraint = "uni_tokens_key"
)
type tokenKeyUniqueConstraint struct {
Name string `gorm:"column:constraint_name"`
Definition string `gorm:"column:constraint_definition"`
Deferrable bool `gorm:"column:is_deferrable"`
Validated bool `gorm:"column:is_validated"`
}
type tokenKeyIndexState struct {
exists bool
definitionValid bool
standaloneValid bool
}
func inspectTokenKeyUniqueConstraints(db *gorm.DB, tableName string) ([]tokenKeyUniqueConstraint, error) {
var constraints []tokenKeyUniqueConstraint
if err := db.Raw(`
SELECT constraint_meta.conname AS constraint_name,
pg_get_constraintdef(constraint_meta.oid) AS constraint_definition,
constraint_meta.condeferrable AS is_deferrable,
constraint_meta.convalidated AS is_validated
FROM pg_catalog.pg_constraint AS constraint_meta
WHERE constraint_meta.conrelid = to_regclass(?)
AND constraint_meta.contype = 'u'
AND cardinality(constraint_meta.conkey) = 1
AND EXISTS (
SELECT 1
FROM pg_catalog.pg_attribute AS attribute_meta
WHERE attribute_meta.attrelid = constraint_meta.conrelid
AND attribute_meta.attnum = constraint_meta.conkey[1]
AND attribute_meta.attname = ?
)
ORDER BY constraint_meta.conname`, tableName, "key").Scan(&constraints).Error; err != nil {
return nil, fmt.Errorf("inspect token key unique constraints: %w", err)
}
return constraints, nil
}
func validateTokenKeyUniqueConstraints(constraints []tokenKeyUniqueConstraint) error {
for _, constraint := range constraints {
switch constraint.Name {
case tokenKeyIndex, postgresTokenKeyConstraint, gormTokenKeyConstraint:
default:
return fmt.Errorf(
"tokens.key has unsupported unique constraint %q with definition %q",
constraint.Name,
constraint.Definition,
)
}
if constraint.Deferrable || !constraint.Validated || strings.Contains(strings.ToUpper(constraint.Definition), "NULLS NOT DISTINCT") {
return fmt.Errorf(
"tokens.key unique constraint %q has unsupported definition %q",
constraint.Name,
constraint.Definition,
)
}
}
return nil
}
func inspectTokenKeyIndex(db *gorm.DB, tableName string) (tokenKeyIndexState, error) {
var state struct {
Exists bool `gorm:"column:index_exists"`
DefinitionValid bool `gorm:"column:definition_valid"`
StandaloneValid bool `gorm:"column:standalone_valid"`
}
if err := db.Raw(`
SELECT count(*) > 0 AS index_exists,
COALESCE(bool_or(
index_meta.indisunique
AND index_meta.indisvalid
AND index_meta.indisready
AND NOT index_meta.indisprimary
AND index_meta.indpred IS NULL
AND index_meta.indexprs IS NULL
AND index_meta.indnatts = 1
AND attribute_meta.attname = ?
), false) AS definition_valid,
COALESCE(bool_or(
index_meta.indisunique
AND index_meta.indisvalid
AND index_meta.indisready
AND NOT index_meta.indisprimary
AND index_meta.indpred IS NULL
AND index_meta.indexprs IS NULL
AND index_meta.indnatts = 1
AND attribute_meta.attname = ?
AND NOT EXISTS (
SELECT 1
FROM pg_catalog.pg_constraint AS constraint_meta
WHERE constraint_meta.conindid = index_meta.indexrelid
)
), false) AS standalone_valid
FROM pg_catalog.pg_index AS index_meta
JOIN pg_catalog.pg_class AS index_class
ON index_class.oid = index_meta.indexrelid
LEFT JOIN pg_catalog.pg_attribute AS attribute_meta
ON attribute_meta.attrelid = index_meta.indrelid
AND attribute_meta.attnum = index_meta.indkey[0]
WHERE index_meta.indrelid = to_regclass(?)
AND index_class.relname = ?`, "key", "key", tableName, tokenKeyIndex).Scan(&state).Error; err != nil {
return tokenKeyIndexState{}, fmt.Errorf("inspect token key unique index: %w", err)
}
return tokenKeyIndexState{
exists: state.Exists,
definitionValid: state.DefinitionValid,
standaloneValid: state.StandaloneValid,
}, nil
}
// migrateTokenKeyUniqueness converts known PostgreSQL UNIQUE constraints left
// on tokens.key into the standalone uniqueIndex represented by the current
// model. Unknown constraint names are reported without modifying the schema.
func migrateTokenKeyUniqueness(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("migrate token key uniqueness: database is nil")
}
if db.Dialector.Name() != "postgres" {
return nil
}
statement := &gorm.Statement{DB: db}
if err := statement.Parse(&Token{}); err != nil {
return fmt.Errorf("parse token schema: %w", err)
}
tableName := statement.Schema.Table
constraints, err := inspectTokenKeyUniqueConstraints(db, tableName)
if err != nil {
return err
}
if len(constraints) == 0 {
return nil
}
if err := validateTokenKeyUniqueConstraints(constraints); err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
migrator := tx.Migrator()
if !migrator.HasTable(&Token{}) {
return nil
}
if err := tx.Exec(
"LOCK TABLE ? IN ACCESS EXCLUSIVE MODE",
clause.Table{Name: tableName},
).Error; err != nil {
return fmt.Errorf("lock tokens for key uniqueness migration: %w", err)
}
constraints, err := inspectTokenKeyUniqueConstraints(tx, tableName)
if err != nil {
return err
}
if len(constraints) == 0 {
return nil
}
if err := validateTokenKeyUniqueConstraints(constraints); err != nil {
return err
}
targetIndex, err := inspectTokenKeyIndex(tx, tableName)
if err != nil {
return err
}
if targetIndex.exists && !targetIndex.definitionValid {
return fmt.Errorf("token key index %q has an unexpected definition", tokenKeyIndex)
}
for _, constraint := range constraints {
if err := migrator.DropConstraint(&Token{}, constraint.Name); err != nil {
return fmt.Errorf("drop token key unique constraint %q: %w", constraint.Name, err)
}
}
targetIndex, err = inspectTokenKeyIndex(tx, tableName)
if err != nil {
return err
}
if !targetIndex.exists {
if err := migrator.CreateIndex(&Token{}, tokenKeyIndex); err != nil {
return fmt.Errorf("create token key unique index: %w", err)
}
targetIndex, err = inspectTokenKeyIndex(tx, tableName)
if err != nil {
return err
}
}
if !targetIndex.standaloneValid {
return fmt.Errorf("token key index %q has an unexpected definition", tokenKeyIndex)
}
remainingConstraints, err := inspectTokenKeyUniqueConstraints(tx, tableName)
if err != nil {
return err
}
if len(remainingConstraints) != 0 {
return fmt.Errorf("tokens.key still has unique constraints after migration")
}
return nil
})
}
+295
View File
@@ -0,0 +1,295 @@
package model
import (
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func requireTokenConstraintExists(t *testing.T, db *gorm.DB, constraintName string) {
t.Helper()
var count int64
require.NoError(t, db.Raw(`
SELECT count(*)
FROM pg_catalog.pg_constraint
WHERE conrelid = to_regclass(?)
AND conname = ?`, "tokens", constraintName).Scan(&count).Error)
require.EqualValues(t, 1, count)
}
func requireTokenIndexExists(t *testing.T, db *gorm.DB, indexName string) {
t.Helper()
var count int64
require.NoError(t, db.Raw(`
SELECT count(*)
FROM pg_catalog.pg_index AS index_meta
JOIN pg_catalog.pg_class AS index_class
ON index_class.oid = index_meta.indexrelid
WHERE index_meta.indrelid = to_regclass(?)
AND index_class.relname = ?`, "tokens", indexName).Scan(&count).Error)
require.EqualValues(t, 1, count)
}
func testTokenKeyMigrationNonPostgreSQL(t *testing.T, db *gorm.DB) {
t.Helper()
tableName := fmt.Sprintf("token_migration_%d", time.Now().UnixNano())
t.Cleanup(func() { _ = db.Migrator().DropTable(tableName) })
tableDB := db.Table(tableName)
require.NoError(t, tableDB.AutoMigrate(&Token{}))
require.NoError(t, tableDB.Create(&Token{UserId: 1, Key: "preserved-key"}).Error)
for range 2 {
require.NoError(t, migrateTokenKeyUniqueness(db))
require.NoError(t, tableDB.AutoMigrate(&Token{}))
}
var preserved Token
require.NoError(t, tableDB.Where(&Token{Key: "preserved-key"}).First(&preserved).Error)
assert.Equal(t, 1, preserved.UserId)
expectedIndex := db.NamingStrategy.IndexName(tableName, "key")
assert.True(t, db.Migrator().HasIndex(tableName, expectedIndex))
}
func TestMigrateTokenKeyUniquenessSQLite(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
testTokenKeyMigrationNonPostgreSQL(t, db)
}
func TestMigrateTokenKeyUniquenessMySQL(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("TEST_MYSQL_DSN"))
if dsn == "" {
t.Skip("TEST_MYSQL_DSN is not configured")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
testTokenKeyMigrationNonPostgreSQL(t, db)
}
func TestMigrateTokenKeyUniquenessPostgreSQL(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("TEST_POSTGRES_DSN"))
if dsn == "" {
t.Skip("TEST_POSTGRES_DSN is not configured")
}
db, err := gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
PreferSimpleProtocol: true,
}), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
tests := []struct {
name string
prepareOld func(*testing.T, *gorm.DB)
expectedError string
preservedConstraints []string
preservedIndexes []string
}{
{name: "fresh"},
{
name: "legacy_idx_constraint",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
require.NoError(t, tx.Migrator().DropIndex(&Token{}, tokenKeyIndex))
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
clause.Table{Name: "tokens"},
clause.Column{Name: tokenKeyIndex},
clause.Column{Name: "key"},
).Error)
},
},
{
name: "gorm_generated_constraint",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
clause.Table{Name: "tokens"},
clause.Column{Name: gormTokenKeyConstraint},
clause.Column{Name: "key"},
).Error)
},
},
{
name: "postgres_default_constraint_without_target_index",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
require.NoError(t, tx.Migrator().DropIndex(&Token{}, tokenKeyIndex))
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
clause.Table{Name: "tokens"},
clause.Column{Name: postgresTokenKeyConstraint},
clause.Column{Name: "key"},
).Error)
},
},
{
name: "non_conflicting_uniqueness_is_preserved",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
clause.Table{Name: "tokens"},
clause.Column{Name: postgresTokenKeyConstraint},
clause.Column{Name: "key"},
).Error)
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?, ?)",
clause.Table{Name: "tokens"},
clause.Column{Name: "keep_tokens_key_user_id"},
clause.Column{Name: "key"},
clause.Column{Name: "user_id"},
).Error)
require.NoError(t, tx.Exec(
"CREATE UNIQUE INDEX ? ON ? (?) WHERE user_id > 0",
clause.Column{Name: "keep_tokens_partial_key"},
clause.Table{Name: "tokens"},
clause.Column{Name: "key"},
).Error)
},
preservedConstraints: []string{"keep_tokens_key_user_id"},
preservedIndexes: []string{"keep_tokens_partial_key"},
},
{
name: "arbitrary_constraint_is_rejected",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
clause.Table{Name: "tokens"},
clause.Column{Name: "keep_tokens_key_unique"},
clause.Column{Name: "key"},
).Error)
},
expectedError: "unsupported unique constraint",
preservedConstraints: []string{"keep_tokens_key_unique"},
},
{
name: "deferrable_constraint_is_rejected",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
require.NoError(t, tx.Migrator().DropIndex(&Token{}, tokenKeyIndex))
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?) DEFERRABLE INITIALLY DEFERRED",
clause.Table{Name: "tokens"},
clause.Column{Name: postgresTokenKeyConstraint},
clause.Column{Name: "key"},
).Error)
},
expectedError: "unsupported definition",
preservedConstraints: []string{postgresTokenKeyConstraint},
},
{
name: "invalid_target_index_is_rejected",
prepareOld: func(t *testing.T, tx *gorm.DB) {
t.Helper()
require.NoError(t, tx.Migrator().DropIndex(&Token{}, tokenKeyIndex))
require.NoError(t, tx.Exec(
"CREATE INDEX ? ON ? (?)",
clause.Column{Name: tokenKeyIndex},
clause.Table{Name: "tokens"},
clause.Column{Name: "key"},
).Error)
require.NoError(t, tx.Exec(
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (?)",
clause.Table{Name: "tokens"},
clause.Column{Name: postgresTokenKeyConstraint},
clause.Column{Name: "key"},
).Error)
},
expectedError: "unexpected definition",
preservedConstraints: []string{postgresTokenKeyConstraint},
preservedIndexes: []string{tokenKeyIndex},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
tx := db.Begin()
require.NoError(t, tx.Error)
t.Cleanup(func() { _ = tx.Rollback().Error })
schemaName := fmt.Sprintf("token_migration_%d", time.Now().UnixNano())
require.NoError(t, tx.Exec(
"CREATE SCHEMA ?",
clause.Table{Name: schemaName},
).Error)
require.NoError(t, tx.Exec(
"SET LOCAL search_path TO ?",
clause.Table{Name: schemaName},
).Error)
require.NoError(t, migrateTokenKeyUniqueness(tx))
require.NoError(t, tx.AutoMigrate(&Token{}))
original := Token{UserId: 1, Key: "preserved-key", Name: "preserve me"}
require.NoError(t, tx.Create(&original).Error)
if test.prepareOld != nil {
test.prepareOld(t, tx)
}
if test.expectedError != "" {
err := migrateTokenKeyUniqueness(tx)
require.Error(t, err)
assert.Contains(t, err.Error(), test.expectedError)
for _, constraintName := range test.preservedConstraints {
requireTokenConstraintExists(t, tx, constraintName)
}
for _, indexName := range test.preservedIndexes {
requireTokenIndexExists(t, tx, indexName)
}
return
}
for range 2 {
require.NoError(t, migrateTokenKeyUniqueness(tx))
require.NoError(t, tx.AutoMigrate(&Token{}))
}
var preserved Token
require.NoError(t, tx.First(&preserved, original.Id).Error)
assert.Equal(t, original.Key, preserved.Key)
assert.Equal(t, original.Name, preserved.Name)
constraints, err := inspectTokenKeyUniqueConstraints(tx, "tokens")
require.NoError(t, err)
assert.Empty(t, constraints)
targetIndex, err := inspectTokenKeyIndex(tx, "tokens")
require.NoError(t, err)
assert.True(t, targetIndex.standaloneValid)
for _, constraintName := range test.preservedConstraints {
requireTokenConstraintExists(t, tx, constraintName)
}
for _, indexName := range test.preservedIndexes {
requireTokenIndexExists(t, tx, indexName)
}
duplicateError := tx.Transaction(func(duplicateTx *gorm.DB) error {
return duplicateTx.Create(&Token{UserId: 2, Key: original.Key}).Error
})
require.Error(t, duplicateError)
var totalRows int64
require.NoError(t, tx.Model(&Token{}).Count(&totalRows).Error)
assert.EqualValues(t, 1, totalRows)
})
}
}
+714
View File
@@ -0,0 +1,714 @@
package model
import (
"errors"
"fmt"
"maps"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type TopUp struct {
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int64 `json:"amount"`
Money float64 `json:"money"`
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
PaymentProvider string `json:"payment_provider" gorm:"type:varchar(50);default:''"`
CreateTime int64 `json:"create_time"`
CompleteTime int64 `json:"complete_time"`
Status string `json:"status"`
}
const (
PaymentMethodStripe = "stripe"
PaymentMethodCreem = "creem"
PaymentMethodWaffo = "waffo"
PaymentMethodWaffoPancake = "waffo_pancake"
PaymentMethodBalance = "balance"
)
const (
PaymentProviderEpay = "epay"
PaymentProviderStripe = "stripe"
PaymentProviderCreem = "creem"
PaymentProviderWaffo = "waffo"
PaymentProviderWaffoPancake = "waffo_pancake"
PaymentProviderBalance = "balance"
)
var (
ErrPaymentMethodMismatch = errors.New("payment method mismatch")
ErrTopUpNotFound = errors.New("topup not found")
ErrTopUpStatusInvalid = errors.New("topup status invalid")
ErrInvalidTopUpQuota = errors.New("invalid top-up quota")
ErrTopUpQuotaLimitExceeded = errors.New("top-up quota limit exceeded")
ErrWalletQuotaLimitExceeded = errors.New("wallet quota limit exceeded")
)
func (topUp *TopUp) Insert() error {
var err error
err = DB.Create(topUp).Error
return err
}
func topUpQuotaMaxCurrent(creditedQuota int) (int, error) {
if creditedQuota <= 0 || creditedQuota > common.MaxWalletQuota {
return 0, ErrInvalidTopUpQuota
}
return common.MaxWalletQuota - creditedQuota, nil
}
// ValidateTopUpQuotaCapacity performs the user-facing pre-payment check. The
// settlement path repeats the same invariant with an atomic conditional
// update, because the wallet balance can change after checkout creation.
func ValidateTopUpQuotaCapacity(userId int, creditedQuota int) error {
maxCurrentQuota, err := topUpQuotaMaxCurrent(creditedQuota)
if err != nil {
return err
}
var user User
if err := DB.Select("quota").Where("id = ?", userId).First(&user).Error; err != nil {
return err
}
if user.Quota > maxCurrentQuota {
return ErrTopUpQuotaLimitExceeded
}
return nil
}
// creditTopUpQuota atomically enforces the wallet ceiling while adding quota.
// Keeping the predicate and increment in one UPDATE prevents two
// concurrent callbacks from both passing a separate read/check.
func creditTopUpQuota(tx *gorm.DB, userId int, creditedQuota int, updates map[string]any) error {
maxCurrentQuota, err := topUpQuotaMaxCurrent(creditedQuota)
if err != nil {
return err
}
updateFields := make(map[string]any, len(updates)+1)
maps.Copy(updateFields, updates)
updateFields["quota"] = gorm.Expr("quota + ?", creditedQuota)
result := tx.Model(&User{}).
Where("id = ? AND quota <= ?", userId, maxCurrentQuota).
Updates(updateFields)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 1 {
return nil
}
var count int64
if err := tx.Model(&User{}).Where("id = ?", userId).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return gorm.ErrRecordNotFound
}
return ErrTopUpQuotaLimitExceeded
}
func (topUp *TopUp) Update() error {
var err error
err = DB.Save(topUp).Error
return err
}
func GetTopUpById(id int) *TopUp {
var topUp *TopUp
var err error
err = DB.Where("id = ?", id).First(&topUp).Error
if err != nil {
return nil
}
return topUp
}
func GetTopUpByTradeNo(tradeNo string) *TopUp {
var topUp *TopUp
var err error
err = DB.Where("trade_no = ?", tradeNo).First(&topUp).Error
if err != nil {
return nil
}
return topUp
}
func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentProvider string, targetStatus string) error {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
refCol := "`trade_no`"
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
refCol = `"trade_no"`
}
return DB.Transaction(func(tx *gorm.DB) error {
topUp := &TopUp{}
if err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
return ErrTopUpNotFound
}
if expectedPaymentProvider != "" && topUp.PaymentProvider != expectedPaymentProvider {
return ErrPaymentMethodMismatch
}
if topUp.Status != common.TopUpStatusPending {
return ErrTopUpStatusInvalid
}
topUp.Status = targetStatus
return tx.Save(topUp).Error
})
}
// RechargeEpay 原子完成易支付订单:订单行锁、状态校验、成功更新与用户额度增加
// 在同一个事务内完成,因此同一订单的并发/重复回调(包括多实例部署下)最多充值一次。
// alreadyDone=true 表示订单此前已完成,本次为幂等重复回调。
// 进程内的 LockOrder 只是优化,正确性由本函数的数据库行锁保证。
func RechargeEpay(tradeNo string, actualPaymentMethod string, callerIp string) (alreadyDone bool, err error) {
if tradeNo == "" {
return false, errors.New("未提供支付单号")
}
refCol := "`trade_no`"
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
refCol = `"trade_no"`
}
var quotaToAdd int
topUp := &TopUp{}
err = DB.Transaction(func(tx *gorm.DB) error {
if err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
return ErrTopUpNotFound
}
if topUp.PaymentProvider != PaymentProviderEpay {
return ErrPaymentMethodMismatch
}
if topUp.Status == common.TopUpStatusSuccess {
alreadyDone = true
return nil
}
if topUp.Status != common.TopUpStatusPending {
return ErrTopUpStatusInvalid
}
if actualPaymentMethod != "" && topUp.PaymentMethod != actualPaymentMethod {
topUp.PaymentMethod = actualPaymentMethod
}
var quotaErr error
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if quotaErr != nil || quotaToAdd <= 0 {
return ErrInvalidTopUpQuota
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}
return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil)
})
if err != nil {
if !errors.Is(err, ErrTopUpNotFound) && !errors.Is(err, ErrPaymentMethodMismatch) && !errors.Is(err, ErrTopUpStatusInvalid) {
common.SysError("epay topup failed: " + err.Error())
}
return false, err
}
if alreadyDone {
return true, nil
}
syncCreditUserQuotaCache(topUp.UserId, quotaToAdd, "epay topup")
common.SysLog(fmt.Sprintf("易支付充值成功 trade_no=%s user_id=%d quota_to_add=%d money=%.2f", topUp.TradeNo, topUp.UserId, quotaToAdd, topUp.Money))
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentProviderEpay)
return false, nil
}
func Recharge(referenceId string, customerId string, callerIp string) (err error) {
if referenceId == "" {
return errors.New("未提供支付单号")
}
var quota int
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := lockForUpdate(tx).Where(refCol+" = ?", referenceId).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.PaymentProvider != PaymentProviderStripe {
return ErrPaymentMethodMismatch
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
err = tx.Save(topUp).Error
if err != nil {
return err
}
quota, err = common.WalletQuotaFromDecimalStrict(
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quota <= 0 {
return ErrInvalidTopUpQuota
}
return creditTopUpQuota(tx, topUp.UserId, quota, map[string]any{
"stripe_customer": customerId,
})
})
if err != nil {
common.SysError("topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
syncCreditUserQuotaCache(topUp.UserId, quota, "stripe topup")
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(quota), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe)
return nil
}
// topUpQueryWindowSeconds 限制充值记录查询的时间窗口(秒)。
const topUpQueryWindowSeconds int64 = 30 * 24 * 60 * 60
// topUpQueryCutoff 返回允许查询的最早 create_time(秒级 Unix 时间戳)。
func topUpQueryCutoff() int64 {
return common.GetTimestamp() - topUpQueryWindowSeconds
}
func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
// Start transaction
tx := DB.Begin()
if tx.Error != nil {
return nil, 0, tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
cutoff := topUpQueryCutoff()
// Get total count within transaction
err = tx.Model(&TopUp{}).Where("user_id = ? AND create_time >= ?", userId, cutoff).Count(&total).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
// Get paginated topups within same transaction
err = tx.Where("user_id = ? AND create_time >= ?", userId, cutoff).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
// Commit transaction
if err = tx.Commit().Error; err != nil {
return nil, 0, err
}
return topups, total, nil
}
// GetAllTopUps 获取全平台的充值记录(管理员使用,不限制时间窗口)
func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
tx := DB.Begin()
if tx.Error != nil {
return nil, 0, tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err = tx.Model(&TopUp{}).Count(&total).Error; err != nil {
tx.Rollback()
return nil, 0, err
}
if err = tx.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
tx.Rollback()
return nil, 0, err
}
if err = tx.Commit().Error; err != nil {
return nil, 0, err
}
return topups, total, nil
}
// searchTopUpCountHardLimit 搜索充值记录时 COUNT 的安全上限,
// 防止对超大表执行无界 COUNT 触发 DoS。
const searchTopUpCountHardLimit = 10000
// SearchUserTopUps 按订单号搜索某用户的充值记录
func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
tx := DB.Begin()
if tx.Error != nil {
return nil, 0, tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
query := tx.Model(&TopUp{}).Where("user_id = ? AND create_time >= ?", userId, topUpQueryCutoff())
if keyword != "" {
pattern, perr := sanitizeLikePattern(keyword)
if perr != nil {
tx.Rollback()
return nil, 0, perr
}
query = query.Where("trade_no LIKE ? ESCAPE '!'", pattern)
}
if err = query.Limit(searchTopUpCountHardLimit).Count(&total).Error; err != nil {
tx.Rollback()
common.SysError("failed to count search topups: " + err.Error())
return nil, 0, errors.New("搜索充值记录失败")
}
if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
tx.Rollback()
common.SysError("failed to search topups: " + err.Error())
return nil, 0, errors.New("搜索充值记录失败")
}
if err = tx.Commit().Error; err != nil {
return nil, 0, err
}
return topups, total, nil
}
// SearchAllTopUps 按订单号搜索全平台充值记录(管理员使用,不限制时间窗口)
func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
tx := DB.Begin()
if tx.Error != nil {
return nil, 0, tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
query := tx.Model(&TopUp{})
if keyword != "" {
pattern, perr := sanitizeLikePattern(keyword)
if perr != nil {
tx.Rollback()
return nil, 0, perr
}
query = query.Where("trade_no LIKE ? ESCAPE '!'", pattern)
}
if err = query.Limit(searchTopUpCountHardLimit).Count(&total).Error; err != nil {
tx.Rollback()
common.SysError("failed to count search topups: " + err.Error())
return nil, 0, errors.New("搜索充值记录失败")
}
if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
tx.Rollback()
common.SysError("failed to search topups: " + err.Error())
return nil, 0, errors.New("搜索充值记录失败")
}
if err = tx.Commit().Error; err != nil {
return nil, 0, err
}
return topups, total, nil
}
// ManualCompleteTopUp 管理员手动完成订单并给用户充值
func ManualCompleteTopUp(tradeNo string, callerIp string) error {
if tradeNo == "" {
return errors.New("未提供订单号")
}
refCol := "`trade_no`"
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
refCol = `"trade_no"`
}
var userId int
var quotaToAdd int
var payMoney float64
var paymentMethod string
err := DB.Transaction(func(tx *gorm.DB) error {
topUp := &TopUp{}
// 行级锁,避免并发补单
if err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
return errors.New("充值订单不存在")
}
// 幂等处理:已成功直接返回
if topUp.Status == common.TopUpStatusSuccess {
return nil
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("订单状态不是待支付,无法补单")
}
// 计算应充值额度:
// - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
var quotaErr error
if topUp.PaymentProvider == PaymentProviderStripe {
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
} else {
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
}
if quotaErr != nil || quotaToAdd <= 0 {
return ErrInvalidTopUpQuota
}
// 标记完成
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}
// 增加用户额度(立即写库,保持一致性)
if err := creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil); err != nil {
return err
}
userId = topUp.UserId
payMoney = topUp.Money
paymentMethod = topUp.PaymentMethod
return nil
})
if err != nil {
return err
}
// 事务外记录日志,避免阻塞
syncCreditUserQuotaCache(userId, quotaToAdd, "manual topup")
RecordTopupLog(userId, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney), callerIp, paymentMethod, "admin")
return nil
}
func RechargeCreem(referenceId string, customerEmail string, customerName string, callerIp string) (err error) {
if referenceId == "" {
return errors.New("未提供支付单号")
}
var quota int
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := lockForUpdate(tx).Where(refCol+" = ?", referenceId).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.PaymentProvider != PaymentProviderCreem {
return ErrPaymentMethodMismatch
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
err = tx.Save(topUp).Error
if err != nil {
return err
}
// Creem 直接使用 Amount 作为充值额度(整数)
quota, err = common.WalletQuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
if err != nil || quota <= 0 {
return ErrInvalidTopUpQuota
}
// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
updateFields := map[string]any{}
// 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
if customerEmail != "" {
// 先检查用户当前邮箱是否为空
var user User
err = tx.Where("id = ?", topUp.UserId).First(&user).Error
if err != nil {
return err
}
// 如果用户邮箱为空,则更新为支付时使用的邮箱
if user.Email == "" {
updateFields["email"] = customerEmail
}
}
return creditTopUpQuota(tx, topUp.UserId, quota, updateFields)
})
if err != nil {
common.SysError("creem topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
syncCreditUserQuotaCache(topUp.UserId, quota, "creem topup")
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodCreem)
return nil
}
func RechargeWaffo(tradeNo string, callerIp string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
var quotaToAdd int
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.PaymentProvider != PaymentProviderWaffo {
return ErrPaymentMethodMismatch
}
if topUp.Status == common.TopUpStatusSuccess {
return nil // 幂等:已成功直接返回
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
quotaToAdd, err = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quotaToAdd <= 0 {
return ErrInvalidTopUpQuota
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}
return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil)
})
if err != nil {
common.SysError("waffo topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
syncCreditUserQuotaCache(topUp.UserId, quotaToAdd, "waffo topup")
if quotaToAdd > 0 {
RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodWaffo)
}
return nil
}
func RechargeWaffoPancake(tradeNo string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
var quotaToAdd int
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.PaymentProvider != PaymentProviderWaffoPancake {
return ErrPaymentMethodMismatch
}
if topUp.Status == common.TopUpStatusSuccess {
return nil
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
quotaToAdd, err = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quotaToAdd <= 0 {
return ErrInvalidTopUpQuota
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}
return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil)
})
if err != nil {
common.SysError("waffo pancake topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
syncCreditUserQuotaCache(topUp.UserId, quotaToAdd, "waffo pancake topup")
if quotaToAdd > 0 {
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
}
return nil
}
+358
View File
@@ -0,0 +1,358 @@
package model
import (
"errors"
"fmt"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
// TwoFA 用户2FA设置表
type TwoFA struct {
Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"unique;not null;index"`
Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥,不返回给前端
IsEnabled bool `json:"is_enabled"`
FailedAttempts int `json:"failed_attempts" gorm:"default:0"`
LockedUntil *time.Time `json:"locked_until,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// TwoFABackupCode 备用码使用记录表
type TwoFABackupCode struct {
Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"not null;index"`
CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希
IsUsed bool `json:"is_used"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// GetTwoFAByUserId 根据用户ID获取2FA设置
func GetTwoFAByUserId(userId int) (*TwoFA, error) {
if userId == 0 {
return nil, errors.New("用户ID不能为空")
}
var twoFA TwoFA
err := DB.Where("user_id = ?", userId).First(&twoFA).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // 返回nil表示未设置2FA
}
return nil, err
}
return &twoFA, nil
}
// IsTwoFAEnabled 检查用户是否启用了2FA
func IsTwoFAEnabled(userId int) (bool, error) {
twoFA, err := GetTwoFAByUserId(userId)
if err != nil {
return false, err
}
return twoFA != nil && twoFA.IsEnabled, nil
}
func (t *TwoFA) updateUsageState() error {
if t.Id == 0 {
return errors.New("2FA记录ID不能为空")
}
return DB.Model(&TwoFA{}).Where("id = ?", t.Id).Updates(map[string]any{
"failed_attempts": t.FailedAttempts,
"locked_until": t.LockedUntil,
"last_used_at": t.LastUsedAt,
}).Error
}
// ResetFailedAttempts 重置失败尝试次数
func (t *TwoFA) ResetFailedAttempts() error {
t.FailedAttempts = 0
t.LockedUntil = nil
return t.updateUsageState()
}
// IncrementFailedAttempts 增加失败尝试次数
func (t *TwoFA) IncrementFailedAttempts() error {
if t.Id == 0 {
return errors.New("2FA记录ID不能为空")
}
const maxUpdateRetries = 5
for range maxUpdateRetries {
var current TwoFA
if err := DB.Select("id", "failed_attempts", "locked_until").First(&current, t.Id).Error; err != nil {
return err
}
now := time.Now()
if current.LockedUntil != nil && now.Before(*current.LockedUntil) {
t.FailedAttempts = current.FailedAttempts
t.LockedUntil = current.LockedUntil
return nil
}
nextFailedAttempts := current.FailedAttempts + 1
nextLockedUntil := current.LockedUntil
if nextFailedAttempts >= common.MaxFailAttempts {
lockUntil := now.Add(time.Duration(common.LockoutDuration) * time.Second)
nextLockedUntil = &lockUntil
}
result := DB.Model(&TwoFA{}).
Where("id = ? AND failed_attempts = ? AND (locked_until IS NULL OR locked_until <= ?)", current.Id, current.FailedAttempts, now).
Updates(map[string]any{
"failed_attempts": nextFailedAttempts,
"locked_until": nextLockedUntil,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
continue
}
t.FailedAttempts = nextFailedAttempts
t.LockedUntil = nextLockedUntil
return nil
}
return errors.New("更新2FA失败次数冲突,请重试")
}
// IsLocked 检查账户是否被锁定
func (t *TwoFA) IsLocked() bool {
if t.LockedUntil == nil {
return false
}
return time.Now().Before(*t.LockedUntil)
}
func replaceBackupCodesWithTx(tx *gorm.DB, userId int, codes []string) error {
if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
for _, code := range codes {
hashedCode, err := common.HashBackupCode(code)
if err != nil {
return err
}
if err := tx.Create(&TwoFABackupCode{UserId: userId, CodeHash: hashedCode, IsUsed: false}).Error; err != nil {
return err
}
}
return nil
}
// ReplaceBackupCodesWithAuthVersion atomically replaces the factor's recovery
// credentials and advances the user's authentication version.
func ReplaceBackupCodesWithAuthVersion(userId int, codes []string) error {
return replaceBackupCodesWithAuthVersion(userId, codes, nil)
}
func ReplaceBackupCodesForSession(identity AuthSessionIdentity, codes []string) error {
return replaceBackupCodesWithAuthVersion(identity.UserID, codes, &identity)
}
func replaceBackupCodesWithAuthVersion(userId int, codes []string, identity *AuthSessionIdentity) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil {
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
return err
}
} else if err := lockForUpdate(tx).Select("id").First(&User{}, userId).Error; err != nil {
return err
}
var enabled TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, true).First(&enabled).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFANotEnabled
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, userId); err != nil {
return err
}
return replaceBackupCodesWithTx(tx, userId, codes)
}); err != nil {
return err
}
return PublishUserAuthCache(userId)
}
// ValidateBackupCode 验证并使用备用码
func ValidateBackupCode(userId int, code string) (bool, error) {
if !common.ValidateBackupCode(code) {
return false, errors.New("验证码或备用码不正确")
}
normalizedCode := common.NormalizeBackupCode(code)
// 查找未使用的备用码
var backupCodes []TwoFABackupCode
if err := DB.Where("user_id = ? AND is_used = false", userId).Find(&backupCodes).Error; err != nil {
return false, err
}
// 验证备用码
for _, bc := range backupCodes {
if common.ValidatePasswordAndHash(normalizedCode, bc.CodeHash) {
now := time.Now()
result := DB.Model(&TwoFABackupCode{}).
Where("id = ? AND is_used = ?", bc.Id, false).
Updates(map[string]any{
"is_used": true,
"used_at": now,
})
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected == 1, nil
}
}
return false, nil
}
// GetUnusedBackupCodeCount 获取未使用的备用码数量
func GetUnusedBackupCodeCount(userId int) (int, error) {
var count int64
err := DB.Model(&TwoFABackupCode{}).Where("user_id = ? AND is_used = false", userId).Count(&count).Error
return int(count), err
}
// DisableTwoFAWithAuthVersion atomically removes the factor and invalidates
// every access token issued against the previous security configuration.
func DisableTwoFAWithAuthVersion(userId int) error {
return disableTwoFAWithAuthVersion(userId, nil)
}
func DisableTwoFAForSession(identity AuthSessionIdentity) error {
return disableTwoFAWithAuthVersion(identity.UserID, &identity)
}
func disableTwoFAWithAuthVersion(userId int, identity *AuthSessionIdentity) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil {
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
return err
}
} else if err := lockForUpdate(tx).Select("id").First(&User{}, userId).Error; err != nil {
return err
}
var twoFA TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, true).First(&twoFA).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFANotEnabled
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, userId); err != nil {
return err
}
if err := tx.Unscoped().Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&twoFA).Error
}); err != nil {
return err
}
return PublishUserAuthCache(userId)
}
// ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录
func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) {
// 检查是否被锁定
if t.IsLocked() {
return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05"))
}
// 验证TOTP码
if !common.ValidateTOTPCode(t.Secret, code) {
// 增加失败次数
if err := t.IncrementFailedAttempts(); err != nil {
return false, err
}
return false, nil
}
// 验证成功,重置失败次数并更新最后使用时间
now := time.Now()
t.FailedAttempts = 0
t.LockedUntil = nil
t.LastUsedAt = &now
if err := t.updateUsageState(); err != nil {
return false, err
}
return true, nil
}
// ValidateBackupCodeAndUpdateUsage 验证备用码并更新使用记录
func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) {
// 检查是否被锁定
if t.IsLocked() {
return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05"))
}
// 验证备用码
valid, err := ValidateBackupCode(t.UserId, code)
if err != nil {
return false, err
}
if !valid {
// 增加失败次数
if err := t.IncrementFailedAttempts(); err != nil {
return false, err
}
return false, nil
}
// 验证成功,重置失败次数并更新最后使用时间
now := time.Now()
t.FailedAttempts = 0
t.LockedUntil = nil
t.LastUsedAt = &now
if err := t.updateUsageState(); err != nil {
return false, err
}
return true, nil
}
// GetTwoFAStats 获取2FA统计信息(管理员使用)
func GetTwoFAStats() (map[string]any, error) {
var totalUsers, enabledUsers int64
// 总用户数
if err := DB.Model(&User{}).Count(&totalUsers).Error; err != nil {
return nil, err
}
// 启用2FA的用户数
if err := DB.Model(&TwoFA{}).Where("is_enabled = true").Count(&enabledUsers).Error; err != nil {
return nil, err
}
enabledRate := float64(0)
if totalUsers > 0 {
enabledRate = float64(enabledUsers) / float64(totalUsers) * 100
}
return map[string]any{
"total_users": totalUsers,
"enabled_users": enabledUsers,
"enabled_rate": fmt.Sprintf("%.1f%%", enabledRate),
}, nil
}
+114
View File
@@ -0,0 +1,114 @@
package model
import (
"crypto/hmac"
"errors"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
var ErrTwoFASetupInvalid = errors.New("The two-factor setup has expired or changed. Start setup again.")
var ErrTwoFACodeInvalid = errors.New("The authenticator code is incorrect.")
type twoFAEnrollmentPayload struct {
TwoFAID int `json:"twofa_id"`
SecretHash string `json:"secret_hash"`
Authorization *AuthFlowAuthorization `json:"authorization"`
}
// CreateTwoFAEnrollment stores the pending credential, recovery codes and
// session-bound flow atomically. Reinitialization invalidates the previous setup.
func CreateTwoFAEnrollment(identity AuthSessionIdentity, authorization *AuthFlowAuthorization, secret string, backupCodes []string, expiresAt time.Time) (string, error) {
if authorization == nil || authorization.ProofID <= 0 || authorization.AuthSessionIdentity != identity {
return "", ErrTwoFASetupInvalid
}
var token string
err := DB.Transaction(func(tx *gorm.DB) error {
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var existing TwoFA
err := lockForUpdate(tx).Where("user_id = ?", identity.UserID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err == nil {
if existing.IsEnabled {
return ErrTwoFAAlreadyEnabled
}
if err := tx.Unscoped().Delete(&existing).Error; err != nil {
return err
}
}
pending := &TwoFA{UserId: identity.UserID, Secret: secret}
if err := tx.Create(pending).Error; err != nil {
return err
}
if err := replaceBackupCodesWithTx(tx, identity.UserID, backupCodes); err != nil {
return err
}
payload, err := common.Marshal(twoFAEnrollmentPayload{
TwoFAID: pending.Id, SecretHash: common.GenerateHMACWithKey([]byte("twofa-enrollment:"+common.SessionSecret), secret),
Authorization: authorization,
})
if err != nil {
return err
}
token, _, err = createAuthFlowWithTx(tx, AuthFlowCreate{
Purpose: AuthFlowPurposeTwoFASetup, UserId: identity.UserID, SessionId: identity.SessionID,
Payload: string(payload), ExpiresAt: expiresAt,
})
return err
})
return token, err
}
// EnableTwoFAEnrollment validates and consumes one setup in the same transaction
// as factor activation and auth_version advancement. A bad code can be retried.
func EnableTwoFAEnrollment(identity AuthSessionIdentity, token, code string) error {
cleanCode, err := common.ValidateNumericCode(code)
if err != nil {
return ErrTwoFACodeInvalid
}
_, err = ConsumeAuthFlowWithAction(token, AuthFlowMatch{
Purpose: AuthFlowPurposeTwoFASetup, UserId: identity.UserID, SessionId: identity.SessionID,
}, func(tx *gorm.DB, flow *AuthFlow) error {
var payload twoFAEnrollmentPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
return err
}
if payload.Authorization == nil || payload.Authorization.ProofID <= 0 || payload.Authorization.AuthSessionIdentity != identity {
return ErrTwoFASetupInvalid
}
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var pending TwoFA
if err := lockForUpdate(tx).Where("id = ? AND user_id = ? AND is_enabled = ?", payload.TwoFAID, identity.UserID, false).First(&pending).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFASetupInvalid
}
return err
}
secretHash := common.GenerateHMACWithKey([]byte("twofa-enrollment:"+common.SessionSecret), pending.Secret)
if !hmac.Equal([]byte(payload.SecretHash), []byte(secretHash)) {
return ErrTwoFASetupInvalid
}
if !common.ValidateTOTPCode(pending.Secret, cleanCode) {
return ErrTwoFACodeInvalid
}
if _, err := IncrementUserAuthVersionWithTx(tx, identity.UserID); err != nil {
return err
}
return tx.Model(&pending).Updates(map[string]any{"is_enabled": true, "failed_attempts": 0, "locked_until": nil}).Error
})
if errors.Is(err, ErrAuthFlowInvalid) || errors.Is(err, ErrAuthFlowExpired) || errors.Is(err, ErrAuthFlowConsumed) {
return ErrTwoFASetupInvalid
}
if err != nil {
return err
}
return PublishUserAuthCache(identity.UserID)
}
+183
View File
@@ -0,0 +1,183 @@
package model
import (
"fmt"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
// QuotaData 柱状图数据
type QuotaData struct {
Id int `json:"id"`
UserID int `json:"user_id" gorm:"index"`
Username string `json:"username" gorm:"index:idx_qdt_model_user_name,priority:2;size:64;default:''"`
ModelName string `json:"model_name" gorm:"index:idx_qdt_model_user_name,priority:1;size:64;default:''"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_qdt_created_at,priority:2"`
UseGroup string `json:"use_group" gorm:"index;size:64;default:''"`
TokenID int `json:"token_id" gorm:"index;default:0"`
ChannelID int `json:"channel_id" gorm:"index;default:0"`
NodeName string `json:"node_name" gorm:"index;size:64;default:''"`
TokenUsed int `json:"token_used" gorm:"default:0"`
Count int `json:"count" gorm:"default:0"`
Quota int `json:"quota" gorm:"default:0"`
}
type QuotaDataLogParams struct {
UserID int
Username string
ModelName string
Quota int
CreatedAt int64
TokenUsed int
UseGroup string
TokenID int
ChannelID int
NodeName string
}
func UpdateQuotaData() {
for {
if common.DataExportEnabled {
common.SysLog("正在更新数据看板数据...")
SaveQuotaDataCache()
}
time.Sleep(time.Duration(common.DataExportInterval) * time.Minute)
}
}
var CacheQuotaData = make(map[string]*QuotaData)
var CacheQuotaDataLock = sync.Mutex{}
func logQuotaDataCache(quotaData *QuotaData) {
key := fmt.Sprintf("%d\x00%s\x00%s\x00%d\x00%s\x00%d\x00%d\x00%s",
quotaData.UserID,
quotaData.Username,
quotaData.ModelName,
quotaData.CreatedAt,
quotaData.UseGroup,
quotaData.TokenID,
quotaData.ChannelID,
quotaData.NodeName,
)
count := quotaData.Count
quota := quotaData.Quota
tokenUsed := quotaData.TokenUsed
cachedQuotaData, ok := CacheQuotaData[key]
if ok {
cachedQuotaData.Count += count
cachedQuotaData.Quota += quota
cachedQuotaData.TokenUsed += tokenUsed
quotaData = cachedQuotaData
}
CacheQuotaData[key] = quotaData
}
func LogQuotaData(params QuotaDataLogParams) {
// 只精确到小时
createdAt := params.CreatedAt - (params.CreatedAt % 3600)
quotaData := &QuotaData{
UserID: params.UserID,
Username: params.Username,
ModelName: params.ModelName,
CreatedAt: createdAt,
UseGroup: params.UseGroup,
TokenID: params.TokenID,
ChannelID: params.ChannelID,
NodeName: params.NodeName,
Count: 1,
Quota: params.Quota,
TokenUsed: params.TokenUsed,
}
CacheQuotaDataLock.Lock()
defer CacheQuotaDataLock.Unlock()
logQuotaDataCache(quotaData)
}
func SaveQuotaDataCache() {
CacheQuotaDataLock.Lock()
defer CacheQuotaDataLock.Unlock()
size := len(CacheQuotaData)
// 如果缓存中有数据,就保存到数据库中
// 1. 先查询数据库中是否有数据
// 2. 如果有数据,就更新数据
// 3. 如果没有数据,就插入数据
for _, quotaData := range CacheQuotaData {
quotaDataDB := &QuotaData{}
DB.Table("quota_data").
Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?",
quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName).
First(quotaDataDB)
if quotaDataDB.Id > 0 {
//quotaDataDB.Count += quotaData.Count
//quotaDataDB.Quota += quotaData.Quota
//DB.Table("quota_data").Save(quotaDataDB)
increaseQuotaData(quotaData)
} else {
DB.Table("quota_data").Create(quotaData)
}
}
CacheQuotaData = make(map[string]*QuotaData)
common.SysLog(fmt.Sprintf("保存数据看板数据成功,共保存%d条数据", size))
}
func increaseQuotaData(quotaData *QuotaData) {
err := DB.Table("quota_data").
Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?",
quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName).
Updates(map[string]any{
"count": gorm.Expr("count + ?", quotaData.Count),
"quota": gorm.Expr("quota + ?", quotaData.Quota),
"token_used": gorm.Expr("token_used + ?", quotaData.TokenUsed),
}).Error
if err != nil {
common.SysLog(fmt.Sprintf("increaseQuotaData error: %s", err))
}
}
func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
// 从quota_data表中查询数据
err = DB.Table("quota_data").
Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime).
Group("user_id, username, model_name, created_at").
Find(&quotaDatas).Error
return quotaDatas, err
}
func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
// 从quota_data表中查询数据
err = DB.Table("quota_data").
Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime).
Group("user_id, username, model_name, created_at").
Find(&quotaDatas).Error
return quotaDatas, err
}
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
err = DB.Table("quota_data").
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Where("created_at >= ? and created_at <= ?", startTime, endTime).
Group("username, created_at").
Find(&quotaDatas).Error
return quotaDatas, err
}
func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
if username != "" {
return GetQuotaDataByUsername(username, startTime, endTime)
}
var quotaDatas []*QuotaData
// 从quota_data表中查询数据
// only select model_name, sum(count) as count, sum(quota) as quota, model_name, created_at from quota_data group by model_name, created_at;
//err = DB.Table("quota_data").Where("created_at >= ? and created_at <= ?", startTime, endTime).Find(&quotaDatas).Error
err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find(&quotaDatas).Error
return quotaDatas, err
}
+178
View File
@@ -0,0 +1,178 @@
package model
import (
"fmt"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
type FlowQuotaData struct {
UserID int `json:"user_id,omitempty" gorm:"column:user_id"`
Username string `json:"username,omitempty" gorm:"column:username"`
NodeName string `json:"node_name,omitempty" gorm:"column:node_name"`
TokenID int `json:"token_id,omitempty" gorm:"column:token_id"`
TokenName string `json:"token_name,omitempty" gorm:"-"`
UseGroup string `json:"use_group" gorm:"column:use_group"`
ChannelID int `json:"channel_id,omitempty" gorm:"column:channel_id"`
ChannelName string `json:"channel_name,omitempty" gorm:"-"`
ModelName string `json:"model_name" gorm:"column:model_name"`
TokenUsed int `json:"token_used" gorm:"column:token_used"`
Count int `json:"count" gorm:"column:count"`
Quota int `json:"quota" gorm:"column:quota"`
}
func GetFlowQuotaData(startTime int64, endTime int64, username string, userID int, role int) ([]*FlowQuotaData, error) {
switch {
case role >= common.RoleRootUser:
return getRootFlowQuotaData(startTime, endTime, username)
case role >= common.RoleAdminUser:
return getAdminFlowQuotaData(startTime, endTime, username)
default:
return getSelfFlowQuotaData(startTime, endTime, userID)
}
}
func flowQuotaBaseQuery(startTime int64, endTime int64) *gorm.DB {
query := DB.Table("quota_data").
Where("use_group <> ''").
Where("created_at >= ? and created_at <= ?", startTime, endTime)
return query
}
func getSelfFlowQuotaData(startTime int64, endTime int64, userID int) ([]*FlowQuotaData, error) {
rows := make([]*FlowQuotaData, 0)
err := flowQuotaBaseQuery(startTime, endTime).
Select("token_id, use_group, model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Where("user_id = ?", userID).
Group("token_id, use_group, model_name").
Order("quota DESC").
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, fillFlowTokenNames(rows)
}
func getAdminFlowQuotaData(startTime int64, endTime int64, username string) ([]*FlowQuotaData, error) {
rows := make([]*FlowQuotaData, 0)
query := flowQuotaBaseQuery(startTime, endTime).
Select("user_id, username, use_group, model_name, channel_id, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used")
if username != "" {
query = query.Where("username = ?", username)
}
err := query.
Group("user_id, username, use_group, model_name, channel_id").
Order("quota DESC").
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, fillFlowChannelNames(rows)
}
func getRootFlowQuotaData(startTime int64, endTime int64, username string) ([]*FlowQuotaData, error) {
rows := make([]*FlowQuotaData, 0)
query := flowQuotaBaseQuery(startTime, endTime).
Select("user_id, username, node_name, token_id, use_group, model_name, channel_id, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used")
if username != "" {
query = query.Where("username = ?", username)
}
err := query.
Group("user_id, username, node_name, token_id, use_group, model_name, channel_id").
Order("quota DESC").
Find(&rows).Error
if err != nil {
return nil, err
}
if err := fillFlowTokenNames(rows); err != nil {
return rows, err
}
return rows, fillFlowChannelNames(rows)
}
func fillFlowTokenNames(rows []*FlowQuotaData) error {
tokenIDSet := make(map[int]struct{})
tokenIDs := make([]int, 0)
for _, row := range rows {
if row.TokenID == 0 {
continue
}
if _, ok := tokenIDSet[row.TokenID]; ok {
continue
}
tokenIDSet[row.TokenID] = struct{}{}
tokenIDs = append(tokenIDs, row.TokenID)
}
if len(tokenIDs) == 0 {
return nil
}
var tokens []struct {
Id int `gorm:"column:id"`
Name string `gorm:"column:name"`
}
if err := DB.Model(&Token{}).Select("id, name").Where("id IN ?", tokenIDs).Find(&tokens).Error; err != nil {
return err
}
tokenNameByID := make(map[int]string, len(tokens))
for _, token := range tokens {
tokenNameByID[token.Id] = token.Name
}
// Deleted tokens are intentionally not resolved here: leave TokenName empty
// so the frontend can render a localized "deleted (id)" label instead.
for _, row := range rows {
if name := tokenNameByID[row.TokenID]; name != "" {
row.TokenName = name
}
}
return nil
}
func fillFlowChannelNames(rows []*FlowQuotaData) error {
channelIDSet := make(map[int]struct{})
channelIDs := make([]int, 0)
for _, row := range rows {
if row.ChannelID == 0 {
continue
}
if _, ok := channelIDSet[row.ChannelID]; ok {
continue
}
channelIDSet[row.ChannelID] = struct{}{}
channelIDs = append(channelIDs, row.ChannelID)
}
if len(channelIDs) == 0 {
return nil
}
channelNameByID := make(map[int]string, len(channelIDs))
if common.MemoryCacheEnabled {
for _, channelID := range channelIDs {
if channel, err := CacheGetChannel(channelID); err == nil {
channelNameByID[channelID] = channel.Name
}
}
} else {
var channels []struct {
Id int `gorm:"column:id"`
Name string `gorm:"column:name"`
}
if err := DB.Table("channels").Select("id, name").Where("id IN ?", channelIDs).Find(&channels).Error; err != nil {
return err
}
for _, channel := range channels {
channelNameByID[channel.Id] = channel.Name
}
}
for _, row := range rows {
if name := channelNameByID[row.ChannelID]; name != "" {
row.ChannelName = name
continue
}
if row.ChannelID > 0 {
row.ChannelName = fmt.Sprintf("channel-%d", row.ChannelID)
}
}
return nil
}
+193
View File
@@ -0,0 +1,193 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/require"
)
func seedFlowQuotaData(t *testing.T, quotaData QuotaData) {
t.Helper()
require.NoError(t, DB.Create(&quotaData).Error)
}
func seedFlowLookupData(t *testing.T) {
t.Helper()
require.NoError(t, DB.Create(&Channel{Id: 1, Name: "east"}).Error)
require.NoError(t, DB.Create(&Channel{Id: 2, Name: "west"}).Error)
require.NoError(t, DB.Create(&Token{Id: 11, UserId: 1, Key: "sk-primary", Name: "primary"}).Error)
require.NoError(t, DB.Create(&Token{Id: 22, UserId: 2, Key: "sk-backup", Name: "backup"}).Error)
require.NoError(t, DB.Delete(&Token{Id: 11}).Error)
}
func TestGetFlowQuotaDataUsesQuotaDataRoleSpecificDimensions(t *testing.T) {
truncateTables(t)
seedFlowLookupData(t)
seedFlowQuotaData(t, QuotaData{
UserID: 1,
Username: "alice",
NodeName: "node-a",
TokenID: 11,
UseGroup: "vip",
ModelName: "gpt-a",
ChannelID: 1,
CreatedAt: 1000,
Count: 2,
Quota: 100,
TokenUsed: 40,
})
seedFlowQuotaData(t, QuotaData{
UserID: 1,
Username: "alice",
NodeName: "node-a",
TokenID: 11,
UseGroup: "vip",
ModelName: "gpt-a",
ChannelID: 1,
CreatedAt: 1100,
Count: 1,
Quota: 50,
TokenUsed: 20,
})
seedFlowQuotaData(t, QuotaData{
UserID: 1,
Username: "alice",
NodeName: "node-a",
TokenID: 11,
UseGroup: "vip",
ModelName: "gpt-a",
ChannelID: 2,
CreatedAt: 1200,
Count: 1,
Quota: 25,
TokenUsed: 10,
})
seedFlowQuotaData(t, QuotaData{
UserID: 2,
Username: "bob",
NodeName: "node-b",
TokenID: 22,
UseGroup: "default",
ModelName: "gpt-b",
ChannelID: 1,
CreatedAt: 1300,
Count: 3,
Quota: 70,
TokenUsed: 30,
})
seedFlowQuotaData(t, QuotaData{
UserID: 1,
Username: "alice",
ModelName: "legacy",
CreatedAt: 1400,
Count: 99,
Quota: 999,
TokenUsed: 999,
})
rootRows, err := GetFlowQuotaData(900, 2000, "", 0, common.RoleRootUser)
require.NoError(t, err)
require.Len(t, rootRows, 3)
// Token 11 was soft-deleted, so its name is intentionally left empty for the
// frontend to render a localized "deleted (id)" label instead.
require.Equal(t, FlowQuotaData{
UserID: 1,
Username: "alice",
NodeName: "node-a",
TokenID: 11,
TokenName: "",
UseGroup: "vip",
ChannelID: 1,
ChannelName: "east",
ModelName: "gpt-a",
TokenUsed: 60,
Count: 3,
Quota: 150,
}, *rootRows[0])
// A token that still exists resolves to its current name.
require.Equal(t, 22, rootRows[1].TokenID)
require.Equal(t, "backup", rootRows[1].TokenName)
adminRows, err := GetFlowQuotaData(900, 2000, "alice", 0, common.RoleAdminUser)
require.NoError(t, err)
require.Len(t, adminRows, 2)
require.Equal(t, 0, adminRows[0].TokenID)
require.Empty(t, adminRows[0].TokenName)
require.Empty(t, adminRows[0].NodeName)
require.Equal(t, "alice", adminRows[0].Username)
require.Equal(t, "vip", adminRows[0].UseGroup)
require.Equal(t, "east", adminRows[0].ChannelName)
require.Equal(t, 150, adminRows[0].Quota)
selfRows, err := GetFlowQuotaData(900, 2000, "", 1, common.RoleCommonUser)
require.NoError(t, err)
require.Len(t, selfRows, 1)
require.Empty(t, selfRows[0].Username)
require.Equal(t, 0, selfRows[0].ChannelID)
require.Empty(t, selfRows[0].ChannelName)
require.Empty(t, selfRows[0].TokenName)
require.Equal(t, "vip", selfRows[0].UseGroup)
require.Equal(t, 175, selfRows[0].Quota)
}
func TestLogQuotaDataSplitsRowsByUseGroupTokenChannelAndNode(t *testing.T) {
truncateTables(t)
CacheQuotaDataLock.Lock()
CacheQuotaData = make(map[string]*QuotaData)
CacheQuotaDataLock.Unlock()
LogQuotaData(QuotaDataLogParams{
UserID: 1,
Username: "alice",
ModelName: "gpt-a",
CreatedAt: 3661,
UseGroup: "vip",
TokenID: 11,
ChannelID: 1,
NodeName: "node-a",
Quota: 100,
TokenUsed: 40,
})
LogQuotaData(QuotaDataLogParams{
UserID: 1,
Username: "alice",
ModelName: "gpt-a",
CreatedAt: 3700,
UseGroup: "vip",
TokenID: 11,
ChannelID: 1,
NodeName: "node-a",
Quota: 50,
TokenUsed: 20,
})
LogQuotaData(QuotaDataLogParams{
UserID: 1,
Username: "alice",
ModelName: "gpt-a",
CreatedAt: 3700,
UseGroup: "default",
TokenID: 11,
ChannelID: 1,
NodeName: "node-a",
Quota: 25,
TokenUsed: 10,
})
SaveQuotaDataCache()
var rows []QuotaData
require.NoError(t, DB.Order("quota DESC").Find(&rows).Error)
require.Len(t, rows, 2)
require.Equal(t, int64(3600), rows[0].CreatedAt)
require.Equal(t, "vip", rows[0].UseGroup)
require.Equal(t, 11, rows[0].TokenID)
require.Equal(t, 1, rows[0].ChannelID)
require.Equal(t, "node-a", rows[0].NodeName)
require.Equal(t, 2, rows[0].Count)
require.Equal(t, 150, rows[0].Quota)
require.Equal(t, 60, rows[0].TokenUsed)
require.Equal(t, "default", rows[1].UseGroup)
require.Equal(t, 25, rows[1].Quota)
}
+66
View File
@@ -0,0 +1,66 @@
package model
import (
"fmt"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
type RankingQuotaTotal struct {
ModelName string `json:"model_name"`
TotalTokens int64 `json:"total_tokens"`
}
type RankingQuotaBucket struct {
ModelName string `json:"model_name"`
Bucket int64 `json:"bucket"`
Tokens int64 `json:"tokens"`
}
func GetRankingQuotaTotals(startTime int64, endTime int64) ([]RankingQuotaTotal, error) {
var rows []RankingQuotaTotal
query := DB.Table("quota_data").
Select("model_name, sum(token_used) as total_tokens").
Where("model_name <> ''").
Group("model_name").
Having("sum(token_used) > 0").
Order("total_tokens DESC")
query = applyRankingQuotaTimeRange(query, startTime, endTime)
err := query.Find(&rows).Error
return rows, err
}
func GetRankingQuotaBuckets(startTime int64, endTime int64, bucketSize int64) ([]RankingQuotaBucket, error) {
if bucketSize <= 0 {
bucketSize = 3600
}
bucketExpr := rankingBucketExpr(bucketSize)
var rows []RankingQuotaBucket
query := DB.Table("quota_data").
Select(fmt.Sprintf("model_name, %s as bucket, sum(token_used) as tokens", bucketExpr)).
Where("model_name <> ''").
Group(fmt.Sprintf("model_name, %s", bucketExpr)).
Having("sum(token_used) > 0").
Order("bucket ASC")
query = applyRankingQuotaTimeRange(query, startTime, endTime)
err := query.Find(&rows).Error
return rows, err
}
func rankingBucketExpr(bucketSize int64) string {
if common.UsingMainDatabase(common.DatabaseTypeMySQL) {
return fmt.Sprintf("FLOOR(created_at / %d) * %d", bucketSize, bucketSize)
}
return fmt.Sprintf("(created_at / %d) * %d", bucketSize, bucketSize)
}
func applyRankingQuotaTimeRange(query *gorm.DB, startTime int64, endTime int64) *gorm.DB {
if startTime > 0 {
query = query.Where("created_at >= ?", startTime)
}
if endTime > 0 {
query = query.Where("created_at <= ?", endTime)
}
return query
}
+1544
View File
File diff suppressed because it is too large. Load diff
+277
View File
@@ -0,0 +1,277 @@
package model
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
// User auth cache fencing uses three Redis keys per user: the cached user
// hash, a short-lived pending fence published before a restrictive database
// transaction, and a monotonic committed version floor published after
// commit. Cache writes below either floor are rejected, readers below the
// effective floor fall back to the database, and the pending fence outlives
// every user-hash TTL so a rolled-back transaction heals without allowing a
// stale snapshot to re-authorize the user.
var ErrUserAuthCachePending = errors.New("user authentication state update is pending")
var ErrUserAuthVersionConflict = errors.New("user authentication version update conflicted")
func getUserAuthFenceKey(userId int) string {
return fmt.Sprintf("auth:user:fence:%d", userId)
}
func getUserAuthVersionKey(userId int) string {
return fmt.Sprintf("auth:user:version:%d", userId)
}
// A pending fence only covers the interval between publishing the next
// version and the surrounding database transaction reaching a decision. Its
// TTL must outlive every user hash that could have been populated before the
// fence, while still allowing an automatically rolled-back transaction to
// recover without an operator repairing Redis.
func userAuthFenceTTLSeconds() int {
cacheTTL := userCacheTTLSeconds()
extra := max(cacheTTL, 60)
return cacheTTL + extra
}
func writeUserCache(user *UserBase, includeQuota bool) error {
if user == nil || user.Id <= 0 || !common.RedisEnabled {
return nil
}
user.CacheSchema = userCacheSchemaVersion
if user.AuthVersion <= 0 {
return fmt.Errorf("invalid user auth version")
}
includeQuotaArg := "0"
if includeQuota {
includeQuotaArg = "1"
}
ttl := userCacheTTLSeconds()
const script = `
local incoming = tonumber(ARGV[1])
local pending = tonumber(redis.call('GET', KEYS[2]) or '0')
local committed = tonumber(redis.call('GET', KEYS[3]) or '0')
local current = tonumber(redis.call('HGET', KEYS[1], 'AuthVersion') or '0')
if pending > incoming or committed > incoming or current > incoming then
return 0
end
if committed < incoming then
redis.call('SET', KEYS[3], ARGV[1])
end
if pending > 0 and pending <= incoming then
redis.call('DEL', KEYS[2])
end
if ARGV[10] == '0' and redis.call('EXISTS', KEYS[1]) == 0 then
return 1
end
redis.call('HSET', KEYS[1],
'Id', ARGV[2], 'Group', ARGV[3], 'Email', ARGV[4],
'Status', ARGV[5], 'Role', ARGV[6], 'Username', ARGV[7],
'Setting', ARGV[8], 'AuthVersion', ARGV[1], 'CacheSchema', ARGV[9])
if ARGV[10] == '1' and redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
redis.call('HSET', KEYS[1], 'Quota', ARGV[11])
end
redis.call('EXPIRE', KEYS[1], ARGV[12])
return 1`
result, err := common.RDB.Eval(context.Background(), script,
[]string{getUserCacheKey(user.Id), getUserAuthFenceKey(user.Id), getUserAuthVersionKey(user.Id)},
user.AuthVersion, user.Id, user.Group, user.Email, user.Status, user.Role,
user.Username, user.Setting, user.CacheSchema, includeQuotaArg, user.Quota, ttl,
).Int()
if err != nil {
return err
}
if result == 0 {
return ErrUserAuthCachePending
}
return nil
}
func getUserAuthVersionFloor(userId int) (int64, error) {
if !common.RedisEnabled {
return 0, nil
}
values, err := common.RDB.MGet(context.Background(), getUserAuthFenceKey(userId), getUserAuthVersionKey(userId)).Result()
if err != nil {
return 0, err
}
var floor int64
for _, value := range values {
if value == nil {
continue
}
parsed, err := strconv.ParseInt(fmt.Sprint(value), 10, 64)
if err != nil {
return 0, err
}
if parsed > floor {
floor = parsed
}
}
return floor, nil
}
// SetUserAuthVersionFence publishes a fail-closed version before a restrictive
// database update. Pending fences expire only after every pre-existing user
// hash must have expired; a committed update is promoted separately to a
// permanent monotonic version floor.
func SetUserAuthVersionFence(userId int, authVersion int64) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 || authVersion <= 0 {
return fmt.Errorf("invalid user auth fence")
}
const script = `
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local incoming = tonumber(ARGV[1])
if current < incoming then
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
elseif current == incoming then
redis.call('EXPIRE', KEYS[1], ARGV[2])
elseif redis.call('TTL', KEYS[1]) < 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return 1`
return common.RDB.Eval(context.Background(), script, []string{getUserAuthFenceKey(userId)}, authVersion, userAuthFenceTTLSeconds()).Err()
}
// publishCommittedUserAuthVersion records the durable lower bound used to
// reject an arbitrarily delayed cache fill after a committed security change.
// It also removes this transaction's now-obsolete pending fence.
func publishCommittedUserAuthVersion(userId int, authVersion int64) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 || authVersion <= 0 {
return fmt.Errorf("invalid committed user auth version")
}
const script = `
local incoming = tonumber(ARGV[1])
local committed = tonumber(redis.call('GET', KEYS[1]) or '0')
local pending = tonumber(redis.call('GET', KEYS[2]) or '0')
if committed < incoming then
redis.call('SET', KEYS[1], ARGV[1])
end
if pending > 0 and pending <= incoming then
redis.call('DEL', KEYS[2])
end
return 1`
return common.RDB.Eval(context.Background(), script,
[]string{getUserAuthVersionKey(userId), getUserAuthFenceKey(userId)}, authVersion,
).Err()
}
// IncrementUserAuthVersionWithTx locks the user, publishes the next deny
// fence, then persists the version in the caller's transaction. Unscoped is
// intentional so the same fail-closed path also covers hard deletion of an
// already soft-deleted user.
func IncrementUserAuthVersionWithTx(tx *gorm.DB, userId int) (int64, error) {
if tx == nil || userId <= 0 {
return 0, fmt.Errorf("invalid user auth version update")
}
for range 3 {
var user User
if err := lockForUpdate(tx.Unscoped()).Select("id", "auth_version").Where("id = ?", userId).First(&user).Error; err != nil {
return 0, err
}
current := max(user.AuthVersion, 1)
next := current + 1
if err := SetUserAuthVersionFence(userId, next); err != nil {
return 0, err
}
result := tx.Unscoped().Model(&User{}).
Where("id = ? AND auth_version = ?", userId, user.AuthVersion).
Update("auth_version", next)
if result.Error != nil {
return 0, result.Error
}
if result.RowsAffected == 1 {
return next, nil
}
}
return 0, ErrUserAuthVersionConflict
}
// BumpUserAuthVersion is the transaction-owning variant used by password,
// role, status and security-factor changes outside another transaction.
func BumpUserAuthVersion(userId int) (int64, error) {
var next int64
if err := DB.Transaction(func(tx *gorm.DB) error {
var err error
next, err = IncrementUserAuthVersionWithTx(tx, userId)
return err
}); err != nil {
return 0, err
}
if err := PublishUserAuthCache(userId); err != nil {
return next, err
}
return next, nil
}
// PublishUserAuthCache refreshes the current database state after a successful
// auth-sensitive transaction without touching the cached quota field.
func PublishUserAuthCache(userId int) error {
user, err := GetUserById(userId, false)
if err != nil {
return err
}
return updateUserCache(*user)
}
// InitializeUserAuthVersions must run after AutoMigrate when upgrading an
// existing database. It is idempotent and portable across all supported DBs.
func InitializeUserAuthVersions() error {
return DB.Model(&User{}).Where("auth_version IS NULL OR auth_version < ?", 1).Update("auth_version", 1).Error
}
func updateUserCacheFieldAtVersion(userId int, field string, value any, authVersion int64) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 || authVersion <= 0 {
return fmt.Errorf("invalid user auth version")
}
const script = `
local incoming = tonumber(ARGV[1])
local pending = tonumber(redis.call('GET', KEYS[2]) or '0')
local committed = tonumber(redis.call('GET', KEYS[3]) or '0')
local current = tonumber(redis.call('HGET', KEYS[1], 'AuthVersion') or '0')
if pending > incoming or committed > incoming or current > incoming then
return 0
end
if committed < incoming then
redis.call('SET', KEYS[3], ARGV[1])
end
if pending > 0 and pending <= incoming then
redis.call('DEL', KEYS[2])
end
if redis.call('EXISTS', KEYS[1]) == 0 then
return 1
end
if current ~= incoming then
return 1
end
redis.call('HSET', KEYS[1], ARGV[2], ARGV[3], 'CacheSchema', ARGV[4])
return 1`
result, err := common.RDB.Eval(context.Background(), script,
[]string{getUserCacheKey(userId), getUserAuthFenceKey(userId), getUserAuthVersionKey(userId)},
authVersion, field, value, userCacheSchemaVersion,
).Int()
if err != nil {
return err
}
if result == 0 {
return ErrUserAuthCachePending
}
return nil
}
+323
View File
@@ -0,0 +1,323 @@
package model
import (
"context"
"encoding/base64"
"errors"
"net"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/go-redis/redis/v8"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestHardDeleteUserFailsClosedWhenAuthFenceCannotPublish(t *testing.T) {
truncateTables(t)
user := User{Username: "hard-delete-user", Password: "password", TelegramId: "hard-delete-telegram"}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id)
}))
require.NoError(t, DB.Create(&Token{UserId: user.Id, Key: "hard-delete-token"}).Error)
require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}).Error)
require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: "hash"}).Error)
require.NoError(t, DB.Create(&PasskeyCredential{UserID: user.Id, CredentialID: "credential", PublicKey: "public-key"}).Error)
require.NoError(t, DB.Create(&UserOAuthBinding{UserId: user.Id, ProviderId: 1, ProviderUserId: "provider-user"}).Error)
require.NoError(t, DB.Create(&UserSession{
SID: "hard-delete-session", UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
LastActiveAt: 1, ExpiresAt: 2,
}).Error)
require.NoError(t, DB.Create(&AuthFlow{
TokenHash: "hard-delete-auth-flow", Purpose: AuthFlowPurposeTwoFALogin,
UserId: user.Id, ExpiresAt: time.Now().Add(time.Minute),
}).Error)
oldRedisEnabled, oldRDB := common.RedisEnabled, common.RDB
common.RedisEnabled = true
common.RDB = redis.NewClient(&redis.Options{
Dialer: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("forced redis failure")
},
MaxRetries: -1,
})
t.Cleanup(func() {
_ = common.RDB.Close()
common.RedisEnabled, common.RDB = oldRedisEnabled, oldRDB
})
require.Error(t, HardDeleteUserById(user.Id))
var count int64
require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error)
assert.EqualValues(t, 1, count)
for _, record := range []any{
&Token{},
&TwoFA{},
&TwoFABackupCode{},
&PasskeyCredential{},
&UserOAuthBinding{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
} {
require.NoError(t, DB.Unscoped().Model(record).Where("user_id = ?", user.Id).Count(&count).Error)
assert.EqualValues(t, 1, count)
}
}
func TestHardDeleteUserPublishesTombstoneAndPurgesAuthenticationData(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "hard-delete-success", Password: "password", AuthVersion: 1,
TelegramId: "hard-delete-success-telegram",
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id)
}))
require.NoError(t, DB.Create(&Token{UserId: user.Id, Key: "hard-delete-success-token"}).Error)
require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}).Error)
require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: "hash"}).Error)
require.NoError(t, DB.Create(&PasskeyCredential{UserID: user.Id, CredentialID: "credential-success", PublicKey: "public-key"}).Error)
require.NoError(t, DB.Create(&UserOAuthBinding{UserId: user.Id, ProviderId: 1, ProviderUserId: "provider-user-success"}).Error)
require.NoError(t, DB.Create(&UserSession{
SID: "hard-delete-success-session", UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
LastActiveAt: 1, ExpiresAt: 2,
}).Error)
require.NoError(t, DB.Create(&AuthFlow{
TokenHash: "hard-delete-success-flow", Purpose: AuthFlowPurposeTwoFALogin,
UserId: user.Id, ExpiresAt: time.Now().Add(time.Minute),
}).Error)
require.NoError(t, populateUserCache(user))
// Administrative hard deletion commonly targets an already soft-deleted
// user; the shared version increment must therefore query unscoped.
require.NoError(t, DB.Delete(&user).Error)
require.NoError(t, HardDeleteUserById(user.Id))
var count int64
require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error)
assert.Zero(t, count)
for _, record := range []any{
&Token{},
&TwoFA{},
&TwoFABackupCode{},
&PasskeyCredential{},
&UserOAuthBinding{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
} {
require.NoError(t, DB.Unscoped().Model(record).Where("user_id = ?", user.Id).Count(&count).Error)
assert.Zero(t, count)
}
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
assert.False(t, server.Exists(getUserCacheKey(user.Id)))
}
func TestIncrementFailedAttemptsCountsConcurrentFailures(t *testing.T) {
truncateTables(t)
user := User{Username: "twofa-cas-user", Password: "password"}
require.NoError(t, DB.Create(&user).Error)
twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}
require.NoError(t, DB.Create(&twoFA).Error)
const attempts = 4
errs := make(chan error, attempts)
var wg sync.WaitGroup
for range attempts {
wg.Go(func() {
errs <- (&TwoFA{Id: twoFA.Id}).IncrementFailedAttempts()
})
}
wg.Wait()
close(errs)
for err := range errs {
require.NoError(t, err)
}
var reloaded TwoFA
require.NoError(t, DB.First(&reloaded, twoFA.Id).Error)
assert.Equal(t, attempts, reloaded.FailedAttempts)
}
func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) {
truncateTables(t)
const code = "ABCD-1234"
user := User{Id: 123, Username: "backup-code-user", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false}).Error)
hashedCode, err := common.HashBackupCode(code)
require.NoError(t, err)
require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: hashedCode}).Error)
const attempts = 2
results := make(chan bool, attempts)
errs := make(chan error, attempts)
var wg sync.WaitGroup
for range attempts {
wg.Go(func() {
valid, err := ValidateBackupCode(123, code)
results <- valid
errs <- err
})
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
require.NoError(t, err)
}
wins := 0
for valid := range results {
if valid {
wins++
}
}
assert.Equal(t, 1, wins)
remaining, err := GetUnusedBackupCodeCount(123)
require.NoError(t, err)
assert.Zero(t, remaining)
}
func TestPendingTwoFASetupAPIsRejectEnabledFactor(t *testing.T) {
truncateTables(t)
user := User{Username: "enabled-twofa-guard", Password: "password", Status: common.UserStatusEnabled, AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}
require.NoError(t, DB.Create(&twoFA).Error)
session := UserSession{SID: "enabled-factor-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, Status: UserSessionStatusActive, ExpiresAt: time.Now().Add(time.Hour).Unix()}
require.NoError(t, DB.Create(&session).Error)
identity := AuthSessionIdentity{UserID: user.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1}
authorization := &AuthFlowAuthorization{AuthSessionIdentity: identity, ProofID: 1, Scope: "2fa.setup", ContextHash: "setup-context", Method: "password"}
_, err := CreateTwoFAEnrollment(identity, authorization, "replacement-secret", []string{"ABCD-1234"}, time.Now().Add(time.Minute))
require.ErrorIs(t, err, ErrTwoFAAlreadyEnabled)
var stored TwoFA
require.NoError(t, DB.First(&stored, twoFA.Id).Error)
assert.True(t, stored.IsEnabled)
var backupCodeCount int64
require.NoError(t, DB.Model(&TwoFABackupCode{}).Where("user_id = ?", user.Id).Count(&backupCodeCount).Error)
assert.Zero(t, backupCodeCount)
}
func TestSecurityFactorMutationsAdvanceUserAuthVersion(t *testing.T) {
truncateTables(t)
user := User{
Username: "security-factor-version-user",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
session := UserSession{SID: "factor-version-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, Status: UserSessionStatusActive, ExpiresAt: time.Now().Add(time.Hour).Unix()}
require.NoError(t, DB.Create(&session).Error)
identity := AuthSessionIdentity{UserID: user.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1}
authorization := &AuthFlowAuthorization{AuthSessionIdentity: identity, ProofID: 1, Scope: "2fa.setup", ContextHash: "setup-context", Method: "password"}
token, err := CreateTwoFAEnrollment(identity, authorization, "JBSWY3DPEHPK3PXP", []string{"ABCD-1234"}, time.Now().Add(time.Minute))
require.NoError(t, err)
code, err := totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now())
require.NoError(t, err)
require.NoError(t, EnableTwoFAEnrollment(identity, token, code))
assertUserAuthVersion(t, user.Id, 2)
assert.ErrorIs(t, EnableTwoFAEnrollment(identity, token, code), ErrTwoFASetupInvalid)
assertUserAuthVersion(t, user.Id, 2)
require.NoError(t, ReplaceBackupCodesWithAuthVersion(user.Id, []string{"ABCD-1234"}))
assertUserAuthVersion(t, user.Id, 3)
require.NoError(t, DisableTwoFAWithAuthVersion(user.Id))
assertUserAuthVersion(t, user.Id, 4)
credential := &PasskeyCredential{UserID: user.Id, CredentialID: "credential-id", PublicKey: "public-key"}
require.NoError(t, UpsertPasskeyCredentialWithAuthVersion(credential))
assertUserAuthVersion(t, user.Id, 5)
require.NoError(t, DeletePasskeyByUserIDWithAuthVersion(user.Id))
assertUserAuthVersion(t, user.Id, 6)
}
func TestUpdatePasskeyAssertionStateCannotRewriteRegistrationIdentity(t *testing.T) {
truncateTables(t)
user := User{Username: "passkey-assertion-state", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
credentialID := []byte("stable-credential-id")
stored := PasskeyCredential{
UserID: user.Id,
CredentialID: base64.StdEncoding.EncodeToString(credentialID),
PublicKey: "original-public-key",
AttestationType: "packed",
AAGUID: "original-aaguid",
SignCount: 1,
Transports: `["usb"]`,
Attachment: "platform",
}
require.NoError(t, DB.Create(&stored).Error)
usedAt := time.Now().UTC().Truncate(time.Second)
validated := &webauthn.Credential{
ID: credentialID,
PublicKey: []byte("replacement-public-key"),
AttestationType: "none",
Flags: webauthn.CredentialFlags{
UserPresent: true,
UserVerified: true,
BackupEligible: true,
BackupState: true,
},
Authenticator: webauthn.Authenticator{
AAGUID: []byte("replacement-aaguid"),
SignCount: 8,
CloneWarning: true,
},
}
require.NoError(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt))
var updated PasskeyCredential
require.NoError(t, DB.First(&updated, stored.ID).Error)
assert.Equal(t, stored.CredentialID, updated.CredentialID)
assert.Equal(t, stored.PublicKey, updated.PublicKey)
assert.Equal(t, stored.AttestationType, updated.AttestationType)
assert.Equal(t, stored.AAGUID, updated.AAGUID)
assert.Equal(t, stored.Transports, updated.Transports)
assert.Equal(t, stored.Attachment, updated.Attachment)
assert.EqualValues(t, 8, updated.SignCount)
assert.True(t, updated.CloneWarning)
assert.True(t, updated.UserPresent)
assert.True(t, updated.UserVerified)
assert.True(t, updated.BackupEligible)
assert.True(t, updated.BackupState)
require.NotNil(t, updated.LastUsedAt)
assert.Equal(t, usedAt.Unix(), updated.LastUsedAt.Unix())
validated.ID = []byte("another-credential")
assert.ErrorIs(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt), ErrPasskeyNotFound)
}
func assertUserAuthVersion(t *testing.T, userID int, expected int64) {
t.Helper()
var version int64
require.NoError(t, DB.Model(&User{}).Where("id = ?", userID).Select("auth_version").Scan(&version).Error)
assert.Equal(t, expected, version)
}
+278
View File
@@ -0,0 +1,278 @@
package model
import (
"errors"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/gin-gonic/gin"
)
const userCacheSchemaVersion = 2
type UserBase struct {
Id int `json:"id"`
Group string `json:"group"`
Email string `json:"email"`
Quota int `json:"quota"`
Status int `json:"status"`
Role int `json:"role"`
Username string `json:"username"`
Setting string `json:"setting"`
AuthVersion int64 `json:"-"`
CacheSchema int `json:"-"`
}
func (user *UserBase) WriteContext(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyUserGroup, user.Group)
common.SetContextKey(c, constant.ContextKeyUserQuota, user.Quota)
common.SetContextKey(c, constant.ContextKeyUserStatus, user.Status)
common.SetContextKey(c, constant.ContextKeyUserEmail, user.Email)
common.SetContextKey(c, constant.ContextKeyUserName, user.Username)
common.SetContextKey(c, constant.ContextKeyUserSetting, user.GetSetting())
}
func (user *UserBase) GetSetting() dto.UserSetting {
setting := dto.UserSetting{}
if user.Setting != "" {
err := common.Unmarshal([]byte(user.Setting), &setting)
if err != nil {
common.SysLog("failed to unmarshal setting: " + err.Error())
}
}
return setting
}
// getUserCacheKey returns the key for user cache
func getUserCacheKey(userId int) string {
return fmt.Sprintf("user:%d", userId)
}
func userCacheTTLSeconds() int {
ttl := common.RedisKeyCacheSeconds()
if ttl <= 0 {
return 60
}
return ttl
}
// invalidateUserCache clears user cache
func invalidateUserCache(userId int) error {
if !common.RedisEnabled {
return nil
}
return common.RedisDelKey(getUserCacheKey(userId))
}
func populateUserCache(user User) error {
if !common.RedisEnabled {
return nil
}
return writeUserCache(user.ToBaseUser(), true)
}
// updateUserCache refreshes non-quota user cache fields.
// Quota is maintained by atomic quota delta paths and must not be overwritten
// by stale user snapshots from profile/settings updates.
func updateUserCache(user User) error {
if !common.RedisEnabled {
return nil
}
return writeUserCache(user.ToBaseUser(), false)
}
// GetUserCache gets complete user cache from hash
func GetUserCache(userId int) (*UserBase, error) {
// Try getting from Redis first
userCache, err := cacheGetUserBase(userId)
if err == nil {
return userCache, nil
}
// Redis misses and read failures both fall back to the shared database. A
// version fence newer than the database is the one exception: allowing that
// snapshot would re-authorize a user while a restrictive update is pending.
user, err := GetUserById(userId, false)
if err != nil {
return nil, err
}
if common.RedisEnabled {
floor, floorErr := getUserAuthVersionFloor(userId)
if floorErr == nil && floor > user.AuthVersion {
return nil, ErrUserAuthCachePending
}
if err := populateUserCache(*user); err != nil {
if errors.Is(err, ErrUserAuthCachePending) {
return nil, err
}
common.SysLog("failed to synchronously populate user cache: " + err.Error())
}
}
return user.ToBaseUser(), nil
}
func cacheGetUserBase(userId int) (*UserBase, error) {
if !common.RedisEnabled {
return nil, fmt.Errorf("redis is not enabled")
}
var userCache UserBase
// Try getting from Redis first
err := common.RedisHGetObj(getUserCacheKey(userId), &userCache)
if err != nil {
return nil, err
}
if userCache.Id != userId || userCache.CacheSchema != userCacheSchemaVersion || userCache.AuthVersion <= 0 {
return nil, fmt.Errorf("user cache schema is stale")
}
floor, err := getUserAuthVersionFloor(userId)
if err != nil {
return nil, err
}
if floor > userCache.AuthVersion {
return nil, ErrUserAuthCachePending
}
return &userCache, nil
}
// Add atomic quota operations using hash fields.
// 通过守卫式 Lua 脚本执行:哈希不存在时直接跳过(下次读取会从数据库水合),
// 不会像裸 HINCRBY 那样创建只含 Quota 字段的残缺哈希。
func cacheIncrUserQuota(userId int, delta int64) error {
if !common.RedisEnabled {
return nil
}
_, err := cacheApplyUserQuotaDelta(userId, delta)
return err
}
func cacheDecrUserQuota(userId int, delta int64) error {
return cacheIncrUserQuota(userId, -delta)
}
// syncCreditUserQuotaCache 在授信事务(充值/兑换等)提交后同步把增量补进缓存
// 余额。预扣以缓存值为准(存在期间),授信不能绕过它,否则新到账的额度在
// 缓存过期前不可用;缓存未命中无需处理,下次读取会从已提交的数据库余额水合。
func syncCreditUserQuotaCache(userId int, quota int, operation string) {
if quota <= 0 {
return
}
if err := cacheIncrUserQuota(userId, int64(quota)); err != nil {
common.SysLog(fmt.Sprintf("failed to sync %s credit to user quota cache: %s", operation, err.Error()))
}
}
// Helper functions to get individual fields if needed
func getUserGroupCache(userId int) (string, error) {
cache, err := GetUserCache(userId)
if err != nil {
return "", err
}
return cache.Group, nil
}
func getUserQuotaCache(userId int) (int, error) {
cache, err := GetUserCache(userId)
if err != nil {
return 0, err
}
return cache.Quota, nil
}
func getUserNameCache(userId int) (string, error) {
cache, err := GetUserCache(userId)
if err != nil {
return "", err
}
return cache.Username, nil
}
func getUserSettingCache(userId int) (dto.UserSetting, error) {
cache, err := GetUserCache(userId)
if err != nil {
return dto.UserSetting{}, err
}
return cache.GetSetting(), nil
}
// RefreshUserGroupCache writes the database-authoritative group into an
// existing user hash without changing the user's authentication version.
func RefreshUserGroupCache(userId int) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 {
return fmt.Errorf("invalid user id")
}
var authoritative User
if err := DB.Select("id", "auth_version", commonGroupCol).Where("id = ?", userId).First(&authoritative).Error; err != nil {
return err
}
// Group transitions intentionally keep the same authentication version. A
// refresh that read the previous group can therefore arrive after a newer
// refresh and still pass the auth-version fence. Re-read after every write
// and repair the cache when the authoritative group changed in between.
for range 3 {
if err := updateUserCacheFieldAtVersion(userId, "Group", authoritative.Group, authoritative.AuthVersion); err != nil {
return err
}
var verified User
if err := DB.Select("id", "auth_version", commonGroupCol).Where("id = ?", userId).First(&verified).Error; err != nil {
return err
}
if verified.AuthVersion == authoritative.AuthVersion && verified.Group == authoritative.Group {
return nil
}
authoritative = verified
}
// Preserve the freshest snapshot observed even when the row was too busy to
// stabilize within the bounded retries. Returning an error lets best-effort
// callers emit an operation-specific warning.
if err := updateUserCacheFieldAtVersion(userId, "Group", authoritative.Group, authoritative.AuthVersion); err != nil {
return err
}
return fmt.Errorf("user group changed repeatedly during cache refresh")
}
func updateUserEmailCache(userId int, email string) error {
return updateUserCacheField(userId, "Email", email)
}
func updateUserNameCache(userId int, username string) error {
return updateUserCacheField(userId, "Username", username)
}
func updateUserSettingCache(userId int, setting string) error {
return updateUserCacheField(userId, "Setting", setting)
}
// updateUserCacheField prevents individual cache refreshes from bypassing the
// auth-version fence. It intentionally does nothing when the complete hash is
// absent; the next GetUserCache call will repopulate it from the database.
func updateUserCacheField(userId int, field string, value any) error {
if !common.RedisEnabled {
return nil
}
var user User
if err := DB.Select("id", "auth_version").Where("id = ?", userId).First(&user).Error; err != nil {
return err
}
if user.AuthVersion <= 0 {
return fmt.Errorf("invalid user auth version")
}
return updateUserCacheFieldAtVersion(userId, field, value, user.AuthVersion)
}
// GetUserLanguage returns the user's language preference from cache
// Uses the existing GetUserCache mechanism for efficiency
func GetUserLanguage(userId int) string {
userCache, err := GetUserCache(userId)
if err != nil {
return ""
}
return userCache.GetSetting().Language
}
+223
View File
@@ -0,0 +1,223 @@
package model
import (
"errors"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func useUserCacheMiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
server := miniredis.RunT(t)
oldRedisEnabled := common.RedisEnabled
oldRDB := common.RDB
oldSyncFrequency := common.SyncFrequency
common.RedisEnabled = true
common.SyncFrequency = 2
common.RDB = redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() {
_ = common.RDB.Close()
common.RedisEnabled = oldRedisEnabled
common.RDB = oldRDB
common.SyncFrequency = oldSyncFrequency
})
return server
}
func TestUserAuthFenceRollbackExpiresAndRecovers(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "auth-fence-rollback",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
tx := DB.Begin()
require.NoError(t, tx.Error)
next, err := IncrementUserAuthVersionWithTx(tx, user.Id)
require.NoError(t, err)
assert.EqualValues(t, 2, next)
_, err = cacheGetUserBase(user.Id)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
cacheTTL, err := common.RDB.TTL(t.Context(), getUserCacheKey(user.Id)).Result()
require.NoError(t, err)
fenceTTL, err := common.RDB.TTL(t.Context(), getUserAuthFenceKey(user.Id)).Result()
require.NoError(t, err)
assert.Greater(t, fenceTTL, cacheTTL)
require.NoError(t, tx.Rollback().Error)
server.FastForward(time.Duration(userAuthFenceTTLSeconds()+1) * time.Second)
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "1", committed)
cached, err := GetUserCache(user.Id)
require.NoError(t, err)
assert.EqualValues(t, 1, cached.AuthVersion)
}
func TestPendingUserAuthFenceRejectsStaleCacheWrite(t *testing.T) {
server := useUserCacheMiniRedis(t)
const userID = 4201
require.NoError(t, SetUserAuthVersionFence(userID, 2))
err := writeUserCache(&UserBase{
Id: userID, Group: "default", Username: "stale", AuthVersion: 1,
}, true)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
assert.False(t, server.Exists(getUserCacheKey(userID)))
}
func TestUserAuthFieldUpdateRejectsVersionMismatch(t *testing.T) {
useUserCacheMiniRedis(t)
const userID = 4202
require.NoError(t, writeUserCache(&UserBase{
Id: userID, Group: "current", Username: "cached", AuthVersion: 3,
}, true))
err := updateUserCacheFieldAtVersion(userID, "Group", "stale", 2)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
group, err := common.RDB.HGet(t.Context(), getUserCacheKey(userID), "Group").Result()
require.NoError(t, err)
assert.Equal(t, "current", group)
}
func TestRefreshUserGroupCacheRepairsDelayedSameVersionWrite(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
user := User{
Username: "delayed-group-refresh",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
firstSnapshotRead := make(chan struct{})
releaseDelayedRefresh := make(chan struct{})
var intercepted atomic.Bool
const callbackName = "test:block_delayed_group_refresh"
require.NoError(t, DB.Callback().Query().After("gorm:query").Register(callbackName, func(*gorm.DB) {
if intercepted.CompareAndSwap(false, true) {
close(firstSnapshotRead)
<-releaseDelayedRefresh
}
}))
t.Cleanup(func() {
_ = DB.Callback().Query().Remove(callbackName)
})
delayedResult := make(chan error, 1)
go func() {
delayedResult <- RefreshUserGroupCache(user.Id)
}()
<-firstSnapshotRead
require.NoError(t, DB.Model(&User{}).Where("id = ?", user.Id).Update("group", "pro").Error)
require.NoError(t, RefreshUserGroupCache(user.Id))
cached, err := cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
close(releaseDelayedRefresh)
require.NoError(t, <-delayedResult)
cached, err = cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
}
func TestCommittedUserAuthVersionPermanentlyRejectsDelayedCacheFill(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "auth-fence-commit",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
stale := *user.ToBaseUser()
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
_, err := IncrementUserAuthVersionWithTx(tx, user.Id)
return err
}))
require.NoError(t, PublishUserAuthCache(user.Id))
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
server.FastForward(time.Duration(userAuthFenceTTLSeconds()+1) * time.Second)
require.NoError(t, common.RedisDelKey(getUserCacheKey(user.Id)))
err = writeUserCache(&stale, true)
assert.True(t, errors.Is(err, ErrUserAuthCachePending))
committed, err = common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
}
func TestUserAuthVersionFenceAndCommittedFloorAreMonotonic(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
const userID = 4101
require.NoError(t, SetUserAuthVersionFence(userID, 5))
require.NoError(t, SetUserAuthVersionFence(userID, 3))
pending, err := common.RDB.Get(t.Context(), getUserAuthFenceKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", pending)
floor, err := getUserAuthVersionFloor(userID)
require.NoError(t, err)
assert.EqualValues(t, 5, floor)
// Committing an older transaction must neither clear a newer pending fence
// nor lower the effective deny floor.
require.NoError(t, publishCommittedUserAuthVersion(userID, 3))
pending, err = common.RDB.Get(t.Context(), getUserAuthFenceKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", pending)
floor, err = getUserAuthVersionFloor(userID)
require.NoError(t, err)
assert.EqualValues(t, 5, floor)
require.NoError(t, publishCommittedUserAuthVersion(userID, 5))
assert.False(t, server.Exists(getUserAuthFenceKey(userID)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", committed)
require.NoError(t, publishCommittedUserAuthVersion(userID, 4))
committed, err = common.RDB.Get(t.Context(), getUserAuthVersionKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", committed)
}
+146
View File
@@ -0,0 +1,146 @@
package model
import (
"errors"
"time"
"gorm.io/gorm"
)
// UserOAuthBinding stores the binding relationship between users and custom OAuth providers
type UserOAuthBinding struct {
Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"not null;uniqueIndex:ux_user_provider"` // User ID - one binding per user per provider
ProviderId int `json:"provider_id" gorm:"not null;uniqueIndex:ux_user_provider;uniqueIndex:ux_provider_userid"` // Custom OAuth provider ID
ProviderUserId string `json:"provider_user_id" gorm:"type:varchar(256);not null;uniqueIndex:ux_provider_userid"` // User ID from OAuth provider - one OAuth account per provider
CreatedAt time.Time `json:"created_at"`
}
func (UserOAuthBinding) TableName() string {
return "user_oauth_bindings"
}
// GetUserOAuthBindingsByUserId returns all OAuth bindings for a user
func GetUserOAuthBindingsByUserId(userId int) ([]*UserOAuthBinding, error) {
var bindings []*UserOAuthBinding
err := DB.Where("user_id = ?", userId).Find(&bindings).Error
return bindings, err
}
// GetUserOAuthBinding returns a specific binding for a user and provider
func GetUserOAuthBinding(userId, providerId int) (*UserOAuthBinding, error) {
var binding UserOAuthBinding
err := DB.Where("user_id = ? AND provider_id = ?", userId, providerId).First(&binding).Error
if err != nil {
return nil, err
}
return &binding, nil
}
// GetUserByOAuthBinding finds a user by provider ID and provider user ID
func GetUserByOAuthBinding(providerId int, providerUserId string) (*User, error) {
var binding UserOAuthBinding
err := DB.Where("provider_id = ? AND provider_user_id = ?", providerId, providerUserId).First(&binding).Error
if err != nil {
return nil, err
}
var user User
err = DB.First(&user, binding.UserId).Error
if err != nil {
return nil, err
}
return &user, nil
}
// IsProviderUserIdTaken checks if a provider user ID is already bound to any user
func IsProviderUserIdTaken(providerId int, providerUserId string) bool {
var count int64
DB.Model(&UserOAuthBinding{}).Where("provider_id = ? AND provider_user_id = ?", providerId, providerUserId).Count(&count)
return count > 0
}
// CreateUserOAuthBinding creates a new OAuth binding
func CreateUserOAuthBinding(binding *UserOAuthBinding) error {
if binding.UserId == 0 {
return errors.New("user ID is required")
}
if binding.ProviderId == 0 {
return errors.New("provider ID is required")
}
if binding.ProviderUserId == "" {
return errors.New("provider user ID is required")
}
// Check if this provider user ID is already taken
if IsProviderUserIdTaken(binding.ProviderId, binding.ProviderUserId) {
return errors.New("this OAuth account is already bound to another user")
}
binding.CreatedAt = time.Now()
return DB.Create(binding).Error
}
// CreateUserOAuthBindingWithTx creates a new OAuth binding within a transaction
func CreateUserOAuthBindingWithTx(tx *gorm.DB, binding *UserOAuthBinding) error {
if binding.UserId == 0 {
return errors.New("user ID is required")
}
if binding.ProviderId == 0 {
return errors.New("provider ID is required")
}
if binding.ProviderUserId == "" {
return errors.New("provider user ID is required")
}
// Check if this provider user ID is already taken (use tx to check within the same transaction)
var count int64
tx.Model(&UserOAuthBinding{}).Where("provider_id = ? AND provider_user_id = ?", binding.ProviderId, binding.ProviderUserId).Count(&count)
if count > 0 {
return errors.New("this OAuth account is already bound to another user")
}
binding.CreatedAt = time.Now()
return tx.Create(binding).Error
}
// UpdateUserOAuthBinding updates an existing OAuth binding (e.g., rebind to different OAuth account)
func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) error {
// Check if the new provider user ID is already taken by another user
var existingBinding UserOAuthBinding
err := DB.Where("provider_id = ? AND provider_user_id = ?", providerId, newProviderUserId).First(&existingBinding).Error
if err == nil && existingBinding.UserId != userId {
return errors.New("this OAuth account is already bound to another user")
}
// Check if user already has a binding for this provider
var binding UserOAuthBinding
err = DB.Where("user_id = ? AND provider_id = ?", userId, providerId).First(&binding).Error
if err != nil {
// No existing binding, create new one
return CreateUserOAuthBinding(&UserOAuthBinding{
UserId: userId,
ProviderId: providerId,
ProviderUserId: newProviderUserId,
})
}
// Update existing binding
return DB.Model(&binding).Update("provider_user_id", newProviderUserId).Error
}
// DeleteUserOAuthBinding deletes an OAuth binding
func DeleteUserOAuthBinding(userId, providerId int) error {
return DB.Where("user_id = ? AND provider_id = ?", userId, providerId).Delete(&UserOAuthBinding{}).Error
}
func deleteUserOAuthBindingsByUserId(tx *gorm.DB, userId int) error {
return tx.Where("user_id = ?", userId).Delete(&UserOAuthBinding{}).Error
}
// GetBindingCountByProviderId returns the number of bindings for a provider
func GetBindingCountByProviderId(providerId int) (int64, error) {
var count int64
err := DB.Model(&UserOAuthBinding{}).Where("provider_id = ?", providerId).Count(&count).Error
return count, err
}
+66
View File
@@ -0,0 +1,66 @@
package model
import (
"fmt"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func insertUsersForPaginationTest(t *testing.T, total int) {
t.Helper()
for id := 1; id <= total; id++ {
user := &User{
Id: id,
Username: fmt.Sprintf("user%02d", id),
Password: "password123",
DisplayName: fmt.Sprintf("User %02d", id),
Email: fmt.Sprintf("user%02d@example.com", id),
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AffCode: fmt.Sprintf("aff%02d", id),
}
require.NoError(t, DB.Create(user).Error)
}
}
func collectUserIDs(users []*User) []int {
ids := make([]int, 0, len(users))
for _, user := range users {
ids = append(ids, user.Id)
}
return ids
}
func TestGetAllUsersSortsBeforePagination(t *testing.T) {
truncateTables(t)
insertUsersForPaginationTest(t, 42)
pageOne, total, err := GetAllUsers(&common.PageInfo{Page: 1, PageSize: 20}, NewUserSortOptions("id", "asc"))
require.NoError(t, err)
assert.Equal(t, int64(42), total)
assert.Equal(t, []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, collectUserIDs(pageOne))
pageTwo, total, err := GetAllUsers(&common.PageInfo{Page: 2, PageSize: 20}, NewUserSortOptions("id", "asc"))
require.NoError(t, err)
assert.Equal(t, int64(42), total)
assert.Equal(t, []int{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40}, collectUserIDs(pageTwo))
pageThree, total, err := GetAllUsers(&common.PageInfo{Page: 3, PageSize: 20}, NewUserSortOptions("id", "asc"))
require.NoError(t, err)
assert.Equal(t, int64(42), total)
assert.Equal(t, []int{41, 42}, collectUserIDs(pageThree))
}
func TestSearchUsersSortsBeforePagination(t *testing.T) {
truncateTables(t)
insertUsersForPaginationTest(t, 42)
users, total, err := SearchUsers("user", "", nil, nil, 20, 20, NewUserSortOptions("id", "asc"))
require.NoError(t, err)
assert.Equal(t, int64(42), total)
assert.Equal(t, []int{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40}, collectUserIDs(users))
}
Loaded 100 of 2381 files, more files were not shown because too many files have changed in this diff. Show more