浏览代码

feat: 添加邮箱后缀注册额度规则功能

管理员可在用户管理页面配置「邮箱后缀→初始额度」映射规则,
用户注册时根据邮箱后缀自动匹配并发放对应额度(替代默认额度)。

- 新增 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
fengsilin 1周前
父节点
当前提交
967309fb56
共有 11 个文件被更改,包括 7263 次插入6182 次删除
  1. +128
    -0
      controller/email_quota_rule.go
  2. +264
    -0
      controller/email_quota_rule_test.go
  3. +133
    -0
      model/email_quota_rule.go
  4. +246
    -0
      model/email_quota_rule_test.go
  5. +3
    -0
      model/main.go
  6. +24
    -6
      model/user.go
  7. +10
    -0
      router/api-router.go
  8. +3112
    -3099
      web/src/i18n/locales/en.json
  9. +3089
    -3076
      web/src/i18n/locales/zh-CN.json
  10. +242
    -0
      web/src/pages/Setting/Operation/SettingsEmailQuotaRule.jsx
  11. +12
    -1
      web/src/pages/User/index.jsx

+ 128
- 0
controller/email_quota_rule.go 查看文件

@@ -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)
}

+ 264
- 0
controller/email_quota_rule_test.go 查看文件

@@ -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)
}

+ 133
- 0
model/email_quota_rule.go 查看文件

@@ -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
}

+ 246
- 0
model/email_quota_rule_test.go 查看文件

@@ -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"))
}

+ 3
- 0
model/main.go 查看文件

@@ -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))


+ 24
- 6
model/user.go 查看文件

@@ -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 {


+ 10
- 0
router/api-router.go 查看文件

@@ -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())


+ 3112
- 3099
web/src/i18n/locales/en.json
文件差异内容过多而无法显示
查看文件


+ 3089
- 3076
web/src/i18n/locales/zh-CN.json
文件差异内容过多而无法显示
查看文件


+ 242
- 0
web/src/pages/Setting/Operation/SettingsEmailQuotaRule.jsx 查看文件

@@ -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>
);
}

+ 12
- 1
web/src/pages/User/index.jsx 查看文件

@@ -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>
);
};


正在加载...
取消
保存