管理员可在用户管理页面配置「邮箱后缀→初始额度」映射规则, 用户注册时根据邮箱后缀自动匹配并发放对应额度(替代默认额度)。 - 新增 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 { | |||
| return err | |||
| } | |||
| LoadEmailQuotaCache() | |||
| LoadChannelPricingCache() | |||
| return nil | |||
| } else { | |||
| @@ -286,6 +287,7 @@ func migrateDB() error { | |||
| &PricingTag{}, | |||
| &PendingSyncRecord{}, | |||
| &QuotaSyncLog{}, | |||
| &EmailQuotaRule{}, | |||
| ) | |||
| if err != nil { | |||
| return err | |||
| @@ -343,6 +345,7 @@ func migrateDBFast() error { | |||
| {&PricingTag{}, "PricingTag"}, | |||
| {&PendingSyncRecord{}, "PendingSyncRecord"}, | |||
| {&QuotaSyncLog{}, "QuotaSyncLog"}, | |||
| {&EmailQuotaRule{}, "EmailQuotaRule"}, | |||
| } | |||
| // 动态计算migration数量,确保errChan缓冲区足够大 | |||
| errChan := make(chan error, len(migrations)) | |||
| @@ -410,7 +410,12 @@ func (user *User) Insert(inviterId int) error { | |||
| 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.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 common.QuotaForInvitee > 0 { | |||
| @@ -469,7 +478,12 @@ func (user *User) InsertWithTx(tx *gorm.DB, inviterId int) error { | |||
| 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) | |||
| // 初始化用户设置 | |||
| @@ -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 common.QuotaForInvitee > 0 { | |||
| @@ -211,6 +211,16 @@ func SetApiRouter(router *gin.Engine) { | |||
| 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) | |||
| customOAuthRoute := apiRouter.Group("/custom-oauth-provider") | |||
| 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 { Tabs, TabPane } from '@douyinfe/semi-ui'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import UsersTable from '../../components/table/users'; | |||
| import SettingsEmailQuotaRule from '../Setting/Operation/SettingsEmailQuotaRule'; | |||
| const User = () => { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <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> | |||
| ); | |||
| }; | |||