feat(invitation): 实现邀请返利系统、专属注册落地页与分成管理

- 数据库与数据模型:新增 migration 000081,支持用户专属邀请码、被邀请人绑定与实名时间戳;新增邀请返利配置表与结算明细表
- 业务与结算逻辑:实现首月 60%、次月 30%、往后 15% 阶梯返利算法;买卖双向独立寻源并根据订单平台利润 50% 单边基准核算;与终态订单结算和钱包可用余额强幂等联动
- 分销站点支持:支持主站与加盟分站独立配置返利比例;跨站交易按照收号端与售卖端独立出资,资金互不侵蚀
- 前端交互与入口:
  - 新增专属短链注册落地页 /i/:code,自动带入并锁定邀请码,引导实名认证
  - 个人中心与移动端新增邀请返利看板(专属码、推广链接、统计面板、好友记录与返利流水明细)
  - 管理后台新增邀请返利管理(比例配置、邀请关系与返利流水审计)
- 验证方式:
  - 通过 repository_test.go 单元测试(阶梯区间、实名激活、跨站多方分流验证)
  - 通过 npm run format、npm run check 与 scripts/check.sh 全量门禁校验
This commit is contained in:
yml2213
2026-10-10 12:48:59 +08:00
parent e92668689b
commit 984433f866
26 changed files with 4292 additions and 16 deletions
@@ -0,0 +1,139 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types'
export interface InviteCodeInfo {
valid: boolean
invite_code: string
inviter_nickname?: string
inviter_avatar?: string
}
export interface UserInvitationSummary {
user_id: number
has_invite_code: boolean
invite_code: string
is_realname_verified: boolean
total_rebate_cent: number
month_rebate_cent: number
total_invited_count: number
active_invited_count: number
first_month_rate: number
second_month_rate: number
subsequent_rate: number
}
export interface InvitedUserRecord {
id: number
invitee_id: number
invitee_phone: string
invitee_nickname: string
status: string
verified_at?: string
created_at: string
}
export interface RebateLedger {
id: number
order_id: number
order_no: string
trade_role: 'buyer' | 'seller'
order_profit_basis_cent: number
single_side_basis_cent: number
rate_applied: number
days_since_verified: number
cycle_stage: 'first_month' | 'second_month' | 'subsequent'
rebate_amount_cent: number
status: string
settled_at: string
}
export interface PaginatedResult<T> {
items: T[]
total: number
page: number
page_size: number
}
export interface InvitationRebateConfig {
distribution_site_id: number
first_month_rate: number
second_month_rate: number
subsequent_rate: number
enabled: boolean
updated_at?: string
}
/** 公开获取邀请码信息(用于注册落地页) */
export async function fetchInviteCodeInfo(code: string): Promise<InviteCodeInfo> {
const { data } = await apiClient.get<ApiResponse<InviteCodeInfo>>(
`/invitations/code-info/${encodeURIComponent(code)}`
)
return data.data
}
/** 获取当前用户的邀请看板汇总数据 */
export async function fetchUserInvitationSummary(): Promise<UserInvitationSummary> {
const { data } = await apiClient.get<ApiResponse<UserInvitationSummary>>(
'/user/invitations/summary'
)
return data.data
}
/** 获取当前用户邀请的好友列表 */
export async function fetchMyInvitedUsers(
page = 1,
pageSize = 20
): Promise<PaginatedResult<InvitedUserRecord>> {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<InvitedUserRecord>>>(
'/user/invitations/records',
{
params: { page, page_size: pageSize },
}
)
return data.data
}
/** 获取当前用户的返利流水明细 */
export async function fetchMyRebates(
page = 1,
pageSize = 20
): Promise<PaginatedResult<RebateLedger>> {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<RebateLedger>>>(
'/user/invitations/rebates',
{
params: { page, page_size: pageSize },
}
)
return data.data
}
/** 管理端获取返利比例配置(siteId 为 0 时为主站) */
export async function fetchAdminRebateConfig(siteId = 0): Promise<InvitationRebateConfig> {
const { data } = await apiClient.get<ApiResponse<InvitationRebateConfig>>(
'/invitations/configs',
{
params: { distribution_site_id: siteId },
}
)
return data.data
}
/** 管理端更新返利比例配置 */
export async function updateAdminRebateConfig(
siteId: number,
config: {
first_month_rate: number
second_month_rate: number
subsequent_rate: number
enabled?: boolean
}
): Promise<InvitationRebateConfig> {
const { data } = await apiClient.put<ApiResponse<InvitationRebateConfig>>(
'/invitations/configs',
config,
{
params: { distribution_site_id: siteId },
}
)
return data.data
}
@@ -0,0 +1,664 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Lock,
Iphone,
Key,
View,
Hide,
Present,
WarningFilled,
Check,
} from '@element-plus/icons-vue'
import { fetchAuthCaptcha, sendSmsCode } from '@/features/auth/api/auth'
import { fetchInviteCodeInfo, type InviteCodeInfo } from '@/features/invitation/api/invitation'
import { useSessionStore } from '@/stores/session'
import { useDistributionSiteStore } from '@/stores/distributionSite'
import { readError } from '@/shared/utils/error'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const siteStore = useDistributionSiteStore()
const inviteCode = computed(() => String(route.params.code || '').trim())
const loadingInfo = ref(true)
const codeInfo = ref<InviteCodeInfo | null>(null)
// 注册表单状态
const form = reactive({
phone: '',
captchaCode: '',
smsCode: '',
password: '',
confirmPassword: '',
})
const showPassword = ref(false)
const showConfirmPassword = ref(false)
const submitting = ref(false)
// 图形验证码状态
const captchaId = ref('')
const captchaImage = ref('')
const loadingCaptcha = ref(false)
// 短信倒计时
const smsCountdown = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await Promise.all([loadCodeInfo(), refreshCaptcha()])
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
async function loadCodeInfo() {
if (!inviteCode.value) {
loadingInfo.value = false
return
}
loadingInfo.value = true
try {
const res = await fetchInviteCodeInfo(inviteCode.value)
codeInfo.value = res
} catch (_err) {
ElMessage.warning('邀请码查询失败,请核对邀请链接')
} finally {
loadingInfo.value = false
}
}
async function refreshCaptcha() {
loadingCaptcha.value = true
try {
const res = await fetchAuthCaptcha()
captchaId.value = res.captcha_id
captchaImage.value = res.image
} catch (_err) {
ElMessage.error('获取图形验证码失败,请点击刷新')
} finally {
loadingCaptcha.value = false
}
}
async function handleSendSms() {
if (!/^1[3-9]\d{9}$/.test(form.phone)) {
ElMessage.warning('请输入正确的11位手机号码')
return
}
if (!form.captchaCode) {
ElMessage.warning('请先输入图形验证码')
return
}
if (smsCountdown.value > 0) return
try {
await sendSmsCode(form.phone, captchaId.value, form.captchaCode)
ElMessage.success('短信验证码已发送')
smsCountdown.value = 60
timer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
} catch (err) {
ElMessage.error(readError(err, '发送验证码失败'))
await refreshCaptcha()
}
}
async function handleSubmit() {
if (!/^1[3-9]\d{9}$/.test(form.phone)) {
ElMessage.warning('请输入正确的11位手机号码')
return
}
if (!form.smsCode || form.smsCode.length !== 6) {
ElMessage.warning('请输入6位短信验证码')
return
}
if (!form.password || form.password.length < 8 || form.password.length > 20) {
ElMessage.warning('密码长度必须为8-20位')
return
}
if (form.password !== form.confirmPassword) {
ElMessage.warning('两次输入的密码不一致')
return
}
submitting.value = true
try {
await session.register(form.phone, form.smsCode, form.password, inviteCode.value)
ElMessageBox.confirm(
'账号注册成功并已绑定邀请人!根据平台规定,完成实名认证后方可正式激活会员特权与邀请返利资格。是否立即前往实名认证?',
'注册成功',
{
confirmButtonText: '去实名认证',
cancelButtonText: '稍后再说',
type: 'success',
distinguishCancelAndClose: true,
}
)
.then(() => {
router.replace('/realname')
})
.catch(() => {
router.replace('/')
})
} catch (err) {
ElMessage.error(readError(err, '注册失败'))
await refreshCaptcha()
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="invite-register-page">
<div class="invite-card-container">
<!-- 顶部邀请人特权卡片 -->
<div class="invite-header-banner">
<div class="banner-badge">
<el-icon><Present /></el-icon>
<span>专属邀请专享特权</span>
</div>
<h1 class="banner-title">加入 {{ siteStore.siteName || '哈夫币' }}</h1>
<p class="banner-sub">安全便捷的游戏资产租赁与寄售平台 · 租客号主双边返利</p>
<div v-if="loadingInfo" class="inviter-box loading">
<span>正在核对邀请信息...</span>
</div>
<div v-else-if="codeInfo && codeInfo.valid" class="inviter-box valid">
<div class="inviter-avatar">
<img v-if="codeInfo.inviter_avatar" :src="codeInfo.inviter_avatar" alt="avatar" />
<span v-else>{{ (codeInfo.inviter_nickname || '友').slice(0, 1) }}</span>
</div>
<div class="inviter-meta">
<div class="inviter-name">
来自 <strong>{{ codeInfo.inviter_nickname || '平台认证号主' }}</strong> 的邀请
</div>
<div class="inviter-desc">
专属邀请码:<code class="code-pill">{{ inviteCode }}</code>
</div>
</div>
</div>
<div v-else class="inviter-box invalid">
<el-icon color="#e6a23c"><WarningFilled /></el-icon>
<span
>邀请码
<strong>{{ inviteCode }}</strong> 暂未生效或邀请人尚未实名,注册仍可正常完成</span
>
</div>
</div>
<!-- 返利权益亮点条 -->
<div class="rebate-perks-row">
<div class="perk-item">
<span class="perk-rate">60%</span>
<span class="perk-label">首月高额分成</span>
</div>
<div class="perk-divider"></div>
<div class="perk-item">
<span class="perk-rate">30%</span>
<span class="perk-label">次月奖励分成</span>
</div>
<div class="perk-divider"></div>
<div class="perk-item">
<span class="perk-rate">15%</span>
<span class="perk-label">往后终身永久</span>
</div>
</div>
<!-- 注册表单卡片 -->
<div class="register-card-body">
<div class="form-title">创建新账号</div>
<form class="register-form" @submit.prevent="handleSubmit">
<!-- 手机号 -->
<div class="form-group">
<label>手机号码</label>
<div class="input-wrapper">
<el-icon class="input-icon"><Iphone /></el-icon>
<input
v-model="form.phone"
type="tel"
maxlength="11"
placeholder="请输入11位手机号码"
required
/>
</div>
</div>
<!-- 图形验证码 -->
<div class="form-group">
<label>图形验证码</label>
<div class="input-wrapper captcha-wrapper">
<el-icon class="input-icon"><Key /></el-icon>
<input
v-model="form.captchaCode"
type="text"
maxlength="4"
placeholder="请输入图形验证码"
required
/>
<div class="captcha-img-box" @click="refreshCaptcha">
<img v-if="captchaImage" :src="captchaImage" alt="captcha" />
<span v-else>点击获取</span>
</div>
</div>
</div>
<!-- 短信验证码 -->
<div class="form-group">
<label>短信验证码</label>
<div class="input-wrapper sms-wrapper">
<el-icon class="input-icon"><Key /></el-icon>
<input
v-model="form.smsCode"
type="text"
maxlength="6"
placeholder="6位短信验证码"
required
/>
<button
type="button"
class="sms-btn"
:disabled="smsCountdown > 0"
@click="handleSendSms"
>
{{ smsCountdown > 0 ? `${smsCountdown}秒后重试` : '获取验证码' }}
</button>
</div>
</div>
<!-- 密码 -->
<div class="form-group">
<label>登录密码</label>
<div class="input-wrapper">
<el-icon class="input-icon"><Lock /></el-icon>
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
placeholder="设置登录密码(8-20位)"
required
/>
<button type="button" class="pwd-toggle" @click="showPassword = !showPassword">
<el-icon><View v-if="showPassword" /><Hide v-else /></el-icon>
</button>
</div>
</div>
<!-- 确认密码 -->
<div class="form-group">
<label>确认密码</label>
<div class="input-wrapper">
<el-icon class="input-icon"><Lock /></el-icon>
<input
v-model="form.confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
placeholder="再次输入登录密码"
required
/>
<button
type="button"
class="pwd-toggle"
@click="showConfirmPassword = !showConfirmPassword"
>
<el-icon><View v-if="showConfirmPassword" /><Hide v-else /></el-icon>
</button>
</div>
</div>
<!-- 邀请码锁定提示 -->
<div class="invite-bind-notice">
<div class="bind-badge">
<el-icon color="#67c23a"><Check /></el-icon>
<span
>已绑定邀请码:<strong>{{ inviteCode }}</strong></span
>
</div>
<p class="bind-tip">注册成功后将自动建立推荐返利关联</p>
</div>
<!-- 提交按钮 -->
<button type="submit" class="submit-btn" :disabled="submitting">
{{ submitting ? '正在注册并绑定...' : '立即注册并领取返利特权' }}
</button>
</form>
<div class="login-redirect">
已有账号?
<RouterLink to="/login" class="login-link">直接登录</RouterLink>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.invite-register-page {
min-height: 100vh;
background: linear-gradient(135deg, #f0f4ff 0%, #e8edf9 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
box-sizing: border-box;
}
.invite-card-container {
width: 100%;
max-width: 480px;
background: #ffffff;
border-radius: 20px;
box-shadow: 0 12px 40px rgba(30, 58, 138, 0.08);
overflow: hidden;
border: 1px solid rgba(226, 232, 240, 0.8);
}
.invite-header-banner {
background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%);
color: #ffffff;
padding: 28px 24px 20px;
text-align: center;
}
.banner-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.18);
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
margin-bottom: 12px;
backdrop-filter: blur(4px);
}
.banner-title {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.5px;
}
.banner-sub {
margin: 8px 0 16px;
font-size: 13px;
opacity: 0.85;
line-height: 1.5;
}
.inviter-box {
background: rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 12px;
text-align: left;
backdrop-filter: blur(8px);
}
.inviter-box.invalid {
background: rgba(234, 179, 8, 0.2);
font-size: 12px;
}
.inviter-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #ffffff;
color: #2563eb;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
overflow: hidden;
flex-shrink: 0;
}
.inviter-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.inviter-meta {
flex: 1;
}
.inviter-name {
font-size: 13px;
font-weight: 500;
}
.inviter-desc {
font-size: 12px;
opacity: 0.8;
margin-top: 2px;
}
.code-pill {
background: rgba(255, 255, 255, 0.25);
padding: 1px 6px;
border-radius: 4px;
font-family: monospace;
font-weight: 700;
}
.rebate-perks-row {
display: flex;
align-items: center;
justify-content: space-around;
background: #f8fafc;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
.perk-item {
display: flex;
flex-direction: column;
align-items: center;
}
.perk-rate {
font-size: 18px;
font-weight: 800;
color: #dc2626;
line-height: 1.2;
}
.perk-label {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
.perk-divider {
width: 1px;
height: 24px;
background: #cbd5e1;
}
.register-card-body {
padding: 24px;
}
.form-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin-bottom: 16px;
}
.register-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #475569;
margin-bottom: 6px;
}
.input-wrapper {
display: flex;
align-items: center;
position: relative;
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 0 12px;
transition: all 0.2s;
}
.input-wrapper:focus-within {
border-color: #2563eb;
background: #ffffff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.input-icon {
color: #94a3b8;
font-size: 16px;
margin-right: 8px;
}
.input-wrapper input {
flex: 1;
height: 42px;
border: none;
background: transparent;
font-size: 14px;
color: #1e293b;
outline: none;
}
.captcha-img-box {
width: 96px;
height: 36px;
cursor: pointer;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #e2e8f0;
font-size: 12px;
color: #64748b;
}
.captcha-img-box img {
width: 100%;
height: 100%;
object-fit: cover;
}
.sms-btn {
background: #2563eb;
color: #ffffff;
border: none;
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.2s;
}
.sms-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
}
.pwd-toggle {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
}
.invite-bind-notice {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 10px 12px;
margin-top: 4px;
}
.bind-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #166534;
}
.bind-tip {
margin: 4px 0 0 22px;
font-size: 11px;
color: #15803d;
}
.submit-btn {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: #ffffff;
border: none;
border-radius: 10px;
height: 44px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
margin-top: 6px;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
transition: all 0.2s;
}
.submit-btn:hover:not(:disabled) {
opacity: 0.95;
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.35);
}
.submit-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
box-shadow: none;
}
.login-redirect {
text-align: center;
font-size: 13px;
color: #64748b;
margin-top: 18px;
}
.login-link {
color: #2563eb;
font-weight: 600;
text-decoration: none;
}
.login-link:hover {
text-decoration: underline;
}
</style>
File diff suppressed because it is too large Load Diff