feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+299 -20
View File
@@ -1,64 +1,343 @@
package controller
import (
"errors"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"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"))
// accessTokenMinLifetimeSecs is the shortest expiry a new access token may have.
const accessTokenMinLifetimeSecs = 60 * 60
type accessTokenItem struct {
model.UserAccessToken
Scopes []string `json:"scopes"`
}
type accessTokenCreateRequest struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresAt int64 `json:"expires_at"`
}
// accessTokenUpdateRequest renames a token; a present scopes field also
// replaces its grant.
type accessTokenUpdateRequest struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
}
func newAccessTokenItem(token *model.UserAccessToken) accessTokenItem {
scopes := token.GetScopes()
if scopes == nil {
scopes = []string{}
}
return accessTokenItem{UserAccessToken: *token, Scopes: scopes}
}
// writeAccessTokenError responds with a stable code and a web console message.
func writeAccessTokenError(c *gin.Context, status int, code, key string, params ...map[string]any) {
common.ApiErrorStatus(c, status, common.NewMessage(key, params...), gin.H{"code": code})
}
// normalizeAccessTokenName returns the trimmed name, or false when it is empty
// or longer than the 64-character column.
func normalizeAccessTokenName(name string) (string, bool) {
name = strings.TrimSpace(name)
count := utf8.RuneCountInString(name)
return name, count > 0 && count <= 64
}
func ListAccessTokens(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
tokens, err := model.ListUserAccessTokens(identity.UserID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
common.ApiSuccess(c, status)
items := make([]accessTokenItem, 0, len(tokens))
for i := range tokens {
items = append(items, newAccessTokenItem(&tokens[i]))
}
var legacy gin.H
if !model.LegacyAccessTokensRetired(common.GetTimestamp()) {
status, err := model.GetUserAccessTokenStatus(identity.UserID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if status.Exists {
legacy = gin.H{
"token_hint": status.TokenHint,
"token_ref": status.TokenRef,
"created_at": status.CreatedAt,
"last_used_at": status.LastUsedAt,
"last_used_ip": status.LastUsedIp,
"retire_at": model.LegacyAccessTokenRetireAt(),
}
}
}
common.ApiSuccess(c, gin.H{"items": items, "legacy": legacy})
}
func GenerateAccessToken(c *gin.Context) {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate}) == nil {
func GetAccessTokenCatalog(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
id := c.GetInt("id")
key, err := common.GenerateRandomKey(29 + common.GetRandomInt(4))
common.ApiSuccess(c, gin.H{
"groups": service.AccessTokenCatalog(identity.UserID, c.GetInt("role")),
"max_tokens": service.AccessTokenMaxPerUser,
"default_expiry_days": service.AccessTokenDefaultExpiryDays,
})
}
// GetAccessTokenScopes returns the unfiltered scope labels used to describe
// stored grants, such as those in audit records.
func GetAccessTokenScopes(c *gin.Context) {
if _, ok := requireBrowserSession(c); !ok {
return
}
common.ApiSuccess(c, gin.H{"resources": service.AccessTokenScopeDictionary()})
}
// CreateAccessToken validates the whole request before consuming the proof, so
// a rejected grant never burns the user's verification.
func CreateAccessToken(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
var req accessTokenCreateRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
writeAccessTokenError(c, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request body.")
return
}
name, ok := normalizeAccessTokenName(req.Name)
if !ok {
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_NAME_INVALID", "Access token name must be 1 to 64 characters.")
return
}
now := common.GetTimestamp()
if req.ExpiresAt != 0 && req.ExpiresAt < now+accessTokenMinLifetimeSecs {
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_EXPIRY_INVALID", "Access token expiry must be at least one hour from now.")
return
}
scopes, err := service.NormalizeAccessTokenScopes(identity.UserID, c.GetInt("role"), req.Scopes)
switch {
case errors.Is(err, service.ErrAccessTokenScopeForbidden):
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_FORBIDDEN", "You cannot grant one or more of these permissions.")
return
case err != nil:
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_INVALID", "Select at least one valid permission.")
return
}
proofContext, err := common.Marshal(service.AccessTokenGenerateContext{Scopes: scopes, ExpiresAt: req.ExpiresAt})
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 {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate, Context: proofContext}) == nil {
return
}
suffix, err := common.GenerateRandomCharsKey(43)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if existing != 0 {
common.ApiErrorI18n(c, i18n.MsgUuidDuplicate)
return
raw := model.AccessTokenPrefix + suffix
token := &model.UserAccessToken{
Name: name,
TokenHash: model.AccessTokenFingerprint(raw),
TokenHint: model.AccessTokenHint(raw),
ExpiresAt: req.ExpiresAt,
CreatedAt: now,
}
if err := model.UpdateUserAccessToken(id, key); err != nil {
if err := token.SetScopes(scopes); 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})
if err := model.CreateUserAccessToken(identity.UserID, token, service.AccessTokenMaxPerUser); err != nil {
if errors.Is(err, model.ErrAccessTokenLimit) {
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_LIMIT", "You can create at most {{count}} access tokens", map[string]any{"count": service.AccessTokenMaxPerUser})
return
}
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, identity.UserID, "access_token.generate", map[string]any{
"token_id": token.Id,
"name": token.Name,
"scopes": scopes,
"expires_at": token.ExpiresAt,
"token_ref": token.TokenHash,
})
common.ApiSuccess(c, gin.H{"token": raw, "item": newAccessTokenItem(token)})
}
func RevokeAccessToken(c *gin.Context) {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}) == nil {
// UpdateAccessToken renames a token without verification. Changing the grant
// is checked in full before the proof is consumed, like creation.
func UpdateAccessToken(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
ref, err := model.RevokeUserAccessToken(c.GetInt("id"))
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
var req accessTokenUpdateRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
writeAccessTokenError(c, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request body.")
return
}
name, ok := normalizeAccessTokenName(req.Name)
if !ok {
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_NAME_INVALID", "Access token name must be 1 to 64 characters.")
return
}
var scopes []string
var previous *model.UserAccessToken
if req.Scopes != nil {
previous, err = model.GetUserAccessToken(identity.UserID, id)
if errors.Is(err, model.ErrAccessTokenNotFound) {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
if err != nil {
writeSecurityOperationError(c, err)
return
}
scopes, err = service.NormalizeAccessTokenScopes(identity.UserID, c.GetInt("role"), req.Scopes)
switch {
case errors.Is(err, service.ErrAccessTokenScopeForbidden):
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_FORBIDDEN", "You cannot grant one or more of these permissions.")
return
case err != nil:
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_INVALID", "Select at least one valid permission.")
return
}
proofContext, err := common.Marshal(service.AccessTokenUpdateContext{TokenID: id, Scopes: scopes})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenUpdate, Context: proofContext}) == nil {
return
}
}
token, err := model.UpdateUserAccessToken(identity.UserID, id, name, scopes)
if errors.Is(err, model.ErrAccessTokenNotFound) {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
if err != nil {
writeSecurityOperationError(c, err)
return
}
if previous == nil {
recordUserSecurityAudit(c, identity.UserID, "access_token.rename", map[string]any{"token_id": token.Id, "name": token.Name})
common.ApiSuccess(c, newAccessTokenItem(token))
return
}
recordUserSecurityAudit(c, identity.UserID, "access_token.update", map[string]any{
"token_id": token.Id,
"name": token.Name,
"scopes": scopes,
"previous_scopes": previous.GetScopes(),
"token_ref": token.TokenHash,
})
common.ApiSuccess(c, newAccessTokenItem(token))
}
// DeleteAccessToken checks ownership before consuming the proof, so probing
// other users' token IDs never burns a verification.
func DeleteAccessToken(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
if _, err := model.GetUserAccessToken(identity.UserID, id); err != nil {
if errors.Is(err, model.ErrAccessTokenNotFound) {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
writeSecurityOperationError(c, err)
return
}
proofContext, err := common.Marshal(service.AccessTokenRevokeContext{TokenID: id})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: proofContext}) == nil {
return
}
token, err := model.DeleteUserAccessToken(identity.UserID, id)
if errors.Is(err, model.ErrAccessTokenNotFound) {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
if err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, identity.UserID, "access_token.revoke", map[string]any{"token_id": token.Id, "name": token.Name, "token_ref": token.TokenHash})
common.ApiSuccess(c, nil)
}
// RevokeLegacyAccessToken removes the pre-scope users.access_token credential.
// After the transition deadline the legacy token no longer exists for callers.
func RevokeLegacyAccessToken(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
if model.LegacyAccessTokensRetired(common.GetTimestamp()) {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
status, err := model.GetUserAccessTokenStatus(identity.UserID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if !status.Exists {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
proofContext, err := common.Marshal(service.AccessTokenRevokeContext{Legacy: true})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: proofContext}) == nil {
return
}
ref, err := model.RevokeUserAccessToken(identity.UserID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if ref != "" {
recordUserSecurityAudit(c, c.GetInt("id"), "access_token.revoke", map[string]any{"token_ref": ref})
recordUserSecurityAudit(c, identity.UserID, "access_token.revoke", map[string]any{"legacy": true, "token_ref": ref})
}
common.ApiSuccess(c, nil)
}
+453 -46
View File
@@ -17,6 +17,7 @@ import (
"gorm.io/driver/clickhouse"
"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"
@@ -37,9 +38,12 @@ func setupAccessTokenAudit(t *testing.T) (*model.User, string) {
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{}))
require.NoError(t, i18n.Init())
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.UserAccessToken{}, &model.Option{},
&model.AuthFlow{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.UserOAuthBinding{}))
model.DB, model.LOG_DB = db, db
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
common.RedisEnabled = false
previousMaster := common.IsMasterNode
common.IsMasterNode = true
@@ -66,56 +70,447 @@ func auditRequest(router http.Handler, method, path, token string) *httptest.Res
return response
}
func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
user, old := setupAccessTokenAudit(t)
// newAccessTokenTestRouter mirrors the production paths, so the access token
// route rules keyed by gin full path apply unchanged.
func newAccessTokenTestRouter() *gin.Engine {
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")
api := router.Group("/api")
api.GET("/verify/methods", middleware.UserAuth(), GetVerificationMethods)
api.POST("/verify", middleware.UserAuth(), UniversalVerify)
userRoute := api.Group("/user")
selfRoute := userRoute.Group("/", middleware.UserAuth())
selfRoute.GET("/self", func(c *gin.Context) { common.ApiSuccess(c, gin.H{"id": c.GetInt("id")}) })
selfRoute.POST("/2fa/setup", Setup2FA)
selfRoute.POST("/2fa/disable", Disable2FA)
tokenRoute := selfRoute.Group("/access_tokens")
tokenRoute.GET("", ListAccessTokens)
tokenRoute.GET("/catalog", GetAccessTokenCatalog)
tokenRoute.GET("/scopes", GetAccessTokenScopes)
tokenRoute.POST("", CreateAccessToken)
tokenRoute.PATCH("/:id", UpdateAccessToken)
tokenRoute.DELETE("/:id", DeleteAccessToken)
tokenRoute.DELETE("/legacy", RevokeLegacyAccessToken)
adminRoute := userRoute.Group("/", middleware.AdminAuth())
adminRoute.DELETE("/:id", DeleteUser)
return router
}
func accessTokenRequest(router http.Handler, method, path, credential, proof, body string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
request.Header.Set("Authorization", "Bearer "+credential)
request.Header.Set("Content-Type", "application/json")
if proof != "" {
request.Header.Set("X-Security-Proof", proof)
}
_, err = model.RevokeUserAccessToken(user.Id)
request.RemoteAddr = "192.0.2.8:4567"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func createScopedAccessToken(t *testing.T, userID int, expiresAt int64, scopes ...string) (string, *model.UserAccessToken) {
t.Helper()
suffix, err := common.GenerateRandomCharsKey(43)
require.NoError(t, err)
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", "new-token").Code)
ref, err := model.RevokeUserAccessToken(user.Id)
raw := model.AccessTokenPrefix + suffix
token := &model.UserAccessToken{Name: "test token", TokenHash: model.AccessTokenFingerprint(raw), TokenHint: model.AccessTokenHint(raw), ExpiresAt: expiresAt}
require.NoError(t, token.SetScopes(scopes))
require.NoError(t, model.CreateUserAccessToken(userID, token, service.AccessTokenMaxPerUser))
return raw, token
}
// createAccessTokenTestSession returns a browser session identity and the
// dashboard JWT that carries it.
func createAccessTokenTestSession(t *testing.T, userID int, sid string) (service.AuthIdentity, string) {
t.Helper()
now := time.Now().Unix()
require.NoError(t, model.CreateUserSession(&model.UserSession{SID: sid, UserID: userID, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "refresh-" + sid, LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600}))
identity := service.AuthIdentity{UserID: userID, SessionID: sid, UserAuthVersion: 1, SessionVersion: 1}
jwt, _, err := service.IssueAccessToken(identity)
require.NoError(t, err)
assert.Empty(t, ref, "repeated revocation is idempotent")
status, err = model.GetUserAccessTokenStatus(user.Id)
return identity, jwt
}
func issueAccessTokenGenerateProof(t *testing.T, identity service.AuthIdentity, expiresAt int64, scopes ...string) string {
t.Helper()
context, err := common.Marshal(service.AccessTokenGenerateContext{Scopes: scopes, ExpiresAt: expiresAt})
require.NoError(t, err)
assert.False(t, status.Exists)
assert.Nil(t, status.CreatedAt)
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate, Context: context}, "password")
}
func issueAccessTokenRevokeProof(t *testing.T, identity service.AuthIdentity, target service.AccessTokenRevokeContext) string {
t.Helper()
context, err := common.Marshal(target)
require.NoError(t, err)
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: context}, "password")
}
func issueAccessTokenUpdateProof(t *testing.T, identity service.AuthIdentity, tokenID int, scopes ...string) string {
t.Helper()
context, err := common.Marshal(service.AccessTokenUpdateContext{TokenID: tokenID, Scopes: scopes})
require.NoError(t, err)
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenUpdate, Context: context}, "password")
}
// setLegacyAccessTokenRetireAt moves the stored transition deadline the way an
// operator restoring the option row would, then reloads it.
func setLegacyAccessTokenRetireAt(t *testing.T, retireAt int64) {
t.Helper()
require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "LegacyAccessTokenRetireAt"}).Update("value", fmt.Sprint(retireAt)).Error)
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
require.Equal(t, retireAt, model.LegacyAccessTokenRetireAt())
}
type accessTokenListResponse struct {
Success bool `json:"success"`
Code string `json:"code"`
Data struct {
Items []struct {
Id int `json:"id"`
Name string `json:"name"`
TokenRef string `json:"token_ref"`
Scopes []string `json:"scopes"`
} `json:"items"`
Legacy *struct {
TokenRef string `json:"token_ref"`
RetireAt int64 `json:"retire_at"`
} `json:"legacy"`
} `json:"data"`
}
func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
user, legacy := setupAccessTokenAudit(t)
router := newAccessTokenTestRouter()
identity, browser := createAccessTokenTestSession(t, user.Id, "lifecycle-session")
scoped, scopedToken := createScopedAccessToken(t, user.Id, 0, "profile:read")
tokenPath := fmt.Sprintf("/api/user/access_tokens/%d", scopedToken.Id)
for _, credential := range []string{scoped, legacy} {
for _, endpoint := range []struct{ method, path string }{
{"GET", "/api/user/access_tokens"}, {"GET", "/api/user/access_tokens/catalog"}, {"GET", "/api/user/access_tokens/scopes"}, {"POST", "/api/user/access_tokens"},
{"PATCH", tokenPath}, {"DELETE", tokenPath}, {"DELETE", "/api/user/access_tokens/legacy"},
} {
response := accessTokenRequest(router, endpoint.method, endpoint.path, credential, "", `{"name":"renamed","scopes":["profile:read"],"expires_at":0}`)
assert.Equal(t, http.StatusForbidden, response.Code, endpoint.method+" "+endpoint.path)
assert.Contains(t, response.Body.String(), `"code":"AUTH_SESSION_REQUIRED"`)
}
}
tokens, err := model.ListUserAccessTokens(user.Id)
require.NoError(t, err)
require.Len(t, tokens, 1)
assert.Equal(t, "test token", tokens[0].Name, "an access token cannot manage access tokens")
response := accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
require.Equal(t, http.StatusOK, response.Code)
for _, secret := range []string{scoped, legacy} {
assert.NotContains(t, response.Body.String(), secret)
}
var listed accessTokenListResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
require.Len(t, listed.Data.Items, 1)
assert.Equal(t, scopedToken.TokenHash, listed.Data.Items[0].TokenRef)
assert.Equal(t, []string{"profile:read"}, listed.Data.Items[0].Scopes)
require.NotNil(t, listed.Data.Legacy)
assert.Equal(t, model.AccessTokenFingerprint(legacy), listed.Data.Legacy.TokenRef)
assert.Equal(t, model.LegacyAccessTokenRetireAt(), listed.Data.Legacy.RetireAt)
// A token id only resolves among the caller's own tokens.
other := &model.User{Username: "audit-other", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "audit-other"}
require.NoError(t, model.DB.Create(other).Error)
otherRaw, otherToken := createScopedAccessToken(t, other.Id, 0, "profile:read")
otherProof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{TokenID: otherToken.Id})
response = accessTokenRequest(router, "DELETE", fmt.Sprintf("/api/user/access_tokens/%d", otherToken.Id), browser, otherProof, "")
assert.Equal(t, http.StatusNotFound, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_NOT_FOUND"`)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", otherRaw, "", "").Code)
// A revoke proof names one target and is not spent on another one.
proof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{TokenID: scopedToken.Id})
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, proof, "")
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONTEXT_MISMATCH"`)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
response = accessTokenRequest(router, "DELETE", tokenPath, browser, proof, "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, "GET", "/api/user/self", scoped, "", "").Code)
response = accessTokenRequest(router, "DELETE", tokenPath, browser, proof, "")
assert.Equal(t, http.StatusNotFound, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_NOT_FOUND"`)
legacyProof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{Legacy: true})
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, legacyProof, "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
response = accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
listed = accessTokenListResponse{}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
assert.Empty(t, listed.Data.Items)
assert.Nil(t, listed.Data.Legacy)
// After the deadline a stored legacy value is inert: it is neither listed,
// revocable nor accepted, and the column itself is left untouched.
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("access_token", legacy).Error)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
retireAt := model.LegacyAccessTokenRetireAt()
setLegacyAccessTokenRetireAt(t, time.Now().Unix()-1)
t.Cleanup(func() { setLegacyAccessTokenRetireAt(t, retireAt) })
response = accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
listed = accessTokenListResponse{}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
assert.Nil(t, listed.Data.Legacy)
legacyProof = issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{Legacy: true})
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, legacyProof, "")
assert.Equal(t, http.StatusNotFound, response.Code)
response = accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "")
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_LEGACY_RETIRED"`)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Equal(t, legacy, stored.GetAccessToken())
var revoked []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "access_token.revoke").Order("id").Find(&revoked).Error)
require.Len(t, revoked, 2)
for i, want := range []string{
fmt.Sprintf(`"token_ref":"%s"`, scopedToken.TokenHash),
fmt.Sprintf(`"legacy":true,"token_ref":"%s"`, model.AccessTokenFingerprint(legacy)),
} {
require.NotNil(t, revoked[i].Other.Op)
params, err := common.Marshal(revoked[i].Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), want)
}
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"} {
for _, secret := range []string{scoped, legacy, browser, proof, legacyProof, otherRaw, otherProof, "Authorization"} {
assert.NotContains(t, string(encoded), secret)
}
}
// The scope dictionary labels stored grants, so it must not shrink to what the
// viewer can grant today.
func TestAccessTokenScopeDictionaryIgnoresTheViewersGrants(t *testing.T) {
user, _ := setupAccessTokenAudit(t)
require.NoError(t, model.DB.Model(user).Update("role", common.RoleCommonUser).Error)
router := newAccessTokenTestRouter()
_, browser := createAccessTokenTestSession(t, user.Id, "dictionary-session")
response := accessTokenRequest(router, "GET", "/api/user/access_tokens/scopes", browser, "", "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var dictionary struct {
Data struct {
Resources []struct {
Resource string `json:"resource"`
LabelKey string `json:"label_key"`
Actions []struct {
Action string `json:"action"`
LabelKey string `json:"label_key"`
} `json:"actions"`
} `json:"resources"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &dictionary))
labels := map[string]string{}
for _, resource := range dictionary.Data.Resources {
for _, action := range resource.Actions {
labels[resource.Resource+":"+action.Action] = resource.LabelKey + " / " + action.LabelKey
}
}
assert.Equal(t, "Profile / View", labels["profile:read"])
assert.Equal(t, "API keys / Reveal full keys", labels["api_key:reveal"])
assert.Equal(t, "Users / Edit", labels["user:write"])
assert.Equal(t, "System settings / Edit", labels["option:write"])
for _, permission := range authz.AllPermissions() {
assert.Contains(t, labels, service.AccessTokenScopeOf(permission))
}
response = accessTokenRequest(router, "GET", "/api/user/access_tokens/catalog", browser, "", "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
assert.NotContains(t, response.Body.String(), `"resource":"user"`, "the grant catalog stays filtered")
}
func TestAccessTokenCreationChecksGrantBeforeConsumingProof(t *testing.T) {
user, legacy := setupAccessTokenAudit(t)
router := newAccessTokenTestRouter()
identity, browser := createAccessTokenTestSession(t, user.Id, "create-session")
response := accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, "", `{"name":"ci","scopes":["profile:read"],"expires_at":0}`)
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_REQUIRED"`)
expiresAt := time.Now().Unix() + 7*24*60*60
for _, test := range []struct {
name, body, code string
role, status int
proofScopes []string
proofExpiry int64
}{
{"common user cannot grant user management", `{"name":"ci","scopes":["user:write"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_FORBIDDEN", common.RoleCommonUser, http.StatusBadRequest, []string{"user:write"}, 0},
{"administrator cannot grant a permission they lack", `{"name":"ci","scopes":["channel:sensitive_write"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_FORBIDDEN", common.RoleAdminUser, http.StatusBadRequest, []string{"channel:sensitive_write"}, 0},
{"unknown scope", `{"name":"ci","scopes":["profile:admin"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:admin"}, 0},
{"empty grant", `{"name":"ci","scopes":[],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, 0},
{"expiry under one hour", fmt.Sprintf(`{"name":"ci","scopes":["profile:read"],"expires_at":%d}`, time.Now().Unix()+60), "ACCESS_TOKEN_EXPIRY_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, time.Now().Unix() + 60},
{"blank name", `{"name":" ","scopes":["profile:read"],"expires_at":0}`, "ACCESS_TOKEN_NAME_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, 0},
{"proof for other scopes", `{"name":"ci","scopes":["profile:read","usage:read"],"expires_at":0}`, "SECURITY_PROOF_CONTEXT_MISMATCH", common.RoleAdminUser, http.StatusForbidden, []string{"profile:read"}, 0},
{"proof for other expiry", fmt.Sprintf(`{"name":"ci","scopes":["profile:read"],"expires_at":%d}`, expiresAt), "SECURITY_PROOF_CONTEXT_MISMATCH", common.RoleAdminUser, http.StatusForbidden, []string{"profile:read"}, 0},
} {
t.Run(test.name, func(t *testing.T) {
require.NoError(t, model.DB.Model(user).Update("role", test.role).Error)
proof := issueAccessTokenGenerateProof(t, identity, test.proofExpiry, test.proofScopes...)
response := accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, test.body)
assert.Equal(t, test.status, response.Code)
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
var consumed int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("consumed_at IS NOT NULL").Count(&consumed).Error)
assert.Zero(t, consumed, "a rejected grant must not spend the verification")
})
}
tokens, err := model.ListUserAccessTokens(user.Id)
require.NoError(t, err)
assert.Empty(t, tokens)
require.NoError(t, model.DB.Model(user).Update("role", common.RoleAdminUser).Error)
proof := issueAccessTokenGenerateProof(t, identity, 0, "usage:read", "profile:read")
response = accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, `{"name":" ci ","scopes":["profile:read","usage:read","profile:read"],"expires_at":0}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var created struct {
Data struct {
Token string `json:"token"`
Item struct {
Id int `json:"id"`
Name string `json:"name"`
TokenRef string `json:"token_ref"`
TokenHint string `json:"token_hint"`
ExpiresAt int64 `json:"expires_at"`
Scopes []string `json:"scopes"`
} `json:"item"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &created))
raw := created.Data.Token
require.True(t, strings.HasPrefix(raw, model.AccessTokenPrefix))
assert.Len(t, raw, len(model.AccessTokenPrefix)+43)
assert.Equal(t, "ci", created.Data.Item.Name)
assert.Equal(t, []string{"profile:read", "usage:read"}, created.Data.Item.Scopes)
assert.Zero(t, created.Data.Item.ExpiresAt, "expires_at 0 creates a token that never expires")
assert.Equal(t, model.AccessTokenFingerprint(raw), created.Data.Item.TokenRef)
assert.Equal(t, raw[len(raw)-4:], created.Data.Item.TokenHint)
var columns []map[string]any
require.NoError(t, model.DB.Table("user_access_tokens").Find(&columns).Error)
require.Len(t, columns, 1)
encodedRow, err := common.Marshal(columns[0])
require.NoError(t, err)
assert.NotContains(t, string(encodedRow), raw, "the database stores only the token hash")
response = accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, `{"name":"ci","scopes":["profile:read","usage:read"],"expires_at":0}`)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", raw, "", "").Code)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Equal(t, legacy, stored.GetAccessToken(), "creating a token never replaces the legacy token")
var generated model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "access_token.generate").First(&generated).Error)
require.NotNil(t, generated.Other.Op)
params, err := common.Marshal(generated.Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), fmt.Sprintf(`"token_ref":"%s"`, created.Data.Item.TokenRef))
assert.Contains(t, string(params), `"scopes":["profile:read","usage:read"]`)
var history []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&history).Error)
encoded, err := common.Marshal(history)
require.NoError(t, err)
for _, secret := range []string{raw, browser, proof} {
assert.NotContains(t, string(encoded), secret)
}
}
func TestAccessTokenUpdateRenamesFreelyAndChangesGrantOnlyWithProof(t *testing.T) {
user, _ := setupAccessTokenAudit(t)
router := newAccessTokenTestRouter()
identity, browser := createAccessTokenTestSession(t, user.Id, "update-session")
raw, token := createScopedAccessToken(t, user.Id, 0, "profile:read")
tokenPath := fmt.Sprintf("/api/user/access_tokens/%d", token.Id)
other := &model.User{Username: "audit-other", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "audit-other"}
require.NoError(t, model.DB.Create(other).Error)
_, otherToken := createScopedAccessToken(t, other.Id, 0, "profile:read")
response := accessTokenRequest(router, "PATCH", tokenPath, browser, "", `{"name":" renamed "}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
stored, err := model.GetUserAccessToken(user.Id, token.Id)
require.NoError(t, err)
assert.Equal(t, "renamed", stored.Name)
assert.Equal(t, []string{"profile:read"}, stored.GetScopes(), "a rename keeps the grant")
response = accessTokenRequest(router, "PATCH", tokenPath, browser, "", `{"name":"ci","scopes":["usage:read"]}`)
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_REQUIRED"`)
generateProof := issueAccessTokenGenerateProof(t, identity, 0, "usage:read")
for _, test := range []struct {
name, path, body, proof, code string
status int
}{
{"permission the owner lacks", tokenPath, `{"name":"ci","scopes":["channel:sensitive_write"]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "channel:sensitive_write"), "ACCESS_TOKEN_SCOPE_FORBIDDEN", http.StatusBadRequest},
{"empty grant", tokenPath, `{"name":"ci","scopes":[]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "profile:read"), "ACCESS_TOKEN_SCOPE_INVALID", http.StatusBadRequest},
{"another user's token", fmt.Sprintf("/api/user/access_tokens/%d", otherToken.Id), `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, otherToken.Id, "usage:read"), "ACCESS_TOKEN_NOT_FOUND", http.StatusNotFound},
{"proof for another token", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, otherToken.Id, "usage:read"), "SECURITY_PROOF_CONTEXT_MISMATCH", http.StatusForbidden},
{"proof for another grant", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "profile:read"), "SECURITY_PROOF_CONTEXT_MISMATCH", http.StatusForbidden},
{"creation proof", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, generateProof, "SECURITY_PROOF_SCOPE_MISMATCH", http.StatusForbidden},
} {
t.Run(test.name, func(t *testing.T) {
response := accessTokenRequest(router, "PATCH", test.path, browser, test.proof, test.body)
assert.Equal(t, test.status, response.Code)
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
var consumed int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("consumed_at IS NOT NULL").Count(&consumed).Error)
assert.Zero(t, consumed, "a rejected change must not spend the verification")
})
}
stored, err = model.GetUserAccessToken(user.Id, token.Id)
require.NoError(t, err)
assert.Equal(t, "renamed", stored.Name, "a rejected grant change does not apply the rename either")
assert.Equal(t, []string{"profile:read"}, stored.GetScopes())
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", raw, "", "").Code)
// The proof binds the grant as a set, so its order and repeats do not matter.
widenProof := issueAccessTokenUpdateProof(t, identity, token.Id, "usage:read", "profile:read", "profile:read")
response = accessTokenRequest(router, "PATCH", tokenPath, browser, widenProof, `{"name":"ci","scopes":["profile:read","usage:read"]}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
stored, err = model.GetUserAccessToken(user.Id, token.Id)
require.NoError(t, err)
assert.Equal(t, []string{"profile:read", "usage:read"}, stored.GetScopes())
proof := issueAccessTokenUpdateProof(t, identity, token.Id, "usage:read")
response = accessTokenRequest(router, "PATCH", tokenPath, browser, proof, `{"name":"ci","scopes":["usage:read"," usage:read"]}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var updated struct {
Data struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &updated))
assert.Equal(t, "ci", updated.Data.Name)
assert.Equal(t, []string{"usage:read"}, updated.Data.Scopes)
response = accessTokenRequest(router, "GET", "/api/user/self", raw, "", "")
assert.Equal(t, http.StatusForbidden, response.Code, "a narrowed grant applies to the next request")
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_SCOPE_DENIED"`)
response = accessTokenRequest(router, "PATCH", tokenPath, browser, proof, `{"name":"ci","scopes":["usage:read"]}`)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action IN ?", []string{"access_token.rename", "access_token.update"}).Order("id").Find(&audits).Error)
require.Len(t, audits, 3)
assert.Equal(t, "access_token.rename", audits[0].Action)
assert.Equal(t, "access_token.update", audits[1].Action)
assert.Equal(t, "access_token.update", audits[2].Action)
require.NotNil(t, audits[2].Other.Op)
params, err := common.Marshal(audits[2].Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), `"previous_scopes":["profile:read","usage:read"]`)
assert.Contains(t, string(params), `"scopes":["usage:read"]`)
assert.Contains(t, string(params), fmt.Sprintf(`"token_ref":"%s"`, token.TokenHash))
encoded, err := common.Marshal(audits)
require.NoError(t, err)
for _, secret := range []string{raw, browser, widenProof, proof, generateProof} {
assert.NotContains(t, string(encoded), secret)
}
}
@@ -226,7 +621,6 @@ func TestAuditIsolationVisibilityAndFailureContracts(t *testing.T) {
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")
@@ -347,7 +741,7 @@ func TestSecurityAndOperationEventsUseAuditTable(t *testing.T) {
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")
model.RecordLog(user.Id, typ, common.NewMessage("business entry"))
}
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
@@ -703,8 +1097,21 @@ func TestAuditDatabaseMatrix(t *testing.T) {
require.NoError(t, db.First(&old).Error)
assert.Equal(t, "historical login", old.Content)
}
require.NoError(t, model.UpdateUserAccessToken(1, "matrix-token"))
require.NoError(t, db.Model(&model.User{}).Where("id = ?", 1).Update("access_token", "matrix-token").Error)
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")
scoped, scopedToken := createScopedAccessToken(t, 1, 0, "profile:read")
found, err := model.FindUserAccessTokenByHash(model.AccessTokenFingerprint(scoped))
require.NoError(t, err)
assert.Equal(t, scopedToken.Id, found.Id)
assert.Equal(t, []string{"profile:read"}, found.GetScopes())
require.Error(t, db.Create(&model.UserAccessToken{UserId: 1, Name: "duplicate", TokenHash: scopedToken.TokenHash, TokenHint: "dupe", Scopes: found.Scopes}).Error, "token hashes stay unique")
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: 99, Category: model.AuditCategoryAccessToken, TokenRef: scopedToken.TokenHash, Ip: "192.0.2.1", Success: true})
byRef, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1, TokenRef: scopedToken.TokenHash}, 0, 10, common.RoleCommonUser)
require.NoError(t, err)
require.Len(t, byRef, 1, "audit rows of a scoped token are found by its token_ref")
_, err = model.DeleteUserAccessToken(1, scopedToken.Id)
require.NoError(t, err)
require.NoError(t, model.LOG_DB.Where("token_ref = ?", scopedToken.TokenHash).Delete(&model.AuditLog{}).Error)
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})
}
@@ -788,7 +1195,7 @@ func TestIndependentAuditLogStores(t *testing.T) {
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"))
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", 1).Update("access_token", "independent-pat").Error)
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)
@@ -799,7 +1206,7 @@ func TestIndependentAuditLogStores(t *testing.T) {
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")
model.RecordLog(1, model.LogTypeTopup, common.NewMessage("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)
+389
View File
@@ -0,0 +1,389 @@
package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupAdminUserTest promotes the enrolled operator to root and creates a
// managed common user for the administrative endpoints to act on.
func setupAdminUserTest(t *testing.T) (*model.User, service.AuthIdentity, *model.User) {
t.Helper()
operator, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.Model(operator).Update("role", common.RoleRootUser).Error)
require.NoError(t, model.PublishUserAuthCache(operator.Id))
password, err := common.Password2Hash("managed-password")
require.NoError(t, err)
target := &model.User{Username: "managed-user", Password: password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "managed-aff", GitHubId: "managed-github"}
require.NoError(t, model.DB.Create(target).Error)
return operator, identity, target
}
// adminUserRequest drives an admin user-management handler with the operator
// context that AdminAuth would have populated for a dashboard session.
func adminUserRequest(method, path, body, proof string, identity service.AuthIdentity, role int, params gin.Params, handler gin.HandlerFunc) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
c, _ := gin.CreateTestContext(response)
c.Request = httptest.NewRequest(method, path, strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
if proof != "" {
c.Request.Header.Set("X-Security-Proof", proof)
}
c.Params = params
c.Set("id", identity.UserID)
c.Set("username", "enrollment-user")
c.Set("role", role)
c.Set("session_id", identity.SessionID)
c.Set("auth_version", identity.UserAuthVersion)
c.Set("session_version", identity.SessionVersion)
handler(c)
return response
}
func TestAdminUserRiskOperationsRequireProofBeforeMutation(t *testing.T) {
for _, test := range []struct {
name, method, path string
body func(target *model.User) string
params func(target *model.User) gin.Params
handler gin.HandlerFunc
unchanged func(t *testing.T, target *model.User)
}{
{
name: "hard delete", method: http.MethodDelete, path: "/api/user/:id", handler: DeleteUser,
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
unchanged: func(t *testing.T, target *model.User) {
_, err := model.GetUserById(target.Id, false)
assert.NoError(t, err)
},
},
{
name: "manage disable", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id) },
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, common.UserStatusEnabled, stored.Status)
},
},
{
name: "manage promote", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"promote"}`, target.Id) },
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, common.RoleCommonUser, stored.Role)
},
},
{
name: "manage delete", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"delete"}`, target.Id) },
unchanged: func(t *testing.T, target *model.User) {
_, err := model.GetUserById(target.Id, false)
assert.NoError(t, err)
},
},
{
name: "password reset", method: http.MethodPut, path: "/api/user/", handler: UpdateUser,
body: func(target *model.User) string {
return fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Managed","password":"replacement-pass"}`, target.Id)
},
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, true)
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash("managed-password", stored.Password))
},
},
{
name: "admin permission matrix", method: http.MethodPut, path: "/api/user/", handler: UpdateUser,
body: func(target *model.User) string {
return fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Managed","admin_permissions":{}}`, target.Id)
},
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, "", stored.DisplayName)
},
},
{
name: "create administrator", method: http.MethodPost, path: "/api/user/", handler: CreateUser,
body: func(*model.User) string { return `{"username":"new-admin","password":"admin-password-1","role":10}` },
unchanged: func(t *testing.T, _ *model.User) {
var count int64
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", "new-admin").Count(&count).Error)
assert.Zero(t, count)
},
},
{
name: "passkey reset", method: http.MethodDelete, path: "/api/user/:id/reset_passkey", handler: AdminResetPasskey,
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
unchanged: func(t *testing.T, target *model.User) {
_, err := model.GetPasskeyByUserID(target.Id)
assert.NoError(t, err)
},
},
{
name: "two-factor disable", method: http.MethodDelete, path: "/api/user/:id/2fa", handler: AdminDisable2FA,
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
unchanged: func(t *testing.T, target *model.User) {
twoFA, err := model.GetTwoFAByUserId(target.Id)
require.NoError(t, err)
assert.True(t, twoFA.IsEnabled)
},
},
{
name: "built-in binding clear", method: http.MethodDelete, path: "/api/user/:id/bindings/:binding_type", handler: AdminClearUserBinding,
params: func(target *model.User) gin.Params {
return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "binding_type", Value: "github"}}
},
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, "managed-github", stored.GitHubId)
},
},
{
name: "custom oauth unbind", method: http.MethodDelete, path: "/api/user/:id/oauth/bindings/:provider_id", handler: UnbindCustomOAuthByAdmin,
params: func(target *model.User) gin.Params {
return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "provider_id", Value: "7"}}
},
unchanged: func(t *testing.T, target *model.User) {
bindings, err := model.GetUserOAuthBindingsByUserId(target.Id)
require.NoError(t, err)
assert.Len(t, bindings, 1)
},
},
} {
t.Run(test.name, func(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: target.Id, CredentialID: "managed-passkey", PublicKey: "public-key"}).Error)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: target.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: target.Id, ProviderId: 7, ProviderUserId: "provider-user"}).Error)
var body string
if test.body != nil {
body = test.body(target)
}
var params gin.Params
if test.params != nil {
params = test.params(target)
}
response := adminUserRequest(test.method, test.path, body, "", identity, common.RoleRootUser, params, 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)
test.unchanged(t, target)
var flows int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error)
assert.Zero(t, flows)
})
}
}
// An administrator (not root) exercises the routine paths without touching the
// Casbin policy store, which the security fixture does not provision.
func TestAdminUserRoutineEditsDoNotRequireProof(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
response := adminUserRequest(http.MethodPut, "/api/user/", fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Renamed","group":"default"}`, target.Id), "", identity, common.RoleAdminUser, nil, UpdateUser)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(target.Id, true)
require.NoError(t, err)
assert.Equal(t, "Renamed", stored.DisplayName)
assert.True(t, common.ValidatePasswordAndHash("managed-password", stored.Password))
response = adminUserRequest(http.MethodPost, "/api/user/", `{"username":"new-member","password":"member-password-1","role":1}`, "", identity, common.RoleAdminUser, nil, CreateUser)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
var count int64
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", "new-member").Count(&count).Error)
assert.EqualValues(t, 1, count)
}
func TestCreateUserRejectsNonStandardRole(t *testing.T) {
_, identity, _ := setupAdminUserTest(t)
for i, test := range []struct {
name string
role int
operatorRole int
}{
{"between common and admin", 5, common.RoleAdminUser},
{"negative", -1, common.RoleAdminUser},
{"between admin and root", 99, common.RoleRootUser},
} {
t.Run(test.name, func(t *testing.T) {
username := fmt.Sprintf("odd-role-%d", i)
body := fmt.Sprintf(`{"username":%q,"password":"member-password-1","role":%d}`, username, test.role)
response := adminUserRequest(http.MethodPost, "/api/user/", body, "", identity, test.operatorRole, nil, CreateUser)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusOK, response.Code)
assert.False(t, result.Success)
assert.Empty(t, result.Code)
var count int64
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", username).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func TestAdminUserProofIsBoundToTargetAndActionAndConsumedOnce(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
other := &model.User{Username: "other-user", Password: target.Password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "other-aff"}
require.NoError(t, model.DB.Create(other).Error)
disableTarget := service.VerificationOperation{Scope: service.VerificationScopeAdminUserManage, Context: []byte(fmt.Sprintf(`{"user_id":%d,"action":"disable"}`, target.Id))}
proof := issueSecurityEnrollmentProof(t, identity, disableTarget, service.VerificationMethodPassword)
for _, test := range []struct {
name, body, code string
params gin.Params
handler gin.HandlerFunc
}{
{"different action", fmt.Sprintf(`{"id":%d,"action":"enable"}`, target.Id), "SECURITY_PROOF_CONTEXT_MISMATCH", nil, ManageUser},
{"different user", fmt.Sprintf(`{"id":%d,"action":"disable"}`, other.Id), "SECURITY_PROOF_CONTEXT_MISMATCH", nil, ManageUser},
{"different scope", "", "SECURITY_PROOF_SCOPE_MISMATCH", gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}}, DeleteUser},
} {
t.Run(test.name, func(t *testing.T) {
response := adminUserRequest(http.MethodPost, "/api/user/manage", test.body, proof, identity, common.RoleRootUser, test.params, test.handler)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, test.code, result.Code)
})
}
for _, id := range []int{target.Id, other.Id} {
stored, err := model.GetUserById(id, false)
require.NoError(t, err)
assert.Equal(t, common.UserStatusEnabled, stored.Status)
}
response := adminUserRequest(http.MethodPost, "/api/user/manage", fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id), proof, identity, common.RoleRootUser, nil, ManageUser)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, common.UserStatusDisabled, stored.Status)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.manage").Last(&audit).Error)
require.NotNil(t, audit.Other.Op)
verificationMethod, err := common.Marshal(audit.Other.Op.Params["verification_method"])
require.NoError(t, err)
assert.JSONEq(t, `"password"`, string(verificationMethod))
targetUserID, err := common.Marshal(audit.Other.Op.Params["target_user_id"])
require.NoError(t, err)
assert.JSONEq(t, fmt.Sprint(target.Id), string(targetUserID))
auditJSON, err := common.Marshal(audit)
require.NoError(t, err)
assert.NotContains(t, string(auditJSON), proof)
response = adminUserRequest(http.MethodPost, "/api/user/manage", fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id), proof, identity, common.RoleRootUser, nil, ManageUser)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_PROOF_CONSUMED", result.Code)
}
func TestAdminUserBindingProofDistinguishesBuiltInAndCustomBindings(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: target.Id, ProviderId: 7, ProviderUserId: "provider-user"}).Error)
custom := service.VerificationOperation{Scope: service.VerificationScopeAdminUserBindingClear, Context: []byte(fmt.Sprintf(`{"user_id":%d,"provider_id":7}`, target.Id))}
proof := issueSecurityEnrollmentProof(t, identity, custom, service.VerificationMethodPassword)
params := gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "binding_type", Value: "github"}}
response := adminUserRequest(http.MethodDelete, "/api/user/:id/bindings/:binding_type", "", proof, identity, common.RoleRootUser, params, AdminClearUserBinding)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, "SECURITY_PROOF_CONTEXT_MISMATCH", result.Code)
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, "managed-github", stored.GitHubId)
params = gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "provider_id", Value: "7"}}
response = adminUserRequest(http.MethodDelete, "/api/user/:id/oauth/bindings/:provider_id", "", proof, identity, common.RoleRootUser, params, UnbindCustomOAuthByAdmin)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
bindings, err := model.GetUserOAuthBindingsByUserId(target.Id)
require.NoError(t, err)
assert.Empty(t, bindings)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.binding_clear").Last(&audit).Error)
require.NotNil(t, audit.Other.Op)
providerID, err := common.Marshal(audit.Other.Op.Params["provider_id"])
require.NoError(t, err)
assert.JSONEq(t, "7", string(providerID))
}
func TestAdminUserVerificationPolicy(t *testing.T) {
t.Run("requires an administrator", func(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
_, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserDelete)
assert.ErrorIs(t, err, service.ErrVerificationForbidden)
})
t.Run("falls back to the password without a second factor", func(t *testing.T) {
_, identity, _ := setupAdminUserTest(t)
requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserDelete)
require.NoError(t, err)
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodPassword, Available: true}}, requirements.Methods)
})
t.Run("prefers an enrolled second factor", func(t *testing.T) {
operator, identity, _ := setupAdminUserTest(t)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: operator.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserManage)
require.NoError(t, err)
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodTwoFA, Available: true}}, requirements.Methods)
})
t.Run("a legacy token cannot present a proof", func(t *testing.T) {
operator, identity, target := setupAdminUserTest(t)
require.NoError(t, model.DB.Model(operator).Update("access_token", "legacy-admin-token").Error)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAdminUserDelete, Context: []byte(fmt.Sprintf(`{"user_id":%d}`, target.Id))}, service.VerificationMethodPassword)
response := accessTokenRequest(newAccessTokenTestRouter(), http.MethodDelete, fmt.Sprintf("/api/user/%d", target.Id), "legacy-admin-token", proof, "")
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_PROOF_INVALID", decodeSecurityEnrollmentResponse(t, response).Code)
_, err := model.GetUserById(target.Id, false)
assert.NoError(t, err)
})
t.Run("a user:write token completes the deletion on its own proof", func(t *testing.T) {
operator, _, target := setupAdminUserTest(t)
require.NoError(t, model.DB.AutoMigrate(&model.ExternalIdentityClaim{}, &model.Token{}))
raw, _ := createScopedAccessToken(t, operator.Id, 0, "user:write")
router := newAccessTokenTestRouter()
response := accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", fmt.Sprintf(`{"scope":"admin.user.delete","method":"password","password":"enrollment-password","context":{"user_id":%d}}`, target.Id))
body := decodeSecurityEnrollmentResponse(t, response)
require.True(t, body.Success, body.Message)
var proof service.SecurityProof
require.NoError(t, common.Unmarshal(body.Data, &proof))
response = accessTokenRequest(router, http.MethodDelete, fmt.Sprintf("/api/user/%d", target.Id), raw, proof.ProofToken, "")
body = decodeSecurityEnrollmentResponse(t, response)
require.True(t, body.Success, body.Message)
_, err := model.GetUserById(target.Id, true)
assert.Error(t, err)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.delete").Last(&audit).Error)
assert.Equal(t, "access_token", audit.AuthMethod)
auditJSON, err := common.Marshal(audit)
require.NoError(t, err)
for _, secret := range []string{raw, proof.ProofToken, "enrollment-password"} {
assert.NotContains(t, string(auditJSON), secret)
}
})
t.Run("a user:read token cannot obtain a proof", func(t *testing.T) {
operator, _, target := setupAdminUserTest(t)
raw, _ := createScopedAccessToken(t, operator.Id, 0, "user:read")
response := accessTokenRequest(newAccessTokenTestRouter(), http.MethodPost, "/api/verify", raw, "", fmt.Sprintf(`{"scope":"admin.user.delete","method":"password","password":"enrollment-password","context":{"user_id":%d}}`, target.Id))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_ACTION_FORBIDDEN", decodeSecurityEnrollmentResponse(t, response).Code)
var flows int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error)
assert.Zero(t, flows)
})
}
+31 -9
View File
@@ -139,10 +139,14 @@ func (astraSamplingHandler) Run(parent context.Context, task *model.SystemTask,
state.Rounds[index].Reason = reason
state.Rounds[index].Usage = usage
state.Rounds[index].FinishedAt = time.Now().Unix()
if persist() && !payload.Force {
if err := model.ApplyDetectionScheduleResult(ctx, id, "modeltrace", state.Rounds[index].ID, state.Rounds[index].FinishedAt, service.FingerprintScheduleOutcome(service.AstraSamplingResultStatus(result))); err != nil {
persistErr = err
cancel()
if persisted := persist(); persisted {
service.InvalidateGroupQualityCache()
publishGroupStatusChange()
if !payload.Force {
if err := model.ApplyDetectionScheduleResult(ctx, id, "modeltrace", state.Rounds[index].ID, state.Rounds[index].FinishedAt, service.FingerprintScheduleOutcome(service.AstraSamplingResultStatus(result))); err != nil {
persistErr = err
cancel()
}
}
}
mu.Unlock()
@@ -179,6 +183,7 @@ func runModelDetectionProbe(parent context.Context, channel *model.Channel, inde
if err != nil {
return fingerprint.ProbeResponse{}, index, err
}
effort := "low"
count, output := probe.Count, fingerprint.OutputTokens
messages := []map[string]any{{"role": "user", "content": probe.Prompt}}
if kind == "benchmark" {
@@ -189,14 +194,23 @@ func runModelDetectionProbe(parent context.Context, channel *model.Channel, inde
count, output = index, benchmark.OutputTokens
messages = []map[string]any{{"role": "system", "content": secondary.System}, {"role": "user", "content": secondary.Prompt}}
}
if kind == "candy" {
count, output = 1, service.CandyOutputTokens
messages = []map[string]any{{"role": "user", "content": service.CandyPrompt}}
if kind == "vision" {
count, output = 1, service.VisionOutputTokens
effort = "medium"
messages = []map[string]any{{"role": "user", "content": []map[string]any{
{"type": "input_text", "text": service.VisionPrompt},
{"type": "input_image", "image_url": service.VisionImageURL, "detail": "high"},
}}}
}
if kind == "ciallo" {
count, output = 1, service.VisionOutputTokens
effort = "medium"
messages = []map[string]any{{"role": "user", "content": service.CialloPrompt}}
}
input, _ := common.Marshal(messages)
stream := true
limit := uint(output)
request := &dto.OpenAIResponsesRequest{Model: fingerprint.Model, Input: input, Stream: &stream, MaxOutputTokens: &limit, Store: []byte("false"), Reasoning: &dto.Reasoning{Effort: "low"}}
request := &dto.OpenAIResponsesRequest{Model: fingerprint.Model, Input: input, Stream: &stream, MaxOutputTokens: &limit, Store: []byte("false"), Reasoning: &dto.Reasoning{Effort: effort}}
encoded, err := common.Marshal(request)
if err != nil {
return fingerprint.ProbeResponse{}, count, err
@@ -230,6 +244,7 @@ func runModelDetectionProbe(parent context.Context, channel *model.Channel, inde
defer boundedCloser.Close()
body = bounded
}
started := time.Now()
response, err := adaptor.DoRequest(c, info, body)
if err != nil {
return fingerprint.ProbeResponse{}, count, errors.New("probe transport failed")
@@ -239,12 +254,19 @@ func runModelDetectionProbe(parent context.Context, channel *model.Channel, inde
return fingerprint.ProbeResponse{}, count, errors.New("unsupported probe transport")
}
defer resp.Body.Close()
stopClose := context.AfterFunc(ctx, func() { _ = resp.Body.Close() })
defer stopClose()
if resp.StatusCode != http.StatusOK {
return fingerprint.ProbeResponse{}, count, errors.New("probe HTTP failure")
}
// Deliberately bypass DoResponse: this is a platform-funded probe, with no
// customer token, wallet charge, session binding or generated response relay.
answer, err := fingerprint.ReadResponse(resp.Body, resp.Header.Get("Content-Type"))
var firstTokenMS *int64
answer, err := fingerprint.ReadResponse(resp.Body, resp.Header.Get("Content-Type"), func() {
elapsed := time.Since(started).Milliseconds()
firstTokenMS = &elapsed
})
answer.FirstTokenMS = firstTokenMS
return answer, count, err
}
+13 -4
View File
@@ -29,8 +29,10 @@ var auditContentTemplates = map[string]string{
"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",
"access_token.generate": "Generated an access token",
"access_token.revoke": "Revoked an access token",
"access_token.rename": "Renamed an access token",
"access_token.update": "Changed access token permissions",
"user.2fa_setup": "Started two-factor authentication setup",
"user.2fa_enable": "Enabled two-factor authentication",
"user.2fa_disable_self": "Disabled two-factor authentication",
@@ -68,8 +70,9 @@ var auditContentTemplates = map[string]string{
"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}",
"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}",
"subscription.user_quota_reset": "Administrator reset the quota of subscription plan ${plan_title} (ID: ${plan_id})",
}
func recordPasskeyDomainAudit(c *gin.Context, change *model.PasskeyDomainChange, confirmed bool, err error) {
@@ -165,6 +168,12 @@ func recordUserSecurityAudit(c *gin.Context, userId int, action string, params m
}
params["code"] = code
}
if c.GetBool("use_access_token") {
if params == nil {
params = map[string]any{}
}
params["token_ref"] = c.GetString("access_token_ref")
}
var auditInfo *model.AuditRequestInfo
if success, ok := params["success"].(bool); ok {
auditInfo = &model.AuditRequestInfo{
+7 -2
View File
@@ -2,10 +2,10 @@ package controller
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
@@ -170,7 +170,7 @@ func writeAuthSessionError(c *gin.Context, err error) {
// 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))
logger.LogError(c.Request.Context(), common.LogText("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)})
}
@@ -179,7 +179,12 @@ func setAuthNoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
}
// authRotationData is empty when the change came through an access token,
// which has no browser session to rotate.
func authRotationData(bundle *service.AuthBundle) gin.H {
if bundle == nil {
return gin.H{}
}
return gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
+146
View File
@@ -1,15 +1,25 @@
package controller
import (
"fmt"
"math"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/pkg/jsplugin"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -177,3 +187,139 @@ export function parseTaskResult() { return {}; }
assert.Contains(t, unresolvable.Body.String(), `"success":false`)
assert.Contains(t, unresolvable.Body.String(), "no task plugin usage schema")
}
// Covers configuration persistence, validation, reservation and reconciliation
// through the same public entry points used by the relay.
func TestPreConsumePolicyDatabaseMatrix(t *testing.T) {
previousConfig := config.GlobalConfig.ExportAllConfigs()
previousUnit, previousBatch := common.QuotaPerUnit, common.BatchUpdateEnabled
common.QuotaPerUnit, common.BatchUpdateEnabled = 500000, false
t.Cleanup(func() {
require.NoError(t, config.GlobalConfig.LoadFromDB(previousConfig))
common.QuotaPerUnit, common.BatchUpdateEnabled = previousUnit, previousBatch
})
for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} {
t.Run(dialect.kind, func(t *testing.T) {
if dialect.env != "" && os.Getenv(dialect.env) == "" {
t.Skip("set " + dialect.env + " to run this database")
}
db := modelManagementDB(t, dialect.kind, os.Getenv(dialect.env))
require.NoError(t, db.AutoMigrate(&model.Token{}))
for key, value := range map[string]string{
"quota_setting.trust_quota_usd": "10.5",
"quota_setting.pre_consume_multiplier": "1.5",
} {
var response struct{ Success bool }
modelManagementRequest(t, UpdateOption, http.MethodPut, "/api/option/", OptionUpdateRequest{Key: key, Value: value}, &response)
require.True(t, response.Success)
var saved model.Option
require.NoError(t, db.Where(&model.Option{Key: key}).First(&saved).Error)
assert.Equal(t, value, saved.Value)
}
var savedOptions []model.Option
require.NoError(t, db.Find(&savedOptions).Error)
stored := map[string]string{}
for _, option := range savedOptions {
stored[option.Key] = option.Value
}
for range 2 {
operation_setting.GetQuotaSetting().TrustQuotaUSD = 10
operation_setting.GetQuotaSetting().PreConsumeMultiplier = 1
require.NoError(t, config.GlobalConfig.LoadFromDB(stored))
assert.Equal(t, 10.5, operation_setting.GetQuotaSetting().TrustQuotaUSD)
assert.Equal(t, 1.5, operation_setting.GetQuotaSetting().PreConsumeMultiplier)
}
for _, tc := range []struct{ key, value string }{
{"trust_quota_usd", "-1"}, {"trust_quota_usd", "NaN"}, {"trust_quota_usd", "+Inf"},
{"pre_consume_multiplier", "0"}, {"pre_consume_multiplier", "-0.5"},
{"pre_consume_multiplier", "NaN"}, {"pre_consume_multiplier", "Inf"},
{"pre_consume_multiplier", "1e309"}, {"pre_consume_multiplier", ""},
} {
key := "quota_setting." + tc.key
var response struct{ Success bool }
modelManagementRequest(t, UpdateOption, http.MethodPut, "/api/option/", OptionUpdateRequest{Key: key, Value: tc.value}, &response)
assert.False(t, response.Success, "%s=%q", key, tc.value)
var saved model.Option
require.NoError(t, db.Where(&model.Option{Key: key}).First(&saved).Error)
assert.Equal(t, stored[key], saved.Value, "invalid saves must preserve the previous value")
}
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
"billing_setting.billing_mode": `{"policy-model":"tiered_expr"}`,
"billing_setting.billing_expr": `{"policy-model":"tier(\"base\", p * 3 + c * 15)"}`,
"group_ratio_setting.group_ratio": `{"default":1}`,
}))
cases := []struct {
name string
threshold, multiplier float64
wallet, token int
force, unlimited bool
wantHeld int
}{
{"above threshold", 10, 1, 5500000, 5500000, false, false, 0},
{"custom threshold", 20, 1, 5500000, 5500000, false, false, 1500},
{"zero disables bypass", 0, 1, 5500000, 5500000, false, false, 1500},
{"wallet equals threshold", 10, 1, 5000000, 5500000, false, false, 1500},
{"token equals threshold", 10, 1, 5500000, 5000000, false, false, 1500},
{"fractional threshold", 10.5, 1, 5250001, 5250001, false, false, 0},
{"unlimited token", 10, 1, 5500000, 0, false, true, 0},
{"forced reservation", 10, 1, 5500000, 5500000, true, false, 1500},
{"half input cost", 0, 0.5, 100000, 100000, false, false, 750},
{"fractional multiple refunds excess", 0, 2.5, 100000, 100000, false, false, 3750},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.NoError(t, model.UpdateOption("quota_setting.trust_quota_usd", strconv.FormatFloat(tc.threshold, 'f', -1, 64)))
require.NoError(t, model.UpdateOption("quota_setting.pre_consume_multiplier", strconv.FormatFloat(tc.multiplier, 'f', -1, 64)))
user := model.User{Username: fmt.Sprintf("policy-user-%d", i), Quota: tc.wallet, Group: "default", AffCode: fmt.Sprintf("policy-aff-%d", i)}
require.NoError(t, db.Create(&user).Error)
token := model.Token{UserId: user.Id, Key: fmt.Sprintf("policy-token-%d", i), RemainQuota: tc.token, UnlimitedQuota: tc.unlimited}
require.NoError(t, db.Create(&token).Error)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
ctx.Set("token_quota", tc.token)
info := &relaycommon.RelayInfo{UserId: user.Id, TokenId: token.Id, TokenKey: token.Key, TokenUnlimited: tc.unlimited, ForcePreConsume: tc.force, OriginModelName: "policy-model", UserGroup: "default", UsingGroup: "default", BillingRequestInput: &billingexpr.RequestInput{}}
info.UserSetting.BillingPreference = "wallet_only"
price, err := helper.ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{MaxTokens: 10000})
require.NoError(t, err)
require.Nil(t, service.PreConsumeBilling(ctx, price.QuotaToPreConsume, info))
assert.Equal(t, tc.wantHeld, info.FinalPreConsumedQuota)
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.wallet-tc.wantHeld, user.Quota)
if !tc.unlimited {
require.NoError(t, db.First(&token, token.Id).Error)
assert.Equal(t, tc.token-tc.wantHeld, token.RemainQuota)
}
_, actual, _ := service.TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 100, Len: 1000})
assert.Equal(t, 2250, actual)
require.NoError(t, info.Billing.Settle(actual))
require.NoError(t, info.Billing.Settle(actual))
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.wallet-actual, user.Quota)
if !tc.unlimited {
require.NoError(t, db.First(&token, token.Id).Error)
assert.Equal(t, tc.token-actual, token.RemainQuota)
}
})
}
})
}
}
func TestPreConsumeMultiplierRejectsInvalidRuntimeAndOverflow(t *testing.T) {
previous := config.GlobalConfig.ExportAllConfigs()
t.Cleanup(func() { require.NoError(t, config.GlobalConfig.LoadFromDB(previous)) })
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
"billing_setting.billing_mode": `{"policy-overflow":"tiered_expr"}`,
"billing_setting.billing_expr": `{"policy-overflow":"tier(\"base\", p * 3)"}`,
"group_ratio_setting.group_ratio": `{"default":1}`,
}))
for _, multiplier := range []float64{0, -0.5, math.NaN(), math.Inf(1), math.MaxFloat64} {
operation_setting.GetQuotaSetting().PreConsumeMultiplier = multiplier
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
info := &relaycommon.RelayInfo{OriginModelName: "policy-overflow", UserGroup: "default", UsingGroup: "default", BillingRequestInput: &billingexpr.RequestInput{}}
_, err := helper.ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
require.Error(t, err)
assert.Nil(t, info.Billing)
}
}
+84
View File
@@ -0,0 +1,84 @@
package controller
import (
"crypto/sha256"
"crypto/subtle"
"math"
"net/http"
"net/mail"
"os"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
// BotBalanceQuery is a dedicated read-only API; its key grants no dashboard access.
func BotBalanceQuery(c *gin.Context) {
c.Header("Cache-Control", "no-store")
key := os.Getenv("BOT_BALANCE_KEY")
raw, bearer := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
expected, supplied := sha256.Sum256([]byte(key)), sha256.Sum256([]byte(raw))
if len(key) < 32 || !bearer || subtle.ConstantTimeCompare(expected[:], supplied[:]) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "code": "unauthorized"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8192)
var request struct {
Email string `json:"email"`
IDs []int `json:"ids"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_request"})
return
}
email := model.NormalizeEmail(request.Email)
if (email == "") == (len(request.IDs) == 0) || len(request.IDs) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_request"})
return
}
if email != "" {
parsed, err := mail.ParseAddress(email)
if err != nil || parsed.Address != email || len(email) > 50 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_email"})
return
}
}
for _, id := range request.IDs {
if id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_request"})
return
}
}
accounts, err := model.FindBotBalanceAccounts(email, request.IDs)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "code": "query_failed"})
return
}
if email != "" && len(accounts) != 1 {
code := "not_found"
if len(accounts) > 1 {
code = "ambiguous_email"
}
c.JSON(http.StatusConflict, gin.H{"success": false, "code": code})
return
}
unit, rate := common.QuotaPerUnit, operation_setting.USDExchangeRate
if unit <= 0 || rate <= 0 || math.IsNaN(unit) || math.IsNaN(rate) || math.IsInf(unit, 0) || math.IsInf(rate, 0) {
c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "code": "invalid_currency"})
return
}
items := make([]gin.H, 0, len(accounts))
for _, account := range accounts {
active := account.Status == common.UserStatusEnabled
item := gin.H{"id": account.ID, "active": active}
if active {
item["balance_cny"] = decimal.NewFromInt(int64(account.Quota)).Mul(decimal.NewFromFloat(rate)).DivRound(decimal.NewFromFloat(unit), 18).String()
}
items = append(items, item)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": items})
}
+113
View File
@@ -0,0 +1,113 @@
package controller
import (
"fmt"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestBotBalanceQuery(t *testing.T) {
key := strings.Repeat("b", 48)
t.Setenv("BOT_BALANCE_KEY", key)
oldUnit, oldRate := common.QuotaPerUnit, operation_setting.USDExchangeRate
common.QuotaPerUnit, operation_setting.USDExchangeRate = 500000, 7.3
t.Cleanup(func() { common.QuotaPerUnit, operation_setting.USDExchangeRate = oldUnit, oldRate })
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
var driver gorm.Dialector = sqlite.Open(":memory:")
if dialect == "mysql" {
require.NotEmpty(t, os.Getenv("TEST_MYSQL_DSN"))
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
}
if dialect == "postgres" {
require.NotEmpty(t, os.Getenv("TEST_POSTGRES_DSN"))
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
}
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("bot_%d_", time.Now().UnixNano())}})
require.NoError(t, err)
conn, err := db.DB()
require.NoError(t, err)
conn.SetMaxOpenConns(1)
oldDB := model.DB
model.DB = db
t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(&model.User{})); model.DB = oldDB; _ = conn.Close() })
require.NoError(t, db.AutoMigrate(&model.User{}))
rows := []model.User{
{Id: 1, Username: "bot1", Email: "a@example.com", Status: 1, Quota: 250000},
{Id: 2, Username: "bot2", Email: "dup@example.com", Status: 1, Quota: 0},
{Id: 3, Username: "bot3", Email: "dup@example.com", Status: 1, Quota: -500000},
{Id: 4, Username: "bot4", Email: "disabled@example.com", Status: 2, Quota: 900000},
{Id: 5, Username: "bot5", Email: "gone@example.com", Status: 1},
}
for i := range rows {
rows[i].AffCode = fmt.Sprintf("bot%d", i)
}
require.NoError(t, db.Create(&rows).Error)
require.NoError(t, db.Delete(&rows[4]).Error)
r := gin.New()
r.POST("/api/bot/balances", BotBalanceQuery)
for _, tc := range []struct {
name, body, token string
status int
code string
}{
{"missing key", `{}`, "", 401, "unauthorized"},
{"wrong key", `{}`, "Bearer wrong", 401, "unauthorized"},
{"empty", `{}`, "Bearer " + key, 400, "invalid_request"},
{"both", `{"email":"a@example.com","ids":[1]}`, "Bearer " + key, 400, "invalid_request"},
{"bad id", `{"ids":[0]}`, "Bearer " + key, 400, "invalid_request"},
{"bad email", `{"email":"invalid"}`, "Bearer " + key, 400, "invalid_email"},
{"deleted", `{"email":"gone@example.com"}`, "Bearer " + key, 409, "not_found"},
{"duplicate", `{"email":"dup@example.com"}`, "Bearer " + key, 409, "ambiguous_email"},
{"exact not substring", `{"email":"aa@example.com"}`, "Bearer " + key, 409, "not_found"},
{"normalized", `{"email":" A@EXAMPLE.COM "}`, "Bearer " + key, 200, ""},
{"batch", `{"ids":[1,2,3,4,5,999]}`, "Bearer " + key, 200, ""},
} {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/bot/balances", strings.NewReader(tc.body))
req.Header.Set("Authorization", tc.token)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, tc.status, w.Code, w.Body.String())
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
assert.Equal(t, tc.code, gjson.Get(w.Body.String(), "code").String())
if tc.status == 200 {
assert.NotContains(t, w.Body.String(), "email")
assert.NotContains(t, w.Body.String(), "quota")
assert.NotContains(t, w.Body.String(), "password")
assert.Equal(t, "3.65", gjson.Get(w.Body.String(), "data.0.balance_cny").String())
if tc.name == "batch" {
assert.Equal(t, int64(4), gjson.Get(w.Body.String(), "data.#").Int())
assert.Equal(t, "0", gjson.Get(w.Body.String(), "data.1.balance_cny").String())
assert.Equal(t, "-7.3", gjson.Get(w.Body.String(), "data.2.balance_cny").String())
assert.False(t, gjson.Get(w.Body.String(), "data.3.active").Bool())
assert.False(t, gjson.Get(w.Body.String(), "data.3.balance_cny").Exists())
}
}
})
}
t.Setenv("BOT_BALANCE_KEY", "")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("POST", "/api/bot/balances", strings.NewReader(`{}`)))
assert.Equal(t, 401, w.Code)
t.Setenv("BOT_BALANCE_KEY", key)
})
}
}
+156
View File
@@ -0,0 +1,156 @@
package controller
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"os"
"slices"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var chainEnqueue sync.Mutex
var chainKinds = []string{"fingerprint", "ciallo", "vision"}
// All three child tasks are committed together, using the existing durable ledger.
func enqueueChainTest(ctx context.Context, group string, channelID int) ([]model.DetectionRun, error) {
chainEnqueue.Lock()
defer chainEnqueue.Unlock()
var runs []model.DetectionRun
err := model.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var active []model.DetectionRun
if err := tx.Where("group_name = ? AND request_key LIKE ? AND settled = ? AND deadline > ?", group, "chain:%", false, time.Now().Unix()).Order("created_at asc").Limit(1).Find(&active).Error; err != nil {
return err
}
if len(active) > 0 {
id, _, _ := strings.Cut(active[0].ID, "_")
return tx.Where("id IN ?", []string{id + "_fingerprint", id + "_ciallo", id + "_vision"}).Find(&runs).Error
}
id, err := common.GenerateRandomCharsKey(32)
if err != nil {
return err
}
now := time.Now().Unix()
for _, kind := range chainKinds {
runs = append(runs, model.DetectionRun{ID: id + "_" + kind, RequestKey: "chain:" + id + ":" + kind, ChannelID: channelID, GroupName: group, Kind: kind, Status: "pending", CreatedAt: now, Deadline: now + 600})
}
return tx.Create(&runs).Error
})
return runs, err
}
func BotChainTest(c *gin.Context) {
c.Header("Cache-Control", "no-store")
key := os.Getenv("BOT_BALANCE_KEY")
derived := sha256.Sum256([]byte("newapi-chaintest-v1:" + key))
raw, bearer := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
expected, supplied := sha256.Sum256([]byte(hex.EncodeToString(derived[:]))), sha256.Sum256([]byte(raw))
if len(key) < 32 || !bearer || subtle.ConstantTimeCompare(expected[:], supplied[:]) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "code": "unauthorized"})
return
}
var runs []model.DetectionRun
if c.Request.Method == http.MethodPost {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1024)
var input struct {
Group string `json:"group"`
}
if c.ShouldBindJSON(&input) != nil || strings.TrimSpace(input.Group) == "" || len(input.Group) > 128 {
c.JSON(400, gin.H{"success": false, "code": "invalid_group"})
return
}
input.Group = strings.TrimSpace(input.Group)
channels, err := service.AstraSamplingChannels(c.Request.Context())
if err != nil {
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
return
}
slices.SortFunc(channels, func(a, b model.Channel) int { return a.Id - b.Id })
selected := 0
for _, channel := range channels {
if slices.Contains(channel.GetGroups(), input.Group) {
selected = channel.Id
break
}
}
if selected == 0 {
c.JSON(404, gin.H{"success": false, "code": "group_unavailable"})
return
}
runs, err = enqueueChainTest(c.Request.Context(), input.Group, selected)
if err != nil {
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
return
}
for _, run := range runs {
go processDetection(context.Background(), run)
}
} else {
id := c.Param("id")
if len(id) != 32 || strings.ContainsAny(id, "_%") {
c.JSON(404, gin.H{"success": false, "code": "not_found"})
return
}
err := model.DB.WithContext(c.Request.Context()).Where("id IN ? AND request_key LIKE ?", []string{id + "_fingerprint", id + "_ciallo", id + "_vision"}, "chain:"+id+":%").Find(&runs).Error
if err != nil {
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
return
}
}
data, err := chainTestResult(runs, time.Now().Unix())
if err != nil {
c.JSON(404, gin.H{"success": false, "code": "not_found"})
return
}
c.JSON(200, gin.H{"success": true, "data": data})
}
func chainTestResult(runs []model.DetectionRun, now int64) (gin.H, error) {
if len(runs) != 3 {
return nil, errors.New("incomplete chain")
}
id, _, _ := strings.Cut(runs[0].ID, "_")
results := make(map[string]any, 3)
done := true
for _, run := range runs {
run = service.NormalizeVisionRun(run)
state, text, match := "running", "", ""
if run.Settled {
state = "failed"
var result fingerprint.Result
if run.Status == "succeeded" && common.UnmarshalJsonStr(run.Result, &result) == nil {
text = result.Closest
if run.Kind == "fingerprint" {
match = service.AstraSamplingResultStatus(result)
state = match
if state != "match" && state != "mismatch" && state != "inconclusive" {
state = "failed"
}
} else if strings.TrimSpace(text) != "" {
state = service.VisionQuality(text)
if run.Kind == "ciallo" {
state = service.CialloQuality(text)
}
}
}
} else if now >= run.Deadline {
state = "failed"
} else {
done = false
}
results[run.Kind] = gin.H{"status": state, "text": text}
}
return gin.H{"id": id, "group": runs[0].GroupName, "deadline": runs[0].Deadline, "done": done, "results": results}, nil
}
+121 -11
View File
@@ -157,7 +157,8 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
for k := range headers {
req.Header.Add(k, headers.Get(k))
}
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy)
setting := channel.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return nil, err
}
@@ -420,6 +421,113 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
return availableBalanceUsd, nil
}
func updateChannelSub2APIBalance(channel *model.Channel) (float64, error) {
baseURL := channel.GetBaseURL()
if baseURL == "" {
return 0, errors.New("Sub2API channel is missing base_url")
}
url := fmt.Sprintf("%s/v1/usage", strings.TrimRight(baseURL, "/"))
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
type sub2apiUsageResponse struct {
Balance *float64 `json:"balance"`
Remaining *float64 `json:"remaining"`
IsValid *bool `json:"isValid"`
Subscription *struct {
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
} `json:"subscription"`
}
usage := sub2apiUsageResponse{}
if err := common.Unmarshal(body, &usage); err != nil {
return 0, err
}
if usage.IsValid != nil && !*usage.IsValid {
return 0, errors.New("Sub2API key is invalid (isValid=false)")
}
balance := usage.Remaining
if balance == nil {
balance = usage.Balance
}
if balance == nil {
return 0, errors.New("Sub2API /v1/usage response is missing remaining and balance fields (the key may belong to a subscription group without an available balance)")
}
// Sub2API reports remaining=-1 for a subscription group with no daily, weekly,
// or monthly limit. A wallet balance can legitimately be negative, so detect
// the unlimited case from the subscription limits rather than the value.
subscription := usage.Subscription
if subscription != nil && subscription.DailyLimitUSD == nil && subscription.WeeklyLimitUSD == nil && subscription.MonthlyLimitUSD == nil {
return 0, errors.New("Sub2API /v1/usage reports an unlimited subscription without a finite balance")
}
channel.UpdateBalance(*balance)
return *balance, nil
}
// updateChannelNewAPIBalance reads the upstream key's remaining quota. When the
// upstream also returns its owner's quota, the smaller of the two is what the
// key can still spend. Quota converts to USD with the upstream's quota_per_unit.
func updateChannelNewAPIBalance(channel *model.Channel) (float64, error) {
baseURL := strings.TrimRight(channel.GetBaseURL(), "/")
if baseURL == "" {
return 0, errors.New("New API channel is missing base_url")
}
body, err := GetResponseBody("GET", baseURL+"/api/usage/token/", channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
var usage struct {
Code bool `json:"code"`
Message string `json:"message"`
Data struct {
TotalAvailable float64 `json:"total_available"`
UnlimitedQuota bool `json:"unlimited_quota"`
UserQuota *struct {
TotalAvailable float64 `json:"total_available"`
} `json:"user_quota"`
} `json:"data"`
}
if err := common.Unmarshal(body, &usage); err != nil {
return 0, err
}
if !usage.Code {
return 0, fmt.Errorf("New API usage query failed: %s", usage.Message)
}
remaining := usage.Data.TotalAvailable
if usage.Data.UnlimitedQuota {
remaining = math.Inf(1)
}
if userQuota := usage.Data.UserQuota; userQuota != nil {
remaining = min(remaining, userQuota.TotalAvailable)
}
if math.IsInf(remaining, 1) {
return 0, common.NewMessage(`The upstream key has unlimited quota. Turn on "Allow querying user quota" for this key on the upstream to see the real balance.`)
}
body, err = GetResponseBody("GET", baseURL+"/api/status", channel, nil)
if err != nil {
return 0, err
}
var status struct {
Data struct {
QuotaPerUnit float64 `json:"quota_per_unit"`
} `json:"data"`
}
if err := common.Unmarshal(body, &status); err != nil {
return 0, err
}
if status.Data.QuotaPerUnit <= 0 {
return 0, errors.New("New API /api/status response is missing quota_per_unit")
}
balance := remaining / status.Data.QuotaPerUnit
channel.UpdateBalance(balance)
return balance, nil
}
func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, error) {
key := strings.TrimSpace(channel.Key)
info := &relaycommon.RelayInfo{
@@ -453,7 +561,8 @@ func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, e
request.Host = headers.Get(name)
}
}
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy)
setting := channel.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
}
@@ -524,7 +633,7 @@ func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
baseURL = channel.GetBaseURL()
}
case constant.ChannelTypeAzure:
return 0, errors.New("尚未实现")
return 0, common.NewMessage("Not implemented yet")
case constant.ChannelTypeCustom:
baseURL = channel.GetBaseURL()
//case common.ChannelTypeOpenAISB:
@@ -543,8 +652,12 @@ func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
return updateChannelOpenRouterBalance(channel)
case constant.ChannelTypeMoonshot:
return updateChannelMoonshotBalance(channel)
case constant.ChannelTypeSub2API:
return updateChannelSub2APIBalance(channel)
case constant.ChannelTypeNewAPI:
return updateChannelNewAPIBalance(channel)
default:
return 0, errors.New("尚未实现")
return 0, common.NewMessage("Not implemented yet")
}
url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL)
@@ -594,10 +707,7 @@ func UpdateChannelBalance(c *gin.Context) {
return
}
if channel.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "多密钥渠道不支持余额查询",
})
common.ApiErrorT(c, "Multi-key channels do not support balance queries")
return
}
result, err := updateChannelBalance(channel)
@@ -639,7 +749,7 @@ func updateAllChannelsBalance() error {
} 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()), "余额不足")
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "Insufficient balance")
}
}
time.Sleep(common.RequestInterval)
@@ -664,8 +774,8 @@ func UpdateAllChannelsBalance(c *gin.Context) {
func AutomaticallyUpdateChannels(frequency int) {
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("updating all channels")
common.SysLog(common.LogText("updating all channels"))
_ = updateAllChannelsBalance()
common.SysLog("channels update done")
common.SysLog(common.LogText("channels update done"))
}
}
+24 -18
View File
@@ -16,6 +16,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
@@ -293,7 +294,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
//// 创建一个用于日志的 info 副本,移除 ApiKey
//logInfo := info
//logInfo.ApiKey = ""
common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString()))
common.SysLog(common.LogText("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString()))
priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
if err != nil {
@@ -447,7 +448,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
common.SysError(fmt.Sprintf(
common.SysError(common.LogText(
"channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v",
channel.Id,
channel.Name,
@@ -508,15 +509,15 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
ModelName: info.OriginModelName,
TokenName: "模型测试",
TokenName: "Model test",
Quota: quota,
Content: "模型测试",
Content: []*common.Message{common.NewMessage("Model test")},
UseTimeSeconds: int(consumedTime),
IsStream: info.IsStream,
Group: info.UsingGroup,
Other: other,
})
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
common.SysLog(common.LogText("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
return testResult{
context: c,
localErr: nil,
@@ -938,7 +939,12 @@ func testChannelForHealthCheck(ctx context.Context, channel *model.Channel, test
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
if milliseconds > disableThreshold {
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
// The reason is stored with the channel and sent in the disable
// notice; no request states a language here.
err := errors.New(i18n.Translate("", i18n.MsgChannelResponseTimeExceeded, map[string]any{
"Seconds": fmt.Sprintf("%.2f", float64(milliseconds)/1000.0),
"Threshold": fmt.Sprintf("%.2f", float64(disableThreshold)/1000.0),
}))
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
shouldBanChannel = true
}
@@ -1085,8 +1091,9 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse
// stops promptly. mode selects the channel set: an empty mode falls back to the
// configured monitor ChannelTestMode (scheduled behavior), while a manual
// trigger passes ChannelTestModeScheduledAll to test every channel. When notify
// is set the root user is notified on completion. Cross-instance execution is
// guarded by the system task per-type lock, so no process-local guard is needed.
// is set, the administrators who receive channel notices are notified on
// completion. Cross-instance execution is guarded by the system task per-type
// lock, so no process-local guard is needed.
func runChannelTestTask(ctx context.Context, mode string, notify bool, report func(processed, total int)) (channelTestSummary, error) {
testUserID, err := resolveChannelTestUserID(nil)
if err != nil {
@@ -1104,7 +1111,9 @@ func runChannelTestTask(ctx context.Context, mode string, notify bool, report fu
concurrency := operation_setting.GetMonitorSetting().ChannelTestConcurrency
summary := performChannelTests(ctx, selected, testUserID, allowDisable, concurrency, report)
if notify && (ctx == nil || ctx.Err() == nil) {
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
service.NotifyChannelWatchers(dto.NotifyTypeChannelTest, func(lang string) (string, string) {
return i18n.Translate(lang, i18n.MsgChannelTestCompletedSubject), i18n.Translate(lang, i18n.MsgChannelTestCompletedContent)
})
}
return summary, nil
}
@@ -1139,15 +1148,12 @@ func TestAllChannels(c *gin.Context) {
return
}
if !created {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"message": "已有通道测试任务正在运行或等待中,不能启动本次手动任务",
"data": gin.H{
"task_id": task.TaskID,
"status": task.Status,
"type": task.Type,
},
})
msg := common.NewMessage("A channel test task is already running or queued. Cannot start this manual task")
common.ApiErrorStatus(c, http.StatusConflict, msg, gin.H{"data": gin.H{
"task_id": task.TaskID,
"status": task.Status,
"type": task.Type,
}})
return
}
c.JSON(http.StatusOK, gin.H{
+142 -214
View File
@@ -6,13 +6,13 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
relaychannel "github.com/QuantumNous/new-api/relay/channel"
@@ -212,14 +212,14 @@ func GetAllChannels(c *gin.Context) {
if enableTagMode {
tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.SysError("failed to get paginated tags: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签失败,请稍后重试"})
common.SysError(common.LogText("failed to get paginated tags: %s", err.Error()))
common.ApiErrorT(c, "Failed to fetch tags. Please try again later")
return
}
total, err = model.CountChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter))
if err != nil {
common.SysError("failed to count tags: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签数量失败,请稍后重试"})
common.SysError(common.LogText("failed to count tags: %s", err.Error()))
common.ApiErrorT(c, "Failed to count tags. Please try again later")
return
}
for _, tag := range tags {
@@ -231,16 +231,16 @@ func GetAllChannels(c *gin.Context) {
Omit("key").
Find(&tagChannels).Error
if err != nil {
common.SysError("failed to get channels by tag: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签渠道失败,请稍后重试"})
common.SysError(common.LogText("failed to get channels by tag: %s", err.Error()))
common.ApiErrorT(c, "Failed to fetch channels with this tag. Please try again later")
return
}
channelData = append(channelData, tagChannels...)
}
} else {
if err := buildChannelListQuery(groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil {
common.SysError("failed to count channels: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道数量失败,请稍后重试"})
common.SysError(common.LogText("failed to count channels: %s", err.Error()))
common.ApiErrorT(c, "Failed to count channels. Please try again later")
return
}
@@ -250,8 +250,8 @@ func GetAllChannels(c *gin.Context) {
Omit("key").
Find(&channelData).Error
if err != nil {
common.SysError("failed to get channels: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"})
common.SysError(common.LogText("failed to get channels: %s", err.Error()))
common.ApiErrorT(c, "Failed to fetch channels. Please try again later")
return
}
}
@@ -266,8 +266,8 @@ func GetAllChannels(c *gin.Context) {
Count int64
}
if err := countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error; err != nil {
common.SysError("failed to count channel types: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道类型统计失败,请稍后重试"})
common.SysError(common.LogText("failed to count channel types: %s", err.Error()))
common.ApiErrorT(c, "Failed to count channel types. Please try again later")
return
}
typeCounts := make(map[int64]int64)
@@ -333,10 +333,7 @@ func FetchUpstreamModels(c *gin.Context) {
ids, err := fetchChannelUpstreamModelIDs(channel)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取模型列表失败: %s", err.Error()),
})
common.ApiErrorT(c, "Failed to fetch models: {{error}}", map[string]any{"error": err.Error()})
return
}
@@ -376,10 +373,7 @@ func SearchChannels(c *gin.Context) {
if enableTagMode {
tags, err := model.SearchTags(keyword, group, modelKeyword, idSort)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
for _, tag := range tags {
@@ -389,10 +383,7 @@ func SearchChannels(c *gin.Context) {
Omit("key").
Find(&tagChannels).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
channelData = append(channelData, tagChannels...)
@@ -401,10 +392,7 @@ func SearchChannels(c *gin.Context) {
} else {
channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort, sortOptions)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
channelData = channels
@@ -506,14 +494,14 @@ func GetChannel(c *gin.Context) {
func GetChannelKey(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil || channelId <= 0 {
common.ApiErrorMsg(c, "渠道ID格式错误")
common.ApiErrorT(c, "Invalid channel ID")
return
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorI18n(c, i18n.MsgChannelNotExists)
common.ApiErrorT(c, "Channel does not exist")
return
}
if err != nil {
@@ -528,15 +516,15 @@ func GetChannelKey(c *gin.Context) {
})
// 返回渠道密钥
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "获取成功",
"data": map[string]any{
"key": channel.Key,
},
common.ApiSuccessT(c, "Fetched successfully", map[string]any{
"key": channel.Key,
})
}
// maxTaskExtendPluginKeys bounds the plugins one New API channel can be
// extended with; it comfortably covers every built-in and installed plugin.
const maxTaskExtendPluginKeys = 32
// validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error {
if channel == nil {
@@ -545,7 +533,7 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
// 校验 channel settings
if err := channel.ValidateSettings(); err != nil {
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
return common.NewMessage("Invalid channel settings format: {{error}}", map[string]any{"error": err.Error()})
}
if channel.Type == constant.ChannelTypeTaskPlugin {
pluginKey := strings.TrimSpace(channel.GetSetting().TaskPluginKey)
@@ -571,6 +559,42 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
}
}
setting := channel.GetSetting()
if channel.Type != constant.ChannelTypeNewAPI && len(setting.TaskExtendPluginKeys) > 0 {
return fmt.Errorf("task_extend_plugin_keys is only supported on New API channels")
}
if channel.Type == constant.ChannelTypeNewAPI {
if len(setting.TaskExtendPluginKeys) > maxTaskExtendPluginKeys {
return fmt.Errorf("task_extend_plugin_keys must not exceed %d plugins", maxTaskExtendPluginKeys)
}
// The single key stays valid on a New API channel, so both bindings are
// checked as one set.
keys := setting.TaskExtendPluginKeys
if setting.TaskPluginKey != "" {
keys = append([]string{setting.TaskPluginKey}, keys...)
}
bound := make(map[string]struct{}, len(keys))
for _, key := range keys {
if key == "" || key != strings.TrimSpace(key) {
return fmt.Errorf("task plugin key %q is invalid", key)
}
if len(key) > 30 {
return fmt.Errorf("task plugin key must not exceed 30 characters")
}
if _, duplicate := bound[key]; duplicate {
return fmt.Errorf("task plugin %q is bound more than once", key)
}
plugin, ok := jsplugin.DefaultRegistry.Get(key)
if !ok {
return fmt.Errorf("task plugin %q is not registered", key)
}
if !plugin.Meta.SupportsUpstream(jsplugin.UpstreamKindNewAPI) {
return fmt.Errorf("task plugin %q does not support a New API upstream and cannot be bound to a New API channel", key)
}
bound[key] = struct{}{}
}
}
if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" {
return fmt.Errorf("New API channel base URL cannot be empty")
}
@@ -590,7 +614,7 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
// 检查模型名称长度是否超过 255
for _, m := range channel.GetModels() {
if len(m) > 255 {
return fmt.Errorf("模型名称过长: %s", m)
return common.NewMessage("Model name is too long: {{model}}", map[string]any{"model": m})
}
}
}
@@ -598,16 +622,16 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
// VertexAI 特殊校验
if channel.Type == constant.ChannelTypeVertexAi {
if channel.Other == "" {
return fmt.Errorf("部署地区不能为空")
return common.NewMessage("Deployment region cannot be empty")
}
regionMap, err := common.StrToMap(channel.Other)
if err != nil {
return fmt.Errorf("部署地区必须是标准的Json格式,例如{\"default\": \"us-central1\", \"region2\": \"us-east1\"}")
return common.NewMessage("Deployment region must be valid JSON, for example {{example}}", map[string]any{"example": `{"default": "us-central1", "region2": "us-east1"}`})
}
if regionMap["default"] == nil {
return fmt.Errorf("部署地区必须包含default字段")
return common.NewMessage("Deployment region must include a default field")
}
}
@@ -646,8 +670,8 @@ func RefreshCodexChannelCredential(c *gin.Context) {
oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true})
if err != nil {
common.SysError("failed to refresh codex channel credential: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "刷新凭证失败,请稍后重试"})
common.SysError(common.LogText("failed to refresh codex channel credential: %s", err.Error()))
common.ApiErrorT(c, "Failed to refresh credentials. Please try again later")
return
}
@@ -680,7 +704,7 @@ func getVertexArrayKeys(keys string) ([]string, error) {
var keyArray []any
err := common.Unmarshal([]byte(keys), &keyArray)
if err != nil {
return nil, fmt.Errorf("批量添加 Vertex AI 必须使用标准的JsonArray格式,例如[{key1}, {key2}...],请检查输入: %w", err)
return nil, common.NewMessage("Batch-adding Vertex AI keys requires a JSON array, for example {{example}}. Check the input: {{error}}", map[string]any{"example": "[{key1}, {key2}...]", "error": err.Error()})
}
cleanKeys := make([]string, 0, len(keyArray))
for _, key := range keyArray {
@@ -689,9 +713,9 @@ func getVertexArrayKeys(keys string) ([]string, error) {
case string:
keyStr = strings.TrimSpace(v)
default:
bytes, err := json.Marshal(v)
bytes, err := common.Marshal(v)
if err != nil {
return nil, fmt.Errorf("Vertex AI key JSON 编码失败: %w", err)
return nil, common.NewMessage("Failed to encode Vertex AI key JSON: {{error}}", map[string]any{"error": err.Error()})
}
keyStr = string(bytes)
}
@@ -700,7 +724,7 @@ func getVertexArrayKeys(keys string) ([]string, error) {
}
}
if len(cleanKeys) == 0 {
return nil, fmt.Errorf("批量添加 Vertex AI 的 keys 不能为空")
return nil, common.NewMessage("Vertex AI keys for batch adding cannot be empty")
}
return cleanKeys, nil
}
@@ -713,7 +737,10 @@ func AddChannel(c *gin.Context) {
return
}
if addChannelRequest.Channel != nil && addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin &&
// Binding a plugin needs the same permission on a New API channel as the
// type-61 channel dedicated to it.
if addChannelRequest.Channel != nil &&
(addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin || len(addChannelRequest.Channel.GetSetting().TaskPluginBindings()) > 0) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -727,10 +754,7 @@ func AddChannel(c *gin.Context) {
(addChannelRequest.Channel.BaseURL == nil || strings.TrimSpace(*addChannelRequest.Channel.BaseURL) == "")
// 使用统一的校验函数
if err := validateChannel(addChannelRequest.Channel, true); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -743,10 +767,7 @@ func AddChannel(c *gin.Context) {
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(array)
@@ -769,10 +790,7 @@ func AddChannel(c *gin.Context) {
// multi json
keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
} else {
@@ -781,10 +799,7 @@ func AddChannel(c *gin.Context) {
case "single":
keys = []string{addChannelRequest.Channel.Key}
default:
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "不支持的添加模式",
})
common.ApiErrorT(c, "Unsupported add mode")
return
}
@@ -905,10 +920,7 @@ func DisableTagChannels(c *gin.Context) {
channelTag := ChannelTag{}
err := c.ShouldBindJSON(&channelTag)
if err != nil || channelTag.Tag == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
common.ApiErrorT(c, "Invalid parameters")
return
}
channels, err := model.GetChannelsByTag(channelTag.Tag, false, false)
@@ -938,10 +950,7 @@ func EnableTagChannels(c *gin.Context) {
channelTag := ChannelTag{}
err := c.ShouldBindJSON(&channelTag)
if err != nil || channelTag.Tag == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
common.ApiErrorT(c, "Invalid parameters")
return
}
err = model.EnableChannelByTag(channelTag.Tag)
@@ -964,31 +973,22 @@ func EditTagChannels(c *gin.Context) {
channelTag := ChannelTag{}
err := c.ShouldBindJSON(&channelTag)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
common.ApiErrorT(c, "Invalid parameters")
return
}
if channelTag.Tag == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "tag不能为空",
})
common.ApiErrorT(c, "Tag cannot be empty")
return
}
if (channelTag.ParamOverride != nil || channelTag.HeaderOverride != nil) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
common.ApiErrorT(c, "Unauthorized, insufficient privileges")
return
}
if channelTag.ParamOverride != nil {
trimmed := strings.TrimSpace(*channelTag.ParamOverride)
if trimmed != "" && !json.Valid([]byte(trimmed)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数覆盖必须是合法的 JSON 格式",
})
common.ApiErrorT(c, "Parameter override must be valid JSON format")
return
}
channelTag.ParamOverride = common.GetPointer[string](trimmed)
@@ -996,10 +996,7 @@ func EditTagChannels(c *gin.Context) {
if channelTag.HeaderOverride != nil {
trimmed := strings.TrimSpace(*channelTag.HeaderOverride)
if trimmed != "" && !json.Valid([]byte(trimmed)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请求头覆盖必须是合法的 JSON 格式",
})
common.ApiErrorT(c, "Header override must be valid JSON format")
return
}
channelTag.HeaderOverride = common.GetPointer[string](trimmed)
@@ -1029,10 +1026,7 @@ func DeleteChannelBatch(c *gin.Context) {
channelBatch := ChannelBatch{}
err := c.ShouldBindJSON(&channelBatch)
if err != nil || len(channelBatch.Ids) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
common.ApiErrorT(c, "Invalid parameters")
return
}
deletedCount, err := model.BatchDeleteChannels(channelBatch.Ids)
@@ -1088,7 +1082,7 @@ func UpdateChannel(c *gin.Context) {
return
}
if _, ok := requestData["status"]; ok {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
clearChannelReadOnlyFields(&channel, requestData)
@@ -1106,35 +1100,42 @@ func UpdateChannel(c *gin.Context) {
(channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "")
// 使用统一的校验函数
if err := validateChannel(&channel.Channel, false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
originChannel, err := model.GetChannelById(channel.Id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
originProxy := originChannel.GetSetting().Proxy
proxyChanged := false
if _, settingProvided := requestData["setting"]; settingProvided {
_, settingProvided := requestData["setting"]
if settingProvided {
newProxy, _ := service.NormalizeProxyURL(channel.GetSetting().Proxy)
normalizedOriginProxy, originProxyErr := service.NormalizeProxyURL(originProxy)
proxyChanged = originProxyErr != nil || normalizedOriginProxy != newProxy
}
// Changing which plugins a channel binds needs the bind permission on any
// channel type; resubmitting an unchanged New API binding list does not, so
// administrators without it can still edit the rest of a gateway channel.
if settingProvided &&
!slices.Equal(channel.GetSetting().TaskPluginBindings(), originChannel.GetSetting().TaskPluginBindings()) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "task plugin channels require the task_plugin.bind permission",
})
return
}
// Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained.
channel.ChannelInfo = originChannel.ChannelInfo
if channelHasSensitiveChanges(&channel, originChannel, requestData) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
common.ApiErrorT(c, "Unauthorized, insufficient privileges")
return
}
@@ -1173,10 +1174,7 @@ func UpdateChannel(c *gin.Context) {
if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
array, err := getVertexArrayKeys(channel.Key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "追加密钥解析失败: " + err.Error(),
})
common.ApiErrorT(c, "Failed to parse appended keys: {{error}}", map[string]any{"error": err.Error()})
return
}
newKeys = array
@@ -1271,12 +1269,12 @@ func UpdateChannel(c *gin.Context) {
func UpdateChannelStatus(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
req := ChannelStatusRequest{}
if err := c.ShouldBindJSON(&req); err != nil || !isManageableChannelStatus(req.Status) {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation")
@@ -1301,7 +1299,7 @@ func UpdateChannelStatus(c *gin.Context) {
func BatchUpdateChannelStatus(c *gin.Context) {
req := ChannelStatusBatchRequest{}
if err := c.ShouldBindJSON(&req); err != nil || len(req.Ids) == 0 || !isManageableChannelStatus(req.Status) {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
changedCount := 0
@@ -1441,10 +1439,7 @@ func FetchModels(c *gin.Context) {
var err error
channel, err = buildAdvancedCustomModelPreviewChannel(req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
} else {
@@ -1469,10 +1464,7 @@ func FetchModels(c *gin.Context) {
models, err := fetchChannelUpstreamModelIDs(channel)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取模型列表失败: %s", err.Error()),
})
common.ApiErrorT(c, "Failed to fetch models: {{error}}", map[string]any{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
@@ -1486,10 +1478,7 @@ func BatchSetChannelTag(c *gin.Context) {
channelBatch := ChannelBatch{}
err := c.ShouldBindJSON(&channelBatch)
if err != nil || len(channelBatch.Ids) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
common.ApiErrorT(c, "Invalid parameters")
return
}
err = model.BatchSetChannelTag(channelBatch.Ids, channelBatch.Tag)
@@ -1512,10 +1501,7 @@ func BatchSetChannelTag(c *gin.Context) {
func GetTagModels(c *gin.Context) {
tag := c.Query("tag")
if tag == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "tag不能为空",
})
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Tag cannot be empty"))
return
}
@@ -1554,7 +1540,7 @@ func GetTagModels(c *gin.Context) {
// POST /api/channel/copy/:id
// Optional query params:
//
// suffix - string appended to the original name (default "_复制")
// suffix - string appended to the original name (default "_copy")
// reset_balance - bool, when true will reset balance & used_quota to 0 (default true)
func CopyChannel(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
@@ -1563,7 +1549,7 @@ func CopyChannel(c *gin.Context) {
return
}
suffix := c.DefaultQuery("suffix", "_复制")
suffix := c.DefaultQuery("suffix", "_copy")
resetBalance := true
if rbStr := c.DefaultQuery("reset_balance", "true"); rbStr != "" {
if v, err := strconv.ParseBool(rbStr); err == nil {
@@ -1574,11 +1560,11 @@ func CopyChannel(c *gin.Context) {
// fetch original channel with key
origin, err := model.GetChannelById(id, true)
if err != nil {
common.SysError("failed to get channel by id: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道信息失败,请稍后重试"})
common.SysError(common.LogText("failed to get channel by id: %s", err.Error()))
common.ApiErrorT(c, "Failed to fetch channel. Please try again later")
return
}
if origin.Type == constant.ChannelTypeTaskPlugin &&
if (origin.Type == constant.ChannelTypeTaskPlugin || len(origin.GetSetting().TaskPluginBindings()) > 0) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "task plugin channels require the task_plugin.bind permission"})
return
@@ -1597,15 +1583,15 @@ func CopyChannel(c *gin.Context) {
}
if err := clone.ValidateSettings(); err != nil {
common.SysError("failed to validate cloned channel: " + err.Error())
common.SysError(common.LogText("failed to validate cloned channel: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "Failed to copy channel: invalid channel settings"})
return
}
// insert
if err := clone.Insert(); err != nil {
common.SysError("failed to clone channel: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"})
common.SysError(common.LogText("failed to clone channel: %s", err.Error()))
common.ApiErrorT(c, "Failed to copy channel. Please try again later")
return
}
model.InitChannelCache()
@@ -1660,23 +1646,17 @@ func ManageMultiKeys(c *gin.Context) {
channel, err := model.GetChannelById(request.ChannelId, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "渠道不存在",
})
common.ApiErrorT(c, "Channel does not exist")
return
}
if !channel.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该渠道不是多密钥模式",
})
common.ApiErrorT(c, "This channel is not in multi-key mode")
return
}
if multiKeyActionRequiresSensitiveWrite(request.Action) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
common.ApiErrorT(c, "Unauthorized, insufficient privileges")
return
}
@@ -1808,19 +1788,13 @@ func ManageMultiKeys(c *gin.Context) {
case "disable_key":
if request.KeyIndex == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "未指定要禁用的密钥索引",
})
common.ApiErrorT(c, "No key index specified to disable")
return
}
keyIndex := *request.KeyIndex
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "密钥索引超出范围",
})
common.ApiErrorT(c, "Key index out of range")
return
}
@@ -1846,27 +1820,18 @@ func ManageMultiKeys(c *gin.Context) {
if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密钥已禁用",
})
common.ApiSuccessT(c, "Key disabled", nil)
return
case "enable_key":
if request.KeyIndex == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "未指定要启用的密钥索引",
})
common.ApiErrorT(c, "No key index specified to enable")
return
}
keyIndex := *request.KeyIndex
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "密钥索引超出范围",
})
common.ApiErrorT(c, "Key index out of range")
return
}
@@ -1889,10 +1854,7 @@ func ManageMultiKeys(c *gin.Context) {
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密钥已启用",
})
common.ApiSuccessT(c, "Key enabled", nil)
return
case "enable_all_keys":
@@ -1914,10 +1876,7 @@ func ManageMultiKeys(c *gin.Context) {
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("已启用 %d 个密钥", enabledCount),
})
common.ApiSuccessT(c, "Enabled {{count}} keys", nil, map[string]any{"count": enabledCount})
return
case "disable_all_keys":
@@ -1947,10 +1906,7 @@ func ManageMultiKeys(c *gin.Context) {
}
if disabledCount == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "没有可禁用的密钥",
})
common.ApiErrorT(c, "No keys to disable")
return
}
@@ -1964,27 +1920,18 @@ func ManageMultiKeys(c *gin.Context) {
if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("已禁用 %d 个密钥", disabledCount),
})
common.ApiSuccessT(c, "Disabled {{count}} keys", nil, map[string]any{"count": disabledCount})
return
case "delete_key":
if request.KeyIndex == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "未指定要删除的密钥索引",
})
common.ApiErrorT(c, "No key index specified to delete")
return
}
keyIndex := *request.KeyIndex
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "密钥索引超出范围",
})
common.ApiErrorT(c, "Key index out of range")
return
}
@@ -2023,10 +1970,7 @@ func ManageMultiKeys(c *gin.Context) {
}
if len(remainingKeys) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "不能删除最后一个密钥",
})
common.ApiErrorT(c, "Cannot delete the last key")
return
}
@@ -2047,10 +1991,7 @@ func ManageMultiKeys(c *gin.Context) {
if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密钥已删除",
})
common.ApiSuccessT(c, "Key deleted", nil)
return
case "delete_disabled_keys":
@@ -2094,10 +2035,7 @@ func ManageMultiKeys(c *gin.Context) {
}
if deletedCount == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "没有需要删除的自动禁用密钥",
})
common.ApiErrorT(c, "No auto-disabled keys to delete")
return
}
@@ -2118,18 +2056,11 @@ func ManageMultiKeys(c *gin.Context) {
if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount),
"data": deletedCount,
})
common.ApiSuccessT(c, "Deleted {{count}} auto-disabled keys", deletedCount, map[string]any{"count": deletedCount})
return
default:
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "不支持的操作",
})
common.ApiErrorT(c, "Unsupported operation")
return
}
}
@@ -2382,10 +2313,7 @@ func OllamaVersion(c *gin.Context) {
key := strings.Split(channel.Key, "\n")[0]
version, err := ollama.FetchOllamaVersion(baseURL, key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取Ollama版本失败: %s", err.Error()),
})
common.ApiErrorT(c, "Failed to fetch Ollama version: {{error}}", map[string]any{"error": err.Error()})
return
}
+3 -8
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
@@ -34,19 +35,13 @@ func ClearChannelAffinityCache(c *gin.Context) {
}
if ruleName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "缺少参数:rule_name,或使用 all=true 清空全部",
})
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Missing parameter rule_name, or use all=true to clear everything"))
return
}
deleted, err := service.ClearChannelAffinityCacheByRuleName(ruleName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiErrorStatus(c, http.StatusBadRequest, err)
return
}
+159
View File
@@ -0,0 +1,159 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// setupSub2APIBalanceTest wires an in-memory SQLite DB into model.DB so
// Channel.UpdateBalance can persist, mirroring the pattern used by other
// controller tests.
func setupSub2APIBalanceTest(t *testing.T) {
t.Helper()
originalDB := model.DB
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Discard,
})
require.NoError(t, err)
sqlDB, err := database.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, database.AutoMigrate(&model.Channel{}))
model.DB = database
t.Cleanup(func() {
model.DB = originalDB
})
}
func newSub2APITestChannel(t *testing.T, serverURL string) *model.Channel {
t.Helper()
channel := &model.Channel{
Type: constant.ChannelTypeSub2API,
BaseURL: &serverURL,
Key: "sk-test",
Name: "sub2api-test",
}
require.NoError(t, model.DB.Create(channel).Error)
return channel
}
func TestUpdateChannelSub2APIBalanceRemainingPreferred(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/usage", r.URL.Path)
assert.Equal(t, "Bearer sk-test", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"balance": 100.0, "remaining": 986.17194888, "isValid": true, "unit": "USD"}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
balance, err := updateChannelSub2APIBalance(channel)
require.NoError(t, err)
assert.InDelta(t, 986.17194888, balance, 1e-9)
assert.InDelta(t, 986.17194888, channel.Balance, 1e-9)
}
func TestUpdateChannelSub2APIBalanceWalletFallback(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"balance": 42.5, "isValid": true, "unit": "USD"}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
balance, err := updateChannelSub2APIBalance(channel)
require.NoError(t, err)
assert.InDelta(t, 42.5, balance, 1e-9)
}
func TestUpdateChannelSub2APIBalanceSubscriptionKeyMissingFields(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"balance": null, "remaining": null, "isValid": true, "mode": "unrestricted"}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
_, err := updateChannelSub2APIBalance(channel)
require.Error(t, err)
}
func TestUpdateChannelSub2APIBalanceInvalidKey(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"isValid": false}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
_, err := updateChannelSub2APIBalance(channel)
require.EqualError(t, err, "Sub2API key is invalid (isValid=false)")
}
func TestUpdateChannelSub2APIBalanceUnlimitedSubscription(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"remaining": -1, "isValid": true, "mode": "unrestricted", "subscription": {"daily_limit_usd": null, "weekly_limit_usd": null, "monthly_limit_usd": null}}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
require.NoError(t, model.DB.Model(channel).Update("balance", 12.5).Error)
_, err := updateChannelSub2APIBalance(channel)
require.EqualError(t, err, "Sub2API /v1/usage reports an unlimited subscription without a finite balance")
stored := model.Channel{}
require.NoError(t, model.DB.First(&stored, channel.Id).Error)
assert.InDelta(t, 12.5, stored.Balance, 1e-9)
}
func TestUpdateChannelSub2APIBalanceNegativeWalletBalance(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"balance": -1, "remaining": -1, "isValid": true, "mode": "unrestricted", "unit": "USD"}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
balance, err := updateChannelSub2APIBalance(channel)
require.NoError(t, err)
assert.InDelta(t, -1, balance, 1e-9)
}
func TestUpdateChannelSub2APIBalanceMissingBaseURL(t *testing.T) {
setupSub2APIBalanceTest(t)
empty := ""
channel := &model.Channel{BaseURL: &empty, Key: "sk-test"}
_, err := updateChannelSub2APIBalance(channel)
require.Error(t, err)
}
func TestUpdateStandardChannelBalanceRoutesSub2API(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"balance": 77.7, "isValid": true, "unit": "USD"}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
result, err := updateChannelBalance(channel)
require.NoError(t, err)
assert.InDelta(t, 77.7, result.Balance, 1e-9)
}
+49
View File
@@ -2,9 +2,13 @@ package controller
import (
"math"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -114,3 +118,48 @@ func TestGetDeepSeekBalanceUSD(t *testing.T) {
})
}
}
func TestUpdateChannelNewAPIBalanceUsesTheSmallerRemainingQuota(t *testing.T) {
for _, tc := range []struct {
name string
usage string
want float64
wantErr string
}{
{name: "user quota below the key quota", usage: `{"code":true,"data":{"total_available":2000000,"unlimited_quota":false,"user_quota":{"total_available":500000}}}`, want: 0.5},
{name: "key quota below the user quota", usage: `{"code":true,"data":{"total_available":500000,"unlimited_quota":false,"user_quota":{"total_available":2000000}}}`, want: 0.5},
{name: "unlimited key uses the user quota", usage: `{"code":true,"data":{"total_available":0,"unlimited_quota":true,"user_quota":{"total_available":3000000}}}`, want: 3},
{name: "key quota alone without user quota", usage: `{"code":true,"data":{"total_available":1500000,"unlimited_quota":false}}`, want: 1.5},
{name: "unlimited key without user quota", usage: `{"code":true,"data":{"total_available":0,"unlimited_quota":true}}`, wantErr: "Allow querying user quota"},
{name: "upstream rejects the key", usage: `{"success":false,"message":"invalid token"}`, wantErr: "invalid token"},
} {
t.Run(tc.name, func(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/usage/token/":
assert.Equal(t, "Bearer sk-upstream", r.Header.Get("Authorization"))
_, _ = w.Write([]byte(tc.usage))
case "/api/status":
assert.Empty(t, r.Header.Get("Authorization"), "the key is not sent to the public status endpoint")
_, _ = w.Write([]byte(`{"success":true,"data":{"quota_per_unit":1000000}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
baseURL := server.URL + "/"
channel := &model.Channel{Type: constant.ChannelTypeNewAPI, BaseURL: &baseURL, Key: "sk-upstream", Name: "new-api-upstream"}
require.NoError(t, model.DB.Create(channel).Error)
result, err := updateChannelBalance(channel)
if tc.wantErr != "" {
require.ErrorContains(t, err, tc.wantErr)
return
}
require.NoError(t, err)
assert.InDelta(t, tc.want, result.Balance, 1e-9)
assert.InDelta(t, tc.want, channel.Balance, 1e-9)
})
}
}
+40
View File
@@ -0,0 +1,40 @@
package controller
import (
"errors"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
func GetChannelPriorityOrder(c *gin.Context) {
order, err := model.GetChannelPriorityOrder()
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": order})
}
func ReorderChannelPriority(c *gin.Context) {
var move model.ChannelPriorityMove
if err := common.DecodeJson(c.Request.Body, &move); err != nil {
common.ApiErrorT(c, "Invalid channel priority move")
return
}
order, err := model.ReorderChannelPriority(move)
if err != nil {
if errors.Is(err, model.ErrChannelPriorityConflict) || errors.Is(err, model.ErrChannelPriorityInvalid) || errors.Is(err, model.ErrChannelPriorityRange) {
common.ApiErrorT(c, err.Error())
} else {
common.ApiError(c, err)
}
return
}
if !move.Preview && len(order.Changes) > 0 {
model.InitChannelCache()
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": order})
}
@@ -164,3 +164,69 @@ export function parseTaskResult() { return {}; }
require.NoError(t, err)
assert.Contains(t, string(encoded), `"base_url_source":"plugin_default"`)
}
func putUpdateChannel(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.MethodPut, "/api/channel", strings.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
UpdateChannel(context)
return recorder
}
func TestNewAPIChannelPluginBindingsRequireBindPermission(t *testing.T) {
setupTaskPluginBindChannelTest(t)
const key = "gateway-bind"
source := `
export const meta = {apiVersion: 1, key: "gateway-bind", name: "Gateway", version: "1.0.0", author: {name: "Test"}, models: ["gateway-doc"], fetchMode: "per_task", upstreams: ["vendor", "new_api"]};
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) })
bound := `{"mode":"single","channel":{"type":60,"name":"gateway","key":"sk","models":"gateway-doc","group":"default","base_url":"https://gateway.example","setting":"{\"task_extend_plugin_keys\":[\"gateway-bind\"]}"}}`
unbound := `{"mode":"single","channel":{"type":60,"name":"plain-gateway","key":"sk","models":"gpt","group":"default","base_url":"https://gateway.example"}}`
adminDenied := postAddChannel(t, 2, common.RoleAdminUser, bound)
assert.Contains(t, adminDenied.Body.String(), "task plugin channels require the task_plugin.bind permission")
rootAllowed := postAddChannel(t, 1, common.RoleRootUser, bound)
assert.Contains(t, rootAllowed.Body.String(), `"success":true`)
adminUnbound := postAddChannel(t, 2, common.RoleAdminUser, unbound)
assert.Contains(t, adminUnbound.Body.String(), `"success":true`, "a gateway channel without plugin bindings needs no bind permission")
baseURL := "https://gateway.example"
setting := `{"task_extend_plugin_keys":["gateway-bind"]}`
channel := model.Channel{Type: constant.ChannelTypeNewAPI, Status: common.ChannelStatusEnabled, Name: "existing-gateway", Models: "gateway-doc", Group: "default", Key: "sk", BaseURL: &baseURL, Setting: &setting}
require.NoError(t, channel.Insert())
update := func(name, setting string) string {
return fmt.Sprintf(`{"id":%d,"type":60,"name":%q,"key":"sk","models":"gateway-doc","group":"default","base_url":"https://gateway.example","setting":%q}`, channel.Id, name, setting)
}
unchanged := putUpdateChannel(t, 2, common.RoleAdminUser, update("renamed-gateway", setting))
assert.Contains(t, unchanged.Body.String(), `"success":true`, "resubmitting the stored bindings does not need the bind permission")
assert.NotContains(t, unchanged.Body.String(), "task_plugin.bind")
rebound := putUpdateChannel(t, 2, common.RoleAdminUser, update("renamed-gateway", `{"task_extend_plugin_keys":[]}`))
assert.Contains(t, rebound.Body.String(), "task plugin channels require the task_plugin.bind permission")
rootRebound := putUpdateChannel(t, 1, common.RoleRootUser, update("renamed-gateway", `{"task_extend_plugin_keys":[]}`))
assert.Contains(t, rootRebound.Body.String(), `"success":true`)
require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", channel.Id).Update("setting", setting).Error)
copyChannel := func(userID, role int) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Set("id", userID)
context.Set("role", role)
context.Params = gin.Params{{Key: "id", Value: fmt.Sprint(channel.Id)}}
context.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/channel/copy/%d", channel.Id), nil)
CopyChannel(context)
return recorder
}
assert.Contains(t, copyChannel(2, common.RoleAdminUser).Body.String(), "task plugin channels require the task_plugin.bind permission")
assert.Contains(t, copyChannel(1, common.RoleRootUser).Body.String(), `"success":true`)
}
@@ -67,3 +67,49 @@ export function parseTaskResult() { return {}; }
require.NoError(t, validateChannel(channel, false))
assert.Equal(t, explicit, *channel.BaseURL, "an administrator value is never replaced by the plugin default")
}
func TestValidateNewAPIChannelTaskPluginExtensions(t *testing.T) {
// gateway-ext-vendor never declares new_api, so it can only run against
// its vendor and must be refused on a New API channel.
for key, upstreams := range map[string]string{"gateway-ext-a": `upstreams: ["vendor", "new_api"],`, "gateway-ext-b": `upstreams: ["new_api"],`, "gateway-ext-vendor": ""} {
source := `
export const meta = {apiVersion: 1, key: "` + key + `", name: "Gateway", version: "1.0.0", author: {name: "Test"}, models: ["doc-` + key + `"], fetchMode: "per_task", ` + upstreams + `};
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://gateway.example"
cases := []struct {
name, setting, wantErr string
}{
{"single and extension keys", `{"task_plugin_key":"gateway-ext-a","task_extend_plugin_keys":["gateway-ext-b"]}`, ""},
{"extension keys only", `{"task_extend_plugin_keys":["gateway-ext-a","gateway-ext-b"]}`, ""},
{"unregistered", `{"task_extend_plugin_keys":["gateway-ext-a","missing"]}`, `task plugin "missing" is not registered`},
{"vendor-only plugin as extension", `{"task_extend_plugin_keys":["gateway-ext-a","gateway-ext-vendor"]}`, `task plugin "gateway-ext-vendor" does not support a New API upstream`},
{"vendor-only plugin as single key", `{"task_plugin_key":"gateway-ext-vendor"}`, `task plugin "gateway-ext-vendor" does not support a New API upstream`},
{"duplicate", `{"task_extend_plugin_keys":["gateway-ext-a","gateway-ext-a"]}`, "bound more than once"},
{"duplicate of single key", `{"task_plugin_key":"gateway-ext-a","task_extend_plugin_keys":["gateway-ext-a"]}`, "bound more than once"},
{"padded", `{"task_extend_plugin_keys":[" gateway-ext-a"]}`, "is invalid"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
setting := tc.setting
channel := &model.Channel{Type: constant.ChannelTypeNewAPI, BaseURL: &baseURL, Setting: &setting}
err := validateChannel(channel, false)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
setting := `{"task_extend_plugin_keys":["gateway-ext-a"]}`
channel := &model.Channel{Type: constant.ChannelTypeOpenAI, Setting: &setting}
require.ErrorContains(t, validateChannel(channel, false), "only supported on New API channels")
}
+1 -1
View File
@@ -491,5 +491,5 @@ func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) {
require.Equal(t, http.StatusConflict, recorder.Code)
require.Contains(t, recorder.Body.String(), existing.TaskID)
require.Contains(t, recorder.Body.String(), "已有通道测试任务正在运行或等待中")
require.Contains(t, recorder.Body.String(), "A channel test task is already running or queued")
}
+4
View File
@@ -82,6 +82,8 @@ func TestChannelTestOpenAIChatCompatibility(t *testing.T) {
}{
{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: "GPT6 sol", model: "gpt-6-sol", upstream: "gpt-6-sol", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "GPT6 luna", model: "gpt-6-luna", upstream: "gpt-6-luna", channelType: constant.ChannelTypeOpenAI, 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"},
@@ -136,6 +138,8 @@ func TestOpenAIChatSamplingCompatibility(t *testing.T) {
{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: "GPT6 sol none keeps sampling", model: "gpt-6-sol", wantRole: "developer", wantParams: sampling},
{name: "GPT6 luna effort drops sampling", model: "gpt-6-luna-high", wantModel: "gpt-6-luna", 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: `{}`},
+78 -63
View File
@@ -15,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
@@ -346,7 +347,8 @@ func getFetchModelsResponseBody(method string, requestURL string, channel *model
request.Host = headers.Get(name)
}
}
client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy)
setting := channel.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return nil, err
}
@@ -388,10 +390,10 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
if channel.Type == constant.ChannelTypeGemini {
key, _, apiErr := channel.GetNextEnabledKey()
if apiErr != nil {
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
}
key = strings.TrimSpace(key)
models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting().Proxy)
models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting())
if err != nil {
return nil, err
}
@@ -437,7 +439,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
key, _, apiErr := channel.GetNextEnabledKey()
if apiErr != nil {
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
}
key = strings.TrimSpace(key)
@@ -467,7 +469,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
func fetchAdvancedCustomUpstreamModelIDs(channel *model.Channel, baseURL string) ([]string, error) {
key, _, apiErr := channel.GetNextEnabledKey()
if apiErr != nil {
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
}
key = strings.TrimSpace(key)
@@ -564,7 +566,7 @@ func refreshChannelRuntimeCache() {
func() {
defer func() {
if r := recover(); r != nil {
common.SysLog(fmt.Sprintf("InitChannelCache panic: %v", r))
common.SysLog(common.LogText("InitChannelCache panic: %v", r))
}
}()
model.InitChannelCache()
@@ -594,6 +596,7 @@ func shouldSendUpstreamModelUpdateNotification(now int64, changedChannels int, f
}
func buildUpstreamModelUpdateTaskNotificationContent(
lang string,
checkedChannels int,
changedChannels int,
detectedAddModels int,
@@ -606,50 +609,62 @@ func buildUpstreamModelUpdateTaskNotificationContent(
) string {
var builder strings.Builder
failedChannels := len(failedChannelIDs)
builder.WriteString(fmt.Sprintf(
"上游模型巡检摘要:检测渠道 %d 个,发现变更 %d 个,新增 %d 个,删除 %d 个,自动同步新增 %d 个,失败 %d 个。",
checkedChannels,
changedChannels,
detectedAddModels,
detectedRemoveModels,
autoAddedModels,
failedChannels,
))
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateSummary, map[string]any{
"Checked": checkedChannels,
"Changed": changedChannels,
"Added": detectedAddModels,
"Removed": detectedRemoveModels,
"AutoAdded": autoAddedModels,
"Failed": failedChannels,
}))
if len(channelSummaries) > 0 {
displayCount := min(len(channelSummaries), channelUpstreamModelUpdateNotifyMaxChannelDetails)
builder.WriteString(fmt.Sprintf("\n\n变更渠道明细(展示 %d/%d):", displayCount, len(channelSummaries)))
builder.WriteString("\n\n")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateChangedChannels, map[string]any{
"Shown": displayCount,
"Total": len(channelSummaries),
}))
for _, summary := range channelSummaries[:displayCount] {
builder.WriteString(fmt.Sprintf("\n- %s (+%d / -%d)", summary.ChannelName, summary.AddCount, summary.RemoveCount))
}
if len(channelSummaries) > displayCount {
builder.WriteString(fmt.Sprintf("\n- 其余 %d 个渠道已省略", len(channelSummaries)-displayCount))
builder.WriteString("\n- ")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreChannels, map[string]any{
"Count": len(channelSummaries) - displayCount,
}))
}
}
normalizedAddModelSamples := normalizeModelNames(addModelSamples)
if len(normalizedAddModelSamples) > 0 {
displayCount := min(len(normalizedAddModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
builder.WriteString(fmt.Sprintf("\n\n新增模型示例(展示 %d/%d):%s",
displayCount,
len(normalizedAddModelSamples),
strings.Join(normalizedAddModelSamples[:displayCount], ", "),
))
builder.WriteString("\n\n")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateAddedModels, map[string]any{
"Shown": displayCount,
"Total": len(normalizedAddModelSamples),
"Models": strings.Join(normalizedAddModelSamples[:displayCount], ", "),
}))
if len(normalizedAddModelSamples) > displayCount {
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", len(normalizedAddModelSamples)-displayCount))
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
"Count": len(normalizedAddModelSamples) - displayCount,
}))
}
}
normalizedRemoveModelSamples := normalizeModelNames(removeModelSamples)
if len(normalizedRemoveModelSamples) > 0 {
displayCount := min(len(normalizedRemoveModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
builder.WriteString(fmt.Sprintf("\n\n删除模型示例(展示 %d/%d):%s",
displayCount,
len(normalizedRemoveModelSamples),
strings.Join(normalizedRemoveModelSamples[:displayCount], ", "),
))
builder.WriteString("\n\n")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateRemovedModels, map[string]any{
"Shown": displayCount,
"Total": len(normalizedRemoveModelSamples),
"Models": strings.Join(normalizedRemoveModelSamples[:displayCount], ", "),
}))
if len(normalizedRemoveModelSamples) > displayCount {
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", len(normalizedRemoveModelSamples)-displayCount))
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
"Count": len(normalizedRemoveModelSamples) - displayCount,
}))
}
}
@@ -658,14 +673,16 @@ func buildUpstreamModelUpdateTaskNotificationContent(
displayIDs := lo.Map(failedChannelIDs[:displayCount], func(channelID int, _ int) string {
return fmt.Sprintf("%d", channelID)
})
builder.WriteString(fmt.Sprintf(
"\n\n失败渠道 ID(展示 %d/%d):%s",
displayCount,
failedChannels,
strings.Join(displayIDs, ", "),
))
builder.WriteString("\n\n")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateFailedChannels, map[string]any{
"Shown": displayCount,
"Total": failedChannels,
"Ids": strings.Join(displayIDs, ", "),
}))
if failedChannels > displayCount {
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", failedChannels-displayCount))
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
"Count": failedChannels - displayCount,
}))
}
}
return builder.String()
@@ -726,7 +743,7 @@ scanLoop:
}
err := query.Find(&channels).Error
if err != nil {
common.SysLog(fmt.Sprintf("upstream model update task query failed: %v", err))
common.SysLog(common.LogText("upstream model update task query failed: %v", err))
break
}
if len(channels) == 0 {
@@ -757,7 +774,7 @@ scanLoop:
if err != nil {
failedChannels++
failedChannelIDs = append(failedChannelIDs, channel.Id)
common.SysLog(fmt.Sprintf("upstream model update check failed: channel_id=%d channel_name=%s err=%v", channel.Id, channel.Name, err))
common.SysLog(common.LogText("upstream model update check failed: channel_id=%d channel_name=%s err=%v", channel.Id, channel.Name, err))
continue
}
currentAddModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels)
@@ -817,7 +834,7 @@ scanLoop:
}
if checkedChannels > 0 || common.DebugEnabled {
common.SysLog(fmt.Sprintf(
common.SysLog(common.LogText(
"upstream model update task done: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d",
checkedChannels,
changedChannels,
@@ -830,27 +847,28 @@ scanLoop:
if changedChannels > 0 || failedChannels > 0 {
now := common.GetTimestamp()
if !shouldSendUpstreamModelUpdateNotification(now, changedChannels, failedChannels) {
common.SysLog(fmt.Sprintf(
common.SysLog(common.LogText(
"upstream model update notification skipped in 24h window: changed_channels=%d failed_channels=%d",
changedChannels,
failedChannels,
))
return summary
}
service.NotifyUpstreamModelUpdateWatchers(
"上游模型巡检通知",
buildUpstreamModelUpdateTaskNotificationContent(
checkedChannels,
changedChannels,
detectedAddModels,
detectedRemoveModels,
autoAddedModels,
failedChannelIDs,
channelSummaries,
addModelSamples,
removeModelSamples,
),
)
service.NotifyUpstreamModelUpdateWatchers(func(lang string) (string, string) {
return i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateNotifySubject),
buildUpstreamModelUpdateTaskNotificationContent(
lang,
checkedChannels,
changedChannels,
detectedAddModels,
detectedRemoveModels,
autoAddedModels,
failedChannelIDs,
channelSummaries,
addModelSamples,
removeModelSamples,
)
})
}
return summary
}
@@ -1119,15 +1137,12 @@ func DetectAllChannelUpstreamModelUpdates(c *gin.Context) {
return
}
if !created {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"message": "已有模型更新任务正在运行或等待中,不能启动本次手动任务",
"data": gin.H{
"task_id": task.TaskID,
"status": task.Status,
"type": task.Type,
},
})
msg := common.NewMessage("A model update task is already running or queued. Cannot start this manual task")
common.ApiErrorStatus(c, http.StatusConflict, msg, gin.H{"data": gin.H{
"task_id": task.TaskID,
"status": task.Status,
"type": task.Type,
}})
return
}
+12 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/gin-gonic/gin"
@@ -542,7 +543,9 @@ func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *
})
}
require.NoError(t, i18n.Init())
content := buildUpstreamModelUpdateTaskNotificationContent(
i18n.LangEn,
24,
12,
56,
@@ -561,10 +564,14 @@ func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *
},
)
require.Contains(t, content, "其余 4 个渠道已省略")
require.Contains(t, content, "其余 1 个已省略")
require.Contains(t, content, "失败渠道 ID(展示 10/12)")
require.Contains(t, content, "其余 2 个已省略")
require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateMoreChannels, map[string]any{"Count": 4}))
require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{"Count": 1}))
require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateFailedChannels, map[string]any{
"Shown": 10,
"Total": 12,
"Ids": "1, 2, 3, 4, 5, 6, 7, 8, 9, 10",
}))
require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{"Count": 2}))
}
func TestShouldSendUpstreamModelUpdateNotification(t *testing.T) {
@@ -602,5 +609,5 @@ func TestDetectAllChannelUpstreamModelUpdatesRejectsExistingActiveTask(t *testin
require.Equal(t, http.StatusConflict, recorder.Code)
require.Contains(t, recorder.Body.String(), existing.TaskID)
require.Contains(t, recorder.Body.String(), "已有模型更新任务正在运行或等待中")
require.Contains(t, recorder.Body.String(), "A model update task is already running or queued")
}
+8 -18
View File
@@ -1,7 +1,6 @@
package controller
import (
"fmt"
"net/http"
"time"
@@ -16,7 +15,7 @@ import (
func GetCheckinStatus(c *gin.Context) {
setting := operation_setting.GetCheckinSetting()
if !setting.Enabled {
common.ApiErrorMsg(c, "签到功能未启用")
common.ApiErrorT(c, "Check-in feature is not enabled")
return
}
userId := c.GetInt("id")
@@ -25,10 +24,7 @@ func GetCheckinStatus(c *gin.Context) {
stats, err := model.GetUserCheckinStats(userId, month)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -47,7 +43,7 @@ func GetCheckinStatus(c *gin.Context) {
func DoCheckin(c *gin.Context) {
setting := operation_setting.GetCheckinSetting()
if !setting.Enabled {
common.ApiErrorMsg(c, "签到功能未启用")
common.ApiErrorT(c, "Check-in feature is not enabled")
return
}
@@ -55,18 +51,12 @@ func DoCheckin(c *gin.Context) {
checkin, err := model.UserCheckin(userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
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},
model.RecordLog(userId, model.LogTypeSystem, common.NewMessage("Daily check-in, received {{quota}}", map[string]any{"quota": logger.FormatQuota(checkin.QuotaAwarded)}))
common.ApiSuccessT(c, "Check-in successful", gin.H{
"quota_awarded": checkin.QuotaAwarded,
"checkin_date": checkin.CheckinDate,
})
}
+118
View File
@@ -0,0 +1,118 @@
package controller
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"os"
"slices"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
var cialloEnqueue sync.Mutex
func scheduleCialloDetections(ctx context.Context) error {
cialloEnqueue.Lock()
defer cialloEnqueue.Unlock()
channels, schedules, err := service.AutomaticDetectionChannelsDue(ctx, "ciallo", time.Now())
if err != nil {
return err
}
for _, channel := range channels {
groups := channel.GetGroups()
if len(groups) == 0 {
continue
}
id, err := common.GenerateRandomCharsKey(32)
if err != nil {
return err
}
run := model.DetectionRun{ID: id, RequestKey: fmt.Sprintf("ciallo:%d:%s", channel.Id, schedules[channel.Id].LastResultID), ChannelID: channel.Id, GroupName: groups[0], Kind: "ciallo", Status: "pending", CreatedAt: time.Now().Unix()}
if err := model.CreateDetection(&run); err != nil {
return err
}
}
return nil
}
// The derived capability only authorizes Ciallo probes, not balance queries.
func BotCiallo(c *gin.Context) {
c.Header("Cache-Control", "no-store")
key := os.Getenv("BOT_BALANCE_KEY")
derived := sha256.Sum256([]byte("newapi-ciallo-v1:" + key))
raw, bearer := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
expected, supplied := sha256.Sum256([]byte(hex.EncodeToString(derived[:]))), sha256.Sum256([]byte(raw))
if len(key) < 32 || !bearer || subtle.ConstantTimeCompare(expected[:], supplied[:]) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "code": "unauthorized"})
return
}
var run model.DetectionRun
if c.Request.Method == http.MethodGet {
if model.DB.Where("id = ? AND kind = ?", c.Param("id"), "ciallo").First(&run).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "code": "not_found"})
return
}
} else {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1024)
var input struct {
Group string `json:"group"`
}
if c.ShouldBindJSON(&input) != nil || strings.TrimSpace(input.Group) == "" || len(input.Group) > 128 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_group"})
return
}
input.Group = strings.TrimSpace(input.Group)
channels, err := service.AstraSamplingChannels(c.Request.Context())
if err != nil {
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
return
}
selected := 0
for _, channel := range channels {
if slices.Contains(channel.GetGroups(), input.Group) {
selected = channel.Id
break
}
}
if selected == 0 {
c.JSON(404, gin.H{"success": false, "code": "group_unavailable"})
return
}
cialloEnqueue.Lock()
var active []model.DetectionRun
err = model.DB.Where("channel_id = ? AND kind = ? AND settled = ?", selected, "ciallo", false).Order("created_at asc").Limit(1).Find(&active).Error
if err == nil && len(active) > 0 {
run = active[0]
} else if err == nil {
id, idErr := common.GenerateRandomCharsKey(32)
err = idErr
if err == nil {
run = model.DetectionRun{ID: id, RequestKey: "bot-ciallo:" + id, ChannelID: selected, GroupName: input.Group, Kind: "ciallo", Status: "pending", CreatedAt: time.Now().Unix(), Deadline: time.Now().Unix() + 200}
err = model.CreateDetection(&run)
}
}
cialloEnqueue.Unlock()
if err != nil {
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
return
}
// Start immediately; the durable worker reconciles interruptions and settlement.
if len(active) == 0 {
go processDetection(context.Background(), run)
}
}
var result fingerprint.Result
_ = common.UnmarshalJsonStr(run.Result, &result)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"id": run.ID, "status": run.Status, "settled": run.Settled, "quality": run.Quality, "text": result.Closest}})
}
+14 -13
View File
@@ -21,8 +21,8 @@ func GetCodexChannelUsage(c *gin.Context) {
fetchCodexChannelWhamData(
c,
service.FetchCodexWhamUsage,
"failed to fetch codex usage",
"获取用量信息失败,请稍后重试",
common.LogText("failed to fetch codex usage"),
"Failed to fetch usage. Please try again later",
)
}
@@ -30,8 +30,8 @@ func GetCodexChannelRateLimitResetCredits(c *gin.Context) {
fetchCodexChannelWhamData(
c,
service.FetchCodexWhamRateLimitResetCredits,
"failed to fetch codex reset credits",
"获取重置次数详情失败,请稍后重试",
common.LogText("failed to fetch codex reset credits"),
"Failed to fetch reset credit details. Please try again later",
)
}
@@ -39,8 +39,8 @@ func ResetCodexChannelUsage(c *gin.Context) {
fetchCodexChannelWhamData(
c,
service.ConsumeCodexWhamRateLimitResetCredit,
"failed to reset codex usage",
"重置用量失败,请稍后重试",
common.LogText("failed to reset codex usage"),
"Failed to reset usage. Please try again later",
)
}
@@ -84,8 +84,8 @@ func fetchCodexChannelWhamData(
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": "解析凭证失败,请检查渠道配置"})
common.SysError(common.LogText("failed to parse oauth key: %s", err.Error()))
common.ApiErrorT(c, "Failed to parse credentials. Please check the channel configuration")
return
}
accessToken := strings.TrimSpace(oauthKey.AccessToken)
@@ -99,7 +99,8 @@ func fetchCodexChannelWhamData(
return
}
client, err := service.GetHttpClientWithProxy(ch.GetSetting().Proxy)
setting := ch.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
common.ApiError(c, err)
return
@@ -111,7 +112,7 @@ func fetchCodexChannelWhamData(
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})
common.ApiErrorT(c, userMessage)
return
}
@@ -119,7 +120,7 @@ func fetchCodexChannelWhamData(
refreshCtx, refreshCancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer refreshCancel()
res, refreshErr := service.RefreshCodexOAuthTokenWithProxy(refreshCtx, oauthKey.RefreshToken, ch.GetSetting().Proxy)
res, refreshErr := service.RefreshCodexOAuthTokenWithProxy(refreshCtx, oauthKey.RefreshToken, setting.Proxy)
if refreshErr == nil {
oauthKey.AccessToken = res.AccessToken
oauthKey.RefreshToken = res.RefreshToken
@@ -139,8 +140,8 @@ func fetchCodexChannelWhamData(
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})
common.SysError(common.LogText("%s after refresh: %s", logPrefix, err.Error()))
common.ApiErrorT(c, userMessage)
return
}
}
+43 -47
View File
@@ -10,6 +10,7 @@ import (
"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"
@@ -96,13 +97,13 @@ func GetCustomOAuthProvider(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiErrorMsg(c, "无效的 ID")
common.ApiErrorT(c, "Invalid ID")
return
}
provider, err := model.GetCustomOAuthProviderById(id)
if err != nil {
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
common.ApiErrorT(c, "OAuth provider not found")
return
}
@@ -144,7 +145,7 @@ type FetchCustomOAuthDiscoveryRequest struct {
func FetchCustomOAuthDiscovery(c *gin.Context) {
var req FetchCustomOAuthDiscoveryRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
common.ApiErrorT(c, "Invalid request parameters: {{error}}", map[string]any{"error": err.Error()})
return
}
@@ -152,7 +153,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
issuerURL := strings.TrimSpace(req.IssuerURL)
if wellKnownURL == "" && issuerURL == "" {
common.ApiErrorMsg(c, "请先填写 Discovery URL 或 Issuer URL")
common.ApiErrorT(c, "Enter the Discovery URL or Issuer URL first")
return
}
@@ -164,7 +165,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
parsedURL, err := url.Parse(targetURL)
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
common.ApiErrorMsg(c, "Discovery URL 无效,仅支持 http/https")
common.ApiErrorT(c, "Invalid Discovery URL. Only http and https are supported")
return
}
@@ -173,7 +174,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
common.ApiErrorMsg(c, "创建 Discovery 请求失败: "+err.Error())
common.ApiErrorT(c, "Failed to create the Discovery request: {{error}}", map[string]any{"error": err.Error()})
return
}
httpReq.Header.Set("Accept", "application/json")
@@ -181,7 +182,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+err.Error())
common.ApiErrorT(c, "Failed to fetch the Discovery configuration: {{error}}", map[string]any{"error": err.Error()})
return
}
defer resp.Body.Close()
@@ -192,13 +193,13 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
if message == "" {
message = resp.Status
}
common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+message)
common.ApiErrorT(c, "Failed to fetch the Discovery configuration: {{error}}", map[string]any{"error": message})
return
}
var discovery map[string]any
if err = common.DecodeJson(resp.Body, &discovery); err != nil {
common.ApiErrorMsg(c, "解析 Discovery 配置失败: "+err.Error())
common.ApiErrorT(c, "Failed to parse the Discovery configuration: {{error}}", map[string]any{"error": err.Error()})
return
}
@@ -216,19 +217,19 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
func CreateCustomOAuthProvider(c *gin.Context) {
var req CreateCustomOAuthProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
common.ApiErrorT(c, "Invalid request parameters: {{error}}", map[string]any{"error": err.Error()})
return
}
// Check if slug is already taken
if model.IsSlugTaken(req.Slug, 0) {
common.ApiErrorMsg(c, "该 Slug 已被使用")
common.ApiErrorT(c, "This slug is already in use")
return
}
// Check if slug conflicts with built-in providers
if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
common.ApiErrorMsg(c, "该 Slug 与内置 OAuth 提供商冲突")
common.ApiErrorT(c, "This slug conflicts with a built-in OAuth provider")
return
}
@@ -261,11 +262,7 @@ func CreateCustomOAuthProvider(c *gin.Context) {
// Register the provider in the OAuth registry
oauth.RegisterOrUpdateCustomProvider(provider)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "创建成功",
"data": toCustomOAuthProviderResponse(provider),
})
common.ApiSuccessT(c, "Provider created successfully", toCustomOAuthProviderResponse(provider))
}
// UpdateCustomOAuthProviderRequest is the request structure for updating a custom OAuth provider
@@ -295,20 +292,20 @@ func UpdateCustomOAuthProvider(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiErrorMsg(c, "无效的 ID")
common.ApiErrorT(c, "Invalid ID")
return
}
var req UpdateCustomOAuthProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
common.ApiErrorT(c, "Invalid request parameters: {{error}}", map[string]any{"error": err.Error()})
return
}
// Get existing provider
provider, err := model.GetCustomOAuthProviderById(id)
if err != nil {
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
common.ApiErrorT(c, "OAuth provider not found")
return
}
@@ -317,12 +314,12 @@ func UpdateCustomOAuthProvider(c *gin.Context) {
// 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 已被使用")
common.ApiErrorT(c, "This slug is already in use")
return
}
// Check if slug conflicts with built-in providers
if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
common.ApiErrorMsg(c, "该 Slug 与内置 OAuth 提供商冲突")
common.ApiErrorT(c, "This slug conflicts with a built-in OAuth provider")
return
}
}
@@ -394,11 +391,7 @@ func UpdateCustomOAuthProvider(c *gin.Context) {
}
oauth.RegisterOrUpdateCustomProvider(provider)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "更新成功",
"data": toCustomOAuthProviderResponse(provider),
})
common.ApiSuccessT(c, "Provider updated successfully", toCustomOAuthProviderResponse(provider))
}
// DeleteCustomOAuthProvider deletes a custom OAuth provider
@@ -406,26 +399,26 @@ func DeleteCustomOAuthProvider(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiErrorMsg(c, "无效的 ID")
common.ApiErrorT(c, "Invalid ID")
return
}
// Get existing provider to get slug
provider, err := model.GetCustomOAuthProviderById(id)
if err != nil {
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
common.ApiErrorT(c, "OAuth provider not found")
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, "检查用户绑定时发生错误,请稍后重试")
common.SysError(common.LogText("Failed to get binding count for provider %s: %s", strconv.Itoa(id), err.Error()))
common.ApiErrorT(c, "Failed to check user bindings, please try again later")
return
}
if count > 0 {
common.ApiErrorMsg(c, "该 OAuth 提供商还有用户绑定,无法删除。请先解除所有用户绑定。")
common.ApiErrorT(c, "This OAuth provider still has linked users and cannot be deleted. Unbind all users first.")
return
}
@@ -437,10 +430,7 @@ func DeleteCustomOAuthProvider(c *gin.Context) {
// Unregister the provider from the OAuth registry
oauth.UnregisterCustomProvider(provider.Slug)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "删除成功",
})
common.ApiSuccessT(c, "Provider deleted successfully", nil)
}
func buildUserOAuthBindingsResponse(userId int) ([]UserOAuthBindingResponse, error) {
@@ -471,7 +461,7 @@ func buildUserOAuthBindingsResponse(userId int) ([]UserOAuthBindingResponse, err
func GetUserOAuthBindings(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
common.ApiErrorMsg(c, "未登录")
common.ApiErrorT(c, "Not logged in")
return
}
@@ -523,7 +513,7 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) {
// UnbindCustomOAuth unbinds a custom OAuth provider from the current user
func UnbindCustomOAuth(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
@@ -532,7 +522,7 @@ func UnbindCustomOAuth(c *gin.Context) {
providerIdStr := c.Param("provider_id")
providerId, err := strconv.Atoi(providerIdStr)
if err != nil || providerId <= 0 {
common.ApiErrorMsg(c, "无效的提供商 ID")
common.ApiErrorT(c, "Invalid provider ID")
return
}
@@ -558,13 +548,9 @@ func UnbindCustomOAuth(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "Login account unlinked") != nil
notificationFailed = service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginUnlinked) != nil
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "解绑成功",
"data": gin.H{"notification_warning": notificationFailed},
})
common.ApiSuccessT(c, "Unbound successfully", gin.H{"notification_warning": notificationFailed})
}
func UnbindCustomOAuthByAdmin(c *gin.Context) {
@@ -589,8 +575,12 @@ func UnbindCustomOAuthByAdmin(c *gin.Context) {
providerIdStr := c.Param("provider_id")
providerId, err := strconv.Atoi(providerIdStr)
if err != nil {
common.ApiErrorMsg(c, "invalid provider id")
if err != nil || providerId <= 0 {
common.ApiErrorT(c, "Invalid provider ID")
return
}
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserBindingClear, service.AdminUserBindingContext{UserID: userId, ProviderID: providerId})
if authorization == nil {
return
}
@@ -599,6 +589,12 @@ func UnbindCustomOAuthByAdmin(c *gin.Context) {
return
}
recordManageAuditFor(c, userId, "user.binding_clear", map[string]any{
"bindingType": "custom_oauth",
"provider_id": providerId,
"username": targetUser.Username,
"verification_method": authorization.Method,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "success",
+24 -6
View File
@@ -16,7 +16,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
benchmark "github.com/QuantumNous/new-api/pkg/model_benchmark"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -42,7 +41,7 @@ func detectionInput(c *gin.Context) (detectionRequest, *model.User, service.Dete
if err := c.ShouldBindJSON(&input); err != nil {
return input, nil, service.DetectionPrice{}, 0, errors.New("invalid detection request")
}
if input.Kind != "benchmark" && input.Kind != "fingerprint" {
if input.Kind != "fingerprint" && input.Kind != "vision" {
return input, nil, service.DetectionPrice{}, 0, errors.New("invalid detection type")
}
user, err := model.GetUserById(c.GetInt("id"), false)
@@ -63,7 +62,7 @@ func EstimateDetection(c *gin.Context) {
return
}
encoded, _ := common.Marshal(price)
calls := benchmark.ProbeCount
calls := 1
if input.Kind == "fingerprint" {
calls = fingerprint.ProbeCount
}
@@ -136,6 +135,7 @@ func GetUserDetection(c *gin.Context) {
}
return
}
run = service.NormalizeVisionRun(run)
var calls []model.DetectionCall
if err := model.DB.Where("run_id = ?", run.ID).Find(&calls).Error; err != nil {
common.ApiError(c, err)
@@ -343,6 +343,14 @@ func DetectionProxy(c *gin.Context) {
}
}
}
if success {
if html := detectionHTML(raw, contentType); html != "" {
if saveErr := model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Update("html", html).Error; saveErr != nil {
call.Error = "artwork_save_failed"
success = false
}
}
}
if err = model.FinishDetectionCall(call); err != nil {
success = false
}
@@ -456,7 +464,14 @@ func detectionResponseUsage(raw []byte, contentType string) (gjson.Result, bool)
// Apply billing and safety limits AFTER channel parameter overrides. Never let
// an administrative override turn a bounded paid test into unbounded tool work.
func boundDetectionBody(body common.ReplayableBody, request *dto.OpenAIResponsesRequest) (common.ReplayableBody, io.Closer, error) {
raw, err := io.ReadAll(io.LimitReader(body, 128<<10))
limit := int64(128 << 10)
if bytes.Contains(request.Input, []byte(service.VisionImageURL)) {
limit = 4 << 20
}
raw, err := io.ReadAll(io.LimitReader(body, limit+1))
if int64(len(raw)) > limit {
return nil, nil, errors.New("detection request too large")
}
if err != nil {
return nil, nil, err
}
@@ -473,11 +488,14 @@ func boundDetectionBody(body common.ReplayableBody, request *dto.OpenAIResponses
_ = common.Unmarshal(request.Instructions, &instructions)
prepared["instructions"] = instructions
prepared["max_output_tokens"] = *request.MaxOutputTokens
if request.Reasoning != nil {
prepared["reasoning"] = request.Reasoning
}
if *request.MaxOutputTokens == 16384 {
prepared["reasoning"] = map[string]string{"effort": "medium"}
}
if *request.MaxOutputTokens == fingerprint.OutputTokens || *request.MaxOutputTokens == benchmark.OutputTokens {
prepared["reasoning"] = map[string]string{"effort": "low"}
if *request.MaxOutputTokens == fingerprint.OutputTokens {
prepared["reasoning"] = map[string]string{"effort": request.Reasoning.Effort}
delete(prepared, "instructions")
delete(prepared, "temperature")
delete(prepared, "top_p")
+41
View File
@@ -0,0 +1,41 @@
package controller
import ("strings"; "github.com/tidwall/gjson")
func detectionHTML(raw []byte, contentType string) string {
result := gjson.ParseBytes(raw)
if strings.Contains(contentType, "text/event-stream") {
result = gjson.Result{}
for line := range strings.SplitSeq(string(raw), "\n") {
if strings.HasPrefix(line, "data:") {
event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
if event.Get("type").String() == "response.completed" {
result = event.Get("response")
}
}
}
}
if result.Get("status").String() != "completed" {
return ""
}
var output strings.Builder
for _, item := range result.Get("output").Array() {
if item.Get("type").String() != "message" {
continue
}
for _, part := range item.Get("content").Array() {
if part.Get("type").String() == "output_text" {
output.WriteString(part.Get("text").String())
}
}
}
html := strings.TrimSpace(output.String())
lower := strings.ToLower(html)
start := strings.Index(lower, "<!doctype html")
if start < 0 {
start = strings.Index(lower, "<html")
}
end := strings.LastIndex(lower, "</html>")
if start < 0 || end < start || len(html) > 2<<20 {
return ""
}
return html[start : end+7]
}
+441 -19
View File
@@ -3,6 +3,7 @@ package controller
import (
"bufio"
"context"
"crypto/sha256"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
@@ -23,6 +24,8 @@ import (
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
@@ -289,7 +292,7 @@ func TestDetectionModelTracePaidRoundAndResume(t *testing.T) {
assert.Equal(t, 982, owner.Quota)
}
func TestCandyAutomaticRoundAndResume(t *testing.T) {
func TestVisionAutomaticRoundAndResume(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
oldRedis := common.RedisEnabled
@@ -308,7 +311,7 @@ func TestCandyAutomaticRoundAndResume(t *testing.T) {
}
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
}
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("candy_%d_", time.Now().UnixNano())}})
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("vision_%d_", time.Now().UnixNano())}})
require.NoError(t, err)
conn, err := db.DB()
require.NoError(t, err)
@@ -329,35 +332,39 @@ func TestCandyAutomaticRoundAndResume(t *testing.T) {
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Less(t, calls, 1)
answer := "21"
answer := "这是音箱"
raw, readErr := io.ReadAll(r.Body)
require.NoError(t, readErr)
assert.Contains(t, gjson.GetBytes(raw, "input.0.content").String(), "圆形 7 9 8")
assert.NotContains(t, gjson.GetBytes(raw, "input.0.content").String(), "21")
assert.Equal(t, "medium", gjson.GetBytes(raw, "reasoning.effort").String())
assert.Equal(t, "不搜索,告诉我这是什么", gjson.GetBytes(raw, "input.0.content.0.text").String())
assert.Equal(t, service.VisionImageURL, gjson.GetBytes(raw, "input.0.content.1.image_url").String())
assert.Equal(t, "none", gjson.GetBytes(raw, "tool_choice").String())
assert.Empty(t, gjson.GetBytes(raw, "tools").Array())
calls++
body, encodeErr := common.Marshal(map[string]any{"status": "completed", "usage": map[string]int{"input_tokens": 10, "output_tokens": 2}, "output": []any{map[string]any{"type": "message", "content": []any{map[string]string{"type": "output_text", "text": answer}}}}})
require.NoError(t, encodeErr)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"这是音箱\"}\n\n")
_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.completed\",\"response\":%s}\n\n", body)
}))
defer upstream.Close()
url := upstream.URL
channel := model.Channel{Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model, Group: "candy-group"}
channel := model.Channel{Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model, Group: "vision-group"}
require.NoError(t, db.Create(&channel).Error)
owner := model.User{Username: "meow-test", Quota: 1000, Status: common.UserStatusEnabled}
require.NoError(t, db.Create(&owner).Error)
price, err := common.Marshal(service.DetectionPrice{Snapshot: billingexpr.BillingSnapshot{ExprString: "p+c", ExprHash: billingexpr.ExprHashString("p+c"), GroupRatio: 1, QuotaPerUnit: 500000, ExprVersion: 1}, Request: billingexpr.RequestInput{Body: []byte(`{"max_output_tokens":4096,"reasoning":{"effort":"low"}}`)}})
price, err := common.Marshal(service.DetectionPrice{Snapshot: billingexpr.BillingSnapshot{ExprString: "p+c", ExprHash: billingexpr.ExprHashString("p+c"), GroupRatio: 1, QuotaPerUnit: 500000, ExprVersion: 1}, Request: billingexpr.RequestInput{Body: []byte(`{"max_output_tokens":4096,"reasoning":{"effort":"medium"}}`)}})
require.NoError(t, err)
run := model.DetectionRun{ID: "meow-test", RequestKey: "meow-test", Kind: "candy", CreatedAt: time.Now().Unix(), ChannelID: channel.Id, Status: "pending", Deadline: time.Now().Unix() + 60, Reserved: 320, Price: string(price)}
run := model.DetectionRun{ID: "meow-test", RequestKey: "meow-test", Kind: "vision", CreatedAt: time.Now().Unix(), ChannelID: channel.Id, Status: "pending", Deadline: time.Now().Unix() + 60, Reserved: 320, Price: string(price)}
require.NoError(t, model.CreateDetection(&run))
require.NoError(t, model.EnsureDetectionSchedule(context.Background(), model.DetectionSchedule{ChannelID: channel.Id, Kind: "candy", IntervalMinutes: 30}))
require.NoError(t, model.EnsureDetectionSchedule(context.Background(), model.DetectionSchedule{ChannelID: channel.Id, Kind: "vision", IntervalMinutes: 30}))
processDetection(context.Background(), run)
require.NoError(t, db.First(&run, "id = ?", run.ID).Error)
assert.Equal(t, "succeeded", run.Status)
assert.Equal(t, "normal", run.Quality)
assert.True(t, run.Settled)
var clock model.DetectionSchedule
require.NoError(t, db.First(&clock, "channel_id = ? AND kind = ?", channel.Id, "candy").Error)
require.NoError(t, db.First(&clock, "channel_id = ? AND kind = ?", channel.Id, "vision").Error)
assert.Equal(t, "normal", clock.LastValid)
assert.Greater(t, clock.NextDueAt, run.FinishedAt)
assert.Equal(t, 1, calls)
@@ -370,19 +377,434 @@ func TestCandyAutomaticRoundAndResume(t *testing.T) {
assert.Equal(t, 1000, owner.Quota)
summaries, err := service.GroupDetectionSummaries(context.Background(), time.Now(), map[string]*service.AstraSamplingSummary{}, map[string]model.StatusPageGroup{})
require.NoError(t, err)
require.NotNil(t, summaries["candy-group"].Candy)
assert.Equal(t, 1, summaries["candy-group"].Candy.Normal)
assert.Equal(t, "21", summaries["candy-group"].Candy.History[0].Closest)
assert.Zero(t, summaries["candy-group"].Benchmark.Normal)
require.NotNil(t, summaries["vision-group"].Vision)
assert.Equal(t, 1, summaries["vision-group"].Vision.Normal)
assert.Equal(t, "这是音箱", summaries["vision-group"].Vision.History[0].Closest)
require.NotNil(t, summaries["vision-group"].Vision.History[0].FirstTokenMS)
assert.GreaterOrEqual(t, *summaries["vision-group"].Vision.History[0].FirstTokenMS, float64(0))
assert.Zero(t, summaries["vision-group"].Benchmark.Normal)
})
}
}
func TestCandyAnswers(t *testing.T) {
func TestVisionAnswers(t *testing.T) {
for _, tc := range []struct{ answer, quality string }{
{"21", "normal"}, {" 21\n", "normal"}, {"28", "degraded"}, {"121", "degraded"}, {"21或28", "degraded"}, {"答案不是21", "degraded"}, {"", "degraded"},
{"这是音响", "normal"}, {"蓝牙音箱", "normal"}, {" 音箱\n", "normal"},
{"石头", "degraded"}, {"扬声器", "degraded"}, {"", "degraded"}, {"音 箱", "degraded"},
} {
t.Run(tc.answer, func(t *testing.T) { assert.Equal(t, tc.quality, service.CandyQuality(tc.answer)) })
t.Run(tc.answer, func(t *testing.T) { assert.Equal(t, tc.quality, service.VisionQuality(tc.answer)) })
}
}
func TestVisionFailuresAndRetiredDetections(t *testing.T) {
for _, tc := range []struct {
name, answer string
httpStatus int
kind string
malformed bool
}{
{name: "missing keyword", answer: "石头", httpStatus: 200, kind: "vision"},
{name: "empty answer", httpStatus: 200, kind: "vision"},
{name: "transport failure", httpStatus: 503, kind: "vision"},
{name: "invalid response", httpStatus: 200, kind: "vision", malformed: true},
{name: "retired candy", kind: "candy"},
{name: "retired pelican", kind: "pelican"},
} {
t.Run(tc.name, func(t *testing.T) {
oldRedis := common.RedisEnabled
common.RedisEnabled = false
t.Cleanup(func() { common.RedisEnabled = oldRedis })
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
conn, err := db.DB()
require.NoError(t, err)
conn.SetMaxOpenConns(1)
old := model.DB
model.DB = db
t.Cleanup(func() { model.DB = old; _ = conn.Close() })
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.DetectionSchedule{}, &model.User{}))
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.httpStatus)
if tc.malformed {
_, _ = io.WriteString(w, "broken")
return
}
body, err := common.Marshal(map[string]any{"status": "completed", "output": []any{map[string]any{"type": "message", "content": []any{map[string]string{"type": "output_text", "text": tc.answer}}}}})
require.NoError(t, err)
_, _ = w.Write(body)
}))
defer upstream.Close()
url := upstream.URL
channel := model.Channel{Type: 1, Key: "test", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model}
require.NoError(t, db.Create(&channel).Error)
run := model.DetectionRun{ID: "failure", RequestKey: "failure", Kind: tc.kind, ChannelID: channel.Id, Status: "pending", Deadline: time.Now().Unix() + 60}
require.NoError(t, model.CreateDetection(&run))
require.NoError(t, model.EnsureDetectionSchedule(context.Background(), model.DetectionSchedule{ChannelID: channel.Id, Kind: "vision", IntervalMinutes: 30}))
events, unsubscribe, err := subscribeGroupStatus()
require.NoError(t, err)
defer unsubscribe()
processDetection(context.Background(), run)
require.NoError(t, db.First(&run, "id = ?", run.ID).Error)
expectedStatus, expectedQuality, expectedOutcome := "failed", "unknown", ""
if tc.name == "missing keyword" {
expectedStatus, expectedQuality, expectedOutcome = "succeeded", "degraded", "degraded"
}
assert.Equal(t, expectedStatus, run.Status)
assert.True(t, run.Settled)
if tc.kind == "vision" {
assert.Equal(t, expectedQuality, run.Quality)
select {
case <-events:
default:
t.Fatal("settled vision did not publish")
}
assert.Equal(t, 1, calls)
var clock model.DetectionSchedule
require.NoError(t, db.First(&clock, "channel_id = ? AND kind = ?", channel.Id, "vision").Error)
assert.Equal(t, expectedOutcome, clock.LastValid)
} else {
assert.Zero(t, calls)
assert.Equal(t, "detection_retired", run.Error)
}
})
}
}
func TestVisionPaidRoundAndResume(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
oldRedis := common.RedisEnabled
common.RedisEnabled = false
t.Cleanup(func() { common.RedisEnabled = oldRedis })
var driver gorm.Dialector = sqlite.Open(":memory:")
if dialect == "mysql" {
if os.Getenv("TEST_MYSQL_DSN") == "" {
t.Skip("TEST_MYSQL_DSN unset")
}
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
}
if dialect == "postgres" {
if os.Getenv("TEST_POSTGRES_DSN") == "" {
t.Skip("TEST_POSTGRES_DSN unset")
}
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
}
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("vision_%d_", time.Now().UnixNano())}})
require.NoError(t, err)
conn, err := db.DB()
require.NoError(t, err)
conn.SetMaxOpenConns(1)
oldType, oldLogType := common.MainDatabaseType(), common.LogDatabaseType()
common.SetDatabaseTypes(common.DatabaseType(dialect), common.DatabaseType(dialect))
t.Cleanup(func() { common.SetDatabaseTypes(oldType, oldLogType) })
old := model.DB
model.DB = db
t.Cleanup(func() {
for _, row := range []any{&model.Channel{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.User{}, &model.DetectionSchedule{}, &model.SystemTask{}} {
require.NoError(t, db.Migrator().DropTable(row))
}
model.DB = old
_ = conn.Close()
})
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.User{}, &model.DetectionSchedule{}, &model.SystemTask{}))
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Less(t, calls, 1)
answer := "这是音箱"
raw, readErr := io.ReadAll(r.Body)
require.NoError(t, readErr)
assert.Equal(t, "medium", gjson.GetBytes(raw, "reasoning.effort").String())
assert.Equal(t, "不搜索,告诉我这是什么", gjson.GetBytes(raw, "input.0.content.0.text").String())
assert.Equal(t, service.VisionImageURL, gjson.GetBytes(raw, "input.0.content.1.image_url").String())
assert.Equal(t, "none", gjson.GetBytes(raw, "tool_choice").String())
assert.Empty(t, gjson.GetBytes(raw, "tools").Array())
calls++
body, encodeErr := common.Marshal(map[string]any{"status": "completed", "usage": map[string]int{"input_tokens": 10, "output_tokens": 2}, "output": []any{map[string]any{"type": "message", "content": []any{map[string]string{"type": "output_text", "text": answer}}}}})
require.NoError(t, encodeErr)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"这是音箱\"}\n\n")
_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.completed\",\"response\":%s}\n\n", body)
}))
defer upstream.Close()
url := upstream.URL
channel := model.Channel{Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model, Group: "vision-group"}
require.NoError(t, db.Create(&channel).Error)
owner := model.User{Username: "meow-test", Quota: 1000, Status: common.UserStatusEnabled}
require.NoError(t, db.Create(&owner).Error)
price, err := common.Marshal(service.DetectionPrice{Snapshot: billingexpr.BillingSnapshot{ExprString: "p+c", ExprHash: billingexpr.ExprHashString("p+c"), GroupRatio: 1, QuotaPerUnit: 500000, ExprVersion: 1}, Request: billingexpr.RequestInput{Body: []byte(`{"max_output_tokens":4096,"reasoning":{"effort":"medium"}}`)}})
require.NoError(t, err)
run := model.DetectionRun{ID: "meow-test", RequestKey: "meow-test", UserID: owner.Id, Kind: "vision", CreatedAt: time.Now().Unix(), ChannelID: channel.Id, Status: "pending", Deadline: time.Now().Unix() + 60, Reserved: 320, Price: string(price)}
require.NoError(t, model.CreateDetection(&run))
require.NoError(t, model.EnsureDetectionSchedule(context.Background(), model.DetectionSchedule{ChannelID: channel.Id, Kind: "vision", IntervalMinutes: 30}))
processDetection(context.Background(), run)
require.NoError(t, db.First(&run, "id = ?", run.ID).Error)
assert.Equal(t, "succeeded", run.Status)
assert.Equal(t, "normal", run.Quality)
assert.True(t, run.Settled)
var clock model.DetectionSchedule
require.NoError(t, db.First(&clock, "channel_id = ? AND kind = ?", channel.Id, "vision").Error)
assert.Empty(t, clock.LastValid)
assert.Zero(t, clock.NextDueAt, "manual detection does not change automatic schedule")
assert.Equal(t, 1, calls)
expectedCharge, err := service.DetectionUsageQuota(string(price), 10, 2, 0)
require.NoError(t, err)
assert.Positive(t, expectedCharge)
assert.Equal(t, expectedCharge, run.Charged)
require.NoError(t, db.First(&owner, owner.Id).Error)
assert.Equal(t, 1000-expectedCharge, owner.Quota)
processDetection(context.Background(), run)
assert.Equal(t, 1, calls, "resume must not resend or recharge")
require.NoError(t, db.First(&owner, owner.Id).Error)
assert.Equal(t, 1000-expectedCharge, owner.Quota)
summaries, err := service.GroupDetectionSummaries(context.Background(), time.Now(), map[string]*service.AstraSamplingSummary{}, map[string]model.StatusPageGroup{})
require.NoError(t, err)
require.NotNil(t, summaries["vision-group"].Vision)
assert.Equal(t, 1, summaries["vision-group"].Vision.Normal)
assert.Equal(t, "这是音箱", summaries["vision-group"].Vision.History[0].Closest)
require.NotNil(t, summaries["vision-group"].Vision.History[0].FirstTokenMS)
assert.GreaterOrEqual(t, *summaries["vision-group"].Vision.History[0].FirstTokenMS, float64(0))
assert.Zero(t, summaries["vision-group"].Benchmark.Normal)
})
}
}
func TestCialloResults(t *testing.T) {
for _, tc := range []struct {
name, answer string
httpStatus int
kind string
malformed bool
}{
{name: "missing keyword", answer: "石头", httpStatus: 200, kind: "ciallo"},
{name: "empty answer", httpStatus: 200, kind: "ciallo"},
{name: "transport failure", httpStatus: 503, kind: "ciallo"},
{name: "invalid response", httpStatus: 200, kind: "ciallo", malformed: true},
{name: "passed", answer: "Ciallo~(∠・ω< )⌒☆ Hi there!", httpStatus: 200, kind: "ciallo"},
{name: "plain punctuation", answer: "Ciallo~", httpStatus: 200, kind: "ciallo"},
} {
t.Run(tc.name, func(t *testing.T) {
oldRedis := common.RedisEnabled
common.RedisEnabled = false
t.Cleanup(func() { common.RedisEnabled = oldRedis })
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
conn, err := db.DB()
require.NoError(t, err)
conn.SetMaxOpenConns(1)
old := model.DB
model.DB = db
t.Cleanup(func() { model.DB = old; _ = conn.Close() })
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.DetectionSchedule{}, &model.User{}))
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
raw, _ := io.ReadAll(r.Body)
assert.Equal(t, "ciallo", gjson.GetBytes(raw, "input.0.content").String())
assert.Equal(t, "medium", gjson.GetBytes(raw, "reasoning.effort").String())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.httpStatus)
if tc.malformed {
_, _ = io.WriteString(w, "broken")
return
}
body, err := common.Marshal(map[string]any{"status": "completed", "output": []any{map[string]any{"type": "message", "content": []any{map[string]string{"type": "output_text", "text": tc.answer}}}}})
require.NoError(t, err)
_, _ = w.Write(body)
}))
defer upstream.Close()
url := upstream.URL
channel := model.Channel{Type: 1, Key: "test", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model}
require.NoError(t, db.Create(&channel).Error)
run := model.DetectionRun{ID: "failure", RequestKey: "failure", Kind: tc.kind, ChannelID: channel.Id, Status: "pending", Deadline: time.Now().Unix() + 60}
require.NoError(t, model.CreateDetection(&run))
require.NoError(t, model.EnsureDetectionSchedule(context.Background(), model.DetectionSchedule{ChannelID: channel.Id, Kind: "ciallo", IntervalMinutes: 30}))
events, unsubscribe, err := subscribeGroupStatus()
require.NoError(t, err)
defer unsubscribe()
processDetection(context.Background(), run)
require.NoError(t, db.First(&run, "id = ?", run.ID).Error)
expectedStatus, expectedQuality, expectedOutcome := "failed", "unknown", ""
if tc.name == "missing keyword" {
expectedStatus, expectedQuality, expectedOutcome = "succeeded", "degraded", "degraded"
}
if tc.name == "passed" || tc.name == "plain punctuation" {
expectedStatus, expectedQuality, expectedOutcome = "succeeded", "normal", "normal"
}
assert.Equal(t, expectedStatus, run.Status)
assert.True(t, run.Settled)
if tc.kind == "ciallo" {
assert.Equal(t, expectedQuality, run.Quality)
select {
case <-events:
default:
t.Fatal("settled vision did not publish")
}
assert.Equal(t, 1, calls)
var clock model.DetectionSchedule
require.NoError(t, db.First(&clock, "channel_id = ? AND kind = ?", channel.Id, "ciallo").Error)
assert.Equal(t, expectedOutcome, clock.LastValid)
assert.Equal(t, 45, clock.IntervalMinutes)
assert.Equal(t, int64(45*60), clock.NextDueAt-clock.LastFinishedAt)
} else {
assert.Zero(t, calls)
assert.Equal(t, "detection_retired", run.Error)
}
})
}
}
func TestCialloBotAuthorization(t *testing.T) {
key := strings.Repeat("k", 32)
t.Setenv("BOT_BALANCE_KEY", key)
derived := sha256.Sum256([]byte("newapi-ciallo-v1:" + key))
for _, tc := range []struct {
token string
code int
}{{"", 401}, {key, 401}, {"incorrect", 401}, {fmt.Sprintf("%x", derived), 400}} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/bot/ciallo", strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Authorization", "Bearer "+tc.token)
BotCiallo(c)
assert.Equal(t, tc.code, w.Code)
}
}
func TestChainDetectionMatrix(t *testing.T) {
drivers := map[string]gorm.Dialector{"sqlite": sqlite.Open(":memory:")}
if dsn := os.Getenv("TEST_MYSQL_DSN"); dsn != "" {
drivers["mysql"] = mysql.Open(dsn)
}
if dsn := os.Getenv("TEST_POSTGRES_DSN"); dsn != "" {
drivers["postgres"] = postgres.Open(dsn)
}
for name, driver := range drivers {
t.Run(name, func(t *testing.T) {
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("chain_%d_", time.Now().UnixNano())}})
require.NoError(t, err)
conn, err := db.DB()
require.NoError(t, err)
conn.SetMaxOpenConns(1)
old, redis := model.DB, common.RedisEnabled
model.DB = db
common.RedisEnabled = false
t.Cleanup(func() {
model.DB = old
common.RedisEnabled = redis
_ = db.Migrator().DropTable(&model.DetectionCall{}, &model.DetectionRun{}, &model.DetectionSchedule{}, &model.Channel{})
_ = conn.Close()
})
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.DetectionSchedule{}))
var count atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count.Add(1)
assert.Equal(t, "Bearer test-only", r.Header.Get("Authorization"))
raw, err := io.ReadAll(r.Body)
require.NoError(t, err)
answer := strings.Repeat("42,", 300)
prompt := gjson.GetBytes(raw, "input.0.content")
if prompt.String() == "ciallo" {
answer = "Ciallo~ hello"
} else if prompt.IsArray() {
w.WriteHeader(503)
return
}
data, err := common.Marshal(map[string]any{"status": "completed", "output": []any{map[string]any{"type": "message", "content": []any{map[string]string{"type": "output_text", "text": answer}}}}})
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}))
defer upstream.Close()
url := upstream.URL
ch := model.Channel{Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model, Group: "test group"}
require.NoError(t, db.Create(&ch).Error)
for _, kind := range []string{"vision", "ciallo"} {
require.NoError(t, model.EnsureDetectionSchedule(context.Background(), model.DetectionSchedule{ChannelID: ch.Id, Kind: kind, IntervalMinutes: 45, NextDueAt: time.Now().Unix() + 2700}))
}
runs, err := enqueueChainTest(context.Background(), "test group", ch.Id)
require.NoError(t, err)
require.Len(t, runs, 3)
again, err := enqueueChainTest(context.Background(), "test group", ch.Id)
require.NoError(t, err)
assert.Equal(t, strings.Split(runs[0].ID, "_")[0], strings.Split(again[0].ID, "_")[0])
var wg sync.WaitGroup
for _, run := range runs {
wg.Add(1)
go func() { defer wg.Done(); processDetection(context.Background(), run) }()
}
wg.Wait()
require.NoError(t, db.Order("id asc").Find(&runs).Error)
result, err := chainTestResult(runs, time.Now().Unix())
require.NoError(t, err)
assert.Equal(t, true, result["done"])
results := result["results"].(map[string]any)
assert.Equal(t, "normal", results["ciallo"].(gin.H)["status"])
assert.Equal(t, "failed", results["vision"].(gin.H)["status"])
assert.NotEqual(t, "failed", results["fingerprint"].(gin.H)["status"])
assert.Equal(t, int32(5), count.Load())
for _, run := range runs {
assert.Zero(t, run.Charged)
assert.True(t, run.Settled)
processDetection(context.Background(), run)
}
assert.Equal(t, int32(5), count.Load(), "completed calls must not replay")
for _, kind := range []string{"vision", "ciallo"} {
states, _, err := service.ReconcileAutomaticDetectionSchedules(context.Background(), []model.Channel{ch}, kind)
require.NoError(t, err)
assert.Empty(t, states[ch.Id].LastResultID, "manual chain must not move the automatic clock")
}
fresh, err := enqueueChainTest(context.Background(), "test group", ch.Id)
require.NoError(t, err)
assert.NotEqual(t, runs[0].ID, fresh[0].ID)
result, err = chainTestResult(fresh, time.Now().Unix()+601)
require.NoError(t, err)
assert.Equal(t, true, result["done"])
})
}
}
func TestChainAuthorizationAndProjection(t *testing.T) {
key := strings.Repeat("k", 32)
t.Setenv("BOT_BALANCE_KEY", key)
derived := sha256.Sum256([]byte("newapi-chaintest-v1:" + key))
wrong := sha256.Sum256([]byte("newapi-ciallo-v1:" + key))
for _, tc := range []struct {
token string
code int
}{{"", 401}, {key, 401}, {fmt.Sprintf("%x", wrong), 401}, {fmt.Sprintf("%x", derived), 400}} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/bot/chaintest", strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Authorization", "Bearer "+tc.token)
BotChainTest(c)
assert.Equal(t, tc.code, w.Code)
}
for _, status := range []string{"match", "mismatch", "inconclusive"} {
encoded, _ := common.Marshal(fingerprint.Result{Engine: fingerprint.Version, Status: status, Closest: fingerprint.Model, ValidSamples: 3, Weight: 1, Gap: 1})
runs := []model.DetectionRun{{ID: "abc_fingerprint", Kind: "fingerprint", Settled: true, Status: "succeeded", Result: string(encoded)}, {Kind: "ciallo", Settled: true, Status: "succeeded", Result: `{"closest":"Ciallo~"}`}, {Kind: "vision", Settled: true, Status: "succeeded", Result: `{"closest":"音箱"}`}}
data, err := chainTestResult(runs, 0)
require.NoError(t, err)
results := data["results"].(map[string]any)
assert.Equal(t, status, results["fingerprint"].(gin.H)["status"])
assert.Equal(t, "normal", results["ciallo"].(gin.H)["status"])
assert.Equal(t, "normal", results["vision"].(gin.H)["status"])
raw, _ := common.Marshal(data)
assert.NotContains(t, string(raw), "channel_id")
assert.NotContains(t, string(raw), "price")
runs[2].Status = "failed"
runs[2].Error = "vision_keyword_missing"
runs[2].Result = `{"closest":"我这边没有收到可查看的图片或附件"}`
data, err = chainTestResult(runs, 0)
require.NoError(t, err)
results = data["results"].(map[string]any)
assert.Equal(t, "incomplete", results["vision"].(gin.H)["status"])
assert.Equal(t, status, results["fingerprint"].(gin.H)["status"])
assert.Equal(t, "normal", results["ciallo"].(gin.H)["status"])
}
}
+61 -38
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
@@ -19,7 +20,7 @@ type detectionHandler struct{}
func (detectionHandler) Type() string { return "group_detection" }
func (detectionHandler) Enabled() bool {
return common.GetEnvOrDefaultBool("MODEL_DETECTION_ENABLED", true)
return common.GetEnvOrDefaultBool("VISION_DETECTION_ENABLED", true)
}
func (detectionHandler) Interval() time.Duration { return 15 * time.Second }
func (detectionHandler) NewPayload() any { return nil }
@@ -28,9 +29,9 @@ func (detectionHandler) Run(ctx context.Context, task *model.SystemTask, runner
_ = model.DeliverDetectionLogs()
// Keep accounting facts, but expire bulky generated artwork after the public window.
_ = model.DB.Model(&model.DetectionRun{}).Where("settled = ? AND created_at < ? AND html <> ?", true, time.Now().Add(-6*time.Hour).Unix(), "").Update("html", "").Error
scheduleErr := scheduleBenchmarkDetections(ctx)
if candyErr := scheduleAutomaticDetections(ctx, "candy"); scheduleErr == nil {
scheduleErr = candyErr
scheduleErr := scheduleVisionDetections(ctx)
if err := scheduleCialloDetections(ctx); err != nil {
scheduleErr = err
}
var err error
{
@@ -66,12 +67,8 @@ func (detectionHandler) Run(ctx context.Context, task *model.SystemTask, runner
finishSystemTaskHandler(task, runner, status, nil, err)
}
func scheduleBenchmarkDetections(ctx context.Context) error {
return scheduleAutomaticDetections(ctx, "benchmark")
}
func scheduleAutomaticDetections(ctx context.Context, kind string) error {
channels, schedules, err := service.AutomaticDetectionChannelsDue(ctx, kind, time.Now())
func scheduleVisionDetections(ctx context.Context) error {
channels, schedules, err := service.AutomaticDetectionChannelsDue(ctx, "vision", time.Now())
if err != nil {
return err
}
@@ -81,7 +78,7 @@ func scheduleAutomaticDetections(ctx context.Context, kind string) error {
if len(groups) == 0 {
continue
}
price, reserve, priceErr := service.DetectionPricing("", groups[0], kind)
price, reserve, priceErr := service.DetectionPricing("", groups[0], "vision")
if priceErr != nil {
return priceErr
}
@@ -92,7 +89,7 @@ func scheduleAutomaticDetections(ctx context.Context, kind string) error {
if err != nil {
return err
}
run := &model.DetectionRun{ID: id, RequestKey: fmt.Sprintf("%s:%d:%s", kind, channel.Id, schedules[channel.Id].LastResultID), ChannelID: channel.Id, GroupName: groups[0], Kind: kind, Status: "pending", CreatedAt: now, Price: string(encoded), Reserved: reserve}
run := &model.DetectionRun{ID: id, RequestKey: fmt.Sprintf("vision:%d:%s", channel.Id, schedules[channel.Id].LastResultID), ChannelID: channel.Id, GroupName: groups[0], Kind: "vision", Status: "pending", CreatedAt: now, Price: string(encoded), Reserved: reserve}
if err = model.CreateDetection(run); err != nil {
return err
}
@@ -100,7 +97,18 @@ func scheduleAutomaticDetections(ctx context.Context, kind string) error {
return nil
}
var activeDetectionRuns sync.Map
func processDetection(ctx context.Context, run model.DetectionRun) {
if _, running := activeDetectionRuns.LoadOrStore(run.ID, true); running {
return
}
defer activeDetectionRuns.Delete(run.ID)
// Immediate bot execution and the durable worker may observe the same task.
if model.DB.First(&run, "id = ?", run.ID).Error != nil || run.Settled {
return
}
if ctx.Err() != nil {
return
}
@@ -110,6 +118,8 @@ func processDetection(ctx context.Context, run model.DetectionRun) {
return
}
}
taskCtx, taskCancel := context.WithDeadline(ctx, time.Unix(run.Deadline, 0))
defer taskCancel()
if time.Now().Unix() >= run.Deadline {
var calls []model.DetectionCall
if model.DB.Where("run_id = ?", run.ID).Find(&calls).Error != nil {
@@ -129,25 +139,31 @@ func processDetection(ctx context.Context, run model.DetectionRun) {
call.Quota = 0
_ = model.FinishDetectionCall(&call)
}
finishDetection(run, "failed", "detection_timeout")
if run.Kind == "vision" || run.Kind == "ciallo" {
finishVisionDetection(run, "", "detection_timeout")
} else {
finishDetection(run, "failed", "detection_timeout")
}
return
}
if run.Status == "succeeded" || run.Status == "failed" {
settleDetectionAndSchedule(run)
return
}
if run.Kind != "fingerprint" && run.Kind != "benchmark" && run.Kind != "candy" {
finishDetection(run, "failed", "detection_retired")
if run.Kind == "fingerprint" {
processPaidFingerprint(taskCtx, run)
return
}
processPaidFingerprint(ctx, run)
if run.Kind == "vision" || run.Kind == "ciallo" {
processVisionDetection(taskCtx, run)
return
}
finishDetection(run, "failed", "detection_retired")
}
func finishDetection(run model.DetectionRun, status, reason string) {
updates := map[string]any{"status": status, "error": reason}
if status == "failed" && run.Kind == "benchmark" {
updates["result"] = ""
}
if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(updates).Error != nil {
return
}
@@ -155,13 +171,21 @@ func finishDetection(run model.DetectionRun, status, reason string) {
}
func settleDetectionAndSchedule(run model.DetectionRun) {
if model.SettleDetection(run.ID) != nil || run.UserID != 0 || (run.Kind != "benchmark" && run.Kind != "candy") {
if model.SettleDetection(run.ID) != nil {
return
}
if run.Kind == "vision" || run.Kind == "ciallo" || run.Kind == "fingerprint" {
service.InvalidateGroupQualityCache()
publishGroupStatusChange()
}
if run.UserID != 0 || strings.HasPrefix(run.RequestKey, "chain:") || (run.Kind != "vision" && run.Kind != "ciallo") {
return
}
// Reconciliation also repairs the clock if a process exits after settlement.
if err := model.DB.Select("id", "channel_id", "finished_at", "status", "quality").First(&run, "id = ?", run.ID).Error; err != nil {
if err := model.DB.Select("id", "channel_id", "finished_at", "status", "quality", "kind", "error", "result").First(&run, "id = ?", run.ID).Error; err != nil {
return
}
run = service.NormalizeVisionRun(run)
outcome := "unknown"
if run.Status == "succeeded" {
outcome = run.Quality
@@ -179,11 +203,9 @@ func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
if run.Kind == "benchmark" {
output, calls = benchmark.OutputTokens, benchmark.ProbeCount
}
if run.Kind == "candy" {
output, calls = service.CandyOutputTokens, 1
}
var price service.DetectionPrice
if common.UnmarshalJsonStr(run.Price, &price) != nil || gjson.GetBytes(price.Request.Body, "max_output_tokens").Int() != int64(output) {
if run.UserID > 0 && (common.UnmarshalJsonStr(run.Price, &price) != nil || gjson.GetBytes(price.Request.Body, "max_output_tokens").Int() != int64(output)) {
finishDetection(run, "failed", "benchmark_changed")
return
}
@@ -211,6 +233,7 @@ func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
probeCtx, cancel := context.WithDeadline(ctx, time.Unix(run.Deadline-10, 0))
detectionCalls.Store(call.ID, cancel)
answer, count, probeErr := runModelDetectionProbe(probeCtx, channel, index, run.Kind)
call.FirstTokenMS = answer.FirstTokenMS
interrupted := probeCtx.Err() != nil
cancel()
available := probeErr == nil || answer.HasUsage
@@ -224,16 +247,18 @@ func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
if answer.HasUsage {
usage, _ := common.Marshal(answer)
call.Usage = string(usage)
quota, quotaErr := service.DetectionUsageQuota(run.Price, answer.InputTokens, answer.OutputTokens, answer.CachedTokens, answer.CacheWriteTokens)
if quotaErr != nil || quota > run.Reserved/calls {
call.Error = "settlement_unavailable"
} else {
call.Quota = quota
if run.UserID > 0 {
quota, quotaErr := service.DetectionUsageQuota(run.Price, answer.InputTokens, answer.OutputTokens, answer.CachedTokens, answer.CacheWriteTokens)
if quotaErr != nil || quota > run.Reserved/calls {
call.Error = "settlement_unavailable"
} else {
call.Quota = quota
}
}
} else {
} else if run.UserID > 0 {
call.Error = "usage_unavailable"
}
if probeErr == nil && ((run.Kind == "candy" && answer.Text != "") || (run.Kind == "benchmark" && benchmark.ValidSample(answer.Text)) || (run.Kind == "fingerprint" && fingerprint.ValidArray(answer.Text, count))) {
if probeErr == nil && ((run.Kind == "benchmark" && benchmark.ValidSample(answer.Text)) || (run.Kind == "fingerprint" && fingerprint.ValidArray(answer.Text, count))) {
sample := fingerprint.Sample{Text: answer.Text, Count: count}
samples = append(samples, sample)
encoded, _ := common.Marshal(sample)
@@ -248,6 +273,10 @@ func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
return
}
}
if strings.HasPrefix(run.RequestKey, "chain:") && len(samples) != calls {
finishDetection(run, "failed", "fingerprint_response_invalid")
return
}
var result fingerprint.Result
if run.Kind == "benchmark" {
secondary := make([]benchmark.Sample, len(samples))
@@ -257,12 +286,6 @@ func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
scored, scoreErr := benchmark.Score(secondary)
err = scoreErr
result = fingerprint.Result{Status: scored.Status, Engine: scored.Engine, ValidSamples: scored.ValidSamples, Closest: scored.Closest, Weight: scored.Weight, Gap: scored.Gap, Matches: scored.Matches}
} else if run.Kind == "candy" {
if len(samples) != 1 {
finishDetection(run, "failed", "answer_unavailable")
return
}
result = fingerprint.Result{Status: service.CandyQuality(samples[0].Text), Engine: "candy-v1", ValidSamples: 1, Closest: samples[0].Text}
} else {
result, err = fingerprint.Score(samples)
}
+9 -7
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"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"
@@ -18,7 +19,7 @@ type emailBindRequest struct {
}
func EmailBindStart(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
@@ -46,7 +47,7 @@ func EmailBindStart(c *gin.Context) {
if authorization == nil {
return
}
data, err := service.StartEmailBinding(identity, authorization, email)
data, err := service.StartEmailBinding(identity, authorization, email, service.AccountMailLanguage(c, identity.UserID))
if err != nil {
writeSecurityOperationError(c, err)
return
@@ -56,7 +57,7 @@ func EmailBindStart(c *gin.Context) {
}
func EmailBindResend(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
@@ -70,7 +71,7 @@ func EmailBindResend(c *gin.Context) {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
data, err := service.ResendAccountEmailBinding(identity, request.FlowToken)
data, err := service.ResendAccountEmailBinding(identity, request.FlowToken, service.AccountMailLanguage(c, identity.UserID))
if err != nil {
writeSecurityOperationError(c, err)
return
@@ -80,7 +81,7 @@ func EmailBindResend(c *gin.Context) {
}
func EmailBind(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
@@ -100,8 +101,9 @@ func EmailBind(c *gin.Context) {
return
}
succeeded = true
notificationFailed = service.NotifyAccountSecurityChange(state.CurrentEmail, "Email address changed") != nil
if err := service.NotifyAccountSecurityChange(state.Email, "Email address confirmed"); err != nil {
lang := service.AccountMailLanguage(c, identity.UserID)
notificationFailed = service.NotifyAccountSecurityChange(lang, state.CurrentEmail, i18n.MsgSecurityEventEmailChanged) != nil
if err := service.NotifyAccountSecurityChange(lang, state.Email, i18n.MsgSecurityEventEmailConfirmed); err != nil {
notificationFailed = true
}
if err := model.PublishUserAuthCache(identity.UserID); err != nil {
+7 -1
View File
@@ -48,6 +48,11 @@ func GetUserGroups(c *gin.Context) {
common.ApiError(c, err)
return
}
visionUnavailable, err := service.VisionUnavailableGroups(c.Request.Context())
if err != nil {
common.ApiError(c, err)
return
}
userUsableGroups := service.GetUserUsableGroups(userGroup)
for groupName, _ := range ratio_setting.GetGroupRatioCopy() {
if _, ok := enabledGroups[groupName]; !ok {
@@ -61,6 +66,7 @@ func GetUserGroups(c *gin.Context) {
"has_personal_ratio": model.HasPersonalGroupRatio(c, groupName),
"tags": metadata[groupName].Tags,
"recent_degraded": recent[groupName] && !metadata[groupName].HideRecentDegraded,
"vision_unavailable": visionUnavailable[groupName],
"desc": desc,
}
}
@@ -68,7 +74,7 @@ func GetUserGroups(c *gin.Context) {
if len(usableGroups) > 0 {
if _, ok := userUsableGroups["auto"]; ok {
usableGroups["auto"] = map[string]any{
"ratio": "自动",
"ratio": "auto",
"desc": setting.GetUsableGroupDescription("auto"),
}
}
+117
View File
@@ -0,0 +1,117 @@
package controller
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
// Coalescing is safe because each snapshot includes the persisted detection
// history. Subscribers register before reading their initial snapshot.
var groupStatusEvents = struct {
sync.Mutex
clients map[chan struct{}]struct{}
}{clients: make(map[chan struct{}]struct{})}
func subscribeGroupStatus() (chan struct{}, func(), error) {
groupStatusEvents.Lock()
defer groupStatusEvents.Unlock()
if len(groupStatusEvents.clients) >= 32 {
return nil, nil, errors.New("too many status subscribers")
}
ch := make(chan struct{}, 1)
groupStatusEvents.clients[ch] = struct{}{}
return ch, func() {
groupStatusEvents.Lock()
delete(groupStatusEvents.clients, ch)
groupStatusEvents.Unlock()
}, nil
}
func publishGroupStatusChange() {
groupStatusEvents.Lock()
defer groupStatusEvents.Unlock()
for ch := range groupStatusEvents.clients {
select {
case ch <- struct{}{}:
default:
}
}
}
func StreamGroupStatus(c *gin.Context) {
streamGroupStatus(c, func(ctx context.Context) (any, error) {
results, err := currentGroupMonitors(ctx, true)
if err != nil {
return nil, err
}
for i := range results {
for j := range results[i].Monitors {
monitor := &results[i].Monitors[j]
monitor.Name, monitor.Alias, monitor.Hidden = monitor.Group, "", false
}
}
return gin.H{"success": true, "data": results}, nil
})
}
func streamGroupStatus(c *gin.Context, snapshot func(context.Context) (any, error)) {
changed, unsubscribe, err := subscribeGroupStatus()
if err != nil {
c.JSON(http.StatusTooManyRequests, gin.H{"success": false})
return
}
defer unsubscribe()
read := func() ([]byte, error) {
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel()
payload, err := snapshot(ctx)
if err != nil {
return nil, err
}
return common.Marshal(payload)
}
data, err := read()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"success": false})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache, no-transform")
c.Header("X-Accel-Buffering", "no")
write := func(frame string) error {
_ = http.NewResponseController(c.Writer).SetWriteDeadline(time.Now().Add(10 * time.Second))
if _, err := fmt.Fprint(c.Writer, frame); err != nil {
return err
}
c.Writer.Flush()
return nil
}
if write("retry: 5000\nevent: status\ndata: "+string(data)+"\n\n") != nil {
return
}
heartbeat := time.NewTicker(20 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-c.Request.Context().Done():
return
case <-heartbeat.C:
// Keep proxies alive without querying the database or polling status.
if write(": keepalive\n\n") != nil {
return
}
case <-changed:
data, err := read()
if err != nil || write("event: status\ndata: "+string(data)+"\n\n") != nil {
return // Reconnect replays persisted history after transient failures.
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package controller
import (
"bufio"
"context"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestGroupStatusStreamPushAndReconnect(t *testing.T) {
var reads atomic.Int32
router := gin.New()
router.GET("/events", func(c *gin.Context) {
streamGroupStatus(c, func(context.Context) (any, error) {
return gin.H{"success": true, "revision": reads.Add(1)}, nil
})
})
server := httptest.NewServer(router)
defer server.Close()
client := &http.Client{Timeout: 3 * time.Second}
response, err := client.Get(server.URL + "/events")
require.NoError(t, err)
defer response.Body.Close()
require.Equal(t, "text/event-stream", response.Header.Get("Content-Type"))
require.Equal(t, "no", response.Header.Get("X-Accel-Buffering"))
reader := bufio.NewReader(response.Body)
frame := func() string {
var value string
for {
line, err := reader.ReadString('\n')
require.NoError(t, err)
value += line
if line == "\n" {
return value
}
}
}
require.Contains(t, frame(), `"revision":1`)
require.EqualValues(t, 1, reads.Load())
publishGroupStatusChange()
require.Contains(t, frame(), `"revision":2`)
response.Body.Close()
response, err = client.Get(server.URL + "/events")
require.NoError(t, err)
defer response.Body.Close()
reader = bufio.NewReader(response.Body)
require.Contains(t, frame(), `"revision":3`)
}
func TestGroupStatusStreamReadFailure(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/events", nil)
streamGroupStatus(c, func(context.Context) (any, error) { return nil, errors.New("unavailable") })
require.Equal(t, http.StatusServiceUnavailable, c.Writer.Status())
}
func TestGroupStatusEventsCoalesceAndRelease(t *testing.T) {
ch, unsubscribe, err := subscribeGroupStatus()
require.NoError(t, err)
publishGroupStatusChange()
publishGroupStatusChange()
require.Len(t, ch, 1)
unsubscribe()
<-ch
publishGroupStatusChange()
require.Empty(t, ch)
}
+20 -12
View File
@@ -5,11 +5,28 @@ import (
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
func limitLogTimeRange(startTimestamp int64, endTimestamp int64) (int64, error) {
maxRange := operation_setting.GetLogSetting().MaxRangeSeconds()
end := common.GetTimestamp()
if endTimestamp != 0 {
end = min(end, endTimestamp)
}
if startTimestamp == 0 {
return end - maxRange, nil
}
if end-startTimestamp > maxRange {
return 0, common.NewMessage("The time range cannot exceed {{days}} days", map[string]any{"days": maxRange / (24 * 60 * 60)})
}
return startTimestamp, nil
}
func GetAllLogs(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
logType, _ := strconv.Atoi(c.Query("type"))
@@ -62,27 +79,18 @@ func GetUserLogs(c *gin.Context) {
// Deprecated: SearchAllLogs 已废弃,前端未使用该接口。
func SearchAllLogs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该接口已废弃",
})
common.ApiErrorT(c, "This API is deprecated")
}
// Deprecated: SearchUserLogs 已废弃,前端未使用该接口。
func SearchUserLogs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该接口已废弃",
})
common.ApiErrorT(c, "This API is deprecated")
}
func GetLogByKey(c *gin.Context) {
tokenId := c.GetInt("token_id")
if tokenId == 0 {
c.JSON(200, gin.H{
"success": false,
"message": "无效的令牌",
})
common.ApiErrorI18n(c, i18n.MsgTokenInvalid)
return
}
logs, err := model.GetLogByTokenId(tokenId)
+246
View File
@@ -0,0 +1,246 @@
package controller
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"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/operation_setting"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// callLogHandler runs a log handler as the given user and returns the JSON
// lines of the response.
func callLogHandler(t *testing.T, handler gin.HandlerFunc, userId int, role int, query string) []map[string]any {
t.Helper()
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/log?"+query, nil)
ctx.Set("id", userId)
ctx.Set("role", role)
handler(ctx)
var lines []map[string]any
for line := range strings.SplitSeq(strings.TrimSpace(recorder.Body.String()), "\n") {
var value map[string]any
require.NoError(t, common.UnmarshalJsonStr(line, &value))
lines = append(lines, value)
}
return lines
}
func exportedRequestIds(lines []map[string]any) []any {
ids := make([]any, 0, len(lines))
for _, line := range lines[:len(lines)-1] {
ids = append(ids, line["request_id"])
}
return ids
}
// seedExportLogs stores logs of two users: three in the last hour and one
// from 400 days ago.
func seedExportLogs(t *testing.T, mainDB *gorm.DB, logDB *gorm.DB, now int64) {
t.Helper()
require.NoError(t, mainDB.Create(&model.Channel{Id: 9, Name: "primary"}).Error)
logs := []model.Log{
{UserId: 1, Username: "alice", CreatedAt: now - 3600, Type: model.LogTypeConsume, ModelName: "gpt-a", ChannelId: 9, Quota: 10, RequestId: "a-1", Other: `{"admin_info":{"use_channel":["9"]}}`},
{UserId: 2, Username: "bob", CreatedAt: now - 1800, Type: model.LogTypeConsume, ModelName: "gpt-b", ChannelId: 9, Quota: 20, RequestId: "b-1"},
{UserId: 1, Username: "alice", CreatedAt: now - 60, Type: model.LogTypeError, ModelName: "gpt-a", RequestId: "a-2"},
{UserId: 1, Username: "alice", CreatedAt: now - 400*24*3600, Type: model.LogTypeConsume, ModelName: "gpt-a", RequestId: "a-old"},
}
for i := range logs {
require.NoError(t, logDB.Create(&logs[i]).Error)
}
}
func useLogExportDatabases(t *testing.T, mainDB *gorm.DB, logDB *gorm.DB, mainType common.DatabaseType, logType common.DatabaseType) {
t.Helper()
previousDB, previousLogDB := model.DB, model.LOG_DB
previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
previousRedis, previousMemory := common.RedisEnabled, common.MemoryCacheEnabled
model.DB, model.LOG_DB = mainDB, logDB
common.SetDatabaseTypes(mainType, logType)
common.RedisEnabled, common.MemoryCacheEnabled = false, false
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.SetDatabaseTypes(previousMain, previousLog)
common.RedisEnabled, common.MemoryCacheEnabled = previousRedis, previousMemory
})
}
func TestLogExportDatabaseMatrix(t *testing.T) {
gin.SetMode(gin.TestMode)
previousMaster := common.IsMasterNode
common.IsMasterNode = true
t.Cleanup(func() { common.IsMasterNode = previousMaster })
for _, tc := range []struct {
kind, env string
typ common.DatabaseType
}{
{"sqlite", "", common.DatabaseTypeSQLite},
{"mysql", "AUDIT_MYSQL_DSN", common.DatabaseTypeMySQL},
{"postgres", "AUDIT_POSTGRES_DSN", common.DatabaseTypePostgreSQL},
{"clickhouse", "AUDIT_CLICKHOUSE_DSN", common.DatabaseTypeClickHouse},
} {
t.Run(tc.kind, func(t *testing.T) {
dsn := os.Getenv(tc.env)
if tc.env != "" && dsn == "" {
t.Skip(tc.env + " is not configured")
}
db, isolatedDSN := newAuditTestDatabase(t, tc.kind, dsn)
mainDB, mainType := db, tc.typ
if tc.kind == "clickhouse" {
mainDB, _ = newAuditTestDatabase(t, "sqlite", "")
mainType = common.DatabaseTypeSQLite
}
useLogExportDatabases(t, mainDB, db, mainType, tc.typ)
require.NoError(t, mainDB.AutoMigrate(&model.Channel{}))
if tc.kind == "clickhouse" {
t.Setenv("LOG_SQL_DSN", isolatedDSN)
require.NoError(t, model.InitLogDB())
} else {
require.NoError(t, db.AutoMigrate(&model.Log{}))
}
versionSQL := "SELECT version()"
if tc.kind == "sqlite" {
versionSQL = "SELECT sqlite_version()"
}
var version string
require.NoError(t, model.LOG_DB.Raw(versionSQL).Scan(&version).Error)
t.Logf("log database: %s %s", tc.kind, version)
now := time.Now().Unix()
seedExportLogs(t, mainDB, model.LOG_DB, now)
query := fmt.Sprintf("start_timestamp=%d&end_timestamp=%d", now-2*24*3600, now+3600)
listed := callLogHandler(t, GetUserLogs, 1, common.RoleCommonUser, query)[0]
require.Equal(t, true, listed["success"])
assert.EqualValues(t, 2, listed["data"].(map[string]any)["total"])
listed = callLogHandler(t, GetAllLogs, 3, common.RoleAdminUser, query+"&username=alice")[0]
require.Equal(t, true, listed["success"])
assert.EqualValues(t, 2, listed["data"].(map[string]any)["total"])
own := callLogHandler(t, ExportUserLogs, 1, common.RoleCommonUser, query)
require.Len(t, own, 3)
assert.Equal(t, []any{"a-2", "a-1"}, exportedRequestIds(own))
assert.Equal(t, map[string]any{"success": true, "message": "", "data": map[string]any{"count": float64(2)}}, own[2])
assert.Equal(t, float64(1), own[0]["id"], "user exports carry list positions, not row ids")
assert.Empty(t, own[1]["channel_name"])
assert.NotContains(t, own[1]["other"], "admin_info")
all := callLogHandler(t, ExportAllLogs, 3, common.RoleAdminUser, query+"&model_name=gpt-%25")
require.Len(t, all, 4)
assert.Equal(t, []any{"a-2", "b-1", "a-1"}, exportedRequestIds(all))
assert.Equal(t, "primary", all[2]["channel_name"])
assert.Contains(t, all[2]["other"], "admin_info")
assert.Equal(t, true, all[3]["success"])
})
}
}
func TestLogExportRules(t *testing.T) {
gin.SetMode(gin.TestMode)
db, _ := newAuditTestDatabase(t, "sqlite", "")
useLogExportDatabases(t, db, db, common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Log{}, &model.QuotaData{}))
now := time.Now().Unix()
seedExportLogs(t, db, db, now)
logSetting := operation_setting.GetLogSetting()
previousSetting := *logSetting
t.Cleanup(func() { *logSetting = previousSetting })
recent := fmt.Sprintf("start_timestamp=%d&end_timestamp=%d", now-2*24*3600, now)
tooLong := fmt.Sprintf("start_timestamp=%d&end_timestamp=%d", now-366*24*3600, now)
t.Run("time range", func(t *testing.T) {
rejected := callLogHandler(t, ExportAllLogs, 3, common.RoleAdminUser, tooLong)
require.Len(t, rejected, 1)
assert.Equal(t, false, rejected[0]["success"])
assert.Equal(t, "The time range cannot exceed {{days}} days", rejected[0]["message_key"])
assert.Equal(t, map[string]any{"days": float64(365)}, rejected[0]["message_params"])
assert.Equal(t, false, callLogHandler(t, GetUserLogs, 1, common.RoleCommonUser, tooLong)[0]["success"])
assert.Equal(t, false, callLogHandler(t, GetLogsSelfStat, 1, common.RoleCommonUser, tooLong)[0]["success"])
assert.Equal(t, true, callLogHandler(t, GetUserLogs, 3, common.RoleAdminUser, tooLong)[0]["success"], "administrators search their own logs without a limit")
// The dashboard follows the same range instead of its old one-month limit.
fortyDays := fmt.Sprintf("start_timestamp=%d&end_timestamp=%d", now-40*24*3600, now)
for _, handler := range []gin.HandlerFunc{GetUserQuotaDates, GetUserFlowQuotaDates} {
assert.Equal(t, "The time range cannot exceed {{days}} days", callLogHandler(t, handler, 1, common.RoleCommonUser, tooLong)[0]["message_key"])
assert.Equal(t, true, callLogHandler(t, handler, 1, common.RoleCommonUser, fortyDays)[0]["success"])
}
// Without a start, a user's search reaches back the whole range.
listed := callLogHandler(t, GetUserLogs, 1, common.RoleCommonUser, "")[0]
assert.Equal(t, true, listed["success"])
assert.EqualValues(t, 2, listed["data"].(map[string]any)["total"])
logSetting.MaxRangeDays = 400
assert.Equal(t, true, callLogHandler(t, GetUserLogs, 1, common.RoleCommonUser, tooLong)[0]["success"])
logSetting.MaxRangeDays = previousSetting.MaxRangeDays
})
t.Run("user export switch", func(t *testing.T) {
logSetting.UserExportEnabled = false
t.Cleanup(func() { logSetting.UserExportEnabled = previousSetting.UserExportEnabled })
disabled := callLogHandler(t, ExportUserLogs, 1, common.RoleCommonUser, recent)
require.Len(t, disabled, 1)
assert.Equal(t, "Log export is disabled", disabled[0]["message_key"])
assert.Equal(t, true, callLogHandler(t, ExportUserLogs, 3, common.RoleAdminUser, recent)[0]["success"], "administrators export their own logs even when users cannot")
})
t.Run("one export per user", func(t *testing.T) {
release, ok, err := service.AcquireLogExportLock(1)
require.NoError(t, err)
require.True(t, ok)
busy := callLogHandler(t, ExportUserLogs, 1, common.RoleCommonUser, recent)
require.Len(t, busy, 1)
assert.Equal(t, "Another log export is running. Try again after it finishes.", busy[0]["message_key"])
other := callLogHandler(t, ExportUserLogs, 2, common.RoleCommonUser, recent)
assert.Equal(t, true, other[len(other)-1]["success"], "other users are not blocked")
release()
assert.Len(t, callLogHandler(t, ExportUserLogs, 1, common.RoleCommonUser, recent), 3)
})
}
func TestAcquireLogExportLock(t *testing.T) {
for _, useRedis := range []bool{false, true} {
t.Run(fmt.Sprintf("redis=%v", useRedis), func(t *testing.T) {
previousRedis, previousClient := common.RedisEnabled, common.RDB
common.RedisEnabled = useRedis
if useRedis {
server := miniredis.RunT(t)
common.RDB = redis.NewClient(&redis.Options{Addr: server.Addr()})
require.NoError(t, common.RDB.Ping(context.Background()).Err())
}
t.Cleanup(func() { common.RedisEnabled, common.RDB = previousRedis, previousClient })
release, ok, err := service.AcquireLogExportLock(1)
require.NoError(t, err)
require.True(t, ok)
_, ok, err = service.AcquireLogExportLock(1)
require.NoError(t, err)
assert.False(t, ok, "a second export of the same user must wait")
releaseOther, ok, err := service.AcquireLogExportLock(2)
require.NoError(t, err)
assert.True(t, ok)
releaseOther()
release()
releaseAgain, ok, err := service.AcquireLogExportLock(1)
require.NoError(t, err)
assert.True(t, ok, "a finished export frees the user")
releaseAgain()
})
}
}
+5 -4
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
@@ -20,7 +21,7 @@ func VerifyLogin(c *gin.Context) {
Code string `json:"code"`
}
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" || request.Code == "" {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
if request.Method == "" {
@@ -44,7 +45,7 @@ func LoginPasskeyBegin(c *gin.Context) {
RPID string `json:"rp_id"`
}
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
verification, err := service.RequireLoginVerification(request.FlowToken, service.VerificationMethodPasskey)
@@ -90,7 +91,7 @@ func LoginPasskeyFinish(c *gin.Context) {
Credential json.RawMessage `json:"credential"`
}
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" || request.PasskeyFlowToken == "" || len(request.Credential) == 0 {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
verification, err := service.RequireLoginVerification(request.FlowToken, service.VerificationMethodPasskey)
@@ -159,7 +160,7 @@ func completeVerifiedLoginResponse(c *gin.Context, bundle *service.AuthBundle, m
c.Set("login_verification_method", method)
if migration != nil {
// The legacy GitHub binding was rewritten together with this session.
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: GitHub") != nil
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": "GitHub"}) != nil
recordLegacyGitHubBindingAudit(c, user, true, map[string]any{
"legacy_id": migration.LegacyID, "provider_user_id": migration.GitHubID,
"verification_method": method, "notification_failed": notificationFailed,
-27
View File
@@ -1,27 +0,0 @@
package controller
import (
"github.com/tidwall/gjson"
"net/url"
)
func manxueArtworkURL(result gjson.Result) string {
for _, key := range []string{"share_url", "public_url", "result_url", "result.share_url", "result.public_url"} {
raw := result.Get(key).String()
u, err := url.Parse(raw)
if err == nil && u.Scheme == "https" && u.Host == "manxue.ai" && u.User == nil && u.Query().Get("result") != "" {
return u.String()
}
}
id := result.Get("public_id").String()
if id == "" {
id = result.Get("result.public_id").String()
}
if id == "" && result.Get("public").Bool() {
id = result.Get("result.id").String()
}
if id == "" {
return ""
}
return "https://manxue.ai/?" + url.Values{"result": {id}}.Encode()
}
+19 -19
View File
@@ -43,7 +43,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
}
summary.UnfinishedTasks = len(tasks)
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
logger.LogInfo(ctx, common.LogText("unfinished tasks found: %v", len(tasks)))
taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Midjourney)
nullTaskIds := make([]int, 0)
@@ -63,9 +63,9 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
"progress": "100%",
})
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
logger.LogError(ctx, common.LogText("Fix null mj_id task error: %v", err))
} else {
logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
logger.LogInfo(ctx, common.LogText("Fix null mj_id task success: %v", nullTaskIds))
}
}
if len(taskChannelM) == 0 {
@@ -83,20 +83,20 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
}
processedChannels++
summary.ChannelsScanned++
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
logger.LogInfo(ctx, common.LogText("channel #%d has %d unfinished tasks", channelId, len(taskIds)))
if len(taskIds) == 0 {
continue
}
midjourneyChannel, err := model.CacheGetChannel(channelId)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
logger.LogError(ctx, common.LogText("CacheGetChannel: %v", err))
err := model.MjBulkUpdate(taskIds, map[string]any{
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
"fail_reason": "Failed to get the channel information. Please contact the administrator",
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
logger.LogInfo(ctx, common.LogText("UpdateMidjourneyTask error: %v", err))
}
continue
}
@@ -106,7 +106,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
"ids": taskIds,
})
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Task marshal body error: %v", err))
logger.LogError(ctx, common.LogText("Get Task marshal body error: %v", err))
continue
}
timeout := time.Second * 15
@@ -114,26 +114,26 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
req, err := http.NewRequestWithContext(requestCtx, "POST", requestUrl, bytes.NewBuffer(body))
if err != nil {
cancel()
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
logger.LogError(ctx, common.LogText("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))
logger.LogError(ctx, common.LogText("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))
logger.LogError(ctx, common.LogText("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))
logger.LogError(ctx, common.LogText("Get Mjp Task parse body error: %v", err))
resp.Body.Close()
cancel()
continue
@@ -141,7 +141,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
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)))
logger.LogError(ctx, common.LogText("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody)))
resp.Body.Close()
cancel()
continue
@@ -153,14 +153,14 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
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))
logger.LogWarn(ctx, common.LogText("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.FailReason = "Upstream task timed out (over 1 hour)"
responseItem.Status = "FAILURE"
}
if !checkMjTaskNeedUpdate(task, responseItem) {
@@ -192,7 +192,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
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))
logger.LogError(ctx, common.LogText("failed to serialize VideoUrls: %v", err))
task.VideoUrls = "[]" // 失败时设置为空数组
} else {
task.VideoUrls = string(videoUrlsStr)
@@ -203,7 +203,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
shouldReturnQuota := false
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
logger.LogInfo(ctx, common.LogText("%s failed: %s", task.MjId, task.FailReason))
task.Progress = "100%"
if task.Quota != 0 {
shouldReturnQuota = true
@@ -211,9 +211,9 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
}
won, err := task.UpdateWithStatus(preStatus)
if err != nil {
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
logger.LogError(ctx, common.LogText("UpdateMidjourneyTask task error: %s", err.Error()))
} else if won && shouldReturnQuota {
service.RefundMidjourneyQuota(ctx, task, "构图失败")
service.RefundMidjourneyQuota(ctx, task, "Composition failed")
}
}
}
+38 -21
View File
@@ -25,10 +25,7 @@ import (
func TestStatus(c *gin.Context) {
err := model.PingDB()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"message": "数据库连接失败",
})
writeAccountStatusError(c, http.StatusServiceUnavailable, "Database connection failed")
return
}
// 获取HTTP统计信息
@@ -126,6 +123,10 @@ func GetStatus(c *gin.Context) {
"user_agreement_enabled": legalSetting.UserAgreement != "",
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
"log_export_enabled": operation_setting.GetLogSetting().UserExportEnabled,
"log_max_range_days": operation_setting.GetLogSetting().MaxRangeDays,
"quota_remind_enabled": common.QuotaRemindEnabled,
"quota_remind_threshold": common.QuotaRemindThreshold,
}
// 根据启用状态注入可选内容
@@ -223,15 +224,21 @@ func SendEmailVerification(c *gin.Context) {
}
if model.IsEmailAlreadyTaken(email) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
common.ApiErrorT(c, "Email address is already in use")
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)
// The address may not belong to an account yet, so the mail follows the
// request language.
systemName := map[string]any{"SystemName": common.SystemName}
subject := i18n.T(c, i18n.MsgEmailVerificationSubject, systemName)
content := fmt.Sprintf("<p>%s</p>"+
"<p>%s</p>"+
"<p>%s</p>",
i18n.T(c, i18n.MsgEmailVerificationGreeting, systemName),
i18n.T(c, i18n.MsgEmailVerificationCode, map[string]any{"Code": code}),
i18n.T(c, i18n.MsgEmailVerificationValidity, map[string]any{"Minutes": common.VerificationValidMinutes}))
err = common.SendEmail(subject, email, content)
if err != nil {
common.ApiError(c, err)
@@ -247,24 +254,34 @@ func SendEmailVerification(c *gin.Context) {
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)
common.ApiErrorT(c, "Invalid parameters")
return
}
if _, err := model.GetUniqueUserByEmail(email); err == nil {
if user, 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)
lang := user.GetSetting().Language
if lang == "" {
lang = i18n.StatedLang(c)
}
systemName := map[string]any{"SystemName": common.SystemName}
linkParams := map[string]any{"Link": link}
subject := i18n.Translate(lang, i18n.MsgEmailPasswordResetSubject, systemName)
content := fmt.Sprintf("<p>%s</p>"+
"<p>%s</p>"+
"<p>%s </p>"+
"<p>%s</p>",
i18n.Translate(lang, i18n.MsgEmailPasswordResetGreeting, systemName),
i18n.Translate(lang, i18n.MsgEmailPasswordResetLink, linkParams),
i18n.Translate(lang, i18n.MsgEmailPasswordResetLinkFallback, linkParams),
i18n.Translate(lang, i18n.MsgEmailPasswordResetValidity, map[string]any{"Minutes": 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()))
logger.LogError(c.Request.Context(), common.LogText("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()))
logger.LogWarn(c.Request.Context(), common.LogText("skip password reset email for %s: %s", email, err.Error()))
}
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -286,18 +303,18 @@ func ResetPassword(c *gin.Context) {
}
req.Email = model.NormalizeEmail(req.Email)
if req.Email == "" || req.Token == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) {
common.ApiErrorI18n(c, i18n.MsgUserPasswordResetLinkInvalid)
common.ApiErrorT(c, "Password reset link is invalid or has expired")
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)
common.ApiErrorT(c, "Password reset link is invalid or has expired")
return
}
common.ApiError(c, err)
+1 -1
View File
@@ -141,7 +141,7 @@ func channelOwnerName(channelType int) string {
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))
common.SysLog(common.LogText("GetPreferredModelOwnerChannelTypes error: %v", err))
return map[string]string{}
}
+7 -6
View File
@@ -170,7 +170,8 @@ func TestModelPricingConversionDatabaseMatrix(t *testing.T) {
{"conversion-fixed", model.PricingValues{"ModelPrice": float64(0.25), "ModelRatio": float64(7)}, `tier("request", fixed(0.25))`, ""},
{"gpt-4o-2024-05-13", model.PricingValues{"ModelRatio": float64(2), "CompletionRatio": float64(99)}, `tier("base", p * 4 + c * 12)`, ""},
{"gpt-image-2", model.PricingValues{"ModelPrice": float64(1)}, `tier("image", fixed(1)) * image_count`, ""},
{"qwen-image-3.0-pro", model.PricingValues{"ModelPrice": float64(1)}, `tier("image", fixed(1)) * image_count`, ""},
// Bailian image models are served by the alibaba task plugin, so their pricing is a task usage expression.
{"qwen-image-3.0-pro", model.PricingValues{"ModelPrice": float64(1)}, "", "Task pricing must be converted manually using the task usage schema."},
{"wan2.7-image-pro", model.PricingValues{"ModelPrice": float64(1)}, "", "Task pricing must be converted manually using the task usage schema."},
{"video-summarizer", model.PricingValues{"ModelPrice": float64(1)}, `tier("request", fixed(1))`, ""},
{"sora-transcript-helper", model.PricingValues{"ModelPrice": float64(1)}, `tier("request", fixed(1))`, ""},
@@ -363,14 +364,14 @@ func TestModelPricingConversionDatabaseMatrix(t *testing.T) {
aliConversion, err := model.PreviewModelPricingConversion(aliName, model.PricingValues{"ModelPrice": 0.04})
require.NoError(t, err)
require.Empty(t, aliConversion.UnsupportedReason)
// Ali image billing lives in the alibaba task plugin, where prompt_extend is a
// usage fact; the legacy conversion no longer adds a per-channel request multiplier.
assert.Equal(t, `tier("image", fixed(0.04)) * image_count`, aliConversion.Expression)
assert.Empty(t, aliConversion.BillingDetails.RequestRules)
quantity := 3
aliCost, _, err := billingexpr.RunExprWithRequest(aliConversion.Expression, billingexpr.TokenParams{}, billingexpr.RequestInput{ImageCount: &quantity, Body: []byte(`{"parameters":{"prompt_extend":true}}`)})
require.NoError(t, err)
assert.Equal(t, 240000.0, aliCost)
require.NoError(t, (&model.Channel{Name: "Different image rules", Models: aliName, Type: 1, Status: common.ChannelStatusEnabled}).Insert())
conflicting, err = model.PreviewModelPricingConversion(aliName, model.PricingValues{"ModelPrice": 0.04})
require.NoError(t, err)
assert.Contains(t, conflicting.UnsupportedReason, "different image request multipliers")
assert.Equal(t, 120000.0, aliCost)
for _, tc := range []struct {
name string
pricing model.PricingValues
+5 -5
View File
@@ -111,7 +111,7 @@ func CreateModelMeta(c *gin.Context) {
return
}
if m.ModelName == "" {
common.ApiErrorMsg(c, "模型名称不能为空")
common.ApiErrorT(c, "Model name cannot be empty")
return
}
if err := model.ValidateMetadataValues(model.MetadataValues{Endpoints: m.Endpoints, Status: m.Status, NameRule: m.NameRule}); err != nil {
@@ -123,7 +123,7 @@ func CreateModelMeta(c *gin.Context) {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
common.ApiErrorT(c, "Model name already exists")
return
}
@@ -146,7 +146,7 @@ func UpdateModelMeta(c *gin.Context) {
return
}
if m.Id == 0 {
common.ApiErrorMsg(c, "缺少模型 ID")
common.ApiErrorT(c, "Missing model ID")
return
}
@@ -162,7 +162,7 @@ func UpdateModelMeta(c *gin.Context) {
}
} else {
if strings.TrimSpace(m.ModelName) == "" {
common.ApiErrorMsg(c, "模型名称不能为空")
common.ApiErrorT(c, "Model name cannot be empty")
return
}
if err := model.ValidateMetadataValues(model.MetadataValues{Endpoints: m.Endpoints, Status: m.Status, NameRule: m.NameRule}); err != nil {
@@ -174,7 +174,7 @@ func UpdateModelMeta(c *gin.Context) {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
common.ApiErrorT(c, "Model name already exists")
return
}
@@ -0,0 +1,71 @@
package controller
import (
"net/http"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Model metadata may declare endpoints either as a type array or as a path map.
// Conversion must understand both; reading only the map form made every model
// with array-form metadata look like a broken routing configuration.
func TestModelPricingConversionAcceptsDeclaredEndpointArray(t *testing.T) {
db := modelManagementDB(t, "sqlite", "")
previousQuota := common.QuotaPerUnit
common.QuotaPerUnit = 500000
t.Cleanup(func() { common.QuotaPerUnit = previousQuota })
for _, tc := range []struct {
name string
endpoints string
draft model.PricingValues
wantImageCt bool
}{
{"array-endpoints-image", `["image-generation","openai"]`,
model.PricingValues{"ModelRatio": 1.7875, "CompletionRatio": 6, "ImageRatio": 1.6}, false},
{"array-endpoints-image-fixed", `["image-generation","openai"]`,
model.PricingValues{"ModelPrice": 1}, true},
{"array-endpoints-gemini", `["gemini","openai"]`,
model.PricingValues{"ModelRatio": 0.10725, "CompletionRatio": 100}, false},
{"array-endpoints-gemini-cache", `["gemini","openai"]`,
model.PricingValues{"ModelRatio": 0.715, "CompletionRatio": 60, "CacheRatio": 0.1}, false},
{"array-endpoints-openai-response", `["openai","openai-response","openai-response-compact","gemini"]`,
model.PricingValues{"ModelRatio": 0.17875, "CompletionRatio": 120}, false},
{"map-endpoints-image", `{"image-generation": {"path": "/v1/images/generations", "method": "POST"}}`,
model.PricingValues{"ModelPrice": 1}, true},
// Endpoint names must be trimmed before the switch matches them: a
// padded name would otherwise match nothing and silently downgrade a
// fixed-price image model to per-request billing.
{"array-endpoints-image-padded", `[" image-generation ","openai"]`,
model.PricingValues{"ModelPrice": 1}, true},
{"map-endpoints-image-padded", `{"image-generation ": {"path": "/v1/images/generations", "method": "POST"}}`,
model.PricingValues{"ModelPrice": 1}, true},
} {
t.Run(tc.name, func(t *testing.T) {
require.NoError(t, db.Create(&model.Model{
ModelName: tc.name, Endpoints: tc.endpoints, Status: 1, NameRule: model.NameRuleExact,
}).Error)
var response struct {
Success bool
Data model.ModelPricingConversion
}
modelManagementRequest(t, PreviewModelPricingConversion, http.MethodPost,
"/api/option/model_pricing/convert",
map[string]any{"model_name": tc.name, "pricing": tc.draft}, &response)
require.True(t, response.Success)
t.Logf("PROBE %-34s expression=%q unsupported=%q image_count=%v",
tc.name, response.Data.Expression, response.Data.UnsupportedReason,
response.Data.BillingDetails.ImageCount)
require.Empty(t, response.Data.UnsupportedReason)
require.NotEmpty(t, response.Data.Expression)
// Declared endpoint types must actually reach the endpoint switch;
// an array of names read as array indices would silently skip it.
assert.Equal(t, tc.wantImageCt, response.Data.BillingDetails.ImageCount,
"declared image-generation endpoint must drive image-count billing")
})
}
}
+1 -1
View File
@@ -94,7 +94,7 @@ func newHTTPClient() *http.Client {
ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
}
if common.TLSInsecureSkipVerify {
transport.TLSClientConfig = common.InsecureTLSConfig
transport.TLSClientConfig = common.InsecureTLSConfig.Clone()
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
+26 -40
View File
@@ -3,7 +3,6 @@ package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
@@ -38,16 +37,16 @@ type oauthFlowPayload struct {
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
}
// providerParams returns map with Provider key for i18n templates
// providerParams returns the params of messages that name the OAuth provider.
func providerParams(name string) map[string]any {
return map[string]any{"Provider": name}
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)
common.ApiErrorT(c, "Invalid parameters")
return
}
request.Provider = strings.TrimSpace(request.Provider)
@@ -58,7 +57,7 @@ func GenerateOAuthCode(c *gin.Context) {
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)
common.ApiErrorT(c, "Invalid parameters")
return
}
userID := 0
@@ -76,7 +75,7 @@ func GenerateOAuthCode(c *gin.Context) {
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": "绑定操作需要登录"})
writeAccountStatusError(c, http.StatusUnauthorized, "Sign in to bind an account")
return
}
userID = identity.UserID
@@ -148,10 +147,7 @@ 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),
})
writeAccountStatusError(c, http.StatusBadRequest, "Unknown OAuth provider")
return
}
@@ -162,10 +158,7 @@ func HandleOAuth(c *gin.Context) {
Provider: providerName,
})
if err != nil {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
})
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
return
}
@@ -184,10 +177,7 @@ func HandleOAuth(c *gin.Context) {
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),
})
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
return
}
consumeMatch.UserId = identity.UserID
@@ -209,7 +199,7 @@ func HandleOAuth(c *gin.Context) {
}
}
} else if pendingFlow.Intent != model.AuthFlowIntentLogin {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
@@ -238,7 +228,7 @@ func HandleOAuth(c *gin.Context) {
c.Set(oauth.TelegramOAuthFlowContextKey, telegramPayload.Telegram)
}
if !provider.IsEnabled() {
common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName()))
common.ApiErrorT(c, "{{provider}} login and registration has not been enabled by administrator", providerParams(provider.GetName()))
return
}
@@ -246,7 +236,7 @@ func HandleOAuth(c *gin.Context) {
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)})
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
return
}
errorDescription := c.Query("error_description")
@@ -287,7 +277,7 @@ func HandleOAuth(c *gin.Context) {
}
flow, err := model.ConsumeAuthFlow(state, consumeMatch)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
writeAccountStatusError(c, http.StatusForbidden, "State parameter is empty or mismatched")
return
}
@@ -325,18 +315,18 @@ func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.
user, migration, err := findOrCreateOAuthUser(c, provider, oauthUser, token, payload.AffiliateCode)
if err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
common.ApiErrorT(c, "Email address is already in use")
return
}
switch err.(type) {
case *OAuthUserDeletedError:
common.ApiErrorI18n(c, i18n.MsgOAuthUserDeleted)
common.ApiErrorT(c, "User has been deleted")
case *OAuthRegistrationDisabledError:
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
common.ApiErrorT(c, "New user registration has been disabled by administrator")
case *OAuthEmailAlreadyTakenError:
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
common.ApiErrorT(c, "Email address is already in use")
case *OAuthLegacyBindingNotConfirmedError:
common.ApiErrorI18n(c, i18n.MsgOAuthNotAutoLinked, providerParams(provider.GetName()))
common.ApiErrorT(c, "This {{provider}} account cannot be linked to an existing account automatically, please sign in or register another way and then link {{provider}} in account settings", providerParams(provider.GetName()))
default:
writeSecurityOperationError(c, err)
}
@@ -345,7 +335,7 @@ func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.
// 8. Check user status
if user.Status != common.UserStatusEnabled {
common.ApiErrorI18n(c, i18n.MsgOAuthUserBanned)
common.ApiErrorT(c, "User has been banned")
return
}
@@ -377,7 +367,7 @@ func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provide
return false, false
}
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
common.ApiErrorT(c, "This {{provider}} account has already been bound", providerParams(provider.GetName()))
return false, false
}
_, err = model.ConsumeAuthFlowWithAction(state, match, func(tx *gorm.DB, _ *model.AuthFlow) error {
@@ -398,8 +388,8 @@ func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provide
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})
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": provider.GetName()}) != nil
common.ApiSuccessT(c, "Binding successful", gin.H{"action": "bind", "notification_warning": notificationFailed})
return true, notificationFailed
}
@@ -454,7 +444,7 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
if emailProvider, ok := provider.(oauth.VerifiedEmailProvider); ok && user.Email != "" {
emails, err := emailProvider.GetVerifiedEmails(c.Request.Context(), token)
if err != nil {
common.SysError(fmt.Sprintf("[OAuth] Failed to load verified emails for user %d: %s", user.Id, err.Error()))
common.SysError(common.LogText("[OAuth] Failed to load verified emails for user %d: %s", user.Id, err.Error()))
reason = "verified_emails_unavailable"
}
accountEmail := model.NormalizeEmail(user.Email)
@@ -475,7 +465,7 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
}
if written {
user.GitHubId = oauthUser.ProviderUserID
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": provider.GetName()}) != nil
recordLegacyGitHubBindingAudit(c, user, true, map[string]any{
"legacy_id": legacyID, "provider_user_id": oauthUser.ProviderUserID,
"verified_email_matched": true, "notification_failed": notificationFailed,
@@ -625,19 +615,15 @@ func (e *OAuthLegacyBindingNotConfirmedError) Error() string {
return "legacy binding was not confirmed"
}
// handleOAuthError handles OAuth errors and returns translated message
// handleOAuthError handles OAuth errors and returns a web console 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)
}
common.ApiError(c, e.Message)
case *oauth.AccessDeniedError:
common.ApiErrorMsg(c, e.Message)
case *oauth.TrustLevelError:
common.ApiErrorI18n(c, i18n.MsgOAuthTrustLevelLow)
common.ApiErrorT(c, "Linux DO trust level does not meet the minimum required by administrator")
default:
writeSecurityOperationError(c, err)
}
+34 -108
View File
@@ -10,7 +10,6 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
@@ -145,7 +144,7 @@ func UpdatePasskeyDomains(c *gin.Context) {
RemovalConfirmation string `json:"removal_confirmation"`
}
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.RPID == nil || request.LegacyRPIDs == nil || request.Origins == nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
change, err := model.UpdatePasskeyDomainOptions(map[string]string{
@@ -167,9 +166,9 @@ func UpdatePasskeyDomains(c *gin.Context) {
func writePasskeyDomainSettingsError(c *gin.Context, err error) {
var removal *model.PasskeyDomainRemovalError
if errors.As(err, &removal) {
c.JSON(http.StatusConflict, gin.H{
"success": false, "code": "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED",
"message": i18n.T(c, i18n.MsgPasskeyRPIDRemovalConfirmation), "data": removal.Change,
common.ApiErrorStatus(c, http.StatusConflict, common.NewMessage("Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."), gin.H{
"code": "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED",
"data": removal.Change,
})
return
}
@@ -184,10 +183,7 @@ 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": "无效的参数",
})
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Invalid parameters"))
return
}
switch option.Value.(type) {
@@ -203,77 +199,55 @@ func UpdateOption(c *gin.Context) {
switch option.Key {
case "QuotaForInviter", "QuotaForInvitee":
if isPositiveOptionValue(option.Value.(string)) && !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
common.ApiErrorT(c, "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them.")
return
}
default:
if isPaymentComplianceOptionKey(option.Key) {
common.ApiErrorMsg(c, "合规确认字段不允许通过通用设置接口修改")
common.ApiErrorT(c, "Compliance confirmation fields cannot be changed through the general settings API")
return
}
}
if option.Key == "TaskPublicAddress" && option.Value.(string) != "" {
if err := service.ValidateTaskArtifactBaseURL(option.Value.(string)); err != nil {
common.ApiErrorMsg(c, err.Error())
common.ApiError(c, err)
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!",
})
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "GitHub OAuth"})
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!",
})
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "Discord OAuth"})
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!",
})
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "OIDC"})
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!",
})
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "LinuxDO OAuth"})
return
}
case "EmailDomainRestrictionEnabled":
if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!",
})
common.ApiErrorT(c, "Cannot enable the email domain restriction. Enter the allowed email domains first.")
return
}
case "WeChatAuthEnabled":
if option.Value == "true" && common.WeChatServerAddress == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用微信登录,请先填入微信登录相关配置信息!",
})
common.ApiErrorT(c, "Cannot enable WeChat login. Enter the WeChat login settings first.")
return
}
case "TurnstileCheckEnabled":
if option.Value == "true" && common.TurnstileSiteKey == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!",
})
common.ApiErrorT(c, "Cannot enable Turnstile verification. Enter the Turnstile settings first.")
return
}
case "TelegramOAuthEnabled":
@@ -287,115 +261,79 @@ func UpdateOption(c *gin.Context) {
}
case "theme.frontend":
if option.Value != "default" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Classic 前端已移除,主题只能设置为 default",
})
common.ApiErrorT(c, "The Classic frontend has been removed. The theme can only be set to 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(),
})
common.ApiError(c, err)
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(),
})
common.ApiError(c, err)
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(),
})
common.ApiError(c, err)
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(),
})
common.ApiError(c, err)
return
}
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "图片倍率设置失败: " + err.Error(),
})
common.ApiErrorT(c, "Failed to set the image ratio: {{error}}", map[string]any{"error": 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(),
})
common.ApiErrorT(c, "Failed to set the audio ratio: {{error}}", map[string]any{"error": 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(),
})
common.ApiErrorT(c, "Failed to set the audio completion ratio: {{error}}", map[string]any{"error": 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(),
})
common.ApiErrorT(c, "Failed to set the cache creation ratio: {{error}}", map[string]any{"error": 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(),
})
common.ApiError(c, err)
return
}
case "AutomaticDisableStatusCodes":
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
case "AutomaticRetryStatusCodes":
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
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())
common.ApiErrorT(c, "Billing expressions must be a JSON object mapping models to expressions: {{error}}", map[string]any{"error": err.Error()})
return
}
models := make([]string, 0, len(expressions))
@@ -416,7 +354,7 @@ func UpdateOption(c *gin.Context) {
billing_setting.PluginBillingExprOption: variants,
})
if err != nil {
common.ApiErrorMsg(c, fmt.Sprintf("模型 %s 的计费表达式无效: %v", modelName, err))
common.ApiErrorT(c, "Invalid billing expression for model {{model}}: {{error}}", map[string]any{"model": modelName, "error": err.Error()})
return
}
}
@@ -435,44 +373,32 @@ func UpdateOption(c *gin.Context) {
if err = model.ValidateModelPricing(name, model.PricingValues{
billing_setting.PluginBillingExprOption: map[string]any{plugin: expression},
}); err != nil {
common.ApiErrorMsg(c, err.Error())
common.ApiError(c, 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(),
})
common.ApiError(c, err)
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(),
})
common.ApiError(c, err)
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(),
})
common.ApiError(c, err)
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(),
})
common.ApiError(c, err)
return
}
}
+43 -76
View File
@@ -37,17 +37,14 @@ func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) {
return nil, err
}
if request.FlowToken == "" || len(request.Credential) == 0 {
return nil, errors.New("Passkey 流程参数不完整")
return nil, errors.New("incomplete Passkey flow parameters")
}
return &request, nil
}
func PasskeyRegisterBegin(c *gin.Context) {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
common.ApiErrorT(c, "Passkey authentication is disabled.")
return
}
@@ -92,9 +89,9 @@ func PasskeyRegisterBegin(c *gin.Context) {
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
common.ApiErrorT(c, "The current sign-in method does not support security verification")
return
}
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
@@ -120,10 +117,7 @@ func PasskeyRegisterBegin(c *gin.Context) {
func PasskeyRegisterFinish(c *gin.Context) {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
common.ApiErrorT(c, "Passkey authentication is disabled.")
return
}
@@ -134,7 +128,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
common.ApiErrorT(c, "Invalid Passkey verification request")
return
}
parsedCredential, err := protocol.ParseCredentialCreationResponseBytes(request.Credential)
@@ -152,9 +146,9 @@ func PasskeyRegisterFinish(c *gin.Context) {
credentialRecord = nil
}
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
common.ApiErrorT(c, "The current sign-in method does not support security verification")
return
}
sessionData, security, err := passkeysvc.PopSessionDataFlow(
@@ -190,7 +184,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential)
if passkeyCredential == nil {
common.ApiErrorMsg(c, "无法创建 Passkey 凭证")
common.ApiErrorT(c, "Unable to create the Passkey credential")
return
}
@@ -206,11 +200,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
}
recordUserSecurityAudit(c, user.Id, "user.passkey_register", nil)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 注册成功",
"data": authRotationData(bundle),
})
common.ApiSuccessT(c, "Passkey registered successfully", authRotationData(bundle))
}
func PasskeyDelete(c *gin.Context) {
@@ -224,9 +214,9 @@ func PasskeyDelete(c *gin.Context) {
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
common.ApiErrorT(c, "The current sign-in method does not support security verification")
return
}
if err := model.DeletePasskeyForSession(identity); err != nil {
@@ -240,11 +230,7 @@ func PasskeyDelete(c *gin.Context) {
}
recordUserSecurityAudit(c, user.Id, "user.passkey_delete", nil)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已解绑",
"data": authRotationData(bundle),
})
common.ApiSuccessT(c, "Passkey removed successfully", authRotationData(bundle))
}
func PasskeyStatus(c *gin.Context) {
@@ -284,10 +270,7 @@ func PasskeyStatus(c *gin.Context) {
func PasskeyLoginBegin(c *gin.Context) {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
common.ApiErrorT(c, "Passkey authentication is disabled.")
return
}
@@ -296,7 +279,7 @@ func PasskeyLoginBegin(c *gin.Context) {
}
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if common.DecodeJson(c.Request.Body, &request) != nil {
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
common.ApiErrorT(c, "Invalid Passkey verification request")
return
}
}
@@ -336,16 +319,13 @@ func PasskeyLoginBegin(c *gin.Context) {
func PasskeyLoginFinish(c *gin.Context) {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
common.ApiErrorT(c, "Passkey authentication is disabled.")
return
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
common.ApiErrorT(c, "Invalid Passkey verification request")
return
}
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
@@ -378,7 +358,7 @@ func PasskeyLoginFinish(c *gin.Context) {
// 首先通过凭证ID查找用户
credential, err := model.GetPasskeyByCredentialID(rawID)
if err != nil {
return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err)
return nil, fmt.Errorf("passkey credential not found: %w", err)
}
if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID {
@@ -388,7 +368,7 @@ func PasskeyLoginFinish(c *gin.Context) {
// 通过凭证获取用户
user := &model.User{Id: credential.UserID}
if err := user.FillUserById(); err != nil {
return nil, fmt.Errorf("用户信息获取失败: %w", err)
return nil, fmt.Errorf("failed to load the passkey user: %w", err)
}
if user.Status != common.UserStatusEnabled {
@@ -399,9 +379,9 @@ func PasskeyLoginFinish(c *gin.Context) {
userID, parseErr := strconv.Atoi(string(userHandle))
if parseErr != nil {
// 记录异常但继续验证,因为某些客户端可能使用非数字格式
common.SysLog(fmt.Sprintf("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle)))
common.SysLog(common.LogText("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle)))
} else if userID != user.Id {
return nil, errors.New("用户句柄与凭证不匹配")
return nil, errors.New("user handle does not match the credential")
}
}
@@ -416,18 +396,18 @@ func PasskeyLoginFinish(c *gin.Context) {
userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser)
if !ok {
common.ApiErrorMsg(c, "Passkey 登录状态异常")
common.ApiErrorT(c, "Invalid Passkey login state")
return
}
modelUser := userWrapper.ModelUser()
if modelUser == nil {
common.ApiErrorMsg(c, "Passkey 登录状态异常")
common.ApiErrorT(c, "Invalid Passkey login state")
return
}
if modelUser.Status != common.UserStatusEnabled {
common.ApiErrorMsg(c, "该用户已被禁用")
common.ApiErrorT(c, "This user account is disabled.")
return
}
@@ -443,7 +423,7 @@ func PasskeyLoginFinish(c *gin.Context) {
func AdminResetPasskey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiErrorMsg(c, "无效的用户 ID")
common.ApiErrorT(c, "Invalid user ID")
return
}
@@ -457,13 +437,14 @@ func AdminResetPasskey(c *gin.Context) {
common.ApiErrorMsg(c, "no permission")
return
}
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserPasskeyReset, service.AdminUserContext{UserID: user.Id})
if authorization == nil {
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",
})
common.ApiErrorT(c, "No Passkey is registered.")
return
}
writeSecurityOperationError(c, err)
@@ -480,21 +461,16 @@ func AdminResetPasskey(c *gin.Context) {
}
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 已重置",
"username": user.Username,
"id": user.Id,
"verification_method": authorization.Method,
})
common.ApiSuccessT(c, "Passkey reset successfully", nil)
}
func PasskeyVerifyBegin(c *gin.Context) {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
common.ApiErrorT(c, "Passkey authentication is disabled.")
return
}
@@ -505,7 +481,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
}
var request passkeyVerifyBeginRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
common.ApiErrorT(c, "Invalid Passkey verification request")
return
}
binding, err := service.BindVerificationOperation(service.VerificationOperation{Scope: request.Scope, Context: request.Context})
@@ -513,7 +489,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
@@ -525,10 +501,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
common.ApiErrorT(c, "No Passkey is registered.")
return
}
@@ -573,10 +546,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
func PasskeyVerifyFinish(c *gin.Context) {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
common.ApiErrorT(c, "Passkey authentication is disabled.")
return
}
@@ -588,7 +558,7 @@ func PasskeyVerifyFinish(c *gin.Context) {
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
common.ApiErrorT(c, "Invalid Passkey verification request")
return
}
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
@@ -599,16 +569,13 @@ func PasskeyVerifyFinish(c *gin.Context) {
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
common.ApiErrorT(c, "No Passkey is registered.")
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
common.ApiErrorT(c, "The current sign-in method does not support security verification")
return
}
sessionData, security, err := passkeysvc.PopSessionDataFlow(
+20 -14
View File
@@ -153,6 +153,7 @@ func TestPasskeyDomainsPreserveCredentialsAcrossVerificationFlows(t *testing.T)
beginHandler, finishHandler = LoginPasskeyBegin, LoginPasskeyFinish
} else if kind == "sensitive action" {
request["scope"] = service.VerificationScopeAccessTokenGenerate
request["context"] = map[string]any{"scopes": []string{"profile:read"}, "expires_at": 0}
beginPath, finishPath = "/api/user/passkey/verify/begin", "/api/user/passkey/verify/finish"
beginHandler, finishHandler = PasskeyVerifyBegin, PasskeyVerifyFinish
}
@@ -293,6 +294,9 @@ func TestPasskeyDomainChoicesRespectOriginAndConfiguration(t *testing.T) {
func setupPasskeyDomainOptions(t *testing.T) {
t.Helper()
require.NoError(t, model.DB.AutoMigrate(&model.Option{}))
// These tests assert on the whole options table. The server-managed legacy
// access token deadline written by the enrollment fixture is unrelated.
require.NoError(t, model.DB.Delete(&model.Option{Key: "LegacyAccessTokenRetireAt"}).Error)
common.OptionMapRWMutex.RLock()
previousOptions := maps.Clone(common.OptionMap)
previousAddress := system_setting.ServerAddress
@@ -661,27 +665,28 @@ func TestPasskeyDomainFailuresDoNotLogCredentials(t *testing.T) {
}
}
func TestPasskeyDomainErrorsRespectRequestLanguage(t *testing.T) {
// The web console translates message_key, so every request language gets the
// same English source text.
func TestPasskeyDomainErrorsReturnMessageKeys(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
setupPasskeyDomainOptions(t)
system_setting.GetPasskeySettings().LegacyRPIDs = "www.example.com"
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "unknown-domain", PublicKey: "key"}).Error)
for _, locale := range []struct {
language, invalid, unavailable, removal string
}{
{"zh-CN", "通行密钥域名无效。请填写域名,不包含协议、端口、路径或通配符。", "此通行密钥域名无法在当前网站使用。请前往原网站或选择其他验证方式。", "请核对受影响的通行密钥并确认删除域名。配置或影响范围发生变化后,需要重新确认。"},
{"zh-TW", "通行金鑰網域無效。請填寫網域,不包含通訊協定、連接埠、路徑或萬用字元。", "此通行金鑰網域無法在目前網站使用。請前往原網站或選擇其他驗證方式。", "請核對受影響的通行金鑰並確認刪除網域。設定或影響範圍變更後,需要重新確認。"},
{"en", "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.", "This Passkey domain is not available on this website. Use its original website or another verification method.", "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."},
} {
t.Run(locale.language, func(t *testing.T) {
const (
invalid = "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard."
unavailable = "This Passkey domain is not available on this website. Use its original website or another verification method."
removal = "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."
)
for _, language := range []string{"zh-CN", "zh-TW", "en"} {
t.Run(language, func(t *testing.T) {
for _, request := range []struct {
path, body, code, message string
handler gin.HandlerFunc
}{
{"/api/option/", `{"key":"passkey.rp_id","value":"localhost:3000"}`, "PASSKEY_RP_ID_INVALID", locale.invalid, UpdateOption},
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":"localhost:3001"}`, "PASSKEY_RP_ID_INVALID", locale.invalid, UpdateOption},
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":""}`, "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED", locale.removal, UpdateOption},
{"/api/user/passkey/login/begin", `{"rp_id":"unconfigured.example.com"}`, "PASSKEY_RP_ID_UNAVAILABLE", locale.unavailable, PasskeyLoginBegin},
{"/api/option/", `{"key":"passkey.rp_id","value":"localhost:3000"}`, "PASSKEY_RP_ID_INVALID", invalid, UpdateOption},
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":"localhost:3001"}`, "PASSKEY_RP_ID_INVALID", invalid, UpdateOption},
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":""}`, "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED", removal, UpdateOption},
{"/api/user/passkey/login/begin", `{"rp_id":"unconfigured.example.com"}`, "PASSKEY_RP_ID_UNAVAILABLE", unavailable, PasskeyLoginBegin},
} {
response := httptest.NewRecorder()
c, _ := gin.CreateTestContext(response)
@@ -690,7 +695,7 @@ func TestPasskeyDomainErrorsRespectRequestLanguage(t *testing.T) {
method = http.MethodPut
}
c.Request = httptest.NewRequest(method, request.path, strings.NewReader(request.body))
c.Request.Header.Set("Accept-Language", locale.language)
c.Request.Header.Set("Accept-Language", language)
c.Request.Header.Set("Origin", "https://example.com")
c.Set("id", user.Id)
c.Set("role", common.RoleRootUser)
@@ -705,6 +710,7 @@ func TestPasskeyDomainErrorsRespectRequestLanguage(t *testing.T) {
assert.False(t, result.Success)
assert.Equal(t, request.code, result.Code)
assert.Equal(t, request.message, result.Message)
assert.Equal(t, request.message, result.MessageKey)
}
})
}
+4 -6
View File
@@ -1,13 +1,11 @@
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"
@@ -21,7 +19,7 @@ type PaymentComplianceRequest struct {
func requirePaymentCompliance(c *gin.Context) bool {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
common.ApiErrorT(c, "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them.")
return false
}
return true
@@ -38,11 +36,11 @@ func ConfirmPaymentCompliance(c *gin.Context) {
var req PaymentComplianceRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
if !req.Confirmed {
common.ApiErrorMsg(c, "请确认合规声明")
common.ApiErrorT(c, "Please confirm the compliance statement")
return
}
@@ -65,7 +63,7 @@ func ConfirmPaymentCompliance(c *gin.Context) {
}
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf(
logger.LogInfo(c.Request.Context(), common.LogText(
"payment compliance confirmed user_id=%d ip=%s terms_version=%s confirmed_at=%d",
userId,
clientIP,
@@ -1,10 +1,19 @@
package controller
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/Calcium-Ion/go-epay/epay"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -167,3 +176,110 @@ func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
operation_setting.PayMethods = nil
require.False(t, isEpayWebhookEnabled())
}
func TestEpayCallbacksVerifyBeforeSettlement(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalPayAddress := operation_setting.PayAddress
originalEpayID := operation_setting.EpayId
originalEpayKey := operation_setting.EpayKey
originalPayMethods := operation_setting.PayMethods
originalServerAddress := system_setting.ServerAddress
originalDB := model.DB
t.Cleanup(func() {
operation_setting.PayAddress = originalPayAddress
operation_setting.EpayId = originalEpayID
operation_setting.EpayKey = originalEpayKey
operation_setting.PayMethods = originalPayMethods
system_setting.ServerAddress = originalServerAddress
model.DB = originalDB
})
operation_setting.PayAddress = "https://pay.example.com"
operation_setting.EpayId = "epay_id"
operation_setting.EpayKey = "test-key"
operation_setting.PayMethods = []map[string]string{{"type": "alipay"}}
system_setting.ServerAddress = "https://dashboard.example.com"
// Rejected callbacks and valid non-success events must never reach settlement.
// A nil database makes an accidental attempt to settle fail the test.
model.DB = nil
require.True(t, isEpayWebhookEnabled())
// Both ambiguous maps below had this same valid signing string in v0.0.4.
// This must fail because of parameter ambiguity, not a mismatched signature.
ambiguousSign := epay.MD5String("money=1.00&name=item&out_trade_no=other&out_trade_no=order&pid=epay_id&trade_status=TRADE_SUCCESS&type=alipay", "test-key")
pendingSign := epay.MD5String("money=1.00&name=item%26other&out_trade_no=order&pid=epay_id&trade_status=WAIT_BUYER_PAY&type=alipay", "test-key")
cases := []struct {
name string
productName string
tradeNo string
status string
sign string
validPending bool
}{
{"separator in product name", "item&out_trade_no=other", "order", epay.StatusTradeSuccess, ambiguousSign, false},
{"separator in order number", "item", "other&out_trade_no=order", epay.StatusTradeSuccess, ambiguousSign, false},
{"invalid signature", "item", "order", epay.StatusTradeSuccess, "invalid", false},
{"missing signature", "item", "order", epay.StatusTradeSuccess, "", false},
{"valid pending with literal percent escape", "item%26other", "order", "WAIT_BUYER_PAY", pendingSign, true},
}
endpoints := []struct {
name string
path string
handler gin.HandlerFunc
status int
rejectedBody string
pendingBody string
rejectedLocation string
pendingLocation string
}{
{
name: "topup notify", path: "/api/user/epay/notify", handler: EpayNotify,
status: http.StatusOK, rejectedBody: "fail", pendingBody: "success",
},
{
name: "subscription notify", path: "/api/subscription/epay/notify", handler: SubscriptionEpayNotify,
status: http.StatusOK, rejectedBody: "fail", pendingBody: "fail",
},
{
name: "subscription return", path: "/api/subscription/epay/return", handler: SubscriptionEpayReturn,
status: http.StatusFound,
rejectedLocation: "https://dashboard.example.com/wallet?pay=fail",
pendingLocation: "https://dashboard.example.com/wallet?pay=pending",
},
}
router := gin.New()
for _, endpoint := range endpoints {
router.Any(endpoint.path, endpoint.handler)
}
for _, endpoint := range endpoints {
for _, method := range []string{http.MethodGet, http.MethodPost} {
for _, tc := range cases {
t.Run(endpoint.name+"/"+method+"/"+tc.name, func(t *testing.T) {
params := url.Values{
"pid": {"epay_id"}, "type": {"alipay"}, "money": {"1.00"},
"name": {tc.productName}, "out_trade_no": {tc.tradeNo},
"trade_status": {tc.status}, "sign_type": {"MD5"},
}
if tc.sign != "" {
params.Set("sign", tc.sign)
}
request := httptest.NewRequest(method, endpoint.path+"?"+params.Encode(), nil)
if method == http.MethodPost {
request = httptest.NewRequest(method, endpoint.path, strings.NewReader(params.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
recorder := httptest.NewRecorder()
require.NotPanics(t, func() { router.ServeHTTP(recorder, request) })
assert.Equal(t, endpoint.status, recorder.Code)
wantBody, wantLocation := endpoint.rejectedBody, endpoint.rejectedLocation
if tc.validPending {
wantBody, wantLocation = endpoint.pendingBody, endpoint.pendingLocation
}
if wantBody != "" {
assert.Equal(t, wantBody, recorder.Body.String())
}
assert.Equal(t, wantLocation, recorder.Header().Get("Location"))
})
}
}
}
}
+7 -1
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -19,8 +20,13 @@ func GetPerfMetricsSummary(c *gin.Context) {
}
}
recentModels := c.QueryArray("recent_model")
if len(recentModels) > perfmetrics.MaxRecentModels {
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Invalid parameters"))
return
}
activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto")
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups)
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups, recentModels...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
+7 -17
View File
@@ -1,7 +1,6 @@
package controller
import (
"fmt"
"net/http"
"os"
"path/filepath"
@@ -149,30 +148,21 @@ func ClearDiskCache(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "不活跃的磁盘缓存已清理",
})
common.ApiSuccessT(c, "Inactive disk cache cleared", nil)
}
// ResetPerformanceStats 重置性能统计
func ResetPerformanceStats(c *gin.Context) {
common.ResetDiskCacheStats()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "统计信息已重置",
})
common.ApiSuccessT(c, "Statistics reset", nil)
}
// ForceGC 强制执行 GC
func ForceGC(c *gin.Context) {
runtime.GC()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "GC 已执行",
})
common.ApiSuccessT(c, "GC executed", nil)
}
// LogFileInfo 日志文件信息
@@ -337,11 +327,11 @@ func CleanupLogFiles(c *gin.Context) {
}
if len(failedFiles) > 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("部分文件删除失败(%d/%d)", len(failedFiles), len(toDelete)),
"data": result,
msg := common.NewMessage("Some files could not be deleted ({{failed}}/{{total}})", map[string]any{
"failed": len(failedFiles),
"total": len(toDelete),
})
common.ApiErrorStatus(c, http.StatusOK, msg, gin.H{"data": result})
return
}
+4 -2
View File
@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -18,14 +20,14 @@ func Playground(c *gin.Context) {
defer func() {
if newAPIError != nil {
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
"error": newAPIError.ToOpenAIError(common.UpstreamAddresses(c)...),
})
}
}()
useAccessToken := c.GetBool("use_access_token")
if useAccessToken {
newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
newAPIError = types.NewError(errors.New(i18n.T(c, i18n.MsgRelayAccessTokenUnsupported)), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
return
}
+58 -39
View File
@@ -42,8 +42,19 @@ type pluginProtocolBridgeDeps struct {
admissionTimeout time.Duration
getByTaskId func(int, string) (*model.Task, bool, error)
resolvePlugin func(constant.TaskPlatform) (*pluginruntime.LoadedPlugin, *pluginruntime.RoutingGeneration, bool)
// imagePollInterval, pollTask and downloadImage serve the synchronous
// OpenAI Images protocol: an asynchronous upstream image task is polled
// inside the request, and b64_json responses download each image URL.
imagePollInterval time.Duration
pollTask func(context.Context, *model.Task) error
downloadImage func(url string) (mimeType string, base64Data string, err error)
}
// taskPluginImagePollInterval paces the in-request polling of asynchronous
// image tasks. Legacy DashScope text-to-image jobs finish within tens of
// seconds, so a short interval keeps the synchronous response prompt.
const taskPluginImagePollInterval = 3 * time.Second
func defaultPluginProtocolBridgeDeps() pluginProtocolBridgeDeps {
timeout := time.Duration(constant.TaskPluginProtocolTimeoutSeconds) * time.Second
if timeout <= 0 {
@@ -78,6 +89,9 @@ func defaultPluginProtocolBridgeDeps() pluginProtocolBridgeDeps {
admissionTimeout: pluginruntime.DefaultCallTimeout,
getByTaskId: model.GetByTaskId,
resolvePlugin: resolveTaskPluginForProtocolRetrieve,
imagePollInterval: taskPluginImagePollInterval,
pollTask: pollTaskPluginImageTask,
downloadImage: service.GetImageFromUrl,
}
}
@@ -128,6 +142,15 @@ func (d pluginProtocolBridgeDeps) withDefaults() pluginProtocolBridgeDeps {
if d.resolvePlugin == nil {
d.resolvePlugin = defaults.resolvePlugin
}
if d.imagePollInterval <= 0 {
d.imagePollInterval = defaults.imagePollInterval
}
if d.pollTask == nil {
d.pollTask = defaults.pollTask
}
if d.downloadImage == nil {
d.downloadImage = defaults.downloadImage
}
return d
}
@@ -189,7 +212,7 @@ func serveTaskPluginProtocol(
missing = true
}
if missing {
logger.LogError(c, "pinned task plugin does not support the requested protocol form")
logger.LogError(c, common.LogText("pinned task plugin does not support the requested protocol form"))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
@@ -270,7 +293,7 @@ func serveTaskPluginProtocol(
}
if relayInfoErr != nil {
err := relayInfoErr
logger.LogError(c, "build task protocol relay info failed: "+err.Error())
logger.LogError(c, common.LogText("build task protocol relay info failed: %s", err.Error()))
logger.LogDebug(c, "task_plugin subsystem=protocol event=bridge_failed generation=%d plugin=%q stage=relay_info reason=invalid_context", generation, pluginKey)
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
@@ -304,7 +327,7 @@ func serveTaskPluginProtocol(
if outcome == nil || outcome.Task == nil || outcome.RelayInfo == nil ||
outcome.Task.UserId != relayInfo.UserId ||
outcome.Task.Platform != constant.TaskPlatform(pinned.Plugin.Meta.Key) {
logger.LogError(c, "task protocol submission returned an invalid durable outcome")
logger.LogError(c, common.LogText("task protocol submission returned an invalid durable outcome"))
logger.LogDebug(c, "task_plugin subsystem=protocol event=submission_failed generation=%d plugin=%q reason=invalid_durable_outcome", generation, pluginKey)
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
@@ -342,7 +365,7 @@ func serveTaskPluginProtocol(
outcome.Task.PrivateData.ResponsesBackground = true
if outcome.Task.ID != 0 {
if err := model.DB.Model(outcome.Task).Update("private_data", outcome.Task.PrivateData).Error; err != nil {
logger.LogError(c, "persist task background flag failed: "+err.Error())
logger.LogError(c, common.LogText("persist task background flag failed: %s", err.Error()))
}
}
machine.SetBackground(true)
@@ -424,7 +447,7 @@ func streamTaskPluginProtocol(
if errors.Is(loadContextErr, context.DeadlineExceeded) &&
observationContext.Err() == nil &&
c.Request.Context().Err() == nil {
logger.LogWarn(c, fmt.Sprintf(
logger.LogWarn(c, common.LogText(
"task protocol database observation overloaded; plugin=%s task=%s",
pinned.Plugin.Meta.Key,
taskID,
@@ -459,7 +482,7 @@ func streamTaskPluginProtocol(
return
}
if loadErr != nil && !errors.Is(loadErr, context.Canceled) {
logger.LogError(c, "task protocol database observation failed")
logger.LogError(c, common.LogText("task protocol database observation failed"))
}
if c.Request.Context().Err() == nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=stream stage=load reason=task_unavailable", generation, pluginKey)
@@ -485,32 +508,26 @@ func streamTaskPluginProtocol(
}
view, viewErr := service.BuildTaskPluginView(task)
if viewErr != nil {
logger.LogError(c, "build task protocol view failed: "+viewErr.Error())
logger.LogError(c, common.LogText("build task protocol view failed: %s", viewErr.Error()))
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
viewValue, viewErr := taskPluginProtocolJSONValue(view)
if viewErr != nil {
logger.LogError(c, "encode task protocol view failed: "+viewErr.Error())
logger.LogError(c, common.LogText("encode task protocol view failed: %s", viewErr.Error()))
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
hookStarted := deps.now()
rendererContext, contextErr := taskPluginProtocolRendererContext(protocolRequest, pinned, task, deps.artifactContentURL)
if contextErr != nil {
logger.LogError(c, "build task protocol renderer context failed")
logger.LogError(c, common.LogText("build task protocol renderer context failed"))
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
args := []any{rendererContext, viewValue}
if previous.Present {
previousValue, stateErr := previous.PluginValue()
if stateErr != nil {
logger.LogError(c, "decode task protocol state failed: "+stateErr.Error())
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
args = append(args, previousValue)
args = append(args, previous.PluginValue())
}
value, callErr := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(observationContext, deps.admissionTimeout, "protocols", []string{pinned.Protocol, "renderEvents"}, args...)
hookElapsed := deps.now().Sub(hookStarted)
@@ -527,13 +544,13 @@ func streamTaskPluginProtocol(
}
if errors.Is(callErr, pluginruntime.ErrCallAdmissionTimeout) {
overloaded = true
logger.LogWarn(c, fmt.Sprintf(
logger.LogWarn(c, common.LogText(
"task protocol render hook overloaded; plugin=%s task=%s",
pinned.Plugin.Meta.Key,
taskID,
))
} else {
logger.LogError(c, "task protocol render hook failed")
logger.LogError(c, common.LogText("task protocol render hook failed"))
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=stream stage=render_events reason=hook_failed elapsed_ms=%d",
@@ -548,13 +565,13 @@ func streamTaskPluginProtocol(
if !overloaded {
result, decodeErr := relay.DecodePluginProtocolEventResult(value, deps.protocolLimits)
if decodeErr != nil {
logger.LogError(c, "task protocol render result invalid: "+decodeErr.Error())
logger.LogError(c, common.LogText("task protocol render result invalid: %s", decodeErr.Error()))
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
events, applyErr := machine.ApplyTick(result, lastStatus)
if applyErr != nil {
logger.LogError(c, "task protocol state transition failed: "+applyErr.Error())
logger.LogError(c, common.LogText("task protocol state transition failed: %s", applyErr.Error()))
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
@@ -590,7 +607,7 @@ func streamTaskPluginProtocol(
delay += deps.tickInterval
} else if hookElapsed > deps.tickInterval {
delay += deps.tickInterval
logger.LogWarn(c, fmt.Sprintf(
logger.LogWarn(c, common.LogText(
"task protocol render hook slow; plugin=%s task=%s elapsed_ms=%d",
pinned.Plugin.Meta.Key,
taskID,
@@ -675,7 +692,7 @@ func waitTaskPluginProtocol(
observationContext.Err() == nil &&
c.Request.Context().Err() == nil
if loadOverloaded {
logger.LogWarn(c, fmt.Sprintf(
logger.LogWarn(c, common.LogText(
"task protocol database observation overloaded; plugin=%s task=%s",
pinned.Plugin.Meta.Key,
taskID,
@@ -695,7 +712,7 @@ func waitTaskPluginProtocol(
return
}
if err != nil && !errors.Is(err, context.Canceled) {
logger.LogError(c, "task protocol database observation failed")
logger.LogError(c, common.LogText("task protocol database observation failed"))
}
if c.Request.Context().Err() == nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=nonstream stage=load reason=task_unavailable", generation, pluginKey)
@@ -756,13 +773,13 @@ func waitTaskPluginProtocol(
}
if errors.Is(callErr, pluginruntime.ErrCallAdmissionTimeout) {
overloaded = true
logger.LogWarn(c, fmt.Sprintf(
logger.LogWarn(c, common.LogText(
"task protocol final hook overloaded; plugin=%s task=%s",
pinned.Plugin.Meta.Key,
taskID,
))
} else {
logger.LogError(c, "task protocol final hook failed")
logger.LogError(c, common.LogText("task protocol final hook failed"))
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=nonstream stage=render_final reason=hook_failed elapsed_ms=%d",
@@ -939,7 +956,7 @@ func retrieveTaskPluginResponse(c *gin.Context, deps pluginProtocolBridgeDeps) {
task, exists, err := deps.getByTaskId(userID, taskID)
if err != nil {
logger.LogError(c, "task protocol retrieve lookup failed")
logger.LogError(c, common.LogText("task protocol retrieve lookup failed"))
logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_failed reason=lookup_error public_task_id=%q", taskID)
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
@@ -948,6 +965,10 @@ func retrieveTaskPluginResponse(c *gin.Context, deps pluginProtocolBridgeDeps) {
writeTaskPluginResponseNotFound(c, responseID, "missing")
return
}
if !task.ResultRetrievable() {
writeTaskPluginResponseNotFound(c, responseID, "result_discarded")
return
}
plugin, generation, ok := deps.resolvePlugin(task.Platform)
if !ok || plugin == nil {
@@ -1039,7 +1060,7 @@ func retrieveTaskPluginResponse(c *gin.Context, deps pluginProtocolBridgeDeps) {
)
}
if renderErr != nil {
logger.LogError(c, "task protocol retrieve render failed")
logger.LogError(c, common.LogText("task protocol retrieve render failed"))
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=retrieve_failed generation=%d plugin=%q public_task_id=%q stage=render_final elapsed_ms=%d",
@@ -1082,7 +1103,7 @@ func writeTaskPluginProtocolFailure(
) {
failed, err := machine.FailureEvent(taskStatus)
if err != nil {
logger.LogError(c, "task protocol failure event failed: "+err.Error())
logger.LogError(c, common.LogText("task protocol failure event failed: %s", err.Error()))
return
}
_ = writeTaskPluginProtocolEvent(c, failed)
@@ -1095,7 +1116,7 @@ func writeTaskPluginProtocolTimeout(
) {
incomplete, err := machine.TimeoutEvent(taskStatus)
if err != nil {
logger.LogError(c, "task protocol timeout event failed: "+err.Error())
logger.LogError(c, common.LogText("task protocol timeout event failed: %s", err.Error()))
return
}
_ = writeTaskPluginProtocolEvent(c, incomplete)
@@ -1109,7 +1130,7 @@ func writeTaskPluginProtocolFailureResponse(
if taskStatus == string(model.TaskStatusFailure) {
response, err := machine.FinalResponse(nil, taskStatus)
if err != nil {
logger.LogError(c, "task protocol terminal failure response failed: "+err.Error())
logger.LogError(c, common.LogText("task protocol terminal failure response failed: %s", err.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
@@ -1118,7 +1139,7 @@ func writeTaskPluginProtocolFailureResponse(
}
response, err := machine.FailureResponse(taskStatus)
if err != nil {
logger.LogError(c, "task protocol failure response failed: "+err.Error())
logger.LogError(c, common.LogText("task protocol failure response failed: %s", err.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
@@ -1132,23 +1153,21 @@ func writeTaskPluginProtocolTimeoutResponse(
) {
response, err := machine.TimeoutResponse(lastStatus)
if err != nil {
logger.LogError(c, "task protocol timeout response failed: "+err.Error())
logger.LogError(c, common.LogText("task protocol timeout response failed: %s", err.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
c.JSON(http.StatusOK, response)
}
func taskPluginProtocolJSONValue(value any) (any, error) {
encoded, err := common.Marshal(value)
// taskPluginProtocolJSONValue encodes a task view for a hook, which receives
// it parsed from the text.
func taskPluginProtocolJSONValue(view dto.TaskView) (any, error) {
encoded, err := common.Marshal(view)
if err != nil {
return nil, err
}
var decoded any
if err = common.Unmarshal(encoded, &decoded); err != nil {
return nil, err
}
return decoded, nil
return pluginruntime.RawJSON(encoded), nil
}
func taskPluginProtocolRendererContext(
+260
View File
@@ -0,0 +1,260 @@
package controller
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"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/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
// serveTaskPluginImageProtocol answers an OpenAI Images API request that a
// task plugin claimed. The API is synchronous: the durable submission runs
// first on a context that survives a client disconnect, an asynchronous
// upstream task is then polled inside the request until it is terminal or the
// protocol timeout elapses, and the plugin's render hook finally shapes the
// OpenAI image response from the terminal task.
func serveTaskPluginImageProtocol(c *gin.Context, pinned pluginruntime.PinnedEndpoint, deps pluginProtocolBridgeDeps) {
deps = deps.withDefaults()
requestValue, exists := c.Get(pluginruntime.ContextKeyProtocolRequest)
protocolRequest, ok := requestValue.(pluginruntime.ProtocolRequestContext)
if !exists || !ok || protocolRequest.Protocol != pinned.Protocol || pinned.Plugin == nil {
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
clientRequest := c.Request
var relayInfo *relaycommon.RelayInfo
var relayInfoErr error
var outcome *taskSubmissionOutcome
var taskErr *dto.TaskError
// DashScope bills the images as soon as it accepts the upstream call. A
// client that gives up while the submission is in flight must not cancel
// that call, the task row, its settlement, or its consume log, so the
// submission runs on its own bounded context exactly like the Responses
// bridge. Only the in-request wait and the response write observe the
// client connection.
func() {
submissionContext, cancelSubmission := context.WithTimeout(
context.WithoutCancel(clientRequest.Context()),
deps.submissionTimeout,
)
c.Request = clientRequest.Clone(submissionContext)
defer func() {
c.Request = clientRequest
cancelSubmission()
}()
relayInfo, relayInfoErr = relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if relayInfoErr != nil {
return
}
relayInfo.IsStream = false
relayInfo.OriginModelName = c.GetString("resolved_task_model")
if action := c.GetString("task_action"); action != "" {
relayInfo.Action = action
}
if taskErr = relay.ResolveOriginTask(c, relayInfo); taskErr != nil {
return
}
if taskErr = relay.ApplyOriginTaskAffinity(c, relayInfo); taskErr != nil {
return
}
outcome, taskErr = deps.submit(c, relayInfo)
}()
if clientRequest.Context().Err() != nil {
// The client is gone. A durable task keeps its settlement and consume
// log; an asynchronous one is finished by the background poller.
return
}
if relayInfoErr != nil {
logger.LogError(c, common.LogText("build task protocol relay info failed: %s", relayInfoErr.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
if taskErr != nil {
respondTaskPluginImageError(c, taskErr)
return
}
if outcome == nil || outcome.Task == nil || outcome.Task.Platform != constant.TaskPlatform(pinned.Plugin.Meta.Key) {
logger.LogError(c, common.LogText("task protocol submission returned an invalid durable outcome"))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
task := outcome.Task
if task.Status != model.TaskStatusSuccess && task.Status != model.TaskStatusFailure {
if taskErr = waitTaskPluginImageTask(c, task, deps); taskErr != nil {
if c.Request.Context().Err() == nil {
respondTaskPluginImageError(c, taskErr)
}
return
}
}
if task.Status == model.TaskStatusFailure {
reason := strings.TrimSpace(task.FailReason)
if reason == "" {
reason = "image generation failed"
}
// The fail reason comes from the upstream, so mask the channel address in it.
taskErr = service.TaskErrorWrapper(errors.New(reason), "image_generation_failed", http.StatusBadRequest, common.UpstreamAddresses(c)...)
taskErr.LocalError = true
respondTaskPluginImageError(c, taskErr)
return
}
view, err := service.BuildTaskPluginView(task)
if err != nil {
logger.LogError(c, common.LogText("build task protocol view failed: %s", err.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
viewValue, err := taskPluginProtocolJSONValue(view)
if err != nil {
logger.LogError(c, common.LogText("encode task protocol view failed: %s", err.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
payload, err := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(c.Request.Context(), deps.admissionTimeout, "protocols", []string{pinned.Protocol, "render"}, protocolRequest.JSValue(), viewValue)
if err != nil {
logger.LogError(c, common.LogText("task protocol image render hook failed: %s", err.Error()))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
response, ok := payload.(map[string]any)
data, hasData := response["data"].([]any)
if !ok || !hasData {
logger.LogError(c, common.LogText("task protocol image render hook must return an object with a data array"))
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
if _, present := response["created"]; !present {
createdAt := task.CreatedAt
if createdAt == 0 {
createdAt = task.SubmitTime
}
response["created"] = createdAt
}
// response_format is host-owned: the plugin renders upstream URLs and the
// host inlines each image when the client asked for Base64.
if taskPluginImageResponseFormat(protocolRequest) == "b64_json" {
for _, entry := range data {
item, isObject := entry.(map[string]any)
if !isObject {
continue
}
url, _ := item["url"].(string)
if existing, _ := item["b64_json"].(string); strings.TrimSpace(url) == "" || existing != "" {
continue
}
_, encoded, downloadErr := deps.downloadImage(url)
if downloadErr != nil {
logger.LogWarn(c, common.LogText("task protocol image download failed; returning the upstream URL instead of b64_json: %s", common.MaskSensitiveInfo(downloadErr.Error(), common.UpstreamAddresses(c)...)))
continue
}
item["b64_json"] = encoded
}
}
c.JSON(http.StatusOK, response)
}
// waitTaskPluginImageTask polls an asynchronous image task inside the client
// request through the ordinary task polling path, so completion settles
// billing exactly as the background poller would, until the task is terminal
// or the protocol timeout elapses. A client disconnect stops waiting; the
// background poller then finishes the durable task.
func waitTaskPluginImageTask(c *gin.Context, task *model.Task, deps pluginProtocolBridgeDeps) *dto.TaskError {
ctx, cancel := context.WithTimeout(c.Request.Context(), deps.submissionTimeout)
defer cancel()
for {
timer := time.NewTimer(deps.imagePollInterval)
select {
case <-ctx.Done():
timer.Stop()
if errors.Is(ctx.Err(), context.DeadlineExceeded) && c.Request.Context().Err() == nil {
return service.TaskErrorWrapperLocal(fmt.Errorf("image generation is still running; task %s settles when it completes", task.TaskID), "task_timeout", http.StatusGatewayTimeout)
}
return service.TaskErrorWrapperLocal(ctx.Err(), "request_cancelled", http.StatusRequestTimeout)
case <-timer.C:
}
if err := deps.pollTask(ctx, task); err != nil {
logger.LogWarn(c, common.LogText("task protocol image poll failed for task %s: %s", task.TaskID, common.MaskSensitiveInfo(err.Error(), common.UpstreamAddresses(c)...)))
continue
}
if task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure {
return nil
}
}
}
// pollTaskPluginImageTask runs one poll round for a single task through the
// same code path as the scheduled task poller, including status CAS and
// completion settlement.
func pollTaskPluginImageTask(ctx context.Context, task *model.Task) error {
upstreamID := task.GetUpstreamTaskID()
if upstreamID == "" {
return errors.New("task has no upstream id")
}
return service.UpdateVideoTasks(ctx, task.Platform, map[int][]string{task.ChannelId: {upstreamID}}, map[string]*model.Task{upstreamID: task})
}
func taskPluginImageResponseFormat(request pluginruntime.ProtocolRequestContext) string {
body, ok := request.Body.(map[string]any)
if !ok {
return ""
}
switch body["kind"] {
case string(pluginruntime.BodyJSON):
value, _ := body["value"].(map[string]any)
format, _ := value["response_format"].(string)
return strings.TrimSpace(format)
case string(pluginruntime.BodyMultipart), string(pluginruntime.BodyForm):
fields, _ := body["fields"].(map[string][]string)
if values := fields["response_format"]; len(values) > 0 {
return strings.TrimSpace(values[0])
}
}
return ""
}
// respondTaskPluginImageError writes an OpenAI error envelope for the Images
// API. DashScope failure bodies arrive as JSON {code, message}; their message
// is surfaced so a client sees the vendor reason instead of raw JSON.
func respondTaskPluginImageError(c *gin.Context, taskErr *dto.TaskError) {
service.RecordRequestPolicyTermination(c, taskSubmissionAPIError(taskErr))
status := http.StatusInternalServerError
if taskErr.StatusCode >= 400 && taskErr.StatusCode <= 599 {
status = taskErr.StatusCode
}
code := taskErr.Code
message := taskErr.Message
var upstream struct {
Code string `json:"code"`
Message string `json:"message"`
}
if common.UnmarshalJsonStr(message, &upstream) == nil && strings.TrimSpace(upstream.Message) != "" {
message = upstream.Message
if strings.TrimSpace(upstream.Code) != "" {
code = upstream.Code
}
}
errorType := "new_api_error"
if status < http.StatusInternalServerError {
errorType = "invalid_request_error"
}
c.JSON(status, gin.H{"error": gin.H{
"message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)),
"type": errorType,
"code": code,
}})
}
+288
View File
@@ -3,6 +3,7 @@ package controller
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -1122,6 +1123,29 @@ func TestRetrieveTaskPluginResponseEchoesOriginModelName(t *testing.T) {
assert.Equal(t, "alias-model", response["model"])
}
func TestRetrieveTaskPluginResponseHidesDiscardedResult(t *testing.T) {
pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-discarded", `
export const protocols = {openai_responses: {
renderEvents: function() { throw new Error("discarded retrieve called renderEvents"); },
renderFinal: function() { throw new Error("discarded retrieve called renderFinal"); }
}};
`, pluginruntime.Options{})
c, recorder := newPluginProtocolRetrieveContext("resp_retrieve_discarded")
deps := pluginProtocolRetrieveDeps(pinned, &model.Task{
TaskID: "task_retrieve_discarded",
Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
UserId: 71,
Status: model.TaskStatusSuccess,
PrivateData: model.TaskPrivateData{ResultDiscarded: true},
CreatedAt: 1_710_000_000,
}, true, nil)
retrieveTaskPluginResponse(c, deps)
assert.Equal(t, http.StatusNotFound, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"not_found"`)
}
func TestRetrieveTaskPluginResponseSuccessRendersFinal(t *testing.T) {
logs := make([]string, 0, 1)
pinned := compilePluginProtocolRetrieveEndpoint(t, "retrieve-success", `
@@ -1579,3 +1603,267 @@ func pluginProtocolRetrieveDeps(pinned pluginruntime.PinnedEndpoint, task *model
}
return deps
}
const imageProtocolTestPlugin = `
export const meta = {apiVersion:1,key:"image-bridge",name:"Image Bridge",version:"1.0.0",author:{name:"Test"},models:["image-model"],fetchMode:"per_task",protocols:["openai_image"]};
export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/images"};}
export function parseSubmitResponse(ctx,resp){return {taskId:"vendor"};}
export function buildQueryRequest(){return {url:"https://provider.example"};}
export function parseTaskResult(){return {status:"SUCCESS"};}
export const protocols = {openai_image: {
decodeRequest: function(ctx) { return {kind:"submit", model: ctx.model, requestBody: ctx.body.value}; },
render: function(ctx, task) {
const data = (task.data && task.data.images || []).map(function(url) { return {url: url}; });
return {data: data, vendor: "test"};
},
}};
`
func imageProtocolTestEndpoint(t *testing.T) pluginruntime.PinnedEndpoint {
t.Helper()
engine, err := pluginruntime.Compile(imageProtocolTestPlugin, pluginruntime.Options{Key: "image-bridge", Version: "1.0.0", Concurrency: 1})
require.NoError(t, err)
return pluginruntime.PinnedEndpoint{
Generation: &pluginruntime.RoutingGeneration{Number: 7},
Plugin: &pluginruntime.LoadedPlugin{Meta: pluginruntime.Meta{Key: "image-bridge", Version: "1.0.0", Protocols: []pluginruntime.ProtocolClaim{{Name: pluginruntime.ProtocolOpenAIImage}}}, Engine: engine},
Protocol: pluginruntime.ProtocolOpenAIImage,
Operation: pluginruntime.HostProtocolOperation{Name: "generate", Methods: []string{http.MethodPost}, Path: "/v1/images/generations", BodyKinds: []pluginruntime.BodyKind{pluginruntime.BodyJSON}, ModelField: "model"},
Model: "image-model",
}
}
func newImageProtocolTestContext(responseFormat string) (*gin.Context, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{}`))
common.SetContextKey(c, constant.ContextKeyUserId, 71)
common.SetContextKey(c, constant.ContextKeyTokenId, 81)
common.SetContextKey(c, constant.ContextKeyUsingGroup, "default")
c.Set("resolved_task_model", "image-model")
value := map[string]any{"model": "image-model", "prompt": "a cat", "n": 2}
if responseFormat != "" {
value["response_format"] = responseFormat
}
c.Set(pluginruntime.ContextKeyProtocolRequest, pluginruntime.ProtocolRequestContext{
RouteRequestContext: pluginruntime.RouteRequestContext{Path: "/v1/images/generations", Method: http.MethodPost, Params: map[string]string{}, Query: map[string][]string{}, Body: map[string]any{"kind": "json", "value": value}, RequestBody: value},
Protocol: pluginruntime.ProtocolOpenAIImage,
Operation: "generate",
Model: "image-model",
})
return c, recorder
}
func imageProtocolTestOutcome(info *relaycommon.RelayInfo, status model.TaskStatus, images ...string) *taskSubmissionOutcome {
task := &model.Task{TaskID: "task_image", Platform: constant.TaskPlatform("image-bridge"), UserId: info.UserId, ChannelId: 3, Status: status, CreatedAt: 1_710_000_000}
task.PrivateData.UpstreamTaskID = "vendor"
if len(images) > 0 {
task.SetData(map[string]any{"images": images})
}
return &taskSubmissionOutcome{Result: &relay.TaskSubmitResult{}, Task: task, RelayInfo: info}
}
// The OpenAI Images protocol answers synchronously: an immediate result is
// rendered by the plugin, `created` is host-filled, and response_format
// b64_json inlines each URL while keeping the URL.
func TestServeTaskPluginImageProtocolRendersImmediateResultAndInlinesBase64(t *testing.T) {
for _, tc := range []struct {
name, responseFormat string
downloadErr error
wantB64 []any
}{
{"url", "", nil, []any{nil, nil}},
{"b64_json", "b64_json", nil, []any{"QUFB", "QUFB"}},
{"b64_json download failure keeps the URL", "b64_json", errors.New("blocked"), []any{nil, nil}},
} {
t.Run(tc.name, func(t *testing.T) {
pinned := imageProtocolTestEndpoint(t)
c, recorder := newImageProtocolTestContext(tc.responseFormat)
deps := pluginProtocolTestDeps()
downloads := 0
deps.downloadImage = func(url string) (string, string, error) {
downloads++
if tc.downloadErr != nil {
return "", "", tc.downloadErr
}
return "image/png", "QUFB", nil
}
deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
return imageProtocolTestOutcome(info, model.TaskStatusSuccess, "https://cdn.example/1.png", "https://cdn.example/2.png"), nil
}
serveTaskPluginImageProtocol(c, pinned, deps)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
var response map[string]any
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, float64(1_710_000_000), response["created"])
assert.Equal(t, "test", response["vendor"], "plugin fields are preserved")
data, ok := response["data"].([]any)
require.True(t, ok)
require.Len(t, data, 2)
for index, entry := range data {
item := entry.(map[string]any)
assert.Equal(t, fmt.Sprintf("https://cdn.example/%d.png", index+1), item["url"])
assert.Equal(t, tc.wantB64[index], item["b64_json"])
}
if tc.responseFormat == "b64_json" {
assert.Equal(t, 2, downloads)
} else {
assert.Zero(t, downloads)
}
})
}
}
// An asynchronous vendor task is polled inside the request until it is
// terminal; a failure and the protocol timeout become OpenAI error envelopes
// while the durable task stays with the background poller.
func TestServeTaskPluginImageProtocolWaitsForAsynchronousTask(t *testing.T) {
for _, tc := range []struct {
name string
terminal model.TaskStatus
wantStatus int
wantCode string
wantMessage string
}{
{"success", model.TaskStatusSuccess, http.StatusOK, "", ""},
{"failure", model.TaskStatusFailure, http.StatusBadRequest, "image_generation_failed", "DataInspectionFailed"},
{"timeout", "", http.StatusGatewayTimeout, "task_timeout", "still running"},
} {
t.Run(tc.name, func(t *testing.T) {
pinned := imageProtocolTestEndpoint(t)
c, recorder := newImageProtocolTestContext("")
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, "https://private.example.com")
deps := pluginProtocolTestDeps()
deps.imagePollInterval = time.Millisecond
deps.submissionTimeout = 50 * time.Millisecond
polls := 0
deps.pollTask = func(_ context.Context, task *model.Task) error {
polls++
if polls == 1 {
return errors.New("transient poll failure")
}
if polls < 3 || tc.terminal == "" {
return nil
}
task.Status = tc.terminal
task.FailReason = "DataInspectionFailed: see https://private.example.com/jobs/1"
task.SetData(map[string]any{"images": []string{"https://cdn.example/polled.png"}})
return nil
}
deps.submit = func(_ *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
return imageProtocolTestOutcome(info, model.TaskStatusSubmitted), nil
}
serveTaskPluginImageProtocol(c, pinned, deps)
require.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String())
var response map[string]any
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
if tc.wantStatus == http.StatusOK {
assert.GreaterOrEqual(t, polls, 3)
assert.Equal(t, []any{map[string]any{"url": "https://cdn.example/polled.png"}}, response["data"])
return
}
errorBody := response["error"].(map[string]any)
assert.Equal(t, tc.wantCode, errorBody["code"])
assert.Contains(t, errorBody["message"], tc.wantMessage)
assert.NotContains(t, errorBody["message"], "private.example.com")
})
}
}
// Submission failures use the OpenAI error envelope and surface a DashScope
// {code, message} failure body as a readable vendor message.
func TestServeTaskPluginImageProtocolSubmissionErrorUsesOpenAIEnvelope(t *testing.T) {
pinned := imageProtocolTestEndpoint(t)
c, recorder := newImageProtocolTestContext("")
deps := pluginProtocolTestDeps()
deps.submit = func(*gin.Context, *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
return nil, service.TaskErrorWrapper(errors.New(`{"code":"InvalidParameter","message":"n must be 1","request_id":"r"}`), "fail_to_fetch_task", http.StatusBadRequest)
}
serveTaskPluginImageProtocol(c, pinned, deps)
require.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]any
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
errorBody := response["error"].(map[string]any)
assert.Equal(t, "InvalidParameter", errorBody["code"])
assert.Equal(t, "invalid_request_error", errorBody["type"])
assert.Contains(t, errorBody["message"], "n must be 1")
assert.NotContains(t, errorBody["message"], "request_id")
}
// A synchronous image submission is billable the moment DashScope accepts it.
// A client that disconnects while the upstream call is in flight must not
// cancel that call or refund a durable result: the task row, its settlement
// and its consume log stay, and nothing is written to the closed connection.
func TestServeTaskPluginImageProtocolDisconnectDuringSubmissionKeepsDurableSettlement(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 })
pinned := imageProtocolTestEndpoint(t)
c, recorder := newImageProtocolTestContext("")
c.Set(pluginruntime.ContextKeyPinnedEndpoint, pinned)
requestContext, cancel := context.WithCancel(c.Request.Context())
c.Request = c.Request.WithContext(requestContext)
billing := &taskSubmissionTestBilling{events: &events}
submitStarted := make(chan struct{})
releaseSubmit := make(chan struct{})
done := make(chan struct{})
polls := 0
deps := pluginProtocolTestDeps()
deps.pollTask = func(context.Context, *model.Task) error {
polls++
return nil
}
deps.submit = func(c *gin.Context, info *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError) {
info.Billing = billing
info.TaskRelayInfo.PublicTaskID = "task_image_disconnect"
info.TaskRelayInfo.LockedChannel = &model.Channel{Id: 1, Type: constant.ChannelTypeTaskPlugin, Name: "image-bridge"}
info.ChannelMeta = &relaycommon.ChannelMeta{ChannelId: 1, ChannelType: constant.ChannelTypeTaskPlugin}
return executeTaskSubmissionWith(c, info, func(c *gin.Context, _ *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
close(submitStarted)
<-releaseSubmit
// The upstream call runs on the request context: a context that
// followed the client would abort here and refund.
if requestErr := c.Request.Context().Err(); requestErr != nil {
return nil, service.TaskErrorWrapperLocal(requestErr, "request_cancelled", http.StatusRequestTimeout)
}
return &relay.TaskSubmitResult{
UpstreamTaskID: "task_image_disconnect",
Platform: constant.TaskPlatform(pinned.Plugin.Meta.Key),
Quota: 7,
TaskData: []byte(`{"images":["https://cdn.example/1.png"]}`),
Immediate: &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, Progress: "100%"},
}, nil
})
}
go func() {
defer close(done)
serveTaskPluginImageProtocol(c, pinned, deps)
}()
select {
case <-submitStarted:
case <-time.After(2 * time.Second):
require.FailNow(t, "submission did not start")
}
cancel()
close(releaseSubmit)
select {
case <-done:
case <-time.After(2 * time.Second):
require.FailNow(t, "detached submission did not finish")
}
assert.Equal(t, []string{"reserve", "insert", "settle"}, events)
assert.Zero(t, billing.refunds)
var persisted model.Task
require.NoError(t, database.Where("task_id = ?", "task_image_disconnect").First(&persisted).Error)
assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), persisted.Status)
assert.Equal(t, 7, persisted.Quota)
assert.True(t, persisted.PrivateData.ResultDiscarded, "the inline image result is not retained after the barrier")
assert.Zero(t, polls, "an immediate result is never polled")
assert.False(t, c.Writer.Written())
assert.Empty(t, recorder.Body.String())
}
+4 -4
View File
@@ -28,7 +28,7 @@ func CreatePrefillGroup(c *gin.Context) {
return
}
if g.Name == "" || g.Type == "" {
common.ApiErrorMsg(c, "组名称和类型不能为空")
common.ApiErrorT(c, "Group name and type cannot be empty")
return
}
// 创建前检查名称
@@ -36,7 +36,7 @@ func CreatePrefillGroup(c *gin.Context) {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
common.ApiErrorT(c, "Group name already exists")
return
}
@@ -55,7 +55,7 @@ func UpdatePrefillGroup(c *gin.Context) {
return
}
if g.Id == 0 {
common.ApiErrorMsg(c, "缺少组 ID")
common.ApiErrorT(c, "Missing group ID")
return
}
// 名称冲突检查
@@ -63,7 +63,7 @@ func UpdatePrefillGroup(c *gin.Context) {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
common.ApiErrorT(c, "Group name already exists")
return
}
+3 -12
View File
@@ -114,22 +114,13 @@ 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(),
})
common.ApiError(c, err)
return
}
err = ratio_setting.UpdateModelRatioByJSONString(defaultStr)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(200, gin.H{
"success": true,
"message": "重置模型倍率成功",
})
common.ApiSuccessT(c, "Model ratios reset", nil)
}
+2 -4
View File
@@ -3,6 +3,7 @@ package controller
import (
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -11,10 +12,7 @@ import (
func GetRatioConfig(c *gin.Context) {
if !ratio_setting.IsExposeRatioEnabled() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "倍率配置接口未启用",
})
common.ApiErrorStatus(c, http.StatusForbidden, common.NewMessage("The ratio config API is not enabled"))
return
}
+16 -16
View File
@@ -211,8 +211,8 @@ func modelPricingSyncValues(data map[string]any, name string) map[string]any {
func FetchUpstreamRatios(c *gin.Context) {
var req dto.UpstreamRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.SysError("failed to bind upstream request: " + err.Error())
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请求参数格式错误"})
common.SysError(common.LogText("failed to bind upstream request: %s", err.Error()))
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Invalid request parameter format"))
return
}
@@ -239,8 +239,8 @@ func FetchUpstreamRatios(c *gin.Context) {
}
dbChannels, err := model.GetChannelsByIds(intIds)
if err != nil {
logger.LogError(c.Request.Context(), "failed to query channels: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"})
logger.LogError(c.Request.Context(), common.LogText("failed to query channels: %s", err.Error()))
common.ApiErrorStatus(c, http.StatusInternalServerError, common.NewMessage("Failed to query channels"))
return
}
for _, ch := range dbChannels {
@@ -256,7 +256,7 @@ func FetchUpstreamRatios(c *gin.Context) {
}
if len(upstreams) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
common.ApiErrorT(c, "No valid upstream channel")
return
}
@@ -268,7 +268,7 @@ func FetchUpstreamRatios(c *gin.Context) {
dialer := &net.Dialer{Timeout: 10 * time.Second}
transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second}
if common.TLSInsecureSkipVerify {
transport.TLSClientConfig = common.InsecureTLSConfig
transport.TLSClientConfig = common.InsecureTLSConfig.Clone()
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
@@ -322,7 +322,7 @@ func FetchUpstreamRatios(c *gin.Context) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
logger.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
logger.LogWarn(c.Request.Context(), common.LogText("build request failed: %s", err.Error()))
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
@@ -360,25 +360,25 @@ func FetchUpstreamRatios(c *gin.Context) {
time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
}
if lastErr != nil {
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+lastErr.Error())
logger.LogWarn(c.Request.Context(), common.LogText("http error on %s: %s", chItem.Name, lastErr.Error()))
ch <- upstreamResult{Name: uniqueName, Err: lastErr.Error()}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
logger.LogWarn(c.Request.Context(), common.LogText("non-200 from %s: %s", chItem.Name, resp.Status))
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
return
}
// Content-Type 和响应体大小校验
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "application/json") {
logger.LogWarn(c.Request.Context(), "unexpected content-type from "+chItem.Name+": "+ct)
logger.LogWarn(c.Request.Context(), common.LogText("unexpected content-type from %s: %s", chItem.Name, ct))
}
limited := io.LimitReader(resp.Body, maxRatioConfigBytes)
bodyBytes, err := io.ReadAll(limited)
if err != nil {
logger.LogWarn(c.Request.Context(), "read response failed from "+chItem.Name+": "+err.Error())
logger.LogWarn(c.Request.Context(), common.LogText("read response failed from %s: %s", chItem.Name, err.Error()))
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
@@ -387,7 +387,7 @@ func FetchUpstreamRatios(c *gin.Context) {
if isOpenRouter {
converted, err := convertOpenRouterToRatioData(bytes.NewReader(bodyBytes))
if err != nil {
logger.LogWarn(c.Request.Context(), "OpenRouter parse failed from "+chItem.Name+": "+err.Error())
logger.LogWarn(c.Request.Context(), common.LogText("OpenRouter parse failed from %s: %s", chItem.Name, err.Error()))
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
@@ -399,7 +399,7 @@ func FetchUpstreamRatios(c *gin.Context) {
if isModelsDev {
converted, err := convertModelsDevToRatioData(bytes.NewReader(bodyBytes))
if err != nil {
logger.LogWarn(c.Request.Context(), "models.dev parse failed from "+chItem.Name+": "+err.Error())
logger.LogWarn(c.Request.Context(), common.LogText("models.dev parse failed from %s: %s", chItem.Name, err.Error()))
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
@@ -417,7 +417,7 @@ func FetchUpstreamRatios(c *gin.Context) {
}
if err := common.DecodeJson(bytes.NewReader(bodyBytes), &body); err != nil {
logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
logger.LogWarn(c.Request.Context(), common.LogText("json decode failed from %s: %s", chItem.Name, err.Error()))
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
@@ -462,8 +462,8 @@ func FetchUpstreamRatios(c *gin.Context) {
BillingExpr string `json:"billing_expr"`
}
if err := common.Unmarshal(body.Data, &pricingItems); err != nil {
logger.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
logger.LogWarn(c.Request.Context(), common.LogText("unrecognized data format from %s: %s", chItem.Name, err.Error()))
ch <- upstreamResult{Name: uniqueName, Err: "failed to parse the upstream response"}
return
}
+17 -23
View File
@@ -1,13 +1,11 @@
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"
@@ -64,7 +62,7 @@ func GetRedemption(c *gin.Context) {
func AddRedemption(c *gin.Context) {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
common.ApiErrorT(c, "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them.")
return
}
@@ -75,27 +73,27 @@ func AddRedemption(c *gin.Context) {
return
}
if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
common.ApiErrorT(c, "Redemption code name length must be between 1-20")
return
}
if redemption.Count <= 0 {
common.ApiErrorI18n(c, i18n.MsgRedemptionCountPositive)
common.ApiErrorT(c, "Redemption code count must be greater than 0")
return
}
if redemption.Count > 100 {
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
common.ApiErrorT(c, "Maximum 100 redemption codes can be generated at once")
return
}
if redemption.Quota <= 0 {
common.ApiError(c, errors.New("redemption quota must be positive"))
common.ApiErrorT(c, "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})
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
common.ApiError(c, err)
return
}
var keys []string
@@ -111,12 +109,8 @@ func AddRedemption(c *gin.Context) {
}
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,
})
common.SysError(common.LogText("failed to insert redemption: %s", err.Error()))
common.ApiErrorStatus(c, http.StatusOK, common.NewMessage("Failed to create redemption code, please try again later"), gin.H{"data": keys})
return
}
keys = append(keys, key)
@@ -124,7 +118,7 @@ func AddRedemption(c *gin.Context) {
recordManageAudit(c, "redemption.create", map[string]any{
"name": redemption.Name,
"count": redemption.Count,
"quota": logger.LogQuota(redemption.Quota),
"quota": logger.FormatQuota(redemption.Quota),
})
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -163,15 +157,15 @@ func UpdateRedemption(c *gin.Context) {
}
if statusOnly == "" {
if redemption.Quota <= 0 {
common.ApiError(c, errors.New("redemption quota must be positive"))
common.ApiErrorT(c, "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})
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
common.ApiError(c, err)
return
}
// If you add more fields, please also update redemption.Update()
@@ -209,11 +203,11 @@ func DeleteInvalidRedemption(c *gin.Context) {
return
}
func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
func validateExpiredTime(expired int64) error {
if expired != 0 && expired < common.GetTimestamp() {
return false, i18n.T(c, i18n.MsgRedemptionExpireTimeInvalid)
return common.NewMessage("Expiration time cannot be earlier than current time")
}
return true, ""
return nil
}
func DeleteRedemptionBatch(c *gin.Context) {
@@ -221,7 +215,7 @@ func DeleteRedemptionBatch(c *gin.Context) {
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)
common.ApiErrorT(c, "Invalid parameters")
return
}
count, err := model.BatchDeleteRedemptions(request.Ids)
+49 -18
View File
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
taskdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
@@ -126,7 +127,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
defer func() {
if newAPIError != nil {
service.RecordRequestPolicyTermination(c, newAPIError)
logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
logger.LogError(c, common.LogText("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
if c.GetBool("response_guard_error_sent") {
return
}
@@ -311,7 +312,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
retryLogStr := common.LogText("retry: %s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
logger.LogInfo(c, retryLogStr)
}
}
@@ -391,10 +392,10 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
}
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())
return nil, types.NewError(errors.New(i18n.T(c, i18n.MsgRelayRetryGetChannelFailed, map[string]any{"Group": selectGroup, "Model": info.OriginModelName, "Error": 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())
return nil, types.NewError(errors.New(i18n.T(c, i18n.MsgRelayRetryNoAvailableChannel, map[string]any{"Group": selectGroup, "Model": info.OriginModelName})), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
@@ -476,7 +477,7 @@ func RelayMidjourney(c *gin.Context) {
policy.Successful = false
statusCode := http.StatusBadRequest
if mjErr.Code == 30 {
mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
mjErr.Result = i18n.T(c, i18n.MsgRelayGroupSaturated)
statusCode = http.StatusTooManyRequests
}
c.JSON(statusCode, gin.H{
@@ -485,7 +486,7 @@ func RelayMidjourney(c *gin.Context) {
"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)))
logger.LogError(c, common.LogText("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result)))
}
}
@@ -538,11 +539,14 @@ func RelayTaskPluginEndpoint(c *gin.Context, fallback gin.HandlerFunc) {
})
return
}
if pinned.Protocol != "openai_responses" {
switch pinned.Protocol {
case "openai_responses":
serveTaskPluginProtocol(c, pinned, defaultPluginProtocolBridgeDeps())
case pluginruntime.ProtocolOpenAIImage:
serveTaskPluginImageProtocol(c, pinned, defaultPluginProtocolBridgeDeps())
default:
fallback(c)
return
}
serveTaskPluginProtocol(c, pinned, defaultPluginProtocolBridgeDeps())
}
func RelayTaskFetch(c *gin.Context) {
@@ -715,7 +719,7 @@ func executeTaskSubmissionWith(
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
retryLogStr := common.LogText("retry: %s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
logger.LogInfo(c, retryLogStr)
}
@@ -740,7 +744,7 @@ func executeTaskSubmissionWith(
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())
common.SysError(common.LogText("reserve adjusted task billing error: %s", 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
@@ -790,9 +794,36 @@ func executeTaskSubmissionWith(
task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
}
}
// A native submit route may declare retainResult: false. It applies only
// to immediate terminal results: the client receives the complete response
// once, the upstream snapshot is never persisted, and the task is not
// retrievable afterwards. An asynchronous result on such a route keeps its
// snapshot because polling and retrieval need it. The OpenAI Images
// protocol delivers its images inline in the same HTTP response, so its
// immediate results follow the same rule; an asynchronous image task is
// expected there and is polled inside the request.
var insertOmits []string
immediateTerminal := result.Immediate != nil && (result.Immediate.Status == model.TaskStatusSuccess || result.Immediate.Status == model.TaskStatusFailure)
if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedRoute); exists {
pinned, ok := pinnedValue.(pluginruntime.PinnedRoute)
if ok && pinned.Route.RetainResult != nil && !*pinned.Route.RetainResult {
if immediateTerminal {
task.PrivateData.ResultDiscarded = true
insertOmits = append(insertOmits, "data")
} else {
logger.LogWarn(c, common.LogText("task plugin route %s %s declares retainResult: false but returned an asynchronous result; retaining task %s", pinned.Route.Method, pinned.Route.Path, task.TaskID))
}
}
}
if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint); exists && immediateTerminal {
if pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint); ok && pinned.Protocol == pluginruntime.ProtocolOpenAIImage {
task.PrivateData.ResultDiscarded = true
insertOmits = append(insertOmits, "data")
}
}
diagnostics.insertStart(task)
if insertErr := task.InsertWithContext(c.Request.Context()); insertErr != nil {
common.SysError("insert task error: " + insertErr.Error())
if insertErr := task.InsertWithContext(c.Request.Context(), insertOmits...); insertErr != nil {
common.SysError(common.LogText("insert task error: %s", 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
@@ -803,7 +834,7 @@ func executeTaskSubmissionWith(
diagnostics.settleStart(task, result.Quota)
if settleErr := service.SettleBilling(c, relayInfo, result.Quota); settleErr != nil {
common.SysError("settle task billing error: " + settleErr.Error())
common.SysError(common.LogText("settle task billing error: %s", 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
@@ -841,13 +872,13 @@ func presentTaskSubmission(c *gin.Context, outcome *taskSubmissionOutcome) {
c.JSON(http.StatusOK, body)
return
} else {
logger.LogError(c, "task plugin native submit presenter failed: "+callErr.Error())
logger.LogError(c, common.LogText("task plugin native submit presenter failed: %s", callErr.Error()))
}
} else {
logger.LogError(c, "encode task plugin native submit view failed: "+valueErr.Error())
logger.LogError(c, common.LogText("encode task plugin native submit view failed: %s", valueErr.Error()))
}
} else {
logger.LogError(c, "build task plugin native submit view failed: "+err.Error())
logger.LogError(c, common.LogText("build task plugin native submit view failed: %s", err.Error()))
}
}
}
@@ -884,7 +915,7 @@ func respondTaskSubmissionError(c *gin.Context, taskErr *taskdto.TaskError) {
// respondTaskError 统一输出 Task 错误响应(含 429 限流提示改写)
func respondTaskError(c *gin.Context, taskErr *taskdto.TaskError) {
if taskErr.StatusCode == http.StatusTooManyRequests {
taskErr.Message = "当前分组上游负载已饱和,请稍后再试"
taskErr.Message = i18n.T(c, i18n.MsgRelayGroupUpstreamSaturated)
}
c.JSON(taskErr.StatusCode, taskErr)
}
+4 -4
View File
@@ -11,10 +11,10 @@ import (
)
func TestClientFacingOpenAIErrorHidesUpstreamDetails(t *testing.T) {
err := types.NewOpenAIError(errors.New("provider.example.invalid provider secret failure"), types.ErrorCodeBadResponseStatusCode, http.StatusBadGateway)
err := types.NewOpenAIError(errors.New("upstream.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") {
if strings.Contains(got.Message, "upstream.example") || strings.Contains(got.Message, "provider secret") {
t.Fatalf("upstream details leaked: %q", got.Message)
}
if !strings.Contains(got.Message, "local-request-id") {
@@ -26,10 +26,10 @@ func TestClientFacingOpenAIErrorHidesUpstreamDetails(t *testing.T) {
}
func TestClientFacingClaudeErrorHidesUpstreamDetails(t *testing.T) {
err := types.NewOpenAIError(errors.New("provider.example.invalid provider secret failure"), types.ErrorCodeBadResponseStatusCode, http.StatusBadGateway)
err := types.NewOpenAIError(errors.New("upstream.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") {
if strings.Contains(got.Message, "upstream.example") || strings.Contains(got.Message, "provider secret") {
t.Fatalf("upstream details leaked: %q", got.Message)
}
if !strings.Contains(got.Message, "local-request-id") {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
func TestPublicUpstreamErrorContent(t *testing.T) {
got := publicUpstreamErrorContent(502)
if strings.Contains(got, "example-provider") || strings.Contains(got, "provider") || strings.Contains(got, "upstream") {
if strings.Contains(got, "private-vendor") || 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) {
+90 -7
View File
@@ -233,6 +233,75 @@ func TestExecuteTaskSubmissionPersistsPinnedPluginProvenance(t *testing.T) {
assert.Equal(t, "upstream-private", stored.PrivateData.UpstreamTaskID)
}
// A submit route declaring retainResult: false persists the task row for
// billing but never writes the upstream snapshot for an immediate terminal
// result, while the in-memory task still carries it for the presenter. An
// asynchronous result on the same route is retained because polling and
// retrieval depend on it.
func TestExecuteTaskSubmissionHonorsRouteRetainResult(t *testing.T) {
retainFalse := false
for _, tc := range []struct {
name string
immediate *relaycommon.TaskInfo
wantDiscarded bool
}{
{"immediate success is discarded", &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, Progress: "100%"}, true},
{"immediate failure is discarded", &relaycommon.TaskInfo{Status: model.TaskStatusFailure, Reason: "rejected"}, true},
{"asynchronous result is retained", nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
events := make([]string, 0, 3)
database, _ := openTaskDialectDatabase(t, &model.Task{}, &model.User{}, &model.Channel{})
previousDB := model.DB
model.DB = database
t.Cleanup(func() { model.DB = previousDB })
previousLogConsumeEnabled := common.LogConsumeEnabled
common.LogConsumeEnabled = false
t.Cleanup(func() { common.LogConsumeEnabled = previousLogConsumeEnabled })
c := taskSubmissionTestContext()
c.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{
Plugin: &pluginruntime.LoadedPlugin{Meta: pluginruntime.Meta{Key: "sync-images"}},
Route: pluginruntime.Route{Method: http.MethodPost, Path: "/sync/images", Type: pluginruntime.RouteTypeSubmit, RetainResult: &retainFalse},
})
info := taskSubmissionRelayInfo(&taskSubmissionTestBilling{events: &events})
upstream := []byte(`{"data":[{"url":"https://cdn.example/a.png"}]}`)
outcome, taskErr := executeTaskSubmissionWith(c, info, func(*gin.Context, *relaycommon.RelayInfo) (*relay.TaskSubmitResult, *dto.TaskError) {
return &relay.TaskSubmitResult{
UpstreamTaskID: "task_public",
Platform: constant.TaskPlatform("sync-images"),
TaskData: upstream,
Immediate: tc.immediate,
}, nil
})
require.Nil(t, taskErr)
require.NotNil(t, outcome)
assert.JSONEq(t, string(upstream), string(outcome.Task.Data), "presenter keeps the in-memory snapshot")
assert.Equal(t, tc.wantDiscarded, outcome.Task.PrivateData.ResultDiscarded)
assert.Equal(t, !tc.wantDiscarded, outcome.Task.ResultRetrievable())
var stored model.Task
require.NoError(t, database.Where("task_id = ?", "task_public").First(&stored).Error)
assert.Equal(t, tc.wantDiscarded, stored.PrivateData.ResultDiscarded)
var nullCount int64
require.NoError(t, database.Model(&model.Task{}).Where("task_id = ? AND data IS NULL", "task_public").Count(&nullCount).Error)
if tc.wantDiscarded {
assert.Equal(t, int64(1), nullCount, "snapshot column must stay NULL")
artifacts, err := projectTaskArtifacts(&stored)
require.NoError(t, err)
assert.Empty(t, artifacts)
} else {
assert.Equal(t, int64(0), nullCount)
assert.JSONEq(t, string(upstream), string(stored.Data))
}
listed := model.TaskGetAllUserTask(1, 0, 10, model.SyncTaskQueryParams{})
require.Len(t, listed, 1)
assert.Empty(t, listed[0].Data, "task lists never select the snapshot column")
})
}
}
func TestExecuteTaskSubmissionRefundsCancellationBeforeDurableBarrier(t *testing.T) {
events := make([]string, 0, 2)
setupTaskSubmissionDatabase(t, true, &events)
@@ -357,17 +426,21 @@ func TestExecuteTaskSubmissionDisconnectAfterDurableInsertDoesNotRefund(t *testi
assert.False(t, c.Writer.Written())
}
// setupTaskSubmissionDatabase opens the dialect selected by
// TEST_TASK_DB_DIALECT (SQLite in memory by default) and records every task
// INSERT in events so tests can assert the reserve → insert → settle order.
// Without migrate the task table does not exist and inserts fail.
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)
var models []any
if migrate {
models = append(models, &model.Task{})
}
database, _ := openTaskDialectDatabase(t, models...)
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
@@ -398,7 +471,13 @@ func taskSubmissionRelayInfo(billing relaycommon.BillingSettler) *relaycommon.Re
// and consume log. Set TEST_TASK_DB_DIALECT plus TEST_MYSQL_DSN or
// TEST_POSTGRES_DSN to exercise the same contract on an external test database.
// Unique table prefixes keep the fixture isolated from all existing tables.
func TestImmediateTaskSettlementDatabase(t *testing.T) {
// openTaskDialectDatabase opens the engine selected by TEST_TASK_DB_DIALECT
// (default SQLite in memory; MySQL and PostgreSQL through TEST_MYSQL_DSN and
// TEST_POSTGRES_DSN) with a unique table prefix, migrates the given models and
// drops them on cleanup. It logs the engine version so database verification
// runs leave a record.
func openTaskDialectDatabase(t *testing.T, models ...any) (*gorm.DB, common.DatabaseType) {
t.Helper()
dialect := common.DatabaseType(os.Getenv("TEST_TASK_DB_DIALECT"))
var driver gorm.Dialector
switch dialect {
@@ -420,7 +499,6 @@ func TestImmediateTaskSettlementDatabase(t *testing.T) {
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
models := []any{&model.User{}, &model.Channel{}, &model.Task{}, &model.Log{}}
require.NoError(t, db.AutoMigrate(models...))
t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(models...)) })
var version string
@@ -430,6 +508,11 @@ func TestImmediateTaskSettlementDatabase(t *testing.T) {
require.NoError(t, db.Raw("SELECT version()").Scan(&version).Error)
}
t.Logf("database: %s %s", dialect, version)
return db, dialect
}
func TestImmediateTaskSettlementDatabase(t *testing.T) {
db, dialect := openTaskDialectDatabase(t, &model.User{}, &model.Channel{}, &model.Task{}, &model.Log{})
oldDB, oldLogDB := model.DB, model.LOG_DB
oldMain, oldLog := common.MainDatabaseType(), common.LogDatabaseType()
oldRedis, oldMemory, oldBatch, oldConsume, oldExport := common.RedisEnabled, common.MemoryCacheEnabled, common.BatchUpdateEnabled, common.LogConsumeEnabled, common.DataExportEnabled
+3 -4
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"errors"
"fmt"
"net"
"net/http"
"sync"
@@ -107,7 +106,7 @@ func newResponsesWSRequestRunner(c *gin.Context) relay.ResponsesWSRequestRunner
if common.Unmarshal(response.body.Bytes(), &body) == nil && body.Error != nil {
return types.WithOpenAIError(*body.Error, response.status, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
// The existing in-memory rate limiter returns a bare 429 response.
// A rejection without an OpenAI error body keeps its HTTP status text.
return types.NewErrorWithStatusCode(errors.New(http.StatusText(response.status)), types.ErrorCodeInvalidRequest, response.status, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
}
@@ -122,8 +121,8 @@ func ResponsesWebSocket(c *gin.Context) {
defer ws.Close()
if apiError := relay.ResponsesWebSocketHelper(c, ws, runner); apiError != nil {
logger.LogError(c, fmt.Sprintf("responses websocket relay error: %s", common.LocalLogPreview(apiError.Error())))
logger.LogError(c, common.LogText("responses websocket relay error: %s", common.LocalLogPreview(apiError.Error())))
apiError.SetMessage(common.MessageWithRequestId(apiError.Error(), requestID))
helper.WssError(c, ws, apiError.ToOpenAIError())
helper.WssError(c, ws, apiError.ToOpenAIError(common.UpstreamAddresses(c)...))
}
}
+122 -8
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -310,6 +309,7 @@ func TestResponsesWSRequestRunnerRejectsRevokedCredentials(t *testing.T) {
}
func TestResponsesWSRequestRunnerUsesExistingMemoryRateLimit(t *testing.T) {
require.NoError(t, i18n.Init())
_, token := setupResponsesWSRequestTest(t)
setting.ModelRequestRateLimitEnabled = true
setting.ModelRequestRateLimitDurationMinutes = 1
@@ -322,7 +322,8 @@ func TestResponsesWSRequestRunnerUsesExistingMemoryRateLimit(t *testing.T) {
apiError := runner(httptest.NewRequest(http.MethodPost, "/v1/responses", nil), "limited", handle)
require.NotNil(t, apiError)
assert.Equal(t, http.StatusTooManyRequests, apiError.StatusCode)
assert.Equal(t, http.StatusText(http.StatusTooManyRequests), apiError.Error())
message := i18n.Translate("", i18n.MsgRateLimitTotalReached, map[string]any{"Minutes": 1, "Max": 1})
assert.Equal(t, common.MessageWithRequestId(message, "limited"), apiError.Error())
assert.Equal(t, 1, called)
}
@@ -902,7 +903,8 @@ func TestResponsesWebSocketInitialUpstreamRejectionRefundsReservation(t *testing
t.Run(tc.name, func(t *testing.T) {
preConsumed := make(chan int, 1)
tokenID := make(chan int, 1)
fixture := newResponsesWSBillingTest(t, `tier("output", c * 2)`, func(ws *websocket.Conn, _ *http.Request) {
// Pre-consume no longer estimates completion tokens, so an output-priced expression reserves nothing to refund.
fixture := newResponsesWSBillingTest(t, `tier("request", fixed(0.002))`, func(ws *websocket.Conn, _ *http.Request) {
if _, _, err := ws.ReadMessage(); !assert.NoError(t, err) {
return
}
@@ -935,7 +937,7 @@ func TestResponsesWebSocketInitialUpstreamRejectionRefundsReservation(t *testing
rejectionError, _ := rejection["error"].(map[string]any)
assert.Equal(t, tc.wantType, rejectionError["type"])
assert.Equal(t, tc.wantMessage, rejectionError["message"])
assert.Equal(t, 2990, <-preConsumed, "the rejected request reserved quota before contacting upstream")
assert.Equal(t, 2000, <-preConsumed, "the rejected request reserved quota before contacting upstream")
deadline := time.NewTimer(3 * time.Second)
defer deadline.Stop()
for {
@@ -1025,7 +1027,13 @@ func TestResponsesStreamOutcomesPreserveAccounting(t *testing.T) {
require.NoError(t, err)
require.NoError(t, response.Body.Close())
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.Contains(t, string(body), terminal)
if tc.failed {
assert.Contains(t, string(body), "model_service_error")
assert.Contains(t, string(body), "responses-http-billing")
assert.NotContains(t, string(body), "sensitive upstream detail")
} else {
assert.Contains(t, string(body), terminal)
}
} else {
require.NoError(t, fixture.client.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","stream_id":"planner","model":"ws-billing","input":"hi","max_output_tokens":1}`)))
for _, expected := range events {
@@ -1191,13 +1199,119 @@ func TestResponsesWebSocketAmbiguousControlErrorClosesAndSettlesOnce(t *testing.
require.NoError(t, err)
assert.JSONEq(t, terminal, string(body))
_, _, err = fixture.client.ReadMessage()
require.Error(t, err, "an unattributed error must close the connection instead of remaining busy")
var timeout net.Error
assert.False(t, errors.As(err, &timeout) && timeout.Timeout(), "the server must close before the read deadline")
// A dropped socket would read as a synthetic 1006; 1011 is the gateway's frame.
var closeErr *websocket.CloseError
require.ErrorAs(t, err, &closeErr, "an unattributed error must close the connection instead of remaining busy")
assert.Equal(t, websocket.CloseInternalServerErr, closeErr.Code)
fixture.closeAndWait(t)
assertResponsesWSAccounting(t, fixture, []int{1000})
}
// Every way a session ends sends the client a close frame whose code says
// why. A dropped socket would read as a synthetic 1006 close error, so a
// received 1000, 1001, 1008 or 1011 proves the gateway sent the frame.
func TestResponsesWebSocketCloseCodes(t *testing.T) {
require.NoError(t, i18n.Init())
const (
created = `{"type":"response.created","response":{"id":"active","status":"in_progress"}}`
delta = `{"type":"response.output_text.delta","delta":"hello"}`
completed = `{"type":"response.completed","response":{"id":"active","status":"completed","usage":{"input_tokens":1000,"output_tokens":10,"total_tokens":1010}}}`
)
for _, tc := range []struct {
name string
events []string // upstream events after the create, all relayed
// After the events, and after the client read them with waitClient,
// upstream sends upstreamClose or drops the socket. Otherwise it reads
// until the gateway closes the connection.
upstreamClose int
drop, waitClient bool
timeout bool
end func(*testing.T, *responsesWSBillingTest)
code int
reason string
upstreamCode int // the close code upstream receives, when checked
}{
{name: "upstream closes after completion", events: []string{completed}, upstreamClose: websocket.CloseNormalClosure, code: websocket.CloseNormalClosure, reason: "upstream connection closed"},
{name: "idle upstream goes away", events: []string{completed}, waitClient: true, upstreamClose: websocket.CloseGoingAway, code: websocket.CloseGoingAway, reason: "upstream connection closed"},
{name: "idle upstream drops", events: []string{completed}, waitClient: true, drop: true, code: websocket.CloseInternalServerErr, reason: "upstream connection closed"},
// A normal upstream close still truncates the active response.
{name: "upstream closes mid-response", events: []string{created, delta}, upstreamClose: websocket.CloseNormalClosure, code: websocket.CloseInternalServerErr, reason: "upstream connection closed"},
{name: "upstream drops mid-response", events: []string{created, delta}, drop: true, code: websocket.CloseInternalServerErr, reason: "upstream connection closed"},
{name: "upstream response times out", events: []string{created}, timeout: true, code: websocket.CloseInternalServerErr, reason: "upstream response timed out", upstreamCode: websocket.CloseInternalServerErr},
{name: "client closes", events: []string{completed}, end: func(t *testing.T, fixture *responsesWSBillingTest) {
require.NoError(t, fixture.client.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Second)))
}, code: websocket.CloseNormalClosure, upstreamCode: websocket.CloseNormalClosure},
{name: "channel disabled", events: []string{completed}, end: func(t *testing.T, fixture *responsesWSBillingTest) {
require.Equal(t, 1, service.CloseActiveWebSocketsForChannel(fixture.channel.Id, service.ChannelDisabledCloseReason))
}, code: websocket.ClosePolicyViolation, reason: service.ChannelDisabledCloseReason, upstreamCode: websocket.ClosePolicyViolation},
{name: "token revoked", events: []string{completed}, end: func(t *testing.T, fixture *responsesWSBillingTest) {
fixture.token.Status = common.TokenStatusDisabled
require.NoError(t, fixture.token.SelectUpdate())
require.NoError(t, fixture.client.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"ws-billing","input":"again"}`)))
assert.Equal(t, float64(http.StatusUnauthorized), readResponsesWSTestEvent(t, fixture.client)["status"])
}, code: websocket.ClosePolicyViolation, upstreamCode: websocket.ClosePolicyViolation},
} {
t.Run(tc.name, func(t *testing.T) {
clientRead := make(chan struct{})
upstreamCodes := make(chan int, 2)
fixture := newResponsesWSBillingTest(t, `tier("request", fixed(0.002))`, func(ws *websocket.Conn, _ *http.Request) {
defaultClose := ws.CloseHandler()
ws.SetCloseHandler(func(code int, text string) error {
upstreamCodes <- code
return defaultClose(code, text)
})
if _, _, err := ws.ReadMessage(); !assert.NoError(t, err) {
return
}
for _, event := range tc.events {
if !assert.NoError(t, ws.WriteMessage(websocket.TextMessage, []byte(event))) {
return
}
}
if tc.waitClient {
<-clientRead
}
if tc.drop {
return
}
if tc.upstreamClose != 0 && !assert.NoError(t, ws.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(tc.upstreamClose, "upstream shutdown"), time.Now().Add(time.Second))) {
return
}
for {
if _, _, err := ws.ReadMessage(); err != nil {
return
}
}
})
if tc.timeout {
constant.StreamingTimeout = 1
}
require.NoError(t, fixture.client.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"ws-billing","input":"hi"}`)))
for _, event := range tc.events {
actual, err := common.Marshal(readResponsesWSTestEvent(t, fixture.client))
require.NoError(t, err)
assert.JSONEq(t, event, string(actual))
}
close(clientRead)
if tc.end != nil {
tc.end(t, fixture)
}
require.NoError(t, fixture.client.SetReadDeadline(time.Now().Add(5*time.Second)))
_, _, err := fixture.client.ReadMessage()
var closeErr *websocket.CloseError
require.ErrorAs(t, err, &closeErr)
assert.Equal(t, tc.code, closeErr.Code)
assert.Equal(t, tc.reason, closeErr.Text)
fixture.closeAndWait(t)
assertResponsesWSAccounting(t, fixture, []int{1000})
if tc.upstreamCode != 0 {
require.Len(t, upstreamCodes, 1)
assert.Equal(t, tc.upstreamCode, <-upstreamCodes)
}
})
}
}
// A control event queued while another is still outstanding is rejected
// locally instead of reaching upstream twice.
func TestResponsesWebSocketRejectsSecondControlWhileOnePending(t *testing.T) {
+30 -10
View File
@@ -7,7 +7,6 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
@@ -20,9 +19,9 @@ import (
)
func GetVerificationMethods(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
writeAccountStatusError(c, http.StatusUnauthorized, "The current sign-in method does not support security verification")
return
}
requirements, err := service.GetVerificationRequirements(identity, c.Query("scope"))
@@ -41,9 +40,9 @@ func writeSecurityOperationError(c *gin.Context, err error) {
var protocolError *protocol.Error
switch {
case errors.Is(err, passkeysvc.ErrRPIDUnavailable):
code, message = "PASSKEY_RP_ID_UNAVAILABLE", i18n.T(c, i18n.MsgPasskeyRPIDUnavailable)
code, message = "PASSKEY_RP_ID_UNAVAILABLE", "This Passkey domain is not available on this website. Use its original website or another verification method."
case errors.Is(err, system_setting.ErrPasskeyRPIDInvalid):
code, message = "PASSKEY_RP_ID_INVALID", i18n.T(c, i18n.MsgPasskeyRPIDInvalid)
code, message = "PASSKEY_RP_ID_INVALID", "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard."
case errors.Is(err, service.ErrAccountEmailInvalid), errors.Is(err, service.ErrAccountEmailRestricted):
code, message = "EMAIL_ADDRESS_REJECTED", err.Error()
case errors.Is(err, model.ErrEmailAlreadyTaken):
@@ -129,20 +128,41 @@ func writeSecurityOperationError(c *gin.Context, err error) {
}
// Protocol details can contain challenges and client-controlled data.
// Only fixed categories and the server-selected public RP ID are logged.
logger.LogWarn(c.Request.Context(), "passkey verification rejected: code=%s reason=%s rp_id=%q", code, reason, c.GetString("passkey_rp_id"))
logger.LogWarn(c.Request.Context(), common.LogText("passkey verification rejected: code=%s reason=%s rp_id=%q", code, reason, c.GetString("passkey_rp_id")))
}
c.JSON(status, gin.H{"success": false, "code": code, "message": message})
// Every message above is fixed English text, so it is also the key the web
// console translates.
common.ApiErrorStatus(c, status, common.NewMessage(message), gin.H{"code": code})
}
// writeAccountStatusError responds with a web console message under a non-200
// status.
func writeAccountStatusError(c *gin.Context, status int, key string) {
common.ApiErrorStatus(c, status, common.NewMessage(key))
}
// requireAdminUserProof consumes the step-up proof for an administrative user
// operation after the caller has already authorized the operator against the
// managed user. context is one of the service.AdminUser*Context structs.
func requireAdminUserProof(c *gin.Context, scope string, context any) *model.AuthFlowAuthorization {
payload, err := common.Marshal(context)
if err != nil {
_ = c.Error(err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"success": false, "code": "AUTH_INTERNAL_ERROR", "message": "Please try again later."})
return nil
}
return middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: scope, Context: payload})
}
func UniversalVerify(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
writeAccountStatusError(c, http.StatusUnauthorized, "The current sign-in method does not support security verification")
return
}
var request service.VerificationInput
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
proof, err := service.VerifySecurityInput(identity, request)
+63 -4
View File
@@ -13,6 +13,7 @@ import (
"io"
"net"
"net/http"
"net/http/httptest"
"net/textproto"
"regexp"
"strings"
@@ -21,8 +22,10 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
@@ -130,7 +133,7 @@ func TestSecurityAccountDeletionAcceptsEitherFactorAndRevokesSessions(t *testing
}
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"))
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("access_token", "account-delete-access-token").Error)
response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
@@ -195,7 +198,8 @@ func TestSecurityAccountDeletionRechecksTransactionAndConsumesFailedProof(t *tes
assert.Contains(t, response.Body.String(), "SECURITY_PROOF_CONSUMED")
}
if scenario != "write failure" {
assert.Error(t, model.DeleteUserForSession(identity))
_, err := model.DeleteUserForSession(identity)
assert.Error(t, err)
}
_, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
@@ -233,6 +237,11 @@ func TestSecurityAccountDeletionConcurrentRequestsHaveOneWinner(t *testing.T) {
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)
_, _, err := service.ValidateLoginSession(identity)
assert.Error(t, err)
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.Zero(t, count)
}
type securityMailbox struct {
@@ -645,7 +654,7 @@ 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)
_, 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)
@@ -656,7 +665,7 @@ func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
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)
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)
@@ -676,6 +685,56 @@ func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
assert.ErrorIs(t, err, model.ErrEmailBindingLocked)
}
// Email binding codes and account security notices are written in the account
// owner's saved language, else in the language the request states, and quote
// provider names HTML-escaped.
func TestSecurityAccountMailsFollowTheOwnersLanguage(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
mails := func(receiver string) string {
mailbox.mutex.Lock()
defer mailbox.mutex.Unlock()
return strings.Join(mailbox.mail[receiver], "\n")
}
saveLanguage := func(language string) {
setting, err := common.Marshal(dto.UserSetting{Language: language})
require.NoError(t, err)
require.NoError(t, model.DB.Model(user).Update("setting", string(setting)).Error)
}
saveLanguage("zhCN")
require.NoError(t, model.DB.Model(user).Update("email", "previous@example.com").Error)
flow := startSecurityEmailBinding(t, identity, "new@example.com", service.VerificationMethodPassword)
request, err := common.Marshal(map[string]string{"flow_token": flow.FlowToken, "new_code": mailbox.code(t, flow.Email), "old_code": mailbox.code(t, "previous@example.com")})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/oauth/email/bind", string(request), "", identity, EmailBind)
require.Contains(t, response.Body.String(), `"success":true`)
assert.Contains(t, mails("new@example.com"), "请确认将此邮箱地址绑定到您的账户。")
assert.Contains(t, mails("new@example.com"), "您的账户安全设置有变动:邮箱地址已确认。")
assert.Contains(t, mails("previous@example.com"), "您的账户正在申请更换邮箱地址,请确认替换当前邮箱。")
assert.Contains(t, mails("previous@example.com"), "您的账户安全设置有变动:邮箱地址已更换。")
for _, tc := range []struct {
name, saved, acceptLanguage, receiver, want string
}{
{"request language when none is saved", "", "zh-TW", "tw@example.com", "您的帳戶安全設定有變動:已綁定登入帳號 &lt;b&gt;Corp&lt;/b&gt;。"},
{"saved language over the request", "en", "zh-CN", "en@example.com", "Your account security settings have changed: Login account linked: &lt;b&gt;Corp&lt;/b&gt;."},
{"default language when neither states one", "", "", "default@example.com", "Your account security settings have changed: Login account linked: &lt;b&gt;Corp&lt;/b&gt;."},
} {
t.Run(tc.name, func(t *testing.T) {
saveLanguage(tc.saved)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/oauth/github", nil)
if tc.acceptLanguage != "" {
c.Request.Header.Set("Accept-Language", tc.acceptLanguage)
}
lang := service.AccountMailLanguage(c, user.Id)
require.NoError(t, service.NotifyAccountSecurityChange(lang, tc.receiver, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": "<b>Corp</b>"}))
assert.Contains(t, mails(tc.receiver), tc.want)
})
}
}
func TestSecurityAccountEmailConcurrentClaimsHaveOneOwner(t *testing.T) {
user, firstIdentity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
+348 -107
View File
@@ -18,6 +18,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
@@ -31,6 +32,7 @@ import (
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/fxamacker/cbor/v2"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
@@ -53,7 +55,17 @@ func setupSecurityEnrollmentTest(t *testing.T) (*model.User, service.AuthIdentit
dialect = "sqlite"
}
dsn := os.Getenv("TEST_" + strings.ToUpper(dialect) + "_DSN")
db, _ := newAuditTestDatabase(t, dialect, dsn)
var db *gorm.DB
if dialect == "sqlite" {
// Match common.SQLitePath: take the writer lock before transaction reads.
// Deferred transactions can fail to upgrade their read lock when concurrent
// proof consumption overlaps account deletion or session revocation.
var err error
db, err = gorm.Open(sqlite.Open(t.TempDir()+"/security.db?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_txlock=immediate"), &gorm.Config{})
require.NoError(t, err)
} else {
db, _ = newAuditTestDatabase(t, dialect, dsn)
}
logDB, _ := newAuditTestDatabase(t, dialect, dsn)
db.Logger = logger.Default.LogMode(logger.Silent)
logDB.Logger = logger.Default.LogMode(logger.Silent)
@@ -64,9 +76,10 @@ func setupSecurityEnrollmentTest(t *testing.T) (*model.User, service.AuthIdentit
var version string
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
t.Logf("database: %s %s", dialect, version)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}, &model.Option{}))
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}, &model.Option{}, &model.UserAccessToken{}))
require.NoError(t, logDB.AutoMigrate(&model.AuditLog{}))
model.DB, model.LOG_DB = db, logDB
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
dbType := common.DatabaseTypeSQLite
if dialect == "mysql" {
dbType = common.DatabaseTypeMySQL
@@ -126,28 +139,61 @@ func issueSecurityEnrollmentProof(t *testing.T, identity service.AuthIdentity, o
return proof
}
// withAccessTokenID sets the :id route parameter the way the router would.
func withAccessTokenID(handler gin.HandlerFunc, id int) gin.HandlerFunc {
return func(c *gin.Context) {
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(id)}}
handler(c)
}
}
type accessTokenEndpoint struct {
name, method, path, body string
scope string
context []byte
handler gin.HandlerFunc
}
// accessTokenMutationEndpoints lists every proof-guarded access token
// operation for one scoped token of the test user.
func accessTokenMutationEndpoints(tokenID int) []accessTokenEndpoint {
return []accessTokenEndpoint{
{"create", "POST", "/api/user/access_tokens", `{"name":"ci","scopes":["profile:read"],"expires_at":0}`, service.VerificationScopeAccessTokenGenerate, []byte(`{"scopes":["profile:read"],"expires_at":0}`), CreateAccessToken},
{"update", "PATCH", fmt.Sprintf("/api/user/access_tokens/%d", tokenID), `{"name":"ci","scopes":["usage:read"]}`, service.VerificationScopeAccessTokenUpdate, []byte(fmt.Sprintf(`{"token_id":%d,"scopes":["usage:read"]}`, tokenID)), withAccessTokenID(UpdateAccessToken, tokenID)},
{"delete", "DELETE", fmt.Sprintf("/api/user/access_tokens/%d", tokenID), "", service.VerificationScopeAccessTokenRevoke, []byte(fmt.Sprintf(`{"token_id":%d}`, tokenID)), withAccessTokenID(DeleteAccessToken, tokenID)},
{"legacy", "DELETE", "/api/user/access_tokens/legacy", "", service.VerificationScopeAccessTokenRevoke, []byte(`{"legacy":true}`), RevokeLegacyAccessToken},
}
}
// assertAccessTokensUnchanged checks that the seeded scoped token keeps its
// name and grant and that the legacy token still authenticates.
func assertAccessTokensUnchanged(t *testing.T, userID int, scopedID int, legacy string) {
t.Helper()
tokens, err := model.ListUserAccessTokens(userID)
require.NoError(t, err)
require.Len(t, tokens, 1)
assert.Equal(t, scopedID, tokens[0].Id)
assert.Equal(t, "test token", tokens[0].Name)
assert.Equal(t, []string{"profile:read"}, tokens[0].GetScopes())
stored, err := model.ValidateAccessToken(legacy)
require.NoError(t, err)
require.NotNil(t, stored)
assert.Equal(t, userID, stored.Id)
}
func TestSecurityEnrollmentAccessTokenRequiresProofBeforeMutation(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.UpdateUserAccessToken(user.Id, "existing-system-token"))
for _, endpoint := range []struct {
method string
handler gin.HandlerFunc
}{
{"GET", GenerateAccessToken},
{"POST", GenerateAccessToken},
{"DELETE", RevokeAccessToken},
} {
t.Run(endpoint.method, func(t *testing.T) {
response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", "", identity, endpoint.handler)
require.NoError(t, model.DB.Model(user).Update("access_token", "existing-system-token").Error)
_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
t.Run(endpoint.name, func(t *testing.T) {
response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, "", identity, endpoint.handler)
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.False(t, body.Success)
assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code)
stored, err := model.ValidateAccessToken("existing-system-token")
require.NoError(t, err)
require.NotNil(t, stored)
assert.Equal(t, user.Id, stored.Id)
assertAccessTokensUnchanged(t, user.Id, scoped.Id, "existing-system-token")
})
}
}
@@ -197,7 +243,7 @@ func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
if !test.password {
passwordScope = service.VerificationScopePasswordSet
}
for _, scope := range []string{service.VerificationScopeAccessTokenGenerate, service.VerificationScopeAccessTokenRevoke,
for _, scope := range []string{service.VerificationScopeAccessTokenGenerate, service.VerificationScopeAccessTokenUpdate, service.VerificationScopeAccessTokenRevoke,
service.VerificationScopeAccountBind, service.VerificationScopeAccountUnbind, passwordScope} {
requirements, err := service.GetVerificationRequirements(identity, scope)
require.NoError(t, err)
@@ -218,6 +264,12 @@ func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
input.Context = []byte(`{"provider":"email","email":"new@example.com"}`)
case service.VerificationScopeAccountUnbind:
input.Context = []byte(`{"provider_id":1}`)
case service.VerificationScopeAccessTokenGenerate:
input.Context = []byte(`{"scopes":["profile:read"],"expires_at":0}`)
case service.VerificationScopeAccessTokenUpdate:
input.Context = []byte(`{"token_id":1,"scopes":["profile:read"]}`)
case service.VerificationScopeAccessTokenRevoke:
input.Context = []byte(`{"token_id":1}`)
}
_, err := service.VerifySecurityInput(identity, input)
assert.ErrorIs(t, err, service.ErrProofMethod)
@@ -229,94 +281,102 @@ func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
func TestSecurityEnrollmentAccessTokenLifecycleConsumesProofs(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.UpdateUserAccessToken(user.Id, "previous-token"))
previousToken := "previous-token"
for _, method := range []string{"GET", "POST"} {
require.NoError(t, model.DB.Model(user).Update("access_token", "previous-token").Error)
createBody := `{"name":"ci","scopes":["profile:read"],"expires_at":0}`
var created []string
for range 2 {
proof, err := service.VerifySecurityInput(identity, service.VerificationInput{
Scope: service.VerificationScopeAccessTokenGenerate, Method: "password", Password: "enrollment-password",
Scope: service.VerificationScopeAccessTokenGenerate, Context: []byte(`{"scopes":["profile:read"],"expires_at":0}`), Method: "password", Password: "enrollment-password",
})
require.NoError(t, err)
wrongScope := securityEnrollmentRequest("DELETE", "/api/user/token", "", proof.ProofToken, identity, RevokeAccessToken)
wrongScope := securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", proof.ProofToken, identity, RevokeLegacyAccessToken)
assert.Contains(t, wrongScope.Body.String(), `"code":"SECURITY_PROOF_SCOPE_MISMATCH"`)
response := securityEnrollmentRequest(method, "/api/user/token", "", proof.ProofToken, identity, GenerateAccessToken)
response := securityEnrollmentRequest("POST", "/api/user/access_tokens", createBody, proof.ProofToken, identity, CreateAccessToken)
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
require.True(t, body.Success, body.Message)
var token string
require.NoError(t, common.Unmarshal(body.Data, &token))
assert.GreaterOrEqual(t, len(token), 28)
assert.LessOrEqual(t, len(token), 32)
assert.NotEqual(t, previousToken, token)
stored, err := model.ValidateAccessToken(token)
var result struct {
Token string `json:"token"`
}
require.NoError(t, common.Unmarshal(body.Data, &result))
require.True(t, strings.HasPrefix(result.Token, model.AccessTokenPrefix))
stored, err := model.FindUserAccessTokenByHash(model.AccessTokenFingerprint(result.Token))
require.NoError(t, err)
require.NotNil(t, stored)
assert.Equal(t, user.Id, stored.Id)
assert.Equal(t, model.AccessTokenFingerprint(token), model.AccessTokenFingerprint(stored.GetAccessToken()))
oldUser, err := model.ValidateAccessToken(previousToken)
assert.Nil(t, oldUser)
require.NoError(t, err)
response = securityEnrollmentRequest(method, "/api/user/token", "", proof.ProofToken, identity, GenerateAccessToken)
response = securityEnrollmentRequest("POST", "/api/user/access_tokens", createBody, proof.ProofToken, identity, CreateAccessToken)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
previousToken = token
created = append(created, result.Token)
}
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}, "password")
response := securityEnrollmentRequest("DELETE", "/api/user/token", "", proof, identity, RevokeAccessToken)
legacyUser, err := model.ValidateAccessToken("previous-token")
require.NoError(t, err)
require.NotNil(t, legacyUser, "creating scoped tokens leaves the legacy token alone")
tokens, err := model.ListUserAccessTokens(user.Id)
require.NoError(t, err)
require.Len(t, tokens, 2)
first, second := tokens[0], tokens[1]
// A revoke proof is bound to one token ID.
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: []byte(fmt.Sprintf(`{"token_id":%d}`, first.Id))}, "password")
response := securityEnrollmentRequest("DELETE", "/api/user/access_tokens/x", "", proof, identity, withAccessTokenID(DeleteAccessToken, second.Id))
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONTEXT_MISMATCH"`)
response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/x", "", proof, identity, withAccessTokenID(DeleteAccessToken, first.Id))
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
require.True(t, body.Success, body.Message)
stored, err := model.GetUserById(user.Id, true)
deleted, err := model.FindUserAccessTokenByHash(first.TokenHash)
require.NoError(t, err)
assert.Empty(t, stored.GetAccessToken())
revokedUser, err := model.ValidateAccessToken(previousToken)
assert.Nil(t, deleted)
kept, err := model.FindUserAccessTokenByHash(second.TokenHash)
require.NoError(t, err)
assert.Nil(t, revokedUser)
response = securityEnrollmentRequest("DELETE", "/api/user/token", "", proof, identity, RevokeAccessToken)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
response = securityEnrollmentRequest("GET", "/api/user/token/status", "", "", identity, GetAccessTokenStatus)
assert.Contains(t, response.Body.String(), `"exists":false`)
assert.NotNil(t, kept)
legacyProof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: []byte(`{"legacy":true}`)}, "password")
response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", legacyProof, identity, RevokeLegacyAccessToken)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
require.True(t, body.Success, body.Message)
legacyUser, err = model.ValidateAccessToken("previous-token")
require.NoError(t, err)
assert.Nil(t, legacyUser)
response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", legacyProof, identity, RevokeLegacyAccessToken)
assert.Equal(t, http.StatusNotFound, response.Code, "there is nothing left to revoke")
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
require.Len(t, audits, 3)
require.NoError(t, model.LOG_DB.Order("id").Find(&audits).Error)
actions := make([]string, 0, len(audits))
for _, audit := range audits {
actions = append(actions, audit.Action)
}
assert.Equal(t, []string{"access_token.generate", "access_token.generate", "access_token.revoke", "access_token.revoke"}, actions)
encoded, err := common.Marshal(audits)
require.NoError(t, err)
assert.Contains(t, string(encoded), "access_token.generate")
assert.Contains(t, string(encoded), "access_token.revoke")
assert.NotContains(t, string(encoded), previousToken)
assert.NotContains(t, string(encoded), proof)
for _, secret := range append(created, "previous-token", proof, legacyProof) {
assert.NotContains(t, string(encoded), secret)
}
}
func TestSecurityEnrollmentAccessTokenRejectsInvalidProofs(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.UpdateUserAccessToken(user.Id, "unchanged-token"))
for _, endpoint := range []struct {
method, scope string
handler gin.HandlerFunc
}{
{"GET", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken},
{"POST", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken},
{"DELETE", service.VerificationScopeAccessTokenRevoke, RevokeAccessToken},
} {
for _, failure := range []string{"session", "user", "expired"} {
t.Run(endpoint.method+"/"+failure, func(t *testing.T) {
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope}, "password")
require.NoError(t, model.DB.Model(user).Update("access_token", "unchanged-token").Error)
_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
for _, failure := range []string{"session", "user version", "expired"} {
t.Run(endpoint.name+"/"+failure, func(t *testing.T) {
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope, Context: endpoint.context}, "password")
requestIdentity := identity
code := "SECURITY_PROOF_INVALID"
switch failure {
case "session":
requestIdentity.SessionID = "other-session"
case "user":
requestIdentity.UserID++
case "user version":
requestIdentity.UserAuthVersion++
case "expired":
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
code = "SECURITY_PROOF_EXPIRED"
}
response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, requestIdentity, endpoint.handler)
response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, requestIdentity, endpoint.handler)
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Contains(t, response.Body.String(), code)
stored, err := model.ValidateAccessToken("unchanged-token")
require.NoError(t, err)
require.NotNil(t, stored)
assert.Equal(t, user.Id, stored.Id)
assertAccessTokensUnchanged(t, user.Id, scoped.Id, "unchanged-token")
})
}
}
@@ -324,30 +384,183 @@ func TestSecurityEnrollmentAccessTokenRejectsInvalidProofs(t *testing.T) {
func TestSecurityEnrollmentAccessTokenFailureDoesNotRestoreProof(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.UpdateUserAccessToken(user.Id, "unchanged-token"))
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("access_token_write_failure", func(tx *gorm.DB) {
if tx.Statement.Table == "users" {
tx.AddError(errors.New("private database failure"))
require.NoError(t, model.DB.Model(user).Update("access_token", "unchanged-token").Error)
_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
failWrite := func(table string) func(tx *gorm.DB) {
return func(tx *gorm.DB) {
if tx.Statement.Table == table {
tx.AddError(errors.New("private database failure"))
}
}
}))
for _, endpoint := range []struct {
method, scope string
handler gin.HandlerFunc
}{
{"POST", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken},
{"DELETE", service.VerificationScopeAccessTokenRevoke, RevokeAccessToken},
} {
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope}, "password")
response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, identity, endpoint.handler)
assert.Equal(t, http.StatusInternalServerError, response.Code)
assert.NotContains(t, response.Body.String(), "private database")
response = securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, identity, endpoint.handler)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
}
stored, err := model.ValidateAccessToken("unchanged-token")
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("access_token_create_failure", failWrite("user_access_tokens")))
require.NoError(t, model.DB.Callback().Delete().Before("gorm:delete").Register("access_token_delete_failure", failWrite("user_access_tokens")))
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("legacy_token_update_failure", failWrite("users")))
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("access_token_update_failure", failWrite("user_access_tokens")))
for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
t.Run(endpoint.name, func(t *testing.T) {
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope, Context: endpoint.context}, "password")
response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, identity, endpoint.handler)
assert.Equal(t, http.StatusInternalServerError, response.Code)
assert.NotContains(t, response.Body.String(), "private database")
response = securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, identity, endpoint.handler)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
})
}
assertAccessTokensUnchanged(t, user.Id, scoped.Id, "unchanged-token")
}
func decodeSecurityEnrollmentResponse(t *testing.T, response *httptest.ResponseRecorder) securityEnrollmentResponse {
t.Helper()
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body), response.Body.String())
return body
}
// A scoped token completes step-up on its own pat:<id> identity. The change it
// makes logs out every browser session and leaves the token usable.
func TestSecurityEnrollmentAccessTokenStepUpDisablesTwoFA(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
browser, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "browser")
require.NoError(t, err)
require.NotNil(t, stored)
assert.Equal(t, user.Id, stored.Id)
raw, token := createScopedAccessToken(t, user.Id, 0, "account_security:write", "profile:read")
router := newAccessTokenTestRouter()
response := accessTokenRequest(router, http.MethodGet, "/api/verify/methods?scope=2fa.disable", raw, "", "")
body := decodeSecurityEnrollmentResponse(t, response)
require.True(t, body.Success, body.Message)
var requirements service.VerificationRequirements
require.NoError(t, common.Unmarshal(body.Data, &requirements))
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodTwoFA, Available: true}}, requirements.Methods)
code, err := totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now())
require.NoError(t, err)
response = accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.disable","method":"2fa","code":"`+code+`"}`)
body = decodeSecurityEnrollmentResponse(t, response)
require.True(t, body.Success, body.Message)
var proof service.SecurityProof
require.NoError(t, common.Unmarshal(body.Data, &proof))
response = accessTokenRequest(router, http.MethodPost, "/api/user/2fa/disable", raw, proof.ProofToken, `{}`)
body = decodeSecurityEnrollmentResponse(t, response)
require.True(t, body.Success, body.Message)
assert.JSONEq(t, `{}`, string(body.Data), "a token-initiated change issues no browser session")
twoFA, err := model.GetTwoFAByUserId(user.Id)
require.NoError(t, err)
assert.False(t, twoFA != nil && twoFA.IsEnabled)
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, http.MethodGet, "/api/user/self", browser.AccessToken, "", "").Code)
session, err := model.GetUserSessionBySID(browser.Session.SID)
require.NoError(t, err)
assert.Equal(t, model.UserSessionStatusRevoked, session.Status)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, http.MethodGet, "/api/user/self", raw, "", "").Code)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action IN ?", []string{"user.security_verify", "user.2fa_disable_self"}).Order("id").Find(&audits).Error)
require.Len(t, audits, 2)
encoded, err := common.Marshal(audits)
require.NoError(t, err)
for _, secret := range []string{raw, proof.ProofToken, code} {
assert.NotContains(t, string(encoded), secret)
}
for _, audit := range audits {
assert.Equal(t, "access_token", audit.AuthMethod)
require.NotNil(t, audit.Other.Op)
params, err := common.Marshal(audit.Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), `"token_ref":"`+token.TokenHash+`"`)
}
}
func TestSecurityEnrollmentAccessTokenStepUpBoundaries(t *testing.T) {
t.Run("token scope gates verification", func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
readOnly, _ := createScopedAccessToken(t, user.Id, 0, "profile:read")
full, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write", "profile:read", "user:write")
router := newAccessTokenTestRouter()
for _, request := range []struct {
name, credential, path, body string
}{
{"methods without account security", readOnly, "/api/verify/methods?scope=2fa.setup", ""},
{"verify without account security", readOnly, "/api/verify", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`},
{"token creation", full, "/api/verify", `{"scope":"access_token.generate","method":"password","password":"enrollment-password","context":{"scopes":["profile:read"],"expires_at":0}}`},
{"token grant change", full, "/api/verify", `{"scope":"access_token.update","method":"password","password":"enrollment-password","context":{"token_id":1,"scopes":["profile:read"]}}`},
{"token revocation", full, "/api/verify", `{"scope":"access_token.revoke","method":"password","password":"enrollment-password","context":{"legacy":true}}`},
} {
method := http.MethodPost
if request.body == "" {
method = http.MethodGet
}
response := accessTokenRequest(router, method, request.path, request.credential, "", request.body)
assert.Equal(t, http.StatusForbidden, response.Code, request.name)
assert.Equal(t, "SECURITY_ACTION_FORBIDDEN", decodeSecurityEnrollmentResponse(t, response).Code, request.name)
}
var proofs int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Count(&proofs).Error)
assert.Zero(t, proofs)
})
t.Run("OAuth-only accounts have no method for a token", func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user"}).Error)
oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
raw, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
router := newAccessTokenTestRouter()
body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodGet, "/api/verify/methods?scope=2fa.setup", raw, "", ""))
require.True(t, body.Success, body.Message)
var requirements service.VerificationRequirements
require.NoError(t, common.Unmarshal(body.Data, &requirements))
require.Len(t, requirements.Methods, 1)
assert.Equal(t, service.VerificationMethodOAuth, requirements.Methods[0].Method)
assert.False(t, requirements.Methods[0].Available)
assert.Empty(t, requirements.OAuthProviders)
body = decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.setup","method":"oauth"}`))
assert.Equal(t, "SECURITY_METHOD_UNAVAILABLE", body.Code)
// The same account still verifies through OAuth in a browser.
browser, err := service.GetVerificationRequirements(identity, service.VerificationScopeTwoFASetup)
require.NoError(t, err)
assert.True(t, browser.Methods[0].Available)
})
t.Run("a proof stays with the token that obtained it", func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
browser, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "browser")
require.NoError(t, err)
first, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
second, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
router := newAccessTokenTestRouter()
body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", first, "", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`))
require.True(t, body.Success, body.Message)
var proof service.SecurityProof
require.NoError(t, common.Unmarshal(body.Data, &proof))
for _, credential := range []string{second, browser.AccessToken} {
response := accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", credential, proof.ProofToken, `{}`)
assert.Equal(t, "SECURITY_PROOF_INVALID", decodeSecurityEnrollmentResponse(t, response).Code)
}
body = decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", first, proof.ProofToken, `{}`))
assert.True(t, body.Success, "rejected holders do not burn the proof: %s", body.Message)
})
t.Run("revoking the token voids its proof", func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
raw, token := createScopedAccessToken(t, user.Id, 0, "account_security:write")
router := newAccessTokenTestRouter()
body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`))
require.True(t, body.Success, body.Message)
var proof service.SecurityProof
require.NoError(t, common.Unmarshal(body.Data, &proof))
_, err := model.DeleteUserAccessToken(user.Id, token.Id)
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", raw, proof.ProofToken, `{}`).Code)
// A request that authenticated just before the revocation cannot spend it either.
identity := service.AuthIdentity{UserID: user.Id, SessionID: model.AccessTokenSessionID(token.Id), UserAuthVersion: user.AuthVersion, SessionVersion: model.AccessTokenSessionVersion}
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup})
assert.Error(t, err)
var consumed int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ? AND consumed_at IS NOT NULL", model.AuthFlowPurposeSecurityProof).Count(&consumed).Error)
assert.Zero(t, consumed)
})
}
func authorizeSecurityEnrollment(t *testing.T, identity service.AuthIdentity) *model.AuthFlowAuthorization {
@@ -433,10 +646,11 @@ func TestSecurityEnrollmentRejectsMissingProofBeforeCreatingCredentials(t *testi
}
type securityEnrollmentResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Code string `json:"code"`
Data json.RawMessage `json:"data"`
Success bool `json:"success"`
Message string `json:"message"`
MessageKey string `json:"message_key"`
Code string `json:"code"`
Data json.RawMessage `json:"data"`
}
func TestSecurityEnrollmentMethodPolicy(t *testing.T) {
@@ -553,10 +767,34 @@ func TestSecurityEnrollmentOperationContext(t *testing.T) {
{"array context", "passkey.register", `[]`, service.ErrVerificationContextInvalid},
{"empty enrollment", "passkey.register", `{}`, nil},
{"implicit enrollment", "passkey.register", ``, nil},
{"generate access token", "access_token.generate", `{}`, nil},
{"revoke access token", "access_token.revoke", ``, nil},
{"generate access token", "access_token.generate", `{"scopes":["profile:read"],"expires_at":0}`, nil},
{"generate access token without grant", "access_token.generate", `{}`, service.ErrVerificationContextInvalid},
{"generate access token empty grant", "access_token.generate", `{"scopes":[],"expires_at":0}`, service.ErrVerificationContextInvalid},
{"generate access token negative expiry", "access_token.generate", `{"scopes":["profile:read"],"expires_at":-1}`, service.ErrVerificationContextInvalid},
{"update access token", "access_token.update", `{"token_id":3,"scopes":["profile:read"]}`, nil},
{"update access token without target", "access_token.update", `{"scopes":["profile:read"]}`, service.ErrVerificationContextInvalid},
{"update access token without grant", "access_token.update", `{"token_id":3}`, service.ErrVerificationContextInvalid},
{"update access token empty grant", "access_token.update", `{"token_id":3,"scopes":[]}`, service.ErrVerificationContextInvalid},
{"update access token target injection", "access_token.update", `{"token_id":3,"scopes":["profile:read"],"user_id":42}`, service.ErrVerificationContextInvalid},
{"revoke access token", "access_token.revoke", `{"token_id":3}`, nil},
{"revoke legacy access token", "access_token.revoke", `{"legacy":true}`, nil},
{"revoke access token without target", "access_token.revoke", ``, service.ErrVerificationContextInvalid},
{"revoke access token two targets", "access_token.revoke", `{"token_id":3,"legacy":true}`, service.ErrVerificationContextInvalid},
{"access token target injection", "access_token.revoke", `{"user_id":42}`, service.ErrVerificationContextInvalid},
{"enrollment target injection", "passkey.register", `{"user_id":42}`, service.ErrVerificationContextInvalid},
{"admin user", "admin.user.delete", `{"user_id":7}`, nil},
{"admin user zero", "admin.user.delete", `{"user_id":0}`, service.ErrVerificationContextInvalid},
{"admin user extra field", "admin.user.update", `{"user_id":7,"password":"x"}`, service.ErrVerificationContextInvalid},
{"admin manage", "admin.user.manage", `{"user_id":7,"action":"promote"}`, nil},
{"admin manage quota", "admin.user.manage", `{"user_id":7,"action":"add_quota"}`, service.ErrVerificationContextInvalid},
{"admin manage delete", "admin.user.manage", `{"user_id":7,"action":"delete"}`, service.ErrVerificationContextInvalid},
{"admin binding type", "admin.user.binding.clear", `{"user_id":7,"binding_type":"github"}`, nil},
{"admin binding provider", "admin.user.binding.clear", `{"user_id":7,"provider_id":3}`, nil},
{"admin binding both", "admin.user.binding.clear", `{"user_id":7,"binding_type":"github","provider_id":3}`, service.ErrVerificationContextInvalid},
{"admin binding neither", "admin.user.binding.clear", `{"user_id":7}`, service.ErrVerificationContextInvalid},
{"admin create administrator", "admin.user.create", `{"role":10}`, nil},
{"admin create common user", "admin.user.create", `{"role":1}`, service.ErrVerificationContextInvalid},
{"admin create unknown role", "admin.user.create", `{"role":50}`, service.ErrVerificationContextInvalid},
{"unknown scope", "user.email.change", `{}`, service.ErrProofScope},
} {
t.Run(test.name, func(t *testing.T) {
@@ -572,6 +810,11 @@ func TestSecurityEnrollmentOperationContext(t *testing.T) {
secondBinding, err := service.BindVerificationOperation(reordered)
require.NoError(t, err)
assert.Equal(t, firstBinding, secondBinding)
sortedGrant, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "access_token.generate", Context: []byte(`{"scopes":["profile:read","usage:read"],"expires_at":0}`)})
require.NoError(t, err)
unsortedGrant, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "access_token.generate", Context: []byte(`{"expires_at":0,"scopes":["usage:read"," profile:read","usage:read"]}`)})
require.NoError(t, err)
assert.Equal(t, sortedGrant, unsortedGrant, "a grant binds as a set")
}
func TestSecurityEnrollmentChannelProofRejectsMismatchesBeforeConsumption(t *testing.T) {
@@ -1168,7 +1411,7 @@ func TestSecurityEnrollmentTwoFAFailureAccountingAndStorageErrors(t *testing.T)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.False(t, body.Success, endpoint.path)
if endpoint.path == "/api/user/login/2fa" {
assert.Equal(t, "参数错误", body.Message, endpoint.path)
assert.Equal(t, "Invalid parameters", body.Message, endpoint.path)
} else {
assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code, endpoint.path)
}
@@ -1535,23 +1778,21 @@ func TestSecurityEnrollmentMissingTargetsAreBusinessErrors(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.AutoMigrate(&model.Channel{}))
for _, target := range []struct {
path, key string
handler gin.HandlerFunc
path, message string
handler gin.HandlerFunc
}{
{"/api/channel/999/key", i18n.MsgChannelNotExists, GetChannelKey},
{"/api/user/999/2fa", i18n.MsgUserNotExists, AdminDisable2FA},
{"/api/channel/999/key", "Channel does not exist", GetChannelKey},
{"/api/user/999/2fa", "User does not exist", AdminDisable2FA},
} {
var expectedMessage string
response := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
c.Params = gin.Params{{Key: "id", Value: "999"}}
expectedMessage = i18n.T(c, target.key)
target.handler(c)
})
assert.Equal(t, http.StatusOK, response.Code)
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.False(t, body.Success)
assert.Equal(t, expectedMessage, body.Message)
assert.Equal(t, target.message, body.Message)
assert.NotEqual(t, "AUTH_UNAUTHORIZED", body.Code)
for _, id := range []string{"invalid", "0", "-1"} {
invalid := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
+10 -40
View File
@@ -46,10 +46,7 @@ func GetSetup(c *gin.Context) {
func PostSetup(c *gin.Context) {
// Check if setup is already completed
if constant.Setup {
c.JSON(200, gin.H{
"success": false,
"message": "系统已经初始化完成",
})
common.ApiErrorT(c, "The system is already initialized")
return
}
@@ -59,10 +56,7 @@ func PostSetup(c *gin.Context) {
var req SetupRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "请求参数有误",
})
common.ApiErrorT(c, "Invalid parameters")
return
}
@@ -70,18 +64,12 @@ func PostSetup(c *gin.Context) {
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个字符",
})
common.ApiErrorT(c, "Username must be {{max}} characters or fewer", map[string]any{"max": 12})
return
}
// Validate password
if req.Password != req.ConfirmPassword {
c.JSON(200, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
common.ApiErrorT(c, "Passwords do not match")
return
}
@@ -96,10 +84,7 @@ func PostSetup(c *gin.Context) {
// Create root user
hashedPassword, err := common.HashAccountPassword(req.Password)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "系统错误: " + err.Error(),
})
common.ApiErrorT(c, "System error: {{error}}", map[string]any{"error": err.Error()})
return
}
rootUser := model.User{
@@ -113,10 +98,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&rootUser).Error
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "创建管理员账号失败: " + err.Error(),
})
common.ApiErrorT(c, "Failed to create the administrator account: {{error}}", map[string]any{"error": err.Error()})
return
}
}
@@ -128,19 +110,13 @@ func PostSetup(c *gin.Context) {
// 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(),
})
common.ApiErrorT(c, "Failed to save the self-use mode setting: {{error}}", map[string]any{"error": err.Error()})
return
}
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "保存演示站点模式设置失败: " + err.Error(),
})
common.ApiErrorT(c, "Failed to save the demo site mode setting: {{error}}", map[string]any{"error": err.Error()})
return
}
@@ -153,17 +129,11 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&setup).Error
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "系统初始化失败: " + err.Error(),
})
common.ApiErrorT(c, "Failed to initialize the system: {{error}}", map[string]any{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "系统初始化成功",
})
common.ApiSuccessT(c, "System initialized successfully", nil)
}
func boolToString(b bool) string {
+4
View File
@@ -62,6 +62,7 @@ func UpdateStatusPageGroup(c *gin.Context) {
return
}
service.InvalidateGroupQualityCache()
publishGroupStatusChange()
c.JSON(http.StatusOK, gin.H{"success": true})
}
@@ -89,6 +90,9 @@ func GetSiteGroupStatus(c *gin.Context) {
for i := range results {
monitors := make([]Monitor, 0, len(results[i].Monitors))
for _, monitor := range results[i].Monitors {
if monitor.Group == "luna" || monitor.Group == "gpt-images" {
continue
}
monitor.Tags = metadata[monitor.Group].Tags
monitor.RecentDegraded = recent[monitor.Group] && !metadata[monitor.Group].HideRecentDegraded
monitor.Name = monitor.Group
+54 -56
View File
@@ -1,7 +1,6 @@
package controller
import (
"fmt"
"strconv"
"strings"
@@ -78,7 +77,7 @@ func UpdateSubscriptionPreference(c *gin.Context) {
userId := c.GetInt("id")
var req BillingPreferenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
pref := common.NormalizeBillingPreference(req.BillingPreference)
@@ -105,7 +104,7 @@ func SubscriptionRequestBalancePay(c *gin.Context) {
userId := c.GetInt("id")
var req SubscriptionBalancePayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
@@ -145,20 +144,20 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
var req AdminUpsertSubscriptionPlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
req.Plan.Id = 0
if strings.TrimSpace(req.Plan.Title) == "" {
common.ApiErrorMsg(c, "套餐标题不能为空")
common.ApiErrorT(c, "Subscription plan title cannot be empty")
return
}
if req.Plan.PriceAmount < 0 {
common.ApiErrorMsg(c, "价格不能为负数")
common.ApiErrorT(c, "Price cannot be negative")
return
}
if req.Plan.PriceAmount > 9999 {
common.ApiErrorMsg(c, "价格不能超过9999")
common.ApiErrorT(c, "Price cannot exceed 9999")
return
}
if req.Plan.Currency == "" {
@@ -178,30 +177,30 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
req.Plan.DurationValue = 1
}
if req.Plan.MaxPurchasePerUser < 0 {
common.ApiErrorMsg(c, "购买上限不能为负数")
common.ApiErrorT(c, "Purchase limit cannot be negative")
return
}
if req.Plan.TotalAmount < 0 {
common.ApiErrorMsg(c, "总额度不能为负数")
common.ApiErrorT(c, "Total quota cannot be negative")
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, "升级分组不存在")
common.ApiErrorT(c, "Upgrade group does not exist")
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, "降级分组不存在")
common.ApiErrorT(c, "Downgrade group does not exist")
return
}
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
common.ApiErrorT(c, "Custom reset cycle must be greater than 0 seconds")
return
}
err := model.DB.Create(&req.Plan).Error
@@ -220,24 +219,24 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
common.ApiErrorMsg(c, "无效的ID")
common.ApiErrorT(c, "Invalid ID")
return
}
var req AdminUpsertSubscriptionPlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
if strings.TrimSpace(req.Plan.Title) == "" {
common.ApiErrorMsg(c, "套餐标题不能为空")
common.ApiErrorT(c, "Subscription plan title cannot be empty")
return
}
if req.Plan.PriceAmount < 0 {
common.ApiErrorMsg(c, "价格不能为负数")
common.ApiErrorT(c, "Price cannot be negative")
return
}
if req.Plan.PriceAmount > 9999 {
common.ApiErrorMsg(c, "价格不能超过9999")
common.ApiErrorT(c, "Price cannot exceed 9999")
return
}
req.Plan.Id = id
@@ -252,30 +251,30 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
req.Plan.DurationValue = 1
}
if req.Plan.MaxPurchasePerUser < 0 {
common.ApiErrorMsg(c, "购买上限不能为负数")
common.ApiErrorT(c, "Purchase limit cannot be negative")
return
}
if req.Plan.TotalAmount < 0 {
common.ApiErrorMsg(c, "总额度不能为负数")
common.ApiErrorT(c, "Total quota cannot be negative")
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, "升级分组不存在")
common.ApiErrorT(c, "Upgrade group does not exist")
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, "降级分组不存在")
common.ApiErrorT(c, "Downgrade group does not exist")
return
}
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
common.ApiErrorT(c, "Custom reset cycle must be greater than 0 seconds")
return
}
@@ -332,12 +331,12 @@ func AdminUpdateSubscriptionPlanStatus(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
common.ApiErrorMsg(c, "无效的ID")
common.ApiErrorT(c, "Invalid ID")
return
}
var req AdminUpdateSubscriptionPlanStatusRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", *req.Enabled).Error; err != nil {
@@ -360,7 +359,7 @@ func AdminBindSubscription(c *gin.Context) {
var req AdminBindSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil || req.UserId <= 0 || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
msg, err := model.AdminBindSubscription(req.UserId, req.PlanId, "")
@@ -368,11 +367,19 @@ func AdminBindSubscription(c *gin.Context) {
common.ApiError(c, err)
return
}
if msg != "" {
common.ApiSuccess(c, gin.H{"message": msg})
subscriptionChangeSuccess(c, msg)
}
// subscriptionChangeSuccess replies to an admin subscription change. notice
// tells the admin how the user's group changes, when it does.
func subscriptionChangeSuccess(c *gin.Context, notice *common.Message) {
if notice == nil {
common.ApiSuccess(c, nil)
return
}
common.ApiSuccess(c, nil)
data := notice.Fields()
data["message"] = notice.Error()
common.ApiSuccess(c, data)
}
// ---- Admin: user subscription management ----
@@ -380,7 +387,7 @@ func AdminBindSubscription(c *gin.Context) {
func AdminListUserSubscriptions(c *gin.Context) {
userId, _ := strconv.Atoi(c.Param("id"))
if userId <= 0 {
common.ApiErrorMsg(c, "无效的用户ID")
common.ApiErrorT(c, "Invalid user ID")
return
}
subs, err := model.GetAllUserSubscriptions(userId)
@@ -411,9 +418,12 @@ func recordSubscriptionResetUserLogs(c *gin.Context, result *model.SubscriptionR
if result == nil || result.ResetCount == 0 {
return
}
content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId)
const action = "subscription.user_quota_reset"
params := map[string]any{"plan_id": result.PlanId, "plan_title": result.PlanTitle}
operation := model.AuditOperation{Action: action, Params: params}
content := auditContentEN(action, params)
for _, userId := range result.AffectedUserIds {
model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, nil, c)
model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, &operation, c)
}
}
@@ -425,12 +435,12 @@ func AdminCreateUserSubscription(c *gin.Context) {
userId, _ := strconv.Atoi(c.Param("id"))
if userId <= 0 {
common.ApiErrorMsg(c, "无效的用户ID")
common.ApiErrorT(c, "Invalid user ID")
return
}
var req AdminCreateUserSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
msg, err := model.AdminBindSubscription(userId, req.PlanId, "")
@@ -438,26 +448,22 @@ func AdminCreateUserSubscription(c *gin.Context) {
common.ApiError(c, err)
return
}
if msg != "" {
common.ApiSuccess(c, gin.H{"message": msg})
return
}
common.ApiSuccess(c, nil)
subscriptionChangeSuccess(c, msg)
}
func AdminResetUserSubscriptionsByPlan(c *gin.Context) {
userId, _ := strconv.Atoi(c.Param("id"))
if userId <= 0 {
common.ApiErrorMsg(c, "无效的用户ID")
common.ApiErrorT(c, "Invalid user ID")
return
}
var req AdminResetSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
if req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime)
@@ -481,12 +487,12 @@ func AdminResetUserSubscriptionsByPlan(c *gin.Context) {
func AdminResetPlanSubscriptions(c *gin.Context) {
planId, _ := strconv.Atoi(c.Param("id"))
if planId <= 0 {
common.ApiErrorMsg(c, "无效的ID")
common.ApiErrorT(c, "Invalid ID")
return
}
var req AdminResetSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime)
@@ -496,7 +502,7 @@ func AdminResetPlanSubscriptions(c *gin.Context) {
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",
common.SysLog(common.LogText("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,
@@ -512,7 +518,7 @@ func AdminResetPlanSubscriptions(c *gin.Context) {
func AdminInvalidateUserSubscription(c *gin.Context) {
subId, _ := strconv.Atoi(c.Param("id"))
if subId <= 0 {
common.ApiErrorMsg(c, "无效的订阅ID")
common.ApiErrorT(c, "Invalid subscription ID")
return
}
msg, err := model.AdminInvalidateUserSubscription(subId)
@@ -520,18 +526,14 @@ func AdminInvalidateUserSubscription(c *gin.Context) {
common.ApiError(c, err)
return
}
if msg != "" {
common.ApiSuccess(c, gin.H{"message": msg})
return
}
common.ApiSuccess(c, nil)
subscriptionChangeSuccess(c, msg)
}
// AdminDeleteUserSubscription hard-deletes a user subscription.
func AdminDeleteUserSubscription(c *gin.Context) {
subId, _ := strconv.Atoi(c.Param("id"))
if subId <= 0 {
common.ApiErrorMsg(c, "无效的订阅ID")
common.ApiErrorT(c, "Invalid subscription ID")
return
}
msg, err := model.AdminDeleteUserSubscription(subId)
@@ -539,9 +541,5 @@ func AdminDeleteUserSubscription(c *gin.Context) {
common.ApiError(c, err)
return
}
if msg != "" {
common.ApiSuccess(c, gin.H{"message": msg})
return
}
common.ApiSuccess(c, nil)
subscriptionChangeSuccess(c, msg)
}
+10 -11
View File
@@ -2,7 +2,6 @@ package controller
import (
"bytes"
"fmt"
"io"
"net/http"
"time"
@@ -30,14 +29,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
// 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()))
logger.LogError(c.Request.Context(), common.LogText("Creem failed to read subscription payment request 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": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
@@ -47,15 +46,15 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
return
}
if !plan.Enabled {
common.ApiErrorMsg(c, "套餐未启用")
common.ApiErrorT(c, "Subscription plan is not enabled")
return
}
if plan.CreemProductId == "" {
common.ApiErrorMsg(c, "该套餐未配置 CreemProductId")
common.ApiErrorT(c, "CreemProductId is not configured for this plan")
return
}
if setting.CreemWebhookSecret == "" && !setting.CreemTestMode {
common.ApiErrorMsg(c, "Creem Webhook 未配置")
common.ApiErrorT(c, "Creem webhook is not configured")
return
}
@@ -66,7 +65,7 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
return
}
if user == nil {
common.ApiErrorMsg(c, "用户不存在")
common.ApiErrorT(c, "User does not exist")
return
}
@@ -77,7 +76,7 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
common.ApiErrorT(c, "Purchase limit for this plan has been reached")
return
}
}
@@ -97,7 +96,7 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
paymentError(c, common.NewMessage("Failed to create order"))
return
}
@@ -121,8 +120,8 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
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": "拉起支付失败"})
logger.LogError(c.Request.Context(), common.LogText("Creem failed to create subscription payment link trade_no=%s product_id=%s error=%q", referenceId, product.ProductId, err.Error()))
paymentError(c, common.NewMessage("Failed to start payment"))
return
}
+10 -10
View File
@@ -28,7 +28,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
var req SubscriptionEpayPayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
@@ -38,15 +38,15 @@ func SubscriptionRequestEpay(c *gin.Context) {
return
}
if !plan.Enabled {
common.ApiErrorMsg(c, "套餐未启用")
common.ApiErrorT(c, "Subscription plan is not enabled")
return
}
if plan.PriceAmount < 0.01 {
common.ApiErrorMsg(c, "套餐金额过低")
common.ApiErrorT(c, "Plan amount is too low")
return
}
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
common.ApiErrorMsg(c, "支付方式不存在")
common.ApiErrorT(c, "Payment method does not exist")
return
}
@@ -58,7 +58,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
common.ApiErrorT(c, "Purchase limit for this plan has been reached")
return
}
}
@@ -66,12 +66,12 @@ func SubscriptionRequestEpay(c *gin.Context) {
callBackAddress := service.GetCallbackAddress()
returnUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/return")
if err != nil {
common.ApiErrorMsg(c, "回调地址配置错误")
common.ApiErrorT(c, "Callback URL configuration error")
return
}
notifyUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/notify")
if err != nil {
common.ApiErrorMsg(c, "回调地址配置错误")
common.ApiErrorT(c, "Callback URL configuration error")
return
}
@@ -80,7 +80,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
client := GetEpayClient()
if client == nil {
common.ApiErrorMsg(c, "当前管理员未配置支付信息")
common.ApiErrorT(c, "Payment information has not been configured by administrator")
return
}
@@ -95,7 +95,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
common.ApiErrorMsg(c, "创建订单失败")
common.ApiErrorT(c, "Failed to create order")
return
}
uri, params, err := client.Purchase(&epay.PurchaseArgs{
@@ -109,7 +109,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
})
if err != nil {
_ = model.ExpireSubscriptionOrder(tradeNo, model.PaymentProviderEpay)
common.ApiErrorMsg(c, "拉起支付失败")
common.ApiErrorT(c, "Failed to start payment")
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
+10 -10
View File
@@ -27,7 +27,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
var req SubscriptionStripePayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
@@ -37,19 +37,19 @@ func SubscriptionRequestStripePay(c *gin.Context) {
return
}
if !plan.Enabled {
common.ApiErrorMsg(c, "套餐未启用")
common.ApiErrorT(c, "Subscription plan is not enabled")
return
}
if plan.StripePriceId == "" {
common.ApiErrorMsg(c, "该套餐未配置 StripePriceId")
common.ApiErrorT(c, "StripePriceId is not configured for this plan")
return
}
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
common.ApiErrorMsg(c, "Stripe 未配置或密钥无效")
common.ApiErrorT(c, "Stripe is not configured or key is invalid")
return
}
if setting.StripeWebhookSecret == "" {
common.ApiErrorMsg(c, "Stripe Webhook 未配置")
common.ApiErrorT(c, "Stripe webhook is not configured")
return
}
@@ -60,7 +60,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
return
}
if user == nil {
common.ApiErrorMsg(c, "用户不存在")
common.ApiErrorT(c, "User does not exist")
return
}
@@ -71,7 +71,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
common.ApiErrorT(c, "Purchase limit for this plan has been reached")
return
}
}
@@ -81,8 +81,8 @@ func SubscriptionRequestStripePay(c *gin.Context) {
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": "拉起支付失败"})
logger.LogError(c.Request.Context(), common.LogText("Stripe failed to create subscription payment link trade_no=%s plan_id=%d error=%q", referenceId, plan.Id, err.Error()))
paymentError(c, common.NewMessage("Failed to start payment"))
return
}
@@ -97,7 +97,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
paymentError(c, common.NewMessage("Failed to create order"))
return
}
@@ -27,7 +27,7 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
var req SubscriptionWaffoPancakePayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
@@ -37,18 +37,18 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
return
}
if !plan.Enabled {
common.ApiErrorMsg(c, "套餐未启用")
common.ApiErrorT(c, "Subscription plan is not enabled")
return
}
if strings.TrimSpace(plan.WaffoPancakeProductId) == "" {
common.ApiErrorMsg(c, "该套餐未配置 WaffoPancakeProductId")
common.ApiErrorT(c, "WaffoPancakeProductId is not configured for this plan")
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 未配置或密钥无效")
common.ApiErrorT(c, "Waffo Pancake is not configured or key is invalid")
return
}
@@ -59,7 +59,7 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
return
}
if user == nil {
common.ApiErrorMsg(c, "用户不存在")
common.ApiErrorT(c, "User does not exist")
return
}
@@ -70,7 +70,7 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
common.ApiErrorT(c, "Purchase limit for this plan has been reached")
return
}
}
@@ -90,8 +90,8 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
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": "创建订单失败"})
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake failed to create subscription order user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
paymentError(c, common.NewMessage("Failed to create order"))
return
}
@@ -108,13 +108,13 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
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()))
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake failed to create subscription checkout session 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": "拉起支付失败"})
paymentError(c, common.NewMessage("Failed to start payment"))
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))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake subscription order created 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",
+1 -2
View File
@@ -2,7 +2,6 @@ package controller
import (
"context"
"fmt"
"sync"
"time"
@@ -230,6 +229,6 @@ func finishSystemTaskHandler(task *model.SystemTask, runnerID string, status mod
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))
common.SysLog(common.LogText("system task %s failed to persist result: %v", task.TaskID, err))
}
}
+45 -3
View File
@@ -1,6 +1,7 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -11,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
@@ -111,6 +113,9 @@ func writeTaskArtifacts(c *gin.Context, task *model.Task, dashboard bool) {
})
}
response := gin.H{"task_id": task.TaskID, "artifacts": items}
if dashboard && task.Status == model.TaskStatusSuccess && task.Platform == constant.TaskPlatformSuno {
response["legacy_audio_clips"] = legacySunoAudioClips(task.Data)
}
if legacyVideoAvailable(task) {
legacyContentURL, buildErr := service.BuildTaskArtifactContentURL(task.TaskID, "video")
if buildErr != nil {
@@ -126,8 +131,41 @@ func writeTaskArtifacts(c *gin.Context, task *model.Task, dashboard bool) {
c.JSON(http.StatusOK, response)
}
// legacySunoAudioClips projects a pre-plugin Suno task's persisted snapshot
// into the clips the dashboard audio preview renders. Task lists no longer
// carry the snapshot, so the dashboard reads it here on demand. The snapshot
// is either a clip array or a JSON string holding one; only clips with an
// audio URL are kept and only preview fields are exposed.
func legacySunoAudioClips(data json.RawMessage) []map[string]any {
clips := make([]map[string]any, 0)
if len(data) == 0 {
return clips
}
var items []map[string]any
if err := common.Unmarshal(data, &items); err != nil {
var encoded string
if common.Unmarshal(data, &encoded) != nil || common.UnmarshalJsonStr(encoded, &items) != nil {
return clips
}
}
for _, item := range items {
audioURL, _ := item["audio_url"].(string)
if strings.TrimSpace(audioURL) == "" {
continue
}
clip := map[string]any{"audio_url": audioURL}
for _, key := range []string{"clip_id", "id", "title", "tags", "duration", "image_url", "image_large_url", "metadata"} {
if value, ok := item[key]; ok {
clip[key] = value
}
}
clips = append(clips, clip)
}
return clips
}
func projectTaskArtifacts(task *model.Task) ([]relaychannel.TaskArtifact, error) {
if task == nil || task.Status != model.TaskStatusSuccess || !taskHasPluginExecution(task) {
if task == nil || task.Status != model.TaskStatusSuccess || !taskHasPluginExecution(task) || !task.ResultRetrievable() {
return []relaychannel.TaskArtifact{}, nil
}
adaptor := relay.GetTaskAdaptor(task.Platform)
@@ -287,7 +325,7 @@ func TaskArtifactContent(c *gin.Context) {
return
}
artifactKey := strings.TrimSpace(c.Param("artifact_key"))
if !taskArtifactKeyPattern.MatchString(artifactKey) {
if !taskArtifactKeyPattern.MatchString(artifactKey) || !task.ResultRetrievable() {
writeTaskArtifactError(c, http.StatusNotFound, "artifact_not_found", "Task or artifact not found")
return
}
@@ -346,8 +384,11 @@ func TaskArtifactContent(c *gin.Context) {
Method: c.Request.Method,
Headers: taskArtifactClientHeaders(c.Request.Header),
}
descriptor, err := provider.BuildContentRequest(task, artifactKey, clientRequest)
descriptor, err := provider.BuildContentRequest(c.Request.Context(), task, artifactKey, clientRequest)
if err != nil || descriptor == nil {
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Failed to resolve plugin %s content for task %s: %v", artifactKey, task.TaskID, err))
}
writeTaskArtifactError(c, http.StatusInternalServerError, "artifact_plugin_error", "Artifact content plugin failed")
return
}
@@ -417,6 +458,7 @@ func tasksToDto(tasks []*model.Task, fillUser bool, viewerRole int) []*dto.TaskD
}
item := relay.TaskModel2Dto(task)
item.LegacyVideoAvailable = legacyVideoAvailable(task)
item.ResultDiscarded = task.PrivateData.ResultDiscarded
if task.Status == model.TaskStatusSuccess {
item.ResultURL = ""
if taskFailReasonIsLegacyResultURL(task.FailReason) {
+151 -5
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
@@ -18,10 +19,8 @@ import (
"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 {
@@ -29,9 +28,9 @@ func setupGenericTaskTest(t *testing.T) *model.Task {
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{}))
// The dialect harness lets the same fixture run against MySQL and
// PostgreSQL when TEST_TASK_DB_DIALECT selects them; SQLite stays the default.
database, _ := openTaskDialectDatabase(t, &model.Task{}, &model.Channel{}, &model.User{})
model.DB = database
t.Cleanup(func() {
model.DB = originalDB
@@ -186,6 +185,72 @@ func TestDashboardTaskArtifactsReturnsLegacyCapabilityWithoutUpstreamURL(t *test
assert.NotContains(t, recorder.Body.String(), "signature=secret")
}
// Task lists no longer carry the persisted snapshot, so the dashboard reads a
// legacy Suno task's playable clips from the artifacts endpoint instead.
func TestDashboardTaskArtifactsProjectsLegacySunoAudioClips(t *testing.T) {
task := setupGenericTaskTest(t)
task.Platform = constant.TaskPlatformSuno
task.Action = "MUSIC"
task.Data = []byte(`[{"id":"clip-1","title":"Song","audio_url":"https://cdn.example/a.mp3","metadata":{"tags":"pop","duration":30},"lyric":"private"},{"id":"clip-2","title":"No audio"}]`)
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)
var response struct {
Success bool `json:"success"`
Data struct {
Artifacts []taskArtifactResponse `json:"artifacts"`
LegacyAudioClips []map[string]any `json:"legacy_audio_clips"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.True(t, response.Success)
assert.Empty(t, response.Data.Artifacts)
require.Len(t, response.Data.LegacyAudioClips, 1)
assert.Equal(t, "https://cdn.example/a.mp3", response.Data.LegacyAudioClips[0]["audio_url"])
assert.Equal(t, "Song", response.Data.LegacyAudioClips[0]["title"])
assert.NotContains(t, recorder.Body.String(), "private")
}
// Task lists omit the data column; the API DTO keeps the key as null so shape
// checks survive while the payload no longer travels with every row.
func TestTaskListsOmitPersistedSnapshot(t *testing.T) {
task := setupGenericTaskTest(t)
task.Data = []byte(`{"data":[{"url":"https://cdn.example/a.png"}]}`)
require.NoError(t, model.DB.Save(task).Error)
userTasks := model.TaskGetAllUserTask(task.UserId, 0, 10, model.SyncTaskQueryParams{})
require.Len(t, userTasks, 1)
assert.Empty(t, userTasks[0].Data)
adminTasks := model.TaskGetAllTasks(0, 10, model.SyncTaskQueryParams{})
require.Len(t, adminTasks, 1)
assert.Empty(t, adminTasks[0].Data)
assert.Equal(t, task.TaskID, adminTasks[0].TaskID)
encoded, err := common.Marshal(tasksToDto(adminTasks, false, common.RoleAdminUser)[0])
require.NoError(t, err)
assert.Contains(t, string(encoded), `"data":null`)
assert.NotContains(t, string(encoded), `"result_discarded"`)
adminTasks[0].PrivateData.ResultDiscarded = true
encoded, err = common.Marshal(tasksToDto(adminTasks, false, common.RoleAdminUser)[0])
require.NoError(t, err)
assert.Contains(t, string(encoded), `"result_discarded":true`, "task lists tell the UI that an inline result was not retained")
stored, exists, err := model.GetByTaskId(task.UserId, task.TaskID)
require.NoError(t, err)
require.True(t, exists)
assert.JSONEq(t, string(task.Data), string(stored.Data), "single-task lookups keep the snapshot")
}
func TestTaskArtifactAccessRequiresActiveOwner(t *testing.T) {
task := setupGenericTaskTest(t)
task.Action = constant.TaskActionTextToVideo
@@ -337,6 +402,87 @@ func TestDisabledArtifactStorePreservesPluginUpstreamContent(t *testing.T) {
assert.Equal(t, "bytes 0-13/14", recorder.Header().Get("Content-Range"))
}
// hailuo looks the MiniMax download link up with utils.fetch; the signed OSS
// link then gets a plain GET without the channel key, even for HEAD clients.
func TestTaskArtifactContentFollowsHailuoRetrieveLink(t *testing.T) {
task := setupGenericTaskTest(t)
var mu sync.Mutex
var ossRequests []string
var upstream *httptest.Server
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/files/retrieve":
assert.Equal(t, "Bearer provider-key", r.Header.Get("Authorization"))
if r.URL.Query().Get("file_id") == "login-fail" {
_, _ = w.Write([]byte(`{"base_resp":{"status_code":1004,"status_msg":"login fail"}}`))
return
}
_, _ = w.Write([]byte(`{"file":{"download_url":"` + upstream.URL + `/oss/video.mp4?Expires=1&Signature=sig"},"base_resp":{"status_code":0}}`))
case "/oss/video.mp4":
mu.Lock()
ossRequests = append(ossRequests, r.Method+" range="+r.Header.Get("Range")+" authorization="+r.Header.Get("Authorization"))
mu.Unlock()
w.Header().Set("Content-Type", "video/mp4")
if r.Header.Get("Range") != "" {
w.Header().Set("Content-Range", "bytes 0-3/10")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("mp4!"))
return
}
_, _ = w.Write([]byte("mp4!video!"))
default:
t.Errorf("unexpected upstream request %s", r.URL.Path)
}
}))
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.ChannelTypeMiniMax,
"key": "provider-key",
"base_url": upstream.URL,
}).Error)
task.Platform = constant.TaskPlatform("hailuo")
task.PrivateData.Execution = &model.TaskExecutionSnapshot{TaskPlugin: &model.TaskPluginSnapshot{
Key: "hailuo", Name: "Hailuo", Version: "1.2.0", APIVersion: 1,
}}
request := func(method, fileID string) *httptest.ResponseRecorder {
task.SetData(map[string]any{"file_id": fileID})
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}, {Key: "artifact_key", Value: "video"}}
c.Request = httptest.NewRequest(method, "/v1/tasks/"+task.TaskID+"/artifacts/video/content", nil)
if method == http.MethodGet {
c.Request.Header.Set("Range", "bytes=0-3")
}
TaskArtifactContent(c)
return recorder
}
ranged := request(http.MethodGet, "file-1")
assert.Equal(t, http.StatusPartialContent, ranged.Code)
assert.Equal(t, "mp4!", ranged.Body.String())
assert.Equal(t, "bytes 0-3/10", ranged.Header().Get("Content-Range"))
head := request(http.MethodHead, "file-1")
assert.Equal(t, http.StatusOK, head.Code)
assert.Empty(t, head.Body.String())
assert.Equal(t, "video/mp4", head.Header().Get("Content-Type"))
failed := request(http.MethodGet, "login-fail")
assert.Equal(t, http.StatusInternalServerError, failed.Code)
assert.Contains(t, failed.Body.String(), "artifact_plugin_error")
mu.Lock()
defer mu.Unlock()
assert.Equal(t, []string{"GET range=bytes=0-3 authorization=", "GET range= authorization="}, ossRequests)
}
func TestProjectedTaskArtifactValidationRejectsAmbiguousIdentity(t *testing.T) {
validated, err := validateProjectedTaskArtifacts([]relaychannel.TaskArtifact{
{Key: "video-main", Type: "video", MimeType: "video/mp4"},
+41 -17
View File
@@ -15,7 +15,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
@@ -25,12 +25,12 @@ import (
"gorm.io/gorm"
)
const maxTaskPluginSourceBytes = 1024 * 1024
const maxTaskPluginSourceBytes = 8 << 20
func taskPluginCompileError(c *gin.Context, err error) {
var unknownField *jsplugin.UnknownMetaFieldError
if errors.As(err, &unknownField) {
common.ApiErrorI18n(c, i18n.MsgTaskPluginUnknownMetaField, map[string]any{"Field": unknownField.Field})
common.ApiErrorT(c, "Plugin metadata contains an unknown field \"{{field}}\". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.", map[string]any{"field": unknownField.Field})
return
}
common.ApiError(c, err)
@@ -54,7 +54,7 @@ func UploadTaskPlugin(c *gin.Context) {
return
}
if len(request.Source) > maxTaskPluginSourceBytes {
common.ApiErrorMsg(c, "plugin source exceeds 1 MiB")
common.ApiErrorMsg(c, "plugin source exceeds 8 MiB")
return
}
if expected := strings.TrimSpace(request.SourceSha256); expected != "" {
@@ -93,8 +93,8 @@ func UploadTaskPlugin(c *gin.Context) {
}
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,
Source: model.LongText(request.Source), SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))),
Icon: model.LongText(icon), Enabled: enabled, Remark: request.Remark,
}
if err = model.SaveTaskPlugin(&plugin); err != nil {
common.ApiError(c, err)
@@ -104,7 +104,7 @@ func UploadTaskPlugin(c *gin.Context) {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: string(plugin.Source), Layer: "override", HasIcon: plugin.HasIcon()})
}
func GetTaskPluginVersions(c *gin.Context) {
@@ -194,7 +194,7 @@ func ListTaskPlugins(c *gin.Context) {
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 {
if compiled, compileErr := jsplugin.NewRegistry().Register(string(row.Source), jsplugin.Options{Key: row.Key, Version: row.Version}); compileErr == nil {
item.Meta = compiled.Meta
}
item.Enabled = row.Enabled
@@ -315,7 +315,7 @@ func GetTaskPluginIcon(c *gin.Context) {
icon := ""
plugin, err := model.GetTaskPluginVersion(key, c.Query("version"))
if err == nil {
icon = plugin.Icon
icon = string(plugin.Icon)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiError(c, err)
return
@@ -343,12 +343,12 @@ func GetTaskPlugin(c *gin.Context) {
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})
loaded, compileErr := jsplugin.NewRegistry().Register(string(plugin.Source), jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
if compileErr != nil {
taskPluginCompileError(c, compileErr)
return
}
common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: string(plugin.Source), Layer: "override", HasIcon: plugin.HasIcon()})
return
}
if !errors.Is(err, gorm.ErrRecordNotFound) || version != "" {
@@ -384,7 +384,7 @@ func DryRunTaskPlugin(c *gin.Context) {
detailSource := ""
plugin, err := model.GetTaskPluginVersion(c.Param("key"), "")
if err == nil {
detailSource = plugin.Source
detailSource = string(plugin.Source)
} else if errors.Is(err, gorm.ErrRecordNotFound) {
detailSource, err = plugins.Source(c.Param("key"))
}
@@ -482,7 +482,7 @@ func ActivateTaskPlugin(c *gin.Context) {
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 {
if _, err = jsplugin.NewRegistry().Register(string(target.Source), jsplugin.Options{Key: target.Key, Version: target.Version}); err != nil {
taskPluginCompileError(c, err)
return
}
@@ -509,6 +509,7 @@ func SetTaskPluginStatus(c *gin.Context) {
}
key := c.Param("key")
disabledChannels := 0
unboundChannels := 0
if !*request.Enabled {
channels, inFlight, usageErr := model.GetTaskPluginUsage(key)
if usageErr != nil {
@@ -523,10 +524,26 @@ func SetTaskPluginStatus(c *gin.Context) {
}
if cascade {
for _, channel := range channels {
if channel.Type == constant.ChannelTypeNewAPI {
// A gateway channel also carries ordinary traffic, so the
// cascade only drops this plugin from its bindings.
unbound, unbindErr := model.UnbindTaskPlugin(channel.Id, key)
if unbindErr != nil {
common.ApiError(c, unbindErr)
return
}
if unbound {
unboundChannels++
}
continue
}
if model.UpdateChannelStatus(channel.Id, "", common.ChannelStatusManuallyDisabled, "task plugin disabled") {
disabledChannels++
}
}
if unboundChannels > 0 {
model.InitChannelCache()
}
}
}
_, lookupErr := model.GetTaskPluginVersion(key, "")
@@ -567,7 +584,7 @@ func SetTaskPluginStatus(c *gin.Context) {
return
}
if !hasActiveOverride {
common.ApiSuccess(c, gin.H{"plugin_enabled": *request.Enabled, "disabled_channels": disabledChannels})
common.ApiSuccess(c, gin.H{"plugin_enabled": *request.Enabled, "disabled_channels": disabledChannels, "unbound_channels": unboundChannels})
return
}
}
@@ -579,7 +596,7 @@ func SetTaskPluginStatus(c *gin.Context) {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{"plugin_enabled": *request.Enabled, "disabled_channels": disabledChannels})
common.ApiSuccess(c, gin.H{"plugin_enabled": *request.Enabled, "disabled_channels": disabledChannels, "unbound_channels": unboundChannels})
}
func taskPluginHasFactory(key string) bool {
@@ -669,6 +686,7 @@ func GetTaskPluginOptions(c *gin.Context) {
"website": meta.Website,
"models": meta.Models,
"channelTypes": meta.ChannelTypes,
"upstreams": meta.Upstreams,
"usageSchema": meta.UsageSchema,
"usageProfiles": meta.UsageProfiles,
})
@@ -751,7 +769,13 @@ func syncTaskPluginsOnceContext(ctx context.Context) error {
plugin.Key,
plugin.Version,
)
compiled, compileErr := jsplugin.CompilePlugin(plugin.Source, jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
var compiled *jsplugin.LoadedPlugin
source, compileErr := model.GetTaskPluginSource(plugin.Id)
if compileErr != nil {
compileErr = fmt.Errorf("load source: %w", compileErr)
} else {
compiled, compileErr = jsplugin.CompilePlugin(string(source), jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
}
if compileErr != nil {
retainedIncumbent := false
if current := currentOverrides[plugin.Key]; current != nil {
@@ -762,7 +786,7 @@ func syncTaskPluginsOnceContext(ctx context.Context) error {
}
}
taskPluginSyncState.errors[plugin.Key] = compileErr.Error()
common.SysError(fmt.Sprintf("compile task plugin %s@%s: %v", plugin.Key, plugin.Version, compileErr))
common.SysError(common.LogText("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",
+118 -44
View File
@@ -13,7 +13,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/plugins"
@@ -106,6 +105,9 @@ func TestDisableThirdPartyPluginSupportsCascadeAndForce(t *testing.T) {
setting := `{"task_plugin_key":"lifecycle-only"}`
channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "linked", Models: "doc", Group: "default", BaseURL: &baseURL, Setting: &setting}
require.NoError(t, channel.Insert())
gatewaySetting := `{"task_extend_plugin_keys":["lifecycle-only","other"]}`
gateway := model.Channel{Type: constant.ChannelTypeNewAPI, Status: common.ChannelStatusEnabled, Name: "gateway", Models: "doc,gpt", Group: "default", BaseURL: &baseURL, Setting: &gatewaySetting}
require.NoError(t, gateway.Insert())
require.NoError(t, model.DB.Create(&model.Task{Platform: "lifecycle-only", Status: model.TaskStatusSubmitted}).Error)
recorder := httptest.NewRecorder()
@@ -116,9 +118,15 @@ func TestDisableThirdPartyPluginSupportsCascadeAndForce(t *testing.T) {
SetTaskPluginStatus(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
assert.Contains(t, recorder.Body.String(), `"disabled_channels":1`)
assert.Contains(t, recorder.Body.String(), `"unbound_channels":1`)
updated, err := model.GetChannelById(channel.Id, true)
require.NoError(t, err)
assert.Equal(t, common.ChannelStatusManuallyDisabled, updated.Status)
updatedGateway, err := model.GetChannelById(gateway.Id, true)
require.NoError(t, err)
assert.Equal(t, common.ChannelStatusEnabled, updatedGateway.Status, "a gateway channel keeps serving its other traffic")
assert.Equal(t, []string{"other"}, updatedGateway.GetSetting().TaskExtendPluginKeys, "only the disabled plugin is unbound")
}
// klingFactoryVersion returns the version declared in the embedded kling factory
@@ -263,7 +271,7 @@ func TestDisableFactoryOverrideRowSuppressesBothLayers(t *testing.T) {
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
plugin := model.TaskPlugin{
Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: overrideSource, SourceHash: "test-hash", Enabled: true,
Source: model.LongText(overrideSource), SourceHash: "test-hash", Enabled: true,
}
require.NoError(t, model.SaveTaskPlugin(&plugin))
require.NoError(t, syncTaskPluginsOnce())
@@ -317,6 +325,77 @@ export function parseTaskResult() { return {}; }
assert.True(t, ok)
}
// The shipped alibaba plugin is already larger than a MySQL TEXT column
// (64 KiB), so storing it through the model path the upload endpoint uses and
// reading it back unchanged proves the source and icon columns are not
// truncated. Set TEST_TASK_DB_DIALECT plus TEST_MYSQL_DSN or TEST_POSTGRES_DSN
// to run the same contract against a real MySQL or PostgreSQL server.
func TestTaskPluginSourceAboveMySQLTextLimitRoundTrips(t *testing.T) {
database, _ := openTaskDialectDatabase(t, &model.TaskPlugin{})
originalDB := model.DB
model.DB = database
t.Cleanup(func() { model.DB = originalDB })
source, err := plugins.Source("alibaba")
require.NoError(t, err)
require.Greater(t, len(source), 65535, "fixture must exceed the MySQL TEXT limit")
icon := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(strings.Repeat(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`, 2048)))
require.Greater(t, len(icon), 65535, "icon fixture must exceed the MySQL TEXT limit")
plugin := model.TaskPlugin{Key: "alibaba", APIVersion: 1, Version: "roundtrip", Source: model.LongText(source), SourceHash: "hash", Enabled: true}
require.NoError(t, model.SaveTaskPlugin(&plugin))
// Re-saving the same version attaches the icon through the update path.
withIcon := model.TaskPlugin{Key: "alibaba", APIVersion: 1, Version: "roundtrip", Source: model.LongText(source), SourceHash: "hash", Icon: model.LongText(icon), Enabled: true}
require.NoError(t, model.SaveTaskPlugin(&withIcon))
stored, err := model.GetTaskPluginVersion("alibaba", "roundtrip")
require.NoError(t, err)
assert.Equal(t, source, string(stored.Source))
assert.Equal(t, icon, string(stored.Icon))
// The 30-second sync poll must not pull these payloads; it reads hashes
// and fetches source only for rows it has to recompile.
snapshot, err := model.GetTaskPluginSyncSnapshot()
require.NoError(t, err)
require.Len(t, snapshot.Plugins, 1)
assert.Equal(t, stored.Id, snapshot.Plugins[0].Id)
assert.Equal(t, "hash", snapshot.Plugins[0].SourceHash)
assert.Empty(t, snapshot.Plugins[0].Source)
assert.Empty(t, snapshot.Plugins[0].Icon)
loaded, err := model.GetTaskPluginSource(snapshot.Plugins[0].Id)
require.NoError(t, err)
assert.Equal(t, source, string(loaded))
}
func TestUploadTaskPluginAcceptsSourcesUpToEightMiB(t *testing.T) {
setupTaskPluginControllerTest(t)
cleanupTaskPluginControllerRuntime(t, "large-source")
upload := func(source string) *httptest.ResponseRecorder {
body, err := common.Marshal(map[string]any{"source": source})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
return recorder
}
// A trailing comment pads a valid plugin past the former 1 MiB cap.
accepted := taskPluginControllerTestSource("large-source", "1.0.0") + "// " + strings.Repeat("x", 2<<20) + "\n"
recorder := upload(accepted)
require.Contains(t, recorder.Body.String(), `"success":true`)
stored, err := model.GetTaskPluginVersion("large-source", "1.0.0")
require.NoError(t, err)
assert.Equal(t, accepted, string(stored.Source))
rejected := upload(accepted + strings.Repeat("x", 6<<20))
assert.Contains(t, rejected.Body.String(), `"success":false`)
assert.Contains(t, rejected.Body.String(), "plugin source exceeds 8 MiB")
_, err = model.GetTaskPluginVersion("large-source", "2.0.0")
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
func TestListTaskPluginsIncludesFactoryWithoutDatabaseRows(t *testing.T) {
setupTaskPluginControllerTest(t)
recorder := httptest.NewRecorder()
@@ -368,6 +447,7 @@ export const meta = {
description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"},
icon: "text:UO", baseUrl: "http://localhost:9000/",
channelTypes: [1990, 1991],
upstreams: ["vendor", "new_api"],
models: ["usage-options-model"], fetchMode: "per_task",
usageSchema: {seconds: {type: "number", unit: "second", description: "Video generation unit price"}},
usageProfiles: [{models:["usage-options-model"],schema:{image_count:{type:"number",unit:"count"}}}]
@@ -395,6 +475,7 @@ export function parseTaskResult() { return {}; }
Icon string `json:"icon"`
BaseURL string `json:"baseUrl"`
ChannelTypes []int `json:"channelTypes"`
Upstreams []string `json:"upstreams"`
UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usageSchema"`
UsageProfiles []jsplugin.UsageProfile `json:"usageProfiles"`
} `json:"data"`
@@ -413,6 +494,7 @@ export function parseTaskResult() { return {}; }
assert.Equal(t, "count", option.UsageProfiles[0].Schema["image_count"].Unit)
assert.Equal(t, "text:UO", option.Icon)
assert.Equal(t, []int{1990, 1991}, option.ChannelTypes)
assert.Equal(t, []string{"vendor", "new_api"}, option.Upstreams, "the New API channel form lists only plugins declaring new_api")
assert.Equal(t, "http://localhost:9000", option.BaseURL, "the drawer prefills the normalized plugin default")
return
}
@@ -431,7 +513,7 @@ func TestDeleteActiveOverrideFallsBackToFactoryAndDeletesRecord(t *testing.T) {
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
plugin := model.TaskPlugin{
Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: overrideSource, SourceHash: "test-hash", Enabled: true,
Source: model.LongText(overrideSource), SourceHash: "test-hash", Enabled: true,
}
require.NoError(t, model.SaveTaskPlugin(&plugin))
recorder := httptest.NewRecorder()
@@ -457,10 +539,10 @@ func TestDeleteActiveTaskPluginPromotesEnabledVersionInRuntime(t *testing.T) {
v1Source := taskPluginControllerTestSource(key, "1.0.0")
v2Source := taskPluginControllerTestSource(key, "2.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "hash-v1", Enabled: true,
Key: key, APIVersion: 1, Version: "1.0.0", Source: model.LongText(v1Source), SourceHash: "hash-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "hash-v2", Enabled: true,
Key: key, APIVersion: 1, Version: "2.0.0", Source: model.LongText(v2Source), SourceHash: "hash-v2", Enabled: true,
}))
_, err := jsplugin.DefaultRegistry.Register(v1Source, jsplugin.Options{Key: key, Version: "1.0.0"})
require.NoError(t, err)
@@ -526,10 +608,10 @@ func TestActivateTaskPluginRefreshesRuntimeSyncState(t *testing.T) {
v1Source := taskPluginControllerTestSource(key, "1.0.0")
v2Source := taskPluginControllerTestSource(key, "2.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "hash-v1", Enabled: true,
Key: key, APIVersion: 1, Version: "1.0.0", Source: model.LongText(v1Source), SourceHash: "hash-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "hash-v2", Enabled: true,
Key: key, APIVersion: 1, Version: "2.0.0", Source: model.LongText(v2Source), SourceHash: "hash-v2", Enabled: true,
}))
_, err := jsplugin.DefaultRegistry.Register(v1Source, jsplugin.Options{Key: key, Version: "1.0.0"})
require.NoError(t, err)
@@ -566,10 +648,10 @@ func TestSyncTaskPluginsPublishesOneGenerationForWholeBatch(t *testing.T) {
firstSource := taskPluginControllerTestSource(firstKey, "1.0.0")
secondSource := taskPluginControllerTestSource(secondKey, "1.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: firstKey, APIVersion: 1, Version: "1.0.0", Source: firstSource, SourceHash: "first-hash", Enabled: true,
Key: firstKey, APIVersion: 1, Version: "1.0.0", Source: model.LongText(firstSource), SourceHash: "first-hash", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: secondKey, APIVersion: 1, Version: "1.0.0", Source: secondSource, SourceHash: "second-hash", Enabled: true,
Key: secondKey, APIVersion: 1, Version: "1.0.0", Source: model.LongText(secondSource), SourceHash: "second-hash", Enabled: true,
}))
before := jsplugin.DefaultRegistry.Generation().Number
@@ -602,7 +684,7 @@ func TestTaskPluginRuntimeExposesDatabaseRevisionAheadOfLocalGeneration(t *testi
v1Source := taskPluginControllerTestSource(key, "1.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0",
Source: v1Source, SourceHash: "runtime-v1", Enabled: true,
Source: model.LongText(v1Source), SourceHash: "runtime-v1", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
localGeneration := jsplugin.DefaultRegistry.Generation().Number
@@ -613,7 +695,7 @@ func TestTaskPluginRuntimeExposesDatabaseRevisionAheadOfLocalGeneration(t *testi
v2Source := taskPluginControllerTestSource(key, "2.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0",
Source: v2Source, SourceHash: "runtime-v2", Enabled: true,
Source: model.LongText(v2Source), SourceHash: "runtime-v2", Enabled: true,
}))
require.NoError(t, model.ActivateTaskPlugin(key, "2.0.0"))
@@ -693,7 +775,7 @@ func TestTaskPluginRuntimeSurvivesDatabaseSyncFailure(t *testing.T) {
source := taskPluginControllerTestSource(key, "1.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0",
Source: source, SourceHash: "runtime-database-v1", Enabled: true,
Source: model.LongText(source), SourceHash: "runtime-database-v1", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
generation := jsplugin.DefaultRegistry.Generation().Number
@@ -735,17 +817,17 @@ func TestSyncTaskPluginsCachesRejectedDesiredSourceWithoutLosingIncumbent(t *tes
v2Source := taskPluginControllerChannelSource(pluginKey, "2.0.0", 9002)
ownerSource := taskPluginControllerChannelSource(ownerKey, "1.0.0", 9002)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: pluginKey, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "retained-v1", Enabled: true,
Key: pluginKey, APIVersion: 1, Version: "1.0.0", Source: model.LongText(v1Source), SourceHash: "retained-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: ownerKey, APIVersion: 1, Version: "1.0.0", Source: ownerSource, SourceHash: "owner-v1", Enabled: true,
Key: ownerKey, APIVersion: 1, Version: "1.0.0", Source: model.LongText(ownerSource), SourceHash: "owner-v1", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
incumbent, ok := jsplugin.DefaultRegistry.Get(pluginKey)
require.True(t, ok)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: pluginKey, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "retained-v2", Enabled: true,
Key: pluginKey, APIVersion: 1, Version: "2.0.0", Source: model.LongText(v2Source), SourceHash: "retained-v2", Enabled: true,
}))
require.NoError(t, model.ActivateTaskPlugin(pluginKey, "2.0.0"))
@@ -767,7 +849,7 @@ func TestSyncTaskPluginsCachesRejectedDesiredSourceWithoutLosingIncumbent(t *tes
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: ownerKey, APIVersion: 1, Version: "2.0.0",
Source: taskPluginControllerChannelSource(ownerKey, "2.0.0", 9003), SourceHash: "owner-v2", Enabled: true,
Source: model.LongText(taskPluginControllerChannelSource(ownerKey, "2.0.0", 9003)), SourceHash: "owner-v2", Enabled: true,
}))
require.NoError(t, model.ActivateTaskPlugin(ownerKey, "2.0.0"))
require.NoError(t, syncTaskPluginsOnce())
@@ -900,37 +982,29 @@ func TestUploadTaskPluginPreflightConflict(t *testing.T) {
}
}
func TestUploadTaskPluginLocalizesUnknownMetaField(t *testing.T) {
require.NoError(t, i18n.Init())
func TestUploadTaskPluginReportsUnknownMetaField(t *testing.T) {
body, err := common.Marshal(map[string]any{
"source": `export const meta = { futureField: true };`,
})
require.NoError(t, err)
for _, tc := range []struct {
language string
message string
}{
{"en", `Plugin metadata contains an unknown field "futureField". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.`},
{"zh-CN", "插件元数据包含未知字段“futureField”。如果插件来自官方市场,可能需要更高版本的 new-api。请尝试更新 new-api 后重新安装插件。"},
{"zh-TW", "外掛中繼資料包含未知欄位「futureField」。如果外掛來自官方市集,可能需要較新版本的 new-api。請嘗試更新 new-api 後重新安裝外掛。"},
} {
t.Run(tc.language, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Accept-Language", tc.language)
UploadTaskPlugin(c)
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)
assert.Equal(t, tc.message, response.Message)
})
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Accept-Language", "zh-CN")
UploadTaskPlugin(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Message string `json:"message"`
MessageKey string `json:"message_key"`
MessageParams map[string]any `json:"message_params"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
assert.Equal(t, `Plugin metadata contains an unknown field "futureField". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.`, response.Message)
assert.Equal(t, `Plugin metadata contains an unknown field "{{field}}". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.`, response.MessageKey)
assert.Equal(t, map[string]any{"field": "futureField"}, response.MessageParams)
}
func TestUploadTaskPluginRejectsMetaViolatingV1Schema(t *testing.T) {
@@ -1186,7 +1260,7 @@ func TestTaskPluginDisplayOrderAndMetadata(t *testing.T) {
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
cleanupTaskPluginControllerRuntime(t, key)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: key, Version: "1.0.0", APIVersion: 1, Source: source, Enabled: true}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: key, Version: "1.0.0", APIVersion: 1, Source: model.LongText(source), Enabled: true}))
}
for _, endpoint := range []string{"list", "options"} {
t.Run(endpoint, func(t *testing.T) {
@@ -1256,7 +1330,7 @@ func TestUploadTaskPluginStoresSidecarIconAndServesIt(t *testing.T) {
assert.NotContains(t, accepted.Body.String(), "base64,", "icon bytes never travel inside detail JSON")
stored, err := model.GetTaskPluginVersion(key, "1.0.0")
require.NoError(t, err)
assert.Equal(t, svgIcon, stored.Icon)
assert.Equal(t, svgIcon, string(stored.Icon))
item := listTaskPluginItem(t, key)
assert.True(t, item.HasIcon)
+1 -1
View File
@@ -24,7 +24,7 @@ func TestUpdateOptionRejectsRetiredFrontendTheme(t *testing.T) {
UpdateOption(context)
assert.Equal(t, http.StatusOK, response.Code)
assert.JSONEq(t, `{"success":false,"message":"Classic 前端已移除,主题只能设置为 default"}`, response.Body.String())
assert.JSONEq(t, `{"success":false,"message":"The Classic frontend has been removed. The theme can only be set to default","message_key":"The Classic frontend has been removed. The theme can only be set to default"}`, response.Body.String())
}
func TestGetStatusAdvertisesDefaultDashboard(t *testing.T) {
+28 -31
View File
@@ -1,7 +1,6 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"strings"
@@ -9,6 +8,7 @@ import (
"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/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
@@ -80,7 +80,7 @@ func buildMaskedTokenResponse(token *model.Token) *tokenResponse {
maskedToken.Key = token.GetMaskedKey()
groupPool, err := token.GetGroupPool()
if err != nil {
common.SysError(fmt.Sprintf("failed to parse auto groups for token %d: %v", token.Id, err))
common.SysError(common.LogText("failed to parse auto groups for token %d: %v", token.Id, err))
groupPool = nil
}
autoGroups := make([]string, 0, len(groupPool))
@@ -123,7 +123,7 @@ func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) boo
maxCount := setting.GetMaxTokenAutoGroups()
if len(groups) > maxCount {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsTooMany, map[string]any{"Max": maxCount})
common.ApiErrorT(c, "A token can select at most {{max}} Auto groups", map[string]any{"max": maxCount})
return false
}
@@ -135,12 +135,12 @@ func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) boo
seen := make(map[string]struct{}, len(groups))
for _, group := range groups {
if _, ok := seen[group]; ok {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsDuplicate, map[string]any{"Group": group})
common.ApiErrorT(c, "Auto group {{group}} is duplicated", map[string]any{"group": group})
return false
}
seen[group] = struct{}{}
if !service.IsUserSelectableGroup(userGroup, group) {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsInvalid, map[string]any{"Group": group})
common.ApiErrorT(c, "Auto group {{group}} is unavailable or unauthorized", map[string]any{"group": group})
return false
}
}
@@ -155,12 +155,12 @@ func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) boo
func setTokenBackupGroup(c *gin.Context, token *model.Token, groups []string) bool {
maxCount := setting.GetMaxTokenAutoGroups()
if len(groups) > maxCount {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsTooMany, map[string]any{"Max": maxCount})
common.ApiErrorT(c, "A token can select at most {{max}} Auto groups", map[string]any{"max": maxCount})
return false
}
for _, group := range groups {
if group == token.Group {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsDuplicate, map[string]any{"Group": group})
common.ApiErrorT(c, "Auto group {{group}} is duplicated", map[string]any{"group": group})
return false
}
}
@@ -174,7 +174,7 @@ func setTokenBackupGroup(c *gin.Context, token *model.Token, groups []string) bo
func setTokenGroupPool(c *gin.Context, token *model.Token, entries []model.TokenGroupEntry) bool {
maxCount := setting.GetMaxTokenAutoGroups()
if len(entries) > maxCount {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsTooMany, map[string]any{"Max": maxCount})
common.ApiErrorT(c, "A token can select at most {{max}} Auto groups", map[string]any{"max": maxCount})
return false
}
userGroup, err := getTokenRequestUserGroup(c)
@@ -185,16 +185,16 @@ func setTokenGroupPool(c *gin.Context, token *model.Token, entries []model.Token
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
if entry.Group == token.Group {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsDuplicate, map[string]any{"Group": entry.Group})
common.ApiErrorT(c, "Auto group {{group}} is duplicated", map[string]any{"group": entry.Group})
return false
}
if _, ok := seen[entry.Group]; ok {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsDuplicate, map[string]any{"Group": entry.Group})
common.ApiErrorT(c, "Auto group {{group}} is duplicated", map[string]any{"group": entry.Group})
return false
}
seen[entry.Group] = struct{}{}
if !service.IsUserSelectableGroup(userGroup, entry.Group) {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsInvalid, map[string]any{"Group": entry.Group})
common.ApiErrorT(c, "Auto group {{group}} is unavailable or unauthorized", map[string]any{"group": entry.Group})
return false
}
}
@@ -327,7 +327,7 @@ func GetTokenUsage(c *gin.Context) {
token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
if err != nil {
common.SysError("failed to get token by key: " + err.Error())
common.SysError(common.LogText("failed to get token by key: %s", err.Error()))
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
@@ -373,7 +373,7 @@ func AddToken(c *gin.Context) {
token.AstraAutoSwitch = false
}
if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
common.ApiErrorT(c, "Token name is too long")
return
}
params := tokenAuditParams(c)
@@ -381,12 +381,12 @@ func AddToken(c *gin.Context) {
// 非无限额度时,检查额度值是否超出有效范围
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
common.ApiErrorT(c, "Quota value cannot be negative")
return
}
maxQuotaValue := maxTokenQuota()
if token.RemainQuota > maxQuotaValue {
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
common.ApiErrorT(c, "Quota value exceeds valid range, maximum is {{max}}", map[string]any{"max": logger.FormatQuota(maxQuotaValue)})
return
}
}
@@ -398,10 +398,7 @@ func AddToken(c *gin.Context) {
return
}
if int(count) >= maxTokens {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("已达到最大令牌数量限制 (%d)", maxTokens),
})
common.ApiErrorT(c, "Maximum number of tokens reached ({{max}})", map[string]any{"max": maxTokens})
return
}
if request.RoutingPolicy != nil {
@@ -436,8 +433,8 @@ func AddToken(c *gin.Context) {
}
key, err := common.GenerateKey()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed)
common.SysLog("failed to generate token key: " + err.Error())
common.ApiErrorT(c, "Failed to generate token")
common.SysLog(common.LogText("failed to generate token key: %s", err.Error()))
return
}
cleanToken := model.Token{
@@ -509,17 +506,17 @@ func UpdateToken(c *gin.Context) {
params["id"] = token.Id
}
if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
common.ApiErrorT(c, "Token name is too long")
return
}
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
common.ApiErrorT(c, "Quota value cannot be negative")
return
}
maxQuotaValue := maxTokenQuota()
if token.RemainQuota > maxQuotaValue {
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
common.ApiErrorT(c, "Quota value exceeds valid range, maximum is {{max}}", map[string]any{"max": logger.FormatQuota(maxQuotaValue)})
return
}
}
@@ -532,11 +529,11 @@ func UpdateToken(c *gin.Context) {
previous := *cleanToken
if token.Status == common.TokenStatusEnabled {
if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable)
common.ApiErrorT(c, "Token has expired and cannot be enabled. Please modify the expiration time or set it to never expire")
return
}
if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota {
common.ApiErrorI18n(c, i18n.MsgTokenExhaustedCannotEable)
common.ApiErrorT(c, "Token quota is exhausted and cannot be enabled. Please modify the remaining quota or set it to unlimited")
return
}
}
@@ -632,12 +629,12 @@ type TokenBatch struct {
func DeleteTokenBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
params := tokenBatchAuditParams(c, tokenBatch.Ids)
if len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
userId := c.GetInt("id")
@@ -658,16 +655,16 @@ func DeleteTokenBatch(c *gin.Context) {
func GetTokenKeysBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
params := tokenBatchAuditParams(c, tokenBatch.Ids)
if len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
if len(tokenBatch.Ids) > 100 {
common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
common.ApiErrorT(c, "Too many items in batch request, maximum is {{max}}", map[string]any{"max": 100})
return
}
userId := c.GetInt("id")
+123
View File
@@ -6,9 +6,11 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"os"
"slices"
"strconv"
"strings"
"testing"
@@ -453,6 +455,8 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
if migratedToken.AutoGroups != "" {
t.Fatalf("expected legacy token to inherit global Auto groups, got %q", migratedToken.AutoGroups)
}
require.True(t, db.Migrator().HasColumn(&model.Token{}, "allow_user_quota_query"))
assert.False(t, migratedToken.AllowUserQuotaQuery, "tokens created before the upgrade keep user quota queries off")
inserted := model.Token{
UserId: 8,
@@ -670,6 +674,124 @@ func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
}
}
func TestTokenAllowUserQuotaQueryOnCreateAndUpdate(t *testing.T) {
for _, tc := range []struct {
name string
target string
stored *bool // nil creates the token through the API
field any // nil omits allow_user_quota_query from the request
want bool
}{
{name: "create without the field turns it on", target: "/api/token/", want: true},
{name: "create with false keeps it off", target: "/api/token/", field: false},
{name: "update without the field keeps it on", target: "/api/token/", stored: common.GetPointer(true), want: true},
{name: "update without the field keeps it off", target: "/api/token/", stored: common.GetPointer(false)},
{name: "update turns it off", target: "/api/token/", stored: common.GetPointer(true), field: false},
{name: "update turns it on", target: "/api/token/", stored: common.GetPointer(false), field: true, want: true},
{name: "status update leaves it alone", target: "/api/token/?status_only=true", stored: common.GetPointer(true), field: false, want: true},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupTokenControllerTestDB(t)
body := map[string]any{"name": "quota-query", "expired_time": -1, "unlimited_quota": true, "status": common.TokenStatusEnabled}
if tc.field != nil {
body["allow_user_quota_query"] = tc.field
}
var recorder *httptest.ResponseRecorder
if tc.stored == nil {
var ctx *gin.Context
ctx, recorder = newAuthenticatedContext(t, http.MethodPost, tc.target, body, 1)
AddToken(ctx)
} else {
token := seedToken(t, db, 1, "quota-query", "quota-query-key")
require.NoError(t, db.Model(token).Update("allow_user_quota_query", *tc.stored).Error)
body["id"] = token.Id
var ctx *gin.Context
ctx, recorder = newAuthenticatedContext(t, http.MethodPut, tc.target, body, 1)
UpdateToken(ctx)
}
response := decodeAPIResponse(t, recorder)
require.True(t, response.Success, response.Message)
var saved model.Token
require.NoError(t, db.Where("name = ?", "quota-query").First(&saved).Error)
assert.Equal(t, tc.want, saved.AllowUserQuotaQuery)
if tc.stored != nil {
var returned struct {
AllowUserQuotaQuery bool `json:"allow_user_quota_query"`
}
require.NoError(t, common.Unmarshal(response.Data, &returned))
assert.Equal(t, tc.want, returned.AllowUserQuotaQuery)
}
})
}
}
func TestGetTokenUsageFollowsTheUserQuotaSwitchAndIPWhitelist(t *testing.T) {
for _, tc := range []struct {
name string
allow bool
expired bool
allowIps string
remoteAddr string
wantStatus int
wantUserQuota bool
}{
{name: "switch off", remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusOK},
{name: "switch on without IP whitelist", allow: true, remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusOK, wantUserQuota: true},
{name: "switch on with matching IP whitelist", allow: true, allowIps: "198.51.100.1\n203.0.113.0/24", remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusOK, wantUserQuota: true},
{name: "expired key with the switch on", allow: true, expired: true, remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusOK, wantUserQuota: true},
{name: "switch on from an IP outside the whitelist", allow: true, allowIps: "198.51.100.1", remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusForbidden},
{name: "switch off from an IP outside the whitelist", allowIps: "198.51.100.1", remoteAddr: "203.0.113.7:4321", wantStatus: http.StatusForbidden},
{name: "unresolved client IP with a whitelist", allow: true, allowIps: "203.0.113.0/24", remoteAddr: "unresolved", wantStatus: http.StatusForbidden},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupTokenControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.User{}))
// Initialize production column quoting for the token key lookup.
t.Setenv("LOG_SQL_DSN", "")
require.NoError(t, model.InitLogDB())
user := &model.User{Username: "quota-owner", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", Quota: 12345, UsedQuota: 678, AuthVersion: 1, AffCode: "quota-owner"}
require.NoError(t, db.Create(user).Error)
token := seedToken(t, db, user.Id, "usage-token", "usagekey1234")
updates := map[string]any{"allow_user_quota_query": tc.allow, "allow_ips": tc.allowIps, "used_quota": 30}
if tc.expired {
updates["status"], updates["expired_time"] = common.TokenStatusExpired, 1
}
require.NoError(t, db.Model(token).Updates(updates).Error)
router := gin.New()
router.GET("/api/usage/token/", middleware.TokenAuthReadOnly(), GetTokenUsage)
request := httptest.NewRequest(http.MethodGet, "/api/usage/token/", nil)
request.Header.Set("Authorization", "Bearer sk-usagekey1234")
request.RemoteAddr = tc.remoteAddr
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
require.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String())
if tc.wantStatus != http.StatusOK {
assert.NotContains(t, recorder.Body.String(), "total_available")
return
}
var response struct {
Code bool `json:"code"`
Data map[string]json.RawMessage `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Code, recorder.Body.String())
fields := []string{"expires_at", "model_limits", "model_limits_enabled", "name", "object", "total_available", "total_granted", "total_used", "unlimited_quota"}
if tc.wantUserQuota {
fields = append(fields, "user_quota")
}
assert.Equal(t, fields, slices.Sorted(maps.Keys(response.Data)))
assert.JSONEq(t, `100`, string(response.Data["total_available"]))
assert.JSONEq(t, `30`, string(response.Data["total_used"]))
if tc.wantUserQuota {
assert.JSONEq(t, `{"total_available":12345,"total_used":678}`, string(response.Data["user_quota"]))
}
})
}
}
func TestAPITokenAuditDatabaseMatrix(t *testing.T) {
for _, database := range []struct {
name, env string
@@ -771,6 +893,7 @@ func verifyAPITokenAudit(t *testing.T) {
{name: "normalized update", method: "PUT", path: "/", body: `{"id":$id,"name":"renamed","expired_time":-1,"remain_quota":200,"unlimited_quota":true,"group":"default","cross_group_retry":true,"allow_ips":""}`, action: "token.update", success: true, params: `{"id":$id,"name":"renamed","changed_fields":["name","remain_quota","group","cross_group_retry","auto_groups"]}`},
{name: "configuration values stay private", method: "PUT", path: "/", body: `{"id":$id,"name":"owned","expired_time":42,"remain_quota":100,"unlimited_quota":false,"model_limits_enabled":true,"model_limits":"private-model-configuration","allow_ips":"203.0.113.57","group":"auto","cross_group_retry":true}`, action: "token.update", success: true, params: `{"id":$id,"name":"owned","changed_fields":["expired_time","unlimited_quota","model_limits_enabled","model_limits","allow_ips"]}`},
{name: "unchanged update", method: "PUT", path: "/", body: `{"id":$id,"name":"owned","expired_time":-1,"remain_quota":100,"unlimited_quota":true,"group":"auto","cross_group_retry":true,"allow_ips":""}`, action: "token.update", success: true, params: `{"id":$id,"name":"owned","changed_fields":[]}`},
{name: "user quota query update", method: "PUT", path: "/", body: `{"id":$id,"name":"owned","expired_time":-1,"remain_quota":100,"unlimited_quota":true,"group":"auto","cross_group_retry":true,"allow_ips":"","allow_user_quota_query":true}`, action: "token.update", success: true, params: `{"id":$id,"name":"owned","changed_fields":["allow_user_quota_query"]}`},
{name: "successful response exceeds audit buffer", method: "PUT", path: "/", body: `{"id":$id,"name":"owned","expired_time":-1,"remain_quota":100,"unlimited_quota":true,"group":"auto","cross_group_retry":true,"allow_ips":"","model_limits":"` + strings.Repeat("m", 64*1024-128) + `"}`, action: "token.update", success: true, params: `{"id":$id,"name":"owned","changed_fields":["model_limits"]}`},
{name: "update storage failure", method: "PUT", path: "/", body: `{"id":$id,"name":"failed-rename","unlimited_quota":true}`, action: "token.update", params: `{"id":$id,"name":"owned"}`, failWrite: true},
{name: "foreign update", method: "PUT", path: "/", body: `{"id":$other,"name":"forged-name","unlimited_quota":true}`, action: "token.update", params: `{"id":$other}`},
+54 -41
View File
@@ -3,6 +3,7 @@ package controller
import (
"errors"
"fmt"
"maps"
"net/http"
"net/url"
"strconv"
@@ -223,10 +224,10 @@ func getMaxTopUpAmount() int64 {
func validateCreditedQuota(quota decimal.Decimal) (int, error) {
value, err := common.WalletQuotaFromDecimalStrict(quota)
if err != nil {
return 0, errors.New("充值额度超出系统可表示范围")
return 0, common.NewMessage("Top-up quota exceeds the range the system can represent")
}
if value <= 0 {
return 0, errors.New("充值额度必须大于 0")
return 0, common.NewMessage("Top-up quota must be greater than 0")
}
return value, nil
}
@@ -238,9 +239,21 @@ func validateTopUpQuota(amount int64) (int, error) {
}
maxAmount := getMaxTopUpAmount()
if maxAmount > 0 && amount > maxAmount {
return 0, fmt.Errorf("单笔充值数量不能大于 %d", maxAmount)
return 0, common.NewMessage("Top-up amount cannot exceed {{max}}", map[string]any{"max": maxAmount})
}
return 0, errors.New("充值数量无效")
return 0, common.NewMessage("Invalid top-up amount")
}
// paymentError answers payment endpoints in their {"message": "error",
// "data": text} shape. When err is or wraps a *common.Message, the response
// also carries message_key and message_params for the web console.
func paymentError(c *gin.Context, err error) {
body := gin.H{"message": "error", "data": err.Error()}
var message *common.Message
if errors.As(err, &message) {
maps.Copy(body, message.Fields())
}
c.JSON(http.StatusOK, body)
}
func rejectInvalidCreditedQuota(c *gin.Context, userId int, quota decimal.Decimal) bool {
@@ -249,7 +262,7 @@ func rejectInvalidCreditedQuota(c *gin.Context, userId int, quota decimal.Decima
err = model.ValidateTopUpQuotaCapacity(userId, creditedQuota)
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
paymentError(c, err)
return true
}
return false
@@ -261,7 +274,7 @@ func rejectInvalidTopUpQuota(c *gin.Context, userId int, amount int64) bool {
err = model.ValidateTopUpQuotaCapacity(userId, creditedQuota)
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
paymentError(c, err)
return true
}
return false
@@ -271,11 +284,11 @@ func RequestEpay(c *gin.Context) {
var req EpayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
if req.Amount < getMinTopup() {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getMinTopup()}))
return
}
id := c.GetInt("id")
@@ -285,17 +298,17 @@ func RequestEpay(c *gin.Context) {
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
paymentError(c, common.NewMessage("Failed to get the user group"))
return
}
payMoney := getPayMoney(req.Amount, group)
if payMoney < 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
paymentError(c, common.NewMessage("Payment amount is too low"))
return
}
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付方式不存在"})
paymentError(c, common.NewMessage("Payment method does not exist"))
return
}
@@ -306,7 +319,7 @@ func RequestEpay(c *gin.Context) {
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
client := GetEpayClient()
if client == nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
paymentError(c, common.NewMessage("Payment information has not been configured by administrator"))
return
}
uri, params, err := client.Purchase(&epay.PurchaseArgs{
@@ -319,8 +332,8 @@ func RequestEpay(c *gin.Context) {
ReturnUrl: returnUrl,
})
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 拉起支付失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), common.LogText("Epay failed to start payment user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
paymentError(c, common.NewMessage("Failed to start payment"))
return
}
amount := req.Amount
@@ -341,11 +354,11 @@ func RequestEpay(c *gin.Context) {
}
err = topUp.Insert()
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 创建充值订单失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), common.LogText("Epay failed to create top-up order user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
paymentError(c, common.NewMessage("Failed to create order"))
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值订单创建成功 user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q", id, tradeNo, req.PaymentMethod, req.Amount, payMoney, uri, common.GetJsonString(params)))
logger.LogInfo(c.Request.Context(), common.LogText("Epay top-up order created user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q", id, tradeNo, req.PaymentMethod, req.Amount, payMoney, uri, common.GetJsonString(params)))
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
}
@@ -393,7 +406,7 @@ func UnlockOrder(tradeNo string) {
func EpayNotify(c *gin.Context) {
if !isEpayWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
logger.LogWarn(c.Request.Context(), common.LogText("Epay webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
_, _ = c.Writer.Write([]byte("fail"))
return
}
@@ -403,7 +416,7 @@ func EpayNotify(c *gin.Context) {
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook POST 表单解析失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to parse POST form path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
_, _ = c.Writer.Write([]byte("fail"))
return
}
@@ -418,35 +431,35 @@ func EpayNotify(c *gin.Context) {
return r
}, map[string]string{})
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 收到请求 path=%q client_ip=%s method=%s params=%q", c.Request.RequestURI, c.ClientIP(), c.Request.Method, common.GetJsonString(params)))
logger.LogInfo(c.Request.Context(), common.LogText("Epay webhook received request path=%q client_ip=%s method=%s params=%q", c.Request.RequestURI, c.ClientIP(), c.Request.Method, common.GetJsonString(params)))
if len(params) == 0 {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 参数为空 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
logger.LogWarn(c.Request.Context(), common.LogText("Epay webhook params are empty path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
_, _ = c.Writer.Write([]byte("fail"))
return
}
client := GetEpayClient()
if client == nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 client 未初始化 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
logger.LogError(c.Request.Context(), common.LogText("Epay client is not initialized path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
_, err := c.Writer.Write([]byte("fail"))
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
}
return
}
verifyInfo, err := client.Verify(params)
if err != nil || !verifyInfo.VerifyStatus {
if _, writeErr := c.Writer.Write([]byte("fail")); writeErr != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), writeErr.Error()))
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), writeErr.Error()))
}
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
logger.LogWarn(c.Request.Context(), common.LogText("Epay webhook signature verification failed path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
} else {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_status=false", c.Request.RequestURI, c.ClientIP()))
logger.LogWarn(c.Request.Context(), common.LogText("Epay webhook signature verification failed path=%q client_ip=%s verify_status=false", c.Request.RequestURI, c.ClientIP()))
}
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
logger.LogInfo(c.Request.Context(), common.LogText("Epay webhook signature verified trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
// 进程内锁只是优化;重复/并发回调的正确性由 RechargeEpay 的
@@ -457,29 +470,29 @@ func EpayNotify(c *gin.Context) {
if err != nil {
switch {
case errors.Is(err, model.ErrTopUpNotFound):
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
logger.LogWarn(c.Request.Context(), common.LogText("Epay callback order not found trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
case errors.Is(err, model.ErrPaymentMethodMismatch):
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付网关不匹配 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
logger.LogWarn(c.Request.Context(), common.LogText("Epay order payment gateway mismatch trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
case errors.Is(err, model.ErrTopUpStatusInvalid):
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单状态非法 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
logger.LogWarn(c.Request.Context(), common.LogText("Epay order status invalid trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
default:
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 充值处理失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Epay top-up processing failed trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
}
if _, writeErr := c.Writer.Write([]byte("fail")); writeErr != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
}
return
}
if alreadyDone {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 重复回调幂等忽略 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Epay duplicate callback ignored trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
} else {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Epay top-up succeeded trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
}
} else {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
logger.LogInfo(c.Request.Context(), common.LogText("Epay webhook ignored event trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
}
if _, writeErr := c.Writer.Write([]byte("success")); writeErr != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
logger.LogError(c.Request.Context(), common.LogText("Epay webhook failed to write response trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
}
}
@@ -487,12 +500,12 @@ func RequestAmount(c *gin.Context) {
var req AmountRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
if req.Amount < getMinTopup() {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getMinTopup()}))
return
}
id := c.GetInt("id")
@@ -501,12 +514,12 @@ func RequestAmount(c *gin.Context) {
}
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
paymentError(c, common.NewMessage("Failed to get the user group"))
return
}
payMoney := getPayMoney(req.Amount, group)
if payMoney <= 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
paymentError(c, common.NewMessage("Payment amount is too low"))
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
@@ -570,7 +583,7 @@ type AdminCompleteTopupRequest struct {
func AdminCompleteTopUp(c *gin.Context) {
var req AdminCompleteTopupRequest
if err := c.ShouldBindJSON(&req); err != nil || req.TradeNo == "" {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
+47 -47
View File
@@ -36,9 +36,9 @@ func generateCreemSignature(payload string, secret string) string {
// 验证Creem webhook签名
func verifyCreemSignature(payload string, signature string, secret string) bool {
if secret == "" {
logger.LogWarn(context.Background(), fmt.Sprintf("Creem webhook secret 未配置 test_mode=%t signature=%q body=%q", setting.CreemTestMode, signature, payload))
logger.LogWarn(context.Background(), common.LogText("Creem webhook secret is not configured test_mode=%t signature=%q body=%q", setting.CreemTestMode, signature, payload))
if setting.CreemTestMode {
logger.LogInfo(context.Background(), fmt.Sprintf("Creem webhook 验签已跳过 reason=test_mode signature=%q body=%q", signature, payload))
logger.LogInfo(context.Background(), common.LogText("Creem webhook signature check skipped reason=test_mode signature=%q body=%q", signature, payload))
return true
}
return false
@@ -66,12 +66,12 @@ type CreemAdaptor struct {
func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
if req.PaymentMethod != model.PaymentMethodCreem {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
paymentError(c, common.NewMessage("Unsupported payment channel"))
return
}
if req.ProductId == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "请选择产品"})
paymentError(c, common.NewMessage("Please select a product"))
return
}
@@ -79,8 +79,8 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
var products []CreemProduct
err := json.Unmarshal([]byte(setting.CreemProducts), &products)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 产品配置解析失败 user_id=%d error=%q", c.GetInt("id"), err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品配置错误"})
logger.LogError(c.Request.Context(), common.LogText("Creem failed to parse product config user_id=%d error=%q", c.GetInt("id"), err.Error()))
paymentError(c, common.NewMessage("Product configuration error"))
return
}
@@ -94,7 +94,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
}
if selectedProduct == nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
paymentError(c, common.NewMessage("Product does not exist"))
return
}
@@ -105,7 +105,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
user, err := model.GetUserById(id, false)
if err != nil || user == nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
paymentError(c, common.NewMessage("User does not exist"))
return
}
@@ -126,20 +126,20 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
}
err = topUp.Insert()
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建充值订单失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), common.LogText("Creem failed to create top-up order user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
paymentError(c, common.NewMessage("Failed to create order"))
return
}
// 创建支付链接,传入用户邮箱
checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, selectedProduct, user.Email, user.Username)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建支付链接失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), common.LogText("Creem failed to create payment link user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
paymentError(c, common.NewMessage("Failed to start payment"))
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单创建成功 user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f", id, referenceId, selectedProduct.ProductId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price))
logger.LogInfo(c.Request.Context(), common.LogText("Creem top-up order created user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f", id, referenceId, selectedProduct.ProductId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price))
c.JSON(http.StatusOK, gin.H{
"message": "success",
@@ -156,19 +156,19 @@ func RequestCreemPay(c *gin.Context) {
// 读取body内容用于打印,同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 支付请求读取失败 error=%q", err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Creem failed to read payment request error=%q", err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付请求已收到 user_id=%d body=%q", c.GetInt("id"), string(bodyBytes)))
logger.LogInfo(c.Request.Context(), common.LogText("Creem payment request received user_id=%d body=%q", c.GetInt("id"), string(bodyBytes)))
// 重新设置body供后续的ShouldBindJSON使用
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
err = c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
creemAdaptor.RequestPay(c, &req)
@@ -237,7 +237,7 @@ type CreemWebhookEvent struct {
func CreemWebhook(c *gin.Context) {
if !isCreemWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
logger.LogWarn(c.Request.Context(), common.LogText("Creem webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.AbortWithStatus(http.StatusForbidden)
return
}
@@ -245,28 +245,28 @@ func CreemWebhook(c *gin.Context) {
// 读取body内容用于打印,同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Creem webhook failed to read request body path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusBadRequest)
return
}
// 获取签名头
signature := c.GetHeader(CreemSignatureHeader)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
logger.LogInfo(c.Request.Context(), common.LogText("Creem webhook received request path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
if signature == "" {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少签名 path=%q client_ip=%s body=%q", c.Request.RequestURI, c.ClientIP(), string(bodyBytes)))
logger.LogWarn(c.Request.Context(), common.LogText("Creem webhook missing signature path=%q client_ip=%s body=%q", c.Request.RequestURI, c.ClientIP(), string(bodyBytes)))
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// 验证签名
if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
logger.LogWarn(c.Request.Context(), common.LogText("Creem webhook signature verification failed path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
c.AbortWithStatus(http.StatusUnauthorized)
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 验签成功 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Creem webhook signature verified path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
// 重新设置body供后续的ShouldBindJSON使用
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
@@ -274,19 +274,19 @@ func CreemWebhook(c *gin.Context) {
// 解析新格式的webhook数据
var webhookEvent CreemWebhookEvent
if err := c.ShouldBindJSON(&webhookEvent); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), string(bodyBytes)))
logger.LogError(c.Request.Context(), common.LogText("Creem webhook failed to parse path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), string(bodyBytes)))
c.AbortWithStatus(http.StatusBadRequest)
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 解析成功 event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s", webhookEvent.EventType, webhookEvent.Id, webhookEvent.Object.RequestId, webhookEvent.Object.Order.Id, webhookEvent.Object.Order.Status))
logger.LogInfo(c.Request.Context(), common.LogText("Creem webhook parsed event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s", webhookEvent.EventType, webhookEvent.Id, webhookEvent.Object.RequestId, webhookEvent.Object.Order.Id, webhookEvent.Object.Order.Status))
// 根据事件类型处理不同的webhook
switch webhookEvent.EventType {
case "checkout.completed":
handleCheckoutCompleted(c, &webhookEvent)
default:
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 忽略事件 event_type=%s event_id=%s", webhookEvent.EventType, webhookEvent.Id))
logger.LogInfo(c.Request.Context(), common.LogText("Creem webhook ignored event event_type=%s event_id=%s", webhookEvent.EventType, webhookEvent.Id))
c.Status(http.StatusOK)
}
}
@@ -295,7 +295,7 @@ func CreemWebhook(c *gin.Context) {
func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 验证订单状态
if event.Object.Order.Status != "paid" {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订单状态未支付,忽略处理 request_id=%s order_id=%s order_status=%s", event.Object.RequestId, event.Object.Order.Id, event.Object.Order.Status))
logger.LogInfo(c.Request.Context(), common.LogText("Creem order not paid, ignored request_id=%s order_id=%s order_status=%s", event.Object.RequestId, event.Object.Order.Id, event.Object.Order.Status))
c.Status(http.StatusOK)
return
}
@@ -303,7 +303,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 获取引用ID(这是我们创建订单时传递的request_id)
referenceId := event.Object.RequestId
if referenceId == "" {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少 request_id event_id=%s order_id=%s", event.Id, event.Object.Order.Id))
logger.LogWarn(c.Request.Context(), common.LogText("Creem webhook missing request_id event_id=%s order_id=%s", event.Id, event.Object.Order.Id))
c.AbortWithStatus(http.StatusBadRequest)
return
}
@@ -312,34 +312,34 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
LockOrder(referenceId)
defer UnlockOrder(referenceId)
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event), model.PaymentProviderCreem, ""); err == nil {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理成功 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
logger.LogInfo(c.Request.Context(), common.LogText("Creem subscription order processed trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
c.Status(http.StatusOK)
return
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理失败 trade_no=%s creem_order_id=%s error=%q", referenceId, event.Object.Order.Id, err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Creem subscription order processing failed trade_no=%s creem_order_id=%s error=%q", referenceId, event.Object.Order.Id, err.Error()))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
// 验证订单类型,目前只处理一次性付款(充值)
if event.Object.Order.Type != "onetime" {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 暂不支持该订单类型,忽略处理 request_id=%s creem_order_id=%s order_type=%s", referenceId, event.Object.Order.Id, event.Object.Order.Type))
logger.LogInfo(c.Request.Context(), common.LogText("Creem order type not supported, ignored request_id=%s creem_order_id=%s order_type=%s", referenceId, event.Object.Order.Id, event.Object.Order.Type))
c.Status(http.StatusOK)
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付完成回调 trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q", referenceId, event.Object.Order.Id, event.Object.Order.AmountPaid, event.Object.Order.Currency, event.Object.Product.Name, event.Object.Customer.Email, event.Object.Customer.Name))
logger.LogInfo(c.Request.Context(), common.LogText("Creem payment completed callback trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q", referenceId, event.Object.Order.Id, event.Object.Order.AmountPaid, event.Object.Order.Currency, event.Object.Product.Name, event.Object.Customer.Email, event.Object.Customer.Name))
// 查询本地订单确认存在
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 充值订单不存在 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
logger.LogWarn(c.Request.Context(), common.LogText("Creem top-up order not found trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
c.AbortWithStatus(http.StatusBadRequest)
return
}
if topUp.Status != common.TopUpStatusPending {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单状态非 pending,忽略处理 trade_no=%s status=%s creem_order_id=%s", referenceId, topUp.Status, event.Object.Order.Id))
logger.LogInfo(c.Request.Context(), common.LogText("Creem top-up order not pending, ignored trade_no=%s status=%s creem_order_id=%s", referenceId, topUp.Status, event.Object.Order.Id))
c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
return
}
@@ -350,20 +350,20 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 防护性检查,确保邮箱和姓名不为空字符串
if customerEmail == "" {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户邮箱为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
logger.LogWarn(c.Request.Context(), common.LogText("Creem callback customer email is empty trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
}
if customerName == "" {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户姓名为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
logger.LogWarn(c.Request.Context(), common.LogText("Creem callback customer name is empty trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
}
err := model.RechargeCreem(referenceId, customerEmail, customerName, c.ClientIP())
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 充值处理失败 trade_no=%s creem_order_id=%s client_ip=%s error=%q", referenceId, event.Object.Order.Id, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Creem top-up processing failed trade_no=%s creem_order_id=%s client_ip=%s error=%q", referenceId, event.Object.Order.Id, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值成功 trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s", referenceId, event.Object.Order.Id, topUp.Amount, topUp.Money, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Creem top-up succeeded trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s", referenceId, event.Object.Order.Id, topUp.Amount, topUp.Money, c.ClientIP()))
c.Status(http.StatusOK)
}
@@ -383,14 +383,14 @@ type CreemCheckoutResponse struct {
func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct, email string, username string) (string, error) {
if setting.CreemApiKey == "" {
return "", fmt.Errorf("未配置Creem API密钥")
return "", errors.New("Creem API key is not configured")
}
// 根据测试模式选择 API 端点
apiUrl := "https://api.creem.io/v1/checkouts"
if setting.CreemTestMode {
apiUrl = "https://test-api.creem.io/v1/checkouts"
logger.LogInfo(ctx, fmt.Sprintf("Creem 使用测试环境 api_url=%s", apiUrl))
logger.LogInfo(ctx, common.LogText("Creem using test environment api_url=%s", apiUrl))
}
// 构建请求数据,确保包含用户邮箱
@@ -413,20 +413,20 @@ func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct
// 序列化请求数据
jsonData, err := json.Marshal(requestData)
if err != nil {
return "", fmt.Errorf("序列化请求数据失败: %v", err)
return "", fmt.Errorf("failed to marshal request data: %v", err)
}
// 创建 HTTP 请求
req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("创建HTTP请求失败: %v", err)
return "", fmt.Errorf("failed to create HTTP request: %v", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", setting.CreemApiKey)
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId))
logger.LogInfo(ctx, common.LogText("Creem payment request sent api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId))
// 发送请求
client := &http.Client{
@@ -434,17 +434,17 @@ func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("发送HTTP请求失败: %v", err)
return "", fmt.Errorf("failed to send HTTP request: %v", err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
return "", fmt.Errorf("failed to read response: %v", err)
}
logger.LogInfo(ctx, fmt.Sprintf("Creem API 响应已收到 trade_no=%s status_code=%d body=%q", referenceId, resp.StatusCode, string(body)))
logger.LogInfo(ctx, common.LogText("Creem API response received trade_no=%s status_code=%d body=%q", referenceId, resp.StatusCode, string(body)))
// 检查响应状态
if resp.StatusCode/100 != 2 {
@@ -454,13 +454,13 @@ func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct
var checkoutResp CreemCheckoutResponse
err = json.Unmarshal(body, &checkoutResp)
if err != nil {
return "", fmt.Errorf("解析响应失败: %v", err)
return "", fmt.Errorf("failed to parse response: %v", err)
}
if checkoutResp.CheckoutUrl == "" {
return "", fmt.Errorf("Creem API resp no checkout url ")
}
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付链接创建成功 trade_no=%s response_id=%s checkout_url=%q", referenceId, checkoutResp.Id, checkoutResp.CheckoutUrl))
logger.LogInfo(ctx, common.LogText("Creem payment link created trade_no=%s response_id=%s checkout_url=%q", referenceId, checkoutResp.Id, checkoutResp.CheckoutUrl))
return checkoutResp.CheckoutUrl, nil
}
+5 -5
View File
@@ -91,7 +91,7 @@ func TestValidateTopUpQuotaReturnsMaximumAmount(t *testing.T) {
_, err := validateTopUpQuota(maxAmount)
require.NoError(t, err)
_, err = validateTopUpQuota(maxAmount + 1)
require.EqualError(t, err, fmt.Sprintf("单笔充值数量不能大于 %d", maxAmount))
require.EqualError(t, err, fmt.Sprintf("Top-up amount cannot exceed %d", maxAmount))
}
func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
@@ -120,7 +120,7 @@ func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
RequestAmount(ctx)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.JSONEq(t, fmt.Sprintf(`{"message":"error","data":"单笔充值数量不能大于 %d"}`, maxAmount), recorder.Body.String())
assert.JSONEq(t, fmt.Sprintf(`{"message":"error","data":"Top-up amount cannot exceed %d","message_key":"Top-up amount cannot exceed {{max}}","message_params":{"max":%d}}`, maxAmount, maxAmount), recorder.Body.String())
}
func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
@@ -165,19 +165,19 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
RequestAmount(ctx)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.JSONEq(t, `{"message":"error","data":"top-up quota limit exceeded"}`, recorder.Body.String())
assert.JSONEq(t, `{"message":"error","data":"Top-up quota limit exceeded","message_key":"Top-up quota limit exceeded"}`, recorder.Body.String())
}
func TestValidateCreditedQuotaRejectsOverflow(t *testing.T) {
_, err := validateCreditedQuota(decimal.NewFromInt(int64(common.MaxWalletQuota / 2)))
require.NoError(t, err)
_, err = validateCreditedQuota(decimal.Zero)
require.EqualError(t, err, "充值额度必须大于 0")
require.EqualError(t, err, "Top-up quota must be greater than 0")
_, err = validateCreditedQuota(decimal.NewFromInt(common.MaxWalletQuota + 1))
require.EqualError(
t,
err,
"充值额度超出系统可表示范围",
"Top-up quota exceeds the range the system can represent",
)
}
+46 -46
View File
@@ -45,17 +45,17 @@ type StripeAdaptor struct {
func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
if req.Amount < getStripeMinTopup() {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getStripeMinTopup()}))
return
}
if req.Amount > 10000 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值数量不能大于 10000"})
paymentError(c, common.NewMessage("Top-up amount cannot exceed {{max}}", map[string]any{"max": 10000}))
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
paymentError(c, common.NewMessage("Failed to get the user group"))
return
}
if rejectInvalidCreditedQuota(c, id, getStripeCreditedQuota(req.Amount, group)) {
@@ -63,7 +63,7 @@ func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
}
payMoney := getStripePayMoney(float64(req.Amount), group)
if payMoney <= 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
paymentError(c, common.NewMessage("Payment amount is too low"))
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
@@ -71,32 +71,32 @@ func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
if req.PaymentMethod != model.PaymentMethodStripe {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
paymentError(c, common.NewMessage("Unsupported payment channel"))
return
}
if req.Amount < getStripeMinTopup() {
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
common.ApiErrorStatus(c, http.StatusOK, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getStripeMinTopup()}), gin.H{"data": 10})
return
}
if req.Amount > 10000 {
c.JSON(http.StatusOK, gin.H{"message": "充值数量不能大于 10000", "data": 10})
common.ApiErrorStatus(c, http.StatusOK, common.NewMessage("Top-up amount cannot exceed {{max}}", map[string]any{"max": 10000}), gin.H{"data": 10})
return
}
if req.SuccessURL != "" && common.ValidateRedirectURL(req.SuccessURL) != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "支付成功重定向URL不在可信任域名列表中", "data": ""})
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Payment success redirect URL is not in the trusted domain list"), gin.H{"data": ""})
return
}
if req.CancelURL != "" && common.ValidateRedirectURL(req.CancelURL) != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "支付取消重定向URL不在可信任域名列表中", "data": ""})
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Payment cancel redirect URL is not in the trusted domain list"), gin.H{"data": ""})
return
}
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil || user == nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
paymentError(c, common.NewMessage("User does not exist"))
return
}
chargedMoney := GetChargedAmount(float64(req.Amount), *user)
@@ -111,8 +111,8 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount, req.SuccessURL, req.CancelURL)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建 Checkout Session 失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), common.LogText("Stripe failed to create Checkout Session user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
paymentError(c, common.NewMessage("Failed to start payment"))
return
}
@@ -128,11 +128,11 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
}
err = topUp.Insert()
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), common.LogText("Stripe failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
paymentError(c, common.NewMessage("Failed to create order"))
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Stripe 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f", id, referenceId, req.Amount, chargedMoney))
logger.LogInfo(c.Request.Context(), common.LogText("Stripe top-up order created user_id=%d trade_no=%s amount=%d money=%.2f", id, referenceId, req.Amount, chargedMoney))
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
@@ -145,7 +145,7 @@ func RequestStripeAmount(c *gin.Context) {
var req StripePayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
stripeAdaptor.RequestAmount(c, &req)
@@ -155,7 +155,7 @@ func RequestStripePay(c *gin.Context) {
var req StripePayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
stripeAdaptor.RequestPay(c, &req)
@@ -164,32 +164,32 @@ func RequestStripePay(c *gin.Context) {
func StripeWebhook(c *gin.Context) {
ctx := c.Request.Context()
if !isStripeWebhookEnabled() {
logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
logger.LogWarn(ctx, common.LogText("Stripe webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.AbortWithStatus(http.StatusForbidden)
return
}
payload, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Stripe webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
logger.LogError(ctx, common.LogText("Stripe webhook failed to read request body path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusServiceUnavailable)
return
}
signature := c.GetHeader("Stripe-Signature")
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(payload)))
logger.LogInfo(ctx, common.LogText("Stripe webhook received request path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(payload)))
event, err := webhook.ConstructEventWithOptions(payload, signature, setting.StripeWebhookSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
})
if err != nil {
logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
logger.LogWarn(ctx, common.LogText("Stripe webhook signature verification failed path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusBadRequest)
return
}
callerIp := c.ClientIP()
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 验签成功 event_type=%s client_ip=%s path=%q", string(event.Type), callerIp, c.Request.RequestURI))
logger.LogInfo(ctx, common.LogText("Stripe webhook signature verified event_type=%s client_ip=%s path=%q", string(event.Type), callerIp, c.Request.RequestURI))
switch event.Type {
case stripe.EventTypeCheckoutSessionCompleted:
sessionCompleted(ctx, event, callerIp)
@@ -200,7 +200,7 @@ func StripeWebhook(c *gin.Context) {
case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
sessionAsyncPaymentFailed(ctx, event, callerIp)
default:
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 忽略事件 event_type=%s client_ip=%s", string(event.Type), callerIp))
logger.LogInfo(ctx, common.LogText("Stripe webhook ignored event event_type=%s client_ip=%s", string(event.Type), callerIp))
}
c.Status(http.StatusOK)
@@ -211,13 +211,13 @@ func sessionCompleted(ctx context.Context, event stripe.Event, callerIp string)
referenceId := event.GetObjectValue("client_reference_id")
status := event.GetObjectValue("status")
if "complete" != status {
logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.completed 状态异常,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, status, callerIp))
logger.LogWarn(ctx, common.LogText("Stripe checkout.completed has unexpected status, ignored trade_no=%s status=%s client_ip=%s", referenceId, status, callerIp))
return
}
paymentStatus := event.GetObjectValue("payment_status")
if paymentStatus != "paid" {
logger.LogInfo(ctx, fmt.Sprintf("Stripe Checkout 支付未完成,等待异步结果 trade_no=%s payment_status=%s client_ip=%s", referenceId, paymentStatus, callerIp))
logger.LogInfo(ctx, common.LogText("Stripe Checkout payment not completed, waiting for async result trade_no=%s payment_status=%s client_ip=%s", referenceId, paymentStatus, callerIp))
return
}
@@ -229,7 +229,7 @@ func sessionCompleted(ctx context.Context, event stripe.Event, callerIp string)
func sessionAsyncPaymentSucceeded(ctx context.Context, event stripe.Event, callerIp string) {
customerId := event.GetObjectValue("customer")
referenceId := event.GetObjectValue("client_reference_id")
logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付成功 trade_no=%s client_ip=%s", referenceId, callerIp))
logger.LogInfo(ctx, common.LogText("Stripe async payment succeeded trade_no=%s client_ip=%s", referenceId, callerIp))
fulfillOrder(ctx, event, referenceId, customerId, callerIp)
}
@@ -238,10 +238,10 @@ func sessionAsyncPaymentSucceeded(ctx context.Context, event stripe.Event, calle
// ultimately fail (e.g. bank transfer not received, SEPA rejected).
func sessionAsyncPaymentFailed(ctx context.Context, event stripe.Event, callerIp string) {
referenceId := event.GetObjectValue("client_reference_id")
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败 trade_no=%s client_ip=%s", referenceId, callerIp))
logger.LogWarn(ctx, common.LogText("Stripe async payment failed trade_no=%s client_ip=%s", referenceId, callerIp))
if len(referenceId) == 0 {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败事件缺少订单号 client_ip=%s", callerIp))
logger.LogWarn(ctx, common.LogText("Stripe async payment failed event missing order number client_ip=%s", callerIp))
return
}
@@ -250,32 +250,32 @@ func sessionAsyncPaymentFailed(ctx context.Context, event stripe.Event, callerIp
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但本地订单不存在 trade_no=%s client_ip=%s", referenceId, callerIp))
logger.LogWarn(ctx, common.LogText("Stripe async payment failed but local order not found trade_no=%s client_ip=%s", referenceId, callerIp))
return
}
if topUp.PaymentProvider != model.PaymentProviderStripe {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但订单支付网关不匹配 trade_no=%s payment_provider=%s client_ip=%s", referenceId, topUp.PaymentProvider, callerIp))
logger.LogWarn(ctx, common.LogText("Stripe async payment failed but order payment gateway mismatch trade_no=%s payment_provider=%s client_ip=%s", referenceId, topUp.PaymentProvider, callerIp))
return
}
if topUp.Status != common.TopUpStatusPending {
logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付失败但订单状态非 pending,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, topUp.Status, callerIp))
logger.LogInfo(ctx, common.LogText("Stripe async payment failed but order not pending, ignored trade_no=%s status=%s client_ip=%s", referenceId, topUp.Status, callerIp))
return
}
topUp.Status = common.TopUpStatusFailed
if err := topUp.Update(); err != nil {
logger.LogError(ctx, fmt.Sprintf("Stripe 标记充值订单失败状态失败 trade_no=%s client_ip=%s error=%q", referenceId, callerIp, err.Error()))
logger.LogError(ctx, common.LogText("Stripe failed to mark top-up order as failed trade_no=%s client_ip=%s error=%q", referenceId, callerIp, err.Error()))
return
}
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已标记为失败 trade_no=%s client_ip=%s", referenceId, callerIp))
logger.LogInfo(ctx, common.LogText("Stripe top-up order marked as failed trade_no=%s client_ip=%s", referenceId, callerIp))
}
// fulfillOrder is the shared logic for crediting quota after payment is confirmed.
func fulfillOrder(ctx context.Context, event stripe.Event, referenceId string, customerId string, callerIp string) {
if len(referenceId) == 0 {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 完成订单时缺少订单号 client_ip=%s", callerIp))
logger.LogWarn(ctx, common.LogText("Stripe order completion missing order number client_ip=%s", callerIp))
return
}
@@ -288,34 +288,34 @@ func fulfillOrder(ctx context.Context, event stripe.Event, referenceId string, c
"event_type": string(event.Type),
}
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload), model.PaymentProviderStripe, ""); err == nil {
logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单处理成功 trade_no=%s event_type=%s client_ip=%s", referenceId, string(event.Type), callerIp))
logger.LogInfo(ctx, common.LogText("Stripe subscription order processed trade_no=%s event_type=%s client_ip=%s", referenceId, string(event.Type), callerIp))
return
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
logger.LogError(ctx, common.LogText("Stripe subscription order processing failed trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
return
}
err := model.Recharge(referenceId, customerId, callerIp)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Stripe 充值处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
logger.LogError(ctx, common.LogText("Stripe top-up processing failed trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
return
}
total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64)
currency := strings.ToUpper(event.GetObjectValue("currency"))
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值成功 trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s", referenceId, total/100, currency, string(event.Type), callerIp))
logger.LogInfo(ctx, common.LogText("Stripe top-up succeeded trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s", referenceId, total/100, currency, string(event.Type), callerIp))
}
func sessionExpired(ctx context.Context, event stripe.Event) {
referenceId := event.GetObjectValue("client_reference_id")
status := event.GetObjectValue("status")
if "expired" != status {
logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.expired 状态异常,忽略处理 trade_no=%s status=%s", referenceId, status))
logger.LogWarn(ctx, common.LogText("Stripe checkout.expired has unexpected status, ignored trade_no=%s status=%s", referenceId, status))
return
}
if len(referenceId) == 0 {
logger.LogWarn(ctx, "Stripe checkout.expired 缺少订单号")
logger.LogWarn(ctx, common.LogText("Stripe checkout.expired missing order number"))
return
}
@@ -323,24 +323,24 @@ func sessionExpired(ctx context.Context, event stripe.Event) {
LockOrder(referenceId)
defer UnlockOrder(referenceId)
if err := model.ExpireSubscriptionOrder(referenceId, model.PaymentProviderStripe); err == nil {
logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单已过期 trade_no=%s", referenceId))
logger.LogInfo(ctx, common.LogText("Stripe subscription order expired trade_no=%s", referenceId))
return
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
logger.LogError(ctx, common.LogText("Stripe subscription order expiry processing failed trade_no=%s error=%q", referenceId, err.Error()))
return
}
err := model.UpdatePendingTopUpStatus(referenceId, model.PaymentProviderStripe, common.TopUpStatusExpired)
if errors.Is(err, model.ErrTopUpNotFound) {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 充值订单不存在,无法标记过期 trade_no=%s", referenceId))
logger.LogWarn(ctx, common.LogText("Stripe top-up order not found, cannot mark as expired trade_no=%s", referenceId))
return
}
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Stripe 充值订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
logger.LogError(ctx, common.LogText("Stripe top-up order expiry processing failed trade_no=%s error=%q", referenceId, err.Error()))
return
}
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已过期 trade_no=%s", referenceId))
logger.LogInfo(ctx, common.LogText("Stripe top-up order expired trade_no=%s", referenceId))
}
// genStripeLink generates a Stripe Checkout session URL for payment.
@@ -357,7 +357,7 @@ func sessionExpired(ctx context.Context, event stripe.Event) {
// Returns the checkout session URL or an error if the session creation fails.
func genStripeLink(referenceId string, customerId string, email string, amount int64, successURL string, cancelURL string) (string, error) {
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
return "", fmt.Errorf("无效的Stripe API密钥")
return "", errors.New("invalid Stripe API key")
}
stripe.Key = setting.StripeApiSecret
+35 -35
View File
@@ -114,13 +114,13 @@ type WaffoPayRequest struct {
func RequestWaffoAmount(c *gin.Context) {
var req WaffoPayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
waffoMinTopup := int64(setting.WaffoMinTopUp)
if req.Amount < waffoMinTopup {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": waffoMinTopup}))
return
}
id := c.GetInt("id")
@@ -130,13 +130,13 @@ func RequestWaffoAmount(c *gin.Context) {
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
paymentError(c, common.NewMessage("Failed to get the user group"))
return
}
payMoney := getWaffoPayMoney(float64(req.Amount), group)
if payMoney <= 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
paymentError(c, common.NewMessage("Payment amount is too low"))
return
}
@@ -146,18 +146,18 @@ func RequestWaffoAmount(c *gin.Context) {
// RequestWaffoPay 创建 Waffo 支付订单
func RequestWaffoPay(c *gin.Context) {
if !setting.WaffoEnabled {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo 支付未启用"})
paymentError(c, common.NewMessage("Waffo payment is not enabled"))
return
}
var req WaffoPayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
waffoMinTopup := int64(setting.WaffoMinTopUp)
if req.Amount < waffoMinTopup {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": waffoMinTopup}))
return
}
id := c.GetInt("id")
@@ -167,7 +167,7 @@ func RequestWaffoPay(c *gin.Context) {
user, err := model.GetUserById(id, false)
if err != nil || user == nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
paymentError(c, common.NewMessage("User does not exist"))
return
}
@@ -178,8 +178,8 @@ func RequestWaffoPay(c *gin.Context) {
// 新协议:按索引查找
idx := *req.PayMethodIndex
if idx < 0 || idx >= len(methods) {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式索引无效 user_id=%d pay_method_index=%d method_count=%d", id, idx, len(methods)))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
logger.LogWarn(c.Request.Context(), common.LogText("Waffo invalid payment method index user_id=%d pay_method_index=%d method_count=%d", id, idx, len(methods)))
paymentError(c, common.NewMessage("Unsupported payment method"))
return
}
resolvedPayMethodType = methods[idx].PayMethodType
@@ -196,8 +196,8 @@ func RequestWaffoPay(c *gin.Context) {
}
}
if !valid {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式无效 user_id=%d pay_method_type=%s pay_method_name=%q", id, req.PayMethodType, req.PayMethodName))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
logger.LogWarn(c.Request.Context(), common.LogText("Waffo invalid payment method user_id=%d pay_method_type=%s pay_method_name=%q", id, req.PayMethodType, req.PayMethodName))
paymentError(c, common.NewMessage("Unsupported payment method"))
return
}
}
@@ -206,7 +206,7 @@ func RequestWaffoPay(c *gin.Context) {
group, _ := model.GetUserGroup(id, true)
payMoney := getWaffoPayMoney(float64(req.Amount), group)
if payMoney < 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
paymentError(c, common.NewMessage("Payment amount is too low"))
return
}
@@ -232,17 +232,17 @@ func RequestWaffoPay(c *gin.Context) {
Status: common.TopUpStatusPending,
}
if err := topUp.Insert(); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, merchantOrderId, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), common.LogText("Waffo failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q", id, merchantOrderId, req.Amount, err.Error()))
paymentError(c, common.NewMessage("Failed to create order"))
return
}
sdk, err := getWaffoSDK()
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo SDK 初始化失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Waffo SDK initialization failed user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付配置错误"})
paymentError(c, common.NewMessage("Payment configuration error"))
return
}
@@ -285,22 +285,22 @@ func RequestWaffoPay(c *gin.Context) {
}
resp, err := sdk.Order().Create(c.Request.Context(), createParams, nil)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建订单失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Waffo failed to create order user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
paymentError(c, common.NewMessage("Failed to start payment"))
return
}
if !resp.IsSuccess() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 创建订单业务失败 user_id=%d trade_no=%s code=%s message=%q response=%q", id, merchantOrderId, resp.Code, resp.Message, common.GetJsonString(resp)))
logger.LogWarn(c.Request.Context(), common.LogText("Waffo order creation rejected user_id=%d trade_no=%s code=%s message=%q response=%q", id, merchantOrderId, resp.Code, resp.Message, common.GetJsonString(resp)))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
paymentError(c, common.NewMessage("Failed to start payment"))
return
}
orderData := resp.GetData()
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q", id, merchantOrderId, req.Amount, payMoney, resolvedPayMethodType, resolvedPayMethodName))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo top-up order created user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q", id, merchantOrderId, req.Amount, payMoney, resolvedPayMethodType, resolvedPayMethodName))
paymentUrl := orderData.FetchRedirectURL()
if paymentUrl == "" {
@@ -335,21 +335,21 @@ type webhookSubscriptionInfo struct {
// WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅)
func WaffoWebhook(c *gin.Context) {
if !isWaffoWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
logger.LogWarn(c.Request.Context(), common.LogText("Waffo webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.AbortWithStatus(http.StatusForbidden)
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Waffo webhook failed to read request body path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusBadRequest)
return
}
sdk, err := getWaffoSDK()
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook SDK 初始化失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Waffo webhook SDK initialization failed path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
@@ -357,18 +357,18 @@ func WaffoWebhook(c *gin.Context) {
wh := sdk.Webhook()
bodyStr := string(bodyBytes)
signature := c.GetHeader("X-SIGNATURE")
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo webhook received request path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
// 验证请求签名
if !wh.VerifySignature(bodyStr, signature) {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
logger.LogWarn(c.Request.Context(), common.LogText("Waffo webhook signature verification failed path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
c.AbortWithStatus(http.StatusBadRequest)
return
}
var event core.WebhookEvent
if err := common.Unmarshal(bodyBytes, &event); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), bodyStr))
logger.LogError(c.Request.Context(), common.LogText("Waffo webhook failed to parse path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), bodyStr))
sendWaffoWebhookResponse(c, wh, false, "invalid payload")
return
}
@@ -378,14 +378,14 @@ func WaffoWebhook(c *gin.Context) {
// 解析为扩展类型,区分普通支付和订阅支付
var payload webhookPayloadWithSubInfo
if err := common.Unmarshal(bodyBytes, &payload); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 支付回调载荷解析失败 event_type=%s client_ip=%s error=%q body=%q", event.EventType, c.ClientIP(), err.Error(), bodyStr))
logger.LogError(c.Request.Context(), common.LogText("Waffo failed to parse payment callback payload event_type=%s client_ip=%s error=%q body=%q", event.EventType, c.ClientIP(), err.Error(), bodyStr))
sendWaffoWebhookResponse(c, wh, false, "invalid payment payload")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签并解析成功 event_type=%s merchant_order_id=%s order_status=%s client_ip=%s", event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo webhook verified and parsed event_type=%s merchant_order_id=%s order_status=%s client_ip=%s", event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus, c.ClientIP()))
handleWaffoPayment(c, wh, &payload.Result.PaymentNotificationResult)
default:
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 忽略事件 event_type=%s client_ip=%s", event.EventType, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo webhook ignored event event_type=%s client_ip=%s", event.EventType, c.ClientIP()))
sendWaffoWebhookResponse(c, wh, true, "")
}
}
@@ -393,13 +393,13 @@ func WaffoWebhook(c *gin.Context) {
// handleWaffoPayment 处理支付完成通知
func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.PaymentNotificationResult) {
if result.OrderStatus != "PAY_SUCCESS" {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 订单状态非成功,忽略充值 trade_no=%s order_status=%s client_ip=%s", result.MerchantOrderID, result.OrderStatus, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo order not successful, top-up skipped trade_no=%s order_status=%s client_ip=%s", result.MerchantOrderID, result.OrderStatus, c.ClientIP()))
// 终态失败订单标记为 failed,避免永远停在 pending
if result.MerchantOrderID != "" {
if err := model.UpdatePendingTopUpStatus(result.MerchantOrderID, model.PaymentProviderWaffo, common.TopUpStatusFailed); err != nil &&
!errors.Is(err, model.ErrTopUpNotFound) &&
!errors.Is(err, model.ErrTopUpStatusInvalid) {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 标记失败订单状态失败 trade_no=%s error=%q", result.MerchantOrderID, err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Waffo failed to mark order as failed trade_no=%s error=%q", result.MerchantOrderID, err.Error()))
}
}
sendWaffoWebhookResponse(c, wh, true, "")
@@ -412,12 +412,12 @@ func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.Pa
defer UnlockOrder(merchantOrderId)
if err := model.RechargeWaffo(merchantOrderId, c.ClientIP()); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 充值处理失败 trade_no=%s client_ip=%s error=%q", merchantOrderId, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Waffo top-up processing failed trade_no=%s client_ip=%s error=%q", merchantOrderId, c.ClientIP(), err.Error()))
sendWaffoWebhookResponse(c, wh, false, err.Error())
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值成功 trade_no=%s client_ip=%s", merchantOrderId, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo top-up succeeded trade_no=%s client_ip=%s", merchantOrderId, c.ClientIP()))
sendWaffoWebhookResponse(c, wh, true, "")
}
+55 -55
View File
@@ -25,12 +25,12 @@ type WaffoPancakePayRequest struct {
func RequestWaffoPancakeAmount(c *gin.Context) {
var req WaffoPancakePayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": setting.WaffoPancakeMinTopUp}))
return
}
id := c.GetInt("id")
@@ -40,13 +40,13 @@ func RequestWaffoPancakeAmount(c *gin.Context) {
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
paymentError(c, common.NewMessage("Failed to get the user group"))
return
}
payMoney := getWaffoPancakePayMoney(req.Amount, group)
if payMoney <= 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
paymentError(c, common.NewMessage("Payment amount is too low"))
return
}
@@ -125,7 +125,7 @@ type createWaffoPancakePairRequest struct {
func SaveWaffoPancake(c *gin.Context) {
var req saveWaffoPancakeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
if err := service.SaveWaffoPancakeConfig(
@@ -136,11 +136,11 @@ func SaveWaffoPancake(c *gin.Context) {
req.StoreID,
req.ProductID,
); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake 保存配置失败 store_id=%q product_id=%q error=%q",
logger.LogError(c.Request.Context(), common.LogText(
"Waffo Pancake failed to save config store_id=%q product_id=%q error=%q",
req.StoreID, req.ProductID, err.Error(),
))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "保存配置失败"})
paymentError(c, common.NewMessage("Failed to save configuration"))
return
}
c.JSON(http.StatusOK, gin.H{
@@ -172,13 +172,13 @@ func CreateWaffoPancakePair(c *gin.Context) {
var req createWaffoPancakePairRequest
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
}
merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey)
if merchantID == "" || privateKey == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"})
paymentError(c, common.NewMessage("Waffo Pancake credentials are not configured"))
return
}
result, err := service.CreateWaffoPancakePrimaryPair(
@@ -186,8 +186,8 @@ func CreateWaffoPancakePair(c *gin.Context) {
)
if err != nil {
orphan := result != nil && result.OrphanStore
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake 创建店铺与产品失败 orphan_store=%t store_id=%q error=%q",
logger.LogError(c.Request.Context(), common.LogText(
"Waffo Pancake failed to create store and product orphan_store=%t store_id=%q error=%q",
orphan, func() string {
if result == nil {
return ""
@@ -225,15 +225,15 @@ func ListWaffoPancakeCatalog(c *gin.Context) {
strings.TrimSpace(c.Query("private_key")),
)
if merchantID == "" || privateKey == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"})
paymentError(c, common.NewMessage("Waffo Pancake credentials are not configured"))
return
}
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake 拉取店铺与产品目录失败 error=%q", err.Error(),
logger.LogError(c.Request.Context(), common.LogText(
"Waffo Pancake failed to fetch store and product catalog error=%q", err.Error(),
))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取目录失败"})
paymentError(c, common.NewMessage("Failed to fetch the catalog"))
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": catalog})
@@ -253,22 +253,22 @@ func CreateWaffoPancakeSubscriptionProduct(c *gin.Context) {
var req createWaffoPancakeSubscriptionProductRequest
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
}
if strings.TrimSpace(req.Name) == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐名称不能为空"})
paymentError(c, common.NewMessage("Plan name cannot be empty"))
return
}
if strings.TrimSpace(req.Amount) == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐价格不能为空"})
paymentError(c, common.NewMessage("Plan price cannot be empty"))
return
}
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
if merchantID == "" || privateKey == "" || storeID == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"})
paymentError(c, common.NewMessage("Waffo Pancake is not fully configured. Complete the gateway binding in payment settings first."))
return
}
productID, err := service.CreateWaffoPancakeProductForPlan(
@@ -281,11 +281,11 @@ func CreateWaffoPancakeSubscriptionProduct(c *gin.Context) {
setting.WaffoPancakeReturnURL,
)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake 创建套餐产品失败 store_id=%q name=%q amount=%q error=%q",
logger.LogError(c.Request.Context(), common.LogText(
"Waffo Pancake failed to create plan product store_id=%q name=%q amount=%q error=%q",
storeID, req.Name, req.Amount, err.Error(),
))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建套餐产品失败"})
paymentError(c, common.NewMessage("Failed to create the plan product"))
return
}
c.JSON(http.StatusOK, gin.H{
@@ -305,15 +305,15 @@ func ListWaffoPancakeSubscriptionProductOptions(c *gin.Context) {
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
if merchantID == "" || privateKey == "" || storeID == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"})
paymentError(c, common.NewMessage("Waffo Pancake is not fully configured. Complete the gateway binding in payment settings first."))
return
}
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake 拉取订阅产品列表失败 store_id=%q error=%q", storeID, err.Error(),
logger.LogError(c.Request.Context(), common.LogText(
"Waffo Pancake failed to fetch subscription product list store_id=%q error=%q", storeID, err.Error(),
))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取产品列表失败"})
paymentError(c, common.NewMessage("Failed to fetch the product list"))
return
}
products := []service.WaffoPancakeCatalogProduct{}
@@ -341,17 +341,17 @@ func getWaffoPancakeBuyerIdentity(user *model.User) string {
func RequestWaffoPancakePay(c *gin.Context) {
if !isWaffoPancakeTopUpEnabled() {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"})
paymentError(c, common.NewMessage("Waffo Pancake configuration is incomplete"))
return
}
var req WaffoPancakePayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
paymentError(c, common.NewMessage("Invalid parameters"))
return
}
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": setting.WaffoPancakeMinTopUp}))
return
}
id := c.GetInt("id")
@@ -361,19 +361,19 @@ func RequestWaffoPancakePay(c *gin.Context) {
user, err := model.GetUserById(id, false)
if err != nil || user == nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
paymentError(c, common.NewMessage("User does not exist"))
return
}
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
paymentError(c, common.NewMessage("Failed to get the user group"))
return
}
payMoney := getWaffoPancakePayMoney(req.Amount, group)
if payMoney < 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
paymentError(c, common.NewMessage("Payment amount is too low"))
return
}
@@ -389,8 +389,8 @@ func RequestWaffoPancakePay(c *gin.Context) {
Status: common.TopUpStatusPending,
}
if err := topUp.Insert(); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error()))
paymentError(c, common.NewMessage("Failed to create order"))
return
}
@@ -407,13 +407,13 @@ func RequestWaffoPancakePay(c *gin.Context) {
OrderMerchantExternalID: tradeNo,
})
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake failed to create checkout session user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
paymentError(c, common.NewMessage("Failed to start payment"))
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值订单创建成功 user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f", id, tradeNo, session.SessionID, req.Amount, payMoney))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake top-up order created user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f", id, tradeNo, session.SessionID, req.Amount, payMoney))
c.JSON(http.StatusOK, gin.H{
"message": "success",
@@ -430,7 +430,7 @@ func RequestWaffoPancakePay(c *gin.Context) {
func WaffoPancakeWebhook(c *gin.Context) {
if !isWaffoPancakeWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
logger.LogWarn(c.Request.Context(), common.LogText("Waffo Pancake webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.String(http.StatusForbidden, "webhook disabled")
return
}
@@ -440,8 +440,8 @@ func WaffoPancakeWebhook(c *gin.Context) {
// We then enforce event.mode == expectedEnv to catch mis-registrations.
expectedEnv := strings.TrimSpace(c.Param("env"))
if expectedEnv != "test" && expectedEnv != "prod" {
logger.LogWarn(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake webhook 路径环境段无效 env=%q path=%q client_ip=%s",
logger.LogWarn(c.Request.Context(), common.LogText(
"Waffo Pancake webhook invalid env path segment env=%q path=%q client_ip=%s",
expectedEnv, c.Request.RequestURI, c.ClientIP(),
))
c.String(http.StatusNotFound, "unknown env")
@@ -450,31 +450,31 @@ func WaffoPancakeWebhook(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake webhook failed to read request body path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.String(http.StatusBadRequest, "bad request")
return
}
signature := c.GetHeader("X-Waffo-Signature")
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake webhook received request path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
event, err := service.VerifyConfiguredWaffoPancakeWebhook(string(bodyBytes), signature)
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签失败 path=%q client_ip=%s signature=%q body=%q error=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes), err.Error()))
logger.LogWarn(c.Request.Context(), common.LogText("Waffo Pancake webhook signature verification failed path=%q client_ip=%s signature=%q body=%q error=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes), err.Error()))
c.String(http.StatusUnauthorized, "invalid signature")
return
}
if !strings.EqualFold(strings.TrimSpace(event.Mode), expectedEnv) {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake webhook 环境不匹配 expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
logger.LogError(c.Request.Context(), common.LogText(
"Waffo Pancake webhook env mismatch expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
expectedEnv, event.Mode, event.ID, event.Data.OrderID, c.ClientIP(),
))
c.String(http.StatusOK, "OK")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake webhook signature verified event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
if event.NormalizedEventType() != "order.completed" {
c.String(http.StatusOK, "OK")
return
@@ -488,8 +488,8 @@ func WaffoPancakeWebhook(c *gin.Context) {
if isSubscription {
tradeNo, err := service.ResolveWaffoPancakeSubscriptionTradeNo(event)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake webhook 订阅订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
logger.LogError(c.Request.Context(), common.LogText(
"Waffo Pancake webhook failed to resolve subscription order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
))
c.String(http.StatusOK, "OK")
@@ -498,11 +498,11 @@ func WaffoPancakeWebhook(c *gin.Context) {
LockOrder(tradeNo)
defer UnlockOrder(tradeNo)
if err := model.CompleteSubscriptionOrder(tradeNo, string(bodyBytes), model.PaymentProviderWaffoPancake, ""); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake subscription completion failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
c.String(http.StatusInternalServerError, "retry")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake subscription completed trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
c.String(http.StatusOK, "OK")
return
}
@@ -512,8 +512,8 @@ func WaffoPancakeWebhook(c *gin.Context) {
// LogError (not LogWarn): covers order-not-found and buyer-identity
// mismatch — both warrant human attention. 200 OK so Waffo doesn't
// retry a permanently-unresolvable webhook.
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake webhook 订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
logger.LogError(c.Request.Context(), common.LogText(
"Waffo Pancake webhook failed to resolve order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
))
c.String(http.StatusOK, "OK")
@@ -524,11 +524,11 @@ func WaffoPancakeWebhook(c *gin.Context) {
defer UnlockOrder(tradeNo)
if err := model.RechargeWaffoPancake(tradeNo); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值处理失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake top-up processing failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
c.String(http.StatusInternalServerError, "retry")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值成功 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake top-up succeeded trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
c.String(http.StatusOK, "OK")
}
+25 -45
View File
@@ -6,7 +6,6 @@ import (
"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"
@@ -25,7 +24,7 @@ func Setup2FA(c *gin.Context) {
if authorization == nil {
return
}
identity, _ := middleware.GetSessionAuthIdentity(c)
identity, _ := middleware.GetStepUpIdentity(c)
setup, err := service.StartTwoFASetup(identity, authorization)
if err != nil {
writeSecurityOperationError(c, err)
@@ -36,14 +35,14 @@ func Setup2FA(c *gin.Context) {
}
func Enable2FA(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
var req Verify2FARequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiErrorMsg(c, "参数错误")
common.ApiErrorT(c, "Invalid parameters")
return
}
if err := service.FinishTwoFASetup(identity, req.FlowToken, req.Code); err != nil {
@@ -64,7 +63,7 @@ func Disable2FA(c *gin.Context) {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeTwoFADisable}) == nil {
return
}
identity, _ := middleware.GetSessionAuthIdentity(c)
identity, _ := middleware.GetStepUpIdentity(c)
userId := identity.UserID
if err := model.DisableTwoFAForSession(identity); err != nil {
writeSecurityOperationError(c, err)
@@ -79,11 +78,7 @@ func Disable2FA(c *gin.Context) {
// 记录操作日志
recordUserSecurityAudit(c, userId, "user.2fa_disable_self", nil)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "两步验证已禁用",
"data": authRotationData(bundle),
})
common.ApiSuccessT(c, "Two-factor authentication disabled", authRotationData(bundle))
}
// Get2FAStatus 获取用户2FA状态
@@ -108,7 +103,7 @@ func Get2FAStatus(c *gin.Context) {
// 获取剩余备用码数量
backupCount, err := model.GetUnusedBackupCodeCount(userId)
if err != nil {
common.SysLog("获取备用码数量失败: " + err.Error())
common.SysLog(common.LogText("failed to count backup codes: %s", err.Error()))
} else {
status["backup_codes_remaining"] = backupCount
}
@@ -127,26 +122,20 @@ func RegenerateBackupCodes(c *gin.Context) {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeTwoFABackupCodes}) == nil {
return
}
identity, _ := middleware.GetSessionAuthIdentity(c)
identity, _ := middleware.GetStepUpIdentity(c)
userId := identity.UserID
// 生成新的备用码
backupCodes, err := common.GenerateBackupCodes()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "生成备用码失败",
})
common.SysLog("生成备用码失败: " + err.Error())
common.ApiErrorT(c, "Failed to regenerate backup codes")
common.SysLog(common.LogText("failed to generate backup codes: %s", err.Error()))
return
}
// 保存新的备用码并原子推进用户鉴权版本
if err := model.ReplaceBackupCodesForSession(identity, backupCodes); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "保存备用码失败",
})
common.SysLog("保存备用码失败: " + err.Error())
common.ApiErrorT(c, "Failed to save backup codes")
common.SysLog(common.LogText("failed to save backup codes: %s", err.Error()))
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_backup_codes_regenerated")
@@ -160,11 +149,7 @@ func RegenerateBackupCodes(c *gin.Context) {
data := authRotationData(bundle)
data["backup_codes"] = backupCodes
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "备用码重新生成成功",
"data": data,
})
common.ApiSuccessT(c, "Backup codes regenerated successfully", data)
}
// Verify2FALogin 登录时验证2FA
@@ -192,17 +177,14 @@ func AdminDisable2FA(c *gin.Context) {
userIdStr := c.Param("id")
userId, err := strconv.Atoi(userIdStr)
if err != nil || userId <= 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户ID格式错误",
})
common.ApiErrorT(c, "Invalid user ID")
return
}
// 检查目标用户权限
targetUser, err := model.GetUserById(userId, false)
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
common.ApiErrorT(c, "User does not exist")
return
}
if err != nil {
@@ -212,20 +194,18 @@ func AdminDisable2FA(c *gin.Context) {
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, targetUser.Role) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权操作同级或更高级用户的2FA设置",
})
common.ApiErrorT(c, "No permission to change the 2FA settings of users at the same or higher level")
return
}
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserTwoFADisable, service.AdminUserContext{UserID: userId})
if authorization == nil {
return
}
// 禁用2FA
if err := model.DisableTwoFAWithAuthVersion(userId); err != nil {
if errors.Is(err, model.ErrTwoFANotEnabled) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户未启用2FA",
})
common.ApiErrorT(c, "Two-factor authentication is not enabled.")
return
}
writeSecurityOperationError(c, err)
@@ -236,10 +216,10 @@ func AdminDisable2FA(c *gin.Context) {
return
}
recordManageAuditFor(c, userId, "user.2fa_disable", nil)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "用户2FA已被强制禁用",
recordManageAuditFor(c, userId, "user.2fa_disable", map[string]any{
"username": targetUser.Username,
"verification_method": authorization.Method,
})
common.ApiSuccessT(c, "Two-factor authentication reset", nil)
}
+46 -28
View File
@@ -19,31 +19,35 @@ import (
const requestTimeout = 30 * time.Second
type Monitor struct {
Tags []string `json:"tags,omitempty"`
RecentDegraded bool `json:"recent_degraded,omitempty"`
ModelCosts map[string]model.GroupModelCost `json:"model_costs,omitempty"`
GroupRatio float64 `json:"group_ratio"`
ProbeHistory []service.ProbeHistoryPoint `json:"probe_history"`
ID string `json:"id"`
Models []string `json:"models"`
Alias string `json:"alias,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Candy *service.BenchmarkSummary `json:"candy,omitempty"`
Benchmark *service.BenchmarkSummary `json:"benchmark,omitempty"`
StatusSource string `json:"status_source"`
StatusCheckedAt int64 `json:"status_checked_at,omitempty"`
Fingerprint *service.AstraSamplingSummary `json:"fingerprint,omitempty"`
Metrics *perfmetrics.GroupResult `json:"metrics,omitempty"`
Name string `json:"name"`
Uptime float64 `json:"uptime"`
Status int `json:"status"`
Group string `json:"group,omitempty"`
Ping int `json:"ping,omitempty"`
LastChecked string `json:"last_checked,omitempty"`
History []MonitorSample `json:"history,omitempty"`
CacheStats *model.GroupCacheStats `json:"cache_stats,omitempty"`
ProbeStatus int `json:"probe_status"`
ChannelDisabled bool `json:"channel_disabled"`
VisionUnavailable bool `json:"vision_unavailable,omitempty"`
QualityResetAt int64 `json:"quality_reset_at,omitempty"`
Tags []string `json:"tags,omitempty"`
RecentDegraded bool `json:"recent_degraded,omitempty"`
ModelCosts map[string]model.GroupModelCost `json:"model_costs,omitempty"`
ModelPerformance map[string]model.GroupModelPerformance `json:"model_performance,omitempty"`
GroupRatio float64 `json:"group_ratio"`
ProbeHistory []service.ProbeHistoryPoint `json:"probe_history"`
ID string `json:"id"`
Models []string `json:"models"`
Alias string `json:"alias,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Ciallo *service.BenchmarkSummary `json:"ciallo,omitempty"`
Vision *service.BenchmarkSummary `json:"vision,omitempty"`
Benchmark *service.BenchmarkSummary `json:"benchmark,omitempty"`
StatusSource string `json:"status_source"`
StatusCheckedAt int64 `json:"status_checked_at,omitempty"`
Fingerprint *service.AstraSamplingSummary `json:"fingerprint,omitempty"`
Metrics *perfmetrics.GroupResult `json:"metrics,omitempty"`
Name string `json:"name"`
Uptime float64 `json:"uptime"`
Status int `json:"status"`
Group string `json:"group,omitempty"`
Ping int `json:"ping,omitempty"`
LastChecked string `json:"last_checked,omitempty"`
History []MonitorSample `json:"history,omitempty"`
CacheStats *model.GroupCacheStats `json:"cache_stats,omitempty"`
ProbeStatus int `json:"probe_status"`
ChannelDisabled bool `json:"channel_disabled"`
}
type MonitorSample struct {
@@ -132,13 +136,25 @@ func currentGroupMonitors(ctx context.Context, admin bool) ([]UptimeGroupResult,
groupCacheSnapshot.updated = now
groupCacheSnapshot.channels = string(encoded)
}
visionUnavailable, qualityErr := service.VisionUnavailableGroups(ctx)
if qualityErr != nil {
return nil, qualityErr
}
userResults := perfmetrics.LatestUserResults(ctx, order)
monitors := make([]Monitor, 0, len(order))
for _, group := range order {
monitor := Monitor{ID: model.DetectionSecretHash(group)[:16], Name: group, Status: -1, Models: models[group], GroupRatio: ratio_setting.GetGroupRatio(group)}
monitor := Monitor{QualityResetAt: settings[group].ResetBefore, ID: model.DetectionSecretHash(group)[:16], Name: group, Status: -1, Models: models[group], GroupRatio: ratio_setting.GetGroupRatio(group)}
if monitor.Models == nil {
monitor.Models = []string{}
}
monitor.ModelPerformance = make(map[string]model.GroupModelPerformance, len(monitor.Models))
for _, modelName := range monitor.Models {
performance, performanceErr := service.GroupModelPerformance(ctx, group, modelName)
if performanceErr != nil {
return nil, performanceErr
}
monitor.ModelPerformance[modelName] = performance
}
if settings[group].Alias != "" {
monitor.Name = settings[group].Alias
}
@@ -163,8 +179,10 @@ func currentGroupMonitors(ctx context.Context, admin bool) ([]UptimeGroupResult,
if !admin && monitor.Metrics != nil {
monitor.Metrics.Group = monitor.Name
}
monitor.Benchmark = detections[group].Benchmark
monitor.Candy = detections[group].Candy
monitor.Benchmark = nil
monitor.Vision = detections[group].Vision
monitor.VisionUnavailable = visionUnavailable[group]
monitor.Ciallo = detections[group].Ciallo
monitor.ProbeHistory = detections[group].ProbeHistory
applyCurrentStatus(&monitor, userResults[group], now.Unix())
monitors = append(monitors, monitor)
+2 -2
View File
@@ -80,7 +80,7 @@ func TestStatusPageDatabaseMatrix(t *testing.T) {
require.NoError(t, db.Raw(q).Scan(&version).Error)
t.Log("database version:", version)
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"internal":"","shared":"","hidden":""}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"internal":0.25,"shared":0,"hidden":1}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"internal":0.185,"shared":0,"hidden":1}`))
now := time.Now().Unix()
channels := []model.Channel{{Id: 1, Name: "private-channel", Key: "private-key", Group: "internal,shared", Models: fingerprint.Model, Status: 1}, {Id: 2, Group: "hidden", Models: fingerprint.Model, Status: 1}, {Id: 3, Group: "internal", Models: "disabled-model", Status: 2}}
require.NoError(t, db.Create(&channels).Error)
@@ -141,7 +141,7 @@ func TestStatusPageDatabaseMatrix(t *testing.T) {
assert.NotContains(t, res.Body.String(), secret)
}
assert.Contains(t, res.Body.String(), "#688")
assert.Contains(t, res.Body.String(), `"group_ratio":0.25`)
assert.Contains(t, res.Body.String(), `"group_ratio":0.185`)
require.NoError(t, model.SaveGroupMetadata(context.Background(), map[string]model.GroupMetadataValue{"internal": {Tags: []string{"打野", "不稳定"}}}))
service.InvalidateGroupQualityCache()
visitorSite := request("GET", "/api/group-status", "", "", "")
+5 -11
View File
@@ -64,12 +64,9 @@ func GetUserQuotaDates(c *gin.Context) {
userId := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
// 判断时间跨度是否超过 1 个月
if endTimestamp-startTimestamp > 2592000 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "时间跨度不能超过 1 个月",
})
startTimestamp, err := limitLogTimeRange(startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp)
@@ -110,11 +107,8 @@ func GetUserFlowQuotaDates(c *gin.Context) {
if !ok {
return
}
if endTimestamp-startTimestamp > 2592000 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "时间跨度不能超过 1 个月",
})
if _, err := limitLogTimeRange(startTimestamp, endTimestamp); err != nil {
common.ApiError(c, err)
return
}
dates, err := model.GetFlowQuotaData(startTimestamp, endTimestamp, "", userId, common.RoleCommonUser)
+174 -127
View File
@@ -40,7 +40,7 @@ func GetPasswordEncryptionKey(c *gin.Context) {
}
keyID, publicKey := common.PasswordEncryptionPublicKey()
if keyID == "" || publicKey == "" {
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
common.ApiErrorT(c, "Database error, please contact the administrator")
return
}
common.ApiSuccess(c, gin.H{
@@ -52,30 +52,30 @@ func GetPasswordEncryptionKey(c *gin.Context) {
func Login(c *gin.Context) {
if !common.PasswordLoginEnabled {
common.ApiErrorI18n(c, i18n.MsgUserPasswordLoginDisabled)
common.ApiErrorT(c, "Password login has been disabled by administrator")
return
}
var loginRequest LoginRequest
err := common.DecodeJson(c.Request.Body, &loginRequest)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
username := loginRequest.Username
password := loginRequest.Password
if common.PasswordLoginEncryptionEnabled {
if loginRequest.PasswordEncrypted == "" || loginRequest.EncryptionKeyID == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
password, err = common.DecryptPassword(loginRequest.PasswordEncrypted, loginRequest.EncryptionKeyID)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
common.ApiErrorT(c, "Username or password is incorrect, or user has been banned")
return
}
}
if username == "" || password == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
user := model.User{
@@ -86,12 +86,12 @@ func Login(c *gin.Context) {
if err != nil {
switch {
case errors.Is(err, model.ErrDatabase):
common.SysLog(fmt.Sprintf("Login database error for user %s: %v", username, err))
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
common.SysLog(common.LogText("Login database error for user %s: %v", username, err))
common.ApiErrorT(c, "Database error, please contact the administrator")
case errors.Is(err, model.ErrUserEmptyCredentials):
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
default:
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
common.ApiErrorT(c, "Username or password is incorrect, or user has been banned")
}
return
}
@@ -163,7 +163,7 @@ func setupLogin(user *model.User, migration *service.LegacyGitHubMigration, c *g
func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin.Context) {
if user == nil || user.Id <= 0 || user.Status != common.UserStatusEnabled {
common.ApiErrorI18n(c, i18n.MsgAuthUserBanned)
common.ApiErrorT(c, "User has been banned")
return
}
currentUser, err := model.GetSelfUserById(user.Id)
@@ -216,44 +216,44 @@ func writeLoginResponse(c *gin.Context, user *model.User, bundle *service.AuthBu
func Register(c *gin.Context) {
if !common.RegisterEnabled {
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
common.ApiErrorT(c, "New user registration has been disabled by administrator")
return
}
if !common.PasswordRegisterEnabled {
common.ApiErrorI18n(c, i18n.MsgUserPasswordRegisterDisabled)
common.ApiErrorT(c, "Password registration has been disabled by administrator, please use third-party account verification")
return
}
var user model.User
err := common.DecodeJson(c.Request.Body, &user)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
user.Username = strings.TrimSpace(user.Username)
user.Email = model.NormalizeEmail(user.Email)
if user.Username == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
if err := common.Validate.Struct(&user); err != nil {
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
common.ApiErrorT(c, "Invalid input {{error}}", map[string]any{"error": err.Error()})
return
}
if common.EmailVerificationEnabled {
if user.Email == "" || user.VerificationCode == "" {
common.ApiErrorI18n(c, i18n.MsgUserEmailVerificationRequired)
common.ApiErrorT(c, "Email verification is enabled, please enter email address and verification code")
return
}
if !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
common.ApiErrorT(c, "Verification code is incorrect or has expired")
return
}
if err := model.EnsureEmailAvailable(user.Email, 0); err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
common.ApiErrorT(c, "Email address is already in use")
return
}
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
common.ApiErrorT(c, "Database error, please contact the administrator")
return
}
}
@@ -263,12 +263,12 @@ func Register(c *gin.Context) {
}
exist, err := model.CheckUserExistOrDeleted(user.Username, emailForExistCheck)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
common.ApiErrorT(c, "Database error, please contact the administrator")
common.SysLog(common.LogText("CheckUserExistOrDeleted error: %v", err))
return
}
if exist {
common.ApiErrorI18n(c, i18n.MsgUserExists)
common.ApiErrorT(c, "Username already exists or has been deleted")
return
}
affCode := user.AffCode // this code is the inviter's code, not the user's own code
@@ -285,7 +285,7 @@ func Register(c *gin.Context) {
}
if err := cleanUser.Insert(inviterId); err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
common.ApiErrorT(c, "Email address is already in use")
return
}
common.ApiError(c, err)
@@ -295,21 +295,21 @@ func Register(c *gin.Context) {
// 获取插入后的用户ID
var insertedUser model.User
if err := model.DB.Where("username = ?", cleanUser.Username).First(&insertedUser).Error; err != nil {
common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed)
common.ApiErrorT(c, "User registration failed or user ID retrieval failed")
return
}
// 生成默认令牌
if constant.GenerateDefaultToken {
key, err := common.GenerateKey()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserDefaultTokenFailed)
common.SysLog("failed to generate token key: " + err.Error())
common.ApiErrorT(c, "Failed to generate default token")
common.SysLog(common.LogText("failed to generate token key: %s", err.Error()))
return
}
// 生成默认令牌
token := model.Token{
UserId: insertedUser.Id, // 使用插入后的用户ID
Name: cleanUser.Username + "的初始令牌",
Name: i18n.T(c, i18n.MsgUserDefaultTokenName, map[string]any{"Username": cleanUser.Username}),
Key: key,
CreatedTime: common.GetTimestamp(),
AccessedTime: common.GetTimestamp(),
@@ -322,7 +322,7 @@ func Register(c *gin.Context) {
token.Group = "auto"
}
if err := token.Insert(); err != nil {
common.ApiErrorI18n(c, i18n.MsgCreateDefaultTokenErr)
common.ApiErrorT(c, "Failed to create default token")
return
}
}
@@ -396,7 +396,7 @@ func GetUser(c *gin.Context) {
}
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, user.Role) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
common.ApiErrorT(c, "No permission to access users of same or higher level")
return
}
user.AdminPermissions = authz.Capabilities(user.Id, user.Role)
@@ -429,11 +429,16 @@ func TransferAffQuota(c *gin.Context) {
return
}
err = user.TransferAffQuotaToQuota(tran.Quota)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserTransferFailed, map[string]any{"Error": err.Error()})
var message *common.Message
if errors.As(err, &message) {
common.ApiError(c, err)
return
}
common.ApiSuccessI18n(c, i18n.MsgUserTransferSuccess, nil)
if err != nil {
common.ApiErrorT(c, "Transfer failed {{error}}", map[string]any{"error": err.Error()})
return
}
common.ApiSuccessT(c, "Transfer successful", nil)
}
func GetAffCode(c *gin.Context) {
@@ -446,10 +451,7 @@ func GetAffCode(c *gin.Context) {
if user.AffCode == "" {
user.AffCode = common.GetRandomString(4)
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
}
@@ -605,7 +607,7 @@ func generateDefaultSidebarConfig(userRole int) string {
// 转换为JSON字符串
configBytes, err := common.Marshal(defaultConfig)
if err != nil {
common.SysLog("生成默认边栏配置失败: " + err.Error())
common.SysLog(common.LogText("failed to generate the default sidebar config: %s", err.Error()))
return ""
}
@@ -650,16 +652,16 @@ func UpdateUser(c *gin.Context) {
var updatedUser model.User
err := common.DecodeJson(c.Request.Body, &updatedUser)
if err != nil || updatedUser.Id == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
updatedUser.Username = strings.TrimSpace(updatedUser.Username)
if updatedUser.Username == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
if err := common.Validate.StructExcept(&updatedUser, "Password"); err != nil {
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
common.ApiErrorT(c, "Invalid input {{error}}", map[string]any{"error": err.Error()})
return
}
originUser, err := model.GetUserById(updatedUser.Id, false)
@@ -668,16 +670,31 @@ func UpdateUser(c *gin.Context) {
return
}
if updatedUser.Role != common.RoleGuestUser && updatedUser.Role != originUser.Role {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
updatedUser.Role = originUser.Role
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, originUser.Role) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
common.ApiErrorT(c, "No permission to update users of same or higher permission level")
return
}
updatePassword := updatedUser.Password != ""
auditParams := map[string]any{
"username": originUser.Username,
"id": updatedUser.Id,
}
// Resetting a password or rewriting the admin permission matrix changes what
// the managed account can do or who can sign in to it.
if updatePassword || updatedUser.AdminPermissions != nil {
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserUpdate, service.AdminUserContext{UserID: updatedUser.Id})
if authorization == nil {
return
}
auditParams["verification_method"] = authorization.Method
auditParams["password_reset"] = updatePassword
auditParams["admin_permissions_updated"] = updatedUser.AdminPermissions != nil
}
authzTouched := false
if err := model.DB.Transaction(func(tx *gorm.DB) error {
if err := updatedUser.EditWithTx(tx, updatePassword); err != nil {
@@ -706,10 +723,7 @@ func UpdateUser(c *gin.Context) {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, updatedUser.Id, "user.update", map[string]any{
"username": originUser.Username,
"id": updatedUser.Id,
})
recordManageAuditFor(c, updatedUser.Id, "user.update", auditParams)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -720,13 +734,13 @@ func UpdateUser(c *gin.Context) {
func AdminClearUserBinding(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
bindingType := strings.ToLower(strings.TrimSpace(c.Param("binding_type")))
if bindingType == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
@@ -738,7 +752,11 @@ func AdminClearUserBinding(c *gin.Context) {
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, user.Role) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
common.ApiErrorT(c, "No permission to access users of same or higher level")
return
}
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserBindingClear, service.AdminUserBindingContext{UserID: user.Id, BindingType: bindingType})
if authorization == nil {
return
}
@@ -748,8 +766,9 @@ func AdminClearUserBinding(c *gin.Context) {
}
recordManageAuditFor(c, user.Id, "user.binding_clear", map[string]any{
"bindingType": bindingType,
"username": user.Username,
"bindingType": bindingType,
"username": user.Username,
"verification_method": authorization.Method,
})
c.JSON(http.StatusOK, gin.H{
@@ -761,7 +780,7 @@ func AdminClearUserBinding(c *gin.Context) {
func UpdateSelf(c *gin.Context) {
var requestData map[string]any
if err := common.DecodeJson(c.Request.Body, &requestData); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
@@ -794,11 +813,11 @@ func UpdateSelf(c *gin.Context) {
}
if err := model.UpdateUserSetting(user.Id, currentSetting); err != nil {
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
common.ApiErrorT(c, "Update failed")
return
}
common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
common.ApiSuccessT(c, "Update successful", nil)
return
}
@@ -820,11 +839,11 @@ func UpdateSelf(c *gin.Context) {
}
if err := model.UpdateUserSetting(user.Id, currentSetting); err != nil {
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
common.ApiErrorT(c, "Update failed")
return
}
common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
common.ApiSuccessT(c, "Update successful", nil)
return
}
@@ -832,16 +851,16 @@ func UpdateSelf(c *gin.Context) {
var user model.User
requestDataBytes, err := common.Marshal(requestData)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
if err = common.Unmarshal(requestDataBytes, &user); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
if err := common.Validate.StructExcept(&user, "Password"); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidInput)
common.ApiErrorT(c, "Invalid input")
return
}
@@ -852,7 +871,7 @@ func UpdateSelf(c *gin.Context) {
DisplayName: user.DisplayName,
}
if user.Password != "" {
identity, ok := middleware.GetSessionAuthIdentity(c)
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
@@ -876,7 +895,7 @@ func UpdateSelf(c *gin.Context) {
return
}
succeeded = true
notificationFailed = service.NotifyAccountSecurityChange(current.Email, "Password updated") != nil
notificationFailed = service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, current.Id), current.Email, i18n.MsgSecurityEventPasswordUpdated) != nil
if err := model.PublishUserAuthCache(cleanUser.Id); err != nil {
writeSecurityOperationError(c, err)
return
@@ -886,17 +905,13 @@ func UpdateSelf(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
data := authRotationData(bundle)
data["has_password"] = true
data["notification_warning"] = notificationFailed
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,
"session": bundle.Session,
"has_password": true,
"notification_warning": notificationFailed,
},
"data": data,
})
return
}
@@ -922,17 +937,23 @@ func DeleteUser(c *gin.Context) {
}
myRole := c.GetInt("role")
if myRole <= originUser.Role {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
common.ApiErrorT(c, "No permission to update users of same or higher permission level")
return
}
err = model.HardDeleteUserById(id)
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserDelete, service.AdminUserContext{UserID: originUser.Id})
if authorization == nil {
return
}
revokedAccessTokens, err := model.HardDeleteUserById(id)
if err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, originUser.Id, "user.delete", map[string]any{
"username": originUser.Username,
"id": originUser.Id,
"username": originUser.Username,
"id": originUser.Id,
"verification_method": authorization.Method,
"revoked_access_tokens": revokedAccessTokens,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -944,16 +965,22 @@ func DeleteUser(c *gin.Context) {
func DeleteSelf(c *gin.Context) {
setAuthNoStore(c)
succeeded := false
var revokedAccessTokens int64
defer func() {
recordUserSecurityAudit(c, c.GetInt("id"), "user.account_delete", map[string]any{"success": succeeded})
params := map[string]any{"success": succeeded}
if succeeded {
params["revoked_access_tokens"] = revokedAccessTokens
}
recordUserSecurityAudit(c, c.GetInt("id"), "user.account_delete", params)
}()
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}) == nil {
return
}
identity, _ := middleware.GetSessionAuthIdentity(c)
if err := model.DeleteUserForSession(identity); err != nil {
identity, _ := middleware.GetStepUpIdentity(c)
var err error
if revokedAccessTokens, err = model.DeleteUserForSession(identity); err != nil {
if errors.Is(err, model.ErrCannotDeleteRootUser) {
common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
common.ApiErrorT(c, "Cannot delete super administrator account")
return
}
writeSecurityOperationError(c, err)
@@ -973,11 +1000,15 @@ func CreateUser(c *gin.Context) {
err := common.DecodeJson(c.Request.Body, &user)
user.Username = strings.TrimSpace(user.Username)
if err != nil || user.Username == "" || user.Password == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
if err := common.Validate.Struct(&user); err != nil {
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
common.ApiErrorT(c, "Invalid input {{error}}", map[string]any{"error": err.Error()})
return
}
if !common.IsValidateRole(user.Role) {
common.ApiErrorT(c, "Invalid parameters")
return
}
if user.DisplayName == "" {
@@ -985,9 +1016,18 @@ func CreateUser(c *gin.Context) {
}
myRole := c.GetInt("role")
if user.Role >= myRole {
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
common.ApiErrorT(c, "Cannot create users with permission level equal to or higher than yourself")
return
}
auditParams := map[string]any{"role": user.Role}
// Creating an administrator grants privilege just like promoting one.
if user.Role >= common.RoleAdminUser {
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserCreate, service.AdminUserCreateContext{Role: user.Role})
if authorization == nil {
return
}
auditParams["verification_method"] = authorization.Method
}
// Even for admin users, we cannot fully trust them!
cleanUser := model.User{
Username: user.Username,
@@ -1015,10 +1055,8 @@ func CreateUser(c *gin.Context) {
}
cleanUser.FinishInsert(0)
recordManageAuditFor(c, cleanUser.Id, "user.create", map[string]any{
"username": cleanUser.Username,
"role": cleanUser.Role,
})
auditParams["username"] = cleanUser.Username
recordManageAuditFor(c, cleanUser.Id, "user.create", auditParams)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -1055,7 +1093,7 @@ func ManageUser(c *gin.Context) {
err := common.DecodeJson(c.Request.Body, &req)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
if req.Action == "add_quota" {
@@ -1068,44 +1106,48 @@ func ManageUser(c *gin.Context) {
// Fill attributes
model.DB.Unscoped().Where(&user).First(&user)
if user.Id == 0 {
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
common.ApiErrorT(c, "User does not exist")
return
}
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, user.Role) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
common.ApiErrorT(c, "No permission to update users of same or higher permission level")
return
}
switch req.Action {
case "disable":
user.Status = common.UserStatusDisabled
if user.Role == common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserCannotDisableRootUser)
common.ApiErrorT(c, "Cannot disable super administrator user")
return
}
case "enable":
user.Status = common.UserStatusEnabled
case "delete":
if user.Role == common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
common.ApiErrorT(c, "Cannot delete super administrator account")
return
}
if err := user.Delete(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserDelete, service.AdminUserContext{UserID: user.Id})
if authorization == nil {
return
}
revokedAccessTokens, err := user.Delete()
if err != nil {
common.ApiError(c, err)
return
}
// 删除用户后,强制清理 Redis 中所有该用户令牌的缓存,
// 避免已缓存的令牌在 TTL 过期前仍能通过 TokenAuth 校验。
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
common.SysLog(common.LogText("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
recordManageAuditFor(c, user.Id, "user.manage", map[string]any{
"action": req.Action,
"username": user.Username,
"id": user.Id,
"action": req.Action,
"username": user.Username,
"id": user.Id,
"verification_method": authorization.Method,
"revoked_access_tokens": revokedAccessTokens,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -1114,29 +1156,33 @@ func ManageUser(c *gin.Context) {
return
case "promote":
if myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
common.ApiErrorT(c, "Regular administrators cannot promote other users to administrator")
return
}
if user.Role >= common.RoleAdminUser {
common.ApiErrorI18n(c, i18n.MsgUserAlreadyAdmin)
common.ApiErrorT(c, "This user is already an administrator")
return
}
user.Role = common.RoleAdminUser
case "demote":
if user.Role == common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserCannotDemoteRootUser)
common.ApiErrorT(c, "Cannot demote super administrator user")
return
}
if user.Role == common.RoleCommonUser {
common.ApiErrorI18n(c, i18n.MsgUserAlreadyCommon)
common.ApiErrorT(c, "This user is already a common user")
return
}
user.Role = common.RoleCommonUser
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserManage, service.AdminUserManageContext{UserID: user.Id, Action: req.Action})
if authorization == nil {
return
}
if req.Action == "demote" {
if err := model.DB.Transaction(func(tx *gorm.DB) error {
if err := user.UpdateWithTx(tx, false); err != nil {
@@ -1170,12 +1216,13 @@ func ManageUser(c *gin.Context) {
// explicit invalidation; deleting the user hash here would discard the
// freshly published auth-version floor.
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
common.SysLog(common.LogText("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
recordManageAuditFor(c, user.Id, "user.manage", map[string]any{
"action": req.Action,
"username": user.Username,
"id": user.Id,
"action": req.Action,
"username": user.Username,
"id": user.Id,
"verification_method": authorization.Method,
})
clearUser := model.User{
Role: user.Role,
@@ -1236,14 +1283,14 @@ func getTopUpLock(userID int) *topUpTryLock {
func TopUp(c *gin.Context) {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
common.ApiErrorT(c, "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them.")
return
}
id := c.GetInt("id")
lock := getTopUpLock(id)
if !lock.TryLock() {
common.ApiErrorI18n(c, i18n.MsgUserTopUpProcessing)
common.ApiErrorT(c, "Top-up is processing, please try again later")
return
}
defer lock.Unlock()
@@ -1256,8 +1303,8 @@ func TopUp(c *gin.Context) {
quota, err := model.Redeem(req.Key, id)
if err != nil {
// 不向用户暴露兑换失败的细分原因,避免攻击者根据错误类型判断兑换码状态。
common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
logger.LogError(c, fmt.Sprintf("failed to redeem key %s for user %d: %s", req.Key, id, err.Error()))
common.ApiErrorT(c, "Redemption failed, please try again later")
logger.LogError(c, common.LogText("failed to redeem key %s for user %d: %s", req.Key, id, err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
@@ -1286,31 +1333,31 @@ type UpdateUserSettingRequest struct {
func UpdateUserSetting(c *gin.Context) {
var req UpdateUserSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
return
}
// 验证预警类型
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
common.ApiErrorI18n(c, i18n.MsgSettingInvalidType)
common.ApiErrorT(c, "Invalid warning type")
return
}
// 验证预警阈值
if req.QuotaWarningThreshold <= 0 {
common.ApiErrorI18n(c, i18n.MsgQuotaThresholdGtZero)
common.ApiErrorT(c, "Warning threshold must be greater than 0")
return
}
// 如果是webhook类型,验证webhook地址
if req.QuotaWarningType == dto.NotifyTypeWebhook {
if req.WebhookUrl == "" {
common.ApiErrorI18n(c, i18n.MsgSettingWebhookEmpty)
common.ApiErrorT(c, "Webhook URL cannot be empty")
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.WebhookUrl); err != nil {
common.ApiErrorI18n(c, i18n.MsgSettingWebhookInvalid)
common.ApiErrorT(c, "Invalid Webhook URL")
return
}
}
@@ -1319,7 +1366,7 @@ func UpdateUserSetting(c *gin.Context) {
if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" {
// 验证邮箱格式
if !strings.Contains(req.NotificationEmail, "@") {
common.ApiErrorI18n(c, i18n.MsgSettingEmailInvalid)
common.ApiErrorT(c, "Invalid email address")
return
}
}
@@ -1327,17 +1374,17 @@ func UpdateUserSetting(c *gin.Context) {
// 如果是Bark类型,验证Bark URL
if req.QuotaWarningType == dto.NotifyTypeBark {
if req.BarkUrl == "" {
common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlEmpty)
common.ApiErrorT(c, "Bark push URL cannot be empty")
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlInvalid)
common.ApiErrorT(c, "Invalid Bark push URL")
return
}
// 检查是否是HTTP或HTTPS
if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp)
common.ApiErrorT(c, "URL must start with http:// or https://")
return
}
}
@@ -1345,21 +1392,21 @@ func UpdateUserSetting(c *gin.Context) {
// 如果是Gotify类型,验证Gotify URL和Token
if req.QuotaWarningType == dto.NotifyTypeGotify {
if req.GotifyUrl == "" {
common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlEmpty)
common.ApiErrorT(c, "Gotify server URL cannot be empty")
return
}
if req.GotifyToken == "" {
common.ApiErrorI18n(c, i18n.MsgSettingGotifyTokenEmpty)
common.ApiErrorT(c, "Gotify token cannot be empty")
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlInvalid)
common.ApiErrorT(c, "Invalid Gotify server URL")
return
}
// 检查是否是HTTP或HTTPS
if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp)
common.ApiErrorT(c, "URL must start with http:// or https://")
return
}
}
@@ -1421,9 +1468,9 @@ func UpdateUserSetting(c *gin.Context) {
// 更新用户设置
if err := model.UpdateUserSetting(user.Id, settings); err != nil {
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
common.ApiErrorT(c, "Update failed")
return
}
common.ApiSuccessI18n(c, i18n.MsgSettingSaved, nil)
common.ApiSuccessT(c, "Settings updated", nil)
}
+129 -11
View File
@@ -17,6 +17,8 @@ import (
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
@@ -31,7 +33,7 @@ func setupManageUserTestDB(t *testing.T) *gorm.DB {
t.Helper()
require.NoError(t, i18n.Init())
previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedisEnabled := common.RedisEnabled
previousRedisEnabled, previousSecret := common.RedisEnabled, common.SessionSecret
previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType()
dialect := os.Getenv("TEST_MANAGE_USER_DIALECT")
if dialect == "" {
@@ -49,11 +51,12 @@ func setupManageUserTestDB(t *testing.T) *gorm.DB {
}
model.DB, model.LOG_DB = db, logDB
common.RedisEnabled = false
common.SessionSecret = "manage-user-test-secret"
common.SetDatabaseTypes(databaseTypes[dialect], databaseTypes[dialect])
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.RedisEnabled = previousRedisEnabled
common.RedisEnabled, common.SessionSecret = previousRedisEnabled, previousSecret
common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType)
sqlDB, err := db.DB()
if err == nil {
@@ -66,7 +69,7 @@ func setupManageUserTestDB(t *testing.T) *gorm.DB {
}
}
})
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.CasbinRule{}, &model.AuthzRole{}))
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.AuthFlow{}, &model.TwoFA{}, &model.PasskeyCredential{}, &model.UserAccessToken{}))
require.NoError(t, logDB.AutoMigrate(&model.Log{}, &model.AuditLog{}))
versionQuery := "SELECT version()"
if dialect == "sqlite" {
@@ -79,20 +82,50 @@ func setupManageUserTestDB(t *testing.T) *gorm.DB {
}
func performManageUserRequest(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
return performVerifiedManageUserRequest(t, body, service.AuthIdentity{UserID: 9999}, "")
}
// performVerifiedManageUserRequest drives ManageUser as the root operator from
// a dashboard login session, carrying the step-up proof status or role changes need.
func performVerifiedManageUserRequest(t *testing.T, body string, identity service.AuthIdentity, proof string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/manage", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("id", 9999)
if proof != "" {
c.Request.Header.Set("X-Security-Proof", proof)
}
c.Set("id", identity.UserID)
c.Set("role", common.RoleRootUser)
c.Set("username", "root-operator")
c.Set("session_id", identity.SessionID)
c.Set("auth_version", identity.UserAuthVersion)
c.Set("session_version", identity.SessionVersion)
c.Set(common.RequestIdKey, "quota-test-request")
ManageUser(c)
return recorder
}
// manageUserProof signs the root operator in and issues a password-method proof
// bound to one ManageUser operation.
func manageUserProof(t *testing.T, db *gorm.DB, operation service.VerificationOperation) (service.AuthIdentity, string) {
t.Helper()
operator := createQuotaTestOperator(t, db, common.RoleRootUser)
require.NoError(t, model.PublishUserAuthCache(operator.Id))
bundle, err := service.CreateLoginSession(operator.Id, "password", "127.0.0.1", "manage-user-test")
require.NoError(t, err)
identity, err := service.ParseAccessToken(bundle.AccessToken)
require.NoError(t, err)
binding, err := service.BindVerificationOperation(operation)
require.NoError(t, err)
proof, _, err := service.IssueSecurityProof(identity, service.VerificationMethodPassword, binding)
require.NoError(t, err)
return identity, proof
}
func TestManageUserDisableAdvancesAuthVersionOnceAndRevokesSession(t *testing.T) {
db := setupManageUserTestDB(t)
now := time.Now().Unix()
@@ -107,7 +140,8 @@ func TestManageUserDisableAdvancesAuthVersionOnceAndRevokesSession(t *testing.T)
LastActiveAt: now, ExpiresAt: now + 3600,
}).Error)
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"disable"}`, user.Id))
identity, proof := manageUserProof(t, db, service.VerificationOperation{Scope: service.VerificationScopeAdminUserManage, Context: []byte(fmt.Sprintf(`{"user_id":%d,"action":"disable"}`, user.Id))})
recorder := performVerifiedManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"disable"}`, user.Id), identity, proof)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":true`)
@@ -148,7 +182,8 @@ func TestManageUserDemoteAdvancesAuthVersionAndRevokesSessionsOnce(t *testing.T)
}
}))
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"demote"}`, user.Id))
identity, proof := manageUserProof(t, db, service.VerificationOperation{Scope: service.VerificationScopeAdminUserManage, Context: []byte(fmt.Sprintf(`{"user_id":%d,"action":"demote"}`, user.Id))})
recorder := performVerifiedManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"demote"}`, user.Id), identity, proof)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":true`)
@@ -174,7 +209,8 @@ func TestManageUserDeleteReturnsImmediatelyAndUnknownActionFails(t *testing.T) {
}
require.NoError(t, db.Create(&deleted).Error)
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"delete"}`, deleted.Id))
identity, proof := manageUserProof(t, db, service.VerificationOperation{Scope: service.VerificationScopeAdminUserDelete, Context: []byte(fmt.Sprintf(`{"user_id":%d}`, deleted.Id))})
recorder := performVerifiedManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"delete"}`, deleted.Id), identity, proof)
assert.Contains(t, recorder.Body.String(), `"success":true`)
var deletedCount int64
require.NoError(t, db.Unscoped().Model(&model.User{}).Where("id = ? AND deleted_at IS NOT NULL", deleted.Id).Count(&deletedCount).Error)
@@ -197,7 +233,7 @@ func createQuotaTestOperator(t *testing.T, db *gorm.DB, role int) model.User {
if role == 0 {
role = common.RoleRootUser
}
operator := model.User{Id: 9999, Username: "root-operator", Role: role, Status: common.UserStatusEnabled, AuthVersion: 1, AffCode: "root-operator-aff"}
operator := model.User{Id: 9999, Username: "root-operator", Password: "root-operator-hash", Role: role, Status: common.UserStatusEnabled, AuthVersion: 1, AffCode: "root-operator-aff"}
require.NoError(t, db.Create(&operator).Error)
return operator
}
@@ -227,7 +263,7 @@ func TestManageUserQuotaRecordsTopupAndAudit(t *testing.T) {
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.wantQuota, user.Quota)
logs, total, err := model.GetAllLogs(model.LogTypeTopup, 0, 0, "", "", "", 0, 20, 0, "", "", "")
logs, total, err := model.GetAllLogs(model.LogFilter{Type: model.LogTypeTopup}, 0, 20)
require.NoError(t, err)
assert.EqualValues(t, 1, total)
require.Len(t, logs, 1)
@@ -239,7 +275,7 @@ func TestManageUserQuotaRecordsTopupAndAudit(t *testing.T) {
require.NoError(t, common.UnmarshalJsonStr(logs[0].Other, &other))
assert.Equal(t, &model.AuditAdminInfo{AdminID: 9999, AdminUsername: "root-operator", AdminRole: common.RoleRootUser, AuthMethod: "session"}, other.AdminInfo)
logs, total, err = model.GetUserLogs(user.Id, model.LogTypeTopup, 0, 0, "", "", 0, 20, "", "", "")
logs, total, err = model.GetUserLogs(user.Id, model.LogFilter{Type: model.LogTypeTopup}, 0, 20)
require.NoError(t, err)
assert.EqualValues(t, 1, total)
require.Len(t, logs, 1)
@@ -260,7 +296,7 @@ func TestManageUserQuotaRecordsTopupAndAudit(t *testing.T) {
assert.Equal(t, "quota-test-request", logs[0].RequestId)
assert.Empty(t, logs[0].Ip, "recipient logs must not disclose the administrator IP")
logs, total, err = model.GetUserLogs(9999, model.LogTypeTopup, 0, 0, "", "", 0, 20, "", "", "")
logs, total, err = model.GetUserLogs(9999, model.LogFilter{Type: model.LogTypeTopup}, 0, 20)
require.NoError(t, err)
assert.Zero(t, total)
assert.Empty(t, logs)
@@ -606,3 +642,85 @@ func TestManageUserQuotaCacheUsesCommittedIntegerDifference(t *testing.T) {
})
}
}
// Saving notification settings starts from what the user stored: fields the
// form does not manage stay, and switches an older client leaves out keep
// their stored values. Only administrators change the channel and upstream
// switches, and a threshold of 0 means the site default.
func TestUpdateUserSettingKeepsWhatTheRequestLeavesOut(t *testing.T) {
on, off := true, false
stored := dto.UserSetting{
NotifyType: dto.NotifyTypeBark,
BarkUrl: "https://api.day.app/key/{{title}}/{{content}}",
QuotaWarningThreshold: 500000,
QuotaWarningEnabled: &off,
ChannelNotifyEnabled: &on,
UpstreamModelUpdateNotifyEnabled: true,
Language: "zhCN",
SidebarModules: `{"chat":{"enabled":true}}`,
BillingPreference: "wallet_only",
}
kept := func(setting dto.UserSetting) dto.UserSetting {
setting.Language, setting.SidebarModules, setting.BillingPreference = stored.Language, stored.SidebarModules, stored.BillingPreference
return setting
}
switchesChanged := `{"notify_type":"email","quota_warning_threshold":0,"quota_warning_enabled":true,"channel_notify_enabled":false,"upstream_model_update_notify_enabled":false}`
for _, tc := range []struct {
name string
role int
body string
wantSuccess bool
want dto.UserSetting
}{
{
name: "older client without the new switches",
role: common.RoleAdminUser,
body: `{"notify_type":"webhook","quota_warning_threshold":2000,"webhook_url":"https://example.com/hook","webhook_secret":"hook-secret","accept_unset_model_ratio_model":true,"record_ip_log":true}`,
wantSuccess: true,
want: kept(dto.UserSetting{
NotifyType: dto.NotifyTypeWebhook, WebhookUrl: "https://example.com/hook", WebhookSecret: "hook-secret",
QuotaWarningThreshold: 2000, QuotaWarningEnabled: &off, ChannelNotifyEnabled: &on, UpstreamModelUpdateNotifyEnabled: true,
AcceptUnsetRatioModel: true, RecordIpLog: true,
}),
},
{
name: "administrator changes the switches and uses the site default threshold",
role: common.RoleAdminUser,
body: switchesChanged,
wantSuccess: true,
want: kept(dto.UserSetting{NotifyType: dto.NotifyTypeEmail, QuotaWarningEnabled: &on, ChannelNotifyEnabled: &off}),
},
{
name: "user cannot change the administrator switches",
role: common.RoleCommonUser,
body: switchesChanged,
wantSuccess: true,
want: kept(dto.UserSetting{NotifyType: dto.NotifyTypeEmail, QuotaWarningEnabled: &on, ChannelNotifyEnabled: &on, UpstreamModelUpdateNotifyEnabled: true}),
},
{
name: "negative threshold",
role: common.RoleCommonUser,
body: `{"notify_type":"email","quota_warning_threshold":-1}`,
want: stored,
},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupManageUserTestDB(t)
setting, err := common.Marshal(stored)
require.NoError(t, err)
user := model.User{Username: "setting-owner", Role: tc.role, Status: common.UserStatusEnabled, AffCode: "setting-owner-aff", Setting: string(setting)}
require.NoError(t, db.Create(&user).Error)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPut, "/api/user/setting", strings.NewReader(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("id", user.Id)
UpdateUserSetting(c)
assert.Contains(t, recorder.Body.String(), fmt.Sprintf(`"success":%t`, tc.wantSuccess))
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.want, user.GetSetting())
})
}
}
+4 -5
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -50,16 +49,16 @@ func manageUserQuota(c *gin.Context, req ManageRequest) {
case errors.Is(err, model.ErrInvalidUserQuotaAdjustment):
params["failure_reason"] = "invalid_parameters"
if (req.Mode == "add" || req.Mode == "subtract") && req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
common.ApiErrorT(c, "Quota change amount cannot be zero")
} else {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
common.ApiErrorT(c, "Invalid parameters")
}
case errors.Is(err, model.ErrUserQuotaPermission):
params["failure_reason"] = "permission_denied"
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
common.ApiErrorT(c, "No permission to update users of same or higher permission level")
case errors.Is(err, gorm.ErrRecordNotFound):
params["failure_reason"] = "target_not_found"
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
common.ApiErrorT(c, "User does not exist")
case errors.Is(err, model.ErrWalletQuotaLimitExceeded):
params["failure_reason"] = "quota_limit_exceeded"
common.ApiError(c, err)
Loaded 100 of 1022 files, more files were not shown because too many files have changed in this diff. Show more