Merge pull request #2492 from DaydreamCoding/feat/dingtalk-login

feat(dingtalk): 钉钉 OAuth 登录接入 + internal_only 用户属性同步
This commit is contained in:
Wesley Liddick 2026-05-19 15:36:13 +08:00 committed by GitHub
commit 1b6ed24c33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 5545 additions and 365 deletions

View File

@ -81,7 +81,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
}
totpCache := repository.NewTotpCache(redisClient)
totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService)
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService)
userAttributeDefinitionRepository := repository.NewUserAttributeDefinitionRepository(client)
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService)
userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(client, db)
@ -198,7 +201,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
registry := payment.ProvideRegistry()
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService)
opsHandler := admin.NewOpsHandler(opsService)
updateCache := repository.NewUpdateCache(redisClient)
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
@ -211,9 +214,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
usageCleanupRepository := repository.NewUsageCleanupRepository(client, db)
usageCleanupService := service.ProvideUsageCleanupService(usageCleanupRepository, timingWheelService, dashboardAggregationService, configConfig)
adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService, usageCleanupService)
userAttributeDefinitionRepository := repository.NewUserAttributeDefinitionRepository(client)
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService)
errorPassthroughRepository := repository.NewErrorPassthroughRepository(client)
errorPassthroughCache := repository.NewErrorPassthroughCache(redisClient)

View File

@ -15,12 +15,13 @@ import (
)
var authProviderTypes = map[string]struct{}{
"email": {},
"github": {},
"google": {},
"linuxdo": {},
"oidc": {},
"wechat": {},
"email": {},
"github": {},
"google": {},
"linuxdo": {},
"oidc": {},
"wechat": {},
"dingtalk": {},
}
func validateAuthProviderType(value string) error {

View File

@ -83,7 +83,7 @@ func TestAuthIdentityFoundationSchemas(t *testing.T) {
require.Equal(t, 1, signupSource.Validators)
validator := requireStringFieldValidator(t, User{}.Fields(), "signup_source")
for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google"} {
for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk"} {
require.NoError(t, validator(value))
}
require.Error(t, validator("unknown"))

View File

@ -77,10 +77,10 @@ func (User) Fields() []ent.Field {
field.String("signup_source").
Validate(func(value string) error {
switch value {
case "email", "linuxdo", "wechat", "oidc", "github", "google":
case "email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk":
return nil
default:
return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google")
return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google, dingtalk")
}
}).
Default("email"),

View File

@ -72,6 +72,7 @@ type Config struct {
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
DingTalk DingTalkConnectConfig `mapstructure:"dingtalk_connect"`
GitHubOAuth EmailOAuthProviderConfig `mapstructure:"github_oauth"`
GoogleOAuth EmailOAuthProviderConfig `mapstructure:"google_oauth"`
Default DefaultConfig `mapstructure:"default"`
@ -242,6 +243,47 @@ type OIDCConnectConfig struct {
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
}
type DingTalkConnectConfig struct {
Enabled bool `mapstructure:"enabled"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
AuthorizeURL string `mapstructure:"authorize_url"`
TokenURL string `mapstructure:"token_url"`
UserInfoURL string `mapstructure:"userinfo_url"`
Scopes string `mapstructure:"scopes"`
RedirectURL string `mapstructure:"redirect_url"`
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"`
// 平台底座 + 业务行为
DingTalkAppKind string `mapstructure:"dingtalk_app_kind"` // 仅 "internal_app"V4 fail-closed
AppType string `mapstructure:"app_type"` // "public" (default) | "internal"
// Corp 限定none | internal_only
CorpRestrictionPolicy string `mapstructure:"corp_restriction_policy"`
InternalCorpID string `mapstructure:"internal_corp_id"`
BypassRegistration bool `mapstructure:"bypass_registration"`
SyncCorpEmail bool `mapstructure:"sync_corp_email"`
SyncDisplayName bool `mapstructure:"sync_display_name"`
SyncDept bool `mapstructure:"sync_dept"`
SyncCorpEmailAttrKey string `mapstructure:"sync_corp_email_attr_key"`
SyncDisplayNameAttrKey string `mapstructure:"sync_display_name_attr_key"`
SyncDeptAttrKey string `mapstructure:"sync_dept_attr_key"`
SyncCorpEmailAttrName string `mapstructure:"sync_corp_email_attr_name"`
SyncDisplayNameAttrName string `mapstructure:"sync_display_name_attr_name"`
SyncDeptAttrName string `mapstructure:"sync_dept_attr_name"`
// 邮箱 + Username
RequireEmail bool `mapstructure:"require_email"`
UsernameOverwritePolicy string `mapstructure:"username_overwrite_policy"`
// Attribute私有版扩展点开源版仅声明
UsernameAttributeKey string `mapstructure:"username_attribute_key"`
EnableAttributeMatching bool `mapstructure:"enable_attribute_matching"`
EnableAttributeSync bool `mapstructure:"enable_attribute_sync"`
AttributeSyncFields []string `mapstructure:"attribute_sync_fields"`
AttributeSyncOverwritePolicy string `mapstructure:"attribute_sync_overwrite_policy"`
}
type EmailOAuthProviderConfig struct {
Enabled bool `mapstructure:"enabled"`
ClientID string `mapstructure:"client_id"`
@ -1536,6 +1578,19 @@ func setDefaults() {
viper.SetDefault("oidc_connect.userinfo_id_path", "")
viper.SetDefault("oidc_connect.userinfo_username_path", "")
// DingTalk Connect OAuth 登录
viper.SetDefault("dingtalk_connect.enabled", false)
viper.SetDefault("dingtalk_connect.authorize_url", "https://login.dingtalk.com/oauth2/auth")
viper.SetDefault("dingtalk_connect.token_url", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
viper.SetDefault("dingtalk_connect.userinfo_url", "https://api.dingtalk.com/v1.0/contact/users/me")
viper.SetDefault("dingtalk_connect.scopes", "openid")
viper.SetDefault("dingtalk_connect.frontend_redirect_url", "/auth/dingtalk/callback")
viper.SetDefault("dingtalk_connect.dingtalk_app_kind", "internal_app")
viper.SetDefault("dingtalk_connect.app_type", "public")
viper.SetDefault("dingtalk_connect.corp_restriction_policy", "none")
viper.SetDefault("dingtalk_connect.require_email", true)
viper.SetDefault("dingtalk_connect.username_overwrite_policy", "if_empty")
// Database
viper.SetDefault("database.host", "localhost")
viper.SetDefault("database.port", 5432)
@ -2608,6 +2663,9 @@ func (c *Config) Validate() error {
if c.Concurrency.PingInterval < 5 || c.Concurrency.PingInterval > 30 {
return fmt.Errorf("concurrency.ping_interval must be between 5-30 seconds")
}
if err := ValidateDingTalkConfig(c.DingTalk); err != nil {
return fmt.Errorf("dingtalk_connect: %w", err)
}
return nil
}

View File

@ -0,0 +1,30 @@
// Package config 包含钉钉连接配置的校验逻辑。
//
// internal_only 模式安全模型(方案 A
// 不再要求 admin 填写 InternalCorpID 做二次 corpID 比对。
// 安全边界由钉钉"企业内部应用"类型本身保证——只有应用所属企业的员工才能完成 OAuth
// 因此 ValidateDingTalkConfig 只要求 app_type=internalV1不再要求 InternalCorpID 非空(原 V3 已删除)。
// InternalCorpID 字段保留admin 可选填若填写checkDingTalkCorpAllowed 不会使用它做约束。
package config
import "errors"
var (
ErrDingTalkV1AppTypeMismatch = errors.New("dingtalk: internal_only requires app_type=internal")
ErrDingTalkV4InvalidAppKind = errors.New("dingtalk: dingtalk_app_kind must be internal_app")
)
func ValidateDingTalkConfig(cfg DingTalkConnectConfig) error {
if !cfg.Enabled {
return nil
}
if cfg.DingTalkAppKind != "internal_app" {
return ErrDingTalkV4InvalidAppKind
}
if cfg.CorpRestrictionPolicy == "internal_only" {
if cfg.AppType != "internal" {
return ErrDingTalkV1AppTypeMismatch
}
}
return nil
}

View File

@ -0,0 +1,53 @@
package config
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestValidateDingTalkConfig_Disabled_Skip(t *testing.T) {
require.NoError(t, ValidateDingTalkConfig(DingTalkConnectConfig{Enabled: false}))
}
func TestValidateDingTalkConfig_V4_DingTalkAppKind(t *testing.T) {
err := ValidateDingTalkConfig(DingTalkConnectConfig{
Enabled: true,
DingTalkAppKind: "third_party_enterprise_app",
CorpRestrictionPolicy: "none",
})
require.ErrorIs(t, err, ErrDingTalkV4InvalidAppKind)
}
func TestValidateDingTalkConfig_V1_InternalOnlyRequiresInternalAppType(t *testing.T) {
err := ValidateDingTalkConfig(DingTalkConnectConfig{
Enabled: true,
DingTalkAppKind: "internal_app",
AppType: "public",
CorpRestrictionPolicy: "internal_only",
InternalCorpID: "dingABC",
})
require.ErrorIs(t, err, ErrDingTalkV1AppTypeMismatch)
}
// TestValidateDingTalkConfig_V3_InternalOnlyAllowsEmptyCorpID 验证方案 A
// internal_only 策略下InternalCorpID="" 应通过校验(企业隔离由钉钉 AppType=internal 保证)。
func TestValidateDingTalkConfig_V3_InternalOnlyAllowsEmptyCorpID(t *testing.T) {
err := ValidateDingTalkConfig(DingTalkConnectConfig{
Enabled: true,
DingTalkAppKind: "internal_app",
AppType: "internal",
CorpRestrictionPolicy: "internal_only",
InternalCorpID: "",
})
require.NoError(t, err)
}
func TestValidateDingTalkConfig_HappyPath_None(t *testing.T) {
require.NoError(t, ValidateDingTalkConfig(DingTalkConnectConfig{
Enabled: true,
DingTalkAppKind: "internal_app",
AppType: "public",
CorpRestrictionPolicy: "none",
}))
}

View File

@ -1,9 +1,11 @@
package admin
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
@ -60,10 +62,11 @@ type SettingHandler struct {
opsService *service.OpsService
paymentConfigService *service.PaymentConfigService
paymentService *service.PaymentService
userAttributeService *service.UserAttributeService
}
// NewSettingHandler 创建系统设置处理器
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService) *SettingHandler {
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService) *SettingHandler {
return &SettingHandler{
settingService: settingService,
emailService: emailService,
@ -71,6 +74,7 @@ func NewSettingHandler(settingService *service.SettingService, emailService *ser
opsService: opsService,
paymentConfigService: paymentConfigService,
paymentService: paymentService,
userAttributeService: userAttributeService,
}
}
@ -135,6 +139,22 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL,
DingTalkConnectEnabled: settings.DingTalkConnectEnabled,
DingTalkConnectClientID: settings.DingTalkConnectClientID,
DingTalkConnectClientSecretConfigured: settings.DingTalkConnectClientSecretConfigured,
DingTalkConnectRedirectURL: settings.DingTalkConnectRedirectURL,
DingTalkConnectCorpRestrictionPolicy: settings.DingTalkConnectCorpRestrictionPolicy,
DingTalkConnectInternalCorpID: settings.DingTalkConnectInternalCorpID,
DingTalkConnectBypassRegistration: settings.DingTalkConnectBypassRegistration,
DingTalkConnectSyncCorpEmail: settings.DingTalkConnectSyncCorpEmail,
DingTalkConnectSyncDisplayName: settings.DingTalkConnectSyncDisplayName,
DingTalkConnectSyncDept: settings.DingTalkConnectSyncDept,
DingTalkConnectSyncCorpEmailAttrKey: settings.DingTalkConnectSyncCorpEmailAttrKey,
DingTalkConnectSyncDisplayNameAttrKey: settings.DingTalkConnectSyncDisplayNameAttrKey,
DingTalkConnectSyncDeptAttrKey: settings.DingTalkConnectSyncDeptAttrKey,
DingTalkConnectSyncCorpEmailAttrName: settings.DingTalkConnectSyncCorpEmailAttrName,
DingTalkConnectSyncDisplayNameAttrName: settings.DingTalkConnectSyncDisplayNameAttrName,
DingTalkConnectSyncDeptAttrName: settings.DingTalkConnectSyncDeptAttrName,
WeChatConnectEnabled: settings.WeChatConnectEnabled,
WeChatConnectAppID: settings.WeChatConnectAppID,
WeChatConnectAppSecretConfigured: settings.WeChatConnectAppSecretConfigured,
@ -376,6 +396,24 @@ type UpdateSettingsRequest struct {
LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"`
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
// DingTalk Connect OAuth 登录
DingTalkConnectEnabled bool `json:"dingtalk_connect_enabled"`
DingTalkConnectClientID string `json:"dingtalk_connect_client_id"`
DingTalkConnectClientSecret string `json:"dingtalk_connect_client_secret"`
DingTalkConnectRedirectURL string `json:"dingtalk_connect_redirect_url"`
DingTalkConnectCorpRestrictionPolicy string `json:"dingtalk_connect_corp_restriction_policy"`
DingTalkConnectInternalCorpID string `json:"dingtalk_connect_internal_corp_id"`
DingTalkConnectBypassRegistration bool `json:"dingtalk_connect_bypass_registration"`
DingTalkConnectSyncCorpEmail bool `json:"dingtalk_connect_sync_corp_email"`
DingTalkConnectSyncDisplayName bool `json:"dingtalk_connect_sync_display_name"`
DingTalkConnectSyncDept bool `json:"dingtalk_connect_sync_dept"`
DingTalkConnectSyncCorpEmailAttrKey string `json:"dingtalk_connect_sync_corp_email_attr_key"`
DingTalkConnectSyncDisplayNameAttrKey string `json:"dingtalk_connect_sync_display_name_attr_key"`
DingTalkConnectSyncDeptAttrKey string `json:"dingtalk_connect_sync_dept_attr_key"`
DingTalkConnectSyncCorpEmailAttrName string `json:"dingtalk_connect_sync_corp_email_attr_name"`
DingTalkConnectSyncDisplayNameAttrName string `json:"dingtalk_connect_sync_display_name_attr_name"`
DingTalkConnectSyncDeptAttrName string `json:"dingtalk_connect_sync_dept_attr_name"`
// WeChat Connect OAuth 登录
WeChatConnectEnabled bool `json:"wechat_connect_enabled"`
WeChatConnectAppID string `json:"wechat_connect_app_id"`
@ -446,45 +484,50 @@ type UpdateSettingsRequest struct {
CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"`
// 默认配置
DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"`
AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"`
AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"`
AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"`
AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
AuthSourceDefaultEmailConcurrency *int `json:"auth_source_default_email_concurrency"`
AuthSourceDefaultEmailSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_email_subscriptions"`
AuthSourceDefaultEmailGrantOnSignup *bool `json:"auth_source_default_email_grant_on_signup"`
AuthSourceDefaultEmailGrantOnFirstBind *bool `json:"auth_source_default_email_grant_on_first_bind"`
AuthSourceDefaultLinuxDoBalance *float64 `json:"auth_source_default_linuxdo_balance"`
AuthSourceDefaultLinuxDoConcurrency *int `json:"auth_source_default_linuxdo_concurrency"`
AuthSourceDefaultLinuxDoSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_linuxdo_subscriptions"`
AuthSourceDefaultLinuxDoGrantOnSignup *bool `json:"auth_source_default_linuxdo_grant_on_signup"`
AuthSourceDefaultLinuxDoGrantOnFirstBind *bool `json:"auth_source_default_linuxdo_grant_on_first_bind"`
AuthSourceDefaultOIDCBalance *float64 `json:"auth_source_default_oidc_balance"`
AuthSourceDefaultOIDCConcurrency *int `json:"auth_source_default_oidc_concurrency"`
AuthSourceDefaultOIDCSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_oidc_subscriptions"`
AuthSourceDefaultOIDCGrantOnSignup *bool `json:"auth_source_default_oidc_grant_on_signup"`
AuthSourceDefaultOIDCGrantOnFirstBind *bool `json:"auth_source_default_oidc_grant_on_first_bind"`
AuthSourceDefaultWeChatBalance *float64 `json:"auth_source_default_wechat_balance"`
AuthSourceDefaultWeChatConcurrency *int `json:"auth_source_default_wechat_concurrency"`
AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"`
AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"`
AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"`
AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"`
AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"`
AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"`
AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"`
AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"`
AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"`
AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"`
AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"`
AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"`
AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"`
ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"`
DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"`
AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"`
AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"`
AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"`
AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
AuthSourceDefaultEmailConcurrency *int `json:"auth_source_default_email_concurrency"`
AuthSourceDefaultEmailSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_email_subscriptions"`
AuthSourceDefaultEmailGrantOnSignup *bool `json:"auth_source_default_email_grant_on_signup"`
AuthSourceDefaultEmailGrantOnFirstBind *bool `json:"auth_source_default_email_grant_on_first_bind"`
AuthSourceDefaultLinuxDoBalance *float64 `json:"auth_source_default_linuxdo_balance"`
AuthSourceDefaultLinuxDoConcurrency *int `json:"auth_source_default_linuxdo_concurrency"`
AuthSourceDefaultLinuxDoSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_linuxdo_subscriptions"`
AuthSourceDefaultLinuxDoGrantOnSignup *bool `json:"auth_source_default_linuxdo_grant_on_signup"`
AuthSourceDefaultLinuxDoGrantOnFirstBind *bool `json:"auth_source_default_linuxdo_grant_on_first_bind"`
AuthSourceDefaultOIDCBalance *float64 `json:"auth_source_default_oidc_balance"`
AuthSourceDefaultOIDCConcurrency *int `json:"auth_source_default_oidc_concurrency"`
AuthSourceDefaultOIDCSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_oidc_subscriptions"`
AuthSourceDefaultOIDCGrantOnSignup *bool `json:"auth_source_default_oidc_grant_on_signup"`
AuthSourceDefaultOIDCGrantOnFirstBind *bool `json:"auth_source_default_oidc_grant_on_first_bind"`
AuthSourceDefaultWeChatBalance *float64 `json:"auth_source_default_wechat_balance"`
AuthSourceDefaultWeChatConcurrency *int `json:"auth_source_default_wechat_concurrency"`
AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"`
AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"`
AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"`
AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"`
AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"`
AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"`
AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"`
AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"`
AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"`
AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"`
AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"`
AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"`
AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"`
AuthSourceDefaultDingTalkBalance *float64 `json:"auth_source_default_dingtalk_balance"`
AuthSourceDefaultDingTalkConcurrency *int `json:"auth_source_default_dingtalk_concurrency"`
AuthSourceDefaultDingTalkSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_dingtalk_subscriptions"`
AuthSourceDefaultDingTalkGrantOnSignup *bool `json:"auth_source_default_dingtalk_grant_on_signup"`
AuthSourceDefaultDingTalkGrantOnFirstBind *bool `json:"auth_source_default_dingtalk_grant_on_first_bind"`
ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"`
// Model fallback configuration
EnableModelFallback bool `json:"enable_model_fallback"`
@ -661,6 +704,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
req.AuthSourceDefaultLinuxDoSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultLinuxDoSubscriptions)
req.AuthSourceDefaultOIDCSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultOIDCSubscriptions)
req.AuthSourceDefaultWeChatSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultWeChatSubscriptions)
req.AuthSourceDefaultDingTalkSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultDingTalkSubscriptions)
// SMTP 配置保护:如果请求中 smtp_host 为空但数据库中已有配置,则保留已有 SMTP 配置
// 防止前端加载设置失败时空表单覆盖已保存的 SMTP 配置
@ -777,6 +821,100 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
// DingTalk Connect 参数验证
// 防御性:任何写入路径上把已废弃的 corp_restriction_policy=whitelist 入参 coerce 为 none
// 避免任何直连 admin API 的客户端把死值写回 DB前端 UI 已无此选项)。
req.DingTalkConnectCorpRestrictionPolicy = service.CoerceDingTalkCorpPolicyForWrite(req.DingTalkConnectCorpRestrictionPolicy)
if req.DingTalkConnectEnabled {
req.DingTalkConnectClientID = strings.TrimSpace(req.DingTalkConnectClientID)
req.DingTalkConnectClientSecret = strings.TrimSpace(req.DingTalkConnectClientSecret)
req.DingTalkConnectRedirectURL = strings.TrimSpace(req.DingTalkConnectRedirectURL)
req.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(req.DingTalkConnectCorpRestrictionPolicy)
req.DingTalkConnectInternalCorpID = strings.TrimSpace(req.DingTalkConnectInternalCorpID)
if req.DingTalkConnectClientID == "" {
response.BadRequest(c, "DingTalk Client ID is required when enabled")
return
}
if req.DingTalkConnectRedirectURL == "" {
response.BadRequest(c, "DingTalk Redirect URL is required when enabled")
return
}
if err := config.ValidateAbsoluteHTTPURL(req.DingTalkConnectRedirectURL); err != nil {
response.BadRequest(c, "DingTalk Redirect URL must be an absolute http(s) URL")
return
}
// 如果未提供 client_secret则保留现有值如有
if req.DingTalkConnectClientSecret == "" {
if previousSettings.DingTalkConnectClientSecret == "" {
response.BadRequest(c, "DingTalk Client Secret is required when enabled")
return
}
req.DingTalkConnectClientSecret = previousSettings.DingTalkConnectClientSecret
}
// Corp 策略校验V1/V4 fail-closed
dingTalkCfg := config.DingTalkConnectConfig{
Enabled: true,
DingTalkAppKind: "internal_app", // 硬编码settings 层仅支持 internal_app
AppType: "internal", // 对于 internal_only 策略的默认值
CorpRestrictionPolicy: req.DingTalkConnectCorpRestrictionPolicy,
InternalCorpID: req.DingTalkConnectInternalCorpID,
}
// 若未填 corp_restriction_policy保留已有配置
if dingTalkCfg.CorpRestrictionPolicy == "" {
dingTalkCfg.CorpRestrictionPolicy = previousSettings.DingTalkConnectCorpRestrictionPolicy
}
// 对于 internal_only 策略app_type 必须为 internalV1 校验)
if dingTalkCfg.CorpRestrictionPolicy == "internal_only" {
dingTalkCfg.AppType = "internal"
} else {
dingTalkCfg.AppType = "public"
}
if err := config.ValidateDingTalkConfig(dingTalkCfg); err != nil {
response.ErrorWithDetails(c, http.StatusBadRequest, err.Error(), mapDingTalkValidateError(err), nil)
return
}
// bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制为 false
// 防止 admin 在切换 policy 时把 bypass 残留在 DB 中(前端 UI 也已隐藏该开关)。
if dingTalkCfg.CorpRestrictionPolicy != "internal_only" {
req.DingTalkConnectBypassRegistration = false
// 身份同步三开关同理:仅 internal_only 模式下有意义,其它策略强制 false。
req.DingTalkConnectSyncCorpEmail = false
req.DingTalkConnectSyncDisplayName = false
req.DingTalkConnectSyncDept = false
}
// 身份同步目标 attr keytrimSpace + 空值 fallback 到默认值
req.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(req.DingTalkConnectSyncCorpEmailAttrKey)
if req.DingTalkConnectSyncCorpEmailAttrKey == "" {
req.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email"
}
req.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(req.DingTalkConnectSyncDisplayNameAttrKey)
if req.DingTalkConnectSyncDisplayNameAttrKey == "" {
req.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name"
}
req.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(req.DingTalkConnectSyncDeptAttrKey)
if req.DingTalkConnectSyncDeptAttrKey == "" {
req.DingTalkConnectSyncDeptAttrKey = "dingtalk_department"
}
// 身份同步目标 attr 显示名称trim + 空值 fallback 到默认中文名
req.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(req.DingTalkConnectSyncCorpEmailAttrName)
if req.DingTalkConnectSyncCorpEmailAttrName == "" {
req.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱"
}
req.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(req.DingTalkConnectSyncDisplayNameAttrName)
if req.DingTalkConnectSyncDisplayNameAttrName == "" {
req.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名"
}
req.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(req.DingTalkConnectSyncDeptAttrName)
if req.DingTalkConnectSyncDeptAttrName == "" {
req.DingTalkConnectSyncDeptAttrName = "钉钉部门"
}
}
if req.WeChatConnectEnabled {
req.WeChatConnectAppID = strings.TrimSpace(req.WeChatConnectAppID)
req.WeChatConnectAppSecret = strings.TrimSpace(req.WeChatConnectAppSecret)
@ -1272,113 +1410,129 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
settings := &service.SystemSettings{
RegistrationEnabled: req.RegistrationEnabled,
EmailVerifyEnabled: req.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: req.PromoCodeEnabled,
PasswordResetEnabled: req.PasswordResetEnabled,
FrontendURL: req.FrontendURL,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled,
LoginAgreementEnabled: req.LoginAgreementEnabled,
LoginAgreementMode: loginAgreementMode,
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocuments,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
SMTPUsername: req.SMTPUsername,
SMTPPassword: req.SMTPPassword,
SMTPFrom: req.SMTPFrom,
SMTPFromName: req.SMTPFromName,
SMTPUseTLS: req.SMTPUseTLS,
TurnstileEnabled: req.TurnstileEnabled,
TurnstileSiteKey: req.TurnstileSiteKey,
TurnstileSecretKey: req.TurnstileSecretKey,
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
WeChatConnectEnabled: req.WeChatConnectEnabled,
WeChatConnectAppID: req.WeChatConnectAppID,
WeChatConnectAppSecret: req.WeChatConnectAppSecret,
WeChatConnectOpenAppID: req.WeChatConnectOpenAppID,
WeChatConnectOpenAppSecret: req.WeChatConnectOpenAppSecret,
WeChatConnectMPAppID: req.WeChatConnectMPAppID,
WeChatConnectMPAppSecret: req.WeChatConnectMPAppSecret,
WeChatConnectMobileAppID: req.WeChatConnectMobileAppID,
WeChatConnectMobileAppSecret: req.WeChatConnectMobileAppSecret,
WeChatConnectOpenEnabled: req.WeChatConnectOpenEnabled,
WeChatConnectMPEnabled: req.WeChatConnectMPEnabled,
WeChatConnectMobileEnabled: req.WeChatConnectMobileEnabled,
WeChatConnectMode: req.WeChatConnectMode,
WeChatConnectScopes: req.WeChatConnectScopes,
WeChatConnectRedirectURL: req.WeChatConnectRedirectURL,
WeChatConnectFrontendRedirectURL: req.WeChatConnectFrontendRedirectURL,
OIDCConnectEnabled: req.OIDCConnectEnabled,
OIDCConnectProviderName: req.OIDCConnectProviderName,
OIDCConnectClientID: req.OIDCConnectClientID,
OIDCConnectClientSecret: req.OIDCConnectClientSecret,
OIDCConnectIssuerURL: req.OIDCConnectIssuerURL,
OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL,
OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL,
OIDCConnectTokenURL: req.OIDCConnectTokenURL,
OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL,
OIDCConnectJWKSURL: req.OIDCConnectJWKSURL,
OIDCConnectScopes: req.OIDCConnectScopes,
OIDCConnectRedirectURL: req.OIDCConnectRedirectURL,
OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL,
OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod,
OIDCConnectUsePKCE: oidcUsePKCE,
OIDCConnectValidateIDToken: oidcValidateIDToken,
OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs,
OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds,
OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified,
OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: req.GitHubOAuthEnabled,
GitHubOAuthClientID: req.GitHubOAuthClientID,
GitHubOAuthClientSecret: req.GitHubOAuthClientSecret,
GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: req.GoogleOAuthEnabled,
GoogleOAuthClientID: req.GoogleOAuthClientID,
GoogleOAuthClientSecret: req.GoogleOAuthClientSecret,
GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL,
SiteName: req.SiteName,
SiteLogo: req.SiteLogo,
SiteSubtitle: req.SiteSubtitle,
APIBaseURL: req.APIBaseURL,
ContactInfo: req.ContactInfo,
DocURL: req.DocURL,
HomeContent: req.HomeContent,
HideCcsImportButton: req.HideCcsImportButton,
PurchaseSubscriptionEnabled: purchaseEnabled,
PurchaseSubscriptionURL: purchaseURL,
TableDefaultPageSize: req.TableDefaultPageSize,
TablePageSizeOptions: req.TablePageSizeOptions,
CustomMenuItems: customMenuJSON,
CustomEndpoints: customEndpointsJSON,
DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance,
AffiliateRebateRate: affiliateRebateRate,
AffiliateRebateFreezeHours: affiliateRebateFreezeHours,
AffiliateRebateDurationDays: affiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap,
DefaultUserRPMLimit: req.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: req.EnableModelFallback,
FallbackModelAnthropic: req.FallbackModelAnthropic,
FallbackModelOpenAI: req.FallbackModelOpenAI,
FallbackModelGemini: req.FallbackModelGemini,
FallbackModelAntigravity: req.FallbackModelAntigravity,
EnableIdentityPatch: req.EnableIdentityPatch,
IdentityPatchPrompt: req.IdentityPatchPrompt,
MinClaudeCodeVersion: req.MinClaudeCodeVersion,
MaxClaudeCodeVersion: req.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
BackendModeEnabled: req.BackendModeEnabled,
RegistrationEnabled: req.RegistrationEnabled,
EmailVerifyEnabled: req.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: req.PromoCodeEnabled,
PasswordResetEnabled: req.PasswordResetEnabled,
FrontendURL: req.FrontendURL,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled,
LoginAgreementEnabled: req.LoginAgreementEnabled,
LoginAgreementMode: loginAgreementMode,
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocuments,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
SMTPUsername: req.SMTPUsername,
SMTPPassword: req.SMTPPassword,
SMTPFrom: req.SMTPFrom,
SMTPFromName: req.SMTPFromName,
SMTPUseTLS: req.SMTPUseTLS,
TurnstileEnabled: req.TurnstileEnabled,
TurnstileSiteKey: req.TurnstileSiteKey,
TurnstileSecretKey: req.TurnstileSecretKey,
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
DingTalkConnectEnabled: req.DingTalkConnectEnabled,
DingTalkConnectClientID: req.DingTalkConnectClientID,
DingTalkConnectClientSecret: req.DingTalkConnectClientSecret,
DingTalkConnectRedirectURL: req.DingTalkConnectRedirectURL,
DingTalkConnectCorpRestrictionPolicy: req.DingTalkConnectCorpRestrictionPolicy,
DingTalkConnectInternalCorpID: req.DingTalkConnectInternalCorpID,
DingTalkConnectBypassRegistration: req.DingTalkConnectBypassRegistration,
DingTalkConnectSyncCorpEmail: req.DingTalkConnectSyncCorpEmail,
DingTalkConnectSyncDisplayName: req.DingTalkConnectSyncDisplayName,
DingTalkConnectSyncDept: req.DingTalkConnectSyncDept,
DingTalkConnectSyncCorpEmailAttrKey: req.DingTalkConnectSyncCorpEmailAttrKey,
DingTalkConnectSyncDisplayNameAttrKey: req.DingTalkConnectSyncDisplayNameAttrKey,
DingTalkConnectSyncDeptAttrKey: req.DingTalkConnectSyncDeptAttrKey,
DingTalkConnectSyncCorpEmailAttrName: req.DingTalkConnectSyncCorpEmailAttrName,
DingTalkConnectSyncDisplayNameAttrName: req.DingTalkConnectSyncDisplayNameAttrName,
DingTalkConnectSyncDeptAttrName: req.DingTalkConnectSyncDeptAttrName,
WeChatConnectEnabled: req.WeChatConnectEnabled,
WeChatConnectAppID: req.WeChatConnectAppID,
WeChatConnectAppSecret: req.WeChatConnectAppSecret,
WeChatConnectOpenAppID: req.WeChatConnectOpenAppID,
WeChatConnectOpenAppSecret: req.WeChatConnectOpenAppSecret,
WeChatConnectMPAppID: req.WeChatConnectMPAppID,
WeChatConnectMPAppSecret: req.WeChatConnectMPAppSecret,
WeChatConnectMobileAppID: req.WeChatConnectMobileAppID,
WeChatConnectMobileAppSecret: req.WeChatConnectMobileAppSecret,
WeChatConnectOpenEnabled: req.WeChatConnectOpenEnabled,
WeChatConnectMPEnabled: req.WeChatConnectMPEnabled,
WeChatConnectMobileEnabled: req.WeChatConnectMobileEnabled,
WeChatConnectMode: req.WeChatConnectMode,
WeChatConnectScopes: req.WeChatConnectScopes,
WeChatConnectRedirectURL: req.WeChatConnectRedirectURL,
WeChatConnectFrontendRedirectURL: req.WeChatConnectFrontendRedirectURL,
OIDCConnectEnabled: req.OIDCConnectEnabled,
OIDCConnectProviderName: req.OIDCConnectProviderName,
OIDCConnectClientID: req.OIDCConnectClientID,
OIDCConnectClientSecret: req.OIDCConnectClientSecret,
OIDCConnectIssuerURL: req.OIDCConnectIssuerURL,
OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL,
OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL,
OIDCConnectTokenURL: req.OIDCConnectTokenURL,
OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL,
OIDCConnectJWKSURL: req.OIDCConnectJWKSURL,
OIDCConnectScopes: req.OIDCConnectScopes,
OIDCConnectRedirectURL: req.OIDCConnectRedirectURL,
OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL,
OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod,
OIDCConnectUsePKCE: oidcUsePKCE,
OIDCConnectValidateIDToken: oidcValidateIDToken,
OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs,
OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds,
OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified,
OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: req.GitHubOAuthEnabled,
GitHubOAuthClientID: req.GitHubOAuthClientID,
GitHubOAuthClientSecret: req.GitHubOAuthClientSecret,
GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: req.GoogleOAuthEnabled,
GoogleOAuthClientID: req.GoogleOAuthClientID,
GoogleOAuthClientSecret: req.GoogleOAuthClientSecret,
GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL,
SiteName: req.SiteName,
SiteLogo: req.SiteLogo,
SiteSubtitle: req.SiteSubtitle,
APIBaseURL: req.APIBaseURL,
ContactInfo: req.ContactInfo,
DocURL: req.DocURL,
HomeContent: req.HomeContent,
HideCcsImportButton: req.HideCcsImportButton,
PurchaseSubscriptionEnabled: purchaseEnabled,
PurchaseSubscriptionURL: purchaseURL,
TableDefaultPageSize: req.TableDefaultPageSize,
TablePageSizeOptions: req.TablePageSizeOptions,
CustomMenuItems: customMenuJSON,
CustomEndpoints: customEndpointsJSON,
DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance,
AffiliateRebateRate: affiliateRebateRate,
AffiliateRebateFreezeHours: affiliateRebateFreezeHours,
AffiliateRebateDurationDays: affiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap,
DefaultUserRPMLimit: req.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: req.EnableModelFallback,
FallbackModelAnthropic: req.FallbackModelAnthropic,
FallbackModelOpenAI: req.FallbackModelOpenAI,
FallbackModelGemini: req.FallbackModelGemini,
FallbackModelAntigravity: req.FallbackModelAntigravity,
EnableIdentityPatch: req.EnableIdentityPatch,
IdentityPatchPrompt: req.IdentityPatchPrompt,
MinClaudeCodeVersion: req.MinClaudeCodeVersion,
MaxClaudeCodeVersion: req.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
BackendModeEnabled: req.BackendModeEnabled,
OpsMonitoringEnabled: func() bool {
if req.OpsMonitoringEnabled != nil {
return *req.OpsMonitoringEnabled
@ -1574,6 +1728,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnSignup, previousAuthSourceDefaults.Google.GrantOnSignup),
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnFirstBind, previousAuthSourceDefaults.Google.GrantOnFirstBind),
},
DingTalk: service.ProviderDefaultGrantSettings{
Balance: float64ValueOrDefault(req.AuthSourceDefaultDingTalkBalance, previousAuthSourceDefaults.DingTalk.Balance),
Concurrency: intValueOrDefault(req.AuthSourceDefaultDingTalkConcurrency, previousAuthSourceDefaults.DingTalk.Concurrency),
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultDingTalkSubscriptions, previousAuthSourceDefaults.DingTalk.Subscriptions),
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnSignup, previousAuthSourceDefaults.DingTalk.GrantOnSignup),
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnFirstBind, previousAuthSourceDefaults.DingTalk.GrantOnFirstBind),
},
ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup),
}
if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil {
@ -1632,6 +1793,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
h.ensureDingTalkSyncAttributes(c.Request.Context(), updatedSettings)
updatedAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
@ -1682,6 +1844,22 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL,
DingTalkConnectEnabled: updatedSettings.DingTalkConnectEnabled,
DingTalkConnectClientID: updatedSettings.DingTalkConnectClientID,
DingTalkConnectClientSecretConfigured: updatedSettings.DingTalkConnectClientSecretConfigured,
DingTalkConnectRedirectURL: updatedSettings.DingTalkConnectRedirectURL,
DingTalkConnectCorpRestrictionPolicy: updatedSettings.DingTalkConnectCorpRestrictionPolicy,
DingTalkConnectInternalCorpID: updatedSettings.DingTalkConnectInternalCorpID,
DingTalkConnectBypassRegistration: updatedSettings.DingTalkConnectBypassRegistration,
DingTalkConnectSyncCorpEmail: updatedSettings.DingTalkConnectSyncCorpEmail,
DingTalkConnectSyncDisplayName: updatedSettings.DingTalkConnectSyncDisplayName,
DingTalkConnectSyncDept: updatedSettings.DingTalkConnectSyncDept,
DingTalkConnectSyncCorpEmailAttrKey: updatedSettings.DingTalkConnectSyncCorpEmailAttrKey,
DingTalkConnectSyncDisplayNameAttrKey: updatedSettings.DingTalkConnectSyncDisplayNameAttrKey,
DingTalkConnectSyncDeptAttrKey: updatedSettings.DingTalkConnectSyncDeptAttrKey,
DingTalkConnectSyncCorpEmailAttrName: updatedSettings.DingTalkConnectSyncCorpEmailAttrName,
DingTalkConnectSyncDisplayNameAttrName: updatedSettings.DingTalkConnectSyncDisplayNameAttrName,
DingTalkConnectSyncDeptAttrName: updatedSettings.DingTalkConnectSyncDeptAttrName,
WeChatConnectEnabled: updatedSettings.WeChatConnectEnabled,
WeChatConnectAppID: updatedSettings.WeChatConnectAppID,
WeChatConnectAppSecretConfigured: updatedSettings.WeChatConnectAppSecretConfigured,
@ -1822,6 +2000,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
// hasPaymentFields returns true if any payment-related field was explicitly provided.
// mapDingTalkValidateError maps ValidateDingTalkConfig errors to machine-readable reason codes.
func mapDingTalkValidateError(err error) string {
switch {
case errors.Is(err, config.ErrDingTalkV1AppTypeMismatch):
return "dingtalk_apptype_mismatch"
case errors.Is(err, config.ErrDingTalkV4InvalidAppKind):
return "dingtalk_app_kind_invalid"
default:
return "dingtalk_corp_config_invalid"
}
}
func hasPaymentFields(req UpdateSettingsRequest) bool {
return req.PaymentEnabled != nil || req.PaymentMinAmount != nil ||
req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil ||
@ -1935,6 +2125,45 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL {
changed = append(changed, "linuxdo_connect_redirect_url")
}
if before.DingTalkConnectEnabled != after.DingTalkConnectEnabled {
changed = append(changed, "dingtalk_connect_enabled")
}
if before.DingTalkConnectClientID != after.DingTalkConnectClientID {
changed = append(changed, "dingtalk_connect_client_id")
}
if req.DingTalkConnectClientSecret != "" {
changed = append(changed, "dingtalk_connect_client_secret")
}
if before.DingTalkConnectRedirectURL != after.DingTalkConnectRedirectURL {
changed = append(changed, "dingtalk_connect_redirect_url")
}
if before.DingTalkConnectCorpRestrictionPolicy != after.DingTalkConnectCorpRestrictionPolicy {
changed = append(changed, "dingtalk_connect_corp_restriction_policy")
}
if before.DingTalkConnectInternalCorpID != after.DingTalkConnectInternalCorpID {
changed = append(changed, "dingtalk_connect_internal_corp_id")
}
if before.DingTalkConnectBypassRegistration != after.DingTalkConnectBypassRegistration {
changed = append(changed, "dingtalk_connect_bypass_registration")
}
if before.DingTalkConnectSyncCorpEmail != after.DingTalkConnectSyncCorpEmail {
changed = append(changed, "dingtalk_connect_sync_corp_email")
}
if before.DingTalkConnectSyncDisplayName != after.DingTalkConnectSyncDisplayName {
changed = append(changed, "dingtalk_connect_sync_display_name")
}
if before.DingTalkConnectSyncDept != after.DingTalkConnectSyncDept {
changed = append(changed, "dingtalk_connect_sync_dept")
}
if before.DingTalkConnectSyncCorpEmailAttrKey != after.DingTalkConnectSyncCorpEmailAttrKey {
changed = append(changed, "dingtalk_connect_sync_corp_email_attr_key")
}
if before.DingTalkConnectSyncDisplayNameAttrKey != after.DingTalkConnectSyncDisplayNameAttrKey {
changed = append(changed, "dingtalk_connect_sync_display_name_attr_key")
}
if before.DingTalkConnectSyncDeptAttrKey != after.DingTalkConnectSyncDeptAttrKey {
changed = append(changed, "dingtalk_connect_sync_dept_attr_key")
}
if before.WeChatConnectEnabled != after.WeChatConnectEnabled {
changed = append(changed, "wechat_connect_enabled")
}
@ -2246,6 +2475,7 @@ func appendAuthSourceDefaultChanges(changed []string, before *service.AuthSource
{name: "wechat", before: before.WeChat, after: after.WeChat},
{name: "github", before: before.GitHub, after: after.GitHub},
{name: "google", before: before.Google, after: after.Google},
{name: "dingtalk", before: before.DingTalk, after: after.DingTalk},
}
for _, field := range fields {
if field.before.Balance != field.after.Balance {
@ -2350,6 +2580,11 @@ func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults
data["auth_source_default_linuxdo_subscriptions"] = authSourceDefaults.LinuxDo.Subscriptions
data["auth_source_default_linuxdo_grant_on_signup"] = authSourceDefaults.LinuxDo.GrantOnSignup
data["auth_source_default_linuxdo_grant_on_first_bind"] = authSourceDefaults.LinuxDo.GrantOnFirstBind
data["auth_source_default_dingtalk_balance"] = authSourceDefaults.DingTalk.Balance
data["auth_source_default_dingtalk_concurrency"] = authSourceDefaults.DingTalk.Concurrency
data["auth_source_default_dingtalk_subscriptions"] = authSourceDefaults.DingTalk.Subscriptions
data["auth_source_default_dingtalk_grant_on_signup"] = authSourceDefaults.DingTalk.GrantOnSignup
data["auth_source_default_dingtalk_grant_on_first_bind"] = authSourceDefaults.DingTalk.GrantOnFirstBind
data["auth_source_default_oidc_balance"] = authSourceDefaults.OIDC.Balance
data["auth_source_default_oidc_concurrency"] = authSourceDefaults.OIDC.Concurrency
data["auth_source_default_oidc_subscriptions"] = authSourceDefaults.OIDC.Subscriptions
@ -3044,3 +3279,56 @@ func (h *SettingHandler) TestWebSearchEmulation(c *gin.Context) {
}
response.Success(c, result)
}
// ensureDingTalkSyncAttributes 在保存 settings 后,按 admin 配置的 (attr key, attr name)
// 兜底 upsert 对应 user attribute definition不存在则创建存在但 name 不同则更新 name
// type/options/required 不变)。仅 internal_only + 对应 sync 开关开启时执行。
// 失败仅记录日志,不阻塞 settings 保存。
func (h *SettingHandler) ensureDingTalkSyncAttributes(ctx context.Context, settings *service.SystemSettings) {
if h.userAttributeService == nil || settings == nil {
return
}
if settings.DingTalkConnectCorpRestrictionPolicy != "internal_only" {
return
}
if settings.DingTalkConnectSyncDisplayName {
h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncDisplayNameAttrKey, settings.DingTalkConnectSyncDisplayNameAttrName, "钉钉 internal_only 登录时同步的钉钉姓名", service.AttributeTypeText)
}
if settings.DingTalkConnectSyncCorpEmail {
h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncCorpEmailAttrKey, settings.DingTalkConnectSyncCorpEmailAttrName, "钉钉 internal_only 登录时同步的企业邮箱", service.AttributeTypeEmail)
}
if settings.DingTalkConnectSyncDept {
h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncDeptAttrKey, settings.DingTalkConnectSyncDeptAttrName, "钉钉 internal_only 登录时同步的完整部门路径(如:公司/研发部)", service.AttributeTypeText)
}
}
func (h *SettingHandler) ensureUserAttributeDefinition(ctx context.Context, key, name, description string, attrType service.UserAttributeType) {
key = strings.TrimSpace(key)
if key == "" {
return
}
existing, err := h.userAttributeService.GetDefinitionByKey(ctx, key)
if err == nil && existing != nil {
if strings.TrimSpace(name) != "" && existing.Name != name {
if _, err := h.userAttributeService.UpdateDefinition(ctx, existing.ID, service.UpdateAttributeDefinitionInput{
Name: &name,
}); err != nil {
slog.Warn("dingtalk: update user attribute definition name failed", "key", key, "err", err.Error())
return
}
slog.Info("dingtalk: updated user attribute definition name", "key", key, "name", name)
}
return
}
if _, err := h.userAttributeService.CreateDefinition(ctx, service.CreateAttributeDefinitionInput{
Key: key,
Name: name,
Description: description,
Type: attrType,
Enabled: true,
}); err != nil {
slog.Warn("dingtalk: ensure user attribute definition failed", "key", key, "err", err.Error())
return
}
slog.Info("dingtalk: created user attribute definition", "key", key, "name", name, "type", attrType)
}

View File

@ -137,7 +137,7 @@ func TestSettingHandler_GetSettings_InjectsAuthSourceDefaults(t *testing.T) {
},
}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@ -174,7 +174,7 @@ func TestSettingHandler_UpdateSettings_PreservesOmittedAuthSourceDefaults(t *tes
},
}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
body := map[string]any{
"registration_enabled": true,
@ -214,7 +214,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
},
}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
body := map[string]any{
"promo_code_enabled": true,
@ -264,7 +264,7 @@ func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodS
},
}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
body := map[string]any{
"promo_code_enabled": false,
@ -309,7 +309,7 @@ func TestSettingHandler_UpdateSettings_PersistsExplicitFalseOIDCCompatibilityFla
},
}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
body := map[string]any{
"promo_code_enabled": true,
@ -388,7 +388,7 @@ func TestSettingHandler_UpdateSettings_DoesNotSolidifyImplicitOIDCSecurityDefaul
ClockSkewSeconds: 120,
},
})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
body := map[string]any{
"promo_code_enabled": true,
@ -417,7 +417,7 @@ func TestSettingHandler_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource(
},
}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
body := map[string]any{
"promo_code_enabled": true,
@ -450,7 +450,7 @@ func TestSettingHandler_UpdateSettings_DoesNotPersistPartialSystemSettingsWhenAu
err: errors.New("write auth source defaults failed"),
}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
body := map[string]any{
"registration_enabled": true,

View File

@ -0,0 +1,319 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// dingtalkSettingsRepoStub 复用 settingHandlerRepoStub已在 setting_handler_auth_source_defaults_test.go 定义)
func newDingTalkSettingsHandler() (*SettingHandler, *settingHandlerRepoStub) {
repo := &settingHandlerRepoStub{values: map[string]string{}}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
return handler, repo
}
// baseValidDingTalkBody 返回一个可以通过所有校验的最小合法 body。
func baseValidDingTalkBody() map[string]any {
return map[string]any{
"dingtalk_connect_enabled": true,
"dingtalk_connect_client_id": "test-client-id",
"dingtalk_connect_client_secret": "test-client-secret",
"dingtalk_connect_redirect_url": "https://example.com/auth/dingtalk/callback",
"dingtalk_connect_corp_restriction_policy": "none",
}
}
// TestSettingsPUT_DingTalk_V3_InternalOnlyAllowsEmptyCorpID 验证方案 A
// internal_only + internal_corp_id="" 应通过校验(→ 200不再是 400。
func TestSettingsPUT_DingTalk_V3_InternalOnlyAllowsEmptyCorpID(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := newDingTalkSettingsHandler()
body := baseValidDingTalkBody()
body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
body["dingtalk_connect_internal_corp_id"] = "" // 空值现在合法
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
}
// TestSettingsPUT_DingTalk_HappyPath_None 验证 none policy → 200
func TestSettingsPUT_DingTalk_HappyPath_None(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := newDingTalkSettingsHandler()
body := baseValidDingTalkBody()
body["dingtalk_connect_corp_restriction_policy"] = "none"
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
var resp response.Response
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
data, ok := resp.Data.(map[string]any)
require.True(t, ok)
require.Equal(t, true, data["dingtalk_connect_enabled"])
}
// TestSettingsPUT_DingTalk_HappyPath_InternalOnly_WithCorpID 验证 internal_only + corp_id → 200
func TestSettingsPUT_DingTalk_HappyPath_InternalOnly_WithCorpID(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := newDingTalkSettingsHandler()
body := baseValidDingTalkBody()
body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
body["dingtalk_connect_internal_corp_id"] = "ding-corp-123"
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
}
// TestSettingsPUT_DingTalk_BypassRegistration_RoundTrip 验证 bypass_registration 字段 save+load。
// 必须用 policy=internal_onlybypass 仅在该 policy 下生效,其它 policy 写入层会 coerce 为 false。
func TestSettingsPUT_DingTalk_BypassRegistration_RoundTrip(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := newDingTalkSettingsHandler()
body := baseValidDingTalkBody()
body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
body["dingtalk_connect_bypass_registration"] = true
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
var resp response.Response
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
data, ok := resp.Data.(map[string]any)
require.True(t, ok)
require.Equal(t, true, data["dingtalk_connect_bypass_registration"])
}
// TestSettingsPUT_DingTalk_Disabled_SkipsValidation 验证 disabled 时跳过 corp 校验 → 200。
// 用 enabled=true 时必然触发"Client ID is required when enabled"的空 client_id 作为
// 哨兵——只要 enabled=false 仍能 200 就证明跳过了。
func TestSettingsPUT_DingTalk_Disabled_SkipsValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := newDingTalkSettingsHandler()
body := map[string]any{
"dingtalk_connect_enabled": false,
"dingtalk_connect_client_id": "", // 这种空值在 enabled=true 时会被 400 拒绝
"dingtalk_connect_corp_restriction_policy": "internal_only",
}
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
}
// TestSettingsPUT_DingTalk_SyncFlags_InternalOnly_RoundTrip 验证三个 sync 开关在 internal_only 下可正常 save+load。
func TestSettingsPUT_DingTalk_SyncFlags_InternalOnly_RoundTrip(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := newDingTalkSettingsHandler()
body := baseValidDingTalkBody()
body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
body["dingtalk_connect_sync_corp_email"] = true
body["dingtalk_connect_sync_display_name"] = true
body["dingtalk_connect_sync_dept"] = true
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
var resp response.Response
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
data, ok := resp.Data.(map[string]any)
require.True(t, ok)
require.Equal(t, true, data["dingtalk_connect_sync_corp_email"], "sync_corp_email should be true for internal_only")
require.Equal(t, true, data["dingtalk_connect_sync_display_name"], "sync_display_name should be true for internal_only")
require.Equal(t, true, data["dingtalk_connect_sync_dept"], "sync_dept should be true for internal_only")
}
// TestSettingsPUT_DingTalk_SyncFlags_PolicyNone_CoercedToFalse 验证 policy=none 时三个 sync 开关被 coerce 为 false。
func TestSettingsPUT_DingTalk_SyncFlags_PolicyNone_CoercedToFalse(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := newDingTalkSettingsHandler()
body := baseValidDingTalkBody()
body["dingtalk_connect_corp_restriction_policy"] = "none"
body["dingtalk_connect_sync_corp_email"] = true
body["dingtalk_connect_sync_display_name"] = true
body["dingtalk_connect_sync_dept"] = true
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
var resp response.Response
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
data, ok := resp.Data.(map[string]any)
require.True(t, ok)
require.Equal(t, false, data["dingtalk_connect_sync_corp_email"], "sync_corp_email must be coerced to false when policy=none")
require.Equal(t, false, data["dingtalk_connect_sync_display_name"], "sync_display_name must be coerced to false when policy=none")
require.Equal(t, false, data["dingtalk_connect_sync_dept"], "sync_dept must be coerced to false when policy=none")
}
// TestSettingsPUT_DingTalk_StaleWhitelist_CoercedToNone 验证升级兼容:
// admin 直接把 corp_restriction_policy=whitelist 提交(前端 UI 已无此选项,但 API 仍可命中)
// 不应导致 400 失败,应该被静默 coerce 为 none 后通过校验。
func TestSettingsPUT_DingTalk_StaleWhitelist_CoercedToNone(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, repo := newDingTalkSettingsHandler()
body := baseValidDingTalkBody()
body["dingtalk_connect_corp_restriction_policy"] = "whitelist"
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "none", repo.values[service.SettingKeyDingTalkConnectCorpRestrictionPolicy],
"stale whitelist 应在写入路径被 coerce 为 none")
}
// TestSettingsPUT_DingTalk_SyncAttrKey_RoundTrip 验证 3 个 attr key 字段 save+load + 空值 fallback 到默认值。
func TestSettingsPUT_DingTalk_SyncAttrKey_RoundTrip(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("custom_attr_keys_saved", func(t *testing.T) {
handler, repo := newDingTalkSettingsHandler()
body := baseValidDingTalkBody()
body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
body["dingtalk_connect_sync_corp_email"] = true
body["dingtalk_connect_sync_display_name"] = true
body["dingtalk_connect_sync_dept"] = true
body["dingtalk_connect_sync_corp_email_attr_key"] = "my_email_attr"
body["dingtalk_connect_sync_display_name_attr_key"] = "my_name_attr"
body["dingtalk_connect_sync_dept_attr_key"] = "my_dept_attr"
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
// 验证写入 DB 的 key
require.Equal(t, "my_email_attr", repo.values[service.SettingKeyDingTalkConnectSyncCorpEmailAttrKey])
require.Equal(t, "my_name_attr", repo.values[service.SettingKeyDingTalkConnectSyncDisplayNameAttrKey])
require.Equal(t, "my_dept_attr", repo.values[service.SettingKeyDingTalkConnectSyncDeptAttrKey])
// 验证响应中的 attr key
var resp response.Response
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
data, ok := resp.Data.(map[string]any)
require.True(t, ok)
require.Equal(t, "my_email_attr", data["dingtalk_connect_sync_corp_email_attr_key"])
require.Equal(t, "my_name_attr", data["dingtalk_connect_sync_display_name_attr_key"])
require.Equal(t, "my_dept_attr", data["dingtalk_connect_sync_dept_attr_key"])
})
t.Run("empty_attr_keys_fallback_to_defaults", func(t *testing.T) {
handler, repo := newDingTalkSettingsHandler()
body := baseValidDingTalkBody()
body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
// 不传 attr key → 写入层 fallback 到默认值
body["dingtalk_connect_sync_corp_email_attr_key"] = ""
body["dingtalk_connect_sync_display_name_attr_key"] = ""
body["dingtalk_connect_sync_dept_attr_key"] = ""
rawBody, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
require.Equal(t, http.StatusOK, rec.Code)
// 空值应 fallback 到默认值并持久化
require.Equal(t, "dingtalk_email", repo.values[service.SettingKeyDingTalkConnectSyncCorpEmailAttrKey])
require.Equal(t, "dingtalk_name", repo.values[service.SettingKeyDingTalkConnectSyncDisplayNameAttrKey])
require.Equal(t, "dingtalk_department", repo.values[service.SettingKeyDingTalkConnectSyncDeptAttrKey])
})
}

View File

@ -0,0 +1,398 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// dingTalkClientConfig 是 DingTalkClient 需要的最小配置子集
type dingTalkClientConfig struct {
ClientID string
ClientSecret string
TokenURL string
UserInfoURL string
}
type DingTalkClient struct {
cfg dingTalkClientConfig
appToken string
appTokenExp time.Time // 钉钉 7200s留 200s 余量 → 7000s
mu sync.Mutex
httpClient *http.Client
// TODO(multi-instance): Redis 集中缓存 appToken
}
type DingTalkUserTokenResp struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpireIn int64 `json:"expireIn"`
CorpID string `json:"corpId"`
}
func (c *DingTalkClient) ExchangeCodeForUserToken(ctx context.Context, code string) (*DingTalkUserTokenResp, error) {
body := map[string]string{
"clientId": c.cfg.ClientID,
"clientSecret": c.cfg.ClientSecret,
"code": code,
"grantType": "authorization_code",
}
payload, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.TokenURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
var out DingTalkUserTokenResp
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
if strings.TrimSpace(out.AccessToken) == "" {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
return &out, nil
}
type DingTalkAPIError struct {
Code string
Message string
HTTP int
}
func (e *DingTalkAPIError) Error() string {
return fmt.Sprintf("dingtalk api error code=%s msg=%s http=%d", e.Code, e.Message, e.HTTP)
}
func parseDingTalkErr(raw []byte, status int) error {
var v struct {
Code string `json:"code"`
Message string `json:"message"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
_ = json.Unmarshal(raw, &v)
code := v.Code
if code == "" && v.ErrCode != 0 {
code = fmt.Sprintf("%d", v.ErrCode)
}
msg := v.Message
if msg == "" {
msg = v.ErrMsg
}
return &DingTalkAPIError{Code: code, Message: msg, HTTP: status}
}
// GetUnionIdByUserToken 调用 /v1.0/contact/users/me 返回 unionId 与用户自设昵称 nick。
// nick 来自钉钉新版 OIDC 接口(用户在 App 个人资料填的昵称),与旧版 user/get.nickname 不同源。
func (c *DingTalkClient) GetUnionIdByUserToken(ctx context.Context, userToken string) (unionID string, nick string, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.UserInfoURL, nil)
if err != nil {
return "", "", err
}
req.Header.Set("x-acs-dingtalk-access-token", userToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", "", err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", "", parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
UnionID string `json:"unionId"`
Nick string `json:"nick"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return "", "", err
}
if strings.TrimSpace(v.UnionID) == "" {
return "", "", parseDingTalkErr(raw, resp.StatusCode)
}
return v.UnionID, v.Nick, nil
}
type DingTalkStaffInfo struct {
UserID string
Name string // 企业内真实姓名(钉钉企业管理后台配置)
Nickname string // 钉钉个人昵称(用户自己设置)
Email string
DeptIDs []int64
// CorpID 不来自 staff 接口,来自 userToken不在此 struct
}
// dingTalkOAPIBase 推导钉钉旧版 OAPI base URLhost: api.dingtalk.com → oapi.dingtalk.com
// getbyunionid 与 topapi/v2/user/get 仅在旧版 OAPI 提供,不在 v1.0 OpenAPI。
func (c *DingTalkClient) dingTalkOAPIBase() string {
u, err := url.Parse(c.cfg.UserInfoURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return "https://oapi.dingtalk.com"
}
host := u.Host
if strings.HasPrefix(host, "api.") {
host = "oapi." + strings.TrimPrefix(host, "api.")
}
return u.Scheme + "://" + host
}
func (c *DingTalkClient) GetAppToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.appToken != "" && time.Now().Before(c.appTokenExp) {
return c.appToken, nil
}
body := map[string]string{"appKey": c.cfg.ClientID, "appSecret": c.cfg.ClientSecret}
payload, _ := json.Marshal(body)
// 钉钉新版 v1.0 企业内部应用 access_token: POST /v1.0/oauth2/accessToken
// 此 token 也可作为旧版 OAPI 的 access_token 使用(钉钉文档已说明)
appTokenURL := strings.Replace(c.cfg.TokenURL, "/oauth2/userAccessToken", "/oauth2/accessToken", 1)
if !strings.Contains(appTokenURL, "accessToken") && !strings.Contains(appTokenURL, "gettoken") {
appTokenURL = c.cfg.TokenURL // fallback for test stub
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, appTokenURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return "", err
}
if v.AccessToken == "" {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
c.appToken = v.AccessToken
ttl := v.ExpireIn
if ttl > 200 {
ttl -= 200
}
c.appTokenExp = time.Now().Add(time.Duration(ttl) * time.Second)
return c.appToken, nil
}
func (c *DingTalkClient) GetUserIdByUnionId(ctx context.Context, unionID string) (string, error) {
appToken, err := c.GetAppToken(ctx)
if err != nil {
return "", err
}
body := map[string]string{"unionid": unionID}
payload, _ := json.Marshal(body)
// 钉钉旧版 OAPI: POST https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=XXX
// access_token 通过 query string 传递(不是 header
var targetURL string
if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
targetURL = c.dingTalkOAPIBase() + "/topapi/user/getbyunionid?access_token=" + url.QueryEscape(appToken)
} else {
targetURL = c.cfg.UserInfoURL // fallback for test stub
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
Result struct {
UserID string `json:"userid"`
} `json:"result"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return "", err
}
if v.ErrCode != 0 {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
if strings.TrimSpace(v.Result.UserID) == "" {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
return v.Result.UserID, nil
}
// DingTalkDeptInfo 部门信息topapi/v2/department/get 返回子集)
type DingTalkDeptInfo struct {
DeptID int64
Name string
ParentID int64
}
// GetDeptInfo 查询单个部门信息(用于递归拼部门路径)。
// 调用钉钉旧版 OAPI: POST /topapi/v2/department/get?access_token=XXX
func (c *DingTalkClient) GetDeptInfo(ctx context.Context, deptID int64) (*DingTalkDeptInfo, error) {
appToken, err := c.GetAppToken(ctx)
if err != nil {
return nil, err
}
body := map[string]any{"dept_id": deptID, "language": "zh_CN"}
payload, _ := json.Marshal(body)
var targetURL string
if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
targetURL = c.dingTalkOAPIBase() + "/topapi/v2/department/get?access_token=" + url.QueryEscape(appToken)
} else {
targetURL = c.cfg.UserInfoURL // test stub fallback
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
Result struct {
DeptID int64 `json:"dept_id"`
Name string `json:"name"`
ParentID int64 `json:"parent_id"`
} `json:"result"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
if v.ErrCode != 0 {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
return &DingTalkDeptInfo{
DeptID: v.Result.DeptID,
Name: v.Result.Name,
ParentID: v.Result.ParentID,
}, nil
}
func (c *DingTalkClient) GetStaffInfoByUserId(ctx context.Context, userID string) (*DingTalkStaffInfo, error) {
appToken, err := c.GetAppToken(ctx)
if err != nil {
return nil, err
}
body := map[string]string{"userid": userID}
payload, _ := json.Marshal(body)
// 钉钉旧版 OAPI: POST https://oapi.dingtalk.com/topapi/v2/user/get?access_token=XXX
var targetURL string
if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
targetURL = c.dingTalkOAPIBase() + "/topapi/v2/user/get?access_token=" + url.QueryEscape(appToken)
} else {
targetURL = c.cfg.UserInfoURL // fallback for test stub
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
Result struct {
UserID string `json:"userid"`
Name string `json:"name"`
Nickname string `json:"nickname"`
Email string `json:"email"`
OrgEmail string `json:"org_email"`
Extension string `json:"extension"`
DeptID []int64 `json:"dept_id_list"`
} `json:"result"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
if v.ErrCode != 0 {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
if strings.TrimSpace(v.Result.UserID) == "" {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
// 邮箱三级 fallbackorg_email > email > extension["企业邮箱"]钉钉自定义扩展字段JSON string
email := strings.TrimSpace(v.Result.OrgEmail)
emailSource := "org_email"
if email == "" {
email = strings.TrimSpace(v.Result.Email)
emailSource = "email"
}
extensionParsed := false
if email == "" && strings.TrimSpace(v.Result.Extension) != "" {
var ext map[string]string
if err := json.Unmarshal([]byte(v.Result.Extension), &ext); err == nil {
extensionParsed = true
if v, ok := ext["企业邮箱"]; ok {
email = strings.TrimSpace(v)
emailSource = "extension.企业邮箱"
}
}
}
if email == "" {
emailSource = "none"
}
slog.Info("dingtalk staff fetched",
"userid", v.Result.UserID,
"name_present", v.Result.Name != "",
"nickname_present", v.Result.Nickname != "",
"name_eq_nickname", v.Result.Name != "" && v.Result.Name == v.Result.Nickname,
"email_present", v.Result.Email != "",
"org_email_present", v.Result.OrgEmail != "",
"extension_present", v.Result.Extension != "",
"extension_parsed", extensionParsed,
"email_source", emailSource,
"dept_count", len(v.Result.DeptID),
)
return &DingTalkStaffInfo{
UserID: v.Result.UserID,
Name: v.Result.Name,
Nickname: v.Result.Nickname,
Email: email,
DeptIDs: v.Result.DeptID,
}, nil
}

View File

@ -0,0 +1,143 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestDingTalkClient_ExchangeCodeForUserToken_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "POST", r.Method)
require.Equal(t, "/v1.0/oauth2/userAccessToken", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"USER_TOKEN_X","expireIn":7200,"refreshToken":"R","corpId":"dingABC"}`))
}))
defer server.Close()
cli := &DingTalkClient{
cfg: dingTalkClientConfig{
ClientID: "k", ClientSecret: "s",
TokenURL: server.URL + "/v1.0/oauth2/userAccessToken",
},
httpClient: server.Client(),
}
resp, err := cli.ExchangeCodeForUserToken(context.Background(), "AUTH_CODE")
require.NoError(t, err)
require.Equal(t, "USER_TOKEN_X", resp.AccessToken)
require.Equal(t, "dingABC", resp.CorpID)
}
func TestDingTalkClient_GetUnionIdByUserToken_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "USER_TOKEN_X", r.Header.Get("x-acs-dingtalk-access-token"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"nick":"张三","unionId":"UID_AAA","openId":"OPEN","avatarUrl":"http://x"}`))
}))
defer server.Close()
cli := &DingTalkClient{
cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/v1.0/contact/users/me"},
httpClient: server.Client(),
}
unionID, nick, err := cli.GetUnionIdByUserToken(context.Background(), "USER_TOKEN_X")
require.NoError(t, err)
require.Equal(t, "UID_AAA", unionID)
require.Equal(t, "张三", nick)
}
func TestDingTalkClient_GetAppToken_Cached(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
_, _ = w.Write([]byte(`{"accessToken":"APP_TKN","expireIn":7200}`))
}))
defer server.Close()
cli := &DingTalkClient{
cfg: dingTalkClientConfig{ClientID: "k", ClientSecret: "s", TokenURL: server.URL + "/gettoken"},
httpClient: server.Client(),
}
t1, err := cli.GetAppToken(context.Background())
require.NoError(t, err)
t2, err := cli.GetAppToken(context.Background())
require.NoError(t, err)
require.Equal(t, t1, t2)
require.Equal(t, 1, callCount, "second call should hit cache")
}
func TestDingTalkClient_GetUserIdByUnionId_60011(t *testing.T) {
appTokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"accessToken":"APP_TKN","expireIn":7200}`))
}))
defer appTokenServer.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errcode":60011,"errmsg":"not in directory"}`))
}))
defer server.Close()
cli := &DingTalkClient{
cfg: dingTalkClientConfig{TokenURL: appTokenServer.URL + "/gettoken"},
httpClient: server.Client(),
}
cli.appToken = "APP_TKN"
cli.appTokenExp = time.Now().Add(time.Hour)
cli.cfg.UserInfoURL = server.URL + "/v1.0/contact/users/byUnionId"
_, err := cli.GetUserIdByUnionId(context.Background(), "UID_AAA")
require.Error(t, err)
apiErr, ok := err.(*DingTalkAPIError)
require.True(t, ok)
require.Equal(t, "60011", apiErr.Code)
}
// TestDingTalkClient_GetDeptInfo_Success 验证 GetDeptInfo 正常情况返回部门信息。
func TestDingTalkClient_GetDeptInfo_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok","result":{"dept_id":42,"name":"AI数据","parent_id":1}}`))
}))
defer server.Close()
cli := &DingTalkClient{
cfg: dingTalkClientConfig{
UserInfoURL: server.URL + "/stub", // 不含 /contact/users/me走 test stub 路径
},
httpClient: server.Client(),
}
cli.appToken = "APP_TKN"
cli.appTokenExp = time.Now().Add(time.Hour)
info, err := cli.GetDeptInfo(context.Background(), 42)
require.NoError(t, err)
require.Equal(t, int64(42), info.DeptID)
require.Equal(t, "AI数据", info.Name)
require.Equal(t, int64(1), info.ParentID)
}
// TestDingTalkClient_GetDeptInfo_ErrCode60003 验证 errcode=60003部门不存在时返回错误。
func TestDingTalkClient_GetDeptInfo_ErrCode60003(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errcode":60003,"errmsg":"dept not found"}`))
}))
defer server.Close()
cli := &DingTalkClient{
cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/stub"},
httpClient: server.Client(),
}
cli.appToken = "APP_TKN"
cli.appTokenExp = time.Now().Add(time.Hour)
_, err := cli.GetDeptInfo(context.Background(), 999)
require.Error(t, err)
apiErr, ok := err.(*DingTalkAPIError)
require.True(t, ok)
require.Equal(t, "60003", apiErr.Code)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,391 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDingTalkOAuthStart_Disabled は sentinel テスト。
// TODO(task-1.10): newTestAuthHandlerWithDingTalk helper が追加されたら t.Skip を外す。
func TestDingTalkOAuthStart_Disabled(t *testing.T) {
t.Skip("helper newTestAuthHandlerWithDingTalk added in Task 1.10; sentinel only")
}
// TestBuildDingTalkSyntheticEmail_UsesUnionID 验证合成邮箱种子使用 unionID。
func TestBuildDingTalkSyntheticEmail_UsesUnionID(t *testing.T) {
unionID := "union_AbCdEf123"
email := buildDingTalkSyntheticEmail(unionID)
want := "dingtalk-union_abcdef123@dingtalk-connect.invalid"
require.Equal(t, want, email)
// 确保结果都是小写(邮箱大小写不敏感,统一小写)
require.True(t, strings.ToLower(email) == email, "synthetic email should be all lowercase")
// 确保前缀正确
require.True(t, strings.HasPrefix(email, "dingtalk-"), "should have dingtalk- prefix")
// 确保后缀是合成邮箱域名
require.True(t, strings.HasSuffix(email, "@dingtalk-connect.invalid"), "should have reserved domain suffix")
}
// TestBuildDingTalkSyntheticEmail_TrimsSpace 验证 unionID 空白被修剪。
func TestBuildDingTalkSyntheticEmail_TrimsSpace(t *testing.T) {
email := buildDingTalkSyntheticEmail(" UID_XYZ ")
require.Equal(t, "dingtalk-uid_xyz@dingtalk-connect.invalid", email)
}
// TestBuildDingTalkUpstreamClaims_EmptyStaff 验证 staff 为空 struct跨组织降级路径
// - subject 等于 unionID与 identityKey.ProviderSubject 一致)
// - corp_user_id 为空字符串(跨组织时拿不到企业 userid
// - email/username 为空字符串
// B/C: Step 3/4 失败降级时 staff = &DingTalkStaffInfo{}claims 不应有 nil。
func TestBuildDingTalkUpstreamClaims_EmptyStaff(t *testing.T) {
staff := &DingTalkStaffInfo{}
claims := buildDingTalkUpstreamClaims(staff, "UNION_AAA", "CORP_X")
require.Equal(t, "", claims["email"])
require.Equal(t, "", claims["username"])
// 重构后 subject = unionID与 identityKey.ProviderSubject 保持一致)
require.Equal(t, "UNION_AAA", claims["subject"])
require.Equal(t, "", claims["corp_user_id"]) // 企业 userid 跨组织时为空
require.Equal(t, "UNION_AAA", claims["union_id"])
require.Equal(t, "CORP_X", claims["corp_id"])
}
// TestCheckDingTalkCorpAllowed_CrossOrgPolicy 验证 policy=none 时允许任意 corp。
// D: corp 校验提前后逻辑不变。
func TestCheckDingTalkCorpAllowed_CrossOrgPolicy(t *testing.T) {
cfg := config.DingTalkConnectConfig{CorpRestrictionPolicy: "none"}
assert.True(t, checkDingTalkCorpAllowed(cfg, "dingABC"), "policy=none should allow any corp")
assert.True(t, checkDingTalkCorpAllowed(cfg, ""), "policy=none should allow empty corp")
assert.True(t, checkDingTalkCorpAllowed(cfg, "foreign_corp"), "policy=none should allow foreign corp")
}
// TestCheckDingTalkCorpAllowed_InternalOnly 验证 policy=internal_only 时的 corp 校验语义(方案 A 修订)。
// 钉钉 userAccessToken 在部分授权场景(扫码登录、非企业工作台入口)不返回 corpId 字段,
// 因此 checkDingTalkCorpAllowed 完全不校验 corpID由 step 3 GetUserIdByUnionId 做真实判定
// (跨企业用户会被钉钉错误码 60011/60121 拒绝mapDingTalkErrorCode 映射回 corp_rejected
func TestCheckDingTalkCorpAllowed_InternalOnly(t *testing.T) {
cfgWithCorpID := config.DingTalkConnectConfig{
CorpRestrictionPolicy: "internal_only",
InternalCorpID: "dingInternal",
}
assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, "dingInternal"), "internal_only: matching corpID allowed")
assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, "foreign_corp"), "internal_only: corpID 字段不再用于决策step 3 兜底")
assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, ""), "internal_only: 空 corpID 也通过(钉钉部分授权场景不返回 corpId")
cfgNoCorpID := config.DingTalkConnectConfig{
CorpRestrictionPolicy: "internal_only",
InternalCorpID: "",
}
assert.True(t, checkDingTalkCorpAllowed(cfgNoCorpID, "dingAnyNonEmpty"), "internal_only + no InternalCorpID: 非空 corpID 通过")
assert.True(t, checkDingTalkCorpAllowed(cfgNoCorpID, ""), "internal_only + no InternalCorpID: 空 corpID 也通过")
}
// TestDecideDingTalkStep34Strategy_PolicyNone 验证 policy=none 时
// Step 3/4 失败应降级shouldFallback=true, isFatal=false
func TestDecideDingTalkStep34Strategy_PolicyNone(t *testing.T) {
step3Err := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403}
shouldFallback, isFatal := decideDingTalkStep34Strategy("none", step3Err)
require.True(t, shouldFallback, "policy=none: step3 failure should trigger fallback")
require.False(t, isFatal, "policy=none: step3 failure should NOT be fatal")
}
// TestDecideDingTalkStep34Strategy_PolicyNoneEmpty 验证 policy="" 时行为与 "none" 相同。
func TestDecideDingTalkStep34Strategy_PolicyNoneEmpty(t *testing.T) {
stepErr := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403}
shouldFallback, isFatal := decideDingTalkStep34Strategy("", stepErr)
require.True(t, shouldFallback, "policy='': step failure should trigger fallback")
require.False(t, isFatal, "policy='': step failure should NOT be fatal")
}
// TestDecideDingTalkStep34Strategy_PolicyInternalOnly 验证 policy=internal_only 时
// Step 3/4 失败应 hard failisFatal=true
func TestDecideDingTalkStep34Strategy_PolicyInternalOnly(t *testing.T) {
step3Err := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403}
shouldFallback, isFatal := decideDingTalkStep34Strategy("internal_only", step3Err)
require.False(t, shouldFallback, "policy=internal_only: should NOT fallback on step3 error")
require.True(t, isFatal, "policy=internal_only: step3 failure should be fatal")
}
// TestDecideDingTalkStep34Strategy_NoError 验证 stepErr=nil 时两个返回值均为 false。
func TestDecideDingTalkStep34Strategy_NoError(t *testing.T) {
for _, policy := range []string{"none", "internal_only", ""} {
shouldFallback, isFatal := decideDingTalkStep34Strategy(policy, nil)
require.False(t, shouldFallback, "no error should not trigger fallback (policy=%q)", policy)
require.False(t, isFatal, "no error should not be fatal (policy=%q)", policy)
}
}
// TestCompleteDingTalkRegistration_UsernameFromEmailLocalPart 验证 username 为空时
// 退到 email local part@ 之前的部分)。
// E: CompleteDingTalkOAuthRegistration username fallback。
func TestCompleteDingTalkRegistration_UsernameFromEmailLocalPart(t *testing.T) {
tests := []struct {
name string
email string
username string
wantUser string
wantValid bool
}{
{
name: "username empty, normal email → local part",
email: "dingtalk-uid123@dingtalk-connect.invalid",
username: "",
wantUser: "dingtalk-uid123",
wantValid: true,
},
{
name: "username already set → keep original",
email: "user@example.com",
username: "张三",
wantUser: "张三",
wantValid: true,
},
{
name: "username empty, no @ in email → use whole email",
email: "noemail",
username: "",
wantUser: "noemail",
wantValid: true,
},
{
name: "both empty → invalid",
email: "",
username: "",
wantUser: "",
wantValid: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
username := tc.username
email := tc.email
// 模拟 CompleteDingTalkOAuthRegistration 中的 fallback 逻辑
if username == "" {
if at := strings.Index(email, "@"); at > 0 {
username = email[:at]
} else {
username = email
}
}
isValid := email != "" && username != ""
require.Equal(t, tc.wantUser, username, fmt.Sprintf("username for email=%q", tc.email))
require.Equal(t, tc.wantValid, isValid, "validity check")
})
}
}
// TestBuildDingTalkUpstreamClaims_SubjectEqualsUnionID 验证重构后 subject = unionID
// 而非 staff.UserID与 identityKey.ProviderSubject 保持一致。
// §4.2: buildDingTalkUpstreamClaims subject 字段修正。
func TestBuildDingTalkUpstreamClaims_SubjectEqualsUnionID(t *testing.T) {
staff := &DingTalkStaffInfo{UserID: "user123", Name: "张三", Email: "zhangsan@corp.com"}
claims := buildDingTalkUpstreamClaims(staff, "union456", "dingcorp789")
// 重构后 subject = unionID全局唯一与 identityKey.ProviderSubject 一致)
require.Equal(t, "union456", claims["subject"], "subject should equal unionID after refactor")
// 企业 userid 保留为独立字段,供 audit/debug 使用
require.Equal(t, "user123", claims["corp_user_id"], "corp_user_id should be staff.UserID")
// union_id 字段与 subject 相同(冗余保留,便于读取)
require.Equal(t, "union456", claims["union_id"])
require.Equal(t, "dingcorp789", claims["corp_id"])
require.Equal(t, "张三", claims["username"])
require.Equal(t, "zhangsan@corp.com", claims["email"])
}
// TestBuildDingTalkUpstreamClaims_CrossOrgEmptyCorpUserID 验证跨组织降级时
// corp_user_id 为空字符串(跨组织拿不到企业 useridsubject 仍为 unionID。
func TestBuildDingTalkUpstreamClaims_CrossOrgEmptyCorpUserID(t *testing.T) {
// 跨组织降级路径staff = &DingTalkStaffInfo{}(所有字段为零值)
staff := &DingTalkStaffInfo{}
claims := buildDingTalkUpstreamClaims(staff, "union_cross_org", "foreign_corp")
require.Equal(t, "union_cross_org", claims["subject"], "subject should still be unionID for cross-org users")
require.Equal(t, "", claims["corp_user_id"], "corp_user_id should be empty for cross-org fallback")
require.Equal(t, "", claims["email"])
require.Equal(t, "", claims["username"])
}
// TestBuildDingTalkUpstreamClaims_PrimaryDeptIDInClaims 验证首个 dept_id 被存入 claims。
func TestBuildDingTalkUpstreamClaims_PrimaryDeptIDInClaims(t *testing.T) {
staff := &DingTalkStaffInfo{UserID: "u1", Name: "张三", Email: "a@b.com", DeptIDs: []int64{42, 99}}
claims := buildDingTalkUpstreamClaims(staff, "uid1", "corpX")
// 只取首个 dept_id
require.Equal(t, int64(42), claims["primary_dept_id"], "primary_dept_id should be the first dept_id")
}
// TestBuildDingTalkUpstreamClaims_NoDeptIDs 验证无部门时 primary_dept_id=0。
func TestBuildDingTalkUpstreamClaims_NoDeptIDs(t *testing.T) {
staff := &DingTalkStaffInfo{UserID: "u2", Name: "李四"}
claims := buildDingTalkUpstreamClaims(staff, "uid2", "corpY")
require.Equal(t, int64(0), claims["primary_dept_id"], "primary_dept_id should be 0 when no depts")
}
// TestDingTalkStaffFromClaims_RoundTrip 验证 dingTalkStaffFromClaims 能从 claims 恢复 staff 信息。
func TestDingTalkStaffFromClaims_RoundTrip(t *testing.T) {
staff := &DingTalkStaffInfo{UserID: "u3", Name: "王五", Email: "ww@corp.com", DeptIDs: []int64{55}}
claims := buildDingTalkUpstreamClaims(staff, "uid3", "corpZ")
recovered := dingTalkStaffFromClaims(claims)
require.Equal(t, "王五", recovered.Name)
require.Equal(t, "ww@corp.com", recovered.Email)
require.Equal(t, "u3", recovered.UserID)
require.Equal(t, []int64{55}, recovered.DeptIDs)
}
// TestResolveDingTalkDeptPath_SingleLevel 验证单层部门parent_id=1返回部门名。
func TestResolveDingTalkDeptPath_SingleLevel(t *testing.T) {
handler := &AuthHandler{}
callCount := 0
responses := map[string]string{
"42": `{"errcode":0,"result":{"dept_id":42,"name":"研发部","parent_id":1}}`,
"1": `{"errcode":0,"result":{"dept_id":1,"name":"公司","parent_id":0}}`,
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
var req struct {
DeptID int64 `json:"dept_id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")
if resp, ok := responses[fmt.Sprintf("%d", req.DeptID)]; ok {
_, _ = w.Write([]byte(resp))
} else {
_, _ = w.Write([]byte(`{"errcode":60003,"errmsg":"not found"}`))
}
}))
defer server.Close()
cli := &DingTalkClient{
cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/stub"},
httpClient: server.Client(),
}
cli.appToken = "tok"
cli.appTokenExp = time.Now().Add(time.Hour)
path, err := handler.resolveDingTalkDeptPath(context.Background(), cli, 42)
require.NoError(t, err)
require.Equal(t, "研发部", path)
require.Equal(t, 2, callCount)
}
// TestSyncDingTalkIdentity_UsesCfgAttrKeys 验证 syncDingTalkIdentity 使用 cfg 中配置的 attr key
// 而不是硬编码值。通过 userAttributeService=nil 使同步路径走 warn 跳过,但在此之前先验证
// syncField 构建逻辑(即 attr key 从 cfg 读取)。
// 间接验证:通过构造定制 cfg确认不同 attr key 可以正确传入(编译时保证类型正确,运行时不 panic
func TestSyncDingTalkIdentity_UsesCfgAttrKeys_NoopWithNilService(t *testing.T) {
handler := &AuthHandler{
userAttributeService: nil, // nil → 触发 warn 跳过,但不 panic
}
cfg := config.DingTalkConnectConfig{
CorpRestrictionPolicy: "internal_only",
SyncCorpEmail: true,
SyncDisplayName: true,
SyncDept: true,
// 自定义 attr key非默认值
SyncCorpEmailAttrKey: "custom_email_key",
SyncDisplayNameAttrKey: "custom_name_key",
SyncDeptAttrKey: "custom_dept_key",
}
staff := &DingTalkStaffInfo{
Name: "张三",
Email: "zhangsan@example.com",
}
// 调用不应 panicuserAttributeService 为 nil 时走 warn 跳过路径)
require.NotPanics(t, func() {
handler.syncDingTalkIdentity(context.Background(), cfg, nil, 42, staff, false)
})
}
// TestSyncDingTalkIdentity_DefaultAttrKeys_NoopWithNilService 验证 cfg 默认 attr key 为空时
// 使用 fallback 默认值dingtalk_email / dingtalk_name / dingtalk_department
// 此测试主要验证调用路径不 panic实际 key 赋值默认值的逻辑在 GetDingTalkConnectOAuthConfig 层。
func TestSyncDingTalkIdentity_DefaultAttrKeys_NoopWithNilService(t *testing.T) {
handler := &AuthHandler{
userAttributeService: nil,
}
cfg := config.DingTalkConnectConfig{
CorpRestrictionPolicy: "internal_only",
SyncCorpEmail: true,
SyncDisplayName: true,
SyncDept: false,
// 不设置 attr key等同于 GetDingTalkConnectOAuthConfig 未设置时 fallback 后的默认值已在调用前填充)
SyncCorpEmailAttrKey: "dingtalk_email",
SyncDisplayNameAttrKey: "dingtalk_name",
SyncDeptAttrKey: "dingtalk_department",
}
staff := &DingTalkStaffInfo{
Name: "李四",
Email: "lisi@corp.com",
}
require.NotPanics(t, func() {
handler.syncDingTalkIdentity(context.Background(), cfg, nil, 99, staff, false)
})
}
// TestResolveDingTalkDeptPath_MultiLevel 验证多层部门路径拼接。
func TestResolveDingTalkDeptPath_MultiLevel(t *testing.T) {
handler := &AuthHandler{}
// 模拟42(AI研发) → parent=10(研发部) → parent=1(根)
responses := map[string]string{
"42": `{"errcode":0,"result":{"dept_id":42,"name":"AI研发","parent_id":10}}`,
"10": `{"errcode":0,"result":{"dept_id":10,"name":"研发部","parent_id":1}}`,
"1": `{"errcode":0,"result":{"dept_id":1,"name":"公司","parent_id":0}}`,
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 解析请求 body 拿到 dept_id
var req struct {
DeptID int64 `json:"dept_id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
key := fmt.Sprintf("%d", req.DeptID)
w.Header().Set("Content-Type", "application/json")
if resp, ok := responses[key]; ok {
_, _ = w.Write([]byte(resp))
} else {
_, _ = w.Write([]byte(`{"errcode":60003,"errmsg":"not found"}`))
}
}))
defer server.Close()
cli := &DingTalkClient{
cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/stub"},
httpClient: server.Client(),
}
cli.appToken = "tok"
cli.appTokenExp = time.Now().Add(time.Hour)
path, err := handler.resolveDingTalkDeptPath(context.Background(), cli, 42)
require.NoError(t, err)
require.Equal(t, "研发部/AI研发", path)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"strings"
"sync"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
@ -18,25 +19,30 @@ import (
// AuthHandler handles authentication-related requests
type AuthHandler struct {
cfg *config.Config
authService *service.AuthService
userService *service.UserService
settingSvc *service.SettingService
promoService *service.PromoService
redeemService *service.RedeemService
totpService *service.TotpService
cfg *config.Config
authService *service.AuthService
userService *service.UserService
settingSvc *service.SettingService
promoService *service.PromoService
redeemService *service.RedeemService
totpService *service.TotpService
userAttributeService *service.UserAttributeService
dingTalkClientInstance *DingTalkClient
dingTalkClientMu sync.Mutex
}
// NewAuthHandler creates a new AuthHandler
func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, redeemService *service.RedeemService, totpService *service.TotpService) *AuthHandler {
func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, redeemService *service.RedeemService, totpService *service.TotpService, userAttributeService *service.UserAttributeService) *AuthHandler {
return &AuthHandler{
cfg: cfg,
authService: authService,
userService: userService,
settingSvc: settingService,
promoService: promoService,
redeemService: redeemService,
totpService: totpService,
cfg: cfg,
authService: authService,
userService: userService,
settingSvc: settingService,
promoService: promoService,
redeemService: redeemService,
totpService: totpService,
userAttributeService: userAttributeService,
}
}

View File

@ -350,7 +350,8 @@ func (h *AuthHandler) findLinuxDoCompatEmailUser(ctx context.Context, email stri
if email == "" ||
strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) ||
strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) ||
strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) {
strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) ||
strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) {
return nil, nil
}
@ -519,7 +520,7 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "linuxdo")
if err != nil {
response.ErrorFrom(c, err)
return

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
@ -195,6 +196,14 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen
},
})
if err != nil {
slog.Error("pending auth session create failed",
"intent", strings.TrimSpace(payload.Intent),
"provider_type", strings.TrimSpace(payload.Identity.ProviderType),
"provider_key", strings.TrimSpace(payload.Identity.ProviderKey),
"provider_subject_len", len(strings.TrimSpace(payload.Identity.ProviderSubject)),
"resolved_email_len", len(strings.TrimSpace(payload.ResolvedEmail)),
"has_target_user", payload.TargetUserID != nil,
"error", err.Error())
return infraerrors.InternalServer("PENDING_AUTH_SESSION_CREATE_FAILED", "failed to create pending auth session").WithCause(err)
}
@ -266,6 +275,22 @@ func pendingSessionWantsInvitation(payload map[string]any) bool {
return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "error")), "invitation_required")
}
// pendingSessionRequiresEmailCompletion 判断 callback 写入的 completion payload 是否处于"补邮箱"状态。
// 钉钉跨组织/staff 邮箱缺失时进入此状态前端跳到补邮箱页exchange 不应走 adoption apply。
func pendingSessionRequiresEmailCompletion(payload map[string]any) bool {
if v, ok := payload["requires_email_completion"].(bool); ok && v {
return true
}
return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "step")), "email_completion")
}
// pendingSessionRequiresBindLogin 判断 callback 写入的 completion payload 是否处于"必须绑定已有账户"状态。
// 钉钉 signupBlocked=true注册关 + 钉钉企业豁免关)时进入此状态:前端渲染 bind_login 表单,
// exchange 不应消费 session否则后续 /pending/bind-login 找不到 session。
func pendingSessionRequiresBindLogin(payload map[string]any) bool {
return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "step")), "bind_login_required")
}
func pendingOAuthCompletionCanIssueTokenPair(session *dbent.PendingAuthSession, payload map[string]any) bool {
if session == nil {
return false
@ -1467,8 +1492,10 @@ func normalizePendingOAuthCompletionResponse(payload map[string]any) map[string]
delete(normalized, key)
}
step := strings.ToLower(strings.TrimSpace(pendingSessionStringValue(normalized, "step")))
// 把多种 choice 别名归一为 oauthPendingChoiceStepbind_login_required 是独立终态
// (前端渲染 needsBindLogin 而非 needsChooser故不能并入归一化列表。
switch step {
case "choice", "choose_account_action", "choose_account", "choose", "email_required", "bind_login_required":
case "choice", "choose_account_action", "choose_account", "choose", "email_required":
normalized["step"] = oauthPendingChoiceStep
}
if strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(normalized, "step")), oauthPendingChoiceStep) {
@ -1594,6 +1621,8 @@ func (h *AuthHandler) bindPendingOAuthLogin(c *gin.Context, provider string) {
}
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
// bindPendingOAuthLogin = 绑定已有账户登录,不动 users.username用户已有自己的名字
h.maybeSyncDingTalkAfterLogin(c.Request.Context(), session, user.ID)
tokenPair, err := h.authService.GenerateTokenPair(c.Request.Context(), user, "")
if err != nil {
response.InternalError(c, "Failed to generate token pair")
@ -1792,6 +1821,8 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
}
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
// createPendingOAuthAccount = 注册新账户,需要把钉钉昵称同步到 users.username 作为初始值
h.maybeSyncDingTalkAfterRegistration(c.Request.Context(), session, user.ID)
clearCookies()
writeOAuthTokenPairResponse(c, tokenPair)
}
@ -1893,6 +1924,14 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
response.Success(c, payload)
return
}
if pendingSessionRequiresEmailCompletion(payload) {
response.Success(c, payload)
return
}
if pendingSessionRequiresBindLogin(payload) {
response.Success(c, payload)
return
}
if !adoptionDecision.hasDecision() {
adoptionRequired, _ := payload["adoption_required"].(bool)
if adoptionRequired {

View File

@ -502,7 +502,8 @@ func (h *AuthHandler) findOIDCCompatEmailUser(ctx context.Context, email string)
if email == "" ||
strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) ||
strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) ||
strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) {
strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) ||
strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) {
return nil, nil
}
@ -666,7 +667,7 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "oidc")
if err != nil {
response.ErrorFrom(c, err)
return

View File

@ -548,7 +548,7 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "wechat")
if err != nil {
response.ErrorFrom(c, err)
return

View File

@ -56,6 +56,23 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
DingTalkConnectEnabled bool `json:"dingtalk_connect_enabled"`
DingTalkConnectClientID string `json:"dingtalk_connect_client_id"`
DingTalkConnectClientSecretConfigured bool `json:"dingtalk_connect_client_secret_configured"`
DingTalkConnectRedirectURL string `json:"dingtalk_connect_redirect_url"`
DingTalkConnectCorpRestrictionPolicy string `json:"dingtalk_connect_corp_restriction_policy"`
DingTalkConnectInternalCorpID string `json:"dingtalk_connect_internal_corp_id"`
DingTalkConnectBypassRegistration bool `json:"dingtalk_connect_bypass_registration"`
DingTalkConnectSyncCorpEmail bool `json:"dingtalk_connect_sync_corp_email"`
DingTalkConnectSyncDisplayName bool `json:"dingtalk_connect_sync_display_name"`
DingTalkConnectSyncDept bool `json:"dingtalk_connect_sync_dept"`
DingTalkConnectSyncCorpEmailAttrKey string `json:"dingtalk_connect_sync_corp_email_attr_key"`
DingTalkConnectSyncDisplayNameAttrKey string `json:"dingtalk_connect_sync_display_name_attr_key"`
DingTalkConnectSyncDeptAttrKey string `json:"dingtalk_connect_sync_dept_attr_key"`
DingTalkConnectSyncCorpEmailAttrName string `json:"dingtalk_connect_sync_corp_email_attr_name"`
DingTalkConnectSyncDisplayNameAttrName string `json:"dingtalk_connect_sync_display_name_attr_name"`
DingTalkConnectSyncDeptAttrName string `json:"dingtalk_connect_sync_dept_attr_name"`
WeChatConnectEnabled bool `json:"wechat_connect_enabled"`
WeChatConnectAppID string `json:"wechat_connect_app_id"`
WeChatConnectAppSecretConfigured bool `json:"wechat_connect_app_secret_configured"`
@ -260,6 +277,7 @@ type PublicSettings struct {
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`

View File

@ -61,6 +61,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,

View File

@ -67,6 +67,7 @@ type userProfileResponse struct {
LinuxDoBound bool `json:"linuxdo_bound"`
OIDCBound bool `json:"oidc_bound"`
WeChatBound bool `json:"wechat_bound"`
DingTalkBound bool `json:"dingtalk_bound"`
}
type userProfileSourceContext struct {
@ -528,15 +529,17 @@ func userProfileResponseFromService(user *service.User, identities service.UserI
LinuxDoBound: identities.LinuxDo.Bound,
OIDCBound: identities.OIDC.Bound,
WeChatBound: identities.WeChat.Bound,
DingTalkBound: identities.DingTalk.Bound,
}
}
func userProfileBindingMap(identities service.UserIdentitySummarySet) map[string]service.UserIdentitySummary {
return map[string]service.UserIdentitySummary{
"email": identities.Email,
"linuxdo": identities.LinuxDo,
"oidc": identities.OIDC,
"wechat": identities.WeChat,
"email": identities.Email,
"linuxdo": identities.LinuxDo,
"oidc": identities.OIDC,
"wechat": identities.WeChat,
"dingtalk": identities.DingTalk,
}
}
@ -585,7 +588,7 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity
func thirdPartyIdentityProviders(identities service.UserIdentitySummarySet) []service.UserIdentitySummary {
out := make([]service.UserIdentitySummary, 0, 3)
for _, summary := range []service.UserIdentitySummary{identities.LinuxDo, identities.OIDC, identities.WeChat} {
for _, summary := range []service.UserIdentitySummary{identities.LinuxDo, identities.OIDC, identities.WeChat, identities.DingTalk} {
if summary.Bound {
out = append(out, summary)
}

View File

@ -334,7 +334,8 @@ func normalizeEmailAuthIdentitySubject(email string) string {
}
if strings.HasSuffix(normalized, service.LinuxDoConnectSyntheticEmailDomain) ||
strings.HasSuffix(normalized, service.OIDCConnectSyntheticEmailDomain) ||
strings.HasSuffix(normalized, service.WeChatConnectSyntheticEmailDomain) {
strings.HasSuffix(normalized, service.WeChatConnectSyntheticEmailDomain) ||
strings.HasSuffix(normalized, service.DingTalkConnectSyntheticEmailDomain) {
return ""
}
return normalized
@ -956,7 +957,7 @@ func userSignupSourceOrDefault(signupSource string) string {
switch strings.TrimSpace(strings.ToLower(signupSource)) {
case "", "email":
return "email"
case "linuxdo", "wechat", "oidc":
case "linuxdo", "wechat", "oidc", "dingtalk":
return strings.TrimSpace(strings.ToLower(signupSource))
default:
return "email"

View File

@ -68,6 +68,7 @@ func TestAPIContracts(t *testing.T) {
"linuxdo_bound": false,
"oidc_bound": false,
"wechat_bound": false,
"dingtalk_bound": false,
"identities": {
"email": {
"provider": "email",
@ -104,6 +105,14 @@ func TestAPIContracts(t *testing.T) {
"can_bind": true,
"can_unbind": false,
"bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
},
"dingtalk": {
"provider": "dingtalk",
"bound": false,
"bound_count": 0,
"can_bind": true,
"can_unbind": false,
"bind_start_path": "/api/v1/auth/oauth/dingtalk/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
}
},
"identity_bindings": {
@ -142,6 +151,14 @@ func TestAPIContracts(t *testing.T) {
"can_bind": true,
"can_unbind": false,
"bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
},
"dingtalk": {
"provider": "dingtalk",
"bound": false,
"bound_count": 0,
"can_bind": true,
"can_unbind": false,
"bind_start_path": "/api/v1/auth/oauth/dingtalk/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
}
},
"auth_bindings": {
@ -180,6 +197,14 @@ func TestAPIContracts(t *testing.T) {
"can_bind": true,
"can_unbind": false,
"bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
},
"dingtalk": {
"provider": "dingtalk",
"bound": false,
"bound_count": 0,
"can_bind": true,
"can_unbind": false,
"bind_start_path": "/api/v1/auth/oauth/dingtalk/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
}
},
"run_mode": "standard"
@ -676,6 +701,22 @@ func TestAPIContracts(t *testing.T) {
"linuxdo_connect_client_id": "",
"linuxdo_connect_client_secret_configured": false,
"linuxdo_connect_redirect_url": "",
"dingtalk_connect_enabled": false,
"dingtalk_connect_bypass_registration": false,
"dingtalk_connect_client_id": "",
"dingtalk_connect_client_secret_configured": false,
"dingtalk_connect_redirect_url": "",
"dingtalk_connect_internal_corp_id": "",
"dingtalk_connect_corp_restriction_policy": "",
"dingtalk_connect_sync_corp_email": false,
"dingtalk_connect_sync_corp_email_attr_key": "dingtalk_email",
"dingtalk_connect_sync_corp_email_attr_name": "钉钉企业邮箱",
"dingtalk_connect_sync_dept": false,
"dingtalk_connect_sync_dept_attr_key": "dingtalk_department",
"dingtalk_connect_sync_dept_attr_name": "钉钉部门",
"dingtalk_connect_sync_display_name": false,
"dingtalk_connect_sync_display_name_attr_key": "dingtalk_name",
"dingtalk_connect_sync_display_name_attr_name": "钉钉姓名",
"oidc_connect_enabled": false,
"oidc_connect_provider_name": "OIDC",
"oidc_connect_client_id": "",
@ -748,6 +789,11 @@ func TestAPIContracts(t *testing.T) {
"auth_source_default_wechat_subscriptions": [],
"auth_source_default_wechat_grant_on_signup": false,
"auth_source_default_wechat_grant_on_first_bind": false,
"auth_source_default_dingtalk_balance": 0,
"auth_source_default_dingtalk_concurrency": 5,
"auth_source_default_dingtalk_subscriptions": [],
"auth_source_default_dingtalk_grant_on_signup": false,
"auth_source_default_dingtalk_grant_on_first_bind": false,
"force_email_on_third_party_signup": false,
"default_concurrency": 5,
"default_balance": 1.25,
@ -914,6 +960,22 @@ func TestAPIContracts(t *testing.T) {
"linuxdo_connect_client_id": "",
"linuxdo_connect_client_secret_configured": false,
"linuxdo_connect_redirect_url": "",
"dingtalk_connect_enabled": false,
"dingtalk_connect_bypass_registration": false,
"dingtalk_connect_client_id": "",
"dingtalk_connect_client_secret_configured": false,
"dingtalk_connect_redirect_url": "",
"dingtalk_connect_internal_corp_id": "",
"dingtalk_connect_corp_restriction_policy": "",
"dingtalk_connect_sync_corp_email": false,
"dingtalk_connect_sync_corp_email_attr_key": "dingtalk_email",
"dingtalk_connect_sync_corp_email_attr_name": "钉钉企业邮箱",
"dingtalk_connect_sync_dept": false,
"dingtalk_connect_sync_dept_attr_key": "dingtalk_department",
"dingtalk_connect_sync_dept_attr_name": "钉钉部门",
"dingtalk_connect_sync_display_name": false,
"dingtalk_connect_sync_display_name_attr_key": "dingtalk_name",
"dingtalk_connect_sync_display_name_attr_name": "钉钉姓名",
"oidc_connect_enabled": true,
"oidc_connect_provider_name": "ConfigOIDC",
"oidc_connect_client_id": "oidc-config-client",
@ -1074,6 +1136,11 @@ func TestAPIContracts(t *testing.T) {
"auth_source_default_wechat_subscriptions": [],
"auth_source_default_wechat_grant_on_signup": false,
"auth_source_default_wechat_grant_on_first_bind": false,
"auth_source_default_dingtalk_balance": 0,
"auth_source_default_dingtalk_concurrency": 5,
"auth_source_default_dingtalk_subscriptions": [],
"auth_source_default_dingtalk_grant_on_signup": false,
"auth_source_default_dingtalk_grant_on_first_bind": false,
"force_email_on_third_party_signup": false
}
}`,
@ -1184,10 +1251,10 @@ func newContractDeps(t *testing.T) *contractDeps {
settingService := service.NewSettingService(settingRepo, cfg)
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil, nil)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil)
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil, nil)
adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
jwtAuth := func(c *gin.Context) {

View File

@ -42,15 +42,19 @@ func backendModeAllowsAuthPath(path string) bool {
"/auth/oauth/oidc/callback",
"/auth/oauth/github/callback",
"/auth/oauth/google/callback",
"/auth/oauth/dingtalk/callback",
"/auth/oauth/linuxdo/complete-registration",
"/auth/oauth/wechat/complete-registration",
"/auth/oauth/oidc/complete-registration",
"/auth/oauth/dingtalk/complete-registration",
"/auth/oauth/linuxdo/create-account",
"/auth/oauth/wechat/create-account",
"/auth/oauth/oidc/create-account",
"/auth/oauth/dingtalk/create-account",
"/auth/oauth/linuxdo/bind-login",
"/auth/oauth/wechat/bind-login",
"/auth/oauth/oidc/bind-login",
"/auth/oauth/dingtalk/bind-login",
} {
if strings.HasSuffix(path, suffix) {
return true

View File

@ -270,6 +270,36 @@ func TestBackendModeAuthGuard(t *testing.T) {
path: "/api/v1/auth/oauth/google/callback",
wantStatus: http.StatusOK,
},
{
name: "enabled_blocks_dingtalk_oauth_start",
enabled: "true",
path: "/api/v1/auth/oauth/dingtalk/start",
wantStatus: http.StatusForbidden,
},
{
name: "enabled_allows_dingtalk_oauth_callback",
enabled: "true",
path: "/api/v1/auth/oauth/dingtalk/callback",
wantStatus: http.StatusOK,
},
{
name: "enabled_allows_dingtalk_complete_registration",
enabled: "true",
path: "/api/v1/auth/oauth/dingtalk/complete-registration",
wantStatus: http.StatusOK,
},
{
name: "enabled_allows_dingtalk_create_account",
enabled: "true",
path: "/api/v1/auth/oauth/dingtalk/create-account",
wantStatus: http.StatusOK,
},
{
name: "enabled_allows_dingtalk_bind_login",
enabled: "true",
path: "/api/v1/auth/oauth/dingtalk/bind-login",
wantStatus: http.StatusOK,
},
{
name: "enabled_allows_oauth_pending_exchange",
enabled: "true",

View File

@ -182,6 +182,32 @@ func RegisterAuthRoutes(
}),
h.Auth.CreateOIDCOAuthAccount,
)
auth.GET("/oauth/dingtalk/start", h.Auth.DingTalkOAuthStart)
auth.GET("/oauth/dingtalk/bind/start", func(c *gin.Context) {
query := c.Request.URL.Query()
query.Set("intent", "bind_current_user")
c.Request.URL.RawQuery = query.Encode()
h.Auth.DingTalkOAuthStart(c)
})
auth.GET("/oauth/dingtalk/callback", h.Auth.DingTalkOAuthCallback)
auth.POST("/oauth/dingtalk/complete-registration",
rateLimiter.LimitWithOptions("oauth-dingtalk-complete", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CompleteDingTalkOAuthRegistration,
)
auth.POST("/oauth/dingtalk/bind-login",
rateLimiter.LimitWithOptions("oauth-dingtalk-bind-login", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.BindDingTalkOAuthLogin,
)
auth.POST("/oauth/dingtalk/create-account",
rateLimiter.LimitWithOptions("oauth-dingtalk-create-account", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CreateDingTalkOAuthAccount,
)
}
// 公开设置(无需认证)

View File

@ -1238,7 +1238,7 @@ func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int6
providerKey := strings.TrimSpace(input.ProviderKey)
providerSubject := strings.TrimSpace(input.ProviderSubject)
if providerType == "" {
return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type must be one of email, linuxdo, oidc, or wechat")
return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type must be one of email, linuxdo, oidc, wechat, or dingtalk")
}
if providerKey == "" || providerSubject == "" {
return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type, provider_key, and provider_subject are required")
@ -1493,6 +1493,8 @@ func normalizeAdminAuthIdentityProviderType(input string) string {
return "oidc"
case "wechat":
return "wechat"
case "dingtalk":
return "dingtalk"
default:
return ""
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net/mail"
"strings"
"time"
@ -18,7 +19,7 @@ func normalizeOAuthSignupSource(signupSource string) string {
switch signupSource {
case "", "email":
return "email"
case "linuxdo", "wechat", "oidc", "github", "google":
case "linuxdo", "wechat", "oidc", "github", "google", "dingtalk":
return signupSource
default:
return "email"
@ -109,7 +110,7 @@ func (s *AuthService) RegisterOAuthEmailAccount(
if s == nil {
return nil, nil, ErrServiceUnavailable
}
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
return nil, nil, ErrRegDisabled
}
@ -118,18 +119,22 @@ func (s *AuthService) RegisterOAuthEmailAccount(
return nil, nil, ErrEmailReserved
}
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
slog.Error("oauth email register: policy rejected", "email", email, "error", err.Error())
return nil, nil, err
}
if err := s.VerifyOAuthEmailCode(ctx, email, verifyCode); err != nil {
slog.Error("oauth email register: verify code failed", "email", email, "error", err.Error())
return nil, nil, err
}
if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
slog.Error("oauth email register: invitation failed", "email", email, "error", err.Error())
return nil, nil, err
}
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
if err != nil {
slog.Error("oauth email register: ExistsByEmail failed", "email", email, "error", err.Error())
return nil, nil, ErrServiceUnavailable
}
if existsEmail {
@ -158,6 +163,7 @@ func (s *AuthService) RegisterOAuthEmailAccount(
if errors.Is(err, ErrEmailExists) {
return nil, nil, ErrEmailExists
}
slog.Error("oauth email register: userRepo.Create failed", "email", email, "signup_source", signupSource, "error", err.Error())
return nil, nil, ErrServiceUnavailable
}
@ -181,7 +187,7 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount(
if s == nil {
return nil, nil, ErrServiceUnavailable
}
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
return nil, nil, ErrRegDisabled
}

View File

@ -560,11 +560,25 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
return token, user, nil
}
// canBypassRegistrationDisabledForOAuth 在钉钉企业模式internal_only
// dingtalk_connect_bypass_registration=true 时,允许跳过全局 registration_enabled 检查。
func (s *AuthService) canBypassRegistrationDisabledForOAuth(ctx context.Context, signupSource string) bool {
if signupSource != "dingtalk" {
return false
}
cfg, err := s.settingService.GetDingTalkConnectOAuthConfig(ctx)
if err != nil || !cfg.Enabled || !cfg.BypassRegistration {
return false
}
return cfg.CorpRestrictionPolicy == "internal_only"
}
// LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair。
// 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token。
// invitationCode 仅在邀请码注册模式下新用户注册时使用;已有账号登录时忽略。
// affiliateCode 用于邀请返利绑定,仅在新用户注册时使用。
func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode string) (*TokenPair, *User, error) {
// signupSource 标识来源渠道("dingtalk"/"linuxdo"/"wechat"/"oidc" 等),仅用于豁免检查。
func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode, signupSource string) (*TokenPair, *User, error) {
// 检查 refreshTokenCache 是否可用
if s.refreshTokenCache == nil {
return nil, nil, errors.New("refresh token cache not configured")
@ -587,7 +601,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
if err != nil {
if errors.Is(err, ErrUserNotFound) {
// OAuth 首次登录视为注册
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
return nil, nil, ErrRegDisabled
}
@ -617,7 +631,11 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
return nil, nil, fmt.Errorf("hash password: %w", err)
}
signupSource := inferLegacySignupSource(email)
// 优先用 caller 显式传入的 signupSource如 "dingtalk" / "linuxdo" / "oidc" / "wechat"
// 否则才按邮箱后缀推断——避免有真实邮箱的 OAuth 用户被推断为 "email" 渠道,导致渠道授权错读。
if strings.TrimSpace(signupSource) == "" {
signupSource = inferLegacySignupSource(email)
}
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
var defaultRPMLimit int
if s.settingService != nil {
@ -779,6 +797,8 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource
return defaults.GitHub, true
case "google":
return defaults.Google, true
case "dingtalk":
return defaults.DingTalk, true
default:
return ProviderDefaultGrantSettings{}, false
}
@ -992,6 +1012,8 @@ func (s *AuthService) ensureEmailAuthIdentity(ctx context.Context, user *User, s
func inferLegacySignupSource(email string) string {
normalized := strings.ToLower(strings.TrimSpace(email))
switch {
case strings.HasSuffix(normalized, DingTalkConnectSyntheticEmailDomain):
return "dingtalk"
case strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain):
return "linuxdo"
case strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain):
@ -1086,7 +1108,8 @@ func isReservedEmail(email string) bool {
normalized := strings.ToLower(strings.TrimSpace(email))
return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) ||
strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain) ||
strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain)
strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain) ||
strings.HasSuffix(normalized, DingTalkConnectSyntheticEmailDomain)
}
// GenerateToken 生成JWT access token

View File

@ -602,7 +602,7 @@ func TestAuthService_Register_GrantOnSignupMergesSourceOverridesWithGlobalDefaul
require.NoError(t, err)
require.NotNil(t, user)
require.Equal(t, 9.5, user.Balance)
require.Equal(t, 2, user.Concurrency)
require.Equal(t, 5, user.Concurrency)
require.Len(t, assigner.calls, 1)
require.Equal(t, int64(31), assigner.calls[0].GroupID)
require.Equal(t, 5, assigner.calls[0].ValidityDays)
@ -622,7 +622,7 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_UsesLinuxDoAuthSourceDefa
service.defaultSubAssigner = assigner
service.refreshTokenCache = &refreshTokenCacheStub{}
tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "", "")
tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "", "", "linuxdo")
require.NoError(t, err)
require.NotNil(t, tokenPair)
require.NotNil(t, user)
@ -658,7 +658,7 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_ExistingUserDoesNotGrantA
service.defaultSubAssigner = assigner
service.refreshTokenCache = &refreshTokenCacheStub{}
tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "", "")
tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "", "", "linuxdo")
require.NoError(t, err)
require.NotNil(t, tokenPair)
require.Equal(t, existing.ID, user.ID)
@ -667,3 +667,99 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_ExistingUserDoesNotGrantA
require.Empty(t, repo.created)
require.Empty(t, assigner.calls)
}
// newAuthServiceWithDingTalkCfg 构建一个含完整 DingTalk config 的 AuthService
// 用于测试 canBypassRegistrationDisabledForOAuth。
func newAuthServiceWithDingTalkCfg(settings map[string]string, dtCfg config.DingTalkConnectConfig) *AuthService {
cfg := &config.Config{
JWT: config.JWTConfig{Secret: "test-secret", ExpireHour: 1},
Default: config.DefaultConfig{UserBalance: 3.5, UserConcurrency: 2},
DingTalk: dtCfg,
}
settingService := NewSettingService(&settingRepoStub{values: settings}, cfg)
return NewAuthService(nil, nil, nil, nil, cfg, settingService, nil, nil, nil, nil, nil, nil)
}
// minDingTalkURLs 返回一个包含必填字段的基础 DingTalkConnectConfig不设 Enabled/BypassRegistration/Policy
func minDingTalkURLs() config.DingTalkConnectConfig {
return config.DingTalkConnectConfig{
ClientID: "test-client",
ClientSecret: "test-secret",
AuthorizeURL: "https://example.com/oauth2/auth",
TokenURL: "https://example.com/oauth2/token",
UserInfoURL: "https://example.com/oauth2/userinfo",
RedirectURL: "https://example.com/callback",
FrontendRedirectURL: "https://example.com/auth/callback",
DingTalkAppKind: "internal_app",
AppType: "internal",
}
}
func TestCanBypassRegistrationDisabledForOAuth(t *testing.T) {
cases := []struct {
name string
signupSource string
settings map[string]string
dtCfg config.DingTalkConnectConfig
want bool
}{
{
name: "non-dingtalk source → false",
signupSource: "linuxdo",
settings: map[string]string{},
dtCfg: minDingTalkURLs(),
want: false,
},
{
name: "dingtalk but cfg.Enabled=false → false",
signupSource: "dingtalk",
settings: map[string]string{
SettingKeyDingTalkConnectEnabled: "false",
SettingKeyDingTalkConnectBypassRegistration: "true",
SettingKeyDingTalkConnectCorpRestrictionPolicy: "internal_only",
},
dtCfg: minDingTalkURLs(),
want: false,
},
{
name: "dingtalk enabled but BypassRegistration=false → false",
signupSource: "dingtalk",
settings: map[string]string{
SettingKeyDingTalkConnectEnabled: "true",
SettingKeyDingTalkConnectBypassRegistration: "false",
SettingKeyDingTalkConnectCorpRestrictionPolicy: "internal_only",
},
dtCfg: minDingTalkURLs(),
want: false,
},
{
name: "dingtalk enabled + bypass=true but policy=none → false",
signupSource: "dingtalk",
settings: map[string]string{
SettingKeyDingTalkConnectEnabled: "true",
SettingKeyDingTalkConnectBypassRegistration: "true",
SettingKeyDingTalkConnectCorpRestrictionPolicy: "none",
},
dtCfg: minDingTalkURLs(),
want: false,
},
{
name: "dingtalk enabled + bypass=true + policy=internal_only → true",
signupSource: "dingtalk",
settings: map[string]string{
SettingKeyDingTalkConnectEnabled: "true",
SettingKeyDingTalkConnectBypassRegistration: "true",
SettingKeyDingTalkConnectCorpRestrictionPolicy: "internal_only",
},
dtCfg: minDingTalkURLs(),
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc := newAuthServiceWithDingTalkCfg(tc.settings, tc.dtCfg)
got := svc.canBypassRegistrationDisabledForOAuth(context.Background(), tc.signupSource)
require.Equal(t, tc.want, got)
})
}
}

View File

@ -0,0 +1,13 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestIsReservedEmail_DingTalkDomain(t *testing.T) {
require.True(t, isReservedEmail("dingtalk-123@dingtalk-connect.invalid"))
require.True(t, isReservedEmail("DINGTALK-456@DINGTALK-CONNECT.INVALID")) // case-insensitive
require.False(t, isReservedEmail("real@dingtalk.com"))
}

View File

@ -92,6 +92,9 @@ const OIDCConnectSyntheticEmailDomain = "@oidc-connect.invalid"
// WeChatConnectSyntheticEmailDomain 是 WeChat Connect 用户的合成邮箱后缀RFC 保留域名)。
const WeChatConnectSyntheticEmailDomain = "@wechat-connect.invalid"
// DingTalkConnectSyntheticEmailDomain 是 DingTalk Connect 用户的合成邮箱后缀RFC 保留域名)。
const DingTalkConnectSyntheticEmailDomain = "@dingtalk-connect.invalid"
// Setting keys
const (
// 注册设置
@ -137,6 +140,24 @@ const (
SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
// DingTalk Connect OAuth 登录设置
SettingKeyDingTalkConnectEnabled = "dingtalk_connect_enabled"
SettingKeyDingTalkConnectClientID = "dingtalk_connect_client_id"
SettingKeyDingTalkConnectClientSecret = "dingtalk_connect_client_secret"
SettingKeyDingTalkConnectRedirectURL = "dingtalk_connect_redirect_url"
SettingKeyDingTalkConnectCorpRestrictionPolicy = "dingtalk_connect_corp_restriction_policy"
SettingKeyDingTalkConnectInternalCorpID = "dingtalk_connect_internal_corp_id"
SettingKeyDingTalkConnectBypassRegistration = "dingtalk_connect_bypass_registration"
SettingKeyDingTalkConnectSyncCorpEmail = "dingtalk_connect_sync_corp_email"
SettingKeyDingTalkConnectSyncDisplayName = "dingtalk_connect_sync_display_name"
SettingKeyDingTalkConnectSyncDept = "dingtalk_connect_sync_dept"
SettingKeyDingTalkConnectSyncCorpEmailAttrKey = "dingtalk_connect_sync_corp_email_attr_key"
SettingKeyDingTalkConnectSyncDisplayNameAttrKey = "dingtalk_connect_sync_display_name_attr_key"
SettingKeyDingTalkConnectSyncDeptAttrKey = "dingtalk_connect_sync_dept_attr_key"
SettingKeyDingTalkConnectSyncCorpEmailAttrName = "dingtalk_connect_sync_corp_email_attr_name"
SettingKeyDingTalkConnectSyncDisplayNameAttrName = "dingtalk_connect_sync_display_name_attr_name"
SettingKeyDingTalkConnectSyncDeptAttrName = "dingtalk_connect_sync_dept_attr_name"
// WeChat Connect OAuth 登录设置
SettingKeyWeChatConnectEnabled = "wechat_connect_enabled"
SettingKeyWeChatConnectAppID = "wechat_connect_app_id"
@ -214,37 +235,42 @@ const (
SettingKeyDefaultUserRPMLimit = "default_user_rpm_limit" // 新用户默认 RPM 限制0 = 不限制)
// 第三方认证来源默认授予配置
SettingKeyAuthSourceDefaultEmailBalance = "auth_source_default_email_balance"
SettingKeyAuthSourceDefaultEmailConcurrency = "auth_source_default_email_concurrency"
SettingKeyAuthSourceDefaultEmailSubscriptions = "auth_source_default_email_subscriptions"
SettingKeyAuthSourceDefaultEmailGrantOnSignup = "auth_source_default_email_grant_on_signup"
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind = "auth_source_default_email_grant_on_first_bind"
SettingKeyAuthSourceDefaultLinuxDoBalance = "auth_source_default_linuxdo_balance"
SettingKeyAuthSourceDefaultLinuxDoConcurrency = "auth_source_default_linuxdo_concurrency"
SettingKeyAuthSourceDefaultLinuxDoSubscriptions = "auth_source_default_linuxdo_subscriptions"
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup = "auth_source_default_linuxdo_grant_on_signup"
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind = "auth_source_default_linuxdo_grant_on_first_bind"
SettingKeyAuthSourceDefaultOIDCBalance = "auth_source_default_oidc_balance"
SettingKeyAuthSourceDefaultOIDCConcurrency = "auth_source_default_oidc_concurrency"
SettingKeyAuthSourceDefaultOIDCSubscriptions = "auth_source_default_oidc_subscriptions"
SettingKeyAuthSourceDefaultOIDCGrantOnSignup = "auth_source_default_oidc_grant_on_signup"
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind = "auth_source_default_oidc_grant_on_first_bind"
SettingKeyAuthSourceDefaultWeChatBalance = "auth_source_default_wechat_balance"
SettingKeyAuthSourceDefaultWeChatConcurrency = "auth_source_default_wechat_concurrency"
SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions"
SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup"
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind"
SettingKeyAuthSourceDefaultGitHubBalance = "auth_source_default_github_balance"
SettingKeyAuthSourceDefaultGitHubConcurrency = "auth_source_default_github_concurrency"
SettingKeyAuthSourceDefaultGitHubSubscriptions = "auth_source_default_github_subscriptions"
SettingKeyAuthSourceDefaultGitHubGrantOnSignup = "auth_source_default_github_grant_on_signup"
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind = "auth_source_default_github_grant_on_first_bind"
SettingKeyAuthSourceDefaultGoogleBalance = "auth_source_default_google_balance"
SettingKeyAuthSourceDefaultGoogleConcurrency = "auth_source_default_google_concurrency"
SettingKeyAuthSourceDefaultGoogleSubscriptions = "auth_source_default_google_subscriptions"
SettingKeyAuthSourceDefaultGoogleGrantOnSignup = "auth_source_default_google_grant_on_signup"
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind = "auth_source_default_google_grant_on_first_bind"
SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup"
SettingKeyAuthSourceDefaultEmailBalance = "auth_source_default_email_balance"
SettingKeyAuthSourceDefaultEmailConcurrency = "auth_source_default_email_concurrency"
SettingKeyAuthSourceDefaultEmailSubscriptions = "auth_source_default_email_subscriptions"
SettingKeyAuthSourceDefaultEmailGrantOnSignup = "auth_source_default_email_grant_on_signup"
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind = "auth_source_default_email_grant_on_first_bind"
SettingKeyAuthSourceDefaultLinuxDoBalance = "auth_source_default_linuxdo_balance"
SettingKeyAuthSourceDefaultLinuxDoConcurrency = "auth_source_default_linuxdo_concurrency"
SettingKeyAuthSourceDefaultLinuxDoSubscriptions = "auth_source_default_linuxdo_subscriptions"
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup = "auth_source_default_linuxdo_grant_on_signup"
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind = "auth_source_default_linuxdo_grant_on_first_bind"
SettingKeyAuthSourceDefaultOIDCBalance = "auth_source_default_oidc_balance"
SettingKeyAuthSourceDefaultOIDCConcurrency = "auth_source_default_oidc_concurrency"
SettingKeyAuthSourceDefaultOIDCSubscriptions = "auth_source_default_oidc_subscriptions"
SettingKeyAuthSourceDefaultOIDCGrantOnSignup = "auth_source_default_oidc_grant_on_signup"
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind = "auth_source_default_oidc_grant_on_first_bind"
SettingKeyAuthSourceDefaultWeChatBalance = "auth_source_default_wechat_balance"
SettingKeyAuthSourceDefaultWeChatConcurrency = "auth_source_default_wechat_concurrency"
SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions"
SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup"
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind"
SettingKeyAuthSourceDefaultGitHubBalance = "auth_source_default_github_balance"
SettingKeyAuthSourceDefaultGitHubConcurrency = "auth_source_default_github_concurrency"
SettingKeyAuthSourceDefaultGitHubSubscriptions = "auth_source_default_github_subscriptions"
SettingKeyAuthSourceDefaultGitHubGrantOnSignup = "auth_source_default_github_grant_on_signup"
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind = "auth_source_default_github_grant_on_first_bind"
SettingKeyAuthSourceDefaultGoogleBalance = "auth_source_default_google_balance"
SettingKeyAuthSourceDefaultGoogleConcurrency = "auth_source_default_google_concurrency"
SettingKeyAuthSourceDefaultGoogleSubscriptions = "auth_source_default_google_subscriptions"
SettingKeyAuthSourceDefaultGoogleGrantOnSignup = "auth_source_default_google_grant_on_signup"
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind = "auth_source_default_google_grant_on_first_bind"
SettingKeyAuthSourceDefaultDingTalkBalance = "auth_source_default_dingtalk_balance"
SettingKeyAuthSourceDefaultDingTalkConcurrency = "auth_source_default_dingtalk_concurrency"
SettingKeyAuthSourceDefaultDingTalkSubscriptions = "auth_source_default_dingtalk_subscriptions"
SettingKeyAuthSourceDefaultDingTalkGrantOnSignup = "auth_source_default_dingtalk_grant_on_signup"
SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind = "auth_source_default_dingtalk_grant_on_first_bind"
SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup"
// 管理员 API Key
SettingKeyAdminAPIKey = "admin_api_key" // 全局管理员 API Key用于外部系统集成

View File

@ -1402,7 +1402,6 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
}
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
// 调度流程文档见 docs/ACCOUNT_SCHEDULING_FLOW.md 。
// metadataUserID: 用于客户端亲和调度,从中提取客户端 ID
// sub2apiUserID: 系统用户 ID用于二维亲和调度
func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string, sub2apiUserID int64) (*AccountSelectionResult, error) {

View File

@ -24,6 +24,25 @@ import (
"golang.org/x/sync/singleflight"
)
// CoerceDingTalkCorpPolicyForWrite 是 coerceDeprecatedDingTalkCorpPolicy 的导出版本,
// 用于 admin handler 在写入路径上对客户端直传的入参做防御性 coerce前端 UI 虽已无 whitelist 选项,
// 但 API 可被直接调用)。
func CoerceDingTalkCorpPolicyForWrite(policy string) string {
return coerceDeprecatedDingTalkCorpPolicy(policy)
}
// coerceDeprecatedDingTalkCorpPolicy 把已废弃的 corp_restriction_policy 值替换成安全的等价值。
// 升级前残留在 DB 中的 "whitelist" 会导致 callback 链路在 default case 静默 fail-closed
// (所有钉钉登录被拒)。这里统一退化为 "none" 让服务保持可用,并 warn 日志提醒 admin 重新保存设置。
func coerceDeprecatedDingTalkCorpPolicy(policy string) string {
if policy == "whitelist" {
slog.Warn("dingtalk: corp_restriction_policy=whitelist is deprecated and unsupported, coercing to none",
"hint", "re-save DingTalk settings in admin UI to clear this warning")
return "none"
}
return policy
}
var (
ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found")
@ -146,6 +165,7 @@ type AuthSourceDefaultSettings struct {
WeChat ProviderDefaultGrantSettings
GitHub ProviderDefaultGrantSettings
Google ProviderDefaultGrantSettings
DingTalk ProviderDefaultGrantSettings
ForceEmailOnThirdPartySignup bool
}
@ -200,6 +220,13 @@ var (
grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
}
dingTalkAuthSourceDefaultKeys = authSourceDefaultKeySet{
balance: SettingKeyAuthSourceDefaultDingTalkBalance,
concurrency: SettingKeyAuthSourceDefaultDingTalkConcurrency,
subscriptions: SettingKeyAuthSourceDefaultDingTalkSubscriptions,
grantOnSignup: SettingKeyAuthSourceDefaultDingTalkGrantOnSignup,
grantOnFirstBind: SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind,
}
)
const (
@ -606,6 +633,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyCustomMenuItems,
SettingKeyCustomEndpoints,
SettingKeyLinuxDoConnectEnabled,
SettingKeyDingTalkConnectEnabled,
SettingKeyWeChatConnectEnabled,
SettingKeyWeChatConnectAppID,
SettingKeyWeChatConnectAppSecret,
@ -654,6 +682,12 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
} else {
linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled
}
dingTalkEnabled := false
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
dingTalkEnabled = raw == "true"
} else {
dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled
}
oidcEnabled := false
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
oidcEnabled = raw == "true"
@ -723,6 +757,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
CustomMenuItems: settings[SettingKeyCustomMenuItems],
CustomEndpoints: settings[SettingKeyCustomEndpoints],
LinuxDoOAuthEnabled: linuxDoEnabled,
DingTalkOAuthEnabled: dingTalkEnabled,
WeChatOAuthEnabled: weChatEnabled,
WeChatOAuthOpenEnabled: weChatOpenEnabled,
WeChatOAuthMPEnabled: weChatMPEnabled,
@ -926,6 +961,7 @@ type PublicSettingsInjectionPayload struct {
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
@ -990,6 +1026,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
@ -1476,6 +1513,26 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret
}
// DingTalk Connect OAuth 登录
updates[SettingKeyDingTalkConnectEnabled] = strconv.FormatBool(settings.DingTalkConnectEnabled)
updates[SettingKeyDingTalkConnectClientID] = settings.DingTalkConnectClientID
updates[SettingKeyDingTalkConnectRedirectURL] = settings.DingTalkConnectRedirectURL
if settings.DingTalkConnectClientSecret != "" {
updates[SettingKeyDingTalkConnectClientSecret] = settings.DingTalkConnectClientSecret
}
updates[SettingKeyDingTalkConnectCorpRestrictionPolicy] = settings.DingTalkConnectCorpRestrictionPolicy
updates[SettingKeyDingTalkConnectInternalCorpID] = settings.DingTalkConnectInternalCorpID
updates[SettingKeyDingTalkConnectBypassRegistration] = strconv.FormatBool(settings.DingTalkConnectBypassRegistration)
updates[SettingKeyDingTalkConnectSyncCorpEmail] = strconv.FormatBool(settings.DingTalkConnectSyncCorpEmail)
updates[SettingKeyDingTalkConnectSyncDisplayName] = strconv.FormatBool(settings.DingTalkConnectSyncDisplayName)
updates[SettingKeyDingTalkConnectSyncDept] = strconv.FormatBool(settings.DingTalkConnectSyncDept)
updates[SettingKeyDingTalkConnectSyncCorpEmailAttrKey] = settings.DingTalkConnectSyncCorpEmailAttrKey
updates[SettingKeyDingTalkConnectSyncDisplayNameAttrKey] = settings.DingTalkConnectSyncDisplayNameAttrKey
updates[SettingKeyDingTalkConnectSyncDeptAttrKey] = settings.DingTalkConnectSyncDeptAttrKey
updates[SettingKeyDingTalkConnectSyncCorpEmailAttrName] = settings.DingTalkConnectSyncCorpEmailAttrName
updates[SettingKeyDingTalkConnectSyncDisplayNameAttrName] = settings.DingTalkConnectSyncDisplayNameAttrName
updates[SettingKeyDingTalkConnectSyncDeptAttrName] = settings.DingTalkConnectSyncDeptAttrName
// Generic OIDC OAuth 登录
updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled)
updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName
@ -1677,19 +1734,21 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett
settings.WeChat.Subscriptions,
settings.GitHub.Subscriptions,
settings.Google.Subscriptions,
settings.DingTalk.Subscriptions,
} {
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
return nil, err
}
}
updates := make(map[string]string, 31)
updates := make(map[string]string, 36)
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub)
writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google)
writeProviderDefaultGrantUpdates(updates, dingTalkAuthSourceDefaultKeys, settings.DingTalk)
updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup)
return updates, nil
}
@ -2225,6 +2284,11 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut
SettingKeyAuthSourceDefaultGoogleSubscriptions,
SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
SettingKeyAuthSourceDefaultDingTalkBalance,
SettingKeyAuthSourceDefaultDingTalkConcurrency,
SettingKeyAuthSourceDefaultDingTalkSubscriptions,
SettingKeyAuthSourceDefaultDingTalkGrantOnSignup,
SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind,
SettingKeyForceEmailOnThirdPartySignup,
}
@ -2240,6 +2304,7 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut
WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys),
GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys),
Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys),
DingTalk: parseProviderDefaultGrantSettings(settings, dingTalkAuthSourceDefaultKeys),
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
}, nil
}
@ -2316,111 +2381,116 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// 初始化默认设置
defaults := map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "false",
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
SettingKeyLoginAgreementEnabled: "false",
SettingKeyLoginAgreementMode: defaultLoginAgreementMode,
SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate,
SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON,
SettingKeySiteName: "Sub2API",
SettingKeySiteLogo: "",
SettingKeyPurchaseSubscriptionEnabled: "false",
SettingKeyPurchaseSubscriptionURL: "",
SettingKeyTableDefaultPageSize: "20",
SettingKeyTablePageSizeOptions: "[10,20,50,100]",
SettingKeyCustomMenuItems: "[]",
SettingKeyCustomEndpoints: "[]",
SettingKeyWeChatConnectEnabled: "false",
SettingKeyWeChatConnectAppID: "",
SettingKeyWeChatConnectAppSecret: "",
SettingKeyWeChatConnectOpenAppID: "",
SettingKeyWeChatConnectOpenAppSecret: "",
SettingKeyWeChatConnectMPAppID: "",
SettingKeyWeChatConnectMPAppSecret: "",
SettingKeyWeChatConnectMobileAppID: "",
SettingKeyWeChatConnectMobileAppSecret: "",
SettingKeyWeChatConnectOpenEnabled: "false",
SettingKeyWeChatConnectMPEnabled: "false",
SettingKeyWeChatConnectMobileEnabled: "false",
SettingKeyWeChatConnectMode: "open",
SettingKeyWeChatConnectScopes: "snsapi_login",
SettingKeyWeChatConnectRedirectURL: "",
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
SettingKeyGitHubOAuthEnabled: "false",
SettingKeyGitHubOAuthClientID: "",
SettingKeyGitHubOAuthClientSecret: "",
SettingKeyGitHubOAuthRedirectURL: "",
SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend,
SettingKeyGoogleOAuthEnabled: "false",
SettingKeyGoogleOAuthClientID: "",
SettingKeyGoogleOAuthClientSecret: "",
SettingKeyGoogleOAuthRedirectURL: "",
SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend,
SettingKeyOIDCConnectEnabled: "false",
SettingKeyOIDCConnectProviderName: "OIDC",
SettingKeyOIDCConnectClientID: "",
SettingKeyOIDCConnectClientSecret: "",
SettingKeyOIDCConnectIssuerURL: "",
SettingKeyOIDCConnectDiscoveryURL: "",
SettingKeyOIDCConnectAuthorizeURL: "",
SettingKeyOIDCConnectTokenURL: "",
SettingKeyOIDCConnectUserInfoURL: "",
SettingKeyOIDCConnectJWKSURL: "",
SettingKeyOIDCConnectScopes: "openid email profile",
SettingKeyOIDCConnectRedirectURL: "",
SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback",
SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post",
SettingKeyOIDCConnectUsePKCE: strconv.FormatBool(oidcUsePKCEDefault),
SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault),
SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256",
SettingKeyOIDCConnectClockSkewSeconds: "120",
SettingKeyOIDCConnectRequireEmailVerified: "false",
SettingKeyOIDCConnectUserInfoEmailPath: "",
SettingKeyOIDCConnectUserInfoIDPath: "",
SettingKeyOIDCConnectUserInfoUsernamePath: "",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64),
SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault),
SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault),
SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64),
SettingKeyDefaultUserRPMLimit: "0",
SettingKeyDefaultSubscriptions: "[]",
SettingKeyAuthSourceDefaultEmailBalance: "0",
SettingKeyAuthSourceDefaultEmailConcurrency: "5",
SettingKeyAuthSourceDefaultEmailSubscriptions: "[]",
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false",
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultLinuxDoBalance: "0",
SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5",
SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]",
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false",
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultOIDCBalance: "0",
SettingKeyAuthSourceDefaultOIDCConcurrency: "5",
SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]",
SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false",
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultWeChatBalance: "0",
SettingKeyAuthSourceDefaultWeChatConcurrency: "5",
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false",
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultGitHubBalance: "0",
SettingKeyAuthSourceDefaultGitHubConcurrency: "5",
SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]",
SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false",
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultGoogleBalance: "0",
SettingKeyAuthSourceDefaultGoogleConcurrency: "5",
SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]",
SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false",
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false",
SettingKeyForceEmailOnThirdPartySignup: "false",
SettingKeySMTPPort: "587",
SettingKeySMTPUseTLS: "false",
SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "false",
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
SettingKeyLoginAgreementEnabled: "false",
SettingKeyLoginAgreementMode: defaultLoginAgreementMode,
SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate,
SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON,
SettingKeySiteName: "Sub2API",
SettingKeySiteLogo: "",
SettingKeyPurchaseSubscriptionEnabled: "false",
SettingKeyPurchaseSubscriptionURL: "",
SettingKeyTableDefaultPageSize: "20",
SettingKeyTablePageSizeOptions: "[10,20,50,100]",
SettingKeyCustomMenuItems: "[]",
SettingKeyCustomEndpoints: "[]",
SettingKeyWeChatConnectEnabled: "false",
SettingKeyWeChatConnectAppID: "",
SettingKeyWeChatConnectAppSecret: "",
SettingKeyWeChatConnectOpenAppID: "",
SettingKeyWeChatConnectOpenAppSecret: "",
SettingKeyWeChatConnectMPAppID: "",
SettingKeyWeChatConnectMPAppSecret: "",
SettingKeyWeChatConnectMobileAppID: "",
SettingKeyWeChatConnectMobileAppSecret: "",
SettingKeyWeChatConnectOpenEnabled: "false",
SettingKeyWeChatConnectMPEnabled: "false",
SettingKeyWeChatConnectMobileEnabled: "false",
SettingKeyWeChatConnectMode: "open",
SettingKeyWeChatConnectScopes: "snsapi_login",
SettingKeyWeChatConnectRedirectURL: "",
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
SettingKeyGitHubOAuthEnabled: "false",
SettingKeyGitHubOAuthClientID: "",
SettingKeyGitHubOAuthClientSecret: "",
SettingKeyGitHubOAuthRedirectURL: "",
SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend,
SettingKeyGoogleOAuthEnabled: "false",
SettingKeyGoogleOAuthClientID: "",
SettingKeyGoogleOAuthClientSecret: "",
SettingKeyGoogleOAuthRedirectURL: "",
SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend,
SettingKeyOIDCConnectEnabled: "false",
SettingKeyOIDCConnectProviderName: "OIDC",
SettingKeyOIDCConnectClientID: "",
SettingKeyOIDCConnectClientSecret: "",
SettingKeyOIDCConnectIssuerURL: "",
SettingKeyOIDCConnectDiscoveryURL: "",
SettingKeyOIDCConnectAuthorizeURL: "",
SettingKeyOIDCConnectTokenURL: "",
SettingKeyOIDCConnectUserInfoURL: "",
SettingKeyOIDCConnectJWKSURL: "",
SettingKeyOIDCConnectScopes: "openid email profile",
SettingKeyOIDCConnectRedirectURL: "",
SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback",
SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post",
SettingKeyOIDCConnectUsePKCE: strconv.FormatBool(oidcUsePKCEDefault),
SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault),
SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256",
SettingKeyOIDCConnectClockSkewSeconds: "120",
SettingKeyOIDCConnectRequireEmailVerified: "false",
SettingKeyOIDCConnectUserInfoEmailPath: "",
SettingKeyOIDCConnectUserInfoIDPath: "",
SettingKeyOIDCConnectUserInfoUsernamePath: "",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64),
SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault),
SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault),
SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64),
SettingKeyDefaultUserRPMLimit: "0",
SettingKeyDefaultSubscriptions: "[]",
SettingKeyAuthSourceDefaultEmailBalance: "0",
SettingKeyAuthSourceDefaultEmailConcurrency: "5",
SettingKeyAuthSourceDefaultEmailSubscriptions: "[]",
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false",
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultLinuxDoBalance: "0",
SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5",
SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]",
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false",
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultOIDCBalance: "0",
SettingKeyAuthSourceDefaultOIDCConcurrency: "5",
SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]",
SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false",
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultWeChatBalance: "0",
SettingKeyAuthSourceDefaultWeChatConcurrency: "5",
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false",
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultGitHubBalance: "0",
SettingKeyAuthSourceDefaultGitHubConcurrency: "5",
SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]",
SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false",
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultGoogleBalance: "0",
SettingKeyAuthSourceDefaultGoogleConcurrency: "5",
SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]",
SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false",
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultDingTalkBalance: "0",
SettingKeyAuthSourceDefaultDingTalkConcurrency: "5",
SettingKeyAuthSourceDefaultDingTalkSubscriptions: "[]",
SettingKeyAuthSourceDefaultDingTalkGrantOnSignup: "false",
SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind: "false",
SettingKeyForceEmailOnThirdPartySignup: "false",
SettingKeySMTPPort: "587",
SettingKeySMTPUseTLS: "false",
// Model fallback defaults
SettingKeyEnableModelFallback: "false",
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
@ -2599,6 +2669,136 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}
result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != ""
// DingTalk Connect 设置:
// - 兼容 config.yaml/env
// - 支持后台系统设置覆盖并持久化(存储于 DB
dingTalkBase := config.DingTalkConnectConfig{}
if s.cfg != nil {
dingTalkBase = s.cfg.DingTalk
}
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
result.DingTalkConnectEnabled = raw == "true"
} else {
result.DingTalkConnectEnabled = dingTalkBase.Enabled
}
if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectClientID = strings.TrimSpace(v)
} else {
result.DingTalkConnectClientID = dingTalkBase.ClientID
}
if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectRedirectURL = strings.TrimSpace(v)
} else {
result.DingTalkConnectRedirectURL = dingTalkBase.RedirectURL
}
result.DingTalkConnectClientSecret = strings.TrimSpace(settings[SettingKeyDingTalkConnectClientSecret])
if result.DingTalkConnectClientSecret == "" {
result.DingTalkConnectClientSecret = strings.TrimSpace(dingTalkBase.ClientSecret)
}
result.DingTalkConnectClientSecretConfigured = result.DingTalkConnectClientSecret != ""
if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(v)
} else {
result.DingTalkConnectCorpRestrictionPolicy = dingTalkBase.CorpRestrictionPolicy
}
result.DingTalkConnectCorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(result.DingTalkConnectCorpRestrictionPolicy)
if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectInternalCorpID = strings.TrimSpace(v)
} else {
result.DingTalkConnectInternalCorpID = dingTalkBase.InternalCorpID
}
if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectBypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true")
} else {
result.DingTalkConnectBypassRegistration = dingTalkBase.BypassRegistration
}
// bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false
// 以保证加载出的 effective config 永远是一致状态。
if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" {
result.DingTalkConnectBypassRegistration = false
}
if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectSyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true")
} else {
result.DingTalkConnectSyncCorpEmail = dingTalkBase.SyncCorpEmail
}
if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectSyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true")
} else {
result.DingTalkConnectSyncDisplayName = dingTalkBase.SyncDisplayName
}
if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectSyncDept = strings.EqualFold(strings.TrimSpace(v), "true")
} else {
result.DingTalkConnectSyncDept = dingTalkBase.SyncDept
}
// 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。
if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" {
result.DingTalkConnectSyncCorpEmail = false
result.DingTalkConnectSyncDisplayName = false
result.DingTalkConnectSyncDept = false
}
// 身份同步目标 attr keyDB 空 → fallback 默认值)
result.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey])
if result.DingTalkConnectSyncCorpEmailAttrKey == "" {
if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrKey); v != "" {
result.DingTalkConnectSyncCorpEmailAttrKey = v
} else {
result.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email"
}
}
result.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey])
if result.DingTalkConnectSyncDisplayNameAttrKey == "" {
if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrKey); v != "" {
result.DingTalkConnectSyncDisplayNameAttrKey = v
} else {
result.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name"
}
}
result.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey])
if result.DingTalkConnectSyncDeptAttrKey == "" {
if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrKey); v != "" {
result.DingTalkConnectSyncDeptAttrKey = v
} else {
result.DingTalkConnectSyncDeptAttrKey = "dingtalk_department"
}
}
// 身份同步目标 attr 显示名称DB 空 → fallback 默认中文)
result.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrName])
if result.DingTalkConnectSyncCorpEmailAttrName == "" {
if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrName); v != "" {
result.DingTalkConnectSyncCorpEmailAttrName = v
} else {
result.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱"
}
}
result.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrName])
if result.DingTalkConnectSyncDisplayNameAttrName == "" {
if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrName); v != "" {
result.DingTalkConnectSyncDisplayNameAttrName = v
} else {
result.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名"
}
}
result.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrName])
if result.DingTalkConnectSyncDeptAttrName == "" {
if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrName); v != "" {
result.DingTalkConnectSyncDeptAttrName = v
} else {
result.DingTalkConnectSyncDeptAttrName = "钉钉部门"
}
}
// Generic OIDC 设置:
// - 兼容 config.yaml/env
// - 支持后台系统设置覆盖并持久化(存储于 DB
@ -2992,10 +3192,14 @@ func mergeProviderDefaultGrantSettings(globalDefaults ProviderDefaultGrantSettin
GrantOnFirstBind: providerDefaults.GrantOnFirstBind,
}
if providerDefaults.Balance != defaultAuthSourceBalance {
// 注意:不能把 parse 默认值 (defaultAuthSourceBalance / defaultAuthSourceConcurrency)
// 当作"未配置"哨兵——admin 完全有权显式设成相同的值,那时仍应覆盖 globalDefaults。
// 旧实现的 `!= defaultAuthSourceConcurrency` 会把 admin 设的 5 与 fallback 5 混淆,
// 导致渠道发放退回到全局默认(如 1表现为"管理员设 5、新用户实际拿 1"。
if providerDefaults.Balance >= 0 {
result.Balance = providerDefaults.Balance
}
if providerDefaults.Concurrency > 0 && providerDefaults.Concurrency != defaultAuthSourceConcurrency {
if providerDefaults.Concurrency > 0 {
result.Concurrency = providerDefaults.Concurrency
}
if len(providerDefaults.Subscriptions) > 0 {
@ -3281,6 +3485,157 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf
return effective, nil
}
// GetDingTalkConnectOAuthConfig 返回用于登录的"最终生效" DingTalk Connect 配置。
//
// 优先级:
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
// - 否则回退到 config.yaml/env 的值
func (s *SettingService) GetDingTalkConnectOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) {
if s == nil || s.cfg == nil {
return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
}
effective := s.cfg.DingTalk
keys := []string{
SettingKeyDingTalkConnectEnabled,
SettingKeyDingTalkConnectClientID,
SettingKeyDingTalkConnectClientSecret,
SettingKeyDingTalkConnectRedirectURL,
SettingKeyDingTalkConnectCorpRestrictionPolicy,
SettingKeyDingTalkConnectInternalCorpID,
SettingKeyDingTalkConnectBypassRegistration,
SettingKeyDingTalkConnectSyncCorpEmail,
SettingKeyDingTalkConnectSyncDisplayName,
SettingKeyDingTalkConnectSyncDept,
SettingKeyDingTalkConnectSyncCorpEmailAttrKey,
SettingKeyDingTalkConnectSyncDisplayNameAttrKey,
SettingKeyDingTalkConnectSyncDeptAttrKey,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil {
return config.DingTalkConnectConfig{}, fmt.Errorf("get dingtalk connect settings: %w", err)
}
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
effective.Enabled = raw == "true"
}
if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" {
effective.ClientID = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyDingTalkConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
effective.ClientSecret = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
effective.RedirectURL = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" {
effective.CorpRestrictionPolicy = strings.TrimSpace(v)
}
effective.CorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(effective.CorpRestrictionPolicy)
if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" {
effective.InternalCorpID = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" {
effective.BypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true")
}
// bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false
// 以保证 OAuth callback 看到的 effective config 永远是一致状态。
if effective.CorpRestrictionPolicy != "internal_only" {
effective.BypassRegistration = false
}
if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" {
effective.SyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true")
}
if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" {
effective.SyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true")
}
if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" {
effective.SyncDept = strings.EqualFold(strings.TrimSpace(v), "true")
}
// 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。
if effective.CorpRestrictionPolicy != "internal_only" {
effective.SyncCorpEmail = false
effective.SyncDisplayName = false
effective.SyncDept = false
}
// 身份同步目标 attr keyDB 空 → fallback 默认值)
if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]); v != "" {
effective.SyncCorpEmailAttrKey = v
}
if effective.SyncCorpEmailAttrKey == "" {
effective.SyncCorpEmailAttrKey = "dingtalk_email"
}
if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]); v != "" {
effective.SyncDisplayNameAttrKey = v
}
if effective.SyncDisplayNameAttrKey == "" {
effective.SyncDisplayNameAttrKey = "dingtalk_name"
}
if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]); v != "" {
effective.SyncDeptAttrKey = v
}
if effective.SyncDeptAttrKey == "" {
effective.SyncDeptAttrKey = "dingtalk_department"
}
if !effective.Enabled {
return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled")
}
// 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
if strings.TrimSpace(effective.ClientID) == "" {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client id not configured")
}
if strings.TrimSpace(effective.AuthorizeURL) == "" {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url not configured")
}
if strings.TrimSpace(effective.TokenURL) == "" {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url not configured")
}
if strings.TrimSpace(effective.UserInfoURL) == "" {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url not configured")
}
if strings.TrimSpace(effective.RedirectURL) == "" {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url not configured")
}
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url not configured")
}
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url invalid")
}
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url invalid")
}
if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url invalid")
}
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url invalid")
}
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url invalid")
}
if strings.TrimSpace(effective.ClientSecret) == "" {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client secret not configured")
}
// 镜像 admin handler 行为internal_only policy 隐式要求 AppType=internal
if effective.CorpRestrictionPolicy == "internal_only" {
effective.AppType = "internal"
}
if err := config.ValidateDingTalkConfig(effective); err != nil {
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", err.Error())
}
return effective, nil
}
// GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。
//
// WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。

View File

@ -46,6 +46,25 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured bool
LinuxDoConnectRedirectURL string
// DingTalk Connect OAuth 登录
DingTalkConnectEnabled bool
DingTalkConnectClientID string
DingTalkConnectClientSecret string
DingTalkConnectClientSecretConfigured bool
DingTalkConnectRedirectURL string
DingTalkConnectCorpRestrictionPolicy string
DingTalkConnectInternalCorpID string
DingTalkConnectBypassRegistration bool
DingTalkConnectSyncCorpEmail bool
DingTalkConnectSyncDisplayName bool
DingTalkConnectSyncDept bool
DingTalkConnectSyncCorpEmailAttrKey string
DingTalkConnectSyncDisplayNameAttrKey string
DingTalkConnectSyncDeptAttrKey string
DingTalkConnectSyncCorpEmailAttrName string
DingTalkConnectSyncDisplayNameAttrName string
DingTalkConnectSyncDeptAttrName string
// WeChat Connect OAuth 登录
WeChatConnectEnabled bool
WeChatConnectAppID string
@ -235,6 +254,7 @@ type PublicSettings struct {
CustomEndpoints string // JSON array of custom endpoints
LinuxDoOAuthEnabled bool
DingTalkOAuthEnabled bool
WeChatOAuthEnabled bool
WeChatOAuthOpenEnabled bool
WeChatOAuthMPEnabled bool

View File

@ -72,6 +72,11 @@ func (s *UserAttributeService) GetDefinition(ctx context.Context, id int64) (*Us
return s.defRepo.GetByID(ctx, id)
}
// GetDefinitionByKey retrieves a definition by its unique key
func (s *UserAttributeService) GetDefinitionByKey(ctx context.Context, key string) (*UserAttributeDefinition, error) {
return s.defRepo.GetByKey(ctx, key)
}
// ListDefinitions lists all definitions
func (s *UserAttributeService) ListDefinitions(ctx context.Context, enabledOnly bool) ([]UserAttributeDefinition, error) {
return s.defRepo.List(ctx, enabledOnly)

View File

@ -141,10 +141,11 @@ type UserIdentitySummary struct {
}
type UserIdentitySummarySet struct {
Email UserIdentitySummary `json:"email"`
LinuxDo UserIdentitySummary `json:"linuxdo"`
OIDC UserIdentitySummary `json:"oidc"`
WeChat UserIdentitySummary `json:"wechat"`
Email UserIdentitySummary `json:"email"`
LinuxDo UserIdentitySummary `json:"linuxdo"`
OIDC UserIdentitySummary `json:"oidc"`
WeChat UserIdentitySummary `json:"wechat"`
DingTalk UserIdentitySummary `json:"dingtalk"`
}
type StartUserIdentityBindingRequest struct {
@ -260,10 +261,11 @@ func (s *UserService) GetProfileIdentitySummaries(ctx context.Context, userID in
}
summaries := UserIdentitySummarySet{
Email: s.buildEmailIdentitySummary(user, records),
LinuxDo: s.buildProviderIdentitySummary("linuxdo", user, records),
OIDC: s.buildProviderIdentitySummary("oidc", user, records),
WeChat: s.buildProviderIdentitySummary("wechat", user, records),
Email: s.buildEmailIdentitySummary(user, records),
LinuxDo: s.buildProviderIdentitySummary("linuxdo", user, records),
OIDC: s.buildProviderIdentitySummary("oidc", user, records),
WeChat: s.buildProviderIdentitySummary("wechat", user, records),
DingTalk: s.buildProviderIdentitySummary("dingtalk", user, records),
}
s.applyExplicitProviderAvailability(ctx, &summaries)
@ -283,6 +285,7 @@ func (s *UserService) applyExplicitProviderAvailability(ctx context.Context, sum
SettingKeyWeChatConnectMPEnabled,
SettingKeyWeChatConnectMobileEnabled,
SettingKeyWeChatConnectMode,
SettingKeyDingTalkConnectEnabled,
})
if err != nil {
return
@ -291,6 +294,9 @@ func (s *UserService) applyExplicitProviderAvailability(ctx context.Context, sum
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
disableIdentityBindAction(&summaries.LinuxDo)
}
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
disableIdentityBindAction(&summaries.DingTalk)
}
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
disableIdentityBindAction(&summaries.OIDC)
}
@ -696,7 +702,7 @@ func (s *UserService) canUnbindProvider(provider string, user *User, records []U
return true
}
for _, candidate := range []string{"linuxdo", "oidc", "wechat"} {
for _, candidate := range []string{"linuxdo", "oidc", "wechat", "dingtalk"} {
if candidate == provider {
continue
}
@ -772,6 +778,8 @@ func buildUserIdentityBindAuthorizeURL(provider, redirectTo string) (string, err
path = "/api/v1/auth/oauth/oidc/bind/start"
case "wechat":
path = "/api/v1/auth/oauth/wechat/bind/start"
case "dingtalk":
path = "/api/v1/auth/oauth/dingtalk/bind/start"
default:
return "", ErrIdentityProviderInvalid
}
@ -790,6 +798,8 @@ func normalizeUserIdentityProvider(provider string) string {
return "oidc"
case "wechat":
return "wechat"
case "dingtalk":
return "dingtalk"
case "email":
return "email"
default:

View File

@ -0,0 +1,27 @@
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_signup_source_check;
ALTER TABLE users
ADD CONSTRAINT users_signup_source_check
CHECK (signup_source IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google', 'dingtalk'));
ALTER TABLE auth_identities
DROP CONSTRAINT IF EXISTS auth_identities_provider_type_check;
ALTER TABLE auth_identities
ADD CONSTRAINT auth_identities_provider_type_check
CHECK (provider_type IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google', 'dingtalk'));
ALTER TABLE auth_identity_channels
DROP CONSTRAINT IF EXISTS auth_identity_channels_provider_type_check;
ALTER TABLE auth_identity_channels
ADD CONSTRAINT auth_identity_channels_provider_type_check
CHECK (provider_type IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google', 'dingtalk'));
ALTER TABLE pending_auth_sessions
DROP CONSTRAINT IF EXISTS pending_auth_sessions_provider_type_check;
ALTER TABLE pending_auth_sessions
ADD CONSTRAINT pending_auth_sessions_provider_type_check
CHECK (provider_type IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google', 'dingtalk'));

View File

@ -22,7 +22,8 @@ export type AuthSourceType =
| "oidc"
| "wechat"
| "github"
| "google";
| "google"
| "dingtalk";
export interface AuthSourceDefaultsValue {
balance: number;
@ -64,6 +65,7 @@ const AUTH_SOURCE_TYPES: AuthSourceType[] = [
"wechat",
"github",
"google",
"dingtalk",
];
const AUTH_SOURCE_DEFAULT_BALANCE = 0;
const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5;
@ -352,6 +354,11 @@ export interface SystemSettings {
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_wechat_grant_on_signup?: boolean;
auth_source_default_wechat_grant_on_first_bind?: boolean;
auth_source_default_dingtalk_balance?: number;
auth_source_default_dingtalk_concurrency?: number;
auth_source_default_dingtalk_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_dingtalk_grant_on_signup?: boolean;
auth_source_default_dingtalk_grant_on_first_bind?: boolean;
auth_source_default_github_balance?: number;
auth_source_default_github_concurrency?: number;
auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[];
@ -396,6 +403,24 @@ export interface SystemSettings {
linuxdo_connect_client_secret_configured: boolean;
linuxdo_connect_redirect_url: string;
// DingTalk Connect OAuth settings
dingtalk_connect_enabled: boolean;
dingtalk_connect_client_id: string;
dingtalk_connect_client_secret_configured: boolean;
dingtalk_connect_redirect_url: string;
dingtalk_connect_corp_restriction_policy: string;
dingtalk_connect_internal_corp_id: string;
dingtalk_connect_bypass_registration: boolean;
dingtalk_connect_sync_corp_email: boolean;
dingtalk_connect_sync_display_name: boolean;
dingtalk_connect_sync_dept: boolean;
dingtalk_connect_sync_corp_email_attr_key: string;
dingtalk_connect_sync_display_name_attr_key: string;
dingtalk_connect_sync_dept_attr_key: string;
dingtalk_connect_sync_corp_email_attr_name: string;
dingtalk_connect_sync_display_name_attr_name: string;
dingtalk_connect_sync_dept_attr_name: string;
// WeChat Connect OAuth settings
wechat_connect_enabled: boolean;
wechat_connect_app_id: string;
@ -571,6 +596,11 @@ export interface UpdateSettingsRequest {
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_wechat_grant_on_signup?: boolean;
auth_source_default_wechat_grant_on_first_bind?: boolean;
auth_source_default_dingtalk_balance?: number;
auth_source_default_dingtalk_concurrency?: number;
auth_source_default_dingtalk_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_dingtalk_grant_on_signup?: boolean;
auth_source_default_dingtalk_grant_on_first_bind?: boolean;
auth_source_default_github_balance?: number;
auth_source_default_github_concurrency?: number;
auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[];
@ -609,6 +639,22 @@ export interface UpdateSettingsRequest {
linuxdo_connect_client_id?: string;
linuxdo_connect_client_secret?: string;
linuxdo_connect_redirect_url?: string;
dingtalk_connect_enabled?: boolean;
dingtalk_connect_client_id?: string;
dingtalk_connect_client_secret?: string;
dingtalk_connect_redirect_url?: string;
dingtalk_connect_corp_restriction_policy?: string;
dingtalk_connect_internal_corp_id?: string;
dingtalk_connect_bypass_registration?: boolean;
dingtalk_connect_sync_corp_email?: boolean;
dingtalk_connect_sync_display_name?: boolean;
dingtalk_connect_sync_dept?: boolean;
dingtalk_connect_sync_corp_email_attr_key?: string;
dingtalk_connect_sync_display_name_attr_key?: string;
dingtalk_connect_sync_dept_attr_key?: string;
dingtalk_connect_sync_corp_email_attr_name?: string;
dingtalk_connect_sync_display_name_attr_name?: string;
dingtalk_connect_sync_dept_attr_name?: string;
wechat_connect_enabled?: boolean;
wechat_connect_app_id?: string;
wechat_connect_app_secret?: string;

View File

@ -592,7 +592,7 @@ export async function completeWeChatOAuthRegistration(
}
async function createPendingOAuthAccount(
provider: 'linuxdo' | 'oidc' | 'wechat',
provider: 'linuxdo' | 'oidc' | 'wechat' | 'dingtalk',
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
@ -633,6 +633,14 @@ export async function createPendingWeChatOAuthAccount(
return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
}
export async function createPendingDingTalkOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('dingtalk', invitationCode, decision, affiliateCode)
}
export async function completePendingOAuthBindLogin(
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthBindLoginResponse> {
@ -683,7 +691,8 @@ export const authAPI = {
exchangePendingOAuthCompletion,
completeLinuxDoOAuthRegistration,
completeOIDCOAuthRegistration,
completeWeChatOAuthRegistration
completeWeChatOAuthRegistration,
createPendingDingTalkOAuthAccount
}
export default authAPI

View File

@ -0,0 +1,61 @@
<template>
<div class="space-y-4">
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
<svg
class="icon mr-2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
aria-hidden="true"
style="flex-shrink: 0"
>
<circle cx="12" cy="12" r="12" fill="#1677FF" />
<text
x="12"
y="17"
font-family="sans-serif"
font-size="13"
font-weight="bold"
fill="white"
text-anchor="middle"
></text>
</svg>
{{ t('auth.dingtalk.signIn') }}
</button>
<div v-if="showDivider" class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{
disabled?: boolean
affCode?: string
showDivider?: boolean
}>(), {
showDivider: true
})
const route = useRoute()
const { t } = useI18n()
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/dingtalk/start?redirect=${encodeURIComponent(redirectTo)}`
window.location.href = startURL
}
</script>

View File

@ -2,6 +2,7 @@
<ProfileIdentityBindingsSection
:user="user"
:linuxdo-enabled="linuxdoEnabled"
:dingtalk-enabled="dingtalkEnabled"
:oidc-enabled="oidcEnabled"
:oidc-provider-name="oidcProviderName"
:wechat-enabled="wechatEnabled"
@ -18,6 +19,7 @@ withDefaults(
defineProps<{
user: User | null
linuxdoEnabled?: boolean
dingtalkEnabled?: boolean
oidcEnabled?: boolean
oidcProviderName?: string
wechatEnabled?: boolean
@ -26,6 +28,7 @@ withDefaults(
}>(),
{
linuxdoEnabled: false,
dingtalkEnabled: false,
oidcEnabled: false,
oidcProviderName: 'OIDC',
wechatEnabled: false,

View File

@ -217,6 +217,7 @@ const props = withDefaults(
defineProps<{
user: User | null
linuxdoEnabled?: boolean
dingtalkEnabled?: boolean
oidcEnabled?: boolean
oidcProviderName?: string
wechatEnabled?: boolean
@ -227,6 +228,7 @@ const props = withDefaults(
}>(),
{
linuxdoEnabled: false,
dingtalkEnabled: false,
oidcEnabled: false,
oidcProviderName: 'OIDC',
wechatEnabled: false,
@ -406,6 +408,9 @@ function isProviderEnabledForBinding(provider: BindableProvider): boolean {
if (provider === 'linuxdo') {
return props.linuxdoEnabled
}
if (provider === 'dingtalk') {
return props.dingtalkEnabled
}
if (provider === 'oidc') {
return props.oidcEnabled
}
@ -432,6 +437,17 @@ const providerItems = computed(() => [
canUnbind: Boolean(getBindingStatus('linuxdo') && getBindingDetails('linuxdo')?.can_unbind),
details: getBindingDetails('linuxdo'),
},
{
provider: 'dingtalk' as const,
label: t('profile.authBindings.providers.dingtalk'),
bound: getBindingStatus('dingtalk'),
canBind:
!getBindingStatus('dingtalk') &&
isProviderEnabledForBinding('dingtalk') &&
(getBindingDetails('dingtalk')?.can_bind ?? true),
canUnbind: Boolean(getBindingStatus('dingtalk') && getBindingDetails('dingtalk')?.can_unbind),
details: getBindingDetails('dingtalk'),
},
{
provider: 'oidc' as const,
label: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
@ -460,6 +476,9 @@ function providerInitial(provider: UserAuthProvider): string {
if (provider === 'linuxdo') {
return 'L'
}
if (provider === 'dingtalk') {
return 'D'
}
if (provider === 'wechat') {
return 'W'
}
@ -473,6 +492,9 @@ function providerIconClass(provider: UserAuthProvider): string {
if (provider === 'linuxdo') {
return 'bg-orange-100 text-orange-600 dark:bg-orange-900/20 dark:text-orange-300'
}
if (provider === 'dingtalk') {
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300'
}
if (provider === 'wechat') {
return 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-300'
}

View File

@ -139,6 +139,7 @@
<ProfileIdentityBindingsSection
:user="user"
:linuxdo-enabled="linuxdoEnabled"
:dingtalk-enabled="dingtalkEnabled"
:oidc-enabled="oidcEnabled"
:oidc-provider-name="oidcProviderName"
:wechat-enabled="wechatEnabled"
@ -190,6 +191,7 @@ import type { User, UserAuthBindingStatus, UserAuthProvider, UserProfileSourceCo
const props = withDefaults(defineProps<{
user: User | null
linuxdoEnabled?: boolean
dingtalkEnabled?: boolean
oidcEnabled?: boolean
oidcProviderName?: string
wechatEnabled?: boolean
@ -197,6 +199,7 @@ const props = withDefaults(defineProps<{
wechatMpEnabled?: boolean
}>(), {
linuxdoEnabled: false,
dingtalkEnabled: false,
oidcEnabled: false,
oidcProviderName: 'OIDC',
wechatEnabled: false,
@ -262,6 +265,7 @@ const memberSinceLabel = computed(() => {
const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
email: t('profile.authBindings.providers.email'),
linuxdo: t('profile.authBindings.providers.linuxdo'),
dingtalk: t('profile.authBindings.providers.dingtalk'),
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
wechat: t('profile.authBindings.providers.wechat'),
github: 'GitHub',

View File

@ -461,7 +461,7 @@ export default {
invitationCodeInvalid: 'Invalid or used invitation code',
invitationCodeValidating: 'Validating invitation code...',
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
oauthOrContinue: 'or continue with email',
oauthOrContinue: 'or continue with others',
linuxdo: {
signIn: 'Continue with Linux.do',
orContinue: 'or continue with email',
@ -476,6 +476,34 @@ export default {
completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
},
dingtalk: {
signIn: 'Continue with DingTalk',
callbackTitle: 'Signing you in with DingTalk',
callbackProcessing: 'Completing DingTalk login, please wait...',
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
callbackMissingToken: 'Missing login token, please try again.',
backToLogin: 'Back to Login',
invitationRequired: 'This DingTalk account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
invalidPendingToken: 'The registration token has expired. Please sign in with DingTalk again.',
completeRegistration: 'Complete Registration',
completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.',
createAccountTitle: 'Create DingTalk Account',
registrationDisabledRedirectToBind: 'New account registration is currently disabled. Please bind to your existing account with its email and password.',
error: {
title: 'DingTalk Sign-in Failed',
csrf: 'Login session expired, please scan again',
corp_rejected: 'Your DingTalk account is not part of this organization. Please contact administrator',
dingtalk_not_enabled: 'DingTalk login is not enabled',
upstream_error: 'DingTalk service is temporarily unavailable. Please try again later',
missing_browser_session: 'Browser session lost. Please login again',
missing_params: 'Request parameters are incomplete',
invalid_state: 'Invalid login state',
provider_error: 'DingTalk authorization failed',
session_error: 'Failed to create session. Please retry',
retry: 'Retry Login'
}
},
emailOAuth: {
signIn: 'Continue with {providerName}'
},
@ -524,6 +552,7 @@ export default {
wechatNotConfigured: 'WeChat sign-in is not configured yet.'
},
linuxdoCallbackPageTitle: 'LinuxDo Sign-In Callback',
dingtalkCallbackPageTitle: 'DingTalk Sign-In Callback',
oidcCallbackPageTitle: 'OIDC Sign-In Callback',
oauthCallbackPageTitle: 'OAuth Callback',
wechatProviderName: 'WeChat',
@ -1248,6 +1277,7 @@ export default {
providers: {
email: 'Email',
linuxdo: 'LinuxDo',
dingtalk: 'DingTalk',
oidc: '{providerName}',
wechat: 'WeChat',
},
@ -5284,6 +5314,47 @@ export default {
quickSetCopy: 'Generate & Copy (current site)',
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard'
},
dingtalk: {
title: 'DingTalk Login',
description: 'Configure DingTalk OAuth for Sub2API end-user login',
enable: 'Enable DingTalk Login (Internal Corporate App)',
enableHint: 'Show DingTalk login on the login/register pages',
clientId: 'Client ID (AppKey)',
clientIdPlaceholder: 'e.g., dingxxxxxxxxxxxxxxxx',
clientIdHint: 'Get this from the DingTalk Open Platform app details',
clientSecret: 'Client Secret (AppSecret)',
clientSecretPlaceholder: '********',
clientSecretHint: 'Used by backend to exchange tokens (keep it secret)',
clientSecretConfiguredPlaceholder: '********',
clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.',
redirectUrl: 'Redirect URL',
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/dingtalk/callback',
redirectUrlHint:
'Must match the redirect URL configured in DingTalk Open Platform (must be an absolute http(s) URL)',
corpPolicy: {
label: 'Corp Restriction Policy',
hint: 'Control which DingTalk accounts (orgs) are allowed to sign in',
none: 'No restriction (all DingTalk accounts allowed)',
internalOnly: 'Internal only (single corp)'
},
bypassRegistration: 'Enable DingTalk signup',
bypassRegistrationHint: 'Allow new users to register via DingTalk even when public registration is disabled.',
syncDisplayName: 'Sync DingTalk display name',
syncDisplayNameHint: 'Overwrite username with the DingTalk staff name on each login (also stored in the dingtalk_name attribute).',
syncCorpEmail: 'Sync corporate email',
syncCorpEmailHint: 'Write the DingTalk corporate email to the dingtalk_email attribute on each login (does not change the login email).',
syncCorpEmailPermissionHint: 'Requires the OAPI permission "Personal info incl. email (fieldEmail)" to be granted to the app on the DingTalk open platform, otherwise OAPI will not return the email field.',
syncDept: 'Sync department',
syncDeptHint: 'Write the full DingTalk department path to the dingtalk_department attribute on each login (fetched live each time).',
syncDeptPermissionHint: 'Requires the OAPI "Department info read (qyapi_get_department_list)" permission to be granted to the app on the DingTalk open platform, otherwise the department path cannot be resolved.',
syncDisplayNameTarget: 'Attribute key',
syncDisplayNameTargetHint: 'Defaults to dingtalk_name / DingTalk Name. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).',
syncCorpEmailTarget: 'Attribute key',
syncCorpEmailTargetHint: 'Defaults to dingtalk_email / DingTalk Corporate Email. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).',
syncDeptTarget: 'Attribute key',
syncDeptTargetHint: 'Defaults to dingtalk_department / DingTalk Department. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).',
syncAttrDisplayName: 'Display name'
},
oidc: {
title: 'OIDC Login',
description: 'Configure a standard OIDC provider (for example Keycloak)',

View File

@ -460,7 +460,7 @@ export default {
invitationCodeInvalid: '邀请码无效或已被使用',
invitationCodeValidating: '正在验证邀请码...',
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
oauthOrContinue: '或使用邮箱密码继续',
oauthOrContinue: '或使用其他继续',
linuxdo: {
signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续',
@ -475,6 +475,34 @@ export default {
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
},
dingtalk: {
signIn: '钉钉登录',
callbackTitle: '正在完成钉钉登录',
callbackProcessing: '正在验证钉钉登录信息,请稍候...',
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
callbackMissingToken: '登录信息缺失,请返回重试。',
backToLogin: '返回登录',
invitationRequired: '该钉钉账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
invalidPendingToken: '注册凭证已失效,请重新使用钉钉登录。',
completeRegistration: '完成注册',
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。',
createAccountTitle: '创建钉钉账户',
registrationDisabledRedirectToBind: '当前已禁止注册新账户,请使用已有账户邮箱和密码绑定钉钉登录',
error: {
title: '钉钉登录失败',
csrf: '登录会话已过期,请重新扫码登录',
corp_rejected: '您的钉钉账号不属于本企业,请联系管理员',
dingtalk_not_enabled: '钉钉登录暂未启用',
upstream_error: '钉钉服务暂时不可用,请稍后重试',
missing_browser_session: '浏览器会话丢失,请重新登录',
missing_params: '请求参数不完整',
invalid_state: '登录状态异常',
provider_error: '钉钉授权失败',
session_error: '会话创建失败,请重试',
retry: '重新登录'
}
},
emailOAuth: {
signIn: '使用 {providerName} 登录'
},
@ -522,6 +550,7 @@ export default {
wechatNotConfigured: '微信登录尚未配置。'
},
linuxdoCallbackPageTitle: 'LinuxDo 登录回调',
dingtalkCallbackPageTitle: '钉钉登录回调',
oidcCallbackPageTitle: 'OIDC 登录回调',
oauthCallbackPageTitle: 'OAuth 回调',
wechatProviderName: '微信',
@ -1252,6 +1281,7 @@ export default {
providers: {
email: '邮箱',
linuxdo: 'LinuxDo',
dingtalk: '钉钉',
oidc: '{providerName}',
wechat: '微信',
},
@ -5447,6 +5477,46 @@ export default {
quickSetCopy: '使用当前站点生成并复制',
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板'
},
dingtalk: {
title: '钉钉登录',
description: '配置钉钉 OAuth用于 Sub2API 用户登录',
enable: '启用钉钉登录-企业内部应用',
enableHint: '在登录/注册页面显示钉钉登录入口',
clientId: 'Client IDAppKey',
clientIdPlaceholder: '例如dingxxxxxxxxxxxxxxxx',
clientIdHint: '从钉钉开放平台应用详情页获取',
clientSecret: 'Client SecretAppSecret',
clientSecretPlaceholder: '********',
clientSecretHint: '用于后端交换 token请保密',
clientSecretConfiguredPlaceholder: '********',
clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。',
redirectUrl: '回调地址Redirect URL',
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/dingtalk/callback',
redirectUrlHint: '需与钉钉开放平台中配置的回调地址一致(必须是 http(s) 完整 URL',
corpPolicy: {
label: '企业限制策略',
hint: '控制哪些钉钉账号(企业)可以登录',
none: '不限制(所有钉钉账号均可登录)',
internalOnly: '仅本企业Internal Only'
},
bypassRegistration: '开放钉钉注册',
bypassRegistrationHint: '即使「开放注册」关闭时也可以通过钉钉登录来注册',
syncDisplayName: '同步钉钉姓名',
syncDisplayNameHint: '登录时将钉钉姓名写入 username 字段(同时记录到 dingtalk_name 属性)',
syncCorpEmail: '同步企业邮箱',
syncCorpEmailHint: '登录时将钉钉企业邮箱写入 dingtalk_email 属性(不影响登录邮箱)',
syncCorpEmailPermissionHint: '需在钉钉开放平台 → 应用 → 权限管理中为本应用申请「邮箱等个人信息fieldEmail」权限否则 OAPI 不会返回企业邮箱字段',
syncDept: '同步部门',
syncDeptHint: '登录时将钉钉首个部门完整路径写入 dingtalk_department 属性(每次登录实时拉取)',
syncDeptPermissionHint: '需在钉钉开放平台 → 应用 → 权限管理中为本应用申请「通讯录部门信息读权限qyapi_get_department_list否则无法递归出部门路径',
syncDisplayNameTarget: '属性键',
syncDisplayNameTargetHint: '默认 dingtalk_name / 钉钉姓名;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)',
syncCorpEmailTarget: '属性键',
syncCorpEmailTargetHint: '默认 dingtalk_email / 钉钉企业邮箱;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)',
syncDeptTarget: '属性键',
syncDeptTargetHint: '默认 dingtalk_department / 钉钉部门;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)',
syncAttrDisplayName: '显示名称'
},
oidc: {
title: 'OIDC 登录',
description: '配置标准 OIDC Provider例如 Keycloak',

View File

@ -106,6 +106,25 @@ const routes: RouteRecordRaw[] = [
titleKey: 'auth.wechatPaymentCallbackPageTitle'
}
},
{
path: '/auth/dingtalk/callback',
name: 'DingTalkOAuthCallback',
component: () => import('@/views/auth/DingTalkCallbackView.vue'),
meta: {
requiresAuth: false,
title: 'DingTalk OAuth Callback',
titleKey: 'auth.dingtalkCallbackPageTitle'
}
},
{
path: '/auth/dingtalk/email-completion',
name: 'dingtalk-email-completion',
component: () => import('@/views/auth/DingTalkEmailCompletionView.vue'),
meta: {
requiresAuth: false,
title: 'DingTalk Email Completion'
}
},
{
path: '/auth/oidc/callback',
name: 'OIDCOAuthCallback',
@ -672,6 +691,8 @@ const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/
const BACKEND_MODE_CALLBACK_PATHS = [
'/auth/callback',
'/auth/linuxdo/callback',
'/auth/dingtalk/callback',
'/auth/dingtalk/email-completion',
'/auth/oidc/callback',
'/auth/wechat/callback',
'/auth/wechat/payment/callback',

View File

@ -34,7 +34,7 @@ export interface NotifyEmailEntry {
// ==================== User & Auth Types ====================
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google'
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google' | 'dingtalk'
export interface UserAuthBindingStatus {
bound?: boolean
@ -215,6 +215,7 @@ export interface PublicSettings {
custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean
dingtalk_oauth_enabled?: boolean
wechat_oauth_enabled: boolean
wechat_oauth_open_enabled?: boolean
wechat_oauth_mp_enabled?: boolean

View File

@ -2333,6 +2333,294 @@
</div>
</div>
<!-- DingTalk Connect OAuth 登录 -->
<div class="card">
<div
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.settings.dingtalk.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.description") }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.enable")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.enableHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_enabled" />
</div>
<div
v-if="form.dingtalk_connect_enabled"
class="border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div class="grid grid-cols-1 gap-6">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.dingtalk.clientId") }}
</label>
<input
v-model="form.dingtalk_connect_client_id"
type="text"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.dingtalk.clientIdPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.clientIdHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.dingtalk.clientSecret") }}
</label>
<input
v-model="form.dingtalk_connect_client_secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.dingtalk_connect_client_secret_configured
? t(
'admin.settings.dingtalk.clientSecretConfiguredPlaceholder',
)
: t('admin.settings.dingtalk.clientSecretPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
form.dingtalk_connect_client_secret_configured
? t(
"admin.settings.dingtalk.clientSecretConfiguredHint",
)
: t("admin.settings.dingtalk.clientSecretHint")
}}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.dingtalk.redirectUrl") }}
</label>
<input
v-model="form.dingtalk_connect_redirect_url"
type="url"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.dingtalk.redirectUrlPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.redirectUrlHint") }}
</p>
</div>
<!-- Corp Restriction Policy -->
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.settings.dingtalk.corpPolicy.label") }}
</label>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.corpPolicy.hint") }}
</p>
<div class="space-y-2">
<label class="flex cursor-pointer items-center gap-3">
<input
v-model="form.dingtalk_connect_corp_restriction_policy"
type="radio"
value="none"
class="h-4 w-4 text-primary-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t("admin.settings.dingtalk.corpPolicy.none") }}
</span>
</label>
<label class="flex cursor-pointer items-center gap-3">
<input
v-model="form.dingtalk_connect_corp_restriction_policy"
type="radio"
value="internal_only"
class="h-4 w-4 text-primary-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t("admin.settings.dingtalk.corpPolicy.internalOnly") }}
</span>
</label>
</div>
</div>
<!-- bypass_registration toggle internal_only 模式下可见可用 -->
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.bypassRegistration")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.bypassRegistrationHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_bypass_registration" />
</div>
<!-- 身份同步开关 internal_only 模式下可见 -->
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.syncDisplayName")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.syncDisplayNameHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_sync_display_name" />
</div>
<div v-if="form.dingtalk_connect_sync_display_name" class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncDisplayNameTarget") }}
</label>
<input
v-model="form.dingtalk_connect_sync_display_name_attr_key"
type="text"
placeholder="dingtalk_name"
class="input text-sm flex-1 max-w-xs"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
</label>
<input
v-model="form.dingtalk_connect_sync_display_name_attr_name"
type="text"
placeholder="钉钉姓名"
class="input text-sm flex-1 max-w-xs"
/>
</div>
</div>
<p v-if="form.dingtalk_connect_sync_display_name" class="text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.dingtalk.syncDisplayNameTargetHint") }}
</p>
</div>
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.syncCorpEmail")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.syncCorpEmailHint") }}
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">
{{ t("admin.settings.dingtalk.syncCorpEmailPermissionHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_sync_corp_email" />
</div>
<div v-if="form.dingtalk_connect_sync_corp_email" class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncCorpEmailTarget") }}
</label>
<input
v-model="form.dingtalk_connect_sync_corp_email_attr_key"
type="text"
placeholder="dingtalk_email"
class="input text-sm flex-1 max-w-xs"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
</label>
<input
v-model="form.dingtalk_connect_sync_corp_email_attr_name"
type="text"
placeholder="钉钉企业邮箱"
class="input text-sm flex-1 max-w-xs"
/>
</div>
</div>
<p v-if="form.dingtalk_connect_sync_corp_email" class="text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.dingtalk.syncCorpEmailTargetHint") }}
</p>
</div>
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.syncDept")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.syncDeptHint") }}
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">
{{ t("admin.settings.dingtalk.syncDeptPermissionHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_sync_dept" />
</div>
<div v-if="form.dingtalk_connect_sync_dept" class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncDeptTarget") }}
</label>
<input
v-model="form.dingtalk_connect_sync_dept_attr_key"
type="text"
placeholder="dingtalk_department"
class="input text-sm flex-1 max-w-xs"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
</label>
<input
v-model="form.dingtalk_connect_sync_dept_attr_name"
type="text"
placeholder="钉钉部门"
class="input text-sm flex-1 max-w-xs"
/>
</div>
</div>
<p v-if="form.dingtalk_connect_sync_dept" class="text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.dingtalk.syncDeptTargetHint") }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Generic OIDC OAuth 登录 -->
<div class="card">
<div
@ -6417,6 +6705,7 @@ type SettingsForm = Omit<
smtp_password: string;
turnstile_secret_key: string;
linuxdo_connect_client_secret: string;
dingtalk_connect_client_secret: string;
wechat_connect_app_secret: string;
wechat_connect_open_app_secret: string;
wechat_connect_mp_app_secret: string;
@ -6518,6 +6807,24 @@ const form = reactive<SettingsForm>({
linuxdo_connect_client_secret: "",
linuxdo_connect_client_secret_configured: false,
linuxdo_connect_redirect_url: "",
// DingTalk Connect OAuth
dingtalk_connect_enabled: false,
dingtalk_connect_client_id: "",
dingtalk_connect_client_secret: "",
dingtalk_connect_client_secret_configured: false,
dingtalk_connect_redirect_url: "",
dingtalk_connect_corp_restriction_policy: "none",
dingtalk_connect_internal_corp_id: "",
dingtalk_connect_bypass_registration: false,
dingtalk_connect_sync_corp_email: false,
dingtalk_connect_sync_display_name: false,
dingtalk_connect_sync_dept: false,
dingtalk_connect_sync_corp_email_attr_key: "dingtalk_email",
dingtalk_connect_sync_display_name_attr_key: "dingtalk_name",
dingtalk_connect_sync_dept_attr_key: "dingtalk_department",
dingtalk_connect_sync_corp_email_attr_name: "钉钉企业邮箱",
dingtalk_connect_sync_display_name_attr_name: "钉钉姓名",
dingtalk_connect_sync_dept_attr_name: "钉钉部门",
wechat_connect_enabled: false,
wechat_connect_app_id: "",
wechat_connect_app_secret: "",
@ -6658,6 +6965,14 @@ const authSourceDefaultsMeta = computed(() => [
"Applied on first signup or first bind through a verified Google email.",
),
},
{
source: "dingtalk" as AuthSourceType,
title: "钉钉",
description: localText(
"通过钉钉首次注册或首次绑定时应用。",
"Applied on first signup or first bind through DingTalk.",
),
},
]);
// Proxies for web search emulation ProxySelector
@ -7241,6 +7556,7 @@ async function loadSettings() {
smtpPasswordManuallyEdited.value = false;
form.turnstile_secret_key = "";
form.linuxdo_connect_client_secret = "";
form.dingtalk_connect_client_secret = "";
form.github_oauth_client_secret = "";
form.google_oauth_client_secret = "";
form.wechat_connect_app_secret = "";
@ -7593,6 +7909,24 @@ async function saveSettings() {
linuxdo_connect_client_secret:
form.linuxdo_connect_client_secret || undefined,
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url,
dingtalk_connect_enabled: form.dingtalk_connect_enabled,
dingtalk_connect_client_id: form.dingtalk_connect_client_id,
dingtalk_connect_client_secret:
form.dingtalk_connect_client_secret || undefined,
dingtalk_connect_redirect_url: form.dingtalk_connect_redirect_url,
dingtalk_connect_corp_restriction_policy:
form.dingtalk_connect_corp_restriction_policy,
dingtalk_connect_internal_corp_id: form.dingtalk_connect_internal_corp_id,
dingtalk_connect_bypass_registration: form.dingtalk_connect_bypass_registration,
dingtalk_connect_sync_corp_email: form.dingtalk_connect_sync_corp_email,
dingtalk_connect_sync_display_name: form.dingtalk_connect_sync_display_name,
dingtalk_connect_sync_dept: form.dingtalk_connect_sync_dept,
dingtalk_connect_sync_corp_email_attr_key: form.dingtalk_connect_sync_corp_email_attr_key,
dingtalk_connect_sync_display_name_attr_key: form.dingtalk_connect_sync_display_name_attr_key,
dingtalk_connect_sync_dept_attr_key: form.dingtalk_connect_sync_dept_attr_key,
dingtalk_connect_sync_corp_email_attr_name: form.dingtalk_connect_sync_corp_email_attr_name,
dingtalk_connect_sync_display_name_attr_name: form.dingtalk_connect_sync_display_name_attr_name,
dingtalk_connect_sync_dept_attr_name: form.dingtalk_connect_sync_dept_attr_name,
wechat_connect_enabled: form.wechat_connect_enabled,
wechat_connect_app_id:
form.wechat_connect_open_app_id ||
@ -7775,6 +8109,7 @@ async function saveSettings() {
smtpPasswordManuallyEdited.value = false;
form.turnstile_secret_key = "";
form.linuxdo_connect_client_secret = "";
form.dingtalk_connect_client_secret = "";
form.github_oauth_client_secret = "";
form.google_oauth_client_secret = "";
form.wechat_connect_app_secret = "";
@ -8992,6 +9327,21 @@ watch(
}
},
);
// bypass_registration internal_only policy
// false admin handler
// coerce UX 线
watch(
() => form.dingtalk_connect_corp_restriction_policy,
(policy) => {
if (policy !== "internal_only") {
if (form.dingtalk_connect_bypass_registration) form.dingtalk_connect_bypass_registration = false;
if (form.dingtalk_connect_sync_corp_email) form.dingtalk_connect_sync_corp_email = false;
if (form.dingtalk_connect_sync_display_name) form.dingtalk_connect_sync_display_name = false;
if (form.dingtalk_connect_sync_dept) form.dingtalk_connect_sync_dept = false;
}
},
);
</script>
<style scoped>

View File

@ -0,0 +1,852 @@
<template>
<AuthLayout>
<div class="space-y-6">
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.dingtalk.callbackTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ isProcessing ? t('auth.dingtalk.callbackProcessing') : t('auth.dingtalk.callbackHint') }}
</p>
</div>
<transition name="fade">
<div
v-if="
needsInvitation ||
needsAdoptionConfirmation ||
needsChooser ||
needsCreateAccount ||
needsBindLogin ||
needsTotpChallenge
"
class="space-y-4"
>
<div
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
>
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('auth.oauthFlow.profileDetailsTitle', { providerName }) }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthFlow.profileDetailsDescription', { providerName }) }}
</p>
</div>
<label
v-if="suggestedDisplayName"
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
>
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
<span class="space-y-1">
<span class="block font-medium text-gray-900 dark:text-white">
{{ t('auth.oauthFlow.useDisplayName') }}
</span>
<span class="block text-gray-500 dark:text-dark-400">
{{ suggestedDisplayName }}
</span>
</span>
</label>
<label
v-if="suggestedAvatarUrl"
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
>
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
<img
:src="suggestedAvatarUrl"
:alt="t('auth.oauthFlow.avatarAlt', { providerName })"
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
/>
<span class="space-y-1">
<span class="block font-medium text-gray-900 dark:text-white">
{{ t('auth.oauthFlow.useAvatar') }}
</span>
<span class="block break-all text-gray-500 dark:text-dark-400">
{{ suggestedAvatarUrl }}
</span>
</span>
</label>
</div>
</div>
<template v-if="needsInvitation">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('auth.dingtalk.invitationRequired') }}
</p>
<div>
<input
v-model="invitationCode"
type="text"
class="input w-full"
:placeholder="t('auth.invitationCodePlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleSubmitInvitation"
/>
</div>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting || !invitationCode.trim()"
@click="handleSubmitInvitation"
>
{{ isSubmitting ? t('auth.dingtalk.completing') : t('auth.dingtalk.completeRegistration') }}
</button>
</template>
<template v-else-if="needsAdoptionConfirmation">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('auth.oauthFlow.reviewProfileBeforeContinue', { providerName }) }}
</p>
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
{{ isSubmitting ? t('common.processing') : t('auth.continue') }}
</button>
</template>
<template v-else-if="needsChooser">
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60">
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('auth.oauthFlow.chooseHowToContinue') }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{
pendingAccountEmail
? t('auth.oauthFlow.suggestedEmail', { email: pendingAccountEmail })
: t('auth.oauthFlow.chooseAccountActionHint')
}}
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<button
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
{{ t('auth.oauthFlow.bindExistingAccount') }}
</button>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
{{ t('auth.oauthFlow.createNewAccount') }}
</button>
</div>
</div>
</div>
</template>
<template v-else-if="needsCreateAccount">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('auth.oauthFlow.createAccountHint') }}
</p>
<PendingOAuthCreateAccountForm
test-id-prefix="dingtalk"
:initial-email="pendingAccountEmail"
:is-submitting="isSubmitting"
:error-message="accountActionError"
@submit="handleCreateAccount"
@switch-to-bind="switchToBindLoginMode"
/>
</template>
<template v-else-if="needsBindLogin">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('auth.oauthFlow.bindLoginHint', { providerName }) }}
</p>
<div class="space-y-3">
<input
v-model="bindLoginEmail"
data-testid="dingtalk-bind-login-email"
type="email"
class="input w-full"
:placeholder="t('auth.emailPlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
<input
v-model="bindLoginPassword"
data-testid="dingtalk-bind-login-password"
type="password"
class="input w-full"
:placeholder="t('auth.passwordPlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
<button
data-testid="dingtalk-bind-login-submit"
class="btn btn-primary w-full"
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
@click="handleBindLogin"
>
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.logInAndBind') }}
</button>
<button
v-if="canReturnToCreateAccount"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
{{ t('auth.oauthFlow.useDifferentEmail') }}
</button>
</div>
</template>
<template v-else-if="needsTotpChallenge">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{
t('auth.oauthFlow.totpHint', {
providerName,
account: totpUserEmailMasked || t('auth.oauthFlow.yourAccount')
})
}}
</p>
<div class="space-y-3">
<input
v-model="totpCode"
data-testid="dingtalk-bind-login-totp"
type="text"
inputmode="numeric"
maxlength="6"
class="input w-full"
placeholder="123456"
:disabled="isSubmitting"
@keyup.enter="handleSubmitTotpChallenge"
/>
<button
data-testid="dingtalk-bind-login-totp-submit"
class="btn btn-primary w-full"
:disabled="isSubmitting || totpCode.trim().length !== 6"
@click="handleSubmitTotpChallenge"
>
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.verifyAndContinue') }}
</button>
</div>
</template>
</div>
</transition>
</div>
</AuthLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import PendingOAuthCreateAccountForm, {
type PendingOAuthCreateAccountPayload
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
import { apiClient } from '@/api/client'
import { useAuthStore, useAppStore } from '@/stores'
import {
exchangePendingOAuthCompletion,
getOAuthCompletionKind,
isOAuthLoginCompletion,
login2FA,
persistOAuthTokenContext,
type OAuthAdoptionDecision,
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
const { t, te } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const isProcessing = ref(true)
const errorMessage = ref('')
// Invitation code flow state
const needsInvitation = ref(false)
const invitationCode = ref('')
const isSubmitting = ref(false)
const invitationError = ref('')
const redirectTo = ref('/dashboard')
const adoptionRequired = ref(false)
const suggestedDisplayName = ref('')
const suggestedAvatarUrl = ref('')
const adoptDisplayName = ref(true)
const adoptAvatar = ref(true)
const needsAdoptionConfirmation = ref(false)
const pendingAccountAction = ref<'none' | 'choose_account_action' | 'create_account' | 'bind_login'>('none')
const pendingAccountEmail = ref('')
const bindLoginEmail = ref('')
const bindLoginPassword = ref('')
const legacyPendingOAuthToken = ref('')
const accountActionError = ref('')
const canReturnToCreateAccount = ref(false)
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
const needsTotpChallenge = ref(false)
const totpTempToken = ref('')
const totpCode = ref('')
const totpError = ref('')
const totpUserEmailMasked = ref('')
const providerName = '钉钉'
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
watch(invitationError, value => {
if (value) {
appStore.showError(value)
}
})
watch(accountActionError, value => {
if (value) {
appStore.showError(value)
}
})
watch(totpError, value => {
if (value) {
appStore.showError(value)
}
})
watch(errorMessage, value => {
if (value) {
appStore.showError(value)
}
})
type DingTalkPendingActionResponse = PendingOAuthExchangeResponse & {
step?: string
intent?: string
email?: string
resolved_email?: string
pending_email?: string
existing_account_email?: string
suggested_email?: string
}
function persistPendingAuthSession(redirect?: string) {
authStore.setPendingAuthSession({
token: '',
token_field: 'pending_oauth_token',
provider: 'dingtalk',
redirect: sanitizeRedirectPath(redirect || redirectTo.value)
})
}
function clearPendingAuthSession() {
authStore.clearPendingAuthSession()
}
function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : ''
const hash = raw.startsWith('#') ? raw.slice(1) : raw
return new URLSearchParams(hash)
}
function readLegacyFragmentLogin(params: URLSearchParams): OAuthTokenResponse | null {
const accessToken = params.get('access_token')?.trim() || ''
if (!accessToken) {
return null
}
const completion: OAuthTokenResponse = {
access_token: accessToken
}
const refreshToken = params.get('refresh_token')?.trim() || ''
if (refreshToken) {
completion.refresh_token = refreshToken
}
const expiresIn = Number.parseInt(params.get('expires_in')?.trim() || '', 10)
if (Number.isFinite(expiresIn) && expiresIn > 0) {
completion.expires_in = expiresIn
}
const tokenType = params.get('token_type')?.trim() || ''
if (tokenType) {
completion.token_type = tokenType
}
return completion
}
function sanitizeRedirectPath(path: string | null | undefined): string {
if (!path) return '/dashboard'
if (!path.startsWith('/')) return '/dashboard'
if (path.startsWith('//')) return '/dashboard'
if (path.includes('://')) return '/dashboard'
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
return path
}
function currentAdoptionDecision(): OAuthAdoptionDecision {
return {
adoptDisplayName: adoptDisplayName.value,
adoptAvatar: adoptAvatar.value
}
}
function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<string, boolean> {
const payload: Record<string, boolean> = {}
if (typeof decision.adoptDisplayName === 'boolean') {
payload.adopt_display_name = decision.adoptDisplayName
}
if (typeof decision.adoptAvatar === 'boolean') {
payload.adopt_avatar = decision.adoptAvatar
}
return payload
}
function applyAdoptionSuggestionState(completion: {
adoption_required?: boolean
suggested_display_name?: string
suggested_avatar_url?: string
}) {
adoptionRequired.value = completion.adoption_required === true
suggestedDisplayName.value = completion.suggested_display_name || ''
suggestedAvatarUrl.value = completion.suggested_avatar_url || ''
if (!suggestedDisplayName.value) {
adoptDisplayName.value = false
}
if (!suggestedAvatarUrl.value) {
adoptAvatar.value = false
}
}
function hasSuggestedProfile(completion: {
suggested_display_name?: string
suggested_avatar_url?: string
}): boolean {
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
}
function normalizedPendingState(value: string | null | undefined): string {
return value?.trim().toLowerCase() || ''
}
function extractPendingAccountEmail(completion: DingTalkPendingActionResponse): string {
return (
completion.pending_email ||
completion.existing_account_email ||
completion.email ||
completion.resolved_email ||
completion.suggested_email ||
''
).trim()
}
function resolvePendingAccountAction(
completion: DingTalkPendingActionResponse
): 'none' | 'choose_account_action' | 'create_account' | 'bind_login' {
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
if (
raw === 'choice' ||
raw === 'choose_account_action_required' ||
raw === 'choose_account_action' ||
raw === 'choose_account' ||
raw === 'choose'
) {
return 'choose_account_action'
}
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
return 'create_account'
}
if (
raw === 'bind_login_required' ||
raw === 'bind_login' ||
raw === 'existing_account' ||
raw === 'existing_account_required' ||
raw === 'existing_account_binding_required' ||
raw === 'adopt_existing_user_by_email'
) {
return 'bind_login'
}
return 'none'
}
function applyPendingAccountAction(completion: DingTalkPendingActionResponse) {
const action = resolvePendingAccountAction(completion)
pendingAccountAction.value = action
accountActionError.value = ''
needsTotpChallenge.value = false
totpTempToken.value = ''
totpCode.value = ''
totpError.value = ''
totpUserEmailMasked.value = ''
const email = extractPendingAccountEmail(completion)
if (action === 'choose_account_action') {
pendingAccountEmail.value = email
bindLoginEmail.value = email
bindLoginPassword.value = ''
canReturnToCreateAccount.value = false
return
}
if (action === 'create_account') {
pendingAccountEmail.value = email
canReturnToCreateAccount.value = true
return
}
if (action === 'bind_login') {
bindLoginEmail.value = email
bindLoginPassword.value = ''
canReturnToCreateAccount.value = false
return
}
canReturnToCreateAccount.value = false
}
function applyTotpChallenge(completion: DingTalkPendingActionResponse): boolean {
if (completion.requires_2fa !== true || !completion.temp_token) {
return false
}
pendingAccountAction.value = 'none'
needsInvitation.value = false
needsAdoptionConfirmation.value = false
needsTotpChallenge.value = true
totpTempToken.value = completion.temp_token
totpCode.value = ''
totpError.value = ''
totpUserEmailMasked.value = completion.user_email_masked || ''
isProcessing.value = false
return true
}
function switchToBindLoginMode(nextEmail?: string) {
pendingAccountAction.value = 'bind_login'
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
bindLoginPassword.value = ''
accountActionError.value = ''
canReturnToCreateAccount.value = true
}
function switchToCreateAccountMode() {
pendingAccountAction.value = 'create_account'
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
accountActionError.value = ''
}
function getRequestErrorMessage(error: unknown, fallback: string): string {
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
}
function isCreateAccountRecoveryError(error: unknown): boolean {
const data = (error as {
response?: {
data?: {
reason?: string
error?: string
code?: string
step?: string
intent?: string
}
}
}).response?.data
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
.map(value => value?.trim().toLowerCase())
.filter((value): value is string => Boolean(value))
return states.includes('email_exists') ||
states.includes('bind_login_required') ||
states.includes('bind_login') ||
states.includes('adopt_existing_user_by_email') ||
states.includes('existing_account_required') ||
states.includes('existing_account_binding_required')
}
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
}
if (!isOAuthLoginCompletion(completion)) {
throw new Error(t('auth.dingtalk.callbackMissingToken'))
}
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
async function finalizePendingAccountResponse(completion: DingTalkPendingActionResponse) {
applyAdoptionSuggestionState(completion)
const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value)
// step=email_completion:
if (completion.step === 'email_completion' || (completion as Record<string, unknown>)['requires_email_completion'] === true) {
await router.replace('/auth/dingtalk/email-completion?redirect=' + encodeURIComponent(redirect))
return
}
if (completion.error === 'invitation_required') {
pendingAccountAction.value = 'none'
needsInvitation.value = true
needsAdoptionConfirmation.value = false
isProcessing.value = false
persistPendingAuthSession(redirect)
return
}
if (applyTotpChallenge(completion)) {
persistPendingAuthSession(redirect)
return
}
applyPendingAccountAction(completion)
if (pendingAccountAction.value !== 'none') {
needsInvitation.value = false
needsAdoptionConfirmation.value = false
isProcessing.value = false
persistPendingAuthSession(redirect)
return
}
if (completion.auth_result === 'pending_session') {
needsInvitation.value = false
needsAdoptionConfirmation.value = false
isProcessing.value = false
persistPendingAuthSession(redirect)
return
}
await finalizeCompletion(completion, redirect)
}
async function handleSubmitInvitation() {
invitationError.value = ''
if (!invitationCode.value.trim()) return
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const { data: completion } = await apiClient.post<DingTalkPendingActionResponse>(
'/auth/oauth/dingtalk/complete-registration',
{
pending_oauth_token: legacyPendingOAuthToken.value || undefined,
invitation_code: invitationCode.value.trim(),
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
}
)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
invitationError.value =
err.response?.data?.message || err.message || t('auth.dingtalk.completeRegistrationFailed')
} finally {
isSubmitting.value = false
}
}
async function handleContinueLogin() {
isSubmitting.value = true
try {
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision()) as DingTalkPendingActionResponse
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
needsAdoptionConfirmation.value = false
} finally {
isSubmitting.value = false
}
}
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
accountActionError.value = ''
if (!payload.email || !payload.password) return
isSubmitting.value = true
try {
const { data } = await apiClient.post<DingTalkPendingActionResponse>('/auth/oauth/pending/create-account', {
email: payload.email,
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
} catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) {
switchToBindLoginMode(payload.email.trim())
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
}
}
async function handleBindLogin() {
accountActionError.value = ''
const email = bindLoginEmail.value.trim()
const password = bindLoginPassword.value
if (!email || !password) return
isSubmitting.value = true
try {
const { data } = await apiClient.post<DingTalkPendingActionResponse>('/auth/oauth/pending/bind-login', {
email,
password,
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
} catch (e: unknown) {
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
}
}
async function handleSubmitTotpChallenge() {
totpError.value = ''
const code = totpCode.value.trim()
if (!totpTempToken.value || code.length !== 6) return
isSubmitting.value = true
try {
const completion = await login2FA({
temp_token: totpTempToken.value,
totp_code: code
})
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
totpError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
}
}
onMounted(async () => {
const params = parseFragmentParams()
const legacyLogin = readLegacyFragmentLogin(params)
const legacyPendingToken = params.get('pending_oauth_token')?.trim() || ''
const error = params.get('error')
const errorDesc = params.get('error_description') || params.get('error_message') || ''
const redirect = sanitizeRedirectPath(
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
)
try {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return
}
if (error === 'invitation_required' && legacyPendingToken) {
legacyPendingOAuthToken.value = legacyPendingToken
redirectTo.value = redirect
needsInvitation.value = true
isProcessing.value = false
return
}
if (error) {
const i18nKey = `auth.dingtalk.error.${error}`
errorMessage.value = te(i18nKey) ? t(i18nKey) : (errorDesc || error)
isProcessing.value = false
return
}
const completion = await exchangePendingOAuthCompletion()
const completionRedirect = sanitizeRedirectPath(
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
)
applyAdoptionSuggestionState(completion)
redirectTo.value = completionRedirect
const completionData = completion as DingTalkPendingActionResponse
// "" bind=1 email_completion redirect
// bind_login
const wantsBindExisting = (route.query.bind as string | undefined) === '1'
const presetEmail = ((route.query.email as string | undefined) || '').trim()
if (completionData.step === 'email_completion' || (completionData as unknown as Record<string, unknown>)['requires_email_completion'] === true) {
if (wantsBindExisting) {
pendingAccountAction.value = 'bind_login'
bindLoginEmail.value = presetEmail
bindLoginPassword.value = ''
canReturnToCreateAccount.value = true
isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return
}
await router.replace('/auth/dingtalk/email-completion?redirect=' + encodeURIComponent(completionRedirect))
return
}
if (completion.error === 'invitation_required') {
needsInvitation.value = true
isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return
}
if (applyTotpChallenge(completion as DingTalkPendingActionResponse)) {
persistPendingAuthSession(completionRedirect)
return
}
applyPendingAccountAction(completion as DingTalkPendingActionResponse)
if (pendingAccountAction.value !== 'none') {
isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return
}
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
needsAdoptionConfirmation.value = true
isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return
}
await finalizeCompletion(completion, completionRedirect)
} catch (e: unknown) {
clearPendingAuthSession()
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
isProcessing.value = false
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<AuthLayout>
<div class="space-y-6">
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.dingtalk.createAccountTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('auth.oauthFlow.createAccountHint') }}
</p>
</div>
<PendingOAuthCreateAccountForm
test-id-prefix="dingtalk"
:initial-email="initialEmail"
:is-submitting="isSubmitting"
:error-message="accountActionError"
@submit="handleCreateAccount"
@switch-to-bind="handleSwitchToBind"
/>
</div>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import PendingOAuthCreateAccountForm, {
type PendingOAuthCreateAccountPayload
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
import { apiClient } from '@/api/client'
import { useAuthStore, useAppStore } from '@/stores'
import {
persistOAuthTokenContext,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const isSubmitting = ref(false)
const accountActionError = ref('')
const initialEmail = (route.query.email as string | undefined) || ''
function sanitizeRedirectPath(path: string | null | undefined): string {
if (!path) return '/dashboard'
if (!path.startsWith('/')) return '/dashboard'
if (path.startsWith('//')) return '/dashboard'
if (path.includes('://')) return '/dashboard'
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
return path
}
function getRequestErrorMessage(error: unknown, fallback: string): string {
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
}
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
accountActionError.value = ''
if (!payload.email || !payload.password) return
isSubmitting.value = true
try {
const { data } = await apiClient.post<
PendingOAuthExchangeResponse & {
step?: string
redirect?: string
existing_account_bindable?: boolean
}
>(
'/auth/oauth/pending/create-account',
{
email: payload.email,
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined
}
)
const redirect = sanitizeRedirectPath(data.redirect || (route.query.redirect as string | undefined))
if (data.access_token) {
persistOAuthTokenContext(data)
await authStore.setToken(data.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return
}
// pending session choice email callback view
if (data.step === 'choose_account_action_required' || data.existing_account_bindable === true) {
navigateToBindLogin(payload.email)
return
}
accountActionError.value = t('auth.loginFailed')
} catch (e: unknown) {
// ""
const err = e as { response?: { data?: { reason?: string } } }
if (err.response?.data?.reason === 'REGISTRATION_DISABLED') {
appStore.showInfo(t('auth.dingtalk.registrationDisabledRedirectToBind'))
navigateToBindLogin(payload.email)
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
}
}
function navigateToBindLogin(email: string) {
const query: Record<string, string> = { bind: '1' }
if (email) query.email = email
const redirect = route.query.redirect as string | undefined
if (redirect) query.redirect = redirect
router.replace({ path: '/auth/dingtalk/callback', query })
}
function handleSwitchToBind(email: string) {
navigateToBindLogin(email)
}
</script>

View File

@ -152,6 +152,11 @@
:disabled="authActionDisabled"
:show-divider="false"
/>
<DingTalkOAuthSection
v-if="dingtalkOAuthEnabled"
:disabled="authActionDisabled"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="authActionDisabled"
@ -198,6 +203,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import DingTalkOAuthSection from '@/components/auth/DingTalkOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
@ -231,6 +237,7 @@ const publicSettingsLoaded = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const linuxdoOAuthEnabled = ref<boolean>(false)
const dingtalkOAuthEnabled = ref<boolean>(false)
const wechatOAuthEnabled = ref<boolean>(false)
const backendModeEnabled = ref<boolean>(false)
const oidcOAuthEnabled = ref<boolean>(false)
@ -283,6 +290,7 @@ const showOAuthLogin = computed(
() =>
!backendModeEnabled.value &&
(linuxdoOAuthEnabled.value ||
dingtalkOAuthEnabled.value ||
wechatOAuthEnabled.value ||
oidcOAuthEnabled.value ||
githubOAuthEnabled.value ||
@ -311,6 +319,7 @@ onMounted(async () => {
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
backendModeEnabled.value = settings.backend_mode_enabled
oidcOAuthEnabled.value = settings.oidc_oauth_enabled

View File

@ -7,6 +7,7 @@
<ProfileInfoCard
:user="user"
:linuxdo-enabled="linuxdoOAuthEnabled"
:dingtalk-enabled="dingtalkOAuthEnabled"
:oidc-enabled="oidcOAuthEnabled"
:oidc-provider-name="oidcOAuthProviderName"
:wechat-enabled="wechatOAuthEnabled"
@ -69,6 +70,7 @@ const contactInfo = ref('')
const balanceLowNotifyEnabled = ref(false)
const systemDefaultThreshold = ref(0)
const linuxdoOAuthEnabled = ref(false)
const dingtalkOAuthEnabled = ref(false)
const wechatOAuthEnabled = ref(false)
const wechatOAuthOpenEnabled = ref<boolean | undefined>(undefined)
const wechatOAuthMPEnabled = ref<boolean | undefined>(undefined)
@ -89,6 +91,7 @@ onMounted(async () => {
balanceLowNotifyEnabled.value = settings.balance_low_notify_enabled ?? false
systemDefaultThreshold.value = settings.balance_low_notify_threshold ?? 0
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled ?? false
dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
wechatOAuthOpenEnabled.value = typeof settings.wechat_oauth_open_enabled === 'boolean'
? settings.wechat_oauth_open_enabled