Commit 4e1bb2b4 authored by shaw's avatar shaw
Browse files

feat(affiliate): add feature toggle and per-user custom invite settings

- 在系统设置「功能开关」中新增邀请返利总开关,默认关闭;
  关闭态:菜单隐藏、注册忽略 aff、新充值不返利,但已有 quota 仍可转余额
- 支持管理员为指定用户设置专属邀请码(覆盖随机码,全局唯一)
- 支持管理员为指定用户设置专属返利比例(覆盖全局比例,可单条/批量调整)
- 在系统设置邀请返利卡片内嵌入专属用户管理表格(搜索/编辑/批量/删除),
  删除采用项目通用 ConfirmDialog,会同时清除专属比例并把邀请码重置为系统随机码
- /affiliate 用户页新增「我的返利比例」卡片与动态使用说明,让用户直观看到
  分享后能拿到多少(同源 resolveRebateRatePercent 计算,与实际充值一致)
- 新增数据库迁移 132 添加 aff_rebate_rate_percent 与 aff_code_custom 列
- 新增 admin 路由组 /api/v1/admin/affiliates/users/* 共 5 个端点
- AffiliateService 改为只依赖 *SettingService,去除冗余的 SettingRepository
- 邀请码格式校验放宽到 [A-Z0-9_-]{4,32},兼容旧 12 位系统码与新自定义码
- 补充单元测试与集成测试覆盖新方法、冲突路径与边界值
parent 9d1751ec
......@@ -634,6 +634,7 @@ const ChevronDownIcon = {
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
......@@ -656,7 +657,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({
path: `/custom/${item.id}`,
......
......@@ -985,6 +985,8 @@ export default {
loadFailed: 'Failed to load affiliate data',
transferFailed: 'Failed to transfer affiliate quota',
stats: {
rebateRate: 'My Rebate Rate',
rebateRateHint: 'What you earn each time an invitee recharges',
invitedUsers: 'Invited Users',
availableQuota: 'Available Rebate Quota',
totalQuota: 'Historical Rebate Quota'
......@@ -1009,7 +1011,7 @@ export default {
tips: {
title: 'How It Works',
line1: 'Share your affiliate code or invite link with new users.',
line2: 'When invitees recharge, you receive rebate quota based on the configured rate.',
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
line3: 'Transfer rebate quota to balance at any time.'
}
},
......@@ -4779,6 +4781,55 @@ export default {
enabled: 'Enable Available Channels',
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
},
affiliate: {
title: 'Affiliate (Invite Rebate)',
description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitee’s recharges. Disabled by default.',
enabled: 'Enable Affiliate',
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',
rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
customUsers: {
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.',
addButton: 'Add Custom User',
searchPlaceholder: 'Search by email or username',
batchButton: 'Batch Set Rate ({count} selected)',
empty: 'No users with custom affiliate settings yet',
customBadge: 'custom',
useGlobal: 'use global',
resetTitle: 'Reset Custom Settings',
resetMessage: 'Reset all custom settings for {email}?\n• The exclusive rebate rate will be cleared (fall back to the global rate)\n• The invite code will be regenerated as a new system code (previously shared links will stop working)',
totalLabel: '{total} total',
col: {
email: 'Email',
username: 'Username',
code: 'Invite Code',
rate: 'Custom Rate',
actions: 'Actions',
},
},
modal: {
addTitle: 'Add Custom User',
editTitle: 'Edit Custom Settings',
userLabel: 'User',
userPlaceholder: 'Search by email or username',
changeUser: 'Change user',
codeLabel: 'Custom Invite Code (optional)',
codePlaceholder: 'e.g. VIP2026',
codeHint: '4-32 characters; A-Z, 0-9, underscore, dash. Leave empty to keep current. Input is upper-cased.',
rateLabel: 'Exclusive Rebate Rate (optional)',
ratePlaceholder: 'e.g. 30',
rateHint: '0-100. Leave empty (in edit mode) to clear and fall back to the global rate.',
errorBadRate: 'Please enter a number between 0 and 100',
errorEmpty: 'Fill at least one: custom invite code or exclusive rebate rate',
},
batchModal: {
title: 'Batch Set Rate ({count} users selected)',
hint: 'Apply the same exclusive rebate rate to all selected users.',
placeholder: 'e.g. 30',
clearHint: 'Submitting empty will clear the exclusive rate for selected users.',
},
},
},
emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
......
......@@ -989,6 +989,8 @@ export default {
loadFailed: '加载邀请返利数据失败',
transferFailed: '转入余额失败',
stats: {
rebateRate: '我的返利比例',
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
invitedUsers: '邀请人数',
availableQuota: '可转返利额度',
totalQuota: '历史返利额度'
......@@ -1013,7 +1015,7 @@ export default {
tips: {
title: '使用说明',
line1: '将邀请码或邀请链接分享给新用户。',
line2: '被邀请用户充值后,你可获得对应比例的返利额度。',
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
line3: '返利额度可随时转入账户余额。'
}
},
......@@ -4942,6 +4944,55 @@ export default {
enabled: '启用可用渠道',
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
},
affiliate: {
title: '邀请返利',
description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
enabled: '启用邀请返利',
enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
rebateRate: '全局返利比例',
rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。',
customUsers: {
title: '专属用户配置',
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
addButton: '添加专属用户',
searchPlaceholder: '搜索邮箱或用户名',
batchButton: '批量设置比例(已选 {count})',
empty: '暂无专属配置用户',
customBadge: '自定义',
useGlobal: '沿用全局',
resetTitle: '重置该用户的专属配置',
resetMessage: '确认将 {email} 的专属配置全部重置为默认?\n• 专属返利比例将清除(沿用全局)\n• 邀请码将重新生成为系统随机码(已分发的旧邀请链接将失效)',
totalLabel: '共 {total} 条',
col: {
email: '邮箱',
username: '用户名',
code: '邀请码',
rate: '专属比例',
actions: '操作',
},
},
modal: {
addTitle: '添加专属用户',
editTitle: '编辑专属配置',
userLabel: '用户',
userPlaceholder: '搜索邮箱或用户名',
changeUser: '更换用户',
codeLabel: '专属邀请码(可选)',
codePlaceholder: '例如 VIP2026',
codeHint: '4-32 位,仅支持大写字母、数字、下划线、连字符;留空表示不修改;输入将自动转大写。',
rateLabel: '专属返利比例(可选)',
ratePlaceholder: '例如 30',
rateHint: '0-100%;留空(编辑模式下)表示清除专属比例并沿用全局。',
errorBadRate: '请输入 0-100 之间的比例',
errorEmpty: '至少填写一项:专属邀请码或专属返利比例',
},
batchModal: {
title: '批量设置专属比例(已选 {count} 个用户)',
hint: '为所选用户统一设置专属返利比例。',
placeholder: '例如 30',
clearHint: '留空提交将清除所选用户的专属比例。',
},
},
},
emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
......
......@@ -355,6 +355,7 @@ export const useAppStore = defineStore('app', () => {
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
affiliate_enabled: false,
}
}
......
......@@ -139,6 +139,8 @@ export interface UserAffiliateDetail {
aff_count: number
aff_quota: number
aff_history_quota: number
/** 当前用户作为邀请人时实际生效的返利比例(专属覆盖全局)。0-100。 */
effective_rebate_rate_percent: number
invitees: AffiliateInvitee[]
}
......@@ -212,6 +214,7 @@ export interface PublicSettings {
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
affiliate_enabled: boolean
}
export interface AuthResponse {
......
......@@ -109,6 +109,11 @@ export const FeatureFlags = {
mode: 'opt-out',
label: 'Payment',
}),
affiliate: defineFlag({
key: 'affiliate_enabled',
mode: 'opt-in',
label: 'Affiliate',
}),
} as const
export type RegisteredFeatureFlag = keyof typeof FeatureFlags
......
This diff is collapsed.
......@@ -8,7 +8,23 @@
</div>
<template v-else-if="detail">
<div class="grid gap-4 md:grid-cols-3">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- 返利比例:用主色突出,让用户一眼看到「能拿多少」 -->
<div class="card relative overflow-hidden p-5">
<div class="absolute -right-6 -top-6 h-24 w-24 rounded-full bg-primary-500/10"></div>
<div class="relative">
<p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400">
<Icon name="dollar" size="sm" class="text-primary-500" />
{{ t('affiliate.stats.rebateRate') }}
</p>
<p class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">
{{ formattedRebateRate }}<span class="ml-0.5 text-base font-medium">%</span>
</p>
<p class="mt-1 text-xs text-gray-400 dark:text-dark-500">
{{ t('affiliate.stats.rebateRateHint') }}
</p>
</div>
</div>
<div class="card p-5">
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p>
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
......@@ -61,7 +77,7 @@
<p class="text-sm font-medium text-primary-800 dark:text-primary-200">{{ t('affiliate.tips.title') }}</p>
<ul class="mt-2 space-y-1 text-sm text-primary-700 dark:text-primary-300">
<li>1. {{ t('affiliate.tips.line1') }}</li>
<li>2. {{ t('affiliate.tips.line2') }}</li>
<li>2. {{ t('affiliate.tips.line2', { rate: `${formattedRebateRate}%` }) }}</li>
<li>3. {{ t('affiliate.tips.line3') }}</li>
</ul>
</div>
......@@ -149,6 +165,14 @@ const inviteLink = computed(() => {
return `${window.location.origin}/register?aff=${encodeURIComponent(detail.value.aff_code)}`
})
// Rebate rate is a percentage in the range [0, 100]; backend already clamps it.
// We trim trailing zeros (e.g. 20.00 → "20", 12.50 → "12.5") for a cleaner UI.
const formattedRebateRate = computed(() => {
const v = detail.value?.effective_rebate_rate_percent ?? 0
const rounded = Math.round(v * 100) / 100
return Number.isInteger(rounded) ? String(rounded) : rounded.toString()
})
function formatCount(value: number): string {
return value.toLocaleString()
}
......
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