From f8c80bf0383cc4814bb4afcc9f043f56e868cbeb Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 11 Jun 2026 18:55:27 +0800 Subject: [PATCH] fix(auth): apply promo codes to oauth signups --- .../internal/handler/auth_dingtalk_oauth.go | 12 +- backend/internal/handler/auth_email_oauth.go | 11 +- .../internal/handler/auth_email_oauth_test.go | 43 +++ .../internal/handler/auth_linuxdo_oauth.go | 22 +- .../handler/auth_oauth_pending_flow.go | 61 +++- .../handler/auth_oauth_pending_flow_test.go | 298 +++++++++++++++++- backend/internal/handler/auth_oidc_oauth.go | 20 +- backend/internal/handler/auth_wechat_oauth.go | 12 +- .../internal/service/auth_email_oauth_auto.go | 17 +- backend/internal/service/auth_service.go | 39 +++ 10 files changed, 516 insertions(+), 19 deletions(-) diff --git a/backend/internal/handler/auth_dingtalk_oauth.go b/backend/internal/handler/auth_dingtalk_oauth.go index a5b27dc61c..25c04ede79 100644 --- a/backend/internal/handler/auth_dingtalk_oauth.go +++ b/backend/internal/handler/auth_dingtalk_oauth.go @@ -143,6 +143,7 @@ func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) { intent := normalizeOAuthIntent(c.Query("intent")) setDingTalkCookie(c, dingTalkOAuthIntentCookieName, encodeCookieValue(intent), dingTalkOAuthCookieMaxAgeSec, secureCookie) + captureOAuthPromoCode(c, secureCookie) setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie) clearOAuthPendingSessionCookie(c, secureCookie) @@ -317,6 +318,7 @@ func (h *AuthHandler) DingTalkOAuthCallback(c *gin.Context) { clearDingTalkCookie(c, dingTalkOAuthStateCookieName, secureCookie) clearDingTalkCookie(c, dingTalkOAuthRedirectCookie, secureCookie) clearDingTalkCookie(c, dingTalkOAuthIntentCookieName, secureCookie) + clearOAuthPromoCodeCookie(c, secureCookie) }() expectedState, err := readCookieDecoded(c, dingTalkOAuthStateCookieName) @@ -779,7 +781,15 @@ func (h *AuthHandler) CompleteDingTalkOAuthRegistration(c *gin.Context) { response.ErrorFrom(c, err) return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "dingtalk") + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode( + c.Request.Context(), + email, + username, + req.InvitationCode, + req.AffCode, + pendingOAuthPromoCode(session), + "dingtalk", + ) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/auth_email_oauth.go b/backend/internal/handler/auth_email_oauth.go index d43acef635..09567b1b3d 100644 --- a/backend/internal/handler/auth_email_oauth.go +++ b/backend/internal/handler/auth_email_oauth.go @@ -78,6 +78,7 @@ func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) { emailOAuthSetCookie(c, emailOAuthStateCookieName, encodeCookieValue(state), secureCookie) emailOAuthSetCookie(c, emailOAuthRedirectCookie, encodeCookieValue(redirectTo), secureCookie) emailOAuthSetCookie(c, emailOAuthProviderCookie, encodeCookieValue(provider), secureCookie) + captureOAuthPromoCode(c, secureCookie) if affCode := strings.TrimSpace(firstNonEmpty(c.Query("aff_code"), c.Query("aff"))); affCode != "" { emailOAuthSetCookie(c, emailOAuthAffiliateCookie, encodeCookieValue(affCode), secureCookie) } else { @@ -119,6 +120,7 @@ func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) { emailOAuthClearCookie(c, emailOAuthRedirectCookie, secureCookie) emailOAuthClearCookie(c, emailOAuthProviderCookie, secureCookie) emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie) + clearOAuthPromoCodeCookie(c, secureCookie) }() expectedState, err := readCookieDecoded(c, emailOAuthStateCookieName) if err != nil || expectedState == "" || expectedState != state { @@ -181,7 +183,13 @@ func (h *AuthHandler) emailOAuthCallbackWithProfile( return } - tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(c.Request.Context(), input, "", affiliateCode) + tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithSignupCodes( + c.Request.Context(), + input, + "", + affiliateCode, + readOAuthPromoCode(c), + ) if err != nil { if errors.Is(err, service.ErrOAuthInvitationRequired) { if pendingErr := h.createEmailOAuthRegistrationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil { @@ -427,6 +435,7 @@ func (h *AuthHandler) completeEmailOAuthRegistration(c *gin.Context, provider st response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err)) return } + h.authService.ApplyOAuthSignupPromoCode(c.Request.Context(), user.ID, pendingOAuthPromoCode(session)) h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) clearCookies() writeOAuthTokenPairResponse(c, tokenPair) diff --git a/backend/internal/handler/auth_email_oauth_test.go b/backend/internal/handler/auth_email_oauth_test.go index ecf71c5a24..493b509989 100644 --- a/backend/internal/handler/auth_email_oauth_test.go +++ b/backend/internal/handler/auth_email_oauth_test.go @@ -184,6 +184,49 @@ func TestEmailOAuthCallbackCreatesPasswordRegistrationSessionForNewEmail(t *test require.Equal(t, "aff-user@example.com", completion["resolved_email"]) } +func TestEmailOAuthStartPreservesPromoCodeInPendingSession(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + settingValues: map[string]string{ + service.SettingKeyGitHubOAuthEnabled: "true", + service.SettingKeyGitHubOAuthClientID: "github-client", + service.SettingKeyGitHubOAuthClientSecret: "github-secret", + service.SettingKeyGitHubOAuthRedirectURL: "https://app.example/api/v1/auth/oauth/github/callback", + }, + }) + ctx := context.Background() + + startRecorder := httptest.NewRecorder() + startCtx, _ := gin.CreateTestContext(startRecorder) + startCtx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start?promo_code=WELCOME2024", nil) + + handler.GitHubOAuthStart(startCtx) + + require.Equal(t, http.StatusFound, startRecorder.Code) + promoCookie := findCookie(startRecorder.Result().Cookies(), oauthPromoCodeCookieName) + require.NotNil(t, promoCookie) + require.Equal(t, "WELCOME2024", decodeCookieValueForTest(t, promoCookie.Value)) + + callbackRecorder := httptest.NewRecorder() + callbackCtx, _ := gin.CreateTestContext(callbackRecorder) + callbackReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback", nil) + callbackReq.AddCookie(promoCookie) + callbackCtx.Request = callbackReq + + handler.emailOAuthCallbackWithProfile(callbackCtx, "github", config.EmailOAuthProviderConfig{ + FrontendRedirectURL: "/auth/oauth/callback", + }, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{ + Subject: "github-promo-user", + Email: "promo-user@example.com", + EmailVerified: true, + Username: "promo-user", + }) + + require.Equal(t, http.StatusFound, callbackRecorder.Code) + session, err := client.PendingAuthSession.Query().Only(ctx) + require.NoError(t, err) + require.Equal(t, "WELCOME2024", pendingOAuthPromoCode(session)) +} + func TestCompleteEmailOAuthRegistrationUsesAffiliateCodeFromPendingSession(t *testing.T) { affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF456": 2002}) handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index 540f1da2d9..fb4a30ece1 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -110,6 +110,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) { setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie) intent := normalizeOAuthIntent(c.Query("intent")) setCookie(c, linuxDoOAuthIntentCookieName, encodeCookieValue(intent), linuxDoOAuthCookieMaxAgeSec, secureCookie) + captureOAuthPromoCode(c, secureCookie) setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie) clearOAuthPendingSessionCookie(c, secureCookie) if intent == oauthIntentBindCurrentUser { @@ -182,6 +183,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) { clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie) clearCookie(c, linuxDoOAuthIntentCookieName, secureCookie) clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie) + clearOAuthPromoCodeCookie(c, secureCookie) }() expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName) @@ -329,7 +331,15 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) { redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "", "", "linuxdo") + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode( + c.Request.Context(), + email, + username, + "", + "", + readOAuthPromoCode(c), + "linuxdo", + ) if err == nil { if err := applyPendingOAuthBinding( c.Request.Context(), @@ -561,7 +571,15 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) { response.ErrorFrom(c, err) return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "linuxdo") + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode( + c.Request.Context(), + email, + username, + req.InvitationCode, + req.AffCode, + pendingOAuthPromoCode(session), + "linuxdo", + ) if 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 550363fd9e..79e9f0a80b 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -32,10 +32,12 @@ const ( oauthPendingBrowserCookieName = "oauth_pending_browser_session" oauthPendingSessionCookiePath = "/api/v1/auth/oauth" oauthPendingSessionCookieName = "oauth_pending_session" + oauthPromoCodeCookieName = "oauth_promo_code" oauthPendingCookieMaxAgeSec = 10 * 60 oauthPendingChoiceStep = "choose_account_action_required" oauthCompletionResponseKey = "completion_response" + oauthPromoCodeStateKey = "promo_code" ) var pendingOAuthCreateAccountPreCommitHook func(context.Context, *dbent.PendingAuthSession) error @@ -161,6 +163,53 @@ func readOAuthPendingSessionCookie(c *gin.Context) (string, error) { return readCookieDecoded(c, oauthPendingSessionCookieName) } +func captureOAuthPromoCode(c *gin.Context, secure bool) { + promoCode := strings.TrimSpace(c.Query("promo_code")) + if promoCode == "" { + clearOAuthPromoCodeCookie(c, secure) + return + } + http.SetCookie(c.Writer, &http.Cookie{ + Name: oauthPromoCodeCookieName, + Value: encodeCookieValue(promoCode), + Path: oauthPendingBrowserCookiePath, + MaxAge: oauthPendingCookieMaxAgeSec, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func clearOAuthPromoCodeCookie(c *gin.Context, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: oauthPromoCodeCookieName, + Value: "", + Path: oauthPendingBrowserCookiePath, + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func readOAuthPromoCode(c *gin.Context) string { + if c == nil { + return "" + } + promoCode, err := readCookieDecoded(c, oauthPromoCodeCookieName) + if err != nil { + return "" + } + return strings.TrimSpace(promoCode) +} + +func pendingOAuthPromoCode(session *dbent.PendingAuthSession) string { + if session == nil { + return "" + } + return pendingSessionStringValue(session.LocalFlowState, oauthPromoCodeStateKey) +} + func redirectToFrontendCallback(c *gin.Context, frontendCallback string) { u, err := url.Parse(frontendCallback) if err != nil { @@ -183,6 +232,13 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen return err } + localFlowState := map[string]any{ + oauthCompletionResponseKey: payload.CompletionResponse, + } + if promoCode := readOAuthPromoCode(c); promoCode != "" { + localFlowState[oauthPromoCodeStateKey] = promoCode + } + session, err := svc.CreatePendingSession(c.Request.Context(), service.CreatePendingAuthSessionInput{ Intent: strings.TrimSpace(payload.Intent), Identity: payload.Identity, @@ -191,9 +247,7 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen RedirectTo: strings.TrimSpace(payload.RedirectTo), BrowserSessionKey: strings.TrimSpace(payload.BrowserSessionKey), UpstreamIdentityClaims: payload.UpstreamIdentityClaims, - LocalFlowState: map[string]any{ - oauthCompletionResponseKey: payload.CompletionResponse, - }, + LocalFlowState: localFlowState, }) if err != nil { slog.Error("pending auth session create failed", @@ -1820,6 +1874,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string) return } + h.authService.ApplyOAuthSignupPromoCode(c.Request.Context(), user.ID, pendingOAuthPromoCode(session)) h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) // createPendingOAuthAccount = 注册新账户,需要把钉钉昵称同步到 users.username 作为初始值 h.maybeSyncDingTalkAfterRegistration(c.Request.Context(), session, user.ID) diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 2f8f4e5802..635a56132f 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -8,6 +8,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -1049,6 +1050,201 @@ func TestCreateOIDCOAuthAccountCreatesUserBindsIdentityAndConsumesSession(t *tes require.NotNil(t, storedSession.ConsumedAt) } +func TestCreateOIDCOAuthAccountAppliesPromoCodeFromPendingSession(t *testing.T) { + promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25) + emailCache := &oauthPendingFlowEmailCacheStub{ + verificationCodes: map[string]*service.VerificationCodeData{ + "promo@example.com": { + Code: "246810", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(15 * time.Minute), + }, + }, + } + handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + emailVerifyEnabled: true, + emailCache: emailCache, + promoRepo: promoRepo, + settingValues: map[string]string{ + service.SettingKeyPromoCodeEnabled: "true", + }, + }) + ctx := context.Background() + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("promo-create-account-session-token"). + SetIntent(oauthIntentLogin). + SetProviderType("oidc"). + SetProviderKey("https://issuer.example"). + SetProviderSubject("oidc-promo-123"). + SetBrowserSessionKey("promo-create-account-browser-key"). + SetUpstreamIdentityClaims(map[string]any{"username": "promo_user"}). + SetLocalFlowState(map[string]any{oauthPromoCodeStateKey: "WELCOME2024"}). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + body := bytes.NewBufferString(`{"email":"promo@example.com","verify_code":"246810","password":"secret-123"}`) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(session.BrowserSessionKey)}) + ginCtx.Request = req + + handler.CreateOIDCOAuthAccount(ginCtx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, []string{"WELCOME2024"}, promoRepo.applyCalls) + createdUser, err := client.User.Query().Where(dbuser.EmailEQ("promo@example.com")).Only(ctx) + require.NoError(t, err) + require.Equal(t, 25.0, createdUser.Balance) + require.Len(t, promoRepo.usages, 1) + require.Equal(t, createdUser.ID, promoRepo.usages[0].UserID) +} + +func TestCreateOIDCOAuthAccountWithoutPromoCodeDoesNotApplyPromo(t *testing.T) { + promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25) + emailCache := &oauthPendingFlowEmailCacheStub{ + verificationCodes: map[string]*service.VerificationCodeData{ + "no-promo@example.com": { + Code: "246810", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(15 * time.Minute), + }, + }, + } + handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + emailVerifyEnabled: true, + emailCache: emailCache, + promoRepo: promoRepo, + settingValues: map[string]string{ + service.SettingKeyPromoCodeEnabled: "true", + }, + }) + ctx := context.Background() + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("no-promo-create-account-session-token"). + SetIntent(oauthIntentLogin). + SetProviderType("oidc"). + SetProviderKey("https://issuer.example"). + SetProviderSubject("oidc-no-promo-123"). + SetBrowserSessionKey("no-promo-create-account-browser-key"). + SetUpstreamIdentityClaims(map[string]any{"username": "no_promo_user"}). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + body := bytes.NewBufferString(`{"email":"no-promo@example.com","verify_code":"246810","password":"secret-123"}`) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(session.BrowserSessionKey)}) + ginCtx.Request = req + + handler.CreateOIDCOAuthAccount(ginCtx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Empty(t, promoRepo.applyCalls) + createdUser, err := client.User.Query().Where(dbuser.EmailEQ("no-promo@example.com")).Only(ctx) + require.NoError(t, err) + require.Zero(t, createdUser.Balance) +} + +func TestCreateOIDCOAuthAccountDoesNotApplyPromoWhenDisabled(t *testing.T) { + promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25) + emailCache := &oauthPendingFlowEmailCacheStub{ + verificationCodes: map[string]*service.VerificationCodeData{ + "promo-disabled@example.com": { + Code: "246810", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(15 * time.Minute), + }, + }, + } + handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + emailVerifyEnabled: true, + emailCache: emailCache, + promoRepo: promoRepo, + settingValues: map[string]string{ + service.SettingKeyPromoCodeEnabled: "false", + }, + }) + ctx := context.Background() + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("promo-disabled-session-token"). + SetIntent(oauthIntentLogin). + SetProviderType("oidc"). + SetProviderKey("https://issuer.example"). + SetProviderSubject("oidc-promo-disabled-123"). + SetBrowserSessionKey("promo-disabled-browser-key"). + SetUpstreamIdentityClaims(map[string]any{"username": "promo_disabled_user"}). + SetLocalFlowState(map[string]any{oauthPromoCodeStateKey: "WELCOME2024"}). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + body := bytes.NewBufferString(`{"email":"promo-disabled@example.com","verify_code":"246810","password":"secret-123"}`) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(session.BrowserSessionKey)}) + ginCtx.Request = req + + handler.CreateOIDCOAuthAccount(ginCtx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Empty(t, promoRepo.applyCalls) + createdUser, err := client.User.Query().Where(dbuser.EmailEQ("promo-disabled@example.com")).Only(ctx) + require.NoError(t, err) + require.Zero(t, createdUser.Balance) +} + +func TestOAuthExistingUserLoginDoesNotApplyPromoCode(t *testing.T) { + promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25) + handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + promoRepo: promoRepo, + settingValues: map[string]string{ + service.SettingKeyPromoCodeEnabled: "true", + }, + }) + ctx := context.Background() + + existingUser, err := client.User.Create(). + SetEmail("existing-promo@example.com"). + SetUsername("existing"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + SetBalance(7). + Save(ctx) + require.NoError(t, err) + + _, loggedInUser, err := handler.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode( + ctx, + existingUser.Email, + existingUser.Username, + "", + "", + "WELCOME2024", + "oidc", + ) + + require.NoError(t, err) + require.Equal(t, existingUser.ID, loggedInUser.ID) + require.Empty(t, promoRepo.applyCalls) + reloadedUser, err := client.User.Get(ctx, existingUser.ID) + require.NoError(t, err) + require.Equal(t, 7.0, reloadedUser.Balance) +} + func TestCreateOIDCOAuthAccountExistingEmailReturnsChoicePendingSessionState(t *testing.T) { handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790") ctx := context.Background() @@ -2120,6 +2316,7 @@ type oauthPendingFlowTestHandlerOptions struct { emailVerifyEnabled bool emailCache service.EmailCache settingValues map[string]string + promoRepo service.PromoCodeRepository defaultSubAssigner service.DefaultSubscriptionAssigner affiliateService *service.AffiliateService affiliateFactory func(*dbent.Client, *service.SettingService) *service.AffiliateService @@ -2212,6 +2409,10 @@ CREATE TABLE IF NOT EXISTS user_affiliates ( options: options.userRepoOptions, } redeemRepo := &oauthPendingFlowRedeemCodeRepo{client: client} + var promoService *service.PromoService + if options.promoRepo != nil { + promoService = service.NewPromoService(options.promoRepo, userRepo, nil, client, nil) + } var emailService *service.EmailService if options.emailCache != nil { emailService = service.NewEmailService(&oauthPendingFlowSettingRepoStub{ @@ -2230,7 +2431,7 @@ CREATE TABLE IF NOT EXISTS user_affiliates ( emailService, nil, nil, - nil, + promoService, options.defaultSubAssigner, affiliateService, nil, @@ -2250,10 +2451,11 @@ CREATE TABLE IF NOT EXISTS user_affiliates ( } return &AuthHandler{ - authService: authSvc, - userService: userSvc, - settingSvc: settingSvc, - totpService: totpSvc, + authService: authSvc, + userService: userSvc, + settingSvc: settingSvc, + promoService: promoService, + totpService: totpSvc, }, client } @@ -2272,6 +2474,84 @@ type oauthPendingFlowSettingRepoStub struct { values map[string]string } +type oauthPendingFlowPromoRepoStub struct { + promo *service.PromoCode + applyCalls []string + usages []service.PromoCodeUsage +} + +func newOAuthPendingFlowPromoRepoStub(code string, bonusAmount float64) *oauthPendingFlowPromoRepoStub { + return &oauthPendingFlowPromoRepoStub{ + promo: &service.PromoCode{ + ID: 1, + Code: code, + BonusAmount: bonusAmount, + Status: service.PromoCodeStatusActive, + }, + } +} + +func (r *oauthPendingFlowPromoRepoStub) Create(context.Context, *service.PromoCode) error { + panic("unexpected Create call") +} + +func (r *oauthPendingFlowPromoRepoStub) GetByID(context.Context, int64) (*service.PromoCode, error) { + panic("unexpected GetByID call") +} + +func (r *oauthPendingFlowPromoRepoStub) GetByCode(_ context.Context, code string) (*service.PromoCode, error) { + if r.promo == nil || !strings.EqualFold(strings.TrimSpace(code), r.promo.Code) { + return nil, service.ErrPromoCodeNotFound + } + clone := *r.promo + return &clone, nil +} + +func (r *oauthPendingFlowPromoRepoStub) GetByCodeForUpdate(ctx context.Context, code string) (*service.PromoCode, error) { + promoCode, err := r.GetByCode(ctx, code) + if err != nil { + return nil, err + } + r.applyCalls = append(r.applyCalls, strings.TrimSpace(code)) + return promoCode, nil +} + +func (r *oauthPendingFlowPromoRepoStub) Update(context.Context, *service.PromoCode) error { + panic("unexpected Update call") +} + +func (r *oauthPendingFlowPromoRepoStub) Delete(context.Context, int64) error { + panic("unexpected Delete call") +} + +func (r *oauthPendingFlowPromoRepoStub) List(context.Context, pagination.PaginationParams) ([]service.PromoCode, *pagination.PaginationResult, error) { + panic("unexpected List call") +} + +func (r *oauthPendingFlowPromoRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, string, string) ([]service.PromoCode, *pagination.PaginationResult, error) { + panic("unexpected ListWithFilters call") +} + +func (r *oauthPendingFlowPromoRepoStub) CreateUsage(_ context.Context, usage *service.PromoCodeUsage) error { + r.usages = append(r.usages, *usage) + return nil +} + +func (r *oauthPendingFlowPromoRepoStub) GetUsageByPromoCodeAndUser(context.Context, int64, int64) (*service.PromoCodeUsage, error) { + return nil, nil +} + +func (r *oauthPendingFlowPromoRepoStub) ListUsagesByPromoCode(context.Context, int64, pagination.PaginationParams) ([]service.PromoCodeUsage, *pagination.PaginationResult, error) { + panic("unexpected ListUsagesByPromoCode call") +} + +func (r *oauthPendingFlowPromoRepoStub) IncrementUsedCount(context.Context, int64) error { + if r.promo != nil { + r.promo.UsedCount++ + } + return nil +} + func (s *oauthPendingFlowSettingRepoStub) Get(context.Context, string) (*service.Setting, error) { return nil, service.ErrSettingNotFound } @@ -2813,8 +3093,12 @@ func (r *oauthPendingFlowUserRepo) ListWithFilters(context.Context, pagination.P panic("unexpected ListWithFilters call") } -func (r *oauthPendingFlowUserRepo) UpdateBalance(context.Context, int64, float64) error { - panic("unexpected UpdateBalance call") +func (r *oauthPendingFlowUserRepo) UpdateBalance(ctx context.Context, userID int64, amount float64) error { + client := r.client + if tx := dbent.TxFromContext(ctx); tx != nil { + client = tx.Client() + } + return client.User.UpdateOneID(userID).AddBalance(amount).Exec(ctx) } func (r *oauthPendingFlowUserRepo) DeductBalance(context.Context, int64, float64) error { diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 76eb9498ea..904bdc2250 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -143,6 +143,7 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) { oidcSetCookie(c, oidcOAuthRedirectCookie, encodeCookieValue(redirectTo), oidcOAuthCookieMaxAgeSec, secureCookie) intent := normalizeOAuthIntent(c.Query("intent")) oidcSetCookie(c, oidcOAuthIntentCookieName, encodeCookieValue(intent), oidcOAuthCookieMaxAgeSec, secureCookie) + captureOAuthPromoCode(c, secureCookie) setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie) clearOAuthPendingSessionCookie(c, secureCookie) if intent == oauthIntentBindCurrentUser { @@ -226,6 +227,7 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) { oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie) oidcClearCookie(c, oidcOAuthIntentCookieName, secureCookie) oidcClearCookie(c, oidcOAuthBindUserCookieName, secureCookie) + clearOAuthPromoCodeCookie(c, secureCookie) }() expectedState, err := readCookieDecoded(c, oidcOAuthStateCookieName) @@ -685,7 +687,15 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) { response.ErrorFrom(c, err) return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "oidc") + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode( + c.Request.Context(), + email, + username, + req.InvitationCode, + req.AffCode, + pendingOAuthPromoCode(session), + "oidc", + ) if err != nil { response.ErrorFrom(c, err) return @@ -1258,7 +1268,13 @@ func (h *AuthHandler) tryOIDCVerifiedEmailFastPath( AvatarURL: pendingSessionStringValue(upstreamClaims, "suggested_avatar_url"), UpstreamMetadata: upstreamMetadata, } - tokenPair, _, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(ctx, input, "", "") + tokenPair, _, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithSignupCodes( + ctx, + input, + "", + "", + readOAuthPromoCode(c), + ) if err != nil { log.Printf("[OIDC OAuth] verified-email fast path skipped: reason=%s", infraerrors.Reason(err)) return false diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index 2199c5bd28..3e8a21dbab 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -125,6 +125,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) { wechatSetCookie(c, wechatOAuthRedirectCookieName, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie) wechatSetCookie(c, wechatOAuthIntentCookieName, encodeCookieValue(intent), wechatOAuthCookieMaxAgeSec, secureCookie) wechatSetCookie(c, wechatOAuthModeCookieName, encodeCookieValue(cfg.mode), wechatOAuthCookieMaxAgeSec, secureCookie) + captureOAuthPromoCode(c, secureCookie) setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie) clearOAuthPendingSessionCookie(c, secureCookie) if intent == oauthIntentBindCurrentUser { @@ -171,6 +172,7 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) { wechatClearCookie(c, wechatOAuthIntentCookieName, secureCookie) wechatClearCookie(c, wechatOAuthModeCookieName, secureCookie) wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie) + clearOAuthPromoCodeCookie(c, secureCookie) }() expectedState, err := readCookieDecoded(c, wechatOAuthStateCookieName) @@ -548,7 +550,15 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) { return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "wechat") + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode( + c.Request.Context(), + email, + username, + req.InvitationCode, + req.AffCode, + pendingOAuthPromoCode(session), + "wechat", + ) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/service/auth_email_oauth_auto.go b/backend/internal/service/auth_email_oauth_auto.go index f86df14c3d..2f2eea60a9 100644 --- a/backend/internal/service/auth_email_oauth_auto.go +++ b/backend/internal/service/auth_email_oauth_auto.go @@ -26,7 +26,7 @@ type EmailOAuthIdentityInput struct { } func (s *AuthService) LoginOrRegisterVerifiedEmailOAuth(ctx context.Context, input EmailOAuthIdentityInput) (*TokenPair, *User, error) { - return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, "", "") + return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, "", "", "") } func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithInvitation( @@ -35,7 +35,17 @@ func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithInvitation( invitationCode string, affiliateCode string, ) (*TokenPair, *User, error) { - return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode) + return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode, "") +} + +func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithSignupCodes( + ctx context.Context, + input EmailOAuthIdentityInput, + invitationCode string, + affiliateCode string, + promoCode string, +) (*TokenPair, *User, error) { + return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode, promoCode) } func (s *AuthService) loginOrRegisterVerifiedEmailOAuth( @@ -43,6 +53,7 @@ func (s *AuthService) loginOrRegisterVerifiedEmailOAuth( input EmailOAuthIdentityInput, invitationCode string, affiliateCode string, + promoCode string, ) (*TokenPair, *User, error) { if s == nil || s.userRepo == nil || s.entClient == nil { return nil, nil, ErrServiceUnavailable @@ -131,6 +142,8 @@ func (s *AuthService) loginOrRegisterVerifiedEmailOAuth( if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, user.ID, providerType); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to apply %s first bind defaults: %v", providerType, err) } + } else { + user = s.applyOAuthSignupPromoCode(ctx, user, promoCode) } s.RecordSuccessfulLogin(ctx, user.ID) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index e4fa876c46..938e5703ec 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -587,6 +587,17 @@ func (s *AuthService) canBypassRegistrationDisabledForOAuth(ctx context.Context, // affiliateCode 用于邀请返利绑定,仅在新用户注册时使用。 // signupSource 标识来源渠道("dingtalk"/"linuxdo"/"wechat"/"oidc" 等),仅用于豁免检查。 func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode, signupSource string) (*TokenPair, *User, error) { + return s.loginOrRegisterOAuthWithTokenPair(ctx, email, username, invitationCode, affiliateCode, "", signupSource) +} + +// LoginOrRegisterOAuthWithTokenPairAndPromoCode behaves like +// LoginOrRegisterOAuthWithTokenPair and applies promoCode only when a new user +// is created. +func (s *AuthService) LoginOrRegisterOAuthWithTokenPairAndPromoCode(ctx context.Context, email, username, invitationCode, affiliateCode, promoCode, signupSource string) (*TokenPair, *User, error) { + return s.loginOrRegisterOAuthWithTokenPair(ctx, email, username, invitationCode, affiliateCode, promoCode, signupSource) +} + +func (s *AuthService) loginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode, promoCode, signupSource string) (*TokenPair, *User, error) { // 检查 refreshTokenCache 是否可用 if s.refreshTokenCache == nil { return nil, nil, errors.New("refresh token cache not configured") @@ -606,6 +617,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema } user, err := s.userRepo.GetByEmail(ctx, email) + created := false if err != nil { if errors.Is(err, ErrUserNotFound) { // OAuth 首次登录视为注册 @@ -691,6 +703,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema return nil, nil, ErrServiceUnavailable } user = newUser + created = true s.postAuthUserBootstrap(ctx, user, signupSource, false) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") // snapshot user × platform quota(fail-open) @@ -711,6 +724,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema } } else { user = newUser + created = true s.postAuthUserBootstrap(ctx, user, signupSource, false) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") // snapshot user × platform quota(fail-open) @@ -739,6 +753,9 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after oauth login: %v", err) } } + if created { + user = s.applyOAuthSignupPromoCode(ctx, user, promoCode) + } tokenPair, err := s.GenerateTokenPair(ctx, user, "") if err != nil { return nil, nil, fmt.Errorf("generate token pair: %w", err) @@ -746,6 +763,28 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema return tokenPair, user, nil } +func (s *AuthService) ApplyOAuthSignupPromoCode(ctx context.Context, userID int64, promoCode string) { + if userID <= 0 { + return + } + s.applyOAuthSignupPromoCode(ctx, &User{ID: userID}, promoCode) +} + +func (s *AuthService) applyOAuthSignupPromoCode(ctx context.Context, user *User, promoCode string) *User { + promoCode = strings.TrimSpace(promoCode) + if user == nil || user.ID <= 0 || promoCode == "" || s.promoService == nil || s.settingService == nil || !s.settingService.IsPromoCodeEnabled(ctx) { + return user + } + if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to apply promo code for oauth user %d: %v", user.ID, err) + return user + } + if updatedUser, err := s.userRepo.GetByID(ctx, user.ID); err == nil { + return updatedUser + } + return user +} + func (s *AuthService) assignSubscriptions(ctx context.Context, userID int64, items []DefaultSubscriptionSetting, notes string) { if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 { return