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,544 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Setting, User, Coin, Refresh, Search, Check } from '@element-plus/icons-vue'
import {
fetchAdminRebateConfig,
updateAdminRebateConfig,
type InvitationRebateConfig,
} from '@/features/invitation/api/invitation'
import {
fetchDistributionSites,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import { apiClient } from '@/shared/api/client'
import { formatCent } from '@/shared/utils/money'
import { readError } from '@/shared/utils/error'
const activeTab = ref<'config' | 'invitations' | 'rebates'>('config')
// 站点列表
const sites = ref<DistributionSite[]>([])
const selectedSiteId = ref(0)
const loadingConfig = ref(false)
const savingConfig = ref(false)
const configForm = reactive({
firstMonthRate: 60,
secondMonthRate: 30,
subsequentRate: 15,
enabled: true,
})
// 邀请关系列表
const loadingInvitations = ref(false)
const invitationQuery = reactive({
phone: '',
invite_code: '',
distribution_site_id: undefined as number | undefined,
page: 1,
pageSize: 20,
})
const invitations = ref<any[]>([])
const invitationsTotal = ref(0)
// 返利流水列表
const loadingRebates = ref(false)
const rebateQuery = reactive({
order_no: '',
inviter_phone: '',
invitee_phone: '',
distribution_site_id: undefined as number | undefined,
page: 1,
pageSize: 20,
})
const rebates = ref<any[]>([])
const rebatesTotal = ref(0)
onMounted(async () => {
await loadSites()
await loadConfig()
})
async function loadSites() {
try {
const list = await fetchDistributionSites()
sites.value = list
} catch {
// 忽略加载分站失败
}
}
async function loadConfig() {
loadingConfig.value = true
try {
const cfg: InvitationRebateConfig = await fetchAdminRebateConfig(selectedSiteId.value)
configForm.firstMonthRate = Math.round((cfg.first_month_rate || 0.6) * 100)
configForm.secondMonthRate = Math.round((cfg.second_month_rate || 0.3) * 100)
configForm.subsequentRate = Math.round((cfg.subsequent_rate || 0.15) * 100)
configForm.enabled = cfg.enabled !== false
} catch (err) {
ElMessage.error(readError(err, '获取返利配置失败'))
} finally {
loadingConfig.value = false
}
}
async function handleSaveConfig() {
if (
configForm.firstMonthRate < 0 ||
configForm.firstMonthRate > 100 ||
configForm.secondMonthRate < 0 ||
configForm.secondMonthRate > 100 ||
configForm.subsequentRate < 0 ||
configForm.subsequentRate > 100
) {
ElMessage.warning('分成比例必须在 0% 到 100% 之间')
return
}
savingConfig.value = true
try {
await updateAdminRebateConfig(selectedSiteId.value, {
first_month_rate: Number((configForm.firstMonthRate / 100).toFixed(4)),
second_month_rate: Number((configForm.secondMonthRate / 100).toFixed(4)),
subsequent_rate: Number((configForm.subsequentRate / 100).toFixed(4)),
enabled: configForm.enabled,
})
ElMessage.success('返利比例配置已保存')
} catch (err) {
ElMessage.error(readError(err, '保存配置失败'))
} finally {
savingConfig.value = false
}
}
async function loadInvitations() {
loadingInvitations.value = true
try {
const params: Record<string, any> = {
page: invitationQuery.page,
page_size: invitationQuery.pageSize,
}
if (invitationQuery.phone.trim()) params.phone = invitationQuery.phone.trim()
if (invitationQuery.invite_code.trim()) params.invite_code = invitationQuery.invite_code.trim()
if (invitationQuery.distribution_site_id !== undefined) {
params.distribution_site_id = invitationQuery.distribution_site_id
}
const { data } = await apiClient.get('/invitations', { params })
invitations.value = data.data.items || []
invitationsTotal.value = data.data.total || 0
} catch (err) {
ElMessage.error(readError(err, '获取邀请关系失败'))
} finally {
loadingInvitations.value = false
}
}
async function loadRebates() {
loadingRebates.value = true
try {
const params: Record<string, any> = {
page: rebateQuery.page,
page_size: rebateQuery.pageSize,
}
if (rebateQuery.order_no.trim()) params.order_no = rebateQuery.order_no.trim()
if (rebateQuery.inviter_phone.trim()) params.inviter_phone = rebateQuery.inviter_phone.trim()
if (rebateQuery.invitee_phone.trim()) params.invitee_phone = rebateQuery.invitee_phone.trim()
if (rebateQuery.distribution_site_id !== undefined) {
params.distribution_site_id = rebateQuery.distribution_site_id
}
const { data } = await apiClient.get('/invitations/rebates', { params })
rebates.value = data.data.items || []
rebatesTotal.value = data.data.total || 0
} catch (err) {
ElMessage.error(readError(err, '获取返利流水失败'))
} finally {
loadingRebates.value = false
}
}
function handleTabChange(tab: 'config' | 'invitations' | 'rebates') {
activeTab.value = tab
if (tab === 'invitations') {
void loadInvitations()
} else if (tab === 'rebates') {
void loadRebates()
}
}
function cycleLabel(cycle: string) {
switch (cycle) {
case 'first_month':
return '首月高额'
case 'second_month':
return '次月奖励'
default:
return '长期分成'
}
}
</script>
<template>
<div class="admin-invitations-page">
<div class="page-header">
<h2>邀请返利与分成管理</h2>
<p class="page-desc">
配置主站及各分销站点的返利阶梯比例(首月、次月、往后永久),监控全网邀请关系与返利流水。
</p>
</div>
<!-- 选项卡导航 -->
<div class="nav-tabs">
<button
class="nav-tab-btn"
:class="{ active: activeTab === 'config' }"
@click="handleTabChange('config')"
>
<el-icon><Setting /></el-icon> 返利比例配置
</button>
<button
class="nav-tab-btn"
:class="{ active: activeTab === 'invitations' }"
@click="handleTabChange('invitations')"
>
<el-icon><User /></el-icon> 全网邀请关系 ({{ invitationsTotal }})
</button>
<button
class="nav-tab-btn"
:class="{ active: activeTab === 'rebates' }"
@click="handleTabChange('rebates')"
>
<el-icon><Coin /></el-icon> 返利结算流水 ({{ rebatesTotal }})
</button>
</div>
<!-- 配置面板 -->
<div v-if="activeTab === 'config'" class="panel-card">
<div class="panel-header">
<div class="site-selector-row">
<label>选择配置生效目标:</label>
<el-select v-model="selectedSiteId" style="width: 260px" @change="loadConfig">
<el-option :value="0" label="主站(默认基础配置)" />
<el-option
v-for="s in sites"
:key="s.id"
:value="s.id"
:label="`分销站: ${s.site_name} (${s.site_code})`"
/>
</el-select>
<el-button :icon="Refresh" circle @click="loadConfig" />
</div>
</div>
<div v-loading="loadingConfig" class="config-form-body">
<el-form label-position="top" style="max-width: 600px">
<el-form-item label="功能开关">
<el-switch
v-model="configForm.enabled"
active-text="启用邀请返利"
inactive-text="暂停该站点返利"
/>
<div class="field-hint">关闭后该站点产生的新订单将不计算并发放邀请返利</div>
</el-form-item>
<el-form-item label="首月分成比例(实名生效 1 ~ 30 天)">
<div class="rate-input-box">
<el-input-number v-model="configForm.firstMonthRate" :min="0" :max="100" :step="5" />
<span class="rate-unit">%</span>
</div>
<div class="field-hint">默认 60%,按平台每笔订单单边毛利计算分成基数</div>
</el-form-item>
<el-form-item label="次月分成比例(实名生效 31 ~ 60 天)">
<div class="rate-input-box">
<el-input-number v-model="configForm.secondMonthRate" :min="0" :max="100" :step="5" />
<span class="rate-unit">%</span>
</div>
<div class="field-hint">默认 30%</div>
</el-form-item>
<el-form-item label="往后长期分成比例(实名生效 60 天后永久)">
<div class="rate-input-box">
<el-input-number v-model="configForm.subsequentRate" :min="0" :max="100" :step="1" />
<span class="rate-unit">%</span>
</div>
<div class="field-hint">默认 15%</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:icon="Check"
:loading="savingConfig"
@click="handleSaveConfig"
>
保存比例配置
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 邀请关系面板 -->
<div v-if="activeTab === 'invitations'" class="panel-card">
<div class="filter-bar">
<el-input
v-model="invitationQuery.phone"
placeholder="搜索手机号"
clearable
style="width: 180px"
@keyup.enter="loadInvitations"
/>
<el-input
v-model="invitationQuery.invite_code"
placeholder="搜索邀请码"
clearable
style="width: 150px"
@keyup.enter="loadInvitations"
/>
<el-button type="primary" :icon="Search" @click="loadInvitations">查询</el-button>
<el-button :icon="Refresh" @click="loadInvitations">刷新</el-button>
</div>
<el-table
v-loading="loadingInvitations"
:data="invitations"
border
style="width: 100%; margin-top: 16px"
>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column label="邀请人" min-width="150">
<template #default="{ row }">
<div>{{ row.inviter_nickname || '-' }}</div>
<code style="font-size: 12px; color: #64748b">{{ row.inviter_phone }}</code>
</template>
</el-table-column>
<el-table-column label="被邀请人" min-width="150">
<template #default="{ row }">
<div>{{ row.invitee_nickname || '-' }}</div>
<code style="font-size: 12px; color: #64748b">{{ row.invitee_phone }}</code>
</template>
</el-table-column>
<el-table-column prop="invite_code" label="使用邀请码" width="120">
<template #default="{ row }">
<el-tag size="small">{{ row.invite_code }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="认证生效状态" width="130">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'warning'" size="small">
{{ row.status === 'active' ? '已实名 (生效中)' : '待实名 (未生效)' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="verified_at" label="实名生效时间" min-width="160">
<template #default="{ row }">
{{ row.verified_at ? row.verified_at.slice(0, 19).replace('T', ' ') : '-' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="绑定时间" min-width="160">
<template #default="{ row }">
{{ row.created_at ? row.created_at.slice(0, 19).replace('T', ' ') : '-' }}
</template>
</el-table-column>
</el-table>
<div class="pagination-box">
<el-pagination
v-model:current-page="invitationQuery.page"
v-model:page-size="invitationQuery.pageSize"
:total="invitationsTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@change="loadInvitations"
/>
</div>
</div>
<!-- 返利流水面板 -->
<div v-if="activeTab === 'rebates'" class="panel-card">
<div class="filter-bar">
<el-input
v-model="rebateQuery.order_no"
placeholder="订单号"
clearable
style="width: 180px"
@keyup.enter="loadRebates"
/>
<el-input
v-model="rebateQuery.inviter_phone"
placeholder="邀请人手机号"
clearable
style="width: 150px"
@keyup.enter="loadRebates"
/>
<el-input
v-model="rebateQuery.invitee_phone"
placeholder="被邀请人手机号"
clearable
style="width: 150px"
@keyup.enter="loadRebates"
/>
<el-button type="primary" :icon="Search" @click="loadRebates">查询</el-button>
<el-button :icon="Refresh" @click="loadRebates">刷新</el-button>
</div>
<el-table
v-loading="loadingRebates"
:data="rebates"
border
style="width: 100%; margin-top: 16px"
>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="关联订单号" min-width="160">
<template #default="{ row }">
<code>{{ row.order_no }}</code>
</template>
</el-table-column>
<el-table-column prop="inviter_phone" label="收益人(邀请人)" width="130" />
<el-table-column prop="invitee_phone" label="交易人(被邀请人)" width="130" />
<el-table-column prop="trade_role" label="交易端" width="100">
<template #default="{ row }">
<el-tag :type="row.trade_role === 'buyer' ? 'primary' : 'danger'" size="small">
{{ row.trade_role === 'buyer' ? '买家端' : '卖家端' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="收益基数" width="110">
<template #default="{ row }">
¥{{ formatCent(row.single_side_basis_cent || 0) }}
</template>
</el-table-column>
<el-table-column label="提成比例" width="90">
<template #default="{ row }"> {{ Math.round((row.rate_applied || 0) * 100) }}% </template>
</el-table-column>
<el-table-column prop="cycle_stage" label="周期阶段" width="100">
<template #default="{ row }">
{{ cycleLabel(row.cycle_stage) }}
</template>
</el-table-column>
<el-table-column label="返利金额" width="110">
<template #default="{ row }">
<strong style="color: #dc2626">+¥{{ formatCent(row.rebate_amount_cent || 0) }}</strong>
</template>
</el-table-column>
<el-table-column prop="settled_at" label="入账时间" min-width="160">
<template #default="{ row }">
{{ row.settled_at ? row.settled_at.slice(0, 19).replace('T', ' ') : '-' }}
</template>
</el-table-column>
</el-table>
<div class="pagination-box">
<el-pagination
v-model:current-page="rebateQuery.page"
v-model:page-size="rebateQuery.pageSize"
:total="rebatesTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@change="loadRebates"
/>
</div>
</div>
</div>
</template>
<style scoped>
.admin-invitations-page {
padding: 20px;
}
.page-header h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #1e293b;
}
.page-desc {
margin: 6px 0 18px;
font-size: 13px;
color: #64748b;
}
.nav-tabs {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.nav-tab-btn {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #475569;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.nav-tab-btn:hover {
background: #f8fafc;
}
.nav-tab-btn.active {
background: #2563eb;
color: #ffffff;
border-color: #2563eb;
}
.panel-card {
background: #ffffff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
border: 1px solid #f1f5f9;
}
.site-selector-row {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 18px;
border-bottom: 1px solid #f1f5f9;
margin-bottom: 20px;
}
.rate-input-box {
display: flex;
align-items: center;
gap: 8px;
}
.rate-unit {
font-weight: 700;
color: #64748b;
}
.field-hint {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.pagination-box {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>