diff --git a/Dockerfile b/Dockerfile index bae531ac6ed..1d73ad95fe2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -110,6 +110,7 @@ RUN apk add --no-cache \ # This ensures version consistency between backup tools and the database server COPY --from=pg-client /usr/local/bin/pg_dump /usr/local/bin/pg_dump COPY --from=pg-client /usr/local/bin/psql /usr/local/bin/psql +COPY --from=pg-client /usr/local/bin/pg_restore /usr/local/bin/pg_restore COPY --from=pg-client /usr/local/lib/libpq.so.5* /usr/local/lib/ # Create non-root user diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index a6fb5266aa7..87b76631622 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -69,6 +69,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { apiKeyCache := repository.NewAPIKeyCache(redisClient) apiKeyService := service.ProvideAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig, billingCacheService) apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService) + feishuOrgPermissionService := service.NewFeishuOrgPermissionService(db, apiKeyAuthCacheInvalidator) promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig) affiliateRepository := repository.NewAffiliateRepository(client, db) @@ -193,7 +194,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { dataManagementHandler := admin.NewDataManagementHandler(dataManagementService) backupObjectStoreFactory := repository.NewS3BackupStoreFactory() dbDumper := repository.NewPgDumper(configConfig) - backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper) + backupRecordRepository := repository.NewBackupRecordRepository(db) + backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, backupRecordRepository, leaderLockCache, db) backupHandler := admin.NewBackupHandler(backupService, userService) oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService) @@ -259,9 +261,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService) paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry) availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService) + feishuOrgHandler := handler.NewFeishuOrgHandler(feishuOrgPermissionService, settingService, usageService) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, idempotencyCoordinator, idempotencyCleanupService) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, feishuOrgHandler, idempotencyCoordinator, idempotencyCleanupService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) @@ -276,9 +279,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountExpiryService := service.ProvideAccountExpiryService(accountRepository) proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository) subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db) - scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) + scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig, leaderLockCache, db) paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db) - channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) + channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, leaderLockCache, db) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher) application := &Application{ diff --git a/backend/ent/schema/auth_identity.go b/backend/ent/schema/auth_identity.go index 3deeadcd96c..a84c005e8d5 100644 --- a/backend/ent/schema/auth_identity.go +++ b/backend/ent/schema/auth_identity.go @@ -22,6 +22,7 @@ var authProviderTypes = map[string]struct{}{ "oidc": {}, "wechat": {}, "dingtalk": {}, + "feishu": {}, } func validateAuthProviderType(value string) error { diff --git a/backend/ent/schema/auth_identity_schema_test.go b/backend/ent/schema/auth_identity_schema_test.go index af272790d1d..2bbfbc45d24 100644 --- a/backend/ent/schema/auth_identity_schema_test.go +++ b/backend/ent/schema/auth_identity_schema_test.go @@ -29,6 +29,10 @@ func TestAuthIdentityFoundationSchemas(t *testing.T) { "metadata", ) requireHasUniqueIndex(t, authIdentity, "provider_type", "provider_key", "provider_subject") + for _, value := range []string{"email", "github", "google", "linuxdo", "oidc", "wechat", "dingtalk", "feishu"} { + require.NoError(t, validateAuthProviderType(value)) + } + require.Error(t, validateAuthProviderType("unknown")) authIdentityChannel := requireSchema(t, schemas, "AuthIdentityChannel") requireSchemaFields(t, authIdentityChannel, @@ -83,7 +87,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", "dingtalk"} { + for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk", "feishu"} { require.NoError(t, validator(value)) } require.Error(t, validator("unknown")) diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index 127b5af9a7f..0d35bfdedf3 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -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", "dingtalk": + case "email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk", "feishu": return nil default: - return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google, dingtalk") + return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google, dingtalk, feishu") } }). Default("email"), diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 18baa348811..bbe165c19d9 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -74,6 +74,7 @@ type Config struct { WeChat WeChatConnectConfig `mapstructure:"wechat_connect"` OIDC OIDCConnectConfig `mapstructure:"oidc_connect"` DingTalk DingTalkConnectConfig `mapstructure:"dingtalk_connect"` + Feishu FeishuConnectConfig `mapstructure:"feishu_connect"` GitHubOAuth EmailOAuthProviderConfig `mapstructure:"github_oauth"` GoogleOAuth EmailOAuthProviderConfig `mapstructure:"google_oauth"` Default DefaultConfig `mapstructure:"default"` @@ -285,6 +286,29 @@ type DingTalkConnectConfig struct { AttributeSyncOverwritePolicy string `mapstructure:"attribute_sync_overwrite_policy"` } +type FeishuConnectConfig struct { + Enabled bool `mapstructure:"enabled"` + AppID string `mapstructure:"app_id"` + AppSecret string `mapstructure:"app_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"` + + TenantRestrictionPolicy string `mapstructure:"tenant_restriction_policy"` // none | internal_only + AllowedTenantKey string `mapstructure:"allowed_tenant_key"` + BypassRegistration bool `mapstructure:"bypass_registration"` + SyncEmail bool `mapstructure:"sync_email"` + SyncDisplayName bool `mapstructure:"sync_display_name"` + SyncDepartment bool `mapstructure:"sync_department"` + OrgSyncEnabled bool `mapstructure:"org_sync_enabled"` + DepartedUserAction string `mapstructure:"departed_user_action"` // auto_disable + DisableThresholdCount int `mapstructure:"sync_disable_threshold_count"` + DisableThresholdPercent int `mapstructure:"sync_disable_threshold_percent"` +} + type EmailOAuthProviderConfig struct { Enabled bool `mapstructure:"enabled"` ClientID string `mapstructure:"client_id"` @@ -302,6 +326,11 @@ const ( defaultWeChatConnectMode = "open" defaultWeChatConnectScopes = "snsapi_login" defaultWeChatConnectFrontendRedirect = "/auth/wechat/callback" + defaultFeishuConnectAuthorizeURL = "https://accounts.feishu.cn/open-apis/authen/v1/authorize" + defaultFeishuConnectTokenURL = "https://accounts.feishu.cn/oauth/v3/token" + defaultFeishuConnectUserInfoURL = "https://open.feishu.cn/open-apis/authen/v1/user_info" + defaultFeishuConnectScopes = "contact:user.base:readonly contact:user.email:readonly contact:user.employee_id:readonly" + defaultFeishuConnectFrontendRedirect = "/auth/feishu/callback" ) func firstNonEmptyString(values ...string) string { @@ -1702,6 +1731,27 @@ func setDefaults() { viper.SetDefault("dingtalk_connect.require_email", true) viper.SetDefault("dingtalk_connect.username_overwrite_policy", "if_empty") + // Feishu Connect OAuth 登录 + viper.SetDefault("feishu_connect.enabled", false) + viper.SetDefault("feishu_connect.app_id", "") + viper.SetDefault("feishu_connect.app_secret", "") + viper.SetDefault("feishu_connect.authorize_url", defaultFeishuConnectAuthorizeURL) + viper.SetDefault("feishu_connect.token_url", defaultFeishuConnectTokenURL) + viper.SetDefault("feishu_connect.userinfo_url", defaultFeishuConnectUserInfoURL) + viper.SetDefault("feishu_connect.scopes", defaultFeishuConnectScopes) + viper.SetDefault("feishu_connect.redirect_url", "") + viper.SetDefault("feishu_connect.frontend_redirect_url", defaultFeishuConnectFrontendRedirect) + viper.SetDefault("feishu_connect.tenant_restriction_policy", "internal_only") + viper.SetDefault("feishu_connect.allowed_tenant_key", "") + viper.SetDefault("feishu_connect.bypass_registration", false) + viper.SetDefault("feishu_connect.sync_email", true) + viper.SetDefault("feishu_connect.sync_display_name", true) + viper.SetDefault("feishu_connect.sync_department", true) + viper.SetDefault("feishu_connect.org_sync_enabled", false) + viper.SetDefault("feishu_connect.departed_user_action", "auto_disable") + viper.SetDefault("feishu_connect.sync_disable_threshold_count", 10) + viper.SetDefault("feishu_connect.sync_disable_threshold_percent", 20) + // Database viper.SetDefault("database.host", "localhost") viper.SetDefault("database.port", 5432) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 0f50ab723b9..b13e5db78c7 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -132,6 +132,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { LoginAgreementMode: settings.LoginAgreementMode, LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), + EmailPasswordLoginEnabled: settings.EmailPasswordLoginEnabled, + AdminEmailLoginFallbackEnabled: settings.AdminEmailLoginFallbackEnabled, SMTPHost: settings.SMTPHost, SMTPPort: settings.SMTPPort, SMTPUsername: settings.SMTPUsername, @@ -163,6 +165,20 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { DingTalkConnectSyncCorpEmailAttrName: settings.DingTalkConnectSyncCorpEmailAttrName, DingTalkConnectSyncDisplayNameAttrName: settings.DingTalkConnectSyncDisplayNameAttrName, DingTalkConnectSyncDeptAttrName: settings.DingTalkConnectSyncDeptAttrName, + FeishuConnectEnabled: settings.FeishuConnectEnabled, + FeishuConnectAppID: settings.FeishuConnectAppID, + FeishuConnectAppSecretConfigured: settings.FeishuConnectAppSecretConfigured, + FeishuConnectRedirectURL: settings.FeishuConnectRedirectURL, + FeishuConnectTenantRestrictionPolicy: settings.FeishuConnectTenantRestrictionPolicy, + FeishuConnectAllowedTenantKey: settings.FeishuConnectAllowedTenantKey, + FeishuConnectBypassRegistration: settings.FeishuConnectBypassRegistration, + FeishuConnectSyncEmail: settings.FeishuConnectSyncEmail, + FeishuConnectSyncDisplayName: settings.FeishuConnectSyncDisplayName, + FeishuConnectSyncDepartment: settings.FeishuConnectSyncDepartment, + FeishuOrgSyncEnabled: settings.FeishuOrgSyncEnabled, + FeishuDepartedUserAction: settings.FeishuDepartedUserAction, + FeishuSyncDisableThresholdCount: settings.FeishuSyncDisableThresholdCount, + FeishuSyncDisableThresholdPercent: settings.FeishuSyncDisableThresholdPercent, WeChatConnectEnabled: settings.WeChatConnectEnabled, WeChatConnectAppID: settings.WeChatConnectAppID, WeChatConnectAppSecretConfigured: settings.WeChatConnectAppSecretConfigured, @@ -407,6 +423,8 @@ type UpdateSettingsRequest struct { LoginAgreementMode string `json:"login_agreement_mode"` LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"` + EmailPasswordLoginEnabled *bool `json:"email_password_login_enabled"` + AdminEmailLoginFallbackEnabled *bool `json:"admin_email_login_fallback_enabled"` // 邮件服务设置 SMTPHost string `json:"smtp_host"` @@ -449,6 +467,22 @@ type UpdateSettingsRequest struct { DingTalkConnectSyncDisplayNameAttrName string `json:"dingtalk_connect_sync_display_name_attr_name"` DingTalkConnectSyncDeptAttrName string `json:"dingtalk_connect_sync_dept_attr_name"` + // Feishu Connect OAuth 登录 + FeishuConnectEnabled *bool `json:"feishu_connect_enabled"` + FeishuConnectAppID string `json:"feishu_connect_app_id"` + FeishuConnectAppSecret string `json:"feishu_connect_app_secret"` + FeishuConnectRedirectURL string `json:"feishu_connect_redirect_url"` + FeishuConnectTenantRestrictionPolicy string `json:"feishu_connect_tenant_restriction_policy"` + FeishuConnectAllowedTenantKey string `json:"feishu_connect_allowed_tenant_key"` + FeishuConnectBypassRegistration bool `json:"feishu_connect_bypass_registration"` + FeishuConnectSyncEmail bool `json:"feishu_connect_sync_email"` + FeishuConnectSyncDisplayName bool `json:"feishu_connect_sync_display_name"` + FeishuConnectSyncDepartment bool `json:"feishu_connect_sync_department"` + FeishuOrgSyncEnabled bool `json:"feishu_org_sync_enabled"` + FeishuDepartedUserAction string `json:"feishu_departed_user_action"` + FeishuSyncDisableThresholdCount int `json:"feishu_sync_disable_threshold_count"` + FeishuSyncDisableThresholdPercent int `json:"feishu_sync_disable_threshold_percent"` + // WeChat Connect OAuth 登录 WeChatConnectEnabled bool `json:"wechat_connect_enabled"` WeChatConnectAppID string `json:"wechat_connect_app_id"` @@ -562,6 +596,11 @@ type UpdateSettingsRequest struct { 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"` + AuthSourceDefaultFeishuBalance *float64 `json:"auth_source_default_feishu_balance"` + AuthSourceDefaultFeishuConcurrency *int `json:"auth_source_default_feishu_concurrency"` + AuthSourceDefaultFeishuSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_feishu_subscriptions"` + AuthSourceDefaultFeishuGrantOnSignup *bool `json:"auth_source_default_feishu_grant_on_signup"` + AuthSourceDefaultFeishuGrantOnFirstBind *bool `json:"auth_source_default_feishu_grant_on_first_bind"` ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"` // Model fallback configuration @@ -686,6 +725,7 @@ type UpdateSettingsRequest struct { AuthSourceGitHubPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_github_platform_quotas"` AuthSourceGooglePlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_google_platform_quotas"` AuthSourceDingTalkPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_dingtalk_platform_quotas"` + AuthSourceFeishuPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_feishu_platform_quotas"` AllowUserViewErrorRequests *bool `json:"allow_user_view_error_requests"` } @@ -775,6 +815,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.AuthSourceDefaultOIDCSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultOIDCSubscriptions) req.AuthSourceDefaultWeChatSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultWeChatSubscriptions) req.AuthSourceDefaultDingTalkSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultDingTalkSubscriptions) + req.AuthSourceDefaultFeishuSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultFeishuSubscriptions) // SMTP 配置保护:如果请求中 smtp_host 为空但数据库中已有配置,则保留已有 SMTP 配置 // 防止前端加载设置失败时空表单覆盖已保存的 SMTP 配置 @@ -985,6 +1026,70 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + feishuConnectEnabled := boolValueOrDefault(req.FeishuConnectEnabled, previousSettings.FeishuConnectEnabled) + if req.FeishuConnectEnabled == nil { + req.FeishuConnectBypassRegistration = previousSettings.FeishuConnectBypassRegistration + req.FeishuConnectSyncEmail = previousSettings.FeishuConnectSyncEmail + req.FeishuConnectSyncDisplayName = previousSettings.FeishuConnectSyncDisplayName + req.FeishuConnectSyncDepartment = previousSettings.FeishuConnectSyncDepartment + req.FeishuOrgSyncEnabled = previousSettings.FeishuOrgSyncEnabled + req.FeishuConnectTenantRestrictionPolicy = previousSettings.FeishuConnectTenantRestrictionPolicy + } + req.FeishuConnectTenantRestrictionPolicy = service.NormalizeFeishuTenantRestrictionPolicy(req.FeishuConnectTenantRestrictionPolicy) + req.FeishuDepartedUserAction = strings.TrimSpace(req.FeishuDepartedUserAction) + if req.FeishuDepartedUserAction == "" { + req.FeishuDepartedUserAction = previousSettings.FeishuDepartedUserAction + } + if req.FeishuDepartedUserAction == "" { + req.FeishuDepartedUserAction = service.FeishuDepartedUserActionAutoDisable + } + if req.FeishuSyncDisableThresholdCount <= 0 { + req.FeishuSyncDisableThresholdCount = previousSettings.FeishuSyncDisableThresholdCount + } + if req.FeishuSyncDisableThresholdCount <= 0 { + req.FeishuSyncDisableThresholdCount = service.FeishuDefaultDisableThresholdCount + } + if req.FeishuSyncDisableThresholdPercent <= 0 { + req.FeishuSyncDisableThresholdPercent = previousSettings.FeishuSyncDisableThresholdPercent + } + if req.FeishuSyncDisableThresholdPercent <= 0 { + req.FeishuSyncDisableThresholdPercent = service.FeishuDefaultDisableThresholdPct + } + if feishuConnectEnabled { + req.FeishuConnectAppID = strings.TrimSpace(firstNonEmpty(req.FeishuConnectAppID, previousSettings.FeishuConnectAppID)) + req.FeishuConnectAppSecret = strings.TrimSpace(req.FeishuConnectAppSecret) + req.FeishuConnectRedirectURL = strings.TrimSpace(firstNonEmpty(req.FeishuConnectRedirectURL, previousSettings.FeishuConnectRedirectURL)) + req.FeishuConnectAllowedTenantKey = strings.TrimSpace(firstNonEmpty(req.FeishuConnectAllowedTenantKey, previousSettings.FeishuConnectAllowedTenantKey)) + + if req.FeishuConnectAppID == "" { + response.BadRequest(c, "Feishu App ID is required when enabled") + return + } + if req.FeishuConnectRedirectURL == "" { + response.BadRequest(c, "Feishu Redirect URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.FeishuConnectRedirectURL); err != nil { + response.BadRequest(c, "Feishu Redirect URL must be an absolute http(s) URL") + return + } + if req.FeishuConnectAppSecret == "" { + if previousSettings.FeishuConnectAppSecret == "" { + response.BadRequest(c, "Feishu App Secret is required when enabled") + return + } + req.FeishuConnectAppSecret = previousSettings.FeishuConnectAppSecret + } + if req.FeishuConnectTenantRestrictionPolicy != service.FeishuTenantRestrictionInternalOnly { + req.FeishuConnectBypassRegistration = false + req.FeishuOrgSyncEnabled = false + } + } else { + req.FeishuConnectAppID = strings.TrimSpace(firstNonEmpty(req.FeishuConnectAppID, previousSettings.FeishuConnectAppID)) + req.FeishuConnectRedirectURL = strings.TrimSpace(firstNonEmpty(req.FeishuConnectRedirectURL, previousSettings.FeishuConnectRedirectURL)) + req.FeishuConnectAllowedTenantKey = strings.TrimSpace(firstNonEmpty(req.FeishuConnectAllowedTenantKey, previousSettings.FeishuConnectAllowedTenantKey)) + } + if req.WeChatConnectEnabled { req.WeChatConnectAppID = strings.TrimSpace(req.WeChatConnectAppID) req.WeChatConnectAppSecret = strings.TrimSpace(req.WeChatConnectAppSecret) @@ -1522,6 +1627,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { return } + emailPasswordLoginEnabled := boolValueOrDefault(req.EmailPasswordLoginEnabled, previousSettings.EmailPasswordLoginEnabled) + adminEmailLoginFallbackEnabled := boolValueOrDefault(req.AdminEmailLoginFallbackEnabled, previousSettings.AdminEmailLoginFallbackEnabled) + settings := &service.SystemSettings{ // 系统全局 platform quota 默认值(整体替换语义) DefaultPlatformQuotas: req.DefaultPlatformQuotas, @@ -1538,6 +1646,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { LoginAgreementMode: loginAgreementMode, LoginAgreementUpdatedAt: loginAgreementUpdatedAt, LoginAgreementDocuments: loginAgreementDocuments, + EmailPasswordLoginEnabled: emailPasswordLoginEnabled, + AdminEmailLoginFallbackEnabled: adminEmailLoginFallbackEnabled, + LoginEntrySettingsExplicit: true, SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort, SMTPUsername: req.SMTPUsername, @@ -1574,6 +1685,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { DingTalkConnectSyncCorpEmailAttrName: req.DingTalkConnectSyncCorpEmailAttrName, DingTalkConnectSyncDisplayNameAttrName: req.DingTalkConnectSyncDisplayNameAttrName, DingTalkConnectSyncDeptAttrName: req.DingTalkConnectSyncDeptAttrName, + FeishuConnectEnabled: feishuConnectEnabled, + FeishuConnectAppID: req.FeishuConnectAppID, + FeishuConnectAppSecret: req.FeishuConnectAppSecret, + FeishuConnectRedirectURL: req.FeishuConnectRedirectURL, + FeishuConnectTenantRestrictionPolicy: req.FeishuConnectTenantRestrictionPolicy, + FeishuConnectAllowedTenantKey: req.FeishuConnectAllowedTenantKey, + FeishuConnectBypassRegistration: req.FeishuConnectBypassRegistration, + FeishuConnectSyncEmail: req.FeishuConnectSyncEmail, + FeishuConnectSyncDisplayName: req.FeishuConnectSyncDisplayName, + FeishuConnectSyncDepartment: req.FeishuConnectSyncDepartment, + FeishuOrgSyncEnabled: req.FeishuOrgSyncEnabled, + FeishuDepartedUserAction: req.FeishuDepartedUserAction, + FeishuSyncDisableThresholdCount: req.FeishuSyncDisableThresholdCount, + FeishuSyncDisableThresholdPercent: req.FeishuSyncDisableThresholdPercent, WeChatConnectEnabled: req.WeChatConnectEnabled, WeChatConnectAppID: req.WeChatConnectAppID, WeChatConnectAppSecret: req.WeChatConnectAppSecret, @@ -1931,6 +2056,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnFirstBind, previousAuthSourceDefaults.DingTalk.GrantOnFirstBind), PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceDingTalkPlatformQuotas, previousAuthSourceDefaults.DingTalk.PlatformQuotas), }, + Feishu: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultFeishuBalance, previousAuthSourceDefaults.Feishu.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultFeishuConcurrency, previousAuthSourceDefaults.Feishu.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultFeishuSubscriptions, previousAuthSourceDefaults.Feishu.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultFeishuGrantOnSignup, previousAuthSourceDefaults.Feishu.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultFeishuGrantOnFirstBind, previousAuthSourceDefaults.Feishu.GrantOnFirstBind), + PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceFeishuPlatformQuotas, previousAuthSourceDefaults.Feishu.PlatformQuotas), + }, ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup), } if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil { @@ -2871,6 +3004,11 @@ func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults 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_feishu_balance"] = authSourceDefaults.Feishu.Balance + data["auth_source_default_feishu_concurrency"] = authSourceDefaults.Feishu.Concurrency + data["auth_source_default_feishu_subscriptions"] = authSourceDefaults.Feishu.Subscriptions + data["auth_source_default_feishu_grant_on_signup"] = authSourceDefaults.Feishu.GrantOnSignup + data["auth_source_default_feishu_grant_on_first_bind"] = authSourceDefaults.Feishu.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 @@ -2898,11 +3036,23 @@ func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults data["auth_source_default_github_platform_quotas"] = authSourceDefaults.GitHub.PlatformQuotas data["auth_source_default_google_platform_quotas"] = authSourceDefaults.Google.PlatformQuotas data["auth_source_default_dingtalk_platform_quotas"] = authSourceDefaults.DingTalk.PlatformQuotas + data["auth_source_default_feishu_platform_quotas"] = authSourceDefaults.Feishu.PlatformQuotas data["force_email_on_third_party_signup"] = authSourceDefaults.ForceEmailOnThirdPartySignup return data } +// CheckFeishuPreflight returns local Feishu configuration readiness without calling Feishu. +// POST /api/v1/admin/settings/feishu/preflight +func (h *SettingHandler) CheckFeishuPreflight(c *gin.Context) { + result, err := h.settingService.CheckFeishuPreflight(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + func equalStringSlice(a, b []string) bool { if len(a) != len(b) { return false diff --git a/backend/internal/handler/admin/setting_handler_feishu_test.go b/backend/internal/handler/admin/setting_handler_feishu_test.go new file mode 100644 index 00000000000..71fa3b0dfc8 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_feishu_test.go @@ -0,0 +1,153 @@ +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/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newFeishuSettingsHandler(repo *settingHandlerRepoStub) *SettingHandler { + svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) + return NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) +} + +func TestAdminSettingsExposeFeishuConnectFields(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &settingHandlerRepoStub{values: map[string]string{ + service.SettingKeyEmailPasswordLoginEnabled: "false", + service.SettingKeyAdminEmailLoginFallbackEnabled: "true", + service.SettingKeyFeishuConnectEnabled: "true", + service.SettingKeyFeishuConnectAppID: "cli_test", + service.SettingKeyFeishuConnectAppSecret: "secret", + service.SettingKeyFeishuConnectRedirectURL: "https://example.com/oauth/feishu/callback", + service.SettingKeyFeishuConnectTenantRestrictionPolicy: "internal_only", + service.SettingKeyFeishuConnectAllowedTenantKey: "tenant_test", + service.SettingKeyFeishuConnectBypassRegistration: "true", + service.SettingKeyFeishuConnectSyncEmail: "true", + service.SettingKeyFeishuConnectSyncDisplayName: "true", + service.SettingKeyFeishuConnectSyncDepartment: "true", + service.SettingKeyFeishuOrgSyncEnabled: "true", + service.SettingKeyFeishuDepartedUserAction: "auto_disable", + service.SettingKeyFeishuSyncDisableThresholdCount: "10", + service.SettingKeyFeishuSyncDisableThresholdPercent: "20", + service.SettingKeyAuthSourceDefaultFeishuBalance: "3.5", + service.SettingKeyAuthSourceDefaultFeishuConcurrency: "7", + service.SettingKeyAuthSourceDefaultFeishuGrantOnSignup: "true", + service.SettingKeyAuthSourceDefaultFeishuGrantOnFirstBind: "false", + }} + handler := newFeishuSettingsHandler(repo) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil) + + handler.GetSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got struct { + Data map[string]any `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, false, got.Data["email_password_login_enabled"]) + require.Equal(t, true, got.Data["admin_email_login_fallback_enabled"]) + require.Equal(t, true, got.Data["feishu_connect_enabled"]) + require.Equal(t, "cli_test", got.Data["feishu_connect_app_id"]) + require.Equal(t, true, got.Data["feishu_connect_app_secret_configured"]) + require.NotContains(t, got.Data, "feishu_connect_app_secret") + require.Equal(t, "internal_only", got.Data["feishu_connect_tenant_restriction_policy"]) + require.Equal(t, "tenant_test", got.Data["feishu_connect_allowed_tenant_key"]) + require.Equal(t, true, got.Data["feishu_connect_bypass_registration"]) + require.Equal(t, true, got.Data["feishu_org_sync_enabled"]) + require.Equal(t, "auto_disable", got.Data["feishu_departed_user_action"]) + require.Equal(t, float64(10), got.Data["feishu_sync_disable_threshold_count"]) + require.Equal(t, float64(20), got.Data["feishu_sync_disable_threshold_percent"]) + require.Equal(t, 3.5, got.Data["auth_source_default_feishu_balance"]) + require.Equal(t, float64(7), got.Data["auth_source_default_feishu_concurrency"]) + require.Equal(t, true, got.Data["auth_source_default_feishu_grant_on_signup"]) +} + +func TestFeishuPreflightReportsMissingCredential(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &settingHandlerRepoStub{values: map[string]string{ + service.SettingKeyEmailPasswordLoginEnabled: "false", + service.SettingKeyAdminEmailLoginFallbackEnabled: "true", + service.SettingKeyFeishuConnectEnabled: "true", + service.SettingKeyFeishuConnectTenantRestrictionPolicy: "internal_only", + }} + handler := newFeishuSettingsHandler(repo) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/settings/feishu/preflight", bytes.NewReader(nil)) + + handler.CheckFeishuPreflight(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got struct { + Data struct { + OK bool `json:"ok"` + Login struct { + Status string `json:"status"` + Reason string `json:"reason"` + } `json:"login"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.False(t, got.Data.OK) + require.Equal(t, "error", got.Data.Login.Status) + require.Equal(t, "FEISHU_CONFIG_INCOMPLETE", got.Data.Login.Reason) +} + +func TestFeishuPreflightReportsDefaultGrantWarning(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &settingHandlerRepoStub{values: map[string]string{ + service.SettingKeyEmailPasswordLoginEnabled: "false", + service.SettingKeyAdminEmailLoginFallbackEnabled: "true", + service.SettingKeyFeishuConnectEnabled: "true", + service.SettingKeyFeishuConnectAppID: "cli_test", + service.SettingKeyFeishuConnectAppSecret: "secret", + service.SettingKeyFeishuConnectRedirectURL: "https://example.com/oauth/feishu/callback", + service.SettingKeyFeishuConnectTenantRestrictionPolicy: "internal_only", + }} + handler := newFeishuSettingsHandler(repo) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/settings/feishu/preflight", bytes.NewReader(nil)) + + handler.CheckFeishuPreflight(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got struct { + Data struct { + OK bool `json:"ok"` + Login struct { + Status string `json:"status"` + } `json:"login"` + Warnings []struct { + Reason string `json:"reason"` + } `json:"warnings"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.True(t, got.Data.OK) + require.Equal(t, "ok", got.Data.Login.Status) + require.Contains(t, preflightWarningReasons(got.Data.Warnings), "FEISHU_DEFAULT_GRANT_UNCONFIGURED") +} + +func preflightWarningReasons(items []struct { + Reason string `json:"reason"` +}) []string { + reasons := make([]string, 0, len(items)) + for _, item := range items { + reasons = append(reasons, item.Reason) + } + return reasons +} diff --git a/backend/internal/handler/auth_email_password_login_test.go b/backend/internal/handler/auth_email_password_login_test.go new file mode 100644 index 00000000000..578220f8abd --- /dev/null +++ b/backend/internal/handler/auth_email_password_login_test.go @@ -0,0 +1,118 @@ +package handler + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +type authEmailPasswordSettingRepo struct { + values map[string]string +} + +func (r *authEmailPasswordSettingRepo) Get(context.Context, string) (*service.Setting, error) { + panic("unexpected Get call") +} + +func (r *authEmailPasswordSettingRepo) GetValue(context.Context, string) (string, error) { + panic("unexpected GetValue call") +} + +func (r *authEmailPasswordSettingRepo) Set(context.Context, string, string) error { + panic("unexpected Set call") +} + +func (r *authEmailPasswordSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + out := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := r.values[key]; ok { + out[key] = value + } + } + return out, nil +} + +func (r *authEmailPasswordSettingRepo) SetMultiple(context.Context, map[string]string) error { + panic("unexpected SetMultiple call") +} + +func (r *authEmailPasswordSettingRepo) GetAll(context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +func (r *authEmailPasswordSettingRepo) Delete(context.Context, string) error { + panic("unexpected Delete call") +} + +func newAuthHandlerWithEmailPasswordSettings(values map[string]string) *AuthHandler { + return &AuthHandler{ + settingSvc: service.NewSettingService(&authEmailPasswordSettingRepo{values: values}, &config.Config{}), + } +} + +func TestEnsureEmailPasswordLoginAllowsUser_DefaultAllowsRegularUser(t *testing.T) { + h := newAuthHandlerWithEmailPasswordSettings(map[string]string{}) + + err := h.ensureEmailPasswordLoginAllowsUser(context.Background(), &service.User{ID: 1, Role: service.RoleUser}) + + require.NoError(t, err) +} + +func TestEnsureEmailPasswordLoginAllowsUser_AdminFallback(t *testing.T) { + h := newAuthHandlerWithEmailPasswordSettings(map[string]string{ + service.SettingKeyEmailPasswordLoginEnabled: "false", + service.SettingKeyAdminEmailLoginFallbackEnabled: "true", + }) + + err := h.ensureEmailPasswordLoginAllowsUser(context.Background(), &service.User{ID: 1, Role: service.RoleAdmin}) + + require.NoError(t, err) +} + +func TestEnsureEmailPasswordLoginAllowsUser_RejectsRegularUserWhenDisabled(t *testing.T) { + h := newAuthHandlerWithEmailPasswordSettings(map[string]string{ + service.SettingKeyEmailPasswordLoginEnabled: "false", + service.SettingKeyAdminEmailLoginFallbackEnabled: "true", + }) + + err := h.ensureEmailPasswordLoginAllowsUser(context.Background(), &service.User{ID: 1, Role: service.RoleUser}) + + require.Error(t, err) + require.Equal(t, "EMAIL_PASSWORD_LOGIN_DISABLED", infraerrors.Reason(err)) +} + +func TestEnsureEmailPasswordLoginAllowsUser_RejectsAdminWhenFallbackDisabled(t *testing.T) { + h := newAuthHandlerWithEmailPasswordSettings(map[string]string{ + service.SettingKeyEmailPasswordLoginEnabled: "false", + service.SettingKeyAdminEmailLoginFallbackEnabled: "false", + }) + + err := h.ensureEmailPasswordLoginAllowsUser(context.Background(), &service.User{ID: 1, Role: service.RoleAdmin}) + + require.Error(t, err) + require.Equal(t, "EMAIL_PASSWORD_LOGIN_DISABLED", infraerrors.Reason(err)) +} + +func TestEnsureEmailPasswordRegistrationAllowed_DefaultAllows(t *testing.T) { + h := newAuthHandlerWithEmailPasswordSettings(map[string]string{}) + + err := h.ensureEmailPasswordRegistrationAllowed(context.Background()) + + require.NoError(t, err) +} + +func TestEnsureEmailPasswordRegistrationAllowed_RejectsWhenEmailLoginDisabled(t *testing.T) { + h := newAuthHandlerWithEmailPasswordSettings(map[string]string{ + service.SettingKeyEmailPasswordLoginEnabled: "false", + service.SettingKeyAdminEmailLoginFallbackEnabled: "true", + }) + + err := h.ensureEmailPasswordRegistrationAllowed(context.Background()) + + require.Error(t, err) + require.Equal(t, "EMAIL_PASSWORD_LOGIN_DISABLED", infraerrors.Reason(err)) +} diff --git a/backend/internal/handler/auth_feishu_oauth.go b/backend/internal/handler/auth_feishu_oauth.go new file mode 100644 index 00000000000..da1fcc95366 --- /dev/null +++ b/backend/internal/handler/auth_feishu_oauth.go @@ -0,0 +1,643 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/oauth" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +type feishuOAuthConfig = config.FeishuConnectConfig + +const ( + feishuOAuthCookiePath = "/api/v1/auth/oauth/feishu" + feishuOAuthStateCookieName = "feishu_oauth_state" + feishuOAuthRedirectCookieName = "feishu_oauth_redirect" + feishuOAuthIntentCookieName = "feishu_oauth_intent" + feishuOAuthBindUserCookieName = "feishu_oauth_bind_user" + feishuOAuthCookieMaxAgeSec = 10 * 60 + feishuOAuthDefaultRedirectTo = "/dashboard" + feishuOAuthDefaultFrontendCB = "/auth/feishu/callback" +) + +type feishuOAuthTokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` +} + +type feishuUserInfoResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data feishuUserInfo `json:"data"` +} + +type feishuUserInfo struct { + AvatarBig string `json:"avatar_big"` + AvatarMiddle string `json:"avatar_middle"` + AvatarThumb string `json:"avatar_thumb"` + AvatarURL string `json:"avatar_url"` + Email string `json:"email"` + EnterpriseEmail string `json:"enterprise_email"` + EnName string `json:"en_name"` + Name string `json:"name"` + OpenID string `json:"open_id"` + TenantKey string `json:"tenant_key"` + UnionID string `json:"union_id"` + UserID string `json:"user_id"` + EmployeeNo string `json:"employee_no"` +} + +type feishuOAuthIdentity struct { + Token feishuOAuthTokenResponse + User feishuUserInfo +} + +var fetchFeishuOAuthIdentity = defaultFetchFeishuOAuthIdentity + +func (h *AuthHandler) getFeishuOAuthConfig(ctx context.Context) (config.FeishuConnectConfig, error) { + if h != nil && h.settingSvc != nil { + return h.settingSvc.GetFeishuConnectOAuthConfig(ctx) + } + if h == nil || h.cfg == nil { + return config.FeishuConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + if !h.cfg.Feishu.Enabled { + return config.FeishuConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "feishu oauth login is disabled") + } + return h.cfg.Feishu, nil +} + +func feishuSetCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: value, + Path: feishuOAuthCookiePath, + MaxAge: maxAgeSec, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func feishuClearCookie(c *gin.Context, name string, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: "", + Path: feishuOAuthCookiePath, + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// FeishuOAuthStart starts the Feishu OAuth login flow. +// GET /api/v1/auth/oauth/feishu/start?redirect=/dashboard&intent=login +func (h *AuthHandler) FeishuOAuthStart(c *gin.Context) { + cfg, err := h.getFeishuOAuthConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + state, err := oauth.GenerateState() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err)) + return + } + redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect")) + if redirectTo == "" { + redirectTo = feishuOAuthDefaultRedirectTo + } + browserSessionKey, err := generateOAuthPendingBrowserSession() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err)) + return + } + + secureCookie := isRequestHTTPS(c) + feishuSetCookie(c, feishuOAuthStateCookieName, encodeCookieValue(state), feishuOAuthCookieMaxAgeSec, secureCookie) + feishuSetCookie(c, feishuOAuthRedirectCookieName, encodeCookieValue(redirectTo), feishuOAuthCookieMaxAgeSec, secureCookie) + intent := normalizeOAuthIntent(c.Query("intent")) + feishuSetCookie(c, feishuOAuthIntentCookieName, encodeCookieValue(intent), feishuOAuthCookieMaxAgeSec, secureCookie) + captureOAuthPromoCode(c, secureCookie) + setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie) + clearOAuthPendingSessionCookie(c, secureCookie) + if intent == oauthIntentBindCurrentUser { + bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c) + if err != nil { + response.ErrorFrom(c, err) + return + } + feishuSetCookie(c, feishuOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), feishuOAuthCookieMaxAgeSec, secureCookie) + } else { + feishuClearCookie(c, feishuOAuthBindUserCookieName, secureCookie) + } + + authURL, err := buildFeishuAuthorizeURL(cfg, state) + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build feishu authorization url").WithCause(err)) + return + } + c.Redirect(http.StatusFound, authURL) +} + +// FeishuOAuthCallback handles Feishu OAuth callback and creates a browser-bound +// pending session, keeping token issuance on the existing pending OAuth exchange. +// GET /api/v1/auth/oauth/feishu/callback?code=...&state=... +func (h *AuthHandler) FeishuOAuthCallback(c *gin.Context) { + cfg, cfgErr := h.getFeishuOAuthConfig(c.Request.Context()) + if cfgErr != nil { + response.ErrorFrom(c, cfgErr) + return + } + + frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL) + if frontendCallback == "" { + frontendCallback = feishuOAuthDefaultFrontendCB + } + if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" { + redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description")) + return + } + + code := strings.TrimSpace(c.Query("code")) + state := strings.TrimSpace(c.Query("state")) + if code == "" || state == "" { + redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "") + return + } + + secureCookie := isRequestHTTPS(c) + defer func() { + feishuClearCookie(c, feishuOAuthStateCookieName, secureCookie) + feishuClearCookie(c, feishuOAuthRedirectCookieName, secureCookie) + feishuClearCookie(c, feishuOAuthIntentCookieName, secureCookie) + feishuClearCookie(c, feishuOAuthBindUserCookieName, secureCookie) + clearOAuthPromoCodeCookie(c, secureCookie) + }() + + expectedState, err := readCookieDecoded(c, feishuOAuthStateCookieName) + if err != nil || expectedState == "" || state != expectedState { + redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "") + return + } + redirectTo, _ := readCookieDecoded(c, feishuOAuthRedirectCookieName) + redirectTo = sanitizeFrontendRedirectPath(redirectTo) + if redirectTo == "" { + redirectTo = feishuOAuthDefaultRedirectTo + } + intent, _ := readCookieDecoded(c, feishuOAuthIntentCookieName) + intent = normalizeOAuthIntent(intent) + browserSessionKey, _ := readOAuthPendingBrowserCookie(c) + if strings.TrimSpace(browserSessionKey) == "" { + redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "") + return + } + + identity, err := fetchFeishuOAuthIdentity(c.Request.Context(), cfg, code) + if err != nil { + log.Printf("[Feishu OAuth] identity fetch failed: %v", err) + redirectOAuthError(c, frontendCallback, "provider_error", "feishu_identity_fetch_failed", singleLine(err.Error())) + return + } + if !checkFeishuTenantAllowed(cfg, identity.User.TenantKey) { + redirectOAuthError(c, frontendCallback, "tenant_rejected", "", "") + return + } + + providerSubject := feishuProviderSubject(identity.User) + if providerSubject == "" { + redirectOAuthError(c, frontendCallback, "missing_subject", "missing feishu identity subject", "") + return + } + providerKey := strings.TrimSpace(identity.User.TenantKey) + if providerKey == "" { + redirectOAuthError(c, frontendCallback, "missing_tenant", "missing feishu tenant key", "") + return + } + + email := feishuPrimaryEmail(identity.User) + syntheticEmail := buildFeishuSyntheticEmail(providerSubject) + resolvedEmail := email + if resolvedEmail == "" { + resolvedEmail = syntheticEmail + } + username := firstNonEmpty(identity.User.Name, identity.User.EnName, feishuFallbackUsername(providerSubject)) + identityRef := service.PendingAuthIdentityKey{ + ProviderType: "feishu", + ProviderKey: providerKey, + ProviderSubject: providerSubject, + } + upstreamClaims := buildFeishuUpstreamClaims(identity, resolvedEmail, syntheticEmail, username) + + if intent == oauthIntentBindCurrentUser { + targetUserID, err := h.readOAuthBindUserIDFromCookie(c, feishuOAuthBindUserCookieName) + if err != nil { + redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth bind target", "") + return + } + if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ + Intent: oauthIntentBindCurrentUser, + Identity: identityRef, + TargetUserID: &targetUserID, + ResolvedEmail: resolvedEmail, + RedirectTo: redirectTo, + BrowserSessionKey: browserSessionKey, + UpstreamIdentityClaims: upstreamClaims, + CompletionResponse: map[string]any{"redirect": redirectTo}, + }); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + redirectToFrontendCallback(c, frontendCallback) + return + } + + existingIdentityUser, err := h.findOAuthIdentityUser(c.Request.Context(), identityRef) + if err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + if existingIdentityUser != nil { + if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ + Intent: oauthIntentLogin, + Identity: identityRef, + TargetUserID: &existingIdentityUser.ID, + ResolvedEmail: existingIdentityUser.Email, + RedirectTo: redirectTo, + BrowserSessionKey: browserSessionKey, + UpstreamIdentityClaims: upstreamClaims, + CompletionResponse: map[string]any{"redirect": redirectTo}, + }); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + redirectToFrontendCallback(c, frontendCallback) + return + } + + signupBlocked := h.isFeishuSignupBlocked(c.Request.Context(), cfg) + if signupBlocked { + redirectOAuthError(c, frontendCallback, "identity_unbound", "feishu identity is not bound to a local user", "") + return + } + + compatEmailUser, err := h.findFeishuCompatEmailUser(c.Request.Context(), email) + if err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + if compatEmailUser != nil { + redirectOAuthError(c, frontendCallback, "identity_unbound", "feishu identity is not bound to a local user", "") + return + } + emailVerificationRequired := h != nil && h.authService != nil && h.authService.IsEmailVerifyEnabled(c.Request.Context()) + forceEmailOnSignup := h.isForceEmailOnThirdPartySignup(c.Request.Context()) + if compatEmailUser == nil && !emailVerificationRequired && !forceEmailOnSignup && h != nil && h.authService != nil { + if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode( + c.Request.Context(), + resolvedEmail, + username, + "", + "", + readOAuthPromoCode(c), + "feishu", + ) + if err == nil { + if err := applyPendingOAuthBinding( + c.Request.Context(), + h.entClient(), + h.authService, + h.userService, + &dbent.PendingAuthSession{ + Intent: oauthIntentLogin, + ProviderType: identityRef.ProviderType, + ProviderKey: identityRef.ProviderKey, + ProviderSubject: identityRef.ProviderSubject, + ResolvedEmail: resolvedEmail, + UpstreamIdentityClaims: upstreamClaims, + }, + nil, + &user.ID, + true, + false, + ); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", "failed to bind oauth identity", "") + return + } + h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) + clearOAuthPendingSessionCookie(c, secureCookie) + clearOAuthPendingBrowserCookie(c, secureCookie) + redirectOAuthTokenPair(c, frontendCallback, tokenPair, redirectTo) + return + } + if !errors.Is(err, service.ErrOAuthInvitationRequired) { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + } + if err := h.createFeishuOAuthChoicePendingSession( + c, + identityRef, + resolvedEmail, + resolvedEmail, + redirectTo, + browserSessionKey, + upstreamClaims, + email, + compatEmailUser, + forceEmailOnSignup, + ); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + redirectToFrontendCallback(c, frontendCallback) +} + +func buildFeishuAuthorizeURL(cfg config.FeishuConnectConfig, state string) (string, error) { + base := strings.TrimSpace(cfg.AuthorizeURL) + if base == "" { + return "", infraerrors.InternalServer("FEISHU_AUTHORIZE_URL_EMPTY", "feishu authorize_url not configured") + } + redirectURI := strings.TrimSpace(cfg.RedirectURL) + if redirectURI == "" { + return "", infraerrors.InternalServer("FEISHU_REDIRECT_URL_EMPTY", "feishu redirect_url not configured") + } + u, err := url.Parse(base) + if err != nil { + return "", infraerrors.InternalServer("FEISHU_AUTHORIZE_URL_PARSE_FAILED", "failed to parse feishu authorize_url").WithCause(err) + } + q := u.Query() + q.Set("client_id", strings.TrimSpace(cfg.AppID)) + q.Set("response_type", "code") + q.Set("redirect_uri", redirectURI) + q.Set("state", state) + if scopes := strings.TrimSpace(cfg.Scopes); scopes != "" { + q.Set("scope", scopes) + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +func defaultFetchFeishuOAuthIdentity(ctx context.Context, cfg feishuOAuthConfig, code string) (*feishuOAuthIdentity, error) { + tokenResp, err := feishuExchangeCode(ctx, cfg, code) + if err != nil { + return nil, err + } + userInfo, err := feishuFetchUserInfo(ctx, cfg, tokenResp.AccessToken) + if err != nil { + return nil, err + } + return &feishuOAuthIdentity{Token: *tokenResp, User: *userInfo}, nil +} + +func feishuExchangeCode(ctx context.Context, cfg feishuOAuthConfig, code string) (*feishuOAuthTokenResponse, error) { + body := map[string]string{ + "grant_type": "authorization_code", + "client_id": strings.TrimSpace(cfg.AppID), + "client_secret": strings.TrimSpace(cfg.AppSecret), + "code": strings.TrimSpace(code), + "redirect_uri": strings.TrimSpace(cfg.RedirectURL), + } + if scopes := strings.TrimSpace(cfg.Scopes); scopes != "" { + body["scope"] = scopes + } + payload, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(cfg.TokenURL), bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("feishu token endpoint status %d: %s", resp.StatusCode, truncateLogValue(string(raw), 1024)) + } + var out feishuOAuthTokenResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + if out.Code != 0 { + return nil, fmt.Errorf("feishu token error code=%d msg=%s", out.Code, out.Msg) + } + if strings.TrimSpace(out.AccessToken) == "" { + return nil, fmt.Errorf("feishu token response missing access_token") + } + return &out, nil +} + +func feishuFetchUserInfo(ctx context.Context, cfg feishuOAuthConfig, accessToken string) (*feishuUserInfo, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(cfg.UserInfoURL), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken)) + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("feishu userinfo endpoint status %d: %s", resp.StatusCode, truncateLogValue(string(raw), 1024)) + } + var out feishuUserInfoResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + if out.Code != 0 { + return nil, fmt.Errorf("feishu userinfo error code=%d msg=%s", out.Code, out.Msg) + } + return &out.Data, nil +} + +func feishuProviderSubject(user feishuUserInfo) string { + return strings.TrimSpace(user.OpenID) +} + +func feishuPrimaryEmail(user feishuUserInfo) string { + return strings.TrimSpace(strings.ToLower(firstNonEmpty(user.EnterpriseEmail, user.Email))) +} + +func buildFeishuSyntheticEmail(subject string) string { + return "feishu-" + strings.ToLower(strings.TrimSpace(subject)) + service.FeishuConnectSyntheticEmailDomain +} + +func feishuFallbackUsername(subject string) string { + subject = strings.TrimSpace(subject) + if subject == "" { + return "feishu_user" + } + if len([]rune(subject)) > 32 { + return "feishu_" + string([]rune(subject)[:32]) + } + return "feishu_" + subject +} + +func buildFeishuUpstreamClaims(identity *feishuOAuthIdentity, resolvedEmail string, syntheticEmail string, username string) map[string]any { + user := feishuUserInfo{} + scope := "" + if identity != nil { + user = identity.User + scope = identity.Token.Scope + } + claims := map[string]any{ + "email": strings.TrimSpace(resolvedEmail), + "username": strings.TrimSpace(username), + "subject": feishuProviderSubject(user), + "union_id": strings.TrimSpace(user.UnionID), + "open_id": strings.TrimSpace(user.OpenID), + "user_id": strings.TrimSpace(user.UserID), + "tenant_key": strings.TrimSpace(user.TenantKey), + "employee_no": strings.TrimSpace(user.EmployeeNo), + "scope": strings.TrimSpace(scope), + "suggested_display_name": firstNonEmpty(user.Name, user.EnName, username), + "suggested_avatar_url": firstNonEmpty(user.AvatarURL, user.AvatarThumb, user.AvatarMiddle, user.AvatarBig), + } + if syntheticEmail != "" && !strings.EqualFold(strings.TrimSpace(syntheticEmail), strings.TrimSpace(resolvedEmail)) { + claims["synthetic_email"] = strings.TrimSpace(syntheticEmail) + } + return claims +} + +func checkFeishuTenantAllowed(cfg config.FeishuConnectConfig, tenantKey string) bool { + if cfg.TenantRestrictionPolicy != service.FeishuTenantRestrictionInternalOnly { + return true + } + allowedTenant := strings.TrimSpace(cfg.AllowedTenantKey) + if allowedTenant == "" { + return true + } + return strings.EqualFold(allowedTenant, strings.TrimSpace(tenantKey)) +} + +func (h *AuthHandler) isFeishuSignupBlocked(ctx context.Context, cfg config.FeishuConnectConfig) bool { + if h.settingSvc == nil { + return false + } + if h.settingSvc.IsRegistrationEnabled(ctx) { + return false + } + if cfg.BypassRegistration && cfg.TenantRestrictionPolicy == service.FeishuTenantRestrictionInternalOnly { + return false + } + return true +} + +func (h *AuthHandler) findFeishuCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) { + client := h.entClient() + if client == nil { + return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready") + } + email = strings.TrimSpace(strings.ToLower(email)) + if email == "" || + strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) || + strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) || + strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) || + strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) || + strings.HasSuffix(email, service.FeishuConnectSyntheticEmailDomain) { + return nil, nil + } + userEntity, err := findUserByNormalizedEmail(ctx, client, email) + if err != nil { + if errors.Is(err, service.ErrUserNotFound) { + return nil, nil + } + return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err) + } + return userEntity, nil +} + +func (h *AuthHandler) createFeishuOAuthChoicePendingSession( + c *gin.Context, + identity service.PendingAuthIdentityKey, + suggestedEmail string, + resolvedEmail string, + redirectTo string, + browserSessionKey string, + upstreamClaims map[string]any, + compatEmail string, + compatEmailUser *dbent.User, + forceEmailOnSignup bool, +) error { + suggestionEmail := strings.TrimSpace(suggestedEmail) + canonicalEmail := strings.TrimSpace(resolvedEmail) + if suggestionEmail == "" { + suggestionEmail = canonicalEmail + } + completionResponse := map[string]any{ + "step": oauthPendingChoiceStep, + "adoption_required": true, + "redirect": strings.TrimSpace(redirectTo), + "email": suggestionEmail, + "resolved_email": canonicalEmail, + "existing_account_email": "", + "existing_account_bindable": false, + "create_account_allowed": true, + "force_email_on_signup": forceEmailOnSignup, + "choice_reason": "third_party_signup", + } + if strings.TrimSpace(compatEmail) != "" { + completionResponse["compat_email"] = strings.TrimSpace(compatEmail) + } + if compatEmailUser != nil { + completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email) + completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email) + completionResponse["existing_account_bindable"] = true + completionResponse["choice_reason"] = "compat_email_match" + } + if forceEmailOnSignup && compatEmailUser == nil { + completionResponse["choice_reason"] = "force_email_on_signup" + } + resolvedChoiceEmail := suggestionEmail + if compatEmailUser != nil { + resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email) + } + var targetUserID *int64 + if compatEmailUser != nil && compatEmailUser.ID > 0 { + targetUserID = &compatEmailUser.ID + } + return h.createOAuthPendingSession(c, oauthPendingSessionPayload{ + Intent: oauthIntentLogin, + Identity: identity, + TargetUserID: targetUserID, + ResolvedEmail: resolvedChoiceEmail, + RedirectTo: redirectTo, + BrowserSessionKey: browserSessionKey, + UpstreamIdentityClaims: upstreamClaims, + CompletionResponse: completionResponse, + }) +} diff --git a/backend/internal/handler/auth_feishu_oauth_test.go b/backend/internal/handler/auth_feishu_oauth_test.go new file mode 100644 index 00000000000..3ed58b0588c --- /dev/null +++ b/backend/internal/handler/auth_feishu_oauth_test.go @@ -0,0 +1,397 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/authidentity" + "github.com/Wei-Shaw/sub2api/ent/pendingauthsession" + dbuser "github.com/Wei-Shaw/sub2api/ent/user" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + entsql "entgo.io/ent/dialect/sql" +) + +func newFeishuOAuthTestHandler(t *testing.T, extraSettings map[string]string) (*AuthHandler, *dbent.Client) { + t.Helper() + + values := map[string]string{ + service.SettingKeyFeishuConnectEnabled: "true", + service.SettingKeyFeishuConnectAppID: "cli_test", + service.SettingKeyFeishuConnectAppSecret: "secret_test", + service.SettingKeyFeishuConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/feishu/callback", + service.SettingKeyFeishuConnectTenantRestrictionPolicy: "internal_only", + service.SettingKeyFeishuConnectAllowedTenantKey: "tenant_test", + } + for key, value := range extraSettings { + values[key] = value + } + return newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + settingValues: values, + }) +} + +func TestFeishuOAuthStartRedirectsWithClientIDAndScope(t *testing.T) { + handler, client := newFeishuOAuthTestHandler(t, nil) + t.Cleanup(func() { _ = client.Close() }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/feishu/start?redirect=/dashboard", nil) + + handler.FeishuOAuthStart(c) + + require.Equal(t, http.StatusFound, recorder.Code) + location := recorder.Header().Get("Location") + parsed, err := url.Parse(location) + require.NoError(t, err) + require.Equal(t, "accounts.feishu.cn", parsed.Host) + require.Equal(t, "cli_test", parsed.Query().Get("client_id")) + require.Equal(t, "code", parsed.Query().Get("response_type")) + require.Equal(t, "https://api.example.com/api/v1/auth/oauth/feishu/callback", parsed.Query().Get("redirect_uri")) + require.Contains(t, parsed.Query().Get("scope"), "contact:user.base:readonly") + require.NotEmpty(t, parsed.Query().Get("state")) + + require.NotNil(t, findCookie(recorder.Result().Cookies(), feishuOAuthStateCookieName)) + require.NotNil(t, findCookie(recorder.Result().Cookies(), feishuOAuthRedirectCookieName)) + require.NotNil(t, findCookie(recorder.Result().Cookies(), feishuOAuthIntentCookieName)) + require.NotNil(t, findCookie(recorder.Result().Cookies(), oauthPendingBrowserCookieName)) +} + +func TestFeishuOAuthCallbackUsesExistingOpenIDIdentityWithoutEmail(t *testing.T) { + originalFetch := fetchFeishuOAuthIdentity + fetchFeishuOAuthIdentity = func(ctx context.Context, cfg feishuOAuthConfig, code string) (*feishuOAuthIdentity, error) { + return &feishuOAuthIdentity{ + Token: feishuOAuthTokenResponse{ + Scope: "contact:user.base:readonly contact:user.email:readonly", + }, + User: feishuUserInfo{ + Name: "曹辰旭", + OpenID: "ou_open_id", + UnionID: "on_union_id", + UserID: "feishu_user_id", + TenantKey: "tenant_test", + }, + }, nil + } + t.Cleanup(func() { fetchFeishuOAuthIdentity = originalFetch }) + + handler, client := newFeishuOAuthTestHandler(t, nil) + t.Cleanup(func() { _ = client.Close() }) + + ctx := context.Background() + existingUser, err := client.User.Create(). + SetEmail("cao@example.com"). + SetUsername("曹辰旭"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + Save(ctx) + require.NoError(t, err) + _, err = client.AuthIdentity.Create(). + SetUserID(existingUser.ID). + SetProviderType("feishu"). + SetProviderKey("tenant_test"). + SetProviderSubject("ou_open_id"). + SetMetadata(map[string]any{"open_id": "ou_open_id"}). + Save(ctx) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/feishu/callback?code=feishu-code&state=state-123", nil) + req.AddCookie(encodedCookie(feishuOAuthStateCookieName, "state-123")) + req.AddCookie(encodedCookie(feishuOAuthRedirectCookieName, "/dashboard")) + req.AddCookie(encodedCookie(feishuOAuthIntentCookieName, oauthIntentLogin)) + req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123")) + c.Request = req + + handler.FeishuOAuthCallback(c) + + require.Equal(t, http.StatusFound, recorder.Code) + require.Equal(t, "/auth/feishu/callback", recorder.Header().Get("Location")) + + sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName) + require.NotNil(t, sessionCookie) + session, err := client.PendingAuthSession.Query(). + Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))). + Only(ctx) + require.NoError(t, err) + require.Equal(t, oauthIntentLogin, session.Intent) + require.Equal(t, "feishu", session.ProviderType) + require.Equal(t, "tenant_test", session.ProviderKey) + require.Equal(t, "ou_open_id", session.ProviderSubject) + require.NotNil(t, session.TargetUserID) + require.Equal(t, existingUser.ID, *session.TargetUserID) + require.Equal(t, existingUser.Email, session.ResolvedEmail) + require.Equal(t, "ou_open_id", session.UpstreamIdentityClaims["open_id"]) + require.Equal(t, "ou_open_id", session.UpstreamIdentityClaims["subject"]) + require.Equal(t, "feishu_user_id", session.UpstreamIdentityClaims["user_id"]) + + completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any) + require.True(t, ok) + require.Equal(t, "/dashboard", completion["redirect"]) + require.Nil(t, completion["access_token"]) + require.Nil(t, completion["refresh_token"]) +} + +func TestFeishuOAuthCallbackAutoCreatesSyntheticUserWithFeishuSource(t *testing.T) { + originalFetch := fetchFeishuOAuthIdentity + fetchFeishuOAuthIdentity = func(ctx context.Context, cfg feishuOAuthConfig, code string) (*feishuOAuthIdentity, error) { + return &feishuOAuthIdentity{ + Token: feishuOAuthTokenResponse{Scope: "contact:user.base:readonly"}, + User: feishuUserInfo{ + Name: "新员工", + OpenID: "ou_new_open", + UnionID: "on_new_union", + UserID: "feishu_new_user", + TenantKey: "tenant_test", + }, + }, nil + } + t.Cleanup(func() { fetchFeishuOAuthIdentity = originalFetch }) + + handler, client := newFeishuOAuthTestHandler(t, nil) + t.Cleanup(func() { _ = client.Close() }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/feishu/callback?code=feishu-code&state=state-new", nil) + req.AddCookie(encodedCookie(feishuOAuthStateCookieName, "state-new")) + req.AddCookie(encodedCookie(feishuOAuthRedirectCookieName, "/dashboard")) + req.AddCookie(encodedCookie(feishuOAuthIntentCookieName, oauthIntentLogin)) + req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-new")) + c.Request = req + + handler.FeishuOAuthCallback(c) + + require.Equal(t, http.StatusFound, recorder.Code) + fragment := parseOAuthRedirectFragment(t, recorder.Header().Get("Location")) + require.NotEmpty(t, fragment.Get("access_token")) + require.NotEmpty(t, fragment.Get("refresh_token")) + require.Equal(t, "/dashboard", fragment.Get("redirect")) + + ctx := context.Background() + createdUser, err := client.User.Query(). + Where(dbuser.EmailEQ("feishu-ou_new_open@feishu-connect.invalid")). + Only(ctx) + require.NoError(t, err) + require.Equal(t, "feishu", createdUser.SignupSource) + + identity, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("feishu"), + authidentity.ProviderKeyEQ("tenant_test"), + authidentity.ProviderSubjectEQ("ou_new_open"), + ). + Only(ctx) + require.NoError(t, err) + require.Equal(t, createdUser.ID, identity.UserID) + require.Equal(t, "ou_new_open", identity.Metadata["open_id"]) +} + +func TestFeishuOAuthCallbackBindsExistingOrgMirrorAfterAutoCreate(t *testing.T) { + originalFetch := fetchFeishuOAuthIdentity + fetchFeishuOAuthIdentity = func(ctx context.Context, cfg feishuOAuthConfig, code string) (*feishuOAuthIdentity, error) { + return &feishuOAuthIdentity{ + Token: feishuOAuthTokenResponse{Scope: "contact:user.base:readonly"}, + User: feishuUserInfo{ + Name: "田晶晶", + OpenID: "ou_existing_org_user", + UnionID: "on_existing_org_user", + UserID: "feishu_existing_org_user", + TenantKey: "tenant_test", + }, + }, nil + } + t.Cleanup(func() { fetchFeishuOAuthIdentity = originalFetch }) + + handler, client := newFeishuOAuthTestHandler(t, nil) + t.Cleanup(func() { _ = client.Close() }) + createFeishuOrgMirrorTablesForTest(t, client) + insertFeishuOrgMirrorUserForTest(t, client, "tenant_test", "ou_existing_org_user", "on_existing_org_user", "feishu_existing_org_user", "od-tech") + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/feishu/callback?code=feishu-code&state=state-existing-org", nil) + req.AddCookie(encodedCookie(feishuOAuthStateCookieName, "state-existing-org")) + req.AddCookie(encodedCookie(feishuOAuthRedirectCookieName, "/dashboard")) + req.AddCookie(encodedCookie(feishuOAuthIntentCookieName, oauthIntentLogin)) + req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-existing-org")) + c.Request = req + + handler.FeishuOAuthCallback(c) + + require.Equal(t, http.StatusFound, recorder.Code) + fragment := parseOAuthRedirectFragment(t, recorder.Header().Get("Location")) + require.NotEmpty(t, fragment.Get("access_token")) + + ctx := context.Background() + createdUser, err := client.User.Query(). + Where(dbuser.EmailEQ("feishu-ou_existing_org_user@feishu-connect.invalid")). + Only(ctx) + require.NoError(t, err) + require.Equal(t, createdUser.ID, loadFeishuOrgMirrorUserIDForTest(t, client, "tenant_test", "ou_existing_org_user")) + require.Equal(t, createdUser.ID, loadFeishuOrgMirrorDepartmentUserIDForTest(t, client, "tenant_test", "ou_existing_org_user", "od-tech")) +} + +func createFeishuOrgMirrorTablesForTest(t *testing.T, client *dbent.Client) { + t.Helper() + + execFeishuOAuthTestSQL(t, client, ` +CREATE TABLE IF NOT EXISTS feishu_org_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NULL, + tenant_key TEXT NOT NULL DEFAULT '', + open_id TEXT NOT NULL, + union_id TEXT NOT NULL DEFAULT '', + feishu_user_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + employee_no TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + primary_open_department_id TEXT NOT NULL DEFAULT '', + manager_open_id TEXT NOT NULL DEFAULT '', + department_open_ids TEXT NOT NULL DEFAULT '[]', + raw TEXT NOT NULL DEFAULT '{}', + last_synced_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(tenant_key, open_id) +)`) + execFeishuOAuthTestSQL(t, client, ` +CREATE TABLE IF NOT EXISTS feishu_user_departments ( + tenant_key TEXT NOT NULL DEFAULT '', + open_id TEXT NOT NULL, + open_department_id TEXT NOT NULL, + user_id INTEGER NULL, + is_primary BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (tenant_key, open_id, open_department_id) +)`) +} + +func insertFeishuOrgMirrorUserForTest(t *testing.T, client *dbent.Client, tenantKey string, openID string, unionID string, feishuUserID string, departmentID string) { + t.Helper() + + execFeishuOAuthTestSQL(t, client, ` +INSERT INTO feishu_org_users ( + user_id, tenant_key, open_id, union_id, feishu_user_id, name, status, + primary_open_department_id, department_open_ids, raw, last_synced_at, updated_at +) VALUES ( + NULL, ?, ?, ?, ?, '田晶晶', 'active', ?, '["' || ? || '"]', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +) +ON CONFLICT(tenant_key, open_id) DO UPDATE SET + user_id = NULL, + union_id = excluded.union_id, + feishu_user_id = excluded.feishu_user_id, + primary_open_department_id = excluded.primary_open_department_id, + department_open_ids = excluded.department_open_ids, + updated_at = CURRENT_TIMESTAMP`, tenantKey, openID, unionID, feishuUserID, departmentID, departmentID) + execFeishuOAuthTestSQL(t, client, ` +INSERT INTO feishu_user_departments ( + tenant_key, open_id, open_department_id, user_id, is_primary, updated_at +) VALUES (?, ?, ?, NULL, true, CURRENT_TIMESTAMP) +ON CONFLICT(tenant_key, open_id, open_department_id) DO UPDATE SET + user_id = NULL, + is_primary = excluded.is_primary, + updated_at = CURRENT_TIMESTAMP`, tenantKey, openID, departmentID) +} + +func execFeishuOAuthTestSQL(t *testing.T, client *dbent.Client, query string, args ...any) { + t.Helper() + + var result entsql.Result + require.NoError(t, client.Driver().Exec(context.Background(), query, args, &result)) +} + +func loadFeishuOrgMirrorUserIDForTest(t *testing.T, client *dbent.Client, tenantKey string, openID string) int64 { + t.Helper() + + return loadFeishuOrgMirrorIDForTest(t, client, `SELECT COALESCE(user_id, 0) FROM feishu_org_users WHERE tenant_key = ? AND open_id = ?`, tenantKey, openID) +} + +func loadFeishuOrgMirrorDepartmentUserIDForTest(t *testing.T, client *dbent.Client, tenantKey string, openID string, departmentID string) int64 { + t.Helper() + + return loadFeishuOrgMirrorIDForTest(t, client, `SELECT COALESCE(user_id, 0) FROM feishu_user_departments WHERE tenant_key = ? AND open_id = ? AND open_department_id = ?`, tenantKey, openID, departmentID) +} + +func loadFeishuOrgMirrorIDForTest(t *testing.T, client *dbent.Client, query string, args ...any) int64 { + t.Helper() + + var rows entsql.Rows + require.NoError(t, client.Driver().Query(context.Background(), query, args, &rows)) + defer func() { _ = rows.Close() }() + require.True(t, rows.Next()) + var id int64 + require.NoError(t, rows.Scan(&id)) + require.NoError(t, rows.Err()) + return id +} + +func TestFeishuOAuthCallbackRejectsUnboundExistingEmail(t *testing.T) { + originalFetch := fetchFeishuOAuthIdentity + fetchFeishuOAuthIdentity = func(ctx context.Context, cfg feishuOAuthConfig, code string) (*feishuOAuthIdentity, error) { + return &feishuOAuthIdentity{ + Token: feishuOAuthTokenResponse{Scope: "contact:user.base:readonly contact:user.email:readonly"}, + User: feishuUserInfo{ + Name: "已有员工", + OpenID: "ou_unbound_open", + UnionID: "on_unbound_union", + UserID: "feishu_unbound_user", + TenantKey: "tenant_test", + EnterpriseEmail: "existing@example.com", + }, + }, nil + } + t.Cleanup(func() { fetchFeishuOAuthIdentity = originalFetch }) + + handler, client := newFeishuOAuthTestHandler(t, nil) + t.Cleanup(func() { _ = client.Close() }) + + ctx := context.Background() + _, err := client.User.Create(). + SetEmail("existing@example.com"). + SetUsername("existing"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + Save(ctx) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/feishu/callback?code=feishu-code&state=state-existing", nil) + req.AddCookie(encodedCookie(feishuOAuthStateCookieName, "state-existing")) + req.AddCookie(encodedCookie(feishuOAuthRedirectCookieName, "/dashboard")) + req.AddCookie(encodedCookie(feishuOAuthIntentCookieName, oauthIntentLogin)) + req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-existing")) + c.Request = req + + handler.FeishuOAuthCallback(c) + + require.Equal(t, http.StatusFound, recorder.Code) + fragment := parseOAuthRedirectFragment(t, recorder.Header().Get("Location")) + require.Equal(t, "identity_unbound", fragment.Get("error")) + require.Contains(t, fragment.Get("error_message"), "feishu identity is not bound") + require.Empty(t, fragment.Get("access_token")) + require.Nil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)) + + count, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("feishu"), + authidentity.ProviderKeyEQ("tenant_test"), + authidentity.ProviderSubjectEQ("ou_unbound_open"), + ). + Count(ctx) + require.NoError(t, err) + require.Zero(t, count) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 592a0d82d6b..e19d467923d 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -138,6 +138,34 @@ func (h *AuthHandler) ensureBackendModeAllowsUser(ctx context.Context, user *ser return infraerrors.Forbidden("BACKEND_MODE_ADMIN_ONLY", "Backend mode is active. Only admin login is allowed.") } +func (h *AuthHandler) ensureEmailPasswordLoginAllowsUser(ctx context.Context, user *service.User) error { + if user == nil { + return infraerrors.Unauthorized("INVALID_USER", "user not found") + } + if h == nil || h.settingSvc == nil { + return nil + } + settings, err := h.settingSvc.GetPublicSettings(ctx) + if err != nil || settings == nil || settings.EmailPasswordLoginEnabled { + return nil + } + if settings.AdminEmailLoginFallbackEnabled && user.IsAdmin() { + return nil + } + return infraerrors.Forbidden("EMAIL_PASSWORD_LOGIN_DISABLED", "Email password login is disabled.") +} + +func (h *AuthHandler) ensureEmailPasswordRegistrationAllowed(ctx context.Context) error { + if h == nil || h.settingSvc == nil { + return nil + } + settings, err := h.settingSvc.GetPublicSettings(ctx) + if err != nil || settings == nil || settings.EmailPasswordLoginEnabled { + return nil + } + return infraerrors.Forbidden("EMAIL_PASSWORD_LOGIN_DISABLED", "Email password registration is disabled.") +} + func (h *AuthHandler) ensureBackendModeAllowsNewUserLogin(ctx context.Context) error { if h == nil || !h.isBackendModeEnabled(ctx) { return nil @@ -164,6 +192,10 @@ func (h *AuthHandler) Register(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } + if err := h.ensureEmailPasswordRegistrationAllowed(c.Request.Context()); err != nil { + response.ErrorFrom(c, err) + return + } // Turnstile 验证(邮箱验证码注册场景避免重复校验一次性 token) if err := h.authService.VerifyTurnstileForRegister(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c), req.VerifyCode); err != nil { @@ -196,6 +228,10 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } + if err := h.ensureEmailPasswordRegistrationAllowed(c.Request.Context()); err != nil { + response.ErrorFrom(c, err) + return + } // Turnstile 验证 if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { @@ -237,6 +273,11 @@ func (h *AuthHandler) Login(c *gin.Context) { } _ = token // token 由 authService.Login 返回但此处由 respondWithTokenPair 重新生成 + if err := h.ensureEmailPasswordLoginAllowsUser(c.Request.Context(), user); err != nil { + response.ErrorFrom(c, err) + return + } + if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 79e9f0a80bb..0ce58ccbbf3 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -23,6 +23,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" + entdialect "entgo.io/ent/dialect" entsql "entgo.io/ent/dialect/sql" "github.com/gin-gonic/gin" ) @@ -1233,6 +1234,9 @@ func applyPendingOAuthBindingTx( if _, err := updateIdentity.Save(ctx); err != nil { return err } + if err := bindFeishuOrgMirrorUserTx(ctx, tx, session, targetUserID); err != nil { + return err + } if decision != nil && (decision.IdentityID == nil || *decision.IdentityID != identity.ID) { if _, err := tx.Client().IdentityAdoptionDecision.Update(). @@ -1266,6 +1270,115 @@ func applyPendingOAuthBindingTx( return nil } +func bindFeishuOrgMirrorUserTx(ctx context.Context, tx *dbent.Tx, session *dbent.PendingAuthSession, userID int64) error { + if tx == nil || session == nil || userID <= 0 || !strings.EqualFold(strings.TrimSpace(session.ProviderType), "feishu") { + return nil + } + tenantKey := firstNonEmpty(session.ProviderKey, pendingSessionStringValue(session.UpstreamIdentityClaims, "tenant_key")) + openID := firstNonEmpty(pendingSessionStringValue(session.UpstreamIdentityClaims, "open_id"), session.ProviderSubject) + if tenantKey == "" || openID == "" { + return nil + } + unionID := pendingSessionStringValue(session.UpstreamIdentityClaims, "union_id") + feishuUserID := pendingSessionStringValue(session.UpstreamIdentityClaims, "user_id") + + driver := tx.Client().Driver() + dialectName := driver.Dialect() + placeholder := func(index int) string { + if dialectName == entdialect.Postgres { + return fmt.Sprintf("$%d", index) + } + return "?" + } + + orgUserSQL := fmt.Sprintf(` +UPDATE feishu_org_users +SET user_id = %s, + updated_at = CURRENT_TIMESTAMP +WHERE tenant_key = %s + AND ( + open_id = %s + OR (%s <> '' AND union_id = %s) + OR (%s <> '' AND feishu_user_id = %s) + ) + AND (user_id IS NULL OR user_id = %s)`, + placeholder(1), placeholder(2), placeholder(3), placeholder(4), placeholder(5), placeholder(6), placeholder(7), placeholder(8)) + if err := execOAuthRawSQL(ctx, driver, orgUserSQL, userID, tenantKey, openID, unionID, unionID, feishuUserID, feishuUserID, userID); err != nil { + if isMissingFeishuOrgMirrorTableError(err) { + return nil + } + return err + } + + departmentSQL := fmt.Sprintf(` +UPDATE feishu_user_departments +SET user_id = %s, + updated_at = CURRENT_TIMESTAMP +WHERE tenant_key = %s + AND open_id = %s + AND (user_id IS NULL OR user_id = %s) + AND EXISTS ( + SELECT 1 + FROM feishu_org_users fu + WHERE fu.tenant_key = feishu_user_departments.tenant_key + AND fu.open_id = feishu_user_departments.open_id + AND fu.user_id = %s + )`, + placeholder(1), placeholder(2), placeholder(3), placeholder(4), placeholder(5)) + if err := execOAuthRawSQL(ctx, driver, departmentSQL, userID, tenantKey, openID, userID, userID); err != nil { + if isMissingFeishuOrgMirrorTableError(err) { + return nil + } + return err + } + + managerSQL := fmt.Sprintf(` +UPDATE feishu_department_managers +SET manager_user_id = %s, + updated_at = CURRENT_TIMESTAMP +WHERE tenant_key = %s + AND manager_open_id = %s + AND (manager_user_id IS NULL OR manager_user_id = %s) + AND EXISTS ( + SELECT 1 + FROM feishu_org_users fu + WHERE fu.tenant_key = feishu_department_managers.tenant_key + AND fu.open_id = feishu_department_managers.manager_open_id + AND fu.user_id = %s + )`, + placeholder(1), placeholder(2), placeholder(3), placeholder(4), placeholder(5)) + if err := execOAuthRawSQL(ctx, driver, managerSQL, userID, tenantKey, openID, userID, userID); err != nil { + if isMissingFeishuOrgMirrorTableError(err) { + return nil + } + return err + } + + return nil +} + +func execOAuthRawSQL(ctx context.Context, driver dialectDriver, query string, args ...any) error { + var result entsql.Result + return driver.Exec(ctx, query, args, &result) +} + +type dialectDriver interface { + Dialect() string + Exec(context.Context, string, any, any) error +} + +func isMissingFeishuOrgMirrorTableError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "feishu_") && + (strings.Contains(msg, "no such table") || + strings.Contains(msg, "does not exist") || + strings.Contains(msg, "unknown table") || + strings.Contains(msg, "1146")) +} + func consumePendingOAuthBrowserSessionTx( ctx context.Context, tx *dbent.Tx, @@ -1640,6 +1753,10 @@ func (h *AuthHandler) bindPendingOAuthLogin(c *gin.Context, provider string) { response.ErrorFrom(c, infraerrors.Conflict("PENDING_AUTH_TARGET_USER_MISMATCH", "pending oauth session must be completed by the targeted user")) return } + if err := h.ensureEmailPasswordLoginAllowsUser(c.Request.Context(), user); err != nil { + response.ErrorFrom(c, err) + return + } if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 1d6f73bb990..21876f15622 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -40,6 +40,8 @@ type SystemSettings struct { LoginAgreementMode string `json:"login_agreement_mode"` LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + EmailPasswordLoginEnabled bool `json:"email_password_login_enabled"` + AdminEmailLoginFallbackEnabled bool `json:"admin_email_login_fallback_enabled"` SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` @@ -76,6 +78,21 @@ type SystemSettings struct { DingTalkConnectSyncDisplayNameAttrName string `json:"dingtalk_connect_sync_display_name_attr_name"` DingTalkConnectSyncDeptAttrName string `json:"dingtalk_connect_sync_dept_attr_name"` + FeishuConnectEnabled bool `json:"feishu_connect_enabled"` + FeishuConnectAppID string `json:"feishu_connect_app_id"` + FeishuConnectAppSecretConfigured bool `json:"feishu_connect_app_secret_configured"` + FeishuConnectRedirectURL string `json:"feishu_connect_redirect_url"` + FeishuConnectTenantRestrictionPolicy string `json:"feishu_connect_tenant_restriction_policy"` + FeishuConnectAllowedTenantKey string `json:"feishu_connect_allowed_tenant_key"` + FeishuConnectBypassRegistration bool `json:"feishu_connect_bypass_registration"` + FeishuConnectSyncEmail bool `json:"feishu_connect_sync_email"` + FeishuConnectSyncDisplayName bool `json:"feishu_connect_sync_display_name"` + FeishuConnectSyncDepartment bool `json:"feishu_connect_sync_department"` + FeishuOrgSyncEnabled bool `json:"feishu_org_sync_enabled"` + FeishuDepartedUserAction string `json:"feishu_departed_user_action"` + FeishuSyncDisableThresholdCount int `json:"feishu_sync_disable_threshold_count"` + FeishuSyncDisableThresholdPercent int `json:"feishu_sync_disable_threshold_percent"` + WeChatConnectEnabled bool `json:"wechat_connect_enabled"` WeChatConnectAppID string `json:"wechat_connect_app_id"` WeChatConnectAppSecretConfigured bool `json:"wechat_connect_app_secret_configured"` @@ -307,7 +324,11 @@ type PublicSettings struct { TablePageSizeOptions []int `json:"table_page_size_options"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + EmailPasswordLoginEnabled bool `json:"email_password_login_enabled"` + AdminEmailLoginFallbackEnabled bool `json:"admin_email_login_fallback_enabled"` DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` + FeishuOAuthEnabled bool `json:"feishu_oauth_enabled"` + FeishuOrgSyncEnabled bool `json:"feishu_org_sync_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` diff --git a/backend/internal/handler/feishu_org_handler.go b/backend/internal/handler/feishu_org_handler.go new file mode 100644 index 00000000000..49da97f576d --- /dev/null +++ b/backend/internal/handler/feishu_org_handler.go @@ -0,0 +1,618 @@ +package handler + +import ( + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type FeishuOrgHandler struct { + orgService *service.FeishuOrgPermissionService + settingSvc *service.SettingService + usageService *service.UsageService +} + +func NewFeishuOrgHandler(orgService *service.FeishuOrgPermissionService, settingSvc *service.SettingService, usageService *service.UsageService) *FeishuOrgHandler { + return &FeishuOrgHandler{orgService: orgService, settingSvc: settingSvc, usageService: usageService} +} + +type feishuSetUserGroupsRequest struct { + GroupIDs []int64 `json:"group_ids"` + Reason string `json:"reason"` +} + +type feishuSetDepartmentGroupsRequest struct { + TenantKey string `json:"tenant_key"` + GroupIDs []int64 `json:"group_ids"` + Reason string `json:"reason"` +} + +func (h *FeishuOrgHandler) ListDepartments(c *gin.Context) { + if h == nil || h.orgService == nil { + response.InternalError(c, "Feishu organization permission service is not configured") + return + } + result, err := h.orgService.ListDepartments(c.Request.Context(), parseFeishuOrgListInput(c)) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *FeishuOrgHandler) ListUsers(c *gin.Context) { + if h == nil || h.orgService == nil { + response.InternalError(c, "Feishu organization permission service is not configured") + return + } + result, err := h.orgService.ListUsers(c.Request.Context(), parseFeishuOrgListInput(c)) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *FeishuOrgHandler) ListSyncRuns(c *gin.Context) { + if h == nil || h.orgService == nil { + response.InternalError(c, "Feishu organization permission service is not configured") + return + } + result, err := h.orgService.ListSyncRuns(c.Request.Context(), parseFeishuOrgListInput(c)) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *FeishuOrgHandler) RunManualReconcile(c *gin.Context) { + if h == nil || h.orgService == nil { + response.InternalError(c, "Feishu organization permission service is not configured") + return + } + if h.settingSvc == nil { + response.InternalError(c, "Feishu settings service is not configured") + return + } + subject, _ := middleware.GetAuthSubjectFromContext(c) + cfg, err := h.settingSvc.GetFeishuConnectOAuthConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + policy := service.FeishuDeparturePolicy{ + Action: cfg.DepartedUserAction, + ThresholdCount: cfg.DisableThresholdCount, + ThresholdPercent: cfg.DisableThresholdPercent, + } + result, err := h.orgService.RunFeishuOrgSync(c.Request.Context(), subject.UserID, cfg, policy) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *FeishuOrgHandler) ListManagedUsers(c *gin.Context) { + if h == nil || h.orgService == nil { + response.InternalError(c, "Feishu organization permission service is not configured") + return + } + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + response.Unauthorized(c, "Unauthorized") + return + } + result, err := h.orgService.ListManagerUsers(c.Request.Context(), subject.UserID, parseFeishuOrgListInput(c)) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *FeishuOrgHandler) ManagerAccess(c *gin.Context) { + if h == nil || h.orgService == nil { + response.InternalError(c, "Feishu organization permission service is not configured") + return + } + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + response.Unauthorized(c, "Unauthorized") + return + } + hasAccess, err := h.orgService.HasManagerScope(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{"has_access": hasAccess}) +} + +// SetManagedUserGroupGrants lets a Feishu department manager update the +// department-manager source grants for a user inside their Feishu scope. +func (h *FeishuOrgHandler) SetManagedUserGroupGrants(c *gin.Context) { + if h == nil || h.orgService == nil { + response.InternalError(c, "Feishu organization permission service is not configured") + return + } + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + response.Unauthorized(c, "Unauthorized") + return + } + targetUserID, ok := parseFeishuOrgUserIDParam(c) + if !ok { + return + } + var req feishuSetUserGroupsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.orgService.SetDepartmentManagerUserGroupGrants(c.Request.Context(), service.FeishuDepartmentManagerAssignmentInput{ + ManagerUserID: subject.UserID, + TargetUserID: targetUserID, + GroupIDs: req.GroupIDs, + Reason: req.Reason, + }) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +type feishuManagerUsageFilters struct { + Filters usagestats.UsageLogFilters + StartTime time.Time + EndTime time.Time +} + +func (h *FeishuOrgHandler) ListManagerUsage(c *gin.Context) { + parsed, ok := h.parseManagerUsageFilters(c, false) + if !ok { + return + } + page, pageSize := response.ParsePagination(c) + records, result, err := h.usageService.ListWithFilters(c.Request.Context(), pagination.PaginationParams{ + Page: page, + PageSize: pageSize, + SortBy: c.DefaultQuery("sort_by", "created_at"), + SortOrder: c.DefaultQuery("sort_order", "desc"), + }, parsed.Filters) + if err != nil { + response.ErrorFrom(c, err) + return + } + out := make([]dto.UsageLog, 0, len(records)) + for i := range records { + out = append(out, *dto.UsageLogFromService(&records[i])) + } + response.Paginated(c, out, result.Total, result.Page, result.PageSize) +} + +func (h *FeishuOrgHandler) ManagerUsageStats(c *gin.Context) { + parsed, ok := h.parseManagerUsageFilters(c, true) + if !ok { + return + } + stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), parsed.Filters) + if err != nil { + response.ErrorFrom(c, err) + return + } + stats.TotalAccountCost = nil + stats.UpstreamEndpoints = nil + stats.EndpointPaths = nil + response.Success(c, stats) +} + +func (h *FeishuOrgHandler) ManagerDashboardStats(c *gin.Context) { + parsed, ok := h.parseManagerUsageFilters(c, false) + if !ok { + return + } + ctx := c.Request.Context() + + totalStats, err := h.usageService.GetStatsWithFilters(ctx, parsed.Filters) + if err != nil { + response.ErrorFrom(c, err) + return + } + + userTZ := c.Query("timezone") + now := timezone.NowInUserLocation(userTZ) + todayStart := timezone.StartOfDayInUserLocation(now, userTZ) + todayEnd := timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) + todayFilters := parsed.Filters + todayFilters.StartTime = &todayStart + todayFilters.EndTime = &todayEnd + todayStats, err := h.usageService.GetStatsWithFilters(ctx, todayFilters) + if err != nil { + response.ErrorFrom(c, err) + return + } + + fiveMinutesAgo := now.Add(-5 * time.Minute) + perfFilters := parsed.Filters + perfFilters.StartTime = &fiveMinutesAgo + perfFilters.EndTime = &now + perfStats, err := h.usageService.GetStatsWithFilters(ctx, perfFilters) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, usagestats.UserDashboardStats{ + TotalRequests: totalStats.TotalRequests, + TotalInputTokens: totalStats.TotalInputTokens, + TotalOutputTokens: totalStats.TotalOutputTokens, + TotalCacheCreationTokens: totalStats.TotalCacheCreationTokens, + TotalCacheReadTokens: totalStats.TotalCacheReadTokens, + TotalTokens: totalStats.TotalTokens, + TotalCost: totalStats.TotalCost, + TotalActualCost: totalStats.TotalActualCost, + TodayRequests: todayStats.TotalRequests, + TodayInputTokens: todayStats.TotalInputTokens, + TodayOutputTokens: todayStats.TotalOutputTokens, + TodayCacheCreationTokens: todayStats.TotalCacheCreationTokens, + TodayCacheReadTokens: todayStats.TotalCacheReadTokens, + TodayTokens: todayStats.TotalTokens, + TodayCost: todayStats.TotalCost, + TodayActualCost: todayStats.TotalActualCost, + AverageDurationMs: totalStats.AverageDurationMs, + Rpm: perfStats.TotalRequests / 5, + Tpm: perfStats.TotalTokens / 5, + }) +} + +func (h *FeishuOrgHandler) ManagerUsageTrend(c *gin.Context) { + parsed, ok := h.parseManagerUsageFilters(c, true) + if !ok { + return + } + granularity := c.DefaultQuery("granularity", "day") + trend, err := h.usageService.GetUsageTrendWithFilters(c.Request.Context(), parsed.StartTime, parsed.EndTime, granularity, parsed.Filters) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{ + "trend": trend, + "start_date": parsed.StartTime.Format("2006-01-02"), + "end_date": parsed.EndTime.Add(-24 * time.Hour).Format("2006-01-02"), + "granularity": granularity, + }) +} + +func (h *FeishuOrgHandler) ManagerUsageModels(c *gin.Context) { + parsed, ok := h.parseManagerUsageFilters(c, true) + if !ok { + return + } + modelSource := strings.TrimSpace(c.Query("model_source")) + if modelSource != "" && modelSource != usagestats.ModelSourceRequested { + response.BadRequest(c, "Invalid model_source, manager usage only supports requested") + return + } + stats, err := h.usageService.GetModelStatsWithFiltersBySource(c.Request.Context(), parsed.StartTime, parsed.EndTime, parsed.Filters, usagestats.ModelSourceRequested) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{ + "models": userModelStatsFromUsageStats(stats), + "start_date": parsed.StartTime.Format("2006-01-02"), + "end_date": parsed.EndTime.Add(-24 * time.Hour).Format("2006-01-02"), + }) +} + +func (h *FeishuOrgHandler) ManagerUsageSnapshotV2(c *gin.Context) { + parsed, ok := h.parseManagerUsageFilters(c, true) + if !ok { + return + } + granularity := strings.TrimSpace(c.DefaultQuery("granularity", "day")) + if granularity != "hour" { + granularity = "day" + } + includeTrend, ok := parseBoolQueryWithDefault(c, "include_trend", true) + if !ok { + return + } + includeModels, ok := parseBoolQueryWithDefault(c, "include_model_stats", true) + if !ok { + return + } + includeGroups, ok := parseBoolQueryWithDefault(c, "include_group_stats", true) + if !ok { + return + } + + resp := gin.H{ + "generated_at": time.Now().UTC().Format(time.RFC3339), + "start_date": parsed.StartTime.Format("2006-01-02"), + "end_date": parsed.EndTime.Add(-24 * time.Hour).Format("2006-01-02"), + "granularity": granularity, + } + if includeTrend { + trend, err := h.usageService.GetUsageTrendWithFilters(c.Request.Context(), parsed.StartTime, parsed.EndTime, granularity, parsed.Filters) + if err != nil { + response.ErrorFrom(c, err) + return + } + resp["trend"] = trend + } + if includeModels { + models, err := h.usageService.GetModelStatsWithFiltersBySource(c.Request.Context(), parsed.StartTime, parsed.EndTime, parsed.Filters, usagestats.ModelSourceRequested) + if err != nil { + response.ErrorFrom(c, err) + return + } + resp["models"] = userModelStatsFromUsageStats(models) + } + if includeGroups { + groups, err := h.usageService.GetGroupStatsWithFilters(c.Request.Context(), parsed.StartTime, parsed.EndTime, parsed.Filters) + if err != nil { + response.ErrorFrom(c, err) + return + } + resp["groups"] = userGroupStatsFromUsageStats(groups) + } + response.Success(c, resp) +} + +func (h *FeishuOrgHandler) parseManagerUsageFilters(c *gin.Context, requireRange bool) (*feishuManagerUsageFilters, bool) { + if h == nil || h.orgService == nil || h.usageService == nil { + response.InternalError(c, "Feishu organization usage service is not configured") + return nil, false + } + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + response.Unauthorized(c, "Unauthorized") + return nil, false + } + managedUserIDs, err := h.orgService.ListManagerLocalUserIDs(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return nil, false + } + if userIDStr := strings.TrimSpace(c.Query("user_id")); userIDStr != "" { + userID, err := strconv.ParseInt(userIDStr, 10, 64) + if err != nil || userID <= 0 { + response.BadRequest(c, "Invalid user_id") + return nil, false + } + if !int64SliceContains(managedUserIDs, userID) { + response.Forbidden(c, "Not authorized to access this user's usage records") + return nil, false + } + managedUserIDs = []int64{userID} + } + + var groupID int64 + if groupIDStr := strings.TrimSpace(c.Query("group_id")); groupIDStr != "" { + id, err := strconv.ParseInt(groupIDStr, 10, 64) + if err != nil { + response.BadRequest(c, "Invalid group_id") + return nil, false + } + groupID = id + } + + var requestType *int16 + var stream *bool + if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" { + parsed, err := service.ParseUsageRequestType(requestTypeStr) + if err != nil { + response.BadRequest(c, err.Error()) + return nil, false + } + value := int16(parsed) + requestType = &value + } else if streamStr := strings.TrimSpace(c.Query("stream")); streamStr != "" { + parsed, err := strconv.ParseBool(streamStr) + if err != nil { + response.BadRequest(c, "Invalid stream value, use true or false") + return nil, false + } + stream = &parsed + } + + var billingType *int8 + if billingTypeStr := strings.TrimSpace(c.Query("billing_type")); billingTypeStr != "" { + id, err := strconv.ParseInt(billingTypeStr, 10, 8) + if err != nil { + response.BadRequest(c, "Invalid billing_type") + return nil, false + } + parsed := int8(id) + billingType = &parsed + } + billingMode := strings.TrimSpace(c.Query("billing_mode")) + if billingMode != "" && !service.BillingMode(billingMode).IsValid() { + response.BadRequest(c, "Invalid billing_mode") + return nil, false + } + + userTZ := c.Query("timezone") + now := timezone.NowInUserLocation(userTZ) + startDateStr := strings.TrimSpace(c.Query("start_date")) + endDateStr := strings.TrimSpace(c.Query("end_date")) + var startTime, endTime time.Time + var startPtr, endPtr *time.Time + if startDateStr != "" { + t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) + if err != nil { + response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") + return nil, false + } + startTime = t + startPtr = &startTime + } + if endDateStr != "" { + t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) + if err != nil { + response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") + return nil, false + } + endTime = t.AddDate(0, 0, 1) + endPtr = &endTime + } + if requireRange { + if startPtr == nil { + switch c.DefaultQuery("period", "") { + case "today": + startTime = timezone.StartOfDayInUserLocation(now, userTZ) + case "week": + startTime = now.AddDate(0, 0, -7) + case "month": + startTime = now.AddDate(0, -1, 0) + default: + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) + } + startPtr = &startTime + } + if endPtr == nil { + if strings.TrimSpace(c.Query("period")) != "" { + endTime = now + } else { + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) + } + endPtr = &endTime + } + } + + return &feishuManagerUsageFilters{ + Filters: usagestats.UsageLogFilters{ + UserIDs: managedUserIDs, + GroupID: groupID, + Model: strings.TrimSpace(c.Query("model")), + ModelFilterSource: usagestats.ModelSourceRequested, + RequestType: requestType, + Stream: stream, + BillingType: billingType, + BillingMode: billingMode, + StartTime: startPtr, + EndTime: endPtr, + }, + StartTime: derefTime(startPtr), + EndTime: derefTime(endPtr), + }, true +} + +func int64SliceContains(values []int64, target int64) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +// SetUserOverrideGroupGrants lets a super admin update the super-admin override +// source grants for a user. The admin middleware protects the route. +func (h *FeishuOrgHandler) SetUserOverrideGroupGrants(c *gin.Context) { + if h == nil || h.orgService == nil { + response.InternalError(c, "Feishu organization permission service is not configured") + return + } + subject, _ := middleware.GetAuthSubjectFromContext(c) + targetUserID, ok := parseFeishuOrgUserIDParam(c) + if !ok { + return + } + var req feishuSetUserGroupsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.orgService.SetUserGroupGrants(c.Request.Context(), service.FeishuSetUserGroupGrantsInput{ + ActorUserID: subject.UserID, + TargetUserID: targetUserID, + Source: service.FeishuGrantSourceSuperAdmin, + GroupIDs: req.GroupIDs, + Reason: req.Reason, + }) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +// SetDepartmentGroupPool lets a super admin configure which groups can be +// assigned to employees whose primary Feishu department is this department. +func (h *FeishuOrgHandler) SetDepartmentGroupPool(c *gin.Context) { + if h == nil || h.orgService == nil { + response.InternalError(c, "Feishu organization permission service is not configured") + return + } + subject, _ := middleware.GetAuthSubjectFromContext(c) + deptID := c.Param("department_id") + if deptID == "" { + response.BadRequest(c, "Invalid department ID") + return + } + var req feishuSetDepartmentGroupsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.orgService.SetDepartmentGroupPool(c.Request.Context(), service.FeishuSetDepartmentGroupPoolInput{ + ActorUserID: subject.UserID, + TenantKey: req.TenantKey, + OpenDepartmentID: deptID, + GroupIDs: req.GroupIDs, + Reason: req.Reason, + }) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func parseFeishuOrgUserIDParam(c *gin.Context) (int64, bool) { + raw := c.Param("id") + if raw == "" { + raw = c.Param("user_id") + } + userID, err := strconv.ParseInt(raw, 10, 64) + if err != nil || userID <= 0 { + response.BadRequest(c, "Invalid user ID") + return 0, false + } + return userID, true +} + +func parseFeishuOrgListInput(c *gin.Context) service.FeishuOrgListInput { + return service.FeishuOrgListInput{ + TenantKey: c.Query("tenant_key"), + Query: c.Query("q"), + Limit: parseFeishuOrgIntQuery(c.Query("limit")), + Offset: parseFeishuOrgIntQuery(c.Query("offset")), + } +} + +func parseFeishuOrgIntQuery(raw string) int { + value, err := strconv.Atoi(raw) + if err != nil { + return 0 + } + return value +} diff --git a/backend/internal/handler/feishu_org_handler_usage_test.go b/backend/internal/handler/feishu_org_handler_usage_test.go new file mode 100644 index 00000000000..084708f6ad6 --- /dev/null +++ b/backend/internal/handler/feishu_org_handler_usage_test.go @@ -0,0 +1,149 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestFeishuOrgHandlerManagerUsageStatsScopesToManagedLocalUsers(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery(regexp.QuoteMeta("WITH RECURSIVE manager_roots")). + WithArgs(int64(7)). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}). + AddRow(int64(42)). + AddRow(int64(99))) + + repo := &userUsageRepoCapture{} + usageSvc := service.NewUsageService(repo, nil, nil, nil) + orgSvc := service.NewFeishuOrgPermissionService(db, nil) + handler := NewFeishuOrgHandler(orgSvc, nil, usageSvc) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 7}) + c.Next() + }) + router.GET("/org-manager/usage/stats", handler.ManagerUsageStats) + + req := httptest.NewRequest(http.MethodGet, "/org-manager/usage/stats?start_date=2026-03-01&end_date=2026-03-02", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, []int64{42, 99}, repo.statsFilters.UserIDs) + require.Equal(t, int64(0), repo.statsFilters.UserID) + require.NotNil(t, repo.statsFilters.StartTime) + require.NotNil(t, repo.statsFilters.EndTime) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgHandlerManagerUsageStatsUsesEmptyScopeWhenNoBoundUsers(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery(regexp.QuoteMeta("WITH RECURSIVE manager_roots")). + WithArgs(int64(7)). + WillReturnRows(sqlmock.NewRows([]string{"user_id"})) + + repo := &userUsageRepoCapture{} + usageSvc := service.NewUsageService(repo, nil, nil, nil) + orgSvc := service.NewFeishuOrgPermissionService(db, nil) + handler := NewFeishuOrgHandler(orgSvc, nil, usageSvc) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 7}) + c.Next() + }) + router.GET("/org-manager/usage/stats", handler.ManagerUsageStats) + + req := httptest.NewRequest(http.MethodGet, "/org-manager/usage/stats?start_date=2026-03-01&end_date=2026-03-02", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, repo.statsFilters.UserIDs) + require.Empty(t, repo.statsFilters.UserIDs) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgHandlerListManagerUsageCanFilterManagedUser(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery(regexp.QuoteMeta("WITH RECURSIVE manager_roots")). + WithArgs(int64(7)). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}). + AddRow(int64(42)). + AddRow(int64(99))) + + repo := &userUsageRepoCapture{} + usageSvc := service.NewUsageService(repo, nil, nil, nil) + orgSvc := service.NewFeishuOrgPermissionService(db, nil) + handler := NewFeishuOrgHandler(orgSvc, nil, usageSvc) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 7}) + c.Next() + }) + router.GET("/org-manager/usage", handler.ListManagerUsage) + + req := httptest.NewRequest(http.MethodGet, "/org-manager/usage?user_id=99&page=1&page_size=20", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, []int64{99}, repo.listFilters.UserIDs) + require.Equal(t, int64(0), repo.listFilters.UserID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgHandlerListManagerUsageRejectsUnmanagedUserFilter(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery(regexp.QuoteMeta("WITH RECURSIVE manager_roots")). + WithArgs(int64(7)). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}). + AddRow(int64(42)). + AddRow(int64(99))) + + repo := &userUsageRepoCapture{} + usageSvc := service.NewUsageService(repo, nil, nil, nil) + orgSvc := service.NewFeishuOrgPermissionService(db, nil) + handler := NewFeishuOrgHandler(orgSvc, nil, usageSvc) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 7}) + c.Next() + }) + router.GET("/org-manager/usage", handler.ListManagerUsage) + + req := httptest.NewRequest(http.MethodGet, "/org-manager/usage?user_id=100&page=1&page_size=20", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) + require.Empty(t, repo.listFilters.UserIDs) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 014cf7d2baa..ab03ca27288 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -58,6 +58,7 @@ type Handlers struct { Payment *PaymentHandler PaymentWebhook *PaymentWebhookHandler AvailableChannel *AvailableChannelHandler + FeishuOrg *FeishuOrgHandler } // BuildInfo contains build-time information diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index f6a5de4f1dc..04291186987 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -73,7 +73,11 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { TablePageSizeOptions: settings.TablePageSizeOptions, CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), + EmailPasswordLoginEnabled: settings.EmailPasswordLoginEnabled, + AdminEmailLoginFallbackEnabled: settings.AdminEmailLoginFallbackEnabled, DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, + FeishuOAuthEnabled: settings.FeishuOAuthEnabled, + FeishuOrgSyncEnabled: settings.FeishuOrgSyncEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, WeChatOAuthEnabled: settings.WeChatOAuthEnabled, WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 090a734c9f9..2862f337177 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -115,6 +115,7 @@ func ProvideHandlers( paymentHandler *PaymentHandler, paymentWebhookHandler *PaymentWebhookHandler, availableChannelHandler *AvailableChannelHandler, + feishuOrgHandler *FeishuOrgHandler, _ *service.IdempotencyCoordinator, _ *service.IdempotencyCleanupService, ) *Handlers { @@ -135,6 +136,7 @@ func ProvideHandlers( Payment: paymentHandler, PaymentWebhook: paymentWebhookHandler, AvailableChannel: availableChannelHandler, + FeishuOrg: feishuOrgHandler, } } @@ -156,6 +158,7 @@ var ProviderSet = wire.NewSet( NewPaymentHandler, NewPaymentWebhookHandler, NewAvailableChannelHandler, + NewFeishuOrgHandler, // Admin handlers admin.NewDashboardHandler, diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index a96ea670c4d..0909f400fbe 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -262,6 +262,7 @@ type PlatformDashboardStats struct { // UsageLogFilters represents filters for usage log queries type UsageLogFilters struct { UserID int64 + UserIDs []int64 APIKeyID int64 AccountID int64 GroupID int64 diff --git a/backend/internal/repository/backup_record_repo.go b/backend/internal/repository/backup_record_repo.go new file mode 100644 index 00000000000..42cabd21e2c --- /dev/null +++ b/backend/internal/repository/backup_record_repo.go @@ -0,0 +1,228 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type backupRecordRepository struct { + sql sqlExecutor +} + +func NewBackupRecordRepository(sqlDB *sql.DB) service.BackupRecordRepository { + return &backupRecordRepository{sql: sqlDB} +} + +const backupRecordSelectColumns = ` + id, status, backup_type, file_name, s3_key, size_bytes, triggered_by, + error_message, started_at, finished_at, expires_at, progress, + restore_status, restore_error, restored_at +` + +func (r *backupRecordRepository) List(ctx context.Context) ([]service.BackupRecord, error) { + rows, err := r.sql.QueryContext(ctx, ` + SELECT `+backupRecordSelectColumns+` + FROM backup_records + ORDER BY started_at DESC, id DESC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + records := make([]service.BackupRecord, 0) + for rows.Next() { + record, err := scanBackupRecord(rows) + if err != nil { + return nil, err + } + records = append(records, *record) + } + return records, rows.Err() +} + +func (r *backupRecordRepository) Get(ctx context.Context, id string) (*service.BackupRecord, error) { + record := &service.BackupRecord{} + err := scanSingleRow(ctx, r.sql, ` + SELECT `+backupRecordSelectColumns+` + FROM backup_records + WHERE id = $1 + `, []any{id}, + &record.ID, + &record.Status, + &record.BackupType, + &record.FileName, + &record.S3Key, + &record.SizeBytes, + &record.TriggeredBy, + &record.ErrorMsg, + &record.StartedAt, + &record.FinishedAt, + &record.ExpiresAt, + &record.Progress, + &record.RestoreStatus, + &record.RestoreError, + &record.RestoredAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, service.ErrBackupNotFound + } + if err != nil { + return nil, err + } + return record, nil +} + +func (r *backupRecordRepository) Upsert(ctx context.Context, record *service.BackupRecord) error { + if record == nil { + return nil + } + _, err := r.sql.ExecContext(ctx, ` + INSERT INTO backup_records ( + id, status, backup_type, file_name, s3_key, size_bytes, triggered_by, + error_message, started_at, finished_at, expires_at, progress, + restore_status, restore_error, restored_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + ) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + backup_type = EXCLUDED.backup_type, + file_name = EXCLUDED.file_name, + s3_key = EXCLUDED.s3_key, + size_bytes = EXCLUDED.size_bytes, + triggered_by = EXCLUDED.triggered_by, + error_message = EXCLUDED.error_message, + started_at = EXCLUDED.started_at, + finished_at = EXCLUDED.finished_at, + expires_at = EXCLUDED.expires_at, + progress = EXCLUDED.progress, + restore_status = EXCLUDED.restore_status, + restore_error = EXCLUDED.restore_error, + restored_at = EXCLUDED.restored_at, + updated_at = NOW() + `, backupRecordArgs(record)...) + if err != nil { + return err + } + return r.prune(ctx) +} + +func (r *backupRecordRepository) Update(ctx context.Context, record *service.BackupRecord) error { + if record == nil { + return service.ErrBackupNotFound + } + args := append([]any{record.ID}, backupRecordUpdateArgs(record)...) + result, err := r.sql.ExecContext(ctx, ` + UPDATE backup_records SET + status = $2, + backup_type = $3, + file_name = $4, + s3_key = $5, + size_bytes = $6, + triggered_by = $7, + error_message = $8, + started_at = $9, + finished_at = $10, + expires_at = $11, + progress = $12, + restore_status = $13, + restore_error = $14, + restored_at = $15, + updated_at = NOW() + WHERE id = $1 + `, args...) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return service.ErrBackupNotFound + } + return nil +} + +func (r *backupRecordRepository) Delete(ctx context.Context, id string) error { + result, err := r.sql.ExecContext(ctx, `DELETE FROM backup_records WHERE id = $1`, id) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return service.ErrBackupNotFound + } + return nil +} + +func (r *backupRecordRepository) prune(ctx context.Context) error { + _, err := r.sql.ExecContext(ctx, ` + DELETE FROM backup_records + WHERE id IN ( + SELECT id + FROM backup_records + WHERE status <> 'running' + AND restore_status <> 'running' + ORDER BY started_at DESC, id DESC + OFFSET $1 + ) + `, maxBackupRecordsForRepo) + return err +} + +func scanBackupRecord(row scannable) (*service.BackupRecord, error) { + record := &service.BackupRecord{} + if err := row.Scan( + &record.ID, + &record.Status, + &record.BackupType, + &record.FileName, + &record.S3Key, + &record.SizeBytes, + &record.TriggeredBy, + &record.ErrorMsg, + &record.StartedAt, + &record.FinishedAt, + &record.ExpiresAt, + &record.Progress, + &record.RestoreStatus, + &record.RestoreError, + &record.RestoredAt, + ); err != nil { + return nil, err + } + return record, nil +} + +func backupRecordArgs(record *service.BackupRecord) []any { + return append([]any{record.ID}, backupRecordUpdateArgs(record)...) +} + +func backupRecordUpdateArgs(record *service.BackupRecord) []any { + return []any{ + record.Status, + record.BackupType, + record.FileName, + record.S3Key, + record.SizeBytes, + record.TriggeredBy, + record.ErrorMsg, + record.StartedAt, + record.FinishedAt, + record.ExpiresAt, + record.Progress, + record.RestoreStatus, + record.RestoreError, + record.RestoredAt, + } +} + +const maxBackupRecordsForRepo = 100 diff --git a/backend/internal/repository/backup_record_repo_test.go b/backend/internal/repository/backup_record_repo_test.go new file mode 100644 index 00000000000..52c4ef52cd1 --- /dev/null +++ b/backend/internal/repository/backup_record_repo_test.go @@ -0,0 +1,177 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql" + "database/sql/driver" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func backupRecordTestRows(records ...service.BackupRecord) *sqlmock.Rows { + rows := sqlmock.NewRows([]string{ + "id", "status", "backup_type", "file_name", "s3_key", "size_bytes", "triggered_by", + "error_message", "started_at", "finished_at", "expires_at", "progress", + "restore_status", "restore_error", "restored_at", + }) + for _, record := range records { + rows.AddRow( + record.ID, + record.Status, + record.BackupType, + record.FileName, + record.S3Key, + record.SizeBytes, + record.TriggeredBy, + record.ErrorMsg, + record.StartedAt, + record.FinishedAt, + record.ExpiresAt, + record.Progress, + record.RestoreStatus, + record.RestoreError, + record.RestoredAt, + ) + } + return rows +} + +func backupRecordTestArgs(record *service.BackupRecord) []driver.Value { + return []driver.Value{ + record.ID, + record.Status, + record.BackupType, + record.FileName, + record.S3Key, + record.SizeBytes, + record.TriggeredBy, + record.ErrorMsg, + record.StartedAt, + record.FinishedAt, + record.ExpiresAt, + record.Progress, + record.RestoreStatus, + record.RestoreError, + record.RestoredAt, + } +} + +func TestBackupRecordRepositoryListAndGet(t *testing.T) { + db, mock := newSQLMock(t) + repo := NewBackupRecordRepository(db) + record := service.BackupRecord{ + ID: "abc123", + Status: "completed", + BackupType: "postgres", + FileName: "db.sql.gz", + S3Key: "backups/db.sql.gz", + SizeBytes: 42, + TriggeredBy: "manual", + StartedAt: "2026-07-03T00:00:00Z", + } + + mock.ExpectQuery("SELECT .* FROM backup_records\\s+ORDER BY started_at DESC, id DESC"). + WillReturnRows(backupRecordTestRows(record)) + mock.ExpectQuery("SELECT .* FROM backup_records\\s+WHERE id = \\$1"). + WithArgs(record.ID). + WillReturnRows(backupRecordTestRows(record)) + + records, err := repo.List(context.Background()) + require.NoError(t, err) + require.Len(t, records, 1) + require.Equal(t, record.ID, records[0].ID) + + got, err := repo.Get(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, record.S3Key, got.S3Key) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestBackupRecordRepositoryGetNotFound(t *testing.T) { + db, mock := newSQLMock(t) + repo := NewBackupRecordRepository(db) + + mock.ExpectQuery("SELECT .* FROM backup_records\\s+WHERE id = \\$1"). + WithArgs("missing"). + WillReturnRows(backupRecordTestRows()) + + _, err := repo.Get(context.Background(), "missing") + require.ErrorIs(t, err, service.ErrBackupNotFound) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestBackupRecordRepositoryUpsertPrunes(t *testing.T) { + db, mock := newSQLMock(t) + repo := NewBackupRecordRepository(db) + record := &service.BackupRecord{ + ID: "abc123", + Status: "running", + BackupType: "postgres", + FileName: "db.sql.gz", + S3Key: "backups/db.sql.gz", + TriggeredBy: "manual", + StartedAt: "2026-07-03T00:00:00Z", + } + + mock.ExpectExec("INSERT INTO backup_records"). + WithArgs(backupRecordTestArgs(record)...). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("DELETE FROM backup_records[\\s\\S]+status <> 'running'[\\s\\S]+restore_status <> 'running'"). + WithArgs(100). + WillReturnResult(sqlmock.NewResult(0, 0)) + + require.NoError(t, repo.Upsert(context.Background(), record)) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestBackupRecordRepositoryUpdateNotFoundDoesNotUpsert(t *testing.T) { + db, mock := newSQLMock(t) + repo := NewBackupRecordRepository(db) + record := &service.BackupRecord{ + ID: "deleted", + Status: "completed", + BackupType: "postgres", + StartedAt: "2026-07-03T00:00:00Z", + } + + mock.ExpectExec("UPDATE backup_records SET"). + WithArgs(backupRecordTestArgs(record)...). + WillReturnResult(sqlmock.NewResult(0, 0)) + + err := repo.Update(context.Background(), record) + require.ErrorIs(t, err, service.ErrBackupNotFound) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestBackupRecordRepositoryDeleteNotFound(t *testing.T) { + db, mock := newSQLMock(t) + repo := NewBackupRecordRepository(db) + + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM backup_records WHERE id = $1")). + WithArgs("missing"). + WillReturnResult(sqlmock.NewResult(0, 0)) + + err := repo.Delete(context.Background(), "missing") + require.ErrorIs(t, err, service.ErrBackupNotFound) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestBackupRecordRepositoryPropagatesRowsAffectedError(t *testing.T) { + db, mock := newSQLMock(t) + repo := NewBackupRecordRepository(db) + record := &service.BackupRecord{ID: "abc123"} + + mock.ExpectExec("UPDATE backup_records SET"). + WithArgs(backupRecordTestArgs(record)...). + WillReturnResult(sqlmock.NewErrorResult(sql.ErrConnDone)) + + err := repo.Update(context.Background(), record) + require.ErrorIs(t, err, sql.ErrConnDone) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 885e63f9fd8..e1eef72cf1c 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -178,6 +178,36 @@ func appendUsageLogBillingModeQueryFilter(query string, args []any, billingMode return query + " AND " + conditions[0], args } +func appendUsageLogUserScopeWhereCondition(conditions []string, args []any, userID int64, userIDs []int64, alias string) ([]string, []any) { + column := "user_id" + if alias != "" { + column = alias + ".user_id" + } + if userID > 0 { + conditions = append(conditions, fmt.Sprintf("%s = $%d", column, len(args)+1)) + args = append(args, userID) + return conditions, args + } + if userIDs == nil { + return conditions, args + } + if len(userIDs) == 0 { + conditions = append(conditions, "1 = 0") + return conditions, args + } + conditions = append(conditions, fmt.Sprintf("%s = ANY($%d)", column, len(args)+1)) + args = append(args, pq.Array(userIDs)) + return conditions, args +} + +func appendUsageLogUserScopeQueryFilter(query string, args []any, userID int64, userIDs []int64, alias string) (string, []any) { + conditions, args := appendUsageLogUserScopeWhereCondition(nil, args, userID, userIDs, alias) + if len(conditions) == 0 { + return query, args + } + return query + " AND " + conditions[0], args +} + func appendUsageLogModelWhereCondition(conditions []string, args []any, model string, source string) ([]string, []any) { if strings.TrimSpace(source) == "" { return appendRawUsageLogModelWhereCondition(conditions, args, model) @@ -2786,7 +2816,7 @@ func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, user // GetUserModelStats 获取指定用户的模型统计 func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "") + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, nil, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "") } // UsageLogFilters represents filters for usage log queries @@ -2797,10 +2827,7 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat conditions := make([]string, 0, 9) args := make([]any, 0, 9) - if filters.UserID > 0 { - conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1)) - args = append(args, filters.UserID) - } + conditions, args = appendUsageLogUserScopeWhereCondition(conditions, args, filters.UserID, filters.UserIDs, "") if filters.APIKeyID > 0 { conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1)) args = append(args, filters.APIKeyID) @@ -2855,7 +2882,7 @@ func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool { return false } // 强选择过滤下记录集通常较小,保留精确总数。 - return filters.UserID == 0 && filters.APIKeyID == 0 && filters.AccountID == 0 + return filters.UserID == 0 && filters.UserIDs == nil && filters.APIKeyID == 0 && filters.AccountID == 0 } // UsageStats represents usage statistics @@ -3025,15 +3052,15 @@ func (r *usageLogRepository) GetBatchAPIKeyUsageStats(ctx context.Context, apiKe // GetUsageTrendWithFilters returns usage trend data with optional filters func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) { - return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") + return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, nil, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") } func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) { - return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.UserIDs, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) } -func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) { - if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) { +func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID int64, userIDs []int64, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) { + if shouldUsePreaggregatedTrend(granularity, userID, userIDs, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) { aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity) if aggregatedErr == nil && len(aggregated) > 0 { return aggregated, nil @@ -3058,10 +3085,7 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start `, dateFormat) args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) - args = append(args, userID) - } + query, args = appendUsageLogUserScopeQueryFilter(query, args, userID, userIDs, "") if apiKeyID > 0 { query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) args = append(args, apiKeyID) @@ -3103,11 +3127,12 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start return results, nil } -func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool { +func shouldUsePreaggregatedTrend(granularity string, userID int64, userIDs []int64, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool { if granularity != "day" && granularity != "hour" { return false } return userID == 0 && + userIDs == nil && apiKeyID == 0 && accountID == 0 && groupID == 0 && @@ -3180,20 +3205,20 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st // GetModelStatsWithFilters returns model statistics with optional filters func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "") + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, nil, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "") } // GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension. // source: requested | upstream | mapping. func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "") + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, nil, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "") } func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode) + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.UserIDs, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode) } -func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) { +func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID int64, userIDs []int64, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) { actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" // 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。 if accountID > 0 && userID == 0 && apiKeyID == 0 { @@ -3219,10 +3244,7 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex `, modelExpr, actualCostExpr, accountCostExpr) args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) - args = append(args, userID) - } + query, args = appendUsageLogUserScopeQueryFilter(query, args, userID, userIDs, "") if apiKeyID > 0 { query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) args = append(args, apiKeyID) @@ -3269,14 +3291,14 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex // GetGroupStatsWithFilters returns group usage statistics with optional filters func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) { - return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "") + return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, nil, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "") } func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) { - return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.UserIDs, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) } -func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) { +func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID int64, userIDs []int64, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) { query := ` SELECT COALESCE(ul.group_id, 0) as group_id, @@ -3292,10 +3314,7 @@ func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, start ` args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND ul.user_id = $%d", len(args)+1) - args = append(args, userID) - } + query, args = appendUsageLogUserScopeQueryFilter(query, args, userID, userIDs, "ul") if apiKeyID > 0 { query += fmt.Sprintf(" AND ul.api_key_id = $%d", len(args)+1) args = append(args, apiKeyID) @@ -3557,10 +3576,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us conditions := make([]string, 0, 9) args := make([]any, 0, 9) - if filters.UserID > 0 { - conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1)) - args = append(args, filters.UserID) - } + conditions, args = appendUsageLogUserScopeWhereCondition(conditions, args, filters.UserID, filters.UserIDs, "") if filters.APIKeyID > 0 { conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1)) args = append(args, filters.APIKeyID) @@ -3637,7 +3653,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us } // endpoint 明细:best-effort(失败 log + 返空),不致命。 runEndpoints := func(c context.Context) { - res, err := r.getEndpointStatsByColumnWithFilters(c, "inbound_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + res, err := r.getEndpointStatsByColumnWithFilters(c, "inbound_endpoint", start, end, filters.UserID, filters.UserIDs, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) if err != nil { if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err) @@ -3647,7 +3663,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us endpoints = res } runUpstream := func(c context.Context) { - res, err := r.getEndpointStatsByColumnWithFilters(c, "upstream_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + res, err := r.getEndpointStatsByColumnWithFilters(c, "upstream_endpoint", start, end, filters.UserID, filters.UserIDs, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) if err != nil { if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err) @@ -3657,7 +3673,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us upstreamEndpoints = res } runPaths := func(c context.Context) { - res, err := r.getEndpointPathStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + res, err := r.getEndpointPathStatsWithFilters(c, start, end, filters.UserID, filters.UserIDs, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) if err != nil { if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { logger.LegacyPrintf("repository.usage_log", "getEndpointPathStatsWithFilters failed in GetStatsWithFilters: %v", err) @@ -3708,7 +3724,7 @@ type AccountUsageStatsResponse = usagestats.AccountUsageStatsResponse // EndpointStat represents endpoint usage statistics row. type EndpointStat = usagestats.EndpointStat -func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Context, endpointColumn string, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) { +func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Context, endpointColumn string, startTime, endTime time.Time, userID int64, userIDs []int64, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) { actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" if accountID > 0 && userID == 0 && apiKeyID == 0 { actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" @@ -3726,10 +3742,7 @@ func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Con `, endpointColumn, actualCostExpr) args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) - args = append(args, userID) - } + query, args = appendUsageLogUserScopeQueryFilter(query, args, userID, userIDs, "") if apiKeyID > 0 { query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) args = append(args, apiKeyID) @@ -3776,7 +3789,7 @@ func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Con return results, nil } -func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) { +func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID int64, userIDs []int64, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) { actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" if accountID > 0 && userID == 0 && apiKeyID == 0 { actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" @@ -3798,10 +3811,7 @@ func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context `, actualCostExpr) args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) - args = append(args, userID) - } + query, args = appendUsageLogUserScopeQueryFilter(query, args, userID, userIDs, "") if apiKeyID > 0 { query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) args = append(args, apiKeyID) @@ -3850,12 +3860,12 @@ func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context // GetEndpointStatsWithFilters returns inbound endpoint statistics with optional filters. func (r *usageLogRepository) GetEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]EndpointStat, error) { - return r.getEndpointStatsByColumnWithFilters(ctx, "inbound_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") + return r.getEndpointStatsByColumnWithFilters(ctx, "inbound_endpoint", startTime, endTime, userID, nil, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") } // GetUpstreamEndpointStatsWithFilters returns upstream endpoint statistics with optional filters. func (r *usageLogRepository) GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]EndpointStat, error) { - return r.getEndpointStatsByColumnWithFilters(ctx, "upstream_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") + return r.getEndpointStatsByColumnWithFilters(ctx, "upstream_endpoint", startTime, endTime, userID, nil, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") } // GetAccountUsageStats returns comprehensive usage statistics for an account over a time range diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 37f8e9bd2fb..3d00b287e9f 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -81,6 +81,7 @@ var ProviderSet = wire.NewSet( NewUsageCleanupRepository, NewDashboardAggregationRepository, NewSettingRepository, + NewBackupRecordRepository, NewOpsRepository, NewUserSubscriptionRepository, NewUserAttributeDefinitionRepository, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index a456c8c3701..8994861dc5c 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -106,6 +106,21 @@ func RegisterAdminRoutes( // 邀请返利(专属用户管理) registerAffiliateRoutes(admin, h) + + // 飞书组织权限 + registerFeishuOrgAdminRoutes(admin, h) + } +} + +func registerFeishuOrgAdminRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + feishuOrg := admin.Group("/feishu-org") + { + feishuOrg.GET("/departments", h.FeishuOrg.ListDepartments) + feishuOrg.GET("/users", h.FeishuOrg.ListUsers) + feishuOrg.GET("/sync-runs", h.FeishuOrg.ListSyncRuns) + feishuOrg.POST("/sync-runs", h.FeishuOrg.RunManualReconcile) + feishuOrg.PUT("/departments/:department_id/groups", h.FeishuOrg.SetDepartmentGroupPool) + feishuOrg.PUT("/users/:id/overrides", h.FeishuOrg.SetUserOverrideGroupGrants) } } @@ -459,6 +474,7 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { { adminSettings.GET("", h.Admin.Setting.GetSettings) adminSettings.PUT("", h.Admin.Setting.UpdateSettings) + adminSettings.POST("/feishu/preflight", h.Admin.Setting.CheckFeishuPreflight) adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection) adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail) adminSettings.GET("/email-templates", h.Admin.Setting.ListEmailTemplates) diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 2c44a2b3a38..6ba45030d2f 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -208,6 +208,14 @@ func RegisterAuthRoutes( }), h.Auth.CreateDingTalkOAuthAccount, ) + auth.GET("/oauth/feishu/start", h.Auth.FeishuOAuthStart) + auth.GET("/oauth/feishu/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.FeishuOAuthStart(c) + }) + auth.GET("/oauth/feishu/callback", h.Auth.FeishuOAuthCallback) } // 公开设置(无需认证) diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 9f89687ad11..460638e6a6e 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -72,6 +72,20 @@ func RegisterUserRoutes( groups.GET("/rates", h.APIKey.GetUserGroupRates) } + // 飞书部门负责人受限管理入口(非全局管理员) + orgManager := authenticated.Group("/org-manager") + { + orgManager.GET("/access", h.FeishuOrg.ManagerAccess) + orgManager.GET("/users", h.FeishuOrg.ListManagedUsers) + orgManager.PUT("/users/:id/group-grants", h.FeishuOrg.SetManagedUserGroupGrants) + orgManager.GET("/usage", h.FeishuOrg.ListManagerUsage) + orgManager.GET("/usage/stats", h.FeishuOrg.ManagerUsageStats) + orgManager.GET("/usage/dashboard/stats", h.FeishuOrg.ManagerDashboardStats) + orgManager.GET("/usage/dashboard/trend", h.FeishuOrg.ManagerUsageTrend) + orgManager.GET("/usage/dashboard/models", h.FeishuOrg.ManagerUsageModels) + orgManager.GET("/usage/dashboard/snapshot-v2", h.FeishuOrg.ManagerUsageSnapshotV2) + } + // 用户可用渠道(非管理员接口) channels := authenticated.Group("/channels") { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index bacd134db4c..6560367b21e 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -1394,7 +1394,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, wechat, or dingtalk") + return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type must be one of email, linuxdo, oidc, wechat, dingtalk, or feishu") } if providerKey == "" || providerSubject == "" { return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type, provider_key, and provider_subject are required") @@ -1651,6 +1651,8 @@ func normalizeAdminAuthIdentityProviderType(input string) string { return "wechat" case "dingtalk": return "dingtalk" + case "feishu": + return "feishu" default: return "" } diff --git a/backend/internal/service/admin_service_auth_identity_binding_test.go b/backend/internal/service/admin_service_auth_identity_binding_test.go index 719199f251a..ce8f6f42cdd 100644 --- a/backend/internal/service/admin_service_auth_identity_binding_test.go +++ b/backend/internal/service/admin_service_auth_identity_binding_test.go @@ -188,6 +188,52 @@ func TestAdminServiceBindUserAuthIdentityIsIdempotentForSameUser(t *testing.T) { require.Equal(t, "second", identities[0].Metadata["source"]) } +func TestBindUserAuthIdentityAcceptsFeishu(t *testing.T) { + client := newAdminServiceAuthIdentityBindingTestClient(t) + ctx := context.Background() + + user, err := client.User.Create(). + SetEmail("feishu-bind@example.com"). + SetPasswordHash("hash"). + SetRole(RoleUser). + SetStatus(StatusActive). + Save(ctx) + require.NoError(t, err) + + svc := &adminServiceImpl{ + userRepo: &userRepoStub{user: &User{ID: user.ID, Email: user.Email, Status: StatusActive}}, + entClient: client, + } + + result, err := svc.BindUserAuthIdentity(ctx, user.ID, AdminBindAuthIdentityInput{ + ProviderType: "feishu", + ProviderKey: "tenant-key-1", + ProviderSubject: "ou_open_id_1", + Metadata: map[string]any{ + "union_id": "on_union_id_1", + "user_id_in_tenant": "user-id-1", + }, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, user.ID, result.UserID) + require.Equal(t, "feishu", result.ProviderType) + require.Equal(t, "tenant-key-1", result.ProviderKey) + require.Equal(t, "ou_open_id_1", result.ProviderSubject) + + identity, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("feishu"), + authidentity.ProviderKeyEQ("tenant-key-1"), + authidentity.ProviderSubjectEQ("ou_open_id_1"), + ). + Only(ctx) + require.NoError(t, err) + require.Equal(t, user.ID, identity.UserID) + require.Equal(t, "on_union_id_1", identity.Metadata["union_id"]) + require.Equal(t, "user-id-1", identity.Metadata["user_id_in_tenant"]) +} + func TestAdminServiceBindUserAuthIdentityReusesLegacyWeChatAliasRecords(t *testing.T) { client := newAdminServiceAuthIdentityBindingTestClient(t) ctx := context.Background() @@ -293,10 +339,36 @@ func TestAdminServiceBindUserAuthIdentityRejectsInvalidProviderType(t *testing.T } _, err = svc.BindUserAuthIdentity(ctx, user.ID, AdminBindAuthIdentityInput{ - ProviderType: "github", - ProviderKey: "github-main", + ProviderType: "unsupported-provider", + ProviderKey: "unsupported-main", ProviderSubject: "subject-3", }) require.Error(t, err) require.Equal(t, "INVALID_INPUT", infraerrors.Reason(err)) } + +func TestBindUserAuthIdentityRejectsUnknownProvider(t *testing.T) { + client := newAdminServiceAuthIdentityBindingTestClient(t) + ctx := context.Background() + + user, err := client.User.Create(). + SetEmail("unknown-provider@example.com"). + SetPasswordHash("hash"). + SetRole(RoleUser). + SetStatus(StatusActive). + Save(ctx) + require.NoError(t, err) + + svc := &adminServiceImpl{ + userRepo: &userRepoStub{user: &User{ID: user.ID, Email: user.Email, Status: StatusActive}}, + entClient: client, + } + + _, err = svc.BindUserAuthIdentity(ctx, user.ID, AdminBindAuthIdentityInput{ + ProviderType: "not-a-provider", + ProviderKey: "provider-main", + ProviderSubject: "subject-4", + }) + require.Error(t, err) + require.Equal(t, "INVALID_INPUT", infraerrors.Reason(err)) +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index a4c67a24e31..4b9255f675e 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -568,17 +568,25 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username return token, user, nil } -// canBypassRegistrationDisabledForOAuth 在钉钉企业模式(internal_only)且 -// dingtalk_connect_bypass_registration=true 时,允许跳过全局 registration_enabled 检查。 +// canBypassRegistrationDisabledForOAuth 在企业 SSO 模式(internal_only)且 +// 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 { + switch strings.TrimSpace(strings.ToLower(signupSource)) { + case "dingtalk": + cfg, err := s.settingService.GetDingTalkConnectOAuthConfig(ctx) + if err != nil || !cfg.Enabled || !cfg.BypassRegistration { + return false + } + return cfg.CorpRestrictionPolicy == "internal_only" + case "feishu": + cfg, err := s.settingService.GetFeishuConnectOAuthConfig(ctx) + if err != nil || !cfg.Enabled || !cfg.BypassRegistration { + return false + } + return cfg.TenantRestrictionPolicy == FeishuTenantRestrictionInternalOnly + default: return false } - return cfg.CorpRestrictionPolicy == "internal_only" } // LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair。 @@ -871,6 +879,8 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource return defaults.Google, true case "dingtalk": return defaults.DingTalk, true + case "feishu": + return defaults.Feishu, true default: return ProviderDefaultGrantSettings{}, false } @@ -1084,6 +1094,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, FeishuConnectSyntheticEmailDomain): + return "feishu" case strings.HasSuffix(normalized, DingTalkConnectSyntheticEmailDomain): return "dingtalk" case strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain): @@ -1181,7 +1193,8 @@ func isReservedEmail(email string) bool { return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) || strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain) || strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain) || - strings.HasSuffix(normalized, DingTalkConnectSyntheticEmailDomain) + strings.HasSuffix(normalized, DingTalkConnectSyntheticEmailDomain) || + strings.HasSuffix(normalized, FeishuConnectSyntheticEmailDomain) } // GenerateToken 生成JWT access token diff --git a/backend/internal/service/backup_service.go b/backend/internal/service/backup_service.go index 2fcf2da89f7..11852b10610 100644 --- a/backend/internal/service/backup_service.go +++ b/backend/internal/service/backup_service.go @@ -3,6 +3,7 @@ package service import ( "compress/gzip" "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -27,6 +28,11 @@ const ( settingKeyBackupRecords = "backup_records" maxBackupRecords = 100 + + backupOperationLeaderLockKey = "backup:operation:leader" + backupOperationLeaderLockTTL = 35 * time.Minute + backupScheduleReloadInterval = time.Minute + backupStaleRecoveryInterval = time.Minute ) var ( @@ -58,6 +64,17 @@ type BackupObjectStore interface { // BackupObjectStoreFactory creates an object store from S3 config type BackupObjectStoreFactory func(ctx context.Context, cfg *BackupS3Config) (BackupObjectStore, error) +// BackupRecordRepository stores backup metadata in a shared database table for +// multi-instance deployments. BackupService keeps a settings-backed fallback for +// tests and legacy single-instance setups where this repository is not injected. +type BackupRecordRepository interface { + List(ctx context.Context) ([]BackupRecord, error) + Get(ctx context.Context, id string) (*BackupRecord, error) + Upsert(ctx context.Context, record *BackupRecord) error + Update(ctx context.Context, record *BackupRecord) error + Delete(ctx context.Context, id string) error +} + // ─── 数据模型 ─── // BackupS3Config S3 兼容存储配置(支持 Cloudflare R2) @@ -110,6 +127,7 @@ type BackupService struct { encryptor SecretEncryptor storeFactory BackupObjectStoreFactory dumper DBDumper + recordRepo BackupRecordRepository opMu sync.Mutex // 保护 backingUp/restoring 标志 backingUp bool @@ -121,14 +139,21 @@ type BackupService struct { recordsMu sync.Mutex // 保护 records 的 load/save 操作 + lockCache LeaderLockCache + db *sql.DB + instanceID string + cronMu sync.Mutex cronSched *cron.Cron cronEntryID cron.EntryID - - wg sync.WaitGroup // 追踪活跃的备份/恢复 goroutine - shuttingDown atomic.Bool // 阻止新备份启动 - bgCtx context.Context // 所有后台操作的 parent context - bgCancel context.CancelFunc // 取消所有活跃后台操作 + cronExpr string + + wg sync.WaitGroup // 追踪活跃的备份/恢复 goroutine 和调度重载循环 + shuttingDown atomic.Bool // 阻止新备份启动 + bgCtx context.Context // 所有后台操作的 parent context + bgCancel context.CancelFunc // 取消所有活跃后台操作 + scheduleStopCh chan struct{} + scheduleStopOnce sync.Once } func NewBackupService( @@ -140,14 +165,34 @@ func NewBackupService( ) *BackupService { bgCtx, bgCancel := context.WithCancel(context.Background()) return &BackupService{ - settingRepo: settingRepo, - dbCfg: &cfg.Database, - encryptor: encryptor, - storeFactory: storeFactory, - dumper: dumper, - bgCtx: bgCtx, - bgCancel: bgCancel, + settingRepo: settingRepo, + dbCfg: &cfg.Database, + encryptor: encryptor, + storeFactory: storeFactory, + dumper: dumper, + bgCtx: bgCtx, + bgCancel: bgCancel, + instanceID: uuid.NewString(), + scheduleStopCh: make(chan struct{}), + } +} + +// SetLeaderLock injects the distributed operation lock backend used to ensure +// backup/restore/recovery operations run on at most one app instance. With no +// backend, the service keeps single-instance/test behavior and runs ungated. +func (s *BackupService) SetLeaderLock(lockCache LeaderLockCache, db *sql.DB) { + if s == nil { + return } + s.lockCache = lockCache + s.db = db +} + +func (s *BackupService) SetBackupRecordRepository(repo BackupRecordRepository) { + if s == nil { + return + } + s.recordRepo = repo } // Start 启动定时备份调度器并清理孤立记录 @@ -161,16 +206,62 @@ func (s *BackupService) Start() { // 加载已有的定时配置 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - schedule, err := s.GetSchedule(ctx) - if err != nil { - logger.LegacyPrintf("service.backup", "[Backup] 加载定时备份配置失败: %v", err) + if err := s.syncCronScheduleFromSettings(ctx); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 应用定时备份配置失败: %v", err) + } + s.startScheduleReloadLoop() + s.startStaleRecoveryLoop(backupStaleRecoveryInterval) +} + +func (s *BackupService) tryAcquireOperationLock(ctx context.Context) (func(), bool) { + if s == nil { + return func() {}, true + } + return tryAcquireStrictSingletonLeaderLock(ctx, s.lockCache, s.db, backupOperationLeaderLockKey, s.instanceID, backupOperationLeaderLockTTL) +} + +func (s *BackupService) startScheduleReloadLoop() { + if s == nil { return } - if schedule.Enabled && schedule.CronExpr != "" { - if err := s.applyCronSchedule(schedule); err != nil { - logger.LegacyPrintf("service.backup", "[Backup] 应用定时备份配置失败: %v", err) + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(backupScheduleReloadInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := s.syncCronScheduleFromSettings(ctx); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] reload schedule failed: %v", err) + } + cancel() + case <-s.scheduleStopCh: + return + } } + }() +} + +func (s *BackupService) startStaleRecoveryLoop(interval time.Duration) { + if s == nil || interval <= 0 { + return } + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.recoverStaleRecords() + case <-s.scheduleStopCh: + return + } + } + }() } // recoverStaleRecords 启动时将孤立的 running 记录标记为 failed @@ -178,6 +269,22 @@ func (s *BackupService) recoverStaleRecords() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + if !s.hasStaleBackupRecords(ctx) { + return + } + + release, ok := s.tryAcquireOperationLock(ctx) + if !ok { + logger.LegacyPrintf("service.backup", "[Backup] skip stale record recovery: operation lock held by another instance") + return + } + defer release() + + if err := s.ensureNoLocalBackupOperation(); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] skip stale record recovery: local operation active: %v", err) + return + } + records, err := s.loadRecords(ctx) if err != nil { return @@ -188,21 +295,37 @@ func (s *BackupService) recoverStaleRecords() { records[i].ErrorMsg = "interrupted by server restart" records[i].Progress = "" records[i].FinishedAt = time.Now().Format(time.RFC3339) - _ = s.saveRecord(ctx, &records[i]) + _ = s.updateRecord(ctx, &records[i]) logger.LegacyPrintf("service.backup", "[Backup] recovered stale running record: %s", records[i].ID) } if records[i].RestoreStatus == "running" { records[i].RestoreStatus = "failed" records[i].RestoreError = "interrupted by server restart" - _ = s.saveRecord(ctx, &records[i]) + _ = s.updateRecord(ctx, &records[i]) logger.LegacyPrintf("service.backup", "[Backup] recovered stale restoring record: %s", records[i].ID) } } } +func (s *BackupService) hasStaleBackupRecords(ctx context.Context) bool { + records, err := s.loadRecords(ctx) + if err != nil { + return false + } + for i := range records { + if records[i].Status == "running" || records[i].RestoreStatus == "running" { + return true + } + } + return false +} + // Stop 停止定时备份并等待活跃操作完成 func (s *BackupService) Stop() { s.shuttingDown.Store(true) + s.scheduleStopOnce.Do(func() { + close(s.scheduleStopCh) + }) s.cronMu.Lock() if s.cronSched != nil { @@ -252,7 +375,7 @@ func (s *BackupService) GetS3Config(ctx context.Context) (*BackupS3Config, error func (s *BackupService) UpdateS3Config(ctx context.Context, cfg BackupS3Config) (*BackupS3Config, error) { // 如果没提供 secret,保留原有值 if cfg.SecretAccessKey == "" { - old, _ := s.loadS3Config(ctx) + old, _ := s.loadRawS3Config(ctx) if old != nil { cfg.SecretAccessKey = old.SecretAccessKey } @@ -337,18 +460,26 @@ func (s *BackupService) UpdateSchedule(ctx context.Context, cfg BackupScheduleCo return nil, fmt.Errorf("save schedule config: %w", err) } - // 应用或停止定时任务 - if cfg.Enabled { - if err := s.applyCronSchedule(&cfg); err != nil { - return nil, err - } - } else { - s.removeCronSchedule() + // 应用或停止当前实例的定时任务。其他实例会通过定时重载循环看到新配置。 + if err := s.syncCronScheduleFromSettings(ctx); err != nil { + return nil, err } return &cfg, nil } +func (s *BackupService) syncCronScheduleFromSettings(ctx context.Context) error { + schedule, err := s.GetSchedule(ctx) + if err != nil { + return err + } + if schedule == nil || !schedule.Enabled || schedule.CronExpr == "" { + s.removeCronSchedule() + return nil + } + return s.applyCronSchedule(schedule) +} + func (s *BackupService) applyCronSchedule(cfg *BackupScheduleConfig) error { s.cronMu.Lock() defer s.cronMu.Unlock() @@ -356,20 +487,26 @@ func (s *BackupService) applyCronSchedule(cfg *BackupScheduleConfig) error { if s.cronSched == nil { return fmt.Errorf("cron scheduler not initialized") } + if s.cronEntryID != 0 && s.cronExpr == cfg.CronExpr { + return nil + } // 移除旧任务 if s.cronEntryID != 0 { s.cronSched.Remove(s.cronEntryID) s.cronEntryID = 0 + s.cronExpr = "" } + registeredCronExpr := cfg.CronExpr entryID, err := s.cronSched.AddFunc(cfg.CronExpr, func() { - s.runScheduledBackup() + s.runScheduledBackup(registeredCronExpr) }) if err != nil { return infraerrors.BadRequest("INVALID_CRON", fmt.Sprintf("failed to schedule: %v", err)) } s.cronEntryID = entryID + s.cronExpr = registeredCronExpr logger.LegacyPrintf("service.backup", "[Backup] 定时备份已启用: %s", cfg.CronExpr) return nil } @@ -380,19 +517,34 @@ func (s *BackupService) removeCronSchedule() { if s.cronSched != nil && s.cronEntryID != 0 { s.cronSched.Remove(s.cronEntryID) s.cronEntryID = 0 + s.cronExpr = "" logger.LegacyPrintf("service.backup", "[Backup] 定时备份已停用") } } -func (s *BackupService) runScheduledBackup() { +func (s *BackupService) runScheduledBackup(registeredCronExpr string) { s.wg.Add(1) defer s.wg.Done() ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute) defer cancel() - // 读取定时备份配置中的过期天数 - schedule, _ := s.GetSchedule(ctx) + // 读取当前定时配置;旧 cron entry 在配置关闭或表达式变更后必须变成 no-op, + // 这样多实例部署中未命中 UpdateSchedule 的实例也不会继续按旧配置执行。 + schedule, err := s.GetSchedule(ctx) + if err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 读取定时备份配置失败: %v", err) + return + } + if schedule == nil || !schedule.Enabled || schedule.CronExpr == "" { + logger.LegacyPrintf("service.backup", "[Backup] 定时备份跳过: 当前配置已停用") + return + } + if registeredCronExpr != "" && schedule.CronExpr != registeredCronExpr { + logger.LegacyPrintf("service.backup", "[Backup] 定时备份跳过: cron 已变更 current=%q registered=%q", schedule.CronExpr, registeredCronExpr) + return + } + expireDays := 14 // 默认14天过期 if schedule != nil && schedule.RetainDays > 0 { expireDays = schedule.RetainDays @@ -411,9 +563,6 @@ func (s *BackupService) runScheduledBackup() { logger.LegacyPrintf("service.backup", "[Backup] 定时备份完成: id=%s size=%d", record.ID, record.SizeBytes) // 清理过期备份(复用已加载的 schedule) - if schedule == nil { - return - } if err := s.cleanupOldBackups(ctx, schedule); err != nil { logger.LegacyPrintf("service.backup", "[Backup] 清理过期备份失败: %v", err) } @@ -428,6 +577,12 @@ func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, ex return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down") } + release, ok := s.tryAcquireOperationLock(ctx) + if !ok { + return nil, ErrBackupInProgress + } + defer release() + s.opMu.Lock() if s.backingUp { s.opMu.Unlock() @@ -545,9 +700,15 @@ func (s *BackupService) StartBackup(ctx context.Context, triggeredBy string, exp return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down") } + release, ok := s.tryAcquireOperationLock(ctx) + if !ok { + return nil, ErrBackupInProgress + } + s.opMu.Lock() if s.backingUp { s.opMu.Unlock() + release() return nil, ErrBackupInProgress } s.backingUp = true @@ -557,6 +718,7 @@ func (s *BackupService) StartBackup(ctx context.Context, triggeredBy string, exp launched := false defer func() { if !launched { + release() s.opMu.Lock() s.backingUp = false s.opMu.Unlock() @@ -610,6 +772,7 @@ func (s *BackupService) StartBackup(ctx context.Context, triggeredBy string, exp s.wg.Add(1) go func() { defer s.wg.Done() + defer release() defer func() { s.opMu.Lock() s.backingUp = false @@ -622,7 +785,7 @@ func (s *BackupService) StartBackup(ctx context.Context, triggeredBy string, exp record.ErrorMsg = fmt.Sprintf("internal panic: %v", r) record.Progress = "" record.FinishedAt = time.Now().Format(time.RFC3339) - _ = s.saveRecord(context.Background(), record) + _ = s.updateRecord(context.Background(), record) } }() s.executeBackup(record, objectStore) @@ -638,7 +801,7 @@ func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupOb // 阶段1: pg_dump record.Progress = "dumping" - _ = s.saveRecord(ctx, record) + _ = s.updateRecord(ctx, record) dumpReader, err := s.dumper.Dump(ctx) if err != nil { @@ -646,13 +809,13 @@ func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupOb record.ErrorMsg = fmt.Sprintf("pg_dump failed: %v", err) record.Progress = "" record.FinishedAt = time.Now().Format(time.RFC3339) - _ = s.saveRecord(context.Background(), record) + _ = s.updateRecord(context.Background(), record) return } // 阶段2: gzip + upload record.Progress = "uploading" - _ = s.saveRecord(ctx, record) + _ = s.updateRecord(ctx, record) pr, pw := io.Pipe() gzipDone := make(chan error, 1) @@ -693,7 +856,7 @@ func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupOb record.ErrorMsg = errMsg record.Progress = "" record.FinishedAt = time.Now().Format(time.RFC3339) - _ = s.saveRecord(context.Background(), record) + _ = s.updateRecord(context.Background(), record) return } <-gzipDone // 确保 gzip goroutine 已退出 @@ -702,13 +865,19 @@ func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupOb record.Status = "completed" record.Progress = "" record.FinishedAt = time.Now().Format(time.RFC3339) - if err := s.saveRecord(context.Background(), record); err != nil { + if err := s.updateRecord(context.Background(), record); err != nil { logger.LegacyPrintf("service.backup", "[Backup] 保存备份记录失败: %v", err) } } // RestoreBackup 从 S3 下载备份并流式恢复到数据库 func (s *BackupService) RestoreBackup(ctx context.Context, backupID string) error { + release, ok := s.tryAcquireOperationLock(ctx) + if !ok { + return ErrRestoreInProgress + } + defer release() + s.opMu.Lock() if s.restoring { s.opMu.Unlock() @@ -767,9 +936,15 @@ func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*Bac return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down") } + release, ok := s.tryAcquireOperationLock(ctx) + if !ok { + return nil, ErrRestoreInProgress + } + s.opMu.Lock() if s.restoring { s.opMu.Unlock() + release() return nil, ErrRestoreInProgress } s.restoring = true @@ -779,6 +954,7 @@ func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*Bac launched := false defer func() { if !launched { + release() s.opMu.Lock() s.restoring = false s.opMu.Unlock() @@ -803,7 +979,9 @@ func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*Bac } record.RestoreStatus = "running" - _ = s.saveRecord(ctx, record) + if err := s.updateRecord(ctx, record); err != nil { + return nil, err + } launched = true result := *record @@ -811,6 +989,7 @@ func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*Bac s.wg.Add(1) go func() { defer s.wg.Done() + defer release() defer func() { s.opMu.Lock() s.restoring = false @@ -821,7 +1000,7 @@ func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*Bac logger.LegacyPrintf("service.backup", "[Backup] restore panic recovered: %v", r) record.RestoreStatus = "failed" record.RestoreError = fmt.Sprintf("internal panic: %v", r) - _ = s.saveRecord(context.Background(), record) + _ = s.updateRecord(context.Background(), record) } }() s.executeRestore(record, objectStore) @@ -839,7 +1018,7 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO if err != nil { record.RestoreStatus = "failed" record.RestoreError = fmt.Sprintf("S3 download failed: %v", err) - _ = s.saveRecord(context.Background(), record) + _ = s.updateRecord(context.Background(), record) return } defer func() { _ = body.Close() }() @@ -848,7 +1027,7 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO if err != nil { record.RestoreStatus = "failed" record.RestoreError = fmt.Sprintf("gzip reader: %v", err) - _ = s.saveRecord(context.Background(), record) + _ = s.updateRecord(context.Background(), record) return } defer func() { _ = gzReader.Close() }() @@ -856,13 +1035,13 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO if err := s.dumper.Restore(ctx, gzReader); err != nil { record.RestoreStatus = "failed" record.RestoreError = fmt.Sprintf("pg restore: %v", err) - _ = s.saveRecord(context.Background(), record) + _ = s.updateRecord(context.Background(), record) return } record.RestoreStatus = "completed" record.RestoredAt = time.Now().Format(time.RFC3339) - if err := s.saveRecord(context.Background(), record); err != nil { + if err := s.updateRecord(context.Background(), record); err != nil { logger.LegacyPrintf("service.backup", "[Backup] 保存恢复记录失败: %v", err) } } @@ -882,6 +1061,9 @@ func (s *BackupService) ListBackups(ctx context.Context) ([]BackupRecord, error) } func (s *BackupService) GetBackupRecord(ctx context.Context, backupID string) (*BackupRecord, error) { + if s.recordRepo != nil { + return s.recordRepo.Get(ctx, backupID) + } records, err := s.loadRecords(ctx) if err != nil { return nil, err @@ -895,25 +1077,22 @@ func (s *BackupService) GetBackupRecord(ctx context.Context, backupID string) (* } func (s *BackupService) DeleteBackup(ctx context.Context, backupID string) error { - s.recordsMu.Lock() - defer s.recordsMu.Unlock() + release, ok := s.tryAcquireOperationLock(ctx) + if !ok { + return ErrBackupInProgress + } + defer release() - records, err := s.loadRecordsLocked(ctx) - if err != nil { + if err := s.ensureNoLocalBackupOperation(); err != nil { return err } - var found *BackupRecord - var remaining []BackupRecord - for i := range records { - if records[i].ID == backupID { - found = &records[i] - } else { - remaining = append(remaining, records[i]) - } + found, err := s.GetBackupRecord(ctx, backupID) + if err != nil { + return err } - if found == nil { - return ErrBackupNotFound + if found.RestoreStatus == "running" { + return ErrRestoreInProgress } // 从 S3 删除 @@ -927,7 +1106,19 @@ func (s *BackupService) DeleteBackup(ctx context.Context, backupID string) error } } - return s.saveRecordsLocked(ctx, remaining) + return s.deleteRecord(ctx, backupID) +} + +func (s *BackupService) ensureNoLocalBackupOperation() error { + s.opMu.Lock() + defer s.opMu.Unlock() + if s.backingUp { + return ErrBackupInProgress + } + if s.restoring { + return ErrRestoreInProgress + } + return nil } // GetBackupDownloadURL 获取备份文件预签名下载 URL @@ -959,13 +1150,9 @@ func (s *BackupService) GetBackupDownloadURL(ctx context.Context, backupID strin // ─── 内部方法 ─── func (s *BackupService) loadS3Config(ctx context.Context) (*BackupS3Config, error) { - raw, err := s.settingRepo.GetValue(ctx, settingKeyBackupS3Config) - if err != nil || raw == "" { - return nil, nil //nolint:nilnil // no config is a valid state - } - var cfg BackupS3Config - if err := json.Unmarshal([]byte(raw), &cfg); err != nil { - return nil, ErrBackupS3ConfigCorrupt + cfg, err := s.loadRawS3Config(ctx) + if err != nil || cfg == nil { + return cfg, err } // 解密 SecretAccessKey if cfg.SecretAccessKey != "" { @@ -977,6 +1164,18 @@ func (s *BackupService) loadS3Config(ctx context.Context) (*BackupS3Config, erro cfg.SecretAccessKey = decrypted } } + return cfg, nil +} + +func (s *BackupService) loadRawS3Config(ctx context.Context) (*BackupS3Config, error) { + raw, err := s.settingRepo.GetValue(ctx, settingKeyBackupS3Config) + if err != nil || raw == "" { + return nil, nil //nolint:nilnil // no config is a valid state + } + var cfg BackupS3Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return nil, ErrBackupS3ConfigCorrupt + } return &cfg, nil } @@ -984,23 +1183,37 @@ func (s *BackupService) getOrCreateStore(ctx context.Context, cfg *BackupS3Confi s.storeMu.Lock() defer s.storeMu.Unlock() - if s.store != nil && s.s3Cfg != nil { - return s.store, nil - } - if cfg == nil { return nil, ErrBackupS3NotConfigured } + if s.store != nil && backupS3ConfigEqual(s.s3Cfg, cfg) { + return s.store, nil + } + store, err := s.storeFactory(ctx, cfg) if err != nil { return nil, err } + cfgCopy := *cfg s.store = store - s.s3Cfg = cfg + s.s3Cfg = &cfgCopy return store, nil } +func backupS3ConfigEqual(a, b *BackupS3Config) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.Endpoint == b.Endpoint && + a.Region == b.Region && + a.Bucket == b.Bucket && + a.AccessKeyID == b.AccessKeyID && + a.SecretAccessKey == b.SecretAccessKey && + a.Prefix == b.Prefix && + a.ForcePathStyle == b.ForcePathStyle +} + func (s *BackupService) buildS3Key(cfg *BackupS3Config, fileName string) string { prefix := strings.TrimRight(cfg.Prefix, "/") if prefix == "" { @@ -1011,6 +1224,9 @@ func (s *BackupService) buildS3Key(cfg *BackupS3Config, fileName string) string // loadRecords 加载备份记录,区分"无数据"和"数据损坏" func (s *BackupService) loadRecords(ctx context.Context) ([]BackupRecord, error) { + if s.recordRepo != nil { + return s.recordRepo.List(ctx) + } s.recordsMu.Lock() defer s.recordsMu.Unlock() return s.loadRecordsLocked(ctx) @@ -1040,6 +1256,9 @@ func (s *BackupService) saveRecordsLocked(ctx context.Context, records []BackupR // saveRecord 保存单条记录(带互斥锁保护) func (s *BackupService) saveRecord(ctx context.Context, record *BackupRecord) error { + if s.recordRepo != nil { + return s.recordRepo.Upsert(ctx, record) + } s.recordsMu.Lock() defer s.recordsMu.Unlock() @@ -1066,11 +1285,17 @@ func (s *BackupService) saveRecord(ctx context.Context, record *BackupRecord) er return s.saveRecordsLocked(ctx, records) } -func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupScheduleConfig) error { - if schedule == nil { - return nil +func (s *BackupService) updateRecord(ctx context.Context, record *BackupRecord) error { + if s.recordRepo != nil { + return s.recordRepo.Update(ctx, record) } + return s.saveRecord(ctx, record) +} +func (s *BackupService) deleteRecord(ctx context.Context, id string) error { + if s.recordRepo != nil { + return s.recordRepo.Delete(ctx, id) + } s.recordsMu.Lock() defer s.recordsMu.Unlock() @@ -1078,6 +1303,38 @@ func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupS if err != nil { return err } + remaining := records[:0] + found := false + for _, record := range records { + if record.ID == id { + found = true + continue + } + remaining = append(remaining, record) + } + if !found { + return ErrBackupNotFound + } + return s.saveRecordsLocked(ctx, remaining) +} + +func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupScheduleConfig) error { + if schedule == nil { + return nil + } + release, ok := s.tryAcquireOperationLock(ctx) + if !ok { + return ErrBackupInProgress + } + defer release() + if err := s.ensureNoLocalBackupOperation(); err != nil { + return err + } + + records, err := s.loadRecords(ctx) + if err != nil { + return err + } // 按时间倒序 sort.Slice(records, func(i, j int) bool { @@ -1085,8 +1342,6 @@ func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupS }) var toDelete []BackupRecord - var toKeep []BackupRecord - for i, r := range records { shouldDelete := false @@ -1103,10 +1358,8 @@ func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupS } } - if shouldDelete && r.Status == "completed" { + if shouldDelete && r.Status == "completed" && r.RestoreStatus != "running" { toDelete = append(toDelete, r) - } else { - toKeep = append(toKeep, r) } } @@ -1115,11 +1368,13 @@ func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupS if r.S3Key != "" { _ = s.deleteS3Object(ctx, r.S3Key) } + if err := s.deleteRecord(ctx, r.ID); err != nil && !errors.Is(err, ErrBackupNotFound) { + return err + } } if len(toDelete) > 0 { logger.LegacyPrintf("service.backup", "[Backup] 自动清理了 %d 个过期备份", len(toDelete)) - return s.saveRecordsLocked(ctx, toKeep) } return nil } diff --git a/backend/internal/service/backup_service_test.go b/backend/internal/service/backup_service_test.go index b308e6d09d8..6840f3de20e 100644 --- a/backend/internal/service/backup_service_test.go +++ b/backend/internal/service/backup_service_test.go @@ -10,9 +10,11 @@ import ( "io" "strings" "sync" + "sync/atomic" "testing" "time" + "github.com/robfig/cron/v3" "github.com/stretchr/testify/require" "github.com/Wei-Shaw/sub2api/internal/config" @@ -109,13 +111,15 @@ func (e *plainEncryptor) Decrypt(ciphertext string) (string, error) { } type mockDumper struct { - dumpData []byte - dumpErr error - restored []byte - restErr error + dumpData []byte + dumpErr error + restored []byte + restErr error + dumpCalls atomic.Int64 } func (m *mockDumper) Dump(_ context.Context) (io.ReadCloser, error) { + m.dumpCalls.Add(1) if m.dumpErr != nil { return nil, m.dumpErr } @@ -203,6 +207,29 @@ func (m *mockObjectStore) HeadBucket(_ context.Context) error { return nil } +type recordingStoreFactory struct { + mu sync.Mutex + stores []*mockObjectStore + configs []BackupS3Config +} + +func (f *recordingStoreFactory) Create(_ context.Context, cfg *BackupS3Config) (BackupObjectStore, error) { + f.mu.Lock() + defer f.mu.Unlock() + store := newMockObjectStore() + f.stores = append(f.stores, store) + if cfg != nil { + f.configs = append(f.configs, *cfg) + } + return store, nil +} + +func (f *recordingStoreFactory) CallCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.stores) +} + func newTestBackupService(repo *mockSettingRepo, dumper DBDumper, store *mockObjectStore) *BackupService { cfg := &config.Config{ Database: config.DatabaseConfig{ @@ -218,6 +245,18 @@ func newTestBackupService(repo *mockSettingRepo, dumper DBDumper, store *mockObj return NewBackupService(repo, cfg, &plainEncryptor{}, factory, dumper) } +func newTestBackupServiceWithFactory(repo *mockSettingRepo, dumper DBDumper, factory BackupObjectStoreFactory) *BackupService { + cfg := &config.Config{ + Database: config.DatabaseConfig{ + Host: "localhost", + Port: 5432, + User: "test", + DBName: "testdb", + }, + } + return NewBackupService(repo, cfg, &plainEncryptor{}, factory, dumper) +} + func seedS3Config(t *testing.T, repo *mockSettingRepo) { t.Helper() cfg := BackupS3Config{ @@ -230,6 +269,76 @@ func seedS3Config(t *testing.T, repo *mockSettingRepo) { require.NoError(t, repo.Set(context.Background(), settingKeyBackupS3Config, string(data))) } +func seedCustomS3Config(t *testing.T, repo *mockSettingRepo, cfg BackupS3Config) { + t.Helper() + data, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, repo.Set(context.Background(), settingKeyBackupS3Config, string(data))) +} + +func seedBackupSchedule(t *testing.T, repo *mockSettingRepo, cfg BackupScheduleConfig) { + t.Helper() + data, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, repo.Set(context.Background(), settingKeyBackupSchedule, string(data))) +} + +type memoryBackupRecordRepo struct { + mu sync.Mutex + records map[string]BackupRecord +} + +func newMemoryBackupRecordRepo() *memoryBackupRecordRepo { + return &memoryBackupRecordRepo{records: make(map[string]BackupRecord)} +} + +func (r *memoryBackupRecordRepo) List(context.Context) ([]BackupRecord, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]BackupRecord, 0, len(r.records)) + for _, record := range r.records { + out = append(out, record) + } + return out, nil +} + +func (r *memoryBackupRecordRepo) Get(_ context.Context, id string) (*BackupRecord, error) { + r.mu.Lock() + defer r.mu.Unlock() + record, ok := r.records[id] + if !ok { + return nil, ErrBackupNotFound + } + return &record, nil +} + +func (r *memoryBackupRecordRepo) Upsert(_ context.Context, record *BackupRecord) error { + r.mu.Lock() + defer r.mu.Unlock() + r.records[record.ID] = *record + return nil +} + +func (r *memoryBackupRecordRepo) Update(_ context.Context, record *BackupRecord) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.records[record.ID]; !ok { + return ErrBackupNotFound + } + r.records[record.ID] = *record + return nil +} + +func (r *memoryBackupRecordRepo) Delete(_ context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.records[id]; !ok { + return ErrBackupNotFound + } + delete(r.records, id) + return nil +} + // ─── Tests ─── func TestBackupService_S3ConfigEncryption(t *testing.T) { @@ -286,6 +395,45 @@ func TestBackupService_S3ConfigKeepExistingSecret(t *testing.T) { require.NoError(t, err) require.Equal(t, "original-secret", internal.SecretAccessKey) require.Equal(t, "AKID-NEW", internal.AccessKeyID) + + raw, err := repo.GetValue(context.Background(), settingKeyBackupS3Config) + require.NoError(t, err) + var stored BackupS3Config + require.NoError(t, json.Unmarshal([]byte(raw), &stored)) + require.Equal(t, "ENC:original-secret", stored.SecretAccessKey) +} + +func TestBackupService_RecreatesObjectStoreWhenS3ConfigChangesInSettings(t *testing.T) { + repo := newMockSettingRepo() + factory := &recordingStoreFactory{} + svc := newTestBackupServiceWithFactory(repo, &mockDumper{}, factory.Create) + + seedCustomS3Config(t, repo, BackupS3Config{ + Bucket: "bucket-a", + AccessKeyID: "AKID-A", + SecretAccessKey: "ENC:secret-a", + Prefix: "backups-a", + }) + cfg, err := svc.loadS3Config(context.Background()) + require.NoError(t, err) + storeA, err := svc.getOrCreateStore(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, 1, factory.CallCount()) + + seedCustomS3Config(t, repo, BackupS3Config{ + Bucket: "bucket-b", + AccessKeyID: "AKID-B", + SecretAccessKey: "ENC:secret-b", + Prefix: "backups-b", + }) + cfg, err = svc.loadS3Config(context.Background()) + require.NoError(t, err) + storeB, err := svc.getOrCreateStore(context.Background(), cfg) + require.NoError(t, err) + + require.NotSame(t, storeA, storeB, "cached object store must be rebuilt when another instance changes S3 settings") + require.Equal(t, 2, factory.CallCount()) + require.Equal(t, "bucket-b", factory.configs[1].Bucket) } func TestBackupService_SaveRecordConcurrency(t *testing.T) { @@ -313,6 +461,48 @@ func TestBackupService_SaveRecordConcurrency(t *testing.T) { require.Len(t, records, n) } +func TestBackupService_UsesRecordRepositoryWhenConfigured(t *testing.T) { + settingRepo := newMockSettingRepo() + recordRepo := newMemoryBackupRecordRepo() + svc := newTestBackupService(settingRepo, &mockDumper{}, newMockObjectStore()) + svc.SetBackupRecordRepository(recordRepo) + + err := svc.saveRecord(context.Background(), &BackupRecord{ + ID: "repo-1", + Status: "completed", + StartedAt: time.Now().Format(time.RFC3339), + }) + + require.NoError(t, err) + raw, _ := settingRepo.GetValue(context.Background(), settingKeyBackupRecords) + require.Empty(t, raw, "backup records must not be written to settings when repository is configured") + record, err := recordRepo.Get(context.Background(), "repo-1") + require.NoError(t, err) + require.Equal(t, "completed", record.Status) +} + +func TestBackupService_UpdateRecordDoesNotRecreateDeletedRecord(t *testing.T) { + settingRepo := newMockSettingRepo() + recordRepo := newMemoryBackupRecordRepo() + svc := newTestBackupService(settingRepo, &mockDumper{}, newMockObjectStore()) + svc.SetBackupRecordRepository(recordRepo) + + record := &BackupRecord{ + ID: "running-1", + Status: "running", + StartedAt: time.Now().Format(time.RFC3339), + } + require.NoError(t, svc.saveRecord(context.Background(), record)) + require.NoError(t, svc.deleteRecord(context.Background(), record.ID)) + + record.Status = "completed" + err := svc.updateRecord(context.Background(), record) + + require.ErrorIs(t, err, ErrBackupNotFound) + _, err = svc.GetBackupRecord(context.Background(), record.ID) + require.ErrorIs(t, err, ErrBackupNotFound) +} + func TestBackupService_LoadRecords_Empty(t *testing.T) { repo := newMockSettingRepo() svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) @@ -393,6 +583,36 @@ func TestBackupService_CreateBackup_ConcurrentBlocked(t *testing.T) { require.ErrorIs(t, err, ErrBackupInProgress) } +func TestBackupService_CreateBackup_DistributedLockBlocksPeer(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumper := &mockDumper{dumpData: []byte("data")} + svc := newTestBackupService(repo, dumper, newMockObjectStore()) + cache := &fakeLeaderLockCache{} + _, _ = cache.TryAcquireLeaderLock(context.Background(), backupOperationLeaderLockKey, "peer", time.Minute) + svc.SetLeaderLock(cache, nil) + + _, err := svc.CreateBackup(context.Background(), "manual", 14) + + require.ErrorIs(t, err, ErrBackupInProgress) + require.Zero(t, dumper.dumpCalls.Load(), "peer-held operation lock must block before pg_dump") +} + +func TestBackupService_CreateBackup_SkipsWhenLeaderCacheErrors(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumper := &mockDumper{dumpData: []byte("data")} + svc := newTestBackupService(repo, dumper, newMockObjectStore()) + svc.SetLeaderLock(&fakeLeaderLockCache{acquireErr: context.DeadlineExceeded}, nil) + + _, err := svc.CreateBackup(context.Background(), "manual", 14) + + require.ErrorIs(t, err, ErrBackupInProgress) + require.Zero(t, dumper.dumpCalls.Load(), "cache errors must not fall through to ungated backup execution") +} + func TestBackupService_RestoreBackup_Streaming(t *testing.T) { repo := newMockSettingRepo() seedS3Config(t, repo) @@ -429,6 +649,26 @@ func TestBackupService_RestoreBackup_NotCompleted(t *testing.T) { require.Error(t, err) } +func TestBackupService_RestoreBackup_DistributedLockBlocksPeer(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumper := &mockDumper{dumpData: []byte("data")} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + record, err := svc.CreateBackup(context.Background(), "manual", 14) + require.NoError(t, err) + + cache := &fakeLeaderLockCache{} + _, _ = cache.TryAcquireLeaderLock(context.Background(), backupOperationLeaderLockKey, "peer", time.Minute) + svc.SetLeaderLock(cache, nil) + + err = svc.RestoreBackup(context.Background(), record.ID) + + require.ErrorIs(t, err, ErrRestoreInProgress) + require.Empty(t, dumper.restored, "peer-held operation lock must block before restore") +} + func TestBackupService_DeleteBackup(t *testing.T) { repo := newMockSettingRepo() seedS3Config(t, repo) @@ -460,6 +700,33 @@ func TestBackupService_DeleteBackup(t *testing.T) { require.ErrorIs(t, err, ErrBackupNotFound) } +func TestBackupService_DeleteBackupRejectsRunningRestore(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + record := &BackupRecord{ + ID: "restore-running", + Status: "completed", + S3Key: "backups/running.sql.gz", + StartedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339), + RestoreStatus: "running", + } + require.NoError(t, svc.saveRecord(context.Background(), record)) + store.objects[record.S3Key] = []byte("backup") + + err := svc.DeleteBackup(context.Background(), record.ID) + + require.ErrorIs(t, err, ErrRestoreInProgress) + got, getErr := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, getErr) + require.Equal(t, "running", got.RestoreStatus) + store.mu.Lock() + _, stillExists := store.objects[record.S3Key] + store.mu.Unlock() + require.True(t, stillExists, "running restore backup object must not be deleted") +} + func TestBackupService_GetDownloadURL(t *testing.T) { repo := newMockSettingRepo() seedS3Config(t, repo) @@ -541,6 +808,82 @@ func TestBackupService_Schedule_CronValidation(t *testing.T) { require.Error(t, err) } +func TestBackupService_SyncCronScheduleReloadsChangedConfig(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + svc.cronSched = cron.New() + + seedBackupSchedule(t, repo, BackupScheduleConfig{Enabled: true, CronExpr: "* * * * *", RetainDays: 7}) + require.NoError(t, svc.syncCronScheduleFromSettings(context.Background())) + require.Equal(t, "* * * * *", svc.cronExpr) + firstEntry := svc.cronEntryID + require.NotZero(t, firstEntry) + + seedBackupSchedule(t, repo, BackupScheduleConfig{Enabled: true, CronExpr: "*/5 * * * *", RetainDays: 7}) + require.NoError(t, svc.syncCronScheduleFromSettings(context.Background())) + require.Equal(t, "*/5 * * * *", svc.cronExpr) + require.NotZero(t, svc.cronEntryID) + require.NotEqual(t, firstEntry, svc.cronEntryID) + + seedBackupSchedule(t, repo, BackupScheduleConfig{Enabled: false, CronExpr: "*/5 * * * *"}) + require.NoError(t, svc.syncCronScheduleFromSettings(context.Background())) + require.Zero(t, svc.cronEntryID) + require.Empty(t, svc.cronExpr) +} + +func TestBackupService_RunScheduledBackupSkipsDisabledCurrentSchedule(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + seedBackupSchedule(t, repo, BackupScheduleConfig{Enabled: false, CronExpr: "* * * * *"}) + + dumper := &mockDumper{dumpData: []byte("data")} + svc := newTestBackupService(repo, dumper, newMockObjectStore()) + + svc.runScheduledBackup("* * * * *") + + require.Zero(t, dumper.dumpCalls.Load(), "disabled current schedule must make stale cron entries no-op") +} + +func TestBackupService_RunScheduledBackupSkipsStaleCronExpression(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + seedBackupSchedule(t, repo, BackupScheduleConfig{Enabled: true, CronExpr: "*/5 * * * *", RetainDays: 7}) + + dumper := &mockDumper{dumpData: []byte("data")} + svc := newTestBackupService(repo, dumper, newMockObjectStore()) + + svc.runScheduledBackup("* * * * *") + + require.Zero(t, dumper.dumpCalls.Load(), "old cron entries must not run after schedule expression changes") +} + +func TestBackupService_CleanupOldBackupsSkipsRunningRestore(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + record := &BackupRecord{ + ID: "old-restore-running", + Status: "completed", + S3Key: "backups/old.sql.gz", + StartedAt: time.Now().Add(-48 * time.Hour).Format(time.RFC3339), + RestoreStatus: "running", + } + require.NoError(t, svc.saveRecord(context.Background(), record)) + store.objects[record.S3Key] = []byte("backup") + + err := svc.cleanupOldBackups(context.Background(), &BackupScheduleConfig{RetainDays: 1}) + + require.NoError(t, err) + got, getErr := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, getErr) + require.Equal(t, "running", got.RestoreStatus) + store.mu.Lock() + _, stillExists := store.objects[record.S3Key] + store.mu.Unlock() + require.True(t, stillExists, "cleanup must not delete S3 object for a running restore") +} + func TestBackupService_LoadS3Config_Corrupted(t *testing.T) { repo := newMockSettingRepo() _ = repo.Set(context.Background(), settingKeyBackupS3Config, "not json!!!!") @@ -597,6 +940,22 @@ func TestStartBackup_ConcurrentBlocked(t *testing.T) { svc.wg.Wait() } +func TestStartBackup_DistributedLockBlocksPeer(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumper := &mockDumper{dumpData: []byte("data")} + svc := newTestBackupService(repo, dumper, newMockObjectStore()) + cache := &fakeLeaderLockCache{} + _, _ = cache.TryAcquireLeaderLock(context.Background(), backupOperationLeaderLockKey, "peer", time.Minute) + svc.SetLeaderLock(cache, nil) + + _, err := svc.StartBackup(context.Background(), "manual", 14) + + require.ErrorIs(t, err, ErrBackupInProgress) + require.Zero(t, dumper.dumpCalls.Load(), "peer-held operation lock must block before async pg_dump") +} + func TestStartBackup_ShuttingDown(t *testing.T) { repo := newMockSettingRepo() seedS3Config(t, repo) @@ -638,6 +997,116 @@ func TestRecoverStaleRecords(t *testing.T) { require.Contains(t, r2.RestoreError, "server restart") } +func TestRecoverStaleRecords_SkipsWhenOperationLockHeldByPeer(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + _ = svc.saveRecord(context.Background(), &BackupRecord{ + ID: "running-elsewhere", + Status: "running", + StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + }) + cache := &fakeLeaderLockCache{} + _, _ = cache.TryAcquireLeaderLock(context.Background(), backupOperationLeaderLockKey, "peer", time.Minute) + svc.SetLeaderLock(cache, nil) + + svc.recoverStaleRecords() + + record, err := svc.GetBackupRecord(context.Background(), "running-elsewhere") + require.NoError(t, err) + require.Equal(t, "running", record.Status, "startup recovery must not fail a backup owned by another instance") +} + +func TestRecoverStaleRecords_SkipsWhenLocalOperationActive(t *testing.T) { + t.Run("backup", func(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + _ = svc.saveRecord(context.Background(), &BackupRecord{ + ID: "local-backup", + Status: "running", + StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + }) + svc.opMu.Lock() + svc.backingUp = true + svc.opMu.Unlock() + defer func() { + svc.opMu.Lock() + svc.backingUp = false + svc.opMu.Unlock() + }() + + svc.recoverStaleRecords() + + record, err := svc.GetBackupRecord(context.Background(), "local-backup") + require.NoError(t, err) + require.Equal(t, "running", record.Status, "local active backup must not be recovered as stale") + }) + + t.Run("restore", func(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + _ = svc.saveRecord(context.Background(), &BackupRecord{ + ID: "local-restore", + Status: "completed", + RestoreStatus: "running", + StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + }) + svc.opMu.Lock() + svc.restoring = true + svc.opMu.Unlock() + defer func() { + svc.opMu.Lock() + svc.restoring = false + svc.opMu.Unlock() + }() + + svc.recoverStaleRecords() + + record, err := svc.GetBackupRecord(context.Background(), "local-restore") + require.NoError(t, err) + require.Equal(t, "running", record.RestoreStatus, "local active restore must not be recovered as stale") + }) +} + +func TestStaleRecoveryLoop_RetriesAfterOperationLockReleased(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + _ = svc.saveRecord(context.Background(), &BackupRecord{ + ID: "crashed-backup", + Status: "running", + StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + }) + cache := &fakeLeaderLockCache{} + _, _ = cache.TryAcquireLeaderLock(context.Background(), backupOperationLeaderLockKey, "peer", time.Minute) + svc.SetLeaderLock(cache, nil) + svc.startStaleRecoveryLoop(10 * time.Millisecond) + t.Cleanup(svc.Stop) + + time.Sleep(30 * time.Millisecond) + record, err := svc.GetBackupRecord(context.Background(), "crashed-backup") + require.NoError(t, err) + require.Equal(t, "running", record.Status, "record should remain running while a peer owns the operation lock") + + require.NoError(t, cache.ReleaseLeaderLock(context.Background(), backupOperationLeaderLockKey, "peer")) + waitForBackupCondition(t, time.Second, "stale record recovered after peer lock released", func() bool { + record, err := svc.GetBackupRecord(context.Background(), "crashed-backup") + return err == nil && record.Status == "failed" + }) +} + +func waitForBackupCondition(t *testing.T, timeout time.Duration, msg string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + if !cond() { + t.Fatalf("waitForBackupCondition timed out: %s", msg) + } +} + func TestGracefulShutdown(t *testing.T) { repo := newMockSettingRepo() seedS3Config(t, repo) diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index 61f9f79894f..46bf8c2036b 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -147,6 +147,9 @@ var ( ErrChannelMonitorMissingPrimaryModel = infraerrors.BadRequest( "CHANNEL_MONITOR_MISSING_PRIMARY_MODEL", "primary_model is required", ) + ErrChannelMonitorDisabled = infraerrors.BadRequest( + "CHANNEL_MONITOR_DISABLED", "channel monitor is disabled", + ) ErrChannelMonitorAPIKeyDecryptFailed = infraerrors.InternalServer( "CHANNEL_MONITOR_KEY_DECRYPT_FAILED", "api key decryption failed; please re-edit the monitor with a fresh key", ) diff --git a/backend/internal/service/channel_monitor_runner.go b/backend/internal/service/channel_monitor_runner.go index 748ffc36380..6f408d7738e 100644 --- a/backend/internal/service/channel_monitor_runner.go +++ b/backend/internal/service/channel_monitor_runner.go @@ -2,12 +2,21 @@ package service import ( "context" + "database/sql" "log/slog" "math/rand/v2" + "strconv" "sync" "time" "github.com/alitto/pond/v2" + "github.com/google/uuid" +) + +const ( + channelMonitorRunLeaderLockTTL = monitorRequestTimeout + monitorPingTimeout + monitorRunOneBuffer + 30*time.Second + channelMonitorReconcileInterval = time.Minute + channelMonitorReconcileTimeout = monitorStartupLoadTimeout ) // MonitorScheduler 调度器接口,供 ChannelMonitorService 在 CRUD 时回调, @@ -28,7 +37,7 @@ type MonitorScheduler interface { // 避免依赖完整的 repo + encryptor 链路。生产实现 *ChannelMonitorService 自然满足。 type monitorRunnerSvc interface { ListEnabledMonitors(ctx context.Context) ([]*ChannelMonitor, error) - RunCheck(ctx context.Context, id int64) ([]*CheckResult, error) + RunScheduledCheck(ctx context.Context, id int64) ([]*CheckResult, error) } // ChannelMonitorRunner 渠道监控调度器。 @@ -62,6 +71,10 @@ type ChannelMonitorRunner struct { // 防止单次检测耗时 > interval 时同一 monitor 被并发执行。 inFlight map[int64]struct{} inFlightMu sync.Mutex + + lockCache LeaderLockCache + db *sql.DB + instanceID string } // scheduledMonitor 单个监控的运行时上下文。 @@ -108,7 +121,23 @@ func newChannelMonitorRunner(svc monitorRunnerSvc, settingService *SettingServic parentCancel: cancel, tasks: make(map[int64]*scheduledMonitor), inFlight: make(map[int64]struct{}), + instanceID: uuid.NewString(), + } +} + +// SetLeaderLock injects the distributed lock backend used to ensure a monitor ID +// is checked by at most one app instance at a time. With no backend, checks keep +// single-instance/test behavior and run ungated. +func (r *ChannelMonitorRunner) SetLeaderLock(lockCache LeaderLockCache, db *sql.DB) { + if r == nil { + return } + r.lockCache = lockCache + r.db = db +} + +func channelMonitorRunLeaderLockKey(id int64) string { + return "channel_monitor:run:" + strconv.FormatInt(id, 10) } // Start 加载所有 enabled monitor 并为每个建立独立定时任务。 @@ -130,14 +159,103 @@ func (r *ChannelMonitorRunner) Start() { enabled, err := r.svc.ListEnabledMonitors(ctx) if err != nil { slog.Error("channel_monitor: load enabled monitors failed at startup", "error", err) + r.startReconcileLoop(channelMonitorReconcileInterval) + slog.Info("channel_monitor: runner started", "scheduled_tasks", 0) return } for _, m := range enabled { r.Schedule(m) } + r.startReconcileLoop(channelMonitorReconcileInterval) slog.Info("channel_monitor: runner started", "scheduled_tasks", len(enabled)) } +func (r *ChannelMonitorRunner) startReconcileLoop(interval time.Duration) { + if r == nil || interval <= 0 { + return + } + r.wg.Add(1) + go r.runReconcileLoop(r.parentCtx, interval) +} + +func (r *ChannelMonitorRunner) runReconcileLoop(ctx context.Context, interval time.Duration) { + defer r.wg.Done() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + reconcileCtx, cancel := context.WithTimeout(ctx, channelMonitorReconcileTimeout) + if err := r.reconcileEnabledMonitors(reconcileCtx); err != nil { + slog.Warn("channel_monitor: reconcile enabled monitors failed", "error", err) + } + cancel() + } + } +} + +func (r *ChannelMonitorRunner) reconcileEnabledMonitors(ctx context.Context) error { + if r == nil || r.svc == nil { + return nil + } + enabled, err := r.svc.ListEnabledMonitors(ctx) + if err != nil { + return err + } + + desired := make(map[int64]*ChannelMonitor, len(enabled)) + for _, m := range enabled { + if m == nil || !m.Enabled { + continue + } + desired[m.ID] = m + if r.needsScheduleRefresh(m) { + r.Schedule(m) + } + } + + var staleIDs []int64 + r.mu.Lock() + if !r.stopped { + for id := range r.tasks { + if _, ok := desired[id]; !ok { + staleIDs = append(staleIDs, id) + } + } + } + r.mu.Unlock() + for _, id := range staleIDs { + r.Unschedule(id) + } + return nil +} + +func (r *ChannelMonitorRunner) needsScheduleRefresh(m *ChannelMonitor) bool { + if r == nil || m == nil || !m.Enabled { + return false + } + interval := time.Duration(m.IntervalSeconds) * time.Second + jitter := time.Duration(m.JitterSeconds) * time.Second + if jitter < 0 { + jitter = 0 + } + + r.mu.Lock() + defer r.mu.Unlock() + if r.stopped || !r.started { + return false + } + existing, ok := r.tasks[m.ID] + if !ok { + return true + } + return existing.name != m.Name || + existing.interval != interval || + existing.jitter != jitter +} + // Schedule 为指定监控创建(或重置)独立定时任务。 // - m.Enabled=false → 等同于 Unschedule(m.ID) // - 已存在的任务会先被取消再重建(适用于 IntervalSeconds 变更场景) @@ -308,7 +426,15 @@ func (r *ChannelMonitorRunner) runOne(id int64, name string) { } }() - if _, err := r.svc.RunCheck(ctx, id); err != nil { + release, ok := tryAcquireStrictSingletonLeaderLock(ctx, r.lockCache, r.db, channelMonitorRunLeaderLockKey(id), r.instanceID, channelMonitorRunLeaderLockTTL) + if !ok { + slog.Debug("channel_monitor: skip monitor check, lock held by peer", + "monitor_id", id, "name", name) + return + } + defer release() + + if _, err := r.svc.RunScheduledCheck(ctx, id); err != nil { slog.Warn("channel_monitor: run check failed", "monitor_id", id, "name", name, "error", err) } diff --git a/backend/internal/service/channel_monitor_runner_test.go b/backend/internal/service/channel_monitor_runner_test.go index 5eed3c2092a..e7430c77c9f 100644 --- a/backend/internal/service/channel_monitor_runner_test.go +++ b/backend/internal/service/channel_monitor_runner_test.go @@ -12,6 +12,7 @@ import ( // stubMonitorSvc 实现 monitorRunnerSvc,用于隔离 runner 与真实 service/repo。 type stubMonitorSvc struct { + mu sync.Mutex enabled []*ChannelMonitor runCount atomic.Int64 runCalled chan int64 // 每次 RunCheck 触发时 push 一次(缓冲足够大避免阻塞) @@ -21,13 +22,21 @@ type stubMonitorSvc struct { } func (s *stubMonitorSvc) ListEnabledMonitors(_ context.Context) ([]*ChannelMonitor, error) { + s.mu.Lock() + defer s.mu.Unlock() if s.listErr != nil { return nil, s.listErr } - return s.enabled, nil + return append([]*ChannelMonitor(nil), s.enabled...), nil } -func (s *stubMonitorSvc) RunCheck(ctx context.Context, id int64) ([]*CheckResult, error) { +func (s *stubMonitorSvc) setEnabled(monitors ...*ChannelMonitor) { + s.mu.Lock() + defer s.mu.Unlock() + s.enabled = append([]*ChannelMonitor(nil), monitors...) +} + +func (s *stubMonitorSvc) RunScheduledCheck(ctx context.Context, id int64) ([]*CheckResult, error) { s.runCount.Add(1) if s.runCalled != nil { select { @@ -75,6 +84,20 @@ func runnerTaskPtr(r *ChannelMonitorRunner, id int64) *scheduledMonitor { return r.tasks[id] } +func requireRunnerTaskIDs(t *testing.T, r *ChannelMonitorRunner, ids ...int64) { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + if len(r.tasks) != len(ids) { + t.Fatalf("expected %d tasks, got %d", len(ids), len(r.tasks)) + } + for _, id := range ids { + if _, ok := r.tasks[id]; !ok { + t.Fatalf("expected task %d to be scheduled", id) + } + } +} + // TestSchedule_AddsTaskAndFiresOnce 验证 Schedule 后立即触发一次首检测,并把任务记入 tasks 表。 func TestSchedule_AddsTaskAndFiresOnce(t *testing.T) { svc := &stubMonitorSvc{runCalled: make(chan int64, 4)} @@ -201,6 +224,36 @@ func TestStart_LoadsAllEnabledMonitors(t *testing.T) { stoppedWithin(t, r, 3*time.Second) } +func TestReconcileEnabledMonitorsAddsUpdatesAndRemovesTasks(t *testing.T) { + svc := &stubMonitorSvc{} + svc.setEnabled( + &ChannelMonitor{ID: 1, Name: "m1", Enabled: true, IntervalSeconds: 60}, + &ChannelMonitor{ID: 2, Name: "m2", Enabled: true, IntervalSeconds: 60}, + ) + r := newRunnerForTest(svc) + r.Start() + waitFor(t, 2*time.Second, "initial tasks scheduled", func() bool { return runnerTaskCount(r) == 2 }) + + svc.setEnabled( + &ChannelMonitor{ID: 2, Name: "m2-new", Enabled: true, IntervalSeconds: 120, JitterSeconds: 15}, + &ChannelMonitor{ID: 3, Name: "m3", Enabled: true, IntervalSeconds: 60}, + ) + if err := r.reconcileEnabledMonitors(context.Background()); err != nil { + t.Fatalf("reconcile failed: %v", err) + } + + requireRunnerTaskIDs(t, r, 2, 3) + task2 := runnerTaskPtr(r, 2) + if task2 == nil { + t.Fatal("expected task 2 after reconcile") + } + if task2.name != "m2-new" || task2.interval != 120*time.Second || task2.jitter != 15*time.Second { + t.Fatalf("task 2 was not refreshed from DB: %+v", task2) + } + + stoppedWithin(t, r, 3*time.Second) +} + // TestStop_DrainsAllGoroutines 验证 Stop 会等待所有调度 goroutine 退出(无游离)。 func TestStop_DrainsAllGoroutines(t *testing.T) { svc := &stubMonitorSvc{} @@ -260,6 +313,48 @@ func TestInFlight_AcquireReleaseSymmetric(t *testing.T) { r.releaseInFlight(42) } +func TestRunOne_SkipsCheckWhenMonitorLockHeldByPeer(t *testing.T) { + svc := &stubMonitorSvc{} + r := newRunnerForTest(svc) + cache := &fakeLeaderLockCache{} + _, _ = cache.TryAcquireLeaderLock(context.Background(), channelMonitorRunLeaderLockKey(42), "peer", time.Minute) + r.SetLeaderLock(cache, nil) + + r.runOne(42, "m42") + + if got := svc.runCount.Load(); got != 0 { + t.Fatalf("non-leader must not run monitor check, got %d calls", got) + } +} + +func TestRunOne_SkipsCheckWhenMonitorLockErrors(t *testing.T) { + svc := &stubMonitorSvc{} + r := newRunnerForTest(svc) + r.SetLeaderLock(&fakeLeaderLockCache{acquireErr: context.DeadlineExceeded}, nil) + + r.runOne(42, "m42") + + if got := svc.runCount.Load(); got != 0 { + t.Fatalf("lock errors must not run monitor check ungated, got %d calls", got) + } +} + +func TestRunOne_RunsAndReleasesMonitorLockWhenLeader(t *testing.T) { + svc := &stubMonitorSvc{} + r := newRunnerForTest(svc) + cache := &fakeLeaderLockCache{} + r.SetLeaderLock(cache, nil) + + r.runOne(42, "m42") + + if got := svc.runCount.Load(); got != 1 { + t.Fatalf("leader should run monitor check once, got %d calls", got) + } + if owner := cache.heldBy(channelMonitorRunLeaderLockKey(42)); owner != "" { + t.Fatalf("monitor lock should be released after runOne, still held by %q", owner) + } +} + // stoppedWithin 在 timeout 内并行调用 Stop,超时则 Fatal。验证 Stop 不会阻塞。 func stoppedWithin(t *testing.T, r *ChannelMonitorRunner, timeout time.Duration) { t.Helper() diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index 7b53bb20b09..8f696987ad9 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -255,13 +255,32 @@ func (s *ChannelMonitorService) ListHistory(ctx context.Context, id int64, model // ---------- 业务 ---------- -// RunCheck 同步触发对一个监控的检测:并发跑 primary + extra 模型, +// RunCheck 同步触发一次手动检测:并发跑 primary + extra 模型, // 写历史记录并更新 last_checked_at。返回每个模型的检测结果。 +// +// 手动检测允许 disabled monitor 执行,方便管理员在启用前验证配置。 func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*CheckResult, error) { m, err := s.Get(ctx, id) // 已解密 APIKey if err != nil { return nil, err } + return s.runCheckLoaded(ctx, m) +} + +// RunScheduledCheck 是后台调度入口。disabled monitor 不应被后台执行; +// 手动入口 RunCheck 则允许 disabled monitor 运行一次配置验证。 +func (s *ChannelMonitorService) RunScheduledCheck(ctx context.Context, id int64) ([]*CheckResult, error) { + m, err := s.Get(ctx, id) // 已解密 APIKey + if err != nil { + return nil, err + } + if !m.Enabled { + return nil, ErrChannelMonitorDisabled + } + return s.runCheckLoaded(ctx, m) +} + +func (s *ChannelMonitorService) runCheckLoaded(ctx context.Context, m *ChannelMonitor) ([]*CheckResult, error) { if m.APIKeyDecryptFailed { return nil, ErrChannelMonitorAPIKeyDecryptFailed } diff --git a/backend/internal/service/channel_monitor_service_test.go b/backend/internal/service/channel_monitor_service_test.go new file mode 100644 index 00000000000..d7d628cfde1 --- /dev/null +++ b/backend/internal/service/channel_monitor_service_test.go @@ -0,0 +1,117 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type channelMonitorRepoStub struct { + monitor *ChannelMonitor + historyWrites int + markWrites int +} + +func (r *channelMonitorRepoStub) Create(context.Context, *ChannelMonitor) error { return nil } +func (r *channelMonitorRepoStub) Update(context.Context, *ChannelMonitor) error { return nil } +func (r *channelMonitorRepoStub) Delete(context.Context, int64) error { return nil } +func (r *channelMonitorRepoStub) List(context.Context, ChannelMonitorListParams) ([]*ChannelMonitor, int64, error) { + return nil, 0, nil +} +func (r *channelMonitorRepoStub) ListEnabled(context.Context) ([]*ChannelMonitor, error) { + if r.monitor == nil || !r.monitor.Enabled { + return nil, nil + } + return []*ChannelMonitor{r.monitor}, nil +} +func (r *channelMonitorRepoStub) GetByID(context.Context, int64) (*ChannelMonitor, error) { + if r.monitor == nil { + return nil, ErrChannelMonitorNotFound + } + copy := *r.monitor + return ©, nil +} +func (r *channelMonitorRepoStub) MarkChecked(context.Context, int64, time.Time) error { + r.markWrites++ + return nil +} +func (r *channelMonitorRepoStub) InsertHistoryBatch(context.Context, []*ChannelMonitorHistoryRow) error { + r.historyWrites++ + return nil +} +func (r *channelMonitorRepoStub) DeleteHistoryBefore(context.Context, time.Time) (int64, error) { + return 0, nil +} +func (r *channelMonitorRepoStub) ListHistory(context.Context, int64, string, int) ([]*ChannelMonitorHistoryEntry, error) { + return nil, nil +} +func (r *channelMonitorRepoStub) ListLatestPerModel(context.Context, int64) ([]*ChannelMonitorLatest, error) { + return nil, nil +} +func (r *channelMonitorRepoStub) ComputeAvailability(context.Context, int64, int) ([]*ChannelMonitorAvailability, error) { + return nil, nil +} +func (r *channelMonitorRepoStub) ListLatestForMonitorIDs(context.Context, []int64) (map[int64][]*ChannelMonitorLatest, error) { + return nil, nil +} +func (r *channelMonitorRepoStub) ComputeAvailabilityForMonitors(context.Context, []int64, int) (map[int64][]*ChannelMonitorAvailability, error) { + return nil, nil +} +func (r *channelMonitorRepoStub) ListRecentHistoryForMonitors(context.Context, []int64, map[int64]string, int) (map[int64][]*ChannelMonitorHistoryEntry, error) { + return nil, nil +} +func (r *channelMonitorRepoStub) UpsertDailyRollupsFor(context.Context, time.Time) (int64, error) { + return 0, nil +} +func (r *channelMonitorRepoStub) DeleteRollupsBefore(context.Context, time.Time) (int64, error) { + return 0, nil +} +func (r *channelMonitorRepoStub) LoadAggregationWatermark(context.Context) (*time.Time, error) { + return nil, nil +} +func (r *channelMonitorRepoStub) UpdateAggregationWatermark(context.Context, time.Time) error { + return nil +} + +func TestChannelMonitorService_RunScheduledCheckRejectsDisabledMonitor(t *testing.T) { + repo := &channelMonitorRepoStub{monitor: &ChannelMonitor{ + ID: 42, + Enabled: false, + Provider: MonitorProviderOpenAI, + Endpoint: "https://api.example.com", + APIKey: "ENC:test-key", + PrimaryModel: "gpt-test", + IntervalSeconds: 60, + }} + svc := NewChannelMonitorService(repo, &plainEncryptor{}) + + _, err := svc.RunScheduledCheck(context.Background(), 42) + + require.ErrorIs(t, err, ErrChannelMonitorDisabled) + require.Zero(t, repo.historyWrites, "disabled scheduled checks must not write history") + require.Zero(t, repo.markWrites, "disabled scheduled checks must not update last_checked_at") +} + +func TestChannelMonitorService_RunCheckDoesNotRejectDisabledManualCheck(t *testing.T) { + repo := &channelMonitorRepoStub{monitor: &ChannelMonitor{ + ID: 42, + Enabled: false, + Provider: MonitorProviderOpenAI, + Endpoint: "https://api.example.com", + APIKey: "legacy-plaintext-key", + PrimaryModel: "gpt-test", + IntervalSeconds: 60, + }} + svc := NewChannelMonitorService(repo, &plainEncryptor{}) + + _, err := svc.RunCheck(context.Background(), 42) + + require.ErrorIs(t, err, ErrChannelMonitorAPIKeyDecryptFailed) + require.NotErrorIs(t, err, ErrChannelMonitorDisabled) + require.Zero(t, repo.historyWrites) + require.Zero(t, repo.markWrites) +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index ab853c280a3..bf5e534fa0f 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -123,6 +123,29 @@ const WeChatConnectSyntheticEmailDomain = "@wechat-connect.invalid" // DingTalkConnectSyntheticEmailDomain 是 DingTalk Connect 用户的合成邮箱后缀(RFC 保留域名)。 const DingTalkConnectSyntheticEmailDomain = "@dingtalk-connect.invalid" +// FeishuConnectSyntheticEmailDomain 是 Feishu Connect 用户的合成邮箱后缀(RFC 保留域名)。 +const FeishuConnectSyntheticEmailDomain = "@feishu-connect.invalid" + +const ( + FeishuTenantRestrictionNone = "none" + FeishuTenantRestrictionInternalOnly = "internal_only" + FeishuDepartedUserActionAutoDisable = "auto_disable" + FeishuDefaultDisableThresholdCount = 10 + FeishuDefaultDisableThresholdPct = 20 +) + +// Auth source constants +const ( + AuthSourceEmail = "email" + AuthSourceLinuxDo = "linuxdo" + AuthSourceOIDC = "oidc" + AuthSourceWeChat = "wechat" + AuthSourceGitHub = "github" + AuthSourceGoogle = "google" + AuthSourceDingTalk = "dingtalk" + AuthSourceFeishu = "feishu" +) + // Setting keys const ( // 注册设置 @@ -146,6 +169,8 @@ const ( SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) + SettingKeyEmailPasswordLoginEnabled = "email_password_login_enabled" // 是否允许普通用户邮箱密码登录 + SettingKeyAdminEmailLoginFallbackEnabled = "admin_email_login_fallback_enabled" // 普通邮箱登录关闭时是否保留超管邮箱兜底入口 // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 @@ -191,6 +216,22 @@ const ( SettingKeyDingTalkConnectSyncDisplayNameAttrName = "dingtalk_connect_sync_display_name_attr_name" SettingKeyDingTalkConnectSyncDeptAttrName = "dingtalk_connect_sync_dept_attr_name" + // Feishu Connect OAuth 登录和组织同步设置 + SettingKeyFeishuConnectEnabled = "feishu_connect_enabled" + SettingKeyFeishuConnectAppID = "feishu_connect_app_id" + SettingKeyFeishuConnectAppSecret = "feishu_connect_app_secret" + SettingKeyFeishuConnectRedirectURL = "feishu_connect_redirect_url" + SettingKeyFeishuConnectTenantRestrictionPolicy = "feishu_connect_tenant_restriction_policy" + SettingKeyFeishuConnectAllowedTenantKey = "feishu_connect_allowed_tenant_key" + SettingKeyFeishuConnectBypassRegistration = "feishu_connect_bypass_registration" + SettingKeyFeishuConnectSyncEmail = "feishu_connect_sync_email" + SettingKeyFeishuConnectSyncDisplayName = "feishu_connect_sync_display_name" + SettingKeyFeishuConnectSyncDepartment = "feishu_connect_sync_department" + SettingKeyFeishuOrgSyncEnabled = "feishu_org_sync_enabled" + SettingKeyFeishuDepartedUserAction = "feishu_departed_user_action" + SettingKeyFeishuSyncDisableThresholdCount = "feishu_sync_disable_threshold_count" + SettingKeyFeishuSyncDisableThresholdPercent = "feishu_sync_disable_threshold_percent" + // WeChat Connect OAuth 登录设置 SettingKeyWeChatConnectEnabled = "wechat_connect_enabled" SettingKeyWeChatConnectAppID = "wechat_connect_app_id" @@ -303,6 +344,11 @@ const ( SettingKeyAuthSourceDefaultDingTalkSubscriptions = "auth_source_default_dingtalk_subscriptions" SettingKeyAuthSourceDefaultDingTalkGrantOnSignup = "auth_source_default_dingtalk_grant_on_signup" SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind = "auth_source_default_dingtalk_grant_on_first_bind" + SettingKeyAuthSourceDefaultFeishuBalance = "auth_source_default_feishu_balance" + SettingKeyAuthSourceDefaultFeishuConcurrency = "auth_source_default_feishu_concurrency" + SettingKeyAuthSourceDefaultFeishuSubscriptions = "auth_source_default_feishu_subscriptions" + SettingKeyAuthSourceDefaultFeishuGrantOnSignup = "auth_source_default_feishu_grant_on_signup" + SettingKeyAuthSourceDefaultFeishuGrantOnFirstBind = "auth_source_default_feishu_grant_on_first_bind" SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup" // 管理员 API Key diff --git a/backend/internal/service/feishu_org_permissions.go b/backend/internal/service/feishu_org_permissions.go new file mode 100644 index 00000000000..b1332146a23 --- /dev/null +++ b/backend/internal/service/feishu_org_permissions.go @@ -0,0 +1,1571 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/lib/pq" +) + +const ( + FeishuGrantSourceDepartmentManager = "department_manager" + FeishuGrantSourceSuperAdmin = "super_admin_override" +) + +type FeishuOrgPermissionSnapshot struct { + ManagerDepartmentIDs []string + EmployeePrimaryDepartmentID string + EmployeeDepartmentIDs []string + DepartmentAssignableGroupIDs map[string][]int64 + DepartmentManagerGroupGrants []int64 + UserOverrideGroupGrants []int64 +} + +func (s FeishuOrgPermissionSnapshot) ManagerAssignableGroupIDs() []int64 { + deptID := strings.TrimSpace(s.EmployeePrimaryDepartmentID) + if deptID == "" { + deptID = firstString(s.EmployeeDepartmentIDs) + } + if deptID == "" || !containsString(s.ManagerDepartmentIDs, deptID) { + return nil + } + return uniqueSortedInt64s(s.DepartmentAssignableGroupIDs[deptID]) +} + +func (s FeishuOrgPermissionSnapshot) CanManagerAssignGroup(groupID int64) bool { + if groupID <= 0 { + return false + } + for _, candidate := range s.ManagerAssignableGroupIDs() { + if candidate == groupID { + return true + } + } + return false +} + +func (s FeishuOrgPermissionSnapshot) EffectiveEmployeeGroupIDs() []int64 { + ids := append([]int64{}, s.DepartmentManagerGroupGrants...) + ids = append(ids, s.UserOverrideGroupGrants...) + return uniqueSortedInt64s(ids) +} + +type feishuOrgSQLExecutor interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) +} + +type FeishuOrgPermissionService struct { + db feishuOrgSQLExecutor + authCacheInvalidator APIKeyAuthCacheInvalidator +} + +func NewFeishuOrgPermissionService(db *sql.DB, invalidator APIKeyAuthCacheInvalidator) *FeishuOrgPermissionService { + return &FeishuOrgPermissionService{ + db: db, + authCacheInvalidator: invalidator, + } +} + +type FeishuSetUserGroupGrantsInput struct { + ActorUserID int64 + TargetUserID int64 + Source string + SourceOpenDepartmentID string + GroupIDs []int64 + Reason string +} + +type FeishuUserGroupGrantResult struct { + UserID int64 `json:"user_id"` + Source string `json:"source"` + SourceOpenDepartmentID string `json:"source_open_department_id,omitempty"` + GroupIDs []int64 `json:"group_ids"` +} + +type FeishuDepartmentManagerAssignmentInput struct { + ManagerUserID int64 + TargetUserID int64 + GroupIDs []int64 + Reason string +} + +type FeishuDepartmentManagerAssignmentResult struct { + FeishuUserGroupGrantResult + TenantKey string `json:"tenant_key"` + AssignableGroupIDs []int64 `json:"assignable_group_ids"` +} + +type FeishuSetDepartmentGroupPoolInput struct { + ActorUserID int64 + TenantKey string + OpenDepartmentID string + GroupIDs []int64 + Reason string +} + +type FeishuDepartmentGroupPoolResult struct { + TenantKey string `json:"tenant_key"` + OpenDepartmentID string `json:"open_department_id"` + GroupIDs []int64 `json:"group_ids"` + RevokedUserIDs []int64 `json:"revoked_user_ids,omitempty"` +} + +type FeishuOrgListInput struct { + TenantKey string + Query string + Limit int + Offset int +} + +type FeishuOrgGroupBrief struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + SubscriptionType string `json:"subscription_type"` +} + +type FeishuOrgDepartmentView struct { + TenantKey string `json:"tenant_key"` + OpenDepartmentID string `json:"open_department_id"` + ParentOpenDepartmentID string `json:"parent_open_department_id"` + Name string `json:"name"` + Path string `json:"path"` + Status string `json:"status"` + LeaderOpenIDs []string `json:"leader_open_ids"` + EmployeeCount int64 `json:"employee_count"` + ManagerCount int64 `json:"manager_count"` + AssignableGroups []FeishuOrgGroupBrief `json:"assignable_groups"` + LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` +} + +type FeishuOrgDepartmentListResult struct { + Items []FeishuOrgDepartmentView `json:"items"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type FeishuOrgUserView struct { + UserID int64 `json:"user_id"` + LocalEmail string `json:"local_email"` + LocalUsername string `json:"local_username"` + LocalStatus string `json:"local_status"` + TenantKey string `json:"tenant_key"` + OpenID string `json:"open_id"` + UnionID string `json:"union_id"` + FeishuUserID string `json:"feishu_user_id"` + Name string `json:"name"` + Email string `json:"email"` + EmployeeNo string `json:"employee_no"` + Status string `json:"status"` + PrimaryOpenDepartmentID string `json:"primary_open_department_id"` + PrimaryDepartmentName string `json:"primary_department_name"` + PrimaryDepartmentPath string `json:"primary_department_path"` + DepartmentOpenIDs []string `json:"department_open_ids"` + DepartmentManagerGroupIDs []int64 `json:"department_manager_group_ids"` + SuperAdminOverrideGroupIDs []int64 `json:"super_admin_override_group_ids"` + EffectiveGroupIDs []int64 `json:"effective_group_ids"` + AssignableGroups []FeishuOrgGroupBrief `json:"assignable_groups,omitempty"` + LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` +} + +type FeishuOrgUserListResult struct { + Items []FeishuOrgUserView `json:"items"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type FeishuOrgSyncRunView struct { + ID int64 `json:"id"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + DepartmentsSynced int `json:"departments_synced"` + UsersSynced int `json:"users_synced"` + ManagersSynced int `json:"managers_synced"` + UsersToCreate int `json:"users_to_create"` + UsersToDisable int `json:"users_to_disable"` + BindingsMissing int `json:"bindings_missing"` + ReviewRequired bool `json:"review_required"` + ErrorMessage string `json:"error_message"` + TriggeredByUserID int64 `json:"triggered_by_user_id"` +} + +type FeishuOrgSyncRunListResult struct { + Items []FeishuOrgSyncRunView `json:"items"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type FeishuManualReconcileResult struct { + SyncRun FeishuOrgSyncRunView `json:"sync_run"` + Decision FeishuDepartureDecision `json:"decision"` +} + +func (s *FeishuOrgPermissionService) ListDepartments(ctx context.Context, input FeishuOrgListInput) (*FeishuOrgDepartmentListResult, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + tenantKey, search, limit, offset := normalizeFeishuOrgListInput(input) + searchClause := "" + countArgs := []any{tenantKey} + listArgs := []any{tenantKey} + limitPlaceholder := "$2" + offsetPlaceholder := "$3" + if search != "" { + searchClause = "\n AND " + feishuOrgDepartmentSearchCondition("$2") + countArgs = append(countArgs, search) + listArgs = append(listArgs, search) + limitPlaceholder = "$3" + offsetPlaceholder = "$4" + } + + total, err := scanFeishuCount(ctx, s.db, ` +SELECT COUNT(*) +FROM feishu_departments d +WHERE ($1 = '' OR d.tenant_key = $1) + AND d.status = 'active'`+searchClause, countArgs...) + if err != nil { + return nil, fmt.Errorf("count feishu departments: %w", err) + } + listArgs = append(listArgs, limit, offset) + rows, err := s.db.QueryContext(ctx, ` +SELECT d.tenant_key, + d.open_department_id, + d.parent_open_department_id, + d.name, + d.path, + d.status, + d.leader_open_ids::text AS leader_open_ids, + d.last_synced_at, + COALESCE(employee_counts.employee_count, 0) AS employee_count, + COALESCE(manager_counts.manager_count, 0) AS manager_count, + COALESCE(group_pools.assignable_groups, '[]'::jsonb)::text AS assignable_groups +FROM feishu_departments d +LEFT JOIN ( + SELECT tenant_key, primary_open_department_id AS open_department_id, COUNT(*) AS employee_count + FROM feishu_org_users + WHERE status = 'active' + GROUP BY tenant_key, primary_open_department_id +) employee_counts + ON employee_counts.tenant_key = d.tenant_key + AND employee_counts.open_department_id = d.open_department_id +LEFT JOIN ( + SELECT tenant_key, open_department_id, COUNT(*) AS manager_count + FROM feishu_department_managers + WHERE status = 'active' + GROUP BY tenant_key, open_department_id +) manager_counts + ON manager_counts.tenant_key = d.tenant_key + AND manager_counts.open_department_id = d.open_department_id +LEFT JOIN ( + SELECT grants.tenant_key, + grants.open_department_id, + jsonb_agg( + jsonb_build_object( + 'id', groups.id, + 'name', groups.name, + 'platform', groups.platform, + 'subscription_type', groups.subscription_type + ) + ORDER BY groups.sort_order, groups.id + ) AS assignable_groups + FROM feishu_department_group_grants grants + JOIN groups + ON groups.id = grants.group_id + AND groups.deleted_at IS NULL + GROUP BY grants.tenant_key, grants.open_department_id +) group_pools + ON group_pools.tenant_key = d.tenant_key + AND group_pools.open_department_id = d.open_department_id +WHERE ($1 = '' OR d.tenant_key = $1) + AND d.status = 'active' +`+searchClause+` +ORDER BY d.path, d.name, d.open_department_id +LIMIT `+limitPlaceholder+` OFFSET `+offsetPlaceholder, listArgs...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + items := make([]FeishuOrgDepartmentView, 0) + for rows.Next() { + var item FeishuOrgDepartmentView + var leaderRaw string + var groupsRaw string + var lastSyncedAt sql.NullTime + if err := rows.Scan( + &item.TenantKey, + &item.OpenDepartmentID, + &item.ParentOpenDepartmentID, + &item.Name, + &item.Path, + &item.Status, + &leaderRaw, + &lastSyncedAt, + &item.EmployeeCount, + &item.ManagerCount, + &groupsRaw, + ); err != nil { + return nil, err + } + leaders, err := decodeFeishuJSONStringSlice(leaderRaw) + if err != nil { + return nil, fmt.Errorf("decode department leaders: %w", err) + } + groups, err := decodeFeishuJSONGroups(groupsRaw) + if err != nil { + return nil, fmt.Errorf("decode department group pool: %w", err) + } + item.LeaderOpenIDs = leaders + item.AssignableGroups = groups + item.LastSyncedAt = sqlNullTimePtr(lastSyncedAt) + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return &FeishuOrgDepartmentListResult{Items: items, Total: total, Limit: limit, Offset: offset}, nil +} + +func (s *FeishuOrgPermissionService) ListUsers(ctx context.Context, input FeishuOrgListInput) (*FeishuOrgUserListResult, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + tenantKey, search, limit, offset := normalizeFeishuOrgListInput(input) + countJoin := "" + searchClause := "" + countArgs := []any{tenantKey} + listArgs := []any{tenantKey} + limitPlaceholder := "$2" + offsetPlaceholder := "$3" + if search != "" { + countJoin = ` +LEFT JOIN users local_user + ON local_user.id = fu.user_id +LEFT JOIN feishu_departments primary_department + ON primary_department.tenant_key = fu.tenant_key + AND primary_department.open_department_id = fu.primary_open_department_id` + searchClause = "\n AND " + feishuOrgUserSearchCondition("$2") + countArgs = append(countArgs, search) + listArgs = append(listArgs, search) + limitPlaceholder = "$3" + offsetPlaceholder = "$4" + } + + total, err := scanFeishuCount(ctx, s.db, ` +SELECT COUNT(*) +FROM feishu_org_users fu +`+countJoin+` +WHERE ($1 = '' OR fu.tenant_key = $1)`+searchClause, countArgs...) + if err != nil { + return nil, fmt.Errorf("count feishu org users: %w", err) + } + listArgs = append(listArgs, limit, offset) + rows, err := s.db.QueryContext(ctx, feishuOrgUsersListSQL(` +WHERE ($1 = '' OR fu.tenant_key = $1) +`+searchClause+` +ORDER BY fu.name, fu.open_id +LIMIT `+limitPlaceholder+` OFFSET `+offsetPlaceholder), listArgs...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + items, err := scanFeishuOrgUserViews(rows) + if err != nil { + return nil, err + } + return &FeishuOrgUserListResult{Items: items, Total: total, Limit: limit, Offset: offset}, nil +} + +func (s *FeishuOrgPermissionService) ListSyncRuns(ctx context.Context, input FeishuOrgListInput) (*FeishuOrgSyncRunListResult, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + _, _, limit, offset := normalizeFeishuOrgListInput(input) + total, err := scanFeishuCount(ctx, s.db, ` +SELECT COUNT(*) +FROM feishu_org_sync_runs`) + if err != nil { + return nil, fmt.Errorf("count feishu org sync runs: %w", err) + } + rows, err := s.db.QueryContext(ctx, ` +SELECT id, + status, + started_at, + finished_at, + departments_synced, + users_synced, + managers_synced, + users_to_create, + users_to_disable, + bindings_missing, + review_required, + error_message, + triggered_by_user_id +FROM feishu_org_sync_runs +ORDER BY started_at DESC, id DESC +LIMIT $1 OFFSET $2`, limit, offset) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + items := make([]FeishuOrgSyncRunView, 0) + for rows.Next() { + var item FeishuOrgSyncRunView + var finishedAt sql.NullTime + var triggeredBy sql.NullInt64 + if err := rows.Scan( + &item.ID, + &item.Status, + &item.StartedAt, + &finishedAt, + &item.DepartmentsSynced, + &item.UsersSynced, + &item.ManagersSynced, + &item.UsersToCreate, + &item.UsersToDisable, + &item.BindingsMissing, + &item.ReviewRequired, + &item.ErrorMessage, + &triggeredBy, + ); err != nil { + return nil, err + } + item.FinishedAt = sqlNullTimePtr(finishedAt) + if triggeredBy.Valid { + item.TriggeredByUserID = triggeredBy.Int64 + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return &FeishuOrgSyncRunListResult{Items: items, Total: total, Limit: limit, Offset: offset}, nil +} + +func (s *FeishuOrgPermissionService) RunManualReconcile(ctx context.Context, actorUserID int64, policy FeishuDeparturePolicy) (*FeishuManualReconcileResult, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + previousActiveUsers, err := s.countActiveFeishuOrgUsers(ctx) + if err != nil { + return nil, fmt.Errorf("count active feishu org users: %w", err) + } + departedUserIDs, err := s.listDepartedActiveLocalUserIDs(ctx) + if err != nil { + return nil, fmt.Errorf("list departed local users: %w", err) + } + decision := EvaluateFeishuDepartureDecision(previousActiveUsers, departedUserIDs, policy) + + status := "success" + reviewRequired := false + errorMessage := "" + if decision.RequiresReview { + status = "partial_failed" + reviewRequired = true + errorMessage = decision.Reason + if err := s.appendAuditLog(ctx, actorUserID, 0, "", "", "sync_blocked_for_review", decision.Reason); err != nil { + return nil, fmt.Errorf("append review audit log: %w", err) + } + } else if decision.AutoDisable { + if err := s.disableLocalUsers(ctx, decision.UserIDs); err != nil { + return nil, fmt.Errorf("disable local users: %w", err) + } + for _, userID := range decision.UserIDs { + if err := s.appendAuditLog(ctx, actorUserID, userID, "", "", "auto_disable_user", "feishu_departure_auto_disable"); err != nil { + return nil, fmt.Errorf("append auto disable audit log: %w", err) + } + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) + } + } + } + + run, err := s.insertSyncRun(ctx, status, len(departedUserIDs), reviewRequired, errorMessage, actorUserID) + if err != nil { + return nil, fmt.Errorf("insert sync run: %w", err) + } + return &FeishuManualReconcileResult{SyncRun: *run, Decision: decision}, nil +} + +func (s *FeishuOrgPermissionService) ListManagerUsers(ctx context.Context, managerUserID int64, input FeishuOrgListInput) (*FeishuOrgUserListResult, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + if managerUserID <= 0 { + return nil, errors.New("manager_user_id is required") + } + _, search, limit, offset := normalizeFeishuOrgListInput(input) + managerScopeCTE := ` +WITH RECURSIVE manager_roots AS ( + SELECT tenant_key, open_department_id, include_subdepartments + FROM feishu_department_managers + WHERE manager_user_id = $1 + AND status = 'active' +), +managed_scope AS ( + SELECT tenant_key, open_department_id, include_subdepartments + FROM manager_roots + UNION ALL + SELECT child.tenant_key, child.open_department_id, scope.include_subdepartments + FROM feishu_departments child + JOIN managed_scope scope + ON child.tenant_key = scope.tenant_key + AND child.parent_open_department_id = scope.open_department_id + WHERE scope.include_subdepartments = true + AND child.status = 'active' +), +manager_scope AS ( + SELECT DISTINCT tenant_key, open_department_id + FROM managed_scope +) +` + countJoin := "" + searchClause := "" + countArgs := []any{managerUserID} + listArgs := []any{managerUserID} + limitPlaceholder := "$2" + offsetPlaceholder := "$3" + if search != "" { + countJoin = ` +LEFT JOIN users local_user + ON local_user.id = fu.user_id +LEFT JOIN feishu_departments primary_department + ON primary_department.tenant_key = fu.tenant_key + AND primary_department.open_department_id = fu.primary_open_department_id` + searchClause = "\n AND " + feishuOrgUserSearchCondition("$2") + countArgs = append(countArgs, search) + listArgs = append(listArgs, search) + limitPlaceholder = "$3" + offsetPlaceholder = "$4" + } + total, err := scanFeishuCount(ctx, s.db, managerScopeCTE+` +SELECT COUNT(DISTINCT fu.open_id) +FROM feishu_org_users fu +`+countJoin+` +JOIN manager_scope scope + ON scope.tenant_key = fu.tenant_key + AND scope.open_department_id = fu.primary_open_department_id +WHERE fu.status = 'active'`+searchClause, countArgs...) + if err != nil { + return nil, fmt.Errorf("count manager feishu users: %w", err) + } + listArgs = append(listArgs, limit, offset) + rows, err := s.db.QueryContext(ctx, managerScopeCTE+feishuOrgUsersListSQL(` +JOIN manager_scope scope + ON scope.tenant_key = fu.tenant_key + AND scope.open_department_id = fu.primary_open_department_id +WHERE fu.status = 'active' +`+searchClause+` +ORDER BY fu.name, fu.open_id +LIMIT `+limitPlaceholder+` OFFSET `+offsetPlaceholder), listArgs...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + items, err := scanFeishuOrgUserViews(rows) + if err != nil { + return nil, err + } + return &FeishuOrgUserListResult{Items: items, Total: total, Limit: limit, Offset: offset}, nil +} + +func (s *FeishuOrgPermissionService) HasManagerScope(ctx context.Context, managerUserID int64) (bool, error) { + if s == nil || s.db == nil { + return false, errors.New("feishu org permission service is not configured") + } + if managerUserID <= 0 { + return false, errors.New("manager_user_id is required") + } + + rows, err := s.db.QueryContext(ctx, ` +SELECT COUNT(*) +FROM feishu_department_managers +WHERE manager_user_id = $1 + AND status = 'active'`, managerUserID) + if err != nil { + return false, fmt.Errorf("check manager scope: %w", err) + } + defer func() { _ = rows.Close() }() + + var count int + if rows.Next() { + if err := rows.Scan(&count); err != nil { + return false, fmt.Errorf("scan manager scope: %w", err) + } + } + if err := rows.Err(); err != nil { + return false, fmt.Errorf("read manager scope: %w", err) + } + return count > 0, nil +} + +func (s *FeishuOrgPermissionService) ListManagerLocalUserIDs(ctx context.Context, managerUserID int64) ([]int64, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + if managerUserID <= 0 { + return nil, errors.New("manager_user_id is required") + } + + rows, err := s.db.QueryContext(ctx, ` +WITH RECURSIVE manager_roots AS ( + SELECT tenant_key, open_department_id, include_subdepartments + FROM feishu_department_managers + WHERE manager_user_id = $1 + AND status = 'active' +), +managed_scope AS ( + SELECT tenant_key, open_department_id, include_subdepartments + FROM manager_roots + UNION ALL + SELECT child.tenant_key, child.open_department_id, scope.include_subdepartments + FROM feishu_departments child + JOIN managed_scope scope + ON child.tenant_key = scope.tenant_key + AND child.parent_open_department_id = scope.open_department_id + WHERE scope.include_subdepartments = true + AND child.status = 'active' +), +manager_scope AS ( + SELECT DISTINCT tenant_key, open_department_id + FROM managed_scope +) +SELECT DISTINCT fu.user_id +FROM feishu_org_users fu +JOIN manager_scope scope + ON scope.tenant_key = fu.tenant_key + AND scope.open_department_id = fu.primary_open_department_id +WHERE fu.status = 'active' + AND fu.user_id > 0 +ORDER BY fu.user_id`, managerUserID) + if err != nil { + return nil, fmt.Errorf("list manager local user ids: %w", err) + } + defer func() { _ = rows.Close() }() + + ids := make([]int64, 0) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return ids, nil +} + +func (s *FeishuOrgPermissionService) countActiveFeishuOrgUsers(ctx context.Context) (int, error) { + var count int + err := scanFeishuSingleRow(ctx, s.db, ` +SELECT COUNT(*) FROM feishu_org_users +WHERE user_id IS NOT NULL + AND status = 'active'`, nil, &count) + return count, err +} + +func (s *FeishuOrgPermissionService) listDepartedActiveLocalUserIDs(ctx context.Context) ([]int64, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT DISTINCT org_user.user_id +FROM feishu_org_users org_user +JOIN users local_user + ON local_user.id = org_user.user_id +WHERE org_user.user_id IS NOT NULL + AND org_user.status IN ('departed', 'disabled') + AND local_user.status = $1 + AND local_user.role <> $2 +ORDER BY org_user.user_id`, StatusActive, RoleAdmin) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return uniqueSortedInt64s(ids), nil +} + +func (s *FeishuOrgPermissionService) disableLocalUsers(ctx context.Context, userIDs []int64) error { + userIDs = uniqueSortedInt64s(userIDs) + if len(userIDs) == 0 { + return nil + } + _, err := s.db.ExecContext(ctx, ` +UPDATE users +SET status = $1, + updated_at = NOW() +WHERE id = ANY($2) + AND role <> $3`, StatusDisabled, pq.Array(userIDs), RoleAdmin) + return err +} + +func (s *FeishuOrgPermissionService) insertSyncRun(ctx context.Context, status string, usersToDisable int, reviewRequired bool, errorMessage string, actorUserID int64) (*FeishuOrgSyncRunView, error) { + var item FeishuOrgSyncRunView + var finishedAt sql.NullTime + var triggeredBy sql.NullInt64 + err := scanFeishuSingleRow(ctx, s.db, ` +INSERT INTO feishu_org_sync_runs ( + status, + started_at, + finished_at, + users_to_disable, + review_required, + error_message, + triggered_by_user_id +) +VALUES ($1, NOW(), NOW(), $2, $3, $4, NULLIF($5, 0)) +RETURNING id, + status, + started_at, + finished_at, + departments_synced, + users_synced, + managers_synced, + users_to_create, + users_to_disable, + bindings_missing, + review_required, + error_message, + triggered_by_user_id`, []any{status, usersToDisable, reviewRequired, errorMessage, actorUserID}, + &item.ID, + &item.Status, + &item.StartedAt, + &finishedAt, + &item.DepartmentsSynced, + &item.UsersSynced, + &item.ManagersSynced, + &item.UsersToCreate, + &item.UsersToDisable, + &item.BindingsMissing, + &item.ReviewRequired, + &item.ErrorMessage, + &triggeredBy, + ) + if err != nil { + return nil, err + } + item.FinishedAt = sqlNullTimePtr(finishedAt) + if triggeredBy.Valid { + item.TriggeredByUserID = triggeredBy.Int64 + } + return &item, nil +} + +func (s *FeishuOrgPermissionService) SetDepartmentGroupPool(ctx context.Context, input FeishuSetDepartmentGroupPoolInput) (*FeishuDepartmentGroupPoolResult, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + tenantKey := strings.TrimSpace(input.TenantKey) + if tenantKey == "" { + return nil, errors.New("tenant_key is required") + } + deptID := strings.TrimSpace(input.OpenDepartmentID) + if deptID == "" { + return nil, errors.New("open_department_id is required") + } + reason := strings.TrimSpace(input.Reason) + if reason == "" { + reason = "pool_updated" + } + groupIDs := uniqueSortedInt64s(input.GroupIDs) + + affectedUserIDs, err := s.listUsersWithInvalidDepartmentManagerGrants(ctx, deptID, groupIDs) + if err != nil { + return nil, fmt.Errorf("list invalid department manager grants: %w", err) + } + if err := s.revokeInvalidDepartmentManagerGrants(ctx, deptID, groupIDs, input.ActorUserID, reason); err != nil { + return nil, fmt.Errorf("revoke invalid department manager grants: %w", err) + } + if err := s.replaceDepartmentGroupPool(ctx, tenantKey, deptID, groupIDs, input.ActorUserID); err != nil { + return nil, fmt.Errorf("replace department group pool: %w", err) + } + for _, userID := range affectedUserIDs { + if err := s.recalculateUserAllowedGroups(ctx, userID); err != nil { + return nil, fmt.Errorf("recalculate allowed groups for user %d: %w", userID, err) + } + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) + } + } + if err := s.appendAuditLog(ctx, input.ActorUserID, 0, deptID, FeishuGrantSourceDepartmentManager, "auto_revoke", reason); err != nil { + return nil, fmt.Errorf("append audit log: %w", err) + } + return &FeishuDepartmentGroupPoolResult{ + TenantKey: tenantKey, + OpenDepartmentID: deptID, + GroupIDs: groupIDs, + RevokedUserIDs: affectedUserIDs, + }, nil +} + +func (s *FeishuOrgPermissionService) SetDepartmentManagerUserGroupGrants(ctx context.Context, input FeishuDepartmentManagerAssignmentInput) (*FeishuDepartmentManagerAssignmentResult, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + if input.ManagerUserID <= 0 { + return nil, errors.New("manager_user_id is required") + } + if input.TargetUserID <= 0 { + return nil, errors.New("target_user_id is required") + } + + tenantKey, primaryDeptID, err := s.getUserPrimaryDepartment(ctx, input.TargetUserID) + if err != nil { + return nil, err + } + if primaryDeptID == "" { + return nil, errors.New("target user has no primary feishu department") + } + + canManage, err := s.canManagerManageDepartment(ctx, tenantKey, primaryDeptID, input.ManagerUserID) + if err != nil { + return nil, err + } + if !canManage { + return nil, errors.New("manager cannot manage target user") + } + + assignableGroupIDs, err := s.listDepartmentGroupPool(ctx, tenantKey, primaryDeptID) + if err != nil { + return nil, err + } + groupIDs := uniqueSortedInt64s(input.GroupIDs) + if missing := firstMissingInt64(groupIDs, assignableGroupIDs); missing > 0 { + return nil, fmt.Errorf("group %d is not in primary department group pool", missing) + } + + grantResult, err := s.SetUserGroupGrants(ctx, FeishuSetUserGroupGrantsInput{ + ActorUserID: input.ManagerUserID, + TargetUserID: input.TargetUserID, + Source: FeishuGrantSourceDepartmentManager, + SourceOpenDepartmentID: primaryDeptID, + GroupIDs: groupIDs, + Reason: input.Reason, + }) + if err != nil { + return nil, err + } + return &FeishuDepartmentManagerAssignmentResult{ + FeishuUserGroupGrantResult: *grantResult, + TenantKey: tenantKey, + AssignableGroupIDs: assignableGroupIDs, + }, nil +} + +func (s *FeishuOrgPermissionService) listUsersWithInvalidDepartmentManagerGrants(ctx context.Context, deptID string, allowedGroupIDs []int64) ([]int64, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT DISTINCT user_id +FROM feishu_user_group_grants +WHERE source = 'department_manager' + AND source_open_department_id = $1 + AND revoked_at IS NULL + AND NOT (group_id = ANY($2)) +ORDER BY user_id`, deptID, pq.Array(allowedGroupIDs)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return uniqueSortedInt64s(ids), nil +} + +func (s *FeishuOrgPermissionService) revokeInvalidDepartmentManagerGrants(ctx context.Context, deptID string, allowedGroupIDs []int64, actorUserID int64, reason string) error { + _, err := s.db.ExecContext(ctx, ` +UPDATE feishu_user_group_grants +SET revoked_at = NOW(), + revoked_by_user_id = NULLIF($3, 0), + revoke_reason = $4, + updated_at = NOW() +WHERE source = 'department_manager' + AND source_open_department_id = $1 + AND revoked_at IS NULL + AND NOT (group_id = ANY($2))`, deptID, pq.Array(allowedGroupIDs), actorUserID, reason) + return err +} + +func (s *FeishuOrgPermissionService) replaceDepartmentGroupPool(ctx context.Context, tenantKey, deptID string, groupIDs []int64, actorUserID int64) error { + if _, err := s.db.ExecContext(ctx, ` +DELETE FROM feishu_department_group_grants +WHERE tenant_key = $1 + AND open_department_id = $2 + AND NOT (group_id = ANY($3))`, tenantKey, deptID, pq.Array(groupIDs)); err != nil { + return err + } + if len(groupIDs) == 0 { + return nil + } + _, err := s.db.ExecContext(ctx, ` +INSERT INTO feishu_department_group_grants ( + tenant_key, + open_department_id, + group_id, + granted_by_user_id +) +SELECT $1, + $2, + UNNEST($3::bigint[]), + NULLIF($4, 0) +ON CONFLICT (tenant_key, open_department_id, group_id) +DO UPDATE SET + granted_by_user_id = EXCLUDED.granted_by_user_id, + updated_at = NOW()`, tenantKey, deptID, pq.Array(groupIDs), actorUserID) + return err +} + +func (s *FeishuOrgPermissionService) SetUserGroupGrants(ctx context.Context, input FeishuSetUserGroupGrantsInput) (*FeishuUserGroupGrantResult, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + if input.TargetUserID <= 0 { + return nil, errors.New("target_user_id is required") + } + source := normalizeFeishuGrantSource(input.Source) + if source == "" { + return nil, fmt.Errorf("unsupported grant source: %s", input.Source) + } + sourceDeptID := strings.TrimSpace(input.SourceOpenDepartmentID) + if source == FeishuGrantSourceDepartmentManager && sourceDeptID == "" { + return nil, errors.New("source_open_department_id is required for department manager grants") + } + if source != FeishuGrantSourceDepartmentManager { + sourceDeptID = "" + } + reason := strings.TrimSpace(input.Reason) + if reason == "" { + reason = "manual" + } + groupIDs := uniqueSortedInt64s(input.GroupIDs) + + if err := s.ensureLegacyAllowedGroupsImported(ctx, input.TargetUserID, input.ActorUserID); err != nil { + return nil, fmt.Errorf("import legacy allowed groups: %w", err) + } + if err := s.replaceActiveSourceGrants(ctx, input.TargetUserID, source, sourceDeptID, groupIDs, input.ActorUserID, reason); err != nil { + return nil, fmt.Errorf("replace source grants: %w", err) + } + if err := s.recalculateUserAllowedGroups(ctx, input.TargetUserID); err != nil { + return nil, fmt.Errorf("recalculate allowed groups: %w", err) + } + if err := s.appendAuditLog(ctx, input.ActorUserID, input.TargetUserID, sourceDeptID, source, "grant", reason); err != nil { + return nil, fmt.Errorf("append audit log: %w", err) + } + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, input.TargetUserID) + } + + return &FeishuUserGroupGrantResult{ + UserID: input.TargetUserID, + Source: source, + SourceOpenDepartmentID: sourceDeptID, + GroupIDs: groupIDs, + }, nil +} + +func (s *FeishuOrgPermissionService) getUserPrimaryDepartment(ctx context.Context, userID int64) (tenantKey string, primaryDeptID string, err error) { + err = scanFeishuSingleRow(ctx, s.db, ` +SELECT tenant_key, primary_open_department_id +FROM feishu_org_users +WHERE user_id = $1 + AND status = 'active' +LIMIT 1`, []any{userID}, &tenantKey, &primaryDeptID) + if errors.Is(err, sql.ErrNoRows) { + return "", "", errors.New("target feishu org user is not active or not synced") + } + return strings.TrimSpace(tenantKey), strings.TrimSpace(primaryDeptID), err +} + +func (s *FeishuOrgPermissionService) canManagerManageDepartment(ctx context.Context, tenantKey, targetDeptID string, managerUserID int64) (bool, error) { + var count int64 + err := scanFeishuSingleRow(ctx, s.db, ` +WITH RECURSIVE department_scope(open_department_id, parent_open_department_id) AS ( + SELECT open_department_id, parent_open_department_id + FROM feishu_departments + WHERE tenant_key = $1 + AND open_department_id = $2 + AND status = 'active' + UNION ALL + SELECT parent.open_department_id, parent.parent_open_department_id + FROM feishu_departments parent + JOIN department_scope child + ON parent.tenant_key = $1 + AND parent.open_department_id = child.parent_open_department_id + WHERE parent.status = 'active' +) +SELECT COUNT(*) +FROM feishu_department_managers manager +WHERE manager.tenant_key = $1 + AND manager.manager_user_id = $3 + AND manager.status = 'active' + AND ( + manager.open_department_id = $2 + OR ( + manager.include_subdepartments = true + AND manager.open_department_id IN (SELECT open_department_id FROM department_scope) + ) + )`, []any{tenantKey, targetDeptID, managerUserID}, &count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func (s *FeishuOrgPermissionService) listDepartmentGroupPool(ctx context.Context, tenantKey, deptID string) ([]int64, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT group_id +FROM feishu_department_group_grants +WHERE tenant_key = $1 + AND open_department_id = $2 +ORDER BY group_id`, tenantKey, deptID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return uniqueSortedInt64s(ids), nil +} + +func (s *FeishuOrgPermissionService) ensureLegacyAllowedGroupsImported(ctx context.Context, userID, actorUserID int64) error { + var count int64 + if err := scanFeishuSingleRow(ctx, s.db, "SELECT COUNT(*) FROM feishu_user_group_grants WHERE user_id = $1", []any{userID}, &count); err != nil { + return err + } + if count > 0 { + return nil + } + _, err := s.db.ExecContext(ctx, ` +INSERT INTO feishu_user_group_grants ( + user_id, + group_id, + source, + source_open_department_id, + granted_by_user_id, + reason +) +SELECT user_id, + group_id, + 'super_admin_override', + '', + NULLIF($2, 0), + 'legacy_import' +FROM user_allowed_groups +WHERE user_id = $1 +ON CONFLICT (user_id, group_id, source, source_open_department_id) WHERE revoked_at IS NULL +DO NOTHING`, userID, actorUserID) + return err +} + +func (s *FeishuOrgPermissionService) replaceActiveSourceGrants(ctx context.Context, userID int64, source, sourceDeptID string, groupIDs []int64, actorUserID int64, reason string) error { + if _, err := s.db.ExecContext(ctx, ` +UPDATE feishu_user_group_grants +SET revoked_at = NOW(), + revoked_by_user_id = NULLIF($5, 0), + revoke_reason = $6, + updated_at = NOW() +WHERE user_id = $1 + AND source = $2 + AND source_open_department_id = $3 + AND revoked_at IS NULL + AND NOT (group_id = ANY($4))`, userID, source, sourceDeptID, pq.Array(groupIDs), actorUserID, reason); err != nil { + return err + } + if len(groupIDs) == 0 { + return nil + } + _, err := s.db.ExecContext(ctx, ` +INSERT INTO feishu_user_group_grants ( + user_id, + group_id, + source, + source_open_department_id, + granted_by_user_id, + reason +) +SELECT $1, + UNNEST($2::bigint[]), + $3, + $4, + NULLIF($5, 0), + $6 +ON CONFLICT (user_id, group_id, source, source_open_department_id) WHERE revoked_at IS NULL +DO UPDATE SET + granted_by_user_id = EXCLUDED.granted_by_user_id, + reason = EXCLUDED.reason, + updated_at = NOW()`, userID, pq.Array(groupIDs), source, sourceDeptID, actorUserID, reason) + return err +} + +func (s *FeishuOrgPermissionService) recalculateUserAllowedGroups(ctx context.Context, userID int64) error { + if _, err := s.db.ExecContext(ctx, "DELETE FROM user_allowed_groups WHERE user_id = $1", userID); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, ` +INSERT INTO user_allowed_groups (user_id, group_id) +SELECT DISTINCT user_id, group_id +FROM feishu_user_group_grants +WHERE user_id = $1 + AND revoked_at IS NULL +ON CONFLICT (user_id, group_id) DO NOTHING`, userID) + return err +} + +func (s *FeishuOrgPermissionService) appendAuditLog(ctx context.Context, actorUserID, targetUserID int64, openDepartmentID, source, action, reason string) error { + _, err := s.db.ExecContext(ctx, ` +INSERT INTO feishu_org_permission_audit_logs ( + actor_user_id, + target_user_id, + open_department_id, + source, + action, + reason +) +VALUES (NULLIF($1, 0), NULLIF($2, 0), $3, $4, $5, $6)`, actorUserID, targetUserID, openDepartmentID, source, action, reason) + return err +} + +type FeishuDeparturePolicy struct { + Action string + ThresholdCount int + ThresholdPercent int +} + +type FeishuDepartureDecision struct { + AutoDisable bool + RequiresReview bool + Reason string + UserIDs []int64 +} + +func EvaluateFeishuDepartureDecision(previousActiveUsers int, departedUserIDs []int64, policy FeishuDeparturePolicy) FeishuDepartureDecision { + userIDs := uniqueSortedInt64s(departedUserIDs) + if len(userIDs) == 0 { + return FeishuDepartureDecision{} + } + if strings.TrimSpace(policy.Action) != FeishuDepartedUserActionAutoDisable { + return FeishuDepartureDecision{ + RequiresReview: true, + Reason: "FEISHU_DEPARTURE_REVIEW_REQUIRED", + UserIDs: userIDs, + } + } + countThreshold := policy.ThresholdCount + if countThreshold <= 0 { + countThreshold = FeishuDefaultDisableThresholdCount + } + percentThreshold := policy.ThresholdPercent + if percentThreshold <= 0 { + percentThreshold = FeishuDefaultDisableThresholdPct + } + + if len(userIDs) > countThreshold || departurePercent(previousActiveUsers, len(userIDs)) > percentThreshold { + return FeishuDepartureDecision{ + RequiresReview: true, + Reason: "FEISHU_DEPARTURE_THRESHOLD_EXCEEDED", + UserIDs: userIDs, + } + } + return FeishuDepartureDecision{ + AutoDisable: true, + UserIDs: userIDs, + } +} + +func normalizeFeishuGrantSource(source string) string { + switch strings.TrimSpace(source) { + case FeishuGrantSourceDepartmentManager: + return FeishuGrantSourceDepartmentManager + case FeishuGrantSourceSuperAdmin: + return FeishuGrantSourceSuperAdmin + default: + return "" + } +} + +func scanFeishuSingleRow(ctx context.Context, q feishuOrgSQLExecutor, query string, args []any, dest ...any) (err error) { + rows, err := q.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + err = errors.Join(err, closeErr) + } + }() + + if !rows.Next() { + if err = rows.Err(); err != nil { + return err + } + return sql.ErrNoRows + } + if err = rows.Scan(dest...); err != nil { + return err + } + if err = rows.Err(); err != nil { + return err + } + return nil +} + +func scanFeishuCount(ctx context.Context, q feishuOrgSQLExecutor, query string, args ...any) (int64, error) { + var count int64 + if err := scanFeishuSingleRow(ctx, q, query, args, &count); err != nil { + return 0, err + } + return count, nil +} + +func feishuOrgUsersListSQL(suffix string) string { + return ` +SELECT COALESCE(fu.user_id, 0) AS user_id, + COALESCE(local_user.email, '') AS local_email, + COALESCE(local_user.username, '') AS local_username, + COALESCE(local_user.status, '') AS local_status, + fu.tenant_key, + fu.open_id, + fu.union_id, + fu.feishu_user_id, + fu.name, + fu.email, + fu.employee_no, + fu.status, + fu.primary_open_department_id, + COALESCE(primary_department.name, '') AS primary_department_name, + COALESCE(primary_department.path, '') AS primary_department_path, + fu.department_open_ids::text AS department_open_ids, + COALESCE(department_grants.group_ids, '[]'::jsonb)::text AS department_manager_group_ids, + COALESCE(override_grants.group_ids, '[]'::jsonb)::text AS super_admin_override_group_ids, + COALESCE(effective_groups.group_ids, '[]'::jsonb)::text AS effective_group_ids, + COALESCE(assignable_groups.groups, '[]'::jsonb)::text AS assignable_groups, + fu.last_synced_at +FROM feishu_org_users fu +LEFT JOIN users local_user + ON local_user.id = fu.user_id +LEFT JOIN feishu_departments primary_department + ON primary_department.tenant_key = fu.tenant_key + AND primary_department.open_department_id = fu.primary_open_department_id +LEFT JOIN ( + SELECT user_id, jsonb_agg(group_id ORDER BY group_id) AS group_ids + FROM feishu_user_group_grants + WHERE revoked_at IS NULL + AND source = 'department_manager' + GROUP BY user_id +) department_grants + ON department_grants.user_id = fu.user_id +LEFT JOIN ( + SELECT user_id, jsonb_agg(group_id ORDER BY group_id) AS group_ids + FROM feishu_user_group_grants + WHERE revoked_at IS NULL + AND source = 'super_admin_override' + GROUP BY user_id +) override_grants + ON override_grants.user_id = fu.user_id +LEFT JOIN ( + SELECT user_id, jsonb_agg(group_id ORDER BY group_id) AS group_ids + FROM user_allowed_groups + GROUP BY user_id +) effective_groups + ON effective_groups.user_id = fu.user_id +LEFT JOIN LATERAL ( + SELECT jsonb_agg( + jsonb_build_object( + 'id', groups.id, + 'name', groups.name, + 'platform', groups.platform, + 'subscription_type', groups.subscription_type + ) + ORDER BY groups.sort_order, groups.id + ) AS groups + FROM feishu_department_group_grants grants + JOIN groups + ON groups.id = grants.group_id + AND groups.deleted_at IS NULL + WHERE grants.tenant_key = fu.tenant_key + AND grants.open_department_id = fu.primary_open_department_id +) assignable_groups ON true +` + suffix +} + +func feishuOrgDepartmentSearchCondition(placeholder string) string { + terms := []string{ + feishuOrgSearchTerm("d.name", placeholder), + feishuOrgSearchTerm("d.path", placeholder), + feishuOrgSearchTerm("d.open_department_id", placeholder), + } + return "(\n " + strings.Join(terms, "\n OR ") + "\n )" +} + +func feishuOrgUserSearchCondition(placeholder string) string { + terms := []string{ + feishuOrgSearchTerm("fu.name", placeholder), + feishuOrgSearchTerm("fu.email", placeholder), + feishuOrgSearchTerm("fu.employee_no", placeholder), + feishuOrgSearchTerm("fu.open_id", placeholder), + feishuOrgSearchTerm("fu.union_id", placeholder), + feishuOrgSearchTerm("fu.feishu_user_id", placeholder), + feishuOrgSearchTerm("local_user.email", placeholder), + feishuOrgSearchTerm("local_user.username", placeholder), + feishuOrgSearchTerm("primary_department.name", placeholder), + feishuOrgSearchTerm("primary_department.path", placeholder), + feishuOrgSearchTerm("fu.primary_open_department_id", placeholder), + } + return "(\n " + strings.Join(terms, "\n OR ") + "\n )" +} + +func feishuOrgSearchTerm(expression string, placeholder string) string { + return "LOWER(COALESCE(" + expression + ", '')) LIKE '%' || " + placeholder + " || '%'" +} + +func scanFeishuOrgUserViews(rows *sql.Rows) ([]FeishuOrgUserView, error) { + items := make([]FeishuOrgUserView, 0) + for rows.Next() { + var item FeishuOrgUserView + var departmentRaw string + var managerGrantRaw string + var overrideGrantRaw string + var effectiveRaw string + var assignableRaw string + var lastSyncedAt sql.NullTime + if err := rows.Scan( + &item.UserID, + &item.LocalEmail, + &item.LocalUsername, + &item.LocalStatus, + &item.TenantKey, + &item.OpenID, + &item.UnionID, + &item.FeishuUserID, + &item.Name, + &item.Email, + &item.EmployeeNo, + &item.Status, + &item.PrimaryOpenDepartmentID, + &item.PrimaryDepartmentName, + &item.PrimaryDepartmentPath, + &departmentRaw, + &managerGrantRaw, + &overrideGrantRaw, + &effectiveRaw, + &assignableRaw, + &lastSyncedAt, + ); err != nil { + return nil, err + } + departmentIDs, err := decodeFeishuJSONStringSlice(departmentRaw) + if err != nil { + return nil, fmt.Errorf("decode feishu user departments: %w", err) + } + managerGroupIDs, err := decodeFeishuJSONInt64Slice(managerGrantRaw) + if err != nil { + return nil, fmt.Errorf("decode department manager grants: %w", err) + } + overrideGroupIDs, err := decodeFeishuJSONInt64Slice(overrideGrantRaw) + if err != nil { + return nil, fmt.Errorf("decode super admin override grants: %w", err) + } + effectiveGroupIDs, err := decodeFeishuJSONInt64Slice(effectiveRaw) + if err != nil { + return nil, fmt.Errorf("decode effective groups: %w", err) + } + assignableGroups, err := decodeFeishuJSONGroups(assignableRaw) + if err != nil { + return nil, fmt.Errorf("decode assignable groups: %w", err) + } + item.DepartmentOpenIDs = departmentIDs + item.DepartmentManagerGroupIDs = managerGroupIDs + item.SuperAdminOverrideGroupIDs = overrideGroupIDs + item.EffectiveGroupIDs = effectiveGroupIDs + item.AssignableGroups = assignableGroups + item.LastSyncedAt = sqlNullTimePtr(lastSyncedAt) + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +func normalizeFeishuOrgListInput(input FeishuOrgListInput) (tenantKey string, search string, limit int, offset int) { + tenantKey = strings.TrimSpace(input.TenantKey) + search = strings.ToLower(strings.TrimSpace(input.Query)) + limit = input.Limit + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + offset = input.Offset + if offset < 0 { + offset = 0 + } + return tenantKey, search, limit, offset +} + +func decodeFeishuJSONStringSlice(raw string) ([]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var values []string + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil, err + } + out := make([]string, 0, len(values)) + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + out = append(out, trimmed) + } + } + return out, nil +} + +func decodeFeishuJSONInt64Slice(raw string) ([]int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var values []int64 + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil, err + } + return uniqueSortedInt64s(values), nil +} + +func decodeFeishuJSONGroups(raw string) ([]FeishuOrgGroupBrief, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var groups []FeishuOrgGroupBrief + if err := json.Unmarshal([]byte(raw), &groups); err != nil { + return nil, err + } + out := make([]FeishuOrgGroupBrief, 0, len(groups)) + for _, group := range groups { + if group.ID > 0 { + out = append(out, group) + } + } + return out, nil +} + +func sqlNullTimePtr(value sql.NullTime) *time.Time { + if !value.Valid { + return nil + } + t := value.Time + return &t +} + +func departurePercent(total int, departed int) int { + if total <= 0 || departed <= 0 { + return 0 + } + return (departed * 100) / total +} + +func firstString(values []string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func containsString(values []string, target string) bool { + target = strings.TrimSpace(target) + if target == "" { + return false + } + for _, value := range values { + if strings.TrimSpace(value) == target { + return true + } + } + return false +} + +func firstMissingInt64(required []int64, allowed []int64) int64 { + if len(required) == 0 { + return 0 + } + allowedSet := make(map[int64]struct{}, len(allowed)) + for _, id := range allowed { + if id > 0 { + allowedSet[id] = struct{}{} + } + } + for _, id := range required { + if id <= 0 { + continue + } + if _, ok := allowedSet[id]; !ok { + return id + } + } + return 0 +} + +func uniqueSortedInt64s(values []int64) []int64 { + if len(values) == 0 { + return nil + } + seen := make(map[int64]struct{}, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} diff --git a/backend/internal/service/feishu_org_permissions_test.go b/backend/internal/service/feishu_org_permissions_test.go new file mode 100644 index 00000000000..d915ea30096 --- /dev/null +++ b/backend/internal/service/feishu_org_permissions_test.go @@ -0,0 +1,994 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestFeishuOrgPermissionSnapshotSeparatesDepartmentPoolAssignmentsAndPersonalOverride(t *testing.T) { + snapshot := FeishuOrgPermissionSnapshot{ + ManagerDepartmentIDs: []string{"dept-a"}, + EmployeePrimaryDepartmentID: "dept-a", + EmployeeDepartmentIDs: []string{"dept-a", "dept-side"}, + DepartmentAssignableGroupIDs: map[string][]int64{ + "dept-a": {11, 12}, + "dept-side": {99}, + }, + DepartmentManagerGroupGrants: []int64{11}, + UserOverrideGroupGrants: []int64{77}, + } + + require.Equal(t, []int64{11, 12}, snapshot.ManagerAssignableGroupIDs()) + require.True(t, snapshot.CanManagerAssignGroup(11)) + require.False(t, snapshot.CanManagerAssignGroup(77), "个人 override 不会扩大部门领导的可分配池") + require.Equal(t, []int64{11, 77}, snapshot.EffectiveEmployeeGroupIDs(), "部门池只是可分配范围,不会自动全量发给员工") +} + +func TestFeishuOrgPermissionSnapshotUsesPrimaryDepartmentForMultiDepartmentEmployee(t *testing.T) { + snapshot := FeishuOrgPermissionSnapshot{ + ManagerDepartmentIDs: []string{"dept-side"}, + EmployeePrimaryDepartmentID: "dept-a", + EmployeeDepartmentIDs: []string{"dept-a", "dept-side"}, + DepartmentAssignableGroupIDs: map[string][]int64{ + "dept-a": {11}, + "dept-side": {99}, + }, + } + + require.Empty(t, snapshot.ManagerAssignableGroupIDs()) + require.False(t, snapshot.CanManagerAssignGroup(99)) +} + +func TestFeishuOrgPermissionServiceHasManagerScope(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*)")). + WithArgs(int64(7)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + svc := NewFeishuOrgPermissionService(db, nil) + ok, err := svc.HasManagerScope(context.Background(), 7) + + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceSetDepartmentManagerGrantImportsLegacyAndRecalculates(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) FROM feishu_user_group_grants WHERE user_id = $1")). + WithArgs(int64(42)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectExec("INSERT INTO feishu_user_group_grants"). + WithArgs(int64(42), int64(7)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE feishu_user_group_grants"). + WithArgs(int64(42), FeishuGrantSourceDepartmentManager, "dept-a", sqlmock.AnyArg(), int64(7), "manual"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO feishu_user_group_grants"). + WithArgs(int64(42), sqlmock.AnyArg(), FeishuGrantSourceDepartmentManager, "dept-a", int64(7), "manual"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM user_allowed_groups WHERE user_id = $1")). + WithArgs(int64(42)). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectExec("INSERT INTO user_allowed_groups"). + WithArgs(int64(42)). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectExec("INSERT INTO feishu_org_permission_audit_logs"). + WithArgs(int64(7), int64(42), "dept-a", FeishuGrantSourceDepartmentManager, "grant", "manual"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + invalidator := &feishuOrgPermissionInvalidatorStub{} + svc := NewFeishuOrgPermissionService(db, invalidator) + + result, err := svc.SetUserGroupGrants(context.Background(), FeishuSetUserGroupGrantsInput{ + ActorUserID: 7, + TargetUserID: 42, + Source: FeishuGrantSourceDepartmentManager, + SourceOpenDepartmentID: "dept-a", + GroupIDs: []int64{11}, + Reason: "manual", + }) + + require.NoError(t, err) + require.Equal(t, []int64{11}, result.GroupIDs) + require.Equal(t, []int64{42}, invalidator.userIDs) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceRejectsDepartmentGrantWithoutDepartment(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + svc := NewFeishuOrgPermissionService(db, nil) + _, err = svc.SetUserGroupGrants(context.Background(), FeishuSetUserGroupGrantsInput{ + ActorUserID: 7, + TargetUserID: 42, + Source: FeishuGrantSourceDepartmentManager, + GroupIDs: []int64{11}, + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "source_open_department_id is required") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceSetDepartmentManagerUserGroupGrants(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery("SELECT tenant_key, primary_open_department_id FROM feishu_org_users"). + WithArgs(int64(42)). + WillReturnRows(sqlmock.NewRows([]string{"tenant_key", "primary_open_department_id"}).AddRow("tenant-a", "dept-a")) + mock.ExpectQuery("WITH RECURSIVE department_scope"). + WithArgs("tenant-a", "dept-a", int64(7)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery("SELECT group_id FROM feishu_department_group_grants"). + WithArgs("tenant-a", "dept-a"). + WillReturnRows(sqlmock.NewRows([]string{"group_id"}).AddRow(11).AddRow(12)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) FROM feishu_user_group_grants WHERE user_id = $1")). + WithArgs(int64(42)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectExec("UPDATE feishu_user_group_grants"). + WithArgs(int64(42), FeishuGrantSourceDepartmentManager, "dept-a", sqlmock.AnyArg(), int64(7), "manual"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO feishu_user_group_grants"). + WithArgs(int64(42), sqlmock.AnyArg(), FeishuGrantSourceDepartmentManager, "dept-a", int64(7), "manual"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM user_allowed_groups WHERE user_id = $1")). + WithArgs(int64(42)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO user_allowed_groups"). + WithArgs(int64(42)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO feishu_org_permission_audit_logs"). + WithArgs(int64(7), int64(42), "dept-a", FeishuGrantSourceDepartmentManager, "grant", "manual"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + svc := NewFeishuOrgPermissionService(db, nil) + result, err := svc.SetDepartmentManagerUserGroupGrants(context.Background(), FeishuDepartmentManagerAssignmentInput{ + ManagerUserID: 7, + TargetUserID: 42, + GroupIDs: []int64{12}, + }) + + require.NoError(t, err) + require.Equal(t, "dept-a", result.SourceOpenDepartmentID) + require.Equal(t, []int64{12}, result.GroupIDs) + require.Equal(t, []int64{11, 12}, result.AssignableGroupIDs) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceRejectsGroupOutsidePrimaryDepartmentPool(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery("SELECT tenant_key, primary_open_department_id FROM feishu_org_users"). + WithArgs(int64(42)). + WillReturnRows(sqlmock.NewRows([]string{"tenant_key", "primary_open_department_id"}).AddRow("tenant-a", "dept-a")) + mock.ExpectQuery("WITH RECURSIVE department_scope"). + WithArgs("tenant-a", "dept-a", int64(7)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery("SELECT group_id FROM feishu_department_group_grants"). + WithArgs("tenant-a", "dept-a"). + WillReturnRows(sqlmock.NewRows([]string{"group_id"}).AddRow(11)) + + svc := NewFeishuOrgPermissionService(db, nil) + _, err = svc.SetDepartmentManagerUserGroupGrants(context.Background(), FeishuDepartmentManagerAssignmentInput{ + ManagerUserID: 7, + TargetUserID: 42, + GroupIDs: []int64{99}, + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "not in primary department group pool") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceRejectsManagerOutsideDepartmentScope(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery("SELECT tenant_key, primary_open_department_id FROM feishu_org_users"). + WithArgs(int64(42)). + WillReturnRows(sqlmock.NewRows([]string{"tenant_key", "primary_open_department_id"}).AddRow("tenant-a", "dept-a")) + mock.ExpectQuery("WITH RECURSIVE department_scope"). + WithArgs("tenant-a", "dept-a", int64(7)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + svc := NewFeishuOrgPermissionService(db, nil) + _, err = svc.SetDepartmentManagerUserGroupGrants(context.Background(), FeishuDepartmentManagerAssignmentInput{ + ManagerUserID: 7, + TargetUserID: 42, + GroupIDs: []int64{11}, + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "manager cannot manage target user") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceSetDepartmentGroupPoolRevokesInvalidManagerGrants(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery("SELECT DISTINCT user_id FROM feishu_user_group_grants"). + WithArgs("dept-a", sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(42).AddRow(43)) + mock.ExpectExec("UPDATE feishu_user_group_grants"). + WithArgs("dept-a", sqlmock.AnyArg(), int64(1), "pool_updated"). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectExec("DELETE FROM feishu_department_group_grants"). + WithArgs("tenant-a", "dept-a", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO feishu_department_group_grants"). + WithArgs("tenant-a", "dept-a", sqlmock.AnyArg(), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM user_allowed_groups WHERE user_id = $1")). + WithArgs(int64(42)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO user_allowed_groups"). + WithArgs(int64(42)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM user_allowed_groups WHERE user_id = $1")). + WithArgs(int64(43)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO user_allowed_groups"). + WithArgs(int64(43)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO feishu_org_permission_audit_logs"). + WithArgs(int64(1), int64(0), "dept-a", FeishuGrantSourceDepartmentManager, "auto_revoke", "pool_updated"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + svc := NewFeishuOrgPermissionService(db, &feishuOrgPermissionInvalidatorStub{}) + result, err := svc.SetDepartmentGroupPool(context.Background(), FeishuSetDepartmentGroupPoolInput{ + ActorUserID: 1, + TenantKey: "tenant-a", + OpenDepartmentID: "dept-a", + GroupIDs: []int64{11}, + Reason: "pool_updated", + }) + + require.NoError(t, err) + require.Equal(t, []int64{11}, result.GroupIDs) + require.Equal(t, []int64{42, 43}, result.RevokedUserIDs) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceListDepartmentsIncludesAssignableGroups(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + syncedAt := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM feishu_departments"). + WithArgs("tenant-a"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(123))) + mock.ExpectQuery("SELECT d.tenant_key"). + WithArgs("tenant-a", 50, 0). + WillReturnRows(sqlmock.NewRows([]string{ + "tenant_key", + "open_department_id", + "parent_open_department_id", + "name", + "path", + "status", + "leader_open_ids", + "last_synced_at", + "employee_count", + "manager_count", + "assignable_groups", + }).AddRow( + "tenant-a", + "dept-a", + "", + "研发部", + "/研发部", + "active", + `["ou_manager"]`, + syncedAt, + int64(3), + int64(1), + `[{"id":11,"name":"Claude Code","platform":"anthropic","subscription_type":"pro"}]`, + )) + + svc := NewFeishuOrgPermissionService(db, nil) + result, err := svc.ListDepartments(context.Background(), FeishuOrgListInput{ + TenantKey: "tenant-a", + Limit: 50, + }) + + require.NoError(t, err) + require.Equal(t, int64(123), result.Total) + require.Len(t, result.Items, 1) + dept := result.Items[0] + require.Equal(t, "dept-a", dept.OpenDepartmentID) + require.Equal(t, []string{"ou_manager"}, dept.LeaderOpenIDs) + require.Equal(t, int64(3), dept.EmployeeCount) + require.Equal(t, int64(1), dept.ManagerCount) + require.Equal(t, int64(11), dept.AssignableGroups[0].ID) + require.Equal(t, "Claude Code", dept.AssignableGroups[0].Name) + require.NotNil(t, dept.LastSyncedAt) + require.Equal(t, syncedAt, *dept.LastSyncedAt) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceListDepartmentsSearchesNameAndPath(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery("LOWER\\(COALESCE\\(d\\.name"). + WithArgs("tenant-a", "工程"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(1))) + mock.ExpectQuery("LOWER\\(COALESCE\\(d\\.name"). + WithArgs("tenant-a", "工程", 20, 0). + WillReturnRows(sqlmock.NewRows([]string{ + "tenant_key", + "open_department_id", + "parent_open_department_id", + "name", + "path", + "status", + "leader_open_ids", + "last_synced_at", + "employee_count", + "manager_count", + "assignable_groups", + }).AddRow( + "tenant-a", + "dept-engineering", + "", + "工程部", + "/技术中心/工程部", + "active", + `[]`, + nil, + int64(5), + int64(1), + `[]`, + )) + + svc := NewFeishuOrgPermissionService(db, nil) + result, err := svc.ListDepartments(context.Background(), FeishuOrgListInput{ + TenantKey: "tenant-a", + Query: " 工程 ", + Limit: 20, + }) + + require.NoError(t, err) + require.Equal(t, int64(1), result.Total) + require.Len(t, result.Items, 1) + require.Equal(t, "工程部", result.Items[0].Name) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceListManagerUsersIncludesAssignableGroups(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery("WITH RECURSIVE manager_roots"). + WithArgs(int64(7)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(321))) + mock.ExpectQuery("WITH RECURSIVE manager_roots"). + WithArgs(int64(7), 50, 0). + WillReturnRows(sqlmock.NewRows([]string{ + "user_id", + "local_email", + "local_username", + "local_status", + "tenant_key", + "open_id", + "union_id", + "feishu_user_id", + "name", + "email", + "employee_no", + "status", + "primary_open_department_id", + "primary_department_name", + "primary_department_path", + "department_open_ids", + "department_manager_group_ids", + "super_admin_override_group_ids", + "effective_group_ids", + "assignable_groups", + "last_synced_at", + }).AddRow( + int64(42), + "a@example.com", + "员工A", + "active", + "tenant-a", + "ou_a", + "on_a", + "u_a", + "员工A", + "a@example.com", + "E001", + "active", + "dept-a", + "研发部", + "/研发部", + `["dept-a","dept-side"]`, + `[11]`, + `[77]`, + `[11,77]`, + `[{"id":11,"name":"Claude Code","platform":"anthropic","subscription_type":"pro"}]`, + nil, + )) + + svc := NewFeishuOrgPermissionService(db, nil) + result, err := svc.ListManagerUsers(context.Background(), 7, FeishuOrgListInput{Limit: 50}) + + require.NoError(t, err) + require.Equal(t, int64(321), result.Total) + require.Len(t, result.Items, 1) + user := result.Items[0] + require.Equal(t, int64(42), user.UserID) + require.Equal(t, "dept-a", user.PrimaryOpenDepartmentID) + require.Equal(t, []string{"dept-a", "dept-side"}, user.DepartmentOpenIDs) + require.Equal(t, []int64{11}, user.DepartmentManagerGroupIDs) + require.Equal(t, []int64{77}, user.SuperAdminOverrideGroupIDs) + require.Equal(t, []int64{11, 77}, user.EffectiveGroupIDs) + require.Equal(t, int64(11), user.AssignableGroups[0].ID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceListManagerLocalUserIDsOnlyReturnsBoundManagedUsers(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery("WITH RECURSIVE manager_roots"). + WithArgs(int64(7)). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}). + AddRow(int64(42)). + AddRow(int64(99))) + + svc := NewFeishuOrgPermissionService(db, nil) + ids, err := svc.ListManagerLocalUserIDs(context.Background(), 7) + + require.NoError(t, err) + require.Equal(t, []int64{42, 99}, ids) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceListUsersSearchesEmployeeAndDepartmentFields(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery("LOWER\\(COALESCE\\(fu\\.name"). + WithArgs("tenant-a", "jane"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(1))) + mock.ExpectQuery("LOWER\\(COALESCE\\(fu\\.name"). + WithArgs("tenant-a", "jane", 20, 0). + WillReturnRows(sqlmock.NewRows([]string{ + "user_id", + "local_email", + "local_username", + "local_status", + "tenant_key", + "open_id", + "union_id", + "feishu_user_id", + "name", + "email", + "employee_no", + "status", + "primary_open_department_id", + "primary_department_name", + "primary_department_path", + "department_open_ids", + "department_manager_group_ids", + "super_admin_override_group_ids", + "effective_group_ids", + "assignable_groups", + "last_synced_at", + }).AddRow( + int64(42), + "jane@example.com", + "Jane", + "active", + "tenant-a", + "ou_jane", + "on_jane", + "u_jane", + "Jane", + "jane@example.com", + "E001", + "active", + "dept-a", + "工程部", + "/技术中心/工程部", + `["dept-a"]`, + `[]`, + `[]`, + `[]`, + `[]`, + nil, + )) + + svc := NewFeishuOrgPermissionService(db, nil) + result, err := svc.ListUsers(context.Background(), FeishuOrgListInput{ + TenantKey: "tenant-a", + Query: " Jane ", + Limit: 20, + }) + + require.NoError(t, err) + require.Equal(t, int64(1), result.Total) + require.Len(t, result.Items, 1) + require.Equal(t, "Jane", result.Items[0].Name) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceListManagerUsersSearchesWithinManagedScope(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectQuery("LOWER\\(COALESCE\\(fu\\.name"). + WithArgs(int64(7), "jane"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(1))) + mock.ExpectQuery("LOWER\\(COALESCE\\(fu\\.name"). + WithArgs(int64(7), "jane", 20, 0). + WillReturnRows(sqlmock.NewRows([]string{ + "user_id", + "local_email", + "local_username", + "local_status", + "tenant_key", + "open_id", + "union_id", + "feishu_user_id", + "name", + "email", + "employee_no", + "status", + "primary_open_department_id", + "primary_department_name", + "primary_department_path", + "department_open_ids", + "department_manager_group_ids", + "super_admin_override_group_ids", + "effective_group_ids", + "assignable_groups", + "last_synced_at", + }).AddRow( + int64(42), + "jane@example.com", + "Jane", + "active", + "tenant-a", + "ou_jane", + "on_jane", + "u_jane", + "Jane", + "jane@example.com", + "E001", + "active", + "dept-a", + "工程部", + "/技术中心/工程部", + `["dept-a"]`, + `[]`, + `[]`, + `[]`, + `[]`, + nil, + )) + + svc := NewFeishuOrgPermissionService(db, nil) + result, err := svc.ListManagerUsers(context.Background(), 7, FeishuOrgListInput{ + Query: " Jane ", + Limit: 20, + }) + + require.NoError(t, err) + require.Equal(t, int64(1), result.Total) + require.Len(t, result.Items, 1) + require.Equal(t, "Jane", result.Items[0].Name) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceListSyncRuns(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + startedAt := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC) + finishedAt := time.Date(2026, 7, 3, 13, 1, 0, 0, time.UTC) + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM feishu_org_sync_runs"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(12))) + mock.ExpectQuery("SELECT id, status, started_at"). + WithArgs(20, 0). + WillReturnRows(sqlmock.NewRows([]string{ + "id", + "status", + "started_at", + "finished_at", + "departments_synced", + "users_synced", + "managers_synced", + "users_to_create", + "users_to_disable", + "bindings_missing", + "review_required", + "error_message", + "triggered_by_user_id", + }).AddRow( + int64(9), + "success", + startedAt, + finishedAt, + 3, + 12, + 2, + 1, + 0, + 4, + false, + "", + int64(1), + )) + + svc := NewFeishuOrgPermissionService(db, nil) + result, err := svc.ListSyncRuns(context.Background(), FeishuOrgListInput{Limit: 20}) + + require.NoError(t, err) + require.Equal(t, int64(12), result.Total) + require.Len(t, result.Items, 1) + run := result.Items[0] + require.Equal(t, int64(9), run.ID) + require.Equal(t, "success", run.Status) + require.Equal(t, 12, run.UsersSynced) + require.Equal(t, 4, run.BindingsMissing) + require.NotNil(t, run.FinishedAt) + require.Equal(t, finishedAt, *run.FinishedAt) + require.Equal(t, int64(1), run.TriggeredByUserID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceRunManualReconcileAutoDisablesDepartedUsers(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + startedAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC) + finishedAt := time.Date(2026, 7, 3, 14, 1, 0, 0, time.UTC) + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM feishu_org_users"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(100)) + mock.ExpectQuery("SELECT DISTINCT org_user.user_id"). + WithArgs(StatusActive, RoleAdmin). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(int64(42)).AddRow(int64(43))) + mock.ExpectExec("UPDATE users"). + WithArgs(StatusDisabled, sqlmock.AnyArg(), RoleAdmin). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectExec("INSERT INTO feishu_org_permission_audit_logs"). + WithArgs(int64(7), int64(42), "", "", "auto_disable_user", "feishu_departure_auto_disable"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO feishu_org_permission_audit_logs"). + WithArgs(int64(7), int64(43), "", "", "auto_disable_user", "feishu_departure_auto_disable"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery("INSERT INTO feishu_org_sync_runs"). + WithArgs("success", 2, false, "", int64(7)). + WillReturnRows(sqlmock.NewRows([]string{ + "id", + "status", + "started_at", + "finished_at", + "departments_synced", + "users_synced", + "managers_synced", + "users_to_create", + "users_to_disable", + "bindings_missing", + "review_required", + "error_message", + "triggered_by_user_id", + }).AddRow(int64(10), "success", startedAt, finishedAt, 0, 0, 0, 0, 2, 0, false, "", int64(7))) + + invalidator := &feishuOrgPermissionInvalidatorStub{} + svc := NewFeishuOrgPermissionService(db, invalidator) + result, err := svc.RunManualReconcile(context.Background(), 7, FeishuDeparturePolicy{ + Action: FeishuDepartedUserActionAutoDisable, + ThresholdCount: 10, + ThresholdPercent: 20, + }) + + require.NoError(t, err) + require.True(t, result.Decision.AutoDisable) + require.Equal(t, []int64{42, 43}, result.Decision.UserIDs) + require.Equal(t, int64(10), result.SyncRun.ID) + require.Equal(t, []int64{42, 43}, invalidator.userIDs) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgPermissionServiceRunManualReconcileRequiresReviewWhenThresholdExceeded(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + startedAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC) + finishedAt := time.Date(2026, 7, 3, 14, 1, 0, 0, time.UTC) + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM feishu_org_users"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(10)) + mock.ExpectQuery("SELECT DISTINCT org_user.user_id"). + WithArgs(StatusActive, RoleAdmin). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(int64(42)).AddRow(int64(43)).AddRow(int64(44))) + mock.ExpectExec("INSERT INTO feishu_org_permission_audit_logs"). + WithArgs(int64(7), int64(0), "", "", "sync_blocked_for_review", "FEISHU_DEPARTURE_THRESHOLD_EXCEEDED"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery("INSERT INTO feishu_org_sync_runs"). + WithArgs("partial_failed", 3, true, "FEISHU_DEPARTURE_THRESHOLD_EXCEEDED", int64(7)). + WillReturnRows(sqlmock.NewRows([]string{ + "id", + "status", + "started_at", + "finished_at", + "departments_synced", + "users_synced", + "managers_synced", + "users_to_create", + "users_to_disable", + "bindings_missing", + "review_required", + "error_message", + "triggered_by_user_id", + }).AddRow(int64(11), "partial_failed", startedAt, finishedAt, 0, 0, 0, 0, 3, 0, true, "FEISHU_DEPARTURE_THRESHOLD_EXCEEDED", int64(7))) + + svc := NewFeishuOrgPermissionService(db, nil) + result, err := svc.RunManualReconcile(context.Background(), 7, FeishuDeparturePolicy{ + Action: FeishuDepartedUserActionAutoDisable, + ThresholdCount: 10, + ThresholdPercent: 20, + }) + + require.NoError(t, err) + require.True(t, result.Decision.RequiresReview) + require.Equal(t, "FEISHU_DEPARTURE_THRESHOLD_EXCEEDED", result.Decision.Reason) + require.Equal(t, "partial_failed", result.SyncRun.Status) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFeishuOrgDirectorySnapshotNormalizesRawFields(t *testing.T) { + snapshot := normalizeFeishuDirectorySnapshot(&FeishuOrgDirectorySnapshot{ + TenantKey: "tenant-a", + Departments: []FeishuOrgDirectoryDepartment{ + parseFeishuDirectoryDepartment(map[string]any{ + "department_id": "od-a", + "parent_department_id": "0", + "name": "Engineering", + "leaders": []any{ + map[string]any{"open_id": "ou-manager"}, + }, + }), + }, + Users: []FeishuOrgDirectoryUser{ + parseFeishuDirectoryUser(map[string]any{ + "open_id": "ou-a", + "union_id": "on-a", + "user_id": "ou-a", + "name": "Alice", + "enterprise_email": "alice@example.com", + "employee_no": "E001", + "department_ids": []any{"od-b", "od-a"}, + "status": map[string]any{ + "is_frozen": true, + }, + }), + }, + }) + + require.Len(t, snapshot.Departments, 1) + require.Equal(t, "tenant-a", snapshot.Departments[0].TenantKey) + require.Equal(t, "od-a", snapshot.Departments[0].OpenDepartmentID) + require.Equal(t, []string{"ou-manager"}, snapshot.Departments[0].LeaderOpenIDs) + require.Equal(t, "Engineering", snapshot.Departments[0].Path) + require.Len(t, snapshot.Users, 1) + require.Equal(t, "tenant-a", snapshot.Users[0].TenantKey) + require.Equal(t, "disabled", snapshot.Users[0].Status) + require.Equal(t, "od-a", snapshot.Users[0].PrimaryOpenDepartmentID) + require.Equal(t, []string{"od-a", "od-b"}, snapshot.Users[0].DepartmentOpenIDs) +} + +func TestFeishuOrgDirectoryHTTPClientRequiresTenantKey(t *testing.T) { + _, err := NewFeishuOrgDirectoryHTTPClient(config.FeishuConnectConfig{ + AppID: "cli_test", + AppSecret: "secret", + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "tenant key") +} + +func TestFeishuOrgDirectoryHTTPClientAllowsLongRunningSyncRequests(t *testing.T) { + client, err := NewFeishuOrgDirectoryHTTPClient(config.FeishuConnectConfig{ + AppID: "cli_test", + AppSecret: "secret", + AllowedTenantKey: "tenant-a", + }) + + require.NoError(t, err) + require.NotNil(t, client.http) + require.GreaterOrEqual(t, client.http.Timeout, time.Minute) +} + +func TestFeishuOrgDirectoryHTTPClientUsesFeishuPageSizeLimit(t *testing.T) { + var departmentPageSizes []string + var departmentParents []string + var userDepartmentIDs []string + var userPageSizes []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/open-apis/auth/v3/tenant_access_token/internal": + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "tenant_access_token": "tenant-token", + }) + case "/open-apis/contact/v3/departments": + departmentPageSizes = append(departmentPageSizes, r.URL.Query().Get("page_size")) + departmentParents = append(departmentParents, r.URL.Query().Get("parent_department_id")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "data": map[string]any{ + "items": []map[string]any{ + { + "department_id": "od-a", + "parent_department_id": "0", + "name": "Engineering", + }, + }, + "has_more": false, + }, + }) + case "/open-apis/contact/v3/users/find_by_department": + userPageSizes = append(userPageSizes, r.URL.Query().Get("page_size")) + userDepartmentIDs = append(userDepartmentIDs, r.URL.Query().Get("department_id")) + items := []map[string]any{} + if r.URL.Query().Get("department_id") == "od-a" { + items = append(items, map[string]any{ + "open_id": "ou-a", + "union_id": "on-a", + "user_id": "u-a", + "name": "Alice", + }) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "data": map[string]any{ + "items": items, + "has_more": false, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client, err := NewFeishuOrgDirectoryHTTPClient(config.FeishuConnectConfig{ + AppID: "cli_test", + AppSecret: "secret", + AllowedTenantKey: "tenant-a", + UserInfoURL: server.URL, + }) + require.NoError(t, err) + + snapshot, err := client.FetchSnapshot(context.Background()) + require.NoError(t, err) + require.Equal(t, []string{"50"}, departmentPageSizes) + require.Equal(t, []string{"0"}, departmentParents) + require.Equal(t, []string{"0", "od-a"}, userDepartmentIDs) + require.Equal(t, []string{"50", "50"}, userPageSizes) + require.Len(t, snapshot.Departments, 1) + require.Equal(t, "od-a", snapshot.Departments[0].OpenDepartmentID) + require.Len(t, snapshot.Users, 1) + require.Equal(t, "od-a", snapshot.Users[0].PrimaryOpenDepartmentID) + require.Equal(t, []string{"od-a"}, snapshot.Users[0].DepartmentOpenIDs) +} + +func TestFeishuOrgPermissionServiceRunFeishuSyncRejectsEmptyUserSnapshot(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + startedAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC) + finishedAt := time.Date(2026, 7, 3, 14, 1, 0, 0, time.UTC) + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM feishu_org_users"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(3)) + mock.ExpectQuery("INSERT INTO feishu_org_sync_runs"). + WithArgs("failed", 0, 0, 0, 0, 0, false, "feishu org sync returned no users", int64(7)). + WillReturnRows(sqlmock.NewRows([]string{ + "id", + "status", + "started_at", + "finished_at", + "departments_synced", + "users_synced", + "managers_synced", + "users_to_create", + "users_to_disable", + "bindings_missing", + "review_required", + "error_message", + "triggered_by_user_id", + }).AddRow(int64(12), "failed", startedAt, finishedAt, 0, 0, 0, 0, 0, 0, false, "feishu org sync returned no users", int64(7))) + + svc := NewFeishuOrgPermissionService(db, nil) + _, err = svc.RunFeishuOrgSyncWithClient(context.Background(), 7, feishuOrgDirectoryClientStub{ + snapshot: &FeishuOrgDirectorySnapshot{TenantKey: "tenant-a"}, + }, FeishuDeparturePolicy{Action: FeishuDepartedUserActionAutoDisable}) + + require.Error(t, err) + require.Contains(t, err.Error(), "no users") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestEvaluateFeishuDepartureDecisionAutoDisablesWithinThreshold(t *testing.T) { + got := EvaluateFeishuDepartureDecision(100, []int64{1, 2}, FeishuDeparturePolicy{ + Action: FeishuDepartedUserActionAutoDisable, + ThresholdCount: 10, + ThresholdPercent: 20, + }) + + require.True(t, got.AutoDisable) + require.False(t, got.RequiresReview) + require.Equal(t, []int64{1, 2}, got.UserIDs) +} + +func TestEvaluateFeishuDepartureDecisionRequiresReviewWhenThresholdExceeded(t *testing.T) { + got := EvaluateFeishuDepartureDecision(10, []int64{1, 2, 3}, FeishuDeparturePolicy{ + Action: FeishuDepartedUserActionAutoDisable, + ThresholdCount: 10, + ThresholdPercent: 20, + }) + + require.False(t, got.AutoDisable) + require.True(t, got.RequiresReview) + require.Equal(t, "FEISHU_DEPARTURE_THRESHOLD_EXCEEDED", got.Reason) +} + +type feishuOrgPermissionInvalidatorStub struct { + userIDs []int64 +} + +type feishuOrgDirectoryClientStub struct { + snapshot *FeishuOrgDirectorySnapshot + err error +} + +func (s feishuOrgDirectoryClientStub) FetchSnapshot(context.Context) (*FeishuOrgDirectorySnapshot, error) { + return s.snapshot, s.err +} + +func (s *feishuOrgPermissionInvalidatorStub) InvalidateAuthCacheByKey(context.Context, string) {} + +func (s *feishuOrgPermissionInvalidatorStub) InvalidateAuthCacheByGroupID(context.Context, int64) {} + +func (s *feishuOrgPermissionInvalidatorStub) InvalidateAuthCacheByUserID(_ context.Context, userID int64) { + s.userIDs = append(s.userIDs, userID) +} diff --git a/backend/internal/service/feishu_org_sync.go b/backend/internal/service/feishu_org_sync.go new file mode 100644 index 00000000000..e7760e9dd24 --- /dev/null +++ b/backend/internal/service/feishu_org_sync.go @@ -0,0 +1,1066 @@ +package service + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/lib/pq" +) + +const ( + feishuOpenAPIBaseURL = "https://open.feishu.cn" + feishuRootDepartmentID = "0" + feishuOrgSyncHTTPTimeout = 60 * time.Second + feishuOrgSyncPageSize = 50 + feishuOrgSyncResponseBodySize = 4 << 20 +) + +type FeishuOrgDirectoryClient interface { + FetchSnapshot(ctx context.Context) (*FeishuOrgDirectorySnapshot, error) +} + +type FeishuOrgDirectorySnapshot struct { + TenantKey string + Departments []FeishuOrgDirectoryDepartment + Users []FeishuOrgDirectoryUser + FetchedAt time.Time +} + +type FeishuOrgDirectoryDepartment struct { + TenantKey string + OpenDepartmentID string + ParentOpenDepartmentID string + Name string + Path string + Status string + LeaderOpenIDs []string + Raw map[string]any +} + +type FeishuOrgDirectoryUser struct { + TenantKey string + OpenID string + UnionID string + FeishuUserID string + Name string + Email string + EmployeeNo string + Status string + PrimaryOpenDepartmentID string + ManagerOpenID string + DepartmentOpenIDs []string + Raw map[string]any +} + +type feishuOrgSyncImportCounts struct { + DepartmentsSynced int + UsersSynced int + ManagersSynced int + BindingsMissing int +} + +type FeishuOrgDirectoryHTTPClient struct { + appID string + appSecret string + tenantKey string + baseURL string + http *http.Client +} + +func NewFeishuOrgDirectoryHTTPClient(cfg config.FeishuConnectConfig) (*FeishuOrgDirectoryHTTPClient, error) { + tenantKey := strings.TrimSpace(cfg.AllowedTenantKey) + if tenantKey == "" { + return nil, infraerrors.BadRequest( + "FEISHU_TENANT_KEY_REQUIRED", + "feishu org sync requires allowed tenant key", + ) + } + appID := strings.TrimSpace(cfg.AppID) + if appID == "" { + return nil, infraerrors.BadRequest("FEISHU_APP_ID_REQUIRED", "feishu app id is required") + } + appSecret := strings.TrimSpace(cfg.AppSecret) + if appSecret == "" { + return nil, infraerrors.BadRequest("FEISHU_APP_SECRET_REQUIRED", "feishu app secret is required") + } + baseURL := feishuOpenAPIBaseURL + if parsed, err := url.Parse(strings.TrimSpace(cfg.UserInfoURL)); err == nil && parsed.Scheme != "" && parsed.Host != "" { + baseURL = parsed.Scheme + "://" + parsed.Host + } + return &FeishuOrgDirectoryHTTPClient{ + appID: appID, + appSecret: appSecret, + tenantKey: tenantKey, + baseURL: strings.TrimRight(baseURL, "/"), + http: &http.Client{Timeout: feishuOrgSyncHTTPTimeout}, + }, nil +} + +func (c *FeishuOrgDirectoryHTTPClient) FetchSnapshot(ctx context.Context) (*FeishuOrgDirectorySnapshot, error) { + if c == nil { + return nil, errors.New("feishu org directory client is not configured") + } + token, err := c.fetchTenantAccessToken(ctx) + if err != nil { + return nil, err + } + departments, err := c.fetchDepartments(ctx, token) + if err != nil { + return nil, err + } + users, err := c.fetchUsers(ctx, token, departments) + if err != nil { + return nil, err + } + return normalizeFeishuDirectorySnapshot(&FeishuOrgDirectorySnapshot{ + TenantKey: c.tenantKey, + Departments: departments, + Users: users, + FetchedAt: time.Now().UTC(), + }), nil +} + +func (c *FeishuOrgDirectoryHTTPClient) fetchTenantAccessToken(ctx context.Context) (string, error) { + payload, err := json.Marshal(map[string]string{ + "app_id": c.appID, + "app_secret": c.appSecret, + }) + if err != nil { + return "", err + } + var out struct { + Code int `json:"code"` + Msg string `json:"msg"` + TenantAccessToken string `json:"tenant_access_token"` + } + if err := c.doJSON(ctx, http.MethodPost, "/open-apis/auth/v3/tenant_access_token/internal", nil, "", bytes.NewReader(payload), &out); err != nil { + return "", err + } + if out.Code != 0 { + return "", fmt.Errorf("feishu tenant access token error code=%d msg=%s", out.Code, out.Msg) + } + if strings.TrimSpace(out.TenantAccessToken) == "" { + return "", errors.New("feishu tenant access token response missing token") + } + return out.TenantAccessToken, nil +} + +func (c *FeishuOrgDirectoryHTTPClient) fetchDepartments(ctx context.Context, token string) ([]FeishuOrgDirectoryDepartment, error) { + var departments []FeishuOrgDirectoryDepartment + pageToken := "" + for { + query := url.Values{} + query.Set("department_id_type", "open_department_id") + query.Set("user_id_type", "open_id") + query.Set("parent_department_id", feishuRootDepartmentID) + query.Set("page_size", fmt.Sprintf("%d", feishuOrgSyncPageSize)) + query.Set("fetch_child", "true") + if pageToken != "" { + query.Set("page_token", pageToken) + } + var out struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Items []map[string]any `json:"items"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + } `json:"data"` + } + if err := c.doJSON(ctx, http.MethodGet, "/open-apis/contact/v3/departments", query, token, nil, &out); err != nil { + return nil, err + } + if out.Code != 0 { + return nil, fmt.Errorf("feishu departments error code=%d msg=%s", out.Code, out.Msg) + } + for _, raw := range out.Data.Items { + dept := parseFeishuDirectoryDepartment(raw) + dept.TenantKey = c.tenantKey + if dept.OpenDepartmentID == "" || dept.OpenDepartmentID == feishuRootDepartmentID { + continue + } + departments = append(departments, dept) + } + if !out.Data.HasMore || strings.TrimSpace(out.Data.PageToken) == "" { + break + } + pageToken = strings.TrimSpace(out.Data.PageToken) + } + return departments, nil +} + +func (c *FeishuOrgDirectoryHTTPClient) fetchUsers(ctx context.Context, token string, departments []FeishuOrgDirectoryDepartment) ([]FeishuOrgDirectoryUser, error) { + departmentIDs := []string{feishuRootDepartmentID} + for _, department := range departments { + departmentIDs = append(departmentIDs, department.OpenDepartmentID) + } + seenDepartments := uniqueSortedStrings(departmentIDs) + seenUsers := make(map[string]FeishuOrgDirectoryUser) + for _, departmentID := range seenDepartments { + pageToken := "" + for { + query := url.Values{} + query.Set("department_id", departmentID) + query.Set("department_id_type", "open_department_id") + query.Set("user_id_type", "open_id") + query.Set("page_size", fmt.Sprintf("%d", feishuOrgSyncPageSize)) + if pageToken != "" { + query.Set("page_token", pageToken) + } + var out struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Items []map[string]any `json:"items"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + } `json:"data"` + } + if err := c.doJSON(ctx, http.MethodGet, "/open-apis/contact/v3/users/find_by_department", query, token, nil, &out); err != nil { + return nil, err + } + if out.Code != 0 { + return nil, fmt.Errorf("feishu users by department %s error code=%d msg=%s", departmentID, out.Code, out.Msg) + } + for _, raw := range out.Data.Items { + user := parseFeishuDirectoryUser(raw) + user.TenantKey = c.tenantKey + if user.OpenID == "" { + continue + } + if departmentID != feishuRootDepartmentID && len(user.DepartmentOpenIDs) == 0 { + user.DepartmentOpenIDs = []string{departmentID} + if user.PrimaryOpenDepartmentID == "" { + user.PrimaryOpenDepartmentID = departmentID + } + } + existing, ok := seenUsers[user.OpenID] + if ok { + user.DepartmentOpenIDs = uniqueSortedStrings(append(existing.DepartmentOpenIDs, user.DepartmentOpenIDs...)) + if user.PrimaryOpenDepartmentID == "" { + user.PrimaryOpenDepartmentID = existing.PrimaryOpenDepartmentID + } + } + seenUsers[user.OpenID] = user + } + if !out.Data.HasMore || strings.TrimSpace(out.Data.PageToken) == "" { + break + } + pageToken = strings.TrimSpace(out.Data.PageToken) + } + } + users := make([]FeishuOrgDirectoryUser, 0, len(seenUsers)) + for _, user := range seenUsers { + users = append(users, user) + } + sort.Slice(users, func(i, j int) bool { + return users[i].OpenID < users[j].OpenID + }) + return users, nil +} + +func (c *FeishuOrgDirectoryHTTPClient) doJSON(ctx context.Context, method, path string, query url.Values, bearerToken string, body io.Reader, dest any) error { + endpoint := c.baseURL + path + if len(query) > 0 { + endpoint += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + if bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+bearerToken) + } + if body != nil { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(resp.Body, feishuOrgSyncResponseBodySize)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("feishu api status %d: %s", resp.StatusCode, truncateFeishuOrgSyncLogValue(string(raw), 1024)) + } + if err := json.Unmarshal(raw, dest); err != nil { + return fmt.Errorf("decode feishu api response: %w", err) + } + return nil +} + +func (s *FeishuOrgPermissionService) RunFeishuOrgSync(ctx context.Context, actorUserID int64, cfg config.FeishuConnectConfig, policy FeishuDeparturePolicy) (*FeishuManualReconcileResult, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + if !cfg.OrgSyncEnabled { + return nil, infraerrors.BadRequest("FEISHU_ORG_SYNC_DISABLED", "feishu org sync is disabled") + } + if cfg.TenantRestrictionPolicy != FeishuTenantRestrictionInternalOnly { + return nil, infraerrors.BadRequest("FEISHU_ORG_SYNC_REQUIRES_INTERNAL", "feishu org sync requires internal tenant policy") + } + client, err := NewFeishuOrgDirectoryHTTPClient(cfg) + if err != nil { + return nil, err + } + return s.RunFeishuOrgSyncWithClient(ctx, actorUserID, client, policy) +} + +func (s *FeishuOrgPermissionService) RunFeishuOrgSyncWithClient(ctx context.Context, actorUserID int64, client FeishuOrgDirectoryClient, policy FeishuDeparturePolicy) (*FeishuManualReconcileResult, error) { + if s == nil || s.db == nil { + return nil, errors.New("feishu org permission service is not configured") + } + if client == nil { + return nil, errors.New("feishu org directory client is not configured") + } + previousActiveUsers, err := s.countActiveFeishuOrgUsers(ctx) + if err != nil { + return nil, fmt.Errorf("count active feishu org users: %w", err) + } + snapshot, err := client.FetchSnapshot(ctx) + if err != nil { + _, _ = s.insertDetailedSyncRun(ctx, "failed", feishuOrgSyncImportCounts{}, 0, false, err.Error(), actorUserID) + return nil, err + } + snapshot = normalizeFeishuDirectorySnapshot(snapshot) + if snapshot == nil || strings.TrimSpace(snapshot.TenantKey) == "" { + err := errors.New("feishu org snapshot missing tenant key") + _, _ = s.insertDetailedSyncRun(ctx, "failed", feishuOrgSyncImportCounts{}, 0, false, err.Error(), actorUserID) + return nil, err + } + if len(snapshot.Users) == 0 { + err := errors.New("feishu org sync returned no users") + _, _ = s.insertDetailedSyncRun(ctx, "failed", feishuOrgSyncImportCounts{}, 0, false, err.Error(), actorUserID) + return nil, err + } + counts, err := s.importFeishuOrgSnapshot(ctx, snapshot) + if err != nil { + _, _ = s.insertDetailedSyncRun(ctx, "failed", counts, 0, false, err.Error(), actorUserID) + return nil, err + } + departedUserIDs, err := s.listDepartedActiveLocalUserIDs(ctx) + if err != nil { + return nil, fmt.Errorf("list departed local users: %w", err) + } + decision := EvaluateFeishuDepartureDecision(previousActiveUsers, departedUserIDs, policy) + + status := "success" + reviewRequired := false + errorMessage := "" + if decision.RequiresReview { + status = "partial_failed" + reviewRequired = true + errorMessage = decision.Reason + if err := s.appendAuditLog(ctx, actorUserID, 0, "", "", "sync_blocked_for_review", decision.Reason); err != nil { + return nil, fmt.Errorf("append review audit log: %w", err) + } + } else if decision.AutoDisable { + if err := s.disableLocalUsers(ctx, decision.UserIDs); err != nil { + return nil, fmt.Errorf("disable local users: %w", err) + } + for _, userID := range decision.UserIDs { + if err := s.appendAuditLog(ctx, actorUserID, userID, "", "", "auto_disable_user", "feishu_departure_auto_disable"); err != nil { + return nil, fmt.Errorf("append auto disable audit log: %w", err) + } + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) + } + } + } + run, err := s.insertDetailedSyncRun(ctx, status, counts, len(departedUserIDs), reviewRequired, errorMessage, actorUserID) + if err != nil { + return nil, fmt.Errorf("insert sync run: %w", err) + } + return &FeishuManualReconcileResult{SyncRun: *run, Decision: decision}, nil +} + +func (s *FeishuOrgPermissionService) importFeishuOrgSnapshot(ctx context.Context, snapshot *FeishuOrgDirectorySnapshot) (feishuOrgSyncImportCounts, error) { + counts := feishuOrgSyncImportCounts{} + for _, department := range snapshot.Departments { + if err := s.upsertFeishuDepartment(ctx, department); err != nil { + return counts, err + } + counts.DepartmentsSynced++ + } + + seenOpenIDs := make([]string, 0, len(snapshot.Users)) + for _, user := range snapshot.Users { + if err := s.upsertFeishuOrgUser(ctx, user); err != nil { + return counts, err + } + if err := s.replaceFeishuUserDepartments(ctx, user); err != nil { + return counts, err + } + seenOpenIDs = append(seenOpenIDs, user.OpenID) + counts.UsersSynced++ + } + if len(seenOpenIDs) > 0 { + if err := s.markMissingFeishuUsersDeparted(ctx, snapshot.TenantKey, seenOpenIDs); err != nil { + return counts, err + } + } + if len(snapshot.Departments) > 0 { + seenDepartments := make([]string, 0, len(snapshot.Departments)) + for _, department := range snapshot.Departments { + seenDepartments = append(seenDepartments, department.OpenDepartmentID) + } + if err := s.markMissingFeishuDepartmentsDeleted(ctx, snapshot.TenantKey, seenDepartments); err != nil { + return counts, err + } + } + managerCount, err := s.replaceFeishuDepartmentManagers(ctx, snapshot.TenantKey, snapshot.Departments) + if err != nil { + return counts, err + } + counts.ManagersSynced = managerCount + bindingsMissing, err := s.countFeishuBindingsMissing(ctx, snapshot.TenantKey) + if err != nil { + return counts, err + } + counts.BindingsMissing = bindingsMissing + return counts, nil +} + +func (s *FeishuOrgPermissionService) upsertFeishuDepartment(ctx context.Context, department FeishuOrgDirectoryDepartment) error { + leaderJSON, err := json.Marshal(uniqueSortedStrings(department.LeaderOpenIDs)) + if err != nil { + return err + } + rawJSON, err := json.Marshal(nonNilMap(department.Raw)) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` +INSERT INTO feishu_departments ( + tenant_key, + open_department_id, + parent_open_department_id, + name, + path, + leader_open_ids, + status, + raw, + last_synced_at, + updated_at +) +VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb, NOW(), NOW()) +ON CONFLICT (tenant_key, open_department_id) DO UPDATE SET + parent_open_department_id = EXCLUDED.parent_open_department_id, + name = EXCLUDED.name, + path = EXCLUDED.path, + leader_open_ids = EXCLUDED.leader_open_ids, + status = EXCLUDED.status, + raw = EXCLUDED.raw, + last_synced_at = NOW(), + updated_at = NOW()`, + department.TenantKey, + department.OpenDepartmentID, + department.ParentOpenDepartmentID, + department.Name, + department.Path, + string(leaderJSON), + normalizeFeishuDepartmentStatus(department.Status), + string(rawJSON), + ) + return err +} + +func (s *FeishuOrgPermissionService) upsertFeishuOrgUser(ctx context.Context, user FeishuOrgDirectoryUser) error { + departmentsJSON, err := json.Marshal(uniqueSortedStrings(user.DepartmentOpenIDs)) + if err != nil { + return err + } + rawJSON, err := json.Marshal(nonNilMap(user.Raw)) + if err != nil { + return err + } + identitySubjects := uniqueSortedStrings([]string{user.OpenID, user.UnionID, user.FeishuUserID}) + _, err = s.db.ExecContext(ctx, ` +WITH matched_identity AS ( + SELECT user_id + FROM auth_identities + WHERE provider_type = 'feishu' + AND provider_subject = ANY($13) + AND (provider_key = $1 OR provider_key = '') + ORDER BY CASE WHEN provider_key = $1 THEN 0 ELSE 1 END, id + LIMIT 1 +) +INSERT INTO feishu_org_users ( + user_id, + tenant_key, + open_id, + union_id, + feishu_user_id, + name, + email, + employee_no, + status, + primary_open_department_id, + manager_open_id, + department_open_ids, + raw, + last_synced_at, + updated_at +) +VALUES ((SELECT user_id FROM matched_identity), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12::jsonb, NOW(), NOW()) +ON CONFLICT (tenant_key, open_id) DO UPDATE SET + user_id = COALESCE((SELECT user_id FROM matched_identity), feishu_org_users.user_id), + union_id = EXCLUDED.union_id, + feishu_user_id = EXCLUDED.feishu_user_id, + name = EXCLUDED.name, + email = EXCLUDED.email, + employee_no = EXCLUDED.employee_no, + status = EXCLUDED.status, + primary_open_department_id = EXCLUDED.primary_open_department_id, + manager_open_id = EXCLUDED.manager_open_id, + department_open_ids = EXCLUDED.department_open_ids, + raw = EXCLUDED.raw, + last_synced_at = NOW(), + updated_at = NOW()`, + user.TenantKey, + user.OpenID, + user.UnionID, + user.FeishuUserID, + user.Name, + user.Email, + user.EmployeeNo, + normalizeFeishuUserStatus(user.Status), + user.PrimaryOpenDepartmentID, + user.ManagerOpenID, + string(departmentsJSON), + string(rawJSON), + pq.Array(identitySubjects), + ) + return err +} + +func (s *FeishuOrgPermissionService) replaceFeishuUserDepartments(ctx context.Context, user FeishuOrgDirectoryUser) error { + if _, err := s.db.ExecContext(ctx, ` +DELETE FROM feishu_user_departments +WHERE tenant_key = $1 + AND open_id = $2`, user.TenantKey, user.OpenID); err != nil { + return err + } + for _, departmentID := range uniqueSortedStrings(user.DepartmentOpenIDs) { + _, err := s.db.ExecContext(ctx, ` +INSERT INTO feishu_user_departments ( + tenant_key, + open_id, + open_department_id, + user_id, + is_primary, + updated_at +) +VALUES ( + $1, + $2, + $3, + (SELECT user_id FROM feishu_org_users WHERE tenant_key = $1 AND open_id = $2), + $4, + NOW() +) +ON CONFLICT (tenant_key, open_id, open_department_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + is_primary = EXCLUDED.is_primary, + updated_at = NOW()`, user.TenantKey, user.OpenID, departmentID, departmentID == user.PrimaryOpenDepartmentID) + if err != nil { + return err + } + } + return nil +} + +func (s *FeishuOrgPermissionService) replaceFeishuDepartmentManagers(ctx context.Context, tenantKey string, departments []FeishuOrgDirectoryDepartment) (int, error) { + if _, err := s.db.ExecContext(ctx, ` +UPDATE feishu_department_managers +SET status = 'disabled', + updated_at = NOW() +WHERE tenant_key = $1 + AND source = 'feishu'`, tenantKey); err != nil { + return 0, err + } + count := 0 + for _, department := range departments { + for _, managerOpenID := range uniqueSortedStrings(department.LeaderOpenIDs) { + _, err := s.db.ExecContext(ctx, ` +INSERT INTO feishu_department_managers ( + tenant_key, + open_department_id, + manager_open_id, + manager_user_id, + source, + relation_type, + include_subdepartments, + status, + updated_at +) +VALUES ( + $1, + $2, + $3, + (SELECT user_id FROM feishu_org_users WHERE tenant_key = $1 AND open_id = $3), + 'feishu', + 'department_leader', + true, + 'active', + NOW() +) +ON CONFLICT (tenant_key, open_department_id, manager_open_id) DO UPDATE SET + manager_user_id = EXCLUDED.manager_user_id, + source = 'feishu', + relation_type = 'department_leader', + include_subdepartments = true, + status = 'active', + updated_at = NOW()`, tenantKey, department.OpenDepartmentID, managerOpenID) + if err != nil { + return count, err + } + count++ + } + } + return count, nil +} + +func (s *FeishuOrgPermissionService) markMissingFeishuUsersDeparted(ctx context.Context, tenantKey string, seenOpenIDs []string) error { + _, err := s.db.ExecContext(ctx, ` +UPDATE feishu_org_users +SET status = 'departed', + last_synced_at = NOW(), + updated_at = NOW() +WHERE tenant_key = $1 + AND status = 'active' + AND NOT (open_id = ANY($2))`, tenantKey, pq.Array(uniqueSortedStrings(seenOpenIDs))) + return err +} + +func (s *FeishuOrgPermissionService) markMissingFeishuDepartmentsDeleted(ctx context.Context, tenantKey string, seenDepartmentIDs []string) error { + _, err := s.db.ExecContext(ctx, ` +UPDATE feishu_departments +SET status = 'deleted', + last_synced_at = NOW(), + updated_at = NOW() +WHERE tenant_key = $1 + AND status = 'active' + AND NOT (open_department_id = ANY($2))`, tenantKey, pq.Array(uniqueSortedStrings(seenDepartmentIDs))) + return err +} + +func (s *FeishuOrgPermissionService) countFeishuBindingsMissing(ctx context.Context, tenantKey string) (int, error) { + var count int + err := scanFeishuSingleRow(ctx, s.db, ` +SELECT COUNT(*) +FROM feishu_org_users +WHERE tenant_key = $1 + AND status = 'active' + AND user_id IS NULL`, []any{tenantKey}, &count) + return count, err +} + +func (s *FeishuOrgPermissionService) insertDetailedSyncRun(ctx context.Context, status string, counts feishuOrgSyncImportCounts, usersToDisable int, reviewRequired bool, errorMessage string, actorUserID int64) (*FeishuOrgSyncRunView, error) { + var item FeishuOrgSyncRunView + var finishedAt sql.NullTime + var triggeredBy sql.NullInt64 + err := scanFeishuSingleRow(ctx, s.db, ` +INSERT INTO feishu_org_sync_runs ( + status, + started_at, + finished_at, + departments_synced, + users_synced, + managers_synced, + users_to_create, + users_to_disable, + bindings_missing, + review_required, + error_message, + triggered_by_user_id +) +VALUES ($1, NOW(), NOW(), $2, $3, $4, 0, $5, $6, $7, $8, NULLIF($9, 0)) +RETURNING id, + status, + started_at, + finished_at, + departments_synced, + users_synced, + managers_synced, + users_to_create, + users_to_disable, + bindings_missing, + review_required, + error_message, + triggered_by_user_id`, []any{ + status, + counts.DepartmentsSynced, + counts.UsersSynced, + counts.ManagersSynced, + usersToDisable, + counts.BindingsMissing, + reviewRequired, + errorMessage, + actorUserID, + }, + &item.ID, + &item.Status, + &item.StartedAt, + &finishedAt, + &item.DepartmentsSynced, + &item.UsersSynced, + &item.ManagersSynced, + &item.UsersToCreate, + &item.UsersToDisable, + &item.BindingsMissing, + &item.ReviewRequired, + &item.ErrorMessage, + &triggeredBy, + ) + if err != nil { + return nil, err + } + item.FinishedAt = sqlNullTimePtr(finishedAt) + if triggeredBy.Valid { + item.TriggeredByUserID = triggeredBy.Int64 + } + return &item, nil +} + +func normalizeFeishuDirectorySnapshot(snapshot *FeishuOrgDirectorySnapshot) *FeishuOrgDirectorySnapshot { + if snapshot == nil { + return nil + } + snapshot.TenantKey = strings.TrimSpace(snapshot.TenantKey) + if snapshot.FetchedAt.IsZero() { + snapshot.FetchedAt = time.Now().UTC() + } + snapshot.Departments = normalizeFeishuDirectoryDepartments(snapshot.TenantKey, snapshot.Departments) + snapshot.Users = normalizeFeishuDirectoryUsers(snapshot.TenantKey, snapshot.Users) + return snapshot +} + +func normalizeFeishuDirectoryDepartments(tenantKey string, departments []FeishuOrgDirectoryDepartment) []FeishuOrgDirectoryDepartment { + departmentsByID := make(map[string]FeishuOrgDirectoryDepartment) + for _, department := range departments { + department.TenantKey = firstNonEmpty(strings.TrimSpace(department.TenantKey), tenantKey) + department.OpenDepartmentID = strings.TrimSpace(department.OpenDepartmentID) + if department.OpenDepartmentID == "" || department.OpenDepartmentID == feishuRootDepartmentID { + continue + } + department.ParentOpenDepartmentID = strings.TrimSpace(department.ParentOpenDepartmentID) + department.Name = strings.TrimSpace(department.Name) + if department.Name == "" { + department.Name = department.OpenDepartmentID + } + department.Status = normalizeFeishuDepartmentStatus(department.Status) + department.LeaderOpenIDs = uniqueSortedStrings(department.LeaderOpenIDs) + departmentsByID[department.OpenDepartmentID] = department + } + out := make([]FeishuOrgDirectoryDepartment, 0, len(departmentsByID)) + for id, department := range departmentsByID { + if strings.TrimSpace(department.Path) == "" { + department.Path = buildFeishuDepartmentPath(id, departmentsByID, nil) + } + out = append(out, department) + } + sort.Slice(out, func(i, j int) bool { + return out[i].OpenDepartmentID < out[j].OpenDepartmentID + }) + return out +} + +func normalizeFeishuDirectoryUsers(tenantKey string, users []FeishuOrgDirectoryUser) []FeishuOrgDirectoryUser { + usersByOpenID := make(map[string]FeishuOrgDirectoryUser) + for _, user := range users { + user.TenantKey = firstNonEmpty(strings.TrimSpace(user.TenantKey), tenantKey) + user.OpenID = strings.TrimSpace(user.OpenID) + if user.OpenID == "" { + continue + } + user.UnionID = strings.TrimSpace(user.UnionID) + user.FeishuUserID = strings.TrimSpace(user.FeishuUserID) + user.Name = strings.TrimSpace(user.Name) + if user.Name == "" { + user.Name = firstNonEmpty(user.Email, user.OpenID) + } + user.Email = strings.TrimSpace(user.Email) + user.EmployeeNo = strings.TrimSpace(user.EmployeeNo) + user.Status = normalizeFeishuUserStatus(user.Status) + user.DepartmentOpenIDs = uniqueSortedStrings(user.DepartmentOpenIDs) + if user.PrimaryOpenDepartmentID == "" { + user.PrimaryOpenDepartmentID = firstString(user.DepartmentOpenIDs) + } + usersByOpenID[user.OpenID] = user + } + out := make([]FeishuOrgDirectoryUser, 0, len(usersByOpenID)) + for _, user := range usersByOpenID { + out = append(out, user) + } + sort.Slice(out, func(i, j int) bool { + return out[i].OpenID < out[j].OpenID + }) + return out +} + +func buildFeishuDepartmentPath(departmentID string, departments map[string]FeishuOrgDirectoryDepartment, visiting map[string]bool) string { + department, ok := departments[departmentID] + if !ok { + return departmentID + } + if visiting == nil { + visiting = make(map[string]bool) + } + if visiting[departmentID] { + return department.Name + } + visiting[departmentID] = true + parentID := strings.TrimSpace(department.ParentOpenDepartmentID) + if parentID == "" || parentID == feishuRootDepartmentID { + return department.Name + } + parentPath := buildFeishuDepartmentPath(parentID, departments, visiting) + if parentPath == "" { + return department.Name + } + return parentPath + "/" + department.Name +} + +func parseFeishuDirectoryDepartment(raw map[string]any) FeishuOrgDirectoryDepartment { + departmentID := firstNonEmpty( + feishuMapString(raw, "open_department_id"), + feishuMapString(raw, "department_id"), + feishuMapString(raw, "id"), + ) + parentID := firstNonEmpty( + feishuMapString(raw, "parent_open_department_id"), + feishuMapString(raw, "parent_department_id"), + ) + return FeishuOrgDirectoryDepartment{ + OpenDepartmentID: departmentID, + ParentOpenDepartmentID: parentID, + Name: firstNonEmpty(feishuMapString(raw, "name"), feishuMapString(raw, "department_name")), + Status: parseFeishuDepartmentStatus(raw), + LeaderOpenIDs: parseFeishuLeaderOpenIDs(raw), + Raw: raw, + } +} + +func parseFeishuDirectoryUser(raw map[string]any) FeishuOrgDirectoryUser { + openID := firstNonEmpty( + feishuMapString(raw, "open_id"), + feishuMapString(raw, "user_id"), + feishuMapString(raw, "id"), + ) + departmentIDs := firstNonEmptyStringSlice( + feishuMapStringSlice(raw, "department_open_ids"), + feishuMapStringSlice(raw, "department_ids"), + ) + return FeishuOrgDirectoryUser{ + OpenID: openID, + UnionID: feishuMapString(raw, "union_id"), + FeishuUserID: firstNonEmpty(feishuMapString(raw, "user_id"), openID), + Name: firstNonEmpty(feishuMapString(raw, "name"), feishuMapString(raw, "en_name")), + Email: firstNonEmpty(feishuMapString(raw, "email"), feishuMapString(raw, "enterprise_email")), + EmployeeNo: firstNonEmpty(feishuMapString(raw, "employee_no"), feishuMapString(raw, "employee_id")), + Status: parseFeishuUserStatus(raw), + PrimaryOpenDepartmentID: firstNonEmpty(feishuMapString(raw, "primary_department_id"), firstString(departmentIDs)), + ManagerOpenID: firstNonEmpty(feishuMapString(raw, "leader_user_id"), feishuMapString(raw, "manager_open_id")), + DepartmentOpenIDs: departmentIDs, + Raw: raw, + } +} + +func parseFeishuDepartmentStatus(raw map[string]any) string { + if strings.EqualFold(feishuMapString(raw, "status"), "deleted") || feishuMapBool(raw, "is_deleted") { + return "deleted" + } + return "active" +} + +func parseFeishuUserStatus(raw map[string]any) string { + if status, ok := raw["status"].(map[string]any); ok { + if feishuMapBool(status, "is_resigned") || feishuMapBool(status, "is_exited") { + return "departed" + } + if feishuMapBool(status, "is_frozen") || feishuMapBool(status, "is_suspended") { + return "disabled" + } + } + status := strings.ToLower(strings.TrimSpace(feishuMapString(raw, "status"))) + switch status { + case "departed", "resigned", "exited": + return "departed" + case "disabled", "frozen", "suspended": + return "disabled" + default: + return "active" + } +} + +func parseFeishuLeaderOpenIDs(raw map[string]any) []string { + ids := []string{ + feishuMapString(raw, "leader_open_id"), + feishuMapString(raw, "leader_user_id"), + feishuMapString(raw, "department_leader_user_id"), + } + ids = append(ids, feishuMapStringSlice(raw, "leader_open_ids")...) + ids = append(ids, feishuMapStringSlice(raw, "leader_user_ids")...) + if leaders, ok := raw["leaders"].([]any); ok { + for _, value := range leaders { + if leader, ok := value.(map[string]any); ok { + ids = append(ids, + feishuMapString(leader, "open_id"), + feishuMapString(leader, "user_id"), + feishuMapString(leader, "leader_id"), + feishuMapString(leader, "leaderID"), + ) + } + } + } + return uniqueSortedStrings(ids) +} + +func normalizeFeishuDepartmentStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "deleted": + return "deleted" + default: + return "active" + } +} + +func normalizeFeishuUserStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "departed", "resigned", "exited": + return "departed" + case "disabled", "frozen", "suspended": + return "disabled" + default: + return "active" + } +} + +func feishuMapString(raw map[string]any, key string) string { + if raw == nil { + return "" + } + value, ok := raw[key] + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return strings.TrimSpace(typed.String()) + case fmt.Stringer: + return strings.TrimSpace(typed.String()) + default: + return strings.TrimSpace(fmt.Sprintf("%v", typed)) + } +} + +func feishuMapBool(raw map[string]any, key string) bool { + if raw == nil { + return false + } + value, ok := raw[key] + if !ok || value == nil { + return false + } + switch typed := value.(type) { + case bool: + return typed + case string: + return strings.EqualFold(strings.TrimSpace(typed), "true") + default: + return false + } +} + +func feishuMapStringSlice(raw map[string]any, key string) []string { + if raw == nil { + return nil + } + value, ok := raw[key] + if !ok || value == nil { + return nil + } + switch typed := value.(type) { + case []string: + return uniqueSortedStrings(typed) + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if item == nil { + continue + } + out = append(out, strings.TrimSpace(fmt.Sprintf("%v", item))) + } + return uniqueSortedStrings(out) + case string: + if strings.TrimSpace(typed) == "" { + return nil + } + return []string{strings.TrimSpace(typed)} + default: + return nil + } +} + +func firstNonEmptyStringSlice(values ...[]string) []string { + for _, value := range values { + if len(uniqueSortedStrings(value)) > 0 { + return uniqueSortedStrings(value) + } + } + return nil +} + +func uniqueSortedStrings(values []string) []string { + seen := make(map[string]struct{}) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + seen[trimmed] = struct{}{} + } + out := make([]string, 0, len(seen)) + for value := range seen { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func nonNilMap(value map[string]any) map[string]any { + if value == nil { + return map[string]any{} + } + return value +} + +func truncateFeishuOrgSyncLogValue(value string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) + "..." +} diff --git a/backend/internal/service/leader_lock.go b/backend/internal/service/leader_lock.go index a0c774a6539..9cd43df0625 100644 --- a/backend/internal/service/leader_lock.go +++ b/backend/internal/service/leader_lock.go @@ -66,3 +66,32 @@ func tryAcquireSingletonLeaderLock(ctx context.Context, cache LeaderLockCache, d // No coordination backend available: run without gating. return func() {}, true } + +// tryAcquireStrictSingletonLeaderLock is for jobs where duplicate execution is +// worse than skipping one cycle. If Redis is configured but errors, it skips +// instead of falling through to a different backend, avoiding split-brain when a +// peer may already hold the Redis lock. +func tryAcquireStrictSingletonLeaderLock(ctx context.Context, cache LeaderLockCache, db *sql.DB, key, owner string, ttl time.Duration) (func(), bool) { + if ctx == nil { + ctx = context.Background() + } + + if cache != nil { + ok, err := cache.TryAcquireLeaderLock(ctx, key, owner, ttl) + if err != nil || !ok { + return nil, false + } + release := func() { + ctx2, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = cache.ReleaseLeaderLock(ctx2, key, owner) + } + return release, true + } + + if db != nil { + return tryAcquireDBAdvisoryLock(ctx, db, hashAdvisoryLockID(key)) + } + + return func() {}, true +} diff --git a/backend/internal/service/leader_lock_test.go b/backend/internal/service/leader_lock_test.go index aba1c62c3ee..4633c2b832a 100644 --- a/backend/internal/service/leader_lock_test.go +++ b/backend/internal/service/leader_lock_test.go @@ -86,6 +86,13 @@ func TestTryAcquireSingletonLeaderLock_CacheErrorFallsThrough(t *testing.T) { require.NotPanics(t, release) } +func TestTryAcquireStrictSingletonLeaderLock_CacheErrorSkips(t *testing.T) { + cache := &fakeLeaderLockCache{acquireErr: context.DeadlineExceeded} + release, ok := tryAcquireStrictSingletonLeaderLock(context.Background(), cache, nil, "k", "inst", time.Minute) + require.False(t, ok, "strict lock must skip instead of running ungated when cache errors") + require.Nil(t, release) +} + func TestSubscriptionExpiryService_ReminderSkipsScanWhenNotLeader(t *testing.T) { cache := &fakeLeaderLockCache{} // A peer already holds the reminder leader lock. diff --git a/backend/internal/service/scheduled_test_runner_service.go b/backend/internal/service/scheduled_test_runner_service.go index f4d35f69865..fd8ed46bc51 100644 --- a/backend/internal/service/scheduled_test_runner_service.go +++ b/backend/internal/service/scheduled_test_runner_service.go @@ -2,15 +2,21 @@ package service import ( "context" + "database/sql" "sync" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/google/uuid" "github.com/robfig/cron/v3" ) -const scheduledTestDefaultMaxWorkers = 10 +const ( + scheduledTestDefaultMaxWorkers = 10 + scheduledTestRunnerLeaderLockKey = "scheduled_test:runner:leader" + scheduledTestRunnerLeaderLockTTL = 6 * time.Minute +) // ScheduledTestRunnerService periodically scans due test plans and executes them. type ScheduledTestRunnerService struct { @@ -20,6 +26,10 @@ type ScheduledTestRunnerService struct { rateLimitSvc *RateLimitService cfg *config.Config + lockCache LeaderLockCache + db *sql.DB + instanceID string + cron *cron.Cron startOnce sync.Once stopOnce sync.Once @@ -39,7 +49,19 @@ func NewScheduledTestRunnerService( accountTestSvc: accountTestSvc, rateLimitSvc: rateLimitSvc, cfg: cfg, + instanceID: uuid.NewString(), + } +} + +// SetLeaderLock injects the distributed lock backend used to ensure only one +// instance scans and runs due plans each minute. With no backend, the runner +// keeps single-instance/test behavior and runs ungated. +func (s *ScheduledTestRunnerService) SetLeaderLock(lockCache LeaderLockCache, db *sql.DB) { + if s == nil { + return } + s.lockCache = lockCache + s.db = db } // Start begins the cron ticker (every minute). @@ -85,12 +107,24 @@ func (s *ScheduledTestRunnerService) Stop() { } func (s *ScheduledTestRunnerService) runScheduled() { + s.runScheduledCycle(10 * time.Second) +} + +func (s *ScheduledTestRunnerService) runScheduledCycle(delay time.Duration) { // Delay 10s so execution lands at ~:10 of each minute instead of :00. - time.Sleep(10 * time.Second) + if delay > 0 { + time.Sleep(delay) + } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() + release, ok := tryAcquireStrictSingletonLeaderLock(ctx, s.lockCache, s.db, scheduledTestRunnerLeaderLockKey, s.instanceID, scheduledTestRunnerLeaderLockTTL) + if !ok { + return + } + defer release() + now := time.Now() plans, err := s.planRepo.ListDue(ctx, now) if err != nil { diff --git a/backend/internal/service/scheduled_test_runner_service_test.go b/backend/internal/service/scheduled_test_runner_service_test.go new file mode 100644 index 00000000000..c2267413cf3 --- /dev/null +++ b/backend/internal/service/scheduled_test_runner_service_test.go @@ -0,0 +1,123 @@ +package service + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type scheduledTestPlanRepoStub struct { + listDueCalls atomic.Int64 +} + +func (r *scheduledTestPlanRepoStub) Create(context.Context, *ScheduledTestPlan) (*ScheduledTestPlan, error) { + return nil, nil +} + +func (r *scheduledTestPlanRepoStub) GetByID(context.Context, int64) (*ScheduledTestPlan, error) { + return nil, nil +} + +func (r *scheduledTestPlanRepoStub) ListByAccountID(context.Context, int64) ([]*ScheduledTestPlan, error) { + return nil, nil +} + +func (r *scheduledTestPlanRepoStub) ListDue(context.Context, time.Time) ([]*ScheduledTestPlan, error) { + r.listDueCalls.Add(1) + return nil, nil +} + +func (r *scheduledTestPlanRepoStub) Update(context.Context, *ScheduledTestPlan) (*ScheduledTestPlan, error) { + return nil, nil +} + +func (r *scheduledTestPlanRepoStub) Delete(context.Context, int64) error { + return nil +} + +func (r *scheduledTestPlanRepoStub) UpdateAfterRun(context.Context, int64, time.Time, time.Time) error { + return nil +} + +func TestScheduledTestRunner_SkipsScanWhenNotLeader(t *testing.T) { + cache := &fakeLeaderLockCache{} + _, _ = cache.TryAcquireLeaderLock(context.Background(), scheduledTestRunnerLeaderLockKey, "peer", time.Minute) + + repo := &scheduledTestPlanRepoStub{} + runner := NewScheduledTestRunnerService(repo, nil, nil, nil, &config.Config{}) + runner.SetLeaderLock(cache, nil) + + runner.runScheduledCycle(0) + + require.Zero(t, repo.listDueCalls.Load(), "non-leader must not scan due scheduled tests") +} + +func TestScheduledTestRunner_SkipsScanWhenLeaderLockErrors(t *testing.T) { + repo := &scheduledTestPlanRepoStub{} + runner := NewScheduledTestRunnerService(repo, nil, nil, nil, &config.Config{}) + runner.SetLeaderLock(&fakeLeaderLockCache{acquireErr: context.DeadlineExceeded}, nil) + + runner.runScheduledCycle(0) + + require.Zero(t, repo.listDueCalls.Load(), "lock errors must not run scheduled test scan ungated") +} + +func TestScheduledTestRunner_ScansWhenLeader(t *testing.T) { + repo := &scheduledTestPlanRepoStub{} + runner := NewScheduledTestRunnerService(repo, nil, nil, nil, &config.Config{}) + runner.SetLeaderLock(&fakeLeaderLockCache{}, nil) + + runner.runScheduledCycle(0) + + require.Equal(t, int64(1), repo.listDueCalls.Load(), "leader should scan due scheduled tests once") +} + +type recordingLeaderLockCache struct { + fakeLeaderLockCache + acquireCalled chan struct{} +} + +func (c *recordingLeaderLockCache) TryAcquireLeaderLock(ctx context.Context, key, owner string, ttl time.Duration) (bool, error) { + select { + case <-c.acquireCalled: + default: + close(c.acquireCalled) + } + return c.fakeLeaderLockCache.TryAcquireLeaderLock(ctx, key, owner, ttl) +} + +func TestScheduledTestRunner_DelaysBeforeTryingLeaderLock(t *testing.T) { + repo := &scheduledTestPlanRepoStub{} + cache := &recordingLeaderLockCache{acquireCalled: make(chan struct{})} + runner := NewScheduledTestRunnerService(repo, nil, nil, nil, &config.Config{}) + runner.SetLeaderLock(cache, nil) + + done := make(chan struct{}) + go func() { + runner.runScheduledCycle(40 * time.Millisecond) + close(done) + }() + + select { + case <-cache.acquireCalled: + t.Fatal("leader lock should not be acquired before the scheduled delay") + case <-time.After(15 * time.Millisecond): + } + + select { + case <-cache.acquireCalled: + case <-time.After(200 * time.Millisecond): + t.Fatal("leader lock was not acquired after the scheduled delay") + } + + select { + case <-done: + case <-time.After(200 * time.Millisecond): + t.Fatal("runScheduledCycle did not finish") + } + require.Equal(t, int64(1), repo.listDueCalls.Load()) +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 1a5e677a120..7ab24bccfd8 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -45,6 +45,24 @@ func coerceDeprecatedDingTalkCorpPolicy(policy string) string { return policy } +func NormalizeFeishuTenantRestrictionPolicy(policy string) string { + switch strings.ToLower(strings.TrimSpace(policy)) { + case FeishuTenantRestrictionNone: + return FeishuTenantRestrictionNone + default: + return FeishuTenantRestrictionInternalOnly + } +} + +func normalizeFeishuDepartedUserAction(action string) string { + switch strings.ToLower(strings.TrimSpace(action)) { + case FeishuDepartedUserActionAutoDisable: + return FeishuDepartedUserActionAutoDisable + default: + return FeishuDepartedUserActionAutoDisable + } +} + var ( ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found") @@ -242,6 +260,7 @@ type AuthSourceDefaultSettings struct { GitHub ProviderDefaultGrantSettings Google ProviderDefaultGrantSettings DingTalk ProviderDefaultGrantSettings + Feishu ProviderDefaultGrantSettings ForceEmailOnThirdPartySignup bool } @@ -321,6 +340,15 @@ var ( grantOnFirstBind: SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind, platformQuotas: SettingKeyAuthSourcePlatformQuotas("dingtalk"), } + feishuAuthSourceDefaultKeys = authSourceDefaultKeySet{ + source: "feishu", + balance: SettingKeyAuthSourceDefaultFeishuBalance, + concurrency: SettingKeyAuthSourceDefaultFeishuConcurrency, + subscriptions: SettingKeyAuthSourceDefaultFeishuSubscriptions, + grantOnSignup: SettingKeyAuthSourceDefaultFeishuGrantOnSignup, + grantOnFirstBind: SettingKeyAuthSourceDefaultFeishuGrantOnFirstBind, + platformQuotas: SettingKeyAuthSourcePlatformQuotas("feishu"), + } ) const ( @@ -783,6 +811,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyLoginAgreementMode, SettingKeyLoginAgreementUpdatedAt, SettingKeyLoginAgreementDocuments, + SettingKeyEmailPasswordLoginEnabled, + SettingKeyAdminEmailLoginFallbackEnabled, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, SettingKeyAPIKeyACLTrustForwardedIP, @@ -802,6 +832,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyCustomEndpoints, SettingKeyLinuxDoConnectEnabled, SettingKeyDingTalkConnectEnabled, + SettingKeyFeishuConnectEnabled, + SettingKeyFeishuOrgSyncEnabled, SettingKeyWeChatConnectEnabled, SettingKeyWeChatConnectAppID, SettingKeyWeChatConnectAppSecret, @@ -857,6 +889,20 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings } else { dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled } + feishuEnabled := false + if raw, ok := settings[SettingKeyFeishuConnectEnabled]; ok { + feishuEnabled = raw == "true" + } else { + feishuEnabled = s.cfg != nil && s.cfg.Feishu.Enabled + } + emailPasswordLoginEnabled := true + if raw, ok := settings[SettingKeyEmailPasswordLoginEnabled]; ok { + emailPasswordLoginEnabled = raw != "false" + } + adminEmailLoginFallbackEnabled := true + if raw, ok := settings[SettingKeyAdminEmailLoginFallbackEnabled]; ok { + adminEmailLoginFallbackEnabled = raw != "false" + } oidcEnabled := false if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { oidcEnabled = raw == "true" @@ -925,8 +971,12 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings TablePageSizeOptions: tablePageSizeOptions, CustomMenuItems: settings[SettingKeyCustomMenuItems], CustomEndpoints: settings[SettingKeyCustomEndpoints], + EmailPasswordLoginEnabled: emailPasswordLoginEnabled, + AdminEmailLoginFallbackEnabled: adminEmailLoginFallbackEnabled, LinuxDoOAuthEnabled: linuxDoEnabled, DingTalkOAuthEnabled: dingTalkEnabled, + FeishuOAuthEnabled: feishuEnabled, + FeishuOrgSyncEnabled: settings[SettingKeyFeishuOrgSyncEnabled] == "true", WeChatOAuthEnabled: weChatEnabled, WeChatOAuthOpenEnabled: weChatOpenEnabled, WeChatOAuthMPEnabled: weChatMPEnabled, @@ -1449,8 +1499,12 @@ type PublicSettingsInjectionPayload struct { TablePageSizeOptions []int `json:"table_page_size_options"` CustomMenuItems json.RawMessage `json:"custom_menu_items"` CustomEndpoints json.RawMessage `json:"custom_endpoints"` + EmailPasswordLoginEnabled bool `json:"email_password_login_enabled"` + AdminEmailLoginFallbackEnabled bool `json:"admin_email_login_fallback_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` + FeishuOAuthEnabled bool `json:"feishu_oauth_enabled"` + FeishuOrgSyncEnabled bool `json:"feishu_org_sync_enabled"` WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` @@ -1518,8 +1572,12 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any TablePageSizeOptions: settings.TablePageSizeOptions, CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), + EmailPasswordLoginEnabled: settings.EmailPasswordLoginEnabled, + AdminEmailLoginFallbackEnabled: settings.AdminEmailLoginFallbackEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, + FeishuOAuthEnabled: settings.FeishuOAuthEnabled, + FeishuOrgSyncEnabled: settings.FeishuOrgSyncEnabled, WeChatOAuthEnabled: settings.WeChatOAuthEnabled, WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, @@ -1953,6 +2011,14 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting if settings.GoogleOAuthFrontendRedirectURL == "" { settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend } + normalizeFeishuSystemSettings(settings) + if !settings.LoginEntrySettingsExplicit { + settings.EmailPasswordLoginEnabled = true + settings.AdminEmailLoginFallbackEnabled = true + } + if err := s.ValidateLoginEntryAvailability(ctx, settings); err != nil { + return nil, err + } updates := make(map[string]string) @@ -1982,6 +2048,8 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON + updates[SettingKeyEmailPasswordLoginEnabled] = strconv.FormatBool(settings.EmailPasswordLoginEnabled) + updates[SettingKeyAdminEmailLoginFallbackEnabled] = strconv.FormatBool(settings.AdminEmailLoginFallbackEnabled) // 邮件服务设置(只有非空才更新密码) updates[SettingKeySMTPHost] = settings.SMTPHost @@ -2030,6 +2098,24 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyDingTalkConnectSyncDisplayNameAttrName] = settings.DingTalkConnectSyncDisplayNameAttrName updates[SettingKeyDingTalkConnectSyncDeptAttrName] = settings.DingTalkConnectSyncDeptAttrName + // Feishu Connect OAuth 登录 + updates[SettingKeyFeishuConnectEnabled] = strconv.FormatBool(settings.FeishuConnectEnabled) + updates[SettingKeyFeishuConnectAppID] = settings.FeishuConnectAppID + updates[SettingKeyFeishuConnectRedirectURL] = settings.FeishuConnectRedirectURL + if settings.FeishuConnectAppSecret != "" { + updates[SettingKeyFeishuConnectAppSecret] = settings.FeishuConnectAppSecret + } + updates[SettingKeyFeishuConnectTenantRestrictionPolicy] = settings.FeishuConnectTenantRestrictionPolicy + updates[SettingKeyFeishuConnectAllowedTenantKey] = settings.FeishuConnectAllowedTenantKey + updates[SettingKeyFeishuConnectBypassRegistration] = strconv.FormatBool(settings.FeishuConnectBypassRegistration) + updates[SettingKeyFeishuConnectSyncEmail] = strconv.FormatBool(settings.FeishuConnectSyncEmail) + updates[SettingKeyFeishuConnectSyncDisplayName] = strconv.FormatBool(settings.FeishuConnectSyncDisplayName) + updates[SettingKeyFeishuConnectSyncDepartment] = strconv.FormatBool(settings.FeishuConnectSyncDepartment) + updates[SettingKeyFeishuOrgSyncEnabled] = strconv.FormatBool(settings.FeishuOrgSyncEnabled) + updates[SettingKeyFeishuDepartedUserAction] = settings.FeishuDepartedUserAction + updates[SettingKeyFeishuSyncDisableThresholdCount] = strconv.Itoa(settings.FeishuSyncDisableThresholdCount) + updates[SettingKeyFeishuSyncDisableThresholdPercent] = strconv.Itoa(settings.FeishuSyncDisableThresholdPercent) + // Generic OIDC OAuth 登录 updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled) updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName @@ -2255,6 +2341,215 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting return updates, nil } +func normalizeFeishuSystemSettings(settings *SystemSettings) { + if settings == nil { + return + } + settings.FeishuConnectAppID = strings.TrimSpace(settings.FeishuConnectAppID) + settings.FeishuConnectAppSecret = strings.TrimSpace(settings.FeishuConnectAppSecret) + settings.FeishuConnectRedirectURL = strings.TrimSpace(settings.FeishuConnectRedirectURL) + settings.FeishuConnectTenantRestrictionPolicy = NormalizeFeishuTenantRestrictionPolicy(settings.FeishuConnectTenantRestrictionPolicy) + settings.FeishuConnectAllowedTenantKey = strings.TrimSpace(settings.FeishuConnectAllowedTenantKey) + settings.FeishuDepartedUserAction = normalizeFeishuDepartedUserAction(settings.FeishuDepartedUserAction) + if settings.FeishuSyncDisableThresholdCount <= 0 { + settings.FeishuSyncDisableThresholdCount = FeishuDefaultDisableThresholdCount + } + if settings.FeishuSyncDisableThresholdPercent <= 0 { + settings.FeishuSyncDisableThresholdPercent = FeishuDefaultDisableThresholdPct + } + if settings.FeishuConnectTenantRestrictionPolicy != FeishuTenantRestrictionInternalOnly { + settings.FeishuConnectBypassRegistration = false + settings.FeishuOrgSyncEnabled = false + } +} + +func (s *SettingService) ValidateLoginEntryAvailability(ctx context.Context, settings *SystemSettings) error { + if settings == nil { + return nil + } + normalizeFeishuSystemSettings(settings) + + if settings.FeishuConnectEnabled && !feishuConnectConfigComplete(settings) { + return infraerrors.BadRequest( + "FEISHU_CONFIG_INCOMPLETE", + "feishu login requires app id, app secret, redirect url, and tenant restriction policy", + ) + } + + hasUserLoginEntry := settings.EmailPasswordLoginEnabled || + settings.LinuxDoConnectEnabled || + settings.DingTalkConnectEnabled || + settings.FeishuConnectEnabled || + settings.WeChatConnectEnabled || + settings.OIDCConnectEnabled || + settings.GitHubOAuthEnabled || + settings.GoogleOAuthEnabled + if !hasUserLoginEntry { + return infraerrors.BadRequest( + "LOGIN_ENTRY_UNAVAILABLE", + "at least one user login entry must remain available", + ) + } + if !settings.EmailPasswordLoginEnabled && !settings.AdminEmailLoginFallbackEnabled { + return infraerrors.BadRequest( + "ADMIN_LOGIN_FALLBACK_REQUIRED", + "admin email fallback login must remain enabled when normal email login is disabled", + ) + } + return nil +} + +func feishuConnectConfigComplete(settings *SystemSettings) bool { + if settings == nil { + return false + } + return strings.TrimSpace(settings.FeishuConnectAppID) != "" && + strings.TrimSpace(settings.FeishuConnectAppSecret) != "" && + strings.TrimSpace(settings.FeishuConnectRedirectURL) != "" && + strings.TrimSpace(settings.FeishuConnectTenantRestrictionPolicy) != "" +} + +func defaultFeishuConnectConfig(base config.FeishuConnectConfig) config.FeishuConnectConfig { + if strings.TrimSpace(base.AuthorizeURL) == "" { + base.AuthorizeURL = "https://accounts.feishu.cn/open-apis/authen/v1/authorize" + } + if strings.TrimSpace(base.TokenURL) == "" { + base.TokenURL = "https://accounts.feishu.cn/oauth/v3/token" + } + if strings.TrimSpace(base.UserInfoURL) == "" { + base.UserInfoURL = "https://open.feishu.cn/open-apis/authen/v1/user_info" + } + if strings.TrimSpace(base.Scopes) == "" { + base.Scopes = "contact:user.base:readonly contact:user.email:readonly contact:user.employee_id:readonly" + } + if strings.TrimSpace(base.FrontendRedirectURL) == "" { + base.FrontendRedirectURL = "/auth/feishu/callback" + } + base.TenantRestrictionPolicy = NormalizeFeishuTenantRestrictionPolicy(base.TenantRestrictionPolicy) + base.DepartedUserAction = normalizeFeishuDepartedUserAction(base.DepartedUserAction) + if base.DisableThresholdCount <= 0 { + base.DisableThresholdCount = FeishuDefaultDisableThresholdCount + } + if base.DisableThresholdPercent <= 0 { + base.DisableThresholdPercent = FeishuDefaultDisableThresholdPct + } + return base +} + +type FeishuPreflightCapability struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} + +type FeishuPreflightWarning struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +type FeishuPreflightResult struct { + OK bool `json:"ok"` + Login FeishuPreflightCapability `json:"login"` + Email FeishuPreflightCapability `json:"email"` + OrgSync FeishuPreflightCapability `json:"org_sync"` + DepartedDetection FeishuPreflightCapability `json:"departed_detection"` + ManagerRelation FeishuPreflightCapability `json:"manager_relation"` + Warnings []FeishuPreflightWarning `json:"warnings"` +} + +func (s *SettingService) CheckFeishuPreflight(ctx context.Context) (*FeishuPreflightResult, error) { + settings, err := s.GetAllSettings(ctx) + if err != nil { + return nil, err + } + authDefaults, err := s.GetAuthSourceDefaultSettings(ctx) + if err != nil { + return nil, err + } + + result := &FeishuPreflightResult{ + OK: true, + Login: FeishuPreflightCapability{ + Status: "ok", + Message: "feishu login configuration is complete", + }, + Email: FeishuPreflightCapability{ + Status: "disabled", + Message: "email sync is disabled", + }, + OrgSync: FeishuPreflightCapability{ + Status: "disabled", + Message: "feishu org sync is disabled", + }, + DepartedDetection: FeishuPreflightCapability{ + Status: "disabled", + Message: "departed user detection requires org sync", + }, + ManagerRelation: FeishuPreflightCapability{ + Status: "disabled", + Message: "manager relation sync requires org sync", + }, + } + if !settings.FeishuConnectEnabled { + result.OK = false + result.Login = FeishuPreflightCapability{ + Status: "disabled", + Reason: "FEISHU_DISABLED", + Message: "feishu login is disabled", + } + return result, nil + } + if !feishuConnectConfigComplete(settings) { + result.OK = false + result.Login = FeishuPreflightCapability{ + Status: "error", + Reason: "FEISHU_CONFIG_INCOMPLETE", + Message: "feishu app id, app secret, redirect url, or tenant restriction policy is missing", + } + } + if settings.FeishuConnectSyncEmail { + result.Email = FeishuPreflightCapability{ + Status: "warning", + Reason: "FEISHU_EMAIL_SCOPE_UNVERIFIED", + Message: "email sync is enabled; runtime verification still depends on Feishu scope and tenant email availability", + } + } + if settings.FeishuOrgSyncEnabled { + result.OrgSync = FeishuPreflightCapability{ + Status: "warning", + Reason: "FEISHU_ORG_SCOPE_UNVERIFIED", + Message: "org sync is enabled; runtime verification still depends on Feishu contact scopes", + } + if settings.FeishuDepartedUserAction == FeishuDepartedUserActionAutoDisable { + result.DepartedDetection = FeishuPreflightCapability{ + Status: "warning", + Reason: "FEISHU_DEPARTED_SCOPE_UNVERIFIED", + Message: "departed users will auto-disable locally after org sync verifies access", + } + } + result.ManagerRelation = FeishuPreflightCapability{ + Status: "warning", + Reason: "FEISHU_MANAGER_SCOPE_UNVERIFIED", + Message: "manager relation sync depends on Feishu employee data scope", + } + } + if authDefaults == nil || !feishuDefaultGrantConfigured(authDefaults.Feishu) { + result.Warnings = append(result.Warnings, FeishuPreflightWarning{ + Reason: "FEISHU_DEFAULT_GRANT_UNCONFIGURED", + Message: "feishu source default grant is not configured; auto-created Feishu users will fall back to system defaults", + }) + } + return result, nil +} + +func feishuDefaultGrantConfigured(settings ProviderDefaultGrantSettings) bool { + return settings.Balance > 0 || + len(settings.Subscriptions) > 0 || + settings.GrantOnSignup || + settings.GrantOnFirstBind || + len(settings.PlatformQuotas) > 0 +} + // validateDefaultPlatformQuotaMap 校验 platform quota map 的合法性: // 平台名须在 AllowedQuotaPlatforms 白名单内,每个非 nil 上限须 finite 且 >= 0。 // 系统层和 auth-source 层共用此 helper。 @@ -2288,6 +2583,7 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett settings.GitHub.Subscriptions, settings.Google.Subscriptions, settings.DingTalk.Subscriptions, + settings.Feishu.Subscriptions, } { if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil { return nil, err @@ -2306,6 +2602,7 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett {"github", settings.GitHub.PlatformQuotas}, {"google", settings.Google.PlatformQuotas}, {"dingtalk", settings.DingTalk.PlatformQuotas}, + {"feishu", settings.Feishu.PlatformQuotas}, } { if pgs.pq != nil { if err := validateDefaultPlatformQuotaMap(pgs.pq); err != nil { @@ -2314,7 +2611,7 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett } } - updates := make(map[string]string, 36) + updates := make(map[string]string, 42) writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email) writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo) writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC) @@ -2322,6 +2619,7 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub) writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google) writeProviderDefaultGrantUpdates(updates, dingTalkAuthSourceDefaultKeys, settings.DingTalk) + writeProviderDefaultGrantUpdates(updates, feishuAuthSourceDefaultKeys, settings.Feishu) updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup) return updates, nil } @@ -2940,6 +3238,11 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut SettingKeyAuthSourceDefaultDingTalkSubscriptions, SettingKeyAuthSourceDefaultDingTalkGrantOnSignup, SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind, + SettingKeyAuthSourceDefaultFeishuBalance, + SettingKeyAuthSourceDefaultFeishuConcurrency, + SettingKeyAuthSourceDefaultFeishuSubscriptions, + SettingKeyAuthSourceDefaultFeishuGrantOnSignup, + SettingKeyAuthSourceDefaultFeishuGrantOnFirstBind, SettingKeyAuthSourcePlatformQuotas("email"), SettingKeyAuthSourcePlatformQuotas("linuxdo"), SettingKeyAuthSourcePlatformQuotas("oidc"), @@ -2947,6 +3250,7 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut SettingKeyAuthSourcePlatformQuotas("github"), SettingKeyAuthSourcePlatformQuotas("google"), SettingKeyAuthSourcePlatformQuotas("dingtalk"), + SettingKeyAuthSourcePlatformQuotas("feishu"), SettingKeyForceEmailOnThirdPartySignup, } @@ -2963,6 +3267,7 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys), Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys), DingTalk: parseProviderDefaultGrantSettings(settings, dingTalkAuthSourceDefaultKeys), + Feishu: parseProviderDefaultGrantSettings(settings, feishuAuthSourceDefaultKeys), ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", }, nil } @@ -3047,6 +3352,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyLoginAgreementMode: defaultLoginAgreementMode, SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, + SettingKeyEmailPasswordLoginEnabled: "true", + SettingKeyAdminEmailLoginFallbackEnabled: "true", SettingKeyAPIKeyACLTrustForwardedIP: "false", SettingKeySiteName: "Sub2API", SettingKeySiteLogo: "", @@ -3104,6 +3411,20 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyOIDCConnectUserInfoEmailPath: "", SettingKeyOIDCConnectUserInfoIDPath: "", SettingKeyOIDCConnectUserInfoUsernamePath: "", + SettingKeyFeishuConnectEnabled: "false", + SettingKeyFeishuConnectAppID: "", + SettingKeyFeishuConnectAppSecret: "", + SettingKeyFeishuConnectRedirectURL: "", + SettingKeyFeishuConnectTenantRestrictionPolicy: FeishuTenantRestrictionInternalOnly, + SettingKeyFeishuConnectAllowedTenantKey: "", + SettingKeyFeishuConnectBypassRegistration: "false", + SettingKeyFeishuConnectSyncEmail: "true", + SettingKeyFeishuConnectSyncDisplayName: "true", + SettingKeyFeishuConnectSyncDepartment: "true", + SettingKeyFeishuOrgSyncEnabled: "false", + SettingKeyFeishuDepartedUserAction: FeishuDepartedUserActionAutoDisable, + SettingKeyFeishuSyncDisableThresholdCount: strconv.Itoa(FeishuDefaultDisableThresholdCount), + SettingKeyFeishuSyncDisableThresholdPercent: strconv.Itoa(FeishuDefaultDisableThresholdPct), SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64), @@ -3147,6 +3468,11 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyAuthSourceDefaultDingTalkSubscriptions: "[]", SettingKeyAuthSourceDefaultDingTalkGrantOnSignup: "false", SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultFeishuBalance: "0", + SettingKeyAuthSourceDefaultFeishuConcurrency: "5", + SettingKeyAuthSourceDefaultFeishuSubscriptions: "[]", + SettingKeyAuthSourceDefaultFeishuGrantOnSignup: "false", + SettingKeyAuthSourceDefaultFeishuGrantOnFirstBind: "false", SettingKeyForceEmailOnThirdPartySignup: "false", SettingKeySMTPPort: "587", SettingKeySMTPUseTLS: "false", @@ -3241,6 +3567,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), LoginAgreementUpdatedAt: loginAgreementUpdatedAt, LoginAgreementDocuments: loginAgreementDocuments, + EmailPasswordLoginEnabled: true, + AdminEmailLoginFallbackEnabled: true, SMTPHost: settings[SettingKeySMTPHost], SMTPUsername: settings[SettingKeySMTPUsername], SMTPFrom: settings[SettingKeySMTPFrom], @@ -3318,6 +3646,12 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // 敏感信息直接返回,方便测试连接时使用 result.SMTPPassword = settings[SettingKeySMTPPassword] result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] + if raw, ok := settings[SettingKeyEmailPasswordLoginEnabled]; ok { + result.EmailPasswordLoginEnabled = strings.TrimSpace(raw) != "false" + } + if raw, ok := settings[SettingKeyAdminEmailLoginFallbackEnabled]; ok { + result.AdminEmailLoginFallbackEnabled = strings.TrimSpace(raw) != "false" + } // LinuxDo Connect 设置: // - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭) @@ -3481,6 +3815,87 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } } + // Feishu Connect 设置: + // - 兼容 config.yaml/env + // - 支持后台系统设置覆盖并持久化(存储于 DB) + feishuBase := defaultFeishuConnectConfig(config.FeishuConnectConfig{}) + if s.cfg != nil { + feishuBase = defaultFeishuConnectConfig(s.cfg.Feishu) + } + + if raw, ok := settings[SettingKeyFeishuConnectEnabled]; ok { + result.FeishuConnectEnabled = raw == "true" + } else { + result.FeishuConnectEnabled = feishuBase.Enabled + } + if v, ok := settings[SettingKeyFeishuConnectAppID]; ok && strings.TrimSpace(v) != "" { + result.FeishuConnectAppID = strings.TrimSpace(v) + } else { + result.FeishuConnectAppID = feishuBase.AppID + } + if v, ok := settings[SettingKeyFeishuConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.FeishuConnectRedirectURL = strings.TrimSpace(v) + } else { + result.FeishuConnectRedirectURL = feishuBase.RedirectURL + } + result.FeishuConnectAppSecret = strings.TrimSpace(settings[SettingKeyFeishuConnectAppSecret]) + if result.FeishuConnectAppSecret == "" { + result.FeishuConnectAppSecret = strings.TrimSpace(feishuBase.AppSecret) + } + result.FeishuConnectAppSecretConfigured = result.FeishuConnectAppSecret != "" + if v, ok := settings[SettingKeyFeishuConnectTenantRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { + result.FeishuConnectTenantRestrictionPolicy = strings.TrimSpace(v) + } else { + result.FeishuConnectTenantRestrictionPolicy = feishuBase.TenantRestrictionPolicy + } + result.FeishuConnectTenantRestrictionPolicy = NormalizeFeishuTenantRestrictionPolicy(result.FeishuConnectTenantRestrictionPolicy) + if v, ok := settings[SettingKeyFeishuConnectAllowedTenantKey]; ok && strings.TrimSpace(v) != "" { + result.FeishuConnectAllowedTenantKey = strings.TrimSpace(v) + } else { + result.FeishuConnectAllowedTenantKey = feishuBase.AllowedTenantKey + } + if v, ok := settings[SettingKeyFeishuConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { + result.FeishuConnectBypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.FeishuConnectBypassRegistration = feishuBase.BypassRegistration + } + if v, ok := settings[SettingKeyFeishuConnectSyncEmail]; ok && strings.TrimSpace(v) != "" { + result.FeishuConnectSyncEmail = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.FeishuConnectSyncEmail = feishuBase.SyncEmail + } + if v, ok := settings[SettingKeyFeishuConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { + result.FeishuConnectSyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.FeishuConnectSyncDisplayName = feishuBase.SyncDisplayName + } + if v, ok := settings[SettingKeyFeishuConnectSyncDepartment]; ok && strings.TrimSpace(v) != "" { + result.FeishuConnectSyncDepartment = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.FeishuConnectSyncDepartment = feishuBase.SyncDepartment + } + if v, ok := settings[SettingKeyFeishuOrgSyncEnabled]; ok && strings.TrimSpace(v) != "" { + result.FeishuOrgSyncEnabled = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.FeishuOrgSyncEnabled = feishuBase.OrgSyncEnabled + } + if v, ok := settings[SettingKeyFeishuDepartedUserAction]; ok && strings.TrimSpace(v) != "" { + result.FeishuDepartedUserAction = strings.TrimSpace(v) + } else { + result.FeishuDepartedUserAction = feishuBase.DepartedUserAction + } + if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyFeishuSyncDisableThresholdCount])); err == nil && v > 0 { + result.FeishuSyncDisableThresholdCount = v + } else { + result.FeishuSyncDisableThresholdCount = feishuBase.DisableThresholdCount + } + if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyFeishuSyncDisableThresholdPercent])); err == nil && v > 0 { + result.FeishuSyncDisableThresholdPercent = v + } else { + result.FeishuSyncDisableThresholdPercent = feishuBase.DisableThresholdPercent + } + normalizeFeishuSystemSettings(result) + // Generic OIDC 设置: // - 兼容 config.yaml/env // - 支持后台系统设置覆盖并持久化(存储于 DB) @@ -4384,6 +4799,125 @@ func (s *SettingService) GetDingTalkConnectOAuthConfig(ctx context.Context) (con return effective, nil } +func (s *SettingService) GetFeishuConnectOAuthConfig(ctx context.Context) (config.FeishuConnectConfig, error) { + if s == nil || s.cfg == nil { + return config.FeishuConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + + effective := defaultFeishuConnectConfig(s.cfg.Feishu) + keys := []string{ + SettingKeyFeishuConnectEnabled, + SettingKeyFeishuConnectAppID, + SettingKeyFeishuConnectAppSecret, + SettingKeyFeishuConnectRedirectURL, + SettingKeyFeishuConnectTenantRestrictionPolicy, + SettingKeyFeishuConnectAllowedTenantKey, + SettingKeyFeishuConnectBypassRegistration, + SettingKeyFeishuConnectSyncEmail, + SettingKeyFeishuConnectSyncDisplayName, + SettingKeyFeishuConnectSyncDepartment, + SettingKeyFeishuOrgSyncEnabled, + SettingKeyFeishuDepartedUserAction, + SettingKeyFeishuSyncDisableThresholdCount, + SettingKeyFeishuSyncDisableThresholdPercent, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.FeishuConnectConfig{}, fmt.Errorf("get feishu connect settings: %w", err) + } + + if raw, ok := settings[SettingKeyFeishuConnectEnabled]; ok { + effective.Enabled = raw == "true" + } + if v, ok := settings[SettingKeyFeishuConnectAppID]; ok && strings.TrimSpace(v) != "" { + effective.AppID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyFeishuConnectAppSecret]; ok && strings.TrimSpace(v) != "" { + effective.AppSecret = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyFeishuConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.RedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyFeishuConnectTenantRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { + effective.TenantRestrictionPolicy = strings.TrimSpace(v) + } + effective.TenantRestrictionPolicy = NormalizeFeishuTenantRestrictionPolicy(effective.TenantRestrictionPolicy) + if v, ok := settings[SettingKeyFeishuConnectAllowedTenantKey]; ok && strings.TrimSpace(v) != "" { + effective.AllowedTenantKey = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyFeishuConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { + effective.BypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") + } + if v, ok := settings[SettingKeyFeishuConnectSyncEmail]; ok && strings.TrimSpace(v) != "" { + effective.SyncEmail = strings.EqualFold(strings.TrimSpace(v), "true") + } + if v, ok := settings[SettingKeyFeishuConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { + effective.SyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") + } + if v, ok := settings[SettingKeyFeishuConnectSyncDepartment]; ok && strings.TrimSpace(v) != "" { + effective.SyncDepartment = strings.EqualFold(strings.TrimSpace(v), "true") + } + if v, ok := settings[SettingKeyFeishuOrgSyncEnabled]; ok && strings.TrimSpace(v) != "" { + effective.OrgSyncEnabled = strings.EqualFold(strings.TrimSpace(v), "true") + } + if v, ok := settings[SettingKeyFeishuDepartedUserAction]; ok && strings.TrimSpace(v) != "" { + effective.DepartedUserAction = normalizeFeishuDepartedUserAction(v) + } + if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyFeishuSyncDisableThresholdCount])); err == nil && v > 0 { + effective.DisableThresholdCount = v + } + if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyFeishuSyncDisableThresholdPercent])); err == nil && v > 0 { + effective.DisableThresholdPercent = v + } + effective = defaultFeishuConnectConfig(effective) + if effective.TenantRestrictionPolicy != FeishuTenantRestrictionInternalOnly { + effective.BypassRegistration = false + effective.OrgSyncEnabled = false + } + + if !effective.Enabled { + return config.FeishuConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "feishu oauth login is disabled") + } + if strings.TrimSpace(effective.AppID) == "" { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth app id not configured") + } + if strings.TrimSpace(effective.AppSecret) == "" { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth app secret not configured") + } + if strings.TrimSpace(effective.AuthorizeURL) == "" { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth authorize url not configured") + } + if strings.TrimSpace(effective.TokenURL) == "" { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth token url not configured") + } + if strings.TrimSpace(effective.UserInfoURL) == "" { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth userinfo url not configured") + } + if strings.TrimSpace(effective.RedirectURL) == "" { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth redirect url not configured") + } + if strings.TrimSpace(effective.FrontendRedirectURL) == "" { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth frontend redirect url not configured") + } + if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth authorize url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth token url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth userinfo url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth redirect url invalid") + } + if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { + return config.FeishuConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "feishu oauth frontend redirect url invalid") + } + + return effective, nil +} + // GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。 // // WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。 diff --git a/backend/internal/service/setting_service_auth_source_defaults_test.go b/backend/internal/service/setting_service_auth_source_defaults_test.go index 1ff4974066c..9b3e1d1b72a 100644 --- a/backend/internal/service/setting_service_auth_source_defaults_test.go +++ b/backend/internal/service/setting_service_auth_source_defaults_test.go @@ -21,7 +21,10 @@ func (s *authSourceDefaultsRepoStub) Get(ctx context.Context, key string) (*Sett } func (s *authSourceDefaultsRepoStub) GetValue(ctx context.Context, key string) (string, error) { - panic("unexpected GetValue call") + if value, ok := s.values[key]; ok { + return value, nil + } + return "", ErrSettingNotFound } func (s *authSourceDefaultsRepoStub) Set(ctx context.Context, key, value string) error { @@ -85,11 +88,39 @@ func TestSettingService_GetAuthSourceDefaultSettings_ParsesValuesAndDefaults(t * require.True(t, got.LinuxDo.GrantOnFirstBind) require.Equal(t, 5, got.OIDC.Concurrency) require.Equal(t, 5, got.WeChat.Concurrency) + require.Equal(t, 5, got.Feishu.Concurrency) require.False(t, got.OIDC.GrantOnSignup) require.False(t, got.WeChat.GrantOnSignup) + require.False(t, got.Feishu.GrantOnSignup) require.True(t, got.ForceEmailOnThirdPartySignup) } +func TestAuthSourceDefaultSettingsIncludesFeishu(t *testing.T) { + repo := &authSourceDefaultsRepoStub{ + values: map[string]string{ + SettingKeyAuthSourceDefaultFeishuBalance: "18.75", + SettingKeyAuthSourceDefaultFeishuConcurrency: "9", + SettingKeyAuthSourceDefaultFeishuSubscriptions: `[{"group_id":77,"validity_days":45}]`, + SettingKeyAuthSourceDefaultFeishuGrantOnSignup: "true", + SettingKeyAuthSourceDefaultFeishuGrantOnFirstBind: "true", + SettingKeyAuthSourcePlatformQuotas("feishu"): `{"openai":{"daily":1.5}}`, + }, + } + svc := NewSettingService(repo, &config.Config{}) + + got, err := svc.GetAuthSourceDefaultSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 18.75, got.Feishu.Balance) + require.Equal(t, 9, got.Feishu.Concurrency) + require.Equal(t, []DefaultSubscriptionSetting{{GroupID: 77, ValidityDays: 45}}, got.Feishu.Subscriptions) + require.True(t, got.Feishu.GrantOnSignup) + require.True(t, got.Feishu.GrantOnFirstBind) + require.NotNil(t, got.Feishu.PlatformQuotas) + require.NotNil(t, got.Feishu.PlatformQuotas["openai"]) + require.NotNil(t, got.Feishu.PlatformQuotas["openai"].DailyLimitUSD) + require.Equal(t, 1.5, *got.Feishu.PlatformQuotas["openai"].DailyLimitUSD) +} + func TestSettingService_UpdateAuthSourceDefaultSettings_PersistsAllKeys(t *testing.T) { repo := &authSourceDefaultsRepoStub{} svc := NewSettingService(repo, &config.Config{}) @@ -123,6 +154,16 @@ func TestSettingService_UpdateAuthSourceDefaultSettings_PersistsAllKeys(t *testi GrantOnSignup: false, GrantOnFirstBind: false, }, + Feishu: ProviderDefaultGrantSettings{ + Balance: 8.5, + Concurrency: 10, + Subscriptions: []DefaultSubscriptionSetting{{GroupID: 88, ValidityDays: 120}}, + GrantOnSignup: true, + GrantOnFirstBind: true, + PlatformQuotas: map[string]*DefaultPlatformQuotaSetting{ + "openai": {DailyLimitUSD: floatPtrForAuthSourceDefaultsTest(2.25)}, + }, + }, ForceEmailOnThirdPartySignup: true, }) require.NoError(t, err) @@ -135,4 +176,56 @@ func TestSettingService_UpdateAuthSourceDefaultSettings_PersistsAllKeys(t *testi var got []DefaultSubscriptionSetting require.NoError(t, json.Unmarshal([]byte(repo.updates[SettingKeyAuthSourceDefaultWeChatSubscriptions]), &got)) require.Equal(t, []DefaultSubscriptionSetting{{GroupID: 24, ValidityDays: 90}}, got) + + var feishuSubscriptions []DefaultSubscriptionSetting + require.NoError(t, json.Unmarshal([]byte(repo.updates[SettingKeyAuthSourceDefaultFeishuSubscriptions]), &feishuSubscriptions)) + require.Equal(t, []DefaultSubscriptionSetting{{GroupID: 88, ValidityDays: 120}}, feishuSubscriptions) + require.Equal(t, "8.50000000", repo.updates[SettingKeyAuthSourceDefaultFeishuBalance]) + require.Equal(t, "10", repo.updates[SettingKeyAuthSourceDefaultFeishuConcurrency]) + require.Equal(t, "true", repo.updates[SettingKeyAuthSourceDefaultFeishuGrantOnSignup]) + require.Equal(t, "true", repo.updates[SettingKeyAuthSourceDefaultFeishuGrantOnFirstBind]) + require.JSONEq(t, `{"openai":{"daily":2.25,"weekly":null,"monthly":null}}`, repo.updates[SettingKeyAuthSourcePlatformQuotas("feishu")]) +} + +func TestUpdateAuthSourceDefaultSettingsPersistsFeishu(t *testing.T) { + repo := &authSourceDefaultsRepoStub{} + svc := NewSettingService(repo, &config.Config{}) + + err := svc.UpdateAuthSourceDefaultSettings(context.Background(), &AuthSourceDefaultSettings{ + Feishu: ProviderDefaultGrantSettings{ + Balance: 31.5, + Concurrency: 11, + Subscriptions: []DefaultSubscriptionSetting{{GroupID: 93, ValidityDays: 12}}, + GrantOnSignup: true, + GrantOnFirstBind: false, + }, + }) + require.NoError(t, err) + require.Equal(t, "31.50000000", repo.updates[SettingKeyAuthSourceDefaultFeishuBalance]) + require.Equal(t, "11", repo.updates[SettingKeyAuthSourceDefaultFeishuConcurrency]) + require.Equal(t, "true", repo.updates[SettingKeyAuthSourceDefaultFeishuGrantOnSignup]) + require.Equal(t, "false", repo.updates[SettingKeyAuthSourceDefaultFeishuGrantOnFirstBind]) + require.JSONEq(t, `[{"group_id":93,"validity_days":12}]`, repo.updates[SettingKeyAuthSourceDefaultFeishuSubscriptions]) +} + +func TestResolveAuthSourceDefaultsFallsBackWhenFeishuUnset(t *testing.T) { + repo := &authSourceDefaultsRepoStub{ + values: map[string]string{ + SettingKeyDefaultBalance: "6.5", + SettingKeyDefaultConcurrency: "4", + SettingKeyDefaultSubscriptions: `[{"group_id":12,"validity_days":7}]`, + }, + } + svc := NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserBalance: 6.5, UserConcurrency: 4}}) + + got, enabled, err := svc.ResolveAuthSourceGrantSettings(context.Background(), "feishu", false) + require.NoError(t, err) + require.False(t, enabled) + require.Equal(t, 6.5, got.Balance) + require.Equal(t, 4, got.Concurrency) + require.Equal(t, []DefaultSubscriptionSetting{{GroupID: 12, ValidityDays: 7}}, got.Subscriptions) +} + +func floatPtrForAuthSourceDefaultsTest(v float64) *float64 { + return &v } diff --git a/backend/internal/service/setting_service_feishu_test.go b/backend/internal/service/setting_service_feishu_test.go new file mode 100644 index 00000000000..9743c674d01 --- /dev/null +++ b/backend/internal/service/setting_service_feishu_test.go @@ -0,0 +1,144 @@ +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +type feishuSettingsRepoStub struct { + values map[string]string + updates map[string]string +} + +func (s *feishuSettingsRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + panic("unexpected Get call") +} + +func (s *feishuSettingsRepoStub) GetValue(ctx context.Context, key string) (string, error) { + if s.values != nil { + if value, ok := s.values[key]; ok { + return value, nil + } + } + return "", ErrSettingNotFound +} + +func (s *feishuSettingsRepoStub) Set(ctx context.Context, key, value string) error { + panic("unexpected Set call") +} + +func (s *feishuSettingsRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + out := make(map[string]string, len(keys)) + for _, key := range keys { + if s.values != nil { + if value, ok := s.values[key]; ok { + out[key] = value + } + } + } + return out, nil +} + +func (s *feishuSettingsRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + s.updates = make(map[string]string, len(settings)) + if s.values == nil { + s.values = map[string]string{} + } + for key, value := range settings { + s.updates[key] = value + s.values[key] = value + } + return nil +} + +func (s *feishuSettingsRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + out := make(map[string]string, len(s.values)) + for key, value := range s.values { + out[key] = value + } + return out, nil +} + +func (s *feishuSettingsRepoStub) Delete(ctx context.Context, key string) error { + panic("unexpected Delete call") +} + +func TestGetFeishuConnectSettingsDefaults(t *testing.T) { + svc := NewSettingService(&feishuSettingsRepoStub{}, &config.Config{}) + + got, err := svc.GetAllSettings(context.Background()) + require.NoError(t, err) + + require.False(t, got.FeishuConnectEnabled) + require.False(t, got.FeishuConnectAppSecretConfigured) + require.Equal(t, "internal_only", got.FeishuConnectTenantRestrictionPolicy) + require.False(t, got.FeishuConnectBypassRegistration) + require.False(t, got.FeishuOrgSyncEnabled) + require.Equal(t, "auto_disable", got.FeishuDepartedUserAction) + require.Equal(t, 10, got.FeishuSyncDisableThresholdCount) + require.Equal(t, 20, got.FeishuSyncDisableThresholdPercent) + require.True(t, got.EmailPasswordLoginEnabled) + require.True(t, got.AdminEmailLoginFallbackEnabled) +} + +func TestUpdateSecuritySettingsRejectsNoLoginEntry(t *testing.T) { + svc := NewSettingService(&feishuSettingsRepoStub{}, &config.Config{}) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + LoginEntrySettingsExplicit: true, + EmailPasswordLoginEnabled: false, + AdminEmailLoginFallbackEnabled: true, + FeishuConnectEnabled: false, + LinuxDoConnectEnabled: false, + DingTalkConnectEnabled: false, + WeChatConnectEnabled: false, + OIDCConnectEnabled: false, + GitHubOAuthEnabled: false, + GoogleOAuthEnabled: false, + }) + + require.Error(t, err) + require.Equal(t, "LOGIN_ENTRY_UNAVAILABLE", infraerrors.Reason(err)) +} + +func TestUpdateSecuritySettingsRejectsFeishuOnlyWithoutHealthyConfig(t *testing.T) { + svc := NewSettingService(&feishuSettingsRepoStub{}, &config.Config{}) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + LoginEntrySettingsExplicit: true, + EmailPasswordLoginEnabled: false, + AdminEmailLoginFallbackEnabled: true, + FeishuConnectEnabled: true, + }) + + require.Error(t, err) + require.Equal(t, "FEISHU_CONFIG_INCOMPLETE", infraerrors.Reason(err)) +} + +func TestUpdateSecuritySettingsAllowsAdminEmailFallback(t *testing.T) { + repo := &feishuSettingsRepoStub{} + svc := NewSettingService(repo, &config.Config{}) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + LoginEntrySettingsExplicit: true, + EmailPasswordLoginEnabled: false, + AdminEmailLoginFallbackEnabled: true, + FeishuConnectEnabled: true, + FeishuConnectAppID: "cli_test", + FeishuConnectAppSecret: "secret", + FeishuConnectRedirectURL: "https://example.com/oauth/feishu/callback", + FeishuConnectTenantRestrictionPolicy: "internal_only", + FeishuDepartedUserAction: "auto_disable", + FeishuSyncDisableThresholdCount: 10, + FeishuSyncDisableThresholdPercent: 20, + }) + + require.NoError(t, err) + require.Equal(t, "false", repo.updates[SettingKeyEmailPasswordLoginEnabled]) + require.Equal(t, "true", repo.updates[SettingKeyAdminEmailLoginFallbackEnabled]) + require.Equal(t, "true", repo.updates[SettingKeyFeishuConnectEnabled]) +} diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 621620fd01c..0b20be99db4 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -91,6 +91,19 @@ func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t require.True(t, settings.ForceEmailOnThirdPartySignup) } +func TestSettingService_GetPublicSettings_ExposesFeishuOrgSyncFlag(t *testing.T) { + repo := &settingPublicRepoStub{ + values: map[string]string{ + SettingKeyFeishuOrgSyncEnabled: "true", + }, + } + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.FeishuOrgSyncEnabled) +} + func TestSettingService_GetPublicSettings_ExposesAllowUserViewErrorRequests(t *testing.T) { repo := &settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index ac225e1b14b..c48ec0ec792 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -24,6 +24,9 @@ type SystemSettings struct { LoginAgreementMode string LoginAgreementUpdatedAt string LoginAgreementDocuments []LoginAgreementDocument + EmailPasswordLoginEnabled bool + AdminEmailLoginFallbackEnabled bool + LoginEntrySettingsExplicit bool SMTPHost string SMTPPort int @@ -66,6 +69,23 @@ type SystemSettings struct { DingTalkConnectSyncDisplayNameAttrName string DingTalkConnectSyncDeptAttrName string + // Feishu Connect OAuth 登录 + FeishuConnectEnabled bool + FeishuConnectAppID string + FeishuConnectAppSecret string + FeishuConnectAppSecretConfigured bool + FeishuConnectRedirectURL string + FeishuConnectTenantRestrictionPolicy string + FeishuConnectAllowedTenantKey string + FeishuConnectBypassRegistration bool + FeishuConnectSyncEmail bool + FeishuConnectSyncDisplayName bool + FeishuConnectSyncDepartment bool + FeishuOrgSyncEnabled bool + FeishuDepartedUserAction string + FeishuSyncDisableThresholdCount int + FeishuSyncDisableThresholdPercent int + // WeChat Connect OAuth 登录 WeChatConnectEnabled bool WeChatConnectAppID string @@ -276,19 +296,23 @@ type PublicSettings struct { CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints - LinuxDoOAuthEnabled bool - DingTalkOAuthEnabled bool - WeChatOAuthEnabled bool - WeChatOAuthOpenEnabled bool - WeChatOAuthMPEnabled bool - WeChatOAuthMobileEnabled bool - BackendModeEnabled bool - PaymentEnabled bool - OIDCOAuthEnabled bool - OIDCOAuthProviderName string - GitHubOAuthEnabled bool - GoogleOAuthEnabled bool - Version string + LinuxDoOAuthEnabled bool + DingTalkOAuthEnabled bool + FeishuOAuthEnabled bool + FeishuOrgSyncEnabled bool + EmailPasswordLoginEnabled bool + AdminEmailLoginFallbackEnabled bool + WeChatOAuthEnabled bool + WeChatOAuthOpenEnabled bool + WeChatOAuthMPEnabled bool + WeChatOAuthMobileEnabled bool + BackendModeEnabled bool + PaymentEnabled bool + OIDCOAuthEnabled bool + OIDCOAuthProviderName string + GitHubOAuthEnabled bool + GoogleOAuthEnabled bool + Version string BalanceLowNotifyEnabled bool AccountQuotaNotifyEnabled bool diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 7278a4e0c53..b649e99715b 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -416,8 +416,11 @@ func ProvideScheduledTestRunnerService( accountTestSvc *AccountTestService, rateLimitSvc *RateLimitService, cfg *config.Config, + lockCache LeaderLockCache, + db *sql.DB, ) *ScheduledTestRunnerService { svc := NewScheduledTestRunnerService(planRepo, scheduledSvc, accountTestSvc, rateLimitSvc, cfg) + svc.SetLeaderLock(lockCache, db) svc.Start() return svc } @@ -449,8 +452,13 @@ func ProvideBackupService( encryptor SecretEncryptor, storeFactory BackupObjectStoreFactory, dumper DBDumper, + recordRepo BackupRecordRepository, + lockCache LeaderLockCache, + db *sql.DB, ) *BackupService { svc := NewBackupService(settingRepo, cfg, encryptor, storeFactory, dumper) + svc.SetBackupRecordRepository(recordRepo) + svc.SetLeaderLock(lockCache, db) svc.Start() return svc } @@ -562,6 +570,7 @@ var ProviderSet = wire.NewSet( ProvideBillingCacheService, NewAnnouncementService, NewAdminService, + NewFeishuOrgPermissionService, NewGatewayService, NewOpenAIGatewayService, wire.Bind(new(AccountRuntimeBlocker), new(*OpenAIGatewayService)), @@ -693,8 +702,9 @@ func ProvideChannelMonitorService( // 通过 SetScheduler 注入回 service 后再 Start,确保启动时加载所有 enabled monitor, // 后续 CRUD 也能即时同步任务表。Runner.Stop 由 cleanup function 调用。 // settingService 用于 runner 每次 fire 读取功能开关。 -func ProvideChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner { +func ProvideChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService, lockCache LeaderLockCache, db *sql.DB) *ChannelMonitorRunner { r := NewChannelMonitorRunner(svc, settingService) + r.SetLeaderLock(lockCache, db) svc.SetScheduler(r) r.Start() return r diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go index 2279d913209..489faa963b8 100644 --- a/backend/internal/web/embed_on.go +++ b/backend/internal/web/embed_on.go @@ -143,33 +143,20 @@ func (s *FrontendServer) serveIndexHTML(c *gin.Context) { // Get nonce from context (generated by SecurityHeaders middleware) nonce := middleware.GetNonceFromContext(c) - // Check cache first - cached := s.cache.Get() - if cached != nil { - // Check If-None-Match for 304 response - if match := c.GetHeader("If-None-Match"); match == cached.ETag { - c.Status(http.StatusNotModified) - c.Abort() - return - } - - // Replace nonce placeholder with actual nonce before serving - content := replaceNoncePlaceholder(cached.Content, nonce) - - c.Header("ETag", cached.ETag) - c.Header("Cache-Control", "no-cache") // Must revalidate - c.Data(http.StatusOK, "text/html; charset=utf-8", content) - c.Abort() - return - } - - // Cache miss - fetch settings and render ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() settings, err := s.settings.GetPublicSettingsForInjection(ctx) if err != nil { - // Fallback: serve without injection + // Fallback: serve last rendered HTML if available, otherwise without injection. + if cached := s.cache.Get(); cached != nil { + content := replaceNoncePlaceholder(cached.Content, nonce) + c.Header("ETag", cached.ETag) + c.Header("Cache-Control", "no-cache") + c.Data(http.StatusOK, "text/html; charset=utf-8", content) + c.Abort() + return + } c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML) c.Abort() return @@ -177,20 +164,45 @@ func (s *FrontendServer) serveIndexHTML(c *gin.Context) { settingsJSON, err := json.Marshal(settings) if err != nil { - // Fallback: serve without injection + // Fallback: serve last rendered HTML if available, otherwise without injection. + if cached := s.cache.Get(); cached != nil { + content := replaceNoncePlaceholder(cached.Content, nonce) + c.Header("ETag", cached.ETag) + c.Header("Cache-Control", "no-cache") + c.Data(http.StatusOK, "text/html; charset=utf-8", content) + c.Abort() + return + } c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML) c.Abort() return } + // The cache is per process, but settings can be updated by any instance in a + // distributed deployment. Revalidate against the current settings payload + // before serving cached HTML. + if cached := s.cache.GetForSettings(settingsJSON); cached != nil { + if match := c.GetHeader("If-None-Match"); match == cached.ETag { + c.Status(http.StatusNotModified) + c.Abort() + return + } + + content := replaceNoncePlaceholder(cached.Content, nonce) + c.Header("ETag", cached.ETag) + c.Header("Cache-Control", "no-cache") + c.Data(http.StatusOK, "text/html; charset=utf-8", content) + c.Abort() + return + } + rendered := s.injectSettings(settingsJSON) s.cache.Set(rendered, settingsJSON) // Replace nonce placeholder with actual nonce before serving content := replaceNoncePlaceholder(rendered, nonce) - cached = s.cache.Get() - if cached != nil { + if cached := s.cache.Get(); cached != nil { c.Header("ETag", cached.ETag) } c.Header("Cache-Control", "no-cache") diff --git a/backend/internal/web/embed_test.go b/backend/internal/web/embed_test.go index 583d98a0b9b..e8ab1eade01 100644 --- a/backend/internal/web/embed_test.go +++ b/backend/internal/web/embed_test.go @@ -243,7 +243,7 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { assert.Contains(t, body, `nonce="`+testNonce+`"`) }) - t.Run("caches_html_content", func(t *testing.T) { + t.Run("caches_html_content_after_settings_revalidation", func(t *testing.T) { provider := &mockSettingsProvider{ settings: map[string]string{"test": "value"}, } @@ -267,13 +267,43 @@ func TestFrontendServer_ServeIndexHTML(t *testing.T) { c2.Set(middleware.CSPNonceKey, "nonce2") server.serveIndexHTML(c2) - // Settings provider should not be called again - assert.Equal(t, 1, provider.called) + // Settings are revalidated before cached HTML is reused. This prevents + // stale injected settings in distributed deployments. + assert.Equal(t, 2, provider.called) // But nonce should be different assert.Contains(t, w2.Body.String(), `nonce="nonce2"`) }) + t.Run("re-renders_when_settings_change_without_explicit_invalidation", func(t *testing.T) { + provider := &mockSettingsProvider{ + settings: map[string]bool{"feishu_org_sync_enabled": false}, + } + + server, err := NewFrontendServer(provider) + require.NoError(t, err) + + w1 := httptest.NewRecorder() + c1, _ := gin.CreateTestContext(w1) + c1.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c1.Set(middleware.CSPNonceKey, "nonce1") + + server.serveIndexHTML(c1) + assert.Contains(t, w1.Body.String(), `"feishu_org_sync_enabled":false`) + + provider.settings = map[string]bool{"feishu_org_sync_enabled": true} + + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + c2.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c2.Set(middleware.CSPNonceKey, "nonce2") + + server.serveIndexHTML(c2) + assert.Equal(t, 2, provider.called) + assert.Contains(t, w2.Body.String(), `"feishu_org_sync_enabled":true`) + assert.NotContains(t, w2.Body.String(), `"feishu_org_sync_enabled":false`) + }) + t.Run("sets_etag_header", func(t *testing.T) { provider := &mockSettingsProvider{ settings: map[string]string{"test": "value"}, @@ -688,6 +718,20 @@ func TestHTMLCache(t *testing.T) { assert.NotEmpty(t, result.ETag) }) + t.Run("get_for_settings_only_matches_same_payload", func(t *testing.T) { + cache := NewHTMLCache() + cache.SetBaseHTML([]byte("")) + + html := []byte("
cached") + cache.Set(html, []byte(`{"v":1}`)) + + matched := cache.GetForSettings([]byte(`{"v":1}`)) + require.NotNil(t, matched) + assert.Equal(t, html, matched.Content) + + assert.Nil(t, cache.GetForSettings([]byte(`{"v":2}`))) + }) + t.Run("invalidate_clears_cache", func(t *testing.T) { cache := NewHTMLCache() cache.SetBaseHTML([]byte("")) diff --git a/backend/internal/web/html_cache.go b/backend/internal/web/html_cache.go index 28269c89c22..4d0e52f8776 100644 --- a/backend/internal/web/html_cache.go +++ b/backend/internal/web/html_cache.go @@ -61,6 +61,25 @@ func (c *HTMLCache) Get() *CachedHTML { } } +// GetForSettings returns cached HTML only when it was rendered from the same +// settings payload. This keeps per-process HTML caches safe in distributed +// deployments where another instance may update settings. +func (c *HTMLCache) GetForSettings(settingsJSON []byte) *CachedHTML { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.cachedHTML == nil { + return nil + } + if c.etag != c.generateETag(settingsJSON) { + return nil + } + return &CachedHTML{ + Content: c.cachedHTML, + ETag: c.etag, + } +} + // Set updates the cache with new rendered HTML func (c *HTMLCache) Set(html []byte, settingsJSON []byte) { c.mu.Lock() diff --git a/backend/migrations/159_feishu_org_permissions.sql b/backend/migrations/159_feishu_org_permissions.sql new file mode 100644 index 00000000000..65d5f502db6 --- /dev/null +++ b/backend/migrations/159_feishu_org_permissions.sql @@ -0,0 +1,186 @@ +CREATE TABLE IF NOT EXISTS feishu_departments ( + id BIGSERIAL PRIMARY KEY, + tenant_key TEXT NOT NULL DEFAULT '', + open_department_id TEXT NOT NULL, + parent_open_department_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + leader_open_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + status VARCHAR(20) NOT NULL DEFAULT 'active', + raw JSONB NOT NULL DEFAULT '{}'::jsonb, + last_synced_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT feishu_departments_status_check CHECK (status IN ('active', 'deleted')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS feishu_departments_tenant_open_department_key + ON feishu_departments (tenant_key, open_department_id); + +CREATE INDEX IF NOT EXISTS feishu_departments_parent_idx + ON feishu_departments (tenant_key, parent_open_department_id); + +CREATE INDEX IF NOT EXISTS feishu_departments_status_idx + ON feishu_departments (status); + +CREATE TABLE IF NOT EXISTS feishu_org_users ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, + tenant_key TEXT NOT NULL DEFAULT '', + open_id TEXT NOT NULL, + union_id TEXT NOT NULL DEFAULT '', + feishu_user_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + employee_no TEXT NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'active', + primary_open_department_id TEXT NOT NULL DEFAULT '', + manager_open_id TEXT NOT NULL DEFAULT '', + department_open_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + raw JSONB NOT NULL DEFAULT '{}'::jsonb, + last_synced_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT feishu_org_users_status_check CHECK (status IN ('active', 'disabled', 'departed')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS feishu_org_users_tenant_open_id_key + ON feishu_org_users (tenant_key, open_id); + +CREATE INDEX IF NOT EXISTS feishu_org_users_local_user_idx + ON feishu_org_users (user_id); + +CREATE INDEX IF NOT EXISTS feishu_org_users_union_id_idx + ON feishu_org_users (union_id); + +CREATE INDEX IF NOT EXISTS feishu_org_users_feishu_user_id_idx + ON feishu_org_users (feishu_user_id); + +CREATE INDEX IF NOT EXISTS feishu_org_users_status_idx + ON feishu_org_users (status); + +CREATE TABLE IF NOT EXISTS feishu_user_departments ( + tenant_key TEXT NOT NULL DEFAULT '', + open_id TEXT NOT NULL, + open_department_id TEXT NOT NULL, + user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, + is_primary BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_key, open_id, open_department_id) +); + +CREATE INDEX IF NOT EXISTS feishu_user_departments_user_idx + ON feishu_user_departments (user_id); + +CREATE INDEX IF NOT EXISTS feishu_user_departments_department_idx + ON feishu_user_departments (tenant_key, open_department_id); + +CREATE TABLE IF NOT EXISTS feishu_department_managers ( + tenant_key TEXT NOT NULL DEFAULT '', + open_department_id TEXT NOT NULL, + manager_open_id TEXT NOT NULL, + manager_user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, + source VARCHAR(20) NOT NULL DEFAULT 'feishu', + relation_type VARCHAR(30) NOT NULL DEFAULT 'department_leader', + include_subdepartments BOOLEAN NOT NULL DEFAULT true, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_key, open_department_id, manager_open_id), + CONSTRAINT feishu_department_managers_source_check CHECK (source IN ('feishu', 'manual')), + CONSTRAINT feishu_department_managers_relation_check CHECK (relation_type IN ('department_leader', 'manager_chain')), + CONSTRAINT feishu_department_managers_status_check CHECK (status IN ('active', 'disabled')) +); + +CREATE INDEX IF NOT EXISTS feishu_department_managers_user_idx + ON feishu_department_managers (manager_user_id); + +CREATE INDEX IF NOT EXISTS feishu_department_managers_department_idx + ON feishu_department_managers (tenant_key, open_department_id, status); + +CREATE TABLE IF NOT EXISTS feishu_department_group_grants ( + tenant_key TEXT NOT NULL DEFAULT '', + open_department_id TEXT NOT NULL, + group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + granted_by_user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_key, open_department_id, group_id) +); + +CREATE INDEX IF NOT EXISTS feishu_department_group_grants_group_idx + ON feishu_department_group_grants (group_id); + +CREATE TABLE IF NOT EXISTS feishu_user_group_grants ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + source VARCHAR(40) NOT NULL, + source_open_department_id TEXT NOT NULL DEFAULT '', + granted_by_user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, + reason TEXT NOT NULL DEFAULT '', + revoked_at TIMESTAMPTZ NULL, + revoked_by_user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, + revoke_reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT feishu_user_group_grants_source_check CHECK (source IN ('department_manager', 'super_admin_override')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS feishu_user_group_grants_active_unique + ON feishu_user_group_grants (user_id, group_id, source, source_open_department_id) + WHERE revoked_at IS NULL; + +CREATE INDEX IF NOT EXISTS feishu_user_group_grants_user_idx + ON feishu_user_group_grants (user_id, revoked_at); + +CREATE INDEX IF NOT EXISTS feishu_user_group_grants_group_idx + ON feishu_user_group_grants (group_id); + +CREATE INDEX IF NOT EXISTS feishu_user_group_grants_source_department_idx + ON feishu_user_group_grants (source, source_open_department_id) + WHERE revoked_at IS NULL; + +CREATE TABLE IF NOT EXISTS feishu_org_permission_audit_logs ( + id BIGSERIAL PRIMARY KEY, + actor_user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, + target_user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, + group_id BIGINT NULL REFERENCES groups(id) ON DELETE SET NULL, + tenant_key TEXT NOT NULL DEFAULT '', + open_department_id TEXT NOT NULL DEFAULT '', + action VARCHAR(40) NOT NULL, + source VARCHAR(40) NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + details JSONB NOT NULL DEFAULT '{}'::jsonb, + sync_run_id BIGINT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT feishu_org_permission_audit_action_check CHECK (action IN ('grant', 'revoke', 'auto_revoke', 'sync_import', 'auto_disable_user', 'sync_blocked_for_review', 'recalculate')) +); + +CREATE INDEX IF NOT EXISTS feishu_org_permission_audit_target_idx + ON feishu_org_permission_audit_logs (target_user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS feishu_org_permission_audit_department_idx + ON feishu_org_permission_audit_logs (tenant_key, open_department_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS feishu_org_sync_runs ( + id BIGSERIAL PRIMARY KEY, + status VARCHAR(20) NOT NULL DEFAULT 'running', + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ NULL, + departments_synced INTEGER NOT NULL DEFAULT 0, + users_synced INTEGER NOT NULL DEFAULT 0, + managers_synced INTEGER NOT NULL DEFAULT 0, + users_to_create INTEGER NOT NULL DEFAULT 0, + users_to_disable INTEGER NOT NULL DEFAULT 0, + bindings_missing INTEGER NOT NULL DEFAULT 0, + review_required BOOLEAN NOT NULL DEFAULT false, + error_message TEXT NOT NULL DEFAULT '', + triggered_by_user_id BIGINT NULL REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT feishu_org_sync_runs_status_check CHECK (status IN ('running', 'success', 'partial_failed', 'failed')) +); + +CREATE INDEX IF NOT EXISTS feishu_org_sync_runs_status_idx + ON feishu_org_sync_runs (status, started_at DESC); diff --git a/backend/migrations/160_add_feishu_provider_type.sql b/backend/migrations/160_add_feishu_provider_type.sql new file mode 100644 index 00000000000..5241864bb8f --- /dev/null +++ b/backend/migrations/160_add_feishu_provider_type.sql @@ -0,0 +1,34 @@ +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', 'feishu')); + +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', 'feishu')); + +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', 'feishu')); + +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', 'feishu')); + +ALTER TABLE user_provider_default_grants + DROP CONSTRAINT IF EXISTS user_provider_default_grants_provider_type_check; + +ALTER TABLE user_provider_default_grants + ADD CONSTRAINT user_provider_default_grants_provider_type_check + CHECK (provider_type IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google', 'dingtalk', 'feishu')); diff --git a/backend/migrations/161_backup_records.sql b/backend/migrations/161_backup_records.sql new file mode 100644 index 00000000000..7e3ab9b9bbb --- /dev/null +++ b/backend/migrations/161_backup_records.sql @@ -0,0 +1,99 @@ +-- Store backup metadata in a shared table so multi-instance deployments do not +-- race on process-local settings JSON. + +CREATE TABLE IF NOT EXISTS backup_records ( + id TEXT PRIMARY KEY, + status VARCHAR(32) NOT NULL DEFAULT '', + backup_type VARCHAR(32) NOT NULL DEFAULT 'postgres', + file_name TEXT NOT NULL DEFAULT '', + s3_key TEXT NOT NULL DEFAULT '', + size_bytes BIGINT NOT NULL DEFAULT 0, + triggered_by VARCHAR(32) NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '', + started_at TEXT NOT NULL DEFAULT '', + finished_at TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL DEFAULT '', + progress VARCHAR(32) NOT NULL DEFAULT '', + restore_status VARCHAR(32) NOT NULL DEFAULT '', + restore_error TEXT NOT NULL DEFAULT '', + restored_at TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_backup_records_started_at + ON backup_records (started_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_backup_records_status + ON backup_records (status); + +DO $$ +DECLARE + raw_records TEXT; + raw_json JSONB; +BEGIN + SELECT value INTO raw_records FROM settings WHERE key = 'backup_records'; + IF raw_records IS NULL OR btrim(raw_records) = '' THEN + RETURN; + END IF; + + raw_json := raw_records::jsonb; + IF jsonb_typeof(raw_json) <> 'array' THEN + RAISE EXCEPTION 'settings.backup_records must be a JSON array, got %', jsonb_typeof(raw_json); + END IF; + + INSERT INTO backup_records ( + id, status, backup_type, file_name, s3_key, size_bytes, triggered_by, + error_message, started_at, finished_at, expires_at, progress, + restore_status, restore_error, restored_at + ) + SELECT + COALESCE(NULLIF(id, ''), substr(md5(random()::text || clock_timestamp()::text), 1, 8)), + COALESCE(status, ''), + COALESCE(backup_type, 'postgres'), + COALESCE(file_name, ''), + COALESCE(s3_key, ''), + COALESCE(size_bytes, 0), + COALESCE(triggered_by, ''), + COALESCE(error_message, ''), + COALESCE(started_at, ''), + COALESCE(finished_at, ''), + COALESCE(expires_at, ''), + COALESCE(progress, ''), + COALESCE(restore_status, ''), + COALESCE(restore_error, ''), + COALESCE(restored_at, '') + FROM jsonb_to_recordset(raw_json) AS x( + id TEXT, + status TEXT, + backup_type TEXT, + file_name TEXT, + s3_key TEXT, + size_bytes BIGINT, + triggered_by TEXT, + error_message TEXT, + started_at TEXT, + finished_at TEXT, + expires_at TEXT, + progress TEXT, + restore_status TEXT, + restore_error TEXT, + restored_at TEXT + ) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + backup_type = EXCLUDED.backup_type, + file_name = EXCLUDED.file_name, + s3_key = EXCLUDED.s3_key, + size_bytes = EXCLUDED.size_bytes, + triggered_by = EXCLUDED.triggered_by, + error_message = EXCLUDED.error_message, + started_at = EXCLUDED.started_at, + finished_at = EXCLUDED.finished_at, + expires_at = EXCLUDED.expires_at, + progress = EXCLUDED.progress, + restore_status = EXCLUDED.restore_status, + restore_error = EXCLUDED.restore_error, + restored_at = EXCLUDED.restored_at, + updated_at = NOW(); +END $$; diff --git a/backend/migrations/auth_identity_payment_migrations_regression_test.go b/backend/migrations/auth_identity_payment_migrations_regression_test.go index 14dab99160d..11c79360000 100644 --- a/backend/migrations/auth_identity_payment_migrations_regression_test.go +++ b/backend/migrations/auth_identity_payment_migrations_regression_test.go @@ -180,6 +180,31 @@ func TestMigration158BackfillsGrokMediaGenerationGroups(t *testing.T) { require.Contains(t, sql, "AND allow_image_generation = false") } +func TestMigration160AllowsFeishuAuthProvider(t *testing.T) { + content, err := FS.ReadFile("160_add_feishu_provider_type.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "users_signup_source_check") + require.Contains(t, sql, "auth_identities_provider_type_check") + require.Contains(t, sql, "auth_identity_channels_provider_type_check") + require.Contains(t, sql, "pending_auth_sessions_provider_type_check") + require.Contains(t, sql, "user_provider_default_grants_provider_type_check") + require.Contains(t, sql, "'feishu'") +} + +func TestMigration161DoesNotSilentlyDropBackupRecordBackfill(t *testing.T) { + content, err := FS.ReadFile("161_backup_records.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "backup_records") + require.Contains(t, sql, "jsonb_typeof") + require.Contains(t, sql, "RAISE EXCEPTION") + require.NotContains(t, sql, "EXCEPTION WHEN others") + require.NotContains(t, sql, "skip backup_records backfill") +} + func TestMigration154AddsSparkShadowColumnsAndConstraintsWithoutHotIndexes(t *testing.T) { content, err := FS.ReadFile("154_account_spark_shadow.sql") require.NoError(t, err) diff --git a/deploy/README.md b/deploy/README.md index dd311721d91..6e7c4937179 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,6 +15,7 @@ This directory contains files for deploying Sub2API on Linux servers. |------|-------------| | `docker-compose.yml` | Docker Compose configuration (named volumes) | | `docker-compose.local.yml` | Docker Compose configuration (local directories, easy migration) | +| `docker-compose.standalone.yml` | App-only Compose configuration for external PostgreSQL/Redis and multi-instance deployment | | `docker-deploy.sh` | **One-click Docker deployment script (recommended)** | | `.env.example` | Docker environment variables template | | `DOCKER.md` | Docker Hub documentation | @@ -106,6 +107,21 @@ docker compose -f docker-compose.local.yml logs -f sub2api **Recommendation:** Use `docker-compose.local.yml` (deployed by `docker-deploy.sh`) for easier data management and migration. +### Multi-Instance Deployment + +For two or more Sub2API app instances behind a load balancer, use `docker-compose.standalone.yml` on each app server and provide shared external PostgreSQL and Redis. + +Requirements: + +- Use the same image tag on every app server; pin a version tag instead of `latest` for production rollouts. +- Use the same `.env` values for `JWT_SECRET`, `TOTP_ENCRYPTION_KEY`, OAuth client secrets, and other encryption/signing secrets. +- Point every instance to the same PostgreSQL database and Redis database. +- Size PostgreSQL for the total pool budget: `instance_count * DATABASE_MAX_OPEN_CONNS`, plus headroom. +- Put the load balancer health check on `GET /health`. +- Treat `/app/data` as node-local. Do not rely on runtime-edited local pages/static overrides unless they are deployed identically to every app server or moved to shared storage. + +The app stores shared state in PostgreSQL and uses Redis-backed, fail-closed leader locks for duplicate-sensitive background work, including scheduled tests, channel monitor checks, backup/restore operations, subscription expiry reminders, dashboard aggregation, and payment order expiry. Treat Redis as required for multi-instance deployments; PostgreSQL advisory locking is only a fallback for jobs that are explicitly wired without a Redis lock backend. + ### How Auto-Setup Works When using Docker Compose with `AUTO_SETUP=true`: diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index 32afb28d6c5..81f1b1b9629 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -75,6 +75,7 @@ services: # ======================================================================= - JWT_SECRET=${JWT_SECRET:-} - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} + - TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-} # ======================================================================= # Timezone Configuration diff --git a/docs/superpowers/plans/2026-07-03-feishu-org-permissions-implementation.md b/docs/superpowers/plans/2026-07-03-feishu-org-permissions-implementation.md new file mode 100644 index 00000000000..15594aaa915 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-feishu-org-permissions-implementation.md @@ -0,0 +1,907 @@ +# Feishu Org Permissions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` when executing this plan. If a single agent executes inline, use `superpowers:executing-plans` and complete the checklist task by task. Do not skip the test-first steps. + +**Goal:** 为公司内部使用场景接入飞书登录和飞书组织架构权限。员工通过飞书内部应用登录;系统从飞书同步部门、员工状态、部门负责人关系;超管给部门配置可用模型分组池;部门负责人只能给自己负责范围内的员工分配该部门已授权分组;超管仍可给单个员工做个人额外授权。 + +**Architecture:** 在现有 Sub2API 后端中新增 `feishu` 作为一等认证来源,复用 `auth_identities` 做已有用户精确绑定,复用现有 `auth_source_default_*` settings key 模式做飞书自动创建用户默认权益。组织架构作为飞书只读镜像保存,本系统不维护部门树、员工归属、上下级。权限生效层统一落到现有 `user_allowed_groups`,新增部门池、部门负责人授权、个人额外授权和审计表来解释权限来源。 + +**Tech Stack:** Go + Ent + Gin 后端;Vue 3 + TypeScript + Vite 前端;现有 Makefile 的 `make test-backend`、`make test-frontend-critical`、`make build` 作为主要验证入口。 + +--- + +## 当前约束 + +- 当前分支:`feat/feishu-org-permissions`。 +- 设计文档:`docs/superpowers/specs/2026-07-02-feishu-org-permissions-design.md`。 +- 不把飞书返回的邮箱或姓名作为首次登录自动绑定依据。第一版已有用户只通过预写入的 `auth_identities(provider_type=feishu, provider_key=tenant_key, provider_subject=open_id)` 精确匹配。 +- 不在 Sub2API 维护组织架构。部门、员工归属、负责人和离职/禁用状态只从飞书同步。 +- 飞书离职、禁用、移出可用范围后,默认自动禁用本地用户;触发批量保护或最后 active 超管保护时进入待确认。 +- 被本地禁用的用户必须同时阻断网页登录、JWT、API Key 调用和 OAuth 会话刷新。 +- `tools/feishu-login-demo/` 是临时验证工具,里面可能有本地测试凭证。实现主功能时不要把里面的密钥写进代码、测试、日志或提交信息。 + +--- + +## 数据和权限模型 + +### 现有表扩展 + +- `users.signup_source` 增加枚举值 `feishu`。 +- `auth_identities.provider_type` 增加枚举值 `feishu`。 +- 后台用户身份绑定接口允许 `provider_type=feishu`,字段含义: + - `provider_key`:飞书 `tenant_key`。 + - `provider_subject`:飞书 `open_id`。 + - `provider_union_id`:如果现有 schema 没有该字段,第一版存到 `raw_data` 或等价 JSON 字段。 + +### 新增表 + +- `feishu_departments`:飞书部门只读镜像。 +- `feishu_user_profiles`:飞书用户只读镜像和本地用户映射。 +- `department_group_policies`:超管给部门授权的分组池。 +- `department_managers`:飞书负责人关系投影。 +- `user_group_grants`:用户分组授权来源明细。 +- `org_permission_audit_logs`:组织权限审计日志。 +- `feishu_org_sync_runs`:飞书组织同步运行记录、健康检查和影响预览。 + +### 权限生效规则 + +- 超管不受部门范围限制。 +- 部门负责人 `B` 给员工 `A` 分配或取消分组 `G` 时必须同时满足: + - `B` 是 active 用户。 + - `B` 来自飞书同步结果,负责 `A` 的主部门或其上级部门范围。 + - `A` 是 active 用户,并归属于 `B` 可管理的部门范围。 + - `G` 属于 `A` 所在部门或其继承部门池。 + - 本次修改不会移除来源为 `super_admin_override` 的个人额外授权。 +- `user_allowed_groups` 是最终运行时读取表;`user_group_grants` 是解释来源和重算依据。 + +--- + +## 任务 1:提交前基线检查和计划落盘 + +- [ ] 确认工作树只包含设计文档、计划文档、飞书 demo 目录和本次实现改动: + + ```bash + git status --short --branch + ``` + +- [ ] 不提交 `tools/feishu-login-demo/.env`、本地日志、浏览器截图和任何密钥。 +- [ ] 对计划文档运行占位符检查: + + ```bash + bad_terms='T''ODO|T''BD|待''补|待''定|稍''后|以''后再|place''holder|fill'' in' + rg -n --pcre2 "$bad_terms" docs/superpowers/plans/2026-07-03-feishu-org-permissions-implementation.md + ``` + +- [ ] 保存设计文档和计划文档后提交一次文档基线: + + ```bash + git add docs/superpowers/specs/2026-07-02-feishu-org-permissions-design.md docs/superpowers/plans/2026-07-03-feishu-org-permissions-implementation.md + git commit -m "docs: add feishu org permission implementation plan" + ``` + +--- + +## 任务 2:后端增加 `feishu` provider 和来源默认权益 + +### 先写测试 + +- [ ] 在 `backend/internal/service/setting_service_auth_source_defaults_test.go` 或现有同类测试文件中新增测试: + - `TestAuthSourceDefaultSettingsIncludesFeishu` + - `TestUpdateAuthSourceDefaultSettingsPersistsFeishu` + - `TestResolveAuthSourceDefaultsFallsBackWhenFeishuUnset` +- [ ] 在 `backend/internal/service/admin_service_test.go` 增加测试: + - `TestBindUserAuthIdentityAcceptsFeishu` + - `TestBindUserAuthIdentityRejectsUnknownProvider` +- [ ] 测试断言: + - `AuthSourceDefaultSettings` 返回结构包含 `Feishu`。 + - `feishu` 默认余额、并发、订阅、注册发放、首次绑定发放能保存和读取。 + - 管理员绑定身份时 `provider_type=feishu` 允许通过。 + - 未知 provider 仍拒绝。 + +运行预期失败: + +```bash +cd backend && go test ./internal/service -run 'Test(AuthSourceDefaultSettingsIncludesFeishu|UpdateAuthSourceDefaultSettingsPersistsFeishu|ResolveAuthSourceDefaultsFallsBackWhenFeishuUnset|BindUserAuthIdentityAcceptsFeishu|BindUserAuthIdentityRejectsUnknownProvider)' +``` + +### 实现 + +- [ ] 修改 `backend/ent/schema/user.go`:`signup_source` enum 增加 `feishu`。 +- [ ] 修改 `backend/ent/schema/auth_identity.go`:`provider_type` enum 增加 `feishu`。 +- [ ] 修改 `backend/internal/service/domain_constants.go`: + - 增加 `AuthSourceFeishu = "feishu"`。 + - 增加 `SettingKeyAuthSourceDefaultFeishuBalance`。 + - 增加 `SettingKeyAuthSourceDefaultFeishuConcurrency`。 + - 增加 `SettingKeyAuthSourceDefaultFeishuSubscriptions`。 + - 增加 `SettingKeyAuthSourceDefaultFeishuGrantOnSignup`。 + - 增加 `SettingKeyAuthSourceDefaultFeishuGrantOnFirstBind`。 + - 增加 `SettingKeyAuthSourceDefaultFeishuPlatformQuotas`,使用现有 `SettingKeyAuthSourcePlatformQuotas("feishu")` 形式。 +- [ ] 修改 `backend/internal/service/setting_service.go`: + - `AuthSourceDefaultSettings` 增加 `Feishu ProviderDefaultGrantSettings`。 + - 默认 settings map 增加飞书默认值:余额 `0`、并发 `5`、订阅 `[]`、注册发放 `false`、首次绑定发放 `false`。 + - `GetAuthSourceDefaultSettings` 解析 `feishuAuthSourceDefaultKeys`。 + - `UpdateAuthSourceDefaultSettings` 写入 `settings.Feishu`。 +- [ ] 修改 `backend/internal/service/admin_service.go`: + - `normalizeAdminAuthIdentityProviderType` 支持 `feishu`。 + - 绑定时继续依赖现有唯一约束,保证一个飞书身份只绑定一个本地用户。 + +### 验证和提交 + +- [ ] 运行本任务测试: + + ```bash + cd backend && go test ./internal/service -run 'Test(AuthSourceDefaultSettingsIncludesFeishu|UpdateAuthSourceDefaultSettingsPersistsFeishu|ResolveAuthSourceDefaultsFallsBackWhenFeishuUnset|BindUserAuthIdentityAcceptsFeishu|BindUserAuthIdentityRejectsUnknownProvider)' + ``` + +- [ ] 运行 Ent 生成或仓库已有生成命令。如果项目没有脚本,使用后端现有命令: + + ```bash + cd backend && go generate ./ent + ``` + +- [ ] 若 Ent 生成命令不适用,使用项目现有 README 或 Makefile 中的生成方式,并在提交说明里写清实际命令。 +- [ ] 提交: + + ```bash + git add backend/ent backend/internal/service + git commit -m "feat: add feishu auth source defaults" + ``` + +--- + +## 任务 3:后端增加飞书设置、登录入口保护和预检接口 + +### 先写测试 + +- [ ] 在 `backend/internal/service/setting_service_feishu_test.go` 新增: + - `TestGetFeishuConnectSettingsDefaults` + - `TestUpdateSecuritySettingsRejectsNoLoginEntry` + - `TestUpdateSecuritySettingsRejectsFeishuOnlyWithoutHealthyConfig` + - `TestUpdateSecuritySettingsAllowsAdminEmailFallback` +- [ ] 在 `backend/internal/handler/admin/setting_handler_feishu_test.go` 新增: + - `TestAdminSettingsExposeFeishuConnectFields` + - `TestFeishuPreflightReportsMissingCredential` + - `TestFeishuPreflightReportsDefaultGrantWarning` +- [ ] 测试断言: + - `email_password_login_enabled=false` 且 `feishu_connect_enabled=false` 时拒绝保存。 + - 只剩飞书登录但 App ID、App Secret、Redirect URL 缺失时拒绝保存。 + - 保留超管邮箱备用登录时,后台仍返回风险警告。 + - 预检接口返回能力拆分状态:登录、邮箱、组织同步、离职识别、负责人关系。 + +运行预期失败: + +```bash +cd backend && go test ./internal/service ./internal/handler/admin -run 'Test(GetFeishuConnectSettingsDefaults|UpdateSecuritySettingsRejectsNoLoginEntry|UpdateSecuritySettingsRejectsFeishuOnlyWithoutHealthyConfig|UpdateSecuritySettingsAllowsAdminEmailFallback|AdminSettingsExposeFeishuConnectFields|FeishuPreflightReportsMissingCredential|FeishuPreflightReportsDefaultGrantWarning)' +``` + +### 实现 + +- [ ] 修改 `backend/internal/service/domain_constants.go` 增加设置键: + - `feishu_connect_enabled` + - `feishu_connect_app_id` + - `feishu_connect_app_secret` + - `feishu_connect_redirect_url` + - `feishu_connect_tenant_restriction_policy` + - `feishu_connect_allowed_tenant_key` + - `feishu_connect_bypass_registration` + - `feishu_connect_sync_email` + - `feishu_connect_sync_display_name` + - `feishu_connect_sync_department` + - `feishu_org_sync_enabled` + - `feishu_departed_user_action` + - `feishu_sync_disable_threshold_count` + - `feishu_sync_disable_threshold_percent` + - `email_password_login_enabled` + - `admin_email_login_fallback_enabled` +- [ ] 如果已有邮箱登录开关命名不同,保留既有 key,新增兼容映射,不迁移历史设置。 +- [ ] 修改 `backend/internal/service/setting_service.go`: + - 增加 `FeishuConnectSettings` 结构。 + - 增加读取飞书登录最终生效配置的方法 `GetFeishuConnectOAuthConfig(ctx)`。 + - 增加 `ValidateLoginEntryAvailability(ctx, settings)`,在保存安全与认证设置时调用。 + - 增加 `CheckFeishuPreflight(ctx)`,第一版至少做配置完整性、默认权益配置、最近同步状态检查。 +- [ ] 修改 admin settings handler: + - GET settings 返回飞书配置,App Secret 只返回 configured 布尔值,不回显密文。 + - PATCH/PUT settings 支持保存飞书配置和登录入口开关。 + - 新增 `POST /api/v1/admin/settings/feishu/preflight`。 +- [ ] 错误文案使用清晰业务码: + - `LOGIN_ENTRY_UNAVAILABLE` + - `FEISHU_CONFIG_INCOMPLETE` + - `ADMIN_LOGIN_FALLBACK_REQUIRED` + +### 验证和提交 + +- [ ] 运行本任务测试: + + ```bash + cd backend && go test ./internal/service ./internal/handler/admin -run 'Test(GetFeishuConnectSettingsDefaults|UpdateSecuritySettingsRejectsNoLoginEntry|UpdateSecuritySettingsRejectsFeishuOnlyWithoutHealthyConfig|UpdateSecuritySettingsAllowsAdminEmailFallback|AdminSettingsExposeFeishuConnectFields|FeishuPreflightReportsMissingCredential|FeishuPreflightReportsDefaultGrantWarning)' + ``` + +- [ ] 运行后端关键设置测试: + + ```bash + cd backend && go test ./internal/service ./internal/handler/admin + ``` + +- [ ] 提交: + + ```bash + git add backend/internal/service backend/internal/handler/admin + git commit -m "feat: add feishu login settings guard" + ``` + +--- + +## 任务 4:飞书 OAuth 登录和精确身份绑定 + +### 先写测试 + +- [ ] 参考 `backend/internal/handler/auth_dingtalk_oauth_test.go` 新增 `backend/internal/handler/auth_feishu_oauth_test.go`: + - `TestFeishuOAuthStartDisabled` + - `TestFeishuOAuthCallbackBindsExistingIdentity` + - `TestFeishuOAuthCallbackDoesNotBindByEmail` + - `TestFeishuOAuthCallbackCreatesUserWithFeishuDefaults` + - `TestFeishuOAuthCallbackRejectsUnboundWhenAutoCreateDisabled` + - `TestFeishuOAuthCallbackRejectsDisabledLocalUser` + - `TestFeishuOAuthCallbackRejectsWrongTenant` +- [ ] 参考 `backend/internal/handler/auth_dingtalk_client_test.go` 新增 `backend/internal/handler/auth_feishu_client_test.go`: + - `TestFeishuExchangeCodeParsesUserInfo` + - `TestFeishuUserInfoAllowsEmptyEmail` + - `TestFeishuUserInfoCapturesTenantKeyOpenIDUnionID` +- [ ] 测试断言: + - OAuth disabled 时 start/callback 全部拒绝。 + - 已预写 `auth_identities(provider_type=feishu, provider_key=tenant_key, provider_subject=open_id)` 时登录到同一个本地用户,保留原余额、API Key、分组和订阅。 + - 飞书返回 email 为空或与本地 email 相同,都不触发邮箱自动绑定。 + - 自动创建用户时 `signup_source=feishu`,并应用 `settings.Feishu` 默认权益。 + +运行预期失败: + +```bash +cd backend && go test ./internal/handler -run 'TestFeishu' +``` + +### 实现 + +- [ ] 新增 `backend/internal/handler/auth_feishu_client.go`: + - 构造授权 URL。 + - 用 code 换 access token。 + - 调用飞书用户身份接口读取 `open_id`、`union_id`、`tenant_key`、`name`、`email`。 + - 支持 email 为空。 + - 所有日志只记录 request id、租户、open_id 前后缀,不记录 token。 +- [ ] 新增 `backend/internal/handler/auth_feishu_oauth.go`: + - `GET /api/v1/auth/oauth/feishu/start` + - `GET /api/v1/auth/oauth/feishu/callback` + - 如前端已有统一 OAuth pending 流程,飞书按现有 GitHub/Google/DingTalk 结构接入。 +- [ ] 修改 `backend/internal/service/auth_service.go` 或现有 OAuth service: + - `FindUserByAuthIdentity(provider=feishu, key=tenant_key, subject=open_id)`。 + - 未找到绑定且自动创建关闭时返回 `FEISHU_ACCOUNT_NOT_BOUND`。 + - 未找到绑定且自动创建开启时创建用户,应用 `feishu` 来源默认权益。 + - 登录成功后 upsert `feishu_user_profiles` 中该用户快照。 +- [ ] 修改后端路由注册文件,把飞书 OAuth route 加入公开 auth routes。 +- [ ] 修改 JWT/API Key 登录链路时不需要新增禁用判断,因为现有 `User.IsActive()` 已覆盖。补一条测试确认飞书禁用本地用户后 API Key 被拒绝。 + +### 验证和提交 + +- [ ] 运行飞书 handler 测试: + + ```bash + cd backend && go test ./internal/handler -run 'TestFeishu' + ``` + +- [ ] 运行认证相关测试: + + ```bash + cd backend && go test ./internal/handler ./internal/service ./internal/server/middleware + ``` + +- [ ] 提交: + + ```bash + git add backend/internal/handler backend/internal/service backend/internal/server + git commit -m "feat: add feishu oauth login" + ``` + +--- + +## 任务 5:新增飞书组织同步 Ent schema 和迁移 + +### 先写测试 + +- [ ] 新增 `backend/internal/service/feishu_org_schema_test.go` 或放入组织服务测试文件: + - `TestFeishuDepartmentUniqueTenantDepartment` + - `TestFeishuUserProfileUniqueTenantOpenID` + - `TestDepartmentGroupPolicyUniqueDepartmentGroup` + - `TestDepartmentManagerUniqueManagerScope` + - `TestUserGroupGrantUniqueUserGroupSource` + - `TestFeishuOrgSyncRunStoresImpactSummary` +- [ ] 测试用 Ent sqlite 或项目现有 test DB helper 创建数据,断言唯一约束和 JSON 字段可读写。 + +运行预期失败: + +```bash +cd backend && go test ./internal/service -run 'Test(FeishuDepartment|FeishuUserProfile|DepartmentGroupPolicy|DepartmentManager|UserGroupGrant|FeishuOrgSyncRun)' +``` + +### 实现 + +- [ ] 新增 `backend/ent/schema/feishu_department.go` 字段: + - `tenant_key` + - `department_id` + - `parent_department_id` + - `name` + - `path` + - `level` + - `leader_user_ids` + - `raw_data` + - `synced_at` + - 唯一索引 `(tenant_key, department_id)` +- [ ] 新增 `backend/ent/schema/feishu_user_profile.go` 字段: + - `tenant_key` + - `open_id` + - `union_id` + - `user_id_in_tenant` + - `local_user_id` + - `name` + - `email` + - `department_ids` + - `primary_department_id` + - `manager_open_id` + - `status` + - `in_app_scope` + - `raw_data` + - `last_seen_at` + - `synced_at` + - 唯一索引 `(tenant_key, open_id)` + - 普通索引 `local_user_id` +- [ ] 新增 `backend/ent/schema/department_group_policy.go` 字段: + - `tenant_key` + - `department_id` + - `group_id` + - `inherit_to_children` + - `enabled` + - `created_by` + - `updated_by` + - 唯一索引 `(tenant_key, department_id, group_id)` +- [ ] 新增 `backend/ent/schema/department_manager.go` 字段: + - `tenant_key` + - `department_id` + - `manager_open_id` + - `manager_user_id` + - `scope_mode`,值为 `self` 或 `subtree` + - `source` + - `synced_at` + - 唯一索引 `(tenant_key, department_id, manager_open_id, scope_mode)` +- [ ] 新增 `backend/ent/schema/user_group_grant.go` 字段: + - `user_id` + - `group_id` + - `source_type`,值为 `department_manager`、`department_policy`、`super_admin_override` + - `source_id` + - `tenant_key` + - `department_id` + - `granted_by` + - `expires_at` + - `created_at` + - `updated_at` + - 唯一索引 `(user_id, group_id, source_type, source_id)` +- [ ] 新增 `backend/ent/schema/org_permission_audit_log.go` 字段: + - `actor_user_id` + - `target_user_id` + - `action` + - `resource_type` + - `resource_id` + - `before_data` + - `after_data` + - `request_id` + - `created_at` +- [ ] 新增 `backend/ent/schema/feishu_org_sync_run.go` 字段: + - `tenant_key` + - `status` + - `started_at` + - `finished_at` + - `departments_seen` + - `users_seen` + - `bound_users` + - `unbound_users` + - `users_to_disable` + - `review_required` + - `blocked_reason` + - `impact_summary` + - `error_message` +- [ ] 运行 Ent 生成: + + ```bash + cd backend && go generate ./ent + ``` + +### 验证和提交 + +- [ ] 运行 schema 测试: + + ```bash + cd backend && go test ./internal/service -run 'Test(FeishuDepartment|FeishuUserProfile|DepartmentGroupPolicy|DepartmentManager|UserGroupGrant|FeishuOrgSyncRun)' + ``` + +- [ ] 运行后端编译: + + ```bash + cd backend && go test ./... + ``` + +- [ ] 提交: + + ```bash + git add backend/ent + git commit -m "feat: add feishu org permission schema" + ``` + +--- + +## 任务 6:飞书组织同步服务、健康检查和影响预览 + +### 先写测试 + +- [ ] 新增 `backend/internal/service/feishu_org_service_test.go`: + - `TestFeishuOrgSyncUpsertsDepartmentsAndUsers` + - `TestFeishuOrgSyncMapsBoundUsersByAuthIdentity` + - `TestFeishuOrgSyncDoesNotMatchByEmail` + - `TestFeishuOrgSyncDisablesDepartedUser` + - `TestFeishuOrgSyncBlocksLastActiveAdminDisable` + - `TestFeishuOrgSyncRequiresReviewWhenDisableThresholdExceeded` + - `TestFeishuOrgPreflightReportsScopeAndLastRun` +- [ ] 用 fake Feishu client 返回: + - 一个 active 用户。 + - 一个 disabled 用户。 + - 一个不在可用范围内的用户。 + - 一个多部门用户。 + - 一个部门负责人。 +- [ ] 测试断言: + - sync 只 upsert 飞书镜像,不允许在本系统修改部门树。 + - 已绑定本地用户通过 `auth_identities` 映射。 + - email 相同但没有 `auth_identity` 时仍是 unbound。 + - 常规离职/禁用自动禁用本地用户并写 audit log。 + - 最后 active 超管保护阻断禁用。 + - 超阈值批量禁用写 `review_required=true`,不直接禁用。 + +运行预期失败: + +```bash +cd backend && go test ./internal/service -run 'TestFeishuOrg' +``` + +### 实现 + +- [ ] 新增 `backend/internal/service/feishu_client.go`: + - 定义接口 `FeishuClient`,包含获取 tenant token、用户列表、部门列表、用户详情、权限 scope 或健康检查所需方法。 + - HTTP 实现放在同文件或 `feishu_http_client.go`。 + - 测试只使用 fake client。 +- [ ] 新增 `backend/internal/service/feishu_org_service.go`: + - `SyncFeishuOrg(ctx, opts)`。 + - `PreviewFeishuOrgSync(ctx)`。 + - `CheckFeishuOrgHealth(ctx)`。 + - `ApplyPendingDisableReview(ctx, runID, adminID)`。 +- [ ] 同步流程: + - 读取飞书部门列表,upsert `feishu_departments`。 + - 读取飞书用户列表,upsert `feishu_user_profiles`。 + - 通过 `auth_identities` 映射本地用户。 + - 计算 `primary_department_id`,第一版使用飞书返回的第一个部门,并在多部门时写入提示字段。 + - upsert `department_managers`。 + - 计算离职/禁用/移出范围影响。 + - 通过批量保护后自动禁用本地用户。 + - 写 `feishu_org_sync_runs` 和 `org_permission_audit_logs`。 +- [ ] 默认阈值: + - `feishu_sync_disable_threshold_count=10` + - `feishu_sync_disable_threshold_percent=20` + - 任一条件超过即进入 review。 +- [ ] 禁用用户时不要删除: + - API Key + - 余额 + - 订阅 + - 分组授权记录 + - 审计日志 + +### 验证和提交 + +- [ ] 运行组织同步测试: + + ```bash + cd backend && go test ./internal/service -run 'TestFeishuOrg' + ``` + +- [ ] 运行服务层测试: + + ```bash + cd backend && go test ./internal/service + ``` + +- [ ] 提交: + + ```bash + git add backend/internal/service + git commit -m "feat: sync feishu org mirror" + ``` + +--- + +## 任务 7:组织权限服务和最终分组重算 + +### 先写测试 + +- [ ] 新增 `backend/internal/service/org_permission_service_test.go`: + - `TestAdminAssignsDepartmentGroupPolicy` + - `TestManagerGrantsGroupInsideManagedDepartment` + - `TestManagerCannotGrantGroupOutsideDepartmentPool` + - `TestManagerCannotManageEmployeeOutsideScope` + - `TestManagerCannotRemoveAdminOverrideGrant` + - `TestRecomputeUserAllowedGroupsIncludesDepartmentAndOverrideGrants` + - `TestRecomputeUserAllowedGroupsRemovesRevokedManagerGrant` +- [ ] 测试数据: + - 部门 `D1`,子部门 `D1-1`,部门 `D2`。 + - 超管授权 `D1` 可用分组 `G1`,授权 `D2` 可用分组 `G2`。 + - 负责人 `B` 管理 `D1`。 + - 员工 `A` 主部门 `D1-1`。 + - 员工 `C` 主部门 `D2`。 +- [ ] 测试断言: + - `B` 可给 `A` 授权 `G1`。 + - `B` 不可给 `A` 授权 `G2`。 + - `B` 不可给 `C` 授权任何分组。 + - 超管个人 override 不受 `B` 取消操作影响。 + +运行预期失败: + +```bash +cd backend && go test ./internal/service -run 'Test(AdminAssignsDepartmentGroupPolicy|ManagerGrantsGroupInsideManagedDepartment|ManagerCannotGrantGroupOutsideDepartmentPool|ManagerCannotManageEmployeeOutsideScope|ManagerCannotRemoveAdminOverrideGrant|RecomputeUserAllowedGroups)' +``` + +### 实现 + +- [ ] 新增 `backend/internal/service/org_permission_service.go`: + - `SetDepartmentGroupPolicies(ctx, adminID, departmentID, groupIDs, inheritToChildren)`。 + - `GrantUserGroupAsManager(ctx, managerID, userID, groupID)`。 + - `RevokeUserGroupAsManager(ctx, managerID, userID, groupID)`。 + - `GrantUserGroupAsAdminOverride(ctx, adminID, userID, groupID)`。 + - `RecomputeUserAllowedGroups(ctx, userID)`。 + - `ListManagerScope(ctx, managerID)`。 +- [ ] `RecomputeUserAllowedGroups` 写入现有 `user_allowed_groups`: + - 收集 active grant。 + - 合并去重 group id。 + - 调用现有 user repository 的 allowed groups 同步方法。 +- [ ] 所有分配和撤销写 `org_permission_audit_logs`。 +- [ ] 多部门提示: + - 逻辑仍按 `primary_department_id` 判断。 + - 接口返回 `department_ids` 和 `primary_department_id`,前端展示“仅按主部门授权”。 + +### 验证和提交 + +- [ ] 运行权限服务测试: + + ```bash + cd backend && go test ./internal/service -run 'Test(AdminAssignsDepartmentGroupPolicy|ManagerGrantsGroupInsideManagedDepartment|ManagerCannotGrantGroupOutsideDepartmentPool|ManagerCannotManageEmployeeOutsideScope|ManagerCannotRemoveAdminOverrideGrant|RecomputeUserAllowedGroups)' + ``` + +- [ ] 运行用户分组相关测试: + + ```bash + cd backend && go test ./internal/service ./internal/repository + ``` + +- [ ] 提交: + + ```bash + git add backend/internal/service backend/internal/repository + git commit -m "feat: add org-scoped group grants" + ``` + +--- + +## 任务 8:后台和负责人 API + +### 先写测试 + +- [ ] 新增 `backend/internal/handler/admin/feishu_org_handler_test.go`: + - `TestAdminListFeishuDepartments` + - `TestAdminPreviewFeishuSync` + - `TestAdminRunFeishuSync` + - `TestAdminSetDepartmentGroupPolicy` + - `TestAdminGrantUserOverrideGroup` +- [ ] 新增 `backend/internal/handler/org_manager_handler_test.go`: + - `TestManagerListOwnDepartments` + - `TestManagerListEmployeesInScope` + - `TestManagerGrantGroup` + - `TestManagerGrantGroupRejectsOutOfScopeEmployee` + - `TestManagerGrantGroupRejectsOutOfPoolGroup` +- [ ] 测试断言: + - 非超管无法调用 admin org API。 + - 非负责人无法调用 manager API。 + - 负责人 scope 变化后接口立即拒绝新的授权。 + +运行预期失败: + +```bash +cd backend && go test ./internal/handler ./internal/handler/admin -run 'Test(Admin.*Feishu|Manager.*)' +``` + +### 实现 + +- [ ] 新增后台路由: + - `GET /api/v1/admin/feishu/departments` + - `GET /api/v1/admin/feishu/users` + - `GET /api/v1/admin/feishu/sync/preview` + - `POST /api/v1/admin/feishu/sync` + - `GET /api/v1/admin/feishu/sync-runs` + - `POST /api/v1/admin/feishu/sync-runs/:id/apply-review` + - `PUT /api/v1/admin/org/departments/:department_id/groups` + - `POST /api/v1/admin/users/:id/group-overrides` + - `DELETE /api/v1/admin/users/:id/group-overrides/:group_id` +- [ ] 新增负责人路由: + - `GET /api/v1/org-manager/departments` + - `GET /api/v1/org-manager/employees` + - `GET /api/v1/org-manager/employees/:id/groups` + - `POST /api/v1/org-manager/employees/:id/groups` + - `DELETE /api/v1/org-manager/employees/:id/groups/:group_id` +- [ ] 修改当前用户信息接口: + - 返回 `is_org_manager`。 + - 返回 manager 入口所需的最小 scope 摘要。 +- [ ] 响应结构包含: + - `source_type`:`department_manager`、`department_policy`、`super_admin_override`。 + - `source_label`:页面显示用中文文案。 + - `primary_department_id` 和 `department_ids`。 + - `multi_department_notice`。 + +### 验证和提交 + +- [ ] 运行 handler 测试: + + ```bash + cd backend && go test ./internal/handler ./internal/handler/admin -run 'Test(Admin.*Feishu|Manager.*)' + ``` + +- [ ] 运行后端全量测试: + + ```bash + make test-backend + ``` + +- [ ] 提交: + + ```bash + git add backend/internal/handler backend/internal/server backend/internal/service + git commit -m "feat: expose feishu org permission APIs" + ``` + +--- + +## 任务 9:前端类型、登录页和安全与认证设置页 + +### 先写测试 + +- [ ] 修改或新增前端测试: + - `frontend/src/views/auth/__tests__/FeishuCallbackView.spec.ts` + - `frontend/src/views/admin/__tests__/SettingsView.spec.ts` + - `frontend/src/api/__tests__/auth.spec.ts` +- [ ] 测试用例: + - `UserAuthProvider` 接受 `feishu`。 + - 飞书登录关闭时登录页不显示飞书入口。 + - 只启用飞书登录时飞书按钮是主操作。 + - 保存所有登录入口不可用时展示后端错误。 + - 飞书 App Secret 已配置时显示“已配置”,不回显密文。 + - `feishu` 来源默认权益区域可以编辑并提交。 + - 多部门用户展示“仅按主部门授权”提示。 + +运行预期失败: + +```bash +pnpm --dir frontend exec vitest run src/views/auth/__tests__/FeishuCallbackView.spec.ts src/views/admin/__tests__/SettingsView.spec.ts src/api/__tests__/auth.spec.ts +``` + +### 实现 + +- [ ] 修改 `frontend/src/types/index.ts`: + - `UserAuthProvider` 增加 `'feishu'`。 + - 设置类型增加 `feishu` 来源默认权益字段。 + - settings DTO 增加飞书登录配置和预检结果类型。 +- [ ] 修改 `frontend/src/api/auth.ts`: + - OAuth provider union 增加 `feishu`。 + - 新增飞书 OAuth start/callback API 或复用现有 generic OAuth helper。 +- [ ] 新增或复用 auth callback view: + - `frontend/src/views/auth/FeishuCallbackView.vue` + - 失败时显示明确文案:未绑定本地账号、飞书配置未启用、租户不匹配、账号已禁用。 +- [ ] 修改 `frontend/src/router/index.ts`: + - 增加 `/auth/feishu/callback`。 + - 如果存在 email-completion 流程,飞书第一版不使用邮箱补全作为绑定手段。 +- [ ] 修改登录页: + - 按 `feishu_connect_enabled` 显示飞书按钮。 + - 按 `email_password_login_enabled` 显示邮箱密码表单。 + - 若所有入口都不可用,显示“当前没有可用登录方式,请联系管理员”。 +- [ ] 修改 `frontend/src/views/admin/SettingsView.vue`: + - 在“安全与认证”增加飞书登录卡片,布局参考钉钉登录。 + - 增加“检查配置”按钮,调用 `POST /api/v1/admin/settings/feishu/preflight`。 + - 增加登录入口开关:邮箱密码登录、超管邮箱备用登录、飞书登录。 + - 在来源默认权益里增加 `feishu`。 + - 保存失败时展示后端业务错误,不吞掉错误。 +- [ ] 修改 `frontend/src/i18n/locales/zh.ts` 和 `frontend/src/i18n/locales/en.ts`: + - 增加飞书登录、飞书配置、飞书默认权益、登录入口保护相关文案。 + +### 验证和提交 + +- [ ] 运行前端相关测试: + + ```bash + pnpm --dir frontend exec vitest run src/views/auth/__tests__/FeishuCallbackView.spec.ts src/views/admin/__tests__/SettingsView.spec.ts src/api/__tests__/auth.spec.ts + ``` + +- [ ] 运行关键前端测试: + + ```bash + make test-frontend-critical + ``` + +- [ ] 提交: + + ```bash + git add frontend/src + git commit -m "feat: add feishu login settings UI" + ``` + +--- + +## 任务 10:前端组织权限页面和部门负责人页面 + +### 先写测试 + +- [ ] 新增测试: + - `frontend/src/views/admin/__tests__/FeishuOrgView.spec.ts` + - `frontend/src/views/org-manager/__tests__/OrgManagerView.spec.ts` +- [ ] 测试用例: + - 超管页面展示飞书同步健康状态、最近同步时间、同步影响预览。 + - 超管可为部门选择分组池。 + - 超管可给单个员工添加或取消个人额外授权。 + - 部门负责人页面只展示自己负责范围内的员工。 + - 负责人只能看到员工所在部门可用分组。 + - 个人额外授权显示为不可由负责人取消。 + - 多部门员工显示“仅按主部门授权”。 + +运行预期失败: + +```bash +pnpm --dir frontend exec vitest run src/views/admin/__tests__/FeishuOrgView.spec.ts src/views/org-manager/__tests__/OrgManagerView.spec.ts +``` + +### 实现 + +- [ ] 新增 `frontend/src/api/feishuOrg.ts`: + - Admin API client。 + - Manager API client。 + - 类型定义和错误处理。 +- [ ] 新增 `frontend/src/views/admin/FeishuOrgView.vue`: + - 顶部状态区:飞书连接、组织同步、最近运行、影响预览。 + - 部门列表:部门名、路径、负责人、分组池。 + - 员工列表:姓名、本地用户绑定状态、主部门、多部门提示、当前可用分组来源。 + - 同步按钮:先预览,再确认执行。 + - 待确认禁用列表:只展示触发 review 的 run。 +- [ ] 新增 `frontend/src/views/org-manager/OrgManagerView.vue`: + - 负责人部门列表。 + - 员工列表。 + - 员工分组授权面板。 + - 禁用态和离职态用户不可分配。 +- [ ] 修改导航: + - 超管显示“飞书组织权限”入口。 + - `is_org_manager=true` 的普通用户显示“我的部门”入口。 +- [ ] 页面设计约束: + - 使用现有后台列表、筛选、开关、抽屉或弹窗样式。 + - 不做营销式大 hero。 + - 操作按钮用清晰图标和短文案。 + - 权限来源用 tag 展示:部门池、负责人授权、个人额外授权。 + +### 验证和提交 + +- [ ] 运行组织权限前端测试: + + ```bash + pnpm --dir frontend exec vitest run src/views/admin/__tests__/FeishuOrgView.spec.ts src/views/org-manager/__tests__/OrgManagerView.spec.ts + ``` + +- [ ] 运行前端构建: + + ```bash + pnpm --dir frontend run build + ``` + +- [ ] 提交: + + ```bash + git add frontend/src + git commit -m "feat: add feishu org permission UI" + ``` + +--- + +## 任务 11:端到端冒烟和真实飞书应用验证 + +- [ ] 本地后端启动,使用本地配置连接测试飞书应用。 +- [ ] 飞书开发者后台配置回调: + + ```text + http://localhost:3000/oauth/feishu/callback + ``` + + 如果本地前端端口不是 `3000`,以实际 Vite 端口为准,并同步修改飞书后台重定向 URL。 + +- [ ] 使用真实用户完成飞书登录,验证: + - 首次未绑定时显示“未绑定本地账号,请联系管理员”。 + - 手动写入 `auth_identities` 后可登录同一个本地账号。 + - 飞书 email 为空不影响精确绑定登录。 + - `feishu_user_profiles` 写入 `open_id`、`union_id`、`tenant_key`、`department_ids`。 +- [ ] 手动触发组织同步,验证: + - 部门树只读展示。 + - 负责人来自飞书同步。 + - 多部门员工显示提示。 + - 影响预览和实际同步数量一致。 +- [ ] 验证部门负责人权限: + - 负责人可以给自己部门员工分配部门池内分组。 + - 负责人不能分配超管未授权给该部门的分组。 + - 负责人不能管理其他部门员工。 + - 负责人不能取消个人额外授权。 +- [ ] 验证禁用策略: + - 将测试飞书用户从可用范围移除或使用 fake 数据模拟,确认本地用户禁用。 + - 禁用后网页登录、JWT、API Key 都失败。 + - API Key、余额、订阅、授权记录没有被删除。 +- [ ] 运行最终验证: + + ```bash + make test + make build + ``` + +- [ ] 提交冒烟修复: + + ```bash + git add backend frontend docs + git commit -m "test: verify feishu org permission flow" + ``` + +--- + +## 任务 12:收尾检查 + +- [ ] 检查没有密钥: + + ```bash + make secret-scan + ``` + +- [ ] 检查格式和空白: + + ```bash + git diff --check + ``` + +- [ ] 检查提交历史: + + ```bash + git log --oneline --decorate -n 12 + ``` + +- [ ] 检查工作树: + + ```bash + git status --short --branch + ``` + +- [ ] 若 `tools/feishu-login-demo/` 仍需要保留,确认 `.env` 未被加入 git。 +- [ ] 准备合并前说明: + - 飞书登录如何开启。 + - 已有用户如何预写 `auth_identities`。 + - 飞书 scope 缺失时如何看健康检查。 + - 离职/禁用自动禁用和批量保护规则。 + - 部门负责人如何分配分组。 + - 多部门员工按主部门授权的限制。 + +--- + +## 执行顺序建议 + +1. 先完成任务 1 到 4,拿到可登录的飞书身份闭环。 +2. 再完成任务 5 到 8,拿到组织同步和权限 API 闭环。 +3. 再完成任务 9 到 10,补齐后台和负责人页面。 +4. 最后完成任务 11 到 12,用真实飞书应用做一次手动验证。 + +第一版可以按这条边界上线:飞书登录、精确绑定、飞书来源默认权益、手动组织同步、部门池、负责人受限授权、个人额外授权、离职/禁用自动禁用。飞书事件回调、邮箱自动绑定、在本系统编辑组织架构、复杂多部门主部门选择器不进入第一版。 diff --git a/docs/superpowers/specs/2026-07-02-feishu-org-permissions-design.md b/docs/superpowers/specs/2026-07-02-feishu-org-permissions-design.md new file mode 100644 index 00000000000..e1402df7e56 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-feishu-org-permissions-design.md @@ -0,0 +1,559 @@ +# 飞书组织权限设计 + +> **状态:** 已根据 2026-07-03 用户决策更新,待最终评审。当前方向来自前面对话:部门分组池 + 部门负责人分配 + 超管个人额外授权。 + +## 目标 + +为公司内部使用场景增加飞书/Lark 企业身份和组织权限层。公司员工通过飞书内部应用登录后,系统能识别员工所在部门、同步组织架构,并按部门授权规则给员工分配可用模型分组。 + +最终效果: + +- 员工用飞书登录。 +- 超管可以分别控制飞书登录、邮箱密码登录、开放注册。 +- 组织架构从飞书同步,本系统不维护部门树、员工归属和上下级关系。 +- 超管给部门配置“可分配的模型分组池”。 +- 部门负责人只能给自己部门内员工分配部门池子里的组。 +- 超管仍然可以给任意员工单独额外授权其他组。 + +## 不做什么 + +- 本功能不接火山方舟。 +- 本功能不改支付和计费逻辑。 +- 不把部门负责人变成全局 `admin`。 +- 不在本系统维护组织架构;部门、员工归属、上下级和部门负责人都以飞书同步结果为准。 +- 不直接替换现有 `user_allowed_groups` 运行时机制;本功能新增“带来源的授权记录”,再把最终有效权限同步到现有访问路径里。 + +## 产品模型 + +### 角色 + +- **超管:** 现有 `admin` 角色。可以配置飞书、触发组织架构同步、给部门配置分组池、给员工做个人额外授权。不能在本系统编辑部门树或员工归属。 +- **部门负责人:** 普通用户,但拥有受限的组织管理权限。负责人身份来自飞书同步结果,只能管理自己负责部门里的员工,只能分配超管授权给该部门的分组。 +- **员工:** 普通用户。使用 API Key 和模型分组,分组来源可以是部门负责人分配、超管个人额外授权,或现有公开分组规则。 + +### 权限规则 + +部门负责人 `B` 想给员工 `A` 分配或取消分组 `G`,必须同时满足: + +1. `B` 是有效用户,并且根据飞书同步结果,是 `A` 所在主部门的负责人,或负责包含 `A` 所在部门的上级部门范围。 +2. `A` 是有效用户,并且属于 `B` 可管理的部门范围。 +3. `G` 是有效分组,并且属于 `A` 主部门被超管配置的分组池。 +4. 本次修改的授权来源是“部门负责人分配”,不是“超管个人额外授权”。 + +超管不受这些限制。超管可以管理任意部门池和员工个人额外授权,但不在本系统编辑飞书组织架构。 + +### 员工最终可用分组 + +员工最终可用分组由以下来源合并: + +- 现有非专属公开分组,保持不变。 +- 用户来源默认权益,第一版复用现有 auth source defaults 设置体系,并新增 `feishu` 来源默认值。 +- 从旧 `user_allowed_groups` 迁移导入的超管个人额外授权。 +- 部门负责人分配的组。 +- 超管个人额外授权的组。 + +为了兼容当前 API Key 和路由检查逻辑,所有带来源的授权变化都要重新计算员工的有效专属分组,并同步到现有 `user_allowed_groups` 表。 + +### 授权来源与旧入口兼容 + +第一版不再让后台用户编辑弹窗直接写入一份没有来源的 `user_allowed_groups`。超管在用户编辑弹窗或员工详情抽屉里修改专属分组时,统一写入 `user_group_grants.source=super_admin_override`,再由组织权限服务重算 `user_allowed_groups`。 + +兼容规则: + +- 已存在的 `user_allowed_groups` 数据在迁移或首次重算时,按 `super_admin_override` 导入 `user_group_grants`。 +- 导入后,`user_allowed_groups` 只作为运行时有效权限缓存,不作为新的人工维护入口。 +- 部门负责人永远不能修改 `super_admin_override` 来源的授权。 +- 超管可以在同一个员工详情里看到部门负责人分配、个人额外授权、公开分组三类结果,但只有个人额外授权可由超管直接编辑。 + +### 部门分组池有效范围 + +第一版按员工主部门判断可分配分组,不做隐式继承: + +- 员工 `A` 的主部门是 `D`,部门负责人给 `A` 分配分组时,分组必须存在于 `D` 的部门分组池。 +- 上级部门负责人可以管理子部门员工,但不能因此自动使用上级部门的分组池给子部门员工授权。 +- 如果公司希望子部门使用上级部门的分组池,需要超管显式给子部门也配置相同分组。 +- 后续可以增加“部门分组池继承”开关,但不放进第一版。 + +## 登录入口开关与安全策略 + +### 开关模型 + +新增或使用以下开关: + +- `feishu_connect_enabled`:是否启用飞书登录。关闭后不显示飞书入口,后端飞书 OAuth 接口也拒绝请求。 +- `email_password_login_enabled`:是否启用邮箱密码登录。关闭后普通用户不能用邮箱和密码登录。 +- `admin_email_login_fallback_enabled`:邮箱密码登录关闭时,是否保留超管邮箱备用登录。默认建议开启,避免飞书配置异常时超管被锁在系统外。 +- `registration_enabled`:沿用现有开放注册开关,控制新用户是否可自行注册。 +- `feishu_connect_bypass_registration`:在飞书企业限制为 `internal_only` 时,是否允许本企业飞书用户绕过全局 `registration_enabled=false` 自动创建本地账号。 +- `feishu_departed_user_action`:飞书用户离职、禁用或被移出可用范围后的本地处理策略。第一版默认并固定为 `auto_disable`,即常规同步通过影响保护后自动禁用本地用户;高风险批量变更进入超管确认。 + +推荐的公司内配置: + +```text +启用飞书登录:开 +启用邮箱密码登录:关 +保留超管邮箱备用登录:开 +开放注册:关 +飞书企业限制:仅本企业 +允许本企业飞书用户绕过关闭注册自动创建账号:开 +``` + +### 登录页显示规则 + +- `email_password_login_enabled=true` 时,显示现有邮箱密码表单。 +- `email_password_login_enabled=false` 且 `admin_email_login_fallback_enabled=true` 时,不显示普通邮箱登录表单,只显示弱化的“管理员邮箱登录”入口。 +- `email_password_login_enabled=false` 且 `admin_email_login_fallback_enabled=false` 时,完全隐藏邮箱密码登录入口。 +- `feishu_connect_enabled=true` 时显示飞书登录入口。 +- 当只启用飞书登录时,飞书按钮作为主登录动作,不需要显示“其他方式登录”的分割线。 +- 注册入口只有在 `registration_enabled=true` 且 `email_password_login_enabled=true` 时才显示。 +- 忘记密码入口只有在邮箱密码登录可用,或当前处于管理员邮箱备用登录界面时才显示。 + +### 后端拒绝规则 + +- `/api/v1/auth/login` 必须检查 `email_password_login_enabled`。 +- 邮箱密码登录关闭且超管备用登录关闭时,`/api/v1/auth/login` 直接返回邮箱登录已关闭。 +- 邮箱密码登录关闭但超管备用登录开启时,后端只允许 `role=admin` 的用户通过邮箱密码登录;普通用户即使密码正确也拒绝。 +- `/api/v1/auth/register` 必须同时满足 `registration_enabled=true` 和 `email_password_login_enabled=true`,否则拒绝邮箱注册。 +- `/api/v1/auth/oauth/feishu/*` 必须检查 `feishu_connect_enabled`。 +- 飞书自动创建账号仅在 `feishu_connect_bypass_registration=true` 且企业限制策略为 `internal_only` 时允许绕过全局关闭注册。 +- 后端 public settings 需要暴露 `email_password_login_enabled`、`admin_email_login_fallback_enabled`、`feishu_oauth_enabled`,供登录页决定显示状态。 + +### 登录入口保存兜底 + +后台保存安全与认证设置时,必须禁止保存“所有登录入口都不可用”的组合。 + +有效登录入口定义: + +- 普通邮箱密码登录可用:`email_password_login_enabled=true`。 +- 飞书登录可用:`feishu_connect_enabled=true`,并且飞书 App ID、App Secret、回调地址、企业限制策略等必要配置通过基础校验。 +- 超管备用邮箱登录只算超管兜底,不算普通员工登录入口。 + +保存规则: + +- 如果 `email_password_login_enabled=false` 且飞书登录未启用或配置不完整,后端拒绝保存,并返回明确错误。 +- 如果普通员工只剩飞书登录,但飞书健康检查尚未通过,后台至少显示强警告;第一版推荐后端也拒绝保存,除非保留 `admin_email_login_fallback_enabled=true` 且操作人显式确认。 +- 如果 `admin_email_login_fallback_enabled=false` 且飞书登录未通过健康检查,禁止保存,避免超管被锁在系统外。 + +### 飞书启用前预检 + +飞书配置不要做成“填完凭证直接启用”。后台安全与认证页需要把“保存配置”和“正式启用飞书登录”拆开: + +- 保存 App ID、App Secret、回调地址和企业限制策略时,只落库配置,不立刻切换登录入口。 +- 启用前必须通过健康检查:凭证可用、回调地址匹配、企业限制策略可验证、能获取当前登录用户身份、能拉取部门和用户基础信息、实际授权 scope 覆盖已启用的同步能力。 +- 健康检查结果要按能力拆开显示:飞书登录、邮箱同步、组织同步、离职/禁用识别、部门负责人关系。不要只显示一个“成功/失败”。 +- 如果 App Secret 表单留空,应保留已配置密钥,不应误清空;只有显式点击“清除密钥”才删除。 +- 启用前展示影响预览:已绑定本地用户数、未绑定飞书用户数、预计自动创建用户数、可能被自动禁用用户数、`feishu` 来源默认权益是否已配置、当前仍可用的超管兜底登录入口。 + +第一版如果不做完整向导,也至少要有一个“检查配置”按钮和后端健康检查接口,避免管理员在不确定状态下打开飞书登录。 + +## 数据模型 + +### `feishu_departments` + +保存从飞书同步下来的部门信息。该表是只读镜像,不作为组织架构的维护入口。 + +- `id` +- `tenant_key`:飞书租户标识。 +- `department_id`:飞书部门 ID。 +- `parent_department_id`:父部门 ID。 +- `name`:部门名。 +- `path`:完整部门路径。 +- `status`:部门状态。 +- `synced_at`:最近同步时间。 +- 创建和更新时间。 + +唯一键:`(tenant_key, department_id)`。 + +### `feishu_user_profiles` + +保存本地用户和飞书用户、部门关系的映射。用户部门归属、直属上级和状态来自飞书同步,不在本系统手工编辑。 + +- `id` +- `user_id`:本地用户 ID。 +- `tenant_key` +- `open_id` +- `union_id` +- `user_id_in_tenant`:飞书企业内用户 ID。 +- `email` +- `name` +- `primary_department_id`:主部门 ID。 +- `department_ids`:用户所属部门 ID 数组,使用 JSON 保存。 +- `status`:飞书用户状态。 +- `manager_open_id`:飞书侧直属上级。 +- `synced_at` +- 创建和更新时间。 + +唯一键:`(tenant_key, open_id)`、`(user_id)`。 + +### `auth_identities` + +复用现有第三方身份表保存飞书登录绑定关系。第一版支持管理员或迁移脚本提前为已有用户写入飞书身份,用于首次飞书登录时精确匹配本地账号。 + +- `provider_type=feishu` +- `provider_key=+ 按飞书同步的部门维护可分配分组,并处理员工个人额外授权。 +
+| 部门 | +人数 | +负责人 | +可分配分组 | +操作 | +
|---|---|---|---|---|
|
+ {{ dept.name || dept.open_department_id }}
+ {{ dept.path || dept.open_department_id }}
+ |
+ {{ dept.employee_count }} | +{{ dept.manager_count }} | +
+
+
+ {{ group.name }}
+
+ 未配置
+
+ |
+ + + | +
| 暂无部门同步数据 | +||||
| 员工 | +主部门 | +部门授权 | +个人额外授权 | +最终分组 | +操作 | +
|---|---|---|---|---|---|
|
+ {{ user.name || user.local_username || user.open_id }}
+ {{ user.local_email || user.email || user.open_id }}
+ |
+
+ {{ user.primary_department_name || user.primary_open_department_id || '-' }}
+
+ 多部门,仅按主部门授权
+
+ |
+ {{ formatGroupIds(user.department_manager_group_ids) }} | +{{ formatGroupIds(user.super_admin_override_group_ids) }} | +{{ formatGroupIds(user.effective_group_ids) }} | ++ + | +
| 暂无员工同步数据 | +|||||
| 时间 | +状态 | +部门/员工/负责人 | +待创建/待禁用/缺绑定 | +复核 | +
|---|---|---|---|---|
| {{ formatDate(run.started_at) }} | ++ + {{ run.status }} + + | +{{ run.departments_synced }} / {{ run.users_synced }} / {{ run.managers_synced }} | +{{ run.users_to_create }} / {{ run.users_to_disable }} / {{ run.bindings_missing }} | +{{ run.review_required ? '需要' : '否' }} | +
| 暂无同步记录 | +||||
+ {{ + localText( + "控制普通用户是否可以在登录页使用邮箱和密码登录。", + "Controls whether regular users can sign in with email and password.", + ) + }} +
++ {{ + localText( + "关闭普通邮箱登录时,建议保留超管邮箱入口,避免第三方登录异常时无法进入后台。", + "When regular email login is disabled, keep an admin fallback so administrators are not locked out.", + ) + }} +
++ {{ + localText( + "配置飞书企业自建应用登录,并为后续组织架构同步、部门授权和离职禁用提供身份来源。", + "Configure Feishu app login as the identity source for org sync, department grants, and departure handling.", + ) + }} +
++ {{ + localText( + "开启后登录页显示飞书入口,用户通过飞书 OAuth 完成登录或绑定。", + "Shows Feishu on the login page and lets users sign in or bind through Feishu OAuth.", + ) + }} +
++ {{ localText("飞书开放平台企业自建应用的 App ID。", "The App ID of the Feishu internal app.") }} +
++ {{ + localText( + "需要与飞书开发者后台的重定向 URL 完全一致。", + "Must exactly match the redirect URL configured in the Feishu developer console.", + ) + }} +
++ {{ + localText( + "公司内用建议保持“仅本企业”。如果放开限制,组织同步和离职禁用会自动关闭。", + "For internal use, keep this restricted to your tenant. Org sync and departure handling are disabled when unrestricted.", + ) + }} +
++ {{ + localText( + "留空时只依赖飞书授权来源;填入后会强校验 tenant_key。", + "Leave empty to rely on Feishu authorization source; fill it to strictly check tenant_key.", + ) + }} +
++ {{ + localText( + "本企业飞书用户可在关闭注册时创建或绑定账号。", + "Internal Feishu users may create or bind accounts even when registration is disabled.", + ) + }} +
++ {{ localText("依赖飞书邮箱权限和企业邮箱字段。", "Depends on Feishu email scope and tenant email availability.") }} +
++ {{ localText("登录时更新本地展示名。", "Updates local display name on login.") }} +
++ {{ localText("多部门员工按主部门参与授权。", "Multi-department users are authorized by primary department.") }} +
++ {{ + localText( + "组织架构从飞书同步,本地只保存镜像和授权关系,不手工维护部门树。", + "The org chart is synced from Feishu. This app stores only the mirror and permission grants.", + ) + }} +
++ {{ localText("检查登录、邮箱、组织同步和负责人关系需要的配置状态。", "Checks login, email, org sync, and manager relation readiness.") }} +
+{{ item.message }}
++ {{ warning.message }} +
+