feat(affiliate): 完善邀请返利系统

- 修复返利不到账的根因:tryClaimAffiliateRebateAudit 中 PostgreSQL 参数类型推断冲突
  - 补全 OAuth 注册路径(LinuxDo/OIDC/WeChat/Pending Flow)的邀请码绑定
  - 前端 OAuth 注册页面传递 aff_code 参数
  - 新增返利冻结期机制:可配置冻结时间,到期后自动解冻(懒解冻)
  - 新增返利有效期:绑定后 N 天内有效,过期不再产生返利
  - 新增单人返利上限:超出上限部分精确截断
  - 增强返利流程 slog 结构化日志,便于排查问题
  - 已邀请用户列表增加返利明细列
This commit is contained in:
shaw
2026-04-26 12:31:52 +08:00
parent 496469ac4e
commit 9b6dcc57bd
42 changed files with 852 additions and 104 deletions
+8 -1
View File
@@ -167,6 +167,11 @@ import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
import {
clearAllAffiliateReferralCodes,
loadAffiliateReferralCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n()
@@ -261,7 +266,7 @@ onMounted(async () => {
initialTurnstileToken.value = registerData.turnstile_token || ''
promoCode.value = registerData.promo_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 || ''
pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token'
pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || ''
@@ -501,6 +506,7 @@ async function handleVerify(): Promise<void> {
password: password.value,
verify_code: verifyCode.value.trim(),
invitation_code: invitationCode.value || undefined,
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName,
adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar
}
@@ -533,6 +539,7 @@ async function handleVerify(): Promise<void> {
// Clear session data
sessionStorage.removeItem('register_data')
clearAllAffiliateReferralCodes()
// Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
@@ -255,6 +255,11 @@ import {
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
@@ -568,6 +573,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
@@ -579,6 +585,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
@@ -627,18 +634,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value
? (
await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
})
).data
: await completeLinuxDoOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
: affCode
? await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision, affCode)
: await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
@@ -673,6 +682,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
@@ -720,6 +730,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code
})
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
@@ -743,6 +754,7 @@ onMounted(async () => {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return
+3
View File
@@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
import type { TotpLoginResponse } from '@/types'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n()
@@ -355,6 +356,7 @@ async function handleLogin(): Promise<void> {
}
// Show success toast
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
@@ -397,6 +399,7 @@ async function handle2FAVerify(code: string): Promise<void> {
// Close modal and show success
show2FAModal.value = false
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
+17 -5
View File
@@ -264,6 +264,11 @@ import {
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
@@ -590,6 +595,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
@@ -601,6 +607,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
@@ -649,18 +656,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingOidcCompletion = legacyPendingOAuthToken.value
? (
await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
})
).data
: await completeOIDCOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
: affCode
? await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision, affCode)
: await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
@@ -695,6 +704,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
@@ -742,6 +752,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code
})
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
@@ -767,6 +778,7 @@ onMounted(async () => {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return
+34 -6
View File
@@ -15,17 +15,20 @@
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:provider-name="oidcOAuthProviderName"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<div class="flex items-center gap-3">
@@ -293,6 +296,11 @@ import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
import {
clearAffiliateReferralCode,
loadAffiliateReferralCode,
resolveAffiliateReferralCode
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n()
@@ -378,9 +386,19 @@ watch(validationToastMessage, (value, previousValue) => {
}
})
function syncAffiliateReferralCode(): string {
const code = resolveAffiliateReferralCode(route.query.aff, route.query.aff_code)
if (code) {
formData.aff_code = code
}
return code
}
// ==================== Lifecycle ====================
onMounted(async () => {
syncAffiliateReferralCode()
try {
const settings = await getPublicSettings()
registrationEnabled.value = settings.registration_enabled
@@ -407,10 +425,7 @@ onMounted(async () => {
await validatePromoCodeDebounced(promoParam)
}
}
const affParam = (route.query.aff as string) || (route.query.aff_code as string)
if (affParam) {
formData.aff_code = affParam.trim()
}
syncAffiliateReferralCode()
} catch (error) {
console.error('Failed to load public settings:', error)
} finally {
@@ -418,6 +433,13 @@ onMounted(async () => {
}
})
watch(
() => [route.query.aff, route.query.aff_code],
() => {
syncAffiliateReferralCode()
}
)
onUnmounted(() => {
if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout)
@@ -702,6 +724,11 @@ async function handleRegister(): Promise<void> {
isLoading.value = true
try {
const affCode = formData.aff_code.trim() || loadAffiliateReferralCode()
if (affCode) {
formData.aff_code = affCode
}
// If email verification is enabled, redirect to verification page
if (emailVerifyEnabled.value) {
// Store registration data in sessionStorage
@@ -713,7 +740,7 @@ async function handleRegister(): Promise<void> {
turnstile_token: turnstileToken.value,
promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined,
...(formData.aff_code ? { aff_code: formData.aff_code } : {})
...(affCode ? { aff_code: affCode } : {})
})
)
@@ -729,8 +756,9 @@ async function handleRegister(): Promise<void> {
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined,
...(formData.aff_code ? { aff_code: formData.aff_code } : {})
...(affCode ? { aff_code: affCode } : {})
})
clearAffiliateReferralCode()
// Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
+17 -5
View File
@@ -340,6 +340,11 @@ import {
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
@@ -802,6 +807,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
@@ -813,6 +819,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
@@ -861,18 +868,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingWeChatCompletion = legacyPendingOAuthToken.value
? (
await apiClient.post<PendingWeChatCompletion>('/auth/oauth/wechat/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
})
).data
: await completeWeChatOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
: affCode
? await completeWeChatOAuthRegistration(invitationCode.value.trim(), decision, affCode)
: await completeWeChatOAuthRegistration(invitationCode.value.trim(), decision)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
@@ -907,6 +916,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
@@ -955,6 +965,7 @@ async function handleSubmitTotpChallenge() {
})
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
@@ -1015,6 +1026,7 @@ onMounted(async () => {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return
@@ -112,6 +112,7 @@ describe('EmailVerifyView', () => {
apiClientPostMock.mockReset()
authStoreState.pendingAuthSession = null
sessionStorage.clear()
localStorage.clear()
getPublicSettingsMock.mockResolvedValue({
turnstile_enabled: false,
@@ -136,6 +137,7 @@ describe('EmailVerifyView', () => {
JSON.stringify({
email: 'fresh@example.com',
password: 'secret-123',
aff_code: 'AFF123',
})
)
@@ -334,6 +336,7 @@ describe('EmailVerifyView', () => {
email: 'fresh@example.com',
password: 'secret-123',
verify_code: '123456',
aff_code: 'AFF123',
})
expect(persistOAuthTokenContextMock).toHaveBeenCalledWith({
access_token: 'oauth-access-token',
@@ -93,6 +93,7 @@ describe('LinuxDoCallbackView', () => {
})
window.location.hash = ''
localStorage.clear()
sessionStorage.clear()
})
it('accepts the legacy fragment token success callback without pending-session exchange', async () => {
@@ -97,6 +97,7 @@ describe('OidcCallbackView', () => {
})
window.location.hash = ''
localStorage.clear()
sessionStorage.clear()
})
it('accepts the legacy fragment token success callback without pending-session exchange', async () => {
@@ -172,6 +172,7 @@ describe('WechatCallbackView', () => {
appStoreState.cachedPublicSettings = null
appStoreState.publicSettingsLoaded = false
localStorage.clear()
sessionStorage.clear()
locationState.current = {
href: 'http://localhost/auth/wechat/callback',
hash: '',