diff --git a/common/topup-ratio.go b/common/topup-ratio.go
index 8f03395d2f97..79d448ca2539 100644
--- a/common/topup-ratio.go
+++ b/common/topup-ratio.go
@@ -2,6 +2,7 @@ package common
import (
"encoding/json"
+ "sync"
)
var TopupGroupRatio = map[string]float64{
@@ -10,7 +11,21 @@ var TopupGroupRatio = map[string]float64{
"svip": 1,
}
+var topupGroupRatioMutex sync.RWMutex
+
+func GetTopupGroupRatioCopy() map[string]float64 {
+ topupGroupRatioMutex.RLock()
+ defer topupGroupRatioMutex.RUnlock()
+ cp := make(map[string]float64, len(TopupGroupRatio))
+ for k, v := range TopupGroupRatio {
+ cp[k] = v
+ }
+ return cp
+}
+
func TopupGroupRatio2JSONString() string {
+ topupGroupRatioMutex.RLock()
+ defer topupGroupRatioMutex.RUnlock()
jsonBytes, err := json.Marshal(TopupGroupRatio)
if err != nil {
SysError("error marshalling model ratio: " + err.Error())
@@ -19,12 +34,20 @@ func TopupGroupRatio2JSONString() string {
}
func UpdateTopupGroupRatioByJSONString(jsonStr string) error {
- TopupGroupRatio = make(map[string]float64)
- return json.Unmarshal([]byte(jsonStr), &TopupGroupRatio)
+ var tmp map[string]float64
+ if err := json.Unmarshal([]byte(jsonStr), &tmp); err != nil {
+ return err
+ }
+ topupGroupRatioMutex.Lock()
+ TopupGroupRatio = tmp
+ topupGroupRatioMutex.Unlock()
+ return nil
}
func GetTopupGroupRatio(name string) float64 {
+ topupGroupRatioMutex.RLock()
ratio, ok := TopupGroupRatio[name]
+ topupGroupRatioMutex.RUnlock()
if !ok {
SysError("topup group ratio not found: " + name)
return 1
diff --git a/controller/misc.go b/controller/misc.go
index 875142ffbf04..93c4f07b7e60 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -42,6 +42,12 @@ func GetStatus(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
+ // 获取用户角色信息(如果已登录)
+ var userRole int = -1
+ if role := c.GetInt("role"); role > 0 {
+ userRole = role
+ }
+
data := gin.H{
"version": common.Version,
"start_time": common.StartTime,
@@ -63,6 +69,7 @@ func GetStatus(c *gin.Context) {
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
+ "invitation_enabled": operation_setting.GetGeneralSetting().InvitationEnabled,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
"enable_batch_update": common.BatchUpdateEnabled,
@@ -87,9 +94,8 @@ func GetStatus(c *gin.Context) {
"announcements_enabled": cs.AnnouncementsEnabled,
"faq_enabled": cs.FAQEnabled,
- // 模块管理配置
- "HeaderNavModules": common.OptionMap["HeaderNavModules"],
- "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
+ // 模块管理配置 - 根据用户权限过滤
+ "header_nav_modules": filterHeaderNavModulesForUser(common.OptionMap["HeaderNavModules"], userRole),
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
@@ -303,3 +309,307 @@ func ResetPassword(c *gin.Context) {
})
return
}
+
+// filterHeaderNavModulesForUser 根据用户权限过滤顶栏模块配置
+func filterHeaderNavModulesForUser(headerNavModulesRaw interface{}, userRole int) interface{} {
+ // 如果配置为空,返回原配置
+ if headerNavModulesRaw == nil {
+ return headerNavModulesRaw
+ }
+
+ headerNavModulesStr, ok := headerNavModulesRaw.(string)
+ if !ok || headerNavModulesStr == "" {
+ return headerNavModulesRaw
+ }
+
+ // 解析配置
+ var config map[string]interface{}
+ if err := json.Unmarshal([]byte(headerNavModulesStr), &config); err != nil {
+ // 解析失败时返回空配置,采用安全优先策略
+ return "{}"
+ }
+
+ // 对于所有用户(包括未登录用户),都需要移除被禁用的模块
+ filteredConfig := make(map[string]interface{})
+ for key, value := range config {
+ // 首先检查模块是否启用
+ if isHeaderNavModuleEnabled(key, value) {
+ // 未登录用户:仅移除被禁用的模块,保留启用模块(包括 pricing,以便前端根据 requireAuth 决定跳转到 /login)
+ if userRole == -1 {
+ filteredConfig[key] = value
+ continue
+ }
+
+ // 超级管理员可以看到所有启用的模块
+ if userRole >= common.RoleRootUser {
+ filteredConfig[key] = value
+ } else {
+ // 管理员和普通用户:进行权限检查
+ if hasHeaderNavModulePermission(key, value, userRole) {
+ filteredConfig[key] = value
+ }
+ }
+ }
+ // 被禁用的模块(isHeaderNavModuleEnabled返回false)不会被添加到filteredConfig中
+ }
+
+ // 转换回JSON字符串
+ filteredBytes, err := json.Marshal(filteredConfig)
+ if err != nil {
+ return "{}"
+ }
+
+ return string(filteredBytes)
+}
+
+// FilterSidebarModulesAdminForUser 根据用户权限过滤侧边栏管理配置
+func FilterSidebarModulesAdminForUser(sidebarModulesRaw interface{}, userRole int) interface{} {
+ // 如果用户未登录,返回空配置以保护敏感信息
+ if userRole == -1 {
+ return "{}"
+ }
+
+ if sidebarModulesRaw == nil {
+ return sidebarModulesRaw
+ }
+
+ sidebarModulesStr, ok := sidebarModulesRaw.(string)
+ if !ok || sidebarModulesStr == "" {
+ return sidebarModulesRaw
+ }
+
+ // 解析配置
+ var config map[string]interface{}
+ if err := json.Unmarshal([]byte(sidebarModulesStr), &config); err != nil {
+ // 解析失败时返回空配置,采用安全优先策略
+ return "{}"
+ }
+
+
+
+ // 对于所有用户,移除被禁用的模块
+ filteredConfig := make(map[string]interface{})
+ for sectionKey, sectionValue := range config {
+ if sectionObj, ok := sectionValue.(map[string]interface{}); ok {
+ // 检查区域是否启用
+ sectionEnabledByConfig := true
+ if enabled, hasEnabled := sectionObj["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok && !enabledBool {
+ sectionEnabledByConfig = false
+ }
+ }
+
+ // 如果区域被配置为禁用,跳过整个区域(但console区域需要特殊处理,因为数据看板始终可访问)
+ if !sectionEnabledByConfig && sectionKey != "console" {
+ continue
+ }
+
+ filteredSection := make(map[string]interface{})
+ hasValidModules := false // 标记区域是否有有效的模块
+
+ // 复制区域配置
+ for moduleKey, moduleValue := range sectionObj {
+ // 检查用户是否有权限访问此模块
+ modulePath := sectionKey + "." + moduleKey
+
+ // 数据看板始终允许访问,强制设置为启用
+ if modulePath == "console.detail" {
+ filteredSection[moduleKey] = true
+ hasValidModules = true
+ } else if moduleKey == "enabled" {
+ // 只有当区域启用时才复制enabled字段
+ if sectionEnabledByConfig {
+ filteredSection[moduleKey] = moduleValue
+ }
+ } else if sectionEnabledByConfig && hasModulePermissionForUser(userRole, modulePath, moduleValue) {
+ // 处理嵌套权限(如admin.user.groupManagement)
+ if moduleObj, ok := moduleValue.(map[string]interface{}); ok {
+ filteredModule := make(map[string]interface{})
+
+ // 首先检查模块本身是否启用
+ if enabled, hasEnabled := moduleObj["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok && !enabledBool {
+ continue // 跳过被禁用的模块
+ }
+ filteredModule["enabled"] = enabled
+ }
+
+ // 过滤子模块
+ for subKey, subValue := range moduleObj {
+ if subKey == "enabled" {
+ continue // enabled字段已经处理过了
+ }
+
+ subModulePath := modulePath + "." + subKey
+
+ // 检查子模块是否启用
+ if subValueBool, ok := subValue.(bool); ok && !subValueBool {
+ continue // 跳过被禁用的子模块
+ }
+
+ if hasModulePermissionForUser(userRole, subModulePath, subValue) {
+ filteredModule[subKey] = subValue
+ }
+ }
+
+ // 只有当过滤后的模块不为空时才添加
+ if len(filteredModule) > 0 {
+ filteredSection[moduleKey] = filteredModule
+ hasValidModules = true
+ }
+ } else {
+ // 检查简单模块值是否启用
+ if moduleValueBool, ok := moduleValue.(bool); ok && !moduleValueBool {
+ continue // 跳过被禁用的简单模块
+ }
+ filteredSection[moduleKey] = moduleValue
+ hasValidModules = true
+ }
+ }
+ }
+
+ // 只有当区域有有效模块时才添加到结果中
+ if hasValidModules && len(filteredSection) > 0 {
+ filteredConfig[sectionKey] = filteredSection
+ }
+ }
+ }
+
+ // 转换回JSON字符串
+ filteredBytes, err := json.Marshal(filteredConfig)
+ if err != nil {
+ return "{}"
+ }
+
+ return string(filteredBytes)
+}
+
+// isHeaderNavModuleEnabled 检查顶栏模块是否启用
+func isHeaderNavModuleEnabled(moduleKey string, moduleValue interface{}) bool {
+ switch v := moduleValue.(type) {
+ case bool:
+ return v
+ case map[string]interface{}:
+ if enabled, hasEnabled := v["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok {
+ return enabledBool
+ }
+ }
+ return true // 如果没有enabled字段,默认启用
+ default:
+ return true
+ }
+}
+
+// hasHeaderNavModulePermission 检查用户是否有权限访问顶栏模块
+func hasHeaderNavModulePermission(moduleKey string, moduleValue interface{}, userRole int) bool {
+ // 对于模型广场,需要检查requireAuth配置
+ if moduleKey == "pricing" {
+ return checkPricingModulePermission(moduleValue, userRole)
+ }
+
+ // 未登录用户和普通用户只能访问基础模块
+ if userRole < common.RoleAdminUser {
+ allowedModules := map[string]bool{
+ "home": true,
+ "console": true,
+ "docs": true, // 文档允许未登录用户访问
+ "about": true, // 关于页面允许未登录用户访问
+ }
+ return allowedModules[moduleKey]
+ }
+
+ // 管理员可以访问更多模块
+ return true
+}
+
+// checkPricingModulePermission 检查模型广场模块的权限
+func checkPricingModulePermission(moduleValue interface{}, userRole int) bool {
+ // 如果是布尔值配置,默认不需要登录
+ if boolValue, ok := moduleValue.(bool); ok {
+ return boolValue // 简单的启用/禁用
+ }
+
+ // 如果是对象配置,检查requireAuth设置
+ if objValue, ok := moduleValue.(map[string]interface{}); ok {
+ // 检查模块是否启用
+ if enabled, hasEnabled := objValue["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok && !enabledBool {
+ return false // 模块被禁用
+ }
+ }
+
+ // 检查是否需要登录
+ if requireAuth, hasRequireAuth := objValue["requireAuth"]; hasRequireAuth {
+ if requireAuthBool, ok := requireAuth.(bool); ok && requireAuthBool {
+ // 需要登录才能访问,未登录用户不能访问
+ return userRole >= common.RoleCommonUser
+ }
+ }
+
+ // 默认不需要登录
+ return true
+ }
+
+ // 其他情况默认允许
+ return true
+}
+
+// hasModulePermissionForUser 检查用户是否有权限访问指定模块
+func hasModulePermissionForUser(userRole int, modulePath string, moduleValue interface{}) bool {
+ // 数据看板始终允许访问,不受控制台区域开关影响
+ if modulePath == "console.detail" {
+ return true
+ }
+
+ // 普通用户只能访问基础功能
+ if userRole < common.RoleAdminUser {
+ return isUserModuleAllowedInFilter(modulePath)
+ }
+
+ // 管理员需要检查模块是否启用
+ if userRole >= common.RoleAdminUser && userRole < common.RoleRootUser {
+ // 检查模块值是否为启用状态
+ switch v := moduleValue.(type) {
+ case bool:
+ return v
+ case map[string]interface{}:
+ if enabled, hasEnabled := v["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok {
+ return enabledBool
+ }
+ }
+ return true // 如果没有enabled字段,默认启用
+ default:
+ return true
+ }
+ }
+
+ return true
+}
+
+// isUserModuleAllowedInFilter 检查普通用户是否允许访问指定模块(用于过滤)
+func isUserModuleAllowedInFilter(modulePath string) bool {
+ // 数据看板始终允许访问,不受控制台区域开关影响
+ if modulePath == "console.detail" {
+ return true
+ }
+
+ // 普通用户允许访问的模块列表
+ allowedModules := map[string]bool{
+ "console.enabled": true,
+ "console.detail": true,
+ "console.token": true,
+ "console.log": true,
+ "console.midjourney": true,
+ "console.task": true,
+ "personal.enabled": true,
+ "personal.topup": true,
+ "personal.personal": true,
+ "chat.enabled": true,
+ "chat.playground": true,
+ "chat.chat": true,
+ }
+
+ return allowedModules[modulePath]
+}
diff --git a/controller/option.go b/controller/option.go
index 3e59c68e044e..7d1c676f540e 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -129,7 +129,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "ImageRatio":
- err = ratio_setting.UpdateImageRatioByJSONString(option.Value)
+ err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -138,7 +138,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "AudioRatio":
- err = ratio_setting.UpdateAudioRatioByJSONString(option.Value)
+ err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -147,7 +147,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "AudioCompletionRatio":
- err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value)
+ err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
diff --git a/controller/pricing.go b/controller/pricing.go
index 4b7cc86d505c..c77250e0a174 100644
--- a/controller/pricing.go
+++ b/controller/pricing.go
@@ -1,6 +1,9 @@
package controller
import (
+ "encoding/json"
+ "net/http"
+ "one-api/common"
"one-api/model"
"one-api/setting"
"one-api/setting/ratio_setting"
@@ -9,6 +12,15 @@ import (
)
func GetPricing(c *gin.Context) {
+ // 检查模型广场访问权限
+ allowed, code, msg := checkPricingAccess(c)
+ if !allowed {
+ c.JSON(code, gin.H{
+ "success": false,
+ "message": msg,
+ })
+ return
+ }
pricing := model.GetPricing()
userId, exists := c.Get("id")
usableGroup := map[string]string{}
@@ -49,6 +61,91 @@ func GetPricing(c *gin.Context) {
})
}
+// checkPricingAccess 检查用户是否有权限访问模型广场
+// 返回值:(是否允许访问, HTTP状态码, 错误消息)
+func checkPricingAccess(c *gin.Context) (bool, int, string) {
+ // 获取顶栏模块配置
+ common.OptionMapRWMutex.RLock()
+ headerNavModulesRaw, exists := common.OptionMap["HeaderNavModules"]
+ common.OptionMapRWMutex.RUnlock()
+
+ if !exists || headerNavModulesRaw == "" {
+ // 如果没有配置,默认允许访问
+ return true, 0, ""
+ }
+
+ // 解析配置
+ var config map[string]interface{}
+ if err := json.Unmarshal([]byte(headerNavModulesRaw), &config); err != nil {
+ // 解析失败时返回500错误
+ return false, http.StatusInternalServerError, "配置解析失败"
+ }
+
+ // 检查pricing模块配置
+ pricingConfig, hasPricing := config["pricing"]
+ if !hasPricing {
+ // 如果没有pricing配置,默认允许访问
+ return true, 0, ""
+ }
+
+ // 检查模块是否启用
+ if !isPricingModuleEnabled(pricingConfig) {
+ return false, http.StatusForbidden, "模型广场功能已被禁用"
+ }
+
+ // 检查是否需要登录
+ if isPricingRequireAuth(pricingConfig) {
+ // 需要登录,检查用户是否已登录
+ userId, exists := c.Get("id")
+ if !exists || userId == nil {
+ return false, http.StatusUnauthorized, "需要登录才能访问模型广场"
+ }
+
+ // 从数据库获取用户信息验证角色
+ user, err := model.GetUserById(userId.(int), false)
+ if err != nil {
+ return false, http.StatusInternalServerError, "获取用户信息失败"
+ }
+
+ if user.Role < common.RoleCommonUser {
+ return false, http.StatusForbidden, "权限不足"
+ }
+ }
+
+ // 允许访问
+ return true, 0, ""
+}
+
+// isPricingModuleEnabled 检查pricing模块是否启用
+func isPricingModuleEnabled(moduleValue interface{}) bool {
+ switch v := moduleValue.(type) {
+ case bool:
+ return v
+ case map[string]interface{}:
+ if enabled, hasEnabled := v["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok {
+ return enabledBool
+ }
+ }
+ return true // 如果没有enabled字段,默认启用
+ default:
+ return true
+ }
+}
+
+// isPricingRequireAuth 检查pricing模块是否需要登录
+func isPricingRequireAuth(moduleValue interface{}) bool {
+ if objValue, ok := moduleValue.(map[string]interface{}); ok {
+ if requireAuth, hasRequireAuth := objValue["requireAuth"]; hasRequireAuth {
+ if requireAuthBool, ok := requireAuth.(bool); ok {
+ return requireAuthBool
+ }
+ }
+ }
+ // 默认不需要登录
+ return false
+}
+
func ResetModelRatio(c *gin.Context) {
defaultStr := ratio_setting.DefaultModelRatio2JSONString()
err := model.UpdateOption("ModelRatio", defaultStr)
diff --git a/controller/user.go b/controller/user.go
index 982329cec0de..79ee42e4a851 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -1,6 +1,7 @@
package controller
import (
+ "encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -10,7 +11,9 @@ import (
"one-api/logger"
"one-api/model"
"one-api/setting"
+ "one-api/setting/operation_setting"
"strconv"
+ "time"
"strings"
"sync"
@@ -25,6 +28,61 @@ type LoginRequest struct {
Password string `json:"password"`
}
+// validateAvatar 验证头像数据
+func validateAvatar(avatarData string) error {
+ if avatarData == "" {
+ return nil // 允许空头像
+ }
+
+ // 检查是否是有效的base64数据
+ if !strings.HasPrefix(avatarData, "data:image/") {
+ return fmt.Errorf("头像必须是有效的图片格式")
+ }
+
+ // 提取base64数据部分
+ parts := strings.Split(avatarData, ",")
+ if len(parts) != 2 {
+ return fmt.Errorf("头像数据格式无效")
+ }
+
+ // 检查MIME类型
+ mimeType := parts[0]
+ allowedTypes := []string{
+ "data:image/jpeg;base64",
+ "data:image/jpg;base64",
+ "data:image/png;base64",
+ "data:image/gif;base64",
+ "data:image/webp;base64",
+ }
+
+ isValidType := false
+ for _, allowedType := range allowedTypes {
+ if mimeType == allowedType {
+ isValidType = true
+ break
+ }
+ }
+
+ if !isValidType {
+ return fmt.Errorf("不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP")
+ }
+
+ // 解码base64数据检查大小
+ base64Data := parts[1]
+ decodedData, err := base64.StdEncoding.DecodeString(base64Data)
+ if err != nil {
+ return fmt.Errorf("头像数据解码失败")
+ }
+
+ // 检查文件大小(2MB限制)
+ const maxSize = 2 * 1024 * 1024 // 2MB
+ if len(decodedData) > maxSize {
+ return fmt.Errorf("头像文件大小不能超过2MB")
+ }
+
+ return nil
+}
+
func Login(c *gin.Context) {
if !common.PasswordLoginEnabled {
c.JSON(http.StatusOK, gin.H{
@@ -115,6 +173,7 @@ func setupLogin(user *model.User, c *gin.Context) {
Role: user.Role,
Status: user.Status,
Group: user.Group,
+ Avatar: user.Avatar,
}
c.JSON(http.StatusOK, gin.H{
"message": "",
@@ -375,6 +434,16 @@ type TransferAffQuotaRequest struct {
}
func TransferAffQuota(c *gin.Context) {
+ // 检查邀请功能是否启用
+ generalSetting := operation_setting.GetGeneralSetting()
+ if !generalSetting.InvitationEnabled {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "邀请功能已被管理员禁用",
+ })
+ return
+ }
+
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
@@ -401,6 +470,16 @@ func TransferAffQuota(c *gin.Context) {
}
func GetAffCode(c *gin.Context) {
+ // 检查邀请功能是否启用
+ generalSetting := operation_setting.GetGeneralSetting()
+ if !generalSetting.InvitationEnabled {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "邀请功能已被管理员禁用",
+ })
+ return
+ }
+
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
@@ -436,13 +515,45 @@ func GetSelf(c *gin.Context) {
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = ""
+ // 完全移除头像数据,头像通过专用端点获取
+ user.Avatar = ""
+
// 计算用户权限信息
permissions := calculateUserPermissions(userRole)
// 获取用户设置并提取sidebar_modules
userSetting := user.GetSetting()
- // 构建响应数据,包含用户信息和权限
+ // 计算系统允许的最大权限范围
+ systemSidebarConfig := calculateFinalSidebarConfig(userRole, userSetting)
+
+ // 提取并过滤用户的侧边栏偏好设置,确保与系统权限一致
+ var userSidebarModules interface{}
+ if userSetting.SidebarModules != "" {
+ var userSidebarModulesMap map[string]interface{}
+ if err := json.Unmarshal([]byte(userSetting.SidebarModules), &userSidebarModulesMap); err == nil {
+ // 基于系统权限过滤用户偏好
+ filteredUserModules := filterUserModulesBySystemConfig(userSidebarModulesMap, systemSidebarConfig)
+ userSidebarModules = filteredUserModules
+ } else {
+ userSidebarModules = userSetting.SidebarModules
+ }
+ } else {
+ userSidebarModules = map[string]interface{}{}
+ }
+
+ // 清理用户设置中的sidebar_modules,确保与最终配置一致
+ cleanedSetting := cleanUserSettingForResponse(user.Setting, systemSidebarConfig)
+
+ // 计算最终的显示配置(系统权限 ∩ 用户偏好)
+ finalSidebarConfig := calculateFinalDisplayConfig(systemSidebarConfig, userSidebarModules)
+
+ // 精简权限信息,只保留必要的权限标识
+ simplifiedPermissions := map[string]interface{}{
+ "sidebar_settings": permissions["sidebar_settings"], // 是否有侧边栏设置权限
+ }
+
+ // 构建响应数据,包含用户信息和精简的配置
responseData := map[string]interface{}{
"id": user.Id,
"username": user.Username,
@@ -460,10 +571,11 @@ func GetSelf(c *gin.Context) {
"aff_history_quota": user.AffHistoryQuota,
"inviter_id": user.InviterId,
"linux_do_id": user.LinuxDOId,
- "setting": user.Setting,
+ "setting": cleanedSetting, // 完整用户设置(保持兼容性)
"stripe_customer": user.StripeCustomer,
- "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
- "permissions": permissions, // 新增权限字段
+ "avatar": user.Avatar,
+ "sidebar_config": finalSidebarConfig, // 最终的侧边栏配置
+ "permissions": simplifiedPermissions, // 精简的权限信息
}
c.JSON(http.StatusOK, gin.H{
@@ -502,66 +614,529 @@ func calculateUserPermissions(userRole int) map[string]interface{} {
return permissions
}
-// 根据用户角色生成默认的边栏配置
-func generateDefaultSidebarConfig(userRole int) string {
- defaultConfig := map[string]interface{}{}
-
- // 聊天区域 - 所有用户都可以访问
- defaultConfig["chat"] = map[string]interface{}{
- "enabled": true,
- "playground": true,
- "chat": true,
+// 计算最终的侧边栏配置(系统配置 + 权限过滤,不包含用户偏好)
+func calculateFinalSidebarConfig(userRole int, userSetting dto.UserSetting) map[string]interface{} {
+ // 1. 获取系统的侧边栏管理配置
+ common.OptionMapRWMutex.RLock()
+ sidebarAdminConfigRaw := common.OptionMap["SidebarModulesAdmin"]
+ common.OptionMapRWMutex.RUnlock()
+
+ // 2. 解析系统配置
+ var systemConfig map[string]interface{}
+ if sidebarAdminConfigRaw != "" {
+ if err := json.Unmarshal([]byte(sidebarAdminConfigRaw), &systemConfig); err != nil {
+ // 解析失败时使用默认配置
+ systemConfig = getDefaultSystemConfig()
+ }
+ } else {
+ systemConfig = getDefaultSystemConfig()
}
- // 控制台区域 - 所有用户都可以访问
- defaultConfig["console"] = map[string]interface{}{
- "enabled": true,
- "detail": true,
- "token": true,
- "log": true,
- "midjourney": true,
- "task": true,
- }
+ // 3. 不再考虑用户个人偏好,sidebar_config只反映系统允许的最大权限范围
+
+ // 4. 计算最终配置
+ finalConfig := map[string]interface{}{}
+
+ // 遍历系统配置的所有区域
+ for sectionKey, sectionValue := range systemConfig {
+ sectionObj, ok := sectionValue.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ // 检查用户是否有权限访问这个区域
+ if !hasUserPermissionForSection(userRole, sectionKey) {
+ continue
+ }
+
+ // 检查系统是否启用了这个区域
+ sectionEnabled := true
+ if enabled, hasEnabled := sectionObj["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok {
+ sectionEnabled = enabledBool
+ }
+ }
+
+ if !sectionEnabled {
+ continue
+ }
+
+ // 计算区域的最终配置(只考虑系统配置和用户权限,不考虑用户偏好)
+ sectionConfig := map[string]interface{}{}
+
+ // 区域级别的enabled状态:只要系统启用就为true
+ sectionConfig["enabled"] = sectionEnabled
+
+ // 处理区域内的各个模块
+ for moduleKey, moduleValue := range sectionObj {
+ if moduleKey == "enabled" {
+ continue
+ }
- // 个人中心区域 - 所有用户都可以访问
- defaultConfig["personal"] = map[string]interface{}{
- "enabled": true,
- "topup": true,
- "personal": true,
+ // 检查用户是否有权限访问这个模块
+ modulePath := sectionKey + "." + moduleKey
+ if !hasUserPermissionForModule(userRole, modulePath) {
+ sectionConfig[moduleKey] = false
+ continue
+ }
+
+ // 处理嵌套的模块配置(如 admin.user)
+ switch v := moduleValue.(type) {
+ case bool:
+ // 简单的布尔值模块
+ systemModuleEnabled := v
+ finalModuleEnabled := systemModuleEnabled && sectionConfig["enabled"].(bool)
+ sectionConfig[moduleKey] = finalModuleEnabled
+ case map[string]interface{}:
+ // 嵌套的对象模块(如 admin.user 包含 enabled 和 groupManagement)
+ nestedModuleConfig := map[string]interface{}{}
+
+ // 检查嵌套模块的enabled状态
+ nestedEnabled := true
+ if enabled, hasEnabled := v["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok {
+ nestedEnabled = enabledBool
+ }
+ }
+
+ // 最终的enabled状态
+ finalNestedEnabled := nestedEnabled && sectionConfig["enabled"].(bool)
+ nestedModuleConfig["enabled"] = finalNestedEnabled
+
+ // 处理嵌套模块的子功能
+ for subModuleKey, subModuleValue := range v {
+ if subModuleKey == "enabled" {
+ continue
+ }
+
+ // 检查用户是否有权限访问这个子功能
+ subModulePath := sectionKey + "." + moduleKey + "." + subModuleKey
+ if !hasUserPermissionForModule(userRole, subModulePath) {
+ nestedModuleConfig[subModuleKey] = false
+ continue
+ }
+
+ // 检查系统是否启用了这个子功能
+ subModuleEnabled := true
+ if subModuleBool, ok := subModuleValue.(bool); ok {
+ subModuleEnabled = subModuleBool
+ }
+
+ // 最终状态:系统启用 && 用户权限允许 && 父模块启用
+ finalSubModuleEnabled := subModuleEnabled && finalNestedEnabled
+ nestedModuleConfig[subModuleKey] = finalSubModuleEnabled
+ }
+
+ sectionConfig[moduleKey] = nestedModuleConfig
+ default:
+ // 其他类型,直接设置为false
+ sectionConfig[moduleKey] = false
+ }
+ }
+
+ finalConfig[sectionKey] = sectionConfig
}
- // 管理员区域 - 根据角色决定
- if userRole == common.RoleAdminUser {
- // 管理员可以访问管理员区域,但不能访问系统设置
- defaultConfig["admin"] = map[string]interface{}{
+ return finalConfig
+}
+
+// 获取默认的系统配置
+func getDefaultSystemConfig() map[string]interface{} {
+ return map[string]interface{}{
+ "chat": map[string]interface{}{
"enabled": true,
- "channel": true,
- "models": true,
- "redemption": true,
- "user": true,
- "setting": false, // 管理员不能访问系统设置
- }
- } else if userRole == common.RoleRootUser {
- // 超级管理员可以访问所有功能
- defaultConfig["admin"] = map[string]interface{}{
+ "playground": true,
+ "chat": true,
+ },
+ "console": map[string]interface{}{
+ "enabled": true,
+ "detail": true,
+ "token": true,
+ "log": true,
+ "midjourney": true,
+ "task": true,
+ },
+ "personal": map[string]interface{}{
+ "enabled": true,
+ "topup": true,
+ "personal": true,
+ },
+ "admin": map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
- "user": true,
- "setting": true,
+ "user": map[string]interface{}{
+ "enabled": true,
+ "groupManagement": true, // 默认启用分组管理
+ },
+ "setting": true,
+ },
+ }
+}
+
+// 检查用户是否有权限访问指定区域
+func hasUserPermissionForSection(userRole int, sectionKey string) bool {
+ // 普通用户不能访问管理员区域
+ if userRole < common.RoleAdminUser && sectionKey == "admin" {
+ return false
+ }
+ return true
+}
+
+// 检查用户是否有权限访问指定模块
+func hasUserPermissionForModule(userRole int, modulePath string) bool {
+ // 数据看板始终允许访问
+ if modulePath == "console.detail" {
+ return true
+ }
+
+ // 管理员不能访问系统设置
+ if userRole == common.RoleAdminUser && modulePath == "admin.setting" {
+ return false
+ }
+
+ // 处理嵌套的模块路径(如 admin.user.groupManagement)
+ pathParts := strings.Split(modulePath, ".")
+ if len(pathParts) >= 2 {
+ sectionKey := pathParts[0]
+
+ // 普通用户不能访问管理员区域的任何模块
+ if userRole < common.RoleAdminUser && sectionKey == "admin" {
+ return false
+ }
+
+ // 对于三层路径(如 admin.user.groupManagement),检查特殊权限
+ if len(pathParts) == 3 && sectionKey == "admin" && pathParts[1] == "user" && pathParts[2] == "groupManagement" {
+ // 分组管理功能:管理员和超级管理员都可以访问
+ return userRole >= common.RoleAdminUser
}
}
- // 普通用户不包含admin区域
- // 转换为JSON字符串
- configBytes, err := json.Marshal(defaultConfig)
- if err != nil {
- common.SysLog("生成默认边栏配置失败: " + err.Error())
+ return true
+}
+
+// 清理用户设置,添加系统权限信息供个人设置页面使用
+func cleanUserSettingForResponse(originalSetting string, systemSidebarConfig map[string]interface{}) string {
+ if originalSetting == "" {
return ""
}
- return string(configBytes)
+ // 解析原始设置
+ var userSetting dto.UserSetting
+ if err := json.Unmarshal([]byte(originalSetting), &userSetting); err != nil {
+ // 解析失败,返回原始设置
+ return originalSetting
+ }
+
+ // 如果没有sidebar_modules配置,直接返回
+ if userSetting.SidebarModules == "" {
+ return originalSetting
+ }
+
+ // 解析用户的sidebar_modules配置
+ var userSidebarModules map[string]interface{}
+ if err := json.Unmarshal([]byte(userSetting.SidebarModules), &userSidebarModules); err != nil {
+ // 解析失败,返回原始设置
+ return originalSetting
+ }
+
+ // 基于系统配置过滤用户的sidebar_modules,同时保留系统权限信息
+ filteredSidebarModules := map[string]interface{}{}
+ for sectionKey, sectionValue := range systemSidebarConfig {
+ sectionObj, ok := sectionValue.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ // 检查系统是否允许这个区域
+ systemSectionEnabled, hasEnabled := sectionObj["enabled"]
+ if !hasEnabled || systemSectionEnabled != true {
+ continue
+ }
+
+ // 获取用户对这个区域的配置
+ userSection := map[string]interface{}{}
+ if userSidebarModules[sectionKey] != nil {
+ if userSectionObj, ok := userSidebarModules[sectionKey].(map[string]interface{}); ok {
+ userSection = userSectionObj
+ }
+ }
+
+ // 构建过滤后的区域配置
+ filteredSection := map[string]interface{}{
+ "enabled": userSection["enabled"], // 保持用户的enabled偏好
+ }
+
+ // 只保留最终配置中存在的模块(支持布尔与嵌套对象)
+ for moduleKey, moduleValue := range sectionObj {
+ if moduleKey == "enabled" {
+ continue
+ }
+
+ // 判断系统是否允许该模块
+ systemAllows := false
+ switch v := moduleValue.(type) {
+ case bool:
+ systemAllows = v
+ case map[string]interface{}:
+ // 嵌套对象,检查其enabled状态,缺省视为true
+ if enabled, hasEnabled := v["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok {
+ systemAllows = enabledBool
+ } else {
+ systemAllows = true
+ }
+ } else {
+ systemAllows = true
+ }
+ default:
+ systemAllows = false
+ }
+
+ if systemAllows {
+ // 保持用户对这个模块的偏好(布尔或对象),若未设置则默认启用
+ if userModuleValue, exists := userSection[moduleKey]; exists {
+ filteredSection[moduleKey] = userModuleValue
+ } else {
+ filteredSection[moduleKey] = true // 默认启用
+ }
+ }
+ }
+
+ filteredSidebarModules[sectionKey] = filteredSection
+ }
+
+ // 更新用户设置中的sidebar_modules
+ filteredSidebarModulesJSON, err := json.Marshal(filteredSidebarModules)
+ if err != nil {
+ // 序列化失败,返回原始设置
+ return originalSetting
+ }
+
+ userSetting.SidebarModules = string(filteredSidebarModulesJSON)
+
+ // 添加系统权限信息供个人设置页面使用
+ systemConfigJSON, err := json.Marshal(systemSidebarConfig)
+ if err == nil {
+ // 创建一个扩展的用户设置结构
+ extendedSetting := map[string]interface{}{
+ "sidebar_modules": userSetting.SidebarModules,
+ "sidebar_system_config": string(systemConfigJSON), // 系统权限信息
+ }
+
+ // 添加其他用户设置字段(如果有的话)
+ var originalSettingMap map[string]interface{}
+ if err := json.Unmarshal([]byte(originalSetting), &originalSettingMap); err == nil {
+ for key, value := range originalSettingMap {
+ if key != "sidebar_modules" && key != "sidebar_system_config" {
+ extendedSetting[key] = value
+ }
+ }
+ }
+
+ // 序列化扩展的设置
+ if extendedSettingJSON, err := json.Marshal(extendedSetting); err == nil {
+ return string(extendedSettingJSON)
+ }
+ }
+
+ // 如果添加系统配置失败,使用原有逻辑
+ cleanedSettingJSON, err := json.Marshal(userSetting)
+ if err != nil {
+ return originalSetting
+ }
+
+ return string(cleanedSettingJSON)
+}
+
+// 基于系统权限过滤用户偏好设置
+func filterUserModulesBySystemConfig(userModules map[string]interface{}, systemConfig map[string]interface{}) map[string]interface{} {
+ filteredModules := map[string]interface{}{}
+
+ // 只保留系统允许的区域和模块
+ for sectionKey, sectionValue := range systemConfig {
+ systemSection, ok := sectionValue.(map[string]interface{})
+ if !ok || systemSection["enabled"] != true {
+ continue
+ }
+
+ // 获取用户对这个区域的偏好
+ userSection := map[string]interface{}{}
+ if userModules[sectionKey] != nil {
+ if userSectionObj, ok := userModules[sectionKey].(map[string]interface{}); ok {
+ userSection = userSectionObj
+ }
+ }
+
+ // 构建过滤后的区域配置
+ filteredSection := map[string]interface{}{
+ "enabled": userSection["enabled"], // 保持用户的enabled偏好
+ }
+
+ // 只保留系统允许的模块(同时支持布尔模块与嵌套对象模块)
+ for moduleKey, moduleValue := range systemSection {
+ if moduleKey == "enabled" {
+ continue
+ }
+
+ // 判断系统是否允许该模块
+ systemAllows := false
+ switch v := moduleValue.(type) {
+ case bool:
+ systemAllows = v
+ case map[string]interface{}:
+ // 嵌套对象,检查其enabled状态,缺省视为true
+ if enabled, hasEnabled := v["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok {
+ systemAllows = enabledBool
+ } else {
+ systemAllows = true
+ }
+ } else {
+ systemAllows = true
+ }
+ default:
+ systemAllows = false
+ }
+
+ if systemAllows {
+ // 保持用户对这个模块的偏好(支持布尔或对象),若未设置则默认启用
+ if userModuleValue, exists := userSection[moduleKey]; exists {
+ filteredSection[moduleKey] = userModuleValue
+ } else {
+ filteredSection[moduleKey] = true // 默认启用
+ }
+ }
+ }
+
+ filteredModules[sectionKey] = filteredSection
+ }
+
+ return filteredModules
+}
+
+// 计算最终的显示配置(系统权限 ∩ 用户偏好)
+func calculateFinalDisplayConfig(systemConfig map[string]interface{}, userModules interface{}) map[string]interface{} {
+ finalConfig := map[string]interface{}{}
+
+ // 解析用户偏好设置
+ var userPreferences map[string]interface{}
+ switch v := userModules.(type) {
+ case map[string]interface{}:
+ userPreferences = v
+ case string:
+ if err := json.Unmarshal([]byte(v), &userPreferences); err != nil {
+ userPreferences = map[string]interface{}{}
+ }
+ default:
+ userPreferences = map[string]interface{}{}
+ }
+
+ // 遍历系统允许的所有区域
+ for sectionKey, sectionValue := range systemConfig {
+ systemSection, ok := sectionValue.(map[string]interface{})
+ if !ok || systemSection["enabled"] != true {
+ continue
+ }
+
+ // 获取用户对这个区域的偏好
+ userSection := map[string]interface{}{}
+ if userPreferences[sectionKey] != nil {
+ if userSectionObj, ok := userPreferences[sectionKey].(map[string]interface{}); ok {
+ userSection = userSectionObj
+ }
+ }
+
+ // 计算区域的最终配置
+ sectionConfig := map[string]interface{}{}
+
+ // 区域级别:用户可以关闭系统允许的区域
+ userSectionEnabled := userSection["enabled"] != false
+ sectionConfig["enabled"] = userSectionEnabled
+
+ // 处理区域内的模块
+ for moduleKey, moduleValue := range systemSection {
+ if moduleKey == "enabled" {
+ continue
+ }
+
+ // 检查系统是否允许这个模块
+ var systemModuleEnabled bool
+ switch v := moduleValue.(type) {
+ case bool:
+ systemModuleEnabled = v
+ case map[string]interface{}:
+ // 对于嵌套对象,检查其enabled状态
+ if enabled, hasEnabled := v["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok {
+ systemModuleEnabled = enabledBool
+ } else {
+ systemModuleEnabled = true // 默认启用
+ }
+ } else {
+ systemModuleEnabled = true // 没有enabled字段时默认启用
+ }
+ default:
+ systemModuleEnabled = false
+ }
+
+ if !systemModuleEnabled {
+ sectionConfig[moduleKey] = false
+ continue
+ }
+
+ // 对于嵌套对象,需要合并系统配置和用户偏好
+ if nestedObj, isNested := moduleValue.(map[string]interface{}); isNested {
+ // 获取用户对这个嵌套对象的偏好
+ userNestedObj := map[string]interface{}{}
+ if userSection[moduleKey] != nil {
+ if userNestedMap, ok := userSection[moduleKey].(map[string]interface{}); ok {
+ userNestedObj = userNestedMap
+ }
+ }
+
+ // 计算有效的enabled:支持用户以布尔值直接覆盖嵌套对象(个人设置场景)
+ var effectiveEnabled interface{}
+ if userBool, ok := userSection[moduleKey].(bool); ok {
+ effectiveEnabled = userBool
+ } else if ue, exists := userNestedObj["enabled"]; exists {
+ effectiveEnabled = ue
+ } else if sysEnabled, has := nestedObj["enabled"]; has {
+ effectiveEnabled = sysEnabled
+ } else {
+ effectiveEnabled = true
+ }
+
+ // 合并系统配置和用户偏好
+ finalNestedObj := make(map[string]interface{})
+ for k, v := range nestedObj {
+ if k == "enabled" {
+ finalNestedObj[k] = effectiveEnabled
+ } else {
+ // 其他字段保持系统配置
+ finalNestedObj[k] = v
+ }
+ }
+
+ // 如果区域被禁用,强制将嵌套对象的enabled设置为false
+ if !userSectionEnabled {
+ finalNestedObj["enabled"] = false
+ }
+
+ sectionConfig[moduleKey] = finalNestedObj
+ } else {
+ // 简单布尔值模块,用户可以关闭系统允许的模块
+ userModuleEnabled := userSection[moduleKey] != false
+ // 最终状态:系统允许 && 用户偏好 && 区域启用
+ sectionConfig[moduleKey] = systemModuleEnabled && userModuleEnabled && userSectionEnabled
+ }
+ }
+
+ finalConfig[sectionKey] = sectionConfig
+ }
+
+ return finalConfig
}
func GetUserModels(c *gin.Context) {
@@ -649,6 +1224,54 @@ func UpdateUser(c *gin.Context) {
return
}
+func GetUserAvatar(c *gin.Context) {
+ id := c.GetInt("id")
+ user, err := model.GetUserById(id, false)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ // 检查是否强制获取头像(用于上传后刷新)
+ forceRefresh := c.Query("force_refresh") == "true"
+
+ // 检查会话中是否已获取过头像
+ sessionId := c.GetHeader("X-Session-ID")
+ if sessionId == "" {
+ // 如果没有会话ID,生成一个
+ sessionId = fmt.Sprintf("session_%d_%d", id, time.Now().Unix())
+ }
+
+ // sessionKey := fmt.Sprintf("avatar_session_%s", sessionId) // 保留用于未来扩展
+
+ // 如果不是强制刷新且会话中已获取过,返回空响应
+ if !forceRefresh {
+ if sessionValue := c.GetHeader("X-Avatar-Fetched"); sessionValue == "true" {
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "avatar_cached",
+ "data": gin.H{
+ "avatar": "",
+ "cached": true,
+ },
+ })
+ return
+ }
+ }
+
+ // 返回头像数据
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": gin.H{
+ "avatar": user.Avatar,
+ "cached": false,
+ "session_id": sessionId,
+ },
+ })
+ return
+}
+
func UpdateSelf(c *gin.Context) {
var requestData map[string]interface{}
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
@@ -694,6 +1317,48 @@ func UpdateSelf(c *gin.Context) {
return
}
+ // 检查是否是纯头像更新请求
+ if len(requestData) == 1 {
+ if avatarData, exists := requestData["avatar"]; exists {
+ userId := c.GetInt("id")
+
+ // 验证头像数据
+ avatarStr, ok := avatarData.(string)
+ if !ok {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "头像数据格式无效",
+ })
+ return
+ }
+
+ if err := validateAvatar(avatarStr); err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+
+ // 直接更新头像字段
+ user := model.User{
+ Id: userId,
+ Avatar: avatarStr,
+ }
+
+ if err := user.UpdateAvatar(); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ })
+ return
+ }
+ }
+
// 原有的用户信息更新逻辑
var user model.User
requestDataBytes, err := json.Marshal(requestData)
@@ -724,20 +1389,35 @@ func UpdateSelf(c *gin.Context) {
return
}
+ // 验证头像数据
+ if err := validateAvatar(user.Avatar); err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+
cleanUser := model.User{
Id: c.GetInt("id"),
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
+ Avatar: user.Avatar,
}
if user.Password == "$I_LOVE_U" {
user.Password = "" // rollback to what it should be
cleanUser.Password = ""
}
- updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id)
- if err != nil {
- common.ApiError(c, err)
- return
+
+ // 只有当明确提供了 original_password 或者要更新密码时才进行密码验证
+ var updatePassword bool
+ if _, hasOriginalPassword := requestData["original_password"]; hasOriginalPassword || user.Password != "" {
+ updatePassword, err = checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
}
if err := cleanUser.Update(updatePassword); err != nil {
common.ApiError(c, err)
@@ -842,6 +1522,10 @@ func CreateUser(c *gin.Context) {
if user.DisplayName == "" {
user.DisplayName = user.Username
}
+ // 如果没有指定分组,设置为默认分组
+ if user.Group == "" {
+ user.Group = "default"
+ }
myRole := c.GetInt("role")
if user.Role >= myRole {
c.JSON(http.StatusOK, gin.H{
@@ -856,6 +1540,7 @@ func CreateUser(c *gin.Context) {
Password: user.Password,
DisplayName: user.DisplayName,
Role: user.Role, // 保持管理员设置的角色
+ Group: user.Group, // 保持管理员设置的分组
}
if err := cleanUser.Insert(0); err != nil {
common.ApiError(c, err)
@@ -949,6 +1634,15 @@ func ManageUser(c *gin.Context) {
return
}
user.Role = common.RoleAdminUser
+
+ // 同步更新用户的sidebar_modules配置
+ currentSetting := user.GetSetting()
+ newSidebarConfig := model.GenerateDefaultSidebarConfigForRole(user.Role)
+ if newSidebarConfig != "" {
+ currentSetting.SidebarModules = newSidebarConfig
+ user.SetSetting(currentSetting)
+ common.SysLog(fmt.Sprintf("用户 %s 提升为管理员,已同步更新边栏配置", user.Username))
+ }
case "demote":
if user.Role == common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
@@ -965,6 +1659,15 @@ func ManageUser(c *gin.Context) {
return
}
user.Role = common.RoleCommonUser
+
+ // 同步更新用户的sidebar_modules配置
+ currentSetting := user.GetSetting()
+ newSidebarConfig := model.GenerateDefaultSidebarConfigForRole(user.Role)
+ if newSidebarConfig != "" {
+ currentSetting.SidebarModules = newSidebarConfig
+ user.SetSetting(currentSetting)
+ common.SysLog(fmt.Sprintf("用户 %s 降级为普通用户,已同步更新边栏配置", user.Username))
+ }
}
if err := user.Update(false); err != nil {
diff --git a/controller/user_group.go b/controller/user_group.go
new file mode 100644
index 000000000000..5c28b8c5475a
--- /dev/null
+++ b/controller/user_group.go
@@ -0,0 +1,430 @@
+package controller
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "one-api/common"
+ "one-api/model"
+ "one-api/service"
+ "one-api/setting"
+ "one-api/setting/ratio_setting"
+
+ "github.com/gin-gonic/gin"
+)
+
+// GetAllUserGroups 获取用户分组列表
+func GetAllUserGroups(c *gin.Context) {
+ groups, err := model.GetAllUserGroups()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, groups)
+}
+
+// isReservedGroup reports whether the name is one of the system reserved groups.
+func isReservedGroup(name string) bool {
+ switch strings.ToLower(name) {
+ case "default", "vip", "svip":
+ return true
+ default:
+ return false
+ }
+}
+
+// CreateUserGroup 创建新的用户分组
+func CreateUserGroup(c *gin.Context) {
+ var g model.UserGroup
+ if err := c.ShouldBindJSON(&g); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ g.Name = strings.TrimSpace(g.Name)
+ if g.Name == "" {
+ common.ApiErrorMsg(c, "分组名称不能为空")
+ return
+ }
+ // 禁止使用系统保留分组名
+ if isReservedGroup(strings.ToLower(g.Name)) {
+ common.ApiErrorMsg(c, "不能使用系统保留分组名:default、vip、svip")
+ return
+ }
+ if g.Ratio < 0 {
+ common.ApiErrorMsg(c, "分组倍率不能小于0")
+ return
+ }
+ if g.Ratio == 0 {
+ g.Ratio = 1.0 // 默认倍率为1.0
+ }
+
+ // 创建前检查名称
+ if dup, err := model.IsUserGroupNameDuplicated(0, g.Name); err != nil {
+ common.ApiError(c, err)
+ return
+ } else if dup {
+ common.ApiErrorMsg(c, "分组名称已存在")
+ return
+ }
+
+ if err := g.Insert(); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ // 同步到内存
+ if err := service.SyncUserGroupsToMemory(); err != nil {
+ common.SysLog("同步用户分组到内存失败: " + err.Error())
+ }
+
+ common.ApiSuccess(c, &g)
+}
+
+// UpdateUserGroup 更新用户分组
+func UpdateUserGroup(c *gin.Context) {
+ var g model.UserGroup
+ if err := c.ShouldBindJSON(&g); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if g.Id == 0 {
+ common.ApiErrorMsg(c, "缺少分组 ID")
+ return
+ }
+ g.Name = strings.TrimSpace(g.Name)
+ if g.Name == "" {
+ common.ApiErrorMsg(c, "分组名称不能为空")
+ return
+ }
+ if g.Ratio < 0 {
+ common.ApiErrorMsg(c, "分组倍率不能小于0")
+ return
+ }
+ if g.Ratio == 0 {
+ g.Ratio = 1.0
+ }
+ // 获取原分组信息
+ oldGroup, err := model.GetUserGroupById(g.Id)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ // 名称冲突检查
+ if dup, err := model.IsUserGroupNameDuplicated(g.Id, g.Name); err != nil {
+ common.ApiError(c, err)
+ return
+ } else if dup {
+ common.ApiErrorMsg(c, "分组名称已存在")
+ return
+ }
+
+ // 使用事务确保分组更新和用户更新的数据一致性
+ tx := model.DB.Begin()
+ if tx.Error != nil {
+ common.ApiError(c, tx.Error)
+ return
+ }
+
+ defer func() {
+ if r := recover(); r != nil {
+ tx.Rollback()
+ }
+ }()
+
+ // 在事务中更新分组
+ if err := g.UpdateTx(tx); err != nil {
+ tx.Rollback()
+ common.ApiError(c, err)
+ return
+ }
+
+ // 如果名称发生变化,需要在同一事务中更新用户数据
+ if oldGroup.Name != g.Name {
+ if isReservedGroup(strings.ToLower(g.Name)) {
+ tx.Rollback()
+ common.ApiErrorMsg(c, "不能将分组名称修改为系统保留分组名")
+ return
+ }
+ common.SysLog(fmt.Sprintf("检测到分组名称变化: '%s' -> '%s'", oldGroup.Name, g.Name))
+
+ // 禁止修改系统保留分组名
+ if isReservedGroup(strings.ToLower(oldGroup.Name)) {
+ tx.Rollback()
+ common.ApiErrorMsg(c, "不能修改系统保留分组名称")
+ return
+ }
+
+ // 在同一事务中更新所有使用旧分组名的用户
+ result := tx.Model(&model.User{}).
+ Where("`group` = ?", oldGroup.Name).
+ Update("group", g.Name)
+ if result.Error != nil {
+ tx.Rollback()
+ common.SysLog("更新用户分组名称失败: " + result.Error.Error())
+ common.ApiErrorMsg(c, "更新用户分组名称失败: "+result.Error.Error())
+ return
+ }
+ common.SysLog(fmt.Sprintf("在事务中成功更新 %d 个用户的分组名称", result.RowsAffected))
+ }
+
+ // 提交事务
+ if err := tx.Commit().Error; err != nil {
+ common.SysLog("提交分组更新事务失败: " + err.Error())
+ common.ApiError(c, err)
+ return
+ }
+
+ // 事务提交成功后,同步到内存
+ if err := service.SyncUserGroupsToMemory(); err != nil {
+ common.SysLog("同步用户分组到内存失败: " + err.Error())
+ }
+
+ common.ApiSuccess(c, &g)
+}
+
+// DeleteUserGroup 删除用户分组
+func DeleteUserGroup(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ common.ApiErrorMsg(c, "无效的分组 ID")
+ return
+ }
+
+ group, err := model.GetUserGroupById(id)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ // 检查是否有用户正在使用此分组
+ if inUse, err := model.IsUserGroupInUse(group.Name); err != nil {
+ common.ApiError(c, err)
+ return
+ } else if inUse {
+ common.ApiErrorMsg(c, "该分组正在被用户使用,无法删除")
+ return
+ }
+
+ // 不允许删除默认分组
+ if isReservedGroup(group.Name) {
+ common.ApiErrorMsg(c, "不能删除系统默认分组")
+ return
+ }
+
+ if err := group.Delete(); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ // 同步到内存
+ if err := service.SyncUserGroupsToMemory(); err != nil {
+ common.SysLog("同步用户分组到内存失败: " + err.Error())
+ }
+
+ common.ApiSuccess(c, nil)
+}
+
+// syncGroupToRatioSetting 同步分组到倍率设置
+func syncGroupToRatioSetting(groupName string, ratio float64, add bool) error {
+ groupRatio := ratio_setting.GetGroupRatioCopy()
+
+ if add {
+ groupRatio[groupName] = ratio
+ } else {
+ delete(groupRatio, groupName)
+ }
+
+ jsonBytes, err := json.Marshal(groupRatio)
+ if err != nil {
+ return err
+ }
+
+ // 更新到数据库
+ if err := model.UpdateOption("GroupRatio", string(jsonBytes)); err != nil {
+ return err
+ }
+
+ // 更新内存中的设置
+ return ratio_setting.UpdateGroupRatioByJSONString(string(jsonBytes))
+}
+
+// syncGroupToUserUsableGroups 同步分组到用户可选分组
+func syncGroupToUserUsableGroups(groupName, description string, add bool) error {
+ userUsableGroups := setting.GetUserUsableGroupsCopy()
+
+ if add {
+ if description == "" {
+ description = groupName + "分组"
+ }
+ userUsableGroups[groupName] = description
+ } else {
+ delete(userUsableGroups, groupName)
+ }
+
+ jsonBytes, err := json.Marshal(userUsableGroups)
+ if err != nil {
+ return err
+ }
+
+ // 更新到数据库
+ if err := model.UpdateOption("UserUsableGroups", string(jsonBytes)); err != nil {
+ return err
+ }
+
+ // 更新内存中的设置
+ return setting.UpdateUserUsableGroupsByJSONString(string(jsonBytes))
+}
+
+// syncGroupToTopupRatio 同步分组到充值倍率设置
+func syncGroupToTopupRatio(groupName string, ratio float64, add bool) error {
+ // 获取当前充值分组倍率的副本(线程安全)
+ topupGroupRatio := common.GetTopupGroupRatioCopy()
+ if add {
+ topupGroupRatio[groupName] = ratio
+ } else {
+ delete(topupGroupRatio, groupName)
+ }
+
+ jsonBytes, err := json.Marshal(topupGroupRatio)
+ if err != nil {
+ return err
+ }
+
+ // 更新到数据库
+ if err := model.UpdateOption("TopupGroupRatio", string(jsonBytes)); err != nil {
+ return err
+ }
+
+ // 更新内存中的设置
+ return common.UpdateTopupGroupRatioByJSONString(string(jsonBytes))
+}
+
+// MigrateUserGroupData 迁移用户分组数据
+func MigrateUserGroupData(c *gin.Context) {
+ if err := service.MigrateUserGroupsFromOptions(); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ // 同步到内存
+ if err := service.SyncUserGroupsToMemory(); err != nil {
+ common.SysLog("同步用户分组到内存失败: " + err.Error())
+ }
+
+ common.ApiSuccess(c, "用户分组数据迁移完成")
+}
+
+// GetUserGroupsAsOptions 获取用户分组数据(以 options 格式返回)
+func GetUserGroupsAsOptions(c *gin.Context) {
+ options, err := service.GetUserGroupsAsOptions()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ common.ApiSuccess(c, options)
+}
+
+// BatchUpdateUserGroups 批量更新用户分组
+func BatchUpdateUserGroups(c *gin.Context) {
+ var req struct {
+ GroupRatio string `json:"GroupRatio"`
+ UserUsableGroups string `json:"UserUsableGroups"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ // 解析分组倍率
+ var groupRatio map[string]float64
+ if req.GroupRatio != "" {
+ if err := json.Unmarshal([]byte(req.GroupRatio), &groupRatio); err != nil {
+ common.ApiErrorMsg(c, "分组倍率格式错误: "+err.Error())
+ return
+ }
+ }
+
+ // 解析用户可选分组
+ var userUsableGroups map[string]string
+ if req.UserUsableGroups != "" {
+ if err := json.Unmarshal([]byte(req.UserUsableGroups), &userUsableGroups); err != nil {
+ common.ApiErrorMsg(c, "用户可选分组格式错误: "+err.Error())
+ return
+ }
+ }
+
+ // 合并所有分组名称
+ allGroups := make(map[string]bool)
+ for name := range groupRatio {
+ allGroups[name] = true
+ }
+ for name := range userUsableGroups {
+ allGroups[name] = true
+ }
+
+ // 批量更新分组
+ updatedCount := 0
+ for groupName := range allGroups {
+ // 获取现有分组
+ existingGroup, err := model.GetUserGroupByName(groupName)
+ if err != nil && err.Error() != "record not found" {
+ common.SysLog("获取分组 " + groupName + " 时出错: " + err.Error())
+ continue
+ }
+
+ ratio := groupRatio[groupName]
+ if ratio == 0 {
+ ratio = 1.0 // 默认倍率
+ }
+
+ description := userUsableGroups[groupName]
+ if description == "" {
+ description = groupName + "分组"
+ }
+
+ if existingGroup == nil {
+ // 创建新分组
+ newGroup := &model.UserGroup{
+ Name: groupName,
+ Description: description,
+ Ratio: ratio,
+ }
+ if err := newGroup.Insert(); err != nil {
+ common.SysLog("创建分组 " + groupName + " 失败: " + err.Error())
+ continue
+ }
+ updatedCount++
+ } else {
+ // 更新现有分组
+ needUpdate := false
+ if existingGroup.Ratio != ratio {
+ existingGroup.Ratio = ratio
+ needUpdate = true
+ }
+ if existingGroup.Description != description {
+ existingGroup.Description = description
+ needUpdate = true
+ }
+
+ if needUpdate {
+ if err := existingGroup.Update(); err != nil {
+ common.SysLog("更新分组 " + groupName + " 失败: " + err.Error())
+ continue
+ }
+ updatedCount++
+ }
+ }
+ }
+
+ // 同步到内存
+ if err := service.SyncUserGroupsToMemory(); err != nil {
+ common.SysLog("同步用户分组到内存失败: " + err.Error())
+ }
+
+ common.ApiSuccess(c, fmt.Sprintf("批量更新完成,处理了 %d 个分组", updatedCount))
+}
diff --git a/middleware/auth.go b/middleware/auth.go
index 25caf50d9be0..b2fc104f68a4 100644
--- a/middleware/auth.go
+++ b/middleware/auth.go
@@ -1,6 +1,7 @@
package middleware
import (
+ "encoding/json"
"fmt"
"net/http"
"one-api/common"
@@ -175,6 +176,172 @@ func WssAuth(c *gin.Context) {
}
+// ModuleAuth 检查用户是否有权限访问特定功能模块
+func ModuleAuth(modulePath string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // 优先从上游鉴权放入的上下文读取
+ userRole := c.GetInt("role")
+ userId := c.GetInt("id")
+ if userRole == 0 || userId == 0 {
+ // 兼容旧流程:再从 session 兜底
+ sess := sessions.Default(c)
+ if v, ok := sess.Get("role").(int); ok {
+ userRole = v
+ }
+ if v, ok := sess.Get("id").(int); ok {
+ userId = v
+ }
+ }
+ // 如果用户未登录,先进行基础认证
+ if userRole == 0 || userId == 0 {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "success": false,
+ "message": "未登录,无权访问",
+ })
+ c.Abort()
+ return
+ }
+
+ // 超级管理员始终允许访问所有功能
+ if userRole >= common.RoleRootUser {
+ c.Next()
+ return
+ }
+
+ // 检查用户是否有权限访问指定模块
+ if !hasModulePermission(userRole, userId, modulePath) {
+ c.JSON(http.StatusForbidden, gin.H{
+ "success": false,
+ "message": "无权访问此功能模块",
+ })
+ c.Abort()
+ return
+ }
+
+ c.Next()
+ }
+}
+
+// hasModulePermission 检查用户是否有权限访问指定模块
+func hasModulePermission(userRole int, userId int, modulePath string) bool {
+ // 普通用户只能访问基础功能
+ if userRole < common.RoleAdminUser {
+ return isUserModuleAllowed(modulePath)
+ }
+
+ // 管理员需要检查侧边栏管理配置
+ if userRole >= common.RoleAdminUser && userRole < common.RoleRootUser {
+ return isAdminModuleAllowed(modulePath)
+ }
+
+ return true
+}
+
+// isUserModuleAllowed 检查普通用户是否允许访问指定模块
+func isUserModuleAllowed(modulePath string) bool {
+ // 数据看板始终允许访问,不受控制台区域开关影响
+ if modulePath == "console.detail" {
+ return true
+ }
+
+ // 普通用户允许访问的模块列表
+ allowedModules := map[string]bool{
+ "console.detail": true,
+ "console.token": true,
+ "console.log": true,
+ "console.midjourney": true,
+ "console.task": true,
+ "personal.topup": true,
+ "personal.personal": true,
+ "chat.playground": true,
+ "chat.chat": true,
+ }
+
+ return allowedModules[modulePath]
+}
+
+// isAdminModuleAllowed 检查管理员是否允许访问指定模块
+func isAdminModuleAllowed(modulePath string) bool {
+ // 数据看板始终允许访问,不受控制台区域开关影响
+ if modulePath == "console.detail" {
+ return true
+ }
+
+ // 获取侧边栏管理配置
+ common.OptionMapRWMutex.RLock()
+ sidebarConfig, exists := common.OptionMap["SidebarModulesAdmin"]
+ common.OptionMapRWMutex.RUnlock()
+
+ if !exists || sidebarConfig == "" {
+ // 如果没有配置,默认允许管理员访问所有功能(除了系统设置)
+ if modulePath == "admin.setting" {
+ return false
+ }
+ return true
+ }
+
+ // 解析配置
+ var config map[string]interface{}
+ if err := json.Unmarshal([]byte(sidebarConfig), &config); err != nil {
+ // 解析失败时采用安全优先策略,拒绝访问
+ common.SysLog("解析侧边栏配置失败: " + err.Error())
+ return false
+ }
+
+ // 检查嵌套权限
+ return checkNestedPermission(config, modulePath)
+}
+
+// checkNestedPermission 检查嵌套权限路径
+func checkNestedPermission(config map[string]interface{}, modulePath string) bool {
+ parts := strings.Split(modulePath, ".")
+ current := config
+
+ for i, part := range parts {
+ if current == nil {
+ return false
+ }
+
+ value, exists := current[part]
+ if !exists {
+ return false
+ }
+
+ // 如果是最后一个部分,检查布尔值
+ if i == len(parts)-1 {
+ if boolVal, ok := value.(bool); ok {
+ return boolVal
+ }
+ // 如果是对象且有enabled字段,检查enabled
+ if objVal, ok := value.(map[string]interface{}); ok {
+ if enabled, hasEnabled := objVal["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok {
+ return enabledBool
+ }
+ }
+ // 如果没有enabled字段,默认为true
+ return true
+ }
+ return false
+ }
+
+ // 中间路径必须是对象
+ if objVal, ok := value.(map[string]interface{}); ok {
+ // 检查区域是否启用
+ if enabled, hasEnabled := objVal["enabled"]; hasEnabled {
+ if enabledBool, ok := enabled.(bool); ok && !enabledBool {
+ return false
+ }
+ }
+ current = objVal
+ } else {
+ return false
+ }
+ }
+
+ return false
+}
+
func TokenAuth() func(c *gin.Context) {
return func(c *gin.Context) {
// 先检测是否为ws
diff --git a/model/main.go b/model/main.go
index 1a38d371b847..087ada2fd8ee 100644
--- a/model/main.go
+++ b/model/main.go
@@ -114,6 +114,8 @@ func CheckSetup() {
}
}
+
+
func chooseDB(envName string, isLog bool) (*gorm.DB, error) {
defer func() {
initCol()
@@ -262,6 +264,7 @@ func migrateDB() error {
&Model{},
&Vendor{},
&PrefillGroup{},
+ &UserGroup{},
&Setup{},
&TwoFA{},
&TwoFABackupCode{},
@@ -269,6 +272,17 @@ func migrateDB() error {
if err != nil {
return err
}
+
+ // 初始化默认用户分组
+ if err := InitDefaultUserGroups(); err != nil {
+ common.SysLog("初始化默认用户分组失败: " + err.Error())
+ }
+
+ // 迁移用户分组数据
+ if err := MigrateUserGroupData(); err != nil {
+ common.SysLog("迁移用户分组数据失败: " + err.Error())
+ }
+
return nil
}
@@ -294,6 +308,7 @@ func migrateDBFast() error {
{&Model{}, "Model"},
{&Vendor{}, "Vendor"},
{&PrefillGroup{}, "PrefillGroup"},
+ {&UserGroup{}, "UserGroup"},
{&Setup{}, "Setup"},
{&TwoFA{}, "TwoFA"},
{&TwoFABackupCode{}, "TwoFABackupCode"},
@@ -473,3 +488,45 @@ func PingDB() error {
common.SysLog("Database pinged successfully")
return nil
}
+
+// MigrateUserGroupData 迁移用户分组数据
+func MigrateUserGroupData() error {
+ // 检查是否已经执行过迁移标记
+ var migrationOption Option
+ err := DB.Where("`key` = ?", "UserGroupMigrationCompleted").First(&migrationOption).Error
+ if err == nil && migrationOption.Value == "true" {
+ // 已经迁移过,跳过
+ return nil
+ }
+
+ // 检查是否需要迁移
+ var count int64
+ DB.Model(&UserGroup{}).Count(&count)
+ if count == 0 {
+ // 没有用户分组数据,标记为已迁移
+ UpdateOption("UserGroupMigrationCompleted", "true")
+ return nil
+ }
+
+ // 检查是否存在 options 表中的分组数据需要迁移
+ var groupRatioOption Option
+ err = DB.Where("`key` = ?", "GroupRatio").First(&groupRatioOption).Error
+ if err != nil {
+ // 没有 GroupRatio 配置,说明不需要迁移,标记为已完成
+ UpdateOption("UserGroupMigrationCompleted", "true")
+ return nil
+ }
+
+ // 检查是否已经迁移过(通过检查是否存在非默认分组)
+ var nonDefaultCount int64
+ DB.Model(&UserGroup{}).Where("name NOT IN (?)", []string{"default", "vip", "svip"}).Count(&nonDefaultCount)
+
+ if nonDefaultCount > 0 {
+ // 已经有非默认分组,说明已经迁移过,标记为已完成
+ UpdateOption("UserGroupMigrationCompleted", "true")
+ return nil
+ }
+
+ common.SysLog("检测到需要迁移用户分组数据,请访问分组管理页面或调用迁移 API")
+ return nil
+}
diff --git a/model/option.go b/model/option.go
index ceecff658f5e..9f44ae828d6a 100644
--- a/model/option.go
+++ b/model/option.go
@@ -159,6 +159,8 @@ func loadOptionsFromDatabase() {
}
}
+
+
func SyncOptions(frequency int) {
for {
time.Sleep(time.Duration(frequency) * time.Second)
diff --git a/model/user.go b/model/user.go
index ea0584c5a05a..727b7c43f1e0 100644
--- a/model/user.go
+++ b/model/user.go
@@ -7,6 +7,7 @@ import (
"one-api/common"
"one-api/dto"
"one-api/logger"
+ "one-api/setting/operation_setting"
"strconv"
"strings"
@@ -45,6 +46,7 @@ type User struct {
Setting string `json:"setting" gorm:"type:text;column:setting"`
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
+ Avatar string `json:"avatar,omitempty" gorm:"type:longtext;column:avatar"`
}
func (user *User) ToBaseUser() *UserBase {
@@ -92,7 +94,7 @@ func (user *User) SetSetting(setting dto.UserSetting) {
}
// 根据用户角色生成默认的边栏配置
-func generateDefaultSidebarConfigForRole(userRole int) string {
+func GenerateDefaultSidebarConfigForRole(userRole int) string {
defaultConfig := map[string]interface{}{}
// 聊天区域 - 所有用户都可以访问
@@ -127,8 +129,11 @@ func generateDefaultSidebarConfigForRole(userRole int) string {
"channel": true,
"models": true,
"redemption": true,
- "user": true,
- "setting": false, // 管理员不能访问系统设置
+ "user": map[string]interface{}{
+ "enabled": true,
+ "groupManagement": true, // 管理员默认可以访问分组管理
+ },
+ "setting": false, // 管理员不能访问系统设置
}
} else if userRole == common.RoleRootUser {
// 超级管理员可以访问所有功能
@@ -137,8 +142,11 @@ func generateDefaultSidebarConfigForRole(userRole int) string {
"channel": true,
"models": true,
"redemption": true,
- "user": true,
- "setting": true,
+ "user": map[string]interface{}{
+ "enabled": true,
+ "groupManagement": true, // 超级管理员默认可以访问分组管理
+ },
+ "setting": true,
}
}
// 普通用户不包含admin区域
@@ -400,7 +408,7 @@ func (user *User) Insert(inviterId int) error {
var createdUser User
if err := DB.Where("username = ?", user.Username).First(&createdUser).Error; err == nil {
// 生成基于角色的默认边栏配置
- defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role)
+ defaultSidebarConfig := GenerateDefaultSidebarConfigForRole(createdUser.Role)
if defaultSidebarConfig != "" {
currentSetting := createdUser.GetSetting()
currentSetting.SidebarModules = defaultSidebarConfig
@@ -414,13 +422,18 @@ func (user *User) Insert(inviterId int) error {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
}
if inviterId != 0 {
- if common.QuotaForInvitee > 0 {
- _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true)
- RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee)))
- }
- if common.QuotaForInviter > 0 {
- //_ = IncreaseUserQuota(inviterId, common.QuotaForInviter)
- RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter)))
+ // 检查邀请功能是否启用
+ generalSetting := operation_setting.GetGeneralSetting()
+ if generalSetting.InvitationEnabled {
+ if common.QuotaForInvitee > 0 {
+ _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true)
+ RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee)))
+ }
+ if common.QuotaForInviter > 0 {
+ //_ = IncreaseUserQuota(inviterId, common.QuotaForInviter)
+ RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter)))
+ }
+ // 无论是否有奖励额度,都要更新邀请者的邀请统计
_ = inviteUser(inviterId)
}
}
@@ -445,6 +458,16 @@ func (user *User) Update(updatePassword bool) error {
return updateUserCache(*user)
}
+func (user *User) UpdateAvatar() error {
+ if err := DB.Model(&User{}).Where("id = ?", user.Id).Update("avatar", user.Avatar).Error; err != nil {
+ return err
+ }
+
+ // Update cache
+ DB.First(&user, user.Id)
+ return updateUserCache(*user)
+}
+
func (user *User) Edit(updatePassword bool) error {
var err error
if updatePassword {
@@ -461,6 +484,7 @@ func (user *User) Edit(updatePassword bool) error {
"group": newUser.Group,
"quota": newUser.Quota,
"remark": newUser.Remark,
+ "avatar": newUser.Avatar,
}
if updatePassword {
updates["password"] = newUser.Password
@@ -915,3 +939,72 @@ func RootUserExists() bool {
}
return true
}
+
+// UpdateUsersGroupName 更新所有使用指定分组名的用户的分组名称
+func UpdateUsersGroupName(oldGroupName, newGroupName string) error {
+ if oldGroupName == "" || newGroupName == "" {
+ return errors.New("分组名称不能为空")
+ }
+
+ common.SysLog(fmt.Sprintf("开始更新用户分组名称: '%s' -> '%s'", oldGroupName, newGroupName))
+
+ // 先查询有多少用户使用旧分组名
+ var count int64
+ if err := DB.Model(&User{}).Where(commonGroupCol+" = ?", oldGroupName).Count(&count).Error; err != nil {
+ common.SysLog(fmt.Sprintf("查询使用分组 '%s' 的用户数量失败: %s", oldGroupName, err.Error()))
+ return err
+ }
+ common.SysLog(fmt.Sprintf("找到 %d 个用户使用分组 '%s'", count, oldGroupName))
+
+ // 使用事务确保数据一致性
+ tx := DB.Begin()
+ if tx.Error != nil {
+ return tx.Error
+ }
+
+ defer func() {
+ if r := recover(); r != nil {
+ tx.Rollback()
+ }
+ }()
+
+ // 更新所有使用旧分组名的用户
+ result := tx.Model(&User{}).Where(commonGroupCol+" = ?", oldGroupName).Update(commonGroupCol, newGroupName)
+ if result.Error != nil {
+ tx.Rollback()
+ common.SysLog(fmt.Sprintf("更新用户分组名称失败: %s", result.Error.Error()))
+ return result.Error
+ }
+
+ // 提交事务
+ if err := tx.Commit().Error; err != nil {
+ common.SysLog(fmt.Sprintf("提交事务失败: %s", err.Error()))
+ return err
+ }
+
+ // 刷新缓存(异步),避免旧分组缓存导致读取不一致
+ if common.RedisEnabled {
+ gopool.Go(func() {
+ var ids []int
+ if err := DB.Model(&User{}).
+ Where(commonGroupCol+" = ?", newGroupName).
+ Pluck("id", &ids).Error; err == nil {
+ for _, id := range ids {
+ _ = invalidateUserCache(id)
+ }
+ }
+ })
+ }
+
+ common.SysLog(fmt.Sprintf("成功更新 %d 个用户的分组名称从 '%s' 到 '%s'", result.RowsAffected, oldGroupName, newGroupName))
+
+ // 验证更新结果
+ var newCount int64
+ if err := DB.Model(&User{}).Where(commonGroupCol+" = ?", newGroupName).Count(&newCount).Error; err != nil {
+ common.SysLog(fmt.Sprintf("验证更新结果失败: %s", err.Error()))
+ } else {
+ common.SysLog(fmt.Sprintf("验证: 现在有 %d 个用户使用分组 '%s'", newCount, newGroupName))
+ }
+
+ return nil
+}
diff --git a/model/user_group.go b/model/user_group.go
new file mode 100644
index 000000000000..7f60316c9cf3
--- /dev/null
+++ b/model/user_group.go
@@ -0,0 +1,139 @@
+package model
+
+import (
+ "one-api/common"
+ "gorm.io/gorm"
+)
+
+type UserGroup struct {
+ Id int `json:"id"`
+ Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_user_group_name,where:deleted_at IS NULL"`
+ Description string `json:"description,omitempty" gorm:"type:varchar(255)"`
+ Ratio float64 `json:"ratio" gorm:"default:1.0"`
+ CreatedTime int64 `json:"created_time" gorm:"bigint"`
+ UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
+}
+
+// Insert 创建新的用户分组
+func (g *UserGroup) Insert() error {
+ now := common.GetTimestamp()
+ g.CreatedTime = now
+ g.UpdatedTime = now
+ return DB.Create(g).Error
+}
+
+// Update 更新用户分组
+ func (g *UserGroup) Update() error {
+ g.UpdatedTime = common.GetTimestamp()
+ return DB.Model(&UserGroup{}).
+ Where("id = ?", g.Id).
+ Updates(map[string]any{
+ "name": g.Name,
+ "description": g.Description,
+ "ratio": g.Ratio,
+ "updated_time": g.UpdatedTime,
+ }).Error
+ }
+
+// UpdateTx 在事务中更新用户分组
+func (g *UserGroup) UpdateTx(tx *gorm.DB) error {
+ g.UpdatedTime = common.GetTimestamp()
+ return tx.Model(g).Updates(g).Error
+}
+
+// Delete 硬删除用户分组
+func (g *UserGroup) Delete() error {
+ return DB.Unscoped().Delete(g).Error
+}
+
+// GetAllUserGroups 获取所有用户分组
+func GetAllUserGroups() ([]*UserGroup, error) {
+ var groups []*UserGroup
+ err := DB.Order("created_time desc").Find(&groups).Error
+ return groups, err
+}
+
+// GetUserGroupById 根据ID获取用户分组
+func GetUserGroupById(id int) (*UserGroup, error) {
+ var group UserGroup
+ err := DB.First(&group, id).Error
+ if err != nil {
+ return nil, err
+ }
+ return &group, nil
+}
+
+// GetUserGroupByName 根据名称获取用户分组
+func GetUserGroupByName(name string) (*UserGroup, error) {
+ var group UserGroup
+ err := DB.Where("name = ?", name).First(&group).Error
+ if err != nil {
+ return nil, err
+ }
+ return &group, nil
+}
+
+// IsUserGroupNameDuplicated 检查用户分组名称是否重复
+func IsUserGroupNameDuplicated(id int, name string) (bool, error) {
+ var count int64
+ query := DB.Model(&UserGroup{}).Where("name = ?", name)
+ if id != 0 {
+ query = query.Where("id != ?", id)
+ }
+ err := query.Count(&count).Error
+ return count > 0, err
+}
+
+// IsUserGroupInUse 检查用户分组是否正在被使用
+func IsUserGroupInUse(name string) (bool, error) {
+ var count int64
+ err := DB.Model(&User{}).Where("`group` = ?", name).Count(&count).Error
+ return count > 0, err
+}
+
+// GetUserGroupNames 获取所有用户分组名称
+func GetUserGroupNames() ([]string, error) {
+ var names []string
+ err := DB.Model(&UserGroup{}).Pluck("name", &names).Error
+ return names, err
+}
+
+// InitDefaultUserGroups 初始化默认用户分组
+func InitDefaultUserGroups() error {
+ // 检查是否已经存在默认分组
+ var count int64
+ DB.Model(&UserGroup{}).Count(&count)
+ if count > 0 {
+ return nil // 已经有分组了,不需要初始化
+ }
+
+ // 创建默认分组
+ defaultGroups := []*UserGroup{
+ {
+ Name: "default",
+ Description: "默认分组",
+ Ratio: 1.0,
+ },
+ {
+ Name: "vip",
+ Description: "VIP分组",
+ Ratio: 1.0,
+ },
+ {
+ Name: "svip",
+ Description: "SVIP分组",
+ Ratio: 1.0,
+ },
+ }
+
+ for _, group := range defaultGroups {
+ if err := group.Insert(); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+
diff --git a/router/api-router.go b/router/api-router.go
index e16d06628955..c1302f261c9f 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -55,6 +55,7 @@ func SetApiRouter(router *gin.Engine) {
{
selfRoute.GET("/self/groups", controller.GetUserGroups)
selfRoute.GET("/self", controller.GetSelf)
+ selfRoute.GET("/avatar", controller.GetUserAvatar)
selfRoute.GET("/models", controller.GetUserModels)
selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf)
@@ -79,6 +80,7 @@ func SetApiRouter(router *gin.Engine) {
adminRoute := userRoute.Group("/")
adminRoute.Use(middleware.AdminAuth())
+ adminRoute.Use(middleware.ModuleAuth("admin.user"))
{
adminRoute.GET("/", controller.GetAllUsers)
adminRoute.GET("/search", controller.SearchUsers)
@@ -109,6 +111,7 @@ func SetApiRouter(router *gin.Engine) {
}
channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.AdminAuth())
+ channelRoute.Use(middleware.ModuleAuth("admin.channel"))
{
channelRoute.GET("/", controller.GetAllChannels)
channelRoute.GET("/search", controller.SearchChannels)
@@ -138,6 +141,7 @@ func SetApiRouter(router *gin.Engine) {
}
tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth())
+ tokenRoute.Use(middleware.ModuleAuth("console.token"))
{
tokenRoute.GET("/", controller.GetAllTokens)
tokenRoute.GET("/search", controller.SearchTokens)
@@ -160,6 +164,7 @@ func SetApiRouter(router *gin.Engine) {
redemptionRoute := apiRouter.Group("/redemption")
redemptionRoute.Use(middleware.AdminAuth())
+ redemptionRoute.Use(middleware.ModuleAuth("admin.redemption"))
{
redemptionRoute.GET("/", controller.GetAllRedemptions)
redemptionRoute.GET("/search", controller.SearchRedemptions)
@@ -170,17 +175,17 @@ func SetApiRouter(router *gin.Engine) {
redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
}
logRoute := apiRouter.Group("/log")
- logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs)
- logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)
- logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
- logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
- logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
- logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
- logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs)
+ logRoute.GET("/", middleware.AdminAuth(), middleware.ModuleAuth("console.log"), controller.GetAllLogs)
+ logRoute.DELETE("/", middleware.AdminAuth(), middleware.ModuleAuth("console.log"), controller.DeleteHistoryLogs)
+ logRoute.GET("/stat", middleware.AdminAuth(), middleware.ModuleAuth("console.log"), controller.GetLogsStat)
+ logRoute.GET("/self/stat", middleware.UserAuth(), middleware.ModuleAuth("console.log"), controller.GetLogsSelfStat)
+ logRoute.GET("/search", middleware.AdminAuth(), middleware.ModuleAuth("console.log"), controller.SearchAllLogs)
+ logRoute.GET("/self", middleware.UserAuth(), middleware.ModuleAuth("console.log"), controller.GetUserLogs)
+ logRoute.GET("/self/search", middleware.UserAuth(), middleware.ModuleAuth("console.log"), controller.SearchUserLogs)
dataRoute := apiRouter.Group("/data")
- dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
- dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
+ dataRoute.GET("/", middleware.AdminAuth(), middleware.ModuleAuth("console.detail"), controller.GetAllQuotaDates)
+ dataRoute.GET("/self", middleware.UserAuth(), middleware.ModuleAuth("console.detail"), controller.GetUserQuotaDates)
logRoute.Use(middleware.CORS())
{
@@ -201,14 +206,27 @@ func SetApiRouter(router *gin.Engine) {
prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup)
}
+ userGroupRoute := apiRouter.Group("/user_group")
+ userGroupRoute.Use(middleware.AdminAuth())
+ userGroupRoute.Use(middleware.ModuleAuth("admin.user.groupManagement"))
+ {
+ userGroupRoute.GET("/", controller.GetAllUserGroups)
+ userGroupRoute.POST("/", controller.CreateUserGroup)
+ userGroupRoute.PUT("/", controller.UpdateUserGroup)
+ userGroupRoute.DELETE("/:id", controller.DeleteUserGroup)
+ userGroupRoute.POST("/migrate", controller.MigrateUserGroupData)
+ userGroupRoute.GET("/options", controller.GetUserGroupsAsOptions)
+ userGroupRoute.PUT("/batch", controller.BatchUpdateUserGroups)
+ }
+
mjRoute := apiRouter.Group("/mj")
- mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney)
- mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney)
+ mjRoute.GET("/self", middleware.UserAuth(), middleware.ModuleAuth("console.midjourney"), controller.GetUserMidjourney)
+ mjRoute.GET("/", middleware.AdminAuth(), middleware.ModuleAuth("console.midjourney"), controller.GetAllMidjourney)
taskRoute := apiRouter.Group("/task")
{
- taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
- taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
+ taskRoute.GET("/self", middleware.UserAuth(), middleware.ModuleAuth("console.task"), controller.GetUserTask)
+ taskRoute.GET("/", middleware.AdminAuth(), middleware.ModuleAuth("console.task"), controller.GetAllTask)
}
vendorRoute := apiRouter.Group("/vendors")
@@ -224,6 +242,7 @@ func SetApiRouter(router *gin.Engine) {
modelsRoute := apiRouter.Group("/models")
modelsRoute.Use(middleware.AdminAuth())
+ modelsRoute.Use(middleware.ModuleAuth("admin.models"))
{
modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview)
modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels)
diff --git a/service/cf_worker.go b/service/cf_worker.go
index 4a7b437603ef..d60b6fad5f78 100644
--- a/service/cf_worker.go
+++ b/service/cf_worker.go
@@ -6,7 +6,7 @@ import (
"fmt"
"net/http"
"one-api/common"
- "one-api/setting"
+ "one-api/setting/system_setting"
"strings"
)
@@ -21,14 +21,14 @@ type WorkerRequest struct {
// DoWorkerRequest 通过Worker发送请求
func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
- if !setting.EnableWorker() {
+ if !system_setting.EnableWorker() {
return nil, fmt.Errorf("worker not enabled")
}
- if !setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") {
+ if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") {
return nil, fmt.Errorf("only support https url")
}
- workerUrl := setting.WorkerUrl
+ workerUrl := system_setting.WorkerUrl
if !strings.HasSuffix(workerUrl, "/") {
workerUrl += "/"
}
@@ -43,11 +43,11 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
}
func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) {
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
req := &WorkerRequest{
URL: originUrl,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
}
return DoWorkerRequest(req)
} else {
diff --git a/service/user_group_service.go b/service/user_group_service.go
new file mode 100644
index 000000000000..377830d399aa
--- /dev/null
+++ b/service/user_group_service.go
@@ -0,0 +1,216 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "one-api/common"
+ "one-api/model"
+ "one-api/setting"
+ "one-api/setting/ratio_setting"
+)
+
+// MigrateUserGroupsFromOptions 从 options 表迁移用户分组数据到 UserGroup 表
+func MigrateUserGroupsFromOptions() error {
+ common.SysLog("开始迁移用户分组数据...")
+
+ // 获取当前 options 中的分组数据
+ groupRatioStr := common.OptionMap["GroupRatio"]
+ userUsableGroupsStr := common.OptionMap["UserUsableGroups"]
+ topupGroupRatioStr := common.OptionMap["TopupGroupRatio"]
+
+ // 解析分组倍率
+ var groupRatio map[string]float64
+ if groupRatioStr != "" {
+ if err := json.Unmarshal([]byte(groupRatioStr), &groupRatio); err != nil {
+ common.SysLog("解析 GroupRatio 失败: " + err.Error())
+ groupRatio = make(map[string]float64)
+ }
+ } else {
+ groupRatio = make(map[string]float64)
+ }
+
+ // 解析用户可选分组
+ var userUsableGroups map[string]string
+ if userUsableGroupsStr != "" {
+ if err := json.Unmarshal([]byte(userUsableGroupsStr), &userUsableGroups); err != nil {
+ common.SysLog("解析 UserUsableGroups 失败: " + err.Error())
+ userUsableGroups = make(map[string]string)
+ }
+ } else {
+ userUsableGroups = make(map[string]string)
+ }
+
+ // 解析充值分组倍率
+ var topupGroupRatio map[string]float64
+ if topupGroupRatioStr != "" {
+ if err := json.Unmarshal([]byte(topupGroupRatioStr), &topupGroupRatio); err != nil {
+ common.SysLog("解析 TopupGroupRatio 失败: " + err.Error())
+ topupGroupRatio = make(map[string]float64)
+ }
+ } else {
+ topupGroupRatio = make(map[string]float64)
+ }
+
+ // 合并所有分组名称
+ allGroups := make(map[string]bool)
+ for name := range groupRatio {
+ allGroups[name] = true
+ }
+ for name := range userUsableGroups {
+ allGroups[name] = true
+ }
+ for name := range topupGroupRatio {
+ allGroups[name] = true
+ }
+
+ // 添加默认分组
+ defaultGroups := []string{"default", "vip", "svip"}
+ for _, name := range defaultGroups {
+ allGroups[name] = true
+ }
+
+ // 检查并创建/更新分组
+ migratedCount := 0
+ for groupName := range allGroups {
+ // 检查分组是否已存在
+ existingGroup, err := model.GetUserGroupByName(groupName)
+ if err != nil && err.Error() != "record not found" {
+ common.SysLog("检查分组 " + groupName + " 时出错: " + err.Error())
+ continue
+ }
+
+ ratio := groupRatio[groupName]
+ if ratio == 0 {
+ ratio = 1.0 // 默认倍率
+ }
+
+ description := userUsableGroups[groupName]
+ if description == "" {
+ // 为默认分组设置描述
+ switch groupName {
+ case "default":
+ description = "默认分组"
+ case "vip":
+ description = "VIP分组"
+ case "svip":
+ description = "SVIP分组"
+ default:
+ description = groupName + "分组"
+ }
+ }
+
+ if existingGroup == nil {
+ // 创建新分组
+ newGroup := &model.UserGroup{
+ Name: groupName,
+ Description: description,
+ Ratio: ratio,
+ }
+ if err := newGroup.Insert(); err != nil {
+ common.SysLog("创建分组 " + groupName + " 失败: " + err.Error())
+ continue
+ }
+ migratedCount++
+ common.SysLog("创建分组: " + groupName + " (倍率: " + fmt.Sprintf("%.4f", ratio) + ")")
+ } else {
+ // 更新现有分组(如果数据不同)
+ needUpdate := false
+ if existingGroup.Ratio != ratio {
+ existingGroup.Ratio = ratio
+ needUpdate = true
+ }
+ if existingGroup.Description != description {
+ existingGroup.Description = description
+ needUpdate = true
+ }
+
+ if needUpdate {
+ if err := existingGroup.Update(); err != nil {
+ common.SysLog("更新分组 " + groupName + " 失败: " + err.Error())
+ continue
+ }
+ migratedCount++
+ common.SysLog("更新分组: " + groupName + " (倍率: " + fmt.Sprintf("%.4f", ratio) + ")")
+ }
+ }
+ }
+
+ common.SysLog("用户分组数据迁移完成,处理了 " + fmt.Sprintf("%d", migratedCount) + " 个分组")
+
+ // 设置迁移完成标记
+ if err := model.UpdateOption("UserGroupMigrationCompleted", "true"); err != nil {
+ common.SysLog("设置迁移完成标记失败: " + err.Error())
+ }
+
+ return nil
+}
+
+// SyncUserGroupsToMemory 将 UserGroup 表数据同步到内存中的设置
+func SyncUserGroupsToMemory() error {
+ groups, err := model.GetAllUserGroups()
+ if err != nil {
+ return err
+ }
+
+ // 构建分组倍率映射
+ groupRatio := make(map[string]float64)
+ userUsableGroups := make(map[string]string)
+ topupGroupRatio := make(map[string]float64)
+
+ for _, group := range groups {
+ groupRatio[group.Name] = group.Ratio
+ userUsableGroups[group.Name] = group.Description
+ topupGroupRatio[group.Name] = group.Ratio // 充值倍率使用相同的倍率
+ }
+
+ // 更新内存中的设置
+ if groupRatioJson, err := json.Marshal(groupRatio); err == nil {
+ ratio_setting.UpdateGroupRatioByJSONString(string(groupRatioJson))
+ }
+
+ if userUsableGroupsJson, err := json.Marshal(userUsableGroups); err == nil {
+ setting.UpdateUserUsableGroupsByJSONString(string(userUsableGroupsJson))
+ }
+
+ if topupGroupRatioJson, err := json.Marshal(topupGroupRatio); err == nil {
+ common.UpdateTopupGroupRatioByJSONString(string(topupGroupRatioJson))
+ }
+
+ return nil
+}
+
+// GetUserGroupsAsOptions 获取用户分组数据并转换为 options 格式
+func GetUserGroupsAsOptions() (map[string]string, error) {
+ groups, err := model.GetAllUserGroups()
+ if err != nil {
+ return nil, err
+ }
+
+ options := make(map[string]string)
+
+ // 构建分组倍率
+ groupRatio := make(map[string]float64)
+ userUsableGroups := make(map[string]string)
+ topupGroupRatio := make(map[string]float64)
+
+ for _, group := range groups {
+ groupRatio[group.Name] = group.Ratio
+ userUsableGroups[group.Name] = group.Description
+ topupGroupRatio[group.Name] = group.Ratio
+ }
+
+ // 转换为 JSON 字符串
+ if groupRatioJson, err := json.Marshal(groupRatio); err == nil {
+ options["GroupRatio"] = string(groupRatioJson)
+ }
+
+ if userUsableGroupsJson, err := json.Marshal(userUsableGroups); err == nil {
+ options["UserUsableGroups"] = string(userUsableGroupsJson)
+ }
+
+ if topupGroupRatioJson, err := json.Marshal(topupGroupRatio); err == nil {
+ options["TopupGroupRatio"] = string(topupGroupRatioJson)
+ }
+
+ return options, nil
+}
diff --git a/service/user_notify.go b/service/user_notify.go
index c4a3ea91f9de..972ca655c52d 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -7,7 +7,7 @@ import (
"one-api/common"
"one-api/dto"
"one-api/model"
- "one-api/setting"
+ "one-api/setting/system_setting"
"strings"
)
@@ -91,11 +91,11 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
var resp *http.Response
var err error
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
// 使用worker发送请求
workerReq := &WorkerRequest{
URL: finalURL,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
Method: http.MethodGet,
Headers: map[string]string{
"User-Agent": "OneAPI-Bark-Notify/1.0",
diff --git a/service/webhook.go b/service/webhook.go
index 8faccda30102..9c6ec8102783 100644
--- a/service/webhook.go
+++ b/service/webhook.go
@@ -9,7 +9,7 @@ import (
"fmt"
"net/http"
"one-api/dto"
- "one-api/setting"
+ "one-api/setting/system_setting"
"time"
)
@@ -56,11 +56,11 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
var req *http.Request
var resp *http.Response
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
// 构建worker请求数据
workerReq := &WorkerRequest{
URL: webhookURL,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
Method: http.MethodPost,
Headers: map[string]string{
"Content-Type": "application/json",
diff --git a/setting/operation_setting/general_setting.go b/setting/operation_setting/general_setting.go
index ae0c436ecefd..fc9190f4d1cf 100644
--- a/setting/operation_setting/general_setting.go
+++ b/setting/operation_setting/general_setting.go
@@ -6,6 +6,7 @@ type GeneralSetting struct {
DocsLink string `json:"docs_link"`
PingIntervalEnabled bool `json:"ping_interval_enabled"`
PingIntervalSeconds int `json:"ping_interval_seconds"`
+ InvitationEnabled bool `json:"invitation_enabled"`
}
// 默认配置
@@ -13,6 +14,7 @@ var generalSetting = GeneralSetting{
DocsLink: "https://docs.newapi.pro",
PingIntervalEnabled: false,
PingIntervalSeconds: 60,
+ InvitationEnabled: true,
}
func init() {
diff --git a/web/index.html b/web/index.html
index 09d87ae1a890..d54a471b3631 100644
--- a/web/index.html
+++ b/web/index.html
@@ -10,6 +10,30 @@
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
/>
New API
+
diff --git a/web/src/App.jsx b/web/src/App.jsx
index 635742f9161e..ed2261ad569b 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -22,6 +22,7 @@ import { Route, Routes, useLocation } from 'react-router-dom';
import Loading from './components/common/ui/Loading';
import User from './pages/User';
import { AuthRedirect, PrivateRoute, AdminRoute } from './helpers';
+import ModuleRoute from './components/auth/ModuleRoute';
import RegisterForm from './components/auth/RegisterForm';
import LoginForm from './components/auth/LoginForm';
import NotFound from './pages/NotFound';
@@ -58,7 +59,7 @@ function App() {
// 获取模型广场权限配置
const pricingRequireAuth = useMemo(() => {
- const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
+ const headerNavModulesConfig = statusState?.status?.header_nav_modules;
if (headerNavModulesConfig) {
try {
const modules = JSON.parse(headerNavModulesConfig);
@@ -76,7 +77,7 @@ function App() {
}
}
return false; // 默认不需要登录
- }, [statusState?.status?.HeaderNavModules]);
+ }, [statusState?.status?.header_nav_modules]);
return (
@@ -102,7 +103,9 @@ function App() {
path='/console/models'
element={
-
+
+
+
}
/>
@@ -110,7 +113,9 @@ function App() {
path='/console/channel'
element={
-
+
+
+
}
/>
@@ -118,7 +123,9 @@ function App() {
path='/console/token'
element={
-
+
+
+
}
/>
@@ -126,7 +133,9 @@ function App() {
path='/console/playground'
element={
-
+
+
+
}
/>
@@ -134,7 +143,9 @@ function App() {
path='/console/redemption'
element={
-
+
+
+
}
/>
@@ -142,7 +153,9 @@ function App() {
path='/console/user'
element={
-
+
+
+
}
/>
@@ -210,9 +223,11 @@ function App() {
path='/console/setting'
element={
- } key={location.pathname}>
-
-
+
+ } key={location.pathname}>
+
+
+
}
/>
@@ -220,9 +235,11 @@ function App() {
path='/console/personal'
element={
- } key={location.pathname}>
-
-
+
+ } key={location.pathname}>
+
+
+
}
/>
@@ -230,9 +247,11 @@ function App() {
path='/console/topup'
element={
- } key={location.pathname}>
-
-
+
+ } key={location.pathname}>
+
+
+
}
/>
@@ -240,7 +259,9 @@ function App() {
path='/console/log'
element={
-
+
+
+
}
/>
@@ -248,9 +269,11 @@ function App() {
path='/console'
element={
- } key={location.pathname}>
-
-
+
+ } key={location.pathname}>
+
+
+
}
/>
@@ -258,9 +281,11 @@ function App() {
path='/console/midjourney'
element={
- } key={location.pathname}>
-
-
+
+ } key={location.pathname}>
+
+
+
}
/>
@@ -268,9 +293,11 @@ function App() {
path='/console/task'
element={
- } key={location.pathname}>
-
-
+
+ } key={location.pathname}>
+
+
+
}
/>
@@ -304,9 +331,11 @@ function App() {
} key={location.pathname}>
-
-
+
+ } key={location.pathname}>
+
+
+
}
/>
{/* 方便使用chat2link直接跳转聊天... */}
@@ -314,9 +343,11 @@ function App() {
path='/chat2link'
element={
- } key={location.pathname}>
-
-
+
+ } key={location.pathname}>
+
+
+
}
/>
diff --git a/web/src/components/auth/AuthPageLayout.jsx b/web/src/components/auth/AuthPageLayout.jsx
new file mode 100644
index 000000000000..b2c027c73d68
--- /dev/null
+++ b/web/src/components/auth/AuthPageLayout.jsx
@@ -0,0 +1,39 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+
+const AuthPageLayout = ({ children }) => {
+ return (
+
+ {/* 背景模糊晕染球 */}
+
+
+ {children}
+
+ );
+};
+
+export default AuthPageLayout;
diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx
index 32087ab02484..8714aeaae3d2 100644
--- a/web/src/components/auth/LoginForm.jsx
+++ b/web/src/components/auth/LoginForm.jsx
@@ -45,6 +45,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
import TwoFAVerification from './TwoFAVerification';
import { useTranslation } from 'react-i18next';
+import AuthPageLayout from './AuthPageLayout';
const LoginForm = () => {
let navigate = useNavigate();
@@ -603,17 +604,8 @@ const LoginForm = () => {
};
return (
-
- {/* 背景模糊晕染球 */}
-
-
-
+
+
{showEmailLogin ||
!(
status.github_oauth ||
@@ -638,7 +630,7 @@ const LoginForm = () => {
)}
-
+
);
};
diff --git a/web/src/components/auth/ModuleRoute.jsx b/web/src/components/auth/ModuleRoute.jsx
new file mode 100644
index 000000000000..4c4bd359f929
--- /dev/null
+++ b/web/src/components/auth/ModuleRoute.jsx
@@ -0,0 +1,149 @@
+import React, { useState, useEffect, useContext } from 'react';
+import { Navigate } from 'react-router-dom';
+import { StatusContext } from '../../context/Status';
+import Loading from '../common/ui/Loading';
+import { useSidebar } from '../../hooks/common/useSidebar';
+import { USER_ROLES } from '../../constants/user.constants';
+
+/**
+ * ModuleRoute - 基于功能模块权限的路由保护组件
+ *
+ * @param {Object} props
+ * @param {React.ReactNode} props.children - 要保护的子组件
+ * @param {string} props.modulePath - 模块权限路径,如 "admin.channel", "console.token"
+ * @param {React.ReactNode} props.fallback - 无权限时显示的组件,默认跳转到 /forbidden
+ * @returns {React.ReactNode}
+ */
+const ModuleRoute = ({ children, modulePath, fallback = }) => {
+ const [hasPermission, setHasPermission] = useState(null);
+ const [statusState] = useContext(StatusContext);
+
+ // 复用 useSidebar 钩子的配置数据,避免重复 API 调用
+ const { loading: sidebarLoading, finalConfig } = useSidebar();
+
+ // 获取用户信息的辅助函数
+ const getUserFromStorage = () => {
+ try {
+ return JSON.parse(localStorage.getItem('user') || 'null');
+ } catch {
+ return null;
+ }
+ };
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const checkPermission = async () => {
+ const userObj = getUserFromStorage();
+ const permission = await checkModulePermission(userObj);
+
+ if (!cancelled) {
+ setHasPermission(permission);
+ }
+ };
+
+ checkPermission();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [modulePath, statusState?.status, sidebarLoading, finalConfig]); // 依赖 sidebar 配置变化
+
+ const checkModulePermission = async (userObj) => {
+ try {
+ // 检查用户是否已登录
+ if (!userObj) {
+ return false;
+ }
+
+ // 不再基于本地角色直接授予 ROOT 权限,统一依赖服务端下发的最终配置
+ const userRole = userObj.role;
+
+ // 如果 sidebar 配置还在加载中,返回 null 表示需要等待
+ if (sidebarLoading || !finalConfig) {
+ return null;
+ }
+
+ // 检查模块权限
+ return checkModulePermissionInConfig(userRole, modulePath);
+ } catch (error) {
+ console.error('检查模块权限失败:', error);
+ // 出错时采用安全优先策略,拒绝访问
+ return false;
+ }
+ };
+
+ const checkModulePermissionInConfig = (userRole, modulePath) => {
+ // 数据看板始终允许访问,不受控制台区域开关影响
+ if (modulePath === 'console.detail') {
+ return true;
+ }
+
+ // 解析模块路径
+ const pathParts = modulePath.split('.');
+ if (pathParts.length < 2) {
+ console.warn(`无效的模块路径: ${modulePath}`);
+ return false;
+ }
+
+ // 所有用户角色统一依赖服务端下发的最终配置进行权限检查
+ if (userRole === USER_ROLES.COMMON) {
+ // 普通用户:使用最终计算的配置进行权限检查
+ return checkModuleInSidebarConfig(finalConfig, modulePath);
+ } else if (userRole === USER_ROLES.ADMIN) {
+ // 管理员:不能访问系统设置,其他基于配置检查
+ if (modulePath === 'admin.setting') {
+ return false;
+ }
+ return checkModuleInSidebarConfig(finalConfig, modulePath);
+ } else if (userRole === USER_ROLES.ROOT) {
+ // 超级管理员:也依赖服务端配置,不再客户端直接授权
+ return checkModuleInSidebarConfig(finalConfig, modulePath);
+ }
+
+ // 未知角色,拒绝访问
+ return false;
+ };
+
+ // 检查sidebar_config结构中的模块权限
+ const checkModuleInSidebarConfig = (sidebarConfig, modulePath) => {
+ const parts = modulePath.split('.');
+ if (parts.length !== 2) {
+ return false;
+ }
+
+ const [sectionKey, moduleKey] = parts;
+ const section = sidebarConfig[sectionKey];
+
+ // 检查区域是否存在且启用
+ if (!section || !section.enabled) {
+ return false;
+ }
+
+ // 检查模块是否启用
+ const moduleValue = section[moduleKey];
+ // 处理布尔值和嵌套对象两种情况
+ if (typeof moduleValue === 'boolean') {
+ return moduleValue === true;
+ } else if (typeof moduleValue === 'object' && moduleValue !== null) {
+ // 对于嵌套对象,检查其enabled状态
+ return moduleValue.enabled === true;
+ }
+ return false;
+ };
+
+ // 权限检查中
+ if (hasPermission === null) {
+ return ;
+ }
+
+ // 无权限
+ if (!hasPermission) {
+ return fallback;
+ }
+
+ // 有权限,渲染子组件
+ return children;
+};
+
+export default ModuleRoute;
\ No newline at end of file
diff --git a/web/src/components/auth/PasswordResetConfirm.jsx b/web/src/components/auth/PasswordResetConfirm.jsx
index 9bc37b3ce560..fd48b6011904 100644
--- a/web/src/components/auth/PasswordResetConfirm.jsx
+++ b/web/src/components/auth/PasswordResetConfirm.jsx
@@ -30,6 +30,7 @@ import { useSearchParams, Link } from 'react-router-dom';
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
+import AuthPageLayout from './AuthPageLayout';
const { Text, Title } = Typography;
@@ -104,17 +105,8 @@ const PasswordResetConfirm = () => {
}
return (
-
- {/* 背景模糊晕染球 */}
-
-
-
+
+
@@ -213,7 +205,7 @@ const PasswordResetConfirm = () => {
-
+
);
};
diff --git a/web/src/components/auth/PasswordResetForm.jsx b/web/src/components/auth/PasswordResetForm.jsx
index 92afc2afa439..c07d8bef6b3f 100644
--- a/web/src/components/auth/PasswordResetForm.jsx
+++ b/web/src/components/auth/PasswordResetForm.jsx
@@ -31,6 +31,7 @@ import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail } from '@douyinfe/semi-icons';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
+import AuthPageLayout from './AuthPageLayout';
const { Text, Title } = Typography;
@@ -104,17 +105,8 @@ const PasswordResetForm = () => {
}
return (
-
- {/* 背景模糊晕染球 */}
-
-
-
+
+
@@ -186,7 +178,7 @@ const PasswordResetForm = () => {
-
+
);
};
diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx
index 9c98bdc3a0e9..e3c59d930647 100644
--- a/web/src/components/auth/RegisterForm.jsx
+++ b/web/src/components/auth/RegisterForm.jsx
@@ -51,6 +51,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
import TelegramLoginButton from 'react-telegram-login/src';
import { UserContext } from '../../context/User';
import { useTranslation } from 'react-i18next';
+import AuthPageLayout from './AuthPageLayout';
const RegisterForm = () => {
let navigate = useNavigate();
@@ -601,17 +602,8 @@ const RegisterForm = () => {
};
return (
-
- {/* 背景模糊晕染球 */}
-
-
-
+
+
{showEmailRegister ||
!(
status.github_oauth ||
@@ -635,7 +627,7 @@ const RegisterForm = () => {
)}
-
+
);
};
diff --git a/web/src/components/layout/Footer.jsx b/web/src/components/layout/Footer.jsx
index 5c210fca89bf..59b25ddd8608 100644
--- a/web/src/components/layout/Footer.jsx
+++ b/web/src/components/layout/Footer.jsx
@@ -221,7 +221,7 @@ const FooterBar = () => {
}, []);
return (
-
+
{footer ? (
{
overflow: isMobile ? 'visible' : 'auto',
display: 'flex',
flexDirection: 'column',
+ height: '100%',
+ flex: '1 1 auto',
}}
>
{showSider && (
diff --git a/web/src/components/layout/headerbar/LanguageSelector.jsx b/web/src/components/layout/headerbar/LanguageSelector.jsx
index cbfd69b35706..3952771eecdd 100644
--- a/web/src/components/layout/headerbar/LanguageSelector.jsx
+++ b/web/src/components/layout/headerbar/LanguageSelector.jsx
@@ -32,8 +32,8 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
onClick={() => onLanguageChange('zh')}
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
-
-
中文
+
+
简体中文
onLanguageChange('en')}
diff --git a/web/src/components/layout/headerbar/UserArea.jsx b/web/src/components/layout/headerbar/UserArea.jsx
index 8ea70f47f586..e89129d0fb10 100644
--- a/web/src/components/layout/headerbar/UserArea.jsx
+++ b/web/src/components/layout/headerbar/UserArea.jsx
@@ -120,10 +120,11 @@ const UserArea = ({
>
- {userState.user.username[0].toUpperCase()}
+ {!userState.user?.avatar && userState.user.username[0].toUpperCase()}
diff --git a/web/src/components/settings/OtherSetting.jsx b/web/src/components/settings/OtherSetting.jsx
index 18119d2427db..46d22b696a26 100644
--- a/web/src/components/settings/OtherSetting.jsx
+++ b/web/src/components/settings/OtherSetting.jsx
@@ -106,6 +106,11 @@ const OtherSetting = () => {
SystemName: true,
}));
await updateOption('SystemName', inputs.SystemName);
+ // 更新localStorage并触发title更新事件
+ localStorage.setItem('system_name', inputs.SystemName);
+ window.dispatchEvent(new CustomEvent('systemNameUpdated', {
+ detail: { systemName: inputs.SystemName }
+ }));
showSuccess(t('系统名称已更新'));
} catch (error) {
console.error(t('系统名称更新失败'), error);
diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx
index 3ba8dcfd33d2..92c294834592 100644
--- a/web/src/components/settings/PersonalSetting.jsx
+++ b/web/src/components/settings/PersonalSetting.jsx
@@ -23,6 +23,7 @@ import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
import { UserContext } from '../../context/User';
import { Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
+import { getUserData as fetchUserData } from '../../helpers/userDataManager';
// 导入子组件
import UserInfoHeader from './personal/components/UserInfoHeader';
@@ -80,8 +81,9 @@ const PersonalSetting = () => {
setTurnstileSiteKey(status.turnstile_site_key);
}
}
- getUserData().then((res) => {
- console.log(userState);
+
+ getUserData().then(() => {
+ console.log('用户数据已加载');
});
}, []);
@@ -132,12 +134,17 @@ const PersonalSetting = () => {
};
const getUserData = async () => {
- let res = await API.get(`/api/user/self`);
- const { success, message, data } = res.data;
- if (success) {
- userDispatch({ type: 'login', payload: data });
- } else {
- showError(message);
+ try {
+ // 使用用户数据获取(包括头像)
+ const result = await fetchUserData();
+ if (result.success) {
+ userDispatch({ type: 'login', payload: result.data });
+ } else {
+ showError(result.message);
+ }
+ } catch (error) {
+ console.error('获取用户数据失败:', error);
+ showError('获取用户数据失败');
}
};
@@ -302,12 +309,14 @@ const PersonalSetting = () => {
}
};
+
+
return (
{/* 顶部用户信息区域 */}
-
+
{/* 账户管理和其他设置 */}
diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx
index 0b097eaff232..61e4341cce8f 100644
--- a/web/src/components/settings/personal/cards/NotificationSettings.jsx
+++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx
@@ -44,6 +44,7 @@ import CodeViewer from '../../../playground/CodeViewer';
import { StatusContext } from '../../../../context/Status';
import { UserContext } from '../../../../context/User';
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
+import { useSidebar } from '../../../../hooks/common/useSidebar';
const NotificationSettings = ({
t,
@@ -52,8 +53,6 @@ const NotificationSettings = ({
saveNotificationSettings,
}) => {
const formApiRef = useRef(null);
- const [statusState] = useContext(StatusContext);
- const [userState] = useContext(UserContext);
// 左侧边栏设置相关状态
const [sidebarLoading, setSidebarLoading] = useState(false);
@@ -97,6 +96,9 @@ const NotificationSettings = ({
isSidebarModuleAllowed,
} = useUserPermissions();
+ // 使用useSidebar钩子获取刷新方法
+ const { refreshUserConfig } = useSidebar();
+
// 左侧边栏设置处理函数
const handleSectionChange = (sectionKey) => {
return (checked) => {
@@ -132,6 +134,9 @@ const NotificationSettings = ({
});
if (res.data.success) {
showSuccess(t('侧边栏设置保存成功'));
+
+ // 刷新useSidebar钩子中的用户配置,实现实时更新
+ await refreshUserConfig();
} else {
showError(res.data.message);
}
@@ -165,29 +170,85 @@ const NotificationSettings = ({
setSidebarModulesUser(defaultConfig);
};
+ // 获取默认系统配置
+ const getDefaultSystemConfig = () => {
+ return {
+ chat: {
+ enabled: true,
+ playground: true,
+ chat: true
+ },
+ console: {
+ enabled: true,
+ detail: true,
+ token: true,
+ log: true,
+ midjourney: true,
+ task: true
+ },
+ personal: {
+ enabled: true,
+ topup: true,
+ personal: true
+ },
+ admin: {
+ enabled: true,
+ channel: true,
+ models: true,
+ redemption: true,
+ user: true,
+ setting: true
+ }
+ };
+ };
+
// 加载左侧边栏配置
useEffect(() => {
const loadSidebarConfigs = async () => {
try {
- // 获取管理员全局配置
- if (statusState?.status?.SidebarModulesAdmin) {
- const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin);
- setAdminConfig(adminConf);
- }
-
- // 获取用户个人配置
+ // 获取侧边栏配置
const userRes = await API.get('/api/user/self');
- if (userRes.data.success && userRes.data.data.sidebar_modules) {
- const userConf = JSON.parse(userRes.data.data.sidebar_modules);
- setSidebarModulesUser(userConf);
+ if (userRes.data.success) {
+ // 从setting字段中获取系统配置和用户偏好设置
+ if (userRes.data.data.setting) {
+ try {
+ const setting = JSON.parse(userRes.data.data.setting);
+
+ // 获取系统配置(用于权限检查)
+ const systemConfig = setting.sidebar_system_config ?
+ JSON.parse(setting.sidebar_system_config) :
+ getDefaultSystemConfig();
+ setAdminConfig(systemConfig);
+
+ // 获取用户偏好设置(用于显示当前状态)
+ if (setting.sidebar_modules) {
+ let userConf;
+ if (typeof setting.sidebar_modules === 'string') {
+ userConf = JSON.parse(setting.sidebar_modules);
+ } else {
+ userConf = setting.sidebar_modules;
+ }
+ setSidebarModulesUser(userConf);
+ }
+ } catch (error) {
+ console.error('解析用户设置失败:', error);
+ // 出错时使用默认配置
+ setAdminConfig(getDefaultSystemConfig());
+ }
+ } else {
+ // 没有setting时使用默认配置
+ setAdminConfig(getDefaultSystemConfig());
+ }
}
} catch (error) {
console.error('加载边栏配置失败:', error);
+ // 出错时使用默认配置
+ setAdminConfig(getDefaultSystemConfig());
}
};
loadSidebarConfigs();
- }, [statusState]);
+ }, []);
// 初始化表单值
useEffect(() => {
@@ -334,7 +395,7 @@ const NotificationSettings = ({
loading={sidebarLoading}
className='!rounded-lg'
>
- {t('保存边栏设置')}
+ {t('保存设置')}
>
) : (
@@ -664,7 +725,7 @@ const NotificationSettings = ({
color: 'var(--semi-color-text-2)',
}}
>
- {t('您可以个性化设置侧边栏的要显示功能')}
+ {t('您可以个性化设置侧边栏要显示的功能')}
{/* 边栏设置功能区域容器 */}
diff --git a/web/src/components/settings/personal/components/UserInfoHeader.jsx b/web/src/components/settings/personal/components/UserInfoHeader.jsx
index 3209a17ee84a..81954b97f877 100644
--- a/web/src/components/settings/personal/components/UserInfoHeader.jsx
+++ b/web/src/components/settings/personal/components/UserInfoHeader.jsx
@@ -17,7 +17,7 @@ along with this program. If not, see
.
For commercial licensing, please contact support@quantumnous.com
*/
-import React from 'react';
+import React, { useState } from 'react';
import {
Avatar,
Card,
@@ -25,7 +25,11 @@ import {
Divider,
Typography,
Badge,
+ Upload,
+ Toast,
+ Modal,
} from '@douyinfe/semi-ui';
+import { IconCamera } from '@douyinfe/semi-icons';
import {
isRoot,
isAdmin,
@@ -33,8 +37,30 @@ import {
stringToColor,
} from '../../../../helpers';
import { Coins, BarChart2, Users } from 'lucide-react';
+import { updateUserAvatar } from '../../../../helpers/userDataManager';
+
+// 添加样式确保头像上传的点击热区是圆形
+const avatarUploadStyle = `
+.avatar-upload .semi-upload-add {
+ border-radius: 50% !important;
+}
+`;
+
+// 注入样式
+if (typeof document !== 'undefined') {
+ const styleElement = document.createElement('style');
+ styleElement.textContent = avatarUploadStyle;
+ if (!document.head.querySelector('style[data-avatar-upload]')) {
+ styleElement.setAttribute('data-avatar-upload', 'true');
+ document.head.appendChild(styleElement);
+ }
+}
+
+const UserInfoHeader = ({ t, userState, onUserDataUpdate }) => {
+ const [previewVisible, setPreviewVisible] = useState(false);
+ const [previewImage, setPreviewImage] = useState('');
+ const [uploading, setUploading] = useState(false);
-const UserInfoHeader = ({ t, userState }) => {
const getUsername = () => {
if (userState.user) {
return userState.user.username;
@@ -51,6 +77,127 @@ const UserInfoHeader = ({ t, userState }) => {
return 'NA';
};
+ // 将文件转换为base64
+ const fileToBase64 = (file) => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = (error) => reject(error);
+ });
+ };
+
+ // 上传前验证
+ const beforeUpload = ({ file }) => {
+ console.log('beforeUpload file:', file); // 调试日志
+
+ // 获取文件类型,可能在 file.type 或 file.fileInstance.type 中
+ const fileType = file.type || (file.fileInstance && file.fileInstance.type) || '';
+ const fileSize = file.size || (file.fileInstance && file.fileInstance.size) || 0;
+
+ const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
+ if (!allowedTypes.includes(fileType.toLowerCase())) {
+ console.log('File type validation failed:', fileType); // 调试日志
+ Toast.error(t('不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP'));
+ return {
+ autoRemove: true,
+ shouldUpload: false,
+ status: 'validateFail',
+ validateMessage: t('不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP')
+ };
+ }
+
+ // 考虑base64编码会增加约33%的大小,所以原始文件限制为1.5MB
+ const maxSize = 1.5 * 1024 * 1024; // 1.5MB
+ if (fileSize > maxSize) {
+ Toast.error(t('图片文件大小不能超过1.5MB'));
+ return {
+ autoRemove: true,
+ shouldUpload: false,
+ status: 'validateFail',
+ validateMessage: t('图片文件大小不能超过1.5MB')
+ };
+ }
+
+ return {
+ shouldUpload: false, // 不直接上传,而是先预览
+ status: 'success'
+ };
+ };
+
+ // 处理文件选择
+ const handleFileChange = async ({ currentFile }) => {
+ console.log('handleFileChange currentFile:', currentFile); // 调试日志
+
+ if (currentFile && currentFile.fileInstance) {
+ // 再次验证文件类型(客户端验证)
+ const fileType = currentFile.fileInstance.type;
+ const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
+
+ if (!allowedTypes.includes(fileType.toLowerCase())) {
+ Toast.error(t('不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP'));
+ return;
+ }
+
+ try {
+ setUploading(true);
+ const base64Data = await fileToBase64(currentFile.fileInstance);
+
+ // 验证base64数据大小(约2MB限制)
+ const base64Size = base64Data.length;
+ const maxBase64Size = 2 * 1024 * 1024; // 2MB
+
+ if (base64Size > maxBase64Size) {
+ Toast.error(t('图片编码后数据过大,请选择更小的图片'));
+ return;
+ }
+
+ setPreviewImage(base64Data);
+ setPreviewVisible(true);
+ } catch (error) {
+ Toast.error(t('图片处理失败,请重试'));
+ console.error('File processing error:', error);
+ } finally {
+ setUploading(false);
+ }
+ }
+ };
+
+ // 确认上传头像
+ const handleConfirmUpload = async () => {
+ if (!previewImage) return;
+
+ try {
+ setUploading(true);
+
+ // 使用新的用户数据管理器上传头像
+ const result = await updateUserAvatar(previewImage, {
+ id: userState.user?.id,
+ username: userState.user?.username || '',
+ display_name: userState.user?.display_name || '',
+ });
+
+ if (result.success) {
+ setPreviewVisible(false);
+ setPreviewImage('');
+ Toast.success(t('头像更新成功'));
+ // 刷新用户数据
+ if (onUserDataUpdate) {
+ await onUserDataUpdate();
+ }
+ } else {
+ Toast.error(result.message || t('头像更新失败,请重试'));
+ }
+ } catch (error) {
+ Toast.error(t('头像更新失败,请重试'));
+ console.error('Avatar update error:', error);
+ } finally {
+ setUploading(false);
+ }
+ };
+
+
+
return (
{
-
- {getAvatarText()}
-
+
{
+ Toast.error(t('图片文件大小不能超过1.5MB'));
+ }}
+ onAcceptInvalid={() => {
+ Toast.error(t('不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP'));
+ }}
+ disabled={uploading}
+ >
+
+
+
+ }
+ style={{ cursor: 'pointer' }}
+ >
+ {!userState.user?.avatar && getAvatarText()}
+
+
{
{t('用户分组')}
- {userState?.user?.group || t('默认')}
+ {userState?.user?.group === 'default' ? t('默认') : (userState?.user?.group || t('默认'))}
@@ -207,12 +388,38 @@ const UserInfoHeader = ({ t, userState }) => {
- {userState?.user?.group || t('默认')}
+ {userState?.user?.group === 'default' ? t('默认') : (userState?.user?.group || t('默认'))}
+
+ {/* 头像预览模态框 */}
+
{
+ setPreviewVisible(false);
+ setPreviewImage('');
+ }}
+ onOk={handleConfirmUpload}
+ okText={t('确认上传')}
+ cancelText={t('取消')}
+ confirmLoading={uploading}
+ width={400}
+ >
+
+
+
+ {t('确认要使用这张图片作为头像吗?')}
+
+
+
);
};
diff --git a/web/src/components/table/users/UsersActions.jsx b/web/src/components/table/users/UsersActions.jsx
index c3f2602a2596..2241ea8d06b2 100644
--- a/web/src/components/table/users/UsersActions.jsx
+++ b/web/src/components/table/users/UsersActions.jsx
@@ -17,21 +17,90 @@ along with this program. If not, see
.
For commercial licensing, please contact support@quantumnous.com
*/
-import React from 'react';
+import { useState } from 'react';
import { Button } from '@douyinfe/semi-ui';
+import UserGroupManagement from './modals/UserGroupManagement';
+import { useSidebar } from '../../../hooks/common/useSidebar';
+
+const UsersActions = ({ setShowAddUser, onRefreshUsers, t }) => {
+ const [showGroupManagement, setShowGroupManagement] = useState(false);
+ const { finalConfig, loading: sidebarLoading } = useSidebar();
+
+ // 检查用户权限
+ const getUserRole = () => {
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
+ return user?.role || 0;
+ };
+
+ const isAdmin = () => getUserRole() >= 10;
+ const isRoot = () => getUserRole() >= 100;
+
+ // 检查分组管理功能是否可见
+ const canShowGroupManagement = () => {
+ // 如果侧边栏配置还在加载中,暂时不显示按钮
+ if (sidebarLoading) {
+ return false;
+ }
+
+ // 超级管理员始终可以看到分组管理按钮
+ if (isRoot()) {
+ return true;
+ }
+
+ // 管理员需要检查系统设置中的分组管理开关
+ if (isAdmin()) {
+ // 从useSidebar钩子获取最终的权限配置
+ const userSection = finalConfig?.admin?.user;
+
+ // 检查用户管理模块是否启用
+ if (!userSection || userSection.enabled === false) {
+ return false;
+ }
+
+ // 检查分组管理子功能是否启用
+ return userSection.groupManagement === true;
+ }
+
+ // 普通用户无权访问
+ return false;
+ };
-const UsersActions = ({ setShowAddUser, t }) => {
// Add new user
const handleAddUser = () => {
setShowAddUser(true);
};
+ // Show group management
+ const handleGroupManagement = () => {
+ setShowGroupManagement(true);
+ };
+
return (
-
-
-
+ <>
+
+
+ {canShowGroupManagement() && (
+
+ )}
+
+
+ {canShowGroupManagement() && (
+
setShowGroupManagement(false)}
+ onGroupUpdated={onRefreshUsers}
+ />
+ )}
+ >
);
};
diff --git a/web/src/components/table/users/index.jsx b/web/src/components/table/users/index.jsx
index 59e12a4e5fc0..0f8fc08a9752 100644
--- a/web/src/components/table/users/index.jsx
+++ b/web/src/components/table/users/index.jsx
@@ -88,7 +88,11 @@ const UsersPage = () => {
}
actionsArea={
-
+
.
For commercial licensing, please contact support@quantumnous.com
*/
-import React, { useState, useRef } from 'react';
+import React, { useState, useRef, useEffect } from 'react';
import { API, showError, showSuccess } from '../../../../helpers';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
@@ -42,15 +42,34 @@ const AddUserModal = (props) => {
const { t } = useTranslation();
const formApiRef = useRef(null);
const [loading, setLoading] = useState(false);
+ const [groupOptions, setGroupOptions] = useState([]);
const isMobile = useIsMobile();
const getInitValues = () => ({
username: '',
display_name: '',
password: '',
+ group: 'default', // 默认分组
remark: '',
});
+ // 获取分组列表
+ const fetchGroups = async () => {
+ try {
+ const res = await API.get('/api/group/');
+ if (res.data.success) {
+ setGroupOptions(
+ res.data.data.map((group) => ({
+ label: group,
+ value: group,
+ }))
+ );
+ }
+ } catch (error) {
+ showError(t('获取分组列表失败'));
+ }
+ };
+
const submit = async (values) => {
setLoading(true);
const res = await API.post(`/api/user/`, values);
@@ -70,6 +89,13 @@ const AddUserModal = (props) => {
props.handleClose();
};
+ // 组件加载时获取分组列表
+ useEffect(() => {
+ if (props.visible) {
+ fetchGroups();
+ }
+ }, [props.visible]);
+
return (
<>
{
showClear
/>
+
+
+
.
+
+For commercial licensing, please contact support@quantumnous.com
++*/
+
+import React, { useState, useRef, useEffect } from 'react';
+import {
+ Modal,
+ Form,
+ Button,
+ Space,
+ Spin,
+ Avatar,
+ Typography,
+ Tag,
+} from '@douyinfe/semi-ui';
+import {
+ IconSave,
+ IconClose,
+ IconUserGroup,
+} from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+import { API, showError, showSuccess } from '../../../../helpers';
+import { useSidebar } from '../../../../hooks/common/useSidebar';
+
+const EditUserGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => {
+ const { t } = useTranslation();
+ const formApiRef = useRef(null);
+ const [loading, setLoading] = useState(false);
+ const { finalConfig, loading: sidebarLoading } = useSidebar();
+
+ // 检查用户权限
+ const getUserRole = () => {
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
+ return user?.role || 0;
+ };
+
+ const isRoot = () => getUserRole() >= 100;
+ const isAdmin = () => getUserRole() >= 10;
+
+ // 检查是否有分组管理权限
+ const hasGroupManagementPermission = () => {
+ // 如果侧边栏配置还在加载中,暂时拒绝访问
+ if (sidebarLoading) {
+ return false;
+ }
+
+ // 超级管理员始终有权限
+ if (isRoot()) {
+ return true;
+ }
+
+ // 管理员需要检查权限配置
+ if (isAdmin()) {
+ // 从useSidebar钩子获取最终的权限配置
+ const userSection = finalConfig?.admin?.user;
+
+ // 检查用户管理模块是否启用
+ if (!userSection || userSection.enabled === false) {
+ return false;
+ }
+
+ // 检查分组管理子功能是否启用
+ return userSection.groupManagement === true;
+ }
+
+ // 普通用户无权访问
+ return false;
+ };
+
+ const isEdit = editingGroup && editingGroup.id;
+ const isSystemGroup = editingGroup && (
+ editingGroup.name === 'default' ||
+ editingGroup.name === 'vip' ||
+ editingGroup.name === 'svip'
+ );
+
+ // 获取分组显示名称
+ const getGroupDisplayName = (groupName) => {
+ if (groupName === 'default') {
+ return t('默认');
+ }
+ return groupName;
+ };
+
+ const getInitValues = () => ({
+ name: editingGroup?.name || '',
+ description: editingGroup?.description || '',
+ ratio: editingGroup?.ratio ?? 1.0,
+ });
+
+ const submit = async (values) => {
+ // 检查权限
+ if (!hasGroupManagementPermission()) {
+ showError(t('无权访问分组管理功能'));
+ onClose();
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const ratioNum =
+ typeof values.ratio === 'number' ? values.ratio : parseFloat(values.ratio);
+ const data = {
+ ...values,
+ ratio: Number.isFinite(ratioNum) ? ratioNum : 1.0,
+ };
+
+ if (isEdit) {
+ data.id = editingGroup.id;
+ }
+
+ const url = isEdit ? '/api/user_group' : '/api/user_group';
+ const method = isEdit ? 'PUT' : 'POST';
+
+ const res = await API[method.toLowerCase()](url, data);
+ const { success, message } = res.data;
+
+ if (success) {
+ showSuccess(isEdit ? t('分组更新成功!') : t('分组创建成功!'));
+ onSuccess();
+ } else {
+ showError(message);
+ }
+ } catch (error) {
+ if (error.response?.status === 403) {
+ showError(t('无权访问分组管理功能'));
+ onClose();
+ } else {
+ showError(isEdit ? t('分组更新失败') : t('分组创建失败'));
+ }
+ }
+ setLoading(false);
+ };
+
+ const handleCancel = () => {
+ onClose();
+ };
+
+ // 重置表单当编辑分组改变时
+ useEffect(() => {
+ if (visible && formApiRef.current) {
+ formApiRef.current.setValues(getInitValues());
+ }
+ }, [visible, editingGroup]);
+
+ return (
+
+
+
+
+
+ {isEdit ? t('编辑分组') : t('新建分组')}
+
+ {isEdit && (
+
+ {getGroupDisplayName(editingGroup.name)}
+
+ )}
+
+ }
+ visible={visible}
+ onCancel={handleCancel}
+ width={500}
+ footer={
+
+
+
+ }
+ >
+ {t('取消')}
+
+
+
+ }
+ closeIcon={null}
+ >
+
+
+
+
+ );
+};
+
+export default EditUserGroupModal;
diff --git a/web/src/components/table/users/modals/UserGroupManagement.jsx b/web/src/components/table/users/modals/UserGroupManagement.jsx
new file mode 100644
index 000000000000..b2fbc564a9b0
--- /dev/null
+++ b/web/src/components/table/users/modals/UserGroupManagement.jsx
@@ -0,0 +1,378 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
++*/
+
+import React, { useState, useEffect } from 'react';
+import {
+ SideSheet,
+ Button,
+ Space,
+ Card,
+ Avatar,
+ Typography,
+ Spin,
+ Empty,
+ Popconfirm,
+ Tag,
+} from '@douyinfe/semi-ui';
+import {
+ IconUserGroup,
+ IconPlus,
+} from '@douyinfe/semi-icons';
+import {
+ IllustrationNoResult,
+ IllustrationNoResultDark,
+} from '@douyinfe/semi-illustrations';
+import { useTranslation } from 'react-i18next';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+import { API, showError, showSuccess } from '../../../../helpers';
+import CardTable from '../../../common/ui/CardTable';
+import EditUserGroupModal from './EditUserGroupModal';
+import { useSidebar } from '../../../../hooks/common/useSidebar';
+
+const UserGroupManagement = ({ visible, onClose, onGroupUpdated }) => {
+ const { t } = useTranslation();
+ const isMobile = useIsMobile();
+ const [loading, setLoading] = useState(false);
+ const [groups, setGroups] = useState([]);
+ const [showEdit, setShowEdit] = useState(false);
+ const [editingGroup, setEditingGroup] = useState({ id: undefined });
+ const { finalConfig, loading: sidebarLoading } = useSidebar();
+
+ // 检查用户权限
+ const getUserRole = () => {
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
+ return user?.role || 0;
+ };
+
+ const isRoot = () => getUserRole() >= 100;
+ const isAdmin = () => getUserRole() >= 10;
+
+ // 检查是否有分组管理权限
+ const hasGroupManagementPermission = () => {
+ // 如果侧边栏配置还在加载中,返回null表示未判定
+ if (sidebarLoading) {
+ return null;
+ }
+
+ // 超级管理员始终有权限
+ if (isRoot()) {
+ return true;
+ }
+
+ // 管理员需要检查权限配置
+ if (isAdmin()) {
+ // 从useSidebar钩子获取最终的权限配置
+ const userSection = finalConfig?.admin?.user;
+
+ // 检查用户管理模块是否启用
+ if (!userSection || userSection.enabled === false) {
+ return false;
+ }
+
+ // 检查分组管理子功能是否启用
+ return userSection.groupManagement === true;
+ }
+
+ // 普通用户无权访问
+ return false;
+ };
+
+ // 加载分组列表
+ const loadGroups = async () => {
+ // 检查权限
+ const perm = hasGroupManagementPermission();
+ if (perm === null) {
+ return; // 等待权限加载完成后再拉取
+ }
+ if (perm === false) {
+ showError(t('无权访问分组管理功能'));
+ onClose();
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const res = await API.get('/api/user_group');
+ if (res.data.success) {
+ setGroups(res.data.data || []);
+ } else {
+ showError(res.data.message || t('获取分组列表失败'));
+ // 如果是权限错误,关闭模态框
+ if (res.status === 403) {
+ onClose();
+ }
+ }
+ } catch (error) {
+ showError(t('获取分组列表失败'));
+ // 如果是权限错误,关闭模态框
+ if (error.response?.status === 403) {
+ onClose();
+ }
+ }
+ setLoading(false);
+ };
+
+ // 删除分组
+ const deleteGroup = async (id) => {
+ // 检查权限
+ if (!hasGroupManagementPermission()) {
+ showError(t('无权访问分组管理功能'));
+ return;
+ }
+
+ try {
+ const res = await API.delete(`/api/user_group/${id}`);
+ if (res.data.success) {
+ showSuccess(t('删除成功'));
+ loadGroups();
+ } else {
+ showError(res.data.message || t('删除失败'));
+ }
+ } catch (error) {
+ if (error.response?.status === 403) {
+ showError(t('无权访问分组管理功能'));
+ onClose();
+ } else {
+ showError(t('删除失败'));
+ }
+ }
+ };
+
+ // 编辑分组
+ const handleEdit = (group = {}) => {
+ // 检查权限
+ const perm = hasGroupManagementPermission();
+ if (perm === null) return; // 等待权限加载
+ if (perm === false) {
+ showError(t('无权访问分组管理功能'));
+ return;
+ }
+
+ setEditingGroup(group);
+ setShowEdit(true);
+ };
+
+ // 关闭编辑
+ const closeEdit = () => {
+ setShowEdit(false);
+ setTimeout(() => {
+ setEditingGroup({ id: undefined });
+ }, 300);
+ };
+
+ // 编辑成功回调
+ const handleEditSuccess = () => {
+ closeEdit();
+ loadGroups();
+ // 通知父组件刷新用户数据
+ if (onGroupUpdated) {
+ onGroupUpdated();
+ }
+ };
+
+ // 获取分组显示名称
+ const getGroupDisplayName = (groupName) => {
+ if (groupName === 'default') {
+ return t('默认');
+ }
+ return groupName;
+ };
+
+ // 获取分组描述的翻译
+ const getGroupDescription = (groupName, originalDescription) => {
+ // 对于系统默认分组,使用翻译
+ if (groupName === 'default' && originalDescription === '默认分组') {
+ return t('默认分组');
+ }
+ if (groupName === 'vip' && originalDescription === 'VIP分组') {
+ return t('VIP分组');
+ }
+ if (groupName === 'svip' && originalDescription === 'SVIP分组') {
+ return t('SVIP分组');
+ }
+ // 对于用户自定义分组,使用原始描述
+ return originalDescription;
+ };
+
+ // 表格列定义
+ const columns = [
+ {
+ title: 'ID',
+ dataIndex: 'id',
+ width: 80,
+ },
+ {
+ title: t('分组名称'),
+ dataIndex: 'name',
+ render: (text, record) => (
+
+
+ {getGroupDisplayName(text)}
+
+ {(record.name === 'default' || record.name === 'vip' || record.name === 'svip') && (
+
+ {t('系统默认')}
+
+ )}
+
+ ),
+ },
+ {
+ title: t('分组描述'),
+ dataIndex: 'description',
+ render: (text, record) => {
+ const translatedDescription = getGroupDescription(record.name, text);
+ return translatedDescription || {t('无描述')};
+ },
+ },
+ {
+ title: t('分组倍率'),
+ dataIndex: 'ratio',
+ width: 100,
+ render: (text) => (
+
+ {text}
+
+ ),
+ },
+ {
+ title: t('创建时间'),
+ dataIndex: 'created_time',
+ width: 150,
+ render: (text) => new Date(text * 1000).toLocaleString(),
+ },
+ {
+ title: '',
+ key: 'action',
+ fixed: 'right',
+ width: 140,
+ render: (_, record) => (
+
+
+ {record.name !== 'default' && record.name !== 'vip' && record.name !== 'svip' && (
+ deleteGroup(record.id)}
+ >
+
+
+ )}
+
+ ),
+ },
+ ];
+
+ useEffect(() => {
+ if (visible && !sidebarLoading) {
+ loadGroups();
+ }
+ }, [visible, sidebarLoading]);
+
+ return (
+ <>
+
+
+
+
+ {t('用户分组管理')}
+
+ }
+ visible={visible}
+ onCancel={onClose}
+ width={isMobile ? '100%' : 1000}
+ bodyStyle={{ padding: '0' }}
+ closeIcon={null}
+ >
+
+
+
+
+
+
+
+
+
{t('分组列表')}
+
+ {t('管理用户分组,设置分组倍率')}
+
+
+
+
+ }
+ onClick={() => handleEdit()}
+ >
+ {t('新建分组')}
+
+
+ {groups.length > 0 ? (
+
+ ) : (
+
+ }
+ darkModeImage={
+
+ }
+ description={t('暂无用户分组')}
+ style={{ padding: 30 }}
+ />
+ )}
+
+
+
+
+
+ {/* 编辑组件 */}
+
+ >
+ );
+};
+
+export default UserGroupManagement;
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx
index 929a47e39b04..32d7dfae4bf1 100644
--- a/web/src/components/topup/index.jsx
+++ b/web/src/components/topup/index.jsx
@@ -60,6 +60,8 @@ const TopUp = () => {
const [enableStripeTopUp, setEnableStripeTopUp] = useState(
statusState?.status?.enable_stripe_topup || false,
);
+ const [invitationEnabled, setInvitationEnabled] = useState(false); // 初始为false,避免闪烁
+ const [invitationConfigLoaded, setInvitationConfigLoaded] = useState(false); // 添加配置加载状态
const [statusLoading, setStatusLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -359,6 +361,44 @@ const TopUp = () => {
}
};
+ // 获取邀请功能配置状态
+ const getInvitationConfig = async () => {
+ try {
+ const res = await API.get('/api/status');
+ const { success, data } = res.data;
+ if (success) {
+ //console.log('从status接口获取到的邀请功能配置:', data.invitation_enabled);
+ const enabled = data.invitation_enabled === true;
+ //console.log('邀请功能状态:', enabled);
+ setInvitationEnabled(enabled);
+ setInvitationConfigLoaded(true);
+ // 只有在邀请功能启用时才获取邀请链接
+ if (enabled && !affFetchedRef.current) {
+ affFetchedRef.current = true;
+ getAffLink();
+ }
+ } else {
+ // API调用失败,使用后端默认值(true)
+ //console.log('status接口调用失败,使用默认值true');
+ setInvitationEnabled(true);
+ setInvitationConfigLoaded(true);
+ if (!affFetchedRef.current) {
+ affFetchedRef.current = true;
+ getAffLink();
+ }
+ }
+ } catch (error) {
+ //console.error('获取邀请功能配置失败:', error);
+ // 出错时使用后端默认值(true)
+ setInvitationEnabled(true);
+ setInvitationConfigLoaded(true);
+ if (!affFetchedRef.current) {
+ affFetchedRef.current = true;
+ getAffLink();
+ }
+ }
+ };
+
// 划转邀请额度
const transfer = async () => {
if (transferAmount < getQuotaPerUnit()) {
@@ -389,18 +429,55 @@ const TopUp = () => {
getUserQuota().then();
}
setTransferAmount(getQuotaPerUnit());
- }, []);
+ getInvitationConfig().then();
- useEffect(() => {
- if (affFetchedRef.current) return;
- affFetchedRef.current = true;
- getAffLink().then();
- }, []);
+ let payMethods = localStorage.getItem('pay_methods');
+ try {
+ payMethods = JSON.parse(payMethods);
+ if (payMethods && payMethods.length > 0) {
+ // 检查name和type是否为空
+ payMethods = payMethods.filter((method) => {
+ return method.name && method.type;
+ });
+ // 如果没有color,则设置默认颜色
+ payMethods = payMethods.map((method) => {
+ if (!method.color) {
+ if (method.type === 'alipay') {
+ method.color = 'rgba(var(--semi-blue-5), 1)';
+ } else if (method.type === 'wxpay') {
+ method.color = 'rgba(var(--semi-green-5), 1)';
+ } else if (method.type === 'stripe') {
+ method.color = 'rgba(var(--semi-purple-5), 1)';
+ } else {
+ method.color = 'rgba(var(--semi-primary-5), 1)';
+ }
+ }
+ return method;
+ });
+ } else {
+ payMethods = [];
+ }
- // 在 statusState 可用时获取充值信息
- useEffect(() => {
- getTopupInfo().then();
- }, []);
+ // 如果启用了 Stripe 支付,添加到支付方法列表
+ if (statusState?.status?.enable_stripe_topup) {
+ const hasStripe = payMethods.some((method) => method.type === 'stripe');
+ if (!hasStripe) {
+ payMethods.push({
+ name: 'Stripe',
+ type: 'stripe',
+ color: 'rgba(var(--semi-purple-5), 1)',
+ });
+ }
+ }
+
+ setPayMethods(payMethods);
+ } catch (e) {
+ console.log(e);
+ showError(t('支付方式配置错误, 请联系管理员'));
+ }
+ }, [statusState?.status?.enable_stripe_topup]);
+
+ // 移除独立的getAffLink调用,现在由getInvitationConfig统一处理
useEffect(() => {
if (statusState?.status) {
@@ -537,9 +614,9 @@ const TopUp = () => {
{/* 用户信息头部 */}
-
+
{/* 左侧充值区域 */}
-
+
{
/>
- {/* 右侧信息区域 */}
-
-
-
+ {/* 右侧信息区域 - 仅在配置加载完成且邀请功能启用时显示 */}
+ {invitationConfigLoaded && invitationEnabled && (
+
+
+
+ )}
diff --git a/web/src/constants/user.constants.js b/web/src/constants/user.constants.js
index 05d3e1fa6e52..4cf1f7a51f5e 100644
--- a/web/src/constants/user.constants.js
+++ b/web/src/constants/user.constants.js
@@ -36,3 +36,62 @@ export const userConstants = {
DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
DELETE_FAILURE: 'USERS_DELETE_FAILURE',
};
+
+/**
+ * 用户角色常量 - 与后端保持一致
+ * 对应后端 common/constants.go 中的角色定义
+ */
+export const USER_ROLES = {
+ GUEST: 0, // RoleGuestUser
+ COMMON: 1, // RoleCommonUser
+ ADMIN: 10, // RoleAdminUser
+ ROOT: 100, // RoleRootUser
+};
+
+/**
+ * 检查用户是否为管理员(包括超级管理员)
+ * @param {number} role - 用户角色
+ * @returns {boolean}
+ */
+export const isAdmin = (role) => {
+ return role === USER_ROLES.ADMIN || role === USER_ROLES.ROOT;
+};
+
+/**
+ * 检查用户是否为超级管理员
+ * @param {number} role - 用户角色
+ * @returns {boolean}
+ */
+export const isRoot = (role) => {
+ return role === USER_ROLES.ROOT;
+};
+
+/**
+ * 检查用户是否为普通用户
+ * @param {number} role - 用户角色
+ * @returns {boolean}
+ */
+export const isCommonUser = (role) => {
+ return role === USER_ROLES.COMMON;
+};
+
+/**
+ * 获取角色显示名称
+ * @param {number} role - 用户角色
+ * @param {function} t - 翻译函数
+ * @returns {string}
+ */
+export const getRoleDisplayName = (role, t) => {
+ switch (role) {
+ case USER_ROLES.COMMON:
+ return t('普通用户');
+ case USER_ROLES.ADMIN:
+ return t('管理员');
+ case USER_ROLES.ROOT:
+ return t('超级管理员');
+ case USER_ROLES.GUEST:
+ return t('访客');
+ default:
+ return t('未知身份');
+ }
+};
diff --git a/web/src/context/User/reducer.js b/web/src/context/User/reducer.js
index 80275e1fa926..2e8f2db8af64 100644
--- a/web/src/context/User/reducer.js
+++ b/web/src/context/User/reducer.js
@@ -20,11 +20,18 @@ For commercial licensing, please contact support@quantumnous.com
export const reducer = (state, action) => {
switch (action.type) {
case 'login':
+ // 用户登录时,头像数据通过独立端点获取和缓存
return {
...state,
user: action.payload,
};
case 'logout':
+ // 当用户登出时,清理头像缓存和会话标记
+ if (state.user?.id) {
+ import('../../helpers/userDataManager').then(({ cleanupOnLogout }) => {
+ cleanupOnLogout(state.user.id);
+ });
+ }
return {
...state,
user: undefined,
diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js
index b7092fe775e2..84ab6c4e02ac 100644
--- a/web/src/helpers/api.js
+++ b/web/src/helpers/api.js
@@ -25,6 +25,7 @@ import {
} from './utils';
import axios from 'axios';
import { MESSAGE_ROLES } from '../constants/playground.constants';
+import i18next from 'i18next';
export let API = axios.create({
baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
@@ -185,15 +186,34 @@ export const processModelsData = (data, currentModel) => {
return { modelOptions, selectedModel };
};
+// 获取分组描述的翻译
+const getGroupDescription = (groupName, originalDescription) => {
+ // 对于系统默认分组,使用翻译
+ if (groupName === 'default' && originalDescription === '默认分组') {
+ return i18next.t('默认分组');
+ }
+ if (groupName === 'vip' && originalDescription === 'VIP分组') {
+ return i18next.t('VIP分组');
+ }
+ if (groupName === 'svip' && originalDescription === 'SVIP分组') {
+ return i18next.t('SVIP分组');
+ }
+ // 对于用户自定义分组,使用原始描述
+ return originalDescription;
+};
+
// 处理分组数据
export const processGroupsData = (data, userGroup) => {
- let groupOptions = Object.entries(data).map(([group, info]) => ({
- label:
- info.desc.length > 20 ? info.desc.substring(0, 20) + '...' : info.desc,
- value: group,
- ratio: info.ratio,
- fullLabel: info.desc,
- }));
+ let groupOptions = Object.entries(data).map(([group, info]) => {
+ const translatedDesc = getGroupDescription(group, info.desc);
+ return {
+ label:
+ translatedDesc.length > 20 ? translatedDesc.substring(0, 20) + '...' : translatedDesc,
+ value: group,
+ ratio: info.ratio,
+ fullLabel: translatedDesc,
+ };
+ });
if (groupOptions.length === 0) {
groupOptions = [
diff --git a/web/src/helpers/avatarCache.js b/web/src/helpers/avatarCache.js
new file mode 100644
index 000000000000..c4af14cea456
--- /dev/null
+++ b/web/src/helpers/avatarCache.js
@@ -0,0 +1,72 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see
.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+/**
+ * 头像缓存管理工具
+ * 核心功能:缓存存储、读取、清理
+ */
+
+const AVATAR_CACHE_KEY = 'user_avatar';
+
+/**
+ * 获取缓存的头像数据
+ * @param {number} userId - 用户ID
+ * @returns {string|null} 头像数据或null
+ */
+export const getCachedAvatar = (userId) => {
+ try {
+ const avatarData = localStorage.getItem(`${AVATAR_CACHE_KEY}_${userId}`);
+ return avatarData || null;
+ } catch (error) {
+ console.error('获取头像缓存失败:', error);
+ return null;
+ }
+};
+
+/**
+ * 缓存头像数据
+ * @param {number} userId - 用户ID
+ * @param {string} avatarData - 头像数据(base64)
+ */
+export const cacheAvatar = (userId, avatarData) => {
+ try {
+ if (!avatarData) {
+ clearAvatarCache(userId);
+ return;
+ }
+
+ localStorage.setItem(`${AVATAR_CACHE_KEY}_${userId}`, avatarData);
+ } catch (error) {
+ console.error('缓存头像失败:', error);
+ }
+};
+
+/**
+ * 清除指定用户的头像缓存
+ * @param {number} userId - 用户ID
+ */
+export const clearAvatarCache = (userId) => {
+ try {
+ localStorage.removeItem(`${AVATAR_CACHE_KEY}_${userId}`);
+ } catch (error) {
+ console.error('清除头像缓存失败:', error);
+ }
+};
+
+
diff --git a/web/src/helpers/data.js b/web/src/helpers/data.js
index b894a953c318..9c938246b9b8 100644
--- a/web/src/helpers/data.js
+++ b/web/src/helpers/data.js
@@ -19,8 +19,18 @@ For commercial licensing, please contact support@quantumnous.com
export function setStatusData(data) {
localStorage.setItem('status', JSON.stringify(data));
- localStorage.setItem('system_name', data.system_name);
localStorage.setItem('logo', data.logo);
+
+ // 同步系统名称并触发事件(支持清空还原默认标题)
+ const name = (data.system_name ?? '').toString().trim();
+ if (name) {
+ localStorage.setItem('system_name', name);
+ } else {
+ localStorage.removeItem('system_name');
+ }
+ window.dispatchEvent(new CustomEvent('systemNameUpdated', {
+ detail: { systemName: name }
+ }));
localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('quota_per_unit', data.quota_per_unit);
localStorage.setItem('display_in_currency', data.display_in_currency);
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index 65332701bbb6..8de251036853 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -629,6 +629,14 @@ export function renderGroup(group) {
premium: 'red',
};
+ // 获取分组显示名称
+ const getGroupDisplayName = (groupName) => {
+ if (groupName === 'default') {
+ return i18next.t('默认');
+ }
+ return groupName;
+ };
+
const groups = group.split(',').sort();
return (
@@ -650,7 +658,7 @@ export function renderGroup(group) {
}
}}
>
- {group}
+ {getGroupDisplayName(group)}
))}
diff --git a/web/src/helpers/userDataManager.js b/web/src/helpers/userDataManager.js
new file mode 100644
index 000000000000..0ae2b43a3bc9
--- /dev/null
+++ b/web/src/helpers/userDataManager.js
@@ -0,0 +1,114 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see
.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { API } from './api';
+import {
+ getCachedAvatar,
+ cacheAvatar,
+ clearAvatarCache
+} from './avatarCache';
+
+/**
+ * 用户数据管理器
+ */
+
+/**
+ * 获取用户数据(包含头像)
+ * @returns {Promise