feat(affiliate): 完善邀请返利系统
- 修复返利不到账的根因:tryClaimAffiliateRebateAudit 中 PostgreSQL 参数类型推断冲突 - 补全 OAuth 注册路径(LinuxDo/OIDC/WeChat/Pending Flow)的邀请码绑定 - 前端 OAuth 注册页面传递 aff_code 参数 - 新增返利冻结期机制:可配置冻结时间,到期后自动解冻(懒解冻) - 新增返利有效期:绑定后 N 天内有效,过期不再产生返利 - 新增单人返利上限:超出上限部分精确截断 - 增强返利流程 slog 结构化日志,便于排查问题 - 已邀请用户列表增加返利明细列
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user