From b4c815ec9daf52c5102a06777329bbbe9d6bfaca Mon Sep 17 00:00:00 2001 From: chengqian Date: Mon, 8 Jun 2026 18:01:13 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix:=E7=A7=BB=E9=99=A4=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E6=BC=94=E7=A4=BA=E6=A8=A1=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/common/settings_registry.go | 4 ---- internal/logic/settings/settings.go | 9 +++++++++ internal/middleware/demo.go | 7 ++++--- manifest/config/config.example.yaml | 6 ++++++ manifest/docker/config.example.yaml | 6 ++++++ .../src/views/system/settings/GeneralTab.vue | 18 ------------------ 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 0db7299..1fd34a8 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -67,10 +67,6 @@ var Registry = []SettingDef{ Label: "预计维护时长", Description: "维护模式预计持续时间,展示给用户", IsPublic: true}, {Key: "api_maintenance_enabled", Type: SettingTypeBool, Default: "false", Category: "general", Label: "全局 API 维护", Description: "开启后 API 代理返回 503,叠加维护模式使用", IsPublic: true}, - {Key: "demo_mode", Type: SettingTypeBool, Default: "false", Category: "general", - Label: "演示模式", Description: "开启后所有写操作被拦截,仅允许只读访问", IsPublic: true}, - {Key: "demo_message", Type: SettingTypeString, Default: "演示环境,数据不可修改", Category: "general", - Label: "演示模式提示", Description: "演示模式下返回给用户的提示信息", IsPublic: true}, {Key: "tenant_console_url", Type: SettingTypeString, Default: "", Category: "general", Label: "租户控制台地址", Description: "租户控制台的完整 URL,如 https://console.example.com,用于生成邀请链接等"}, diff --git a/internal/logic/settings/settings.go b/internal/logic/settings/settings.go index a3d6a76..071ead7 100644 --- a/internal/logic/settings/settings.go +++ b/internal/logic/settings/settings.go @@ -3,6 +3,7 @@ package settings import ( "context" + "github.com/gogf/gf/v2/frame/g" "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/service" @@ -42,6 +43,14 @@ func (s *sSettings) PublicSettingsGet(ctx context.Context, _ *v1.PublicSettingsG } } + // Inject demo mode from config file (not database — avoids the self-lock bug) + settings["demo_mode"] = g.Cfg().MustGet(ctx, "demo.enabled").Bool() + demoMsg := g.Cfg().MustGet(ctx, "demo.message").String() + if demoMsg == "" { + demoMsg = "演示环境,数据不可修改" + } + settings["demo_message"] = demoMsg + return &v1.PublicSettingsGetRes{Settings: settings}, nil } diff --git a/internal/middleware/demo.go b/internal/middleware/demo.go index 06d9b56..c8694ca 100644 --- a/internal/middleware/demo.go +++ b/internal/middleware/demo.go @@ -3,10 +3,10 @@ package middleware import ( "strings" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/qianfree/team-api/internal/consts" - "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/response" ) @@ -21,10 +21,11 @@ var demoWhitelistPaths = map[string]bool{ } // DemoMode blocks all write operations when demo mode is enabled. +// Controlled via config file: demo.enabled = true. // Read-only requests (GET/HEAD/OPTIONS) and whitelisted paths are allowed. func DemoMode(r *ghttp.Request) { ctx := r.Context() - if !common.Config().GetBool(ctx, "demo_mode") { + if !g.Cfg().MustGet(ctx, "demo.enabled").Bool() { r.Middleware.Next() return } @@ -50,7 +51,7 @@ func DemoMode(r *ghttp.Request) { return } - message := common.Config().GetString(ctx, "demo_message") + message := g.Cfg().MustGet(ctx, "demo.message").String() if message == "" { message = consts.MsgDemoModeRestricted } diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml index e60b232..d7787bd 100644 --- a/manifest/config/config.example.yaml +++ b/manifest/config/config.example.yaml @@ -65,5 +65,11 @@ email: password: "" from: "" +# Demo mode (read-only mode, configured here — not in admin UI) +# Set enabled: true to block all write operations and show a watermark +demo: + enabled: false + message: "演示环境,数据不可修改" + # Channel proxy (for accessing Google/Anthropic etc. from China) channel_proxy_url: "" diff --git a/manifest/docker/config.example.yaml b/manifest/docker/config.example.yaml index 5d0cc04..2982df3 100644 --- a/manifest/docker/config.example.yaml +++ b/manifest/docker/config.example.yaml @@ -81,3 +81,9 @@ email: username: "" password: "" from: "" + +# 演示模式(通过配置文件控制,不在管理后台设置) +# 开启后所有写操作被拦截,仅允许只读访问,并在控制台显示水印 +demo: + enabled: false + message: "演示环境,数据不可修改" diff --git a/web/admin/src/views/system/settings/GeneralTab.vue b/web/admin/src/views/system/settings/GeneralTab.vue index 4a9c0cb..2583e9b 100644 --- a/web/admin/src/views/system/settings/GeneralTab.vue +++ b/web/admin/src/views/system/settings/GeneralTab.vue @@ -42,24 +42,6 @@ const values = useFormValues() - -
-
演示模式
-
- 启用演示模式 - 开启后所有写操作被拦截,仅允许只读访问 - -
-
- - - -
-
-
维护模式
From 13073babc000407ec8b91dbcd429409bbffff161 Mon Sep 17 00:00:00 2001 From: chengqian Date: Tue, 9 Jun 2026 13:45:23 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E9=A2=91=E7=8E=87=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/consts/consts.go | 4 + internal/logic/common/register_rate_limit.go | 155 +++++++++++++++++ .../logic/common/register_rate_limit_test.go | 158 ++++++++++++++++++ internal/logic/common/settings_registry.go | 6 + internal/logic/tenant/auth.go | 8 +- 5 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 internal/logic/common/register_rate_limit.go create mode 100644 internal/logic/common/register_rate_limit_test.go diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 0413d3f..c4e419b 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -174,6 +174,8 @@ const ( // Registration errors CodeRegistrationDisabled = 10077 CodeEmailVerificationDisabled = 10082 + CodeIpRateLimitExceeded = 10090 // IP级别限流 + CodeGlobalRateLimitExceeded = 10091 // 全局限流 // Setup errors CodeSetupCompleted = 10078 @@ -308,6 +310,8 @@ const ( // Registration errors MsgRegistrationDisabled = "注册功能已关闭" MsgEmailVerificationDisabled = "注册邮箱验证未启用" + MsgIpRateLimitExceeded = "该IP注册频率过高,请稍后再试" + MsgGlobalRateLimitExceeded = "系统注册繁忙,请稍后再试" // Setup errors MsgSetupCompleted = "系统已完成初始化" diff --git a/internal/logic/common/register_rate_limit.go b/internal/logic/common/register_rate_limit.go new file mode 100644 index 0000000..f5322a8 --- /dev/null +++ b/internal/logic/common/register_rate_limit.go @@ -0,0 +1,155 @@ +package common + +import ( + "context" + "fmt" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/qianfree/team-api/internal/consts" +) + +// CheckRegisterRateLimit 检查注册速率限制 +// 1. IP级别限流(每小时/每天) +// 2. 全局限流(每分钟) +func CheckRegisterRateLimit(ctx context.Context, ipAddress string) error { + // 1. 检查IP级别限流 + if err := checkIPRateLimit(ctx, ipAddress); err != nil { + return err + } + + // 2. 检查全局限流 + if err := checkGlobalRateLimit(ctx); err != nil { + return err + } + + return nil +} + +// checkIPRateLimit 检查IP级别的注册限流 +func checkIPRateLimit(ctx context.Context, ipAddress string) error { + if ipAddress == "" { + return nil // 无法获取IP时跳过检查 + } + + // 每小时限制 + hourlyLimit := Config().GetInt(ctx, "register_ip_limit_per_hour") + if hourlyLimit > 0 { + hourlyKey := fmt.Sprintf("register:ip:hourly:%s", ipAddress) + count, err := incrementWithExpire(ctx, hourlyKey, 3600) // 1小时过期 + if err != nil { + g.Log().Warningf(ctx, "IP注册限流检查失败: %v", err) + return nil // Redis失败时不阻塞注册 + } + if count > int64(hourlyLimit) { + return NewBusinessError(consts.CodeIpRateLimitExceeded, consts.MsgIpRateLimitExceeded) + } + } + + // 每天限制 + dailyLimit := Config().GetInt(ctx, "register_ip_limit_per_day") + if dailyLimit > 0 { + dailyKey := fmt.Sprintf("register:ip:daily:%s", ipAddress) + count, err := incrementWithExpire(ctx, dailyKey, 86400) // 24小时过期 + if err != nil { + g.Log().Warningf(ctx, "IP注册限流检查失败: %v", err) + return nil // Redis失败时不阻塞注册 + } + if count > int64(dailyLimit) { + return NewBusinessError(consts.CodeIpRateLimitExceeded, consts.MsgIpRateLimitExceeded) + } + } + + return nil +} + +// checkGlobalRateLimit 检查全局注册速率限制 +func checkGlobalRateLimit(ctx context.Context) error { + globalLimit := Config().GetInt(ctx, "register_global_limit_per_minute") + if globalLimit <= 0 { + return nil // 未启用全局限流 + } + + // 使用当前分钟的Unix时间戳作为key,确保每分钟重置 + now := time.Now() + minuteKey := fmt.Sprintf("register:global:%d", now.Unix()/60) // 每分钟一个key + + count, err := incrementWithExpire(ctx, minuteKey, 120) // 2分钟过期(确保当前分钟和下一分钟都能计数) + if err != nil { + g.Log().Warningf(ctx, "全局注册限流检查失败: %v", err) + return nil // Redis失败时不阻塞注册 + } + + if count > int64(globalLimit) { + return NewBusinessError(consts.CodeGlobalRateLimitExceeded, consts.MsgGlobalRateLimitExceeded) + } + + return nil +} + +// incrementWithExpire 原子递增计数器,首次递增时设置过期时间 +// 使用Lua脚本确保原子性:INCR + EXPIRE(仅在第一次) +func incrementWithExpire(ctx context.Context, key string, expireSeconds int) (int64, error) { + // Lua脚本:先递增,如果结果为1则设置过期时间 + luaScript := ` + local count = redis.call("INCR", KEYS[1]) + if count == 1 then + redis.call("EXPIRE", KEYS[1], ARGV[1]) + end + return count + ` + + result, err := g.Redis().Do(ctx, "EVAL", luaScript, 1, key, expireSeconds) + if err != nil { + return 0, err + } + + return result.Int64(), nil +} + +// GetRegisterRateLimitStatus 获取当前IP的注册限流状态(用于前端提示) +func GetRegisterRateLimitStatus(ctx context.Context, ipAddress string) map[string]any { + status := make(map[string]any) + + if ipAddress == "" { + return status + } + + // 查询IP每小时限制 + hourlyLimit := Config().GetInt(ctx, "register_ip_limit_per_hour") + if hourlyLimit > 0 { + hourlyKey := fmt.Sprintf("register:ip:hourly:%s", ipAddress) + hourlyCount, _ := g.Redis().Do(ctx, "GET", hourlyKey) + if !hourlyCount.IsNil() { + remaining := hourlyLimit - hourlyCount.Int() + status["hourly_remaining"] = remaining + status["hourly_limit"] = hourlyLimit + if ttl, err := g.Redis().Do(ctx, "TTL", hourlyKey); err == nil { + status["hourly_reset_seconds"] = ttl.Int() + } + } else { + status["hourly_remaining"] = hourlyLimit + status["hourly_limit"] = hourlyLimit + } + } + + // 查询IP每天限制 + dailyLimit := Config().GetInt(ctx, "register_ip_limit_per_day") + if dailyLimit > 0 { + dailyKey := fmt.Sprintf("register:ip:daily:%s", ipAddress) + dailyCount, _ := g.Redis().Do(ctx, "GET", dailyKey) + if !dailyCount.IsNil() { + remaining := dailyLimit - dailyCount.Int() + status["daily_remaining"] = remaining + status["daily_limit"] = dailyLimit + if ttl, err := g.Redis().Do(ctx, "TTL", dailyKey); err == nil { + status["daily_reset_seconds"] = ttl.Int() + } + } else { + status["daily_remaining"] = dailyLimit + status["daily_limit"] = dailyLimit + } + } + + return status +} diff --git a/internal/logic/common/register_rate_limit_test.go b/internal/logic/common/register_rate_limit_test.go new file mode 100644 index 0000000..3c29ae3 --- /dev/null +++ b/internal/logic/common/register_rate_limit_test.go @@ -0,0 +1,158 @@ +package common + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/qianfree/team-api/internal/consts" +) + +// TestCheckRegisterRateLimit 测试注册速率限制 +func TestCheckRegisterRateLimit(t *testing.T) { + ctx := context.Background() + + // 测试IP级别限流 + t.Run("IP hourly limit", func(t *testing.T) { + testIP := "192.168.1.100" + + // 清理测试数据 + hourlyKey := "register:ip:hourly:" + testIP + g.Redis().Do(ctx, "DEL", hourlyKey) + + // 设置配置为每小时最多3次 + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "3") + + // 前3次应该成功 + for i := 0; i < 3; i++ { + err := CheckRegisterRateLimit(ctx, testIP) + if err != nil { + t.Errorf("第 %d 次注册应该成功,但得到错误: %v", i+1, err) + } + } + + // 第4次应该失败 + err := CheckRegisterRateLimit(ctx, testIP) + if err == nil { + t.Error("第4次注册应该被限流,但成功了") + } else if err.Error() != consts.MsgIpRateLimitExceeded { + t.Errorf("第4次注册应该返回IP限流错误,但得到: %v", err) + } + + // 清理测试数据 + g.Redis().Do(ctx, "DEL", hourlyKey) + }) + + t.Run("IP daily limit", func(t *testing.T) { + testIP := "192.168.1.101" + + // 清理测试数据 + dailyKey := "register:ip:daily:" + testIP + g.Redis().Do(ctx, "DEL", dailyKey) + + // 设置配置为每天最多5次 + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "5") + + // 禁用每小时限制 + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "0") + + // 前5次应该成功 + for i := 0; i < 5; i++ { + err := CheckRegisterRateLimit(ctx, testIP) + if err != nil { + t.Errorf("第 %d 次注册应该成功,但得到错误: %v", i+1, err) + } + } + + // 第6次应该失败 + err := CheckRegisterRateLimit(ctx, testIP) + if err == nil { + t.Error("第6次注册应该被限流,但成功了") + } else if err.Error() != consts.MsgIpRateLimitExceeded { + t.Errorf("第6次注册应该返回IP限流错误,但得到: %v", err) + } + + // 清理测试数据 + g.Redis().Do(ctx, "DEL", dailyKey) + }) +} + +// TestGlobalRateLimit 测试全局限流 +func TestGlobalRateLimit(t *testing.T) { + ctx := context.Background() + + // 清理测试数据 + now := time.Now() + minuteKey := fmt.Sprintf("register:global:%d", now.Unix()/60) + g.Redis().Do(ctx, "DEL", minuteKey) + + // 设置配置为每分钟最多2次 + g.Redis().Do(ctx, "SETEX", "config:register_global_limit_per_minute", 60, "2") + + // 禁用IP限制 + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "0") + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "0") + + // 前2次应该成功(使用不同IP) + for i := 0; i < 2; i++ { + testIP := fmt.Sprintf("192.168.1.%d", i+100) + err := CheckRegisterRateLimit(ctx, testIP) + if err != nil { + t.Errorf("第 %d 次注册应该成功,但得到错误: %v", i+1, err) + } + } + + // 第3次应该失败(无论使用什么IP) + err := CheckRegisterRateLimit(ctx, "192.168.1.200") + if err == nil { + t.Error("第3次注册应该被全局限流,但成功了") + } else if err.Error() != consts.MsgGlobalRateLimitExceeded { + t.Errorf("第3次注册应该返回全局限流错误,但得到: %v", err) + } + + // 清理测试数据 + g.Redis().Do(ctx, "DEL", minuteKey) +} + +// TestGetRegisterRateLimitStatus 测试获取限流状态 +func TestGetRegisterRateLimitStatus(t *testing.T) { + ctx := context.Background() + testIP := "192.168.1.102" + + // 清理测试数据 + hourlyKey := "register:ip:hourly:" + testIP + dailyKey := "register:ip:daily:" + testIP + g.Redis().Do(ctx, "DEL", hourlyKey) + g.Redis().Do(ctx, "DEL", dailyKey) + + // 设置配置 + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "5") + g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "20") + + // 注册2次 + g.Redis().Do(ctx, "SETEX", hourlyKey, 3600, "2") + g.Redis().Do(ctx, "SETEX", dailyKey, 86400, "2") + + // 获取状态 + status := GetRegisterRateLimitStatus(ctx, testIP) + + // 验证状态 + if hourlyLimit, ok := status["hourly_limit"].(int); !ok || hourlyLimit != 5 { + t.Errorf("期望每小时限制为5,得到: %v", status["hourly_limit"]) + } + if hourlyRemaining, ok := status["hourly_remaining"].(int); !ok || hourlyRemaining != 3 { + t.Errorf("期望每小时剩余3次,得到: %v", status["hourly_remaining"]) + } + if dailyLimit, ok := status["daily_limit"].(int); !ok || dailyLimit != 20 { + t.Errorf("期望每天限制为20,得到: %v", status["daily_limit"]) + } + if dailyRemaining, ok := status["daily_remaining"].(int); !ok || dailyRemaining != 18 { + t.Errorf("期望每天剩余18次,得到: %v", status["daily_remaining"]) + } + + // 清理测试数据 + g.Redis().Do(ctx, "DEL", hourlyKey) + g.Redis().Do(ctx, "DEL", dailyKey) +} diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 1fd34a8..13effad 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -59,6 +59,12 @@ var Registry = []SettingDef{ Label: "开放注册", Description: "是否允许新用户注册", IsPublic: true}, {Key: "register_email_verification", Type: SettingTypeBool, Default: "false", Category: "general", Label: "注册邮箱验证", Description: "注册时是否需要邮箱验证码,关闭时使用滑块验证", IsPublic: true}, + {Key: "register_ip_limit_per_hour", Type: SettingTypeInt, Default: "3", Category: "general", + Label: "IP每小时注册限制", Description: "同一IP每小时最多注册次数(0表示不限制)", Validation: "min:0,max:20"}, + {Key: "register_ip_limit_per_day", Type: SettingTypeInt, Default: "10", Category: "general", + Label: "IP每天注册限制", Description: "同一IP每天最多注册次数(0表示不限制)", Validation: "min:0,max:100"}, + {Key: "register_global_limit_per_minute", Type: SettingTypeInt, Default: "10", Category: "general", + Label: "全局每分钟注册限制", Description: "全系统每分钟最多注册次数(0表示不限制)", Validation: "min:0,max:100"}, {Key: "maintenance_mode", Type: SettingTypeBool, Default: "false", Category: "general", Label: "维护模式", Description: "开启后控制台显示维护提示"}, {Key: "maintenance_message", Type: SettingTypeString, Default: "", Category: "general", diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 43ffb13..7b5f957 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -40,6 +40,12 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error()) } + // Check register rate limit (IP + global) + ipAddress := g.RequestFromCtx(ctx).GetClientIp() + if err := common.CheckRegisterRateLimit(ctx, ipAddress); err != nil { + return nil, err + } + // 根据配置选择验证方式 emailVerificationEnabled := common.Config().GetBool(ctx, "register_email_verification") if emailVerificationEnabled { @@ -80,8 +86,6 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. var tenantID, ownerUserID int64 - ipAddress := g.RequestFromCtx(ctx).GetClientIp() - err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { // Create tenant tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{ From e5e7080ddfcec76f072356ae715d78d5b7cd9c4d Mon Sep 17 00:00:00 2001 From: chengqian Date: Tue, 9 Jun 2026 18:51:45 +0800 Subject: [PATCH 03/17] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0Turnstile=E9=AA=8C?= =?UTF-8?q?=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/tenant/v1/auth.go | 32 ++-- internal/logic/common/captcha.go | 44 +++++ internal/logic/tenant/auth.go | 11 +- .../src/components/common/Turnstile.vue | 154 ++++++++++++++++++ .../src/composables/usePublicSettings.ts | 2 + web/tenant/src/views/auth/LoginPage.vue | 2 + web/tenant/src/views/auth/RegisterPage.vue | 2 + 7 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 web/tenant/src/components/common/Turnstile.vue diff --git a/api/tenant/v1/auth.go b/api/tenant/v1/auth.go index ee1bd98..122ad3a 100644 --- a/api/tenant/v1/auth.go +++ b/api/tenant/v1/auth.go @@ -4,15 +4,16 @@ import "github.com/gogf/gf/v2/frame/g" // TenantRegisterReq 租户注册请求 type TenantRegisterReq struct { - g.Meta `path:"/auth/register" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户注册" group:"public" middleware:"-"` - Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"` - Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"` - Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"` - TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"` - TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"` - Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"` - CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(邮箱验证关闭时必填)"` - CaptchaX int `json:"captcha_x" dc:"滑块X坐标(邮箱验证关闭时必填)"` + g.Meta `path:"/auth/register" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户注册" group:"public" middleware:"-"` + Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"` + Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"` + Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"` + TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"` + TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"` + Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"` + CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(Turnstile关闭时必填)"` + CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"` + TurnstileToken string `json:"turnstile_token" dc:"Cloudflare Turnstile验证token(Turnstile启用时必填)"` } type TenantRegisterRes struct { @@ -33,12 +34,13 @@ type TenantRegisterRes struct { // TenantLoginReq 租户登录请求 type TenantLoginReq struct { - g.Meta `path:"/auth/login" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户登录" group:"public" middleware:"-"` - Account string `json:"account" v:"required#请输入账号" dc:"账号(username@tenant_code 或邮箱)"` - Password string `json:"password" v:"required#请输入密码" dc:"密码"` - Type string `json:"type" v:"required|in:ram,admin#请选择登录方式|登录方式无效" dc:"登录方式:ram(RAM账号)/ admin(管理员邮箱)"` - CaptchaKey string `json:"captcha_key" dc:"验证码key(验证码启用时必填)"` - CaptchaX int `json:"captcha_x" dc:"滑块X坐标(验证码启用时必填)"` + g.Meta `path:"/auth/login" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户登录" group:"public" middleware:"-"` + Account string `json:"account" v:"required#请输入账号" dc:"账号(username@tenant_code 或邮箱)"` + Password string `json:"password" v:"required#请输入密码" dc:"密码"` + Type string `json:"type" v:"required|in:ram,admin#请选择登录方式|登录方式无效" dc:"登录方式:ram(RAM账号)/ admin(管理员邮箱)"` + CaptchaKey string `json:"captcha_key" dc:"验证码key(Turnstile关闭时必填)"` + CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"` + TurnstileToken string `json:"turnstile_token" dc:"Cloudflare Turnstile验证token(Turnstile启用时必填)"` } type TenantLoginRes struct { diff --git a/internal/logic/common/captcha.go b/internal/logic/common/captcha.go index 5eca1f1..6084b5d 100644 --- a/internal/logic/common/captcha.go +++ b/internal/logic/common/captcha.go @@ -15,6 +15,7 @@ import ( "github.com/wenlng/go-captcha/v2/slide" "github.com/qianfree/team-api/internal/consts" + "github.com/qianfree/team-api/internal/utility/turnstile" ) const ( @@ -211,3 +212,46 @@ func getCaptchaExpireSeconds(ctx context.Context) time.Duration { } return time.Duration(secs) * time.Second } + +// CheckTurnstileRequired checks Cloudflare Turnstile verification when enabled. +// If Turnstile is disabled, passes through (allows fallback to captcha). +func CheckTurnstileRequired(ctx context.Context, token string) error { + // Check if Turnstile is enabled + enabled := Config().GetBool(ctx, "turnstile_enabled") + if !enabled { + // Turnstile not enabled, allow pass-through to captcha + return nil + } + + // Turnstile is enabled, token is required + if token == "" { + return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired) + } + + // Verify token with Cloudflare + secretKey := Config().GetString(ctx, "turnstile_secret_key") + if secretKey == "" { + g.Log().Errorf(ctx, "[Turnstile] secret_key not configured") + return NewBusinessError(consts.CodeInternalServerError, "验证服务配置错误") + } + + // Get client IP for verification + ipAddress := "" + if r := g.RequestFromCtx(ctx); r != nil { + ipAddress = r.GetClientIp() + } + + result, err := turnstile.Verify(ctx, secretKey, token, ipAddress) + if err != nil { + g.Log().Errorf(ctx, "[Turnstile] verification failed: %v", err) + return NewBusinessError(consts.CodeCaptchaFailed, "人机验证失败") + } + + if !result.Success { + g.Log().Warningf(ctx, "[Turnstile] verification failed: error codes=%v", result.ErrorCodes) + return NewBusinessError(consts.CodeCaptchaFailed, "人机验证失败") + } + + // Verification successful + return nil +} diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 7b5f957..4c4b99a 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -58,7 +58,12 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. return nil, err } } else { - // 滑块验证模式 + // 人机验证:优先 Turnstile,否则滑块验证 + if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { + return nil, err + } + + // Turnstile 未启用时,使用滑块验证 if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil { return nil, err } @@ -188,6 +193,10 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant account := strings.TrimSpace(req.Account) // Check captcha if required + if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { + return nil, err + } + if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil { return nil, err } diff --git a/web/tenant/src/components/common/Turnstile.vue b/web/tenant/src/components/common/Turnstile.vue new file mode 100644 index 0000000..d9cceb9 --- /dev/null +++ b/web/tenant/src/components/common/Turnstile.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/web/tenant/src/composables/usePublicSettings.ts b/web/tenant/src/composables/usePublicSettings.ts index 04441e7..908bd35 100644 --- a/web/tenant/src/composables/usePublicSettings.ts +++ b/web/tenant/src/composables/usePublicSettings.ts @@ -24,6 +24,8 @@ const settings = ref({ site_description: '', demo_mode: false, demo_message: '', + turnstile_enabled: false, + turnstile_site_key: '', }) let fetchPromise: Promise | null = null diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index 4ebcf9b..05de066 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -8,6 +8,7 @@ import request from '@/utils/request' import AuthLayout from '@/components/layout/AuthLayout.vue' import Icon from '@/components/common/Icon.vue' import SlideCaptcha from '@/components/common/SlideCaptcha.vue' +import Turnstile from '@/components/common/Turnstile.vue const router = useRouter() const route = useRoute() @@ -35,6 +36,7 @@ const totpLoading = ref(false) // Captcha state — always required const captcha = reactive({ captchaKey: '', captchaX: 0 }) + const turnstileToken = ref('') const captchaRef = ref | null>(null) const emailInput = ref(null) diff --git a/web/tenant/src/views/auth/RegisterPage.vue b/web/tenant/src/views/auth/RegisterPage.vue index 24468c8..7c44ab4 100644 --- a/web/tenant/src/views/auth/RegisterPage.vue +++ b/web/tenant/src/views/auth/RegisterPage.vue @@ -5,6 +5,7 @@ import { useTenantAuthStore } from '@/stores/tenant-auth' import { usePublicSettings } from '@/composables/usePublicSettings' import AuthLayout from '@/components/layout/AuthLayout.vue' import SlideCaptcha from '@/components/common/SlideCaptcha.vue' +import Turnstile from '@/components/common/Turnstile.vue import Icon from '@/components/common/Icon.vue' import request from '@/utils/request' import { extractApiError } from '@/utils/request' @@ -19,6 +20,7 @@ const codeSending = ref(false) const countdown = ref(0) const emailVerification = ref(true) const captcha = ref<{ captchaKey: string; captchaX: number }>({ captchaKey: '', captchaX: 0 }) + const turnstileToken = ref('') let countdownTimer: ReturnType | null = null const orgForm = reactive({ orgName: '', orgCode: '' }) From 974efb6685007da35984ea99155a7e6758c3e89d Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 9 Jun 2026 22:53:20 +0800 Subject: [PATCH 04/17] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E9=A1=B5=E9=9D=A2=E5=B8=83=E5=B0=94=E5=80=BC?= =?UTF-8?q?=E8=BD=AC=E4=B8=BA=E5=AD=97=E7=AC=A6=E4=B8=B2=E7=9A=84=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=A4=84=E7=90=86=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/admin/settings.go | 35 +++++++++++++++++++ .../src/views/system/settings/ChannelTab.vue | 12 +++---- .../system/settings/DataGovernanceTab.vue | 4 +-- .../src/views/system/settings/EmailTab.vue | 4 +-- .../src/views/system/settings/GeneralTab.vue | 16 ++++----- .../src/views/system/settings/OAuthTab.vue | 12 +++---- .../views/system/settings/PerformanceTab.vue | 8 ++--- .../src/views/system/settings/SecurityTab.vue | 2 +- .../src/views/system/settings/StorageTab.vue | 4 +-- .../src/views/system/settings/useSettings.ts | 7 +--- 10 files changed, 67 insertions(+), 37 deletions(-) diff --git a/internal/logic/admin/settings.go b/internal/logic/admin/settings.go index 070d6e0..0ddcc3d 100644 --- a/internal/logic/admin/settings.go +++ b/internal/logic/admin/settings.go @@ -2,6 +2,7 @@ package admin import ( "context" + "strings" v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/logic/common" @@ -56,12 +57,46 @@ func (s *sAdmin) UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdate for key, val := range req.Settings { strValues[key] = common.NormalizeSettingValue(val) } + + if err := s.validateCrossFieldSettings(ctx, req.Category, strValues); err != nil { + return nil, err + } + if err := common.Config().UpdateCategory(ctx, req.Category, strValues); err != nil { return nil, err } return nil, nil } +// validateCrossFieldSettings validates interdependent settings within a category. +func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string, values map[string]string) error { + if category != "security" { + return nil + } + + enabled, ok := values["turnstile_enabled"] + if !ok || (enabled != "true" && enabled != "1") { + return nil + } + + siteKey := values["turnstile_site_key"] + secretKey := values["turnstile_secret_key"] + + // If keys not in the current request, read existing values from DB + if siteKey == "" { + siteKey = common.Config().GetString(ctx, "turnstile_site_key") + } + if secretKey == "" || secretKey == "******" { + secretKey = common.Config().GetString(ctx, "turnstile_secret_key") + } + + if strings.TrimSpace(siteKey) == "" || strings.TrimSpace(secretKey) == "" { + return common.NewBadRequestError("启用 Turnstile 前必须先配置 Site Key 和 Secret Key") + } + + return nil +} + func isValidCategory(category string) bool { for _, c := range common.Categories { if c.Key == category { diff --git a/web/admin/src/views/system/settings/ChannelTab.vue b/web/admin/src/views/system/settings/ChannelTab.vue index 9e3fe75..0b1fc18 100644 --- a/web/admin/src/views/system/settings/ChannelTab.vue +++ b/web/admin/src/views/system/settings/ChannelTab.vue @@ -12,16 +12,16 @@ const values = useFormValues() 渠道自动探测 定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)
自动恢复探测 定期测试已自动禁用的渠道,测试通过则恢复启用(依赖自动探测开启)
@@ -33,8 +33,8 @@ const values = useFormValues() 渠道自动禁用 连续失败达到阈值时自动禁用渠道
diff --git a/web/admin/src/views/system/settings/DataGovernanceTab.vue b/web/admin/src/views/system/settings/DataGovernanceTab.vue index aae21ee..021defb 100644 --- a/web/admin/src/views/system/settings/DataGovernanceTab.vue +++ b/web/admin/src/views/system/settings/DataGovernanceTab.vue @@ -47,8 +47,8 @@ function set(key: string, v: number | undefined): void {
启用文件保留期检查
diff --git a/web/admin/src/views/system/settings/EmailTab.vue b/web/admin/src/views/system/settings/EmailTab.vue index 8ca0ee4..74989ea 100644 --- a/web/admin/src/views/system/settings/EmailTab.vue +++ b/web/admin/src/views/system/settings/EmailTab.vue @@ -40,8 +40,8 @@ const values = useFormValues() 启用 TLS 587/465 端口建议开启 diff --git a/web/admin/src/views/system/settings/GeneralTab.vue b/web/admin/src/views/system/settings/GeneralTab.vue index 2583e9b..1955d14 100644 --- a/web/admin/src/views/system/settings/GeneralTab.vue +++ b/web/admin/src/views/system/settings/GeneralTab.vue @@ -28,16 +28,16 @@ const values = useFormValues() 开放注册 是否允许新用户注册
注册邮箱验证 注册时是否需要邮箱验证码,关闭时使用滑块验证
@@ -49,8 +49,8 @@ const values = useFormValues() 启用维护模式 开启后控制台显示维护提示
@@ -65,8 +65,8 @@ const values = useFormValues() 全局 API 维护 开启后 API 代理返回 503
diff --git a/web/admin/src/views/system/settings/OAuthTab.vue b/web/admin/src/views/system/settings/OAuthTab.vue index c3f4425..8978e8d 100644 --- a/web/admin/src/views/system/settings/OAuthTab.vue +++ b/web/admin/src/views/system/settings/OAuthTab.vue @@ -12,8 +12,8 @@ const values = useFormValues() OAuth 自动注册 首次 OAuth 登录自动创建租户账号
@@ -30,8 +30,8 @@ const values = useFormValues() 启用 GitHub 登录
@@ -51,8 +51,8 @@ const values = useFormValues() 启用 Google 登录
diff --git a/web/admin/src/views/system/settings/PerformanceTab.vue b/web/admin/src/views/system/settings/PerformanceTab.vue index 855d538..f8d99c6 100644 --- a/web/admin/src/views/system/settings/PerformanceTab.vue +++ b/web/admin/src/views/system/settings/PerformanceTab.vue @@ -65,8 +65,8 @@ function set(key: string, v: number | undefined): void {
@@ -107,8 +107,8 @@ function set(key: string, v: number | undefined): void {
diff --git a/web/admin/src/views/system/settings/SecurityTab.vue b/web/admin/src/views/system/settings/SecurityTab.vue index 45f2204..a7ae65b 100644 --- a/web/admin/src/views/system/settings/SecurityTab.vue +++ b/web/admin/src/views/system/settings/SecurityTab.vue @@ -89,7 +89,7 @@ const values = useFormValues() diff --git a/web/admin/src/views/system/settings/StorageTab.vue b/web/admin/src/views/system/settings/StorageTab.vue index 98637fa..da2b5c0 100644 --- a/web/admin/src/views/system/settings/StorageTab.vue +++ b/web/admin/src/views/system/settings/StorageTab.vue @@ -47,8 +47,8 @@ const values = useFormValues()
启用 SSL
diff --git a/web/admin/src/views/system/settings/useSettings.ts b/web/admin/src/views/system/settings/useSettings.ts index 1b2c3ec..64f8854 100644 --- a/web/admin/src/views/system/settings/useSettings.ts +++ b/web/admin/src/views/system/settings/useSettings.ts @@ -19,12 +19,7 @@ export function useSettings(category: () => string) { const items = res.data?.data?.list || [] const vals: Record = {} for (const item of items) { - let val = item.value ?? item.default ?? '' - // Normalize bool values to strings for consistent switch handling - if (item.type === 'bool') { - val = String(val) - } - vals[item.key] = val + vals[item.key] = item.value ?? item.default ?? '' } Object.keys(formValues).forEach(k => delete formValues[k]) Object.assign(formValues, vals) From d5ae4f1ff24245a228eecc27665376c480f0972f Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 9 Jun 2026 22:54:37 +0800 Subject: [PATCH 05/17] =?UTF-8?q?fix(type):=E4=BF=AE=E5=A4=8D=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E7=9A=84=E5=BC=95=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/tenant/src/views/auth/LoginPage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index 05de066..a17b123 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -8,7 +8,7 @@ import request from '@/utils/request' import AuthLayout from '@/components/layout/AuthLayout.vue' import Icon from '@/components/common/Icon.vue' import SlideCaptcha from '@/components/common/SlideCaptcha.vue' -import Turnstile from '@/components/common/Turnstile.vue +import Turnstile from '@/components/common/Turnstile.vue' const router = useRouter() const route = useRoute() From 8dc9266c1477f08f5798d2d9c7a025f41abdbbf7 Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 9 Jun 2026 23:14:27 +0800 Subject: [PATCH 06/17] =?UTF-8?q?improve:=E7=BD=91=E9=A1=B5=E4=B8=AD?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E9=85=8D=E7=BD=AE=E7=9A=84=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E5=92=8C=E4=BB=8B=E7=BB=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/admin/src/router/index.ts | 7 +++++- web/admin/src/views/AdminLayout.vue | 19 ++++++-------- web/admin/src/views/LoginPage.vue | 10 +++++--- .../src/views/system/settings/GeneralTab.vue | 2 +- .../src/components/layout/AuthLayout.vue | 9 ++++--- web/tenant/src/composables/useSeo.ts | 6 +++-- web/tenant/src/router/index.ts | 5 +++- web/tenant/src/views/Layout.vue | 3 +++ web/tenant/src/views/auth/RegisterPage.vue | 2 +- web/tenant/src/views/landing/LandingPage.vue | 25 +++++++++++-------- 10 files changed, 53 insertions(+), 35 deletions(-) diff --git a/web/admin/src/router/index.ts b/web/admin/src/router/index.ts index 771cb2d..2af1bfa 100644 --- a/web/admin/src/router/index.ts +++ b/web/admin/src/router/index.ts @@ -3,6 +3,7 @@ import type { RouteRecordRaw } from 'vue-router' import adminRoutes from './admin' import { useAuthStore } from '@/stores/auth' import { shouldRefresh, getRefreshToken } from '@/utils/request' +import { useSiteName } from '@/composables/useSiteName' import axios from 'axios' const routes: RouteRecordRaw[] = [ @@ -42,12 +43,16 @@ async function checkSetupStatus(): Promise { return systemInitialized === true } +const { siteName, fetchSiteName } = useSiteName() + router.beforeEach(async (to) => { const authStore = useAuthStore() authStore.loadFromStorage() if (to.meta.title) { - document.title = `${to.meta.title as string} — Team-API` + fetchSiteName() + const name = siteName.value || 'Team-API' + document.title = `${to.meta.title as string} — ${name}` } if (to.name === 'AdminSetup') { diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index aab0cb1..d2bdf66 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -6,6 +6,7 @@ import axios from 'axios' import { marked } from 'marked' import request from '@/utils/request' import { useWatermark } from '@/composables/useWatermark' +import { useSiteName } from '@/composables/useSiteName' import { IconDashboard, IconUserGroup, @@ -37,6 +38,7 @@ import { useAuthStore } from '@/stores/auth' const router = useRouter() const route = useRoute() const authStore = useAuthStore() +const { siteName } = useSiteName() const collapsed = ref(false) const mobileMenuOpen = ref(false) @@ -445,10 +447,10 @@ onUnmounted(() => {