diff --git a/.gitignore b/.gitignore index bf7ee064..a61f406d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ docs/claude-relay-service/ +.codex # =================== # Go 后端 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 40bf1c69..320dbd6b 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -186,6 +186,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { DefaultConcurrency: settings.DefaultConcurrency, DefaultBalance: settings.DefaultBalance, AffiliateRebateRate: settings.AffiliateRebateRate, + AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours, + AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap, DefaultUserRPMLimit: settings.DefaultUserRPMLimit, DefaultSubscriptions: defaultSubscriptions, EnableModelFallback: settings.EnableModelFallback, @@ -342,6 +345,9 @@ type UpdateSettingsRequest struct { DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"` + AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"` + AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"` + AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"` DefaultUserRPMLimit int `json:"default_user_rpm_limit"` DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"` AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"` @@ -485,6 +491,33 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if affiliateRebateRate > service.AffiliateRebateRateMax { affiliateRebateRate = service.AffiliateRebateRateMax } + affiliateRebateFreezeHours := previousSettings.AffiliateRebateFreezeHours + if req.AffiliateRebateFreezeHours != nil { + affiliateRebateFreezeHours = *req.AffiliateRebateFreezeHours + } + if affiliateRebateFreezeHours < 0 { + affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursDefault + } + if affiliateRebateFreezeHours > service.AffiliateRebateFreezeHoursMax { + affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursMax + } + affiliateRebateDurationDays := previousSettings.AffiliateRebateDurationDays + if req.AffiliateRebateDurationDays != nil { + affiliateRebateDurationDays = *req.AffiliateRebateDurationDays + } + if affiliateRebateDurationDays < 0 { + affiliateRebateDurationDays = service.AffiliateRebateDurationDaysDefault + } + if affiliateRebateDurationDays > service.AffiliateRebateDurationDaysMax { + affiliateRebateDurationDays = service.AffiliateRebateDurationDaysMax + } + affiliateRebatePerInviteeCap := previousSettings.AffiliateRebatePerInviteeCap + if req.AffiliateRebatePerInviteeCap != nil { + affiliateRebatePerInviteeCap = *req.AffiliateRebatePerInviteeCap + } + if affiliateRebatePerInviteeCap < 0 { + affiliateRebatePerInviteeCap = service.AffiliateRebatePerInviteeCapDefault + } // 通用表格配置:兼容旧客户端未传字段时保留当前值。 if req.TableDefaultPageSize <= 0 { req.TableDefaultPageSize = previousSettings.TableDefaultPageSize @@ -1137,6 +1170,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { DefaultConcurrency: req.DefaultConcurrency, DefaultBalance: req.DefaultBalance, AffiliateRebateRate: affiliateRebateRate, + AffiliateRebateFreezeHours: affiliateRebateFreezeHours, + AffiliateRebateDurationDays: affiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap, DefaultUserRPMLimit: req.DefaultUserRPMLimit, DefaultSubscriptions: defaultSubscriptions, EnableModelFallback: req.EnableModelFallback, @@ -1458,6 +1494,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultBalance: updatedSettings.DefaultBalance, AffiliateRebateRate: updatedSettings.AffiliateRebateRate, + AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours, + AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap, DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit, DefaultSubscriptions: updatedDefaultSubscriptions, EnableModelFallback: updatedSettings.EnableModelFallback, @@ -1768,6 +1807,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.AffiliateRebateRate != after.AffiliateRebateRate { changed = append(changed, "affiliate_rebate_rate") } + if before.AffiliateRebateFreezeHours != after.AffiliateRebateFreezeHours { + changed = append(changed, "affiliate_rebate_freeze_hours") + } + if before.AffiliateRebateDurationDays != after.AffiliateRebateDurationDays { + changed = append(changed, "affiliate_rebate_duration_days") + } + if before.AffiliateRebatePerInviteeCap != after.AffiliateRebatePerInviteeCap { + changed = append(changed, "affiliate_rebate_per_invitee_cap") + } if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) { changed = append(changed, "default_subscriptions") } diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index 2ef05963..7df4abfd 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -435,6 +435,7 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession( type completeLinuxDoOAuthRequest struct { InvitationCode string `json:"invitation_code" binding:"required"` + AffCode string `json:"aff_code,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } @@ -518,7 +519,7 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) { response.ErrorFrom(c, err) return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode) 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 604ad903..490afd0f 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -67,6 +67,7 @@ type createPendingOAuthAccountRequest struct { VerifyCode string `json:"verify_code,omitempty"` Password string `json:"password" binding:"required,min=6"` InvitationCode string `json:"invitation_code,omitempty"` + AffCode string `json:"aff_code,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } @@ -1751,6 +1752,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string) user, strings.TrimSpace(req.InvitationCode), strings.TrimSpace(session.ProviderType), + strings.TrimSpace(req.AffCode), ); err != nil { _ = tx.Rollback() if rollbackCreatedUser(err) { diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 0ac8871b..4264002d 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -582,6 +582,7 @@ func (h *AuthHandler) createOIDCOAuthChoicePendingSession( type completeOIDCOAuthRequest struct { InvitationCode string `json:"invitation_code" binding:"required"` + AffCode string `json:"aff_code,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } @@ -665,7 +666,7 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) { response.ErrorFrom(c, err) return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index efee4cc0..34e70ed0 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -481,6 +481,7 @@ func (h *AuthHandler) wechatPaymentResumeService() *service.PaymentResumeService type completeWeChatOAuthRequest struct { InvitationCode string `json:"invitation_code" binding:"required"` + AffCode string `json:"aff_code,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } @@ -547,7 +548,7 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) { return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 051fab18..92ae4dc6 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -106,11 +106,14 @@ type SystemSettings struct { CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` - DefaultConcurrency int `json:"default_concurrency"` - DefaultBalance float64 `json:"default_balance"` - AffiliateRebateRate float64 `json:"affiliate_rebate_rate"` - DefaultUserRPMLimit int `json:"default_user_rpm_limit"` - DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"` + DefaultConcurrency int `json:"default_concurrency"` + DefaultBalance float64 `json:"default_balance"` + AffiliateRebateRate float64 `json:"affiliate_rebate_rate"` + AffiliateRebateFreezeHours int `json:"affiliate_rebate_freeze_hours"` + AffiliateRebateDurationDays int `json:"affiliate_rebate_duration_days"` + AffiliateRebatePerInviteeCap float64 `json:"affiliate_rebate_per_invitee_cap"` + DefaultUserRPMLimit int `json:"default_user_rpm_limit"` + DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"` // Model fallback configuration EnableModelFallback bool `json:"enable_model_fallback"` diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go index e3dd56b8..ef89e5b6 100644 --- a/backend/internal/repository/affiliate_repo.go +++ b/backend/internal/repository/affiliate_repo.go @@ -86,17 +86,21 @@ func (r *affiliateRepository) BindInviter(ctx context.Context, userID, inviterID return bound, nil } -func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error) { +func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error) { if amount <= 0 { return false, nil } var applied bool err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { - res, err := txClient.ExecContext(txCtx, - "UPDATE user_affiliates SET aff_quota = aff_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2", - amount, inviterID, - ) + // freezeHours > 0: add to frozen quota; == 0: add to available quota directly + var updateSQL string + if freezeHours > 0 { + updateSQL = "UPDATE user_affiliates SET aff_frozen_quota = aff_frozen_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2" + } else { + updateSQL = "UPDATE user_affiliates SET aff_quota = aff_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2" + } + res, err := txClient.ExecContext(txCtx, updateSQL, amount, inviterID) if err != nil { return err } @@ -106,10 +110,19 @@ func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, invite return nil } - if _, err = txClient.ExecContext(txCtx, ` + if freezeHours > 0 { + if _, err = txClient.ExecContext(txCtx, ` +INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, frozen_until, created_at, updated_at) +VALUES ($1, 'accrue', $2, $3, NOW() + make_interval(hours => $4), NOW(), NOW())`, + inviterID, amount, inviteeUserID, freezeHours); err != nil { + return fmt.Errorf("insert affiliate accrue ledger: %w", err) + } + } else { + if _, err = txClient.ExecContext(txCtx, ` INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at) VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID); err != nil { - return fmt.Errorf("insert affiliate accrue ledger: %w", err) + return fmt.Errorf("insert affiliate accrue ledger: %w", err) + } } applied = true @@ -121,6 +134,76 @@ VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID); return applied, nil } +func (r *affiliateRepository) GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) { + client := clientFromContext(ctx, r.client) + rows, err := client.QueryContext(ctx, + `SELECT COALESCE(SUM(amount), 0)::double precision FROM user_affiliate_ledger WHERE user_id = $1 AND source_user_id = $2 AND action = 'accrue'`, + inviterID, inviteeUserID) + if err != nil { + return 0, fmt.Errorf("query accrued rebate from invitee: %w", err) + } + defer func() { _ = rows.Close() }() + var total float64 + if rows.Next() { + if err := rows.Scan(&total); err != nil { + return 0, err + } + } + return total, rows.Close() +} + +func (r *affiliateRepository) ThawFrozenQuota(ctx context.Context, userID int64) (float64, error) { + var thawed float64 + err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { + var err error + thawed, err = thawFrozenQuotaTx(txCtx, txClient, userID) + return err + }) + return thawed, err +} + +// thawFrozenQuotaTx moves matured frozen quota to available quota within an existing tx. +func thawFrozenQuotaTx(txCtx context.Context, txClient *dbent.Client, userID int64) (float64, error) { + rows, err := txClient.QueryContext(txCtx, ` +WITH matured AS ( + UPDATE user_affiliate_ledger + SET frozen_until = NULL, updated_at = NOW() + WHERE user_id = $1 + AND frozen_until IS NOT NULL + AND frozen_until <= NOW() + RETURNING amount +) +SELECT COALESCE(SUM(amount), 0) FROM matured`, userID) + if err != nil { + return 0, fmt.Errorf("thaw frozen quota: %w", err) + } + defer func() { _ = rows.Close() }() + + var thawed float64 + if rows.Next() { + if err := rows.Scan(&thawed); err != nil { + return 0, err + } + } + if err := rows.Close(); err != nil { + return 0, err + } + if thawed <= 0 { + return 0, nil + } + + _, err = txClient.ExecContext(txCtx, ` +UPDATE user_affiliates +SET aff_quota = aff_quota + $1, + aff_frozen_quota = GREATEST(aff_frozen_quota - $1, 0), + updated_at = NOW() +WHERE user_id = $2`, thawed, userID) + if err != nil { + return 0, fmt.Errorf("move thawed quota: %w", err) + } + return thawed, nil +} + func (r *affiliateRepository) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) { var transferred float64 var newBalance float64 @@ -130,6 +213,11 @@ func (r *affiliateRepository) TransferQuotaToBalance(ctx context.Context, userID return err } + // Thaw any matured frozen quota before transfer. + if _, err := thawFrozenQuotaTx(txCtx, txClient, userID); err != nil { + return fmt.Errorf("thaw before transfer: %w", err) + } + rows, err := txClient.QueryContext(txCtx, ` WITH claimed AS ( SELECT aff_quota::double precision AS amount @@ -211,10 +299,16 @@ func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, SELECT ua.user_id, COALESCE(u.email, ''), COALESCE(u.username, ''), - ua.created_at + ua.created_at, + COALESCE(SUM(ual.amount), 0)::double precision AS total_rebate FROM user_affiliates ua LEFT JOIN users u ON u.id = ua.user_id +LEFT JOIN user_affiliate_ledger ual + ON ual.user_id = $1 + AND ual.source_user_id = ua.user_id + AND ual.action = 'accrue' WHERE ua.inviter_id = $1 +GROUP BY ua.user_id, u.email, u.username, ua.created_at ORDER BY ua.created_at DESC LIMIT $2`, inviterID, limit) if err != nil { @@ -226,7 +320,7 @@ LIMIT $2`, inviterID, limit) for rows.Next() { var item service.AffiliateInvitee var createdAt time.Time - if err := rows.Scan(&item.UserID, &item.Email, &item.Username, &createdAt); err != nil { + if err := rows.Scan(&item.UserID, &item.Email, &item.Username, &createdAt, &item.TotalRebate); err != nil { return nil, err } item.CreatedAt = &createdAt @@ -299,6 +393,7 @@ SELECT user_id, inviter_id, aff_count, aff_quota::double precision, + aff_frozen_quota::double precision, aff_history_quota::double precision, created_at, updated_at @@ -326,6 +421,7 @@ WHERE user_id = $1`, userID) &inviterID, &out.AffCount, &out.AffQuota, + &out.AffFrozenQuota, &out.AffHistoryQuota, &out.CreatedAt, &out.UpdatedAt, @@ -351,6 +447,7 @@ SELECT user_id, inviter_id, aff_count, aff_quota::double precision, + aff_frozen_quota::double precision, aff_history_quota::double precision, created_at, updated_at @@ -380,6 +477,7 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code))) &inviterID, &out.AffCount, &out.AffQuota, + &out.AffFrozenQuota, &out.AffHistoryQuota, &out.CreatedAt, &out.UpdatedAt, diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go index 369f57cf..697a193b 100644 --- a/backend/internal/repository/affiliate_repo_integration_test.go +++ b/backend/internal/repository/affiliate_repo_integration_test.go @@ -125,7 +125,7 @@ func TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction(t *testing.T) { require.NoError(t, err) require.True(t, bound, "invitee must bind to inviter") - applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5) + applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0) require.NoError(t, err) require.True(t, applied, "AccrueQuota must report applied=true") diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 39286cbf..ca6fd0cc 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -716,6 +716,9 @@ func TestAPIContracts(t *testing.T) { "default_concurrency": 5, "default_balance": 1.25, "affiliate_rebate_rate": 20, + "affiliate_rebate_freeze_hours": 0, + "affiliate_rebate_duration_days": 0, + "affiliate_rebate_per_invitee_cap": 0, "default_user_rpm_limit": 0, "default_subscriptions": [], "enable_model_fallback": false, @@ -898,6 +901,9 @@ func TestAPIContracts(t *testing.T) { "default_concurrency": 0, "default_balance": 0, "affiliate_rebate_rate": 20, + "affiliate_rebate_freeze_hours": 0, + "affiliate_rebate_duration_days": 0, + "affiliate_rebate_per_invitee_cap": 0, "default_user_rpm_limit": 0, "default_subscriptions": [], "enable_model_fallback": false, diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go index aca32076..5a4e91e7 100644 --- a/backend/internal/service/affiliate_service.go +++ b/backend/internal/service/affiliate_service.go @@ -65,16 +65,18 @@ type AffiliateSummary struct { InviterID *int64 `json:"inviter_id,omitempty"` AffCount int `json:"aff_count"` AffQuota float64 `json:"aff_quota"` + AffFrozenQuota float64 `json:"aff_frozen_quota"` AffHistoryQuota float64 `json:"aff_history_quota"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type AffiliateInvitee struct { - UserID int64 `json:"user_id"` - Email string `json:"email"` - Username string `json:"username"` - CreatedAt *time.Time `json:"created_at,omitempty"` + UserID int64 `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + CreatedAt *time.Time `json:"created_at,omitempty"` + TotalRebate float64 `json:"total_rebate"` } type AffiliateDetail struct { @@ -83,6 +85,7 @@ type AffiliateDetail struct { InviterID *int64 `json:"inviter_id,omitempty"` AffCount int `json:"aff_count"` AffQuota float64 `json:"aff_quota"` + AffFrozenQuota float64 `json:"aff_frozen_quota"` AffHistoryQuota float64 `json:"aff_history_quota"` // EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例: // 优先用户自己的专属比例(aff_rebate_rate_percent),否则回退到全局比例。 @@ -95,7 +98,9 @@ type AffiliateRepository interface { EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error) BindInviter(ctx context.Context, userID, inviterID int64) (bool, error) - AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error) + AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error) + GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) + ThawFrozenQuota(ctx context.Context, userID int64) (float64, error) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) @@ -160,6 +165,12 @@ func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64 } func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64) (*AffiliateDetail, error) { + // Lazy thaw: move any matured frozen quota to available before reading. + if s != nil && s.repo != nil { + // best-effort: thaw failure is non-fatal + _, _ = s.repo.ThawFrozenQuota(ctx, userID) + } + summary, err := s.EnsureUserAffiliate(ctx, userID) if err != nil { return nil, err @@ -174,6 +185,7 @@ func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64) InviterID: summary.InviterID, AffCount: summary.AffCount, AffQuota: summary.AffQuota, + AffFrozenQuota: summary.AffFrozenQuota, AffHistoryQuota: summary.AffHistoryQuota, EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary), Invitees: invitees, @@ -250,13 +262,43 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID if err != nil { return 0, err } + // 有效期检查:超过返利有效期后不再产生返利 + if s.settingService != nil { + if durationDays := s.settingService.GetAffiliateRebateDurationDays(ctx); durationDays > 0 { + if time.Now().After(inviteeSummary.CreatedAt.AddDate(0, 0, durationDays)) { + return 0, nil + } + } + } + rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary) rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8) if rebate <= 0 { return 0, nil } - applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate) + // 单人上限检查:精确截断到剩余额度 + if s.settingService != nil { + if perInviteeCap := s.settingService.GetAffiliateRebatePerInviteeCap(ctx); perInviteeCap > 0 { + existing, err := s.repo.GetAccruedRebateFromInvitee(ctx, *inviteeSummary.InviterID, inviteeUserID) + if err != nil { + return 0, err + } + if existing >= perInviteeCap { + return 0, nil + } + if remaining := perInviteeCap - existing; rebate > remaining { + rebate = roundTo(remaining, 8) + } + } + } + + var freezeHours int + if s.settingService != nil { + freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx) + } + + applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours) if err != nil { return 0, err } diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index a18cf39c..9815f31b 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -175,6 +175,7 @@ func (s *AuthService) FinalizeOAuthEmailAccount( user *User, invitationCode string, signupSource string, + affiliateCode string, ) error { if s == nil || user == nil || user.ID <= 0 { return ErrServiceUnavailable @@ -194,6 +195,7 @@ func (s *AuthService) FinalizeOAuthEmailAccount( s.updateOAuthSignupSource(ctx, user.ID, signupSource) grantPlan := s.resolveSignupGrantPlan(ctx, signupSource) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") + s.bindOAuthAffiliate(ctx, user.ID, affiliateCode) return nil } diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 08b0f4b7..b1adf071 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -563,7 +563,8 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username // LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair。 // 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token。 // invitationCode 仅在邀请码注册模式下新用户注册时使用;已有账号登录时忽略。 -func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode string) (*TokenPair, *User, error) { +// affiliateCode 用于邀请返利绑定,仅在新用户注册时使用。 +func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode string) (*TokenPair, *User, error) { // 检查 refreshTokenCache 是否可用 if s.refreshTokenCache == nil { return nil, nil, errors.New("refresh token cache not configured") @@ -666,6 +667,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema user = newUser s.postAuthUserBootstrap(ctx, user, signupSource, false) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") + s.bindOAuthAffiliate(ctx, user.ID, affiliateCode) } } else { if err := s.userRepo.Create(ctx, newUser); err != nil { @@ -683,6 +685,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema user = newUser s.postAuthUserBootstrap(ctx, user, signupSource, false) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") + s.bindOAuthAffiliate(ctx, user.ID, affiliateCode) if invitationRedeemCode != nil { if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil { return nil, nil, ErrInvitationCodeInvalid @@ -777,6 +780,22 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource } } +// bindOAuthAffiliate initializes the affiliate profile and binds the inviter +// for an OAuth-registered user. Failures are logged but never block registration. +func (s *AuthService) bindOAuthAffiliate(ctx context.Context, userID int64, affiliateCode string) { + if s.affiliateService == nil || userID <= 0 { + return + } + if _, err := s.affiliateService.EnsureUserAffiliate(ctx, userID); err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to initialize affiliate profile for user %d: %v", userID, err) + } + if code := strings.TrimSpace(affiliateCode); code != "" { + if err := s.affiliateService.BindInviterByCode(ctx, userID, code); err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to bind affiliate inviter for user %d: %v", userID, err) + } + } +} + func (s *AuthService) postAuthUserBootstrap(ctx context.Context, user *User, signupSource string, touchLogin bool) { if user == nil || user.ID <= 0 { return diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index c1ad6240..acc44a38 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -622,7 +622,7 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_UsesLinuxDoAuthSourceDefa service.defaultSubAssigner = assigner service.refreshTokenCache = &refreshTokenCacheStub{} - tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "") + tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "", "") require.NoError(t, err) require.NotNil(t, tokenPair) require.NotNil(t, user) @@ -658,7 +658,7 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_ExistingUserDoesNotGrantA service.defaultSubAssigner = assigner service.refreshTokenCache = &refreshTokenCacheStub{} - tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "") + tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "", "") require.NoError(t, err) require.NotNil(t, tokenPair) require.Equal(t, existing.ID, user.ID) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 04037987..0ef4a486 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -20,10 +20,15 @@ const ( // Affiliate rebate settings const ( - AffiliateRebateRateDefault = 20.0 - AffiliateRebateRateMin = 0.0 - AffiliateRebateRateMax = 100.0 - AffiliateEnabledDefault = false // 邀请返利总开关默认关闭 + AffiliateRebateRateDefault = 20.0 + AffiliateRebateRateMin = 0.0 + AffiliateRebateRateMax = 100.0 + AffiliateEnabledDefault = false // 邀请返利总开关默认关闭 + AffiliateRebateFreezeHoursDefault = 0 // 0 = 不冻结(向后兼容) + AffiliateRebateFreezeHoursMax = 720 // 最大 30 天 + AffiliateRebateDurationDaysDefault = 0 // 0 = 永久有效 + AffiliateRebateDurationDaysMax = 3650 // ~10 年 + AffiliateRebatePerInviteeCapDefault = 0.0 // 0 = 无上限 ) // Platform constants @@ -97,6 +102,9 @@ const ( SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100) + SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) + SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) + SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index c6167447..5df69aea 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -269,7 +269,9 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e switch action { case redeemActionSkipCompleted: - s.applyAffiliateRebateForOrder(ctx, o) + if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil { + return err + } // Code already created and redeemed — just mark completed return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") case redeemActionCreate: @@ -283,7 +285,9 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil { return fmt.Errorf("redeem balance: %w", err) } - s.applyAffiliateRebateForOrder(ctx, o) + if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil { + return err + } return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") } @@ -361,12 +365,12 @@ func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action return c > 0 } -func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *dbent.PaymentOrder) { +func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *dbent.PaymentOrder) error { if o == nil || o.OrderType != payment.OrderTypeBalance || o.Amount <= 0 { - return + return nil } if s.affiliateService == nil { - return + return nil } tx, err := s.entClient.Tx(ctx) @@ -374,7 +378,7 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": fmt.Sprintf("begin affiliate rebate tx: %v", err), }) - return + return fmt.Errorf("begin affiliate rebate tx: %w", err) } defer func() { _ = tx.Rollback() }() @@ -384,10 +388,10 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": err.Error(), }) - return + return fmt.Errorf("claim affiliate rebate audit: %w", err) } if !claimed { - return + return nil } rebateAmount, err := s.affiliateService.AccrueInviteRebate(txCtx, o.UserID, o.Amount) @@ -395,7 +399,7 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": err.Error(), }) - return + return fmt.Errorf("accrue affiliate rebate: %w", err) } if rebateAmount <= 0 { @@ -406,14 +410,15 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": err.Error(), }) - return + return fmt.Errorf("update affiliate rebate skipped audit: %w", err) } if err := tx.Commit(); err != nil { s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": fmt.Sprintf("commit affiliate rebate tx: %v", err), }) + return fmt.Errorf("commit affiliate rebate tx: %w", err) } - return + return nil } if err := s.updateClaimedAffiliateRebateAudit(txCtx, tx.Client(), o.ID, "AFFILIATE_REBATE_APPLIED", map[string]any{ @@ -423,14 +428,16 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": err.Error(), }) - return + return fmt.Errorf("update affiliate rebate applied audit: %w", err) } if err := tx.Commit(); err != nil { s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": fmt.Sprintf("commit affiliate rebate tx: %v", err), }) + return fmt.Errorf("commit affiliate rebate tx: %w", err) } + return nil } func (s *PaymentService) tryClaimAffiliateRebateAudit(ctx context.Context, client *dbent.Client, orderID int64, baseAmount float64) (bool, error) { @@ -444,11 +451,11 @@ func (s *PaymentService) tryClaimAffiliateRebateAudit(ctx context.Context, clien }) rows, err := client.QueryContext(ctx, ` INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at) -SELECT $1, 'AFFILIATE_REBATE_APPLIED', $2, 'system', NOW() +SELECT $1::text, 'AFFILIATE_REBATE_APPLIED', $2::text, 'system', NOW() WHERE NOT EXISTS ( SELECT 1 FROM payment_audit_logs - WHERE order_id = $1 + WHERE order_id = $1::text AND action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED') ) ON CONFLICT (order_id, action) DO NOTHING diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index f871ee85..33316031 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -1175,6 +1175,24 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate) updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64) + if settings.AffiliateRebateFreezeHours < 0 { + settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursDefault + } + if settings.AffiliateRebateFreezeHours > AffiliateRebateFreezeHoursMax { + settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursMax + } + updates[SettingKeyAffiliateRebateFreezeHours] = strconv.Itoa(settings.AffiliateRebateFreezeHours) + if settings.AffiliateRebateDurationDays < 0 { + settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysDefault + } + if settings.AffiliateRebateDurationDays > AffiliateRebateDurationDaysMax { + settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysMax + } + updates[SettingKeyAffiliateRebateDurationDays] = strconv.Itoa(settings.AffiliateRebateDurationDays) + if settings.AffiliateRebatePerInviteeCap < 0 { + settings.AffiliateRebatePerInviteeCap = AffiliateRebatePerInviteeCapDefault + } + updates[SettingKeyAffiliateRebatePerInviteeCap] = strconv.FormatFloat(settings.AffiliateRebatePerInviteeCap, 'f', 8, 64) updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit) defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions) if err != nil { @@ -1512,6 +1530,54 @@ func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) floa return clampAffiliateRebateRate(rate) } +// GetAffiliateRebateFreezeHours 返回返利冻结期(小时)。 +// 返回 0 表示不冻结(向后兼容)。 +func (s *SettingService) GetAffiliateRebateFreezeHours(ctx context.Context) int { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateFreezeHours) + if err != nil { + return AffiliateRebateFreezeHoursDefault + } + hours, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || hours < 0 { + return AffiliateRebateFreezeHoursDefault + } + if hours > AffiliateRebateFreezeHoursMax { + return AffiliateRebateFreezeHoursMax + } + return hours +} + +// GetAffiliateRebateDurationDays 返回返利有效期(天)。 +// 返回 0 表示永久有效。 +func (s *SettingService) GetAffiliateRebateDurationDays(ctx context.Context) int { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateDurationDays) + if err != nil { + return AffiliateRebateDurationDaysDefault + } + days, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || days < 0 { + return AffiliateRebateDurationDaysDefault + } + if days > AffiliateRebateDurationDaysMax { + return AffiliateRebateDurationDaysMax + } + return days +} + +// GetAffiliateRebatePerInviteeCap 返回单人返利上限。 +// 返回 0 表示无上限。 +func (s *SettingService) GetAffiliateRebatePerInviteeCap(ctx context.Context) float64 { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebatePerInviteeCap) + if err != nil { + return AffiliateRebatePerInviteeCapDefault + } + cap, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil || cap < 0 || math.IsNaN(cap) || math.IsInf(cap, 0) { + return AffiliateRebatePerInviteeCapDefault + } + return cap +} + // IsPasswordResetEnabled 检查是否启用密码重置功能 // 要求:必须同时开启邮件验证 func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { @@ -1755,6 +1821,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64), + SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault), + SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault), + SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64), SettingKeyDefaultUserRPMLimit: "0", SettingKeyDefaultSubscriptions: "[]", SettingKeyAuthSourceDefaultEmailBalance: "0", @@ -1890,6 +1959,21 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } else { result.AffiliateRebateRate = AffiliateRebateRateDefault } + if freezeHours, err := strconv.Atoi(settings[SettingKeyAffiliateRebateFreezeHours]); err == nil && freezeHours >= 0 { + if freezeHours > AffiliateRebateFreezeHoursMax { + freezeHours = AffiliateRebateFreezeHoursMax + } + result.AffiliateRebateFreezeHours = freezeHours + } + if durationDays, err := strconv.Atoi(settings[SettingKeyAffiliateRebateDurationDays]); err == nil && durationDays >= 0 { + if durationDays > AffiliateRebateDurationDaysMax { + durationDays = AffiliateRebateDurationDaysMax + } + result.AffiliateRebateDurationDays = durationDays + } + if perInviteeCap, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebatePerInviteeCap], 64); err == nil && perInviteeCap >= 0 { + result.AffiliateRebatePerInviteeCap = perInviteeCap + } result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions]) // 敏感信息直接返回,方便测试连接时使用 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 70d8efc3..5ec7d313 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -104,12 +104,15 @@ type SystemSettings struct { CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints - DefaultConcurrency int - DefaultBalance float64 - AffiliateEnabled bool - AffiliateRebateRate float64 - DefaultUserRPMLimit int - DefaultSubscriptions []DefaultSubscriptionSetting + DefaultConcurrency int + DefaultBalance float64 + AffiliateEnabled bool + AffiliateRebateRate float64 + AffiliateRebateFreezeHours int + AffiliateRebateDurationDays int + AffiliateRebatePerInviteeCap float64 + DefaultUserRPMLimit int + DefaultSubscriptions []DefaultSubscriptionSetting // Model fallback configuration EnableModelFallback bool `json:"enable_model_fallback"` diff --git a/backend/migrations/133_affiliate_rebate_freeze.sql b/backend/migrations/133_affiliate_rebate_freeze.sql new file mode 100644 index 00000000..b87d59b7 --- /dev/null +++ b/backend/migrations/133_affiliate_rebate_freeze.sql @@ -0,0 +1,17 @@ +-- 1) Add frozen quota column to user_affiliates for rebate freeze period. +ALTER TABLE user_affiliates + ADD COLUMN IF NOT EXISTS aff_frozen_quota DECIMAL(20,8) NOT NULL DEFAULT 0; + +COMMENT ON COLUMN user_affiliates.aff_frozen_quota IS 'Rebate quota currently frozen (pending thaw after freeze period)'; + +-- 2) Add frozen_until column to user_affiliate_ledger for per-entry freeze tracking. +-- NULL = no freeze (or already thawed); non-NULL = frozen until this timestamp. +ALTER TABLE user_affiliate_ledger + ADD COLUMN IF NOT EXISTS frozen_until TIMESTAMPTZ NULL; + +COMMENT ON COLUMN user_affiliate_ledger.frozen_until IS 'Rebate frozen until this time; NULL means already thawed or never frozen'; + +-- 3) Partial index for efficient thaw queries (only rows still frozen). +CREATE INDEX IF NOT EXISTS idx_ual_frozen_thaw + ON user_affiliate_ledger (user_id, frozen_until) + WHERE frozen_until IS NOT NULL; diff --git a/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts b/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts index a484d7ed..07a68c03 100644 --- a/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts +++ b/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts @@ -74,6 +74,26 @@ describe('oauth adoption auth api', () => { }) }) + it('posts affiliate code when completing linuxdo oauth registration', async () => { + const { completeLinuxDoOAuthRegistration } = await import('@/api/auth') + + await completeLinuxDoOAuthRegistration( + 'invite-code', + { + adoptDisplayName: true, + adoptAvatar: false + }, + ' AFF123 ' + ) + + expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', { + invitation_code: 'invite-code', + aff_code: 'AFF123', + adopt_display_name: true, + adopt_avatar: false + }) + }) + it('posts oidc invitation completion with adoption decisions', async () => { const { completeOIDCOAuthRegistration } = await import('@/api/auth') @@ -134,6 +154,26 @@ describe('oauth adoption auth api', () => { }) }) + it('posts affiliate code when creating pending wechat oauth account', async () => { + const { createPendingWeChatOAuthAccount } = await import('@/api/auth') + + await createPendingWeChatOAuthAccount( + 'invite-code', + { + adoptDisplayName: false, + adoptAvatar: true + }, + 'WXAFF' + ) + + expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', { + invitation_code: 'invite-code', + aff_code: 'WXAFF', + adopt_display_name: false, + adopt_avatar: true + }) + }) + it('classifies oauth completion results as login or bind', async () => { const { getOAuthCompletionKind } = await import('@/api/auth') diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 0d98c9e9..defbab43 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -309,6 +309,9 @@ export interface SystemSettings { // Default settings default_balance: number; affiliate_rebate_rate: number; + affiliate_rebate_freeze_hours: number; + affiliate_rebate_duration_days: number; + affiliate_rebate_per_invitee_cap: number; default_concurrency: number; default_user_rpm_limit: number; default_subscriptions: DefaultSubscriptionSetting[]; @@ -494,6 +497,9 @@ export interface UpdateSettingsRequest { totp_enabled?: boolean; // TOTP 双因素认证 default_balance?: number; affiliate_rebate_rate?: number; + affiliate_rebate_freeze_hours?: number; + affiliate_rebate_duration_days?: number; + affiliate_rebate_per_invitee_cap?: number; default_concurrency?: number; default_user_rpm_limit?: number; default_subscriptions?: DefaultSubscriptionSetting[]; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index f49f3a1f..bb990fc4 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise { - return createPendingLinuxDoOAuthAccount(invitationCode, decision) + return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode) } /** @@ -576,27 +577,32 @@ export async function completeLinuxDoOAuthRegistration( */ export async function completeOIDCOAuthRegistration( invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { - return createPendingOIDCOAuthAccount(invitationCode, decision) + return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode) } export async function completeWeChatOAuthRegistration( invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { - return createPendingWeChatOAuthAccount(invitationCode, decision) + return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode) } async function createPendingOAuthAccount( provider: 'linuxdo' | 'oidc' | 'wechat', invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { + const normalizedAffiliateCode = affiliateCode?.trim() const { data } = await apiClient.post( `/auth/oauth/${provider}/complete-registration`, { invitation_code: invitationCode, + ...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}), ...serializeOAuthAdoptionDecision(decision) } ) @@ -605,23 +611,26 @@ async function createPendingOAuthAccount( export async function createPendingLinuxDoOAuthAccount( invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { - return createPendingOAuthAccount('linuxdo', invitationCode, decision) + return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode) } export async function createPendingOIDCOAuthAccount( invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { - return createPendingOAuthAccount('oidc', invitationCode, decision) + return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode) } export async function createPendingWeChatOAuthAccount( invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { - return createPendingOAuthAccount('wechat', invitationCode, decision) + return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode) } export async function completePendingOAuthBindLogin( diff --git a/frontend/src/components/auth/LinuxDoOAuthSection.vue b/frontend/src/components/auth/LinuxDoOAuthSection.vue index c740d06f..6b245123 100644 --- a/frontend/src/components/auth/LinuxDoOAuthSection.vue +++ b/frontend/src/components/auth/LinuxDoOAuthSection.vue @@ -42,9 +42,11 @@