package controller import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/console_setting" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" ) func TestStatus(c *gin.Context) { err := model.PingDB() if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "success": false, "message": "数据库连接失败", }) return } // 获取HTTP统计信息 httpStats := middleware.GetStats() c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Server is running", "http_stats": httpStats, }) return } func GetStatus(c *gin.Context) { cs := console_setting.GetConsoleSetting() common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() passkeySetting := system_setting.GetPasskeySettings() legalSetting := system_setting.GetLegalSettings() distributorMinWithdrawQuota := int(common.QuotaPerUnit) if raw := strings.TrimSpace(common.Interface2String(common.OptionMap["DistributorMinWithdrawQuota"])); raw != "" { if n, err := strconv.Atoi(raw); err == nil && n > 0 { distributorMinWithdrawQuota = n } } data := gin.H{ "version": common.Version, "start_time": common.StartTime, "email_verification": common.EmailVerificationEnabled, "sms_verification_enabled": common.SMSVerificationEnabled, "github_oauth": common.GitHubOAuthEnabled, "github_client_id": common.GitHubClientId, "discord_oauth": system_setting.GetDiscordSettings().Enabled, "discord_client_id": system_setting.GetDiscordSettings().ClientId, "linuxdo_oauth": common.LinuxDOOAuthEnabled, "linuxdo_client_id": common.LinuxDOClientId, "linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel, "telegram_oauth": common.TelegramOAuthEnabled, "telegram_bot_name": common.TelegramBotName, "system_name": common.SystemName, "logo": common.Logo, "footer_html": common.Footer, "wechat_qrcode": common.WeChatAccountQRCodeImageURL, "wechat_login": common.WeChatAuthEnabled, "server_address": system_setting.ServerAddress, "turnstile_check": common.TurnstileCheckEnabled, "turnstile_site_key": common.TurnstileSiteKey, "captcha_enabled": common.CaptchaEnabled, "top_up_link": common.TopUpLink, "docs_link": operation_setting.GetGeneralSetting().DocsLink, "default_site_language": operation_setting.GetDefaultSiteLanguage(), "quota_per_unit": common.QuotaPerUnit, // 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type "display_in_currency": operation_setting.IsCurrencyDisplay(), "quota_display_type": operation_setting.GetQuotaDisplayType(), "recharge_display_currency": operation_setting.GetGeneralSetting().RechargeDisplayCurrency, "custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol, "custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate, "enable_batch_update": common.BatchUpdateEnabled, "enable_drawing": common.DrawingEnabled, "enable_task": common.TaskEnabled, "enable_data_export": common.DataExportEnabled, "data_export_default_time": common.DataExportDefaultTime, "default_collapse_sidebar": common.DefaultCollapseSidebar, "mj_notify_enabled": setting.MjNotifyEnabled, "chats": setting.Chats, "demo_site_enabled": operation_setting.DemoSiteEnabled, "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, "model_default_docs_enabled": common.OptionMap["ModelDefaultDocsEnabled"] != "false", "default_use_auto_group": setting.DefaultUseAutoGroup, "usd_exchange_rate": operation_setting.USDExchangeRate, "price": operation_setting.Price, "stripe_unit_price": setting.StripeUnitPrice, // 面板启用开关 "api_info_enabled": cs.ApiInfoEnabled, "uptime_kuma_enabled": cs.UptimeKumaEnabled, "announcements_enabled": cs.AnnouncementsEnabled, "faq_enabled": cs.FAQEnabled, // 模块管理配置 "HeaderNavModules": common.OptionMap["HeaderNavModules"], "SidebarModulesByRole": common.OptionMap["SidebarModulesByRole"], "oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_client_id": system_setting.GetOIDCSettings().ClientId, "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, "passkey_login": passkeySetting.Enabled, "passkey_display_name": passkeySetting.RPDisplayName, "passkey_rp_id": passkeySetting.RPID, "passkey_origins": passkeySetting.Origins, "passkey_allow_insecure": passkeySetting.AllowInsecureOrigin, "passkey_user_verification": passkeySetting.UserVerification, "passkey_attachment": passkeySetting.AttachmentPreference, "setup": constant.Setup, "user_agreement_enabled": legalSetting.UserAgreement != "", "privacy_policy_enabled": legalSetting.PrivacyPolicy != "", "checkin_enabled": operation_setting.GetCheckinSetting().Enabled, "distributor_apply_cs_image_url": common.OptionMap["DistributorApplyCsImageUrl"], "distributor_withdraw_cs_image_url": common.OptionMap["DistributorWithdrawCsImageUrl"], "distributor_withdraw_notice": common.OptionMap["DistributorWithdrawNotice"], "distributor_apply_intro_html": common.OptionMap["DistributorApplyIntroHtml"], "distributor_min_withdraw_quota": distributorMinWithdrawQuota, "affiliate_default_commission_bps": common.AffiliateDefaultCommissionBps, "distributor_commission_mode": common.DistributorCommissionMode, "home_banner_slides": strings.TrimSpace(common.Interface2String(common.OptionMap["HomeBannerSlides"])), } // 根据启用状态注入可选内容 if cs.ApiInfoEnabled { data["api_info"] = console_setting.GetApiInfo() } if cs.AnnouncementsEnabled { data["announcements"] = console_setting.GetAnnouncements() } if cs.FAQEnabled { data["faq"] = console_setting.GetFAQ() } // Add enabled custom OAuth providers customProviders := oauth.GetEnabledCustomProviders() if len(customProviders) > 0 { type CustomOAuthInfo struct { Id int `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Icon string `json:"icon"` ClientId string `json:"client_id"` AuthorizationEndpoint string `json:"authorization_endpoint"` Scopes string `json:"scopes"` } providersInfo := make([]CustomOAuthInfo, 0, len(customProviders)) for _, p := range customProviders { config := p.GetConfig() providersInfo = append(providersInfo, CustomOAuthInfo{ Id: config.Id, Name: config.Name, Slug: config.Slug, Icon: config.Icon, ClientId: config.ClientId, AuthorizationEndpoint: config.AuthorizationEndpoint, Scopes: config.Scopes, }) } data["custom_oauth_providers"] = providersInfo } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": data, }) return } func GetNotice(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": common.OptionMap["Notice"], }) return } func GetAbout(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": common.OptionMap["About"], }) return } func GetUserAgreement(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": system_setting.GetLegalSettings().UserAgreement, }) return } func GetPrivacyPolicy(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": system_setting.GetLegalSettings().PrivacyPolicy, }) return } func GetMidjourney(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": common.OptionMap["Midjourney"], }) return } func GetHomePageContent(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": common.OptionMap["HomePageContent"], }) return } func SendEmailVerification(c *gin.Context) { email := c.Query("email") if err := common.Validate.Var(email, "required,email"); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无效的参数", }) return } parts := strings.Split(email, "@") if len(parts) != 2 { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无效的邮箱地址", }) return } localPart := parts[0] domainPart := parts[1] if common.EmailDomainRestrictionEnabled { allowed := false for _, domain := range common.EmailDomainWhitelist { if domainPart == domain { allowed = true break } } if !allowed { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "The administrator has enabled the email domain name whitelist, and your email address is not allowed due to special symbols or it's not in the whitelist.", }) return } } if common.EmailAliasRestrictionEnabled { containsSpecialSymbols := strings.Contains(localPart, "+") || strings.Contains(localPart, ".") if containsSpecialSymbols { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "管理员已启用邮箱地址别名限制,您的邮箱地址由于包含特殊符号而被拒绝。", }) return } } if model.IsEmailAlreadyTaken(email) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "邮箱地址已被占用", }) return } code := common.GenerateVerificationCode(6) common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose) subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName) content := fmt.Sprintf("

您好,你正在进行%s邮箱验证。

"+ "

您的验证码为: %s

"+ "

验证码 %d 分钟内有效,如果不是本人操作,请忽略。

", common.SystemName, code, common.VerificationValidMinutes) err := common.SendEmail(subject, email, content) if err != nil { common.ApiError(c, err) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } func SendPasswordResetEmail(c *gin.Context) { email := c.Query("email") if err := common.Validate.Var(email, "required,email"); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无效的参数", }) return } if model.IsEmailAlreadyTaken(email) { code := common.GenerateVerificationCode(0) common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose) link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code) subject := fmt.Sprintf("%s密码重置", common.SystemName) content := fmt.Sprintf("

您好,你正在进行%s密码重置。

"+ "

点击 此处 进行密码重置。

"+ "

如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:
%s

"+ "

重置链接 %d 分钟内有效,如果不是本人操作,请忽略。

", common.SystemName, link, link, common.VerificationValidMinutes) err := common.SendEmail(subject, email, content) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email to %s: %s", email, err.Error())) } } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) } // validatePublicEmailConstraints 校验公开流程中的邮箱格式、域名白名单与别名限制(与注册/验证邮件一致)。 func validatePublicEmailConstraints(email string) *gin.H { if err := common.Validate.Var(email, "required,email"); err != nil { return &gin.H{"success": false, "message": "无效的参数"} } parts := strings.Split(email, "@") if len(parts) != 2 { return &gin.H{"success": false, "message": "无效的邮箱地址"} } localPart := parts[0] domainPart := parts[1] if common.EmailDomainRestrictionEnabled { allowed := false for _, domain := range common.EmailDomainWhitelist { if domainPart == domain { allowed = true break } } if !allowed { return &gin.H{ "success": false, "message": "The administrator has enabled the email domain name whitelist, and your email address is not allowed due to special symbols or it's not in the whitelist.", } } } if common.EmailAliasRestrictionEnabled { if strings.Contains(localPart, "+") || strings.Contains(localPart, ".") { return &gin.H{"success": false, "message": "管理员已启用邮箱地址别名限制,您的邮箱地址由于包含特殊符号而被拒绝。"} } } return nil } // SendPasswordResetEmailCode 发送忘记密码用的邮箱数字验证码(仅已绑定该邮箱的用户实际收到邮件;未注册亦返回成功以保护隐私)。 func SendPasswordResetEmailCode(c *gin.Context) { email := strings.TrimSpace(c.Query("email")) if bad := validatePublicEmailConstraints(email); bad != nil { c.JSON(http.StatusOK, *bad) return } if model.IsEmailAlreadyTaken(email) { code := common.GenerateNumericVerificationCode(6) common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetEmailCodePurpose) subject := fmt.Sprintf("%s密码重置验证码", common.SystemName) content := fmt.Sprintf("

您好,您正在进行%s密码重置。

"+ "

您的验证码为: %s

"+ "

验证码 %d 分钟内有效,如非本人操作请忽略。

", common.SystemName, code, common.VerificationValidMinutes) if err := common.SendEmail(subject, email, content); err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email code to %s: %s", email, err.Error())) } } c.JSON(http.StatusOK, gin.H{"success": true, "message": ""}) } // PasswordResetByEmailCodeRequest 通过邮箱验证码重置密码的请求体。 type PasswordResetByEmailCodeRequest struct { Email string `json:"email"` VerificationCode string `json:"verification_code"` NewPassword string `json:"new_password"` ConfirmPassword string `json:"confirm_password"` } // ResetPasswordByEmailCode 校验邮箱验证码后将密码更新为用户指定的新密码。 func ResetPasswordByEmailCode(c *gin.Context) { var req PasswordResetByEmailCodeRequest if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": "无效的参数"}) return } req.Email = strings.TrimSpace(req.Email) req.VerificationCode = strings.TrimSpace(req.VerificationCode) req.NewPassword = strings.TrimSpace(req.NewPassword) req.ConfirmPassword = strings.TrimSpace(req.ConfirmPassword) if bad := validatePublicEmailConstraints(req.Email); bad != nil { c.JSON(http.StatusOK, *bad) return } if len(req.VerificationCode) != 6 { c.JSON(http.StatusOK, gin.H{"success": false, "message": "请输入 6 位邮箱验证码"}) return } if !common.VerifyCodeWithKey(req.Email, req.VerificationCode, common.PasswordResetEmailCodePurpose) { c.JSON(http.StatusOK, gin.H{"success": false, "message": "验证码错误或已过期"}) return } if len(req.NewPassword) < 8 || len(req.NewPassword) > 20 { c.JSON(http.StatusOK, gin.H{"success": false, "message": "密码长度需为 8-20 位"}) return } if req.NewPassword != req.ConfirmPassword { c.JSON(http.StatusOK, gin.H{"success": false, "message": "两次输入的密码不一致"}) return } if !model.IsEmailAlreadyTaken(req.Email) { c.JSON(http.StatusOK, gin.H{"success": false, "message": "该邮箱未注册"}) return } if err := model.ResetUserPasswordByEmail(req.Email, req.NewPassword); err != nil { common.ApiError(c, err) return } common.DeleteKey(req.Email, common.PasswordResetEmailCodePurpose) c.JSON(http.StatusOK, gin.H{"success": true, "message": ""}) } type PasswordResetRequest struct { Email string `json:"email"` Token string `json:"token"` } func ResetPassword(c *gin.Context) { var req PasswordResetRequest err := json.NewDecoder(c.Request.Body).Decode(&req) if req.Email == "" || req.Token == "" { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无效的参数", }) return } if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "重置链接非法或已过期", }) return } password := common.GenerateVerificationCode(12) err = model.ResetUserPasswordByEmail(req.Email, password) if err != nil { common.ApiError(c, err) return } common.DeleteKey(req.Email, common.PasswordResetPurpose) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": password, }) return } // SendPasswordResetSMS 发送“忘记密码”短信验证码(仅已注册手机号可发送)。 func SendPasswordResetSMS(c *gin.Context) { if !common.SMSVerificationEnabled { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "短信验证码功能未启用", }) return } phone := common.NormalizePhone(c.Query("phone")) if !common.ValidateMainlandChinaPhone(phone) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "手机号格式无效,请输入 11 位中国大陆手机号", }) return } if !model.IsPhoneAlreadyTaken(phone) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "该手机号未注册", }) return } if common.IsSMSPhoneBlacklisted(phone) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "该手机号已被加入短信黑名单", }) return } if err := common.CheckSMSCanSend(phone); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } code := common.GenerateNumericVerificationCode(6) if err := service.SendAliyunSMSCode(phone, code); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } if err := common.RecordSMSSend(phone); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } if err := common.StoreSMSVerificationCode(phone, code); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "短信验证码存储失败,请稍后重试", }) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) } // PasswordResetByPhoneRequest 手机号找回密码请求体。 type PasswordResetByPhoneRequest struct { Phone string `json:"phone"` SMSCode string `json:"sms_verification_code"` NewPassword string `json:"new_password"` ConfirmPassword string `json:"confirm_password"` } // ResetPasswordByPhone 通过手机号+短信验证码重置密码。 func ResetPasswordByPhone(c *gin.Context) { var req PasswordResetByPhoneRequest if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无效的参数", }) return } req.Phone = common.NormalizePhone(req.Phone) req.SMSCode = strings.TrimSpace(req.SMSCode) req.NewPassword = strings.TrimSpace(req.NewPassword) req.ConfirmPassword = strings.TrimSpace(req.ConfirmPassword) if !common.ValidateMainlandChinaPhone(req.Phone) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "手机号格式无效,请输入 11 位中国大陆手机号", }) return } if !model.IsPhoneAlreadyTaken(req.Phone) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "该手机号未注册", }) return } if len(req.SMSCode) != 6 { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "请输入 6 位短信验证码", }) return } if !common.VerifyAndConsumeSMSCode(req.Phone, req.SMSCode) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "短信验证码错误或已过期", }) return } if len(req.NewPassword) < 8 || len(req.NewPassword) > 20 { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "密码长度需为 8-20 位", }) return } if req.NewPassword != req.ConfirmPassword { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "两次输入的密码不一致", }) return } if err := model.ResetUserPasswordByPhone(req.Phone, req.NewPassword); err != nil { common.ApiError(c, err) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) }