feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
+299
-20
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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: `{}`},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)...))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", "您的帳戶安全設定有變動:已綁定登入帳號 <b>Corp</b>。"},
|
||||
{"saved language over the request", "en", "zh-CN", "en@example.com", "Your account security settings have changed: Login account linked: <b>Corp</b>."},
|
||||
{"default language when neither states one", "", "", "default@example.com", "Your account security settings have changed: Login account linked: <b>Corp</b>."},
|
||||
} {
|
||||
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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user