Unverified Commit 3f05ef2a authored by Oliver Li's avatar Oliver Li Committed by GitHub
Browse files

Merge branch 'Wei-Shaw:main' into vertex

parents 6d11f9ed c056db74
...@@ -269,7 +269,9 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e ...@@ -269,7 +269,9 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
switch action { switch action {
case redeemActionSkipCompleted: case redeemActionSkipCompleted:
s.applyAffiliateRebateForOrder(ctx, o) if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {
return err
}
// Code already created and redeemed — just mark completed // Code already created and redeemed — just mark completed
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
case redeemActionCreate: case redeemActionCreate:
...@@ -283,7 +285,9 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e ...@@ -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 { if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
return fmt.Errorf("redeem balance: %w", err) 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") return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
} }
...@@ -361,12 +365,12 @@ func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action ...@@ -361,12 +365,12 @@ func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action
return c > 0 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 { if o == nil || o.OrderType != payment.OrderTypeBalance || o.Amount <= 0 {
return return nil
} }
if s.affiliateService == nil { if s.affiliateService == nil {
return return nil
} }
tx, err := s.entClient.Tx(ctx) tx, err := s.entClient.Tx(ctx)
...@@ -374,7 +378,7 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db ...@@ -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{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": fmt.Sprintf("begin affiliate rebate tx: %v", err), "error": fmt.Sprintf("begin affiliate rebate tx: %v", err),
}) })
return return fmt.Errorf("begin affiliate rebate tx: %w", err)
} }
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
...@@ -384,10 +388,10 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db ...@@ -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{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": err.Error(), "error": err.Error(),
}) })
return return fmt.Errorf("claim affiliate rebate audit: %w", err)
} }
if !claimed { if !claimed {
return return nil
} }
rebateAmount, err := s.affiliateService.AccrueInviteRebate(txCtx, o.UserID, o.Amount) rebateAmount, err := s.affiliateService.AccrueInviteRebate(txCtx, o.UserID, o.Amount)
...@@ -395,7 +399,7 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db ...@@ -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{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": err.Error(), "error": err.Error(),
}) })
return return fmt.Errorf("accrue affiliate rebate: %w", err)
} }
if rebateAmount <= 0 { if rebateAmount <= 0 {
...@@ -406,14 +410,15 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db ...@@ -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{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": err.Error(), "error": err.Error(),
}) })
return return fmt.Errorf("update affiliate rebate skipped audit: %w", err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": fmt.Sprintf("commit affiliate rebate tx: %v", err), "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{ 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 ...@@ -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{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": err.Error(), "error": err.Error(),
}) })
return return fmt.Errorf("update affiliate rebate applied audit: %w", err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": fmt.Sprintf("commit affiliate rebate tx: %v", err), "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) { 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 ...@@ -444,11 +451,11 @@ func (s *PaymentService) tryClaimAffiliateRebateAudit(ctx context.Context, clien
}) })
rows, err := client.QueryContext(ctx, ` rows, err := client.QueryContext(ctx, `
INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at) 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 ( WHERE NOT EXISTS (
SELECT 1 SELECT 1
FROM payment_audit_logs FROM payment_audit_logs
WHERE order_id = $1 WHERE order_id = $1::text
AND action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED') AND action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED')
) )
ON CONFLICT (order_id, action) DO NOTHING ON CONFLICT (order_id, action) DO NOTHING
......
...@@ -1175,6 +1175,24 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting ...@@ -1175,6 +1175,24 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate) settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate)
updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64) 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) updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit)
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions) defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
if err != nil { if err != nil {
...@@ -1512,6 +1530,54 @@ func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) floa ...@@ -1512,6 +1530,54 @@ func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) floa
return clampAffiliateRebateRate(rate) 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 检查是否启用密码重置功能 // IsPasswordResetEnabled 检查是否启用密码重置功能
// 要求:必须同时开启邮件验证 // 要求:必须同时开启邮件验证
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
...@@ -1755,6 +1821,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { ...@@ -1755,6 +1821,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, '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", SettingKeyDefaultUserRPMLimit: "0",
SettingKeyDefaultSubscriptions: "[]", SettingKeyDefaultSubscriptions: "[]",
SettingKeyAuthSourceDefaultEmailBalance: "0", SettingKeyAuthSourceDefaultEmailBalance: "0",
...@@ -1890,6 +1959,21 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -1890,6 +1959,21 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
} else { } else {
result.AffiliateRebateRate = AffiliateRebateRateDefault 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]) result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions])
// 敏感信息直接返回,方便测试连接时使用 // 敏感信息直接返回,方便测试连接时使用
......
...@@ -108,6 +108,9 @@ type SystemSettings struct { ...@@ -108,6 +108,9 @@ type SystemSettings struct {
DefaultBalance float64 DefaultBalance float64
AffiliateEnabled bool AffiliateEnabled bool
AffiliateRebateRate float64 AffiliateRebateRate float64
AffiliateRebateFreezeHours int
AffiliateRebateDurationDays int
AffiliateRebatePerInviteeCap float64
DefaultUserRPMLimit int DefaultUserRPMLimit int
DefaultSubscriptions []DefaultSubscriptionSetting DefaultSubscriptions []DefaultSubscriptionSetting
......
-- 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;
...@@ -74,6 +74,26 @@ describe('oauth adoption auth api', () => { ...@@ -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 () => { it('posts oidc invitation completion with adoption decisions', async () => {
const { completeOIDCOAuthRegistration } = await import('@/api/auth') const { completeOIDCOAuthRegistration } = await import('@/api/auth')
...@@ -134,6 +154,26 @@ describe('oauth adoption auth api', () => { ...@@ -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 () => { it('classifies oauth completion results as login or bind', async () => {
const { getOAuthCompletionKind } = await import('@/api/auth') const { getOAuthCompletionKind } = await import('@/api/auth')
......
...@@ -309,6 +309,9 @@ export interface SystemSettings { ...@@ -309,6 +309,9 @@ export interface SystemSettings {
// Default settings // Default settings
default_balance: number; default_balance: number;
affiliate_rebate_rate: 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_concurrency: number;
default_user_rpm_limit: number; default_user_rpm_limit: number;
default_subscriptions: DefaultSubscriptionSetting[]; default_subscriptions: DefaultSubscriptionSetting[];
...@@ -494,6 +497,9 @@ export interface UpdateSettingsRequest { ...@@ -494,6 +497,9 @@ export interface UpdateSettingsRequest {
totp_enabled?: boolean; // TOTP 双因素认证 totp_enabled?: boolean; // TOTP 双因素认证
default_balance?: number; default_balance?: number;
affiliate_rebate_rate?: 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_concurrency?: number;
default_user_rpm_limit?: number; default_user_rpm_limit?: number;
default_subscriptions?: DefaultSubscriptionSetting[]; default_subscriptions?: DefaultSubscriptionSetting[];
......
...@@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese ...@@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
*/ */
export async function completeLinuxDoOAuthRegistration( export async function completeLinuxDoOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingLinuxDoOAuthAccount(invitationCode, decision) return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode)
} }
/** /**
...@@ -576,27 +577,32 @@ export async function completeLinuxDoOAuthRegistration( ...@@ -576,27 +577,32 @@ export async function completeLinuxDoOAuthRegistration(
*/ */
export async function completeOIDCOAuthRegistration( export async function completeOIDCOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingOIDCOAuthAccount(invitationCode, decision) return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode)
} }
export async function completeWeChatOAuthRegistration( export async function completeWeChatOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingWeChatOAuthAccount(invitationCode, decision) return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode)
} }
async function createPendingOAuthAccount( async function createPendingOAuthAccount(
provider: 'linuxdo' | 'oidc' | 'wechat', provider: 'linuxdo' | 'oidc' | 'wechat',
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
const normalizedAffiliateCode = affiliateCode?.trim()
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>( const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
`/auth/oauth/${provider}/complete-registration`, `/auth/oauth/${provider}/complete-registration`,
{ {
invitation_code: invitationCode, invitation_code: invitationCode,
...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}),
...serializeOAuthAdoptionDecision(decision) ...serializeOAuthAdoptionDecision(decision)
} }
) )
...@@ -605,23 +611,26 @@ async function createPendingOAuthAccount( ...@@ -605,23 +611,26 @@ async function createPendingOAuthAccount(
export async function createPendingLinuxDoOAuthAccount( export async function createPendingLinuxDoOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('linuxdo', invitationCode, decision) return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode)
} }
export async function createPendingOIDCOAuthAccount( export async function createPendingOIDCOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('oidc', invitationCode, decision) return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode)
} }
export async function createPendingWeChatOAuthAccount( export async function createPendingWeChatOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('wechat', invitationCode, decision) return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
} }
export async function completePendingOAuthBindLogin( export async function completePendingOAuthBindLogin(
......
...@@ -42,9 +42,11 @@ ...@@ -42,9 +42,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
showDivider: true showDivider: true
...@@ -55,6 +57,7 @@ const { t } = useI18n() ...@@ -55,6 +57,7 @@ const { t } = useI18n()
function startLogin(): void { function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}` const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`
......
...@@ -23,9 +23,11 @@ ...@@ -23,9 +23,11 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
providerName?: string providerName?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
...@@ -45,6 +47,7 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to ...@@ -45,6 +47,7 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to
function startLogin(): void { function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}` const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`
......
...@@ -33,9 +33,11 @@ import { useRoute } from 'vue-router' ...@@ -33,9 +33,11 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveWeChatOAuthStart } from '@/api/auth' import { resolveWeChatOAuthStart } from '@/api/auth'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
showDivider: true, showDivider: true,
...@@ -84,6 +86,7 @@ function startLogin(): void { ...@@ -84,6 +86,7 @@ function startLogin(): void {
return return
} }
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const mode = resolvedStart.value.mode const mode = resolvedStart.value.mode
......
...@@ -989,6 +989,8 @@ export default { ...@@ -989,6 +989,8 @@ export default {
rebateRateHint: 'What you earn each time an invitee recharges', rebateRateHint: 'What you earn each time an invitee recharges',
invitedUsers: 'Invited Users', invitedUsers: 'Invited Users',
availableQuota: 'Available Rebate Quota', availableQuota: 'Available Rebate Quota',
frozenQuota: 'Frozen',
frozenQuotaHint: 'Recently earned rebates pending release',
totalQuota: 'Historical Rebate Quota' totalQuota: 'Historical Rebate Quota'
}, },
transfer: { transfer: {
...@@ -1005,6 +1007,7 @@ export default { ...@@ -1005,6 +1007,7 @@ export default {
columns: { columns: {
email: 'Email', email: 'Email',
username: 'Username', username: 'Username',
rebate: 'Rebate',
joinedAt: 'Joined At' joinedAt: 'Joined At'
} }
}, },
...@@ -1012,7 +1015,8 @@ export default { ...@@ -1012,7 +1015,8 @@ export default {
title: 'How It Works', title: 'How It Works',
line1: 'Share your affiliate code or invite link with new users.', line1: 'Share your affiliate code or invite link with new users.',
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.', line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
line3: 'Transfer rebate quota to balance at any time.' line3: 'Transfer rebate quota to balance at any time.',
line4: 'Newly earned rebates may have a waiting period before they can be transferred.'
} }
}, },
...@@ -4788,6 +4792,12 @@ export default { ...@@ -4788,6 +4792,12 @@ export default {
enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.', enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.',
rebateRate: 'Global Rebate Rate', rebateRate: 'Global Rebate Rate',
rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).', rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
freezeHours: 'Rebate Freeze Period (hours)',
freezeHoursDesc: 'New rebates will be frozen for this period before becoming available for withdrawal. 0 = no freeze.',
durationDays: 'Rebate Duration (days)',
durationDaysDesc: 'Rebate relationship expires after this many days since invitee registration. 0 = permanent.',
perInviteeCap: 'Per-Invitee Rebate Cap',
perInviteeCapDesc: 'Maximum total rebate from a single invitee. 0 = no limit.',
customUsers: { customUsers: {
title: 'Per-User Overrides', title: 'Per-User Overrides',
description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.', description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',
......
...@@ -993,6 +993,8 @@ export default { ...@@ -993,6 +993,8 @@ export default {
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例', rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
invitedUsers: '邀请人数', invitedUsers: '邀请人数',
availableQuota: '可转返利额度', availableQuota: '可转返利额度',
frozenQuota: '冻结中',
frozenQuotaHint: '新产生的返利正在冻结期中',
totalQuota: '历史返利额度' totalQuota: '历史返利额度'
}, },
transfer: { transfer: {
...@@ -1009,6 +1011,7 @@ export default { ...@@ -1009,6 +1011,7 @@ export default {
columns: { columns: {
email: '邮箱', email: '邮箱',
username: '用户名', username: '用户名',
rebate: '返利明细',
joinedAt: '注册时间' joinedAt: '注册时间'
} }
}, },
...@@ -1016,7 +1019,8 @@ export default { ...@@ -1016,7 +1019,8 @@ export default {
title: '使用说明', title: '使用说明',
line1: '将邀请码或邀请链接分享给新用户。', line1: '将邀请码或邀请链接分享给新用户。',
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。', line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
line3: '返利额度可随时转入账户余额。' line3: '返利额度可随时转入账户余额。',
line4: '新产生的返利需要经过冻结期后才能提现。'
} }
}, },
...@@ -4951,6 +4955,12 @@ export default { ...@@ -4951,6 +4955,12 @@ export default {
enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。', enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
rebateRate: '全局返利比例', rebateRate: '全局返利比例',
rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。', rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。',
freezeHours: '返利冻结期(小时)',
freezeHoursDesc: '新产生的返利将在冻结期内无法提现。0 = 不冻结。',
durationDays: '返利有效期(天)',
durationDaysDesc: '被邀请用户注册后多少天内的充值产生返利。0 = 永久有效。',
perInviteeCap: '单人返利上限',
perInviteeCapDesc: '每个被邀请用户最多产生的返利总额。0 = 无上限。',
customUsers: { customUsers: {
title: '专属用户配置', title: '专属用户配置',
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。', description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
......
...@@ -130,6 +130,7 @@ export interface AffiliateInvitee { ...@@ -130,6 +130,7 @@ export interface AffiliateInvitee {
email: string email: string
username: string username: string
created_at?: string created_at?: string
total_rebate: number
} }
export interface UserAffiliateDetail { export interface UserAffiliateDetail {
...@@ -138,6 +139,7 @@ export interface UserAffiliateDetail { ...@@ -138,6 +139,7 @@ export interface UserAffiliateDetail {
inviter_id?: number | null inviter_id?: number | null
aff_count: number aff_count: number
aff_quota: number aff_quota: number
aff_frozen_quota: number
aff_history_quota: number aff_history_quota: number
/** 当前用户作为邀请人时实际生效的返利比例(专属覆盖全局)。0-100。 */ /** 当前用户作为邀请人时实际生效的返利比例(专属覆盖全局)。0-100。 */
effective_rebate_rate_percent: number effective_rebate_rate_percent: number
......
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearAffiliateReferralCode,
clearOAuthAffiliateCode,
loadAffiliateReferralCode,
loadOAuthAffiliateCode,
resolveAffiliateReferralCode,
storeAffiliateReferralCode,
storeOAuthAffiliateCode
} from '@/utils/oauthAffiliate'
describe('oauthAffiliate', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.useRealTimers()
})
it('persists affiliate referral code across pages', () => {
expect(resolveAffiliateReferralCode(' 5579J7CFG9PF ')).toBe('5579J7CFG9PF')
expect(loadAffiliateReferralCode()).toBe('5579J7CFG9PF')
expect(resolveAffiliateReferralCode()).toBe('5579J7CFG9PF')
})
it('expires stale affiliate referral code', () => {
const now = Date.UTC(2026, 0, 1)
storeAffiliateReferralCode('AFF123', now)
expect(loadAffiliateReferralCode(now + 30 * 24 * 60 * 60 * 1000 - 1)).toBe('AFF123')
expect(loadAffiliateReferralCode(now + 30 * 24 * 60 * 60 * 1000 + 1)).toBe('')
expect(localStorage.getItem('affiliate_referral_code')).toBeNull()
})
it('keeps oauth transient code separate from persistent referral code', () => {
storeAffiliateReferralCode('PERSISTED')
storeOAuthAffiliateCode('OAUTH')
expect(loadAffiliateReferralCode()).toBe('PERSISTED')
expect(loadOAuthAffiliateCode()).toBe('OAUTH')
clearOAuthAffiliateCode()
expect(loadOAuthAffiliateCode()).toBe('')
expect(loadAffiliateReferralCode()).toBe('PERSISTED')
clearAffiliateReferralCode()
expect(loadAffiliateReferralCode()).toBe('')
})
})
const OAUTH_AFFILIATE_CODE_KEY = 'oauth_aff_code'
const AFFILIATE_REFERRAL_CODE_KEY = 'affiliate_referral_code'
const AFFILIATE_REFERRAL_TTL_MS = 30 * 24 * 60 * 60 * 1000
interface StoredAffiliateReferralCode {
code: string
expiresAt: number
}
export function normalizeOAuthAffiliateCode(value?: unknown): string {
const raw = Array.isArray(value) ? value[0] : value
return typeof raw === 'string' ? raw.trim() : ''
}
export function pickOAuthAffiliateCode(...values: unknown[]): string {
for (const value of values) {
const code = normalizeOAuthAffiliateCode(value)
if (code) {
return code
}
}
return ''
}
export function storeAffiliateReferralCode(value?: unknown, now = Date.now()): void {
if (typeof window === 'undefined') {
return
}
const code = normalizeOAuthAffiliateCode(value)
if (!code) {
return
}
try {
const payload: StoredAffiliateReferralCode = {
code,
expiresAt: now + AFFILIATE_REFERRAL_TTL_MS
}
window.localStorage.setItem(AFFILIATE_REFERRAL_CODE_KEY, JSON.stringify(payload))
} catch {
// 忽略浏览器存储异常。
}
}
export function loadAffiliateReferralCode(now = Date.now()): string {
if (typeof window === 'undefined') {
return ''
}
try {
const raw = window.localStorage.getItem(AFFILIATE_REFERRAL_CODE_KEY)
if (!raw) {
return ''
}
const parsed = JSON.parse(raw) as Partial<StoredAffiliateReferralCode>
const code = normalizeOAuthAffiliateCode(parsed.code)
const expiresAt = Number(parsed.expiresAt) || 0
if (!code || expiresAt <= now) {
clearAffiliateReferralCode()
return ''
}
return code
} catch {
clearAffiliateReferralCode()
return ''
}
}
export function clearAffiliateReferralCode(): void {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.removeItem(AFFILIATE_REFERRAL_CODE_KEY)
} catch {
// 忽略浏览器存储异常。
}
}
export function resolveAffiliateReferralCode(...values: unknown[]): string {
const code = pickOAuthAffiliateCode(...values)
if (code) {
storeAffiliateReferralCode(code)
return code
}
return loadAffiliateReferralCode()
}
export function storeOAuthAffiliateCode(value?: unknown): void {
if (typeof window === 'undefined') {
return
}
const code = normalizeOAuthAffiliateCode(value)
try {
if (code) {
window.sessionStorage.setItem(OAUTH_AFFILIATE_CODE_KEY, code)
} else {
window.sessionStorage.removeItem(OAUTH_AFFILIATE_CODE_KEY)
}
} catch {
// 忽略浏览器存储异常。
}
}
export function loadOAuthAffiliateCode(): string {
if (typeof window === 'undefined') {
return ''
}
try {
return normalizeOAuthAffiliateCode(window.sessionStorage.getItem(OAUTH_AFFILIATE_CODE_KEY))
} catch {
return ''
}
}
export function clearOAuthAffiliateCode(): void {
if (typeof window === 'undefined') {
return
}
try {
window.sessionStorage.removeItem(OAUTH_AFFILIATE_CODE_KEY)
} catch {
// 忽略浏览器存储异常。
}
}
export function clearAllAffiliateReferralCodes(): void {
clearOAuthAffiliateCode()
clearAffiliateReferralCode()
}
export function oauthAffiliatePayload(value?: unknown): { aff_code?: string } {
const code = normalizeOAuthAffiliateCode(value)
return code ? { aff_code: code } : {}
}
...@@ -3898,6 +3898,56 @@ ...@@ -3898,6 +3898,56 @@
</p> </p>
</div> </div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.freezeHours') }}
</label>
<input
v-model.number="form.affiliate_rebate_freeze_hours"
type="number"
step="1"
min="0"
max="720"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.freezeHoursDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.durationDays') }}
</label>
<input
v-model.number="form.affiliate_rebate_duration_days"
type="number"
step="1"
min="0"
max="3650"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.durationDaysDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.perInviteeCap') }}
</label>
<input
v-model.number="form.affiliate_rebate_per_invitee_cap"
type="number"
step="0.01"
min="0"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.perInviteeCapDesc') }}
</p>
</div>
<!-- 专属用户管理 --> <!-- 专属用户管理 -->
<div class="border-t border-gray-100 pt-6 dark:border-dark-700"> <div class="border-t border-gray-100 pt-6 dark:border-dark-700">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
...@@ -5333,6 +5383,9 @@ const form = reactive<SettingsForm>({ ...@@ -5333,6 +5383,9 @@ const form = reactive<SettingsForm>({
totp_encryption_key_configured: false, totp_encryption_key_configured: false,
default_balance: 0, default_balance: 0,
affiliate_rebate_rate: 20, affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
affiliate_rebate_duration_days: 0,
affiliate_rebate_per_invitee_cap: 0,
default_concurrency: 1, default_concurrency: 1,
default_subscriptions: [], default_subscriptions: [],
force_email_on_third_party_signup: false, force_email_on_third_party_signup: false,
...@@ -6261,6 +6314,9 @@ async function saveSettings() { ...@@ -6261,6 +6314,9 @@ async function saveSettings() {
100, 100,
Math.max(0, Number(form.affiliate_rebate_rate) || 0), Math.max(0, Number(form.affiliate_rebate_rate) || 0),
), ),
affiliate_rebate_freeze_hours: Math.max(0, Math.min(720, Number(form.affiliate_rebate_freeze_hours) || 0)),
affiliate_rebate_duration_days: Math.max(0, Math.min(3650, Math.floor(Number(form.affiliate_rebate_duration_days) || 0))),
affiliate_rebate_per_invitee_cap: Math.max(0, Number(form.affiliate_rebate_per_invitee_cap) || 0),
default_concurrency: form.default_concurrency, default_concurrency: form.default_concurrency,
default_subscriptions: normalizedDefaultSubscriptions, default_subscriptions: normalizedDefaultSubscriptions,
force_email_on_third_party_signup: form.force_email_on_third_party_signup, force_email_on_third_party_signup: form.force_email_on_third_party_signup,
......
...@@ -167,6 +167,11 @@ import { ...@@ -167,6 +167,11 @@ import {
isRegistrationEmailSuffixAllowed, isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy' } from '@/utils/registrationEmailPolicy'
import {
clearAllAffiliateReferralCodes,
loadAffiliateReferralCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n() const { t, locale } = useI18n()
...@@ -261,7 +266,7 @@ onMounted(async () => { ...@@ -261,7 +266,7 @@ onMounted(async () => {
initialTurnstileToken.value = registerData.turnstile_token || '' initialTurnstileToken.value = registerData.turnstile_token || ''
promoCode.value = registerData.promo_code || '' promoCode.value = registerData.promo_code || ''
invitationCode.value = registerData.invitation_code || '' invitationCode.value = registerData.invitation_code || ''
affCode.value = registerData.aff_code || '' affCode.value = registerData.aff_code || loadAffiliateReferralCode()
pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || '' pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || ''
pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token' pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token'
pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || '' pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || ''
...@@ -501,6 +506,7 @@ async function handleVerify(): Promise<void> { ...@@ -501,6 +506,7 @@ async function handleVerify(): Promise<void> {
password: password.value, password: password.value,
verify_code: verifyCode.value.trim(), verify_code: verifyCode.value.trim(),
invitation_code: invitationCode.value || undefined, invitation_code: invitationCode.value || undefined,
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName, adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName,
adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar
} }
...@@ -533,6 +539,7 @@ async function handleVerify(): Promise<void> { ...@@ -533,6 +539,7 @@ async function handleVerify(): Promise<void> {
// Clear session data // Clear session data
sessionStorage.removeItem('register_data') sessionStorage.removeItem('register_data')
clearAllAffiliateReferralCodes()
// Show success toast // Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value })) appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
......
...@@ -255,6 +255,11 @@ import { ...@@ -255,6 +255,11 @@ import {
type OAuthTokenResponse, type OAuthTokenResponse,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
} from '@/api/auth' } from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
...@@ -568,6 +573,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -568,6 +573,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession() clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage) appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect) await router.replace(bindRedirect)
return return
...@@ -579,6 +585,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -579,6 +585,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion) persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
} }
...@@ -627,18 +634,20 @@ async function handleSubmitInvitation() { ...@@ -627,18 +634,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true isSubmitting.value = true
try { try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value
? ( ? (
await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', { await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value, pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(), invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision()) ...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
}) })
).data ).data
: await completeLinuxDoOAuthRegistration( : affCode
invitationCode.value.trim(), ? await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision, affCode)
currentAdoptionDecision() : await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision)
)
await finalizePendingAccountResponse(completion) await finalizePendingAccountResponse(completion)
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } } const err = e as { message?: string; response?: { data?: { message?: string } } }
...@@ -673,6 +682,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) { ...@@ -673,6 +682,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password, password: payload.password,
verify_code: payload.verifyCode || undefined, verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined, invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision()) ...serializeAdoptionDecision(currentAdoptionDecision())
}) })
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
...@@ -720,6 +730,7 @@ async function handleSubmitTotpChallenge() { ...@@ -720,6 +730,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code totp_code: code
}) })
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value) await router.replace(redirectTo.value)
} catch (e: unknown) { } catch (e: unknown) {
...@@ -743,6 +754,7 @@ onMounted(async () => { ...@@ -743,6 +754,7 @@ onMounted(async () => {
if (legacyLogin) { if (legacyLogin) {
persistOAuthTokenContext(legacyLogin) persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token) await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
return return
......
...@@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue' ...@@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth' import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
import type { TotpLoginResponse } from '@/types' import type { TotpLoginResponse } from '@/types'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n() const { t } = useI18n()
...@@ -355,6 +356,7 @@ async function handleLogin(): Promise<void> { ...@@ -355,6 +356,7 @@ async function handleLogin(): Promise<void> {
} }
// Show success toast // Show success toast
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route // Redirect to dashboard or intended route
...@@ -397,6 +399,7 @@ async function handle2FAVerify(code: string): Promise<void> { ...@@ -397,6 +399,7 @@ async function handle2FAVerify(code: string): Promise<void> {
// Close modal and show success // Close modal and show success
show2FAModal.value = false show2FAModal.value = false
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route // Redirect to dashboard or intended route
......
...@@ -264,6 +264,11 @@ import { ...@@ -264,6 +264,11 @@ import {
type OAuthTokenResponse, type OAuthTokenResponse,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
} from '@/api/auth' } from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
...@@ -590,6 +595,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -590,6 +595,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession() clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage) appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect) await router.replace(bindRedirect)
return return
...@@ -601,6 +607,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -601,6 +607,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion) persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
} }
...@@ -649,18 +656,20 @@ async function handleSubmitInvitation() { ...@@ -649,18 +656,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true isSubmitting.value = true
try { try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingOidcCompletion = legacyPendingOAuthToken.value const completion: PendingOidcCompletion = legacyPendingOAuthToken.value
? ( ? (
await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', { await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value, pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(), invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision()) ...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
}) })
).data ).data
: await completeOIDCOAuthRegistration( : affCode
invitationCode.value.trim(), ? await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision, affCode)
currentAdoptionDecision() : await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision)
)
await finalizePendingAccountResponse(completion) await finalizePendingAccountResponse(completion)
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } } const err = e as { message?: string; response?: { data?: { message?: string } } }
...@@ -695,6 +704,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) { ...@@ -695,6 +704,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password, password: payload.password,
verify_code: payload.verifyCode || undefined, verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined, invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision()) ...serializeAdoptionDecision(currentAdoptionDecision())
}) })
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
...@@ -742,6 +752,7 @@ async function handleSubmitTotpChallenge() { ...@@ -742,6 +752,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code totp_code: code
}) })
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value) await router.replace(redirectTo.value)
} catch (e: unknown) { } catch (e: unknown) {
...@@ -767,6 +778,7 @@ onMounted(async () => { ...@@ -767,6 +778,7 @@ onMounted(async () => {
if (legacyLogin) { if (legacyLogin) {
persistOAuthTokenContext(legacyLogin) persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token) await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
return return
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment