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
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:
commit
bb7968b612
2381 files changed
+551494
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(¤t, 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(¤t, 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
|
||||
}
|
||||
@@ -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") == ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 // 已处理
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
// RefreshPricing 强制立即重新计算与定价相关的缓存。
|
||||
// 该方法用于需要最新数据的内部管理 API,
|
||||
// 因此会绕过默认的 1 分钟延迟刷新。
|
||||
func RefreshPricing() {
|
||||
updatePricingLock.Lock()
|
||||
defer updatePricingLock.Unlock()
|
||||
|
||||
modelSupportEndpointsLock.Lock()
|
||||
defer modelSupportEndpointsLock.Unlock()
|
||||
|
||||
updatePricing()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(¤t, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("aDatas).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("aDatas).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("aDatas).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("aDatas).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("aDatas).Error
|
||||
return quotaDatas, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("aData).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)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user