管理员可在用户管理页面配置「邮箱后缀→初始额度」映射规则, 用户注册时根据邮箱后缀自动匹配并发放对应额度(替代默认额度)。 - 新增 email_quota_rule 数据表 + CRUD API(管理员权限) - 内存缓存匹配,启动时加载,增删改时刷新 - 注册流程 Insert/InsertWithTx/FinalizeOAuthUserCreation 同步支持 - 前端用户管理页新增 Tab 展示规则管理卡片 - 含 24 个测试(model 16 + controller 8) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>master
| @@ -0,0 +1,128 @@ | |||||
| package controller | |||||
| import ( | |||||
| "strconv" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/model" | |||||
| "github.com/gin-gonic/gin" | |||||
| ) | |||||
| func GetAllEmailQuotaRules(c *gin.Context) { | |||||
| list, err := model.GetAllEmailQuotaRules() | |||||
| if err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| common.ApiSuccess(c, list) | |||||
| } | |||||
| type CreateEmailQuotaRuleRequest struct { | |||||
| EmailSuffix string `json:"email_suffix" binding:"required"` | |||||
| Quota int64 `json:"quota" binding:"required"` | |||||
| Enabled *bool `json:"enabled"` | |||||
| Description string `json:"description"` | |||||
| } | |||||
| func CreateEmailQuotaRule(c *gin.Context) { | |||||
| var req CreateEmailQuotaRuleRequest | |||||
| if err := c.ShouldBindJSON(&req); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| existing, _ := model.GetEmailQuotaRuleBySuffix(req.EmailSuffix) | |||||
| if existing != nil { | |||||
| common.ApiErrorMsg(c, "该邮箱后缀已存在") | |||||
| return | |||||
| } | |||||
| enabled := true | |||||
| if req.Enabled != nil { | |||||
| enabled = *req.Enabled | |||||
| } | |||||
| rule := &model.EmailQuotaRule{ | |||||
| EmailSuffix: req.EmailSuffix, | |||||
| Quota: req.Quota, | |||||
| Enabled: enabled, | |||||
| Description: req.Description, | |||||
| } | |||||
| if err := rule.Insert(); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| common.ApiSuccess(c, rule) | |||||
| } | |||||
| type UpdateEmailQuotaRuleRequest struct { | |||||
| EmailSuffix string `json:"email_suffix"` | |||||
| Quota *int64 `json:"quota"` | |||||
| Enabled *bool `json:"enabled"` | |||||
| Description *string `json:"description"` | |||||
| } | |||||
| func UpdateEmailQuotaRule(c *gin.Context) { | |||||
| idStr := c.Param("id") | |||||
| id, err := strconv.Atoi(idStr) | |||||
| if err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| var req UpdateEmailQuotaRuleRequest | |||||
| if err := c.ShouldBindJSON(&req); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| rule, err := model.GetEmailQuotaRuleById(id) | |||||
| if err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| if req.EmailSuffix != "" && req.EmailSuffix != rule.EmailSuffix { | |||||
| existing, _ := model.GetEmailQuotaRuleBySuffix(req.EmailSuffix) | |||||
| if existing != nil && existing.Id != id { | |||||
| common.ApiErrorMsg(c, "该邮箱后缀已存在") | |||||
| return | |||||
| } | |||||
| rule.EmailSuffix = req.EmailSuffix | |||||
| } | |||||
| if req.Quota != nil { | |||||
| rule.Quota = *req.Quota | |||||
| } | |||||
| if req.Enabled != nil { | |||||
| rule.Enabled = *req.Enabled | |||||
| } | |||||
| if req.Description != nil { | |||||
| rule.Description = *req.Description | |||||
| } | |||||
| if err := rule.Update(); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| common.ApiSuccess(c, rule) | |||||
| } | |||||
| func DeleteEmailQuotaRule(c *gin.Context) { | |||||
| idStr := c.Param("id") | |||||
| id, err := strconv.Atoi(idStr) | |||||
| if err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| rule := &model.EmailQuotaRule{Id: id} | |||||
| if err := rule.Delete(); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| common.ApiSuccess(c, nil) | |||||
| } | |||||
| @@ -0,0 +1,264 @@ | |||||
| package controller | |||||
| import ( | |||||
| "bytes" | |||||
| "encoding/json" | |||||
| "net/http" | |||||
| "net/http/httptest" | |||||
| "strconv" | |||||
| "testing" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/model" | |||||
| "github.com/glebarez/sqlite" | |||||
| "github.com/gin-gonic/gin" | |||||
| "github.com/stretchr/testify/assert" | |||||
| "github.com/stretchr/testify/require" | |||||
| "gorm.io/gorm" | |||||
| ) | |||||
| func setupEmailQuotaRuleControllerDB(t *testing.T) *gorm.DB { | |||||
| t.Helper() | |||||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||||
| require.NoError(t, err) | |||||
| sqlDB, _ := db.DB() | |||||
| sqlDB.SetMaxOpenConns(1) | |||||
| origDB := model.DB | |||||
| model.DB = db | |||||
| common.UsingSQLite = true | |||||
| common.RedisEnabled = false | |||||
| require.NoError(t, db.AutoMigrate(&model.EmailQuotaRule{})) | |||||
| t.Cleanup(func() { | |||||
| model.DB = origDB | |||||
| sqlDB.Close() | |||||
| }) | |||||
| return db | |||||
| } | |||||
| func setupEmailQuotaRuleRouter() *gin.Engine { | |||||
| gin.SetMode(gin.TestMode) | |||||
| r := gin.New() | |||||
| g := r.Group("/api/email_quota_rule") | |||||
| { | |||||
| g.GET("/", GetAllEmailQuotaRules) | |||||
| g.POST("/", CreateEmailQuotaRule) | |||||
| g.PUT("/:id", UpdateEmailQuotaRule) | |||||
| g.DELETE("/:id", DeleteEmailQuotaRule) | |||||
| } | |||||
| return r | |||||
| } | |||||
| func TestGetAllEmailQuotaRules_Empty(t *testing.T) { | |||||
| setupEmailQuotaRuleControllerDB(t) | |||||
| router := setupEmailQuotaRuleRouter() | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("GET", "/api/email_quota_rule/", nil) | |||||
| router.ServeHTTP(w, req) | |||||
| assert.Equal(t, http.StatusOK, w.Code) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| assert.True(t, resp["success"].(bool)) | |||||
| assert.Empty(t, resp["data"]) | |||||
| } | |||||
| func TestCreateEmailQuotaRule_Success(t *testing.T) { | |||||
| setupEmailQuotaRuleControllerDB(t) | |||||
| router := setupEmailQuotaRuleRouter() | |||||
| body, _ := json.Marshal(map[string]interface{}{ | |||||
| "email_suffix": "@test.com", | |||||
| "quota": 500000, | |||||
| "description": "Test company", | |||||
| }) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("POST", "/api/email_quota_rule/", bytes.NewReader(body)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| router.ServeHTTP(w, req) | |||||
| assert.Equal(t, http.StatusOK, w.Code) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| assert.True(t, resp["success"].(bool)) | |||||
| data := resp["data"].(map[string]interface{}) | |||||
| assert.Equal(t, "@test.com", data["email_suffix"]) | |||||
| assert.Equal(t, float64(500000), data["quota"]) | |||||
| assert.Equal(t, true, data["enabled"]) | |||||
| } | |||||
| func TestCreateEmailQuotaRule_Duplicate(t *testing.T) { | |||||
| setupEmailQuotaRuleControllerDB(t) | |||||
| router := setupEmailQuotaRuleRouter() | |||||
| body, _ := json.Marshal(map[string]interface{}{ | |||||
| "email_suffix": "@dup.com", | |||||
| "quota": 100, | |||||
| }) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("POST", "/api/email_quota_rule/", bytes.NewReader(body)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| router.ServeHTTP(w, req) | |||||
| var firstResp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &firstResp)) | |||||
| assert.True(t, firstResp["success"].(bool)) | |||||
| // Second create should fail | |||||
| w2 := httptest.NewRecorder() | |||||
| req2, _ := http.NewRequest("POST", "/api/email_quota_rule/", bytes.NewReader(body)) | |||||
| req2.Header.Set("Content-Type", "application/json") | |||||
| router.ServeHTTP(w2, req2) | |||||
| var resp2 map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &resp2)) | |||||
| assert.False(t, resp2["success"].(bool)) | |||||
| } | |||||
| func TestCreateEmailQuotaRule_MissingFields(t *testing.T) { | |||||
| setupEmailQuotaRuleControllerDB(t) | |||||
| router := setupEmailQuotaRuleRouter() | |||||
| body, _ := json.Marshal(map[string]interface{}{ | |||||
| "description": "no suffix", | |||||
| }) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("POST", "/api/email_quota_rule/", bytes.NewReader(body)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| router.ServeHTTP(w, req) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| assert.False(t, resp["success"].(bool)) | |||||
| } | |||||
| func TestUpdateEmailQuotaRule_Success(t *testing.T) { | |||||
| setupEmailQuotaRuleControllerDB(t) | |||||
| router := setupEmailQuotaRuleRouter() | |||||
| // Create first | |||||
| rule := &model.EmailQuotaRule{EmailSuffix: "@up.com", Quota: 100, Enabled: true} | |||||
| require.NoError(t, rule.Insert()) | |||||
| body, _ := json.Marshal(map[string]interface{}{ | |||||
| "quota": 999, | |||||
| "description": "updated desc", | |||||
| }) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("PUT", "/api/email_quota_rule/"+itoa(rule.Id), bytes.NewReader(body)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| router.ServeHTTP(w, req) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| assert.True(t, resp["success"].(bool)) | |||||
| data := resp["data"].(map[string]interface{}) | |||||
| assert.Equal(t, float64(999), data["quota"]) | |||||
| assert.Equal(t, "updated desc", data["description"]) | |||||
| } | |||||
| func TestUpdateEmailQuotaRule_ToggleEnabled(t *testing.T) { | |||||
| setupEmailQuotaRuleControllerDB(t) | |||||
| router := setupEmailQuotaRuleRouter() | |||||
| rule := &model.EmailQuotaRule{EmailSuffix: "@toggle.com", Quota: 500, Enabled: true} | |||||
| require.NoError(t, rule.Insert()) | |||||
| body, _ := json.Marshal(map[string]interface{}{ | |||||
| "enabled": false, | |||||
| }) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("PUT", "/api/email_quota_rule/"+itoa(rule.Id), bytes.NewReader(body)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| router.ServeHTTP(w, req) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| assert.True(t, resp["success"].(bool)) | |||||
| data := resp["data"].(map[string]interface{}) | |||||
| assert.Equal(t, false, data["enabled"]) | |||||
| // Cache should reflect disabled | |||||
| assert.Equal(t, int64(-1), model.MatchEmailQuotaRule("user@toggle.com")) | |||||
| } | |||||
| func TestDeleteEmailQuotaRule_Success(t *testing.T) { | |||||
| setupEmailQuotaRuleControllerDB(t) | |||||
| router := setupEmailQuotaRuleRouter() | |||||
| rule := &model.EmailQuotaRule{EmailSuffix: "@del.com", Quota: 100, Enabled: true} | |||||
| require.NoError(t, rule.Insert()) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("DELETE", "/api/email_quota_rule/"+itoa(rule.Id), nil) | |||||
| router.ServeHTTP(w, req) | |||||
| var resp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| assert.True(t, resp["success"].(bool)) | |||||
| // Verify deleted | |||||
| assert.Equal(t, int64(-1), model.MatchEmailQuotaRule("user@del.com")) | |||||
| } | |||||
| func TestCRUD_FullFlow(t *testing.T) { | |||||
| setupEmailQuotaRuleControllerDB(t) | |||||
| router := setupEmailQuotaRuleRouter() | |||||
| // 1. Create | |||||
| body, _ := json.Marshal(map[string]interface{}{ | |||||
| "email_suffix": "@full.com", | |||||
| "quota": 1000, | |||||
| "description": "full flow test", | |||||
| }) | |||||
| w := httptest.NewRecorder() | |||||
| req, _ := http.NewRequest("POST", "/api/email_quota_rule/", bytes.NewReader(body)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| router.ServeHTTP(w, req) | |||||
| var createResp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &createResp)) | |||||
| assert.True(t, createResp["success"].(bool)) | |||||
| // 2. List | |||||
| w = httptest.NewRecorder() | |||||
| req, _ = http.NewRequest("GET", "/api/email_quota_rule/", nil) | |||||
| router.ServeHTTP(w, req) | |||||
| var listResp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp)) | |||||
| data := listResp["data"].([]interface{}) | |||||
| assert.Len(t, data, 1) | |||||
| // 3. Update | |||||
| ruleId := itoa(int(data[0].(map[string]interface{})["id"].(float64))) | |||||
| body, _ = json.Marshal(map[string]interface{}{"quota": 2000}) | |||||
| w = httptest.NewRecorder() | |||||
| req, _ = http.NewRequest("PUT", "/api/email_quota_rule/"+ruleId, bytes.NewReader(body)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| router.ServeHTTP(w, req) | |||||
| var updateResp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updateResp)) | |||||
| assert.True(t, updateResp["success"].(bool)) | |||||
| // 4. Verify cache | |||||
| assert.Equal(t, int64(2000), model.MatchEmailQuotaRule("user@full.com")) | |||||
| // 5. Delete | |||||
| w = httptest.NewRecorder() | |||||
| req, _ = http.NewRequest("DELETE", "/api/email_quota_rule/"+ruleId, nil) | |||||
| router.ServeHTTP(w, req) | |||||
| var delResp map[string]interface{} | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &delResp)) | |||||
| assert.True(t, delResp["success"].(bool)) | |||||
| // 6. Verify cache cleared | |||||
| assert.Equal(t, int64(-1), model.MatchEmailQuotaRule("user@full.com")) | |||||
| } | |||||
| func itoa(i int) string { | |||||
| return strconv.Itoa(i) | |||||
| } | |||||
| @@ -0,0 +1,133 @@ | |||||
| package model | |||||
| import ( | |||||
| "strings" | |||||
| "sync" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| ) | |||||
| type EmailQuotaRule struct { | |||||
| Id int `json:"id" gorm:"primaryKey"` | |||||
| EmailSuffix string `json:"email_suffix" gorm:"size:128;not null;uniqueIndex"` | |||||
| Quota int64 `json:"quota" gorm:"not null"` | |||||
| Enabled bool `json:"enabled" gorm:"default:1"` | |||||
| Description string `json:"description" gorm:"size:256"` | |||||
| CreatedTime int64 `json:"created_time" gorm:"bigint"` | |||||
| UpdatedTime int64 `json:"updated_time" gorm:"bigint"` | |||||
| } | |||||
| var ( | |||||
| emailQuotaCache map[string]int64 | |||||
| emailQuotaCacheMu sync.RWMutex | |||||
| ) | |||||
| // LoadEmailQuotaCache loads all enabled rules into memory cache. | |||||
| func LoadEmailQuotaCache() { | |||||
| var rules []EmailQuotaRule | |||||
| DB.Where("enabled = ?", true).Find(&rules) | |||||
| cache := make(map[string]int64, len(rules)) | |||||
| for _, r := range rules { | |||||
| cache[strings.ToLower(r.EmailSuffix)] = r.Quota | |||||
| } | |||||
| emailQuotaCacheMu.Lock() | |||||
| emailQuotaCache = cache | |||||
| emailQuotaCacheMu.Unlock() | |||||
| } | |||||
| // MatchEmailQuotaRule checks if the email matches any enabled suffix rule. | |||||
| // Returns the matched quota, or -1 if no match. | |||||
| func MatchEmailQuotaRule(email string) int64 { | |||||
| if email == "" { | |||||
| return -1 | |||||
| } | |||||
| at := strings.LastIndex(email, "@") | |||||
| if at < 0 { | |||||
| return -1 | |||||
| } | |||||
| suffix := strings.ToLower(email[at:]) | |||||
| emailQuotaCacheMu.RLock() | |||||
| defer emailQuotaCacheMu.RUnlock() | |||||
| if quota, ok := emailQuotaCache[suffix]; ok { | |||||
| return quota | |||||
| } | |||||
| return -1 | |||||
| } | |||||
| func boolToInt(b bool) int { | |||||
| if b { | |||||
| return 1 | |||||
| } | |||||
| return 0 | |||||
| } | |||||
| func (r *EmailQuotaRule) Insert() error { | |||||
| r.CreatedTime = common.GetTimestamp() | |||||
| r.UpdatedTime = r.CreatedTime | |||||
| err := DB.Model(&EmailQuotaRule{}).Create(map[string]interface{}{ | |||||
| "email_suffix": r.EmailSuffix, | |||||
| "quota": r.Quota, | |||||
| "enabled": boolToInt(r.Enabled), | |||||
| "description": r.Description, | |||||
| "created_time": r.CreatedTime, | |||||
| "updated_time": r.UpdatedTime, | |||||
| }).Error | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| var last EmailQuotaRule | |||||
| if err := DB.Where("email_suffix = ?", r.EmailSuffix).First(&last).Error; err == nil { | |||||
| r.Id = last.Id | |||||
| } | |||||
| LoadEmailQuotaCache() | |||||
| return nil | |||||
| } | |||||
| func (r *EmailQuotaRule) Update() error { | |||||
| r.UpdatedTime = common.GetTimestamp() | |||||
| err := DB.Model(&EmailQuotaRule{}).Where("id = ?", r.Id).Updates(map[string]interface{}{ | |||||
| "email_suffix": r.EmailSuffix, | |||||
| "quota": r.Quota, | |||||
| "enabled": boolToInt(r.Enabled), | |||||
| "description": r.Description, | |||||
| "updated_time": r.UpdatedTime, | |||||
| }).Error | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| LoadEmailQuotaCache() | |||||
| return nil | |||||
| } | |||||
| func (r *EmailQuotaRule) Delete() error { | |||||
| err := DB.Delete(r).Error | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| LoadEmailQuotaCache() | |||||
| return nil | |||||
| } | |||||
| func GetAllEmailQuotaRules() ([]EmailQuotaRule, error) { | |||||
| var list []EmailQuotaRule | |||||
| err := DB.Order("id ASC").Find(&list).Error | |||||
| return list, err | |||||
| } | |||||
| func GetEmailQuotaRuleById(id int) (*EmailQuotaRule, error) { | |||||
| var r EmailQuotaRule | |||||
| err := DB.First(&r, id).Error | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| return &r, nil | |||||
| } | |||||
| func GetEmailQuotaRuleBySuffix(suffix string) (*EmailQuotaRule, error) { | |||||
| var r EmailQuotaRule | |||||
| err := DB.Where("email_suffix = ?", suffix).First(&r).Error | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| return &r, nil | |||||
| } | |||||
| @@ -0,0 +1,246 @@ | |||||
| package model | |||||
| import ( | |||||
| "testing" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/glebarez/sqlite" | |||||
| "github.com/stretchr/testify/assert" | |||||
| "github.com/stretchr/testify/require" | |||||
| "gorm.io/gorm" | |||||
| ) | |||||
| func setupEmailQuotaRuleDB(t *testing.T) *gorm.DB { | |||||
| t.Helper() | |||||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||||
| require.NoError(t, err) | |||||
| sqlDB, _ := db.DB() | |||||
| sqlDB.SetMaxOpenConns(1) | |||||
| origDB := DB | |||||
| DB = db | |||||
| common.UsingSQLite = true | |||||
| common.RedisEnabled = false | |||||
| require.NoError(t, db.AutoMigrate(&EmailQuotaRule{})) | |||||
| t.Cleanup(func() { | |||||
| DB = origDB | |||||
| sqlDB.Close() | |||||
| }) | |||||
| return db | |||||
| } | |||||
| func TestMatchEmailQuotaRule_EmptyEmail(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| LoadEmailQuotaCache() | |||||
| result := MatchEmailQuotaRule("") | |||||
| assert.Equal(t, int64(-1), result) | |||||
| } | |||||
| func TestMatchEmailQuotaRule_NoAtSign(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| LoadEmailQuotaCache() | |||||
| result := MatchEmailQuotaRule("invalidemail") | |||||
| assert.Equal(t, int64(-1), result) | |||||
| } | |||||
| func TestMatchEmailQuotaRule_NoRules(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| LoadEmailQuotaCache() | |||||
| result := MatchEmailQuotaRule("user@example.com") | |||||
| assert.Equal(t, int64(-1), result) | |||||
| } | |||||
| func TestMatchEmailQuotaRule_MatchEnabled(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rule := &EmailQuotaRule{ | |||||
| EmailSuffix: "@example.com", | |||||
| Quota: 500000, | |||||
| Enabled: true, | |||||
| } | |||||
| require.NoError(t, rule.Insert()) | |||||
| result := MatchEmailQuotaRule("user@example.com") | |||||
| assert.Equal(t, int64(500000), result) | |||||
| } | |||||
| func TestMatchEmailQuotaRule_CaseInsensitive(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rule := &EmailQuotaRule{ | |||||
| EmailSuffix: "@Example.COM", | |||||
| Quota: 300000, | |||||
| Enabled: true, | |||||
| } | |||||
| require.NoError(t, rule.Insert()) | |||||
| assert.Equal(t, int64(300000), MatchEmailQuotaRule("user@example.com")) | |||||
| assert.Equal(t, int64(300000), MatchEmailQuotaRule("user@EXAMPLE.COM")) | |||||
| assert.Equal(t, int64(300000), MatchEmailQuotaRule("user@Example.Com")) | |||||
| } | |||||
| func TestMatchEmailQuotaRule_DisabledRule(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rule := &EmailQuotaRule{ | |||||
| EmailSuffix: "@disabled.com", | |||||
| Quota: 100000, | |||||
| Enabled: false, | |||||
| } | |||||
| require.NoError(t, rule.Insert()) | |||||
| result := MatchEmailQuotaRule("user@disabled.com") | |||||
| assert.Equal(t, int64(-1), result) | |||||
| } | |||||
| func TestMatchEmailQuotaRule_NoMatch(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rule := &EmailQuotaRule{ | |||||
| EmailSuffix: "@company.com", | |||||
| Quota: 500000, | |||||
| Enabled: true, | |||||
| } | |||||
| require.NoError(t, rule.Insert()) | |||||
| result := MatchEmailQuotaRule("user@other.com") | |||||
| assert.Equal(t, int64(-1), result) | |||||
| } | |||||
| func TestEmailQuotaRule_Insert(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rule := &EmailQuotaRule{ | |||||
| EmailSuffix: "@test.com", | |||||
| Quota: 100000, | |||||
| Enabled: true, | |||||
| Description: "Test rule", | |||||
| } | |||||
| require.NoError(t, rule.Insert()) | |||||
| assert.Greater(t, rule.Id, 0) | |||||
| assert.Greater(t, rule.CreatedTime, int64(0)) | |||||
| assert.Equal(t, rule.CreatedTime, rule.UpdatedTime) | |||||
| // Verify cache is populated | |||||
| assert.Equal(t, int64(100000), MatchEmailQuotaRule("user@test.com")) | |||||
| } | |||||
| func TestEmailQuotaRule_Insert_DuplicateSuffix(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rule1 := &EmailQuotaRule{EmailSuffix: "@dup.com", Quota: 100, Enabled: true} | |||||
| require.NoError(t, rule1.Insert()) | |||||
| rule2 := &EmailQuotaRule{EmailSuffix: "@dup.com", Quota: 200, Enabled: true} | |||||
| assert.Error(t, rule2.Insert()) | |||||
| } | |||||
| func TestEmailQuotaRule_Update(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rule := &EmailQuotaRule{EmailSuffix: "@update.com", Quota: 100, Enabled: true} | |||||
| require.NoError(t, rule.Insert()) | |||||
| rule.Quota = 999 | |||||
| rule.Description = "updated" | |||||
| require.NoError(t, rule.Update()) | |||||
| // Verify DB | |||||
| found, err := GetEmailQuotaRuleById(rule.Id) | |||||
| require.NoError(t, err) | |||||
| assert.Equal(t, int64(999), found.Quota) | |||||
| assert.Equal(t, "updated", found.Description) | |||||
| // Verify cache refreshed | |||||
| assert.Equal(t, int64(999), MatchEmailQuotaRule("user@update.com")) | |||||
| } | |||||
| func TestEmailQuotaRule_Update_Disable(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rule := &EmailQuotaRule{EmailSuffix: "@toggled.com", Quota: 500, Enabled: true} | |||||
| require.NoError(t, rule.Insert()) | |||||
| assert.Equal(t, int64(500), MatchEmailQuotaRule("user@toggled.com")) | |||||
| rule.Enabled = false | |||||
| require.NoError(t, rule.Update()) | |||||
| assert.Equal(t, int64(-1), MatchEmailQuotaRule("user@toggled.com")) | |||||
| } | |||||
| func TestEmailQuotaRule_Delete(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rule := &EmailQuotaRule{EmailSuffix: "@delete.com", Quota: 100, Enabled: true} | |||||
| require.NoError(t, rule.Insert()) | |||||
| assert.Equal(t, int64(100), MatchEmailQuotaRule("user@delete.com")) | |||||
| require.NoError(t, rule.Delete()) | |||||
| assert.Equal(t, int64(-1), MatchEmailQuotaRule("user@delete.com")) | |||||
| } | |||||
| func TestGetAllEmailQuotaRules(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| r1 := &EmailQuotaRule{EmailSuffix: "@a.com", Quota: 100, Enabled: true} | |||||
| r2 := &EmailQuotaRule{EmailSuffix: "@b.com", Quota: 200, Enabled: false} | |||||
| require.NoError(t, r1.Insert()) | |||||
| require.NoError(t, r2.Insert()) | |||||
| list, err := GetAllEmailQuotaRules() | |||||
| require.NoError(t, err) | |||||
| assert.Len(t, list, 2) | |||||
| // Ordered by id ASC | |||||
| assert.Equal(t, "@a.com", list[0].EmailSuffix) | |||||
| assert.Equal(t, "@b.com", list[1].EmailSuffix) | |||||
| } | |||||
| func TestGetEmailQuotaRuleBySuffix(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rule := &EmailQuotaRule{EmailSuffix: "@find.com", Quota: 300, Enabled: true} | |||||
| require.NoError(t, rule.Insert()) | |||||
| found, err := GetEmailQuotaRuleBySuffix("@find.com") | |||||
| require.NoError(t, err) | |||||
| assert.Equal(t, int64(300), found.Quota) | |||||
| _, err = GetEmailQuotaRuleBySuffix("@notexist.com") | |||||
| assert.Error(t, err) | |||||
| } | |||||
| func TestLoadEmailQuotaCache_OnlyEnabled(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| r1 := &EmailQuotaRule{EmailSuffix: "@enabled.com", Quota: 100, Enabled: true} | |||||
| r2 := &EmailQuotaRule{EmailSuffix: "@disabled.com", Quota: 200, Enabled: false} | |||||
| require.NoError(t, r1.Insert()) | |||||
| require.NoError(t, r2.Insert()) | |||||
| LoadEmailQuotaCache() | |||||
| assert.Equal(t, int64(100), MatchEmailQuotaRule("user@enabled.com")) | |||||
| assert.Equal(t, int64(-1), MatchEmailQuotaRule("user@disabled.com")) | |||||
| } | |||||
| func TestMatchEmailQuotaRule_MultipleRules(t *testing.T) { | |||||
| setupEmailQuotaRuleDB(t) | |||||
| rules := []*EmailQuotaRule{ | |||||
| {EmailSuffix: "@company.com", Quota: 500000, Enabled: true}, | |||||
| {EmailSuffix: "@tsinghua.edu.cn", Quota: 1000000, Enabled: true}, | |||||
| {EmailSuffix: "@vip.org", Quota: 2000000, Enabled: true}, | |||||
| } | |||||
| for _, r := range rules { | |||||
| require.NoError(t, r.Insert()) | |||||
| } | |||||
| assert.Equal(t, int64(500000), MatchEmailQuotaRule("user@company.com")) | |||||
| assert.Equal(t, int64(1000000), MatchEmailQuotaRule("student@tsinghua.edu.cn")) | |||||
| assert.Equal(t, int64(2000000), MatchEmailQuotaRule("admin@vip.org")) | |||||
| assert.Equal(t, int64(-1), MatchEmailQuotaRule("random@unknown.net")) | |||||
| } | |||||
| @@ -206,6 +206,7 @@ func InitDB() (err error) { | |||||
| if err != nil { | if err != nil { | ||||
| return err | return err | ||||
| } | } | ||||
| LoadEmailQuotaCache() | |||||
| LoadChannelPricingCache() | LoadChannelPricingCache() | ||||
| return nil | return nil | ||||
| } else { | } else { | ||||
| @@ -286,6 +287,7 @@ func migrateDB() error { | |||||
| &PricingTag{}, | &PricingTag{}, | ||||
| &PendingSyncRecord{}, | &PendingSyncRecord{}, | ||||
| &QuotaSyncLog{}, | &QuotaSyncLog{}, | ||||
| &EmailQuotaRule{}, | |||||
| ) | ) | ||||
| if err != nil { | if err != nil { | ||||
| return err | return err | ||||
| @@ -343,6 +345,7 @@ func migrateDBFast() error { | |||||
| {&PricingTag{}, "PricingTag"}, | {&PricingTag{}, "PricingTag"}, | ||||
| {&PendingSyncRecord{}, "PendingSyncRecord"}, | {&PendingSyncRecord{}, "PendingSyncRecord"}, | ||||
| {&QuotaSyncLog{}, "QuotaSyncLog"}, | {&QuotaSyncLog{}, "QuotaSyncLog"}, | ||||
| {&EmailQuotaRule{}, "EmailQuotaRule"}, | |||||
| } | } | ||||
| // 动态计算migration数量,确保errChan缓冲区足够大 | // 动态计算migration数量,确保errChan缓冲区足够大 | ||||
| errChan := make(chan error, len(migrations)) | errChan := make(chan error, len(migrations)) | ||||
| @@ -410,7 +410,12 @@ func (user *User) Insert(inviterId int) error { | |||||
| return err | return err | ||||
| } | } | ||||
| } | } | ||||
| user.Quota = common.QuotaForNewUser | |||||
| matchedQuota := MatchEmailQuotaRule(user.Email) | |||||
| if matchedQuota >= 0 { | |||||
| user.Quota = int(matchedQuota) | |||||
| } else { | |||||
| user.Quota = common.QuotaForNewUser | |||||
| } | |||||
| //user.SetAccessToken(common.GetUUID()) | //user.SetAccessToken(common.GetUUID()) | ||||
| user.AffCode = common.GetRandomString(4) | user.AffCode = common.GetRandomString(4) | ||||
| @@ -441,8 +446,12 @@ func (user *User) Insert(inviterId int) error { | |||||
| } | } | ||||
| } | } | ||||
| if common.QuotaForNewUser > 0 { | |||||
| RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) | |||||
| if user.Quota > 0 { | |||||
| if MatchEmailQuotaRule(user.Email) >= 0 { | |||||
| RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s(邮箱后缀规则匹配)", logger.LogQuota(user.Quota))) | |||||
| } else { | |||||
| RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(user.Quota))) | |||||
| } | |||||
| } | } | ||||
| if inviterId != 0 { | if inviterId != 0 { | ||||
| if common.QuotaForInvitee > 0 { | if common.QuotaForInvitee > 0 { | ||||
| @@ -469,7 +478,12 @@ func (user *User) InsertWithTx(tx *gorm.DB, inviterId int) error { | |||||
| return err | return err | ||||
| } | } | ||||
| } | } | ||||
| user.Quota = common.QuotaForNewUser | |||||
| matchedQuota := MatchEmailQuotaRule(user.Email) | |||||
| if matchedQuota >= 0 { | |||||
| user.Quota = int(matchedQuota) | |||||
| } else { | |||||
| user.Quota = common.QuotaForNewUser | |||||
| } | |||||
| user.AffCode = common.GetRandomString(4) | user.AffCode = common.GetRandomString(4) | ||||
| // 初始化用户设置 | // 初始化用户设置 | ||||
| @@ -502,8 +516,12 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) { | |||||
| } | } | ||||
| } | } | ||||
| if common.QuotaForNewUser > 0 { | |||||
| RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) | |||||
| if user.Quota > 0 { | |||||
| if matchedQuota := MatchEmailQuotaRule(user.Email); matchedQuota >= 0 { | |||||
| RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s(邮箱后缀规则匹配)", logger.LogQuota(user.Quota))) | |||||
| } else { | |||||
| RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(user.Quota))) | |||||
| } | |||||
| } | } | ||||
| if inviterId != 0 { | if inviterId != 0 { | ||||
| if common.QuotaForInvitee > 0 { | if common.QuotaForInvitee > 0 { | ||||
| @@ -211,6 +211,16 @@ func SetApiRouter(router *gin.Engine) { | |||||
| pricingTagRoute.DELETE("/:id", controller.DeletePricingTag) | pricingTagRoute.DELETE("/:id", controller.DeletePricingTag) | ||||
| } | } | ||||
| // 邮箱后缀额度规则路由(管理员权限) | |||||
| emailQuotaRuleRoute := apiRouter.Group("/email_quota_rule") | |||||
| emailQuotaRuleRoute.Use(middleware.AdminAuth()) | |||||
| { | |||||
| emailQuotaRuleRoute.GET("/", controller.GetAllEmailQuotaRules) | |||||
| emailQuotaRuleRoute.POST("/", controller.CreateEmailQuotaRule) | |||||
| emailQuotaRuleRoute.PUT("/:id", controller.UpdateEmailQuotaRule) | |||||
| emailQuotaRuleRoute.DELETE("/:id", controller.DeleteEmailQuotaRule) | |||||
| } | |||||
| // Custom OAuth provider management (root only) | // Custom OAuth provider management (root only) | ||||
| customOAuthRoute := apiRouter.Group("/custom-oauth-provider") | customOAuthRoute := apiRouter.Group("/custom-oauth-provider") | ||||
| customOAuthRoute.Use(middleware.RootAuth()) | customOAuthRoute.Use(middleware.RootAuth()) | ||||
| @@ -0,0 +1,242 @@ | |||||
| import React, { useEffect, useState, useRef } from 'react'; | |||||
| import { | |||||
| Button, | |||||
| Form, | |||||
| Input, | |||||
| InputNumber, | |||||
| Modal, | |||||
| Popconfirm, | |||||
| Space, | |||||
| Spin, | |||||
| Switch, | |||||
| Table, | |||||
| Tag, | |||||
| Typography, | |||||
| } from '@douyinfe/semi-ui'; | |||||
| import { IconDelete, IconEdit, IconPlus } from '@douyinfe/semi-icons'; | |||||
| import { API, showError, showSuccess } from '../../../helpers'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| export default function SettingsEmailQuotaRule() { | |||||
| const { t } = useTranslation(); | |||||
| const { Text } = Typography; | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [rules, setRules] = useState([]); | |||||
| const [modalVisible, setModalVisible] = useState(false); | |||||
| const [editingRule, setEditingRule] = useState(null); | |||||
| const [isEdit, setIsEdit] = useState(false); | |||||
| const modalFormRef = useRef(); | |||||
| const [modalFormKey, setModalFormKey] = useState(0); | |||||
| const fetchRules = async () => { | |||||
| setLoading(true); | |||||
| try { | |||||
| const res = await API.get('/api/email_quota_rule/'); | |||||
| const { success, message, data } = res.data; | |||||
| if (success) { | |||||
| setRules(data || []); | |||||
| } else { | |||||
| showError(message); | |||||
| } | |||||
| } catch { | |||||
| showError(t('获取邮箱后缀额度规则失败')); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }; | |||||
| useEffect(() => { | |||||
| fetchRules(); | |||||
| }, []); | |||||
| const openModal = (record = null) => { | |||||
| setEditingRule(record); | |||||
| setIsEdit(record !== null); | |||||
| modalFormRef.current = null; | |||||
| setModalFormKey((k) => k + 1); | |||||
| setModalVisible(true); | |||||
| }; | |||||
| const handleDelete = async (id) => { | |||||
| try { | |||||
| const res = await API.delete(`/api/email_quota_rule/${id}`); | |||||
| const { success, message } = res.data; | |||||
| if (success) { | |||||
| showSuccess(t('删除成功')); | |||||
| fetchRules(); | |||||
| } else { | |||||
| showError(message); | |||||
| } | |||||
| } catch { | |||||
| showError(t('删除失败')); | |||||
| } | |||||
| }; | |||||
| const handleToggleEnabled = async (record) => { | |||||
| try { | |||||
| const res = await API.put(`/api/email_quota_rule/${record.id}`, { | |||||
| enabled: !record.enabled, | |||||
| }); | |||||
| const { success, message } = res.data; | |||||
| if (success) { | |||||
| showSuccess(t('更新成功')); | |||||
| fetchRules(); | |||||
| } else { | |||||
| showError(message); | |||||
| } | |||||
| } catch { | |||||
| showError(t('更新失败')); | |||||
| } | |||||
| }; | |||||
| const handleModalOk = async () => { | |||||
| try { | |||||
| const values = await modalFormRef.current.validate(); | |||||
| const emailSuffix = (values.email_suffix || '').trim(); | |||||
| if (!emailSuffix.startsWith('@')) { | |||||
| showError(t('邮箱后缀必须以 @ 开头')); | |||||
| return; | |||||
| } | |||||
| const payload = { | |||||
| email_suffix: emailSuffix, | |||||
| quota: Number(values.quota || 0), | |||||
| description: (values.description || '').trim(), | |||||
| }; | |||||
| let res; | |||||
| if (isEdit && editingRule) { | |||||
| res = await API.put(`/api/email_quota_rule/${editingRule.id}`, payload); | |||||
| } else { | |||||
| payload.enabled = true; | |||||
| res = await API.post('/api/email_quota_rule/', payload); | |||||
| } | |||||
| const { success, message } = res.data; | |||||
| if (success) { | |||||
| showSuccess(t('保存成功')); | |||||
| setModalVisible(false); | |||||
| fetchRules(); | |||||
| } else { | |||||
| showError(message); | |||||
| } | |||||
| } catch { | |||||
| showError(t('请检查输入')); | |||||
| } | |||||
| }; | |||||
| const columns = [ | |||||
| { | |||||
| title: t('邮箱后缀'), | |||||
| dataIndex: 'email_suffix', | |||||
| render: (text) => <Text strong>{text}</Text>, | |||||
| }, | |||||
| { | |||||
| title: t('赠送额度'), | |||||
| dataIndex: 'quota', | |||||
| render: (val) => <Tag color='green' size='large'>{val}</Tag>, | |||||
| }, | |||||
| { | |||||
| title: t('状态'), | |||||
| dataIndex: 'enabled', | |||||
| render: (val, record) => ( | |||||
| <Switch | |||||
| checked={val} | |||||
| onChange={() => handleToggleEnabled(record)} | |||||
| checkedText='|' | |||||
| uncheckedText='O' | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| title: t('备注'), | |||||
| dataIndex: 'description', | |||||
| render: (text) => <Text type='tertiary'>{text || '-'}</Text>, | |||||
| }, | |||||
| { | |||||
| title: t('操作'), | |||||
| render: (_, record) => ( | |||||
| <Space> | |||||
| <Button | |||||
| icon={<IconEdit />} | |||||
| theme='borderless' | |||||
| onClick={() => openModal(record)} | |||||
| /> | |||||
| <Popconfirm | |||||
| title={t('确认删除该规则?')} | |||||
| onConfirm={() => handleDelete(record.id)} | |||||
| > | |||||
| <Button icon={<IconDelete />} theme='borderless' type='danger' /> | |||||
| </Popconfirm> | |||||
| </Space> | |||||
| ), | |||||
| }, | |||||
| ]; | |||||
| return ( | |||||
| <Spin spinning={loading}> | |||||
| <div style={{ marginBottom: 12 }}> | |||||
| <Space> | |||||
| <Button icon={<IconPlus />} onClick={() => openModal()}> | |||||
| {t('新增规则')} | |||||
| </Button> | |||||
| <Button onClick={fetchRules}>{t('刷新')}</Button> | |||||
| </Space> | |||||
| </div> | |||||
| <Table | |||||
| columns={columns} | |||||
| dataSource={rules} | |||||
| rowKey='id' | |||||
| pagination={false} | |||||
| size='small' | |||||
| empty={t('暂无规则,点击「新增规则」添加')} | |||||
| /> | |||||
| <Modal | |||||
| title={isEdit ? t('编辑邮箱后缀额度规则') : t('新增邮箱后缀额度规则')} | |||||
| visible={modalVisible} | |||||
| onCancel={() => setModalVisible(false)} | |||||
| onOk={handleModalOk} | |||||
| okText={t('保存')} | |||||
| cancelText={t('取消')} | |||||
| > | |||||
| <Form | |||||
| key={`email-quota-rule-form-${modalFormKey}`} | |||||
| initValues={ | |||||
| isEdit && editingRule | |||||
| ? { | |||||
| email_suffix: editingRule.email_suffix, | |||||
| quota: editingRule.quota, | |||||
| description: editingRule.description || '', | |||||
| } | |||||
| : { email_suffix: '@', quota: 0, description: '' } | |||||
| } | |||||
| getFormApi={(api) => { | |||||
| modalFormRef.current = api; | |||||
| }} | |||||
| > | |||||
| <Form.Input | |||||
| field='email_suffix' | |||||
| label={t('邮箱后缀')} | |||||
| placeholder='@example.com' | |||||
| rules={[{ required: true, message: t('请输入邮箱后缀') }]} | |||||
| extraText={t('必须以 @ 开头,如 @company.com、@edu.cn')} | |||||
| /> | |||||
| <Form.InputNumber | |||||
| field='quota' | |||||
| label={t('赠送额度')} | |||||
| step={1000} | |||||
| min={0} | |||||
| rules={[{ required: true, message: t('请输入额度') }]} | |||||
| extraText={t('匹配该后缀的用户注册时获得的初始额度')} | |||||
| /> | |||||
| <Form.Input | |||||
| field='description' | |||||
| label={t('备注')} | |||||
| placeholder={t('如:清华大学学生')} | |||||
| /> | |||||
| </Form> | |||||
| </Modal> | |||||
| </Spin> | |||||
| ); | |||||
| } | |||||
| @@ -18,12 +18,23 @@ For commercial licensing, please contact support@quantumnous.com | |||||
| */ | */ | ||||
| import React from 'react'; | import React from 'react'; | ||||
| import { Tabs, TabPane } from '@douyinfe/semi-ui'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import UsersTable from '../../components/table/users'; | import UsersTable from '../../components/table/users'; | ||||
| import SettingsEmailQuotaRule from '../Setting/Operation/SettingsEmailQuotaRule'; | |||||
| const User = () => { | const User = () => { | ||||
| const { t } = useTranslation(); | |||||
| return ( | return ( | ||||
| <div className='mt-[60px] px-2'> | <div className='mt-[60px] px-2'> | ||||
| <UsersTable /> | |||||
| <Tabs type='line'> | |||||
| <TabPane tab={t('用户列表')} itemKey='users'> | |||||
| <UsersTable /> | |||||
| </TabPane> | |||||
| <TabPane tab={t('邮箱后缀额度规则')} itemKey='email-quota'> | |||||
| <SettingsEmailQuotaRule /> | |||||
| </TabPane> | |||||
| </Tabs> | |||||
| </div> | </div> | ||||
| ); | ); | ||||
| }; | }; | ||||