mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #2492 from DaydreamCoding/feat/dingtalk-login
feat(dingtalk): 钉钉 OAuth 登录接入 + internal_only 用户属性同步
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package config 包含钉钉连接配置的校验逻辑。
|
||||
//
|
||||
// internal_only 模式安全模型(方案 A):
|
||||
// 不再要求 admin 填写 InternalCorpID 做二次 corpID 比对。
|
||||
// 安全边界由钉钉"企业内部应用"类型本身保证——只有应用所属企业的员工才能完成 OAuth,
|
||||
// 因此 ValidateDingTalkConfig 只要求 app_type=internal(V1),不再要求 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
|
||||
}
|
||||
@@ -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",
|
||||
}))
|
||||
}
|
||||
@@ -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 必须为 internal(V1 校验)
|
||||
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 key:trimSpace + 空值 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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_only:bypass 仅在该 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])
|
||||
})
|
||||
}
|
||||
@@ -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 URL(host: 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)
|
||||
}
|
||||
// 邮箱三级 fallback:org_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
|
||||
}
|
||||
@@ -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
@@ -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 fail(isFatal=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 为空字符串(跨组织拿不到企业 userid),subject 仍为 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",
|
||||
}
|
||||
|
||||
// 调用不应 panic(userAttributeService 为 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 别名归一为 oauthPendingChoiceStep;bind_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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
// 公开设置(无需认证)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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(用于外部系统集成)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 key(DB 空 → 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 key(DB 空 → 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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'));
|
||||
Reference in New Issue
Block a user