feat: publish v1.0.0-rc.36-custom.1

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

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
package controller
import (
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func GetAccessTokenStatus(c *gin.Context) {
status, err := model.GetUserAccessTokenStatus(c.GetInt("id"))
if err != nil {
writeSecurityOperationError(c, err)
return
}
common.ApiSuccess(c, status)
}
func GenerateAccessToken(c *gin.Context) {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate}) == nil {
return
}
id := c.GetInt("id")
key, err := common.GenerateRandomKey(29 + common.GetRandomInt(4))
if err != nil {
writeSecurityOperationError(c, err)
return
}
var existing int64
if err := model.DB.Model(&model.User{}).Where("access_token = ?", key).Count(&existing).Error; err != nil {
writeSecurityOperationError(c, err)
return
}
if existing != 0 {
common.ApiErrorI18n(c, i18n.MsgUuidDuplicate)
return
}
if err := model.UpdateUserAccessToken(id, key); err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, id, "access_token.generate", map[string]any{"token_ref": model.AccessTokenFingerprint(key)})
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": key})
}
func RevokeAccessToken(c *gin.Context) {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}) == nil {
return
}
ref, err := model.RevokeUserAccessToken(c.GetInt("id"))
if err != nil {
writeSecurityOperationError(c, err)
return
}
if ref != "" {
recordUserSecurityAudit(c, c.GetInt("id"), "access_token.revoke", map[string]any{"token_ref": ref})
}
common.ApiSuccess(c, nil)
}
func GetAuditLogs(c *gin.Context) {
page := common.GetPageQuery(c)
if page.Page < 1 || page.PageSize < 1 || page.Page > 100000000 {
common.ApiErrorMsg(c, "Invalid audit pagination")
return
}
filter := model.AuditLogFilter{Username: c.Query("username"), Category: c.Query("category"), TokenRef: c.Query("token_ref"), ExcludeTokenRef: c.Query("exclude_token_ref"), RequestId: c.Query("request_id")}
viewerRole := c.GetInt("role")
if c.FullPath() == "/api/audit/self" {
filter.UserId = c.GetInt("id")
filter.Username = ""
filter.SelfView = true
}
if !model.ValidAuditCategory(filter.Category) || !model.ValidTokenFingerprint(filter.TokenRef) || !model.ValidTokenFingerprint(filter.ExcludeTokenRef) {
common.ApiErrorMsg(c, "Invalid audit filters")
return
}
for name, target := range map[string]*int64{"start_timestamp": &filter.StartTimestamp, "end_timestamp": &filter.EndTimestamp} {
if raw := c.Query(name); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed < 0 {
common.ApiErrorMsg(c, "Invalid audit time range")
return
}
*target = parsed
}
}
if filter.EndTimestamp > 0 && filter.EndTimestamp < filter.StartTimestamp {
common.ApiErrorMsg(c, "Invalid audit time range")
return
}
if raw := c.Query("success"); raw != "" {
if raw != "true" && raw != "false" {
common.ApiErrorMsg(c, "Invalid audit result")
return
}
success := raw == "true"
filter.Success = &success
}
logs, total, err := model.GetAuditLogs(filter, page.GetStartIdx(), page.GetPageSize(), viewerRole)
if err != nil {
common.ApiError(c, err)
return
}
page.SetItems(logs)
page.SetTotal(int(total))
common.ApiSuccess(c, page)
}
+851
View File
@@ -0,0 +1,851 @@
package controller
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
sqlmysql "github.com/go-sql-driver/mysql"
"gorm.io/driver/clickhouse"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"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"
)
func setupAccessTokenAudit(t *testing.T) (*model.User, string) {
t.Helper()
previousDB, previousLogDB := model.DB, model.LOG_DB
previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
previousRedis := common.RedisEnabled
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{}))
model.DB, model.LOG_DB = db, db
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
common.RedisEnabled = false
previousMaster := common.IsMasterNode
common.IsMasterNode = true
require.NoError(t, authz.Init(db))
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.IsMasterNode = previousMaster
common.SetDatabaseTypes(previousMain, previousLog)
common.RedisEnabled = previousRedis
})
token := "legacy-opaque-token"
user := &model.User{Username: "audit-owner", Password: "placeholder", Role: common.RoleAdminUser, Status: common.UserStatusEnabled, Group: "default", AccessToken: &token, AuthVersion: 1, AffCode: "audit-owner"}
require.NoError(t, db.Create(user).Error)
return user, token
}
func auditRequest(router http.Handler, method, path, token string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(`{"secret":"body-must-not-be-logged"}`))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("User-Agent", "audit-test-client")
request.RemoteAddr = "192.0.2.8:4567"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
user, old := setupAccessTokenAudit(t)
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.GET("/api/user/token/status", middleware.UserAuth(), GetAccessTokenStatus)
router.GET("/api/user/token", middleware.UserAuth(), GenerateAccessToken)
router.POST("/api/user/token", middleware.UserAuth(), GenerateAccessToken)
router.DELETE("/api/user/token", middleware.UserAuth(), RevokeAccessToken)
// Rotate during the handler, after authentication has captured the old PAT.
router.POST("/rotate-in-flight", middleware.UserAuth(), func(c *gin.Context) {
require.NoError(t, model.UpdateUserAccessToken(user.Id, "new-token"))
c.JSON(200, gin.H{"success": true})
})
status, err := model.GetUserAccessTokenStatus(user.Id)
require.NoError(t, err)
assert.True(t, status.Exists)
assert.Nil(t, status.CreatedAt)
assert.Nil(t, status.LastUsedAt)
assert.NotContains(t, auditRequest(router, "GET", "/api/user/token/status", old).Body.String(), old)
require.Equal(t, 200, auditRequest(router, "POST", "/rotate-in-flight", old).Code)
status, err = model.GetUserAccessTokenStatus(user.Id)
require.NoError(t, err)
assert.Equal(t, model.AccessTokenFingerprint("new-token"), status.TokenRef)
assert.NotNil(t, status.CreatedAt)
assert.Nil(t, status.LastUsedAt, "in-flight old requests must not mark the new generation as used")
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", old).Code)
for _, method := range []string{"POST", "GET", "DELETE"} {
response := auditRequest(router, method, "/api/user/token", "new-token")
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_INVALID"`)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Equal(t, "new-token", stored.GetAccessToken(), "a PAT cannot manage itself without a dashboard verification")
}
_, err = model.RevokeUserAccessToken(user.Id)
require.NoError(t, err)
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", "new-token").Code)
ref, err := model.RevokeUserAccessToken(user.Id)
require.NoError(t, err)
assert.Empty(t, ref, "repeated revocation is idempotent")
status, err = model.GetUserAccessTokenStatus(user.Id)
require.NoError(t, err)
assert.False(t, status.Exists)
assert.Nil(t, status.CreatedAt)
var history []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&history).Error)
require.NotEmpty(t, history)
encoded, err := common.Marshal(history)
require.NoError(t, err)
for _, secret := range []string{old, "new-token", "body-must-not-be-logged", "Authorization"} {
assert.NotContains(t, string(encoded), secret)
}
}
func TestAccessTokenAuditsResultsAndExcludesBrowserSessions(t *testing.T) {
user, pat := setupAccessTokenAudit(t)
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.NoRoute(func(c *gin.Context) { c.JSON(404, gin.H{"success": false}) })
router.GET("/public", func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) })
router.GET("/rate-limited", func(c *gin.Context) { c.AbortWithStatusJSON(429, gin.H{"success": false}) }, middleware.UserAuth())
router.GET("/read/:id", middleware.UserAuth(), func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) })
router.POST("/write", middleware.AdminAuth(), func(c *gin.Context) {
recordManageAudit(c, "option.update", map[string]any{"key": "safe-setting"})
c.JSON(200, gin.H{"success": true})
})
router.POST("/business-failure", middleware.AdminAuth(), func(c *gin.Context) { c.JSON(200, gin.H{"success": false, "message": "secret-response"}) })
router.GET("/forbidden", middleware.RootAuth(), func(c *gin.Context) { c.Status(204) })
cases := []struct {
method, path string
status int
success bool
}{{"GET", "/missing", 404, false}, {"GET", "/public", 200, true}, {"GET", "/rate-limited", 429, false}, {"GET", "/read/sensitive-id?password=secret-query", 200, true}, {"POST", "/write", 200, true}, {"POST", "/business-failure", 200, false}, {"GET", "/forbidden", 403, false}}
for _, tc := range cases {
response := auditRequest(router, tc.method, tc.path, pat)
require.Equal(t, tc.status, response.Code)
var access model.AuditLog
require.NoError(t, model.LOG_DB.Where("request_id = ? AND category = ?", response.Header().Get(common.RequestIdKey), model.AuditCategoryAccessToken).First(&access).Error)
assert.Equal(t, tc.success, access.Success)
assert.Equal(t, tc.status, access.Status)
assert.Equal(t, user.Id, access.UserId)
assert.Equal(t, "192.0.2.8", access.Ip)
assert.Equal(t, "audit-test-client", access.UserAgent)
assert.NotContains(t, access.Route, "sensitive-id")
}
var operationCount int64
require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Where("category = ?", model.AuditCategoryOperation).Count(&operationCount).Error)
assert.EqualValues(t, 2, operationCount, "manual operation must not be duplicated by fallback")
now := time.Now().Unix()
session := &model.UserSession{SID: "audit-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "refresh-placeholder", LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600}
require.NoError(t, model.CreateUserSession(session))
jwt, _, err := service.IssueAccessToken(service.AuthIdentity{UserID: user.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1})
require.NoError(t, err)
assert.Equal(t, 200, auditRequest(router, "GET", "/read/123", jwt).Code)
assert.Equal(t, 401, auditRequest(router, "GET", "/read/123", "unknown-token").Code)
entries, total, err := model.GetAuditLogs(model.AuditLogFilter{Category: model.AuditCategoryAccessToken}, 0, 20, common.RoleRootUser)
require.NoError(t, err)
assert.EqualValues(t, 7, total)
encoded, err := common.Marshal(entries)
require.NoError(t, err)
for _, secret := range []string{pat, jwt, "secret-query", "secret-response", "body-must-not-be-logged", "sensitive-id"} {
assert.NotContains(t, string(encoded), secret)
}
}
func TestAuditIsolationVisibilityAndFailureContracts(t *testing.T) {
user, pat := setupAccessTokenAudit(t)
metadata := model.AuditOther{
Op: &model.AuditOperation{Action: "generic"},
AdminInfo: &model.AuditAdminInfo{AdminID: 1},
RootInfo: model.AuditFields{"private": "root-only"},
}
for _, owner := range []int{user.Id, user.Id + 1} {
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: owner, Username: fmt.Sprint(owner), Category: model.AuditCategorySecurity, Success: false, Other: metadata})
}
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.GET("/api/audit/self", middleware.UserAuth(), GetAuditLogs)
router.GET("/api/audit", middleware.AdminAuth(), middleware.RequirePermission(authz.AuditRead), GetAuditLogs)
response := auditRequest(router, "GET", "/api/audit/self?username=other&user_id=2&category=security&success=false&page_size=1", pat)
var result struct {
Success bool
Data struct {
Items []model.AuditLog
Total int
}
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success)
require.Equal(t, 1, result.Data.Total)
require.Len(t, result.Data.Items, 1)
assert.Equal(t, user.Id, result.Data.Items[0].UserId)
assert.NotContains(t, response.Body.String(), "admin_info")
assert.NotContains(t, response.Body.String(), "root-only")
var payload struct {
Data struct {
Items []struct {
Other map[string]any `json:"other"`
}
}
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload), "audit metadata must be a JSON object, not an encoded string")
require.Len(t, payload.Data.Items, 1)
assert.Contains(t, payload.Data.Items[0].Other, "op")
require.NoError(t, authz.SetUserPermissions(user.Id, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: true}}))
response = auditRequest(router, "GET", "/api/audit?category=security", pat)
assert.Contains(t, response.Body.String(), "admin_info")
assert.NotContains(t, response.Body.String(), "root-only")
for _, query := range []string{"success=bad", "category=bad", "token_ref=secret", "start_timestamp=-1", "start_timestamp=2&end_timestamp=1", "p=-1", "page_size=-1"} {
assert.Contains(t, auditRequest(router, "GET", "/api/audit/self?"+query, pat).Body.String(), `"success":false`)
}
require.NoError(t, model.LOG_DB.Callback().Query().Before("gorm:query").Register("audit:fail", func(tx *gorm.DB) {
if tx.Statement.Table == "audit_logs" {
tx.AddError(errors.New("audit store unavailable"))
}
}))
_, err := model.GetUserAccessTokenStatus(user.Id)
require.Error(t, err, "audit query failure must not look like never used")
model.LOG_DB.Callback().Query().Remove("audit:fail")
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("audit:write-fail", func(tx *gorm.DB) { tx.AddError(errors.New("write unavailable")) }))
require.Error(t, model.UpdateUserAccessToken(user.Id, "replacement"))
_, err = model.RevokeUserAccessToken(user.Id)
require.Error(t, err)
model.DB.Callback().Update().Remove("audit:write-fail")
status, err := model.GetUserAccessTokenStatus(user.Id)
require.NoError(t, err)
assert.Equal(t, model.AccessTokenFingerprint(pat), status.TokenRef)
}
func TestAuditRoleVisibilityAndPermissions(t *testing.T) {
admin, pat := setupAccessTokenAudit(t)
rootToken := "root-audit-token"
root := &model.User{Username: "root-audit", Role: common.RoleRootUser, Status: common.UserStatusEnabled, AuthVersion: 1, AccessToken: &rootToken, AffCode: "root-audit"}
require.NoError(t, model.DB.Create(root).Error)
metadata := model.AuditOther{
AdminInfo: &model.AuditAdminInfo{AdminID: 1},
RootInfo: model.AuditFields{"private": "root-only"},
}
for i, role := range []int{1, 10, 100, 0, -1, 99} {
model.RecordAuditLog(nil, model.AuditLog{ActorRole: role, UserId: admin.Id, Username: admin.Username, Category: model.AuditCategorySecurity, RequestId: fmt.Sprintf("role-%d", role), CreatedAt: int64(100 + i), Other: metadata})
}
model.RecordAuditLog(nil, model.AuditLog{ActorRole: 100, UserId: root.Id, Username: root.Username, Category: model.AuditCategorySecurity, RequestId: "root-owned", Other: metadata})
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.GET("/api/audit", middleware.AdminAuth(), middleware.RequirePermission(authz.AuditRead), GetAuditLogs)
router.GET("/api/audit/self", middleware.UserAuth(), GetAuditLogs)
now := time.Now().Unix()
session := &model.UserSession{SID: "audit-permissions-session", UserID: admin.Id, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "placeholder", LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600}
require.NoError(t, model.CreateUserSession(session))
jwt, _, err := service.IssueAccessToken(service.AuthIdentity{UserID: admin.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1})
require.NoError(t, err)
for _, credential := range []string{pat, jwt} {
assert.Equal(t, http.StatusForbidden, auditRequest(router, "GET", "/api/audit", credential).Code)
}
require.NoError(t, model.DB.Transaction(func(tx *gorm.DB) error {
return authz.SetUserPermissionsInTx(tx, admin.Id, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: true}})
}))
require.NoError(t, authz.ReloadPolicy())
for _, credential := range []string{pat, jwt} {
for _, endpoint := range []string{"/api/audit", "/api/audit/self"} {
response := auditRequest(router, "GET", endpoint+"?category=security&page_size=1", credential)
assert.Equal(t, http.StatusOK, response.Code)
var result struct {
Data struct {
Total int
Items []model.AuditLog
}
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, 2, result.Data.Total)
require.Len(t, result.Data.Items, 1)
assert.Equal(t, 10, result.Data.Items[0].ActorRole)
assert.NotContains(t, response.Body.String(), "root-only")
for _, filter := range []string{"request_id=role-100", "username=" + root.Username, "request_id=role-0"} {
filtered := auditRequest(router, "GET", endpoint+"?category=security&"+filter, credential)
// self ignores supplied usernames, while still excluding every root row.
if endpoint == "/api/audit/self" && strings.HasPrefix(filter, "username=") {
continue
}
assert.Contains(t, filtered.Body.String(), `"total":0`)
}
}
}
rootSelf := auditRequest(router, "GET", "/api/audit/self?category=security", rootToken)
assert.Contains(t, rootSelf.Body.String(), `"actor_role":100`)
assert.NotContains(t, rootSelf.Body.String(), "admin_info")
rootAll := auditRequest(router, "GET", "/api/audit?category=security", rootToken)
assert.Contains(t, rootAll.Body.String(), `"total":7`)
assert.Contains(t, rootAll.Body.String(), "root-only")
require.NoError(t, authz.SetUserPermissions(admin.Id, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: false}}))
for _, credential := range []string{pat, jwt} {
assert.Equal(t, http.StatusForbidden, auditRequest(router, "GET", "/api/audit", credential).Code)
assert.Equal(t, http.StatusOK, auditRequest(router, "GET", "/api/audit/self", credential).Code)
}
}
func TestAuditRoleSnapshotSurvivesActorChanges(t *testing.T) {
user, pat := setupAccessTokenAudit(t)
user.Role = common.RoleRootUser
require.NoError(t, model.DB.Model(user).Update("role", user.Role).Error)
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.POST("/change-role", middleware.UserAuth(), func(c *gin.Context) {
recordLoginAudit(user, c)
recordUserSecurityAudit(c, user.Id, "user.security_verify", nil)
recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{999}}, auditOperatorInfo(c))
require.NoError(t, model.DB.Model(user).Update("role", common.RoleAdminUser).Error)
c.Status(200)
})
require.Equal(t, http.StatusOK, auditRequest(router, "POST", "/change-role", pat).Code)
entries, total, err := model.GetAuditLogs(model.AuditLogFilter{}, 0, 20, common.RoleAdminUser)
require.NoError(t, err)
assert.Zero(t, total)
assert.Empty(t, entries)
status, err := model.GetUserAccessTokenStatus(user.Id)
require.NoError(t, err)
assert.Nil(t, status.LastUsedAt, "a root request must not leak through the last-use summary after demotion")
require.NoError(t, model.DB.Unscoped().Delete(user).Error)
entries, total, err = model.GetAuditLogs(model.AuditLogFilter{}, 0, 20, common.RoleRootUser)
require.NoError(t, err)
assert.EqualValues(t, 4, total)
for _, entry := range entries {
assert.Equal(t, common.RoleRootUser, entry.ActorRole)
}
}
func TestSecurityAndOperationEventsUseAuditTable(t *testing.T) {
user, _ := setupAccessTokenAudit(t)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/event", nil)
c.Set("id", user.Id)
c.Set("username", user.Username)
c.Set("role", user.Role)
c.Set(common.RequestIdKey, "correlated-request")
recordLoginAudit(user, c)
for _, action := range []string{"user.passkey_register", "user.passkey_delete", "user.2fa_setup", "user.2fa_enable", "user.2fa_disable_self", "user.2fa_backup_codes", "user.security_verify"} {
recordUserSecurityAudit(c, user.Id, action, nil)
}
recordManageAudit(c, "option.update", map[string]any{"key": "safe"})
recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{user.Id}}, &model.AuditAdminInfo{AdminID: user.Id})
for _, typ := range []int{model.LogTypeTopup, model.LogTypeConsume, model.LogTypeRefund, model.LogTypeSystem} {
model.RecordLog(user.Id, typ, "business entry")
}
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
assert.Len(t, audits, 10)
for _, entry := range audits {
assert.Equal(t, "correlated-request", entry.RequestId)
assert.Equal(t, user.Role, entry.ActorRole)
}
var logs []model.Log
require.NoError(t, model.LOG_DB.Find(&logs).Error)
assert.Len(t, logs, 4)
_, err := model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100)
require.NoError(t, err)
var count int64
require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Count(&count).Error)
assert.EqualValues(t, 10, count)
}
// Released schemas copied from v1.0.0-rc.33; only the Go type names differ.
type releasedAuditUser struct {
Id int `json:"id"`
Username string `json:"username" gorm:"unique;index" validate:"max=20"`
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
Role int `json:"role" gorm:"type:int;default:1"` // admin, common
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
Email string `json:"email" gorm:"index" validate:"max=50"`
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
DiscordId string `json:"discord_id" gorm:"column:discord_id;index"`
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
Quota int `json:"quota" gorm:"type:int;default:0"`
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
Group string `json:"group" gorm:"type:varchar(64);default:'default'"`
AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"`
AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"`
AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度
AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度
InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"`
DeletedAt gorm.DeletedAt `gorm:"index"`
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
Setting string `json:"setting" gorm:"type:text;column:setting"`
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"`
AuthVersion int64 `json:"-" gorm:"type:bigint;not null;default:1;column:auth_version"`
AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"`
}
func (releasedAuditUser) TableName() string { return "users" }
type releasedAuditLog 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"`
}
func (releasedAuditLog) TableName() string { return "logs" }
// External tests create a new database per case on a loopback-only disposable
// instance. They never drop databases or tables supplied through an environment variable.
func newAuditTestDatabase(t *testing.T, kind, dsn string) (*gorm.DB, string) {
t.Helper()
if kind == "sqlite" {
path := t.TempDir() + "/audit.db"
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
require.NoError(t, err)
return db, path
}
require.NotEmpty(t, dsn)
name := fmt.Sprintf("newapi_audit_%d", time.Now().UnixNano())
var original, isolated gorm.Dialector
var newDSN string
if kind == "mysql" {
config, err := sqlmysql.ParseDSN(dsn)
require.NoError(t, err)
require.Equal(t, "tcp", config.Net)
host, _, err := net.SplitHostPort(config.Addr)
require.NoError(t, err)
require.True(t, net.ParseIP(host).IsLoopback(), "database tests only permit loopback instances")
original = mysql.Open(dsn)
config.DBName = name
newDSN = config.FormatDSN()
isolated = mysql.Open(newDSN)
} else {
parsed, err := url.Parse(dsn)
require.NoError(t, err)
require.True(t, net.ParseIP(parsed.Hostname()).IsLoopback(), "database tests only permit loopback instances")
parsed.Path = "/" + name
newDSN = parsed.String()
if kind == "clickhouse" {
original = clickhouse.Open(dsn)
isolated = clickhouse.Open(newDSN)
} else {
original = postgres.Open(dsn)
isolated = postgres.Open(newDSN)
}
}
admin, err := gorm.Open(original, &gorm.Config{})
require.NoError(t, err)
// No IF NOT EXISTS: a collision fails before any test data can be written.
createSQL := "CREATE DATABASE " + name
if kind == "mysql" {
createSQL += " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
}
require.NoError(t, admin.Exec(createSQL).Error)
sqlDB, err := admin.DB()
require.NoError(t, err)
require.NoError(t, sqlDB.Close())
db, err := gorm.Open(isolated, &gorm.Config{})
require.NoError(t, err)
t.Logf("isolated database: %s (%s)", name, kind)
t.Cleanup(func() {
connection, err := db.DB()
if err == nil {
_ = connection.Close()
}
})
return db, newDSN
}
func verifyAuditRoleStorage(t *testing.T) {
t.Helper()
for i, role := range []int{1, 10, 100, 0, 99} {
model.RecordAuditLog(nil, model.AuditLog{ActorRole: role, UserId: 1, Username: "role-owner", CreatedAt: int64(200 + i), Category: model.AuditCategoryOperation, RequestId: fmt.Sprintf("matrix-role-%d", role)})
}
filter := model.AuditLogFilter{Category: model.AuditCategoryOperation}
visible, total, err := model.GetAuditLogs(filter, 0, 1, common.RoleAdminUser)
require.NoError(t, err)
assert.EqualValues(t, 2, total)
require.Len(t, visible, 1)
assert.Equal(t, common.RoleAdminUser, visible[0].ActorRole)
visible, total, err = model.GetAuditLogs(filter, 1, 1, common.RoleCommonUser)
require.NoError(t, err)
assert.EqualValues(t, 2, total)
require.Len(t, visible, 1)
assert.Equal(t, common.RoleCommonUser, visible[0].ActorRole)
filter.RequestId = "matrix-role-100"
visible, total, err = model.GetAuditLogs(filter, 0, 20, common.RoleAdminUser)
require.NoError(t, err)
assert.Zero(t, total)
assert.Empty(t, visible)
visible, total, err = model.GetAuditLogs(filter, 0, 20, common.RoleRootUser)
require.NoError(t, err)
assert.EqualValues(t, 1, total)
require.Len(t, visible, 1)
assert.Equal(t, common.RoleRootUser, visible[0].ActorRole)
}
func verifyAuditJSONStorage(t *testing.T) {
t.Helper()
columns, err := model.LOG_DB.Migrator().ColumnTypes(&model.AuditLog{})
require.NoError(t, err)
var otherType string
for _, column := range columns {
if column.Name() == "other" {
otherType = strings.ToLower(column.DatabaseTypeName())
}
}
assert.Equal(t, "json", otherType)
metadata := model.AuditOther{
Op: &model.AuditOperation{Action: "channel.update", Params: model.AuditFields{
"id": 42, "name": "渠道", "changed_fields": []string{}, "large_id": uint64(9007199254740993),
"extra": map[string]any{"attempts": 0, "permitted": false, "ratio": 1.25, "targets": []int{1, 2}},
}},
AdminInfo: &model.AuditAdminInfo{AdminID: 1},
AuditInfo: &model.AuditRequestInfo{Method: "PUT", Route: "/api/channel/", Path: "/api/channel/", Status: 200, Success: false},
RootInfo: model.AuditFields{"private": "root-only"},
}
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleCommonUser, UserId: 1, Username: "json-owner", Category: model.AuditCategorySecurity, RequestId: "matrix-json", Other: metadata})
filter := model.AuditLogFilter{RequestId: "matrix-json"}
entries, total, err := model.GetAuditLogs(filter, 0, 20, common.RoleRootUser)
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, entries, 1)
stored, err := common.Marshal(entries[0].Other)
require.NoError(t, err)
expected, err := common.Marshal(metadata)
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(stored))
require.NotNil(t, entries[0].Other.Op)
assert.Equal(t, "channel.update", entries[0].Other.Op.Action)
require.NotNil(t, entries[0].Other.AuditInfo)
assert.False(t, entries[0].Other.AuditInfo.Success)
var details struct {
Op struct {
Params struct {
LargeId uint64 `json:"large_id"`
}
}
}
require.NoError(t, common.Unmarshal(stored, &details))
assert.EqualValues(t, 9007199254740993, details.Op.Params.LargeId, "JSON numbers must retain their type and precision")
for _, role := range []int{common.RoleCommonUser, common.RoleAdminUser} {
entries, _, err = model.GetAuditLogs(filter, 0, 20, role)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Nil(t, entries[0].Other.RootInfo)
if role == common.RoleCommonUser {
assert.Nil(t, entries[0].Other.AdminInfo)
assert.Nil(t, entries[0].Other.AuditInfo)
} else {
assert.NotNil(t, entries[0].Other.AdminInfo)
assert.NotNil(t, entries[0].Other.AuditInfo)
}
}
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleCommonUser, UserId: 1, Username: "json-owner", RequestId: "matrix-json-empty", Other: model.AuditOther{}})
entries, _, err = model.GetAuditLogs(model.AuditLogFilter{RequestId: "matrix-json-empty"}, 0, 20, common.RoleRootUser)
require.NoError(t, err)
require.Len(t, entries, 1)
empty, err := common.Marshal(entries[0].Other)
require.NoError(t, err)
assert.JSONEq(t, `{}`, string(empty))
for range 2 {
require.NoError(t, model.InitLogDB())
}
entries, total, err = model.GetAuditLogs(filter, 0, 20, common.RoleRootUser)
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, entries, 1)
stored, err = common.Marshal(entries[0].Other)
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(stored), "repeated startup must retain structured audit metadata")
}
func TestAuditOtherDatabaseEncoding(t *testing.T) {
const payload = `{"op":{"action":"channel.update","params":{"id":9007199254740993,"nested":{"count":0,"enabled":false}}},"admin_info":{"admin_id":1,"admin_username":"root","admin_role":100,"auth_method":"session"},"audit_info":{"method":"PUT","route":"/api/channel/","path":"/api/channel/","status":200,"success":false},"root_info":{"generation":18446744073709551615}}`
for _, input := range []any{payload, []byte(payload)} {
var other model.AuditOther
require.NoError(t, other.Scan(input))
require.NotNil(t, other.Op)
assert.Equal(t, "channel.update", other.Op.Action)
require.NotNil(t, other.AdminInfo)
assert.Equal(t, 100, other.AdminInfo.AdminRole)
require.NotNil(t, other.AuditInfo)
assert.False(t, other.AuditInfo.Success)
encoded, err := other.Value()
require.NoError(t, err)
text, ok := encoded.(string)
require.True(t, ok, "PostgreSQL simple protocol requires a string parameter")
assert.JSONEq(t, payload, text)
assert.Contains(t, text, "9007199254740993")
assert.Contains(t, text, "18446744073709551615")
for _, empty := range []any{nil, "", []byte(`null`), "{}"} {
require.NoError(t, other.Scan(input))
require.NoError(t, other.Scan(empty))
encoded, err = other.Value()
require.NoError(t, err)
assert.Equal(t, "{}", encoded, "empty input must clear fields from the previous row")
}
}
var invalid model.AuditOther
assert.Error(t, invalid.Scan(42))
assert.Error(t, invalid.Scan([]byte(`{broken`)))
}
func TestAuditDatabaseMatrix(t *testing.T) {
previousDB, previousLogDB := model.DB, model.LOG_DB
previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
previousRedis := common.RedisEnabled
previousMaster, previousSQLite := common.IsMasterNode, common.SQLitePath
common.IsMasterNode = true
common.RedisEnabled = false
t.Cleanup(func() {
common.IsMasterNode, common.SQLitePath = previousMaster, previousSQLite
model.DB, model.LOG_DB = previousDB, previousLogDB
common.SetDatabaseTypes(previousMain, previousLog)
common.RedisEnabled = previousRedis
})
cases := []struct {
name, env string
typ common.DatabaseType
}{
{"sqlite", "", common.DatabaseTypeSQLite}, {"mysql", "AUDIT_MYSQL_DSN", common.DatabaseTypeMySQL}, {"postgres", "AUDIT_POSTGRES_DSN", common.DatabaseTypePostgreSQL},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dsn := os.Getenv(tc.env)
if tc.env != "" && dsn == "" {
t.Skip(tc.env + " is not configured")
}
for _, upgrade := range []bool{false, true} {
t.Run(fmt.Sprintf("upgrade=%v", upgrade), func(t *testing.T) {
db, isolatedDSN := newAuditTestDatabase(t, tc.name, dsn)
t.Setenv("LOG_SQL_DSN", "")
if tc.name == "sqlite" {
common.SQLitePath = isolatedDSN
t.Setenv("SQL_DSN", "local")
} else {
t.Setenv("SQL_DSN", isolatedDSN)
}
model.DB, model.LOG_DB = db, db
common.SetDatabaseTypes(tc.typ, tc.typ)
versionSQL := "SELECT version()"
if tc.name == "sqlite" {
versionSQL = "SELECT sqlite_version()"
}
var version string
require.NoError(t, db.Raw(versionSQL).Scan(&version).Error)
t.Logf("database version: %s", version)
if upgrade {
require.NoError(t, db.AutoMigrate(&releasedAuditUser{}, &releasedAuditLog{}))
legacy := "released-token"
require.NoError(t, db.Create(&releasedAuditUser{Username: "released-owner", Password: "placeholder", AccessToken: &legacy, AffCode: "released-aff", Quota: 1234}).Error)
require.NoError(t, db.Create(&releasedAuditLog{UserId: 1, Type: model.LogTypeLogin, Content: "historical login", CreatedAt: 100, RequestId: "legacy-request"}).Error)
}
for range 2 {
require.NoError(t, model.InitDB())
require.NoError(t, model.InitLogDB())
}
if !upgrade {
require.NoError(t, db.Create(&model.User{Username: "fresh-owner", Password: "placeholder", AffCode: "fresh-aff"}).Error)
}
status, err := model.GetUserAccessTokenStatus(1)
require.NoError(t, err)
assert.Equal(t, upgrade, status.Exists)
assert.Nil(t, status.CreatedAt)
if upgrade {
assert.Equal(t, model.AccessTokenFingerprint("released-token"), status.TokenRef)
legacyUser, validationErr := model.ValidateAccessToken("released-token")
require.NoError(t, validationErr)
require.NotNil(t, legacyUser)
var user model.User
require.NoError(t, db.First(&user, 1).Error)
assert.Equal(t, 1234, user.Quota)
var old model.Log
require.NoError(t, db.First(&old).Error)
assert.Equal(t, "historical login", old.Content)
}
require.NoError(t, model.UpdateUserAccessToken(1, "matrix-token"))
require.Error(t, db.Create(&model.User{Username: "duplicate", Password: "placeholder", AffCode: "duplicate-aff", AccessToken: common.GetPointer("matrix-token")}).Error, "PAT uniqueness must survive upgrade")
for _, timestamp := range []int64{101, 102, 103} {
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: timestamp, Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("matrix-token"), Ip: "192.0.2.1", Success: timestamp != 102})
}
first, total, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 2, common.RoleCommonUser)
require.NoError(t, err)
assert.EqualValues(t, 3, total)
require.Len(t, first, 2)
assert.EqualValues(t, 103, first[0].CreatedAt)
second, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 2, 2, common.RoleCommonUser)
require.NoError(t, err)
require.Len(t, second, 1)
assert.EqualValues(t, 101, second[0].CreatedAt)
failure := false
failed, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1, Success: &failure}, 0, 10, 1)
require.NoError(t, err)
require.Len(t, failed, 1)
assert.EqualValues(t, 102, failed[0].CreatedAt)
status, err = model.GetUserAccessTokenStatus(1)
require.NoError(t, err)
require.NotNil(t, status.LastUsedAt)
assert.EqualValues(t, 103, *status.LastUsedAt)
_, err = model.RevokeUserAccessToken(1)
require.NoError(t, err)
_, err = model.RevokeUserAccessToken(1)
require.NoError(t, err)
require.NoError(t, model.MigrateAuditLogs())
_, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 10, 1)
require.NoError(t, err)
assert.EqualValues(t, 3, total)
verifyAuditRoleStorage(t)
verifyAuditJSONStorage(t)
require.NoError(t, authz.Init(model.DB))
assert.False(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead))
require.NoError(t, model.DB.Transaction(func(tx *gorm.DB) error {
return authz.SetUserPermissionsInTx(tx, 1, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: true}})
}))
require.NoError(t, authz.ReloadPolicy())
assert.True(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead))
require.NoError(t, authz.Init(model.DB))
require.NoError(t, authz.Init(model.DB))
assert.True(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead))
require.NoError(t, model.DB.Transaction(func(tx *gorm.DB) error {
return authz.SetUserPermissionsInTx(tx, 1, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: false}})
}))
require.NoError(t, authz.ReloadPolicy())
assert.False(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead))
assert.True(t, authz.Can(1, common.RoleRootUser, authz.AuditRead))
})
}
})
}
}
func TestIndependentAuditLogStores(t *testing.T) {
_, _ = setupAccessTokenAudit(t)
previousMaster := common.IsMasterNode
common.IsMasterNode = true
t.Cleanup(func() { common.IsMasterNode = previousMaster })
for _, tc := range []struct{ kind, env string }{{"mysql", "AUDIT_MYSQL_DSN"}, {"postgres", "AUDIT_POSTGRES_DSN"}, {"clickhouse", "AUDIT_CLICKHOUSE_DSN"}} {
for _, upgrade := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/upgrade=%v", tc.kind, upgrade), func(t *testing.T) {
dsn := os.Getenv(tc.env)
if dsn == "" {
t.Skip(tc.env + " is not configured")
}
logDB, isolatedDSN := newAuditTestDatabase(t, tc.kind, dsn)
if upgrade {
if tc.kind == "clickhouse" {
require.NoError(t, logDB.Exec(releasedClickHouseLogSchema).Error)
} else {
require.NoError(t, logDB.AutoMigrate(&releasedAuditLog{}))
}
require.NoError(t, logDB.Create(&releasedAuditLog{UserId: 1, Username: "legacy", CreatedAt: time.Now().Unix(), Type: model.LogTypeLogin, Content: "retained historical login", RequestId: "legacy-split-request"}).Error)
}
t.Setenv("LOG_SQL_DSN", isolatedDSN)
t.Setenv("LOG_SQL_CLICKHOUSE_TTL_DAYS", "7")
require.NoError(t, model.InitLogDB())
require.NoError(t, model.InitLogDB())
if upgrade {
var old model.Log
require.NoError(t, model.LOG_DB.Where("request_id = ?", "legacy-split-request").Take(&old).Error)
assert.Equal(t, "retained historical login", old.Content)
}
require.NoError(t, model.UpdateUserAccessToken(1, "independent-pat"))
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "independent", Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("independent-pat"), Ip: "192.0.2.8", Success: false, Status: 403})
entries, total, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, common.RoleAdminUser)
require.NoError(t, err)
assert.EqualValues(t, 1, total)
require.Len(t, entries, 1)
assert.False(t, entries[0].Success)
status, err := model.GetUserAccessTokenStatus(1)
require.NoError(t, err)
require.NotNil(t, status.LastUsedAt)
assert.Equal(t, "192.0.2.8", status.LastUsedIp)
model.RecordLog(1, model.LogTypeTopup, "independent business")
_, err = model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100)
require.NoError(t, err)
_, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, 1)
require.NoError(t, err)
assert.EqualValues(t, 1, total)
var mainCount int64
require.NoError(t, model.DB.Model(&model.AuditLog{}).Count(&mainCount).Error)
assert.Zero(t, mainCount)
verifyAuditRoleStorage(t)
verifyAuditJSONStorage(t)
if tc.kind == "clickhouse" {
var create string
require.NoError(t, model.LOG_DB.Raw("SHOW CREATE TABLE audit_logs").Scan(&create).Error)
assert.NotContains(t, strings.ToUpper(create), "TTL")
require.NoError(t, model.LOG_DB.Raw("SHOW CREATE TABLE logs").Scan(&create).Error)
assert.Contains(t, strings.ToUpper(create), "TTL")
}
})
}
}
}
// ClickHouse logs schema from v1.0.0-rc.33.
const releasedClickHouseLogSchema = `
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)`
+162
View File
@@ -0,0 +1,162 @@
package controller
import (
"fmt"
"os"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// auditContentTemplates 将稳定的操作标识 action 映射为英文兜底模板,渲染后写入
// Log.Content(供导出等非本地化消费者使用)。占位符为 ${name},由该
// action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的
// 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。
var auditContentTemplates = map[string]string{
"user.create": "Created user ${username} (role ${role})",
"user.update": "Updated user ${username} (ID: ${id})",
"user.delete": "Deleted user ${username} (ID: ${id})",
"user.account_delete": "Account deletion",
"user.manage": "Performed ${action} on user ${username} (ID: ${id})",
"user.quota_add": "Increased user quota by ${quota}",
"user.quota_subtract": "Decreased user quota by ${quota}",
"user.quota_override": "Overrode user quota from ${from} to ${to}",
"user.binding_clear": "Cleared ${bindingType} binding for user ${username}",
"user.2fa_disable": "Force-disabled two-factor authentication for the user",
"user.passkey_register": "Registered a passkey",
"access_token.generate": "Generated a system access token",
"access_token.revoke": "Revoked the system access token",
"user.2fa_setup": "Started two-factor authentication setup",
"user.2fa_enable": "Enabled two-factor authentication",
"user.2fa_disable_self": "Disabled two-factor authentication",
"user.2fa_backup_codes": "Regenerated two-factor backup codes",
"user.security_verify": "Completed security verification",
"user.password_change": "Account password change",
"user.binding_start": "Account binding request",
"user.binding_bind": "Account binding",
"user.binding_unbind": "Account unlinking",
"user.email_binding_resend": "Email confirmation code resend",
"user.passkey_delete": "Deleted a passkey",
"user.reset_passkey": "Reset the user passkey",
"option.update": "Updated system setting ${key}",
"channel.create": "Created channel ${name} (type ${type}, count ${count})",
"channel.update": "Updated channel ${name} (ID: ${id})",
"channel.delete": "Deleted channel ${name} (ID: ${id})",
"channel.delete_batch": "Batch deleted ${count} channels",
"channel.delete_disabled": "Deleted all disabled channels (${count})",
"channel.key_view": "Viewed channel key ${name} (ID: ${id})",
"channel.tag_disable": "Disabled channels with tag ${tag}",
"channel.tag_enable": "Enabled channels with tag ${tag}",
"channel.tag_edit": "Edited channels with tag ${tag}",
"channel.tag_batch_set": "Batch set tag for ${count} channels",
"channel.copy": "Copied channel (source ID: ${sourceId}) to ${name} (new ID: ${id})",
"channel.multi_key_manage": "Multi-key management ${action} on channel (ID: ${id})",
"channel.upstream_apply": "Applied upstream model changes to channel (ID: ${id})",
"channel.upstream_apply_all": "Applied upstream model changes to ${count} channels",
"redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)",
"redemption.delete_batch": "Batch deleted ${count} redemption codes",
"subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}",
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
}
// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
func auditContentEN(action string, params map[string]any) string {
tmpl, ok := auditContentTemplates[action]
if !ok {
return action
}
return os.Expand(tmpl, func(key string) string {
if v, ok := params[key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
})
}
// auditOperatorInfo 从上下文构建操作者身份信息(管理员 id/用户名/角色)。
func auditOperatorInfo(c *gin.Context) *model.AuditAdminInfo {
return &model.AuditAdminInfo{
AdminID: c.GetInt("id"),
AdminUsername: c.GetString("username"),
AdminRole: c.GetInt("role"),
AuthMethod: auditAuthMethod(c),
}
}
func auditAuthMethod(c *gin.Context) string {
if c.GetBool("use_access_token") {
return "access_token"
}
return "session"
}
// markAuditLogged 标记当前请求已在 handler 内手动记录审计日志,
// 使鉴权链路中的审计兜底(finishAdminAudit)跳过兜底记录,避免重复。
func markAuditLogged(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyAuditLogged, true)
}
// recordManageAudit 记录一条由操作者本人归属的管理/高危审计日志(资源类操作:
// 渠道 / 系统设置 / 兑换码等)。content 由 action+params 自动渲染。
func recordManageAudit(c *gin.Context, action string, params map[string]any) {
recordManageAuditFor(c, c.GetInt("id"), action, params)
}
// recordManageAuditFor 记录一条管理审计日志,日志归属于操作者;targetUserId
// 只表示被操作用户,用于在结构化参数中保留目标上下文。
func recordManageAuditFor(c *gin.Context, targetUserId int, action string, params map[string]any) {
if params == nil {
params = map[string]any{}
}
operatorUserId := c.GetInt("id")
if _, ok := params["target_user_id"]; !ok && targetUserId > 0 && targetUserId != operatorUserId {
params["target_user_id"] = targetUserId
}
model.RecordOperationAuditLog(operatorUserId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil, c)
markAuditLogged(c)
}
// recordUserSecurityAudit 记录普通用户自己的安全敏感操作(如 passkey 绑定/解绑)。
// 这类日志没有管理员操作者,不写 admin_info;同时不依赖 AdminAuth/RootAuth 的兜底。
func recordUserSecurityAudit(c *gin.Context, userId int, action string, params map[string]any) {
if code := c.GetString("security_error_code"); code != "" {
if params == nil {
params = map[string]any{}
}
params["code"] = code
}
var auditInfo *model.AuditRequestInfo
if success, ok := params["success"].(bool); ok {
auditInfo = &model.AuditRequestInfo{
Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(),
Status: c.Writer.Status(), Success: success,
}
}
model.RecordOperationAuditLog(userId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, nil, auditInfo, c)
}
func tokenAuditParams(c *gin.Context) model.AuditFields {
params, ok := common.GetContextKeyType[model.AuditFields](c, constant.ContextKeyTokenAuditParams)
if !ok {
params = model.AuditFields{}
common.SetContextKey(c, constant.ContextKeyTokenAuditParams, params)
}
return params
}
func tokenBatchAuditParams(c *gin.Context, ids []int) model.AuditFields {
params := tokenAuditParams(c)
params["total"] = len(ids)
// Bound audit payloads without changing the batch operation's limits.
params["requested_ids"] = append([]int{}, ids[:min(len(ids), 100)]...)
if len(ids) > 100 {
params["requested_ids_truncated"] = true
}
return params
}
+818
View File
@@ -0,0 +1,818 @@
package controller
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/fxamacker/cbor/v2"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func newSecurityLoginPasskey(t *testing.T, userID int) *ecdsa.PrivateKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
credentialID := sha256.Sum256(elliptic.Marshal(key.Curve, key.X, key.Y))
publicKey, err := cbor.Marshal(map[int]any{1: 2, 3: -7, -1: 1, -2: key.X.FillBytes(make([]byte, 32)), -3: key.Y.FillBytes(make([]byte, 32))})
require.NoError(t, err)
require.NoError(t, model.DB.Create(&model.PasskeyCredential{
UserID: userID, CredentialID: base64.StdEncoding.EncodeToString(credentialID[:]), PublicKey: base64.StdEncoding.EncodeToString(publicKey),
UserPresent: true, UserVerified: true,
}).Error)
return key
}
func beginSecurityLoginPasskey(t *testing.T, parentToken string) (string, string) {
t.Helper()
body, err := common.Marshal(map[string]string{"flow_token": parentToken})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/login/passkey/begin", string(body), "", service.AuthIdentity{}, LoginPasskeyBegin)
var result struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
UserVerification string `json:"userVerification"`
} `json:"publicKey"`
} `json:"options"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
require.Equal(t, "required", result.Data.Options.PublicKey.UserVerification)
return result.Data.FlowToken, result.Data.Options.PublicKey.Challenge
}
func TestSecurityLoginCodeCompletesOnce(t *testing.T) {
for _, test := range []struct {
path string
backup bool
}{{"/api/user/login/verify", false}, {"/api/user/login/2fa", false}, {"/api/user/login/verify", true}} {
t.Run(fmt.Sprintf("%s/backup=%t", test.path, test.backup), func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
require.NoError(t, model.DB.Create(factor).Error)
challenge, err := service.StartLoginVerification(user, "password")
require.NoError(t, err)
require.NotNil(t, challenge)
code, err := totp.GenerateCode(factor.Secret, time.Now())
require.NoError(t, err)
if test.backup {
code = "ABCD-1234"
hash, err := common.HashBackupCode(code)
require.NoError(t, err)
require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error)
}
body, err := common.Marshal(map[string]string{"flow_token": challenge.FlowToken, "code": code})
require.NoError(t, err)
router := gin.New()
router.POST("/api/user/login/verify", VerifyLogin)
router.POST("/api/user/login/2fa", Verify2FALogin)
for attempt := range 2 {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("POST", test.path, strings.NewReader(string(body))))
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, attempt == 0, result.Success, response.Body.String())
if attempt == 0 {
var bundle service.AuthBundle
require.NoError(t, common.Unmarshal(result.Data, &bundle))
assert.NotEmpty(t, bundle.AccessToken)
assert.Equal(t, "password", bundle.Session.LoginMethod)
assert.NotEmpty(t, response.Header().Values("Set-Cookie"))
} else {
assert.Empty(t, response.Header().Values("Set-Cookie"))
}
}
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.EqualValues(t, 2, count)
})
}
}
func TestSecurityLoginRejectsChangedOrExpiredAuthorization(t *testing.T) {
for _, change := range []string{"expired", "disabled user", "auth version", "factor removed", "factor locked", "legacy flow", "wrong method", "other purpose"} {
t.Run(change, func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
require.NoError(t, model.DB.Create(factor).Error)
challenge, err := service.StartLoginVerification(user, "password")
require.NoError(t, err)
method := "2fa"
switch change {
case "expired":
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("expires_at", time.Now().Add(-time.Minute)).Error)
case "disabled user":
require.NoError(t, model.DB.Model(user).Update("status", common.UserStatusDisabled).Error)
case "auth version":
require.NoError(t, model.DB.Model(user).Update("auth_version", user.AuthVersion+1).Error)
case "factor removed":
require.NoError(t, model.DB.Delete(factor).Error)
case "factor locked":
require.NoError(t, model.DB.Model(factor).Update("locked_until", time.Now().Add(time.Minute)).Error)
case "legacy flow":
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("purpose", model.AuthFlowPurposeTwoFALogin).Error)
case "wrong method":
method = "password"
case "other purpose":
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("purpose", model.AuthFlowPurposeSecurityProof).Error)
}
code, err := totp.GenerateCode(factor.Secret, time.Now())
require.NoError(t, err)
body, err := common.Marshal(map[string]string{"flow_token": challenge.FlowToken, "code": code, "method": method})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/login/verify", string(body), "", service.AuthIdentity{}, VerifyLogin)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.False(t, result.Success, change)
assert.Empty(t, response.Header().Values("Set-Cookie"))
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.EqualValues(t, 1, count)
})
}
}
func TestSecurityLoginPasskeyDoesNotRequireAdditionalTwoFA(t *testing.T) {
for _, direct := range []bool{false, true} {
t.Run(fmt.Sprintf("direct=%t", direct), func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
key := newSecurityLoginPasskey(t, user.Id)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
for _, verified := range []bool{false, true} {
var flowToken, challenge, parentToken string
if direct {
response := securityEnrollmentRequest("POST", "/api/user/passkey/login/begin", "", "", service.AuthIdentity{}, PasskeyLoginBegin)
var result struct {
Data struct {
FlowToken string `json:"flow_token"`
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
UserVerification string `json:"userVerification"`
} `json:"publicKey"`
} `json:"options"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.Equal(t, "required", result.Data.Options.PublicKey.UserVerification)
flowToken, challenge = result.Data.FlowToken, result.Data.Options.PublicKey.Challenge
} else {
pending, err := service.StartLoginVerification(user, "oauth:github")
require.NoError(t, err)
parentToken = pending.FlowToken
flowToken, challenge = beginSecurityLoginPasskey(t, parentToken)
}
var assertion map[string]any
require.NoError(t, common.Unmarshal(securityPasskeyResponse(t, key, challenge, false, 0, verified), &assertion))
if direct {
response, ok := assertion["response"].(map[string]any)
require.True(t, ok)
response["userHandle"] = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(user.Id)))
}
payload := map[string]any{"flow_token": flowToken, "credential": assertion}
handler, path := PasskeyLoginFinish, "/api/user/passkey/login/finish"
if !direct {
payload["flow_token"], payload["passkey_flow_token"] = parentToken, flowToken
handler, path = LoginPasskeyFinish, "/api/user/login/passkey/finish"
}
body, err := common.Marshal(payload)
require.NoError(t, err)
router := gin.New()
router.POST(path, handler)
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("POST", path, strings.NewReader(string(body))))
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.Equal(t, verified, result.Success, response.Body.String())
if verified {
var bundle service.AuthBundle
require.NoError(t, common.Unmarshal(result.Data, &bundle))
assert.NotEmpty(t, bundle.AccessToken)
} else {
assert.Empty(t, response.Header().Values("Set-Cookie"))
}
}
})
}
}
func TestSecurityLoginPasskeyConcurrentCompletionCreatesOneSession(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
key := newSecurityLoginPasskey(t, user.Id)
pending, err := service.StartLoginVerification(user, "password")
require.NoError(t, err)
requests := make([]string, 2)
for index := range requests {
token, challenge := beginSecurityLoginPasskey(t, pending.FlowToken)
body, err := common.Marshal(map[string]any{
"flow_token": pending.FlowToken, "passkey_flow_token": token,
"credential": securityPasskeyResponse(t, key, challenge, false, 0),
})
require.NoError(t, err)
requests[index] = string(body)
}
start := make(chan struct{})
responses := make(chan *httptest.ResponseRecorder, 2)
var workers sync.WaitGroup
for _, body := range requests {
workers.Go(func() {
<-start
responses <- securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", body, "", service.AuthIdentity{}, LoginPasskeyFinish)
})
}
close(start)
workers.Wait()
close(responses)
successes := 0
for response := range responses {
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
if result.Success {
successes++
}
}
assert.Equal(t, 1, successes)
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.EqualValues(t, 2, count)
}
func TestSecurityLoginSessionFailureRollsBackChallengeConsumption(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
key := newSecurityLoginPasskey(t, user.Id)
pending, err := service.StartLoginVerification(user, "password")
require.NoError(t, err)
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("login_session_failure", func(tx *gorm.DB) {
if tx.Statement.Table == "user_sessions" {
tx.AddError(errors.New("private session creation failure"))
}
}))
t.Cleanup(func() { _ = model.DB.Callback().Create().Remove("login_session_failure") })
for _, fail := range []bool{true, false} {
token, challenge := beginSecurityLoginPasskey(t, pending.FlowToken)
body, err := common.Marshal(map[string]any{"flow_token": pending.FlowToken, "passkey_flow_token": token, "credential": securityPasskeyResponse(t, key, challenge, false, 0)})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", string(body), "", service.AuthIdentity{}, LoginPasskeyFinish)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, !fail, result.Success, response.Body.String())
if fail {
assert.Empty(t, response.Header().Values("Set-Cookie"))
assert.NotContains(t, response.Body.String(), "private")
_, err = model.GetAuthFlow(pending.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeLoginVerification})
require.NoError(t, err, "a failed session transaction must leave the parent challenge usable")
require.NoError(t, model.DB.Callback().Create().Remove("login_session_failure"))
}
}
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.EqualValues(t, 2, count)
}
func TestSecurityLoginFactorStateDoesNotAddPasswordLoginQueries(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
newSecurityLoginPasskey(t, user.Id)
previousPasswordLogin := common.PasswordLoginEnabled
common.PasswordLoginEnabled = true
t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordLogin })
queries := 0
require.NoError(t, model.DB.Callback().Query().After("gorm:query").Register("login_query_count", func(tx *gorm.DB) {
if !tx.DryRun {
queries++
}
}))
t.Cleanup(func() { _ = model.DB.Callback().Query().Remove("login_query_count") })
state, err := model.GetUserVerificationState(user.Id)
require.NoError(t, err)
assert.True(t, state.HasPassword)
assert.True(t, state.HasPasskey)
assert.False(t, state.HasTwoFA)
assert.Equal(t, 1, queries, "factor availability must be one database round trip")
queries = 0
response := securityEnrollmentRequest("POST", "/api/user/login", `{"username":"enrollment-user","password":"enrollment-password"}`, "", service.AuthIdentity{}, Login)
assert.Contains(t, response.Body.String(), `"require_verification":true`)
assert.Equal(t, 2, queries, "only the existing credential lookup and the replacement factor-state lookup run before the challenge")
}
type boundLoginOAuthProvider struct {
authFlowTestOAuthProvider
userID int
}
func (*boundLoginOAuthProvider) IsUserIDTaken(string) bool { return true }
func (*boundLoginOAuthProvider) ProviderUserIDColumn() string { return "github_id" }
func (provider *boundLoginOAuthProvider) FillUserByProviderID(user *model.User, _ string) error {
return model.DB.First(user, provider.userID).Error
}
func TestSecurityLoginAllPrimaryTransportsRequireAdditionalVerification(t *testing.T) {
for _, transport := range []string{"oauth", "custom oauth", "wechat", "telegram"} {
t.Run(transport, func(t *testing.T) {
var user *model.User
var telegram *telegramOAuthFixture
if transport == "telegram" {
telegram = setupTelegramOAuthTest(t)
user = telegram.user
} else {
user, _ = setupSecurityEnrollmentTest(t)
}
newSecurityLoginPasskey(t, user.Id)
var response *httptest.ResponseRecorder
switch transport {
case "telegram":
require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error)
state, code := telegram.authorization(t, "login", service.AuthIdentity{}, "", telegramIdentityClaims(42))
response = telegramOAuthCallback(state, code, service.AuthIdentity{})
case "wechat":
require.NoError(t, model.DB.Model(user).Update("wechat_id", "bound-wechat").Error)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "wechat-code", r.URL.Query().Get("code"))
_, _ = w.Write([]byte(`{"success":true,"data":"bound-wechat"}`))
}))
t.Cleanup(upstream.Close)
previousEnabled, previousAddress := common.WeChatAuthEnabled, common.WeChatServerAddress
common.WeChatAuthEnabled, common.WeChatServerAddress = true, upstream.URL
t.Cleanup(func() { common.WeChatAuthEnabled, common.WeChatServerAddress = previousEnabled, previousAddress })
response = securityEnrollmentRequest("GET", "/api/oauth/wechat?code=wechat-code", "", "", service.AuthIdentity{}, WeChatAuth)
default:
const slug = "unified-login-test"
if transport == "custom oauth" {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/token" {
_, _ = w.Write([]byte(`{"access_token":"provider-token","token_type":"Bearer"}`))
return
}
_, _ = w.Write([]byte(`{"sub":"bound-custom","name":"User"}`))
}))
t.Cleanup(upstream.Close)
oauth.RegisterCustom(slug, oauth.NewGenericOAuthProvider(&model.CustomOAuthProvider{
Id: 42, Slug: slug, Name: "Custom login", Enabled: true, ClientId: "client", ClientSecret: "secret", UserIdField: "sub",
TokenEndpoint: upstream.URL + "/token", UserInfoEndpoint: upstream.URL + "/userinfo",
}))
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "bound-custom"}).Error)
} else {
oauth.Register(slug, &boundLoginOAuthProvider{userID: user.Id})
}
t.Cleanup(func() { oauth.Unregister(slug) })
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{Purpose: model.AuthFlowPurposeOAuth, Provider: slug, Intent: model.AuthFlowIntentLogin, Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute)})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("GET", "/api/oauth/"+slug+"?state="+token+"&code=provider-code", nil))
}
var result struct {
Success bool `json:"success"`
Data service.LoginChallenge `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.True(t, result.Data.RequireVerification)
assert.Equal(t, []service.VerificationMethodOption{{Method: "passkey", Available: true}}, result.Data.Methods)
assert.NotEmpty(t, result.Data.FlowToken)
assert.Empty(t, response.Header().Values("Set-Cookie"))
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.EqualValues(t, 1, count)
})
}
}
func TestSecurityLoginPasskeyCannotCompleteAnotherChallenge(t *testing.T) {
for _, otherUser := range []bool{false, true} {
t.Run(fmt.Sprintf("other-user=%t", otherUser), func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
key := newSecurityLoginPasskey(t, user.Id)
first, err := service.StartLoginVerification(user, "password")
require.NoError(t, err)
passkeyToken, challenge := beginSecurityLoginPasskey(t, first.FlowToken)
if otherUser {
user = &model.User{Username: "other-login", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "other-login", AuthVersion: 1}
require.NoError(t, model.DB.Create(user).Error)
newSecurityLoginPasskey(t, user.Id)
}
second, err := service.StartLoginVerification(user, "password")
require.NoError(t, err)
body, err := common.Marshal(map[string]any{"flow_token": second.FlowToken, "passkey_flow_token": passkeyToken, "credential": securityPasskeyResponse(t, key, challenge, false, 0)})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", string(body), "", service.AuthIdentity{}, LoginPasskeyFinish)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.False(t, result.Success)
assert.Empty(t, response.Header().Values("Set-Cookie"))
var count int64
require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error)
assert.EqualValues(t, 1, count)
})
}
}
func TestSecurityLoginPasskeyCanManageTwoFAWithScopedProofs(t *testing.T) {
for _, scope := range []string{service.VerificationScopeTwoFADisable, service.VerificationScopeTwoFABackupCodes} {
t.Run(scope, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
key := newSecurityLoginPasskey(t, user.Id)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
hash, err := common.HashBackupCode("ABCD-1234")
require.NoError(t, err)
require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error)
if scope == service.VerificationScopeTwoFABackupCodes {
_, err := service.VerifySecurityInput(identity, service.VerificationInput{Scope: scope, Method: "2fa", Code: "ABCD-1234"})
assert.ErrorIs(t, err, service.ErrVerificationFailed)
remaining, err := model.GetUnusedBackupCodeCount(user.Id)
require.NoError(t, err)
assert.Equal(t, 1, remaining, "backup codes cannot authorize generating replacement backup codes")
}
beginBody, err := common.Marshal(map[string]string{"scope": scope})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/passkey/verify/begin", string(beginBody), "", identity, PasskeyVerifyBegin)
var started struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
UserVerification string `json:"userVerification"`
} `json:"publicKey"`
} `json:"options"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &started))
require.True(t, started.Success, response.Body.String())
assert.Equal(t, "required", started.Data.Options.PublicKey.UserVerification)
finish, err := common.Marshal(passkeyFinishRequest{FlowToken: started.Data.FlowToken, Credential: securityPasskeyResponse(t, key, started.Data.Options.PublicKey.Challenge, false, 0)})
require.NoError(t, err)
response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/finish", string(finish), "", identity, PasskeyVerifyFinish)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
var proof service.SecurityProof
require.NoError(t, common.Unmarshal(result.Data, &proof))
handler, path := Disable2FA, "/api/user/2fa/disable"
if scope == service.VerificationScopeTwoFABackupCodes {
handler, path = RegenerateBackupCodes, "/api/user/2fa/backup_codes"
response = securityEnrollmentRequest("POST", "/api/user/2fa/disable", `{}`, proof.ProofToken, identity, Disable2FA)
assert.Contains(t, response.Body.String(), "SECURITY_PROOF_SCOPE_MISMATCH")
}
response = securityEnrollmentRequest("POST", path, `{}`, proof.ProofToken, identity, handler)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, identity.UserAuthVersion+1, stored.AuthVersion)
_, err = model.GetPasskeyByUserID(user.Id)
require.NoError(t, err, "managing 2FA must retain the alternative Passkey factor")
factor, err := model.GetTwoFAByUserId(user.Id)
require.NoError(t, err)
if scope == service.VerificationScopeTwoFADisable {
assert.Nil(t, factor)
} else {
require.NotNil(t, factor)
assert.True(t, factor.IsEnabled)
assert.Contains(t, string(result.Data), "backup_codes")
}
response = securityEnrollmentRequest("POST", path, `{}`, proof.ProofToken, identity, handler)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.False(t, result.Success, "the proof cannot authorize a second mutation")
})
}
}
func TestSecurityLoginRegisteredPasskeyRequiresUserVerification(t *testing.T) {
for _, verified := range []bool{false, true} {
t.Run(fmt.Sprintf("verified=%t", verified), func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister}, service.VerificationMethodPassword)
response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
var result struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
AuthenticatorSelection struct {
UserVerification string `json:"userVerification"`
} `json:"authenticatorSelection"`
} `json:"publicKey"`
} `json:"options"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.Equal(t, "required", result.Data.Options.PublicKey.AuthenticatorSelection.UserVerification)
body, err := common.Marshal(passkeyFinishRequest{FlowToken: result.Data.FlowToken, Credential: securityPasskeyResponse(t, key, result.Data.Options.PublicKey.Challenge, true, 0, verified)})
require.NoError(t, err)
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish)
var finished securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &finished))
assert.Equal(t, verified, finished.Success, response.Body.String())
_, err = model.GetPasskeyByUserID(user.Id)
if verified {
require.NoError(t, err)
} else {
assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
}
})
}
}
func TestSecurityLoginRequiresConfiguredFactors(t *testing.T) {
for _, test := range []struct {
name string
twoFA, passkey bool
locked, disabled bool
methods []service.VerificationMethodOption
unavailable bool
}{
{name: "password without additional factors"},
{name: "passkey requires verification", passkey: true, methods: []service.VerificationMethodOption{{Method: "passkey", Available: true}}},
{name: "twofa requires verification", twoFA: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}}},
{name: "both factors are alternatives", twoFA: true, passkey: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}, {Method: "passkey", Available: true}}},
{name: "locked twofa permits passkey", twoFA: true, passkey: true, locked: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: false, Reason: service.ErrVerificationLocked.Error()}, {Method: "passkey", Available: true}}},
{name: "disabled passkey permits twofa", twoFA: true, passkey: true, disabled: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}, {Method: "passkey", Available: false, Reason: "Passkey authentication is disabled."}}},
{name: "only passkey disabled blocks password", passkey: true, disabled: true, unavailable: true},
{name: "both factors unavailable block password", passkey: true, twoFA: true, disabled: true, locked: true, unavailable: true},
} {
t.Run(test.name, func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
previousPasswordLogin := common.PasswordLoginEnabled
common.PasswordLoginEnabled = true
t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordLogin })
if test.passkey {
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "login-key", PublicKey: "public-key"}).Error)
}
if test.twoFA {
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
if test.locked {
until := time.Now().Add(time.Minute)
factor.LockedUntil = &until
}
require.NoError(t, model.DB.Create(factor).Error)
}
system_setting.GetPasskeySettings().Enabled = !test.disabled
before, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
response := securityEnrollmentRequest(http.MethodPost, "/api/user/login", `{"username":"enrollment-user","password":"enrollment-password"}`, "", service.AuthIdentity{}, Login)
var result struct {
Success bool `json:"success"`
Data struct {
RequireVerification bool `json:"require_verification"`
FlowToken string `json:"flow_token"`
ExpiresAt int64 `json:"expires_at"`
AccessToken string `json:"access_token"`
Methods []service.VerificationMethodOption `json:"methods"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
after, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
if test.unavailable {
assert.False(t, result.Success)
assert.Equal(t, before, after)
assert.Empty(t, response.Header().Values("Set-Cookie"))
return
}
require.True(t, result.Success, response.Body.String())
if len(test.methods) == 0 {
assert.False(t, result.Data.RequireVerification)
assert.NotEmpty(t, result.Data.AccessToken)
assert.Equal(t, before+1, after)
return
}
assert.True(t, result.Data.RequireVerification)
assert.NotEmpty(t, result.Data.FlowToken)
assert.Greater(t, result.Data.ExpiresAt, time.Now().Unix())
assert.LessOrEqual(t, result.Data.ExpiresAt, time.Now().Add(5*time.Minute).Unix())
assert.Equal(t, test.methods, result.Data.Methods)
assert.Empty(t, result.Data.AccessToken)
assert.Empty(t, response.Header().Values("Set-Cookie"))
assert.Equal(t, before, after, "a pending challenge must not create a session")
})
}
}
type authFlowTestOAuthProvider struct {
exchangeErr error
userInfoErr error
exchangeCalls int
userInfoCalls int
}
func (*authFlowTestOAuthProvider) GetName() string { return "Auth Flow Test" }
func (*authFlowTestOAuthProvider) IsEnabled() bool { return true }
func (provider *authFlowTestOAuthProvider) ExchangeToken(context.Context, string, *gin.Context) (*oauth.OAuthToken, error) {
provider.exchangeCalls++
if provider.exchangeErr != nil {
return nil, provider.exchangeErr
}
return &oauth.OAuthToken{}, nil
}
func (provider *authFlowTestOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) {
provider.userInfoCalls++
if provider.userInfoErr != nil {
return nil, provider.userInfoErr
}
return &oauth.OAuthUser{ProviderUserID: "external-user"}, nil
}
func (*authFlowTestOAuthProvider) IsUserIDTaken(string) bool { return false }
func (*authFlowTestOAuthProvider) FillUserByProviderID(*model.User, string) error { return nil }
func (*authFlowTestOAuthProvider) SetProviderUserID(*model.User, string) {}
func (*authFlowTestOAuthProvider) GetProviderPrefix() string { return "flow_" }
func (*authFlowTestOAuthProvider) ProviderUserIDColumn() string { return "" }
func setupAuthFlowControllerTest(t *testing.T) *authFlowTestOAuthProvider {
t.Helper()
previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedis := common.RedisEnabled
common.RedisEnabled = false
previousType := common.MainDatabaseType()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.AuthFlow{}, &model.User{}, &model.UserSession{}, &model.AuditLog{}))
model.DB, model.LOG_DB = db, db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
provider := &authFlowTestOAuthProvider{}
oauth.Register("auth-flow-test", provider)
t.Cleanup(func() {
oauth.Unregister("auth-flow-test")
model.DB, model.LOG_DB = previousDB, previousLogDB
common.RedisEnabled = previousRedis
common.SetMainDatabaseType(previousType)
})
return provider
}
func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) {
setupAuthFlowControllerTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"login","aff":"invite-code"}`))
c.Request.Header.Set("Content-Type", "application/json")
GenerateOAuthCode(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
})
require.NoError(t, err)
var payload oauthFlowPayload
require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload))
assert.Equal(t, "invite-code", payload.AffiliateCode)
assert.Zero(t, flow.UserId)
assert.Empty(t, flow.SessionId)
}
func TestGenerateOAuthCodeBindsFlowToAuthenticatedSession(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
oauth.Register("auth-flow-test", &authFlowTestOAuthProvider{})
t.Cleanup(func() { oauth.Unregister("auth-flow-test") })
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"auth-flow-test"}`)}, service.VerificationMethodPassword)
response := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode)
var result struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
flow, err := model.GetAuthFlow(result.Data.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind, UserId: identity.UserID, SessionId: identity.SessionID})
require.NoError(t, err)
assert.Equal(t, identity.UserID, flow.UserId)
assert.Equal(t, identity.SessionID, flow.SessionId)
}
func TestOAuthLoginConsumesFlowOnlyAfterProviderIdentity(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
tests := []struct {
name string
exchangeErr error
userInfoErr error
}{
{name: "exchange failure", exchangeErr: errors.New("exchange failed")},
{name: "user info failure", userInfoErr: errors.New("user info failed")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
provider.exchangeErr = test.exchangeErr
provider.userInfoErr = test.userInfoErr
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+token+"&code=test", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
})
}
}
func TestOAuthLoginConsumesFlowAfterProviderIdentityAndOnProviderError(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
provider.exchangeErr = nil
provider.userInfoErr = nil
successToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{invalid`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+successToken+"&code=test", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
_, err = model.GetAuthFlow(successToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Equal(t, 1, provider.exchangeCalls)
assert.Equal(t, 1, provider.userInfoCalls)
providerErrorToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
request = httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+providerErrorToken+"&error=access_denied", nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
_, err = model.GetAuthFlow(providerErrorToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Equal(t, 1, provider.exchangeCalls)
assert.Equal(t, 1, provider.userInfoCalls)
}
func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
provider := &authFlowTestOAuthProvider{}
oauth.Register("auth-flow-test", provider)
t.Cleanup(func() { oauth.Unregister("auth-flow-test") })
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"auth-flow-test"}`)}, service.VerificationMethodPassword)
started := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode)
var result struct {
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(started.Body.Bytes(), &result))
require.NotEmpty(t, result.Data.FlowToken)
response := securityEnrollmentRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+result.Data.FlowToken+"&error=access_denied&error_description=cancelled", "", "", identity, func(c *gin.Context) {
c.Params = gin.Params{{Key: "provider", Value: "auth-flow-test"}}
HandleOAuth(c)
})
assert.Equal(t, http.StatusOK, response.Code)
_, err := model.GetAuthFlow(result.Data.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Zero(t, provider.exchangeCalls)
assert.Zero(t, provider.userInfoCalls)
}
+197
View File
@@ -0,0 +1,197 @@
package controller
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RefreshAuth(c *gin.Context) {
setAuthNoStore(c)
rawRefreshToken, err := c.Cookie(service.RefreshCookieName)
if err != nil || rawRefreshToken == "" {
service.ClearRefreshCookie(c)
writeAuthSessionError(c, service.ErrRefreshTokenInvalid)
return
}
bundle, user, err := service.RefreshLoginSession(rawRefreshToken, c.GetHeader("X-Auth-Session"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, service.ErrRefreshTokenInvalid) || errors.Is(err, service.ErrLoginSessionRevoked) {
service.ClearRefreshCookie(c)
}
writeAuthSessionError(c, err)
return
}
service.WriteRefreshCookie(c, bundle.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"user": buildSelfUserData(user),
"session": bundle.Session,
},
})
}
func AuthLogout(c *gin.Context) {
setAuthNoStore(c)
expectedSID := strings.TrimSpace(c.GetHeader("X-Auth-Session"))
rawRefreshToken, cookieErr := c.Cookie(service.RefreshCookieName)
cookieSID, hasCookieSID := service.RefreshTokenSID(rawRefreshToken)
if expectedSID != "" && cookieErr == nil && hasCookieSID && cookieSID != expectedSID {
writeAuthSessionError(c, service.ErrLoginSessionMismatch)
return
}
if rawAccessToken, ok := dashboardBearer(c.GetHeader("Authorization")); ok {
if identity, err := service.ParseAccessToken(rawAccessToken); err == nil {
if expectedSID != "" && expectedSID != identity.SessionID {
writeAuthSessionError(c, service.ErrLoginSessionMismatch)
return
}
if _, err := model.RevokeUserSession(identity.UserID, identity.SessionID, "logout"); err != nil {
writeAuthSessionError(c, err)
return
}
cookieCleared := false
if cookieErr == nil && hasCookieSID && cookieSID == identity.SessionID {
if err := service.RevokeByRefreshToken(rawRefreshToken, identity.SessionID, "logout"); err != nil {
writeAuthSessionError(c, err)
return
}
service.ClearRefreshCookie(c)
cookieCleared = true
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{"revoked_sid": identity.SessionID, "cookie_cleared": cookieCleared},
})
return
}
}
if cookieErr != nil || rawRefreshToken == "" {
service.ClearRefreshCookie(c)
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
return
}
if err := service.RevokeByRefreshToken(rawRefreshToken, expectedSID, "logout"); err != nil {
writeAuthSessionError(c, err)
return
}
service.ClearRefreshCookie(c)
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
}
func GetLoginSessions(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
sessions, err := service.ListLoginSessions(identity.UserID, identity.SessionID)
if err != nil {
writeAuthSessionError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": sessions})
}
func DeleteLoginSession(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
sid := strings.TrimSpace(c.Param("sid"))
if sid == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "AUTH_SESSION_ID_REQUIRED", "message": "session id is required"})
return
}
revoked, err := model.RevokeUserSession(identity.UserID, sid, "user_revoked")
if err != nil {
writeAuthSessionError(c, err)
return
}
if !revoked {
c.JSON(http.StatusNotFound, gin.H{"success": false, "code": "AUTH_SESSION_NOT_FOUND", "message": "session not found"})
return
}
if rawRefreshToken, cookieErr := c.Cookie(service.RefreshCookieName); cookieErr == nil {
cookieSID, ok := service.RefreshTokenSID(rawRefreshToken)
if ok && cookieSID == sid {
service.ClearRefreshCookie(c)
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"revoked_sid": sid, "current": sid == identity.SessionID}})
}
func RevokeOtherLoginSessions(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
count, err := model.RevokeOtherUserSessions(identity.UserID, identity.SessionID, "user_revoked_others")
if err != nil {
writeAuthSessionError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"revoked_count": count}})
}
func requireBrowserSession(c *gin.Context) (service.AuthIdentity, bool) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"code": "AUTH_SESSION_REQUIRED",
"message": "a dashboard login session is required",
})
return service.AuthIdentity{}, false
}
return identity, true
}
func writeAuthSessionError(c *gin.Context, err error) {
status, code := service.AuthSessionErrorCode(err)
if errors.Is(err, gorm.ErrRecordNotFound) {
status, code = http.StatusUnauthorized, "AUTH_UNAUTHORIZED"
}
if status == http.StatusInternalServerError {
// The response body only carries the generic AUTH_INTERNAL_ERROR
// code; without this log the underlying Redis/database/session
// failure is indistinguishable from the client side.
logger.LogError(c.Request.Context(), fmt.Sprintf("auth session internal error (%s %s): %v", c.Request.Method, c.Request.URL.Path, err))
}
c.JSON(status, gin.H{"success": false, "code": code, "message": http.StatusText(status)})
}
func setAuthNoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
}
func authRotationData(bundle *service.AuthBundle) gin.H {
return gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
}
}
func dashboardBearer(header string) (string, bool) {
parts := strings.Fields(header)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
return "", false
}
return parts[1], true
}
+155
View File
@@ -0,0 +1,155 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestAuthLogoutRejectsRefreshCookieSessionMismatch(t *testing.T) {
previousDB := model.DB
previousRedis := common.RedisEnabled
previousSecret := common.SessionSecret
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}))
model.DB = db
common.RedisEnabled = false
common.SessionSecret = "auth-logout-mismatch-test-secret"
t.Cleanup(func() {
model.DB = previousDB
common.RedisEnabled = previousRedis
common.SessionSecret = previousSecret
})
user := &model.User{
Username: "logout-mismatch-user", Password: "unused", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(user).Error)
sessionA, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "agent-a")
require.NoError(t, err)
sessionB, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "agent-b")
require.NoError(t, err)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/auth/logout", nil)
c.Request.Header.Set("Authorization", "Bearer "+sessionA.AccessToken)
c.Request.Header.Set("X-Auth-Session", sessionA.Session.SID)
c.Request.AddCookie(&http.Cookie{Name: service.RefreshCookieName, Value: sessionB.RefreshToken})
AuthLogout(c)
assert.Equal(t, http.StatusConflict, recorder.Code)
var response struct {
Success bool `json:"success"`
Code string `json:"code"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
assert.Equal(t, "AUTH_SESSION_MISMATCH", response.Code)
for _, sid := range []string{sessionA.Session.SID, sessionB.Session.SID} {
stored, err := model.GetUserSessionBySID(sid)
require.NoError(t, err)
assert.Equal(t, model.UserSessionStatusActive, stored.Status)
}
}
func TestWriteAuthSessionErrorMapsSessionGrowthLimits(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
err error
expectedStatus int
expectedCode string
}{
{
name: "active session limit",
err: model.ErrUserSessionLimit,
expectedStatus: http.StatusConflict,
expectedCode: "AUTH_SESSION_LIMIT",
},
{
name: "issuance limit",
err: model.ErrUserSessionIssuanceLimit,
expectedStatus: http.StatusTooManyRequests,
expectedCode: "AUTH_SESSION_ISSUANCE_LIMIT",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
writeAuthSessionError(c, test.err)
assert.Equal(t, test.expectedStatus, recorder.Code)
var response struct {
Success bool `json:"success"`
Code string `json:"code"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
assert.Equal(t, test.expectedCode, response.Code)
})
}
}
func TestSessionLimitDoesNotRecordRejectedLoginAsSuccessful(t *testing.T) {
previousDB := model.DB
previousRedis := common.RedisEnabled
previousActiveLimit := common.UserSessionActiveLimit
previousIssuanceLimit := common.UserSessionIssuanceLimit
previousIssuanceWindow := common.UserSessionIssuanceWindowSeconds
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.PasskeyCredential{}))
model.DB = db
common.RedisEnabled = false
common.UserSessionActiveLimit = 1
common.UserSessionIssuanceLimit = 100
common.UserSessionIssuanceWindowSeconds = int64(common.DefaultUserSessionIssuanceWindowSeconds)
t.Cleanup(func() {
model.DB = previousDB
common.RedisEnabled = previousRedis
common.UserSessionActiveLimit = previousActiveLimit
common.UserSessionIssuanceLimit = previousIssuanceLimit
common.UserSessionIssuanceWindowSeconds = previousIssuanceWindow
})
const previousLastLoginAt = int64(123)
user := &model.User{
Username: "rejected-login-audit-user", Password: "unused", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, LastLoginAt: previousLastLoginAt,
}
require.NoError(t, db.Create(user).Error)
now := time.Now().Unix()
require.NoError(t, db.Create(&model.UserSession{
SID: "existing-active-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password",
CreatedAt: now, LastActiveAt: now, ExpiresAt: now + 3600,
}).Error)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/login", nil)
setupLogin(user, c)
assert.Equal(t, http.StatusConflict, recorder.Code)
var stored model.User
require.NoError(t, db.First(&stored, user.Id).Error)
assert.Equal(t, previousLastLoginAt, stored.LastLoginAt)
}
+24
View File
@@ -0,0 +1,24 @@
package controller
import (
"net/http"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
)
// GetPermissionCatalog returns the permission schema used by the client to
// render the permission editor: the registry of resources with their actions
// and display label keys, plus the roles with their baseline grant matrices.
// Defining it in the authz package keeps the schema in a single place.
func GetPermissionCatalog(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"resources": authz.Catalog(),
"roles": authz.Roles(),
},
})
}
+108
View File
@@ -0,0 +1,108 @@
package controller
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
func GetSubscription(c *gin.Context) {
var remainQuota int
var usedQuota int
var err error
var token *model.Token
var expiredTime int64
if common.DisplayTokenStatEnabled {
tokenId := c.GetInt("token_id")
token, err = model.GetTokenById(tokenId)
expiredTime = token.ExpiredTime
remainQuota = token.RemainQuota
usedQuota = token.UsedQuota
} else {
userId := c.GetInt("id")
remainQuota, err = model.GetUserQuota(userId, false)
usedQuota, err = model.GetUserUsedQuota(userId)
}
if expiredTime <= 0 {
expiredTime = 0
}
if err != nil {
openAIError := types.OpenAIError{
Message: err.Error(),
Type: "upstream_error",
}
c.JSON(200, gin.H{
"error": openAIError,
})
return
}
quota := remainQuota + usedQuota
amount := float64(quota)
// OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
// 我们将其解释为以“站点展示类型”为准:
// - USD: 直接除以 QuotaPerUnit
// - CNY: 先转 USD 再乘汇率
// - TOKENS: 直接使用 tokens 数量
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// amount 保持 tokens 数值
default:
amount = amount / common.QuotaPerUnit
}
if token != nil && token.UnlimitedQuota {
amount = 100000000
}
subscription := OpenAISubscriptionResponse{
Object: "billing_subscription",
HasPaymentMethod: true,
SoftLimitUSD: amount,
HardLimitUSD: amount,
SystemHardLimitUSD: amount,
AccessUntil: expiredTime,
}
c.JSON(200, subscription)
return
}
func GetUsage(c *gin.Context) {
var quota int
var err error
var token *model.Token
if common.DisplayTokenStatEnabled {
tokenId := c.GetInt("token_id")
token, err = model.GetTokenById(tokenId)
quota = token.UsedQuota
} else {
userId := c.GetInt("id")
quota, err = model.GetUserUsedQuota(userId)
}
if err != nil {
openAIError := types.OpenAIError{
Message: err.Error(),
Type: "new_api_error",
}
c.JSON(200, gin.H{
"error": openAIError,
})
return
}
amount := float64(quota)
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// tokens 保持原值
default:
amount = amount / common.QuotaPerUnit
}
usage := OpenAIUsageResponse{
Object: "list",
TotalUsage: amount * 100,
}
c.JSON(200, usage)
return
}
+203
View File
@@ -0,0 +1,203 @@
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/setting/config"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestUpdateOptionRejectsInvalidTaskBillingExpressions(t *testing.T) {
const pluginKey = "billing-save-probe"
const modelName = "billing-save-model"
source := `
export const meta = {
apiVersion: 1, key: "billing-save-probe", name: "Billing Save Probe", version: "1.0.0", author: {name: "Test"},
models: ["billing-save-model"], fetchMode: "per_task",
usageSchema: {seconds: {type: "number", unit: "second"}}
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(pluginKey) })
tests := []struct {
name string
expression string
errorText string
}{
{
name: "invalid syntax",
expression: `tier("base",`,
errorText: "expr compile error",
},
{
name: "undeclared usage key",
expression: `tier("base", u("clips") * 0.1)`,
errorText: `usage key \"clips\" is not declared`,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
expressions, marshalErr := common.Marshal(map[string]string{modelName: testCase.expression})
require.NoError(t, marshalErr)
body, marshalErr := common.Marshal(OptionUpdateRequest{
Key: "billing_setting.billing_expr",
Value: string(expressions),
})
require.NoError(t, marshalErr)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(string(body)))
UpdateOption(context)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), modelName)
assert.Contains(t, recorder.Body.String(), testCase.errorText)
})
}
}
func TestUpdateOptionRejectsUsageExpressionWithoutTaskPlugin(t *testing.T) {
const modelName = "billing-save-model-without-plugin"
expressions, err := common.Marshal(map[string]string{
modelName: `u("mode") == "std" ? 1 : 2`,
})
require.NoError(t, err)
body, err := common.Marshal(OptionUpdateRequest{
Key: "billing_setting.billing_expr",
Value: string(expressions),
})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(
http.MethodPut,
"/api/option/",
strings.NewReader(string(body)),
)
UpdateOption(context)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), modelName)
assert.Contains(t, recorder.Body.String(), "mode")
assert.Contains(t, recorder.Body.String(), "no task plugin usage schema")
}
func setupBillingAliasOptionDB(t *testing.T) {
t.Helper()
previousDB := model.DB
previousLogDB := model.LOG_DB
previousType := common.MainDatabaseType()
previousCache := common.MemoryCacheEnabled
previousMap := common.OptionMap
previousRedis := common.RedisEnabled
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Option{}, &model.Log{}, &model.AuditLog{}, &model.User{}))
model.DB = database
model.LOG_DB = database
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.MemoryCacheEnabled = false
common.RedisEnabled = false
common.OptionMap = map[string]string{}
t.Cleanup(func() {
model.DB = previousDB
model.LOG_DB = previousLogDB
common.SetMainDatabaseType(previousType)
common.MemoryCacheEnabled = previousCache
common.OptionMap = previousMap
common.RedisEnabled = previousRedis
model.InitChannelCache()
})
}
func TestUpdateOptionAliasBillingExprUsesPluginSchema(t *testing.T) {
setupBillingAliasOptionDB(t)
const pluginKey = "billing-alias-probe"
source := `
export const meta = {
apiVersion: 1, key: "billing-alias-probe", name: "Billing Alias Probe", version: "1.0.0", author: {name: "Test"},
models: ["declared-model"], fetchMode: "per_task",
usageSchema: {seconds: {type: "number", unit: "second"}}
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(pluginKey) })
mapping := `{"alias-model":"declared-model"}`
require.NoError(t, model.DB.Create(&model.Channel{
Id: 1,
Type: 54,
Key: "key-1",
Status: common.ChannelStatusEnabled,
Name: "ch-1",
Group: "default",
Models: "alias-model,declared-model",
ModelMapping: &mapping,
}).Error)
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))
})
putExpr := func(modelName, expression string) *httptest.ResponseRecorder {
t.Helper()
expressions, marshalErr := common.Marshal(map[string]string{modelName: expression})
require.NoError(t, marshalErr)
body, marshalErr := common.Marshal(OptionUpdateRequest{
Key: "billing_setting.billing_expr",
Value: string(expressions),
})
require.NoError(t, marshalErr)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(string(body)))
UpdateOption(context)
return recorder
}
accepted := putExpr("alias-model", `u("seconds")`)
assert.Equal(t, http.StatusOK, accepted.Code)
assert.Contains(t, accepted.Body.String(), `"success":true`)
rejectedKey := putExpr("alias-model", `u("clips")`)
assert.Equal(t, http.StatusOK, rejectedKey.Code)
assert.Contains(t, rejectedKey.Body.String(), `"success":false`)
assert.Contains(t, rejectedKey.Body.String(), `usage key \"clips\" is not declared`)
unresolvable := putExpr("unknown-alias-model", `u("seconds")`)
assert.Equal(t, http.StatusOK, unresolvable.Code)
assert.Contains(t, unresolvable.Body.String(), `"success":false`)
assert.Contains(t, unresolvable.Body.String(), "no task plugin usage schema")
}
+620
View File
@@ -0,0 +1,620 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/shopspring/decimal"
"github.com/gin-gonic/gin"
)
// https://github.com/songquanpeng/one-api/issues/79
type OpenAISubscriptionResponse struct {
Object string `json:"object"`
HasPaymentMethod bool `json:"has_payment_method"`
SoftLimitUSD float64 `json:"soft_limit_usd"`
HardLimitUSD float64 `json:"hard_limit_usd"`
SystemHardLimitUSD float64 `json:"system_hard_limit_usd"`
AccessUntil int64 `json:"access_until"`
}
type OpenAIUsageDailyCost struct {
Timestamp float64 `json:"timestamp"`
LineItems []struct {
Name string `json:"name"`
Cost float64 `json:"cost"`
}
}
type OpenAICreditGrants struct {
Object string `json:"object"`
TotalGranted float64 `json:"total_granted"`
TotalUsed float64 `json:"total_used"`
TotalAvailable float64 `json:"total_available"`
}
const maxAdvancedCustomBalanceResponseBytes = 256 << 10
type channelBalanceResult struct {
Balance float64
RawResponse string
}
type OpenAIUsageResponse struct {
Object string `json:"object"`
//DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"`
TotalUsage float64 `json:"total_usage"` // unit: 0.01 dollar
}
type OpenAISBUsageResponse struct {
Msg string `json:"msg"`
Data *struct {
Credit string `json:"credit"`
} `json:"data"`
}
type AIProxyUserOverviewResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
ErrorCode int `json:"error_code"`
Data struct {
TotalPoints float64 `json:"totalPoints"`
} `json:"data"`
}
type API2GPTUsageResponse struct {
Object string `json:"object"`
TotalGranted float64 `json:"total_granted"`
TotalUsed float64 `json:"total_used"`
TotalRemaining float64 `json:"total_remaining"`
}
type APGC2DGPTUsageResponse struct {
//Grants interface{} `json:"grants"`
Object string `json:"object"`
TotalAvailable float64 `json:"total_available"`
TotalGranted float64 `json:"total_granted"`
TotalUsed float64 `json:"total_used"`
}
type SiliconFlowUsageResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Status bool `json:"status"`
Data struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
Balance string `json:"balance"`
Status string `json:"status"`
Introduction string `json:"introduction"`
Role string `json:"role"`
ChargeBalance string `json:"chargeBalance"`
TotalBalance string `json:"totalBalance"`
Category string `json:"category"`
} `json:"data"`
}
type DeepSeekUsageResponse struct {
IsAvailable bool `json:"is_available"`
BalanceInfos []struct {
Currency string `json:"currency"`
TotalBalance string `json:"total_balance"`
GrantedBalance string `json:"granted_balance"`
ToppedUpBalance string `json:"topped_up_balance"`
} `json:"balance_infos"`
}
type OpenRouterCreditResponse struct {
Data struct {
TotalCredits float64 `json:"total_credits"`
TotalUsage float64 `json:"total_usage"`
} `json:"data"`
}
// GetAuthHeader get auth header
func GetAuthHeader(token string) http.Header {
h := http.Header{}
h.Add("Authorization", fmt.Sprintf("Bearer %s", token))
return h
}
// GetClaudeAuthHeader get claude auth header
func GetClaudeAuthHeader(token string) http.Header {
h := http.Header{}
h.Add("x-api-key", token)
h.Add("anthropic-version", "2023-06-01")
return h
}
func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, err
}
for k := range headers {
req.Header.Add(k, headers.Get(k))
}
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy)
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status code: %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
err = res.Body.Close()
if err != nil {
return nil, err
}
return body, nil
}
func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) {
url := fmt.Sprintf("%s/dashboard/billing/credit_grants", channel.GetBaseURL())
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := OpenAICreditGrants{}
err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
channel.UpdateBalance(response.TotalAvailable)
return response.TotalAvailable, nil
}
func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) {
url := fmt.Sprintf("https://api.openai-sb.com/sb-api/user/status?api_key=%s", channel.Key)
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := OpenAISBUsageResponse{}
err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
if response.Data == nil {
return 0, errors.New(response.Msg)
}
balance, err := strconv.ParseFloat(response.Data.Credit, 64)
if err != nil {
return 0, err
}
channel.UpdateBalance(balance)
return balance, nil
}
func updateChannelAIProxyBalance(channel *model.Channel) (float64, error) {
url := "https://aiproxy.io/api/report/getUserOverview"
headers := http.Header{}
headers.Add("Api-Key", channel.Key)
body, err := GetResponseBody("GET", url, channel, headers)
if err != nil {
return 0, err
}
response := AIProxyUserOverviewResponse{}
err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
if !response.Success {
return 0, fmt.Errorf("code: %d, message: %s", response.ErrorCode, response.Message)
}
channel.UpdateBalance(response.Data.TotalPoints)
return response.Data.TotalPoints, nil
}
func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) {
url := "https://api.api2gpt.com/dashboard/billing/credit_grants"
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := API2GPTUsageResponse{}
err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
channel.UpdateBalance(response.TotalRemaining)
return response.TotalRemaining, nil
}
func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) {
url := "https://api.siliconflow.cn/v1/user/info"
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := SiliconFlowUsageResponse{}
err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
if response.Code != 20000 {
return 0, fmt.Errorf("code: %d, message: %s", response.Code, response.Message)
}
balance, err := strconv.ParseFloat(response.Data.TotalBalance, 64)
if err != nil {
return 0, err
}
channel.UpdateBalance(balance)
return balance, nil
}
func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) {
url := "https://api.deepseek.com/user/balance"
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := DeepSeekUsageResponse{}
err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
index := -1
for i, balanceInfo := range response.BalanceInfos {
if balanceInfo.Currency == "CNY" {
index = i
break
}
}
if index == -1 {
return 0, errors.New("currency CNY not found")
}
balance, err := strconv.ParseFloat(response.BalanceInfos[index].TotalBalance, 64)
if err != nil {
return 0, err
}
channel.UpdateBalance(balance)
return balance, nil
}
func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) {
url := "https://api.aigc2d.com/dashboard/billing/credit_grants"
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := APGC2DGPTUsageResponse{}
err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
channel.UpdateBalance(response.TotalAvailable)
return response.TotalAvailable, nil
}
func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) {
url := "https://openrouter.ai/api/v1/credits"
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := OpenRouterCreditResponse{}
err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
balance := response.Data.TotalCredits - response.Data.TotalUsage
channel.UpdateBalance(balance)
return balance, nil
}
func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
url := "https://api.moonshot.cn/v1/users/me/balance"
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
type MoonshotBalanceData struct {
AvailableBalance float64 `json:"available_balance"`
VoucherBalance float64 `json:"voucher_balance"`
CashBalance float64 `json:"cash_balance"`
}
type MoonshotBalanceResponse struct {
Code int `json:"code"`
Data MoonshotBalanceData `json:"data"`
Scode string `json:"scode"`
Status bool `json:"status"`
}
response := MoonshotBalanceResponse{}
err = common.Unmarshal(body, &response)
if err != nil {
return 0, err
}
if !response.Status || response.Code != 0 {
return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode)
}
availableBalanceCny := response.Data.AvailableBalance
availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64()
channel.UpdateBalance(availableBalanceUsd)
return availableBalanceUsd, nil
}
func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, error) {
key := strings.TrimSpace(channel.Key)
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,
RelayMode: relayconstant.RelayModeUnknown,
RequestURLPath: dto.AdvancedCustomBalancePath,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeAdvancedCustom,
ChannelBaseUrl: channel.GetBaseURL(),
ApiKey: key,
ChannelOtherSettings: channel.GetOtherSettings(),
},
}
requestURL, headers, err := (&advancedcustom.Adaptor{}).BuildBalanceRequest(info)
if err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
}
if err := applyFetchModelsHeaderOverrides(channel, key, headers); err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
}
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
if err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
}
for name, values := range headers {
for _, value := range values {
request.Header.Add(name, value)
}
if strings.EqualFold(name, "Host") {
request.Host = headers.Get(name)
}
}
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy)
if err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
}
response, err := client.Do(request)
if err != nil {
return channelBalanceResult{}, sanitizeAdvancedCustomRequestError(err, key, requestURL)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return channelBalanceResult{}, fmt.Errorf("status code: %d", response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxAdvancedCustomBalanceResponseBytes+1))
if err != nil {
return channelBalanceResult{}, sanitizeAdvancedCustomRequestError(err, key, requestURL)
}
if len(body) > maxAdvancedCustomBalanceResponseBytes {
return channelBalanceResult{}, fmt.Errorf("balance response exceeds %d bytes", maxAdvancedCustomBalanceResponseBytes)
}
var validated json.RawMessage
if err := common.Unmarshal(body, &validated); err != nil {
return channelBalanceResult{}, fmt.Errorf("invalid balance JSON response: %w", err)
}
if common.GetJsonType(validated) == "object" {
var creditSummary struct {
Object string `json:"object"`
TotalAvailable json.RawMessage `json:"total_available"`
}
if err := common.Unmarshal(body, &creditSummary); err != nil {
return channelBalanceResult{}, fmt.Errorf("invalid balance JSON response: %w", err)
}
if creditSummary.Object == "credit_summary" &&
common.GetJsonType(creditSummary.TotalAvailable) == "number" {
var balance float64
if err := common.Unmarshal(creditSummary.TotalAvailable, &balance); err == nil &&
balance >= 0 &&
!math.IsNaN(balance) &&
!math.IsInf(balance, 0) {
channel.UpdateBalance(balance)
return channelBalanceResult{Balance: balance}, nil
}
}
}
formatted, err := common.IndentJson(body)
if err != nil {
return channelBalanceResult{}, fmt.Errorf("invalid balance JSON response: %w", err)
}
return channelBalanceResult{RawResponse: string(formatted)}, nil
}
func updateChannelBalance(channel *model.Channel) (channelBalanceResult, error) {
if channel.Type == constant.ChannelTypeAdvancedCustom {
return fetchAdvancedCustomBalance(channel)
}
balance, err := updateStandardChannelBalance(channel)
return channelBalanceResult{Balance: balance}, err
}
func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() == "" {
channel.BaseURL = &baseURL
}
switch channel.Type {
case constant.ChannelTypeOpenAI:
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
case constant.ChannelTypeAzure:
return 0, errors.New("尚未实现")
case constant.ChannelTypeCustom:
baseURL = channel.GetBaseURL()
//case common.ChannelTypeOpenAISB:
// return updateChannelOpenAISBBalance(channel)
case constant.ChannelTypeAIProxy:
return updateChannelAIProxyBalance(channel)
case constant.ChannelTypeAPI2GPT:
return updateChannelAPI2GPTBalance(channel)
case constant.ChannelTypeAIGC2D:
return updateChannelAIGC2DBalance(channel)
case constant.ChannelTypeSiliconFlow:
return updateChannelSiliconFlowBalance(channel)
case constant.ChannelTypeDeepSeek:
return updateChannelDeepSeekBalance(channel)
case constant.ChannelTypeOpenRouter:
return updateChannelOpenRouterBalance(channel)
case constant.ChannelTypeMoonshot:
return updateChannelMoonshotBalance(channel)
default:
return 0, errors.New("尚未实现")
}
url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL)
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
subscription := OpenAISubscriptionResponse{}
err = common.Unmarshal(body, &subscription)
if err != nil {
return 0, err
}
now := time.Now()
startDate := fmt.Sprintf("%s-01", now.Format("2006-01"))
endDate := now.Format("2006-01-02")
if !subscription.HasPaymentMethod {
startDate = now.AddDate(0, 0, -100).Format("2006-01-02")
}
url = fmt.Sprintf("%s/v1/dashboard/billing/usage?start_date=%s&end_date=%s", baseURL, startDate, endDate)
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
usage := OpenAIUsageResponse{}
err = common.Unmarshal(body, &usage)
if err != nil {
return 0, err
}
balance := subscription.HardLimitUSD - usage.TotalUsage/100
channel.UpdateBalance(balance)
return balance, nil
}
func UpdateChannelBalance(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
channel, err := model.CacheGetChannel(id)
if err != nil {
common.ApiError(c, err)
return
}
if channel.Type == constant.ChannelTypeTaskPlugin {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "Task Plugin channels do not support balance queries"})
return
}
if channel.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "多密钥渠道不支持余额查询",
})
return
}
result, err := updateChannelBalance(channel)
if err != nil {
common.ApiError(c, err)
return
}
response := gin.H{
"success": true,
"message": "",
}
if result.RawResponse == "" {
response["balance"] = result.Balance
} else {
response["raw_response"] = result.RawResponse
}
c.JSON(http.StatusOK, response)
}
func updateAllChannelsBalance() error {
channels, err := model.GetAllChannels(0, 0, true, false)
if err != nil {
return err
}
for _, channel := range channels {
if channel.Status != common.ChannelStatusEnabled {
continue
}
if channel.ChannelInfo.IsMultiKey {
continue // skip multi-key channels
}
// TODO: support Azure
//if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom {
// continue
//}
result, err := updateChannelBalance(channel)
if err != nil {
continue
} else if result.RawResponse == "" {
// err is nil & balance <= 0 means quota is used up
if result.Balance <= 0 {
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足")
}
}
time.Sleep(common.RequestInterval)
}
return nil
}
func UpdateAllChannelsBalance(c *gin.Context) {
// TODO: make it async
err := updateAllChannelsBalance()
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func AutomaticallyUpdateChannels(frequency int) {
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("updating all channels")
_ = updateAllChannelsBalance()
common.SysLog("channels update done")
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+88
View File
@@ -0,0 +1,88 @@
package controller
import (
"net/http"
"strings"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func GetChannelAffinityCacheStats(c *gin.Context) {
stats := service.GetChannelAffinityCacheStats()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": stats,
})
}
func ClearChannelAffinityCache(c *gin.Context) {
all := strings.TrimSpace(c.Query("all"))
ruleName := strings.TrimSpace(c.Query("rule_name"))
if all == "true" {
deleted := service.ClearChannelAffinityCacheAll()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"deleted": deleted,
},
})
return
}
if ruleName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "缺少参数:rule_name,或使用 all=true 清空全部",
})
return
}
deleted, err := service.ClearChannelAffinityCacheByRuleName(ruleName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"deleted": deleted,
},
})
}
func GetChannelAffinityUsageCacheStats(c *gin.Context) {
ruleName := strings.TrimSpace(c.Query("rule_name"))
usingGroup := strings.TrimSpace(c.Query("using_group"))
keyFp := strings.TrimSpace(c.Query("key_fp"))
if ruleName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "missing param: rule_name",
})
return
}
if keyFp == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "missing param: key_fp",
})
return
}
stats := service.GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": stats,
})
}
+136
View File
@@ -0,0 +1,136 @@
package controller
import "github.com/QuantumNous/new-api/model"
func channelHasSensitiveChanges(channel *PatchChannel, origin *model.Channel, requestData map[string]any) bool {
if _, ok := requestData["type"]; ok && channel.Type != origin.Type {
return true
}
if _, ok := requestData["key"]; ok && channel.Key != "" && channel.Key != origin.Key {
return true
}
if _, ok := requestData["base_url"]; ok && !equalStringPtr(channel.BaseURL, origin.BaseURL) {
return true
}
if _, ok := requestData["openai_organization"]; ok && !equalStringPtr(channel.OpenAIOrganization, origin.OpenAIOrganization) {
return true
}
if _, ok := requestData["header_override"]; ok && !equalStringPtr(channel.HeaderOverride, origin.HeaderOverride) {
return true
}
if _, ok := requestData["param_override"]; ok && !equalStringPtr(channel.ParamOverride, origin.ParamOverride) {
return true
}
if _, ok := requestData["setting"]; ok && !equalStringPtr(channel.Setting, origin.Setting) {
return true
}
if _, ok := requestData["other"]; ok && channel.Other != origin.Other {
return true
}
if _, ok := requestData["settings"]; ok && channel.OtherSettings != origin.OtherSettings {
return true
}
if _, ok := requestData["key_mode"]; ok && channel.KeyMode != nil {
return true
}
// Fail closed: any field present in the request that is neither a known
// sensitive field (gated above) nor an explicitly classified non-sensitive
// field must be treated as sensitive. This keeps a newly added channel field
// from silently becoming editable by ChannelWrite-only admins until it is
// consciously classified in channelNonSensitiveFields.
for field := range requestData {
if _, ok := channelSensitiveFields[field]; ok {
continue
}
if _, ok := channelNonSensitiveFields[field]; ok {
continue
}
if _, ok := channelOperationalFields[field]; ok {
continue
}
if _, ok := channelReadOnlyFields[field]; ok {
continue
}
return true
}
return false
}
// channelSensitiveFields lists the channel fields whose modification requires
// ChannelSensitiveWrite. They are each checked individually in
// channelHasSensitiveChanges with a precise old-vs-new comparison; this set is
// used to exclude them from the fail-closed scan for unknown fields.
var channelSensitiveFields = map[string]struct{}{
"type": {},
"key": {},
"base_url": {},
"openai_organization": {},
"header_override": {},
"param_override": {},
"setting": {},
"other": {},
"settings": {},
"key_mode": {},
}
// channelOperationalFields lists fields managed by operation endpoints instead
// of the general channel edit endpoint.
var channelOperationalFields = map[string]struct{}{
"status": {},
}
// channelReadOnlyFields lists server-managed/accounting fields that the general
// channel edit endpoint must ignore even if a client sends them.
var channelReadOnlyFields = map[string]struct{}{
"created_time": {},
"test_time": {},
"response_time": {},
"balance": {},
"balance_updated_time": {},
"used_quota": {},
}
func clearChannelReadOnlyFields(channel *PatchChannel, requestData map[string]any) {
if _, ok := requestData["created_time"]; ok {
channel.CreatedTime = 0
}
if _, ok := requestData["test_time"]; ok {
channel.TestTime = 0
}
if _, ok := requestData["response_time"]; ok {
channel.ResponseTime = 0
}
if _, ok := requestData["balance"]; ok {
channel.Balance = 0
}
if _, ok := requestData["balance_updated_time"]; ok {
channel.BalanceUpdatedTime = 0
}
if _, ok := requestData["used_quota"]; ok {
channel.UsedQuota = 0
}
}
// channelNonSensitiveFields lists routing / server-managed channel
// fields a ChannelWrite admin may edit without ChannelSensitiveWrite. When a new
// field is added to model.Channel it must be added to either this set or
// channelSensitiveFields or channelOperationalFields; otherwise it falls through
// to the fail-closed branch and is treated as sensitive. The
// TestChannelFieldsAreClassified guard test enforces this.
var channelNonSensitiveFields = map[string]struct{}{
"id": {},
"test_model": {},
"name": {},
"weight": {},
"models": {},
"group": {},
"model_mapping": {},
"status_code_mapping": {},
"priority": {},
"auto_ban": {},
"other_info": {},
"tag": {},
"remark": {},
"channel_info": {},
"multi_key_mode": {},
}
+204
View File
@@ -0,0 +1,204 @@
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChannelHasSensitiveChanges(t *testing.T) {
baseURL := "https://api.example.com"
headerOverride := `{"Authorization":"Bearer {api_key}"}`
origin := &model.Channel{
Type: 1,
Key: "old-key",
BaseURL: &baseURL,
HeaderOverride: &headerOverride,
Models: "gpt-4o",
Group: "default",
}
t.Run("non-sensitive routing fields", func(t *testing.T) {
updated := PatchChannel{Channel: *origin}
updated.Models = "gpt-4o,gpt-4o-mini"
updated.Group = "vip"
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{
"models": updated.Models,
"group": updated.Group,
}))
})
t.Run("key change", func(t *testing.T) {
updated := PatchChannel{Channel: *origin}
updated.Key = "new-key"
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"key": updated.Key}))
})
t.Run("base url change", func(t *testing.T) {
updated := PatchChannel{Channel: *origin}
newBaseURL := "https://leak.example.com"
updated.BaseURL = &newBaseURL
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"base_url": newBaseURL}))
})
t.Run("header override change", func(t *testing.T) {
updated := PatchChannel{Channel: *origin}
newHeaderOverride := `{"X-Key":"{api_key}"}`
updated.HeaderOverride = &newHeaderOverride
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"header_override": newHeaderOverride}))
})
t.Run("omitted sensitive fields do not use zero values", func(t *testing.T) {
updated := PatchChannel{}
updated.Id = origin.Id
updated.Priority = origin.Priority
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"priority": 10}))
})
t.Run("unknown field fails closed", func(t *testing.T) {
updated := PatchChannel{Channel: *origin}
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"future_secret_field": "x"}))
})
t.Run("status is operational", func(t *testing.T) {
updated := PatchChannel{Channel: *origin}
updated.Status = common.ChannelStatusManuallyDisabled
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"status": updated.Status}))
})
t.Run("read-only fields are ignored by sensitivity check", func(t *testing.T) {
updated := PatchChannel{Channel: *origin}
updated.Balance = 99
updated.UsedQuota = 100
updated.ResponseTime = 200
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{
"balance": updated.Balance,
"used_quota": updated.UsedQuota,
"response_time": updated.ResponseTime,
}))
})
}
func TestClearChannelReadOnlyFields(t *testing.T) {
channel := PatchChannel{Channel: model.Channel{
CreatedTime: 11,
TestTime: 22,
ResponseTime: 33,
Balance: 44.5,
BalanceUpdatedTime: 55,
UsedQuota: 66,
Models: "gpt-4o",
Group: "default",
}}
clearChannelReadOnlyFields(&channel, map[string]any{
"created_time": channel.CreatedTime,
"test_time": channel.TestTime,
"response_time": channel.ResponseTime,
"balance": channel.Balance,
"balance_updated_time": channel.BalanceUpdatedTime,
"used_quota": channel.UsedQuota,
"models": channel.Models,
"group": channel.Group,
})
assert.Zero(t, channel.CreatedTime)
assert.Zero(t, channel.TestTime)
assert.Zero(t, channel.ResponseTime)
assert.Zero(t, channel.Balance)
assert.Zero(t, channel.BalanceUpdatedTime)
assert.Zero(t, channel.UsedQuota)
assert.Equal(t, "gpt-4o", channel.Models)
assert.Equal(t, "default", channel.Group)
}
func TestUpdateChannelRejectsStatusField(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(
http.MethodPut,
"/api/channel/",
bytes.NewBufferString(`{"id":1,"status":2}`),
)
ctx.Request.Header.Set("Content-Type", "application/json")
UpdateChannel(ctx)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Message string `json:"message"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
}
func TestChannelStatusValidation(t *testing.T) {
assert.True(t, isManageableChannelStatus(common.ChannelStatusEnabled))
assert.True(t, isManageableChannelStatus(common.ChannelStatusManuallyDisabled))
assert.False(t, isManageableChannelStatus(common.ChannelStatusAutoDisabled))
assert.False(t, isManageableChannelStatus(0))
}
// TestChannelFieldsAreClassified guards the fail-closed sensitivity check: every
// JSON field of PatchChannel (including the embedded model.Channel) must be listed
// in channelSensitiveFields, channelNonSensitiveFields, or
// channelOperationalFields. A newly added field that is left unclassified will
// fail this test, forcing a conscious permission decision instead of silently
// defaulting either way.
func TestChannelFieldsAreClassified(t *testing.T) {
classified := func(name string) bool {
if _, ok := channelSensitiveFields[name]; ok {
return true
}
if _, ok := channelNonSensitiveFields[name]; ok {
return true
}
if _, ok := channelOperationalFields[name]; ok {
return true
}
_, ok := channelReadOnlyFields[name]
return ok
}
var collect func(rt reflect.Type) []string
collect = func(rt reflect.Type) []string {
var names []string
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
if field.Anonymous && field.Type.Kind() == reflect.Struct {
names = append(names, collect(field.Type)...)
continue
}
name := strings.Split(field.Tag.Get("json"), ",")[0]
if name == "" || name == "-" {
continue
}
names = append(names, name)
}
return names
}
for _, name := range collect(reflect.TypeFor[PatchChannel]()) {
assert.Truef(t, classified(name),
"channel field %q is not classified; add it to channelSensitiveFields, channelNonSensitiveFields, channelOperationalFields, or channelReadOnlyFields in channel_authz.go", name)
}
}
+95
View File
@@ -0,0 +1,95 @@
package controller
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldRetryHonorsPinRetryMode(t *testing.T) {
openaiErr := types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError)
c := newPinRetryContext()
assert.True(t, shouldRetry(c, openaiErr, 1))
origin := newPinRetryContext()
service.GetChannelConstraints(origin).AddPin(dto.ChannelPin{
ChannelId: 2,
Source: dto.PinSourceOriginTask,
Rank: dto.PinRankOriginTask,
RetryMode: dto.PinRetrySameChannel,
})
assert.True(t, shouldRetry(origin, openaiErr, 1), "origin pin retries on the same channel")
token := newPinRetryContext()
service.GetChannelConstraints(token).AddPin(dto.ChannelPin{
ChannelId: 1,
Source: dto.PinSourceToken,
Rank: dto.PinRankToken,
RetryMode: dto.PinRetrySingleAttempt,
})
assert.False(t, shouldRetry(token, openaiErr, 1), "token pin suppresses retry")
}
func TestShouldRetryTaskRelayHonorsPinRetryMode(t *testing.T) {
taskErr := &dto.TaskError{StatusCode: http.StatusInternalServerError}
c := newPinRetryContext()
assert.True(t, shouldRetryTaskRelay(c, 1, taskErr, 1))
origin := newPinRetryContext()
service.GetChannelConstraints(origin).AddPin(dto.ChannelPin{
ChannelId: 2,
Source: dto.PinSourceOriginTask,
Rank: dto.PinRankOriginTask,
RetryMode: dto.PinRetrySameChannel,
})
assert.True(t, shouldRetryTaskRelay(origin, 2, taskErr, 1))
token := newPinRetryContext()
service.GetChannelConstraints(token).AddPin(dto.ChannelPin{
ChannelId: 1,
Source: dto.PinSourceToken,
Rank: dto.PinRankToken,
RetryMode: dto.PinRetrySingleAttempt,
})
assert.False(t, shouldRetryTaskRelay(token, 1, taskErr, 1))
}
func TestSameChannelPinsMergeToStricterRetryMode(t *testing.T) {
c := newPinRetryContext()
constraints := service.GetChannelConstraints(c)
constraints.AddPin(dto.ChannelPin{
ChannelId: 7,
Source: dto.PinSourceOriginTask,
Rank: dto.PinRankOriginTask,
RetryMode: dto.PinRetrySameChannel,
})
constraints.AddPin(dto.ChannelPin{
ChannelId: 7,
Source: dto.PinSourceToken,
Rank: dto.PinRankToken,
RetryMode: dto.PinRetrySingleAttempt,
})
pin, found, overridden := constraints.ResolvedPin()
require.True(t, found)
assert.Equal(t, 7, pin.ChannelId)
assert.Equal(t, dto.PinRetrySingleAttempt, pin.RetryMode)
assert.Empty(t, overridden)
assert.False(t, shouldRetry(c, types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError), 1))
}
func newPinRetryContext() *gin.Context {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
return c
}
+166
View File
@@ -0,0 +1,166 @@
package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupTaskPluginBindChannelTest(t *testing.T) {
t.Helper()
wasMaster := common.IsMasterNode
common.IsMasterNode = true
previousRedisEnabled := common.RedisEnabled
common.RedisEnabled = false
originalDB, originalLogDB := model.DB, model.LOG_DB
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := database.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.Log{}, &model.AuditLog{}, &model.User{}))
model.DB = database
model.LOG_DB = database
require.NoError(t, authz.Init(database))
t.Cleanup(func() {
common.IsMasterNode = wasMaster
common.RedisEnabled = previousRedisEnabled
model.DB = originalDB
model.LOG_DB = originalLogDB
})
}
func postAddChannel(t *testing.T, userID, role int, body string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Set("id", userID)
context.Set("role", role)
context.Request = httptest.NewRequest(http.MethodPost, "/api/channel", strings.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
AddChannel(context)
return recorder
}
func TestAddChannelTaskPluginRequiresBindPermission(t *testing.T) {
setupTaskPluginBindChannelTest(t)
const key = "channel-bind"
source := `
export const meta = {apiVersion: 1, key: "channel-bind", name: "Bind", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
taskPluginBody := `{"mode":"single","channel":{"type":61,"name":"plugin-channel","key":"sk","models":"doc","group":"default","base_url":"https://example.com","setting":"{\"task_plugin_key\":\"channel-bind\"}"}}`
openaiBody := `{"mode":"single","channel":{"type":1,"name":"openai-channel","key":"sk","models":"gpt","group":"default"}}`
adminDenied := postAddChannel(t, 2, common.RoleAdminUser, taskPluginBody)
assert.Contains(t, adminDenied.Body.String(), "task plugin channels require the task_plugin.bind permission")
assert.Contains(t, adminDenied.Body.String(), `"success":false`)
rootAllowed := postAddChannel(t, 1, common.RoleRootUser, taskPluginBody)
assert.Contains(t, rootAllowed.Body.String(), `"success":true`)
assert.NotContains(t, rootAllowed.Body.String(), "task_plugin.bind")
adminOtherType := postAddChannel(t, 2, common.RoleAdminUser, openaiBody)
assert.Contains(t, adminOtherType.Body.String(), `"success":true`)
assert.NotContains(t, adminOtherType.Body.String(), "task_plugin.bind")
}
func TestUpdateChannelTaskPluginRequiresBindPermission(t *testing.T) {
setupTaskPluginBindChannelTest(t)
const key = "channel-bind-update"
source := `
export const meta = {apiVersion: 1, key: "channel-bind-update", name: "Bind", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
baseURL := "https://example.com"
setting := `{"task_plugin_key":"channel-bind-update"}`
channel := model.Channel{
Type: constant.ChannelTypeTaskPlugin,
Status: common.ChannelStatusEnabled,
Name: "existing-plugin",
Models: "doc",
Group: "default",
Key: "sk",
BaseURL: &baseURL,
Setting: &setting,
}
require.NoError(t, channel.Insert())
payload := fmt.Sprintf(
`{"id":%d,"type":61,"name":"existing-plugin","key":"sk","models":"doc","group":"default","base_url":"https://example.com","setting":"{\"task_plugin_key\":\"channel-bind-update\"}"}`,
channel.Id,
)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Set("id", 2)
context.Set("role", common.RoleAdminUser)
context.Request = httptest.NewRequest(http.MethodPut, "/api/channel", strings.NewReader(payload))
context.Request.Header.Set("Content-Type", "application/json")
UpdateChannel(context)
assert.Contains(t, recorder.Body.String(), "task plugin channels require the task_plugin.bind permission")
assert.Contains(t, recorder.Body.String(), `"success":false`)
}
func TestAddChannelTaskPluginPersistsPluginDefaultBaseURLAndAuditsSource(t *testing.T) {
setupTaskPluginBindChannelTest(t)
for key, baseURLField := range map[string]string{"bind-default-url": `baseUrl: "http://10.0.0.5:8000/",`, "bind-no-default": ""} {
source := fmt.Sprintf(`
export const meta = {apiVersion: 1, key: %q, name: "Bind", version: "1.0.0", author: {name: "Test"}, %s models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, key, baseURLField)
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
}
body := func(pluginKey string) string {
return fmt.Sprintf(`{"mode":"single","channel":{"type":61,"name":"%s","key":"sk","models":"doc","group":"default","setting":"{\"task_plugin_key\":\"%s\"}"}}`, pluginKey, pluginKey)
}
noDefault := postAddChannel(t, 1, common.RoleRootUser, body("bind-no-default"))
assert.Contains(t, noDefault.Body.String(), "base URL is required for task plugin channels")
filled := postAddChannel(t, 1, common.RoleRootUser, body("bind-default-url"))
require.Contains(t, filled.Body.String(), `"success":true`)
var created model.Channel
require.NoError(t, model.DB.Where("name = ?", "bind-default-url").First(&created).Error)
require.NotNil(t, created.BaseURL)
assert.Equal(t, "http://10.0.0.5:8000", *created.BaseURL, "the normalized plugin default is stored on the channel row")
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "channel.create").Find(&audits).Error)
encoded, err := common.Marshal(audits)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"base_url_source":"plugin_default"`)
}
@@ -0,0 +1,69 @@
package controller
import (
"strings"
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateTaskPluginChannel(t *testing.T) {
source := `
export const meta = {apiVersion: 1, key: "channel-validation", name: "Validation", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("channel-validation") })
baseURL := "https://example.com"
channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, BaseURL: &baseURL}
require.ErrorContains(t, validateChannel(channel, false), "task plugin key is required")
missing := `{"task_plugin_key":"missing"}`
channel.Setting = &missing
require.ErrorContains(t, validateChannel(channel, false), "is not registered")
longKey := `{"task_plugin_key":"` + strings.Repeat("x", 31) + `"}`
channel.Setting = &longKey
require.ErrorContains(t, validateChannel(channel, false), "must not exceed 30")
valid := `{"task_plugin_key":"channel-validation"}`
channel.Setting = &valid
channel.BaseURL = nil
require.ErrorContains(t, validateChannel(channel, false), "base URL is required")
}
func TestValidateTaskPluginChannelFillsPluginDefaultBaseURL(t *testing.T) {
source := `
export const meta = {apiVersion: 1, key: "channel-default-url", name: "Default URL", version: "1.0.0", author: {name: "Test"}, baseUrl: "http://127.0.0.1:8000/", models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("channel-default-url") })
bound := `{"task_plugin_key":"channel-default-url"}`
empty := " "
for _, baseURL := range []*string{nil, &empty} {
channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Key: "sk", Setting: &bound, BaseURL: baseURL}
require.NoError(t, validateChannel(channel, true))
require.NotNil(t, channel.BaseURL)
assert.Equal(t, "http://127.0.0.1:8000", *channel.BaseURL, "normalized plugin default is persisted onto the channel")
}
explicit := "https://override.example.com"
channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Setting: &bound, BaseURL: &explicit}
require.NoError(t, validateChannel(channel, false))
assert.Equal(t, explicit, *channel.BaseURL, "an administrator value is never replaced by the plugin default")
}
+468
View File
@@ -0,0 +1,468 @@
package controller
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateChannelProxy(t *testing.T) {
tests := []struct {
name string
proxy string
wantErr bool
}{
{name: "empty"},
{name: "http", proxy: "http://proxy.example:8080"},
{name: "https", proxy: "https://proxy.example:8443"},
{name: "socks5", proxy: "socks5://proxy.example"},
{name: "socks5h", proxy: "socks5h://proxy.example:1080/"},
{name: "unsupported", proxy: "ftp://proxy.example", wantErr: true},
{name: "path", proxy: "socks5://proxy.example:1080/path", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
setting, err := common.Marshal(dto.ChannelSettings{Proxy: test.proxy})
require.NoError(t, err)
channel := &model.Channel{
Type: constant.ChannelTypeOpenAI,
Setting: common.GetPointer(string(setting)),
}
err = validateChannel(channel, false)
if test.wantErr {
require.ErrorContains(t, err, "invalid channel proxy")
return
}
require.NoError(t, err)
})
}
}
func TestValidateChannelRequiresNewAPIBaseURL(t *testing.T) {
tests := []struct {
name string
baseURL *string
wantErr bool
}{
{name: "missing", wantErr: true},
{name: "blank", baseURL: common.GetPointer(" "), wantErr: true},
{name: "configured", baseURL: common.GetPointer("https://new-api.example")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
channel := &model.Channel{
Type: constant.ChannelTypeNewAPI,
BaseURL: test.baseURL,
}
err := validateChannel(channel, false)
if test.wantErr {
require.ErrorContains(t, err, "New API channel base URL cannot be empty")
return
}
require.NoError(t, err)
})
}
}
func TestNewAPIChannelRegistration(t *testing.T) {
apiType, ok := common.ChannelType2APIType(constant.ChannelTypeNewAPI)
require.True(t, ok)
assert.Equal(t, constant.APITypeNewAPI, apiType)
assert.Equal(t, "New API", constant.GetChannelTypeName(constant.ChannelTypeNewAPI))
require.Greater(t, len(constant.ChannelBaseURLs), constant.ChannelTypeNewAPI)
assert.Empty(t, constant.ChannelBaseURLs[constant.ChannelTypeNewAPI])
}
func TestResponsesCompactChannelSupport(t *testing.T) {
tests := []struct {
name string
channelType int
apiType int
want bool
}{
{name: "OpenAI", channelType: constant.ChannelTypeOpenAI, apiType: constant.APITypeOpenAI, want: true},
{name: "Azure", channelType: constant.ChannelTypeAzure, apiType: constant.APITypeOpenAI, want: true},
{name: "Codex", channelType: constant.ChannelTypeCodex, apiType: constant.APITypeCodex, want: true},
{name: "Advanced Custom", channelType: constant.ChannelTypeAdvancedCustom, apiType: constant.APITypeAdvancedCustom, want: true},
{name: "Sub2API", channelType: constant.ChannelTypeSub2API, apiType: constant.APITypeSub2API, want: true},
{name: "New API", channelType: constant.ChannelTypeNewAPI, apiType: constant.APITypeNewAPI, want: true},
{name: "Anthropic", channelType: constant.ChannelTypeAnthropic, apiType: constant.APITypeAnthropic, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, common.SupportsResponsesCompact(test.channelType, test.apiType))
})
}
}
func TestMultiprotocolGatewayEndpointTypes(t *testing.T) {
want := []constant.EndpointType{
constant.EndpointTypeOpenAI,
constant.EndpointTypeOpenAIResponse,
constant.EndpointTypeOpenAIResponseCompact,
constant.EndpointTypeAnthropic,
constant.EndpointTypeGemini,
constant.EndpointTypeOpenAIAlphaSearch,
}
assert.Equal(t, want, common.GetEndpointTypesByChannelType(constant.ChannelTypeNewAPI, "gpt-5"))
assert.Equal(t, want, common.GetEndpointTypesByChannelType(constant.ChannelTypeSub2API, "gpt-5"))
}
func TestCopyChannelRejectsInvalidLegacyProxySettings(t *testing.T) {
db := setupModelListControllerTestDB(t)
settingBytes, err := common.Marshal(dto.ChannelSettings{
Proxy: "socks5://proxy.example/legacy-path",
})
require.NoError(t, err)
setting := string(settingBytes)
origin := &model.Channel{
Type: constant.ChannelTypeOpenAI,
Name: "legacy proxy channel",
Key: "test-key",
Models: "gpt-test",
Group: "default",
Setting: &setting,
}
require.NoError(t, db.Create(origin).Error)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", origin.Id)}}
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/copy", nil)
CopyChannel(ctx)
assert.Contains(t, recorder.Body.String(), "invalid channel settings")
var channelCount int64
require.NoError(t, db.Model(&model.Channel{}).Count(&channelCount).Error)
assert.Equal(t, int64(1), channelCount)
}
func TestDeleteChannelResetsProxyCacheWhenPreReadFails(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}))
service.ResetProxyClientCache()
t.Cleanup(service.ResetProxyClientCache)
proxyURL := "http://proxy.example:8080"
beforeDelete, err := service.GetHttpClientWithProxy(proxyURL)
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Params = gin.Params{{Key: "id", Value: "999999"}}
ctx.Request = httptest.NewRequest(http.MethodDelete, "/api/channel/999999", nil)
DeleteChannel(ctx)
assert.Contains(t, recorder.Body.String(), `"success":true`)
afterDelete, err := service.GetHttpClientWithProxy(proxyURL)
require.NoError(t, err)
assert.NotSame(t, beforeDelete, afterDelete)
}
func TestDeleteChannelBatchReportsAndAuditsActualDeletedCount(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}))
channel := &model.Channel{Name: "existing", Key: "test-key"}
require.NoError(t, db.Create(channel).Error)
requestBody, err := common.Marshal(ChannelBatch{Ids: []int{channel.Id, 999999}})
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodDelete, "/api/channel/batch", bytes.NewReader(requestBody))
ctx.Request.Header.Set("Content-Type", "application/json")
DeleteChannelBatch(ctx)
var response struct {
Success bool `json:"success"`
Data int64 `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.True(t, response.Success)
assert.Equal(t, int64(1), response.Data)
var auditLog model.AuditLog
require.NoError(t, db.Order("id desc").First(&auditLog).Error)
var auditData struct {
Operation struct {
Params map[string]any `json:"params"`
} `json:"op"`
}
encodedAudit, err := common.Marshal(auditLog.Other)
require.NoError(t, err)
require.NoError(t, common.Unmarshal(encodedAudit, &auditData))
assert.Equal(t, float64(1), auditData.Operation.Params["count"])
}
func TestSettleTestQuotaUsesTieredBilling(t *testing.T) {
info := &relaycommon.RelayInfo{
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: `param("stream") == true ? tier("stream", p * 3) : tier("base", p * 2)`,
ExprHash: billingexpr.ExprHashString(`param("stream") == true ? tier("stream", p * 3) : tier("base", p * 2)`),
GroupRatio: 1,
EstimatedTier: "stream",
QuotaPerUnit: common.QuotaPerUnit,
ExprVersion: 1,
},
BillingRequestInput: &billingexpr.RequestInput{
Body: []byte(`{"stream":true}`),
},
}
quota, result := settleTestQuota(info, types.PriceData{
ModelRatio: 1,
CompletionRatio: 2,
}, &dto.Usage{
PromptTokens: 1000,
})
require.Equal(t, 1500, quota)
require.NotNil(t, result)
require.Equal(t, "stream", result.MatchedTier)
}
func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: `tier("base", p * 2)`,
},
ChannelMeta: &relaycommon.ChannelMeta{},
}
priceData := types.PriceData{
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
}
usage := &dto.Usage{
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 12,
},
}
requestRules := []billingexpr.RequestRuleTrace{{
Cond: `param("service_tier") == "fast"`,
Multiplier: 2,
Matched: true,
}}
other := buildTestLogOther(ctx, info, priceData, usage, &billingexpr.TieredResult{
MatchedTier: "base",
RequestRules: requestRules,
})
fields := other.Snapshot()
require.Equal(t, "tiered_expr", fields["billing_mode"])
require.Equal(t, "base", fields["matched_tier"])
require.Equal(t, requestRules, fields["request_rules"])
require.NotEmpty(t, fields["expr_b64"])
}
func TestResolveChannelTestUserIDUsesRequestUser(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set("id", 2)
userID, err := resolveChannelTestUserID(ctx)
require.NoError(t, err)
require.Equal(t, 2, userID)
}
func TestSelectChannelsForAutomaticTestPassiveRecoveryOnlyUsesAutoDisabled(t *testing.T) {
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled},
{Id: 2, Status: common.ChannelStatusAutoDisabled},
{Id: 3, Status: common.ChannelStatusManuallyDisabled},
}
selected := selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModePassiveRecovery)
require.Len(t, selected, 1)
require.Equal(t, 2, selected[0].Id)
}
func TestSelectChannelsForAutomaticTestScheduledSkipsManualDisabled(t *testing.T) {
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled},
{Id: 2, Status: common.ChannelStatusAutoDisabled},
{Id: 3, Status: common.ChannelStatusManuallyDisabled},
}
selected := selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModeScheduledAll)
require.Len(t, selected, 2)
require.Equal(t, 1, selected[0].Id)
require.Equal(t, 2, selected[1].Id)
}
func TestSelectChannelsForAutomaticTestAutoBanOnlyUsesEligibleChannels(t *testing.T) {
autoBanEnabled := 1
autoBanDisabled := 0
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled, AutoBan: &autoBanEnabled},
{Id: 2, Status: common.ChannelStatusEnabled, AutoBan: &autoBanDisabled},
{Id: 3, Status: common.ChannelStatusAutoDisabled, AutoBan: &autoBanEnabled},
{Id: 4, Status: common.ChannelStatusManuallyDisabled, AutoBan: &autoBanEnabled},
{Id: 5, Status: common.ChannelStatusEnabled},
}
selected := selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModeAutoBanOnly)
require.Len(t, selected, 2)
require.Equal(t, 1, selected[0].Id)
require.Equal(t, 3, selected[1].Id)
}
func TestRunChannelTestWorkersHonorsConfiguredConcurrency(t *testing.T) {
originalInterval := common.RequestInterval
common.RequestInterval = 0
t.Cleanup(func() { common.RequestInterval = originalInterval })
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled},
{Id: 2, Status: common.ChannelStatusEnabled},
{Id: 3, Status: common.ChannelStatusEnabled},
{Id: 4, Status: common.ChannelStatusEnabled},
}
started := make(chan struct{}, len(channels))
release := make(chan struct{})
var active atomic.Int32
var maxActive atomic.Int32
progress := make([]int, 0, len(channels)+1)
summaryResult := make(chan channelTestSummary, 1)
go func() {
summaryResult <- runChannelTestWorkers(
context.Background(),
channels,
2,
func(_ context.Context, _ *model.Channel) channelTestSummary {
current := active.Add(1)
defer active.Add(-1)
for {
observed := maxActive.Load()
if current <= observed || maxActive.CompareAndSwap(observed, current) {
break
}
}
started <- struct{}{}
<-release
return channelTestSummary{Tested: 1, Succeeded: 1}
},
func(processed, _ int) {
progress = append(progress, processed)
},
)
}()
<-started
<-started
select {
case <-started:
t.Fatal("started more channel tests than the configured concurrency")
default:
}
close(release)
summary := <-summaryResult
assert.Equal(t, int32(2), maxActive.Load())
assert.Equal(t, channelTestSummary{Tested: 4, Succeeded: 4}, summary)
assert.Equal(t, []int{0, 1, 2, 3, 4}, progress)
}
func TestRunChannelTestWorkersStopsAfterCancellation(t *testing.T) {
originalInterval := common.RequestInterval
common.RequestInterval = 0
t.Cleanup(func() { common.RequestInterval = originalInterval })
ctx, cancel := context.WithCancel(context.Background())
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled},
{Id: 2, Status: common.ChannelStatusEnabled},
{Id: 3, Status: common.ChannelStatusEnabled},
{Id: 4, Status: common.ChannelStatusEnabled},
}
started := make(chan struct{}, len(channels))
progress := make([]int, 0, 1)
summaryResult := make(chan channelTestSummary, 1)
go func() {
summaryResult <- runChannelTestWorkers(
ctx,
channels,
2,
func(ctx context.Context, _ *model.Channel) channelTestSummary {
started <- struct{}{}
<-ctx.Done()
return channelTestSummary{Tested: 1, Succeeded: 1}
},
func(processed, _ int) {
progress = append(progress, processed)
},
)
}()
<-started
<-started
cancel()
summary := <-summaryResult
select {
case <-started:
t.Fatal("started another channel test after cancellation")
default:
}
assert.Equal(t, channelTestSummary{Tested: 2, Succeeded: 2}, summary)
assert.Equal(t, []int{0}, progress)
}
func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{}))
existing, err := model.CreateSystemTask(model.SystemTaskTypeChannelTest, nil, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/test", nil)
TestAllChannels(ctx)
require.Equal(t, http.StatusConflict, recorder.Code)
require.Contains(t, recorder.Body.String(), existing.TaskID)
require.Contains(t, recorder.Body.String(), "已有通道测试任务正在运行或等待中")
}
+236
View File
@@ -0,0 +1,236 @@
package controller
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/ali"
"github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func convertChatCompatibilityRequest(t *testing.T, request *dto.GeneralOpenAIRequest, channelType int, mapping map[string]string) []byte {
t.Helper()
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
settings := model_setting.GetGlobalSettings()
oldPassThrough, oldBlacklist := settings.PassThroughRequestEnabled, settings.ThinkingModelBlacklist
oldEffortTailModels := settings.EffortTailModelIDs
settings.PassThroughRequestEnabled = false
settings.ThinkingModelBlacklist = nil
settings.EffortTailModelIDs = []string{"gpt-5.1-codex-max"}
t.Cleanup(func() {
settings.PassThroughRequestEnabled = oldPassThrough
settings.ThinkingModelBlacklist = oldBlacklist
settings.EffortTailModelIDs = oldEffortTailModels
})
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
if mapping != nil {
encoded, err := common.Marshal(mapping)
require.NoError(t, err)
c.Set("model_mapping", string(encoded))
}
info := &relaycommon.RelayInfo{
OriginModelName: request.Model,
Request: request,
RelayFormat: types.RelayFormatOpenAI,
IsStream: request.IsStream(nil),
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: channelType,
UpstreamModelName: request.Model,
SupportStreamOptions: true,
},
}
require.NoError(t, helper.ModelMappedHelper(c, info, request))
require.NoError(t, helper.ApplyReasoningModelSuffix(c, info, request))
var converted any
var err error
if channelType == constant.ChannelTypeAli {
converted, err = (&ali.Adaptor{}).ConvertOpenAIRequest(c, info, request)
} else {
converted, err = (&openai.Adaptor{}).ConvertOpenAIRequest(c, info, request)
}
require.NoError(t, err)
encoded, err := common.Marshal(converted)
require.NoError(t, err)
return encoded
}
func TestChannelTestOpenAIChatCompatibility(t *testing.T) {
for _, tt := range []struct {
name string
model string
upstream string
endpoint string
channelType int
stream bool
wantLimit string
}{
{name: "GPT6 automatic", model: "gpt-6-astra", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "GPT6 explicit Azure stream", model: "gpt-6-astra", upstream: "gpt-6-astra", endpoint: string(constant.EndpointTypeOpenAI), channelType: constant.ChannelTypeAzure, stream: true, wantLimit: "max_completion_tokens"},
{name: "alias maps to GPT6", model: "customer-model", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "GPT5 alias maps to Qwen", model: "gpt-5.6-luna", upstream: "qwen-turbo", channelType: constant.ChannelTypeAli, wantLimit: "max_tokens"},
{name: "GPT5 stream", model: "gpt-5.6-luna", upstream: "gpt-5.6-luna", channelType: constant.ChannelTypeOpenAI, stream: true, wantLimit: "max_completion_tokens"},
{name: "GPT4 explicit", model: "gpt-4.1", upstream: "gpt-4.1", endpoint: string(constant.EndpointTypeOpenAI), channelType: constant.ChannelTypeOpenAI, wantLimit: "max_tokens"},
{name: "o series", model: "o3-mini", upstream: "o3-mini", channelType: constant.ChannelTypeAzure, wantLimit: "max_completion_tokens"},
} {
t.Run(tt.name, func(t *testing.T) {
request, ok := buildTestRequest(tt.model, tt.endpoint, &model.Channel{}, tt.stream).(*dto.GeneralOpenAIRequest)
require.True(t, ok)
encoded := convertChatCompatibilityRequest(t, request, tt.channelType, map[string]string{tt.model: tt.upstream})
want := map[string]any{
"model": tt.upstream,
"messages": []dto.Message{{Role: "user", Content: "hi"}},
"stream": tt.stream,
tt.wantLimit: 16,
}
if tt.stream {
want["stream_options"] = map[string]any{"include_usage": true}
}
wantJSON, err := common.Marshal(want)
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(encoded))
})
}
}
func TestOpenAIChatSamplingCompatibility(t *testing.T) {
const sampling = `{"temperature":0.2,"top_p":0.8,"logprobs":true,"top_logprobs":5}`
for _, tt := range []struct {
name string
model string
effort string
reasoning string
mapping map[string]string
zeroValues bool
wantModel string
wantEffort string
wantRole string
wantParams string
}{
{name: "GPT5.1 explicit none", model: "gpt-5.1", effort: "none", wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "GPT5.2 default none", model: "gpt-5.2", wantRole: "developer", wantParams: sampling},
{name: "GPT5.2 dated snapshot", model: "gpt-5.2-2025-12-11", wantRole: "developer", wantParams: sampling},
{name: "GPT5.4 reasoning", model: "gpt-5.4", effort: "high", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
{name: "GPT5.4 snapshot none", model: "gpt-5.4-2026-03-05", effort: "none", wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "explicit zero values", model: "gpt-5.4", effort: "none", zeroValues: true, wantEffort: "none", wantRole: "developer", wantParams: `{"temperature":0,"top_p":0,"logprobs":false}`},
{name: "GPT5 original", model: "gpt-5", wantRole: "developer", wantParams: `{}`},
{name: "GPT5.6 existing policy", model: "gpt-5.6-luna", wantRole: "developer", wantParams: `{}`},
{name: "pro variant", model: "gpt-5.2-pro-2025-12-11", wantRole: "developer", wantParams: `{}`},
{name: "chat variant", model: "gpt-5.2-chat-latest", wantRole: "developer", wantParams: `{}`},
{name: "codex model name keeps max", model: "gpt-5.1-codex-max", wantRole: "developer", wantParams: `{}`},
{name: "GPT6", model: "gpt-6-astra", wantRole: "developer", wantParams: `{}`},
{name: "GPT6 snapshot", model: "gpt-6-astra-2026-09-03", wantRole: "developer", wantParams: `{}`},
{name: "GPT6 effort suffix", model: "gpt-6-astra-high", wantModel: "gpt-6-astra", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
{name: "none effort suffix", model: "gpt-5.2-none", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "modifier overrides explicit effort", model: "gpt-5.2@thinking:off", effort: "high", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "mapped modifier wins", model: "customer-model@thinking:off", mapping: map[string]string{"customer-model": "gpt-5.2@effort:high"}, wantModel: "gpt-5.2", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
{name: "nested reasoning disabled", model: "gpt-5.2", reasoning: `{"enabled":false}`, wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "o1 mini role exception", model: "o1-mini", wantRole: "system", wantParams: `{"top_p":0.8,"logprobs":true,"top_logprobs":5}`},
{name: "GPT4 unchanged", model: "gpt-4.1", wantRole: "system", wantParams: sampling},
{name: "future model unchanged", model: "gpt-7", wantRole: "system", wantParams: sampling},
} {
t.Run(tt.name, func(t *testing.T) {
request := &dto.GeneralOpenAIRequest{
Model: tt.model,
Messages: []dto.Message{
{Role: "system", Content: "first instruction"},
{Role: "system", Content: "second instruction"},
{Role: "user", Content: "hi"},
},
ReasoningEffort: tt.effort,
}
require.NoError(t, common.UnmarshalJsonStr(sampling, request))
if tt.reasoning != "" {
request.Reasoning = []byte(tt.reasoning)
}
if tt.zeroValues {
request.Temperature = lo.ToPtr(0.0)
request.TopP = lo.ToPtr(0.0)
request.LogProbs = lo.ToPtr(false)
request.TopLogProbs = nil
}
encoded := convertChatCompatibilityRequest(t, request, constant.ChannelTypeOpenAI, tt.mapping)
var want map[string]any
require.NoError(t, common.UnmarshalJsonStr(tt.wantParams, &want))
want["model"] = tt.model
if tt.wantModel != "" {
want["model"] = tt.wantModel
}
want["messages"] = []dto.Message{
{Role: tt.wantRole, Content: "first instruction"},
{Role: "system", Content: "second instruction"},
{Role: "user", Content: "hi"},
}
if tt.wantEffort != "" {
want["reasoning_effort"] = tt.wantEffort
}
wantJSON, err := common.Marshal(want)
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(encoded))
})
}
}
func TestOpenAIChatTokenLimitCompatibility(t *testing.T) {
for _, modelName := range []string{"gpt-5", "o3-mini", "gpt-6-astra"} {
for _, tt := range []struct {
name string
input string
want string
}{
{name: "omitted", input: `{}`, want: `{}`},
{name: "legacy only", input: `{"max_tokens":100}`, want: `{"max_completion_tokens":100}`},
{name: "completion only", input: `{"max_completion_tokens":50}`, want: `{"max_completion_tokens":50}`},
{name: "both positive stay present", input: `{"max_tokens":100,"max_completion_tokens":50}`, want: `{"max_tokens":100,"max_completion_tokens":50}`},
{name: "zero completion falls back", input: `{"max_tokens":100,"max_completion_tokens":0}`, want: `{"max_completion_tokens":100}`},
{name: "legacy zero stays present", input: `{"max_tokens":0}`, want: `{"max_tokens":0}`},
{name: "completion zero stays present", input: `{"max_completion_tokens":0}`, want: `{"max_completion_tokens":0}`},
{name: "both zero stay present", input: `{"max_tokens":0,"max_completion_tokens":0}`, want: `{"max_tokens":0,"max_completion_tokens":0}`},
} {
t.Run(modelName+"/"+tt.name, func(t *testing.T) {
request := &dto.GeneralOpenAIRequest{Model: modelName, Messages: []dto.Message{{Role: "user", Content: "hi"}}}
require.NoError(t, common.UnmarshalJsonStr(tt.input, request))
encoded := convertChatCompatibilityRequest(t, request, constant.ChannelTypeOpenAI, nil)
want := dto.GeneralOpenAIRequest{Model: modelName, Messages: []dto.Message{{Role: "user", Content: "hi"}}}
require.NoError(t, common.UnmarshalJsonStr(tt.want, &want))
wantJSON, err := common.Marshal(want)
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(encoded))
})
}
}
}
func TestDirectOpenAIResponsesKeepsExistingParameters(t *testing.T) {
const body = `{"model":"gpt-6-astra","input":"hi","max_output_tokens":100,"temperature":0.2,"top_p":0.8,"top_logprobs":5,"include":["message.output_text.logprobs"],"reasoning":{"effort":"high"}}`
var request dto.OpenAIResponsesRequest
require.NoError(t, common.UnmarshalJsonStr(body, &request))
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-6-astra",
RelayFormat: types.RelayFormatOpenAIResponses,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenAI,
UpstreamModelName: "gpt-6-astra",
},
}
converted, err := (&openai.Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, request)
require.NoError(t, err)
encoded, err := common.Marshal(converted)
require.NoError(t, err)
assert.JSONEq(t, body, string(encoded))
}
File diff suppressed because it is too large. Load diff
+606
View File
@@ -0,0 +1,606 @@
package controller
import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newAdvancedCustomModelListChannel(baseURL string, key string, upstreamPath string, auth *dto.AdvancedCustomRouteAuth) *model.Channel {
config := &dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: dto.AdvancedCustomModelListPath,
UpstreamPath: upstreamPath,
Converter: "none",
Auth: auth,
},
},
}
channel := &model.Channel{
Type: constant.ChannelTypeAdvancedCustom,
Key: key,
BaseURL: &baseURL,
}
channel.SetOtherSettings(dto.ChannelOtherSettings{AdvancedCustom: config})
return channel
}
func TestParseOpenAIModelIDsStrictResponseContract(t *testing.T) {
tests := []struct {
name string
body string
want []string
wantError string
}{
{name: "malformed JSON", body: `{"data":`, wantError: "invalid OpenAI Models response"},
{name: "missing data", body: `{"object":"list"}`, wantError: "data is required"},
{name: "null data", body: `{"data":null}`, wantError: "data is required"},
{name: "empty data", body: `{"data":[]}`, wantError: "no valid model IDs"},
{name: "all IDs empty", body: `{"data":[{"id":""},{"id":" "}]}`, wantError: "no valid model IDs"},
{
name: "filters empty IDs and normalizes valid IDs",
body: `{"data":[{"id":" gpt-4.1 "},{"id":""},{"id":"gpt-4.1"},{"id":"o3"}]}`,
want: []string{"gpt-4.1", "o3"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
models, err := parseOpenAIModelIDs([]byte(test.body))
if test.wantError != "" {
require.ErrorContains(t, err, test.wantError)
require.Nil(t, models)
return
}
require.NoError(t, err)
require.Equal(t, test.want, models)
})
}
}
func TestFetchAdvancedCustomModelsAppliesHeaderOverrideAfterRouteAuth(t *testing.T) {
type receivedRequest struct {
Headers http.Header
Host string
}
received := make(chan receivedRequest, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received <- receivedRequest{Headers: r.Header.Clone(), Host: r.Host}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-4.1"}]}`))
}))
defer server.Close()
channel := newAdvancedCustomModelListChannel(server.URL, "secret-key", "/provider/models", &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeHeader,
Name: "X-Route-Key",
Value: "route-{api_key}",
})
headerOverride := `{
"X-Route-Key":"global-{api_key}",
"X-Static":"static-value",
"X-Client":"{client_header:X-Client}",
"Host":"models.example.test",
"*":""
}`
channel.HeaderOverride = &headerOverride
models, err := fetchChannelUpstreamModelIDs(channel)
require.NoError(t, err)
require.Equal(t, []string{"gpt-4.1"}, models)
request := <-received
require.Equal(t, "global-secret-key", request.Headers.Get("X-Route-Key"))
require.Equal(t, "static-value", request.Headers.Get("X-Static"))
require.Empty(t, request.Headers.Get("X-Client"))
require.Equal(t, "models.example.test", request.Host)
}
func TestFetchAdvancedCustomModelsUsesEnabledSavedMultiKey(t *testing.T) {
authorization := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authorization <- r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-4.1-mini"}]}`))
}))
defer server.Close()
channel := newAdvancedCustomModelListChannel(server.URL, "disabled-key\nenabled-key", "/v1/models", nil)
channel.ChannelInfo = model.ChannelInfo{
IsMultiKey: true,
MultiKeyStatusList: map[int]int{
0: common.ChannelStatusManuallyDisabled,
1: common.ChannelStatusEnabled,
},
}
models, err := fetchChannelUpstreamModelIDs(channel)
require.NoError(t, err)
require.Equal(t, []string{"gpt-4.1-mini"}, models)
require.Equal(t, "Bearer enabled-key", <-authorization)
}
func TestFetchAdvancedCustomModelsRejectsNonOKResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"data":[{"id":"must-not-be-used"}]}`))
}))
defer server.Close()
channel := newAdvancedCustomModelListChannel(server.URL, "secret-key", "/v1/models", nil)
models, err := fetchChannelUpstreamModelIDs(channel)
require.ErrorContains(t, err, "status code: 502")
require.Nil(t, models)
}
func TestFetchAdvancedCustomModelsRedactsQueryKeyFromTransportErrors(t *testing.T) {
const secret = "secret key/+"
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
baseURL := server.URL
server.Close()
channel := newAdvancedCustomModelListChannel(baseURL, secret, "/v1/models", &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "custom-token",
Value: "prefix-{api_key}",
})
_, err := fetchChannelUpstreamModelIDs(channel)
require.Error(t, err)
require.NotContains(t, err.Error(), secret)
require.NotContains(t, err.Error(), "custom-token")
require.NotContains(t, err.Error(), "prefix-")
direct := sanitizeFetchModelsError(&url.Error{
Op: http.MethodGet,
URL: baseURL + "/v1/models?custom-token=prefix-" + url.QueryEscape(secret),
Err: errors.New("connection refused"),
}, secret)
require.EqualError(t, direct, "connection refused")
queryValue := "prefix-" + secret
queryError := sanitizeAdvancedCustomRequestError(
errors.New("dial "+queryValue+": connection refused"),
queryValue,
baseURL+"/v1/models?custom-token="+url.QueryEscape(queryValue),
)
require.NotContains(t, queryError.Error(), queryValue)
require.EqualError(t, queryError, "dial [REDACTED]: connection refused")
}
func TestFetchOrdinaryOpenAIModelsKeepsExistingEmptyDataBehavior(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"object":"list"}`))
}))
defer server.Close()
baseURL := server.URL
channel := &model.Channel{
Type: constant.ChannelTypeOpenAI,
Key: "ordinary-key",
BaseURL: &baseURL,
}
models, err := fetchChannelUpstreamModelIDs(channel)
require.NoError(t, err)
require.Empty(t, models)
}
func TestFetchModelsAdvancedCustomCreatePreview(t *testing.T) {
receivedAuthorization := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuthorization <- r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"data":[{"id":"preview-model"}]}`))
}))
defer server.Close()
config := dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{
IncomingPath: dto.AdvancedCustomModelListPath,
UpstreamPath: "/preview/models",
Converter: "none",
}}}
configBytes, err := common.Marshal(config)
require.NoError(t, err)
rawConfig := string(configBytes)
baseURL := server.URL
emptyProxy := ""
req := fetchModelsRequest{
BaseURL: &baseURL,
Type: constant.ChannelTypeAdvancedCustom,
Key: "create-preview-key",
AdvancedCustom: &rawConfig,
Proxy: &emptyProxy,
}
body, err := common.Marshal(req)
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", bytes.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
FetchModels(ctx)
var response struct {
Success bool `json:"success"`
Message string `json:"message"`
Data []string `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success, response.Message)
require.Equal(t, []string{"preview-model"}, response.Data)
require.Equal(t, "Bearer create-preview-key", <-receivedAuthorization)
}
func TestFetchModelsAdvancedCustomEditPreviewUsesSavedKeyAndExplicitClears(t *testing.T) {
db := setupModelListControllerTestDB(t)
receivedHeaders := make(chan http.Header, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders <- r.Header.Clone()
_, _ = w.Write([]byte(`{"data":[{"id":"edited-preview-model"}]}`))
}))
defer server.Close()
savedChannel := newAdvancedCustomModelListChannel("http://127.0.0.1:1", "disabled-saved-key\nenabled-saved-key", "/saved/models", nil)
savedChannel.Name = "saved advanced channel"
savedChannel.Models = "old-model"
savedChannel.ChannelInfo = model.ChannelInfo{
IsMultiKey: true,
MultiKeyStatusList: map[int]int{
0: common.ChannelStatusManuallyDisabled,
1: common.ChannelStatusEnabled,
},
}
savedHeaderOverride := `{"X-Saved":"must-not-be-sent"}`
savedChannel.HeaderOverride = &savedHeaderOverride
savedChannel.SetSetting(dto.ChannelSettings{Proxy: "http://127.0.0.1:1"})
require.NoError(t, db.Create(savedChannel).Error)
preserved, err := buildAdvancedCustomModelPreviewChannel(fetchModelsRequest{ChannelID: savedChannel.Id})
require.NoError(t, err)
require.Equal(t, "http://127.0.0.1:1", preserved.GetBaseURL())
require.Equal(t, savedHeaderOverride, *preserved.HeaderOverride)
require.Equal(t, "http://127.0.0.1:1", preserved.GetSetting().Proxy)
previewConfig := dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{{
IncomingPath: dto.AdvancedCustomModelListPath,
UpstreamPath: "/edited/models",
Converter: "none",
}}}
configBytes, err := common.Marshal(previewConfig)
require.NoError(t, err)
rawConfig := string(configBytes)
baseURL := server.URL
explicitEmpty := ""
req := fetchModelsRequest{
ChannelID: savedChannel.Id,
BaseURL: &baseURL,
Type: constant.ChannelTypeAdvancedCustom,
Key: "request-key-must-be-ignored",
AdvancedCustom: &rawConfig,
HeaderOverride: &explicitEmpty,
Proxy: &explicitEmpty,
}
cleared, err := buildAdvancedCustomModelPreviewChannel(fetchModelsRequest{
ChannelID: savedChannel.Id,
BaseURL: &explicitEmpty,
AdvancedCustom: &rawConfig,
HeaderOverride: &explicitEmpty,
Proxy: &explicitEmpty,
})
require.NoError(t, err)
require.NotNil(t, cleared.BaseURL)
require.Empty(t, *cleared.BaseURL)
require.NotNil(t, cleared.HeaderOverride)
require.Empty(t, *cleared.HeaderOverride)
require.Empty(t, cleared.GetSetting().Proxy)
body, err := common.Marshal(req)
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", bytes.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
FetchModels(ctx)
var response struct {
Success bool `json:"success"`
Message string `json:"message"`
Data []string `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success, response.Message)
require.Equal(t, []string{"edited-preview-model"}, response.Data)
require.NotContains(t, recorder.Body.String(), "enabled-saved-key")
require.NotContains(t, recorder.Body.String(), "request-key-must-be-ignored")
headers := <-receivedHeaders
require.Equal(t, "Bearer enabled-saved-key", headers.Get("Authorization"))
require.Empty(t, headers.Get("X-Saved"))
}
func TestFailedAdvancedCustomDetectionDoesNotStageFullRemoval(t *testing.T) {
db := setupModelListControllerTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer server.Close()
channel := newAdvancedCustomModelListChannel(server.URL, "secret-key", "/v1/models", nil)
channel.Name = "empty discovery response"
channel.Models = "gpt-4.1,o3"
settings := channel.GetOtherSettings()
settings.UpstreamModelUpdateCheckEnabled = true
settings.UpstreamModelUpdateAutoSyncEnabled = true
channel.SetOtherSettings(settings)
require.NoError(t, db.Create(channel).Error)
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, true, true)
require.ErrorContains(t, err, "no valid model IDs")
require.False(t, modelsChanged)
require.Zero(t, autoAdded)
require.Empty(t, settings.UpstreamModelUpdateLastDetectedModels)
require.Empty(t, settings.UpstreamModelUpdateLastRemovedModels)
reloaded, err := model.GetChannelById(channel.Id, true)
require.NoError(t, err)
persistedSettings := reloaded.GetOtherSettings()
require.Empty(t, persistedSettings.UpstreamModelUpdateLastDetectedModels)
require.Empty(t, persistedSettings.UpstreamModelUpdateLastRemovedModels)
require.Equal(t, "gpt-4.1,o3", reloaded.Models)
}
func TestFetchModelsUsesSharedChannelFetchBehavior(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("x-api-key") != "first-key" {
t.Errorf("unexpected x-api-key header: %s", r.Header.Get("x-api-key"))
}
if r.Header.Get("Authorization") != "" {
t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":" claude-sonnet "},{"id":"claude-sonnet"}]}`))
}))
t.Cleanup(server.Close)
body, err := common.Marshal(map[string]any{
"base_url": server.URL,
"type": constant.ChannelTypeAnthropic,
"key": "first-key\nsecond-key",
})
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", bytes.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
FetchModels(ctx)
require.Equal(t, http.StatusOK, recorder.Code)
require.JSONEq(t, `{"success":true,"message":"","data":["claude-sonnet"]}`, recorder.Body.String())
}
func TestFetchNewAPIModelsUsesOpenAIContract(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/models", r.URL.Path)
assert.Equal(t, "Bearer new-api-key", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte(`{"data":[{"id":"gpt-5"},{"id":" gpt-5-mini "}]}`))
assert.NoError(t, err)
}))
t.Cleanup(server.Close)
baseURL := server.URL
channel := &model.Channel{
Type: constant.ChannelTypeNewAPI,
Key: "new-api-key",
BaseURL: &baseURL,
}
models, err := fetchChannelUpstreamModelIDs(channel)
require.NoError(t, err)
require.Equal(t, []string{"gpt-5", "gpt-5-mini"}, models)
}
func TestNormalizeModelNames(t *testing.T) {
result := normalizeModelNames([]string{
" gpt-4o ",
"",
"gpt-4o",
"gpt-4.1",
" ",
})
require.Equal(t, []string{"gpt-4o", "gpt-4.1"}, result)
}
func TestMergeModelNames(t *testing.T) {
result := mergeModelNames(
[]string{"gpt-4o", "gpt-4.1"},
[]string{"gpt-4.1", " gpt-4.1-mini ", "gpt-4o"},
)
require.Equal(t, []string{"gpt-4o", "gpt-4.1", "gpt-4.1-mini"}, result)
}
func TestSubtractModelNames(t *testing.T) {
result := subtractModelNames(
[]string{"gpt-4o", "gpt-4.1", "gpt-4.1-mini"},
[]string{"gpt-4.1", "not-exists"},
)
require.Equal(t, []string{"gpt-4o", "gpt-4.1-mini"}, result)
}
func TestIntersectModelNames(t *testing.T) {
result := intersectModelNames(
[]string{"gpt-4o", "gpt-4.1", "gpt-4.1", "not-exists"},
[]string{"gpt-4.1", "gpt-4o-mini", "gpt-4o"},
)
require.Equal(t, []string{"gpt-4o", "gpt-4.1"}, result)
}
func TestApplySelectedModelChanges(t *testing.T) {
t.Run("add and remove together", func(t *testing.T) {
result := applySelectedModelChanges(
[]string{"gpt-4o", "gpt-4.1", "claude-3"},
[]string{"gpt-4.1-mini"},
[]string{"claude-3"},
)
require.Equal(t, []string{"gpt-4o", "gpt-4.1", "gpt-4.1-mini"}, result)
})
t.Run("add wins when conflict with remove", func(t *testing.T) {
result := applySelectedModelChanges(
[]string{"gpt-4o"},
[]string{"gpt-4.1"},
[]string{"gpt-4.1"},
)
require.Equal(t, []string{"gpt-4o", "gpt-4.1"}, result)
})
}
func TestCollectPendingApplyUpstreamModelChanges(t *testing.T) {
settings := dto.ChannelOtherSettings{
UpstreamModelUpdateLastDetectedModels: []string{" gpt-4o ", "gpt-4o", "gpt-4.1"},
UpstreamModelUpdateLastRemovedModels: []string{" old-model ", "", "old-model"},
}
pendingAddModels, pendingRemoveModels := collectPendingApplyUpstreamModelChanges(settings)
require.Equal(t, []string{"gpt-4o", "gpt-4.1"}, pendingAddModels)
require.Equal(t, []string{"old-model"}, pendingRemoveModels)
}
func TestNormalizeChannelModelMapping(t *testing.T) {
modelMapping := `{
" alias-model ": " upstream-model ",
"": "invalid",
"invalid-target": ""
}`
channel := &model.Channel{
ModelMapping: &modelMapping,
}
result := normalizeChannelModelMapping(channel)
require.Equal(t, map[string]string{
"alias-model": "upstream-model",
}, result)
}
func TestCollectPendingUpstreamModelChangesFromModels_WithModelMapping(t *testing.T) {
pendingAddModels, pendingRemoveModels := collectPendingUpstreamModelChangesFromModels(
[]string{"alias-model", "gpt-4o", "stale-model"},
[]string{"gpt-4o", "gpt-4.1", "mapped-target"},
[]string{"gpt-4.1"},
map[string]string{
"alias-model": "mapped-target",
},
)
require.Equal(t, []string{}, pendingAddModels)
require.Equal(t, []string{"stale-model"}, pendingRemoveModels)
}
func TestCollectPendingUpstreamModelChangesFromModels_WithIgnoredRegexPatterns(t *testing.T) {
pendingAddModels, pendingRemoveModels := collectPendingUpstreamModelChangesFromModels(
[]string{"gpt-4o"},
[]string{"gpt-4o", "claude-3-5-sonnet", "sora-video", "gpt-4.1"},
[]string{"regex:^sora-.*$", "gpt-4.1"},
nil,
)
require.Equal(t, []string{"claude-3-5-sonnet"}, pendingAddModels)
require.Equal(t, []string{}, pendingRemoveModels)
}
func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *testing.T) {
channelSummaries := make([]upstreamModelUpdateChannelSummary, 0, 12)
for i := range 12 {
channelSummaries = append(channelSummaries, upstreamModelUpdateChannelSummary{
ChannelName: "channel-" + string(rune('A'+i)),
AddCount: i + 1,
RemoveCount: i,
})
}
content := buildUpstreamModelUpdateTaskNotificationContent(
24,
12,
56,
21,
9,
[]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},
channelSummaries,
[]string{
"gpt-4.1", "gpt-4.1-mini", "o3", "o4-mini", "gemini-2.5-pro", "claude-3.7-sonnet",
"qwen-max", "deepseek-r1", "llama-3.3-70b", "mistral-large", "command-r-plus", "doubao-pro-32k",
"hunyuan-large",
},
[]string{
"gpt-3.5-turbo", "claude-2.1", "gemini-1.5-pro", "mixtral-8x7b", "qwen-plus", "glm-4",
"yi-large", "moonshot-v1", "doubao-lite",
},
)
require.Contains(t, content, "其余 4 个渠道已省略")
require.Contains(t, content, "其余 1 个已省略")
require.Contains(t, content, "失败渠道 ID(展示 10/12)")
require.Contains(t, content, "其余 2 个已省略")
}
func TestShouldSendUpstreamModelUpdateNotification(t *testing.T) {
channelUpstreamModelUpdateNotifyState.Lock()
channelUpstreamModelUpdateNotifyState.lastNotifiedAt = 0
channelUpstreamModelUpdateNotifyState.lastChangedChannels = 0
channelUpstreamModelUpdateNotifyState.lastFailedChannels = 0
channelUpstreamModelUpdateNotifyState.Unlock()
baseTime := int64(2000000)
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime, 6, 0))
require.False(t, shouldSendUpstreamModelUpdateNotification(baseTime+3600, 6, 0))
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+3600, 7, 0))
require.False(t, shouldSendUpstreamModelUpdateNotification(baseTime+7200, 7, 0))
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+8000, 0, 3))
require.False(t, shouldSendUpstreamModelUpdateNotification(baseTime+9000, 0, 3))
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+10000, 0, 4))
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90000, 7, 0))
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90001, 0, 0))
}
func TestDetectAllChannelUpstreamModelUpdatesRejectsExistingActiveTask(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{}))
existing, err := model.CreateSystemTask(model.SystemTaskTypeModelUpdate, nil, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/upstream-models/detect-all", nil)
DetectAllChannelUpstreamModelUpdates(ctx)
require.Equal(t, http.StatusConflict, recorder.Code)
require.Contains(t, recorder.Body.String(), existing.TaskID)
require.Contains(t, recorder.Body.String(), "已有模型更新任务正在运行或等待中")
}
+72
View File
@@ -0,0 +1,72 @@
package controller
import (
"fmt"
"net/http"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
// GetCheckinStatus 获取用户签到状态和历史记录
func GetCheckinStatus(c *gin.Context) {
setting := operation_setting.GetCheckinSetting()
if !setting.Enabled {
common.ApiErrorMsg(c, "签到功能未启用")
return
}
userId := c.GetInt("id")
// 获取月份参数,默认为当前月份
month := c.DefaultQuery("month", time.Now().Format("2006-01"))
stats, err := model.GetUserCheckinStats(userId, month)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"enabled": setting.Enabled,
"min_quota": setting.MinQuota,
"max_quota": setting.MaxQuota,
"stats": stats,
},
})
}
// DoCheckin 执行用户签到
func DoCheckin(c *gin.Context) {
setting := operation_setting.GetCheckinSetting()
if !setting.Enabled {
common.ApiErrorMsg(c, "签到功能未启用")
return
}
userId := c.GetInt("id")
checkin, err := model.UserCheckin(userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("用户签到,获得额度 %s", logger.LogQuota(checkin.QuotaAwarded)))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "签到成功",
"data": gin.H{
"quota_awarded": checkin.QuotaAwarded,
"checkin_date": checkin.CheckinDate},
})
}
+165
View File
@@ -0,0 +1,165 @@
package controller
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/codex"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func GetCodexChannelUsage(c *gin.Context) {
fetchCodexChannelWhamData(
c,
service.FetchCodexWhamUsage,
"failed to fetch codex usage",
"获取用量信息失败,请稍后重试",
)
}
func GetCodexChannelRateLimitResetCredits(c *gin.Context) {
fetchCodexChannelWhamData(
c,
service.FetchCodexWhamRateLimitResetCredits,
"failed to fetch codex reset credits",
"获取重置次数详情失败,请稍后重试",
)
}
func ResetCodexChannelUsage(c *gin.Context) {
fetchCodexChannelWhamData(
c,
service.ConsumeCodexWhamRateLimitResetCredit,
"failed to reset codex usage",
"重置用量失败,请稍后重试",
)
}
type codexWhamFetchFunc func(
ctx context.Context,
client *http.Client,
baseURL string,
accessToken string,
accountID string,
) (statusCode int, body []byte, err error)
func fetchCodexChannelWhamData(
c *gin.Context,
fetch codexWhamFetchFunc,
logPrefix string,
userMessage string,
) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("invalid channel id: %w", err))
return
}
ch, err := model.GetChannelById(channelId, true)
if err != nil {
common.ApiError(c, err)
return
}
if ch == nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel not found"})
return
}
if ch.Type != constant.ChannelTypeCodex {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel type is not Codex"})
return
}
if ch.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "multi-key channel is not supported"})
return
}
oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key))
if err != nil {
common.SysError("failed to parse oauth key: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析凭证失败,请检查渠道配置"})
return
}
accessToken := strings.TrimSpace(oauthKey.AccessToken)
accountID := strings.TrimSpace(oauthKey.AccountID)
if accessToken == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: access_token is required"})
return
}
if accountID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: account_id is required"})
return
}
client, err := service.GetHttpClientWithProxy(ch.GetSetting().Proxy)
if err != nil {
common.ApiError(c, err)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
statusCode, body, err := fetch(ctx, client, ch.GetBaseURL(), accessToken, accountID)
if err != nil {
common.SysError(logPrefix + ": " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": userMessage})
return
}
if (statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden) && strings.TrimSpace(oauthKey.RefreshToken) != "" {
refreshCtx, refreshCancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer refreshCancel()
res, refreshErr := service.RefreshCodexOAuthTokenWithProxy(refreshCtx, oauthKey.RefreshToken, ch.GetSetting().Proxy)
if refreshErr == nil {
oauthKey.AccessToken = res.AccessToken
oauthKey.RefreshToken = res.RefreshToken
oauthKey.LastRefresh = time.Now().Format(time.RFC3339)
oauthKey.Expired = res.ExpiresAt.Format(time.RFC3339)
if strings.TrimSpace(oauthKey.Type) == "" {
oauthKey.Type = "codex"
}
encoded, encErr := common.Marshal(oauthKey)
if encErr == nil {
_ = model.DB.Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error
model.InitChannelCache()
}
ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel2()
statusCode, body, err = fetch(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID)
if err != nil {
common.SysError(logPrefix + " after refresh: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": userMessage})
return
}
}
}
var payload any
if common.Unmarshal(body, &payload) != nil {
payload = string(body)
}
ok := statusCode >= 200 && statusCode < 300
resp := gin.H{
"success": ok,
"message": "",
"upstream_status": statusCode,
"data": payload,
}
if !ok {
resp["message"] = fmt.Sprintf("upstream status: %d", statusCode)
}
c.JSON(http.StatusOK, resp)
}
+606
View File
@@ -0,0 +1,606 @@
package controller
import (
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
// CustomOAuthProviderResponse is the response structure for custom OAuth providers
// It excludes sensitive fields like client_secret
type CustomOAuthProviderResponse struct {
Id int `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Icon string `json:"icon"`
Enabled bool `json:"enabled"`
ClientId string `json:"client_id"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"user_info_endpoint"`
Scopes string `json:"scopes"`
UserIdField string `json:"user_id_field"`
UsernameField string `json:"username_field"`
DisplayNameField string `json:"display_name_field"`
EmailField string `json:"email_field"`
WellKnown string `json:"well_known"`
AuthStyle int `json:"auth_style"`
AccessPolicy string `json:"access_policy"`
AccessDeniedMessage string `json:"access_denied_message"`
}
type UserOAuthBindingResponse struct {
ProviderId int `json:"provider_id"`
ProviderName string `json:"provider_name"`
ProviderSlug string `json:"provider_slug"`
ProviderIcon string `json:"provider_icon"`
ProviderUserId string `json:"provider_user_id"`
}
func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthProviderResponse {
return &CustomOAuthProviderResponse{
Id: p.Id,
Name: p.Name,
Slug: p.Slug,
Icon: p.Icon,
Enabled: p.Enabled,
ClientId: p.ClientId,
AuthorizationEndpoint: p.AuthorizationEndpoint,
TokenEndpoint: p.TokenEndpoint,
UserInfoEndpoint: p.UserInfoEndpoint,
Scopes: p.Scopes,
UserIdField: p.UserIdField,
UsernameField: p.UsernameField,
DisplayNameField: p.DisplayNameField,
EmailField: p.EmailField,
WellKnown: p.WellKnown,
AuthStyle: p.AuthStyle,
AccessPolicy: p.AccessPolicy,
AccessDeniedMessage: p.AccessDeniedMessage,
}
}
// GetCustomOAuthProviders returns all custom OAuth providers
func GetCustomOAuthProviders(c *gin.Context) {
providers, err := model.GetAllCustomOAuthProviders()
if err != nil {
common.ApiError(c, err)
return
}
response := make([]*CustomOAuthProviderResponse, len(providers))
for i, p := range providers {
response[i] = toCustomOAuthProviderResponse(p)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": response,
})
}
// GetCustomOAuthProvider returns a single custom OAuth provider by ID
func GetCustomOAuthProvider(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiErrorMsg(c, "无效的 ID")
return
}
provider, err := model.GetCustomOAuthProviderById(id)
if err != nil {
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": toCustomOAuthProviderResponse(provider),
})
}
// CreateCustomOAuthProviderRequest is the request structure for creating a custom OAuth provider
type CreateCustomOAuthProviderRequest struct {
Name string `json:"name" binding:"required"`
Slug string `json:"slug" binding:"required"`
Icon string `json:"icon"`
Enabled bool `json:"enabled"`
ClientId string `json:"client_id" binding:"required"`
ClientSecret string `json:"client_secret" binding:"required"`
AuthorizationEndpoint string `json:"authorization_endpoint" binding:"required"`
TokenEndpoint string `json:"token_endpoint" binding:"required"`
UserInfoEndpoint string `json:"user_info_endpoint" binding:"required"`
Scopes string `json:"scopes"`
UserIdField string `json:"user_id_field"`
UsernameField string `json:"username_field"`
DisplayNameField string `json:"display_name_field"`
EmailField string `json:"email_field"`
WellKnown string `json:"well_known"`
AuthStyle int `json:"auth_style"`
AccessPolicy string `json:"access_policy"`
AccessDeniedMessage string `json:"access_denied_message"`
}
type FetchCustomOAuthDiscoveryRequest struct {
WellKnownURL string `json:"well_known_url"`
IssuerURL string `json:"issuer_url"`
}
// FetchCustomOAuthDiscovery fetches OIDC discovery document via backend (root-only route)
func FetchCustomOAuthDiscovery(c *gin.Context) {
var req FetchCustomOAuthDiscoveryRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
return
}
wellKnownURL := strings.TrimSpace(req.WellKnownURL)
issuerURL := strings.TrimSpace(req.IssuerURL)
if wellKnownURL == "" && issuerURL == "" {
common.ApiErrorMsg(c, "请先填写 Discovery URL 或 Issuer URL")
return
}
targetURL := wellKnownURL
if targetURL == "" {
targetURL = strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
}
targetURL = strings.TrimSpace(targetURL)
parsedURL, err := url.Parse(targetURL)
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
common.ApiErrorMsg(c, "Discovery URL 无效,仅支持 http/https")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 20*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
common.ApiErrorMsg(c, "创建 Discovery 请求失败: "+err.Error())
return
}
httpReq.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
message := strings.TrimSpace(string(body))
if message == "" {
message = resp.Status
}
common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+message)
return
}
var discovery map[string]any
if err = common.DecodeJson(resp.Body, &discovery); err != nil {
common.ApiErrorMsg(c, "解析 Discovery 配置失败: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"well_known_url": targetURL,
"discovery": discovery,
},
})
}
// CreateCustomOAuthProvider creates a new custom OAuth provider
func CreateCustomOAuthProvider(c *gin.Context) {
var req CreateCustomOAuthProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
return
}
// Check if slug is already taken
if model.IsSlugTaken(req.Slug, 0) {
common.ApiErrorMsg(c, "该 Slug 已被使用")
return
}
// Check if slug conflicts with built-in providers
if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
common.ApiErrorMsg(c, "该 Slug 与内置 OAuth 提供商冲突")
return
}
provider := &model.CustomOAuthProvider{
Name: req.Name,
Slug: req.Slug,
Icon: req.Icon,
Enabled: req.Enabled,
ClientId: req.ClientId,
ClientSecret: req.ClientSecret,
AuthorizationEndpoint: req.AuthorizationEndpoint,
TokenEndpoint: req.TokenEndpoint,
UserInfoEndpoint: req.UserInfoEndpoint,
Scopes: req.Scopes,
UserIdField: req.UserIdField,
UsernameField: req.UsernameField,
DisplayNameField: req.DisplayNameField,
EmailField: req.EmailField,
WellKnown: req.WellKnown,
AuthStyle: req.AuthStyle,
AccessPolicy: req.AccessPolicy,
AccessDeniedMessage: req.AccessDeniedMessage,
}
if err := model.CreateCustomOAuthProvider(provider); err != nil {
common.ApiError(c, err)
return
}
// Register the provider in the OAuth registry
oauth.RegisterOrUpdateCustomProvider(provider)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "创建成功",
"data": toCustomOAuthProviderResponse(provider),
})
}
// UpdateCustomOAuthProviderRequest is the request structure for updating a custom OAuth provider
type UpdateCustomOAuthProviderRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Icon *string `json:"icon"` // Optional: if nil, keep existing
Enabled *bool `json:"enabled"` // Optional: if nil, keep existing
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"` // Optional: if empty, keep existing
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"user_info_endpoint"`
Scopes string `json:"scopes"`
UserIdField string `json:"user_id_field"`
UsernameField string `json:"username_field"`
DisplayNameField string `json:"display_name_field"`
EmailField string `json:"email_field"`
WellKnown *string `json:"well_known"` // Optional: if nil, keep existing
AuthStyle *int `json:"auth_style"` // Optional: if nil, keep existing
AccessPolicy *string `json:"access_policy"` // Optional: if nil, keep existing
AccessDeniedMessage *string `json:"access_denied_message"` // Optional: if nil, keep existing
}
// UpdateCustomOAuthProvider updates an existing custom OAuth provider
func UpdateCustomOAuthProvider(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiErrorMsg(c, "无效的 ID")
return
}
var req UpdateCustomOAuthProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
return
}
// Get existing provider
provider, err := model.GetCustomOAuthProviderById(id)
if err != nil {
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
return
}
oldSlug := provider.Slug
// Check if new slug is taken by another provider
if req.Slug != "" && req.Slug != provider.Slug {
if model.IsSlugTaken(req.Slug, id) {
common.ApiErrorMsg(c, "该 Slug 已被使用")
return
}
// Check if slug conflicts with built-in providers
if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
common.ApiErrorMsg(c, "该 Slug 与内置 OAuth 提供商冲突")
return
}
}
// Update fields
if req.Name != "" {
provider.Name = req.Name
}
if req.Slug != "" {
provider.Slug = req.Slug
}
if req.Icon != nil {
provider.Icon = *req.Icon
}
if req.Enabled != nil {
provider.Enabled = *req.Enabled
}
if req.ClientId != "" {
provider.ClientId = req.ClientId
}
if req.ClientSecret != "" {
provider.ClientSecret = req.ClientSecret
}
if req.AuthorizationEndpoint != "" {
provider.AuthorizationEndpoint = req.AuthorizationEndpoint
}
if req.TokenEndpoint != "" {
provider.TokenEndpoint = req.TokenEndpoint
}
if req.UserInfoEndpoint != "" {
provider.UserInfoEndpoint = req.UserInfoEndpoint
}
if req.Scopes != "" {
provider.Scopes = req.Scopes
}
if req.UserIdField != "" {
provider.UserIdField = req.UserIdField
}
if req.UsernameField != "" {
provider.UsernameField = req.UsernameField
}
if req.DisplayNameField != "" {
provider.DisplayNameField = req.DisplayNameField
}
if req.EmailField != "" {
provider.EmailField = req.EmailField
}
if req.WellKnown != nil {
provider.WellKnown = *req.WellKnown
}
if req.AuthStyle != nil {
provider.AuthStyle = *req.AuthStyle
}
if req.AccessPolicy != nil {
provider.AccessPolicy = *req.AccessPolicy
}
if req.AccessDeniedMessage != nil {
provider.AccessDeniedMessage = *req.AccessDeniedMessage
}
if err := model.UpdateCustomOAuthProvider(provider); err != nil {
common.ApiError(c, err)
return
}
// Update the provider in the OAuth registry
if oldSlug != provider.Slug {
oauth.UnregisterCustomProvider(oldSlug)
}
oauth.RegisterOrUpdateCustomProvider(provider)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "更新成功",
"data": toCustomOAuthProviderResponse(provider),
})
}
// DeleteCustomOAuthProvider deletes a custom OAuth provider
func DeleteCustomOAuthProvider(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiErrorMsg(c, "无效的 ID")
return
}
// Get existing provider to get slug
provider, err := model.GetCustomOAuthProviderById(id)
if err != nil {
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
return
}
// Check if there are any user bindings
count, err := model.GetBindingCountByProviderId(id)
if err != nil {
common.SysError("Failed to get binding count for provider " + strconv.Itoa(id) + ": " + err.Error())
common.ApiErrorMsg(c, "检查用户绑定时发生错误,请稍后重试")
return
}
if count > 0 {
common.ApiErrorMsg(c, "该 OAuth 提供商还有用户绑定,无法删除。请先解除所有用户绑定。")
return
}
if err := model.DeleteCustomOAuthProvider(id); err != nil {
common.ApiError(c, err)
return
}
// Unregister the provider from the OAuth registry
oauth.UnregisterCustomProvider(provider.Slug)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "删除成功",
})
}
func buildUserOAuthBindingsResponse(userId int) ([]UserOAuthBindingResponse, error) {
bindings, err := model.GetUserOAuthBindingsByUserId(userId)
if err != nil {
return nil, err
}
response := make([]UserOAuthBindingResponse, 0, len(bindings))
for _, binding := range bindings {
provider, err := model.GetCustomOAuthProviderById(binding.ProviderId)
if err != nil {
continue
}
response = append(response, UserOAuthBindingResponse{
ProviderId: binding.ProviderId,
ProviderName: provider.Name,
ProviderSlug: provider.Slug,
ProviderIcon: provider.Icon,
ProviderUserId: binding.ProviderUserId,
})
}
return response, nil
}
// GetUserOAuthBindings returns all OAuth bindings for the current user
func GetUserOAuthBindings(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
common.ApiErrorMsg(c, "未登录")
return
}
response, err := buildUserOAuthBindingsResponse(userId)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": response,
})
}
func GetUserOAuthBindingsByAdmin(c *gin.Context) {
userIdStr := c.Param("id")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
common.ApiErrorMsg(c, "invalid user id")
return
}
targetUser, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, targetUser.Role) {
common.ApiErrorMsg(c, "no permission")
return
}
response, err := buildUserOAuthBindingsResponse(userId)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": response,
})
}
// UnbindCustomOAuth unbinds a custom OAuth provider from the current user
func UnbindCustomOAuth(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
providerIdStr := c.Param("provider_id")
providerId, err := strconv.Atoi(providerIdStr)
if err != nil || providerId <= 0 {
common.ApiErrorMsg(c, "无效的提供商 ID")
return
}
succeeded, notificationFailed := false, false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.binding_unbind", map[string]any{"provider_id": providerId, "success": succeeded, "notification_failed": notificationFailed})
}()
context, err := common.Marshal(service.AccountUnbindingContext{ProviderID: providerId})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountUnbind, Context: context}) == nil {
return
}
if err := service.UnbindAccountOAuth(identity, providerId); err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded = true
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
writeSecurityOperationError(c, err)
return
}
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "Login account unlinked") != nil
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "解绑成功",
"data": gin.H{"notification_warning": notificationFailed},
})
}
func UnbindCustomOAuthByAdmin(c *gin.Context) {
userIdStr := c.Param("id")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
common.ApiErrorMsg(c, "invalid user id")
return
}
targetUser, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, targetUser.Role) {
common.ApiErrorMsg(c, "no permission")
return
}
providerIdStr := c.Param("provider_id")
providerId, err := strconv.Atoi(providerIdStr)
if err != nil {
common.ApiErrorMsg(c, "invalid provider id")
return
}
if err := model.DeleteUserOAuthBinding(userId, providerId); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "success",
})
}
+807
View File
@@ -0,0 +1,807 @@
package controller
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/ionet"
"github.com/gin-gonic/gin"
)
func getIoAPIKey(c *gin.Context) (string, bool) {
common.OptionMapRWMutex.RLock()
enabled := common.OptionMap["model_deployment.ionet.enabled"] == "true"
apiKey := common.OptionMap["model_deployment.ionet.api_key"]
common.OptionMapRWMutex.RUnlock()
if !enabled || strings.TrimSpace(apiKey) == "" {
common.ApiErrorMsg(c, "io.net model deployment is not enabled or api key missing")
return "", false
}
return apiKey, true
}
func GetModelDeploymentSettings(c *gin.Context) {
common.OptionMapRWMutex.RLock()
enabled := common.OptionMap["model_deployment.ionet.enabled"] == "true"
hasAPIKey := strings.TrimSpace(common.OptionMap["model_deployment.ionet.api_key"]) != ""
common.OptionMapRWMutex.RUnlock()
common.ApiSuccess(c, gin.H{
"provider": "io.net",
"enabled": enabled,
"configured": hasAPIKey,
"can_connect": enabled && hasAPIKey,
})
}
func getIoClient(c *gin.Context) (*ionet.Client, bool) {
apiKey, ok := getIoAPIKey(c)
if !ok {
return nil, false
}
return ionet.NewClient(apiKey), true
}
func getIoEnterpriseClient(c *gin.Context) (*ionet.Client, bool) {
apiKey, ok := getIoAPIKey(c)
if !ok {
return nil, false
}
return ionet.NewEnterpriseClient(apiKey), true
}
func TestIoNetConnection(c *gin.Context) {
var req struct {
APIKey string `json:"api_key"`
}
rawBody, err := c.GetRawData()
if err != nil {
common.ApiError(c, err)
return
}
if len(bytes.TrimSpace(rawBody)) > 0 {
if err := json.Unmarshal(rawBody, &req); err != nil {
common.ApiErrorMsg(c, "invalid request payload")
return
}
}
apiKey := strings.TrimSpace(req.APIKey)
if apiKey == "" {
common.OptionMapRWMutex.RLock()
storedKey := strings.TrimSpace(common.OptionMap["model_deployment.ionet.api_key"])
common.OptionMapRWMutex.RUnlock()
if storedKey == "" {
common.ApiErrorMsg(c, "api_key is required")
return
}
apiKey = storedKey
}
client := ionet.NewEnterpriseClient(apiKey)
result, err := client.GetMaxGPUsPerContainer()
if err != nil {
if apiErr, ok := err.(*ionet.APIError); ok {
message := strings.TrimSpace(apiErr.Message)
if message == "" {
message = "failed to validate api key"
}
common.ApiErrorMsg(c, message)
return
}
common.ApiError(c, err)
return
}
totalHardware := 0
totalAvailable := 0
if result != nil {
totalHardware = len(result.Hardware)
totalAvailable = result.Total
if totalAvailable == 0 {
for _, hw := range result.Hardware {
totalAvailable += hw.Available
}
}
}
common.ApiSuccess(c, gin.H{
"hardware_count": totalHardware,
"total_available": totalAvailable,
})
}
func requireDeploymentID(c *gin.Context) (string, bool) {
deploymentID := strings.TrimSpace(c.Param("id"))
if deploymentID == "" {
common.ApiErrorMsg(c, "deployment ID is required")
return "", false
}
return deploymentID, true
}
func requireContainerID(c *gin.Context) (string, bool) {
containerID := strings.TrimSpace(c.Param("container_id"))
if containerID == "" {
common.ApiErrorMsg(c, "container ID is required")
return "", false
}
return containerID, true
}
func mapIoNetDeployment(d ionet.Deployment) map[string]any {
var created int64
if d.CreatedAt.IsZero() {
created = time.Now().Unix()
} else {
created = d.CreatedAt.Unix()
}
timeRemainingHours := d.ComputeMinutesRemaining / 60
timeRemainingMins := d.ComputeMinutesRemaining % 60
var timeRemaining string
if timeRemainingHours > 0 {
timeRemaining = fmt.Sprintf("%d hour %d minutes", timeRemainingHours, timeRemainingMins)
} else if timeRemainingMins > 0 {
timeRemaining = fmt.Sprintf("%d minutes", timeRemainingMins)
} else {
timeRemaining = "completed"
}
hardwareInfo := fmt.Sprintf("%s %s x%d", d.BrandName, d.HardwareName, d.HardwareQuantity)
return map[string]any{
"id": d.ID,
"deployment_name": d.Name,
"container_name": d.Name,
"status": strings.ToLower(d.Status),
"type": "Container",
"time_remaining": timeRemaining,
"time_remaining_minutes": d.ComputeMinutesRemaining,
"hardware_info": hardwareInfo,
"hardware_name": d.HardwareName,
"brand_name": d.BrandName,
"hardware_quantity": d.HardwareQuantity,
"completed_percent": d.CompletedPercent,
"compute_minutes_served": d.ComputeMinutesServed,
"compute_minutes_remaining": d.ComputeMinutesRemaining,
"created_at": created,
"updated_at": created,
"model_name": "",
"model_version": "",
"instance_count": d.HardwareQuantity,
"resource_config": map[string]any{
"cpu": "",
"memory": "",
"gpu": strconv.Itoa(d.HardwareQuantity),
},
"description": "",
"provider": "io.net",
}
}
func computeStatusCounts(total int, deployments []ionet.Deployment) map[string]int64 {
counts := map[string]int64{
"all": int64(total),
}
for _, status := range []string{"running", "completed", "failed", "deployment requested", "termination requested", "destroyed"} {
counts[status] = 0
}
for _, d := range deployments {
status := strings.ToLower(strings.TrimSpace(d.Status))
counts[status] = counts[status] + 1
}
return counts
}
func GetAllDeployments(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
status := c.Query("status")
opts := &ionet.ListDeploymentsOptions{
Status: strings.ToLower(strings.TrimSpace(status)),
Page: pageInfo.GetPage(),
PageSize: pageInfo.GetPageSize(),
SortBy: "created_at",
SortOrder: "desc",
}
dl, err := client.ListDeployments(opts)
if err != nil {
common.ApiError(c, err)
return
}
items := make([]map[string]any, 0, len(dl.Deployments))
for _, d := range dl.Deployments {
items = append(items, mapIoNetDeployment(d))
}
data := gin.H{
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"total": dl.Total,
"items": items,
"status_counts": computeStatusCounts(dl.Total, dl.Deployments),
}
common.ApiSuccess(c, data)
}
func SearchDeployments(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
keyword := strings.TrimSpace(c.Query("keyword"))
dl, err := client.ListDeployments(&ionet.ListDeploymentsOptions{
Status: status,
Page: pageInfo.GetPage(),
PageSize: pageInfo.GetPageSize(),
SortBy: "created_at",
SortOrder: "desc",
})
if err != nil {
common.ApiError(c, err)
return
}
filtered := make([]ionet.Deployment, 0, len(dl.Deployments))
if keyword == "" {
filtered = dl.Deployments
} else {
kw := strings.ToLower(keyword)
for _, d := range dl.Deployments {
if strings.Contains(strings.ToLower(d.Name), kw) {
filtered = append(filtered, d)
}
}
}
items := make([]map[string]any, 0, len(filtered))
for _, d := range filtered {
items = append(items, mapIoNetDeployment(d))
}
total := dl.Total
if keyword != "" {
total = len(filtered)
}
data := gin.H{
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"total": total,
"items": items,
}
common.ApiSuccess(c, data)
}
func GetDeployment(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
details, err := client.GetDeployment(deploymentID)
if err != nil {
common.ApiError(c, err)
return
}
data := map[string]any{
"id": details.ID,
"deployment_name": details.ID,
"model_name": "",
"model_version": "",
"status": strings.ToLower(details.Status),
"instance_count": details.TotalContainers,
"hardware_id": details.HardwareID,
"resource_config": map[string]any{
"cpu": "",
"memory": "",
"gpu": strconv.Itoa(details.TotalGPUs),
},
"created_at": details.CreatedAt.Unix(),
"updated_at": details.CreatedAt.Unix(),
"description": "",
"amount_paid": details.AmountPaid,
"completed_percent": details.CompletedPercent,
"gpus_per_container": details.GPUsPerContainer,
"total_gpus": details.TotalGPUs,
"total_containers": details.TotalContainers,
"hardware_name": details.HardwareName,
"brand_name": details.BrandName,
"compute_minutes_served": details.ComputeMinutesServed,
"compute_minutes_remaining": details.ComputeMinutesRemaining,
"locations": details.Locations,
"container_config": details.ContainerConfig,
}
common.ApiSuccess(c, data)
}
func UpdateDeploymentName(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
updateReq := &ionet.UpdateClusterNameRequest{
Name: strings.TrimSpace(req.Name),
}
if updateReq.Name == "" {
common.ApiErrorMsg(c, "deployment name cannot be empty")
return
}
available, err := client.CheckClusterNameAvailability(updateReq.Name)
if err != nil {
common.ApiError(c, fmt.Errorf("failed to check name availability: %w", err))
return
}
if !available {
common.ApiErrorMsg(c, "deployment name is not available, please choose a different name")
return
}
resp, err := client.UpdateClusterName(deploymentID, updateReq)
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"status": resp.Status,
"message": resp.Message,
"id": deploymentID,
"name": updateReq.Name,
}
common.ApiSuccess(c, data)
}
func UpdateDeployment(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
var req ionet.UpdateDeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
resp, err := client.UpdateDeployment(deploymentID, &req)
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"status": resp.Status,
"deployment_id": resp.DeploymentID,
}
common.ApiSuccess(c, data)
}
func ExtendDeployment(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
var req ionet.ExtendDurationRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
details, err := client.ExtendDeployment(deploymentID, &req)
if err != nil {
common.ApiError(c, err)
return
}
data := mapIoNetDeployment(ionet.Deployment{
ID: details.ID,
Status: details.Status,
Name: deploymentID,
CompletedPercent: float64(details.CompletedPercent),
HardwareQuantity: details.TotalGPUs,
BrandName: details.BrandName,
HardwareName: details.HardwareName,
ComputeMinutesServed: details.ComputeMinutesServed,
ComputeMinutesRemaining: details.ComputeMinutesRemaining,
CreatedAt: details.CreatedAt,
})
common.ApiSuccess(c, data)
}
func DeleteDeployment(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
resp, err := client.DeleteDeployment(deploymentID)
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"status": resp.Status,
"deployment_id": resp.DeploymentID,
"message": "Deployment termination requested successfully",
}
common.ApiSuccess(c, data)
}
func CreateDeployment(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
var req ionet.DeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
resp, err := client.DeployContainer(&req)
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"deployment_id": resp.DeploymentID,
"status": resp.Status,
"message": "Deployment created successfully",
}
common.ApiSuccess(c, data)
}
func GetHardwareTypes(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
hardwareTypes, totalAvailable, err := client.ListHardwareTypes()
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"hardware_types": hardwareTypes,
"total": len(hardwareTypes),
"total_available": totalAvailable,
}
common.ApiSuccess(c, data)
}
func GetLocations(c *gin.Context) {
client, ok := getIoClient(c)
if !ok {
return
}
locationsResp, err := client.ListLocations()
if err != nil {
common.ApiError(c, err)
return
}
total := locationsResp.Total
if total == 0 {
total = len(locationsResp.Locations)
}
data := gin.H{
"locations": locationsResp.Locations,
"total": total,
}
common.ApiSuccess(c, data)
}
func GetAvailableReplicas(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
hardwareIDStr := c.Query("hardware_id")
gpuCountStr := c.Query("gpu_count")
if hardwareIDStr == "" {
common.ApiErrorMsg(c, "hardware_id parameter is required")
return
}
hardwareID, err := strconv.Atoi(hardwareIDStr)
if err != nil || hardwareID <= 0 {
common.ApiErrorMsg(c, "invalid hardware_id parameter")
return
}
gpuCount := 1
if gpuCountStr != "" {
if parsed, err := strconv.Atoi(gpuCountStr); err == nil && parsed > 0 {
gpuCount = parsed
}
}
replicas, err := client.GetAvailableReplicas(hardwareID, gpuCount)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, replicas)
}
func GetPriceEstimation(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
var req ionet.PriceEstimationRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
priceResp, err := client.GetPriceEstimation(&req)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, priceResp)
}
func CheckClusterNameAvailability(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
clusterName := strings.TrimSpace(c.Query("name"))
if clusterName == "" {
common.ApiErrorMsg(c, "name parameter is required")
return
}
available, err := client.CheckClusterNameAvailability(clusterName)
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"available": available,
"name": clusterName,
}
common.ApiSuccess(c, data)
}
func GetDeploymentLogs(c *gin.Context) {
client, ok := getIoClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
containerID := c.Query("container_id")
if containerID == "" {
common.ApiErrorMsg(c, "container_id parameter is required")
return
}
level := c.Query("level")
stream := c.Query("stream")
cursor := c.Query("cursor")
limitStr := c.Query("limit")
follow := c.Query("follow") == "true"
var limit int = 100
if limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = min(parsedLimit, 1000)
}
}
opts := &ionet.GetLogsOptions{
Level: level,
Stream: stream,
Limit: limit,
Cursor: cursor,
Follow: follow,
}
if startTime := c.Query("start_time"); startTime != "" {
if t, err := time.Parse(time.RFC3339, startTime); err == nil {
opts.StartTime = &t
}
}
if endTime := c.Query("end_time"); endTime != "" {
if t, err := time.Parse(time.RFC3339, endTime); err == nil {
opts.EndTime = &t
}
}
rawLogs, err := client.GetContainerLogsRaw(deploymentID, containerID, opts)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, rawLogs)
}
func ListDeploymentContainers(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
containers, err := client.ListContainers(deploymentID)
if err != nil {
common.ApiError(c, err)
return
}
items := make([]map[string]any, 0)
if containers != nil {
items = make([]map[string]any, 0, len(containers.Workers))
for _, ctr := range containers.Workers {
events := make([]map[string]any, 0, len(ctr.ContainerEvents))
for _, event := range ctr.ContainerEvents {
events = append(events, map[string]any{
"time": event.Time.Unix(),
"message": event.Message,
})
}
items = append(items, map[string]any{
"container_id": ctr.ContainerID,
"device_id": ctr.DeviceID,
"status": strings.ToLower(strings.TrimSpace(ctr.Status)),
"hardware": ctr.Hardware,
"brand_name": ctr.BrandName,
"created_at": ctr.CreatedAt.Unix(),
"uptime_percent": ctr.UptimePercent,
"gpus_per_container": ctr.GPUsPerContainer,
"public_url": ctr.PublicURL,
"events": events,
})
}
}
response := gin.H{
"total": 0,
"containers": items,
}
if containers != nil {
response["total"] = containers.Total
}
common.ApiSuccess(c, response)
}
func GetContainerDetails(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
containerID, ok := requireContainerID(c)
if !ok {
return
}
details, err := client.GetContainerDetails(deploymentID, containerID)
if err != nil {
common.ApiError(c, err)
return
}
if details == nil {
common.ApiErrorMsg(c, "container details not found")
return
}
events := make([]map[string]any, 0, len(details.ContainerEvents))
for _, event := range details.ContainerEvents {
events = append(events, map[string]any{
"time": event.Time.Unix(),
"message": event.Message,
})
}
data := gin.H{
"deployment_id": deploymentID,
"container_id": details.ContainerID,
"device_id": details.DeviceID,
"status": strings.ToLower(strings.TrimSpace(details.Status)),
"hardware": details.Hardware,
"brand_name": details.BrandName,
"created_at": details.CreatedAt.Unix(),
"uptime_percent": details.UptimePercent,
"gpus_per_container": details.GPUsPerContainer,
"public_url": details.PublicURL,
"events": events,
}
common.ApiSuccess(c, data)
}
+112
View File
@@ -0,0 +1,112 @@
package controller
import (
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
type emailBindRequest struct {
Email string `json:"email"`
FlowToken string `json:"flow_token"`
NewCode string `json:"new_code"`
OldCode string `json:"old_code"`
}
func EmailBindStart(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
succeeded, notificationFailed := false, false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.binding_start", map[string]any{"provider": "email", "success": succeeded, "notification_failed": notificationFailed})
}()
var request emailBindRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
writeSecurityOperationError(c, service.ErrVerificationContextInvalid)
return
}
email, err := service.ValidateAccountEmail(request.Email)
if err != nil {
writeSecurityOperationError(c, err)
return
}
context, err := common.Marshal(service.AccountBindingContext{Provider: "email", Email: email})
if err != nil {
writeSecurityOperationError(c, err)
return
}
authorization := middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context})
if authorization == nil {
return
}
data, err := service.StartEmailBinding(identity, authorization, email)
if err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded, notificationFailed = true, data.NotificationWarning
common.ApiSuccess(c, data)
}
func EmailBindResend(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
succeeded := false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.email_binding_resend", map[string]any{"success": succeeded})
}()
var request emailBindRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.FlowToken == "" {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
data, err := service.ResendAccountEmailBinding(identity, request.FlowToken)
if err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded = true
common.ApiSuccess(c, data)
}
func EmailBind(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
succeeded, notificationFailed := false, false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.binding_bind", map[string]any{"provider": "email", "success": succeeded, "notification_failed": notificationFailed})
}()
var request emailBindRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.FlowToken == "" {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
state, err := service.FinishEmailBinding(identity, request.FlowToken, request.NewCode, request.OldCode)
if err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded = true
notificationFailed = service.NotifyAccountSecurityChange(state.CurrentEmail, "Email address changed") != nil
if err := service.NotifyAccountSecurityChange(state.Email, "Email address confirmed"); err != nil {
notificationFailed = true
}
if err := model.PublishUserAuthCache(identity.UserID); err != nil {
writeSecurityOperationError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"notification_warning": notificationFailed}})
}
+63
View File
@@ -0,0 +1,63 @@
package controller
import (
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
func GetGroups(c *gin.Context) {
groupNames := make([]string, 0)
for groupName := range ratio_setting.GetGroupRatioCopy() {
groupNames = append(groupNames, groupName)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": groupNames,
})
}
func GetUserGroups(c *gin.Context) {
usableGroups := make(map[string]map[string]any)
enabledGroups, err := model.GetEnabledGroupSet()
if err != nil {
common.ApiError(c, err)
return
}
userGroup := ""
userId := c.GetInt("id")
userGroup, _ = model.GetUserGroup(userId, false)
userUsableGroups := service.GetUserUsableGroups(userGroup)
for groupName, _ := range ratio_setting.GetGroupRatioCopy() {
if _, ok := enabledGroups[groupName]; !ok {
continue
}
// UserUsableGroups contains the groups that the user can use
if desc, ok := userUsableGroups[groupName]; ok {
usableGroups[groupName] = map[string]any{
"ratio": service.GetUserGroupRatio(userGroup, groupName),
"desc": desc,
}
}
}
if len(usableGroups) > 0 {
if _, ok := userUsableGroups["auto"]; ok {
usableGroups["auto"] = map[string]any{
"ratio": "自动",
"desc": setting.GetUsableGroupDescription("auto"),
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": usableGroups,
})
}
+85
View File
@@ -0,0 +1,85 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSelectableGroupsRequireEnabledChannel(t *testing.T) {
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalRatios := ratio_setting.GroupRatio2JSONString()
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"auto":"Auto","active":"Active","default":"Default","paused":"Paused","no-channel":"No channel"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"active":0.2,"default":1,"paused":0.3,"no-channel":0.4}`))
t.Cleanup(func() {
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
model.InvalidatePricingCache()
})
db := setupModelListControllerTestDB(t)
require.NoError(t, db.Create(&model.User{
Id: 7001,
Username: "group-availability-user",
Password: "password",
Group: "default",
Status: common.UserStatusEnabled,
}).Error)
require.NoError(t, db.Create(&[]model.Channel{
{Id: 11, Type: constant.ChannelTypeOpenAI, Key: "active-key", Name: "active", Status: common.ChannelStatusEnabled},
{Id: 12, Type: constant.ChannelTypeOpenAI, Key: "default-key", Name: "default", Status: common.ChannelStatusManuallyDisabled},
{Id: 13, Type: constant.ChannelTypeOpenAI, Key: "paused-key", Name: "paused", Status: common.ChannelStatusAutoDisabled},
}).Error)
require.NoError(t, db.Create(&[]model.Ability{
{Group: "active", Model: "gpt-4o", ChannelId: 11, Enabled: true},
{Group: "default", Model: "gpt-4o", ChannelId: 12, Enabled: true},
{Group: "paused", Model: "gpt-4o", ChannelId: 13, Enabled: true},
}).Error)
model.InvalidatePricingCache()
userRecorder := httptest.NewRecorder()
userContext, _ := gin.CreateTestContext(userRecorder)
userContext.Request = httptest.NewRequest(http.MethodGet, "/api/user/groups", nil)
userContext.Set("id", 7001)
GetUserGroups(userContext)
var userResponse struct {
Success bool `json:"success"`
Data map[string]map[string]interface{} `json:"data"`
}
require.NoError(t, common.Unmarshal(userRecorder.Body.Bytes(), &userResponse))
require.True(t, userResponse.Success)
assert.ElementsMatch(t, []string{"active", "auto"}, mapKeys(userResponse.Data))
pricingRecorder := httptest.NewRecorder()
pricingContext, _ := gin.CreateTestContext(pricingRecorder)
pricingContext.Request = httptest.NewRequest(http.MethodGet, "/api/pricing", nil)
GetPricing(pricingContext)
var pricingResponse struct {
Success bool `json:"success"`
GroupRatio map[string]float64 `json:"group_ratio"`
UsableGroup map[string]string `json:"usable_group"`
}
require.NoError(t, common.Unmarshal(pricingRecorder.Body.Bytes(), &pricingResponse))
require.True(t, pricingResponse.Success)
assert.Equal(t, map[string]float64{"active": 0.2}, pricingResponse.GroupRatio)
assert.Equal(t, map[string]string{"active": "Active", "auto": "Auto"}, pricingResponse.UsableGroup)
}
func mapKeys[V any](values map[string]V) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
return keys
}
+9
View File
@@ -0,0 +1,9 @@
package controller
import (
"github.com/gin-gonic/gin"
)
func GetImage(c *gin.Context) {
}
+156
View File
@@ -0,0 +1,156 @@
package controller
import (
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
func GetAllLogs(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
username := c.Query("username")
tokenName := c.Query("token_name")
modelName := c.Query("model_name")
channel, _ := strconv.Atoi(c.Query("channel"))
group := c.Query("group")
requestId := c.Query("request_id")
upstreamRequestId := c.Query("upstream_request_id")
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, upstreamRequestId)
if err != nil {
common.ApiError(c, err)
return
}
if c.GetInt("role") < common.RoleRootUser {
model.FormatAdminLogs(logs)
} else {
model.FormatRootLogs(logs)
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
return
}
func GetUserLogs(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
userId := c.GetInt("id")
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
tokenName := c.Query("token_name")
modelName := c.Query("model_name")
group := c.Query("group")
requestId := c.Query("request_id")
upstreamRequestId := c.Query("upstream_request_id")
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId, upstreamRequestId)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
return
}
// Deprecated: SearchAllLogs 已废弃,前端未使用该接口。
func SearchAllLogs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该接口已废弃",
})
}
// Deprecated: SearchUserLogs 已废弃,前端未使用该接口。
func SearchUserLogs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该接口已废弃",
})
}
func GetLogByKey(c *gin.Context) {
tokenId := c.GetInt("token_id")
if tokenId == 0 {
c.JSON(200, gin.H{
"success": false,
"message": "无效的令牌",
})
return
}
logs, err := model.GetLogByTokenId(tokenId)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
})
}
func GetLogsStat(c *gin.Context) {
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
tokenName := c.Query("token_name")
username := c.Query("username")
modelName := c.Query("model_name")
channel, _ := strconv.Atoi(c.Query("channel"))
group := c.Query("group")
stat, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group)
if err != nil {
common.ApiError(c, err)
return
}
//tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"quota": stat.Quota,
"rpm": stat.Rpm,
"tpm": stat.Tpm,
},
})
return
}
func GetLogsSelfStat(c *gin.Context) {
username := c.GetString("username")
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
tokenName := c.Query("token_name")
modelName := c.Query("model_name")
channel, _ := strconv.Atoi(c.Query("channel"))
group := c.Query("group")
quotaNum, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group)
if err != nil {
common.ApiError(c, err)
return
}
//tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName)
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"quota": quotaNum.Quota,
"rpm": quotaNum.Rpm,
"tpm": quotaNum.Tpm,
//"token": tokenNum,
},
})
return
}
+151
View File
@@ -0,0 +1,151 @@
package controller
import (
"encoding/json"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
)
func VerifyLogin(c *gin.Context) {
var request struct {
FlowToken string `json:"flow_token"`
Method string `json:"method"`
Code string `json:"code"`
}
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" || request.Code == "" {
common.ApiErrorMsg(c, "参数错误")
return
}
if request.Method == "" {
request.Method = service.VerificationMethodTwoFA
}
if request.Method != service.VerificationMethodTwoFA {
writeSecurityOperationError(c, service.ErrProofMethod)
return
}
bundle, err := service.VerifyLoginCode(request.FlowToken, request.Code, c.ClientIP(), c.Request.UserAgent())
if err != nil {
writeSecurityOperationError(c, err)
return
}
completeVerifiedLoginResponse(c, bundle, service.VerificationMethodTwoFA)
}
func LoginPasskeyBegin(c *gin.Context) {
var request struct {
FlowToken string `json:"flow_token"`
}
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" {
common.ApiErrorMsg(c, "参数错误")
return
}
verification, err := service.RequireLoginVerification(request.FlowToken, service.VerificationMethodPasskey)
if err != nil {
writeSecurityOperationError(c, err)
return
}
credential, err := model.GetPasskeyByUserID(verification.State.UserID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
user := &model.User{Id: verification.State.UserID}
options, sessionData, err := wa.BeginLogin(passkeysvc.NewWebAuthnUser(user, credential), webauthnlib.WithUserVerification(protocol.VerificationRequired))
if err != nil {
writeSecurityOperationError(c, err)
return
}
token, expiresAt, err := passkeysvc.CreateSessionDataFlow(model.AuthFlowPurposeLoginPasskey, passkeysvc.FlowSecurity{
AuthSessionIdentity: model.AuthSessionIdentity{UserID: user.Id, UserAuthVersion: verification.State.AuthVersion},
LoginFlowID: verification.Flow.Id, LoginExpiresAt: verification.Flow.ExpiresAt.Unix(),
}, sessionData)
if err != nil {
writeSecurityOperationError(c, err)
return
}
common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options})
}
func LoginPasskeyFinish(c *gin.Context) {
var request struct {
FlowToken string `json:"flow_token"`
PasskeyFlowToken string `json:"passkey_flow_token"`
Credential json.RawMessage `json:"credential"`
}
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" || request.PasskeyFlowToken == "" || len(request.Credential) == 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
verification, err := service.RequireLoginVerification(request.FlowToken, service.VerificationMethodPasskey)
if err != nil {
writeSecurityOperationError(c, err)
return
}
parsed, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
if err != nil {
writeSecurityOperationError(c, err)
return
}
identity := model.AuthSessionIdentity{UserID: verification.State.UserID, UserAuthVersion: verification.State.AuthVersion}
sessionData, security, err := passkeysvc.PopSessionDataFlow(request.PasskeyFlowToken, model.AuthFlowPurposeLoginPasskey, identity)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if security.LoginFlowID != verification.Flow.Id || sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
credential, err := model.GetPasskeyByUserID(identity.UserID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
validated, err := wa.ValidateLogin(passkeysvc.NewWebAuthnUser(&model.User{Id: identity.UserID}, credential), *sessionData, parsed)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now()); err != nil {
writeSecurityOperationError(c, err)
return
}
bundle, err := service.CompleteLoginVerification(request.FlowToken, verification, service.VerificationMethodPasskey, c.ClientIP(), c.Request.UserAgent())
if err != nil {
writeSecurityOperationError(c, err)
return
}
completeVerifiedLoginResponse(c, bundle, service.VerificationMethodPasskey)
}
func completeVerifiedLoginResponse(c *gin.Context, bundle *service.AuthBundle, method string) {
identity, err := service.ParseAccessToken(bundle.AccessToken)
if err != nil {
writeAuthSessionError(c, err)
return
}
user, err := model.GetSelfUserById(identity.UserID)
if err != nil {
writeAuthSessionError(c, err)
return
}
c.Set("login_verification_method", method)
writeLoginResponse(c, user, bundle)
}
+329
View File
@@ -0,0 +1,329 @@
package controller
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
// midjourneyPollSummary is the result recorded on a midjourney_poll system task
// row, summarizing one polling pass.
type midjourneyPollSummary struct {
UnfinishedTasks int `json:"unfinished_tasks"`
ChannelsScanned int `json:"channels_scanned"`
NullTasksFailed int `json:"null_tasks_failed"`
}
// runMidjourneyTaskUpdateOnce performs one Midjourney polling pass synchronously.
// It honors ctx cancellation (the system-task runner cancels it when the lease
// is lost) and, when report is non-nil, reports progress as (processedChannels,
// totalChannels) so the system task surfaces a percentage.
func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, total int)) midjourneyPollSummary {
summary := midjourneyPollSummary{}
if ctx == nil {
ctx = context.Background()
}
tasks := model.GetAllUnFinishTasks()
if len(tasks) == 0 {
return summary
}
summary.UnfinishedTasks = len(tasks)
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Midjourney)
nullTaskIds := make([]int, 0)
for _, task := range tasks {
if task.MjId == "" {
// 统计失败的未完成任务
nullTaskIds = append(nullTaskIds, task.Id)
continue
}
taskM[task.MjId] = task
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.MjId)
}
if len(nullTaskIds) > 0 {
summary.NullTasksFailed = len(nullTaskIds)
err := model.MjBulkUpdateByTaskIds(nullTaskIds, map[string]any{
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
} else {
logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
}
}
if len(taskChannelM) == 0 {
return summary
}
totalChannels := len(taskChannelM)
processedChannels := 0
for channelId, taskIds := range taskChannelM {
if ctx != nil && ctx.Err() != nil {
break
}
if report != nil {
report(processedChannels, totalChannels)
}
processedChannels++
summary.ChannelsScanned++
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
continue
}
midjourneyChannel, err := model.CacheGetChannel(channelId)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
err := model.MjBulkUpdate(taskIds, map[string]any{
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
}
continue
}
requestUrl := fmt.Sprintf("%s/mj/task/list-by-condition", *midjourneyChannel.BaseURL)
body, err := common.Marshal(map[string]any{
"ids": taskIds,
})
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Task marshal body error: %v", err))
continue
}
timeout := time.Second * 15
requestCtx, cancel := context.WithTimeout(ctx, timeout)
req, err := http.NewRequestWithContext(requestCtx, "POST", requestUrl, bytes.NewBuffer(body))
if err != nil {
cancel()
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("mj-api-secret", midjourneyChannel.Key)
resp, err := service.GetHttpClient().Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
cancel()
continue
}
if resp.StatusCode != http.StatusOK {
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
resp.Body.Close()
cancel()
continue
}
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error: %v", err))
resp.Body.Close()
cancel()
continue
}
var responseItems []dto.MidjourneyDto
err = common.Unmarshal(responseBody, &responseItems)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody)))
resp.Body.Close()
cancel()
continue
}
resp.Body.Close()
req.Body.Close()
cancel()
for _, responseItem := range responseItems {
task := taskM[responseItem.MjId]
if task == nil {
logger.LogWarn(ctx, fmt.Sprintf("Midjourney task response ignored: unknown mj_id=%s", responseItem.MjId))
continue
}
useTime := (time.Now().UnixNano() / int64(time.Millisecond)) - task.SubmitTime
// 如果时间超过一小时,且进度不是100%,则认为任务失败
if useTime > 3600000 && task.Progress != "100%" {
responseItem.FailReason = "上游任务超时(超过1小时)"
responseItem.Status = "FAILURE"
}
if !checkMjTaskNeedUpdate(task, responseItem) {
continue
}
preStatus := task.Status
task.Code = 1
task.Progress = responseItem.Progress
task.PromptEn = responseItem.PromptEn
task.State = responseItem.State
task.SubmitTime = responseItem.SubmitTime
task.StartTime = responseItem.StartTime
task.FinishTime = responseItem.FinishTime
task.ImageUrl = responseItem.ImageUrl
task.Status = responseItem.Status
task.FailReason = responseItem.FailReason
if responseItem.Properties != nil {
propertiesStr, _ := common.Marshal(responseItem.Properties)
task.Properties = string(propertiesStr)
}
if responseItem.Buttons != nil {
buttonStr, _ := common.Marshal(responseItem.Buttons)
task.Buttons = string(buttonStr)
}
// 映射 VideoUrl
task.VideoUrl = responseItem.VideoUrl
// 映射 VideoUrls - 将数组序列化为 JSON 字符串
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
videoUrlsStr, err := common.Marshal(responseItem.VideoUrls)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
task.VideoUrls = "[]" // 失败时设置为空数组
} else {
task.VideoUrls = string(videoUrlsStr)
}
} else {
task.VideoUrls = "" // 空值时清空字段
}
shouldReturnQuota := false
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
task.Progress = "100%"
if task.Quota != 0 {
shouldReturnQuota = true
}
}
won, err := task.UpdateWithStatus(preStatus)
if err != nil {
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
} else if won && shouldReturnQuota {
service.RefundMidjourneyQuota(ctx, task, "构图失败")
}
}
}
if report != nil && (ctx == nil || ctx.Err() == nil) {
report(totalChannels, totalChannels)
}
return summary
}
func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto) bool {
if oldTask.Code != 1 {
return true
}
if oldTask.Progress != newTask.Progress {
return true
}
if oldTask.PromptEn != newTask.PromptEn {
return true
}
if oldTask.State != newTask.State {
return true
}
if oldTask.SubmitTime != newTask.SubmitTime {
return true
}
if oldTask.StartTime != newTask.StartTime {
return true
}
if oldTask.FinishTime != newTask.FinishTime {
return true
}
if oldTask.ImageUrl != newTask.ImageUrl {
return true
}
if oldTask.Status != newTask.Status {
return true
}
if oldTask.FailReason != newTask.FailReason {
return true
}
if oldTask.FinishTime != newTask.FinishTime {
return true
}
if oldTask.Progress != "100%" && newTask.FailReason != "" {
return true
}
// 检查 VideoUrl 是否需要更新
if oldTask.VideoUrl != newTask.VideoUrl {
return true
}
// 检查 VideoUrls 是否需要更新
if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 {
newVideoUrlsStr, _ := common.Marshal(newTask.VideoUrls)
if oldTask.VideoUrls != string(newVideoUrlsStr) {
return true
}
} else if oldTask.VideoUrls != "" {
// 如果新数据没有 VideoUrls 但旧数据有,需要更新(清空)
return true
}
return false
}
func GetAllMidjourney(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
// 解析其他查询参数
queryParams := model.TaskQueryParams{
ChannelID: c.Query("channel_id"),
MjID: c.Query("mj_id"),
StartTimestamp: c.Query("start_timestamp"),
EndTimestamp: c.Query("end_timestamp"),
}
items := model.GetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllTasks(queryParams)
if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}
func GetUserMidjourney(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
userId := c.GetInt("id")
queryParams := model.TaskQueryParams{
MjID: c.Query("mj_id"),
StartTimestamp: c.Query("start_timestamp"),
EndTimestamp: c.Query("end_timestamp"),
}
items := model.GetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllUserTask(userId, queryParams)
if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}
+313
View File
@@ -0,0 +1,313 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
func TestStatus(c *gin.Context) {
err := model.PingDB()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"message": "数据库连接失败",
})
return
}
// 获取HTTP统计信息
httpStats := middleware.GetStats()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Server is running",
"http_stats": httpStats,
})
return
}
func GetStatus(c *gin.Context) {
cs := console_setting.GetConsoleSetting()
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
passkeySetting := system_setting.GetPasskeySettings()
legalSetting := system_setting.GetLegalSettings()
data := gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"discord_oauth": system_setting.GetDiscordSettings().Enabled,
"discord_client_id": system_setting.GetDiscordSettings().ClientId,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_oauth_configured": oauth.TelegramConfigurationError() == nil,
"telegram_bot_name": common.TelegramBotName,
"theme": "default",
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": system_setting.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit,
// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
"display_in_currency": operation_setting.IsCurrencyDisplay(),
"quota_display_type": operation_setting.GetQuotaDisplayType(),
"custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
"custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"register_enabled": common.RegisterEnabled,
"password_login_enabled": common.PasswordLoginEnabled,
"password_register_enabled": common.PasswordRegisterEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"password_login_encryption_enabled": common.PasswordLoginEncryptionEnabled,
"usd_exchange_rate": operation_setting.USDExchangeRate,
"price": operation_setting.Price,
"stripe_unit_price": setting.StripeUnitPrice,
// 面板启用开关
"api_info_enabled": cs.ApiInfoEnabled,
"uptime_kuma_enabled": cs.UptimeKumaEnabled,
"announcements_enabled": cs.AnnouncementsEnabled,
"faq_enabled": cs.FAQEnabled,
// 模块管理配置
"HeaderNavModules": common.OptionMap["HeaderNavModules"],
"SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"oidc_display_name": system_setting.GetOIDCSettings().GetEffectiveDisplayName(),
"passkey_login": passkeySetting.Enabled,
"passkey_display_name": passkeySetting.RPDisplayName,
"passkey_rp_id": passkeySetting.RPID,
"passkey_origins": passkeySetting.Origins,
"passkey_allow_insecure": passkeySetting.AllowInsecureOrigin,
"passkey_user_verification": passkeySetting.UserVerification,
"passkey_attachment": passkeySetting.AttachmentPreference,
"setup": constant.Setup,
"user_agreement_enabled": legalSetting.UserAgreement != "",
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
}
// 根据启用状态注入可选内容
if cs.ApiInfoEnabled {
data["api_info"] = console_setting.GetApiInfo()
}
if cs.AnnouncementsEnabled {
data["announcements"] = console_setting.GetAnnouncements()
}
if cs.FAQEnabled {
data["faq"] = console_setting.GetFAQ()
}
// Add enabled custom OAuth providers
customProviders := oauth.GetEnabledCustomProviders()
if len(customProviders) > 0 {
type CustomOAuthInfo struct {
Id int `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Icon string `json:"icon"`
ClientId string `json:"client_id"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
Scopes string `json:"scopes"`
}
providersInfo := make([]CustomOAuthInfo, 0, len(customProviders))
for _, p := range customProviders {
config := p.GetConfig()
providersInfo = append(providersInfo, CustomOAuthInfo{
Id: config.Id,
Name: config.Name,
Slug: config.Slug,
Icon: config.Icon,
ClientId: config.ClientId,
AuthorizationEndpoint: config.AuthorizationEndpoint,
Scopes: config.Scopes,
})
}
data["custom_oauth_providers"] = providersInfo
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
return
}
func GetNotice(c *gin.Context) {
common.OptionMapRWMutex.RLock()
notice := common.OptionMap["Notice"]
common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, notice)
}
func GetAbout(c *gin.Context) {
common.OptionMapRWMutex.RLock()
about := common.OptionMap["About"]
common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, about)
}
func GetUserAgreement(c *gin.Context) {
serveRevalidatedJSON(c, system_setting.GetLegalSettings().UserAgreement)
}
func GetPrivacyPolicy(c *gin.Context) {
serveRevalidatedJSON(c, system_setting.GetLegalSettings().PrivacyPolicy)
}
func GetMidjourney(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": common.OptionMap["Midjourney"],
})
return
}
func GetHomePageContent(c *gin.Context) {
common.OptionMapRWMutex.RLock()
homePageContent := common.OptionMap["HomePageContent"]
common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, homePageContent)
}
func SendEmailVerification(c *gin.Context) {
email, err := service.ValidateAccountEmail(c.Query("email"))
if err != nil {
writeSecurityOperationError(c, err)
return
}
if model.IsEmailAlreadyTaken(email) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
return
}
code := common.GenerateVerificationCode(6)
common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose)
subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s邮箱验证。</p>"+
"<p>您的验证码为: <strong>%s</strong></p>"+
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
err = common.SendEmail(subject, email, content)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func SendPasswordResetEmail(c *gin.Context) {
email := model.NormalizeEmail(c.Query("email"))
if err := common.Validate.Var(email, "required,email"); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if _, err := model.GetUniqueUserByEmail(email); err == nil {
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email to %s: %s", email, err.Error()))
}
} else if err != nil && !errors.Is(err, model.ErrEmailNotFound) {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("skip password reset email for %s: %s", email, err.Error()))
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
type PasswordResetRequest struct {
Email string `json:"email"`
Token string `json:"token"`
}
func ResetPassword(c *gin.Context) {
var req PasswordResetRequest
err := json.NewDecoder(c.Request.Body).Decode(&req)
if err != nil {
common.ApiError(c, err)
return
}
req.Email = model.NormalizeEmail(req.Email)
if req.Email == "" || req.Token == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) {
common.ApiErrorI18n(c, i18n.MsgUserPasswordResetLinkInvalid)
return
}
password := common.GenerateVerificationCode(12)
err = model.ResetUserPasswordByEmail(req.Email, password)
if err != nil {
if errors.Is(err, model.ErrEmailNotFound) || errors.Is(err, model.ErrEmailAmbiguous) {
common.ApiErrorI18n(c, i18n.MsgUserPasswordResetLinkInvalid)
return
}
common.ApiError(c, err)
return
}
common.DeleteKey(req.Email, common.PasswordResetPurpose)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": password,
})
return
}
+60
View File
@@ -0,0 +1,60 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetStatusReturnsEffectiveOIDCDisplayName(t *testing.T) {
settings := system_setting.GetOIDCSettings()
originalDisplayName := settings.DisplayName
originalOptionMap := common.OptionMap
t.Cleanup(func() {
settings.DisplayName = originalDisplayName
common.OptionMap = originalOptionMap
})
common.OptionMap = map[string]string{}
tests := []struct {
name string
displayName string
want string
}{
{
name: "custom name is trimmed",
displayName: " Acme SSO ",
want: "Acme SSO",
},
{
name: "whitespace-only name falls back",
displayName: " ",
want: "OIDC",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings.DisplayName = tt.displayName
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(http.MethodGet, "/api/status", nil)
GetStatus(context)
var payload struct {
Success bool `json:"success"`
Data map[string]any `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload))
require.True(t, payload.Success)
assert.Equal(t, tt.want, payload.Data["oidc_display_name"])
})
}
}
+28
View File
@@ -0,0 +1,28 @@
package controller
import (
"net/http"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// GetMissingModels returns the list of model names that are referenced by channels
// but do not have corresponding records in the models meta table.
// This helps administrators quickly discover models that need configuration.
func GetMissingModels(c *gin.Context) {
missing, err := model.GetMissingModels()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": missing,
})
}
+373
View File
@@ -0,0 +1,373 @@
package controller
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/relay/channel/ai360"
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
"github.com/QuantumNous/new-api/relay/channel/minimax"
"github.com/QuantumNous/new-api/relay/channel/moonshot"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
// https://platform.openai.com/docs/api-reference/models/list
var openAIModels []dto.OpenAIModels
var openAIModelsMap map[string]dto.OpenAIModels
var channelId2Models map[int][]string
func init() {
// https://platform.openai.com/docs/models/model-endpoint-compatibility
for i := range constant.APITypeDummy {
if i == constant.APITypeAIProxyLibrary {
continue
}
adaptor := relay.GetAdaptor(i)
channelName := adaptor.GetChannelName()
modelNames := adaptor.GetModelList()
for _, modelName := range modelNames {
openAIModels = append(openAIModels, dto.OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: channelName,
})
}
}
for _, modelName := range ai360.ModelList {
openAIModels = append(openAIModels, dto.OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: ai360.ChannelName,
})
}
for _, modelName := range moonshot.ModelList {
openAIModels = append(openAIModels, dto.OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: moonshot.ChannelName,
})
}
for _, modelName := range lingyiwanwu.ModelList {
openAIModels = append(openAIModels, dto.OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: lingyiwanwu.ChannelName,
})
}
for _, modelName := range minimax.ModelList {
openAIModels = append(openAIModels, dto.OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: minimax.ChannelName,
})
}
for modelName, _ := range constant.MidjourneyModel2Action {
openAIModels = append(openAIModels, dto.OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: "midjourney",
})
}
openAIModelsMap = make(map[string]dto.OpenAIModels)
for _, aiModel := range openAIModels {
openAIModelsMap[aiModel.Id] = aiModel
}
channelId2Models = make(map[int][]string)
for i := 1; i <= constant.ChannelTypeDummy; i++ {
apiType, success := common.ChannelType2APIType(i)
if !success || apiType == constant.APITypeAIProxyLibrary {
if plugin, ok := jsplugin.DefaultRegistry.GetByChannelType(i); ok {
channelId2Models[i] = append([]string(nil), plugin.Meta.Models...)
}
continue
}
meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: i,
}}
adaptor := relay.GetAdaptor(apiType)
adaptor.Init(meta)
channelId2Models[i] = adaptor.GetModelList()
if len(channelId2Models[i]) == 0 {
if plugin, ok := jsplugin.DefaultRegistry.GetByChannelType(i); ok {
channelId2Models[i] = append([]string(nil), plugin.Meta.Models...)
}
}
}
openAIModels = lo.UniqBy(openAIModels, func(m dto.OpenAIModels) string {
return m.Id
})
}
func channelOwnerName(channelType int) string {
apiType, success := common.ChannelType2APIType(channelType)
if !success {
return strings.ToLower(constant.GetChannelTypeName(channelType))
}
adaptor := relay.GetAdaptor(apiType)
if adaptor == nil {
return strings.ToLower(constant.GetChannelTypeName(channelType))
}
adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: channelType,
}})
if name := strings.TrimSpace(adaptor.GetChannelName()); name != "" {
return name
}
return strings.ToLower(constant.GetChannelTypeName(channelType))
}
func getPreferredModelOwners(modelNames []string, groups []string) map[string]string {
channelTypes, err := model.GetPreferredModelOwnerChannelTypes(modelNames, groups)
if err != nil {
common.SysLog(fmt.Sprintf("GetPreferredModelOwnerChannelTypes error: %v", err))
return map[string]string{}
}
ownerByChannelType := make(map[int]string)
owners := make(map[string]string, len(channelTypes))
for modelName, channelType := range channelTypes {
owner, ok := ownerByChannelType[channelType]
if !ok {
owner = channelOwnerName(channelType)
ownerByChannelType[channelType] = owner
}
if owner != "" {
owners[modelName] = owner
}
}
return owners
}
func buildOpenAIModel(modelName string, ownerByModel map[string]string) dto.OpenAIModels {
var oaiModel dto.OpenAIModels
if staticModel, ok := openAIModelsMap[modelName]; ok {
oaiModel = staticModel
} else {
oaiModel = dto.OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: "custom",
}
}
if owner, ok := ownerByModel[modelName]; ok && owner != "" {
oaiModel.OwnedBy = owner
}
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName)
return oaiModel
}
type modelListGroups struct {
userGroup string
tokenGroup string
ownerGroups []string
}
func getModelListGroups(c *gin.Context) (modelListGroups, error) {
tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
if userGroup == "" && (tokenGroup == "" || tokenGroup == "auto") {
var err error
userGroup, err = model.GetUserGroup(c.GetInt("id"), false)
if err != nil {
return modelListGroups{}, err
}
}
if tokenGroup == "auto" {
return modelListGroups{
userGroup: userGroup,
tokenGroup: tokenGroup,
ownerGroups: service.GetRequestAutoGroups(c, userGroup),
}, nil
}
group := userGroup
if tokenGroup != "" {
group = tokenGroup
}
return modelListGroups{
userGroup: userGroup,
tokenGroup: tokenGroup,
ownerGroups: []string{group},
}, nil
}
func ListModels(c *gin.Context, modelType int) {
acceptUnsetRatioModel := operation_setting.SelfUseModeEnabled
if !acceptUnsetRatioModel {
userId := c.GetInt("id")
if userId > 0 {
userSettings, _ := model.GetUserSetting(userId, false)
if userSettings.AcceptUnsetRatioModel {
acceptUnsetRatioModel = true
}
}
}
userModelNames := make([]string, 0)
groups, err := getModelListGroups(c)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "get user group failed",
})
return
}
ownerGroups := groups.ownerGroups
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
var tokenModelLimit map[string]bool
if modelLimitEnable {
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
if ok {
tokenModelLimit, _ = s.(map[string]bool)
}
if tokenModelLimit == nil {
tokenModelLimit = map[string]bool{}
}
}
models := service.GetGroupsEnabledModels(ownerGroups)
for _, modelName := range models {
if modelLimitEnable {
matchingName := ratio_setting.RoutingMatchModelName(modelName)
if !tokenModelLimit[modelName] && !tokenModelLimit[matchingName] {
continue
}
}
if !acceptUnsetRatioModel && !helper.HasModelBillingConfig(modelName) {
continue
}
userModelNames = append(userModelNames, modelName)
}
ownerByModel := map[string]string{}
if len(ownerGroups) > 0 {
ownerByModel = getPreferredModelOwners(userModelNames, ownerGroups)
}
userOpenAiModels := make([]dto.OpenAIModels, 0, len(userModelNames))
for _, modelName := range userModelNames {
userOpenAiModels = append(userOpenAiModels, buildOpenAIModel(modelName, ownerByModel))
}
switch modelType {
case constant.ChannelTypeAnthropic:
useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels))
for i, model := range userOpenAiModels {
useranthropicModels[i] = dto.AnthropicModel{
ID: model.Id,
CreatedAt: time.Unix(int64(model.Created), 0).UTC().Format(time.RFC3339),
DisplayName: model.Id,
Type: "model",
}
}
firstID := ""
lastID := ""
if len(useranthropicModels) > 0 {
firstID = useranthropicModels[0].ID
lastID = useranthropicModels[len(useranthropicModels)-1].ID
}
c.JSON(200, gin.H{
"data": useranthropicModels,
"first_id": firstID,
"has_more": false,
"last_id": lastID,
})
case constant.ChannelTypeGemini:
userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels))
for i, model := range userOpenAiModels {
userGeminiModels[i] = dto.GeminiModel{
Name: model.Id,
DisplayName: model.Id,
}
}
c.JSON(200, gin.H{
"models": userGeminiModels,
"nextPageToken": nil,
})
default:
c.JSON(200, gin.H{
"success": true,
"data": userOpenAiModels,
"object": "list",
})
}
}
func ChannelListModels(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"data": openAIModels,
})
}
func DashboardListModels(c *gin.Context) {
modelsByChannel := make(map[int][]string, len(channelId2Models))
for channelType, models := range channelId2Models {
modelsByChannel[channelType] = append([]string(nil), models...)
}
for channelType := 1; channelType <= constant.ChannelTypeDummy; channelType++ {
if plugin, ok := jsplugin.DefaultRegistry.GetByChannelType(channelType); ok {
modelsByChannel[channelType] = append([]string(nil), plugin.Meta.Models...)
}
}
c.JSON(200, gin.H{
"success": true,
"data": modelsByChannel,
})
}
func EnabledListModels(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"data": model.GetEnabledModels(),
})
}
func RetrieveModel(c *gin.Context, modelType int) {
modelId := c.Param("model")
if aiModel, ok := openAIModelsMap[modelId]; ok {
switch modelType {
case constant.ChannelTypeAnthropic:
c.JSON(200, dto.AnthropicModel{
ID: aiModel.Id,
CreatedAt: time.Unix(int64(aiModel.Created), 0).UTC().Format(time.RFC3339),
DisplayName: aiModel.Id,
Type: "model",
})
default:
c.JSON(200, aiModel)
}
} else {
openAIError := types.OpenAIError{
Message: fmt.Sprintf("The model '%s' does not exist", modelId),
Type: "invalid_request_error",
Param: "model",
Code: "model_not_found",
}
c.JSON(200, gin.H{
"error": openAIError,
})
}
}
+531
View File
@@ -0,0 +1,531 @@
package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto"
"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/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type listModelsResponse struct {
Success bool `json:"success"`
Data []dto.OpenAIModels `json:"data"`
Object string `json:"object"`
}
type userModelsResponse struct {
Success bool `json:"success"`
Data []string `json:"data"`
}
func setupModelListControllerTestDB(t *testing.T) *gorm.DB {
t.Helper()
initModelListColumnNames(t)
gin.SetMode(gin.TestMode)
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
common.RedisEnabled = false
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)
model.DB = db
model.LOG_DB = db
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Channel{}, &model.Ability{}, &model.Model{}, &model.Vendor{}))
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func initModelListColumnNames(t *testing.T) {
t.Helper()
originalIsMasterNode := common.IsMasterNode
originalSQLitePath := common.SQLitePath
originalMainDatabaseType := common.MainDatabaseType()
originalLogDatabaseType := common.LogDatabaseType()
originalSQLDSN, hadSQLDSN := os.LookupEnv("SQL_DSN")
defer func() {
common.IsMasterNode = originalIsMasterNode
common.SQLitePath = originalSQLitePath
common.SetDatabaseTypes(originalMainDatabaseType, originalLogDatabaseType)
if hadSQLDSN {
require.NoError(t, os.Setenv("SQL_DSN", originalSQLDSN))
} else {
require.NoError(t, os.Unsetenv("SQL_DSN"))
}
}()
common.IsMasterNode = false
common.SQLitePath = fmt.Sprintf("file:%s_init?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
require.NoError(t, os.Setenv("SQL_DSN", "local"))
require.NoError(t, model.InitDB())
if model.DB != nil {
sqlDB, err := model.DB.DB()
if err == nil {
_ = sqlDB.Close()
}
}
}
func withTieredBillingConfig(t *testing.T, modes map[string]string, exprs map[string]string) {
t.Helper()
saved := map[string]string{}
require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
if strings.HasPrefix(key, "billing_setting.") {
saved[key] = value
}
return nil
}))
t.Cleanup(func() {
require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
model.InvalidatePricingCache()
})
modeBytes, err := common.Marshal(modes)
require.NoError(t, err)
exprBytes, err := common.Marshal(exprs)
require.NoError(t, err)
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
"billing_setting.billing_mode": string(modeBytes),
"billing_setting.billing_expr": string(exprBytes),
}))
model.InvalidatePricingCache()
}
func withSelfUseModeDisabled(t *testing.T) {
t.Helper()
original := operation_setting.SelfUseModeEnabled
operation_setting.SelfUseModeEnabled = false
t.Cleanup(func() {
operation_setting.SelfUseModeEnabled = original
})
}
func withSelfUseModeEnabled(t *testing.T) {
t.Helper()
original := operation_setting.SelfUseModeEnabled
operation_setting.SelfUseModeEnabled = true
t.Cleanup(func() {
operation_setting.SelfUseModeEnabled = original
})
}
func decodeListModelsPayload(t *testing.T, recorder *httptest.ResponseRecorder) listModelsResponse {
t.Helper()
require.Equal(t, http.StatusOK, recorder.Code)
var payload listModelsResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
require.True(t, payload.Success)
require.Equal(t, "list", payload.Object)
return payload
}
func decodeListModelsResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]struct{} {
t.Helper()
payload := decodeListModelsPayload(t, recorder)
ids := make(map[string]struct{}, len(payload.Data))
for _, item := range payload.Data {
ids[item.Id] = struct{}{}
}
return ids
}
func pricingByModelName(pricings []model.Pricing) map[string]model.Pricing {
byName := make(map[string]model.Pricing, len(pricings))
for _, pricing := range pricings {
byName[pricing.ModelName] = pricing
}
return byName
}
func decodeUserModelsResponse(t *testing.T, recorder *httptest.ResponseRecorder) []string {
t.Helper()
require.Equal(t, http.StatusOK, recorder.Code)
var payload userModelsResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
require.True(t, payload.Success)
return payload.Data
}
func TestGetUserModelsFiltersByRequestedGroup(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.Create(&model.User{
Id: 1002,
Username: "playground-model-user",
Password: "password",
Group: "default",
Status: common.UserStatusEnabled,
}).Error)
require.NoError(t, db.Create(&[]model.Ability{
{Group: "default", Model: "zz-default-only-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-disabled-model", ChannelId: 1, Enabled: false},
}).Error)
defaultRecorder := httptest.NewRecorder()
defaultContext, _ := gin.CreateTestContext(defaultRecorder)
defaultContext.Request = httptest.NewRequest(http.MethodGet, "/api/user/models?group=default", nil)
defaultContext.Set("id", 1002)
GetUserModels(defaultContext)
defaultModels := decodeUserModelsResponse(t, defaultRecorder)
require.ElementsMatch(t, []string{"zz-default-only-model"}, defaultModels)
vipRecorder := httptest.NewRecorder()
vipContext, _ := gin.CreateTestContext(vipRecorder)
vipContext.Request = httptest.NewRequest(http.MethodGet, "/api/user/models?group=vip", nil)
vipContext.Set("id", 1002)
GetUserModels(vipContext)
require.Empty(t, decodeUserModelsResponse(t, vipRecorder))
}
func TestGetUserModelsExpandsAutoGroupsInConfiguredOrder(t *testing.T) {
originalAutoGroups := setting.AutoGroups2JsonString()
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalSpecialGroups := ratio_setting.GetGroupRatioSetting().GroupSpecialUsableGroup.ReadAll()
t.Cleanup(func() {
require.NoError(t, setting.UpdateAutoGroupsByJsonString(originalAutoGroups))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
specialGroups := ratio_setting.GetGroupRatioSetting().GroupSpecialUsableGroup
specialGroups.Clear()
specialGroups.AddAll(originalSpecialGroups)
})
require.NoError(t, setting.UpdateAutoGroupsByJsonString(`["vip","default","unavailable"]`))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"auto":"自动分组","default":"默认分组","unavailable":"不可用分组"}`))
specialGroups := ratio_setting.GetGroupRatioSetting().GroupSpecialUsableGroup
specialGroups.Clear()
specialGroups.Set("default", map[string]string{
"+:vip": "VIP 分组",
"-:unavailable": "",
})
db := setupModelListControllerTestDB(t)
require.NoError(t, db.Create(&model.User{
Id: 1003,
Username: "playground-auto-model-user",
Password: "password",
Group: "default",
Status: common.UserStatusEnabled,
}).Error)
require.NoError(t, db.Create(&[]model.Ability{
{Group: "vip", Model: "zz-vip-model", ChannelId: 1, Enabled: true},
{Group: "vip", Model: "zz-shared-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-default-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-shared-model", ChannelId: 2, Enabled: true},
{Group: "unavailable", Model: "zz-unavailable-model", ChannelId: 1, Enabled: true},
}).Error)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/user/models?group=auto", nil)
context.Set("id", 1003)
GetUserModels(context)
models := decodeUserModelsResponse(t, recorder)
require.Len(t, models, 3)
assert.ElementsMatch(t, []string{"zz-vip-model", "zz-shared-model"}, models[:2])
assert.Equal(t, "zz-default-model", models[2])
}
func TestListModelsIncludesTieredBillingModel(t *testing.T) {
withSelfUseModeDisabled(t)
withTieredBillingConfig(t, map[string]string{
"zz-tiered-visible-model": "tiered_expr",
"zz-tiered-empty-expr-model": "tiered_expr",
"zz-tiered-missing-expr-model": "tiered_expr",
}, map[string]string{
"zz-tiered-visible-model": `tier("base", p * 1 + c * 2)`,
"zz-tiered-empty-expr-model": " ",
})
db := setupModelListControllerTestDB(t)
require.NoError(t, db.Create(&model.User{
Id: 1001,
Username: "model-list-user",
Password: "password",
Group: "default",
Status: common.UserStatusEnabled,
}).Error)
require.NoError(t, db.Create(&[]model.Ability{
{Group: "default", Model: "zz-tiered-visible-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-tiered-empty-expr-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-tiered-missing-expr-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-unpriced-model", ChannelId: 1, Enabled: true},
}).Error)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
ctx.Set("id", 1001)
ListModels(ctx, constant.ChannelTypeOpenAI)
ids := decodeListModelsResponse(t, recorder)
require.Contains(t, ids, "zz-tiered-visible-model")
require.NotContains(t, ids, "zz-tiered-empty-expr-model")
require.NotContains(t, ids, "zz-tiered-missing-expr-model")
require.NotContains(t, ids, "zz-unpriced-model")
pricingByName := pricingByModelName(model.GetPricing())
visiblePricing, ok := pricingByName["zz-tiered-visible-model"]
require.True(t, ok)
require.Equal(t, "tiered_expr", visiblePricing.BillingMode)
require.NotEmpty(t, visiblePricing.BillingExpr)
emptyExprPricing, ok := pricingByName["zz-tiered-empty-expr-model"]
require.True(t, ok)
require.Empty(t, emptyExprPricing.BillingMode)
require.Empty(t, emptyExprPricing.BillingExpr)
missingExprPricing, ok := pricingByName["zz-tiered-missing-expr-model"]
require.True(t, ok)
require.Empty(t, missingExprPricing.BillingMode)
require.Empty(t, missingExprPricing.BillingExpr)
}
func TestListModelsUsesAdvancedCustomEndpointTypesFromPricingCache(t *testing.T) {
withSelfUseModeEnabled(t)
db := setupModelListControllerTestDB(t)
originalMemoryCacheEnabled := common.MemoryCacheEnabled
common.MemoryCacheEnabled = true
t.Cleanup(func() {
common.MemoryCacheEnabled = originalMemoryCacheEnabled
model.InvalidatePricingCache()
})
require.NoError(t, db.Create(&model.User{
Id: 1003,
Username: "advanced-custom-model-list-user",
Password: "password",
Group: "default",
Status: common.UserStatusEnabled,
}).Error)
channel := &model.Channel{
Id: 701,
Type: constant.ChannelTypeAdvancedCustom,
Key: "advanced-custom-key",
Status: common.ChannelStatusEnabled,
Name: "advanced-custom-channel",
Group: "default",
Models: "gemini-3.5-flash",
}
channel.SetOtherSettings(dto.ChannelOtherSettings{
AdvancedCustom: &dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: "openai_responses_to_gemini_generate_content",
Models: []string{"re:^gemini-"},
},
},
},
})
require.NoError(t, db.Create(channel).Error)
require.NoError(t, db.Create(&model.Ability{
Group: "default",
Model: "gemini-3.5-flash",
ChannelId: 701,
Enabled: true,
}).Error)
model.InitChannelCache()
model.GetPricing()
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
ctx.Set("id", 1003)
ListModels(ctx, constant.ChannelTypeOpenAI)
payload := decodeListModelsPayload(t, recorder)
require.Len(t, payload.Data, 1)
require.Equal(t, "gemini-3.5-flash", payload.Data[0].Id)
require.Equal(t, []constant.EndpointType{
constant.EndpointTypeOpenAI,
constant.EndpointTypeOpenAIResponse,
}, payload.Data[0].SupportedEndpointTypes)
}
func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
withSelfUseModeDisabled(t)
withTieredBillingConfig(t, map[string]string{
"zz-token-tiered-visible-model": "tiered_expr",
"zz-token-tiered-empty-expr-model": "tiered_expr",
"zz-token-tiered-missing-expr-model": "tiered_expr",
}, map[string]string{
"zz-token-tiered-visible-model": `tier("base", p * 1 + c * 2)`,
"zz-token-tiered-empty-expr-model": "",
})
db := setupModelListControllerTestDB(t)
require.NoError(t, db.Create(&[]model.Ability{
{Group: "default", Model: "zz-token-tiered-visible-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-token-tiered-empty-expr-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-token-tiered-missing-expr-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-token-unpriced-model", ChannelId: 1, Enabled: true},
}).Error)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimitEnabled, true)
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimit, map[string]bool{
"zz-token-tiered-visible-model": true,
"zz-token-tiered-empty-expr-model": true,
"zz-token-tiered-missing-expr-model": true,
"zz-token-unpriced-model": true,
})
ListModels(ctx, constant.ChannelTypeOpenAI)
ids := decodeListModelsResponse(t, recorder)
require.Contains(t, ids, "zz-token-tiered-visible-model")
require.NotContains(t, ids, "zz-token-tiered-empty-expr-model")
require.NotContains(t, ids, "zz-token-tiered-missing-expr-model")
require.NotContains(t, ids, "zz-token-unpriced-model")
}
func TestListModelsTokenLimitUsesResolvedCustomAutoGroups(t *testing.T) {
withSelfUseModeEnabled(t)
originalMax := setting.GetMaxTokenAutoGroups()
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalRatios := ratio_setting.GroupRatio2JSONString()
require.NoError(t, setting.UpdateMaxTokenAutoGroups("5"))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
t.Cleanup(func() {
require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
})
db := setupModelListControllerTestDB(t)
require.NoError(t, db.Create(&[]model.Ability{
{Group: "vip", Model: "zz-vip-allowed", ChannelId: 1, Enabled: true},
{Group: "vip", Model: "zz-vip-denied", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-default-outside-snapshot", ChannelId: 1, Enabled: true},
}).Error)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "auto")
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimitEnabled, true)
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimit, map[string]bool{
"zz-vip-allowed": true,
"zz-default-outside-snapshot": true,
"zz-not-enabled": true,
})
ListModels(ctx, constant.ChannelTypeOpenAI)
ids := decodeListModelsResponse(t, recorder)
require.Equal(t, map[string]struct{}{"zz-vip-allowed": {}}, ids)
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
emptyRecorder := httptest.NewRecorder()
emptyCtx, _ := gin.CreateTestContext(emptyRecorder)
emptyCtx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
common.SetContextKey(emptyCtx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(emptyCtx, constant.ContextKeyTokenGroup, "auto")
common.SetContextKey(emptyCtx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
common.SetContextKey(emptyCtx, constant.ContextKeyTokenModelLimitEnabled, true)
common.SetContextKey(emptyCtx, constant.ContextKeyTokenModelLimit, map[string]bool{"zz-vip-allowed": true})
require.NotPanics(t, func() {
ListModels(emptyCtx, constant.ChannelTypeAnthropic)
})
var anthropicResponse struct {
Data []dto.AnthropicModel `json:"data"`
FirstID string `json:"first_id"`
LastID string `json:"last_id"`
}
require.NoError(t, common.Unmarshal(emptyRecorder.Body.Bytes(), &anthropicResponse))
require.Empty(t, anthropicResponse.Data)
require.Empty(t, anthropicResponse.FirstID)
require.Empty(t, anthropicResponse.LastID)
}
func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}, &model.UserSession{}, &model.TwoFA{}, &model.PasskeyCredential{}))
hashedPassword, err := common.Password2Hash("CurrentPassword123")
require.NoError(t, err)
user := &model.User{
Username: "twofa-user",
Password: hashedPassword,
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
}
require.NoError(t, db.Create(user).Error)
router := gin.New()
router.GET("/", func(c *gin.Context) {
setupLogin(&model.User{
Id: user.Id,
AuthVersion: user.AuthVersion,
Username: user.Username,
Role: user.Role,
Status: user.Status,
Group: user.Group,
}, c)
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/", nil)
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
var stored model.User
require.NoError(t, db.First(&stored, user.Id).Error)
assert.Equal(t, hashedPassword, stored.Password)
}
File diff suppressed because it is too large. Load diff
+337
View File
@@ -0,0 +1,337 @@
package controller
import (
"net/http"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// GetAllModelsMeta 获取模型列表(分页)
func GetAllModelsMeta(c *gin.Context) {
listModelsMeta(c, "", "")
}
// SearchModelsMeta 搜索模型列表
func SearchModelsMeta(c *gin.Context) {
listModelsMeta(c, c.Query("keyword"), c.Query("vendor"))
}
func listModelsMeta(c *gin.Context, keyword, vendor string) {
squareState := model.ModelSquareState(c.Query("square_state"))
switch squareState {
case "", model.ModelSquareVisible, model.ModelSquareUnavailable, model.ModelSquareHidden, model.ModelSquarePartial:
default:
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid model square state"})
return
}
pageInfo := common.GetPageQuery(c)
if squareState != "" && (pageInfo.GetPage() < 1 || pageInfo.GetPageSize() < 1) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid pagination"})
return
}
offset, limit := pageInfo.GetStartIdx(), pageInfo.GetPageSize()
if squareState != "" {
// Visibility depends on live channels and metadata rules. Filter the
// enriched candidate set before counting and paginating the results.
offset, limit = 0, -1
}
search := model.SearchModels
if c.Query("include_channel_models") == "true" {
search = model.SearchModelsWithChannels
}
modelsMeta, total, err := search(keyword, vendor, c.Query("status"), c.Query("sync_official"), offset, limit)
if err != nil {
common.ApiError(c, err)
return
}
if err := enrichModels(modelsMeta); err != nil {
common.ApiError(c, err)
return
}
if squareState != "" {
filtered := make([]*model.Model, 0, len(modelsMeta))
for _, metadata := range modelsMeta {
if metadata.SquareState == squareState {
filtered = append(filtered, metadata)
}
}
total = int64(len(filtered))
start := len(filtered)
if pageInfo.GetPage()-1 <= len(filtered)/pageInfo.GetPageSize() {
start = (pageInfo.GetPage() - 1) * pageInfo.GetPageSize()
}
end := min(start+pageInfo.GetPageSize(), len(filtered))
modelsMeta = filtered[start:end]
}
vendorCounts, _ := model.GetVendorModelCounts()
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, gin.H{
"items": modelsMeta,
"total": total,
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"vendor_counts": vendorCounts,
})
}
// GetModelMeta 根据 ID 获取单条模型信息
func GetModelMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
var m model.Model
if err := model.DB.First(&m, id).Error; err != nil {
common.ApiError(c, err)
return
}
if err := enrichModels([]*model.Model{&m}); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &m)
}
// CreateModelMeta 新建模型
func CreateModelMeta(c *gin.Context) {
var m model.Model
if err := c.ShouldBindJSON(&m); err != nil {
common.ApiError(c, err)
return
}
if m.ModelName == "" {
common.ApiErrorMsg(c, "模型名称不能为空")
return
}
if err := model.ValidateMetadataValues(model.MetadataValues{Endpoints: m.Endpoints, Status: m.Status, NameRule: m.NameRule}); err != nil {
common.ApiError(c, err)
return
}
// 名称冲突检查
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
return
}
if err := m.Insert(); err != nil {
common.ApiError(c, err)
return
}
model.RefreshPricing()
m.HasMetadata = m.Id > 0
common.ApiSuccess(c, &m)
}
// UpdateModelMeta 更新模型
func UpdateModelMeta(c *gin.Context) {
statusOnly := c.Query("status_only") == "true"
var m model.Model
if err := c.ShouldBindJSON(&m); err != nil {
common.ApiError(c, err)
return
}
if m.Id == 0 {
common.ApiErrorMsg(c, "缺少模型 ID")
return
}
if statusOnly {
if m.Status != 0 && m.Status != 1 {
common.ApiErrorMsg(c, "invalid catalog visibility")
return
}
// 只更新状态,防止误清空其他字段
if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
common.ApiError(c, err)
return
}
} else {
if strings.TrimSpace(m.ModelName) == "" {
common.ApiErrorMsg(c, "模型名称不能为空")
return
}
if err := model.ValidateMetadataValues(model.MetadataValues{Endpoints: m.Endpoints, Status: m.Status, NameRule: m.NameRule}); err != nil {
common.ApiError(c, err)
return
}
// 名称冲突检查
if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
return
}
if err := m.Update(); err != nil {
common.ApiError(c, err)
return
}
}
model.RefreshPricing()
m.HasMetadata = m.Id > 0
common.ApiSuccess(c, &m)
}
// DeleteModelMeta 删除模型
func DeleteModelMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
removeFromChannels, err := strconv.ParseBool(c.DefaultQuery("remove_from_channels", "false"))
if err != nil {
common.ApiError(c, err)
return
}
removePricing, err := strconv.ParseBool(c.DefaultQuery("remove_pricing", "false"))
if err != nil {
common.ApiError(c, err)
return
}
if removePricing && c.GetInt("role") != common.RoleRootUser {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "Model pricing is managed by a super administrator."})
return
}
result, err := model.DeleteModelMetadata([]int{id}, removeFromChannels, removePricing)
if err != nil {
common.ApiError(c, err)
return
}
recordManageAudit(c, "model.delete", map[string]any{"model_ids": []int{id}, "remove_from_channels": removeFromChannels, "remove_pricing": removePricing, "updated_channels": result.UpdatedChannels})
common.ApiSuccess(c, result)
}
func BatchDeleteModelMeta(c *gin.Context) {
var request struct {
ModelIDs []int `json:"model_ids"`
RemoveFromChannels bool `json:"remove_from_channels"`
RemovePricing bool `json:"remove_pricing"`
}
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiError(c, err)
return
}
if request.RemovePricing && c.GetInt("role") != common.RoleRootUser {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "Model pricing is managed by a super administrator."})
return
}
result, err := model.DeleteModelMetadata(request.ModelIDs, request.RemoveFromChannels, request.RemovePricing)
if err != nil {
common.ApiError(c, err)
return
}
recordManageAudit(c, "model.delete_batch", map[string]any{"model_ids": request.ModelIDs, "remove_from_channels": request.RemoveFromChannels, "remove_pricing": request.RemovePricing, "updated_channels": result.UpdatedChannels})
common.ApiSuccess(c, result)
}
// enrichModels keeps configured endpoints intact and derives connections from
// enabled routes, including hidden or unpriced models absent from the catalog.
func enrichModels(models []*model.Model) error {
if len(models) == 0 {
return nil
}
configured, err := model.GetConfiguredModelChannels()
if err != nil {
return err
}
for _, metadata := range models {
if metadata == nil {
continue
}
metadata.HasMetadata = metadata.Id > 0
channelIDs := make(map[int]struct{})
for name, ids := range configured {
if metadata.MatchesName(name) {
for _, id := range ids {
channelIDs[id] = struct{}{}
}
}
}
metadata.ConfiguredChannelCount = len(channelIDs)
}
connections, err := model.GetModelConnections()
if err != nil {
return err
}
if err := model.FillModelSquareStates(models, configured, connections); err != nil {
return err
}
for _, metadata := range models {
if metadata == nil {
continue
}
channels := make(map[int]model.BoundChannel)
groups := make(map[string]bool)
names := make(map[string]bool)
endpoints := make(map[string]bool)
quotas := make(map[int]bool)
for _, connection := range connections {
name := connection.Model
if !metadata.MatchesName(name) {
continue
}
names[name] = true
groups[connection.Group] = true
channels[connection.ChannelId] = model.BoundChannel{Name: connection.ChannelName, Type: connection.ChannelType}
for _, endpoint := range model.GetModelSupportEndpointTypes(name) {
endpoints[string(endpoint)] = true
}
for _, quota := range model.GetModelQuotaTypes(name) {
quotas[quota] = true
}
}
metadata.BoundChannels = nil
metadata.EnableGroups = nil
metadata.SupportedEndpoints = nil
metadata.QuotaTypes = nil
metadata.MatchedModels = nil
for _, channel := range channels {
metadata.BoundChannels = append(metadata.BoundChannels, channel)
}
sort.Slice(metadata.BoundChannels, func(i, j int) bool {
a, b := metadata.BoundChannels[i], metadata.BoundChannels[j]
if a.Name == b.Name {
return a.Type < b.Type
}
return a.Name < b.Name
})
for group := range groups {
metadata.EnableGroups = append(metadata.EnableGroups, group)
}
for endpoint := range endpoints {
metadata.SupportedEndpoints = append(metadata.SupportedEndpoints, endpoint)
}
for quota := range quotas {
metadata.QuotaTypes = append(metadata.QuotaTypes, quota)
}
sort.Strings(metadata.EnableGroups)
sort.Strings(metadata.SupportedEndpoints)
sort.Ints(metadata.QuotaTypes)
if metadata.NameRule != model.NameRuleExact {
for name := range names {
metadata.MatchedModels = append(metadata.MatchedModels, name)
}
sort.Strings(metadata.MatchedModels)
metadata.MatchedCount = len(names)
}
}
return nil
}
+118
View File
@@ -0,0 +1,118 @@
package controller
import (
"fmt"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestChannelOwnerNameUsesAdaptorChannelName(t *testing.T) {
tests := []struct {
name string
channelType int
expected string
}{
{
name: "openai",
channelType: constant.ChannelTypeOpenAI,
expected: "openai",
},
{
name: "codex",
channelType: constant.ChannelTypeCodex,
expected: "codex",
},
{
name: "openrouter",
channelType: constant.ChannelTypeOpenRouter,
expected: "openrouter",
},
{
name: "azure fallback",
channelType: constant.ChannelTypeAzure,
expected: "azure",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.expected, channelOwnerName(tt.channelType))
})
}
}
func TestBuildOpenAIModelOverridesOwnedBy(t *testing.T) {
modelItem := buildOpenAIModel("gpt-5.4", map[string]string{"gpt-5.4": "openai"})
require.Equal(t, "gpt-5.4", modelItem.Id)
require.Equal(t, "openai", modelItem.OwnedBy)
}
func TestBuildOpenAIModelFallsBackToCustomForUnknownModels(t *testing.T) {
modelItem := buildOpenAIModel("custom-test-model", nil)
require.Equal(t, "custom-test-model", modelItem.Id)
require.Equal(t, "custom", modelItem.OwnedBy)
}
func TestGetModelListGroupsUsesUserGroupWhenTokenGroupIsEmpty(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
groups, err := getModelListGroups(ctx)
require.NoError(t, err)
require.Equal(t, "default", groups.userGroup)
require.Empty(t, groups.tokenGroup)
require.Equal(t, []string{"default"}, groups.ownerGroups)
}
func TestGetModelListGroupsUsesExplicitTokenGroup(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "vip")
groups, err := getModelListGroups(ctx)
require.NoError(t, err)
require.Equal(t, "default", groups.userGroup)
require.Equal(t, "vip", groups.tokenGroup)
require.Equal(t, []string{"vip"}, groups.ownerGroups)
}
func TestGetModelListGroupsUsesFilteredTokenAutoGroupsSnapshot(t *testing.T) {
originalMax := setting.GetMaxTokenAutoGroups()
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalRatios := ratio_setting.GroupRatio2JSONString()
require.NoError(t, setting.UpdateMaxTokenAutoGroups("1"))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
t.Cleanup(func() {
require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
})
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "auto")
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip", "default"})
groups, err := getModelListGroups(ctx)
require.NoError(t, err)
require.Equal(t, []string{"vip"}, groups.ownerGroups)
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
groups, err = getModelListGroups(ctx)
require.NoError(t, err)
require.Empty(t, groups.ownerGroups)
}
+43
View File
@@ -0,0 +1,43 @@
package controller
import (
"errors"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
func GetModelPricingConfig(c *gin.Context) {
snapshot, err := model.GetModelPricingSnapshot(c.QueryArray("model"))
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, snapshot)
}
func UpdateModelPricingConfig(c *gin.Context) {
var request struct {
Changes []model.ModelPricingChange `json:"changes"`
}
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
if err := model.UpdateModelPricing(request.Changes); err != nil {
status := http.StatusBadRequest
if errors.Is(err, model.ErrModelPricingConflict) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"success": false, "message": err.Error()})
return
}
names := make([]string, 0, len(request.Changes))
for _, change := range request.Changes {
names = append(names, change.ModelName)
}
recordManageAudit(c, "model.pricing.update", map[string]any{"models": names})
common.ApiSuccess(c, gin.H{"updated_models": names})
}
+452
View File
@@ -0,0 +1,452 @@
package controller
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// 上游地址
const (
upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json"
upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json"
)
func normalizeLocale(locale string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(locale)) {
case "", "zh", "zh-cn":
return "zh", true
case "en":
return "en", true
case "ja":
return "ja", true
default:
return "", false
}
}
func getUpstreamBase() string {
return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata")
}
func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) {
base := strings.TrimRight(getUpstreamBase(), "/")
if l, ok := normalizeLocale(locale); ok && l != "" {
return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l),
fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l)
}
return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base)
}
type upstreamEnvelope[T any] struct {
Success bool `json:"success"`
Message string `json:"message"`
Data []T `json:"data"`
}
type upstreamModel struct {
Description string `json:"description"`
Endpoints json.RawMessage `json:"endpoints"`
Icon string `json:"icon"`
ModelName string `json:"model_name"`
NameRule int `json:"name_rule"`
Status int `json:"status"`
Tags string `json:"tags"`
VendorName string `json:"vendor_name"`
}
type upstreamVendor struct {
Description string `json:"description"`
Icon string `json:"icon"`
Name string `json:"name"`
Status int `json:"status"`
}
var (
etagCache = make(map[string]string)
bodyCache = make(map[string][]byte)
cacheMutex sync.RWMutex
)
func newHTTPClient() *http.Client {
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10)
dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: time.Duration(timeoutSec) * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
}
if common.TLSInsecureSkipVerify {
transport.TLSClientConfig = common.InsecureTLSConfig
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
if strings.HasSuffix(host, "github.io") {
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
return conn, nil
}
return dialer.DialContext(ctx, "tcp6", addr)
}
return dialer.DialContext(ctx, network, addr)
}
return &http.Client{Transport: transport}
}
var (
httpClientOnce sync.Once
httpClient *http.Client
)
func getHTTPClient() *http.Client {
httpClientOnce.Do(func() {
httpClient = newHTTPClient()
})
return httpClient
}
func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error {
var lastErr error
attempts := max(common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3), 1)
baseDelay := 200 * time.Millisecond
maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10)
maxBytes := int64(maxMB) << 20
for attempt := 0; attempt < attempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// ETag conditional request
cacheMutex.RLock()
if et := etagCache[url]; et != "" {
req.Header.Set("If-None-Match", et)
}
cacheMutex.RUnlock()
resp, err := getHTTPClient().Do(req)
if err != nil {
lastErr = err
// backoff with jitter
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
continue
}
func() {
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// read body into buffer for caching and flexible decode
limited := io.LimitReader(resp.Body, maxBytes+1)
buf, err := io.ReadAll(limited)
if err != nil {
lastErr = err
return
}
if int64(len(buf)) > maxBytes {
lastErr = errors.New("upstream metadata exceeds size limit")
return
}
// cache body and ETag
cacheMutex.Lock()
if et := resp.Header.Get("ETag"); et != "" {
etagCache[url] = et
}
bodyCache[url] = buf
cacheMutex.Unlock()
// Try decode as envelope first
if err := common.Unmarshal(buf, out); err != nil {
// Try decode as pure array
var arr []T
if err2 := common.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
case http.StatusNotModified:
// use cache
cacheMutex.RLock()
buf := bodyCache[url]
cacheMutex.RUnlock()
if len(buf) == 0 {
lastErr = errors.New("cache miss for 304 response")
return
}
if err := common.Unmarshal(buf, out); err != nil {
var arr []T
if err2 := common.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
default:
lastErr = errors.New(resp.Status)
}
}()
if lastErr == nil {
return nil
}
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
}
return lastErr
}
type metadataSyncSource struct {
Locale string `json:"locale"`
ModelsURL string `json:"models_url"`
VendorsURL string `json:"vendors_url"`
Version string `json:"version"`
}
type metadataSyncField struct {
Field string `json:"field"`
Local any `json:"local"`
Upstream any `json:"upstream"`
}
type metadataSyncCandidate struct {
ModelName string `json:"model_name"`
Kind string `json:"kind"`
Scope string `json:"scope"`
RecordVersion string `json:"record_version"`
Fields []metadataSyncField `json:"fields"`
Upstream *model.MetadataValues `json:"upstream,omitempty"`
VendorToCreate string `json:"vendor_to_create,omitempty"`
}
func fetchMetadataCatalog(c *gin.Context, locale string) (metadataSyncSource, map[string]model.MetadataValues, map[string]model.Vendor, error) {
resolved, valid := normalizeLocale(locale)
if !valid {
return metadataSyncSource{}, nil, nil, errors.New("unsupported metadata language")
}
modelsURL, vendorsURL := getUpstreamURLs(resolved)
source := metadataSyncSource{Locale: resolved, ModelsURL: modelsURL, VendorsURL: vendorsURL}
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15))*time.Second)
defer cancel()
var modelsEnv upstreamEnvelope[upstreamModel]
var vendorsEnv upstreamEnvelope[upstreamVendor]
var modelsErr, vendorsErr error
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); modelsErr = fetchJSON(ctx, modelsURL, &modelsEnv) }()
go func() { defer wg.Done(); vendorsErr = fetchJSON(ctx, vendorsURL, &vendorsEnv) }()
wg.Wait()
if modelsErr != nil {
return source, nil, nil, fmt.Errorf("fetch models (%s, %s): %w", resolved, modelsURL, modelsErr)
}
if vendorsErr != nil {
return source, nil, nil, fmt.Errorf("fetch vendors (%s, %s): %w", resolved, vendorsURL, vendorsErr)
}
if !modelsEnv.Success || !vendorsEnv.Success {
return source, nil, nil, errors.New("upstream metadata source reported failure")
}
models := make(map[string]model.MetadataValues)
vendors := make(map[string]model.Vendor)
for _, vendor := range vendorsEnv.Data {
vendor.Name = strings.TrimSpace(vendor.Name)
if vendor.Name == "" {
continue
}
vendors[vendor.Name] = model.Vendor{Name: vendor.Name, Description: vendor.Description, Icon: vendor.Icon, Status: vendor.Status}
}
for _, item := range modelsEnv.Data {
if strings.TrimSpace(item.ModelName) == "" {
continue
}
endpoints := ""
if len(item.Endpoints) > 0 && string(item.Endpoints) != "null" {
if err := common.Unmarshal(item.Endpoints, &endpoints); err != nil {
endpoints = string(item.Endpoints)
}
}
values := model.MetadataValues{Description: item.Description, Icon: item.Icon, Tags: item.Tags, Vendor: strings.TrimSpace(item.VendorName), Endpoints: endpoints, NameRule: item.NameRule, Status: item.Status}
if err := model.ValidateMetadataValues(values); err != nil {
return source, nil, nil, fmt.Errorf("model %s: %w", item.ModelName, err)
}
if _, duplicate := models[item.ModelName]; duplicate {
return source, nil, nil, fmt.Errorf("duplicate upstream model: %s", item.ModelName)
}
models[item.ModelName] = values
}
encoded, err := common.Marshal([]any{source.Locale, models, vendors})
if err != nil {
return source, nil, nil, err
}
source.Version = fmt.Sprintf("%x", sha256.Sum256(encoded))
return source, models, vendors, nil
}
func SyncUpstreamPreview(c *gin.Context) {
source, upstream, upstreamVendors, err := fetchMetadataCatalog(c, c.Query("locale"))
if err != nil {
common.ApiError(c, err)
return
}
locals, vendors, err := model.GetMetadataSyncState(model.DB)
if err != nil {
common.ApiError(c, err)
return
}
missing, err := model.GetMissingModels()
if err != nil {
common.ApiError(c, err)
return
}
siteNames := make(map[string]bool)
allNames := make(map[string]bool)
for name := range locals {
siteNames[name] = true
allNames[name] = true
}
for _, name := range missing {
siteNames[name] = true
allNames[name] = true
}
for name := range upstream {
allNames[name] = true
}
names := make([]string, 0, len(allNames))
for name := range allNames {
names = append(names, name)
}
sort.Strings(names)
vendorByID := make(map[int]*model.Vendor)
for _, vendor := range vendors {
vendorByID[vendor.Id] = vendor
}
candidates := make([]metadataSyncCandidate, 0, len(names))
for _, name := range names {
candidate := metadataSyncCandidate{ModelName: name, Scope: "catalog", Kind: "create", Fields: []metadataSyncField{}}
if siteNames[name] {
candidate.Scope = "site"
}
local := locals[name]
up, found := upstream[name]
if !found {
candidate.Kind = "missing_upstream"
candidates = append(candidates, candidate)
continue
}
candidate.Upstream = &up
var localVendor *model.Vendor
if local != nil {
localVendor = vendorByID[local.VendorID]
}
candidate.RecordVersion = model.MetadataRecordVersion(local, localVendor, model.FindMetadataVendor(vendors, up.Vendor))
if local != nil && local.SyncOfficial == 0 {
candidate.Kind = "blocked"
candidates = append(candidates, candidate)
continue
}
if up.Vendor != "" && model.FindMetadataVendor(vendors, up.Vendor) == nil {
if _, exists := upstreamVendors[up.Vendor]; !exists {
candidate.Kind = "missing_vendor"
candidates = append(candidates, candidate)
continue
}
candidate.VendorToCreate = up.Vendor
}
localValues := model.MetadataValues{}
if local != nil {
candidate.Kind = "update"
localValues = model.MetadataValues{Description: local.Description, Icon: local.Icon, Tags: local.Tags, Endpoints: local.Endpoints, NameRule: local.NameRule, Status: local.Status}
if localVendor != nil {
localValues.Vendor = localVendor.Name
}
}
localRaw, _ := common.Marshal(localValues)
upRaw, _ := common.Marshal(up)
var localFields, upFields map[string]any
_ = common.Unmarshal(localRaw, &localFields)
_ = common.Unmarshal(upRaw, &upFields)
for _, field := range model.MetadataSyncFields {
if local == nil || localFields[field] != upFields[field] {
candidate.Fields = append(candidate.Fields, metadataSyncField{Field: field, Local: localFields[field], Upstream: upFields[field]})
}
}
if local != nil && len(candidate.Fields) == 0 {
candidate.Kind = "unchanged"
}
candidates = append(candidates, candidate)
}
common.ApiSuccess(c, gin.H{"source": source, "candidates": candidates})
}
func SyncUpstreamModels(c *gin.Context) {
var request struct {
Locale string `json:"locale"`
SourceVersion string `json:"source_version"`
Selections []model.MetadataSyncSelection `json:"selections"`
}
if err := common.DecodeJson(c.Request.Body, &request); err != nil || len(request.Selections) == 0 || request.SourceVersion == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Preview and select metadata changes before applying"})
return
}
source, upstream, vendors, err := fetchMetadataCatalog(c, request.Locale)
if err != nil {
common.ApiError(c, err)
return
}
if source.Version != request.SourceVersion {
c.JSON(http.StatusConflict, gin.H{"success": false, "message": "Upstream metadata changed; preview again"})
return
}
updates := make([]model.MetadataSyncUpdate, 0, len(request.Selections))
for _, selection := range request.Selections {
values, exists := upstream[selection.ModelName]
if !exists {
c.JSON(http.StatusConflict, gin.H{"success": false, "message": "Selected upstream model is no longer available"})
return
}
updates = append(updates, model.MetadataSyncUpdate{MetadataSyncSelection: selection, Values: values})
}
result, err := model.ApplyMetadataSync(updates, vendors)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, model.ErrMetadataSyncConflict) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"success": false, "message": err.Error()})
return
}
recordManageAudit(c, "model.metadata.sync", map[string]any{"created_models": result.CreatedModels, "updated_models": result.UpdatedModels, "created_vendors": result.CreatedVendors})
common.ApiSuccess(c, result)
}
+589
View File
@@ -0,0 +1,589 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const oauthAuthFlowTTL = 10 * time.Minute
type oauthStateRequest struct {
Provider string `json:"provider"`
Intent string `json:"intent"`
Aff string `json:"aff,omitempty"`
Scope string `json:"scope,omitempty"`
Context json.RawMessage `json:"context,omitempty"`
}
type oauthFlowPayload struct {
AffiliateCode string `json:"affiliate_code,omitempty"`
Verification *service.OAuthVerificationFlow `json:"verification,omitempty"`
Telegram *oauth.TelegramOAuthFlow `json:"telegram,omitempty"`
SessionIdentity *service.AuthIdentity `json:"session_identity,omitempty"`
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
}
// providerParams returns map with Provider key for i18n templates
func providerParams(name string) map[string]any {
return map[string]any{"Provider": name}
}
// GenerateOAuthCode generates a state code for OAuth CSRF protection
func GenerateOAuthCode(c *gin.Context) {
var request oauthStateRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
request.Provider = strings.TrimSpace(request.Provider)
request.Intent = strings.TrimSpace(request.Intent)
request.Aff = strings.TrimSpace(request.Aff)
if oauth.GetProvider(request.Provider) == nil ||
(request.Intent != model.AuthFlowIntentLogin && request.Intent != model.AuthFlowIntentBind && request.Intent != model.AuthFlowIntentVerify) ||
len(request.Aff) > 32 ||
(request.Intent != model.AuthFlowIntentLogin && request.Aff != "") ||
(request.Intent != model.AuthFlowIntentVerify && (request.Scope != "" || len(request.Context) != 0)) {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
userID := 0
sessionID := ""
flowPayload := oauthFlowPayload{AffiliateCode: request.Aff}
bindingStarted := false
if request.Provider == "telegram" {
telegramFlow, err := oauth.NewTelegramOAuthFlow()
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.Telegram = telegramFlow
}
if request.Intent == model.AuthFlowIntentBind || request.Intent == model.AuthFlowIntentVerify {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"})
return
}
userID = identity.UserID
sessionID = identity.SessionID
if request.Intent == model.AuthFlowIntentBind {
defer func() {
recordUserSecurityAudit(c, userID, "user.binding_start", map[string]any{"provider": request.Provider, "success": bindingStarted})
}()
context, err := common.Marshal(service.AccountBindingContext{Provider: request.Provider})
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.Authorization = middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context})
if flowPayload.Authorization == nil {
return
}
flowPayload.SessionIdentity = &identity
}
if flowPayload.Telegram != nil {
if _, _, err := service.ValidateLoginSession(identity); err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.SessionIdentity = &identity
}
if request.Intent == model.AuthFlowIntentVerify {
verification, err := service.StartOAuthVerification(identity, service.VerificationOperation{Scope: request.Scope, Context: request.Context}, request.Provider)
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.Verification = verification
}
}
payload, err := common.Marshal(flowPayload)
if err != nil {
writeSecurityOperationError(c, err)
return
}
expiresAt := time.Now().Add(oauthAuthFlowTTL)
state, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth,
Provider: request.Provider,
Intent: request.Intent,
UserId: userID,
SessionId: sessionID,
Payload: string(payload),
ExpiresAt: expiresAt,
})
if err != nil {
writeSecurityOperationError(c, err)
return
}
bindingStarted = request.Intent == model.AuthFlowIntentBind
data := gin.H{"flow_token": state, "expires_at": expiresAt.Unix()}
if flowPayload.Telegram != nil {
data["authorization_url"] = flowPayload.Telegram.AuthorizationURL(state)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}
// HandleOAuth handles OAuth callback for all standard OAuth providers
func HandleOAuth(c *gin.Context) {
providerName := c.Param("provider")
provider := oauth.GetProvider(providerName)
if provider == nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthUnknownProvider),
})
return
}
// 1. Validate state (CSRF protection)
state := c.Query("state")
pendingFlow, err := model.GetAuthFlow(state, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
})
if err != nil {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
})
return
}
consumeMatch := model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
Intent: pendingFlow.Intent,
}
bindSucceeded, notificationFailed := false, false
if pendingFlow.Intent == model.AuthFlowIntentBind {
defer func() {
recordUserSecurityAudit(c, pendingFlow.UserId, "user.binding_bind", map[string]any{"provider": providerName, "success": bindSucceeded, "notification_failed": notificationFailed})
}()
}
// Bind and verification callbacks must use the dashboard session that started them.
if pendingFlow.Intent == model.AuthFlowIntentBind || pendingFlow.Intent == model.AuthFlowIntentVerify {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
})
return
}
consumeMatch.UserId = identity.UserID
consumeMatch.SessionId = identity.SessionID
if pendingFlow.Intent == model.AuthFlowIntentBind {
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(pendingFlow.Payload, &payload); err != nil {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
context, err := common.Marshal(service.AccountBindingContext{Provider: providerName})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, payload.Authorization); err != nil {
writeSecurityOperationError(c, err)
return
}
}
} else if pendingFlow.Intent != model.AuthFlowIntentLogin {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
// 3. Check if provider is enabled
var telegramPayload oauthFlowPayload
if providerName == "telegram" {
if err := oauth.TelegramConfigurationError(); err != nil {
writeSecurityOperationError(c, err)
return
}
if err := common.UnmarshalJsonStr(pendingFlow.Payload, &telegramPayload); err != nil || telegramPayload.Telegram == nil {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
if pendingFlow.Intent != model.AuthFlowIntentLogin {
identity, _ := middleware.GetSessionAuthIdentity(c)
if telegramPayload.SessionIdentity == nil || *telegramPayload.SessionIdentity != identity {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
if _, _, err := service.ValidateLoginSession(identity); err != nil {
writeSecurityOperationError(c, err)
return
}
}
c.Set(oauth.TelegramOAuthFlowContextKey, telegramPayload.Telegram)
}
if !provider.IsEnabled() {
common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName()))
return
}
// 4. Handle error from provider
errorCode := c.Query("error")
if errorCode != "" {
if _, err := model.ConsumeAuthFlow(state, consumeMatch); err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
errorDescription := c.Query("error_description")
if errorDescription == "" {
errorDescription = errorCode
}
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errorDescription,
})
return
}
// 5. Exchange code for token
code := c.Query("code")
token, err := provider.ExchangeToken(c.Request.Context(), code, c)
if err != nil {
if providerName == "telegram" {
writeSecurityOperationError(c, err)
return
}
handleOAuthError(c, err)
return
}
// 6. Get user info
oauthUser, err := provider.GetUserInfo(c.Request.Context(), token)
if err != nil {
if providerName == "telegram" {
writeSecurityOperationError(c, err)
return
}
handleOAuthError(c, err)
return
}
if pendingFlow.Intent == model.AuthFlowIntentBind {
bindSucceeded, notificationFailed = handleOAuthBind(c, providerName, provider, oauthUser, pendingFlow, state, consumeMatch)
return
}
flow, err := model.ConsumeAuthFlow(state, consumeMatch)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
switch flow.Intent {
case model.AuthFlowIntentLogin:
handleOAuthLogin(c, provider, oauthUser, flow)
case model.AuthFlowIntentVerify:
handleOAuthVerification(c, providerName, oauthUser, flow)
}
}
func handleOAuthVerification(c *gin.Context, provider string, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) {
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
writeSecurityOperationError(c, err)
return
}
identity, _ := middleware.GetSessionAuthIdentity(c)
proof, err := service.FinishOAuthVerification(identity, provider, oauthUser.ProviderUserID, payload.Verification)
if err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]any{"method": proof.Method, "scope": proof.Scope, "provider": provider})
common.ApiSuccess(c, proof)
}
func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) {
// 7. Find or create user
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
writeSecurityOperationError(c, err)
return
}
user, err := findOrCreateOAuthUser(c, provider, oauthUser, payload.AffiliateCode)
if err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
return
}
switch err.(type) {
case *OAuthUserDeletedError:
common.ApiErrorI18n(c, i18n.MsgOAuthUserDeleted)
case *OAuthRegistrationDisabledError:
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
case *OAuthEmailAlreadyTakenError:
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
default:
writeSecurityOperationError(c, err)
}
return
}
// 8. Check user status
if user.Status != common.UserStatusEnabled {
common.ApiErrorI18n(c, i18n.MsgOAuthUserBanned)
return
}
// 9. Setup login
setupLogin(user, c)
}
// handleOAuthBind handles binding OAuth account to existing user
func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow, state string, match model.AuthFlowMatch) (bool, bool) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return false, false
}
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return false, false
}
context, err := common.Marshal(service.AccountBindingContext{Provider: providerName})
if err != nil {
writeSecurityOperationError(c, err)
return false, false
}
// Recheck after the external provider round trip, then validate the session
// under the transaction's locks before consuming the flow and writing.
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, payload.Authorization); err != nil {
writeSecurityOperationError(c, err)
return false, false
}
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
return false, false
}
if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && legacyID != "" && provider.IsUserIDTaken(legacyID) {
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
return false, false
}
_, err = model.ConsumeAuthFlowWithAction(state, match, func(tx *gorm.DB, _ *model.AuthFlow) error {
if providerName == "telegram" {
return model.BindTelegramForSessionWithTx(tx, identity, oauthUser.ProviderUserID)
}
if custom, ok := provider.(*oauth.GenericOAuthProvider); ok {
return model.UpdateUserOAuthBindingForSessionWithTx(tx, identity, custom.GetProviderId(), oauthUser.ProviderUserID)
}
return model.UpdateUserBindColumnForSessionWithTx(tx, identity, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID)
})
if err != nil {
writeSecurityOperationError(c, err)
return false, false
}
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
writeSecurityOperationError(c, err)
return true, true
}
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{"action": "bind", "notification_warning": notificationFailed})
return true, notificationFailed
}
// findOrCreateOAuthUser finds existing user or creates new user
func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, affiliateCode string) (*model.User, error) {
user := &model.User{}
if provider.ProviderUserIDColumn() == "telegram_id" {
err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, oauth.ErrTelegramAccountNotBound
}
return user, err
}
// Check if user already exists with new ID
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID)
if err != nil {
return nil, err
}
// Check if user has been deleted
if user.Id == 0 {
return nil, &OAuthUserDeletedError{}
}
return user, nil
}
// Try to find user with legacy ID (for GitHub migration from login to numeric ID)
if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && legacyID != "" {
if provider.IsUserIDTaken(legacyID) {
err := provider.FillUserByProviderID(user, legacyID)
if err != nil {
return nil, err
}
if user.Id != 0 {
// Found user with legacy ID, migrate to new ID
common.SysLog(fmt.Sprintf("[OAuth] Migrating user %d from legacy_id=%s to new_id=%s",
user.Id, legacyID, oauthUser.ProviderUserID))
if err := user.UpdateGitHubId(oauthUser.ProviderUserID); err != nil {
common.SysError(fmt.Sprintf("[OAuth] Failed to migrate user %d: %s", user.Id, err.Error()))
// Continue with login even if migration fails
}
return user, nil
}
}
}
// User doesn't exist, create new user if registration is enabled
if !common.RegisterEnabled {
return nil, &OAuthRegistrationDisabledError{}
}
// Set up new user
user.Username = provider.GetProviderPrefix() + strconv.Itoa(model.GetMaxUserId()+1)
if oauthUser.Username != "" {
if exists, err := model.CheckUserExistOrDeleted(oauthUser.Username, ""); err == nil && !exists {
// 防止索引退化
if len(oauthUser.Username) <= model.UserNameMaxLength {
user.Username = oauthUser.Username
}
}
}
if oauthUser.DisplayName != "" {
user.DisplayName = oauthUser.DisplayName
} else if oauthUser.Username != "" {
user.DisplayName = oauthUser.Username
} else {
user.DisplayName = provider.GetName() + " User"
}
if oauthUser.Email != "" {
user.Email = model.NormalizeEmail(oauthUser.Email)
if err := model.EnsureEmailAvailable(user.Email, 0); err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
return nil, &OAuthEmailAlreadyTakenError{}
}
return nil, err
}
}
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
// Handle affiliate code
inviterId := 0
if affiliateCode != "" {
inviterId, _ = model.GetUserIdByAffCode(affiliateCode)
}
// Use transaction to ensure user creation and OAuth binding are atomic
if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
// Custom provider: create user and binding in a transaction
err := model.DB.Transaction(func(tx *gorm.DB) error {
// Create user
if err := user.InsertWithTx(tx, inviterId); err != nil {
return err
}
// Create OAuth binding
binding := &model.UserOAuthBinding{
UserId: user.Id,
ProviderId: genericProvider.GetProviderId(),
ProviderUserId: oauthUser.ProviderUserID,
}
if err := model.CreateUserOAuthBindingWithTx(tx, binding); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
// Perform post-transaction tasks (logs, sidebar config, inviter rewards)
user.FinalizeOAuthUserCreation(inviterId)
} else {
// Built-in provider: create user and update provider ID in a transaction
err := model.DB.Transaction(func(tx *gorm.DB) error {
// Create user
if err := user.InsertWithTx(tx, inviterId); err != nil {
return err
}
// Set the provider user ID on the user model and update
provider.SetProviderUserID(user, oauthUser.ProviderUserID)
if err := tx.Model(user).Updates(map[string]any{
"github_id": user.GitHubId,
"discord_id": user.DiscordId,
"oidc_id": user.OidcId,
"linux_do_id": user.LinuxDOId,
"wechat_id": user.WeChatId,
"telegram_id": user.TelegramId,
}).Error; err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
// Perform post-transaction tasks
user.FinalizeOAuthUserCreation(inviterId)
}
return user, nil
}
// Error types for OAuth
type OAuthUserDeletedError struct{}
func (e *OAuthUserDeletedError) Error() string {
return "user has been deleted"
}
type OAuthRegistrationDisabledError struct{}
func (e *OAuthRegistrationDisabledError) Error() string {
return "registration is disabled"
}
type OAuthEmailAlreadyTakenError struct{}
func (e *OAuthEmailAlreadyTakenError) Error() string {
return "email is already in use"
}
// handleOAuthError handles OAuth errors and returns translated message
func handleOAuthError(c *gin.Context, err error) {
switch e := err.(type) {
case *oauth.OAuthError:
if e.Params != nil {
common.ApiErrorI18n(c, e.MsgKey, e.Params)
} else {
common.ApiErrorI18n(c, e.MsgKey)
}
case *oauth.AccessDeniedError:
common.ApiErrorMsg(c, e.Message)
case *oauth.TrustLevelError:
common.ApiErrorI18n(c, i18n.MsgOAuthTrustLevelLow)
default:
writeSecurityOperationError(c, err)
}
}
+431
View File
@@ -0,0 +1,431 @@
package controller
import (
"fmt"
"net/http"
"slices"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
var completionRatioMetaOptionKeys = []string{
"ModelPrice",
"ModelRatio",
"CompletionRatio",
"CacheRatio",
"CreateCacheRatio",
"ImageRatio",
"AudioRatio",
"AudioCompletionRatio",
}
func isPaymentComplianceOptionKey(key string) bool {
return strings.HasPrefix(key, "payment_setting.compliance_")
}
func isPositiveOptionValue(value string) bool {
intValue, err := strconv.Atoi(strings.TrimSpace(value))
if err == nil {
return intValue > 0
}
floatValue, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
return err == nil && floatValue > 0
}
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
if strings.TrimSpace(raw) == "" {
return
}
var parsed map[string]any
if err := common.UnmarshalJsonStr(raw, &parsed); err != nil {
return
}
for modelName := range parsed {
modelNames[modelName] = struct{}{}
}
}
func buildCompletionRatioMetaValue(optionValues map[string]string) string {
modelNames := make(map[string]struct{})
for _, key := range completionRatioMetaOptionKeys {
collectModelNamesFromOptionValue(optionValues[key], modelNames)
}
meta := make(map[string]ratio_setting.CompletionRatioInfo, len(modelNames))
for modelName := range modelNames {
meta[modelName] = ratio_setting.GetCompletionRatioInfo(modelName)
}
jsonBytes, err := common.Marshal(meta)
if err != nil {
return "{}"
}
return string(jsonBytes)
}
func GetOptions(c *gin.Context) {
var options []*model.Option
optionValues := make(map[string]string)
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
if k == "theme.frontend" || k == "billing_setting.billing_mode" || k == "billing_setting.billing_expr" {
continue
}
value := common.Interface2String(v)
isSensitiveKey := strings.HasSuffix(k, "Token") ||
strings.HasSuffix(k, "Secret") ||
strings.HasSuffix(k, "Key") ||
strings.HasSuffix(k, "secret") ||
strings.HasSuffix(k, "api_key")
if isSensitiveKey {
continue
}
options = append(options, &model.Option{
Key: k,
Value: value,
})
if slices.Contains(completionRatioMetaOptionKeys, k) {
optionValues[k] = value
}
}
common.OptionMapRWMutex.Unlock()
// Display the same effective expressions used by pricing and settlement,
// including built-in defaults absent from persisted administrator options.
for key, values := range map[string]map[string]string{
"billing_setting.billing_mode": billing_setting.GetBillingModeCopy(),
"billing_setting.billing_expr": billing_setting.GetBillingExprCopy(),
} {
encoded, err := common.Marshal(values)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
options = append(options, &model.Option{Key: key, Value: string(encoded)})
}
options = append(options, &model.Option{
Key: "CompletionRatioMeta",
Value: buildCompletionRatioMetaValue(optionValues),
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": options,
})
}
type OptionUpdateRequest struct {
Key string `json:"key"`
Value any `json:"value"`
}
func UpdateOption(c *gin.Context) {
var option OptionUpdateRequest
err := common.DecodeJson(c.Request.Body, &option)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
switch option.Value.(type) {
case bool:
option.Value = common.Interface2String(option.Value.(bool))
case float64:
option.Value = common.Interface2String(option.Value.(float64))
case int:
option.Value = common.Interface2String(option.Value.(int))
default:
option.Value = fmt.Sprintf("%v", option.Value)
}
switch option.Key {
case "QuotaForInviter", "QuotaForInvitee":
if isPositiveOptionValue(option.Value.(string)) && !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
default:
if isPaymentComplianceOptionKey(option.Key) {
common.ApiErrorMsg(c, "合规确认字段不允许通过通用设置接口修改")
return
}
}
if option.Key == "TaskPublicAddress" && option.Value.(string) != "" {
if err := service.ValidateTaskArtifactBaseURL(option.Value.(string)); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
}
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用 GitHub OAuth,请先填入 GitHub Client Id 以及 GitHub Client Secret!",
})
return
}
case "discord.enabled":
if option.Value == "true" && system_setting.GetDiscordSettings().ClientId == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用 Discord OAuth,请先填入 Discord Client Id 以及 Discord Client Secret!",
})
return
}
case "oidc.enabled":
if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用 OIDC 登录,请先填入 OIDC Client Id 以及 OIDC Client Secret!",
})
return
}
case "LinuxDOOAuthEnabled":
if option.Value == "true" && common.LinuxDOClientId == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用 LinuxDO OAuth,请先填入 LinuxDO Client Id 以及 LinuxDO Client Secret!",
})
return
}
case "EmailDomainRestrictionEnabled":
if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!",
})
return
}
case "WeChatAuthEnabled":
if option.Value == "true" && common.WeChatServerAddress == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用微信登录,请先填入微信登录相关配置信息!",
})
return
}
case "TurnstileCheckEnabled":
if option.Value == "true" && common.TurnstileSiteKey == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!",
})
return
}
case "TelegramOAuthEnabled":
if option.Value == "true" && !system_setting.GetTelegramSettings().IsConfigured() {
c.JSON(http.StatusOK, gin.H{
"success": false,
"code": "TELEGRAM_OAUTH_NOT_CONFIGURED",
"message": "Telegram OAuth is not configured or enabled. Please contact your administrator.",
})
return
}
case "theme.frontend":
if option.Value != "default" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Classic 前端已移除,主题只能设置为 default",
})
return
}
case "GroupRatio":
err = ratio_setting.CheckGroupRatio(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "gemini.safety_settings":
err = model_setting.ValidateGeminiSafetySettings(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "claude.default_max_tokens":
err = model_setting.ValidateClaudeDefaultMaxTokens(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case operation_setting.ToolPriceOptionKey:
err = operation_setting.ValidateToolPricesJSON(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "图片倍率设置失败: " + err.Error(),
})
return
}
case "AudioRatio":
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "音频倍率设置失败: " + err.Error(),
})
return
}
case "AudioCompletionRatio":
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "音频补全倍率设置失败: " + err.Error(),
})
return
}
case "CreateCacheRatio":
err = ratio_setting.UpdateCreateCacheRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "缓存创建倍率设置失败: " + err.Error(),
})
return
}
case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "AutomaticDisableStatusCodes":
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "AutomaticRetryStatusCodes":
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "billing_setting.billing_expr":
expressions := make(map[string]string)
if err = common.UnmarshalJsonStr(option.Value.(string), &expressions); err != nil {
common.ApiErrorMsg(c, "计费表达式配置必须是模型到表达式的 JSON 对象: "+err.Error())
return
}
models := make([]string, 0, len(expressions))
for modelName := range expressions {
models = append(models, modelName)
}
sort.Strings(models)
generation := jsplugin.DefaultRegistry.Generation()
for _, modelName := range models {
expression := expressions[modelName]
if plugin, ok := generation.GetByModel(modelName); ok {
err = billing_setting.SmokeTestTaskExpr(expression, plugin.Meta.UsageSchema)
} else if target, resolved := model.ResolveTaskModelAlias(generation, modelName); 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 {
common.ApiErrorMsg(c, fmt.Sprintf("模型 %s 的计费表达式无效: %v", modelName, err))
return
}
}
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "console_setting.announcements":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "console_setting.faq":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "console_setting.uptime_kuma_groups":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
}
err = model.UpdateOption(option.Key, option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
}
// 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。
recordManageAudit(c, "option.update", map[string]any{
"key": option.Key,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
+34
View File
@@ -0,0 +1,34 @@
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateOptionRejectsNegativeClaudeDefaultMaxTokens(t *testing.T) {
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(
http.MethodPut,
"/api/option/",
strings.NewReader(`{"key":"claude.default_max_tokens","value":"{\"default\":-1}"}`),
)
UpdateOption(context)
assert.Equal(t, http.StatusOK, response.Code)
var payload struct {
Success bool `json:"success"`
Message string `json:"message"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload))
assert.False(t, payload.Success)
assert.Contains(t, payload.Message, "-1")
}
+34
View File
@@ -0,0 +1,34 @@
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateOptionRejectsInvalidGeminiSafetyThreshold(t *testing.T) {
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(
http.MethodPut,
"/api/option/",
strings.NewReader(`{"key":"gemini.safety_settings","value":"{\"default\":\"BLOCK_SOME\"}"}`),
)
UpdateOption(context)
assert.Equal(t, http.StatusOK, response.Code)
var payload struct {
Success bool `json:"success"`
Message string `json:"message"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload))
assert.False(t, payload.Success)
assert.Contains(t, payload.Message, "BLOCK_SOME")
}
+648
View File
@@ -0,0 +1,648 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
)
type passkeyFinishRequest struct {
FlowToken string `json:"flow_token"`
Credential json.RawMessage `json:"credential"`
}
type passkeyVerifyBeginRequest struct {
Scope string `json:"scope"`
Context json.RawMessage `json:"context,omitempty"`
}
func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) {
var request passkeyFinishRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
return nil, err
}
if request.FlowToken == "" || len(request.Credential) == 0 {
return nil, errors.New("Passkey 流程参数不完整")
}
return &request, nil
}
func PasskeyRegisterBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getAuthenticatedUser(c)
if err != nil {
writeSecurityOperationError(c, err)
return
}
authorization := middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister})
if authorization == nil {
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
writeSecurityOperationError(c, err)
return
}
if errors.Is(err, model.ErrPasskeyNotFound) {
credential = nil
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
selection := wa.Config.AuthenticatorSelection
selection.UserVerification = protocol.VerificationRequired
options := []webauthnlib.RegistrationOption{webauthnlib.WithAuthenticatorSelection(selection)}
if credential != nil {
descriptor := credential.ToWebAuthnCredential().Descriptor()
options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor}))
}
creation, sessionData, err := wa.BeginRegistration(waUser, options...)
if err != nil {
writeSecurityOperationError(c, err)
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
return
}
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
model.AuthFlowPurposePasskeyRegister,
passkeysvc.FlowSecurity{AuthSessionIdentity: identity, Scope: authorization.Scope, ContextHash: authorization.ContextHash, Authorization: authorization},
sessionData,
)
if err != nil {
writeSecurityOperationError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"options": creation,
"flow_token": flowToken,
"expires_at": expiresAt,
},
})
}
func PasskeyRegisterFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getAuthenticatedUser(c)
if err != nil {
writeSecurityOperationError(c, err)
return
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
return
}
parsedCredential, err := protocol.ParseCredentialCreationResponseBytes(request.Credential)
if err != nil {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
credentialRecord, err := model.GetPasskeyByUserID(user.Id)
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
writeSecurityOperationError(c, err)
return
}
if errors.Is(err, model.ErrPasskeyNotFound) {
credentialRecord = nil
}
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
return
}
sessionData, security, err := passkeysvc.PopSessionDataFlow(
request.FlowToken,
model.AuthFlowPurposePasskeyRegister,
identity,
)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister}, security.Authorization); err != nil {
writeSecurityOperationError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord)
credential, err := wa.CreateCredential(waUser, *sessionData, parsedCredential)
if err != nil {
writeSecurityOperationError(c, err)
return
}
passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential)
if passkeyCredential == nil {
common.ApiErrorMsg(c, "无法创建 Passkey 凭证")
return
}
if err := model.RegisterPasskeyForSession(identity, passkeyCredential); err != nil {
writeSecurityOperationError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_registered")
if err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, user.Id, "user.passkey_register", nil)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 注册成功",
"data": authRotationData(bundle),
})
}
func PasskeyDelete(c *gin.Context) {
user, err := getAuthenticatedUser(c)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopePasskeyDelete}) == nil {
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
return
}
if err := model.DeletePasskeyForSession(identity); err != nil {
writeSecurityOperationError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_deleted")
if err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, user.Id, "user.passkey_delete", nil)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已解绑",
"data": authRotationData(bundle),
})
}
func PasskeyStatus(c *gin.Context) {
user, err := getAuthenticatedUser(c)
if err != nil {
writeSecurityOperationError(c, err)
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if errors.Is(err, model.ErrPasskeyNotFound) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"enabled": false,
},
})
return
}
if err != nil {
writeSecurityOperationError(c, err)
return
}
data := gin.H{
"enabled": true,
"last_used_at": credential.LastUsedAt,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}
func PasskeyLoginBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
assertion, sessionData, err := wa.BeginDiscoverableLogin(webauthnlib.WithUserVerification(protocol.VerificationRequired))
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
model.AuthFlowPurposePasskeyLogin,
passkeysvc.FlowSecurity{},
sessionData,
)
if err != nil {
writeSecurityOperationError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"options": assertion,
"flow_token": flowToken,
"expires_at": expiresAt,
},
})
}
func PasskeyLoginFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
return
}
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
if err != nil {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
sessionData, _, err := passkeysvc.PopSessionDataFlow(
request.FlowToken,
model.AuthFlowPurposePasskeyLogin,
service.AuthIdentity{},
)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
handler := func(rawID, userHandle []byte) (webauthnlib.User, error) {
// 首先通过凭证ID查找用户
credential, err := model.GetPasskeyByCredentialID(rawID)
if err != nil {
return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err)
}
// 通过凭证获取用户
user := &model.User{Id: credential.UserID}
if err := user.FillUserById(); err != nil {
return nil, fmt.Errorf("用户信息获取失败: %w", err)
}
if user.Status != common.UserStatusEnabled {
return nil, model.ErrUserSessionInactive
}
if len(userHandle) > 0 {
userID, parseErr := strconv.Atoi(string(userHandle))
if parseErr != nil {
// 记录异常但继续验证,因为某些客户端可能使用非数字格式
common.SysLog(fmt.Sprintf("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle)))
} else if userID != user.Id {
return nil, errors.New("用户句柄与凭证不匹配")
}
}
return passkeysvc.NewWebAuthnUser(user, credential), nil
}
waUser, credential, err := wa.ValidatePasskeyLogin(handler, *sessionData, parsedCredential)
if err != nil {
writeSecurityOperationError(c, err)
return
}
userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser)
if !ok {
common.ApiErrorMsg(c, "Passkey 登录状态异常")
return
}
modelUser := userWrapper.ModelUser()
if modelUser == nil {
common.ApiErrorMsg(c, "Passkey 登录状态异常")
return
}
if modelUser.Status != common.UserStatusEnabled {
common.ApiErrorMsg(c, "该用户已被禁用")
return
}
if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now()); err != nil {
writeSecurityOperationError(c, err)
return
}
c.Set("login_verification_method", service.VerificationMethodPasskey)
setupLoginAtAuthVersion(modelUser, modelUser.AuthVersion, c)
}
func AdminResetPasskey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiErrorMsg(c, "无效的用户 ID")
return
}
user := &model.User{Id: id}
if err := user.FillUserById(); err != nil {
writeSecurityOperationError(c, err)
return
}
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, user.Role) {
common.ApiErrorMsg(c, "no permission")
return
}
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
if errors.Is(err, model.ErrPasskeyNotFound) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
writeSecurityOperationError(c, err)
return
}
if err := model.DeletePasskeyByUserIDWithAuthVersion(user.Id); err != nil {
writeSecurityOperationError(c, err)
return
}
if _, err := model.RevokeAllUserSessions(user.Id, "admin_passkey_reset"); err != nil {
writeSecurityOperationError(c, err)
return
}
recordManageAuditFor(c, user.Id, "user.reset_passkey", map[string]any{
"username": user.Username,
"id": user.Id,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已重置",
})
}
func PasskeyVerifyBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getAuthenticatedUser(c)
if err != nil {
writeSecurityOperationError(c, err)
return
}
var request passkeyVerifyBeginRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
return
}
binding, err := service.BindVerificationOperation(service.VerificationOperation{Scope: request.Scope, Context: request.Context})
if err != nil {
writeSecurityOperationError(c, err)
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
if _, err := service.RequireVerificationMethod(identity, request.Scope, service.VerificationMethodPasskey); err != nil {
writeSecurityOperationError(c, err)
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
assertion, sessionData, err := wa.BeginLogin(waUser, webauthnlib.WithUserVerification(protocol.VerificationRequired))
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
model.AuthFlowPurposePasskeyStepUp,
passkeysvc.FlowSecurity{AuthSessionIdentity: identity, Scope: binding.Scope, ContextHash: binding.ContextHash},
sessionData,
)
if err != nil {
writeSecurityOperationError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"options": assertion,
"flow_token": flowToken,
"expires_at": expiresAt,
},
})
}
func PasskeyVerifyFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getAuthenticatedUser(c)
if err != nil {
writeSecurityOperationError(c, err)
return
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
return
}
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
if err != nil {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
return
}
sessionData, security, err := passkeysvc.PopSessionDataFlow(
request.FlowToken,
model.AuthFlowPurposePasskeyStepUp,
identity,
)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
validatedCredential, err := wa.ValidateLogin(waUser, *sessionData, parsedCredential)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now()); err != nil {
writeSecurityOperationError(c, err)
return
}
proof, err := service.CompleteSecurityVerification(identity, service.VerificationBinding{Scope: security.Scope, ContextHash: security.ContextHash}, service.VerificationMethodPasskey)
if err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, user.Id, "user.security_verify", map[string]any{"method": proof.Method, "scope": proof.Scope})
common.ApiSuccess(c, proof)
}
func getAuthenticatedUser(c *gin.Context) (*model.User, error) {
id := c.GetInt("id")
if id == 0 {
return nil, service.ErrAuthTokenInvalid
}
user, err := model.GetUserById(id, false)
if err != nil {
return nil, err
}
if user.Status != common.UserStatusEnabled {
return nil, model.ErrUserSessionInactive
}
return user, nil
}
+72
View File
@@ -0,0 +1,72 @@
package controller
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type passkeyTestBody struct {
*strings.Reader
}
func (*passkeyTestBody) Close() error { return nil }
func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
bodyText := `{"flow_token":"flow-1","credential":{"id":"credential-1"}}`
body := &passkeyTestBody{Reader: strings.NewReader(bodyText)}
request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", nil)
request.Body = body
request.ContentLength = int64(len(bodyText))
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = request
parsed, err := parsePasskeyFinishRequest(context)
require.NoError(t, err)
assert.Equal(t, "flow-1", parsed.FlowToken)
assert.JSONEq(t, `{"id":"credential-1"}`, string(parsed.Credential))
assert.Same(t, body, context.Request.Body)
assert.Equal(t, int64(len(bodyText)), context.Request.ContentLength)
}
func TestPasskeyRegisterFinishRejectsUnapprovedFlowWithoutConsumingIt(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
system_setting.GetPasskeySettings().UserVerification = "required"
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
payload, err := common.Marshal(map[string]any{"scope": service.VerificationScopePasskeyRegister})
require.NoError(t, err)
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposePasskeyRegister, UserId: identity.UserID, SessionId: identity.SessionID,
Payload: string(payload), ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
body, err := common.Marshal(passkeyFinishRequest{
FlowToken: token, Credential: securityPasskeyResponse(t, key, "test-challenge", true, 0),
})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.False(t, result.Success)
assert.Equal(t, "AUTH_FLOW_INVALID", result.Code)
_, err = model.GetPasskeyByUserID(identity.UserID)
assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposePasskeyRegister})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
}
+82
View File
@@ -0,0 +1,82 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
type PaymentComplianceRequest struct {
Confirmed bool `json:"confirmed"`
}
func requirePaymentCompliance(c *gin.Context) bool {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return false
}
return true
}
func ConfirmPaymentCompliance(c *gin.Context) {
if c.GetBool("use_access_token") {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "This operation requires dashboard session authentication. API access token is not allowed.",
})
return
}
var req PaymentComplianceRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
if !req.Confirmed {
common.ApiErrorMsg(c, "请确认合规声明")
return
}
now := time.Now().Unix()
userId := c.GetInt("id")
clientIP := c.ClientIP()
updates := map[string]string{
"payment_setting.compliance_confirmed": "true",
"payment_setting.compliance_terms_version": operation_setting.CurrentComplianceTermsVersion,
"payment_setting.compliance_confirmed_at": strconv.FormatInt(now, 10),
"payment_setting.compliance_confirmed_by": strconv.Itoa(userId),
"payment_setting.compliance_confirmed_ip": clientIP,
}
for key, value := range updates {
if err := model.UpdateOption(key, value); err != nil {
common.ApiError(c, err)
return
}
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf(
"payment compliance confirmed user_id=%d ip=%s terms_version=%s confirmed_at=%d",
userId,
clientIP,
operation_setting.CurrentComplianceTermsVersion,
now,
))
common.ApiSuccess(c, gin.H{
"confirmed": true,
"terms_version": operation_setting.CurrentComplianceTermsVersion,
"confirmed_at": now,
"confirmed_by": userId,
})
}
+110
View File
@@ -0,0 +1,110 @@
package controller
import (
"strings"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
)
func isPaymentComplianceConfirmed() bool {
return operation_setting.IsPaymentComplianceConfirmed()
}
func isStripeTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
return strings.TrimSpace(setting.StripeApiSecret) != "" &&
strings.TrimSpace(setting.StripeWebhookSecret) != "" &&
strings.TrimSpace(setting.StripePriceId) != ""
}
func isStripeWebhookConfigured() bool {
return strings.TrimSpace(setting.StripeWebhookSecret) != ""
}
func isStripeWebhookEnabled() bool {
return isStripeTopUpEnabled()
}
func isCreemTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
products := strings.TrimSpace(setting.CreemProducts)
return strings.TrimSpace(setting.CreemApiKey) != "" &&
products != "" &&
products != "[]"
}
func isCreemWebhookConfigured() bool {
return strings.TrimSpace(setting.CreemWebhookSecret) != ""
}
func isCreemWebhookEnabled() bool {
return isCreemTopUpEnabled() && isCreemWebhookConfigured()
}
func isWaffoTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
if !setting.WaffoEnabled {
return false
}
return isWaffoWebhookConfigured()
}
func isWaffoWebhookConfigured() bool {
if setting.WaffoSandbox {
return strings.TrimSpace(setting.WaffoSandboxApiKey) != "" &&
strings.TrimSpace(setting.WaffoSandboxPrivateKey) != "" &&
strings.TrimSpace(setting.WaffoSandboxPublicCert) != ""
}
return strings.TrimSpace(setting.WaffoApiKey) != "" &&
strings.TrimSpace(setting.WaffoPrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPublicCert) != ""
}
func isWaffoWebhookEnabled() bool {
return isWaffoTopUpEnabled()
}
func isWaffoPancakeTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
// Presence-of-credentials = enabled. Webhook public keys ship inside
// the SDK; mode (test/prod) is read from each event.
return strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPancakeProductID) != ""
}
func isWaffoPancakeWebhookConfigured() bool {
return isWaffoPancakeTopUpEnabled()
}
func isWaffoPancakeWebhookEnabled() bool {
return isWaffoPancakeTopUpEnabled()
}
func isEpayTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
return isEpayWebhookConfigured() && len(operation_setting.PayMethods) > 0
}
func isEpayWebhookConfigured() bool {
return strings.TrimSpace(operation_setting.PayAddress) != "" &&
strings.TrimSpace(operation_setting.EpayId) != "" &&
strings.TrimSpace(operation_setting.EpayKey) != ""
}
func isEpayWebhookEnabled() bool {
return isEpayTopUpEnabled()
}
@@ -0,0 +1,169 @@
package controller
import (
"testing"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/stretchr/testify/require"
)
func confirmPaymentComplianceForTest(t *testing.T) {
t.Helper()
paymentSetting := operation_setting.GetPaymentSetting()
originalConfirmed := paymentSetting.ComplianceConfirmed
originalTermsVersion := paymentSetting.ComplianceTermsVersion
t.Cleanup(func() {
paymentSetting.ComplianceConfirmed = originalConfirmed
paymentSetting.ComplianceTermsVersion = originalTermsVersion
})
paymentSetting.ComplianceConfirmed = true
paymentSetting.ComplianceTermsVersion = operation_setting.CurrentComplianceTermsVersion
}
func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalAPISecret := setting.StripeApiSecret
originalWebhookSecret := setting.StripeWebhookSecret
originalPriceID := setting.StripePriceId
t.Cleanup(func() {
setting.StripeApiSecret = originalAPISecret
setting.StripeWebhookSecret = originalWebhookSecret
setting.StripePriceId = originalPriceID
})
setting.StripeWebhookSecret = ""
setting.StripeApiSecret = "sk_test_123"
setting.StripePriceId = "price_123"
require.False(t, isStripeWebhookEnabled())
setting.StripeWebhookSecret = "whsec_test"
require.True(t, isStripeWebhookEnabled())
setting.StripePriceId = ""
require.False(t, isStripeWebhookEnabled())
}
func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalAPIKey := setting.CreemApiKey
originalProducts := setting.CreemProducts
originalWebhookSecret := setting.CreemWebhookSecret
t.Cleanup(func() {
setting.CreemApiKey = originalAPIKey
setting.CreemProducts = originalProducts
setting.CreemWebhookSecret = originalWebhookSecret
})
setting.CreemWebhookSecret = ""
setting.CreemApiKey = "creem_api_key"
setting.CreemProducts = `[{"productId":"prod_123"}]`
require.False(t, isCreemWebhookEnabled())
setting.CreemWebhookSecret = "creem_secret"
require.True(t, isCreemWebhookEnabled())
setting.CreemProducts = "[]"
require.False(t, isCreemWebhookEnabled())
}
func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalEnabled := setting.WaffoEnabled
originalSandbox := setting.WaffoSandbox
originalAPIKey := setting.WaffoApiKey
originalPrivateKey := setting.WaffoPrivateKey
originalPublicCert := setting.WaffoPublicCert
originalSandboxAPIKey := setting.WaffoSandboxApiKey
originalSandboxPrivateKey := setting.WaffoSandboxPrivateKey
originalSandboxPublicCert := setting.WaffoSandboxPublicCert
t.Cleanup(func() {
setting.WaffoEnabled = originalEnabled
setting.WaffoSandbox = originalSandbox
setting.WaffoApiKey = originalAPIKey
setting.WaffoPrivateKey = originalPrivateKey
setting.WaffoPublicCert = originalPublicCert
setting.WaffoSandboxApiKey = originalSandboxAPIKey
setting.WaffoSandboxPrivateKey = originalSandboxPrivateKey
setting.WaffoSandboxPublicCert = originalSandboxPublicCert
})
setting.WaffoEnabled = true
setting.WaffoSandbox = false
setting.WaffoApiKey = ""
setting.WaffoPrivateKey = "private"
setting.WaffoPublicCert = "public"
require.False(t, isWaffoWebhookEnabled())
setting.WaffoApiKey = "api"
require.True(t, isWaffoWebhookEnabled())
setting.WaffoEnabled = false
require.False(t, isWaffoWebhookEnabled())
setting.WaffoEnabled = true
setting.WaffoSandbox = true
setting.WaffoSandboxApiKey = ""
setting.WaffoSandboxPrivateKey = "sandbox_private"
setting.WaffoSandboxPublicCert = "sandbox_public"
require.False(t, isWaffoWebhookEnabled())
setting.WaffoSandboxApiKey = "sandbox_api"
require.True(t, isWaffoWebhookEnabled())
}
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalMerchantID := setting.WaffoPancakeMerchantID
originalPrivateKey := setting.WaffoPancakePrivateKey
originalProductID := setting.WaffoPancakeProductID
t.Cleanup(func() {
setting.WaffoPancakeMerchantID = originalMerchantID
setting.WaffoPancakePrivateKey = originalPrivateKey
setting.WaffoPancakeProductID = originalProductID
})
// Presence of all three credentials enables the gateway. Webhook public
// keys are bundled in the SDK and there is no separate Enabled toggle —
// clear any of the three fields to disable.
setting.WaffoPancakeMerchantID = ""
setting.WaffoPancakePrivateKey = "private"
setting.WaffoPancakeProductID = "product"
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeMerchantID = "merchant"
require.True(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeProductID = ""
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeProductID = "product"
setting.WaffoPancakePrivateKey = ""
require.False(t, isWaffoPancakeWebhookEnabled())
}
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalPayAddress := operation_setting.PayAddress
originalEpayID := operation_setting.EpayId
originalEpayKey := operation_setting.EpayKey
originalPayMethods := operation_setting.PayMethods
t.Cleanup(func() {
operation_setting.PayAddress = originalPayAddress
operation_setting.EpayId = originalEpayID
operation_setting.EpayKey = originalEpayKey
operation_setting.PayMethods = originalPayMethods
})
operation_setting.PayAddress = "https://pay.example.com"
operation_setting.EpayId = "epay_id"
operation_setting.EpayKey = ""
operation_setting.PayMethods = []map[string]string{{"type": "alipay"}}
require.False(t, isEpayWebhookEnabled())
operation_setting.EpayKey = "epay_key"
require.True(t, isEpayWebhookEnabled())
operation_setting.PayMethods = nil
require.False(t, isEpayWebhookEnabled())
}
+82
View File
@@ -0,0 +1,82 @@
package controller
import (
"net/http"
"strconv"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func GetPerfMetricsSummary(c *gin.Context) {
hours := 24
if rawHours := c.Query("hours"); rawHours != "" {
if parsed, err := strconv.Atoi(rawHours); err == nil {
hours = parsed
}
}
activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto")
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
func GetPerfMetrics(c *gin.Context) {
modelName := c.Query("model")
if modelName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "model is required",
})
return
}
hours := 24
if rawHours := c.Query("hours"); rawHours != "" {
if parsed, err := strconv.Atoi(rawHours); err == nil {
hours = parsed
}
}
result, err := perfmetrics.Query(perfmetrics.QueryParams{
Model: modelName,
Group: c.Query("group"),
Hours: hours,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
})
return
}
result.Groups = filterActiveGroups(result.Groups)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupResult {
activeRatios := ratio_setting.GetGroupRatioCopy()
return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool {
_, ok := activeRatios[g.Group]
return ok || g.Group == "auto"
})
}
+385
View File
@@ -0,0 +1,385 @@
package controller
import (
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
)
// PerformanceStats 性能统计信息
type PerformanceStats struct {
// 缓存统计
CacheStats common.DiskCacheStats `json:"cache_stats"`
// 系统内存统计
MemoryStats MemoryStats `json:"memory_stats"`
// 磁盘缓存目录信息
DiskCacheInfo DiskCacheInfo `json:"disk_cache_info"`
// 磁盘空间信息
DiskSpaceInfo common.DiskSpaceInfo `json:"disk_space_info"`
// 配置信息
Config PerformanceConfig `json:"config"`
}
// MemoryStats 内存统计
type MemoryStats struct {
// 已分配内存(字节)
Alloc uint64 `json:"alloc"`
// 总分配内存(字节)
TotalAlloc uint64 `json:"total_alloc"`
// 系统内存(字节)
Sys uint64 `json:"sys"`
// GC 次数
NumGC uint32 `json:"num_gc"`
// Goroutine 数量
NumGoroutine int `json:"num_goroutine"`
}
// DiskCacheInfo 磁盘缓存目录信息
type DiskCacheInfo struct {
// 缓存目录路径
Path string `json:"path"`
// 目录是否存在
Exists bool `json:"exists"`
// 文件数量
FileCount int `json:"file_count"`
// 总大小(字节)
TotalSize int64 `json:"total_size"`
}
// PerformanceConfig 性能配置
type PerformanceConfig struct {
// 是否启用磁盘缓存
DiskCacheEnabled bool `json:"disk_cache_enabled"`
// 磁盘缓存阈值(MB)
DiskCacheThresholdMB int `json:"disk_cache_threshold_mb"`
// 磁盘缓存最大大小(MB)
DiskCacheMaxSizeMB int `json:"disk_cache_max_size_mb"`
// 磁盘缓存路径
DiskCachePath string `json:"disk_cache_path"`
// 是否在容器中运行
IsRunningInContainer bool `json:"is_running_in_container"`
// MonitorEnabled 是否启用性能监控
MonitorEnabled bool `json:"monitor_enabled"`
// MonitorCPUThreshold CPU 使用率阈值(%)
MonitorCPUThreshold int `json:"monitor_cpu_threshold"`
// MonitorMemoryThreshold 内存使用率阈值(%)
MonitorMemoryThreshold int `json:"monitor_memory_threshold"`
// MonitorDiskThreshold 磁盘使用率阈值(%)
MonitorDiskThreshold int `json:"monitor_disk_threshold"`
}
// GetPerformanceStats 获取性能统计信息
func GetPerformanceStats(c *gin.Context) {
// 不再每次获取统计都全量扫描磁盘,依赖原子计数器保证性能
// 仅在系统启动或显式清理时同步
cacheStats := common.GetDiskCacheStats()
// 获取内存统计
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
// 获取磁盘缓存目录信息
diskCacheInfo := getDiskCacheInfo()
// 获取配置信息
diskConfig := common.GetDiskCacheConfig()
monitorConfig := common.GetPerformanceMonitorConfig()
config := PerformanceConfig{
DiskCacheEnabled: diskConfig.Enabled,
DiskCacheThresholdMB: diskConfig.ThresholdMB,
DiskCacheMaxSizeMB: diskConfig.MaxSizeMB,
DiskCachePath: diskConfig.Path,
IsRunningInContainer: common.IsRunningInContainer(),
MonitorEnabled: monitorConfig.Enabled,
MonitorCPUThreshold: monitorConfig.CPUThreshold,
MonitorMemoryThreshold: monitorConfig.MemoryThreshold,
MonitorDiskThreshold: monitorConfig.DiskThreshold,
}
// 获取磁盘空间信息
// 使用缓存的系统状态,避免频繁调用系统 API
systemStatus := common.GetSystemStatus()
diskSpaceInfo := common.DiskSpaceInfo{
UsedPercent: systemStatus.DiskUsage,
}
// 如果需要详细信息,可以按需获取,或者扩展 SystemStatus
// 这里为了保持接口兼容性,我们仍然调用 GetDiskSpaceInfo,但注意这可能会有性能开销
// 考虑到 GetPerformanceStats 是管理接口,频率较低,直接调用是可以接受的
// 但为了一致性,我们也可以考虑从 SystemStatus 中获取部分信息
diskSpaceInfo = common.GetDiskSpaceInfo()
stats := PerformanceStats{
CacheStats: cacheStats,
MemoryStats: MemoryStats{
Alloc: memStats.Alloc,
TotalAlloc: memStats.TotalAlloc,
Sys: memStats.Sys,
NumGC: memStats.NumGC,
NumGoroutine: runtime.NumGoroutine(),
},
DiskCacheInfo: diskCacheInfo,
DiskSpaceInfo: diskSpaceInfo,
Config: config,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": stats,
})
}
// ClearDiskCache 清理不活跃的磁盘缓存
func ClearDiskCache(c *gin.Context) {
// 清理超过 10 分钟未使用的缓存文件
// 10 分钟是一个安全的阈值,确保正在进行的请求不会被误删
err := common.CleanupOldDiskCacheFiles(10 * time.Minute)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "不活跃的磁盘缓存已清理",
})
}
// ResetPerformanceStats 重置性能统计
func ResetPerformanceStats(c *gin.Context) {
common.ResetDiskCacheStats()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "统计信息已重置",
})
}
// ForceGC 强制执行 GC
func ForceGC(c *gin.Context) {
runtime.GC()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "GC 已执行",
})
}
// LogFileInfo 日志文件信息
type LogFileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
}
// LogFilesResponse 日志文件列表响应
type LogFilesResponse struct {
LogDir string `json:"log_dir"`
Enabled bool `json:"enabled"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
OldestTime *time.Time `json:"oldest_time,omitempty"`
NewestTime *time.Time `json:"newest_time,omitempty"`
Files []LogFileInfo `json:"files"`
}
// getLogFiles 读取日志目录中的日志文件列表
func getLogFiles() ([]LogFileInfo, error) {
if *common.LogDir == "" {
return nil, nil
}
entries, err := os.ReadDir(*common.LogDir)
if err != nil {
return nil, err
}
var files []LogFileInfo
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, "oneapi-") || !strings.HasSuffix(name, ".log") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, LogFileInfo{
Name: name,
Size: info.Size(),
ModTime: info.ModTime(),
})
}
// 按文件名降序排列(最新在前)
sort.Slice(files, func(i, j int) bool {
return files[i].Name > files[j].Name
})
return files, nil
}
// GetLogFiles 获取日志文件列表
func GetLogFiles(c *gin.Context) {
if *common.LogDir == "" {
common.ApiSuccess(c, LogFilesResponse{Enabled: false})
return
}
files, err := getLogFiles()
if err != nil {
common.ApiError(c, err)
return
}
var totalSize int64
var oldest, newest time.Time
for i, f := range files {
totalSize += f.Size
if i == 0 || f.ModTime.Before(oldest) {
oldest = f.ModTime
}
if i == 0 || f.ModTime.After(newest) {
newest = f.ModTime
}
}
resp := LogFilesResponse{
LogDir: *common.LogDir,
Enabled: true,
FileCount: len(files),
TotalSize: totalSize,
Files: files,
}
if len(files) > 0 {
resp.OldestTime = &oldest
resp.NewestTime = &newest
}
common.ApiSuccess(c, resp)
}
// CleanupLogFiles 清理过期日志文件
func CleanupLogFiles(c *gin.Context) {
mode := c.Query("mode")
valueStr := c.Query("value")
if mode != "by_count" && mode != "by_days" {
common.ApiErrorMsg(c, "invalid mode, must be by_count or by_days")
return
}
value, err := strconv.Atoi(valueStr)
if err != nil || value < 1 {
common.ApiErrorMsg(c, "invalid value, must be a positive integer")
return
}
if *common.LogDir == "" {
common.ApiErrorMsg(c, "log directory not configured")
return
}
files, err := getLogFiles()
if err != nil {
common.ApiError(c, err)
return
}
activeLogPath := logger.GetCurrentLogPath()
var toDelete []LogFileInfo
switch mode {
case "by_count":
// files 已按名称降序(最新在前),保留前 value 个
for i, f := range files {
if i < value {
continue
}
fullPath := filepath.Join(*common.LogDir, f.Name)
if fullPath == activeLogPath {
continue
}
toDelete = append(toDelete, f)
}
case "by_days":
cutoff := time.Now().AddDate(0, 0, -value)
for _, f := range files {
if f.ModTime.Before(cutoff) {
fullPath := filepath.Join(*common.LogDir, f.Name)
if fullPath == activeLogPath {
continue
}
toDelete = append(toDelete, f)
}
}
}
var deletedCount int
var freedBytes int64
var failedFiles []string
for _, f := range toDelete {
fullPath := filepath.Join(*common.LogDir, f.Name)
if err := os.Remove(fullPath); err != nil {
failedFiles = append(failedFiles, f.Name)
continue
}
deletedCount++
freedBytes += f.Size
}
result := gin.H{
"deleted_count": deletedCount,
"freed_bytes": freedBytes,
"failed_files": failedFiles,
}
if len(failedFiles) > 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("部分文件删除失败(%d/%d)", len(failedFiles), len(toDelete)),
"data": result,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": result,
})
}
// getDiskCacheInfo 获取磁盘缓存目录信息
func getDiskCacheInfo() DiskCacheInfo {
// 使用统一的缓存目录
dir := common.GetDiskCacheDir()
info := DiskCacheInfo{
Path: dir,
Exists: false,
}
entries, err := os.ReadDir(dir)
if err != nil {
return info
}
info.Exists = true
info.FileCount = 0
info.TotalSize = 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
info.FileCount++
if fileInfo, err := entry.Info(); err == nil {
info.TotalSize += fileInfo.Size()
}
}
return info
}
+56
View File
@@ -0,0 +1,56 @@
package controller
import (
"errors"
"fmt"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
func Playground(c *gin.Context) {
var newAPIError *types.NewAPIError
defer func() {
if newAPIError != nil {
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
})
}
}()
useAccessToken := c.GetBool("use_access_token")
if useAccessToken {
newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
return
}
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatOpenAI, nil, nil)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
return
}
userId := c.GetInt("id")
// Write user context to ensure acceptUnsetRatio is available
userCache, err := model.GetUserCache(userId)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
return
}
userCache.WriteContext(c)
tempToken := &model.Token{
UserId: userId,
Name: fmt.Sprintf("playground-%s", relayInfo.UsingGroup),
Group: relayInfo.UsingGroup,
}
_ = middleware.SetupContextForToken(c, tempToken)
Relay(c, types.RelayFormatOpenAI)
}
+47
View File
@@ -0,0 +1,47 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestRelayTaskPluginEndpointPreservesUnclaimedFallback(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
fallbackCalls := 0
RelayTaskPluginEndpoint(c, func(c *gin.Context) {
fallbackCalls++
c.Status(http.StatusNoContent)
c.Writer.WriteHeaderNow()
})
assert.Equal(t, 1, fallbackCalls)
assert.Equal(t, http.StatusNoContent, recorder.Code)
}
func TestRelayTaskPluginEndpointNeverEntersOrdinaryRelayWhenClaimed(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set(jsplugin.ContextKeyPinnedEndpoint, jsplugin.PinnedEndpoint{
Generation: &jsplugin.RoutingGeneration{},
Plugin: &jsplugin.LoadedPlugin{},
Protocol: "openai_responses",
Operation: jsplugin.HostProtocolOperation{Name: "create"},
})
fallbackCalls := 0
RelayTaskPluginEndpoint(c, func(c *gin.Context) {
fallbackCalls++
c.Status(http.StatusNoContent)
c.Writer.WriteHeaderNow()
})
assert.Zero(t, fallbackCalls)
assert.NotEqual(t, http.StatusNoContent, recorder.Code)
}
+244
View File
@@ -0,0 +1,244 @@
package controller
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type nativeRouteBilling struct {
events []string
preConsumed int
userID int
settled bool
}
func (b *nativeRouteBilling) Settle(int) error {
b.events = append(b.events, "settle")
b.settled = true
return nil
}
func (b *nativeRouteBilling) Refund(*gin.Context) {
b.events = append(b.events, "refund")
if !b.settled && b.preConsumed > 0 {
_ = model.IncreaseUserQuota(b.userID, b.preConsumed, true)
b.preConsumed = 0
}
}
func (b *nativeRouteBilling) NeedsRefund() bool {
return !b.settled && b.preConsumed > 0
}
func (b *nativeRouteBilling) GetPreConsumedQuota() int {
return b.preConsumed
}
func (b *nativeRouteBilling) Reserve(quota int) error {
b.events = append(b.events, "reserve")
if err := model.DecreaseUserQuota(b.userID, quota, true); err != nil {
return err
}
b.preConsumed = quota
return nil
}
func TestKlingNativeRouteSubmitPollSettleAndQuery(t *testing.T) {
gin.SetMode(gin.TestMode)
service.InitHttpClient()
previousDB := model.DB
previousLogDB := model.LOG_DB
previousMemoryCache := common.MemoryCacheEnabled
previousBatchUpdate := common.BatchUpdateEnabled
previousLogConsume := common.LogConsumeEnabled
previousRedisEnabled := common.RedisEnabled
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.User{}, &model.Channel{}, &model.Task{}, &model.Log{}))
model.DB = database
model.LOG_DB = database
common.MemoryCacheEnabled = false
common.BatchUpdateEnabled = false
common.LogConsumeEnabled = false
common.RedisEnabled = false
previousModelRatios := ratio_setting.ModelRatio2JSONString()
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"kling-v1":1}`))
t.Cleanup(func() {
model.DB = previousDB
model.LOG_DB = previousLogDB
common.MemoryCacheEnabled = previousMemoryCache
common.BatchUpdateEnabled = previousBatchUpdate
common.LogConsumeEnabled = previousLogConsume
common.RedisEnabled = previousRedisEnabled
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(previousModelRatios))
})
require.NoError(t, database.Create(&model.User{
Id: 7,
Username: "native-route-user",
Group: "default",
Quota: 1_000_000,
}).Error)
var submitCalls atomic.Int32
var queryCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/kling/v1/videos/text2video":
submitCalls.Add(1)
body, readErr := io.ReadAll(r.Body)
if !assert.NoError(t, readErr) {
http.Error(w, "read request", http.StatusInternalServerError)
return
}
assert.Contains(t, string(body), `"model_name":"kling-v1"`)
_, _ = io.WriteString(w, `{"code":0,"message":"","data":{"task_id":"kling-private-1","task_status":"submitted"}}`)
case r.Method == http.MethodGet && r.URL.Path == "/kling/v1/videos/text2video/kling-private-1":
queryCalls.Add(1)
_, _ = io.WriteString(w, `{"code":0,"message":"","data":{"task_id":"kling-private-1","task_status":"succeed","task_status_msg":"","task_result":{"videos":[{"id":"video-private","url":"https://cdn.example/video.mp4","duration":"5"}]},"final_unit_deduction":"1"}}`)
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
channel := model.Channel{
Type: constant.ChannelTypeKling,
Name: "kling-native-e2e",
Key: "sk-test",
BaseURL: &upstream.URL,
Status: common.ChannelStatusEnabled,
Models: "kling-v1",
Group: "default",
}
require.NoError(t, database.Create(&channel).Error)
generation := pluginruntime.DefaultRegistry.Generation()
require.NotNil(t, generation)
submitBinding, found := generation.LookupDeclaredRoute(http.MethodPost, "/kling/v1/videos/text2video")
require.True(t, found)
require.Equal(t, "kling", submitBinding.Plugin.Meta.Key)
submitRecorder := httptest.NewRecorder()
submitContext, _ := gin.CreateTestContext(submitRecorder)
submitContext.Request = httptest.NewRequest(
http.MethodPost,
"/kling/v1/videos/text2video",
bytes.NewBufferString(`{"model_name":"kling-v1","prompt":"a lighthouse"}`),
)
submitContext.Request.Header.Set("Content-Type", "application/json")
submitContext.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{
Generation: generation,
Plugin: submitBinding.Plugin,
Route: submitBinding.Route,
})
common.SetContextKey(submitContext, constant.ContextKeyUserId, 7)
common.SetContextKey(submitContext, constant.ContextKeyUserGroup, "default")
common.SetContextKey(submitContext, constant.ContextKeyUsingGroup, "default")
common.SetContextKey(submitContext, constant.ContextKeyTokenGroup, "default")
common.SetContextKey(submitContext, constant.ContextKeyUserQuota, 1_000_000)
middleware.PrepareTaskPluginRoute()(submitContext)
require.False(t, submitContext.IsAborted(), submitRecorder.Body.String())
require.Equal(t, "kling-v1", submitContext.GetString("resolved_task_model"))
require.Equal(t, "text_to_video", submitContext.GetString("task_action"))
require.Nil(t, middleware.SetupContextForSelectedChannel(submitContext, &channel, "kling-v1"))
billing := &nativeRouteBilling{userID: 7}
relayInfo := &relaycommon.RelayInfo{
UserId: 7,
UserGroup: "default",
UsingGroup: "default",
UserQuota: 1_000_000,
TokenGroup: "default",
OriginModelName: "kling-v1",
Billing: billing,
TaskRelayInfo: &relaycommon.TaskRelayInfo{
Action: submitContext.GetString("task_action"),
PublicTaskID: "task_kling_public",
LockedChannel: &channel,
},
}
outcome, taskErr := executeTaskSubmissionWith(submitContext, relayInfo, relay.RelayTaskSubmit)
require.Nil(t, taskErr)
require.NotNil(t, outcome)
require.Equal(t, []string{"reserve", "settle"}, billing.events)
require.False(t, submitContext.Writer.Written())
presentTaskSubmission(submitContext, outcome)
require.Equal(t, http.StatusOK, submitRecorder.Code)
assert.Contains(t, submitRecorder.Body.String(), `"task_id":"task_kling_public"`)
assert.NotContains(t, submitRecorder.Body.String(), "kling-private-1")
assert.Equal(t, int32(1), submitCalls.Load())
var persisted model.Task
require.NoError(t, database.Where("task_id = ?", "task_kling_public").First(&persisted).Error)
assert.Equal(t, constant.TaskPlatform("kling"), persisted.Platform)
assert.Equal(t, "kling-private-1", persisted.PrivateData.UpstreamTaskID)
assert.Equal(t, model.TaskStatus(model.TaskStatusNotStart), persisted.Status)
previousAdaptorFactory := service.GetTaskAdaptorFunc
service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor {
return relay.GetTaskAdaptor(platform)
}
t.Cleanup(func() { service.GetTaskAdaptorFunc = previousAdaptorFactory })
service.DispatchPlatformUpdate(
context.Background(),
persisted.Platform,
map[int][]string{channel.Id: {"kling-private-1"}},
map[string]*model.Task{"kling-private-1": &persisted},
)
require.NoError(t, database.Where("task_id = ?", "task_kling_public").First(&persisted).Error)
assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), persisted.Status)
assert.Equal(t, "100%", persisted.Progress)
assert.Equal(t, 1, persisted.Quota)
assert.Equal(t, int32(1), queryCalls.Load())
var settledUser model.User
require.NoError(t, database.First(&settledUser, 7).Error)
assert.Equal(t, 999_999, settledUser.Quota)
queryBinding, found := generation.LookupDeclaredRoute(http.MethodGet, "/kling/v1/videos/text2video/:task_id")
require.True(t, found)
queryRecorder := httptest.NewRecorder()
queryContext, _ := gin.CreateTestContext(queryRecorder)
queryContext.Request = httptest.NewRequest(http.MethodGet, "/kling/v1/videos/text2video/task_kling_public", nil)
queryContext.Params = gin.Params{{Key: "task_id", Value: "task_kling_public"}}
queryContext.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{
Generation: generation,
Plugin: queryBinding.Plugin,
Route: queryBinding.Route,
})
common.SetContextKey(queryContext, constant.ContextKeyUserId, 7)
middleware.PrepareTaskPluginRoute()(queryContext)
require.True(t, queryContext.IsAborted())
require.Equal(t, http.StatusOK, queryRecorder.Code)
assert.Contains(t, queryRecorder.Body.String(), `"task_id":"task_kling_public"`)
assert.Contains(t, queryRecorder.Body.String(), `"task_status":"succeed"`)
assert.NotContains(t, queryRecorder.Body.String(), "kling-private-1")
assert.NotContains(t, queryRecorder.Body.String(), upstream.URL)
}
File diff suppressed because it is too large. Load diff
+157
View File
@@ -0,0 +1,157 @@
package controller
import (
"errors"
"fmt"
"strings"
"sync"
)
var (
errPluginProtocolObservationLimitExceeded = errors.New("plugin protocol observation limit exceeded")
errInvalidPluginProtocolObservationIdentity = errors.New("invalid plugin protocol observation identity")
)
type pluginProtocolObservationLimits struct {
global int
perPlugin int
perUser int
perToken int
}
var defaultPluginProtocolObservationLimits = pluginProtocolObservationLimits{
global: 128,
perPlugin: 32,
perUser: 4,
perToken: 2,
}
var pluginProtocolObservationAdmissions = newPluginProtocolObservationLimiter(
defaultPluginProtocolObservationLimits,
)
type pluginProtocolObservationLimitError struct {
scope string
limit int
}
func (e *pluginProtocolObservationLimitError) Error() string {
return fmt.Sprintf("%s: %s capacity is %d", errPluginProtocolObservationLimitExceeded, e.scope, e.limit)
}
func (e *pluginProtocolObservationLimitError) Unwrap() error {
return errPluginProtocolObservationLimitExceeded
}
type pluginProtocolObservationLimiter struct {
mu sync.Mutex
limits pluginProtocolObservationLimits
global int
plugin map[string]int
user map[int]int
token map[int]int
}
func newPluginProtocolObservationLimiter(limits pluginProtocolObservationLimits) *pluginProtocolObservationLimiter {
return &pluginProtocolObservationLimiter{
limits: limits,
plugin: make(map[string]int),
user: make(map[int]int),
token: make(map[int]int),
}
}
func (l *pluginProtocolObservationLimiter) acquire(
pluginKey string,
userID int,
tokenID int,
) (func(), error) {
pluginKey = strings.TrimSpace(pluginKey)
switch {
case pluginKey == "":
return nil, fmt.Errorf("%w: plugin key is required", errInvalidPluginProtocolObservationIdentity)
case userID <= 0:
return nil, fmt.Errorf("%w: user id must be positive", errInvalidPluginProtocolObservationIdentity)
case tokenID <= 0:
return nil, fmt.Errorf("%w: token id must be positive", errInvalidPluginProtocolObservationIdentity)
}
l.mu.Lock()
if l.global >= l.limits.global {
l.mu.Unlock()
return nil, &pluginProtocolObservationLimitError{
scope: "global",
limit: l.limits.global,
}
}
l.global++
if l.plugin[pluginKey] >= l.limits.perPlugin {
l.global--
l.mu.Unlock()
return nil, &pluginProtocolObservationLimitError{
scope: "plugin",
limit: l.limits.perPlugin,
}
}
l.plugin[pluginKey]++
if l.user[userID] >= l.limits.perUser {
l.global--
l.plugin[pluginKey]--
if l.plugin[pluginKey] == 0 {
delete(l.plugin, pluginKey)
}
l.mu.Unlock()
return nil, &pluginProtocolObservationLimitError{
scope: "user",
limit: l.limits.perUser,
}
}
l.user[userID]++
if l.token[tokenID] >= l.limits.perToken {
l.global--
l.plugin[pluginKey]--
if l.plugin[pluginKey] == 0 {
delete(l.plugin, pluginKey)
}
l.user[userID]--
if l.user[userID] == 0 {
delete(l.user, userID)
}
l.mu.Unlock()
return nil, &pluginProtocolObservationLimitError{
scope: "token",
limit: l.limits.perToken,
}
}
l.token[tokenID]++
l.mu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
l.mu.Lock()
defer l.mu.Unlock()
l.global--
l.plugin[pluginKey]--
if l.plugin[pluginKey] == 0 {
delete(l.plugin, pluginKey)
}
l.user[userID]--
if l.user[userID] == 0 {
delete(l.user, userID)
}
l.token[tokenID]--
if l.token[tokenID] == 0 {
delete(l.token, tokenID)
}
})
}, nil
}
+248
View File
@@ -0,0 +1,248 @@
package controller
import (
"errors"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPluginProtocolObservationLimiterCaps(t *testing.T) {
t.Run("global", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 2,
perPlugin: 2,
perUser: 2,
perToken: 2,
})
releaseFirst, err := limiter.acquire("first", 1, 1)
require.NoError(t, err)
defer releaseFirst()
releaseSecond, err := limiter.acquire("second", 2, 2)
require.NoError(t, err)
defer releaseSecond()
release, err := limiter.acquire("third", 3, 3)
assert.Nil(t, release)
assertLimitError(t, err, "global", 2)
})
t.Run("plugin", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 3,
perPlugin: 1,
perUser: 3,
perToken: 3,
})
releaseFirst, err := limiter.acquire("shared", 1, 1)
require.NoError(t, err)
defer releaseFirst()
release, err := limiter.acquire("shared", 2, 2)
assert.Nil(t, release)
assertLimitError(t, err, "plugin", 1)
releaseOther, err := limiter.acquire("other", 2, 2)
require.NoError(t, err)
defer releaseOther()
})
t.Run("user", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 3,
perPlugin: 3,
perUser: 1,
perToken: 3,
})
releaseFirst, err := limiter.acquire("first", 1, 1)
require.NoError(t, err)
defer releaseFirst()
release, err := limiter.acquire("second", 1, 2)
assert.Nil(t, release)
assertLimitError(t, err, "user", 1)
releaseOther, err := limiter.acquire("second", 2, 2)
require.NoError(t, err)
defer releaseOther()
})
t.Run("token", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 3,
perPlugin: 3,
perUser: 3,
perToken: 1,
})
releaseFirst, err := limiter.acquire("first", 1, 1)
require.NoError(t, err)
defer releaseFirst()
release, err := limiter.acquire("second", 2, 1)
assert.Nil(t, release)
assertLimitError(t, err, "token", 1)
releaseOther, err := limiter.acquire("second", 2, 2)
require.NoError(t, err)
defer releaseOther()
})
}
func TestPluginProtocolObservationLimiterRollsBackFailedAdmission(t *testing.T) {
t.Run("user failure", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 2,
perPlugin: 2,
perUser: 1,
perToken: 2,
})
releaseHeld, err := limiter.acquire("first", 1, 1)
require.NoError(t, err)
defer releaseHeld()
release, err := limiter.acquire("second", 1, 2)
assert.Nil(t, release)
assertLimitError(t, err, "user", 1)
releaseReplacement, err := limiter.acquire("second", 2, 2)
require.NoError(t, err)
defer releaseReplacement()
})
t.Run("token failure", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 2,
perPlugin: 2,
perUser: 2,
perToken: 1,
})
releaseHeld, err := limiter.acquire("first", 1, 1)
require.NoError(t, err)
defer releaseHeld()
release, err := limiter.acquire("second", 2, 1)
assert.Nil(t, release)
assertLimitError(t, err, "token", 1)
releaseReplacement, err := limiter.acquire("second", 2, 2)
require.NoError(t, err)
defer releaseReplacement()
})
}
func TestPluginProtocolObservationLimiterReleaseIsIdempotent(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 1,
perPlugin: 1,
perUser: 1,
perToken: 1,
})
release, err := limiter.acquire("plugin", 1, 1)
require.NoError(t, err)
release()
release()
releaseAgain, err := limiter.acquire("plugin", 1, 1)
require.NoError(t, err)
releaseAgain()
}
func TestPluginProtocolObservationLimiterRejectsMissingIdentityWithoutConsumingCapacity(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 1,
perPlugin: 1,
perUser: 1,
perToken: 1,
})
for _, testCase := range []struct {
name string
pluginKey string
userID int
tokenID int
}{
{name: "empty plugin", userID: 1, tokenID: 1},
{name: "blank plugin", pluginKey: " \t", userID: 1, tokenID: 1},
{name: "zero user", pluginKey: "plugin", tokenID: 1},
{name: "negative user", pluginKey: "plugin", userID: -1, tokenID: 1},
{name: "zero token", pluginKey: "plugin", userID: 1},
{name: "negative token", pluginKey: "plugin", userID: 1, tokenID: -1},
} {
t.Run(testCase.name, func(t *testing.T) {
release, err := limiter.acquire(testCase.pluginKey, testCase.userID, testCase.tokenID)
assert.Nil(t, release)
assert.ErrorIs(t, err, errInvalidPluginProtocolObservationIdentity)
})
}
release, err := limiter.acquire("plugin", 1, 1)
require.NoError(t, err)
release()
}
func TestPluginProtocolObservationLimiterConcurrentAdmissionsRespectCap(t *testing.T) {
const (
workerCount = 8
globalLimit = 3
)
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: globalLimit,
perPlugin: workerCount,
perUser: workerCount,
perToken: workerCount,
})
start := make(chan struct{})
releases := make(chan func(), workerCount)
errorsFound := make(chan error, workerCount)
var workers sync.WaitGroup
workers.Add(workerCount)
for worker := 1; worker <= workerCount; worker++ {
go func(id int) {
defer workers.Done()
<-start
release, err := limiter.acquire("plugin", id, id)
if err != nil {
errorsFound <- err
return
}
releases <- release
}(worker)
}
close(start)
workers.Wait()
close(releases)
close(errorsFound)
assert.Len(t, releases, globalLimit)
assert.Len(t, errorsFound, workerCount-globalLimit)
for err := range errorsFound {
assert.ErrorIs(t, err, errPluginProtocolObservationLimitExceeded)
}
for release := range releases {
release()
}
release, err := limiter.acquire("plugin", 1, 1)
require.NoError(t, err)
release()
}
func assertLimitError(
t *testing.T,
err error,
expectedScope string,
expectedLimit int,
) {
t.Helper()
require.Error(t, err)
assert.ErrorIs(t, err, errPluginProtocolObservationLimitExceeded)
var limitError *pluginProtocolObservationLimitError
require.True(t, errors.As(err, &limitError))
assert.Equal(t, expectedScope, limitError.scope)
assert.Equal(t, expectedLimit, limitError.limit)
}
+305
View File
@@ -0,0 +1,305 @@
package controller
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
builtinplugins "github.com/QuantumNous/new-api/plugins"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/openai/openai-go/responses"
"github.com/openai/openai-go/shared"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskPluginResponsesNonStreamDecodesWithOfficialGoSDK(t *testing.T) {
pinned := compilePluginProtocolTestEndpoint(t, "official-sdk-non-stream", `
export const protocols = {openai_responses: {
renderEvents: function() { throw new Error("non-stream called renderEvents"); },
renderFinal: function() {
return {
output: [{
type: "message",
status: "completed",
role: "assistant",
content: [{
type: "output_text",
text: "official-sdk-final",
annotations: [],
logprobs: []
}]
}]
};
}
}};
`)
deps := pluginProtocolTestDeps()
deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_sdk_final", nil), nil
}
deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
return &model.Task{
TaskID: "task_sdk_final",
UserId: 71,
Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
Status: model.TaskStatusSuccess,
}, true, nil
}
server := newPluginProtocolSDKTestServer(t, pinned, deps)
defer server.Close()
client := openai.NewClient(
option.WithAPIKey("test-key"),
option.WithBaseURL(server.URL+"/v1/"),
option.WithMaxRetries(0),
)
requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
response, err := client.Responses.New(requestContext, responses.ResponseNewParams{
Model: shared.ResponsesModel("video-model"),
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("create a video"),
},
})
require.NoError(t, err)
assert.Equal(t, "resp_sdk_final", response.ID)
assert.Equal(t, responses.ResponseStatusCompleted, response.Status)
assert.Equal(t, "video-model", response.Model)
assert.Equal(t, "official-sdk-final", response.OutputText())
assert.Equal(t, "task_sdk_final", response.Metadata["task_id"])
}
func TestTaskPluginResponsesStreamDecodesWithOfficialGoSDK(t *testing.T) {
pinned := compilePluginProtocolTestEndpoint(t, "official-sdk-stream", `
export const protocols = {openai_responses: {
renderEvents: function() {
return {events: [{type: "output", data: "official-sdk-stream"}], done: true};
},
renderFinal: function() { throw new Error("stream called renderFinal"); }
}};
`)
deps := pluginProtocolTestDeps()
deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
return pluginProtocolTestOutcome(info, pinned.Plugin.Meta.Key, "task_sdk_stream", nil), nil
}
deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
return &model.Task{
TaskID: "task_sdk_stream",
UserId: 71,
Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
Status: model.TaskStatusSuccess,
}, true, nil
}
server := newPluginProtocolSDKTestServer(t, pinned, deps)
defer server.Close()
client := openai.NewClient(
option.WithAPIKey("test-key"),
option.WithBaseURL(server.URL+"/v1/"),
option.WithMaxRetries(0),
)
requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stream := client.Responses.NewStreaming(requestContext, responses.ResponseNewParams{
Model: shared.ResponsesModel("video-model"),
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("create a video"),
},
})
defer stream.Close()
eventTypes := make([]string, 0, 8)
sequenceNumbers := make([]int64, 0, 8)
var completedText string
for stream.Next() {
event := stream.Current()
eventTypes = append(eventTypes, event.Type)
sequenceNumbers = append(sequenceNumbers, event.SequenceNumber)
if event.Type == "response.completed" {
completedText = event.Response.OutputText()
}
}
require.NoError(t, stream.Err())
assert.Equal(t, []string{
"response.created",
"response.output_item.added",
"response.content_part.added",
"response.output_text.delta",
"response.output_text.done",
"response.content_part.done",
"response.output_item.done",
"response.completed",
}, eventTypes)
assert.Equal(t, []int64{0, 1, 2, 3, 4, 5, 6, 7}, sequenceNumbers)
assert.Equal(t, "official-sdk-stream", completedText)
}
func TestBuiltInKlingResponsesNonStreamDecodesWithOfficialGoSDK(t *testing.T) {
pinned, deps := builtInKlingProtocolSDKFixture(t, "task_kling_sdk_final")
server := newPluginProtocolSDKTestServer(t, pinned, deps)
defer server.Close()
client := openai.NewClient(
option.WithAPIKey("test-key"),
option.WithBaseURL(server.URL+"/v1/"),
option.WithMaxRetries(0),
)
requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
response, err := client.Responses.New(requestContext, responses.ResponseNewParams{
Model: shared.ResponsesModel("kling-v2-master"),
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("camera orbit"),
},
})
require.NoError(t, err)
assert.Equal(t, responses.ResponseStatusCompleted, response.Status)
assert.Equal(t, "kling-v2-master", response.Model)
assert.Contains(t, response.OutputText(), "https://gateway.example/v1/tasks/task_kling_sdk_final/artifacts/video/content")
assert.NotContains(t, response.OutputText(), "upstream.example")
assert.Equal(t, "kling", response.Metadata["vendor"])
assert.Equal(t, "task_kling_sdk_final", response.Metadata["task_id"])
}
func TestBuiltInKlingResponsesStreamDecodesWithOfficialGoSDK(t *testing.T) {
pinned, deps := builtInKlingProtocolSDKFixture(t, "task_kling_sdk_stream")
server := newPluginProtocolSDKTestServer(t, pinned, deps)
defer server.Close()
client := openai.NewClient(
option.WithAPIKey("test-key"),
option.WithBaseURL(server.URL+"/v1/"),
option.WithMaxRetries(0),
)
requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stream := client.Responses.NewStreaming(requestContext, responses.ResponseNewParams{
Model: shared.ResponsesModel("kling-v2-master"),
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("camera orbit"),
},
})
defer stream.Close()
eventTypes := make([]string, 0, 8)
sequenceNumbers := make([]int64, 0, 8)
var completedText string
for stream.Next() {
event := stream.Current()
eventTypes = append(eventTypes, event.Type)
sequenceNumbers = append(sequenceNumbers, event.SequenceNumber)
if event.Type == "response.completed" {
completedText = event.Response.OutputText()
}
}
require.NoError(t, stream.Err())
assert.Equal(t, []string{
"response.created",
"response.output_item.added",
"response.content_part.added",
"response.output_text.delta",
"response.output_text.done",
"response.content_part.done",
"response.output_item.done",
"response.completed",
}, eventTypes)
assert.Equal(t, []int64{0, 1, 2, 3, 4, 5, 6, 7}, sequenceNumbers)
assert.Contains(t, completedText, "https://gateway.example/v1/tasks/task_kling_sdk_stream/artifacts/video/content")
assert.NotContains(t, completedText, "upstream.example")
}
func builtInKlingProtocolSDKFixture(t *testing.T, taskID string) (pluginruntime.PinnedEndpoint, pluginProtocolBridgeDeps) {
t.Helper()
source, err := builtinplugins.Source("kling")
require.NoError(t, err)
registry := pluginruntime.NewRegistry()
plugin, err := registry.RegisterFactory(source, pluginruntime.Options{Key: "kling"})
require.NoError(t, err)
binding, found := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/responses", "kling-v2-master")
require.True(t, found)
pinned := pluginruntime.PinnedEndpoint{
Generation: registry.Generation(),
Plugin: plugin,
Protocol: binding.Protocol,
Operation: binding.Operation,
Model: binding.Model,
Candidates: []pluginruntime.ProtocolBinding{binding},
}
deps := pluginProtocolTestDeps()
deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
return pluginProtocolTestOutcome(info, plugin.Meta.Key, taskID, nil), nil
}
deps.loadTask = func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error) {
task := &model.Task{
TaskID: taskID, Platform: constant.TaskPlatform(plugin.Meta.Key), UserId: 71,
Status: model.TaskStatusSuccess, Progress: "100%", CreatedAt: 1_710_000_000,
}
task.SetData(map[string]any{
"code": 0,
"data": map[string]any{
"task_id": "upstream-private",
"task_status": "succeed",
"task_result": map[string]any{
"videos": []any{map[string]any{"url": "https://upstream.example/private-video.mp4"}},
},
},
})
return task, true, nil
}
deps.artifactContentURL = func(publicTaskID, artifactKey string) (string, error) {
return "https://gateway.example/v1/tasks/" + publicTaskID + "/artifacts/" + artifactKey + "/content", nil
}
return pinned, deps
}
func newPluginProtocolSDKTestServer(
t *testing.T,
pinned pluginruntime.PinnedEndpoint,
deps pluginProtocolBridgeDeps,
) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/v1/responses" {
http.NotFound(writer, request)
return
}
var requestBody map[string]any
if err := common.DecodeJson(request.Body, &requestBody); err != nil {
http.Error(writer, "invalid request", http.StatusBadRequest)
return
}
modelName, _ := requestBody["model"].(string)
stream, _ := requestBody["stream"].(bool)
c, _ := gin.CreateTestContext(writer)
c.Request = request
common.SetContextKey(c, constant.ContextKeyUserId, 71)
common.SetContextKey(c, constant.ContextKeyTokenId, 81)
common.SetContextKey(c, constant.ContextKeyUsingGroup, "default")
c.Set("resolved_task_model", modelName)
c.Set(pluginruntime.ContextKeyProtocolRequest, pluginruntime.ProtocolRequestContext{
RouteRequestContext: pluginruntime.RouteRequestContext{
Path: request.URL.Path,
Method: request.Method,
Params: map[string]string{},
Query: request.URL.Query(),
RequestBody: requestBody,
},
Protocol: pinned.Protocol,
Stream: stream,
})
serveTaskPluginProtocol(c, pinned, deps)
}))
}
File diff suppressed because it is too large. Load diff
+90
View File
@@ -0,0 +1,90 @@
package controller
import (
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤
func GetPrefillGroups(c *gin.Context) {
groupType := c.Query("type")
groups, err := model.GetAllPrefillGroups(groupType)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, groups)
}
// CreatePrefillGroup 创建新的预填组
func CreatePrefillGroup(c *gin.Context) {
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Name == "" || g.Type == "" {
common.ApiErrorMsg(c, "组名称和类型不能为空")
return
}
// 创建前检查名称
if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
if err := g.Insert(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
}
// UpdatePrefillGroup 更新预填组
func UpdatePrefillGroup(c *gin.Context) {
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Id == 0 {
common.ApiErrorMsg(c, "缺少组 ID")
return
}
// 名称冲突检查
if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
if err := g.Update(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
}
// DeletePrefillGroup 删除预填组
func DeletePrefillGroup(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DeletePrefillGroupByID(id); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
+134
View File
@@ -0,0 +1,134 @@
package controller
import (
"maps"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
if len(pricing) == 0 {
return pricing
}
if len(usableGroup) == 0 {
return []model.Pricing{}
}
filtered := make([]model.Pricing, 0, len(pricing))
for _, item := range pricing {
if common.StringsContains(item.EnableGroup, "all") {
filtered = append(filtered, item)
continue
}
for _, group := range item.EnableGroup {
if _, ok := usableGroup[group]; ok {
filtered = append(filtered, item)
break
}
}
}
return filtered
}
func GetPricing(c *gin.Context) {
pricing := model.GetPricing()
enabledGroups, err := model.GetEnabledGroupSet()
if err != nil {
common.ApiError(c, err)
return
}
userId, exists := c.Get("id")
usableGroup := map[string]string{}
groupRatio := map[string]float64{}
maps.Copy(groupRatio, ratio_setting.GetGroupRatioCopy())
var group string
if exists {
user, err := model.GetUserCache(userId.(int))
if err == nil {
group = user.Group
for g := range groupRatio {
ratio, ok := ratio_setting.GetGroupGroupRatio(group, g)
if ok {
groupRatio[g] = ratio
}
}
}
}
usableGroup = service.GetUserUsableGroups(group)
for group := range usableGroup {
if group == "auto" {
continue
}
if _, ok := enabledGroups[group]; !ok {
delete(usableGroup, group)
}
}
if len(enabledGroups) == 0 {
delete(usableGroup, "auto")
}
pricing = filterPricingByUsableGroups(pricing, usableGroup)
pricing = append([]model.Pricing(nil), pricing...)
for i := range pricing {
for _, group := range pricing[i].EnableGroup {
if _, visible := usableGroup[group]; !visible {
continue
}
if expr, ok := billing_setting.GetGroupBillingExpr(group, pricing[i].ModelName); ok {
if pricing[i].GroupBillingExpr == nil {
pricing[i].GroupBillingExpr = make(map[string]string)
}
pricing[i].GroupBillingExpr[group] = expr
}
}
}
// check groupRatio contains usableGroup
for group := range ratio_setting.GetGroupRatioCopy() {
_, usable := usableGroup[group]
_, enabled := enabledGroups[group]
if !usable || !enabled {
delete(groupRatio, group)
}
}
c.JSON(200, gin.H{
"success": true,
"data": pricing,
"vendors": model.GetVendors(),
"group_ratio": groupRatio,
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": service.GetUserAutoGroup(group),
"pricing_version": "a42d372ccf0b5dd13ecf71203521f9d2",
})
}
func ResetModelRatio(c *gin.Context) {
defaultStr := ratio_setting.DefaultModelRatio2JSONString()
err := model.UpdateOption("ModelRatio", defaultStr)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": err.Error(),
})
return
}
err = ratio_setting.UpdateModelRatioByJSONString(defaultStr)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "重置模型倍率成功",
})
}
+24
View File
@@ -0,0 +1,24 @@
package controller
import (
"net/http"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func GetRankings(c *gin.Context) {
result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
+26
View File
@@ -0,0 +1,26 @@
package controller
import (
"net/http"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
func GetRatioConfig(c *gin.Context) {
if !ratio_setting.IsExposeRatioEnabled() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "倍率配置接口未启用",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": billing_setting.GetPricingSyncData(map[string]any(ratio_setting.GetExposedData())),
})
}
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
package controller
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
)
func TestPricingSyncExpressionPriority(t *testing.T) {
expression := `tier("base", p * 2 + c * 8 + cr * 0)`
cases := []struct {
name string
local map[string]any
source map[string]any
wantFields []string
}{
{"equal expressions suppress stale ratios", map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}}, map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}, "model_ratio": map[string]float64{"m": 3}, "model_price": map[string]float64{"m": 2}}, nil},
{"local expression excludes legacy-only source", map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}}, map[string]any{"model_ratio": map[string]float64{"m": 3}, "completion_ratio": map[string]float64{"m": 2}}, nil},
{"expression imports without legacy conflicts", map[string]any{"model_ratio": map[string]float64{"m": 1}}, map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}, "model_ratio": map[string]float64{"m": 3}, "model_price": map[string]float64{"m": 2}}, []string{"billing_mode", "billing_expr"}},
{"inactive expression follows explicit ratio mode", map[string]any{"model_ratio": map[string]float64{"m": 1}}, map[string]any{"billing_mode": map[string]string{"m": "ratio"}, "billing_expr": map[string]string{"m": expression}, "model_ratio": map[string]float64{"m": 3}}, []string{"model_ratio"}},
{"empty active expression never imports a false free price", map[string]any{}, map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": " "}, "model_ratio": map[string]float64{"m": 0}}, nil},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
diff := buildDifferences(tt.local, []struct {
name string
data map[string]any
}{{"source", tt.source}})
fields := make([]string, 0, len(diff["m"]))
for field := range diff["m"] {
fields = append(fields, field)
}
assert.ElementsMatch(t, tt.wantFields, fields)
})
}
}
func TestRatioConfigExportsEffectiveExpressions(t *testing.T) {
before := config.GlobalConfig.ExportAllConfigs()
expose := ratio_setting.IsExposeRatioEnabled()
t.Cleanup(func() {
config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": before["billing_setting.billing_mode"], "billing_expr": before["billing_setting.billing_expr"]})
ratio_setting.SetExposeRatioEnabled(expose)
})
config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": `{"sync-export":"tiered_expr"}`, "billing_expr": `{"sync-export":"tier(\"base\", p * 2)"}`})
ratio_setting.SetExposeRatioEnabled(true)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/ratio_config", nil)
GetRatioConfig(c)
var response struct {
Success bool
Data map[string]any
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, billing_setting.BillingModeTieredExpr, valueMap(response.Data["billing_mode"])["sync-export"])
assert.Equal(t, `tier("base", p * 2)`, valueMap(response.Data["billing_expr"])["sync-export"])
}
func TestPricingSyncCompleteSourcesAndArrayFormats(t *testing.T) {
before := config.GlobalConfig.ExportAllConfigs()
oldRatios, oldCompletion := ratio_setting.ModelRatio2JSONString(), ratio_setting.CompletionRatio2JSONString()
t.Cleanup(func() {
config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": before["billing_setting.billing_mode"], "billing_expr": before["billing_setting.billing_expr"]})
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(oldRatios))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(oldCompletion))
})
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"sync-token":1}`))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"sync-token":2}`))
expression := `len <= 200000 ? tier("short", p * 2 + c * 8 + cr * 0) : tier("long", p * 4 + c * 12)`
expressions, err := common.Marshal(map[string]string{"sync-already": expression})
require.NoError(t, err)
config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": `{"sync-already":"tiered_expr"}`, "billing_expr": string(expressions)})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data any
if r.URL.Path == "/ratio_config" {
data = map[string]any{
"billing_mode": map[string]string{"sync-already": "tiered_expr", "sync-expression": "tiered_expr"},
"billing_expr": map[string]string{"sync-already": expression, "sync-expression": expression},
"model_ratio": map[string]float64{"sync-already": 9, "sync-expression": 9, "sync-token": 1},
"model_price": map[string]float64{"sync-expression": 4},
"completion_ratio": map[string]float64{"sync-token": 4},
"cache_ratio": map[string]float64{"sync-token": 0},
}
} else {
data = []map[string]any{
{"model_name": "sync-already", "model_ratio": 5, "completion_ratio": 3},
{"model_name": "sync-expression", "model_ratio": 2, "model_price": 1},
{"model_name": "sync-array-expression", "billing_mode": "tiered_expr", "billing_expr": expression, "quota_type": 1, "model_price": 0},
{"model_name": "sync-unpriced"},
{"model_name": "sync-invalid-expression", "billing_mode": "tiered_expr", "billing_expr": "", "model_ratio": 0},
{"model_name": "sync-free", "model_ratio": 0, "completion_ratio": 0},
}
}
encoded, err := common.Marshal(map[string]any{"success": true, "data": data})
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(encoded)
}))
defer server.Close()
var response struct {
Success bool
Data struct {
Differences map[string]map[string]dto.DifferenceItem
Prices map[string]struct {
Current map[string]any
Upstreams map[string]map[string]any
}
TestResults []dto.TestResult `json:"test_results"`
}
}
body := map[string]any{"upstreams": []map[string]any{
{"id": 1, "name": "Expressions", "base_url": server.URL, "endpoint": "/ratio_config"},
{"id": 2, "name": "Legacy", "base_url": server.URL, "endpoint": "/pricing"},
}}
recorder := modelManagementRequest(t, FetchUpstreamRatios, http.MethodPost, "/api/channel/fetch_upstream_ratios", body, &response)
require.True(t, response.Success, recorder.Body.String())
require.Len(t, response.Data.TestResults, 2)
for _, result := range response.Data.TestResults {
require.Equal(t, "success", result.Status, result.Error)
}
assert.NotContains(t, response.Data.Differences, "sync-already")
assert.NotContains(t, response.Data.Differences, "sync-unpriced")
assert.NotContains(t, response.Data.Differences, "sync-invalid-expression")
assert.Equal(t, map[string]any{"billing_mode": "tiered_expr", "billing_expr": expression}, response.Data.Prices["sync-expression"].Upstreams["Expressions(1)"])
assert.NotContains(t, response.Data.Prices["sync-expression"].Upstreams, "Legacy(2)")
assert.Equal(t, map[string]any{"billing_mode": "tiered_expr", "billing_expr": expression}, response.Data.Prices["sync-array-expression"].Upstreams["Legacy(2)"])
assert.Equal(t, float64(1), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["model_ratio"], "unchanged base prices are included for a complete price preview")
assert.Equal(t, float64(4), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["completion_ratio"])
assert.Equal(t, float64(0), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["cache_ratio"])
assert.Equal(t, float64(0), response.Data.Prices["sync-free"].Upstreams["Legacy(2)"]["model_ratio"])
}
+238
View File
@@ -0,0 +1,238 @@
package controller
import (
"errors"
"net/http"
"strconv"
"unicode/utf8"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
func GetAllRedemptions(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
redemptions, total, err := model.GetAllRedemptions(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(redemptions)
common.ApiSuccess(c, pageInfo)
return
}
func SearchRedemptions(c *gin.Context) {
keyword := c.Query("keyword")
status := c.Query("status")
pageInfo := common.GetPageQuery(c)
redemptions, total, err := model.SearchRedemptions(keyword, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(redemptions)
common.ApiSuccess(c, pageInfo)
return
}
func GetRedemption(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
redemption, err := model.GetRedemptionById(id)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": redemption,
})
return
}
func AddRedemption(c *gin.Context) {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
redemption := model.Redemption{}
err := c.ShouldBindJSON(&redemption)
if err != nil {
common.ApiError(c, err)
return
}
if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
return
}
if redemption.Count <= 0 {
common.ApiErrorI18n(c, i18n.MsgRedemptionCountPositive)
return
}
if redemption.Count > 100 {
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
return
}
if redemption.Quota <= 0 {
common.ApiError(c, errors.New("redemption quota must be positive"))
return
}
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
common.ApiError(c, err)
return
}
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return
}
var keys []string
for i := 0; i < redemption.Count; i++ {
key := common.GetUUID()
cleanRedemption := model.Redemption{
UserId: c.GetInt("id"),
Name: redemption.Name,
Key: key,
CreatedTime: common.GetTimestamp(),
Quota: redemption.Quota,
ExpiredTime: redemption.ExpiredTime,
}
err = cleanRedemption.Insert()
if err != nil {
common.SysError("failed to insert redemption: " + err.Error())
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgRedemptionCreateFailed),
"data": keys,
})
return
}
keys = append(keys, key)
}
recordManageAudit(c, "redemption.create", map[string]any{
"name": redemption.Name,
"count": redemption.Count,
"quota": logger.LogQuota(redemption.Quota),
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": keys,
})
return
}
func DeleteRedemption(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
err := model.DeleteRedemptionById(id)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func UpdateRedemption(c *gin.Context) {
statusOnly := c.Query("status_only")
redemption := model.Redemption{}
err := c.ShouldBindJSON(&redemption)
if err != nil {
common.ApiError(c, err)
return
}
cleanRedemption, err := model.GetRedemptionById(redemption.Id)
if err != nil {
common.ApiError(c, err)
return
}
if statusOnly == "" {
if redemption.Quota <= 0 {
common.ApiError(c, errors.New("redemption quota must be positive"))
return
}
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
common.ApiError(c, err)
return
}
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return
}
// If you add more fields, please also update redemption.Update()
cleanRedemption.Name = redemption.Name
cleanRedemption.Quota = redemption.Quota
cleanRedemption.ExpiredTime = redemption.ExpiredTime
}
if statusOnly != "" {
cleanRedemption.Status = redemption.Status
}
err = cleanRedemption.Update()
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": cleanRedemption,
})
return
}
func DeleteInvalidRedemption(c *gin.Context) {
rows, err := model.DeleteInvalidRedemptions()
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": rows,
})
return
}
func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
if expired != 0 && expired < common.GetTimestamp() {
return false, i18n.T(c, i18n.MsgRedemptionExpireTimeInvalid)
}
return true, ""
}
func DeleteRedemptionBatch(c *gin.Context) {
var request struct {
Ids []int `json:"ids" binding:"required,min=1,max=1000,dive,gt=0"`
}
if err := c.ShouldBindJSON(&request); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
count, err := model.BatchDeleteRedemptions(request.Ids)
if err != nil {
common.ApiError(c, err)
return
}
recordManageAudit(c, "redemption.delete_batch", map[string]any{
"count": count,
"total": len(request.Ids),
"requested_redemption_ids": request.Ids,
})
common.ApiSuccess(c, count)
}
+201
View File
@@ -0,0 +1,201 @@
package controller
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"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"
)
func TestDeleteRedemptionBatch(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
var driver, logDriver gorm.Dialector
dbType := common.DatabaseTypeSQLite
switch dialect {
case "sqlite":
driver = sqlite.Open(":memory:")
logDriver = 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)
logDSN := os.Getenv("TEST_MYSQL_LOG_DSN")
if logDSN == "" {
logDSN = dsn
}
logDriver = mysql.Open(logDSN)
dbType = common.DatabaseTypeMySQL
case "postgres":
dsn := os.Getenv("TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("TEST_POSTGRES_DSN is not configured")
}
driver = postgres.Open(dsn)
logDSN := os.Getenv("TEST_POSTGRES_LOG_DSN")
if logDSN == "" {
logDSN = dsn
}
logDriver = postgres.Open(logDSN)
dbType = common.DatabaseTypePostgreSQL
}
db, err := gorm.Open(driver, &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
var version string
query := "SELECT version()"
if dialect == "sqlite" {
query = "SELECT sqlite_version()"
}
require.NoError(t, db.Raw(query).Scan(&version).Error)
t.Logf("database version: %s", version)
logDB, err := gorm.Open(logDriver, &gorm.Config{})
require.NoError(t, err)
logSQL, err := logDB.DB()
require.NoError(t, err)
logSQL.SetMaxOpenConns(1)
t.Cleanup(func() { require.NoError(t, logSQL.Close()) })
previousDB, previousLogDB := model.DB, model.LOG_DB
previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
previousRedis := common.RedisEnabled
model.DB, model.LOG_DB = db, logDB
common.SetDatabaseTypes(dbType, dbType)
common.RedisEnabled = false
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.SetDatabaseTypes(previousMain, previousLog)
common.RedisEnabled = previousRedis
})
for _, table := range []any{&model.User{}, &model.Redemption{}} {
require.False(t, db.Migrator().HasTable(table), "use an empty test database")
require.NoError(t, db.AutoMigrate(table))
t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(table)) })
}
require.False(t, logDB.Migrator().HasTable(&model.AuditLog{}), "use an empty test log database")
require.NoError(t, logDB.AutoMigrate(&model.AuditLog{}))
t.Cleanup(func() { require.NoError(t, logDB.Migrator().DropTable(&model.AuditLog{})) })
token := "redemption-audit-test-token"
admin := model.User{Username: "redemption-audit-admin", Password: "unused", Role: common.RoleAdminUser, Status: common.UserStatusEnabled, Group: "default", AccessToken: &token}
require.NoError(t, db.Create(&admin).Error)
codes := make([]model.Redemption, 16)
for index := range codes {
codes[index] = model.Redemption{Name: "selected", Key: fmt.Sprintf("%032d", index+1), Quota: 100, Status: common.RedemptionCodeStatusEnabled}
}
codes[1].Status = common.RedemptionCodeStatusUsed
codes[15].Name = "unselected"
codes[15].Status = common.RedemptionCodeStatusDisabled
require.NoError(t, model.DB.Create(&codes).Error)
router := gin.New()
router.Use(middleware.RequestId())
router.POST("/api/redemption/batch", middleware.AdminAuth(), DeleteRedemptionBatch)
overLimit := make([]int, 1001)
for index := range overLimit {
overLimit[index] = codes[0].Id
}
oversized, err := common.Marshal(map[string]any{"ids": overLimit})
require.NoError(t, err)
for _, body := range []string{"{}", `{"ids":[]}`, `{"ids":null}`, `{"ids":[0]}`, `{"ids":[1,-1]}`, `{"ids":["1"]}`, "{", string(oversized)} {
t.Run("invalid_"+body[:min(len(body), 30)], func(t *testing.T) {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/redemption/batch", bytes.NewBufferString(body))
request.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(response, request)
var result struct {
Success bool `json:"success"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.False(t, result.Success)
var count int64
require.NoError(t, model.DB.Model(&model.Redemption{}).Count(&count).Error)
assert.EqualValues(t, 16, count)
var events []model.AuditLog
require.NoError(t, logDB.Where("request_id = ? AND category = ?", response.Header().Get(common.RequestIdKey), model.AuditCategoryOperation).Find(&events).Error)
require.Len(t, events, 1)
assert.False(t, events[0].Success)
assert.Equal(t, "redemption.delete_batch", events[0].Action)
})
}
_, err = model.BatchDeleteRedemptions(nil)
require.Error(t, err)
requestedIDs := make([]int, 0, 17)
for _, code := range codes[:15] {
requestedIDs = append(requestedIDs, code.Id)
}
requestedIDs = append(requestedIDs, codes[0].Id, 999999)
payload, err := common.Marshal(map[string]any{"ids": requestedIDs})
require.NoError(t, err)
for _, expectedCount := range []int64{15, 0} {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/redemption/batch", bytes.NewReader(payload))
request.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
var result struct {
Success bool `json:"success"`
Data int64 `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.True(t, result.Success)
assert.Equal(t, expectedCount, result.Data)
var events []model.AuditLog
require.NoError(t, logDB.Where("request_id = ? AND category = ?", response.Header().Get(common.RequestIdKey), model.AuditCategoryOperation).Find(&events).Error)
require.Len(t, events, 1, "one operation event, without a duplicate single-delete fallback")
event := events[0]
assert.Equal(t, "redemption.delete_batch", event.Action)
assert.Equal(t, fmt.Sprintf("Batch deleted %d redemption codes", expectedCount), event.Content)
assert.True(t, event.Success)
assert.Equal(t, admin.Id, event.UserId)
assert.Equal(t, "/api/redemption/batch", event.Route)
require.NotNil(t, event.Other.Op)
encoded, err := common.Marshal(event.Other.Op.Params)
require.NoError(t, err)
var params struct {
Count int64 `json:"count"`
Total int `json:"total"`
IDs []int `json:"requested_redemption_ids"`
}
require.NoError(t, common.Unmarshal(encoded, &params))
assert.Equal(t, expectedCount, params.Count)
assert.Equal(t, len(requestedIDs), params.Total)
assert.Equal(t, requestedIDs, params.IDs)
encoded, err = common.Marshal(event)
require.NoError(t, err)
assert.NotContains(t, string(encoded), token)
for _, code := range codes {
assert.NotContains(t, string(encoded), code.Key)
}
}
var active []model.Redemption
require.NoError(t, model.DB.Find(&active).Error)
require.Len(t, active, 1)
assert.Equal(t, codes[15], active[0])
var all []model.Redemption
require.NoError(t, model.DB.Unscoped().Order("id").Find(&all).Error)
require.Len(t, all, 16)
for _, code := range all[:15] {
assert.True(t, code.DeletedAt.Valid)
}
assert.False(t, all[15].DeletedAt.Valid)
})
}
}
+934
View File
@@ -0,0 +1,934 @@
package controller
import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
taskdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/bytedance/gopkg/util/gopool"
"github.com/samber/lo"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError {
var err *types.NewAPIError
switch info.RelayMode {
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
err = relay.ImageHelper(c, info)
case relayconstant.RelayModeAudioSpeech:
fallthrough
case relayconstant.RelayModeAudioTranslation:
fallthrough
case relayconstant.RelayModeAudioTranscription:
err = relay.AudioHelper(c, info)
case relayconstant.RelayModeRerank:
err = relay.RerankHelper(c, info)
case relayconstant.RelayModeEmbeddings:
err = relay.EmbeddingHelper(c, info)
case relayconstant.RelayModeResponses, relayconstant.RelayModeResponsesCompact:
err = relay.ResponsesHelper(c, info)
case relayconstant.RelayModeAlphaSearch:
err = relay.AlphaSearchHelper(c, info)
default:
err = relay.TextHelper(c, info)
}
return err
}
func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError {
var err *types.NewAPIError
if strings.Contains(c.Request.URL.Path, "embed") {
err = relay.GeminiEmbeddingHandler(c, info)
} else {
err = relay.GeminiHelper(c, info)
}
return err
}
const publicUpstreamErrorMessage = "The model service is temporarily unavailable. Please try again later."
func publicUpstreamErrorContent(statusCode int) string {
return fmt.Sprintf("status_code=%d, %s", statusCode, publicUpstreamErrorMessage)
}
func clientFacingOpenAIError(err *types.NewAPIError, upstreamFailure bool, requestId string) types.OpenAIError {
if upstreamFailure {
return types.OpenAIError{
Message: common.MessageWithRequestId(publicUpstreamErrorMessage, requestId),
Type: "api_error",
Code: "model_service_error",
}
}
result := err.ToOpenAIError()
result.Message = common.MessageWithRequestId(result.Message, requestId)
return result
}
func clientFacingClaudeError(err *types.NewAPIError, upstreamFailure bool, requestId string) types.ClaudeError {
if upstreamFailure {
return types.ClaudeError{Type: "api_error", Message: common.MessageWithRequestId(publicUpstreamErrorMessage, requestId)}
}
result := err.ToClaudeError()
result.Message = common.MessageWithRequestId(result.Message, requestId)
return result
}
func Relay(c *gin.Context, relayFormat types.RelayFormat) {
requestId := c.GetString(common.RequestIdKey)
//group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
//originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
var (
newAPIError *types.NewAPIError
ws *websocket.Conn
upstreamFailure bool
)
if relayFormat == types.RelayFormatOpenAIRealtime {
var err error
ws, err = upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError())
return
}
defer ws.Close()
}
defer func() {
if newAPIError != nil {
logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
switch relayFormat {
case types.RelayFormatOpenAIRealtime:
helper.WssError(c, ws, clientFacingOpenAIError(newAPIError, upstreamFailure, requestId))
case types.RelayFormatClaude:
c.JSON(newAPIError.StatusCode, gin.H{
"type": "error",
"error": clientFacingClaudeError(newAPIError, upstreamFailure, requestId),
})
default:
c.JSON(newAPIError.StatusCode, gin.H{
"error": clientFacingOpenAIError(newAPIError, upstreamFailure, requestId),
})
}
}
}()
request, err := helper.GetAndValidateRequest(c, relayFormat)
if err != nil {
// Map "request body too large" to 413 so clients can handle it correctly
if common.IsRequestBodyTooLargeError(err) || errors.Is(err, common.ErrRequestBodyTooLarge) {
newAPIError = types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusRequestEntityTooLarge, types.ErrOptionWithSkipRetry())
} else {
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithStatusCode(http.StatusBadRequest), types.ErrOptionWithSkipRetry())
}
return
}
relayInfo, err := relaycommon.GenRelayInfo(c, relayFormat, request, ws)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed)
return
}
needSensitiveCheck := setting.ShouldCheckPromptSensitive()
needCountToken := constant.CountToken
// Avoid building huge CombineText (strings.Join) when token counting and sensitive check are both disabled.
var meta *types.TokenCountMeta
if needSensitiveCheck || needCountToken {
meta = request.GetTokenCountMeta()
} else {
meta = fastTokenCountMetaForPricing(request)
}
if needSensitiveCheck && meta != nil {
contains, words := service.CheckSensitiveText(meta.CombineText)
if contains {
logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", ")))
newAPIError = types.NewError(err, types.ErrorCodeSensitiveWordsDetected)
return
}
}
tokens, err := service.EstimateRequestToken(c, meta, relayInfo)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeCountTokenFailed)
return
}
relayInfo.SetEstimatePromptTokens(tokens)
priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest))
return
}
// common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta)
if priceData.FreeModel {
logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName))
} else {
newAPIError = service.PreConsumeBilling(c, priceData.QuotaToPreConsume, relayInfo)
if newAPIError != nil {
return
}
}
defer func() {
// Only return quota if downstream failed and quota was actually pre-consumed
if newAPIError != nil {
newAPIError = service.NormalizeViolationFeeError(newAPIError)
if relayInfo.Billing != nil {
relayInfo.Billing.Refund(c)
}
service.ChargeViolationFeeIfNeeded(c, relayInfo, newAPIError)
}
}()
retryParam := &service.RetryParam{
Ctx: c,
TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName,
RequestPath: c.Request.URL.Path,
Retry: common.GetPointer(0),
}
relayInfo.RetryIndex = 0
relayInfo.LastError = nil
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
relayInfo.RetryIndex = retryParam.GetRetry()
channel, channelErr := getChannel(c, relayInfo, retryParam)
if channelErr != nil {
logger.LogError(c, channelErr.Error())
newAPIError = channelErr
break
}
addUsedChannel(c, channel.Id)
if billingErr := service.PrepareTieredBillingForSelectedGroup(c, relayInfo); billingErr != nil {
newAPIError = billingErr
break
}
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
// Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path)
if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) {
newAPIError = types.NewErrorWithStatusCode(bodyErr, types.ErrorCodeReadRequestBodyFailed, http.StatusRequestEntityTooLarge, types.ErrOptionWithSkipRetry())
} else {
newAPIError = types.NewErrorWithStatusCode(bodyErr, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
break
}
c.Request.Body = io.NopCloser(bodyStorage)
switch relayFormat {
case types.RelayFormatOpenAIRealtime:
newAPIError = relay.WssHelper(c, relayInfo)
case types.RelayFormatClaude:
newAPIError = relay.ClaudeHelper(c, relayInfo)
case types.RelayFormatGemini:
newAPIError = geminiRelayHandler(c, relayInfo)
default:
newAPIError = relayHandler(c, relayInfo)
}
if newAPIError == nil {
relayInfo.LastError = nil
return
}
upstreamFailure = true
newAPIError = service.NormalizeViolationFeeError(newAPIError)
relayInfo.LastError = newAPIError
processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError, relayInfo)
if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) {
break
}
}
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
logger.LogInfo(c, retryLogStr)
}
if newAPIError != nil {
gopool.Go(func() {
perfmetrics.RecordRelaySample(relayInfo, false, 0)
})
}
}
// CountClaudeTokens implements Anthropic's token-counting utility endpoint.
// It deliberately skips upstream generation and billing; callers use this
// endpoint to size prompts before creating a Message.
func CountClaudeTokens(c *gin.Context) {
request, err := helper.GetAndValidateClaudeRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"type": "error",
"error": gin.H{
"type": "invalid_request_error",
"message": common.MessageWithRequestId(err.Error(), c.GetString(common.RequestIdKey)),
},
})
return
}
info := relaycommon.GenRelayInfoClaude(c, request)
inputTokens, err := service.CountRequestToken(c, request.GetTokenCountMeta(), info)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"type": "error",
"error": gin.H{
"type": "api_error",
"message": common.MessageWithRequestId(err.Error(), c.GetString(common.RequestIdKey)),
},
})
return
}
c.JSON(http.StatusOK, gin.H{"input_tokens": inputTokens})
}
var upgrader = websocket.Upgrader{
Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol
CheckOrigin: func(r *http.Request) bool {
return true // 允许跨域
},
}
func addUsedChannel(c *gin.Context, channelId int) {
useChannel := c.GetStringSlice("use_channel")
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
c.Set("use_channel", useChannel)
}
func fastTokenCountMetaForPricing(request dto.Request) *types.TokenCountMeta {
if request == nil {
return &types.TokenCountMeta{}
}
meta := &types.TokenCountMeta{
TokenType: types.TokenTypeTokenizer,
}
switch r := request.(type) {
case *dto.GeneralOpenAIRequest:
maxCompletionTokens := lo.FromPtrOr(r.MaxCompletionTokens, uint(0))
maxTokens := lo.FromPtrOr(r.MaxTokens, uint(0))
if maxCompletionTokens > maxTokens {
meta.MaxTokens = int(maxCompletionTokens)
} else {
meta.MaxTokens = int(maxTokens)
}
case *dto.OpenAIResponsesRequest:
meta.MaxTokens = int(lo.FromPtrOr(r.MaxOutputTokens, uint(0)))
case *dto.ClaudeRequest:
meta.MaxTokens = int(lo.FromPtr(r.MaxTokens))
case *dto.ImageRequest:
// Pricing for image requests depends on ImagePriceRatio; safe to compute even when CountToken is disabled.
return r.GetTokenCountMeta()
default:
// Best-effort: leave CombineText empty to avoid large allocations.
}
return meta
}
func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, *types.NewAPIError) {
if info.ChannelMeta == nil {
autoBan := c.GetBool("auto_ban")
autoBanInt := 1
if !autoBan {
autoBanInt = 0
}
return &model.Channel{
Id: c.GetInt("channel_id"),
Type: c.GetInt("channel_type"),
Name: c.GetString("channel_name"),
AutoBan: &autoBanInt,
}, nil
}
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam)
if err != nil {
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
if channel == nil {
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName)
if newAPIError != nil {
return nil, newAPIError
}
return channel, nil
}
func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) bool {
if openaiErr == nil {
return false
}
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
return false
}
if types.IsChannelError(openaiErr) {
return true
}
if types.IsSkipRetryError(openaiErr) {
return false
}
if retryTimes <= 0 {
return false
}
if service.GetChannelConstraints(c).SuppressesRetry() {
return false
}
code := openaiErr.StatusCode
if code >= 200 && code < 300 {
return false
}
if code < 100 || code > 599 {
return true
}
if operation_setting.IsAlwaysSkipRetryCode(openaiErr.GetErrorCode()) {
return false
}
return operation_setting.ShouldRetryByStatusCode(code)
}
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError, relayInfo *relaycommon.RelayInfo) {
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
if service.ShouldDisableChannel(err) && channelError.AutoBan {
gopool.Go(func() {
service.DisableChannel(channelError, err.ErrorWithStatusCode())
})
}
if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
// 保存错误日志到mysql中
userId := c.GetInt("id")
tokenName := c.GetString("token_name")
modelName := c.GetString("original_model")
tokenId := c.GetInt("token_id")
userGroup := c.GetString("group")
other := model.NewLogOther()
if c.Request != nil && c.Request.URL != nil {
other.SetPublic("request_path", c.Request.URL.Path)
}
other.SetPublic("error_type", err.GetErrorType())
other.SetPublic("error_code", err.GetErrorCode())
other.SetPublic("status_code", err.StatusCode)
service.AppendRelayLogAdminInfo(c, relayInfo, other)
service.AppendTaskPluginContextAuditInfo(c, other)
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() {
startTime = time.Now()
}
useTimeSeconds := int(time.Since(startTime).Seconds())
other.SetRoot("upstream_error", err.MaskSensitiveErrorWithStatusCode())
model.RecordErrorLog(c, userId, channelError.ChannelId, modelName, tokenName, publicUpstreamErrorContent(err.StatusCode), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
}
}
func RelayMidjourney(c *gin.Context) {
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatMjProxy, nil, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"description": fmt.Sprintf("failed to generate relay info: %s", err.Error()),
"type": "upstream_error",
"code": 4,
})
return
}
var mjErr *taskdto.MidjourneyResponse
switch relayInfo.RelayMode {
case relayconstant.RelayModeMidjourneyNotify:
mjErr = relay.RelayMidjourneyNotify(c)
case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition:
mjErr = relay.RelayMidjourneyTask(c, relayInfo.RelayMode)
case relayconstant.RelayModeMidjourneyTaskImageSeed:
mjErr = relay.RelayMidjourneyTaskImageSeed(c)
case relayconstant.RelayModeSwapFace:
mjErr = relay.RelaySwapFace(c, relayInfo)
default:
mjErr = relay.RelayMidjourneySubmit(c, relayInfo)
}
//err = relayMidjourneySubmit(c, relayMode)
log.Println(mjErr)
if mjErr != nil {
statusCode := http.StatusBadRequest
if mjErr.Code == 30 {
mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
statusCode = http.StatusTooManyRequests
}
c.JSON(statusCode, gin.H{
"description": fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result),
"type": "upstream_error",
"code": mjErr.Code,
})
channelId := c.GetInt("channel_id")
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result)))
}
}
func RelayNotImplemented(c *gin.Context) {
err := types.OpenAIError{
Message: "API not implemented",
Type: "new_api_error",
Param: "",
Code: "api_not_implemented",
}
c.JSON(http.StatusNotImplemented, gin.H{
"error": err,
})
}
func RelayNotFound(c *gin.Context) {
// The web fallback may already have applied static-asset cache headers.
// A missing API or asset can appear after an upgrade; never cache its 404.
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
err := types.OpenAIError{
Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path),
Type: "invalid_request_error",
Param: "",
Code: "",
}
c.JSON(http.StatusNotFound, gin.H{
"error": err,
})
}
// RelayTaskPluginEndpoint keeps unclaimed shared-endpoint traffic on its
// existing handler while claimed requests enter the generation-pinned
// host-owned protocol bridge.
func RelayTaskPluginEndpoint(c *gin.Context, fallback gin.HandlerFunc) {
pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint)
if !exists {
fallback(c)
return
}
pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint)
if !ok || pinned.Plugin == nil || pinned.Generation == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Task protocol request failed",
"type": "new_api_error",
"code": "task_protocol_error",
},
})
return
}
if pinned.Protocol != "openai_responses" {
fallback(c)
return
}
serveTaskPluginProtocol(c, pinned, defaultPluginProtocolBridgeDeps())
}
func RelayTaskFetch(c *gin.Context) {
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, &taskdto.TaskError{
Code: "gen_relay_info_failed",
Message: err.Error(),
StatusCode: http.StatusInternalServerError,
})
return
}
if taskErr := relay.RelayTaskFetch(c, relayInfo.RelayMode); taskErr != nil {
respondTaskError(c, taskErr)
}
}
type taskSubmissionOutcome struct {
Result *relay.TaskSubmitResult
Task *model.Task
RelayInfo *relaycommon.RelayInfo
}
func RelayTask(c *gin.Context) {
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
respondTaskSubmissionError(c, &taskdto.TaskError{
Code: "gen_relay_info_failed",
Message: err.Error(),
StatusCode: http.StatusInternalServerError,
})
return
}
if action := c.GetString("task_action"); action != "" {
relayInfo.Action = action
}
if taskErr := relay.ResolveOriginTask(c, relayInfo); taskErr != nil {
respondTaskSubmissionError(c, taskErr)
return
}
if taskErr := relay.ApplyOriginTaskAffinity(c, relayInfo); taskErr != nil {
respondTaskSubmissionError(c, taskErr)
return
}
outcome, taskErr := executeTaskSubmission(c, relayInfo)
if taskErr != nil {
respondTaskSubmissionError(c, taskErr)
return
}
presentTaskSubmission(c, outcome)
}
// executeTaskSubmission owns the retry, billing, and persistence lifecycle.
// It deliberately performs no client response writes so JSON and protocol
// presenters share the same durable task barrier. Its cancellation semantics
// come from c.Request.Context: native task endpoints use the client context,
// while the Responses bridge supplies an independently bounded context.
func executeTaskSubmission(c *gin.Context, relayInfo *relaycommon.RelayInfo) (*taskSubmissionOutcome, *taskdto.TaskError) {
return executeTaskSubmissionWith(c, relayInfo, relay.RelayTaskSubmit)
}
type taskSubmitAttempt func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *taskdto.TaskError)
func executeTaskSubmissionWith(
c *gin.Context,
relayInfo *relaycommon.RelayInfo,
submit taskSubmitAttempt,
) (*taskSubmissionOutcome, *taskdto.TaskError) {
diagnostics := newTaskPluginSubmitDiagnostics(c)
diagnostics.start(relayInfo)
var result *relay.TaskSubmitResult
var taskErr *taskdto.TaskError
durable := false
stage := "start"
defer func() {
if !durable && relayInfo.Billing != nil {
diagnostics.refund(stage)
relayInfo.Billing.Refund(c)
}
}()
stage = "before_attempt"
if requestErr := c.Request.Context().Err(); requestErr != nil {
diagnostics.cancelled("before_attempt", 0)
return nil, service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
}
retryParam := &service.RetryParam{
Ctx: c,
TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName,
RequestPath: c.Request.URL.Path,
Retry: common.GetPointer(0),
}
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
stage = "select_channel"
if requestErr := c.Request.Context().Err(); requestErr != nil {
diagnostics.cancelled("before_attempt", retryParam.GetRetry()+1)
taskErr = service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
break
}
var channel *model.Channel
if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil {
channel = lockedCh
if retryParam.GetRetry() > 0 {
if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil {
taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError)
break
}
}
} else {
var channelErr *types.NewAPIError
channel, channelErr = getChannel(c, relayInfo, retryParam)
if channelErr != nil {
logger.LogError(c, channelErr.Error())
taskErr = service.TaskErrorWrapperLocal(channelErr.Err, "get_channel_failed", http.StatusInternalServerError)
break
}
}
diagnostics.attempt(retryParam.GetRetry()+1, channel, relayInfo.LockedChannel != nil)
addUsedChannel(c, channel.Id)
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
stage = "read_body"
if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) {
taskErr = service.TaskErrorWrapperLocal(bodyErr, "read_request_body_failed", http.StatusRequestEntityTooLarge)
} else {
taskErr = service.TaskErrorWrapperLocal(bodyErr, "read_request_body_failed", http.StatusBadRequest)
}
break
}
c.Request.Body = io.NopCloser(bodyStorage)
stage = "submit"
result, taskErr = submit(c, relayInfo)
if requestErr := c.Request.Context().Err(); requestErr != nil {
diagnostics.cancelled("after_submit", retryParam.GetRetry()+1)
taskErr = service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
break
}
if taskErr == nil {
diagnostics.attemptSucceeded(retryParam.GetRetry()+1, result)
break
}
if !taskErr.LocalError {
processChannelError(c,
*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey,
common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()),
types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode),
relayInfo)
}
willRetry := shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry())
diagnostics.attemptFailed(retryParam.GetRetry()+1, channel, taskErr, willRetry)
if !willRetry {
break
}
}
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
logger.LogInfo(c, retryLogStr)
}
if taskErr != nil {
diagnostics.failed(stage, "task_error", taskErr, false)
return nil, taskErr
}
if result == nil {
taskErr = service.TaskErrorWrapperLocal(errors.New("task submission returned no result"), "task_submit_failed", http.StatusInternalServerError)
diagnostics.failed("submit", "missing_result", taskErr, false)
return nil, taskErr
}
if requestErr := c.Request.Context().Err(); requestErr != nil {
diagnostics.cancelled("before_reserve", retryParam.GetRetry()+1)
return nil, service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
}
// Reserve any submit-time upward billing adjustment before persistence.
// This keeps insertion failures fully refundable while ensuring settlement
// after the barrier normally has a zero positive delta.
if relayInfo.Billing != nil {
stage = "reserve"
diagnostics.reserve("reserve_start", result.Quota)
if reserveErr := relayInfo.Billing.Reserve(result.Quota); reserveErr != nil {
common.SysError("reserve adjusted task billing error: " + reserveErr.Error())
taskErr = service.TaskErrorWrapperLocal(errors.New("insufficient quota for adjusted task cost"), string(types.ErrorCodeInsufficientUserQuota), http.StatusForbidden)
diagnostics.failed("reserve", "insufficient_quota", taskErr, false)
return nil, taskErr
}
diagnostics.reserve("reserve_complete", result.Quota)
}
if requestErr := c.Request.Context().Err(); requestErr != nil {
diagnostics.cancelled("before_insert", retryParam.GetRetry()+1)
return nil, service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
}
stage = "insert"
task := model.InitTask(result.Platform, relayInfo)
task.PrivateData.Execution = service.TaskExecutionSnapshotFromContext(c)
task.PrivateData.UpstreamTaskID = result.UpstreamTaskID
task.PrivateData.BillingSource = relayInfo.BillingSource
task.PrivateData.SubscriptionId = relayInfo.SubscriptionId
task.PrivateData.TokenId = relayInfo.TokenId
task.PrivateData.NodeName = common.NodeName
task.PrivateData.BillingContext = &model.TaskBillingContext{
ModelPrice: relayInfo.PriceData.ModelPrice,
GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios(),
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
TieredSnapshot: relayInfo.TieredBillingSnapshot,
}
task.Quota = result.Quota
task.Data = result.TaskData
if len(result.PluginState) > 0 {
task.PrivateData.PluginState = result.PluginState
}
task.Action = relayInfo.Action
if immediate := result.Immediate; immediate != nil {
task.Status = model.TaskStatus(immediate.Status)
task.Progress = immediate.Progress
if immediate.Status == model.TaskStatusSuccess || immediate.Status == model.TaskStatusFailure {
task.FinishTime = time.Now().Unix()
}
if immediate.Status == model.TaskStatusFailure {
task.FailReason = immediate.Reason
}
if immediate.Url != "" {
task.PrivateData.ResultURL = immediate.Url
} else if immediate.Status == model.TaskStatusSuccess {
task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
}
}
diagnostics.insertStart(task)
if insertErr := task.InsertWithContext(c.Request.Context()); insertErr != nil {
common.SysError("insert task error: " + insertErr.Error())
taskErr = service.TaskErrorWrapperLocal(errors.New("failed to persist task"), "task_insert_failed", http.StatusInternalServerError)
diagnostics.failed("insert", "database_error", taskErr, false)
return nil, taskErr
}
durable = true
stage = "settle"
diagnostics.durable(task)
diagnostics.settleStart(task, result.Quota)
if settleErr := service.SettleBilling(c, relayInfo, result.Quota); settleErr != nil {
common.SysError("settle task billing error: " + settleErr.Error())
taskErr = service.TaskErrorWrapperLocal(errors.New("failed to settle task billing"), "task_billing_settlement_failed", http.StatusInternalServerError)
diagnostics.failed("settle", "billing_error", taskErr, true)
return nil, taskErr
}
service.LogTaskConsumption(c, relayInfo, task)
diagnostics.complete(task, result.Quota)
return &taskSubmissionOutcome{Result: result, Task: task, RelayInfo: relayInfo}, nil
}
func presentTaskSubmission(c *gin.Context, outcome *taskSubmissionOutcome) {
diagnostics := newTaskPluginSubmitDiagnostics(c)
otherRatios := outcome.RelayInfo.PriceData.OtherRatios()
if otherRatios == nil {
otherRatios = map[string]float64{}
}
if ratiosJSON, err := common.Marshal(otherRatios); err == nil {
c.Header("X-New-Api-Other-Ratios", string(ratiosJSON))
}
if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedRoute); exists {
if pinned, ok := pinnedValue.(pluginruntime.PinnedRoute); ok && pinned.Plugin != nil && pinned.Route.Render != "" {
view, err := service.BuildTaskPluginView(outcome.Task)
requestValue, _ := c.Get(pluginruntime.ContextKeyRouteRequest)
requestContext, _ := requestValue.(pluginruntime.RouteRequestContext)
if err == nil {
viewValue, valueErr := taskPluginProtocolJSONValue(view)
if valueErr == nil {
if body, callErr := pinned.Plugin.Engine.CallPath(c.Request.Context(), "native", []string{pinned.Route.Render}, requestContext.JSValue(), viewValue); callErr == nil {
diagnostics.present(outcome.Task, "native_presenter")
c.JSON(http.StatusOK, body)
return
} else {
logger.LogError(c, "task plugin native submit presenter failed: "+callErr.Error())
}
} else {
logger.LogError(c, "encode task plugin native submit view failed: "+valueErr.Error())
}
} else {
logger.LogError(c, "build task plugin native submit view failed: "+err.Error())
}
}
}
if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint); exists {
if pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint); ok && pinned.Protocol == "openai_video" && pinned.Operation.Name == "create" {
diagnostics.present(outcome.Task, "openai_video_create")
c.JSON(http.StatusOK, outcome.Task.ToOpenAIVideo())
return
}
}
createdAt := outcome.Task.CreatedAt
if createdAt == 0 {
createdAt = outcome.Task.SubmitTime
}
diagnostics.present(outcome.Task, "host_fallback")
c.JSON(http.StatusOK, map[string]any{
"id": outcome.Task.TaskID,
"task_id": outcome.Task.TaskID,
"status": "queued",
"model": outcome.RelayInfo.OriginModelName,
"created_at": createdAt,
})
}
func respondTaskSubmissionError(c *gin.Context, taskErr *taskdto.TaskError) {
newTaskPluginSubmitDiagnostics(c).presentError(taskErr)
if middleware.RespondTaskPluginError(c, taskErr) {
return
}
respondTaskError(c, taskErr)
}
// respondTaskError 统一输出 Task 错误响应(含 429 限流提示改写)
func respondTaskError(c *gin.Context, taskErr *taskdto.TaskError) {
if taskErr.StatusCode == http.StatusTooManyRequests {
taskErr.Message = "当前分组上游负载已饱和,请稍后再试"
}
c.JSON(taskErr.StatusCode, taskErr)
}
func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *taskdto.TaskError, retryTimes int) bool {
if taskErr == nil {
return false
}
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
return false
}
if retryTimes <= 0 {
return false
}
if service.GetChannelConstraints(c).SuppressesRetry() {
return false
}
if taskErr.StatusCode == http.StatusTooManyRequests {
return true
}
if taskErr.StatusCode == 307 {
return true
}
if taskErr.StatusCode/100 == 5 {
// 超时不重试
if operation_setting.IsAlwaysSkipRetryStatusCode(taskErr.StatusCode) {
return false
}
return true
}
if taskErr.StatusCode == http.StatusBadRequest {
return false
}
if taskErr.StatusCode == 408 {
// azure处理超时不重试
return false
}
if taskErr.LocalError {
return false
}
if taskErr.StatusCode/100 == 2 {
return false
}
return true
}
+45
View File
@@ -0,0 +1,45 @@
package controller
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/QuantumNous/new-api/relaykit/types"
)
func TestClientFacingOpenAIErrorHidesUpstreamDetails(t *testing.T) {
err := types.NewOpenAIError(errors.New("provider.example.invalid provider secret failure"), types.ErrorCodeBadResponseStatusCode, http.StatusBadGateway)
got := clientFacingOpenAIError(err, true, "local-request-id")
if strings.Contains(got.Message, "example-provider") || strings.Contains(got.Message, "provider secret") {
t.Fatalf("upstream details leaked: %q", got.Message)
}
if !strings.Contains(got.Message, "local-request-id") {
t.Fatalf("local request id missing: %q", got.Message)
}
if got.Code != "model_service_error" {
t.Fatalf("unexpected public code: %v", got.Code)
}
}
func TestClientFacingClaudeErrorHidesUpstreamDetails(t *testing.T) {
err := types.NewOpenAIError(errors.New("provider.example.invalid provider secret failure"), types.ErrorCodeBadResponseStatusCode, http.StatusBadGateway)
got := clientFacingClaudeError(err, true, "local-request-id")
if strings.Contains(got.Message, "example-provider") || strings.Contains(got.Message, "provider secret") {
t.Fatalf("upstream details leaked: %q", got.Message)
}
if !strings.Contains(got.Message, "local-request-id") {
t.Fatalf("local request id missing: %q", got.Message)
}
}
func TestClientFacingErrorKeepsLocalValidationMessage(t *testing.T) {
err := types.NewErrorWithStatusCode(errors.New("model is required"), types.ErrorCodeInvalidRequest, http.StatusBadRequest)
got := clientFacingOpenAIError(err, false, "local-request-id")
if !strings.Contains(got.Message, "model is required") {
t.Fatalf("local validation message was hidden: %q", got.Message)
}
}
+73
View File
@@ -0,0 +1,73 @@
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCountClaudeTokensReturnsInputTokensWhenRelayCountingDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
originalCountToken := constant.CountToken
constant.CountToken = false
t.Cleanup(func() {
constant.CountToken = originalCountToken
})
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(
http.MethodPost,
"/v1/messages/count_tokens?beta=true",
strings.NewReader(`{
"model":"gemini-3.6-flash",
"messages":[{"role":"user","content":"count this prompt"}],
"tools":[{"name":"lookup","description":"Look up a value","input_schema":{"type":"object","properties":{"query":{"type":"string"}}}}]
}`),
)
ctx.Request.Header.Set("Content-Type", "application/json")
common.SetContextKey(ctx, constant.ContextKeyOriginalModel, "gemini-3.6-flash")
CountClaudeTokens(ctx)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
InputTokens int `json:"input_tokens"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.Positive(t, response.InputTokens)
}
func TestCountClaudeTokensRejectsMissingMessages(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(
http.MethodPost,
"/v1/messages/count_tokens",
strings.NewReader(`{"model":"gemini-3.6-flash"}`),
)
ctx.Request.Header.Set("Content-Type", "application/json")
CountClaudeTokens(ctx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
var response struct {
Type string `json:"type"`
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "error", response.Type)
assert.Equal(t, "invalid_request_error", response.Error.Type)
assert.Contains(t, response.Error.Message, "messages")
}
+99
View File
@@ -0,0 +1,99 @@
package controller
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestProcessChannelErrorUsesSnapshotWithoutLeakingChannelMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedisEnabled := common.RedisEnabled
previousMainDatabaseType := common.MainDatabaseType()
previousLogDatabaseType := common.LogDatabaseType()
previousErrorLogEnabled := constant.ErrorLogEnabled
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := database.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, database.AutoMigrate(&model.User{}, &model.Log{}))
model.DB, model.LOG_DB = database, database
common.RedisEnabled = false
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
constant.ErrorLogEnabled = true
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.RedisEnabled = previousRedisEnabled
common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType)
constant.ErrorLogEnabled = previousErrorLogEnabled
require.NoError(t, sqlDB.Close())
})
require.NoError(t, database.Create(&model.User{Id: 7, Username: "log-owner", Group: "default"}).Error)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
ctx.Set("id", 7)
ctx.Set("username", "log-owner")
ctx.Set("token_name", "test-token")
ctx.Set("token_id", 11)
ctx.Set("original_model", "gpt-test")
ctx.Set("group", "default")
ctx.Set("channel_id", 202)
ctx.Set("channel_name", "mutable-context-channel")
ctx.Set("channel_type", 9)
ctx.Set("use_channel", []string{"101"})
common.SetContextKey(ctx, constant.ContextKeyRequestStartTime, time.Now().Add(-time.Second))
channelSnapshot := types.ChannelError{
ChannelId: 101,
ChannelType: 1,
ChannelName: "snapshot-channel",
AutoBan: false,
}
apiErr := types.NewOpenAIError(errors.New("upstream failed"), types.ErrorCodeBadResponseStatusCode, http.StatusBadGateway)
processChannelError(ctx, channelSnapshot, apiErr, nil)
var stored model.Log
require.NoError(t, database.First(&stored).Error)
assert.Equal(t, channelSnapshot.ChannelId, stored.ChannelId)
storedOther, err := common.StrToMap(stored.Other)
require.NoError(t, err)
assert.Equal(t, float64(http.StatusBadGateway), storedOther["status_code"])
for _, key := range []string{"channel_id", "channel_name", "channel_type"} {
assert.NotContains(t, storedOther, key)
}
adminInfo, ok := storedOther["admin_info"].(map[string]any)
require.True(t, ok)
assert.Equal(t, []any{"101"}, adminInfo["use_channel"])
logs, total, err := model.GetUserLogs(7, model.LogTypeError, 0, 0, "", "", 0, 10, "", "", "")
require.NoError(t, err)
require.Equal(t, int64(1), total)
require.Len(t, logs, 1)
assert.Equal(t, channelSnapshot.ChannelId, logs[0].ChannelId)
assert.Empty(t, logs[0].ChannelName)
userOther, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.NotContains(t, userOther, "admin_info")
for _, key := range []string{"channel_id", "channel_name", "channel_type"} {
assert.NotContains(t, userOther, key)
}
}
+16
View File
@@ -0,0 +1,16 @@
package controller
import (
"strings"
"testing"
)
func TestPublicUpstreamErrorContent(t *testing.T) {
got := publicUpstreamErrorContent(502)
if strings.Contains(got, "example-provider") || strings.Contains(got, "provider") || strings.Contains(got, "upstream") {
t.Fatalf("upstream details leaked: %q", got)
}
if !strings.Contains(got, "status_code=502") || !strings.Contains(got, publicUpstreamErrorMessage) {
t.Fatalf("unexpected public error content: %q", got)
}
}
+389
View File
@@ -0,0 +1,389 @@
package controller
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type taskSubmissionTestBilling struct {
events *[]string
settleErr error
onSettle func()
refunds int
}
func (b *taskSubmissionTestBilling) Settle(int) error {
*b.events = append(*b.events, "settle")
if b.onSettle != nil {
b.onSettle()
}
return b.settleErr
}
func (b *taskSubmissionTestBilling) Refund(*gin.Context) {
*b.events = append(*b.events, "refund")
b.refunds++
}
func (b *taskSubmissionTestBilling) NeedsRefund() bool { return b.refunds == 0 }
func (b *taskSubmissionTestBilling) GetPreConsumedQuota() int { return 0 }
func (b *taskSubmissionTestBilling) Reserve(int) error {
*b.events = append(*b.events, "reserve")
return nil
}
func TestPresentTaskSubmissionUsesNativePresenterAfterPersistence(t *testing.T) {
plugin, err := pluginruntime.CompilePlugin(`
export const meta = {apiVersion:1,key:"presenter-test",name:"Presenter",version:"1.0.0",author:{name:"Test"},models:["model"],fetchMode:"per_task",routes:[{method:"POST",path:"/vendor/jobs",type:"submit",decode:"decode",render:"created"}]};
export const native = {decode:function(ctx){return {kind:"submit",model:"model",requestBody:ctx.body.value};},created:function(ctx,task){return {data:{task_id:task.task_id},upstream:task.data};}};
export function buildSubmitRequest(){return {}} export function parseSubmitResponse(){return {taskId:"upstream"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
`, pluginruntime.Options{})
require.NoError(t, err)
priceData := types.PriceData{}
priceData.AddOtherRatio("seconds", 5)
task := &model.Task{TaskID: "task_public", SubmitTime: 123}
task.SetData(map[string]any{"task_id": "upstream_private"})
outcome := &taskSubmissionOutcome{
Result: &relay.TaskSubmitResult{},
Task: task,
RelayInfo: &relaycommon.RelayInfo{PriceData: priceData},
}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{"model":"model"}`))
c.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{Plugin: plugin, Route: plugin.Meta.Routes[0]})
c.Set(pluginruntime.ContextKeyRouteRequest, pluginruntime.RouteRequestContext{Path: "/vendor/jobs", Method: http.MethodPost, Body: map[string]any{"kind": "json", "value": map[string]any{"model": "model"}}})
presentTaskSubmission(c, outcome)
assert.JSONEq(t, `{
"data":{"task_id":"task_public"},
"upstream":{"task_id":"upstream_private"}
}`, recorder.Body.String())
assert.JSONEq(t, `{"seconds":5}`, recorder.Header().Get("X-New-Api-Other-Ratios"))
}
func TestPresentTaskSubmissionFallbackUsesPersistedPublicID(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
outcome := &taskSubmissionOutcome{
Result: &relay.TaskSubmitResult{},
Task: &model.Task{TaskID: "task_persisted", SubmitTime: 456},
RelayInfo: &relaycommon.RelayInfo{OriginModelName: "video-model"},
}
presentTaskSubmission(c, outcome)
assert.JSONEq(t, `{
"id":"task_persisted",
"task_id":"task_persisted",
"status":"queued",
"model":"video-model",
"created_at":456
}`, recorder.Body.String())
}
func TestPresentTaskSubmissionUsesHostOpenAIVideoCreateReceipt(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set(pluginruntime.ContextKeyPinnedEndpoint, pluginruntime.PinnedEndpoint{
Protocol: "openai_video",
Operation: pluginruntime.HostProtocolOperation{Name: "create"},
})
task := &model.Task{
TaskID: "task_public",
Status: model.TaskStatusSubmitted,
Progress: "0%",
CreatedAt: 456,
Properties: model.Properties{OriginModelName: "video-model"},
}
outcome := &taskSubmissionOutcome{Result: &relay.TaskSubmitResult{}, Task: task, RelayInfo: &relaycommon.RelayInfo{}}
presentTaskSubmission(c, outcome)
assert.JSONEq(t, `{"id":"task_public","object":"video","model":"video-model","status":"queued","progress":0,"created_at":456}`, recorder.Body.String())
assert.NotContains(t, recorder.Body.String(), "task_id")
}
func TestExecuteTaskSubmissionRefundsWhenInsertFails(t *testing.T) {
events := make([]string, 0, 3)
database := setupTaskSubmissionDatabase(t, false, &events)
_ = database
billing := &taskSubmissionTestBilling{events: &events}
c := taskSubmissionTestContext()
info := taskSubmissionRelayInfo(billing)
outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
return &relay.TaskSubmitResult{
UpstreamTaskID: "upstream_private",
Platform: constant.TaskPlatform("plugin"),
}, nil
})
assert.Nil(t, outcome)
require.NotNil(t, taskErr)
assert.Equal(t, "task_insert_failed", taskErr.Code)
assert.Equal(t, []string{"reserve", "insert", "refund"}, events)
assert.Equal(t, 1, billing.refunds)
assert.False(t, c.Writer.Written())
}
func TestExecuteTaskSubmissionSettlementFailureStaysDurableAndWritesNothing(t *testing.T) {
events := make([]string, 0, 3)
database := setupTaskSubmissionDatabase(t, true, &events)
billing := &taskSubmissionTestBilling{events: &events, settleErr: errors.New("settlement failed")}
c := taskSubmissionTestContext()
info := taskSubmissionRelayInfo(billing)
outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
return &relay.TaskSubmitResult{
UpstreamTaskID: "upstream_private",
Platform: constant.TaskPlatform("plugin"),
}, nil
})
assert.Nil(t, outcome)
require.NotNil(t, taskErr)
assert.Equal(t, "task_billing_settlement_failed", taskErr.Code)
assert.Equal(t, []string{"reserve", "insert", "settle"}, events)
assert.Zero(t, billing.refunds)
var count int64
require.NoError(t, database.Model(&model.Task{}).Where("task_id = ?", "task_public").Count(&count).Error)
assert.Equal(t, int64(1), count)
assert.False(t, c.Writer.Written())
}
func TestExecuteTaskSubmissionPersistsPinnedPluginProvenance(t *testing.T) {
events := make([]string, 0, 3)
database := setupTaskSubmissionDatabase(t, true, &events)
previousLogConsumeEnabled := common.LogConsumeEnabled
common.LogConsumeEnabled = false
t.Cleanup(func() { common.LogConsumeEnabled = previousLogConsumeEnabled })
c := taskSubmissionTestContext()
c.Set(common.RequestIdKey, "request-public")
c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{
Generation: &pluginruntime.RoutingGeneration{Number: 42},
Plugin: &pluginruntime.LoadedPlugin{Meta: pluginruntime.Meta{
Key: "document-parser",
Name: "Document Parser",
Version: "1.2.3",
APIVersion: 1,
Author: pluginruntime.AuthorMeta{
Name: "Community Author",
URL: "https://plugins.example/author",
},
}},
})
billing := &taskSubmissionTestBilling{events: &events}
info := taskSubmissionRelayInfo(billing)
outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
return &relay.TaskSubmitResult{
UpstreamTaskID: "upstream-private",
Platform: constant.TaskPlatform("document-parser"),
}, nil
})
require.Nil(t, taskErr)
require.NotNil(t, outcome)
require.NotNil(t, outcome.Task.PrivateData.Execution)
require.NotNil(t, outcome.Task.PrivateData.Execution.TaskPlugin)
assert.Equal(t, "request-public", outcome.Task.PrivateData.Execution.RequestID)
assert.Equal(t, "/plugin/submit", outcome.Task.PrivateData.Execution.RequestPath)
assert.Equal(t, "1.2.3", outcome.Task.PrivateData.Execution.TaskPlugin.Version)
assert.Equal(t, uint64(42), outcome.Task.PrivateData.Execution.TaskPlugin.Generation)
require.NotNil(t, outcome.Task.PrivateData.Execution.TaskPlugin.Author)
assert.Equal(t, "Community Author", outcome.Task.PrivateData.Execution.TaskPlugin.Author.Name)
assert.Equal(t, "https://plugins.example/author", outcome.Task.PrivateData.Execution.TaskPlugin.Author.URL)
var stored model.Task
require.NoError(t, database.Where("task_id = ?", "task_public").First(&stored).Error)
require.NotNil(t, stored.PrivateData.Execution)
require.NotNil(t, stored.PrivateData.Execution.TaskPlugin)
assert.Equal(t, "document-parser", stored.PrivateData.Execution.TaskPlugin.Key)
require.NotNil(t, stored.PrivateData.Execution.TaskPlugin.Author)
assert.Equal(t, "Community Author", stored.PrivateData.Execution.TaskPlugin.Author.Name)
assert.Equal(t, "upstream-private", stored.PrivateData.UpstreamTaskID)
}
func TestExecuteTaskSubmissionRefundsCancellationBeforeDurableBarrier(t *testing.T) {
events := make([]string, 0, 2)
setupTaskSubmissionDatabase(t, true, &events)
billing := &taskSubmissionTestBilling{events: &events}
c := taskSubmissionTestContext()
requestContext, cancel := context.WithCancel(c.Request.Context())
c.Request = c.Request.WithContext(requestContext)
info := taskSubmissionRelayInfo(billing)
outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
cancel()
return &relay.TaskSubmitResult{
UpstreamTaskID: "upstream_private",
Platform: constant.TaskPlatform("plugin"),
}, nil
})
assert.Nil(t, outcome)
require.NotNil(t, taskErr)
assert.Equal(t, "request_cancelled", taskErr.Code)
assert.Equal(t, []string{"refund"}, events)
assert.Equal(t, 1, billing.refunds)
assert.False(t, c.Writer.Written())
}
func TestExecuteTaskSubmissionDisconnectBeforeUpstreamAcceptanceSkipsSubmitAndRefunds(t *testing.T) {
events := make([]string, 0, 1)
setupTaskSubmissionDatabase(t, true, &events)
billing := &taskSubmissionTestBilling{events: &events}
c := taskSubmissionTestContext()
requestContext, cancel := context.WithCancel(c.Request.Context())
cancel()
c.Request = c.Request.WithContext(requestContext)
info := taskSubmissionRelayInfo(billing)
submitted := false
outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
submitted = true
return nil, nil
})
assert.Nil(t, outcome)
require.NotNil(t, taskErr)
assert.Equal(t, "request_cancelled", taskErr.Code)
assert.False(t, submitted)
assert.Equal(t, []string{"refund"}, events)
assert.Equal(t, 1, billing.refunds)
assert.False(t, c.Writer.Written())
}
func TestExecuteTaskSubmissionCallerCancellationDuringSubmitRefundsBeforeDurableBarrier(t *testing.T) {
events := make([]string, 0, 1)
setupTaskSubmissionDatabase(t, true, &events)
billing := &taskSubmissionTestBilling{events: &events}
c := taskSubmissionTestContext()
requestContext, cancel := context.WithCancel(c.Request.Context())
c.Request = c.Request.WithContext(requestContext)
info := taskSubmissionRelayInfo(billing)
submitStarted := make(chan struct{})
done := make(chan struct{})
var outcome *taskSubmissionOutcome
var taskErr *dto.TaskError
go func() {
defer close(done)
outcome, taskErr = executeTaskSubmissionWith(c, info, func(c *gin.Context, _ *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
close(submitStarted)
<-c.Request.Context().Done()
return nil, service.TaskErrorWrapperLocal(c.Request.Context().Err(), "do_request_failed", http.StatusInternalServerError)
})
}()
select {
case <-submitStarted:
case <-time.After(2 * time.Second):
require.FailNow(t, "submission did not start")
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
require.FailNow(t, "submission did not stop after disconnect")
}
assert.Nil(t, outcome)
require.NotNil(t, taskErr)
assert.Equal(t, "request_cancelled", taskErr.Code)
assert.Equal(t, []string{"refund"}, events)
assert.Equal(t, 1, billing.refunds)
assert.False(t, c.Writer.Written())
}
func TestExecuteTaskSubmissionDisconnectAfterDurableInsertDoesNotRefund(t *testing.T) {
events := make([]string, 0, 3)
database := setupTaskSubmissionDatabase(t, true, &events)
previousLogConsumeEnabled := common.LogConsumeEnabled
common.LogConsumeEnabled = false
t.Cleanup(func() { common.LogConsumeEnabled = previousLogConsumeEnabled })
c := taskSubmissionTestContext()
requestContext, cancel := context.WithCancel(c.Request.Context())
c.Request = c.Request.WithContext(requestContext)
billing := &taskSubmissionTestBilling{
events: &events,
onSettle: cancel,
}
info := taskSubmissionRelayInfo(billing)
outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
return &relay.TaskSubmitResult{
UpstreamTaskID: "upstream_private",
Platform: constant.TaskPlatform("plugin"),
}, nil
})
require.Nil(t, taskErr)
require.NotNil(t, outcome)
assert.Equal(t, "task_public", outcome.Task.TaskID)
assert.Equal(t, []string{"reserve", "insert", "settle"}, events)
assert.Zero(t, billing.refunds)
var count int64
require.NoError(t, database.Model(&model.Task{}).Where("task_id = ?", "task_public").Count(&count).Error)
assert.Equal(t, int64(1), count)
assert.False(t, c.Writer.Written())
}
func setupTaskSubmissionDatabase(t *testing.T, migrate bool, events *[]string) *gorm.DB {
t.Helper()
previousDB := model.DB
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.Callback().Create().Before("gorm:create").Register("test:task-submit-order", func(*gorm.DB) {
*events = append(*events, "insert")
}))
if migrate {
require.NoError(t, database.AutoMigrate(&model.Task{}))
}
model.DB = database
t.Cleanup(func() { model.DB = previousDB })
return database
}
func taskSubmissionTestContext() *gin.Context {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/plugin/submit", strings.NewReader(`{}`))
return c
}
func taskSubmissionRelayInfo(billing relaycommon.BillingSettler) *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{
UserId: 1,
UsingGroup: "default",
OriginModelName: "plugin-model",
Billing: billing,
TaskRelayInfo: &relaycommon.TaskRelayInfo{
PublicTaskID: "task_public",
LockedChannel: &model.Channel{Id: 1, Type: constant.ChannelTypeTaskPlugin, Name: "plugin"},
},
ChannelMeta: &relaycommon.ChannelMeta{ChannelId: 1, ChannelType: constant.ChannelTypeTaskPlugin},
}
}
+44
View File
@@ -0,0 +1,44 @@
package controller
import (
"testing"
"time"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/stretchr/testify/require"
)
func TestRequestStatusReplacesReachableProbe(t *testing.T) {
monitor := Monitor{Status: 1, Uptime: 1, LastChecked: "probe"}
stats := perfmetrics.GroupResult{Group: "test", SuccessRate: 67.9, AvgTtftMs: 7420, Series: []perfmetrics.BucketPoint{{Ts: time.Now().Unix(), SuccessRate: 67.9, AvgTtftMs: 7420}}}
applyRequestMetrics(&monitor, stats)
require.Equal(t, 0, monitor.Status)
require.InDelta(t, 0.679, monitor.Uptime, 0.00001)
require.Equal(t, 7420, monitor.History[0].Ping)
require.Equal(t, int64(7420), monitor.Metrics.AvgTtftMs)
}
func TestRequestStatusNoSamplesAndDisabled(t *testing.T) {
monitor := Monitor{Status: 1, Uptime: 1}
applyRequestMetrics(&monitor, perfmetrics.GroupResult{})
require.Equal(t, -1, monitor.Status)
require.Empty(t, monitor.LastChecked)
require.Empty(t, monitor.History)
monitor.ChannelDisabled = true
applyRequestMetrics(&monitor, perfmetrics.GroupResult{SuccessRate: 100, Series: []perfmetrics.BucketPoint{{Ts: time.Now().Unix(), SuccessRate: 100}}})
require.Equal(t, 0, monitor.Status)
}
func TestRequestStatusStaleSamplesStayUnknown(t *testing.T) {
monitor := Monitor{}
applyRequestMetrics(&monitor, perfmetrics.GroupResult{
SuccessRate: 100,
Series: []perfmetrics.BucketPoint{{
Ts: time.Now().Add(-3 * time.Hour).Unix(),
SuccessRate: 100,
}},
})
require.Equal(t, -1, monitor.Status)
require.NotEmpty(t, monitor.LastChecked)
require.NotNil(t, monitor.Metrics)
}
+12
View File
@@ -0,0 +1,12 @@
package controller
import (
"strings"
"github.com/QuantumNous/new-api/setting/system_setting"
)
func paymentReturnPath(suffix string) string {
base := strings.TrimRight(system_setting.ServerAddress, "/")
return base + suffix
}
+25
View File
@@ -0,0 +1,25 @@
package controller
import (
"testing"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/stretchr/testify/assert"
)
func TestPaymentReturnPathUsesDefaultDashboardRoutes(t *testing.T) {
previousAddress := system_setting.ServerAddress
system_setting.ServerAddress = "https://dashboard.example.com/"
t.Cleanup(func() { system_setting.ServerAddress = previousAddress })
assert.Equal(
t,
"https://dashboard.example.com/wallet?pay=success",
paymentReturnPath("/wallet?pay=success"),
)
assert.Equal(
t,
"https://dashboard.example.com/usage-logs",
paymentReturnPath("/usage-logs"),
)
}
+54
View File
@@ -0,0 +1,54 @@
package controller
import (
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
// etagVersionPublicContent namespaces the public-content ETag; bump it when
// the JSON envelope served by serveRevalidatedJSON changes shape.
const etagVersionPublicContent = "public-content:v1"
type publicContentResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data string `json:"data"`
}
// serveRevalidatedJSON writes public content as JSON with a weak
// content-derived ETag and answers conditional requests with 304 Not
// Modified. The ETag is a weak validator derived from the content, so it is
// stable across replicas and JSON encodings, and a new one is issued when
// the content changes. Cache-Control: no-cache forces revalidation before
// reuse, so an admin edit takes effect on the next request; Vary:
// Accept-Encoding keeps the gzip and identity encodings apart in shared
// caches.
func serveRevalidatedJSON(c *gin.Context, content string) {
body, err := common.Marshal(publicContentResponse{
Success: true,
Message: "",
Data: content,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
})
return
}
etag := common.ETagFor(etagVersionPublicContent, content)
c.Header("ETag", etag)
c.Header("Cache-Control", "no-cache")
c.Header("Vary", "Accept-Encoding")
if common.ETagMatches(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
+136
View File
@@ -0,0 +1,136 @@
package controller
import (
"errors"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
)
func GetVerificationMethods(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
return
}
requirements, err := service.GetVerificationRequirements(identity, c.Query("scope"))
if err != nil {
writeSecurityOperationError(c, err)
return
}
common.ApiSuccess(c, requirements)
}
// writeSecurityOperationError only exposes known, fixed business messages.
// Unexpected errors retain their cause for the existing server-side auth logger.
func writeSecurityOperationError(c *gin.Context, err error) {
status := http.StatusOK
var code, message string
var protocolError *protocol.Error
switch {
case errors.Is(err, service.ErrAccountEmailInvalid), errors.Is(err, service.ErrAccountEmailRestricted):
code, message = "EMAIL_ADDRESS_REJECTED", err.Error()
case errors.Is(err, model.ErrEmailAlreadyTaken):
code, message = "EMAIL_ALREADY_TAKEN", "This email address is already in use."
case errors.Is(err, service.ErrEmailBindingDelivery):
code, message = "EMAIL_BINDING_DELIVERY_FAILED", err.Error()
case errors.Is(err, model.ErrEmailBindingCodeInvalid):
code, message = "EMAIL_BINDING_CODE_INVALID", err.Error()
case errors.Is(err, model.ErrEmailBindingLocked):
code, message = "EMAIL_BINDING_LOCKED", err.Error()
case errors.Is(err, model.ErrEmailBindingResendWait):
status = http.StatusTooManyRequests
code, message = "EMAIL_BINDING_RESEND_WAIT", err.Error()
case errors.Is(err, common.ErrAccountPasswordLength), errors.Is(err, common.ErrAccountPasswordSame), errors.Is(err, common.ErrPasswordLegacyLimit):
code, message = "PASSWORD_POLICY_REJECTED", err.Error()
case errors.Is(err, model.ErrCurrentPasswordInvalid):
code, message = "CURRENT_PASSWORD_INVALID", err.Error()
case errors.Is(err, model.ErrAccountPasswordState), errors.Is(err, model.ErrAccountBindingChanged):
status = http.StatusConflict
code, message = "ACCOUNT_SECURITY_STATE_CHANGED", err.Error()
case errors.Is(err, model.ErrLastLoginMethod):
code, message = "LAST_LOGIN_METHOD", err.Error()
case errors.Is(err, oauth.ErrTelegramOAuthNotConfigured):
code, message = "TELEGRAM_OAUTH_NOT_CONFIGURED", oauth.ErrTelegramOAuthNotConfigured.Error()
case errors.Is(err, oauth.ErrTelegramOAuthConflict):
code, message = "TELEGRAM_OAUTH_CONFLICT", oauth.ErrTelegramOAuthConflict.Error()
case errors.Is(err, oauth.ErrTelegramOAuthFailed):
code, message = "TELEGRAM_OAUTH_FAILED", oauth.ErrTelegramOAuthFailed.Error()
case errors.Is(err, oauth.ErrTelegramAccountNotBound):
code, message = "TELEGRAM_ACCOUNT_NOT_BOUND", oauth.ErrTelegramAccountNotBound.Error()
case errors.Is(err, model.ErrExternalIdentityAlreadyClaimed):
code, message = "ACCOUNT_ALREADY_BOUND", "This external account is already bound."
if c.Param("provider") == "telegram" {
code, message = "TELEGRAM_BIND_ALREADY_BOUND", "This Telegram account is already bound."
}
case errors.Is(err, service.ErrVerificationContextInvalid):
status = http.StatusBadRequest
code, message = "SECURITY_CONTEXT_INVALID", service.ErrVerificationContextInvalid.Error()
case errors.Is(err, service.ErrVerificationForbidden):
status = http.StatusForbidden
code, message = "SECURITY_ACTION_FORBIDDEN", service.ErrVerificationForbidden.Error()
case errors.Is(err, service.ErrVerificationFailed), errors.As(err, &protocolError):
code, message = "SECURITY_VERIFICATION_FAILED", service.ErrVerificationFailed.Error()
case errors.Is(err, service.ErrVerificationLocked):
code, message = "SECURITY_VERIFICATION_LOCKED", service.ErrVerificationLocked.Error()
case errors.Is(err, service.ErrVerificationUnavailable):
code, message = "SECURITY_METHOD_UNAVAILABLE", service.ErrVerificationUnavailable.Error()
case errors.Is(err, service.ErrVerificationFlowRequired):
status = http.StatusBadRequest
code, message = "SECURITY_VERIFICATION_FLOW_REQUIRED", service.ErrVerificationFlowRequired.Error()
case errors.Is(err, service.ErrProofMethod):
code, message = "SECURITY_PROOF_METHOD_MISMATCH", "This verification method is not allowed for this action."
case errors.Is(err, service.ErrProofScope):
code, message = "SECURITY_PROOF_SCOPE_MISMATCH", "Verification does not match this action."
case errors.Is(err, service.ErrOAuthAccountMismatch):
code, message = "OAUTH_ACCOUNT_MISMATCH", service.ErrOAuthAccountMismatch.Error()
case errors.Is(err, model.ErrTwoFASetupInvalid):
status = http.StatusConflict
code, message = "TWOFA_SETUP_INVALID", model.ErrTwoFASetupInvalid.Error()
case errors.Is(err, model.ErrTwoFACodeInvalid):
code, message = "TWOFA_CODE_INVALID", model.ErrTwoFACodeInvalid.Error()
case errors.Is(err, model.ErrTwoFAAlreadyEnabled):
code, message = "TWOFA_ALREADY_ENABLED", "Two-factor authentication is already enabled."
case errors.Is(err, model.ErrTwoFANotEnabled):
code, message = "TWOFA_NOT_ENABLED", "Two-factor authentication is not enabled."
case errors.Is(err, model.ErrPasskeyNotFound):
code, message = "PASSKEY_NOT_FOUND", "No Passkey is registered."
case errors.Is(err, model.ErrAuthFlowInvalid), errors.Is(err, model.ErrAuthFlowExpired), errors.Is(err, model.ErrAuthFlowConsumed):
code, message = "AUTH_FLOW_INVALID", "Verification flow expired"
case errors.Is(err, model.ErrUserSessionInvalid), errors.Is(err, model.ErrUserSessionInactive):
writeAuthSessionError(c, service.ErrAuthTokenInvalid)
return
default:
c.Set("security_error_code", "AUTH_INTERNAL_ERROR")
writeAuthSessionError(c, err)
return
}
c.Set("security_error_code", code)
c.JSON(status, gin.H{"success": false, "code": code, "message": message})
}
func UniversalVerify(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
return
}
var request service.VerificationInput
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
proof, err := service.VerifySecurityInput(identity, request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]any{"method": proof.Method, "scope": proof.Scope})
common.ApiSuccess(c, proof)
}
+847
View File
@@ -0,0 +1,847 @@
package controller
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"io"
"net"
"net/http"
"net/textproto"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestSecurityAccountDeletionRequiresScopedProof(t *testing.T) {
for _, scenario := range []string{"missing", "wrong scope", "expired", "consumed", "other session", "other account", "password disabled", "factor added"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
originalIdentity := identity
operation := service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}
proof := ""
if scenario != "missing" {
proof = issueSecurityEnrollmentProof(t, identity, operation, service.VerificationMethodPassword)
}
if scenario == "wrong scope" {
proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordChange}, service.VerificationMethodPassword)
}
switch scenario {
case "expired":
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
case "consumed":
_, err := service.ConsumeOperationProof(proof, identity, operation)
require.NoError(t, err)
case "other session", "other account":
userID := user.Id
if scenario == "other account" {
other := &model.User{Username: "other", AffCode: "other", Group: "default", Password: user.Password, Status: common.UserStatusEnabled, Role: common.RoleCommonUser, AuthVersion: 1}
require.NoError(t, model.DB.Create(other).Error)
userID = other.Id
}
bundle, err := service.CreateLoginSession(userID, "password", "127.0.0.1", scenario)
require.NoError(t, err)
identity, err = service.ParseAccessToken(bundle.AccessToken)
require.NoError(t, err)
case "password disabled":
previous := common.PasswordLoginEnabled
common.PasswordLoginEnabled = false
t.Cleanup(func() { common.PasswordLoginEnabled = previous })
case "factor added":
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
}
response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
assert.Equal(t, http.StatusForbidden, response.Code, response.Body.String())
stored, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, identity.UserAuthVersion, stored.AuthVersion)
_, _, err = service.ValidateLoginSession(originalIdentity)
assert.NoError(t, err)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.account_delete").Last(&audit).Error)
assert.False(t, audit.Success)
auditJSON, err := common.Marshal(audit)
require.NoError(t, err)
if proof != "" {
assert.NotContains(t, string(auditJSON), proof)
}
})
}
}
func TestSecurityAccountDeletionAcceptsEitherFactorAndRevokesSessions(t *testing.T) {
for _, method := range []string{"password", "oauth", "2fa", "passkey"} {
t.Run(method, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
if method == "oauth" {
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user"}).Error)
oauth.Register("account-delete-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
t.Cleanup(func() { oauth.Unregister("account-delete-oauth") })
}
if method == "2fa" || method == "passkey" {
newSecurityLoginPasskey(t, user.Id)
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
if method == "passkey" {
locked := time.Now().Add(time.Minute)
factor.LockedUntil = &locked
} else {
system_setting.GetPasskeySettings().Enabled = false
}
require.NoError(t, model.DB.Create(factor).Error)
}
requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAccountDelete)
require.NoError(t, err)
_, err = service.RequireVerificationMethod(identity, service.VerificationScopeAccountDelete, method)
require.NoError(t, err)
if method == "2fa" || method == "passkey" {
assert.Len(t, requirements.Methods, 2)
_, err = service.RequireVerificationMethod(identity, service.VerificationScopeAccountDelete, "password")
assert.ErrorIs(t, err, service.ErrProofMethod)
}
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}, method)
if method == "password" || method == "2fa" {
input := service.VerificationInput{Scope: service.VerificationScopeAccountDelete, Method: method, Password: "enrollment-password"}
if method == "2fa" {
input.Code, err = totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now())
require.NoError(t, err)
}
verified, err := service.VerifySecurityInput(identity, input)
require.NoError(t, err)
proof = verified.ProofToken
}
otherSession, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "second-session")
require.NoError(t, err)
require.NoError(t, model.UpdateUserAccessToken(user.Id, "account-delete-access-token"))
response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.Contains(t, response.Header().Get("Set-Cookie"), "Max-Age=0")
assert.Contains(t, response.Header().Get("Cache-Control"), "no-store")
var deleted model.User
require.NoError(t, model.DB.Unscoped().First(&deleted, user.Id).Error)
assert.True(t, deleted.DeletedAt.Valid)
assert.Equal(t, user.AuthVersion+1, deleted.AuthVersion)
_, _, err = service.ValidateLoginSession(identity)
assert.Error(t, err)
otherIdentity, err := service.ParseAccessToken(otherSession.AccessToken)
require.NoError(t, err)
_, _, err = service.ValidateLoginSession(otherIdentity)
assert.Error(t, err)
_, _, err = service.RefreshLoginSession(otherSession.RefreshToken, otherIdentity.SessionID, "127.0.0.1", "second-session")
assert.Error(t, err)
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.Zero(t, count)
tokenUser, err := model.ValidateAccessToken("account-delete-access-token")
require.NoError(t, err)
assert.Nil(t, tokenUser)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.account_delete").Last(&audit).Error)
assert.True(t, audit.Success)
})
}
}
func TestSecurityAccountDeletionRechecksTransactionAndConsumesFailedProof(t *testing.T) {
for _, scenario := range []string{"revoked session", "auth version", "root", "write failure"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
operation := service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}
proof := issueSecurityEnrollmentProof(t, identity, operation, "password")
if scenario != "write failure" {
_, err := service.ConsumeOperationProof(proof, identity, operation)
require.NoError(t, err)
}
switch scenario {
case "revoked session":
_, err := model.RevokeAllUserSessions(user.Id, "test")
require.NoError(t, err)
case "auth version":
require.NoError(t, model.DB.Model(user).Update("auth_version", user.AuthVersion+1).Error)
case "root":
require.NoError(t, model.DB.Model(user).Update("role", common.RoleRootUser).Error)
_, err := service.GetVerificationRequirements(identity, service.VerificationScopeAccountDelete)
assert.ErrorIs(t, err, service.ErrVerificationForbidden)
case "write failure":
require.NoError(t, model.DB.Callback().Delete().Before("gorm:delete").Register("account-delete-failure", func(tx *gorm.DB) {
if tx.Statement.Table == "users" {
_ = tx.AddError(errors.New("injected deletion failure"))
}
}))
response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
assert.Contains(t, response.Body.String(), `"success":false`)
require.NoError(t, model.DB.Callback().Delete().Remove("account-delete-failure"))
response = securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
assert.Contains(t, response.Body.String(), "SECURITY_PROOF_CONSUMED")
}
if scenario != "write failure" {
assert.Error(t, model.DeleteUserForSession(identity))
}
_, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
if scenario == "write failure" {
_, _, err = service.ValidateLoginSession(identity)
assert.NoError(t, err, "failed deletion must roll back the account version and preserve sessions")
}
})
}
}
func TestSecurityAccountDeletionConcurrentRequestsHaveOneWinner(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}, "password")
start := make(chan struct{})
responses := make(chan string, 2)
for range 2 {
go func() {
<-start
response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
responses <- response.Body.String()
}()
}
close(start)
succeeded := 0
for range 2 {
var response securityEnrollmentResponse
require.NoError(t, common.UnmarshalJsonStr(<-responses, &response))
if response.Success {
succeeded++
}
}
assert.Equal(t, 1, succeeded)
var deleted model.User
require.NoError(t, model.DB.Unscoped().First(&deleted, user.Id).Error)
assert.True(t, deleted.DeletedAt.Valid)
assert.Equal(t, identity.UserAuthVersion+1, deleted.AuthVersion)
}
type securityMailbox struct {
mutex sync.Mutex
mail map[string][]string
}
// Use a real local SMTP boundary so delivery, failure and code handling are
// exercised without exporting test-only mail hooks from production packages.
func newSecurityMailbox(t *testing.T) *securityMailbox {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
mailbox := &securityMailbox{mail: map[string][]string{}}
previousServer, previousPort := common.SMTPServer, common.SMTPPort
previousAccount, previousFrom, previousToken := common.SMTPAccount, common.SMTPFrom, common.SMTPToken
previousSSL, previousTLS := common.SMTPSSLEnabled, common.SMTPStartTLSEnabled
common.SMTPServer, common.SMTPPort = "127.0.0.1", listener.Addr().(*net.TCPAddr).Port
common.SMTPAccount, common.SMTPFrom, common.SMTPToken = "", "sender@example.com", ""
common.SMTPSSLEnabled, common.SMTPStartTLSEnabled = false, false
done := make(chan struct{})
go func() {
defer close(done)
for {
connection, err := listener.Accept()
if err != nil {
return
}
mailbox.receive(connection)
}
}()
t.Cleanup(func() {
_ = listener.Close()
<-done
common.SMTPServer, common.SMTPPort = previousServer, previousPort
common.SMTPAccount, common.SMTPFrom, common.SMTPToken = previousAccount, previousFrom, previousToken
common.SMTPSSLEnabled, common.SMTPStartTLSEnabled = previousSSL, previousTLS
})
return mailbox
}
func (mailbox *securityMailbox) receive(connection net.Conn) {
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(10 * time.Second))
client := textproto.NewConn(connection)
if client.PrintfLine("220 localhost ESMTP") != nil {
return
}
receiver := ""
for {
line, err := client.ReadLine()
if err != nil {
return
}
switch {
case strings.HasPrefix(line, "RCPT TO:"):
receiver = strings.TrimSuffix(strings.TrimPrefix(line, "RCPT TO:<"), ">")
case line == "DATA":
if client.PrintfLine("354 Send message") != nil {
return
}
message, err := io.ReadAll(client.DotReader())
if err != nil {
return
}
mailbox.mutex.Lock()
mailbox.mail[receiver] = append(mailbox.mail[receiver], string(message))
mailbox.mutex.Unlock()
case line == "QUIT":
_ = client.PrintfLine("221 Goodbye")
return
}
if client.PrintfLine("250 OK") != nil {
return
}
}
}
func (mailbox *securityMailbox) code(t *testing.T, receiver string) string {
t.Helper()
mailbox.mutex.Lock()
defer mailbox.mutex.Unlock()
for index := len(mailbox.mail[receiver]) - 1; index >= 0; index-- {
match := regexp.MustCompile(`<strong>([0-9]{6})</strong>`).FindStringSubmatch(mailbox.mail[receiver][index])
if len(match) == 2 {
return match[1]
}
}
t.Fatalf("no verification email delivered to %s", receiver)
return ""
}
func startSecurityEmailBinding(t *testing.T, identity service.AuthIdentity, email, method string) service.EmailBindingData {
t.Helper()
context, err := common.Marshal(service.AccountBindingContext{Provider: "email", Email: email})
require.NoError(t, err)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, method)
request, err := common.Marshal(map[string]string{"email": email})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/oauth/email/bind/start", string(request), proof, identity, EmailBindStart)
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
require.True(t, body.Success, response.Body.String())
var flow service.EmailBindingData
require.NoError(t, common.Unmarshal(body.Data, &flow))
require.NotEmpty(t, flow.FlowToken)
return flow
}
func TestSecurityAccountRequiresProofBeforeMutation(t *testing.T) {
for _, test := range []struct {
name, path, body string
handler gin.HandlerFunc
}{
{"password", "/api/user/self", `{"password":"account-password!42","original_password":"enrollment-password"}`, UpdateSelf},
{"oauth binding", "/api/oauth/state", `{"provider":"security-account-test","intent":"bind"}`, GenerateOAuthCode},
} {
t.Run(test.name, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
oauth.Register("security-account-test", &authFlowTestOAuthProvider{})
t.Cleanup(func() { oauth.Unregister("security-account-test") })
response := securityEnrollmentRequest(http.MethodPost, test.path, test.body, "", identity, test.handler)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_PROOF_REQUIRED", result.Code)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash("enrollment-password", stored.Password))
var flows int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error)
assert.Zero(t, flows)
})
}
}
func TestSecurityAccountPasswordRequiresCurrentPassword(t *testing.T) {
for _, test := range []struct {
name, original string
success bool
}{
{"missing", "", false},
{"wrong", "wrong-password", false},
{"correct", "enrollment-password", true},
} {
t.Run(test.name, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordChange}, service.VerificationMethodPassword)
request, err := common.Marshal(map[string]string{"password": "password123", "original_password": test.original})
require.NoError(t, err)
response := securityEnrollmentRequest(http.MethodPut, "/api/user/self", string(request), proof, identity, UpdateSelf)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, test.success, result.Success, response.Body.String())
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
if test.success {
assert.True(t, common.ValidatePasswordAndHash("password123", stored.Password))
assert.True(t, strings.HasPrefix(stored.Password, "$argon2id$"))
} else {
assert.Equal(t, "CURRENT_PASSWORD_INVALID", result.Code)
assert.Equal(t, user.Password, stored.Password)
repeated := securityEnrollmentRequest(http.MethodPut, "/api/user/self", string(request), proof, identity, UpdateSelf)
require.NoError(t, common.Unmarshal(repeated.Body.Bytes(), &result))
assert.Equal(t, "SECURITY_PROOF_CONSUMED", result.Code)
}
})
}
}
func TestSecurityAccountProfileReadsPasswordStatusInOneQuery(t *testing.T) {
for _, hasPassword := range []bool{true, false} {
name := "password set"
if !hasPassword {
name = "password unset"
}
t.Run(name, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
updates := map[string]any{"access_token": "private-profile-token", "remark": "private-profile-remark"}
if !hasPassword {
updates["password"] = ""
}
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", user.Id).Updates(updates).Error)
queries := 0
require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("profile_query_count", func(tx *gorm.DB) {
queries++
}))
profile, err := model.GetSelfUserById(user.Id)
require.NoError(t, err)
assert.Equal(t, 1, queries)
assert.Equal(t, hasPassword, profile.HasPassword)
assert.Empty(t, profile.Password)
assert.Nil(t, profile.AccessToken)
assert.Empty(t, profile.Remark)
assert.Equal(t, user.AuthVersion, profile.AuthVersion)
assert.Equal(t, user.Group, profile.Group)
require.NoError(t, model.DB.Callback().Query().Remove("profile_query_count"))
response := securityEnrollmentRequest(http.MethodGet, "/api/user/self", "", "", identity, GetSelf)
var result struct {
Success bool `json:"success"`
Data map[string]any `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.Equal(t, hasPassword, result.Data["has_password"])
assert.NotContains(t, result.Data, "password")
assert.NotContains(t, result.Data, "access_token")
assert.NotContains(t, result.Data, "remark")
bundle, err := service.CreateLoginSession(user.Id, "profile-test", "127.0.0.1", "profile-test")
require.NoError(t, err)
_, refreshed, err := service.RefreshLoginSession(bundle.RefreshToken, "", "127.0.0.1", "profile-test")
require.NoError(t, err)
assert.Equal(t, hasPassword, refreshed.HasPassword)
assert.Empty(t, refreshed.Password)
assert.Nil(t, refreshed.AccessToken)
})
}
}
func TestSecurityAccountProfileUpdateDoesNotRequireProof(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
response := securityEnrollmentRequest(http.MethodPut, "/api/user/self", `{"display_name":"Updated"}`, "", identity, UpdateSelf)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Equal(t, "Updated", stored.DisplayName)
assert.Equal(t, user.Password, stored.Password)
}
func TestSecurityAccountLongUnicodePasswordAndSessionRotation(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-device")
require.NoError(t, err)
otherIdentity, err := service.ParseAccessToken(other.AccessToken)
require.NoError(t, err)
password := strings.Repeat("安全🔒 ", 24)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordChange}, service.VerificationMethodPassword)
body, err := common.Marshal(map[string]string{"password": password, "original_password": "enrollment-password"})
require.NoError(t, err)
response := securityEnrollmentRequest(http.MethodPut, "/api/user/self", string(body), proof, identity, UpdateSelf)
var result struct {
Success bool `json:"success"`
Data struct {
AccessToken string `json:"access_token"`
HasPassword bool `json:"has_password"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.True(t, result.Data.HasPassword)
currentIdentity, err := service.ParseAccessToken(result.Data.AccessToken)
require.NoError(t, err)
_, _, err = service.ValidateLoginSession(currentIdentity)
require.NoError(t, err)
_, _, err = service.ValidateLoginSession(otherIdentity)
require.Error(t, err)
login := model.User{Username: user.Username, Password: password}
require.NoError(t, login.ValidateAndFill())
assert.False(t, common.ValidatePasswordAndHash(strings.TrimSpace(password), login.Password))
assert.False(t, common.ValidatePasswordAndHash("enrollment-password", login.Password))
}
func TestSecurityAccountPasswordPolicyAndHashCompatibility(t *testing.T) {
t.Setenv("ACCOUNT_PASSWORD_HASH_ALGORITHM", "argon2id")
for _, test := range []struct {
name, password string
valid bool
}{
{"common password accepted", "password123", true},
{"short", "seven77", false},
{"too long", strings.Repeat("界", 129), false},
{"long Unicode", strings.Repeat("界🔒 ", 40), true},
{"whitespace preserved", " phrase with whitespace ", true},
} {
t.Run(test.name, func(t *testing.T) {
hash, err := common.HashAccountPassword(test.password)
if !test.valid {
require.Error(t, err)
assert.Empty(t, hash)
return
}
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash(test.password, hash))
assert.False(t, common.ValidatePasswordAndHash(test.password+"x", hash))
})
}
legacy, err := common.Password2Hash("123456")
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash("123456", legacy), "login preserves historical passwords without applying new policy")
for _, invalid := range []string{"$argon2id$", "$argon2id$v=19$m=4294967295,t=2,p=1$bad$bad", "$argon2id$v=19$m=19456,t=2,p=1$bad$bad"} {
assert.False(t, common.ValidatePasswordAndHash("example-password", invalid))
}
}
func TestSecurityAccountEncryptedLongPasswordLogin(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
password := strings.Repeat("🔒", 128)
hash, err := common.HashAccountPassword(password)
require.NoError(t, err)
require.NoError(t, model.DB.Model(user).Update("password", hash).Error)
keyID, publicPEM := common.PasswordEncryptionPublicKey()
if keyID == "" {
privatePEM, err := common.GeneratePasswordEncryptionPrivateKey()
require.NoError(t, err)
require.NoError(t, common.LoadPasswordEncryptionPrivateKey(privatePEM))
keyID, publicPEM = common.PasswordEncryptionPublicKey()
}
block, _ := pem.Decode([]byte(publicPEM))
require.NotNil(t, block)
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
require.NoError(t, err)
publicKey, ok := parsed.(*rsa.PublicKey)
require.True(t, ok)
key, nonce := make([]byte, 32), make([]byte, 12)
_, err = rand.Read(key)
require.NoError(t, err)
_, err = rand.Read(nonce)
require.NoError(t, err)
wrappedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, key, []byte("password-v2"))
require.NoError(t, err)
aesBlock, err := aes.NewCipher(key)
require.NoError(t, err)
gcm, err := cipher.NewGCM(aesBlock)
require.NoError(t, err)
ciphertext := gcm.Seal(nil, nonce, []byte(password), []byte("password-v2:"+keyID))
parts := []string{"v2", base64.StdEncoding.EncodeToString(wrappedKey), base64.StdEncoding.EncodeToString(nonce), base64.StdEncoding.EncodeToString(ciphertext)}
encrypted := strings.Join(parts, ".")
common.PasswordLoginEncryptionEnabled = true
request, err := common.Marshal(LoginRequest{Username: user.Username, PasswordEncrypted: encrypted, EncryptionKeyID: keyID})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/login", string(request), "", identity, Login)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.Contains(t, string(result.Data), `"has_password":true`)
proof, err := service.VerifySecurityInput(identity, service.VerificationInput{Method: "password", Scope: service.VerificationScopePasswordChange, PasswordEncrypted: encrypted, EncryptionKeyID: keyID})
require.NoError(t, err)
assert.NotEmpty(t, proof.ProofToken)
ciphertext[len(ciphertext)-1] ^= 1
parts[3] = base64.StdEncoding.EncodeToString(ciphertext)
for _, invalid := range []string{strings.Join(parts, "."), "v2.bad.bad.bad", "v2." + strings.Repeat("a", 4096)} {
_, err := common.DecryptPassword(invalid, keyID)
assert.ErrorIs(t, err, common.ErrPasswordEncryptionInvalid)
}
_, err = common.DecryptPassword(encrypted, "wrong-key-id")
assert.ErrorIs(t, err, common.ErrPasswordEncryptionInvalid)
}
func TestSecurityAccountEmailConfirmationAndAudit(t *testing.T) {
for _, scenario := range []string{"first email", "replace without mfa", "replace with mfa"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
method, previous := service.VerificationMethodPassword, ""
if scenario != "first email" {
previous = "previous@example.com"
require.NoError(t, model.DB.Model(user).Update("email", previous).Error)
}
if scenario == "replace with mfa" {
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
method = service.VerificationMethodTwoFA
}
flow := startSecurityEmailBinding(t, identity, "New@Example.com", method)
assert.Equal(t, "new@example.com", flow.Email)
assert.Equal(t, scenario == "replace without mfa", flow.OldEmailRequired)
newCode, oldCode := mailbox.code(t, flow.Email), ""
_, state, err := model.GetEmailBinding(identity, flow.FlowToken)
require.NoError(t, err)
assert.NotEqual(t, newCode, state.NewCodeHash)
assert.True(t, common.ValidatePasswordAndHash(newCode, state.NewCodeHash))
if flow.OldEmailRequired {
oldCode = mailbox.code(t, previous)
_, err := service.FinishEmailBinding(identity, flow.FlowToken, newCode, "")
assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid)
stored, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, previous, stored.Email)
}
request, err := common.Marshal(map[string]string{"flow_token": flow.FlowToken, "new_code": newCode, "old_code": oldCode})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/oauth/email/bind", string(request), "", identity, EmailBind)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, flow.Email, stored.Email)
_, err = service.FinishEmailBinding(identity, flow.FlowToken, newCode, oldCode)
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
legacy := securityEnrollmentRequest("POST", "/api/oauth/email/bind", `{"email":"other@example.com","code":"123456"}`, "", identity, EmailBind)
assert.Contains(t, legacy.Body.String(), `"success":false`)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
require.NotEmpty(t, audits)
assert.False(t, audits[len(audits)-1].Success, "a rejected legacy request must be recorded as failed")
encoded, err := common.Marshal(audits)
require.NoError(t, err)
assert.Contains(t, string(encoded), "user.binding_bind")
assert.NotContains(t, string(encoded), flow.FlowToken)
assert.NotContains(t, string(encoded), newCode)
})
}
}
func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
flow := startSecurityEmailBinding(t, identity, "new@example.com", service.VerificationMethodPassword)
_, err := service.ResendAccountEmailBinding(identity, flow.FlowToken)
assert.ErrorIs(t, err, model.ErrEmailBindingResendWait)
_, err = service.FinishEmailBinding(identity, flow.FlowToken, "invalid", "")
assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid)
stored, state, err := model.GetEmailBinding(identity, flow.FlowToken)
require.NoError(t, err)
oldHash := state.NewCodeHash
state.ResendAt = time.Now().Add(-time.Second).Unix()
payload, err := common.Marshal(state)
require.NoError(t, err)
require.NoError(t, model.DB.Model(stored).Update("payload", string(payload)).Error)
replacement, err := service.ResendAccountEmailBinding(identity, flow.FlowToken)
require.NoError(t, err)
assert.Equal(t, flow.ExpiresAt, replacement.ExpiresAt)
_, state, err = model.GetEmailBinding(identity, flow.FlowToken)
require.NoError(t, err)
assert.Equal(t, 1, state.FailedAttempts)
assert.NotEqual(t, oldHash, state.NewCodeHash)
assert.True(t, common.ValidatePasswordAndHash(mailbox.code(t, flow.Email), state.NewCodeHash))
for attempt := 2; attempt <= model.EmailBindingMaxAttempts; attempt++ {
_, err = service.FinishEmailBinding(identity, flow.FlowToken, "invalid", "")
if attempt < model.EmailBindingMaxAttempts {
assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid)
} else {
assert.ErrorIs(t, err, model.ErrEmailBindingLocked)
}
}
_, err = service.FinishEmailBinding(identity, flow.FlowToken, mailbox.code(t, flow.Email), "")
assert.ErrorIs(t, err, model.ErrEmailBindingLocked)
}
func TestSecurityAccountEmailConcurrentClaimsHaveOneOwner(t *testing.T) {
user, firstIdentity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
other := &model.User{Username: "other-user", AffCode: "other-account", Group: "default", Password: user.Password, Status: common.UserStatusEnabled, Role: common.RoleCommonUser, AuthVersion: 1}
require.NoError(t, model.DB.Create(other).Error)
require.NoError(t, model.PublishUserAuthCache(other.Id))
bundle, err := service.CreateLoginSession(other.Id, "password", "127.0.0.1", "other-user")
require.NoError(t, err)
secondIdentity, err := service.ParseAccessToken(bundle.AccessToken)
require.NoError(t, err)
start, results := make(chan struct{}), make(chan error, 2)
for _, identity := range []service.AuthIdentity{firstIdentity, secondIdentity} {
flow := startSecurityEmailBinding(t, identity, "shared@example.com", service.VerificationMethodPassword)
code := mailbox.code(t, flow.Email)
go func(identity service.AuthIdentity, token, code string) {
<-start
_, err := service.FinishEmailBinding(identity, token, code, "")
results <- err
}(identity, flow.FlowToken, code)
}
close(start)
first, second := <-results, <-results
assert.NotEqual(t, first == nil, second == nil, "only one account may claim the address: %v / %v", first, second)
var owners int64
require.NoError(t, model.DB.Model(&model.User{}).Where("email = ?", "shared@example.com").Count(&owners).Error)
assert.EqualValues(t, 1, owners)
}
func TestSecurityAccountEmailRejectsChangedAuthorization(t *testing.T) {
for _, scenario := range []string{"other session", "expired", "email changed", "mfa enabled", "notification failure"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
flow := startSecurityEmailBinding(t, identity, "new@example.com", service.VerificationMethodPassword)
code := mailbox.code(t, flow.Email)
stored, _, err := model.GetEmailBinding(identity, flow.FlowToken)
require.NoError(t, err)
switch scenario {
case "other session":
identity.SessionID = "different-session"
case "expired":
require.NoError(t, model.DB.Model(stored).Update("expires_at", time.Now().Add(-time.Second)).Error)
case "email changed":
require.NoError(t, model.DB.Model(user).Update("email", "changed@example.com").Error)
case "mfa enabled":
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
case "notification failure":
common.SMTPServer = ""
}
request, err := common.Marshal(map[string]string{"flow_token": flow.FlowToken, "new_code": code})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/oauth/email/bind", string(request), "", identity, EmailBind)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, scenario == "notification failure", result.Success, response.Body.String())
if scenario == "notification failure" {
assert.Contains(t, string(result.Data), `"notification_warning":true`)
}
current, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, scenario == "notification failure", current.Email == flow.Email)
})
}
}
func TestSecurityAccountOAuthBindingRejectsInvalidFlow(t *testing.T) {
for _, scenario := range []string{"success and replay", "other session", "wrong provider", "expired", "old flow", "mfa enabled"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
oauth.Register("account-oauth", &enrollmentOAuthProvider{externalID: "new-binding"})
oauth.Register("other-oauth", &enrollmentOAuthProvider{externalID: "wrong-binding"})
t.Cleanup(func() { oauth.Unregister("account-oauth"); oauth.Unregister("other-oauth") })
operation := service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"account-oauth"}`)}
proof := issueSecurityEnrollmentProof(t, identity, operation, service.VerificationMethodPassword)
response := securityEnrollmentRequest("POST", "/api/oauth/state", `{"provider":"account-oauth","intent":"bind"}`, proof, identity, GenerateOAuthCode)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
var started struct {
FlowToken string `json:"flow_token"`
}
require.NoError(t, common.Unmarshal(result.Data, &started))
flow, err := model.GetAuthFlow(started.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
require.NoError(t, err)
provider := "account-oauth"
switch scenario {
case "other session":
identity.SessionID = "different-session"
case "wrong provider":
provider = "other-oauth"
case "expired":
require.NoError(t, model.DB.Model(flow).Update("expires_at", time.Now().Add(-time.Second)).Error)
case "old flow":
require.NoError(t, model.DB.Model(flow).Update("payload", "{}").Error)
case "mfa enabled":
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
}
handler := func(c *gin.Context) { c.Params = gin.Params{{Key: "provider", Value: provider}}; HandleOAuth(c) }
path := "/api/oauth/" + provider + "?state=" + started.FlowToken + "&code=provider-code"
response = securityEnrollmentRequest("GET", path, "", "", identity, handler)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, scenario == "success and replay", result.Success, response.Body.String())
current, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, scenario == "success and replay", current.GitHubId == "new-binding")
if result.Success {
replay := securityEnrollmentRequest("GET", path, "", "", identity, handler)
assert.Contains(t, replay.Body.String(), `"success":false`)
}
})
}
}
func TestSecurityAccountFirstPasswordRace(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.Model(user).Update("password", "").Error)
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "existing-key", PublicKey: "public-key"}).Error)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordSet}, service.VerificationMethodPasskey)
request := `{"password":"initial-account-password!42"}`
response := securityEnrollmentRequest("PUT", "/api/user/self", request, proof, identity, UpdateSelf)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
user, identity = setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.Model(user).Update("password", "").Error)
start, results := make(chan struct{}), make(chan error, 2)
for _, password := range []string{"first-password!42", "second-password!42"} {
go func(password string) {
<-start
results <- model.ChangeUserPassword(identity, &model.User{Id: user.Id, Password: password}, true)
}(password)
}
close(start)
first, second := <-results, <-results
assert.NotEqual(t, first == nil, second == nil, "only one concurrent first password may succeed: %v / %v", first, second)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash("first-password!42", stored.Password) || common.ValidatePasswordAndHash("second-password!42", stored.Password))
}
func TestSecurityAccountUnbindPreservesUsableLoginMethod(t *testing.T) {
for _, scenario := range []string{"password", "passkey", "email only", "twofa only", "disabled password", "disabled passkey"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
previousPasswordEnabled := common.PasswordLoginEnabled
t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordEnabled })
common.PasswordLoginEnabled = scenario != "disabled password"
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 31, ProviderUserId: "linked-subject"}).Error)
if scenario != "password" && scenario != "disabled password" {
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "email": "verified@example.com"}).Error)
}
if scenario == "passkey" || scenario == "disabled passkey" {
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "existing-key", PublicKey: "public-key"}).Error)
system_setting.GetPasskeySettings().Enabled = scenario == "passkey"
}
if scenario == "twofa only" {
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
}
err := service.UnbindAccountOAuth(identity, 31)
if scenario == "password" || scenario == "passkey" {
require.NoError(t, err)
} else {
assert.ErrorIs(t, err, model.ErrLastLoginMethod)
var binding model.UserOAuthBinding
require.NoError(t, model.DB.Where("user_id = ? AND provider_id = ?", user.Id, 31).First(&binding).Error)
}
})
}
}
File diff suppressed because it is too large. Load diff
+174
View File
@@ -0,0 +1,174 @@
package controller
import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
type Setup struct {
Status bool `json:"status"`
RootInit bool `json:"root_init"`
DatabaseType string `json:"database_type"`
}
type SetupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
ConfirmPassword string `json:"confirmPassword"`
SelfUseModeEnabled bool `json:"SelfUseModeEnabled"`
DemoSiteEnabled bool `json:"DemoSiteEnabled"`
}
func GetSetup(c *gin.Context) {
setup := Setup{
Status: constant.Setup,
}
if constant.Setup {
c.JSON(200, gin.H{
"success": true,
"data": setup,
})
return
}
setup.RootInit = model.RootUserExists()
setup.DatabaseType = string(common.MainDatabaseType())
c.JSON(200, gin.H{
"success": true,
"data": setup,
})
}
func PostSetup(c *gin.Context) {
// Check if setup is already completed
if constant.Setup {
c.JSON(200, gin.H{
"success": false,
"message": "系统已经初始化完成",
})
return
}
// Check if root user already exists
rootExists := model.RootUserExists()
var req SetupRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "请求参数有误",
})
return
}
// If root doesn't exist, validate and create admin account
if !rootExists {
// Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 {
c.JSON(200, gin.H{
"success": false,
"message": "用户名长度不能超过12个字符",
})
return
}
// Validate password
if req.Password != req.ConfirmPassword {
c.JSON(200, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
return
}
if err := common.ValidateNewAccountPassword(req.Password); err != nil {
c.JSON(200, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// Create root user
hashedPassword, err := common.HashAccountPassword(req.Password)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "系统错误: " + err.Error(),
})
return
}
rootUser := model.User{
Username: req.Username,
Password: hashedPassword,
Role: common.RoleRootUser,
Status: common.UserStatusEnabled,
DisplayName: "Root User",
AccessToken: nil,
Quota: 100000000,
}
err = model.DB.Create(&rootUser).Error
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "创建管理员账号失败: " + err.Error(),
})
return
}
}
// Set operation modes
operation_setting.SelfUseModeEnabled = req.SelfUseModeEnabled
operation_setting.DemoSiteEnabled = req.DemoSiteEnabled
// Save operation modes to database for persistence
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "保存自用模式设置失败: " + err.Error(),
})
return
}
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "保存演示站点模式设置失败: " + err.Error(),
})
return
}
// Update setup status
constant.Setup = true
setup := model.Setup{
Version: common.Version,
InitializedAt: time.Now().Unix(),
}
err = model.DB.Create(&setup).Error
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "系统初始化失败: " + err.Error(),
})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "系统初始化成功",
})
}
func boolToString(b bool) string {
if b {
return "true"
}
return "false"
}
+547
View File
@@ -0,0 +1,547 @@
package controller
import (
"fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ---- Shared types ----
type SubscriptionPlanDTO struct {
Plan model.SubscriptionPlan `json:"plan"`
}
type BillingPreferenceRequest struct {
BillingPreference string `json:"billing_preference"`
}
type SubscriptionBalancePayRequest struct {
PlanId int `json:"plan_id"`
}
// ---- User APIs ----
func GetSubscriptionPlans(c *gin.Context) {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiSuccess(c, []SubscriptionPlanDTO{})
return
}
var plans []model.SubscriptionPlan
if err := model.DB.Where("enabled = ?", true).Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
common.ApiError(c, err)
return
}
result := make([]SubscriptionPlanDTO, 0, len(plans))
for _, p := range plans {
p.NormalizeDefaults()
result = append(result, SubscriptionPlanDTO{
Plan: p,
})
}
common.ApiSuccess(c, result)
}
func GetSubscriptionSelf(c *gin.Context) {
userId := c.GetInt("id")
settingMap, _ := model.GetUserSetting(userId, false)
pref := common.NormalizeBillingPreference(settingMap.BillingPreference)
// Get all subscriptions (including expired)
allSubscriptions, err := model.GetAllUserSubscriptions(userId)
if err != nil {
allSubscriptions = []model.SubscriptionSummary{}
}
// Get active subscriptions for backward compatibility
activeSubscriptions, err := model.GetAllActiveUserSubscriptions(userId)
if err != nil {
activeSubscriptions = []model.SubscriptionSummary{}
}
common.ApiSuccess(c, gin.H{
"billing_preference": pref,
"subscriptions": activeSubscriptions, // all active subscriptions
"all_subscriptions": allSubscriptions, // all subscriptions including expired
})
}
func UpdateSubscriptionPreference(c *gin.Context) {
userId := c.GetInt("id")
var req BillingPreferenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
pref := common.NormalizeBillingPreference(req.BillingPreference)
user, err := model.GetUserById(userId, true)
if err != nil {
common.ApiError(c, err)
return
}
current := user.GetSetting()
current.BillingPreference = pref
if err := model.UpdateUserSetting(user.Id, current); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{"billing_preference": pref})
}
func SubscriptionRequestBalancePay(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
userId := c.GetInt("id")
var req SubscriptionBalancePayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
if err := model.PurchaseSubscriptionWithBalance(userId, req.PlanId); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
// ---- Admin APIs ----
func AdminListSubscriptionPlans(c *gin.Context) {
var plans []model.SubscriptionPlan
if err := model.DB.Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
common.ApiError(c, err)
return
}
result := make([]SubscriptionPlanDTO, 0, len(plans))
for _, p := range plans {
p.NormalizeDefaults()
result = append(result, SubscriptionPlanDTO{
Plan: p,
})
}
common.ApiSuccess(c, result)
}
type AdminUpsertSubscriptionPlanRequest struct {
Plan model.SubscriptionPlan `json:"plan"`
}
func AdminCreateSubscriptionPlan(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req AdminUpsertSubscriptionPlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
req.Plan.Id = 0
if strings.TrimSpace(req.Plan.Title) == "" {
common.ApiErrorMsg(c, "套餐标题不能为空")
return
}
if req.Plan.PriceAmount < 0 {
common.ApiErrorMsg(c, "价格不能为负数")
return
}
if req.Plan.PriceAmount > 9999 {
common.ApiErrorMsg(c, "价格不能超过9999")
return
}
if req.Plan.Currency == "" {
req.Plan.Currency = "USD"
}
req.Plan.Currency = "USD"
if req.Plan.AllowBalancePay == nil {
req.Plan.AllowBalancePay = common.GetPointer(true)
}
if req.Plan.AllowWalletOverflow == nil {
req.Plan.AllowWalletOverflow = common.GetPointer(true)
}
if req.Plan.DurationUnit == "" {
req.Plan.DurationUnit = model.SubscriptionDurationMonth
}
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
req.Plan.DurationValue = 1
}
if req.Plan.MaxPurchasePerUser < 0 {
common.ApiErrorMsg(c, "购买上限不能为负数")
return
}
if req.Plan.TotalAmount < 0 {
common.ApiErrorMsg(c, "总额度不能为负数")
return
}
req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup)
if req.Plan.UpgradeGroup != "" {
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok {
common.ApiErrorMsg(c, "升级分组不存在")
return
}
}
req.Plan.DowngradeGroup = strings.TrimSpace(req.Plan.DowngradeGroup)
if req.Plan.DowngradeGroup != "" {
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.DowngradeGroup]; !ok {
common.ApiErrorMsg(c, "降级分组不存在")
return
}
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
return
}
err := model.DB.Create(&req.Plan).Error
if err != nil {
common.ApiError(c, err)
return
}
model.InvalidateSubscriptionPlanCache(req.Plan.Id)
common.ApiSuccess(c, req.Plan)
}
func AdminUpdateSubscriptionPlan(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
common.ApiErrorMsg(c, "无效的ID")
return
}
var req AdminUpsertSubscriptionPlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
if strings.TrimSpace(req.Plan.Title) == "" {
common.ApiErrorMsg(c, "套餐标题不能为空")
return
}
if req.Plan.PriceAmount < 0 {
common.ApiErrorMsg(c, "价格不能为负数")
return
}
if req.Plan.PriceAmount > 9999 {
common.ApiErrorMsg(c, "价格不能超过9999")
return
}
req.Plan.Id = id
if req.Plan.Currency == "" {
req.Plan.Currency = "USD"
}
req.Plan.Currency = "USD"
if req.Plan.DurationUnit == "" {
req.Plan.DurationUnit = model.SubscriptionDurationMonth
}
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
req.Plan.DurationValue = 1
}
if req.Plan.MaxPurchasePerUser < 0 {
common.ApiErrorMsg(c, "购买上限不能为负数")
return
}
if req.Plan.TotalAmount < 0 {
common.ApiErrorMsg(c, "总额度不能为负数")
return
}
req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup)
if req.Plan.UpgradeGroup != "" {
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok {
common.ApiErrorMsg(c, "升级分组不存在")
return
}
}
req.Plan.DowngradeGroup = strings.TrimSpace(req.Plan.DowngradeGroup)
if req.Plan.DowngradeGroup != "" {
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.DowngradeGroup]; !ok {
common.ApiErrorMsg(c, "降级分组不存在")
return
}
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
return
}
err := model.DB.Transaction(func(tx *gorm.DB) error {
// update plan (allow zero values updates with map)
updateMap := map[string]any{
"title": req.Plan.Title,
"subtitle": req.Plan.Subtitle,
"price_amount": req.Plan.PriceAmount,
"currency": req.Plan.Currency,
"duration_unit": req.Plan.DurationUnit,
"duration_value": req.Plan.DurationValue,
"custom_seconds": req.Plan.CustomSeconds,
"enabled": req.Plan.Enabled,
"sort_order": req.Plan.SortOrder,
"stripe_price_id": req.Plan.StripePriceId,
"creem_product_id": req.Plan.CreemProductId,
"waffo_pancake_product_id": req.Plan.WaffoPancakeProductId,
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
"total_amount": req.Plan.TotalAmount,
"upgrade_group": req.Plan.UpgradeGroup,
"downgrade_group": req.Plan.DowngradeGroup,
"quota_reset_period": req.Plan.QuotaResetPeriod,
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
"updated_at": common.GetTimestamp(),
}
if req.Plan.AllowBalancePay != nil {
updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay
}
if req.Plan.AllowWalletOverflow != nil {
updateMap["allow_wallet_overflow"] = *req.Plan.AllowWalletOverflow
}
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
return err
}
return nil
})
if err != nil {
common.ApiError(c, err)
return
}
model.InvalidateSubscriptionPlanCache(id)
common.ApiSuccess(c, nil)
}
type AdminUpdateSubscriptionPlanStatusRequest struct {
Enabled *bool `json:"enabled"`
}
func AdminUpdateSubscriptionPlanStatus(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
common.ApiErrorMsg(c, "无效的ID")
return
}
var req AdminUpdateSubscriptionPlanStatusRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil {
common.ApiErrorMsg(c, "参数错误")
return
}
if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", *req.Enabled).Error; err != nil {
common.ApiError(c, err)
return
}
model.InvalidateSubscriptionPlanCache(id)
common.ApiSuccess(c, nil)
}
type AdminBindSubscriptionRequest struct {
UserId int `json:"user_id"`
PlanId int `json:"plan_id"`
}
func AdminBindSubscription(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req AdminBindSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil || req.UserId <= 0 || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
msg, err := model.AdminBindSubscription(req.UserId, req.PlanId, "")
if err != nil {
common.ApiError(c, err)
return
}
if msg != "" {
common.ApiSuccess(c, gin.H{"message": msg})
return
}
common.ApiSuccess(c, nil)
}
// ---- Admin: user subscription management ----
func AdminListUserSubscriptions(c *gin.Context) {
userId, _ := strconv.Atoi(c.Param("id"))
if userId <= 0 {
common.ApiErrorMsg(c, "无效的用户ID")
return
}
subs, err := model.GetAllUserSubscriptions(userId)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, subs)
}
type AdminCreateUserSubscriptionRequest struct {
PlanId int `json:"plan_id"`
}
type AdminResetSubscriptionRequest struct {
PlanId int `json:"plan_id"`
AdvanceResetTime *bool `json:"advance_reset_time"`
}
func resolveAdvanceResetTime(value *bool) bool {
if value == nil {
return true
}
return *value
}
func recordSubscriptionResetUserLogs(c *gin.Context, result *model.SubscriptionResetResult, adminInfo *model.AuditAdminInfo) {
if result == nil || result.ResetCount == 0 {
return
}
content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId)
for _, userId := range result.AffectedUserIds {
model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, nil, c)
}
}
// AdminCreateUserSubscription creates a new user subscription from a plan (no payment).
func AdminCreateUserSubscription(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
userId, _ := strconv.Atoi(c.Param("id"))
if userId <= 0 {
common.ApiErrorMsg(c, "无效的用户ID")
return
}
var req AdminCreateUserSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
msg, err := model.AdminBindSubscription(userId, req.PlanId, "")
if err != nil {
common.ApiError(c, err)
return
}
if msg != "" {
common.ApiSuccess(c, gin.H{"message": msg})
return
}
common.ApiSuccess(c, nil)
}
func AdminResetUserSubscriptionsByPlan(c *gin.Context) {
userId, _ := strconv.Atoi(c.Param("id"))
if userId <= 0 {
common.ApiErrorMsg(c, "无效的用户ID")
return
}
var req AdminResetSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
if req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime)
result, err := model.AdminResetUserSubscriptionsByPlan(userId, req.PlanId, advanceResetTime)
if err != nil {
common.ApiError(c, err)
return
}
recordSubscriptionResetUserLogs(c, result, auditOperatorInfo(c))
recordManageAuditFor(c, userId, "subscription.user_plan_reset", map[string]any{
"target_user_id": userId,
"plan_id": result.PlanId,
"plan_title": result.PlanTitle,
"reset_count": result.ResetCount,
"user_count": result.UserCount,
"advance_reset_time": result.AdvanceResetTime,
})
common.ApiSuccess(c, result)
}
func AdminResetPlanSubscriptions(c *gin.Context) {
planId, _ := strconv.Atoi(c.Param("id"))
if planId <= 0 {
common.ApiErrorMsg(c, "无效的ID")
return
}
var req AdminResetSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime)
result, err := model.AdminResetPlanSubscriptions(planId, advanceResetTime)
if err != nil {
common.ApiError(c, err)
return
}
recordSubscriptionResetUserLogs(c, result, auditOperatorInfo(c))
common.SysLog(fmt.Sprintf("admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t",
result.PlanId, result.ResetCount, result.UserCount, result.AdvanceResetTime))
recordManageAudit(c, "subscription.plan_reset", map[string]any{
"plan_id": result.PlanId,
"plan_title": result.PlanTitle,
"reset_count": result.ResetCount,
"user_count": result.UserCount,
"advance_reset_time": result.AdvanceResetTime,
})
common.ApiSuccess(c, result)
}
// AdminInvalidateUserSubscription cancels a user subscription immediately.
func AdminInvalidateUserSubscription(c *gin.Context) {
subId, _ := strconv.Atoi(c.Param("id"))
if subId <= 0 {
common.ApiErrorMsg(c, "无效的订阅ID")
return
}
msg, err := model.AdminInvalidateUserSubscription(subId)
if err != nil {
common.ApiError(c, err)
return
}
if msg != "" {
common.ApiSuccess(c, gin.H{"message": msg})
return
}
common.ApiSuccess(c, nil)
}
// AdminDeleteUserSubscription hard-deletes a user subscription.
func AdminDeleteUserSubscription(c *gin.Context) {
subId, _ := strconv.Atoi(c.Param("id"))
if subId <= 0 {
common.ApiErrorMsg(c, "无效的订阅ID")
return
}
msg, err := model.AdminDeleteUserSubscription(subId)
if err != nil {
common.ApiError(c, err)
return
}
if msg != "" {
common.ApiSuccess(c, gin.H{"message": msg})
return
}
common.ApiSuccess(c, nil)
}
+136
View File
@@ -0,0 +1,136 @@
package controller
import (
"bytes"
"fmt"
"io"
"net/http"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/thanhpk/randstr"
)
type SubscriptionCreemPayRequest struct {
PlanId int `json:"plan_id"`
}
func SubscriptionRequestCreemPay(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req SubscriptionCreemPayRequest
// Keep body for debugging consistency (like RequestCreemPay)
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付请求读取失败 error=%q", err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
plan, err := model.GetSubscriptionPlanById(req.PlanId)
if err != nil {
common.ApiError(c, err)
return
}
if !plan.Enabled {
common.ApiErrorMsg(c, "套餐未启用")
return
}
if plan.CreemProductId == "" {
common.ApiErrorMsg(c, "该套餐未配置 CreemProductId")
return
}
if setting.CreemWebhookSecret == "" && !setting.CreemTestMode {
common.ApiErrorMsg(c, "Creem Webhook 未配置")
return
}
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
if user == nil {
common.ApiErrorMsg(c, "用户不存在")
return
}
if plan.MaxPurchasePerUser > 0 {
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
if err != nil {
common.ApiError(c, err)
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
return
}
}
reference := "sub-creem-ref-" + randstr.String(6)
referenceId := "sub_ref_" + common.Sha1([]byte(reference+time.Now().String()+user.Username))
// create pending order first
order := &model.SubscriptionOrder{
UserId: userId,
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: referenceId,
PaymentMethod: model.PaymentMethodCreem,
PaymentProvider: model.PaymentProviderCreem,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
// Reuse Creem checkout generator by building a lightweight product reference.
currency := "USD"
switch operation_setting.GetGeneralSetting().QuotaDisplayType {
case operation_setting.QuotaDisplayTypeCNY:
currency = "CNY"
case operation_setting.QuotaDisplayTypeUSD:
currency = "USD"
default:
currency = "USD"
}
product := &CreemProduct{
ProductId: plan.CreemProductId,
Name: plan.Title,
Price: plan.PriceAmount,
Currency: currency,
Quota: 0,
}
checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, product, user.Email, user.Username)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付链接创建失败 trade_no=%s product_id=%s error=%q", referenceId, product.ProductId, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": checkoutUrl,
"order_id": referenceId,
},
})
}
+220
View File
@@ -0,0 +1,220 @@
package controller
import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/Calcium-Ion/go-epay/epay"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
type SubscriptionEpayPayRequest struct {
PlanId int `json:"plan_id"`
PaymentMethod string `json:"payment_method"`
}
func SubscriptionRequestEpay(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req SubscriptionEpayPayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
plan, err := model.GetSubscriptionPlanById(req.PlanId)
if err != nil {
common.ApiError(c, err)
return
}
if !plan.Enabled {
common.ApiErrorMsg(c, "套餐未启用")
return
}
if plan.PriceAmount < 0.01 {
common.ApiErrorMsg(c, "套餐金额过低")
return
}
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
common.ApiErrorMsg(c, "支付方式不存在")
return
}
userId := c.GetInt("id")
if plan.MaxPurchasePerUser > 0 {
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
if err != nil {
common.ApiError(c, err)
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
return
}
}
callBackAddress := service.GetCallbackAddress()
returnUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/return")
if err != nil {
common.ApiErrorMsg(c, "回调地址配置错误")
return
}
notifyUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/notify")
if err != nil {
common.ApiErrorMsg(c, "回调地址配置错误")
return
}
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("SUBUSR%dNO%s", userId, tradeNo)
client := GetEpayClient()
if client == nil {
common.ApiErrorMsg(c, "当前管理员未配置支付信息")
return
}
order := &model.SubscriptionOrder{
UserId: userId,
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: tradeNo,
PaymentMethod: req.PaymentMethod,
PaymentProvider: model.PaymentProviderEpay,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
common.ApiErrorMsg(c, "创建订单失败")
return
}
uri, params, err := client.Purchase(&epay.PurchaseArgs{
Type: req.PaymentMethod,
ServiceTradeNo: tradeNo,
Name: fmt.Sprintf("SUB:%s", plan.Title),
Money: strconv.FormatFloat(plan.PriceAmount, 'f', 2, 64),
Device: epay.PC,
NotifyUrl: notifyUrl,
ReturnUrl: returnUrl,
})
if err != nil {
_ = model.ExpireSubscriptionOrder(tradeNo, model.PaymentProviderEpay)
common.ApiErrorMsg(c, "拉起支付失败")
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
}
func SubscriptionEpayNotify(c *gin.Context) {
var params map[string]string
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
_, _ = c.Writer.Write([]byte("fail"))
return
}
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.PostForm.Get(t)
return r
}, map[string]string{})
} else {
// GET 请求:从 URL Query 解析参数
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.URL.Query().Get(t)
return r
}, map[string]string{})
}
if len(params) == 0 {
_, _ = c.Writer.Write([]byte("fail"))
return
}
client := GetEpayClient()
if client == nil {
_, _ = c.Writer.Write([]byte("fail"))
return
}
verifyInfo, err := client.Verify(params)
if err != nil || !verifyInfo.VerifyStatus {
_, _ = c.Writer.Write([]byte("fail"))
return
}
if verifyInfo.TradeStatus != epay.StatusTradeSuccess {
_, _ = c.Writer.Write([]byte("fail"))
return
}
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil {
_, _ = c.Writer.Write([]byte("fail"))
return
}
_, _ = c.Writer.Write([]byte("success"))
}
// SubscriptionEpayReturn handles browser return after payment.
// It verifies the payload and completes the order, then redirects to console.
func SubscriptionEpayReturn(c *gin.Context) {
var params map[string]string
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.PostForm.Get(t)
return r
}, map[string]string{})
} else {
// GET 请求:从 URL Query 解析参数
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.URL.Query().Get(t)
return r
}, map[string]string{})
}
if len(params) == 0 {
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
client := GetEpayClient()
if client == nil {
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
verifyInfo, err := client.Verify(params)
if err != nil || !verifyInfo.VerifyStatus {
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil {
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=success"))
return
}
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=pending"))
}
+142
View File
@@ -0,0 +1,142 @@
package controller
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/checkout/session"
"github.com/thanhpk/randstr"
)
type SubscriptionStripePayRequest struct {
PlanId int `json:"plan_id"`
}
func SubscriptionRequestStripePay(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req SubscriptionStripePayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
plan, err := model.GetSubscriptionPlanById(req.PlanId)
if err != nil {
common.ApiError(c, err)
return
}
if !plan.Enabled {
common.ApiErrorMsg(c, "套餐未启用")
return
}
if plan.StripePriceId == "" {
common.ApiErrorMsg(c, "该套餐未配置 StripePriceId")
return
}
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
common.ApiErrorMsg(c, "Stripe 未配置或密钥无效")
return
}
if setting.StripeWebhookSecret == "" {
common.ApiErrorMsg(c, "Stripe Webhook 未配置")
return
}
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
if user == nil {
common.ApiErrorMsg(c, "用户不存在")
return
}
if plan.MaxPurchasePerUser > 0 {
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
if err != nil {
common.ApiError(c, err)
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
return
}
}
reference := fmt.Sprintf("sub-stripe-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
referenceId := "sub_ref_" + common.Sha1([]byte(reference))
payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 订阅支付链接创建失败 trade_no=%s plan_id=%d error=%q", referenceId, plan.Id, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
order := &model.SubscriptionOrder{
UserId: userId,
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: referenceId,
PaymentMethod: model.PaymentMethodStripe,
PaymentProvider: model.PaymentProviderStripe,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"pay_link": payLink,
},
})
}
func genStripeSubscriptionLink(referenceId string, customerId string, email string, priceId string) (string, error) {
stripe.Key = setting.StripeApiSecret
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(paymentReturnPath("/wallet")),
CancelURL: stripe.String(paymentReturnPath("/wallet")),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceId),
Quantity: stripe.Int64(1),
},
},
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
}
if "" == customerId {
if "" != email {
params.CustomerEmail = stripe.String(email)
}
params.CustomerCreation = stripe.String(string(stripe.CheckoutSessionCustomerCreationAlways))
} else {
params.Customer = stripe.String(customerId)
}
result, err := session.New(params)
if err != nil {
return "", err
}
return result.URL, nil
}
@@ -0,0 +1,130 @@
package controller
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/thanhpk/randstr"
)
type SubscriptionWaffoPancakePayRequest struct {
PlanId int `json:"plan_id"`
}
func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req SubscriptionWaffoPancakePayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
plan, err := model.GetSubscriptionPlanById(req.PlanId)
if err != nil {
common.ApiError(c, err)
return
}
if !plan.Enabled {
common.ApiErrorMsg(c, "套餐未启用")
return
}
if strings.TrimSpace(plan.WaffoPancakeProductId) == "" {
common.ApiErrorMsg(c, "该套餐未配置 WaffoPancakeProductId")
return
}
// Plan targets its own Pancake product, so we only require credentials
// here — not the gateway-level WaffoPancakeProductID.
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" {
common.ApiErrorMsg(c, "Waffo Pancake 未配置或密钥无效")
return
}
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
if user == nil {
common.ApiErrorMsg(c, "用户不存在")
return
}
if plan.MaxPurchasePerUser > 0 {
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
if err != nil {
common.ApiError(c, err)
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
return
}
}
// WAFFO_PANCAKE_SUB- prefix (vs. wallet's WAFFO_PANCAKE-) drives webhook
// dispatch in WaffoPancakeWebhook.
tradeNo := fmt.Sprintf("WAFFO_PANCAKE_SUB-%d-%d-%s", userId, time.Now().UnixMilli(), randstr.String(6))
order := &model.SubscriptionOrder{
UserId: userId,
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: tradeNo,
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
expiresInSeconds := 45 * 60
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
ProductID: plan.WaffoPancakeProductId,
BuyerIdentity: service.WaffoPancakeBuyerIdentityFromUserID(user.Id),
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
Amount: decimal.NewFromFloat(plan.PriceAmount).StringFixed(2),
TaxCategory: "saas",
},
BuyerEmail: getWaffoPancakeBuyerEmail(user),
ExpiresInSeconds: &expiresInSeconds,
OrderMerchantExternalID: tradeNo,
})
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
order.Status = common.TopUpStatusFailed
_ = order.Update()
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建成功 user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f", userId, plan.Id, tradeNo, session.SessionID, plan.PriceAmount))
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": session.CheckoutURL,
"session_id": session.SessionID,
"expires_at": session.ExpiresAt,
"order_id": tradeNo,
"token": session.Token,
"token_expires_at": session.TokenExpiresAt,
},
})
}
+65
View File
@@ -0,0 +1,65 @@
package controller
import (
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
func ListSystemInstances(c *gin.Context) {
instances, err := model.ListSystemInstances()
if err != nil {
common.ApiError(c, err)
return
}
now := common.GetTimestamp()
responses := make([]model.SystemInstanceResponse, 0, len(instances))
for _, instance := range instances {
responses = append(responses, instance.ToResponse(now))
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": responses,
})
}
func DeleteStaleSystemInstances(c *gin.Context) {
deletedCount, err := model.DeleteStaleSystemInstances(common.GetTimestamp())
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"deleted_count": deletedCount,
})
}
func DeleteStaleSystemInstance(c *gin.Context) {
nodeName := c.Param("node_name")
if strings.TrimSpace(nodeName) == "" {
common.ApiErrorMsg(c, "node name is required")
return
}
deleted, err := model.DeleteStaleSystemInstance(nodeName, common.GetTimestamp())
if err != nil {
common.ApiError(c, err)
return
}
if !deleted {
common.ApiErrorMsg(c, "instance is not stale or no longer exists")
return
}
common.ApiSuccess(c, gin.H{
"deleted_count": 1,
})
}
+117
View File
@@ -0,0 +1,117 @@
package controller
import (
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func CreateLogCleanupSystemTask(c *gin.Context) {
targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64)
if targetTimestamp == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "target timestamp is required",
})
return
}
task, err := service.StartLogCleanupTask(targetTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": task.ToResponse(),
})
}
func GetCurrentSystemTask(c *gin.Context) {
taskType := c.Query("type")
if taskType == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "type is required",
})
return
}
task, err := model.GetActiveSystemTask(taskType)
if err != nil {
common.ApiError(c, err)
return
}
if task == nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": task.ToResponse(),
})
}
func ListSystemTasks(c *gin.Context) {
limit, _ := strconv.Atoi(c.Query("limit"))
tasks, err := model.ListSystemTasks(limit)
if err != nil {
common.ApiError(c, err)
return
}
responses := make([]model.SystemTaskResponse, 0, len(tasks))
for _, task := range tasks {
responses = append(responses, task.ToResponse())
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": responses,
})
}
func GetSystemTask(c *gin.Context) {
taskID := c.Param("task_id")
if taskID == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "task id is required",
})
return
}
task, err := model.GetSystemTaskByTaskID(taskID)
if err != nil {
common.ApiError(c, err)
return
}
if task == nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "task not found",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": task.ToResponse(),
})
}
+163
View File
@@ -0,0 +1,163 @@
package controller
import (
"context"
"fmt"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
)
// RegisterScheduledSystemTasks wires the periodic channel test, upstream model
// update, and async task polling (Midjourney / Suno / video) jobs into the
// system task framework so a DB lease dedups execution across multiple master
// instances and each run is recorded as one task row. Call this before
// service.StartSystemTaskRunner.
func RegisterScheduledSystemTasks() {
service.RegisterSystemTaskHandler(channelTestHandler{})
service.RegisterSystemTaskHandler(modelUpdateHandler{})
service.RegisterSystemTaskHandler(midjourneyPollHandler{})
service.RegisterSystemTaskHandler(asyncTaskPollHandler{})
}
// channelTestHandler runs the scheduled "test all channels" job. Enablement and
// cadence still come from the monitor settings; only the execution path moved
// into the system task runner.
type channelTestHandler struct{}
func (channelTestHandler) Type() string { return model.SystemTaskTypeChannelTest }
func (channelTestHandler) Enabled() bool {
return operation_setting.GetMonitorSetting().AutoTestChannelEnabled
}
func (channelTestHandler) Interval() time.Duration {
minutes := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
if minutes <= 0 {
minutes = 10
}
return time.Duration(minutes * float64(time.Minute))
}
func (channelTestHandler) NewPayload() any { return nil }
// channelTestTaskPayload controls one channel_test run. A nil/empty payload is a
// scheduled run, which uses the configured monitor ChannelTestMode and does not
// notify. A manual "test all channels" trigger sets Mode=scheduled_all and
// Notify=true to reproduce the legacy manual behavior (test every channel and
// notify root on completion).
type channelTestTaskPayload struct {
Mode string `json:"mode,omitempty"`
Notify bool `json:"notify,omitempty"`
}
func (channelTestHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
payload := channelTestTaskPayload{}
if err := task.DecodePayload(&payload); err != nil {
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err)
return
}
summary, err := runChannelTestTask(ctx, payload.Mode, payload.Notify, service.NewSystemTaskProgressReporter(task, runnerID))
if err != nil {
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err)
return
}
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
}
// modelUpdateHandler runs the scheduled upstream model update detection job.
type modelUpdateHandler struct{}
func (modelUpdateHandler) Type() string { return model.SystemTaskTypeModelUpdate }
func (modelUpdateHandler) Enabled() bool {
return common.GetEnvOrDefaultBool("CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_ENABLED", true)
}
func (modelUpdateHandler) Interval() time.Duration {
intervalMinutes := common.GetEnvOrDefault(
"CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_INTERVAL_MINUTES",
channelUpstreamModelUpdateTaskDefaultIntervalMinutes,
)
if intervalMinutes < 1 {
intervalMinutes = channelUpstreamModelUpdateTaskDefaultIntervalMinutes
}
return time.Duration(intervalMinutes) * time.Minute
}
func (modelUpdateHandler) NewPayload() any { return nil }
// modelUpdateTaskPayload controls one model_update run. A scheduled run
// (Manual=false) respects the per-channel minimum check interval and may
// auto-apply detected models when a channel has auto-sync enabled. A manual
// "detect all" trigger sets Manual=true to reproduce the legacy detect-all
// semantics: force a re-check regardless of the interval and never auto-apply,
// so the admin reviews and applies changes explicitly.
type modelUpdateTaskPayload struct {
Manual bool `json:"manual,omitempty"`
}
func (modelUpdateHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
payload := modelUpdateTaskPayload{}
if err := task.DecodePayload(&payload); err != nil {
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err)
return
}
summary := runChannelUpstreamModelUpdateTaskOnce(ctx, payload.Manual, !payload.Manual, service.NewSystemTaskProgressReporter(task, runnerID))
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
}
// midjourneyPollHandler runs one Midjourney polling pass per scheduled run.
// Enabled() folds the "are there unfinished tasks?" check into enablement so the
// scheduler creates no row when the system is idle; only when at least one
// Midjourney task is in progress does a row get scheduled.
type midjourneyPollHandler struct{}
func (midjourneyPollHandler) Type() string { return model.SystemTaskTypeMidjourneyPoll }
func (midjourneyPollHandler) Enabled() bool {
return constant.UpdateTask && model.HasUnfinishedMidjourneyTasks()
}
func (midjourneyPollHandler) Interval() time.Duration { return 15 * time.Second }
func (midjourneyPollHandler) NewPayload() any { return nil }
func (midjourneyPollHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
summary := runMidjourneyTaskUpdateOnce(ctx, service.NewSystemTaskProgressReporter(task, runnerID))
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
}
// asyncTaskPollHandler runs one async-task (Suno/video) polling pass per
// scheduled run. Like midjourneyPollHandler, Enabled() folds in the unfinished
// task existence check so an idle system schedules no rows.
type asyncTaskPollHandler struct{}
func (asyncTaskPollHandler) Type() string { return model.SystemTaskTypeAsyncTaskPoll }
func (asyncTaskPollHandler) Enabled() bool {
return constant.UpdateTask && model.HasUnfinishedSyncTasks()
}
func (asyncTaskPollHandler) Interval() time.Duration { return 15 * time.Second }
func (asyncTaskPollHandler) NewPayload() any { return nil }
func (asyncTaskPollHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
summary := service.RunTaskPollingOnce(ctx, service.NewSystemTaskProgressReporter(task, runnerID))
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
}
func finishSystemTaskHandler(task *model.SystemTask, runnerID string, status model.SystemTaskStatus, result any, runErr error) {
errorMessage := ""
if runErr != nil {
errorMessage = runErr.Error()
}
if err := model.FinishSystemTask(task.TaskID, runnerID, status, result, errorMessage); err != nil {
common.SysLog(fmt.Sprintf("system task %s failed to persist result: %v", task.TaskID, err))
}
}
+478
View File
@@ -0,0 +1,478 @@
package controller
import (
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
relaychannel "github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
type taskArtifactResponse struct {
Key string `json:"key"`
Type string `json:"type"`
MimeType string `json:"mime_type,omitempty"`
ContentURL string `json:"content_url"`
}
var (
taskArtifactKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`)
errTaskArtifactPluginUnavailable = errors.New("task artifact plugin unavailable")
errTaskArtifactPlugin = errors.New("task artifact plugin error")
)
func GetTask(c *gin.Context) {
task, exists, err := model.GetByTaskId(c.GetInt("id"), c.Param("key"))
if err != nil {
videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to query task")
return
}
if !exists {
videoProxyError(c, http.StatusNotFound, "invalid_request_error", "Task not found")
return
}
createdAt := task.CreatedAt
if createdAt == 0 {
createdAt = task.SubmitTime
}
failReason := task.FailReason
if task.Status == model.TaskStatusSuccess && taskFailReasonIsLegacyResultURL(task.FailReason) {
failReason = ""
}
c.JSON(http.StatusOK, gin.H{
"task_id": task.TaskID,
"platform": task.Platform,
"status": task.Status,
"progress": task.Progress,
"fail_reason": failReason,
"created_at": createdAt,
"finished_at": task.FinishTime,
})
}
func GetTaskArtifacts(c *gin.Context) {
task, exists, err := model.GetByTaskId(c.GetInt("id"), c.Param("key"))
if err != nil {
writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_internal_error", "Failed to query task")
return
}
if !exists || task == nil {
writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
return
}
writeTaskArtifacts(c, task, false)
}
func GetDashboardTaskArtifacts(c *gin.Context) {
task, exists, err := getTaskForArtifactRequest(c, c.Param("task_id"))
if err != nil {
writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_internal_error", "Failed to query task")
return
}
if !exists || task == nil {
writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
return
}
writeTaskArtifacts(c, task, true)
}
func writeTaskArtifacts(c *gin.Context, task *model.Task, dashboard bool) {
c.Header("Cache-Control", "private, no-store")
artifacts, err := projectTaskArtifacts(task)
if err != nil {
writeTaskArtifactProjectionError(c, err)
return
}
items := make([]taskArtifactResponse, 0, len(artifacts))
for _, artifact := range artifacts {
contentURL, buildErr := service.BuildTaskArtifactContentURL(task.TaskID, artifact.Key)
if buildErr != nil {
writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_url_error", "Failed to build artifact content URL")
return
}
items = append(items, taskArtifactResponse{
Key: artifact.Key,
Type: artifact.Type,
MimeType: artifact.MimeType,
ContentURL: contentURL,
})
}
response := gin.H{"task_id": task.TaskID, "artifacts": items}
if legacyVideoAvailable(task) {
legacyContentURL, buildErr := service.BuildTaskArtifactContentURL(task.TaskID, "video")
if buildErr != nil {
writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_url_error", "Failed to build artifact content URL")
return
}
response["legacy_content_url"] = legacyContentURL
}
if dashboard {
common.ApiSuccess(c, response)
return
}
c.JSON(http.StatusOK, response)
}
func projectTaskArtifacts(task *model.Task) ([]relaychannel.TaskArtifact, error) {
if task == nil || task.Status != model.TaskStatusSuccess || !taskHasPluginExecution(task) {
return []relaychannel.TaskArtifact{}, nil
}
adaptor := relay.GetTaskAdaptor(task.Platform)
if adaptor == nil {
return nil, errTaskArtifactPluginUnavailable
}
provider, ok := adaptor.(relaychannel.TaskArtifactProvider)
if !ok {
return []relaychannel.TaskArtifact{}, nil
}
artifacts, err := provider.ListArtifacts(task)
if err != nil {
return nil, fmt.Errorf("%w: %v", errTaskArtifactPlugin, err)
}
return validateProjectedTaskArtifacts(artifacts)
}
func validateProjectedTaskArtifacts(artifacts []relaychannel.TaskArtifact) ([]relaychannel.TaskArtifact, error) {
if len(artifacts) > 64 {
return nil, fmt.Errorf("%w: too many artifacts", errTaskArtifactPlugin)
}
seen := make(map[string]struct{}, len(artifacts))
for i := range artifacts {
if artifacts[i].Key != strings.TrimSpace(artifacts[i].Key) ||
artifacts[i].Type != strings.TrimSpace(artifacts[i].Type) {
return nil, fmt.Errorf("%w: invalid artifact identity", errTaskArtifactPlugin)
}
if !taskArtifactKeyPattern.MatchString(artifacts[i].Key) {
return nil, fmt.Errorf("%w: invalid artifact key", errTaskArtifactPlugin)
}
if _, exists := seen[artifacts[i].Key]; exists {
return nil, fmt.Errorf("%w: duplicate artifact key", errTaskArtifactPlugin)
}
seen[artifacts[i].Key] = struct{}{}
switch artifacts[i].Type {
case "video", "audio", "image", "file":
default:
return nil, fmt.Errorf("%w: invalid artifact type", errTaskArtifactPlugin)
}
if len(artifacts[i].MimeType) > 255 || strings.ContainsAny(artifacts[i].MimeType, "\r\n") {
return nil, fmt.Errorf("%w: invalid artifact mime type", errTaskArtifactPlugin)
}
}
return artifacts, nil
}
func initTaskArtifactAdaptor(task *model.Task) (relaychannel.TaskAdaptor, error) {
if task == nil || !taskHasPluginExecution(task) {
return nil, errTaskArtifactPluginUnavailable
}
channelModel, err := model.CacheGetChannel(task.ChannelId)
if err != nil {
return nil, fmt.Errorf("%w: channel unavailable", errTaskArtifactPluginUnavailable)
}
adaptor := relay.GetTaskAdaptor(task.Platform)
if adaptor == nil {
return nil, errTaskArtifactPluginUnavailable
}
pluginKey := task.PrivateData.Key
if pluginKey == "" {
pluginKey = channelModel.Key
}
baseURL := channelModel.GetBaseURL()
if baseURL == "" {
baseURL = constant.GetChannelBaseURL(channelModel.Type)
}
adaptor.Init(&relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: channelModel.Type,
ChannelBaseUrl: baseURL,
ApiKey: pluginKey,
ChannelSetting: channelModel.GetSetting(),
},
})
return adaptor, nil
}
func taskHasPluginExecution(task *model.Task) bool {
return task != nil &&
task.PrivateData.Execution != nil &&
task.PrivateData.Execution.TaskPlugin != nil &&
strings.TrimSpace(task.PrivateData.Execution.TaskPlugin.Key) != ""
}
func legacyVideoAvailable(task *model.Task) bool {
if task == nil || task.Status != model.TaskStatusSuccess ||
taskHasPluginExecution(task) || task.Platform == constant.TaskPlatformSuno ||
strings.TrimSpace(task.GetResultURL()) == "" {
return false
}
switch constant.NormalizeTaskAction(task.Action) {
case constant.TaskActionImageToVideo,
constant.TaskActionTextToVideo,
constant.TaskActionFirstTailToVideo,
constant.TaskActionReferenceToVideo,
constant.TaskActionRemix:
return true
default:
return false
}
}
func getTaskForArtifactRequest(c *gin.Context, taskID string) (*model.Task, bool, error) {
if middleware.IsTaskArtifactAccess(c) {
task, exists, err := model.GetUniqueByOnlyTaskId(taskID)
if err != nil || !exists || task == nil {
return task, exists, err
}
owner, err := model.GetUserCache(task.UserId)
if err != nil || owner == nil || owner.Status != common.UserStatusEnabled {
return nil, false, err
}
return task, true, nil
}
if c.GetInt("token_id") == 0 && c.GetInt("role") >= common.RoleAdminUser {
return model.GetByOnlyTaskId(taskID)
}
return model.GetByTaskId(c.GetInt("id"), taskID)
}
func writeTaskArtifactProjectionError(c *gin.Context, err error) {
if errors.Is(err, errTaskArtifactPluginUnavailable) {
writeTaskArtifactError(c, http.StatusServiceUnavailable, "artifact_plugin_unavailable", "Artifact preview plugin is unavailable")
return
}
writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_plugin_error", "Artifact preview plugin failed")
}
func writeTaskArtifactError(c *gin.Context, status int, code, message string) {
c.Header("Cache-Control", "private, no-store")
if middleware.IsTaskArtifactAccess(c) {
status = http.StatusNotFound
code = "artifact_not_found"
message = "Task or artifact not found"
}
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(status, gin.H{"success": false, "code": code, "message": message})
return
}
c.JSON(status, gin.H{
"error": gin.H{
"message": message,
"type": code,
"code": code,
},
})
}
func TaskArtifactContent(c *gin.Context) {
task, exists, err := getTaskForArtifactRequest(c, c.Param("key"))
if err != nil {
writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_internal_error", "Failed to query task")
return
}
if !exists || task == nil {
writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
return
}
artifactKey := strings.TrimSpace(c.Param("artifact_key"))
if !taskArtifactKeyPattern.MatchString(artifactKey) {
writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
return
}
if task.Status != model.TaskStatusSuccess {
writeTaskArtifactError(c, http.StatusConflict, "artifact_not_ready", "Task artifacts are not ready")
return
}
if !taskHasPluginExecution(task) {
if artifactKey != "video" || !legacyVideoAvailable(task) {
writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
return
}
descriptor := &relaychannel.TaskContentRequest{
URL: task.GetResultURL(),
Method: c.Request.Method,
Credentialless: true,
}
if err := proxyTaskMedia(c, task, descriptor); err != nil {
writeTaskMediaProxyError(c, err)
}
return
}
artifacts, err := projectTaskArtifacts(task)
if err != nil {
writeTaskArtifactProjectionError(c, err)
return
}
found := false
for _, artifact := range artifacts {
if artifact.Key == artifactKey {
found = true
break
}
}
if !found {
writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
return
}
artifactStore := service.GetTaskArtifactStore()
if ref, resolveErr := artifactStore.Resolve(task, artifactKey); resolveErr == nil && ref != nil {
_ = artifactStore.Serve(c, task, ref)
return
}
adaptor, err := initTaskArtifactAdaptor(task)
if err != nil {
writeTaskArtifactProjectionError(c, err)
return
}
provider, ok := adaptor.(relaychannel.TaskContentRequestProvider)
if !ok {
writeTaskArtifactError(c, http.StatusServiceUnavailable, "artifact_plugin_unavailable", "Artifact content plugin is unavailable")
return
}
clientRequest := relaychannel.TaskArtifactClientRequest{
Method: c.Request.Method,
Headers: taskArtifactClientHeaders(c.Request.Header),
}
descriptor, err := provider.BuildContentRequest(task, artifactKey, clientRequest)
if err != nil || descriptor == nil {
writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_plugin_error", "Artifact content plugin failed")
return
}
if err := proxyTaskMedia(c, task, descriptor); err != nil {
writeTaskMediaProxyError(c, err)
}
}
func taskArtifactClientHeaders(headers http.Header) map[string]string {
result := make(map[string]string, 4)
for _, name := range []string{"Range", "If-Range", "If-None-Match", "If-Modified-Since"} {
if value := strings.TrimSpace(headers.Get(name)); value != "" {
result[name] = value
}
}
return result
}
/*
The task list handlers below deliberately do not call projectTaskArtifacts.
Artifact projection is confined to the explicit endpoints above.
*/
func GetAllTask(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
queryParams := model.SyncTaskQueryParams{Platform: constant.TaskPlatform(c.Query("platform")), TaskID: c.Query("task_id"), Status: c.Query("status"), Action: c.Query("action"), StartTimestamp: startTimestamp, EndTimestamp: endTimestamp, ChannelID: c.Query("channel_id")}
items := model.TaskGetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
pageInfo.SetTotal(int(model.TaskCountAllTasks(queryParams)))
pageInfo.SetItems(tasksToDto(items, true, c.GetInt("role")))
common.ApiSuccess(c, pageInfo)
}
func GetUserTask(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
userID := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
queryParams := model.SyncTaskQueryParams{Platform: constant.TaskPlatform(c.Query("platform")), TaskID: c.Query("task_id"), Status: c.Query("status"), Action: c.Query("action"), StartTimestamp: startTimestamp, EndTimestamp: endTimestamp}
items := model.TaskGetAllUserTask(userID, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
pageInfo.SetTotal(int(model.TaskCountAllUserTask(userID, queryParams)))
pageInfo.SetItems(tasksToDto(items, false, common.RoleCommonUser))
common.ApiSuccess(c, pageInfo)
}
func tasksToDto(tasks []*model.Task, fillUser bool, viewerRole int) []*dto.TaskDto {
var userIDMap map[int]*model.UserBase
if fillUser {
userIDMap = make(map[int]*model.UserBase)
userIDs := types.NewSet[int]()
for _, task := range tasks {
userIDs.Add(task.UserId)
}
for _, userID := range userIDs.Items() {
if cacheUser, err := model.GetUserCache(userID); err == nil {
userIDMap[userID] = cacheUser
}
}
}
result := make([]*dto.TaskDto, len(tasks))
for i, task := range tasks {
if fillUser {
if user, ok := userIDMap[task.UserId]; ok {
task.Username = user.Username
}
}
item := relay.TaskModel2Dto(task)
item.LegacyVideoAvailable = legacyVideoAvailable(task)
if task.Status == model.TaskStatusSuccess {
item.ResultURL = ""
if taskFailReasonIsLegacyResultURL(task.FailReason) {
item.FailReason = ""
}
}
if viewerRole >= common.RoleAdminUser {
adminInfo := &dto.TaskAdminInfo{}
if execution := task.PrivateData.Execution; execution != nil {
adminInfo.RequestID = execution.RequestID
adminInfo.RequestPath = execution.RequestPath
if snapshot := execution.TaskPlugin; snapshot != nil {
adminInfo.TaskPlugin = &dto.TaskPluginInfo{
Key: snapshot.Key,
Name: snapshot.Name,
Version: snapshot.Version,
}
if snapshot.Author != nil {
adminInfo.TaskPlugin.Author = &dto.TaskPluginAuthorInfo{
Name: snapshot.Author.Name,
URL: snapshot.Author.URL,
}
}
}
}
if adminInfo.RequestID != "" || adminInfo.RequestPath != "" || adminInfo.TaskPlugin != nil {
item.AdminInfo = adminInfo
}
}
if viewerRole >= common.RoleRootUser {
rootInfo := &dto.TaskRootInfo{
UpstreamTaskID: task.PrivateData.UpstreamTaskID,
NodeName: task.PrivateData.NodeName,
}
if execution := task.PrivateData.Execution; execution != nil {
if snapshot := execution.TaskPlugin; snapshot != nil {
rootInfo.TaskPlugin = &dto.TaskPluginRuntimeInfo{
Key: snapshot.Key,
Version: snapshot.Version,
APIVersion: snapshot.APIVersion,
Generation: snapshot.Generation,
}
}
}
if rootInfo.TaskPlugin != nil || rootInfo.UpstreamTaskID != "" || rootInfo.NodeName != "" {
item.RootInfo = rootInfo
}
}
result[i] = item
}
return result
}
func taskFailReasonIsLegacyResultURL(value string) bool {
value = strings.TrimSpace(value)
return len(value) >= len("https://") && strings.EqualFold(value[:len("https://")], "https://") ||
len(value) >= len("http://") && strings.EqualFold(value[:len("http://")], "http://") ||
len(value) >= len("data:") && strings.EqualFold(value[:len("data:")], "data:")
}
+590
View File
@@ -0,0 +1,590 @@
package controller
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
relaychannel "github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupGenericTaskTest(t *testing.T) *model.Task {
t.Helper()
originalDB := model.DB
previousRedisEnabled := common.RedisEnabled
common.RedisEnabled = false
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.Task{}, &model.Channel{}, &model.User{}))
model.DB = database
t.Cleanup(func() {
model.DB = originalDB
common.RedisEnabled = previousRedisEnabled
})
require.NoError(t, database.Create(&model.User{
Id: 7, Username: "artifact-owner", Status: common.UserStatusEnabled,
Role: common.RoleCommonUser, Group: "default",
}).Error)
baseURL := "https://example.com"
require.NoError(t, database.Create(&model.Channel{
Id: 1, Name: "artifact", Key: "key", BaseURL: &baseURL, Status: common.ChannelStatusEnabled,
}).Error)
task := &model.Task{
TaskID: "task_generic", Platform: "document", UserId: 7, ChannelId: 1,
Status: model.TaskStatusSuccess, Progress: "100%", SubmitTime: 10, FinishTime: 20,
}
require.NoError(t, database.Create(task).Error)
return task
}
func allowPrivateTaskMediaTest(t *testing.T) {
t.Helper()
originalFetchSetting := *system_setting.GetFetchSetting()
system_setting.GetFetchSetting().EnableSSRFProtection = true
system_setting.GetFetchSetting().AllowPrivateIp = true
system_setting.GetFetchSetting().AllowedPorts = []string{"1-65535"}
t.Cleanup(func() { *system_setting.GetFetchSetting() = originalFetchSetting })
service.InitHttpClient()
}
func TestGetTaskDoesNotProjectArtifacts(t *testing.T) {
task := setupGenericTaskTest(t)
task.FailReason = "https://stale-upstream.invalid/video.mp4"
task.PrivateData = model.TaskPrivateData{
ResultURL: "https://private-upstream.invalid/video.mp4",
Execution: &model.TaskExecutionSnapshot{
TaskPlugin: &model.TaskPluginSnapshot{Key: "missing-plugin", Name: "Missing"},
},
}
require.NoError(t, model.DB.Save(task).Error)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set("id", 7)
c.Params = gin.Params{{Key: "key", Value: task.TaskID}}
c.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/"+task.TaskID, nil)
GetTask(c)
assert.Equal(t, http.StatusOK, recorder.Code)
var response map[string]any
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, task.TaskID, response["task_id"])
assert.NotContains(t, response, "artifacts")
assert.NotContains(t, recorder.Body.String(), "upstream.invalid")
}
func TestGetTaskArtifactsReturnsEmptyForLegacyTask(t *testing.T) {
task := setupGenericTaskTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set("id", task.UserId)
c.Params = gin.Params{{Key: "key", Value: task.TaskID}}
c.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/"+task.TaskID+"/artifacts", nil)
GetTaskArtifacts(c)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control"))
var response struct {
TaskID string `json:"task_id"`
Artifacts []taskArtifactResponse `json:"artifacts"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, task.TaskID, response.TaskID)
assert.Empty(t, response.Artifacts)
}
func TestTaskArtifactAuthorizationKeepsForeignTasksHidden(t *testing.T) {
task := setupGenericTaskTest(t)
commonUser, _ := gin.CreateTestContext(httptest.NewRecorder())
commonUser.Set("id", 8)
commonUser.Set("role", common.RoleCommonUser)
_, exists, err := getTaskForArtifactRequest(commonUser, task.TaskID)
require.NoError(t, err)
assert.False(t, exists)
admin, _ := gin.CreateTestContext(httptest.NewRecorder())
admin.Set("id", 8)
admin.Set("role", common.RoleAdminUser)
found, exists, err := getTaskForArtifactRequest(admin, task.TaskID)
require.NoError(t, err)
require.True(t, exists)
assert.Equal(t, task.TaskID, found.TaskID)
apiToken, _ := gin.CreateTestContext(httptest.NewRecorder())
apiToken.Set("id", 8)
apiToken.Set("role", common.RoleRootUser)
apiToken.Set("token_id", 99)
_, exists, err = getTaskForArtifactRequest(apiToken, task.TaskID)
require.NoError(t, err)
assert.False(t, exists)
}
func TestDashboardTaskArtifactsReturnsLegacyCapabilityWithoutUpstreamURL(t *testing.T) {
task := setupGenericTaskTest(t)
previousSecret := common.CryptoSecret
previousPublicAddress := system_setting.TaskPublicAddress
common.CryptoSecret = "controller-task-artifact-access-secret"
system_setting.TaskPublicAddress = "https://gateway.example/prefix"
t.Cleanup(func() {
common.CryptoSecret = previousSecret
system_setting.TaskPublicAddress = previousPublicAddress
})
task.Action = constant.TaskActionTextToVideo
task.FailReason = "https://upstream.invalid/private-video.mp4?signature=secret"
require.NoError(t, model.DB.Save(task).Error)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set("id", task.UserId)
c.Set("role", common.RoleCommonUser)
c.Params = gin.Params{{Key: "task_id", Value: task.TaskID}}
c.Request = httptest.NewRequest(http.MethodGet, "/api/task/"+task.TaskID+"/artifacts", nil)
GetDashboardTaskArtifacts(c)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control"))
var response struct {
Success bool `json:"success"`
Data struct {
Artifacts []taskArtifactResponse `json:"artifacts"`
LegacyContentURL string `json:"legacy_content_url"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.True(t, response.Success)
assert.Empty(t, response.Data.Artifacts)
contentURL, err := url.Parse(response.Data.LegacyContentURL)
require.NoError(t, err)
assert.Equal(t, "/prefix/v1/tasks/"+task.TaskID+"/artifacts/video/content", contentURL.Path)
assert.True(t, service.VerifyTaskArtifactAccess(
contentURL.Query().Get(service.TaskArtifactAccessQueryParameter),
task.TaskID,
"video",
))
assert.NotContains(t, recorder.Body.String(), "upstream.invalid")
assert.NotContains(t, recorder.Body.String(), "signature=secret")
}
func TestTaskArtifactAccessRequiresActiveOwner(t *testing.T) {
task := setupGenericTaskTest(t)
task.Action = constant.TaskActionTextToVideo
task.FailReason = "https://upstream.invalid/private-video.mp4"
require.NoError(t, model.DB.Save(task).Error)
require.NoError(t, model.DB.Model(&model.User{}).
Where("id = ?", task.UserId).
Update("status", common.UserStatusDisabled).Error)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set(middleware.TaskArtifactAccessContextKey, true)
c.Params = gin.Params{
{Key: "key", Value: task.TaskID},
{Key: "artifact_key", Value: "video"},
}
c.Request = httptest.NewRequest(
http.MethodGet,
"/v1/tasks/"+task.TaskID+"/artifacts/video/content",
nil,
)
TaskArtifactContent(c)
assert.Equal(t, http.StatusNotFound, recorder.Code)
assert.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control"))
}
func TestTaskArtifactAccessRejectsAmbiguousHistoricalTaskID(t *testing.T) {
task := setupGenericTaskTest(t)
task.Action = constant.TaskActionTextToVideo
task.FailReason = "https://first-upstream.invalid/video.mp4"
require.NoError(t, model.DB.Save(task).Error)
require.NoError(t, model.DB.Create(&model.User{
Id: 8, Username: "other-artifact-owner", Status: common.UserStatusEnabled,
Role: common.RoleCommonUser, Group: "default", AffCode: "artifact-owner-8",
}).Error)
require.NoError(t, model.DB.Create(&model.Task{
TaskID: task.TaskID, Platform: task.Platform, UserId: 8, ChannelId: task.ChannelId,
Action: constant.TaskActionTextToVideo, Status: model.TaskStatusSuccess,
FailReason: "https://second-upstream.invalid/video.mp4",
}).Error)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set(middleware.TaskArtifactAccessContextKey, true)
c.Params = gin.Params{
{Key: "key", Value: task.TaskID},
{Key: "artifact_key", Value: "video"},
}
c.Request = httptest.NewRequest(
http.MethodGet,
"/v1/tasks/"+task.TaskID+"/artifacts/video/content",
nil,
)
TaskArtifactContent(c)
assert.Equal(t, http.StatusNotFound, recorder.Code)
assert.NotContains(t, recorder.Body.String(), "upstream.invalid")
}
func TestLegacyVideoArtifactContentUsesGetResultURL(t *testing.T) {
task := setupGenericTaskTest(t)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "bytes=0-3", r.Header.Get("Range"))
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Content-Range", "bytes 0-3/4")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("data"))
}))
defer upstream.Close()
allowPrivateTaskMediaTest(t)
task.Action = constant.TaskActionTextToVideo
task.PrivateData.ResultURL = upstream.URL
task.FailReason = "https://stale.invalid/legacy-fallback.mp4"
require.NoError(t, model.DB.Save(task).Error)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set(middleware.TaskArtifactAccessContextKey, true)
c.Params = gin.Params{
{Key: "key", Value: task.TaskID},
{Key: "artifact_key", Value: "video"},
}
c.Request = httptest.NewRequest(
http.MethodGet,
"/v1/tasks/"+task.TaskID+"/artifacts/video/content",
nil,
)
c.Request.Header.Set("Range", "bytes=0-3")
TaskArtifactContent(c)
assert.Equal(t, http.StatusPartialContent, recorder.Code)
assert.Equal(t, "data", recorder.Body.String())
assert.Equal(t, "bytes 0-3/4", recorder.Header().Get("Content-Range"))
}
func TestDisabledArtifactStorePreservesPluginUpstreamContent(t *testing.T) {
task := setupGenericTaskTest(t)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "provider-key", r.Header.Get("x-goog-api-key"))
assert.Equal(t, "bytes=0-13", r.Header.Get("Range"))
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Content-Range", "bytes 0-13/14")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("artifact-bytes"))
}))
defer upstream.Close()
allowPrivateTaskMediaTest(t)
previousMemoryCache := common.MemoryCacheEnabled
common.MemoryCacheEnabled = false
t.Cleanup(func() { common.MemoryCacheEnabled = previousMemoryCache })
require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", task.ChannelId).Updates(map[string]any{
"type": constant.ChannelTypeGemini,
"key": "provider-key",
"base_url": upstream.URL,
}).Error)
task.Platform = constant.TaskPlatform("google")
task.PrivateData.Execution = &model.TaskExecutionSnapshot{TaskPlugin: &model.TaskPluginSnapshot{
Key: "google", Name: "Google Veo (Gemini API)", Version: "1.0.0", APIVersion: 1,
}}
task.SetData(map[string]any{"response": map[string]any{
"generateVideoResponse": map[string]any{
"generatedVideos": []any{map[string]any{"video": map[string]any{"uri": upstream.URL}}},
},
}})
require.NoError(t, model.DB.Save(task).Error)
require.False(t, service.GetTaskArtifactStore().Enabled())
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set(middleware.TaskArtifactAccessContextKey, true)
c.Params = gin.Params{
{Key: "key", Value: task.TaskID},
{Key: "artifact_key", Value: "video"},
}
c.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/"+task.TaskID+"/artifacts/video/content", nil)
c.Request.Header.Set("Range", "bytes=0-13")
TaskArtifactContent(c)
assert.Equal(t, http.StatusPartialContent, recorder.Code)
assert.Equal(t, "artifact-bytes", recorder.Body.String())
assert.Equal(t, "video/mp4", recorder.Header().Get("Content-Type"))
assert.Equal(t, "bytes 0-13/14", recorder.Header().Get("Content-Range"))
}
func TestProjectedTaskArtifactValidationRejectsAmbiguousIdentity(t *testing.T) {
validated, err := validateProjectedTaskArtifacts([]relaychannel.TaskArtifact{
{Key: "video-main", Type: "video", MimeType: "video/mp4"},
{Key: "cover.main", Type: "image", MimeType: "image/png"},
})
require.NoError(t, err)
require.Len(t, validated, 2)
assert.Equal(t, "video-main", validated[0].Key)
for _, artifacts := range [][]relaychannel.TaskArtifact{
{{Key: "../video", Type: "video"}},
{{Key: "video/0", Type: "video"}},
{{Key: "video-main", Type: "video"}, {Key: "video-main", Type: "image"}},
{{Key: "video-main", Type: "unknown"}},
{{Key: "video-main", Type: "video", MimeType: "video/mp4\r\nX-Test: injected"}},
} {
_, err := validateProjectedTaskArtifacts(artifacts)
assert.ErrorIs(t, err, errTaskArtifactPlugin)
}
}
func TestProxyTaskMediaForwardsRangeAndFiltersResponseHeaders(t *testing.T) {
task := setupGenericTaskTest(t)
var receivedRange, receivedAuthorization string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedRange = r.Header.Get("Range")
receivedAuthorization = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Content-Range", "bytes 0-3/10")
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Set-Cookie", "provider=secret")
w.Header().Set("WWW-Authenticate", "Bearer provider")
w.Header().Set("X-Provider-Secret", "hidden")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("data"))
}))
defer upstream.Close()
allowPrivateTaskMediaTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/task_generic/artifacts/video-main/content", nil)
c.Request.Header.Set("Range", "bytes=0-3")
err := proxyTaskMedia(c, task, &relaychannel.TaskContentRequest{
URL: upstream.URL,
Method: http.MethodGet,
Headers: map[string]string{"Authorization": "Bearer provider-secret"},
})
require.NoError(t, err)
assert.Equal(t, http.StatusPartialContent, recorder.Code)
assert.Equal(t, "data", recorder.Body.String())
assert.Equal(t, "bytes=0-3", receivedRange)
assert.Equal(t, "Bearer provider-secret", receivedAuthorization)
assert.Equal(t, "bytes 0-3/10", recorder.Header().Get("Content-Range"))
assert.Equal(t, "bytes", recorder.Header().Get("Accept-Ranges"))
assert.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control"))
assert.Equal(t, "sandbox; default-src 'none'", recorder.Header().Get("Content-Security-Policy"))
assert.Equal(t, "no-referrer", recorder.Header().Get("Referrer-Policy"))
assert.Equal(t, "nosniff", recorder.Header().Get("X-Content-Type-Options"))
assert.Empty(t, recorder.Header().Get("Set-Cookie"))
assert.Empty(t, recorder.Header().Get("WWW-Authenticate"))
assert.Empty(t, recorder.Header().Get("X-Provider-Secret"))
}
func TestProxyTaskMediaPassesThroughUnsatisfiedRange(t *testing.T) {
task := setupGenericTaskTest(t)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Range", "bytes */10")
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
}))
defer upstream.Close()
allowPrivateTaskMediaTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
require.NoError(t, proxyTaskMedia(c, task, &relaychannel.TaskContentRequest{
URL: upstream.URL, Method: http.MethodGet,
}))
assert.Equal(t, http.StatusRequestedRangeNotSatisfiable, recorder.Code)
assert.Equal(t, "bytes */10", recorder.Header().Get("Content-Range"))
assert.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control"))
}
func TestTaskMediaResponseHeaderTimeoutDoesNotTruncateBody(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "video/mp4")
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
time.Sleep(75 * time.Millisecond)
_, _ = w.Write([]byte("complete-body"))
}))
defer upstream.Close()
request, err := http.NewRequest(http.MethodGet, upstream.URL, nil)
require.NoError(t, err)
response, err := doTaskMediaRequest(upstream.Client(), request, 20*time.Millisecond)
require.NoError(t, err)
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
require.NoError(t, err)
assert.Equal(t, "complete-body", string(body))
}
func TestTaskMediaResponseHeaderTimeoutCancelsBeforeHeaders(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(75 * time.Millisecond)
_, _ = w.Write([]byte("late"))
}))
defer upstream.Close()
request, err := http.NewRequest(http.MethodGet, upstream.URL, nil)
require.NoError(t, err)
_, err = doTaskMediaRequest(upstream.Client(), request, 10*time.Millisecond)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
func TestWriteVideoDataURLStreamsAndSupportsHead(t *testing.T) {
const dataURL = "data:video/mp4;base64,Y29tcGxldGUtYm9keQ=="
getRecorder := httptest.NewRecorder()
getContext, _ := gin.CreateTestContext(getRecorder)
getContext.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
require.NoError(t, writeVideoDataURL(getContext, dataURL))
assert.Equal(t, http.StatusOK, getRecorder.Code)
assert.Equal(t, "complete-body", getRecorder.Body.String())
assert.Equal(t, "13", getRecorder.Header().Get("Content-Length"))
headRecorder := httptest.NewRecorder()
headContext, _ := gin.CreateTestContext(headRecorder)
headContext.Request = httptest.NewRequest(http.MethodHead, "/content", nil)
require.NoError(t, writeVideoDataURL(headContext, dataURL))
assert.Equal(t, http.StatusOK, headRecorder.Code)
assert.Empty(t, headRecorder.Body.String())
assert.Equal(t, "13", headRecorder.Header().Get("Content-Length"))
}
func TestWriteVideoDataURLRejectsOversizedPayloadBeforeDecode(t *testing.T) {
previousLimit := taskMediaDataURLMaxEncodedBytes
taskMediaDataURLMaxEncodedBytes = 32
t.Cleanup(func() { taskMediaDataURLMaxEncodedBytes = previousLimit })
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
err := writeVideoDataURL(c, "data:video/mp4;base64,"+strings.Repeat("A", 64))
assert.ErrorIs(t, err, errTaskMediaRequestRejected)
assert.Empty(t, recorder.Header().Get("Content-Type"))
}
func TestProxyTaskMediaAllowsOnlyCredentiallessCrossOriginRedirect(t *testing.T) {
task := setupGenericTaskTest(t)
var destinationAuthorization, destinationRange string
destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
destinationAuthorization = r.Header.Get("Authorization")
destinationRange = r.Header.Get("Range")
_, _ = w.Write([]byte("redirected"))
}))
defer destination.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, destination.URL, http.StatusFound)
}))
defer source.Close()
allowPrivateTaskMediaTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
c.Request.Header.Set("Range", "bytes=0-3")
err := proxyTaskMedia(c, task, &relaychannel.TaskContentRequest{
URL: source.URL, Method: http.MethodGet, Credentialless: true,
})
require.NoError(t, err)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "redirected", recorder.Body.String())
assert.Empty(t, destinationAuthorization)
assert.Equal(t, "bytes=0-3", destinationRange)
destinationRange = ""
rejectedRecorder := httptest.NewRecorder()
rejectedContext, _ := gin.CreateTestContext(rejectedRecorder)
rejectedContext.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
err = proxyTaskMedia(rejectedContext, task, &relaychannel.TaskContentRequest{
URL: source.URL, Method: http.MethodGet,
Headers: map[string]string{"Authorization": "Bearer provider-secret"},
})
var proxyErr *taskMediaProxyError
require.ErrorAs(t, err, &proxyErr)
assert.Equal(t, "artifact_request_rejected", proxyErr.code)
assert.Empty(t, destinationRange)
}
func TestTaskMediaRequestHeaderPolicy(t *testing.T) {
header := http.Header{}
require.NoError(t, applyTaskMediaRequestHeaders(header, map[string]string{
"Authorization": "Bearer provider-secret",
"X-Signature": "signed",
}))
assert.Equal(t, "Bearer provider-secret", header.Get("Authorization"))
assert.Equal(t, "signed", header.Get("X-Signature"))
for _, name := range []string{"Host", "Content-Length", "Accept-Encoding", "Connection", "Proxy-Authorization", "Transfer-Encoding"} {
t.Run(name, func(t *testing.T) {
assert.ErrorIs(t, applyTaskMediaRequestHeaders(http.Header{}, map[string]string{name: "bad"}), errTaskMediaRequestRejected)
})
}
assert.ErrorIs(t, applyTaskMediaRequestHeaders(http.Header{}, map[string]string{"X-Test": "bad\r\ninjected"}), errTaskMediaRequestRejected)
}
func TestCredentiallessTaskMediaDescriptorRejectsCredentialsAndBody(t *testing.T) {
task := setupGenericTaskTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/content", nil)
for _, descriptor := range []*relaychannel.TaskContentRequest{
{URL: "https://example.com/video", Method: http.MethodPost, Credentialless: true},
{URL: "https://example.com/video", Method: http.MethodGet, Body: []byte("secret"), Credentialless: true},
{URL: "https://example.com/video", Method: http.MethodGet, Headers: map[string]string{"X-Key": "secret"}, Credentialless: true},
} {
err := proxyTaskMedia(c, task, descriptor)
var proxyErr *taskMediaProxyError
require.ErrorAs(t, err, &proxyErr)
assert.Equal(t, "artifact_request_rejected", proxyErr.code)
}
}
func TestSelfTaskMediaURLGuard(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "https://gateway.example/v1/videos/task-1/content", nil)
c.Request.Host = "gateway.example"
selfURL, err := url.Parse("https://gateway.example/v1/videos/task-1/content")
require.NoError(t, err)
assert.True(t, isSelfTaskMediaURL(c, selfURL))
remoteURL, err := url.Parse("https://cdn.example/v1/videos/task-1/content")
require.NoError(t, err)
assert.False(t, isSelfTaskMediaURL(c, remoteURL))
assert.True(t, isTaskMediaFallbackLoop(remoteURL.String(), "task-1"))
assert.False(t, isTaskMediaFallbackLoop(remoteURL.String(), "task-2"))
}
+137
View File
@@ -0,0 +1,137 @@
package controller
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskLogDTOSeparatesUserAdminAndRootDetails(t *testing.T) {
task := &model.Task{
TaskID: "task_public",
Platform: "document-parser",
PrivateData: model.TaskPrivateData{
Key: "channel-secret-canary",
UpstreamTaskID: "upstream-private",
NodeName: "node-a",
Execution: &model.TaskExecutionSnapshot{
RequestID: "request-public",
RequestPath: "/v1/documents",
TaskPlugin: &model.TaskPluginSnapshot{
Key: "document-parser",
Name: "Document Parser",
Version: "1.2.3",
Author: &model.TaskPluginAuthorSnapshot{
Name: "Community Author",
URL: "https://plugins.example/author",
},
APIVersion: 1,
Generation: 42,
},
},
},
}
userView := tasksToDto([]*model.Task{task}, false, common.RoleCommonUser)[0]
assert.Nil(t, userView.AdminInfo)
assert.Nil(t, userView.RootInfo)
adminView := tasksToDto([]*model.Task{task}, false, common.RoleAdminUser)[0]
require.NotNil(t, adminView.AdminInfo)
require.NotNil(t, adminView.AdminInfo.TaskPlugin)
assert.Equal(t, "document-parser", adminView.AdminInfo.TaskPlugin.Key)
assert.Equal(t, "Document Parser", adminView.AdminInfo.TaskPlugin.Name)
assert.Equal(t, "1.2.3", adminView.AdminInfo.TaskPlugin.Version)
require.NotNil(t, adminView.AdminInfo.TaskPlugin.Author)
assert.Equal(t, "Community Author", adminView.AdminInfo.TaskPlugin.Author.Name)
assert.Equal(t, "https://plugins.example/author", adminView.AdminInfo.TaskPlugin.Author.URL)
assert.Equal(t, "request-public", adminView.AdminInfo.RequestID)
assert.Equal(t, "/v1/documents", adminView.AdminInfo.RequestPath)
assert.Nil(t, adminView.RootInfo)
rootView := tasksToDto([]*model.Task{task}, false, common.RoleRootUser)[0]
require.NotNil(t, rootView.AdminInfo)
require.NotNil(t, rootView.RootInfo)
require.NotNil(t, rootView.RootInfo.TaskPlugin)
assert.Equal(t, 1, rootView.RootInfo.TaskPlugin.APIVersion)
assert.Equal(t, uint64(42), rootView.RootInfo.TaskPlugin.Generation)
assert.Equal(t, "upstream-private", rootView.RootInfo.UpstreamTaskID)
assert.Equal(t, "node-a", rootView.RootInfo.NodeName)
adminJSON, err := common.Marshal(adminView)
require.NoError(t, err)
assert.NotContains(t, string(adminJSON), "channel-secret-canary")
assert.NotContains(t, string(adminJSON), "upstream-private")
rootJSON, err := common.Marshal(rootView)
require.NoError(t, err)
assert.NotContains(t, string(rootJSON), "channel-secret-canary")
assert.Contains(t, string(rootJSON), "upstream-private")
}
func TestTaskLogDTODoesNotInventHistoricalPluginProvenance(t *testing.T) {
task := &model.Task{
TaskID: "task_without_snapshot",
Platform: "document-parser",
}
adminView := tasksToDto([]*model.Task{task}, false, common.RoleAdminUser)[0]
assert.Nil(t, adminView.AdminInfo)
assert.Nil(t, adminView.RootInfo)
}
func TestTaskLogDTOReplacesLegacyVideoURLWithAvailabilityFlag(t *testing.T) {
task := &model.Task{
TaskID: "task_legacy_video",
Platform: "jimeng",
Action: constant.TaskActionTextToVideo,
Status: model.TaskStatusSuccess,
FailReason: "https://private-upstream.invalid/video.mp4?signature=secret",
}
view := tasksToDto([]*model.Task{task}, false, common.RoleCommonUser)[0]
assert.True(t, view.LegacyVideoAvailable)
assert.Empty(t, view.ResultURL)
assert.Empty(t, view.FailReason)
encoded, err := common.Marshal(view)
require.NoError(t, err)
assert.NotContains(t, string(encoded), "private-upstream.invalid")
assert.NotContains(t, string(encoded), "result_url")
assert.Contains(t, string(encoded), "legacy_video_available")
}
func TestTaskLogDTOKeepsFailureReasonAndDoesNotMarkPluginTaskLegacy(t *testing.T) {
failed := &model.Task{
TaskID: "task_failed",
Platform: "jimeng",
Action: constant.TaskActionTextToVideo,
Status: model.TaskStatusFailure,
FailReason: "provider rejected the request",
}
failedView := tasksToDto([]*model.Task{failed}, false, common.RoleCommonUser)[0]
assert.Equal(t, "provider rejected the request", failedView.FailReason)
assert.False(t, failedView.LegacyVideoAvailable)
pluginTask := &model.Task{
TaskID: "task_plugin_video",
Platform: "community-video",
Action: constant.TaskActionTextToVideo,
Status: model.TaskStatusSuccess,
FailReason: "https://stale-upstream.invalid/plugin-video.mp4",
PrivateData: model.TaskPrivateData{
ResultURL: "https://private-upstream.invalid/plugin-video.mp4",
Execution: &model.TaskExecutionSnapshot{
TaskPlugin: &model.TaskPluginSnapshot{Key: "community-video"},
},
},
}
pluginView := tasksToDto([]*model.Task{pluginTask}, false, common.RoleCommonUser)[0]
assert.False(t, pluginView.LegacyVideoAvailable)
assert.Empty(t, pluginView.ResultURL)
assert.Empty(t, pluginView.FailReason)
}
+835
View File
@@ -0,0 +1,835 @@
package controller
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/plugins"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const maxTaskPluginSourceBytes = 1024 * 1024
type taskPluginUploadRequest struct {
Source string `json:"source" binding:"required"`
Enabled *bool `json:"enabled"`
Remark string `json:"remark"`
Force bool `json:"force"`
SourceSha256 string `json:"sourceSha256"`
// Icon carries the sidecar icon.svg / icon.png as a data URI. It is optional
// and stored separately from the source so the JavaScript stays readable.
Icon string `json:"icon"`
}
func UploadTaskPlugin(c *gin.Context) {
var request taskPluginUploadRequest
if err := c.ShouldBindJSON(&request); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
if len(request.Source) > maxTaskPluginSourceBytes {
common.ApiErrorMsg(c, "plugin source exceeds 1 MiB")
return
}
if expected := strings.TrimSpace(request.SourceSha256); expected != "" {
actual := fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source)))
if !strings.EqualFold(actual, expected) {
common.ApiErrorMsg(c, "plugin source sha256 mismatch")
return
}
}
temporary := jsplugin.NewRegistry()
loaded, err := temporary.Register(request.Source, jsplugin.Options{})
if err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
if err = jsplugin.ValidateV1Meta(loaded.Meta); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
icon := strings.TrimSpace(request.Icon)
if icon != "" {
if _, _, err = jsplugin.DecodeIconDataURI(icon); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
}
enabled := true
if request.Enabled != nil {
enabled = *request.Enabled
}
if enabled && !request.Force {
if err = jsplugin.PreflightRoutingConflict(jsplugin.DefaultRegistry.Generation(), loaded); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
}
plugin := model.TaskPlugin{
Key: loaded.Meta.Key, APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: request.Source, SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))),
Icon: icon, Enabled: enabled, Remark: request.Remark,
}
if err = model.SaveTaskPlugin(&plugin); err != nil {
common.ApiError(c, err)
return
}
if err = syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
}
func GetTaskPluginVersions(c *gin.Context) {
plugins, err := model.ListTaskPluginVersions(c.Param("key"))
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, plugins)
}
type taskPluginListItem struct {
Meta jsplugin.Meta `json:"meta"`
Source string `json:"source"`
Enabled bool `json:"enabled"`
Active bool `json:"active"`
SourceHash string `json:"source_hash"`
HasIcon bool `json:"has_icon"`
Remark string `json:"remark"`
RuntimeStatus string `json:"runtime_status"`
RuntimeError string `json:"runtime_error,omitempty"`
FactoryMeta *jsplugin.Meta `json:"factory_meta,omitempty"`
ChannelCount int `json:"channel_count"`
InFlightCount int64 `json:"in_flight_count"`
}
type taskPluginRebuildOutcome struct {
Status string `json:"status"`
AttemptedAt time.Time `json:"attempted_at"`
Generation uint64 `json:"generation"`
DatabaseRevision string `json:"database_revision,omitempty"`
PluginErrorCount int `json:"plugin_error_count"`
Error string `json:"error,omitempty"`
}
type taskPluginRuntimeStatus struct {
CurrentGeneration uint64 `json:"current_generation"`
GenerationPublishedAt time.Time `json:"generation_published_at"`
DatabaseRevision string `json:"database_revision"`
DatabaseError string `json:"database_error,omitempty"`
LastRebuild taskPluginRebuildOutcome `json:"last_rebuild"`
PluginErrors map[string]string `json:"plugin_errors"`
}
func ListTaskPlugins(c *gin.Context) {
databasePlugins, err := model.ListTaskPlugins()
if err != nil {
common.ApiError(c, err)
return
}
snapshot := jsplugin.DefaultRegistry.Snapshot()
factory := make(map[string]jsplugin.Meta, len(snapshot.Factory))
override := make(map[string]jsplugin.Meta, len(snapshot.Override))
for _, meta := range snapshot.Factory {
factory[meta.Key] = meta
}
for _, meta := range snapshot.Override {
override[meta.Key] = meta
}
activeRows := make(map[string]model.TaskPlugin)
keys := make(map[string]struct{}, len(factory)+len(databasePlugins))
for key := range factory {
keys[key] = struct{}{}
}
for _, plugin := range databasePlugins {
keys[plugin.Key] = struct{}{}
if plugin.Active {
activeRows[plugin.Key] = plugin
}
}
runtimeErrors := jsplugin.DefaultRegistry.RoutingErrors()
taskPluginSyncState.Lock()
maps.Copy(runtimeErrors, taskPluginSyncState.errors)
taskPluginSyncState.Unlock()
items := make([]taskPluginListItem, 0, len(keys))
for key := range keys {
factoryMeta, hasFactory := factory[key]
row, hasOverride := activeRows[key]
item := taskPluginListItem{Enabled: true, Active: true, RuntimeStatus: "registered"}
if hasOverride {
item.Source = "override"
if hasFactory {
item.Source = "override_over_factory"
factoryCopy := factoryMeta
item.FactoryMeta = &factoryCopy
}
item.Meta = jsplugin.Meta{Key: row.Key, Version: row.Version, APIVersion: row.APIVersion}
if compiled, compileErr := jsplugin.NewRegistry().Register(row.Source, jsplugin.Options{Key: row.Key, Version: row.Version}); compileErr == nil {
item.Meta = compiled.Meta
}
item.Enabled = row.Enabled
item.Active = row.Active
item.SourceHash = row.SourceHash
item.HasIcon = row.HasIcon()
item.Remark = row.Remark
if message := runtimeErrors[key]; message != "" {
item.RuntimeStatus = "compile_failed"
item.RuntimeError = message
} else if runtimeMeta, ok := override[key]; ok {
item.Meta = runtimeMeta
} else if !row.Enabled {
item.RuntimeStatus = "disabled_fallback"
} else {
item.RuntimeStatus = "not_registered"
}
// "disabled_fallback" promises that the built-in still serves. When
// the factory layer is suppressed as well, nothing serves this key.
if item.RuntimeStatus == "disabled_fallback" && hasFactory && setting.IsTaskPluginFactoryDisabled(key) {
item.RuntimeStatus = "disabled"
}
} else {
item.Source = "factory"
item.Meta = factoryMeta
item.Enabled = !setting.IsTaskPluginFactoryDisabled(key)
_, _, item.HasIcon = plugins.Icon(key)
source, sourceErr := plugins.Source(key)
if sourceErr == nil {
item.SourceHash = fmt.Sprintf("%x", sha256.Sum256([]byte(source)))
}
if !item.Enabled {
item.RuntimeStatus = "disabled"
} else if message := runtimeErrors[key]; message != "" {
item.RuntimeStatus = "compile_failed"
item.RuntimeError = message
}
}
if !hasFactory {
channels, inFlight, usageErr := model.GetTaskPluginUsage(key)
if usageErr != nil {
common.ApiError(c, usageErr)
return
}
item.ChannelCount = len(channels)
item.InFlightCount = inFlight
}
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].Meta.SortPriority != items[j].Meta.SortPriority {
return items[i].Meta.SortPriority > items[j].Meta.SortPriority
}
return items[i].Meta.Key < items[j].Meta.Key
})
common.ApiSuccess(c, items)
}
func GetTaskPluginRuntime(c *gin.Context) {
routingStatus := jsplugin.DefaultRegistry.RoutingStatus()
pluginErrors := routingStatus.Errors
taskPluginSyncState.Lock()
maps.Copy(pluginErrors, taskPluginSyncState.errors)
lastRebuild := taskPluginSyncState.lastRebuild
lastDatabaseRevision := lastRebuild.DatabaseRevision
taskPluginSyncState.Unlock()
registryRebuild := routingStatus.LastRebuild
if lastRebuild.AttemptedAt.Before(registryRebuild.AttemptedAt) {
lastRebuild = taskPluginRebuildOutcome{
Status: registryRebuild.Status,
AttemptedAt: registryRebuild.AttemptedAt,
Generation: registryRebuild.Generation,
Error: registryRebuild.Error,
}
}
if lastRebuild.Status == "" {
lastRebuild.Status = "never"
}
lastRebuild.PluginErrorCount = len(pluginErrors)
if lastRebuild.Status == "success" && len(pluginErrors) > 0 {
lastRebuild.Status = "partial"
}
status := taskPluginRuntimeStatus{
DatabaseRevision: lastDatabaseRevision,
LastRebuild: lastRebuild,
PluginErrors: pluginErrors,
}
databaseSnapshot, err := model.GetTaskPluginSyncSnapshot()
if err != nil {
status.DatabaseError = "database snapshot unavailable"
} else {
status.DatabaseRevision = databaseSnapshot.Revision
}
if routingStatus.Generation != nil {
status.CurrentGeneration = routingStatus.Generation.Number
status.GenerationPublishedAt = routingStatus.Generation.PublishedAt
}
common.ApiSuccess(c, status)
}
type taskPluginDetail struct {
Plugin *model.TaskPlugin `json:"plugin,omitempty"`
Meta jsplugin.Meta `json:"meta"`
Source string `json:"source"`
Layer string `json:"layer"`
HasIcon bool `json:"has_icon"`
}
// GetTaskPluginIcon serves a plugin logo as an image. The active override wins
// (or the requested ?version=), then the factory sidecar. Data icons are only
// ever drawn through <img>, and the nosniff header keeps a browser from
// treating an SVG response as a document.
func GetTaskPluginIcon(c *gin.Context) {
key := c.Param("key")
icon := ""
plugin, err := model.GetTaskPluginVersion(key, c.Query("version"))
if err == nil {
icon = plugin.Icon
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiError(c, err)
return
}
if icon == "" && c.Query("version") == "" {
icon = plugins.IconDataURI(key)
}
if icon == "" {
c.AbortWithStatus(http.StatusNotFound)
return
}
mediaType, data, err := jsplugin.DecodeIconDataURI(icon)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Header("Cache-Control", "private, max-age=3600")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
c.Data(http.StatusOK, mediaType, data)
}
func GetTaskPlugin(c *gin.Context) {
key := c.Param("key")
version := c.Query("version")
plugin, err := model.GetTaskPluginVersion(key, version)
if err == nil {
loaded, compileErr := jsplugin.NewRegistry().Register(plugin.Source, jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
if compileErr != nil {
common.ApiErrorMsg(c, compileErr.Error())
return
}
common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
return
}
if !errors.Is(err, gorm.ErrRecordNotFound) || version != "" {
common.ApiError(c, err)
return
}
source, err := plugins.Source(key)
if err != nil {
common.ApiErrorMsg(c, "task plugin not found")
return
}
loaded, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: key})
if err != nil {
common.ApiError(c, err)
return
}
_, _, hasIcon := plugins.Icon(key)
common.ApiSuccess(c, taskPluginDetail{Meta: loaded.Meta, Source: source, Layer: "factory", HasIcon: hasIcon})
}
type taskPluginDryRunRequest struct {
Hook string `json:"hook" binding:"required"`
Member string `json:"member"`
Args []json.RawMessage `json:"args"`
}
func DryRunTaskPlugin(c *gin.Context) {
var request taskPluginDryRunRequest
if err := c.ShouldBindJSON(&request); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
detailSource := ""
plugin, err := model.GetTaskPluginVersion(c.Param("key"), "")
if err == nil {
detailSource = plugin.Source
} else if errors.Is(err, gorm.ErrRecordNotFound) {
detailSource, err = plugins.Source(c.Param("key"))
}
if err != nil {
common.ApiErrorMsg(c, "task plugin not found")
return
}
loaded, err := jsplugin.NewRegistry().Register(detailSource, jsplugin.Options{Key: c.Param("key")})
if err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
args := make([]any, len(request.Args))
for index, raw := range request.Args {
if err = common.Unmarshal(raw, &args[index]); err != nil {
common.ApiErrorMsg(c, fmt.Sprintf("invalid argument %d: %v", index+1, err))
return
}
}
var output any
if request.Member == "" {
output, err = loaded.Engine.Call(context.Background(), request.Hook, args...)
} else {
output, err = loaded.Engine.CallMember(context.Background(), request.Hook, request.Member, args...)
}
if err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
common.ApiSuccess(c, output)
}
func DeleteTaskPluginVersion(c *gin.Context) {
key := c.Param("key")
version := c.Param("version")
plugin, lookupErr := model.GetTaskPluginVersion(key, version)
if lookupErr != nil {
if errors.Is(lookupErr, gorm.ErrRecordNotFound) {
common.ApiErrorMsg(c, "override plugin version not found; factory plugins cannot be deleted")
return
}
common.ApiError(c, lookupErr)
return
}
if plugin.Active && !taskPluginHasFactory(key) {
channels, inFlight, usageErr := model.GetTaskPluginUsage(key)
if usageErr != nil {
common.ApiError(c, usageErr)
return
}
if (len(channels) > 0 || inFlight > 0) && c.Query("force") != "true" {
c.JSON(200, gin.H{"success": false, "message": "task plugin is still in use", "data": gin.H{"channels": channels, "in_flight_count": inFlight}})
return
}
}
_, err := model.DeleteTaskPluginVersion(key, version)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorMsg(c, "override plugin version not found; factory plugins cannot be deleted")
return
}
common.ApiError(c, err)
return
}
if err = syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
type taskPluginActivateRequest struct {
Version string `json:"version" binding:"required"`
}
func ActivateTaskPlugin(c *gin.Context) {
var request taskPluginActivateRequest
if err := c.ShouldBindJSON(&request); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
versions, err := model.ListTaskPluginVersions(c.Param("key"))
if err != nil {
common.ApiError(c, err)
return
}
var target *model.TaskPlugin
for i := range versions {
if versions[i].Version == request.Version {
target = &versions[i]
break
}
}
if target == nil {
common.ApiErrorMsg(c, "plugin version not found")
return
}
if _, err = jsplugin.NewRegistry().Register(target.Source, jsplugin.Options{Key: target.Key, Version: target.Version}); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
if err = model.ActivateTaskPlugin(target.Key, target.Version); err != nil {
common.ApiError(c, err)
return
}
if err = syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
type taskPluginStatusRequest struct {
Enabled *bool `json:"enabled" binding:"required"`
}
func SetTaskPluginStatus(c *gin.Context) {
var request taskPluginStatusRequest
if err := c.ShouldBindJSON(&request); err != nil || request.Enabled == nil {
common.ApiErrorMsg(c, "enabled is required")
return
}
key := c.Param("key")
disabledChannels := 0
if !*request.Enabled {
channels, inFlight, usageErr := model.GetTaskPluginUsage(key)
if usageErr != nil {
common.ApiError(c, usageErr)
return
}
cascade := c.Query("cascade") == "true"
force := c.Query("force") == "true"
if (len(channels) > 0 && !cascade) || (inFlight > 0 && !force) {
c.JSON(200, gin.H{"success": false, "message": "task plugin is still in use", "data": gin.H{"channels": channels, "in_flight_count": inFlight}})
return
}
if cascade {
for _, channel := range channels {
if model.UpdateChannelStatus(channel.Id, "", common.ChannelStatusManuallyDisabled, "task plugin disabled") {
disabledChannels++
}
}
}
}
_, lookupErr := model.GetTaskPluginVersion(key, "")
hasActiveOverride := lookupErr == nil
if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
common.ApiError(c, lookupErr)
return
}
// Switching a key off must silence every layer that can serve it. The
// factory built-in goes into the disabled set even when an override row
// exists; otherwise the built-in would keep routing the same models (and
// blocking same-model uploads) right after the administrator disabled the
// plugin. Switching on reverses both layers.
if taskPluginHasFactory(key) {
keys := setting.GetTaskPluginDisabledFactoryKeys()
if *request.Enabled {
next := make([]string, 0, len(keys))
for _, item := range keys {
if item != key {
next = append(next, item)
}
}
keys = next
} else {
keys = append(append([]string{}, keys...), key)
}
if err := setting.SetTaskPluginDisabledFactoryKeysOption(keys); err != nil {
common.ApiError(c, err)
return
}
encoded, err := common.Marshal(setting.GetTaskPluginDisabledFactoryKeys())
if err != nil {
common.ApiError(c, err)
return
}
if err = model.UpdateOption(setting.TaskPluginDisabledFactoryKeysKey, string(encoded)); err != nil {
common.ApiError(c, err)
return
}
if !hasActiveOverride {
common.ApiSuccess(c, gin.H{"plugin_enabled": *request.Enabled, "disabled_channels": disabledChannels})
return
}
}
if err := model.SetTaskPluginEnabled(key, *request.Enabled); err != nil {
common.ApiError(c, err)
return
}
if err := syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{"plugin_enabled": *request.Enabled, "disabled_channels": disabledChannels})
}
func taskPluginHasFactory(key string) bool {
for _, meta := range jsplugin.DefaultRegistry.Snapshot().Factory {
if meta.Key == key {
return true
}
}
return false
}
func GetTaskPluginMarketplaceSources(c *gin.Context) {
common.ApiSuccess(c, setting.GetTaskPluginMarketplaceSources())
}
func UpdateTaskPluginMarketplaceSources(c *gin.Context) {
var sources []setting.TaskPluginMarketplaceSource
if err := c.ShouldBindJSON(&sources); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
if sources == nil {
sources = []setting.TaskPluginMarketplaceSource{}
}
for i := range sources {
name := strings.TrimSpace(sources[i].Name)
indexURL := strings.TrimSpace(sources[i].IndexURL)
if name == "" {
common.ApiErrorMsg(c, "marketplace source name is required")
return
}
parsed, err := url.Parse(indexURL)
if err != nil || !parsed.IsAbs() || parsed.Host == "" || (!strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https")) {
common.ApiErrorMsg(c, "marketplace source index_url must be an absolute http(s) URL")
return
}
sources[i].Name = name
sources[i].IndexURL = indexURL
}
encoded, err := common.Marshal(sources)
if err != nil {
common.ApiError(c, err)
return
}
if err = model.UpdateOption(setting.TaskPluginMarketplaceSourcesKey, string(encoded)); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, sources)
}
func GetTaskPluginOptions(c *gin.Context) {
snapshot := jsplugin.DefaultRegistry.Snapshot()
seen := make(map[string]bool)
options := make([]gin.H, 0, len(snapshot.Factory)+len(snapshot.Override))
for layer, metas := range [][]jsplugin.Meta{snapshot.Override, snapshot.Factory} {
for _, meta := range metas {
if seen[meta.Key] {
continue
}
// Disabled factory keys are omitted from bind options. The disabled
// set suppresses only the factory fallback; an enabled override for
// the same key is listed in the override pass and still appears.
if layer == 1 && setting.IsTaskPluginFactoryDisabled(meta.Key) {
continue
}
if _, ok := jsplugin.DefaultRegistry.Get(meta.Key); !ok {
continue
}
seen[meta.Key] = true
hasIcon := false
if layer == 0 {
if row, rowErr := model.GetTaskPluginVersion(meta.Key, ""); rowErr == nil {
hasIcon = row.HasIcon()
}
} else {
_, _, hasIcon = plugins.Icon(meta.Key)
}
options = append(options, gin.H{
"key": meta.Key,
"name": meta.Name,
"icon": meta.Icon,
"hasIcon": hasIcon,
"baseUrl": meta.BaseURL,
"sortPriority": meta.SortPriority,
"website": meta.Website,
"models": meta.Models,
"usageSchema": meta.UsageSchema,
})
}
}
sort.Slice(options, func(i, j int) bool {
left, right := options[i]["sortPriority"].(int), options[j]["sortPriority"].(int)
if left != right {
return left > right
}
return options[i]["key"].(string) < options[j]["key"].(string)
})
common.ApiSuccess(c, options)
}
var taskPluginSyncState = struct {
sync.Mutex
hashes map[string]string
errors map[string]string
lastRebuild taskPluginRebuildOutcome
}{hashes: map[string]string{}, errors: map[string]string{}}
func syncTaskPluginsOnce() error {
return syncTaskPluginsOnceContext(context.Background())
}
func syncTaskPluginsOnceContext(ctx context.Context) error {
started := time.Now()
taskPluginSyncState.Lock()
defer taskPluginSyncState.Unlock()
databaseSnapshot, err := model.GetTaskPluginSyncSnapshot()
if err != nil {
syncErr := fmt.Errorf("sync task plugins: %w", err)
taskPluginSyncState.lastRebuild = taskPluginRebuildOutcome{
Status: "failed",
AttemptedAt: time.Now(),
Generation: jsplugin.DefaultRegistry.Generation().Number,
DatabaseRevision: taskPluginSyncState.lastRebuild.DatabaseRevision,
Error: syncErr.Error(),
}
logger.LogDebug(
ctx,
"task_plugin subsystem=sync event=failed stage=database_snapshot retained_generation=%d elapsed_ms=%d",
jsplugin.DefaultRegistry.Generation().Number,
time.Since(started).Milliseconds(),
)
return syncErr
}
databasePlugins := databaseSnapshot.Plugins
sort.Slice(databasePlugins, func(i, j int) bool { return databasePlugins[i].Key < databasePlugins[j].Key })
currentOverrides := jsplugin.DefaultRegistry.OverridePlugins()
generationBefore := jsplugin.DefaultRegistry.Generation().Number
logger.LogDebug(
ctx,
"task_plugin subsystem=sync event=start database_revision=%q generation=%d desired_plugins=%d current_overrides=%d",
databaseSnapshot.Revision,
generationBefore,
len(databasePlugins),
len(currentOverrides),
)
nextOverrides := make([]*jsplugin.LoadedPlugin, 0, len(databasePlugins))
nextHashes := make(map[string]string, len(databasePlugins))
seen := make(map[string]bool, len(databasePlugins))
for _, plugin := range databasePlugins {
seen[plugin.Key] = true
if current := currentOverrides[plugin.Key]; current != nil && taskPluginSyncState.hashes[plugin.Key] == plugin.SourceHash {
nextOverrides = append(nextOverrides, current)
nextHashes[plugin.Key] = plugin.SourceHash
logger.LogDebug(
ctx,
"task_plugin subsystem=sync event=plugin plugin=%q version=%q action=reuse",
plugin.Key,
plugin.Version,
)
continue
}
logger.LogDebug(
ctx,
"task_plugin subsystem=sync event=plugin plugin=%q version=%q action=compile_start",
plugin.Key,
plugin.Version,
)
compiled, compileErr := jsplugin.CompilePlugin(plugin.Source, jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
if compileErr != nil {
retainedIncumbent := false
if current := currentOverrides[plugin.Key]; current != nil {
nextOverrides = append(nextOverrides, current)
retainedIncumbent = true
if currentHash := taskPluginSyncState.hashes[plugin.Key]; currentHash != "" {
nextHashes[plugin.Key] = currentHash
}
}
taskPluginSyncState.errors[plugin.Key] = compileErr.Error()
common.SysError(fmt.Sprintf("compile task plugin %s@%s: %v", plugin.Key, plugin.Version, compileErr))
logger.LogDebug(
ctx,
"task_plugin subsystem=sync event=plugin plugin=%q version=%q action=compile_failed retained_incumbent=%t",
plugin.Key,
plugin.Version,
retainedIncumbent,
)
continue
}
nextOverrides = append(nextOverrides, compiled)
nextHashes[plugin.Key] = plugin.SourceHash
delete(taskPluginSyncState.errors, plugin.Key)
logger.LogDebug(
ctx,
"task_plugin subsystem=sync event=plugin plugin=%q version=%q action=compile_success",
plugin.Key,
plugin.Version,
)
}
if err = jsplugin.DefaultRegistry.ReplaceOverrides(nextOverrides); err != nil {
syncErr := fmt.Errorf("publish task plugin generation: %w", err)
taskPluginSyncState.lastRebuild = taskPluginRebuildOutcome{
Status: "failed",
AttemptedAt: time.Now(),
Generation: jsplugin.DefaultRegistry.Generation().Number,
DatabaseRevision: databaseSnapshot.Revision,
Error: syncErr.Error(),
}
logger.LogDebug(
ctx,
"task_plugin subsystem=sync event=failed stage=publish retained_generation=%d retained_generation_active=true database_revision=%q elapsed_ms=%d",
jsplugin.DefaultRegistry.Generation().Number,
databaseSnapshot.Revision,
time.Since(started).Milliseconds(),
)
return syncErr
}
taskPluginSyncState.hashes = nextHashes
for key := range taskPluginSyncState.errors {
if !seen[key] {
delete(taskPluginSyncState.errors, key)
}
}
pluginErrors := jsplugin.DefaultRegistry.RoutingErrors()
maps.Copy(pluginErrors, taskPluginSyncState.errors)
pluginErrorCount := len(pluginErrors)
status := "success"
if pluginErrorCount > 0 {
status = "partial"
}
taskPluginSyncState.lastRebuild = taskPluginRebuildOutcome{
Status: status,
AttemptedAt: time.Now(),
Generation: jsplugin.DefaultRegistry.Generation().Number,
DatabaseRevision: databaseSnapshot.Revision,
PluginErrorCount: pluginErrorCount,
}
logger.LogDebug(
ctx,
"task_plugin subsystem=sync event=complete database_revision=%q previous_generation=%d generation=%d status=%q active_overrides=%d plugin_errors=%d elapsed_ms=%d",
databaseSnapshot.Revision,
generationBefore,
jsplugin.DefaultRegistry.Generation().Number,
status,
len(jsplugin.DefaultRegistry.ActiveOverridePlugins()),
pluginErrorCount,
time.Since(started).Milliseconds(),
)
return nil
}
func SyncTaskPluginsOnce() {
if err := syncTaskPluginsOnce(); err != nil {
common.SysError(err.Error())
}
}
func SyncTaskPlugins() {
SyncTaskPluginsOnce()
for range time.NewTicker(30 * time.Second).C {
SyncTaskPluginsOnce()
}
}
+269
View File
@@ -0,0 +1,269 @@
package controller
import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
)
// taskPluginSubmitDiagnostics keeps plugin-only lifecycle logging out of the
// ordinary task path. An empty plugin key makes every method a no-op.
type taskPluginSubmitDiagnostics struct {
context *gin.Context
pluginKey string
generation uint64
}
func newTaskPluginSubmitDiagnostics(c *gin.Context) taskPluginSubmitDiagnostics {
diagnostics := taskPluginSubmitDiagnostics{
context: c,
pluginKey: c.GetString("expected_task_plugin_key"),
}
if diagnostics.pluginKey == "" {
return diagnostics
}
if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedPlugin); exists {
if pinned, ok := pinnedValue.(pluginruntime.PinnedPlugin); ok && pinned.Generation != nil {
diagnostics.generation = pinned.Generation.Number
}
}
return diagnostics
}
func (d taskPluginSubmitDiagnostics) start(info *relaycommon.RelayInfo) {
if d.pluginKey == "" {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=start generation=%d plugin=%q model=%q action_present=%t",
d.generation,
d.pluginKey,
info.OriginModelName,
info.Action != "",
)
}
func (d taskPluginSubmitDiagnostics) refund(stage string) {
if d.pluginKey == "" {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=refund_invoked generation=%d plugin=%q stage=%q durable=false",
d.generation,
d.pluginKey,
stage,
)
}
func (d taskPluginSubmitDiagnostics) cancelled(stage string, attempt int) {
if d.pluginKey == "" {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=cancelled generation=%d plugin=%q stage=%q attempt=%d",
d.generation,
d.pluginKey,
stage,
attempt,
)
}
func (d taskPluginSubmitDiagnostics) attempt(attempt int, channel *model.Channel, locked bool) {
if d.pluginKey == "" || channel == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=attempt generation=%d plugin=%q attempt=%d channel_id=%d channel_type=%d locked=%t",
d.generation,
d.pluginKey,
attempt,
channel.Id,
channel.Type,
locked,
)
}
func (d taskPluginSubmitDiagnostics) attemptSucceeded(attempt int, result *relay.TaskSubmitResult) {
if d.pluginKey == "" || result == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=attempt_succeeded generation=%d plugin=%q attempt=%d platform=%q quota=%d task_data_bytes=%d client_response=%t immediate=%t",
d.generation,
d.pluginKey,
attempt,
result.Platform,
result.Quota,
len(result.TaskData),
result.ClientResponse != nil,
result.Immediate != nil,
)
}
func (d taskPluginSubmitDiagnostics) attemptFailed(attempt int, channel *model.Channel, taskErr *dto.TaskError, willRetry bool) {
if d.pluginKey == "" || channel == nil || taskErr == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=attempt_failed generation=%d plugin=%q attempt=%d channel_id=%d channel_type=%d code=%q status=%d local=%t will_retry=%t",
d.generation,
d.pluginKey,
attempt,
channel.Id,
channel.Type,
taskErr.Code,
taskErr.StatusCode,
taskErr.LocalError,
willRetry,
)
}
func (d taskPluginSubmitDiagnostics) failed(stage, reason string, taskErr *dto.TaskError, durable bool) {
if d.pluginKey == "" {
return
}
code := ""
status := 0
local := true
if taskErr != nil {
code = taskErr.Code
status = taskErr.StatusCode
local = taskErr.LocalError
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=failed generation=%d plugin=%q stage=%q reason=%q code=%q status=%d local=%t durable=%t",
d.generation,
d.pluginKey,
stage,
reason,
code,
status,
local,
durable,
)
}
func (d taskPluginSubmitDiagnostics) reserve(event string, quota int) {
if d.pluginKey == "" {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=%s generation=%d plugin=%q quota=%d",
event,
d.generation,
d.pluginKey,
quota,
)
}
func (d taskPluginSubmitDiagnostics) insertStart(task *model.Task) {
if d.pluginKey == "" || task == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=insert_start generation=%d plugin=%q public_task_id=%q platform=%q channel_id=%d quota=%d",
d.generation,
d.pluginKey,
task.TaskID,
task.Platform,
task.ChannelId,
task.Quota,
)
}
func (d taskPluginSubmitDiagnostics) durable(task *model.Task) {
if d.pluginKey == "" || task == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=durable generation=%d plugin=%q public_task_id=%q status=%q durable=true",
d.generation,
d.pluginKey,
task.TaskID,
taskPluginDebugStatus(string(task.Status)),
)
}
func (d taskPluginSubmitDiagnostics) settleStart(task *model.Task, quota int) {
if d.pluginKey == "" || task == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=settle_start generation=%d plugin=%q public_task_id=%q quota=%d durable=true",
d.generation,
d.pluginKey,
task.TaskID,
quota,
)
}
func (d taskPluginSubmitDiagnostics) complete(task *model.Task, quota int) {
if d.pluginKey == "" || task == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=complete generation=%d plugin=%q public_task_id=%q quota=%d durable=true",
d.generation,
d.pluginKey,
task.TaskID,
quota,
)
}
func (d taskPluginSubmitDiagnostics) present(task *model.Task, presenter string) {
if d.pluginKey == "" || task == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=present generation=%d plugin=%q public_task_id=%q presenter=%q durable=true",
d.generation,
d.pluginKey,
task.TaskID,
presenter,
)
}
func (d taskPluginSubmitDiagnostics) presentError(taskErr *dto.TaskError) {
if d.pluginKey == "" || taskErr == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=present_error generation=%d plugin=%q code=%q status=%d local=%t",
d.generation,
d.pluginKey,
taskErr.Code,
taskErr.StatusCode,
taskErr.LocalError,
)
}
func taskPluginDebugStatus(status string) string {
switch model.TaskStatus(status) {
case model.TaskStatusSubmitted,
model.TaskStatusQueued,
model.TaskStatusInProgress,
model.TaskStatusSuccess,
model.TaskStatusFailure:
return status
default:
return "unknown"
}
}
+88
View File
@@ -0,0 +1,88 @@
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskPluginSubmitDiagnosticsArePluginOnlyAndDoNotLogPayloads(t *testing.T) {
previousDebug := common.DebugEnabled
common.DebugEnabled = true
t.Cleanup(func() { common.DebugEnabled = previousDebug })
var output bytes.Buffer
common.LogWriterMu.Lock()
previousWriter := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &output
common.LogWriterMu.Unlock()
t.Cleanup(func() {
common.LogWriterMu.Lock()
gin.DefaultErrorWriter = previousWriter
common.LogWriterMu.Unlock()
})
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/tasks/debug-plugin", nil)
c.Set(common.RequestIdKey, "plugin-submit-request")
info := &relaycommon.RelayInfo{
OriginModelName: "safe-model",
TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: "https://private-action.invalid/?key=hidden"},
}
newTaskPluginSubmitDiagnostics(c).start(info)
assert.Empty(t, output.String())
c.Set("expected_task_plugin_key", "debug-plugin")
diagnostics := newTaskPluginSubmitDiagnostics(c)
diagnostics.start(info)
diagnostics.attemptSucceeded(1, &relay.TaskSubmitResult{
UpstreamTaskID: "private-upstream-canary",
TaskData: []byte("private-task-data-canary"),
ClientResponse: map[string]any{"secret": "private-client-response-canary"},
Platform: constant.TaskPlatform("debug-plugin"),
Quota: 12,
})
task := &model.Task{
TaskID: "public-task-id",
Platform: constant.TaskPlatform("debug-plugin"),
Status: model.TaskStatus("https://private-status.invalid/?key=hidden"),
PrivateData: model.TaskPrivateData{
UpstreamTaskID: "private-task-record-canary",
ResultURL: "https://private-url.invalid/result",
},
}
diagnostics.insertStart(task)
diagnostics.durable(task)
diagnostics.complete(task, 12)
logOutput := output.String()
require.Contains(t, logOutput, "plugin-submit-request")
assert.Contains(t, logOutput, `plugin="debug-plugin"`)
assert.Contains(t, logOutput, `public_task_id="public-task-id"`)
assert.Contains(t, logOutput, "task_data_bytes=24")
assert.Contains(t, logOutput, "action_present=true")
assert.Contains(t, logOutput, `status="unknown"`)
for _, secret := range []string{
"private-upstream-canary",
"private-task-data-canary",
"private-client-response-canary",
"private-task-record-canary",
"private-url.invalid",
"private-action.invalid",
"private-status.invalid",
"key=hidden",
} {
assert.NotContains(t, logOutput, secret)
}
}
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
)
// TelegramLegacyAuth retires the unsigned-flow widget endpoints. Existing
// Telegram bindings are used by the unified OAuth provider instead.
func TelegramLegacyAuth(c *gin.Context) {
c.JSON(http.StatusGone, gin.H{
"success": false,
"code": "TELEGRAM_LEGACY_AUTH_REMOVED",
"message": "Telegram login has changed. Reload the page and start Telegram OAuth again.",
})
}
Loaded 100 of 2381 files, more files were not shown because too many files have changed in this diff. Show more