911 lines
27 KiB
TypeScript
911 lines
27 KiB
TypeScript
|
|
/**
|
|||
|
|
* 分销服务
|
|||
|
|
* 核心功能:绑定追踪、过期检测、佣金计算、自动提现
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import type {
|
|||
|
|
DistributionBinding,
|
|||
|
|
Distributor,
|
|||
|
|
WithdrawRecord,
|
|||
|
|
ClickRecord,
|
|||
|
|
DistributionConfig,
|
|||
|
|
ExpireReminder,
|
|||
|
|
DistributionOverview,
|
|||
|
|
} from './types';
|
|||
|
|
|
|||
|
|
// 默认分销配置
|
|||
|
|
export const DEFAULT_DISTRIBUTION_CONFIG: DistributionConfig = {
|
|||
|
|
bindingDays: 30, // 30天绑定期
|
|||
|
|
bindingPriority: 'first', // 首次绑定优先
|
|||
|
|
defaultCommissionRate: 90, // 默认90%佣金
|
|||
|
|
levelRates: {
|
|||
|
|
normal: 90,
|
|||
|
|
silver: 92,
|
|||
|
|
gold: 95,
|
|||
|
|
diamond: 98,
|
|||
|
|
},
|
|||
|
|
minWithdrawAmount: 10, // 最低10元提现
|
|||
|
|
withdrawFeeRate: 0, // 0手续费
|
|||
|
|
autoWithdrawEnabled: true, // 允许自动提现
|
|||
|
|
autoWithdrawTime: '10:00', // 每天10点自动提现
|
|||
|
|
expireRemindDays: 3, // 过期前3天提醒
|
|||
|
|
enabled: true,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 存储键名
|
|||
|
|
const STORAGE_KEYS = {
|
|||
|
|
BINDINGS: 'distribution_bindings',
|
|||
|
|
DISTRIBUTORS: 'distribution_distributors',
|
|||
|
|
WITHDRAWALS: 'distribution_withdrawals',
|
|||
|
|
CLICKS: 'distribution_clicks',
|
|||
|
|
CONFIG: 'distribution_config',
|
|||
|
|
REMINDERS: 'distribution_reminders',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成唯一ID
|
|||
|
|
*/
|
|||
|
|
function generateId(prefix: string = ''): string {
|
|||
|
|
return `${prefix}${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取配置
|
|||
|
|
*/
|
|||
|
|
export function getDistributionConfig(): DistributionConfig {
|
|||
|
|
if (typeof window === 'undefined') return DEFAULT_DISTRIBUTION_CONFIG;
|
|||
|
|
const stored = localStorage.getItem(STORAGE_KEYS.CONFIG);
|
|||
|
|
return stored ? { ...DEFAULT_DISTRIBUTION_CONFIG, ...JSON.parse(stored) } : DEFAULT_DISTRIBUTION_CONFIG;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新配置
|
|||
|
|
*/
|
|||
|
|
export function updateDistributionConfig(config: Partial<DistributionConfig>): DistributionConfig {
|
|||
|
|
if (typeof window === 'undefined') return DEFAULT_DISTRIBUTION_CONFIG;
|
|||
|
|
const current = getDistributionConfig();
|
|||
|
|
const updated = { ...current, ...config };
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(updated));
|
|||
|
|
return updated;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============== 绑定管理 ==============
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取所有绑定
|
|||
|
|
*/
|
|||
|
|
export function getAllBindings(): DistributionBinding[] {
|
|||
|
|
if (typeof window === 'undefined') return [];
|
|||
|
|
return JSON.parse(localStorage.getItem(STORAGE_KEYS.BINDINGS) || '[]');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 记录链接点击并创建绑定
|
|||
|
|
*/
|
|||
|
|
export function recordClickAndBinding(params: {
|
|||
|
|
referralCode: string;
|
|||
|
|
referrerId: string;
|
|||
|
|
visitorId: string;
|
|||
|
|
visitorPhone?: string;
|
|||
|
|
visitorNickname?: string;
|
|||
|
|
source: 'link' | 'miniprogram' | 'poster' | 'qrcode';
|
|||
|
|
sourceDetail?: string;
|
|||
|
|
deviceInfo?: DistributionBinding['deviceInfo'];
|
|||
|
|
}): { click: ClickRecord; binding: DistributionBinding | null } {
|
|||
|
|
if (typeof window === 'undefined') {
|
|||
|
|
return { click: {} as ClickRecord, binding: null };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const config = getDistributionConfig();
|
|||
|
|
const now = new Date();
|
|||
|
|
|
|||
|
|
// 1. 记录点击
|
|||
|
|
const click: ClickRecord = {
|
|||
|
|
id: generateId('click_'),
|
|||
|
|
referralCode: params.referralCode,
|
|||
|
|
referrerId: params.referrerId,
|
|||
|
|
visitorId: params.visitorId,
|
|||
|
|
isNewVisitor: !hasExistingBinding(params.visitorId),
|
|||
|
|
source: params.source,
|
|||
|
|
deviceInfo: params.deviceInfo,
|
|||
|
|
registered: false,
|
|||
|
|
purchased: false,
|
|||
|
|
clickTime: now.toISOString(),
|
|||
|
|
createdAt: now.toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const clicks = JSON.parse(localStorage.getItem(STORAGE_KEYS.CLICKS) || '[]');
|
|||
|
|
clicks.push(click);
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.CLICKS, JSON.stringify(clicks));
|
|||
|
|
|
|||
|
|
// 2. 检查是否需要创建绑定
|
|||
|
|
let binding: DistributionBinding | null = null;
|
|||
|
|
|
|||
|
|
// 检查现有绑定
|
|||
|
|
const existingBinding = getActiveBindingForVisitor(params.visitorId);
|
|||
|
|
|
|||
|
|
if (!existingBinding || config.bindingPriority === 'last') {
|
|||
|
|
// 创建新绑定(如果没有现有绑定,或策略是"最后绑定")
|
|||
|
|
const expireDate = new Date(now);
|
|||
|
|
expireDate.setDate(expireDate.getDate() + config.bindingDays);
|
|||
|
|
|
|||
|
|
binding = {
|
|||
|
|
id: generateId('bind_'),
|
|||
|
|
referrerId: params.referrerId,
|
|||
|
|
referrerCode: params.referralCode,
|
|||
|
|
visitorId: params.visitorId,
|
|||
|
|
visitorPhone: params.visitorPhone,
|
|||
|
|
visitorNickname: params.visitorNickname,
|
|||
|
|
bindingTime: now.toISOString(),
|
|||
|
|
expireTime: expireDate.toISOString(),
|
|||
|
|
status: 'active',
|
|||
|
|
source: params.source,
|
|||
|
|
sourceDetail: params.sourceDetail,
|
|||
|
|
deviceInfo: params.deviceInfo,
|
|||
|
|
createdAt: now.toISOString(),
|
|||
|
|
updatedAt: now.toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 如果是"最后绑定"策略,先作废之前的绑定
|
|||
|
|
if (existingBinding && config.bindingPriority === 'last') {
|
|||
|
|
cancelBinding(existingBinding.id, '新绑定覆盖');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const bindings = getAllBindings();
|
|||
|
|
bindings.push(binding);
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.BINDINGS, JSON.stringify(bindings));
|
|||
|
|
|
|||
|
|
// 更新分销商统计
|
|||
|
|
updateDistributorStats(params.referrerId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { click, binding };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 检查是否有现有绑定
|
|||
|
|
*/
|
|||
|
|
function hasExistingBinding(visitorId: string): boolean {
|
|||
|
|
const bindings = getAllBindings();
|
|||
|
|
return bindings.some(b => b.visitorId === visitorId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取访客的有效绑定
|
|||
|
|
*/
|
|||
|
|
export function getActiveBindingForVisitor(visitorId: string): DistributionBinding | null {
|
|||
|
|
const bindings = getAllBindings();
|
|||
|
|
const now = new Date();
|
|||
|
|
|
|||
|
|
return bindings.find(b =>
|
|||
|
|
b.visitorId === visitorId &&
|
|||
|
|
b.status === 'active' &&
|
|||
|
|
new Date(b.expireTime) > now
|
|||
|
|
) || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取分销商的所有绑定
|
|||
|
|
*/
|
|||
|
|
export function getBindingsForDistributor(referrerId: string): DistributionBinding[] {
|
|||
|
|
const bindings = getAllBindings();
|
|||
|
|
return bindings.filter(b => b.referrerId === referrerId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 取消绑定
|
|||
|
|
*/
|
|||
|
|
export function cancelBinding(bindingId: string, reason?: string): boolean {
|
|||
|
|
const bindings = getAllBindings();
|
|||
|
|
const index = bindings.findIndex(b => b.id === bindingId);
|
|||
|
|
|
|||
|
|
if (index === -1) return false;
|
|||
|
|
|
|||
|
|
bindings[index] = {
|
|||
|
|
...bindings[index],
|
|||
|
|
status: 'cancelled',
|
|||
|
|
updatedAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.BINDINGS, JSON.stringify(bindings));
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 将绑定标记为已转化(用户付款后调用)
|
|||
|
|
*/
|
|||
|
|
export function convertBinding(params: {
|
|||
|
|
visitorId: string;
|
|||
|
|
orderId: string;
|
|||
|
|
orderAmount: number;
|
|||
|
|
}): { binding: DistributionBinding | null; commission: number } {
|
|||
|
|
const binding = getActiveBindingForVisitor(params.visitorId);
|
|||
|
|
|
|||
|
|
if (!binding) {
|
|||
|
|
return { binding: null, commission: 0 };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const config = getDistributionConfig();
|
|||
|
|
const distributor = getDistributor(binding.referrerId);
|
|||
|
|
const commissionRate = distributor?.commissionRate || config.defaultCommissionRate;
|
|||
|
|
const commission = params.orderAmount * (commissionRate / 100);
|
|||
|
|
|
|||
|
|
// 更新绑定状态
|
|||
|
|
const bindings = getAllBindings();
|
|||
|
|
const index = bindings.findIndex(b => b.id === binding.id);
|
|||
|
|
|
|||
|
|
if (index !== -1) {
|
|||
|
|
bindings[index] = {
|
|||
|
|
...bindings[index],
|
|||
|
|
status: 'converted',
|
|||
|
|
convertedAt: new Date().toISOString(),
|
|||
|
|
orderId: params.orderId,
|
|||
|
|
orderAmount: params.orderAmount,
|
|||
|
|
commission,
|
|||
|
|
updatedAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.BINDINGS, JSON.stringify(bindings));
|
|||
|
|
|
|||
|
|
// 更新分销商收益
|
|||
|
|
addDistributorEarnings(binding.referrerId, commission);
|
|||
|
|
|
|||
|
|
// 更新点击记录
|
|||
|
|
updateClickPurchaseStatus(binding.referrerId, params.visitorId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { binding: bindings[index], commission };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 检查并处理过期绑定
|
|||
|
|
*/
|
|||
|
|
export function processExpiredBindings(): {
|
|||
|
|
expired: DistributionBinding[];
|
|||
|
|
expiringSoon: DistributionBinding[];
|
|||
|
|
} {
|
|||
|
|
const bindings = getAllBindings();
|
|||
|
|
const config = getDistributionConfig();
|
|||
|
|
const now = new Date();
|
|||
|
|
const remindThreshold = new Date();
|
|||
|
|
remindThreshold.setDate(remindThreshold.getDate() + config.expireRemindDays);
|
|||
|
|
|
|||
|
|
const expired: DistributionBinding[] = [];
|
|||
|
|
const expiringSoon: DistributionBinding[] = [];
|
|||
|
|
|
|||
|
|
const updatedBindings = bindings.map(binding => {
|
|||
|
|
if (binding.status !== 'active') return binding;
|
|||
|
|
|
|||
|
|
const expireTime = new Date(binding.expireTime);
|
|||
|
|
|
|||
|
|
if (expireTime <= now) {
|
|||
|
|
// 已过期
|
|||
|
|
expired.push(binding);
|
|||
|
|
createExpireReminder(binding, 'expired');
|
|||
|
|
return {
|
|||
|
|
...binding,
|
|||
|
|
status: 'expired' as const,
|
|||
|
|
updatedAt: now.toISOString(),
|
|||
|
|
};
|
|||
|
|
} else if (expireTime <= remindThreshold) {
|
|||
|
|
// 即将过期
|
|||
|
|
const daysRemaining = Math.ceil((expireTime.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|||
|
|
expiringSoon.push(binding);
|
|||
|
|
createExpireReminder(binding, 'expiring_soon', daysRemaining);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return binding;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.BINDINGS, JSON.stringify(updatedBindings));
|
|||
|
|
|
|||
|
|
// 更新相关分销商统计
|
|||
|
|
const affectedDistributors = new Set([
|
|||
|
|
...expired.map(b => b.referrerId),
|
|||
|
|
...expiringSoon.map(b => b.referrerId),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
affectedDistributors.forEach(distributorId => {
|
|||
|
|
updateDistributorStats(distributorId);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return { expired, expiringSoon };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============== 提醒管理 ==============
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 创建过期提醒
|
|||
|
|
*/
|
|||
|
|
function createExpireReminder(
|
|||
|
|
binding: DistributionBinding,
|
|||
|
|
type: 'expiring_soon' | 'expired',
|
|||
|
|
daysRemaining?: number
|
|||
|
|
): void {
|
|||
|
|
const reminders = JSON.parse(localStorage.getItem(STORAGE_KEYS.REMINDERS) || '[]') as ExpireReminder[];
|
|||
|
|
|
|||
|
|
// 检查是否已存在相同提醒
|
|||
|
|
const exists = reminders.some(r =>
|
|||
|
|
r.bindingId === binding.id &&
|
|||
|
|
r.reminderType === type
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (exists) return;
|
|||
|
|
|
|||
|
|
const reminder: ExpireReminder = {
|
|||
|
|
id: generateId('remind_'),
|
|||
|
|
bindingId: binding.id,
|
|||
|
|
distributorId: binding.referrerId,
|
|||
|
|
bindingInfo: {
|
|||
|
|
visitorNickname: binding.visitorNickname,
|
|||
|
|
visitorPhone: binding.visitorPhone,
|
|||
|
|
bindingTime: binding.bindingTime,
|
|||
|
|
expireTime: binding.expireTime,
|
|||
|
|
},
|
|||
|
|
reminderType: type,
|
|||
|
|
daysRemaining,
|
|||
|
|
isRead: false,
|
|||
|
|
createdAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
reminders.push(reminder);
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.REMINDERS, JSON.stringify(reminders));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取分销商的提醒
|
|||
|
|
*/
|
|||
|
|
export function getRemindersForDistributor(distributorId: string): ExpireReminder[] {
|
|||
|
|
const reminders = JSON.parse(localStorage.getItem(STORAGE_KEYS.REMINDERS) || '[]') as ExpireReminder[];
|
|||
|
|
return reminders.filter(r => r.distributorId === distributorId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取未读提醒数量
|
|||
|
|
*/
|
|||
|
|
export function getUnreadReminderCount(distributorId: string): number {
|
|||
|
|
const reminders = getRemindersForDistributor(distributorId);
|
|||
|
|
return reminders.filter(r => !r.isRead).length;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 标记提醒已读
|
|||
|
|
*/
|
|||
|
|
export function markReminderRead(reminderId: string): void {
|
|||
|
|
const reminders = JSON.parse(localStorage.getItem(STORAGE_KEYS.REMINDERS) || '[]') as ExpireReminder[];
|
|||
|
|
const index = reminders.findIndex(r => r.id === reminderId);
|
|||
|
|
|
|||
|
|
if (index !== -1) {
|
|||
|
|
reminders[index].isRead = true;
|
|||
|
|
reminders[index].readAt = new Date().toISOString();
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.REMINDERS, JSON.stringify(reminders));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============== 分销商管理 ==============
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取分销商信息
|
|||
|
|
*/
|
|||
|
|
export function getDistributor(userId: string): Distributor | null {
|
|||
|
|
const distributors = JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]') as Distributor[];
|
|||
|
|
return distributors.find(d => d.userId === userId) || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取或创建分销商
|
|||
|
|
*/
|
|||
|
|
export function getOrCreateDistributor(params: {
|
|||
|
|
userId: string;
|
|||
|
|
nickname: string;
|
|||
|
|
phone: string;
|
|||
|
|
referralCode: string;
|
|||
|
|
}): Distributor {
|
|||
|
|
let distributor = getDistributor(params.userId);
|
|||
|
|
|
|||
|
|
if (!distributor) {
|
|||
|
|
const config = getDistributionConfig();
|
|||
|
|
distributor = {
|
|||
|
|
id: generateId('dist_'),
|
|||
|
|
userId: params.userId,
|
|||
|
|
nickname: params.nickname,
|
|||
|
|
phone: params.phone,
|
|||
|
|
referralCode: params.referralCode,
|
|||
|
|
totalClicks: 0,
|
|||
|
|
totalBindings: 0,
|
|||
|
|
activeBindings: 0,
|
|||
|
|
convertedBindings: 0,
|
|||
|
|
expiredBindings: 0,
|
|||
|
|
totalEarnings: 0,
|
|||
|
|
pendingEarnings: 0,
|
|||
|
|
withdrawnEarnings: 0,
|
|||
|
|
autoWithdraw: false,
|
|||
|
|
autoWithdrawThreshold: config.minWithdrawAmount,
|
|||
|
|
level: 'normal',
|
|||
|
|
commissionRate: config.defaultCommissionRate,
|
|||
|
|
status: 'active',
|
|||
|
|
createdAt: new Date().toISOString(),
|
|||
|
|
updatedAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const distributors = JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]') as Distributor[];
|
|||
|
|
distributors.push(distributor);
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.DISTRIBUTORS, JSON.stringify(distributors));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return distributor;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新分销商统计
|
|||
|
|
*/
|
|||
|
|
function updateDistributorStats(userId: string): void {
|
|||
|
|
const distributors = JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]') as Distributor[];
|
|||
|
|
const index = distributors.findIndex(d => d.userId === userId);
|
|||
|
|
|
|||
|
|
if (index === -1) return;
|
|||
|
|
|
|||
|
|
const bindings = getBindingsForDistributor(userId);
|
|||
|
|
const clicks = JSON.parse(localStorage.getItem(STORAGE_KEYS.CLICKS) || '[]') as ClickRecord[];
|
|||
|
|
const userClicks = clicks.filter(c => c.referrerId === userId);
|
|||
|
|
|
|||
|
|
distributors[index] = {
|
|||
|
|
...distributors[index],
|
|||
|
|
totalClicks: userClicks.length,
|
|||
|
|
totalBindings: bindings.length,
|
|||
|
|
activeBindings: bindings.filter(b => b.status === 'active').length,
|
|||
|
|
convertedBindings: bindings.filter(b => b.status === 'converted').length,
|
|||
|
|
expiredBindings: bindings.filter(b => b.status === 'expired').length,
|
|||
|
|
updatedAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.DISTRIBUTORS, JSON.stringify(distributors));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 增加分销商收益
|
|||
|
|
*/
|
|||
|
|
function addDistributorEarnings(userId: string, amount: number): void {
|
|||
|
|
const distributors = JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]') as Distributor[];
|
|||
|
|
const index = distributors.findIndex(d => d.userId === userId);
|
|||
|
|
|
|||
|
|
if (index === -1) return;
|
|||
|
|
|
|||
|
|
distributors[index] = {
|
|||
|
|
...distributors[index],
|
|||
|
|
totalEarnings: distributors[index].totalEarnings + amount,
|
|||
|
|
pendingEarnings: distributors[index].pendingEarnings + amount,
|
|||
|
|
updatedAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.DISTRIBUTORS, JSON.stringify(distributors));
|
|||
|
|
|
|||
|
|
// 检查是否需要自动提现
|
|||
|
|
checkAutoWithdraw(distributors[index]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新点击记录的购买状态
|
|||
|
|
*/
|
|||
|
|
function updateClickPurchaseStatus(referrerId: string, visitorId: string): void {
|
|||
|
|
const clicks = JSON.parse(localStorage.getItem(STORAGE_KEYS.CLICKS) || '[]') as ClickRecord[];
|
|||
|
|
const index = clicks.findIndex(c => c.referrerId === referrerId && c.visitorId === visitorId);
|
|||
|
|
|
|||
|
|
if (index !== -1) {
|
|||
|
|
clicks[index].purchased = true;
|
|||
|
|
clicks[index].purchasedAt = new Date().toISOString();
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.CLICKS, JSON.stringify(clicks));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 设置自动提现
|
|||
|
|
*/
|
|||
|
|
export function setAutoWithdraw(params: {
|
|||
|
|
userId: string;
|
|||
|
|
enabled: boolean;
|
|||
|
|
threshold?: number;
|
|||
|
|
account?: Distributor['autoWithdrawAccount'];
|
|||
|
|
}): boolean {
|
|||
|
|
const distributors = JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]') as Distributor[];
|
|||
|
|
const index = distributors.findIndex(d => d.userId === params.userId);
|
|||
|
|
|
|||
|
|
if (index === -1) return false;
|
|||
|
|
|
|||
|
|
distributors[index] = {
|
|||
|
|
...distributors[index],
|
|||
|
|
autoWithdraw: params.enabled,
|
|||
|
|
autoWithdrawThreshold: params.threshold || distributors[index].autoWithdrawThreshold,
|
|||
|
|
autoWithdrawAccount: params.account || distributors[index].autoWithdrawAccount,
|
|||
|
|
updatedAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.DISTRIBUTORS, JSON.stringify(distributors));
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============== 提现管理 ==============
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取所有提现记录
|
|||
|
|
*/
|
|||
|
|
export function getAllWithdrawals(): WithdrawRecord[] {
|
|||
|
|
if (typeof window === 'undefined') return [];
|
|||
|
|
return JSON.parse(localStorage.getItem(STORAGE_KEYS.WITHDRAWALS) || '[]');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取分销商的提现记录
|
|||
|
|
*/
|
|||
|
|
export function getWithdrawalsForDistributor(distributorId: string): WithdrawRecord[] {
|
|||
|
|
const withdrawals = getAllWithdrawals();
|
|||
|
|
return withdrawals.filter(w => w.distributorId === distributorId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 申请提现
|
|||
|
|
*/
|
|||
|
|
export function requestWithdraw(params: {
|
|||
|
|
userId: string;
|
|||
|
|
amount: number;
|
|||
|
|
method: 'wechat' | 'alipay';
|
|||
|
|
account: string;
|
|||
|
|
accountName: string;
|
|||
|
|
}): { success: boolean; withdrawal?: WithdrawRecord; error?: string } {
|
|||
|
|
const config = getDistributionConfig();
|
|||
|
|
const distributor = getDistributor(params.userId);
|
|||
|
|
|
|||
|
|
if (!distributor) {
|
|||
|
|
return { success: false, error: '分销商不存在' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (params.amount < config.minWithdrawAmount) {
|
|||
|
|
return { success: false, error: `最低提现金额为 ${config.minWithdrawAmount} 元` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (params.amount > distributor.pendingEarnings) {
|
|||
|
|
return { success: false, error: '提现金额超过可提现余额' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fee = params.amount * config.withdrawFeeRate;
|
|||
|
|
const actualAmount = params.amount - fee;
|
|||
|
|
|
|||
|
|
const withdrawal: WithdrawRecord = {
|
|||
|
|
id: generateId('withdraw_'),
|
|||
|
|
distributorId: distributor.id,
|
|||
|
|
userId: params.userId,
|
|||
|
|
userName: distributor.nickname,
|
|||
|
|
amount: params.amount,
|
|||
|
|
fee,
|
|||
|
|
actualAmount,
|
|||
|
|
method: params.method,
|
|||
|
|
account: params.account,
|
|||
|
|
accountName: params.accountName,
|
|||
|
|
status: 'pending',
|
|||
|
|
isAuto: false,
|
|||
|
|
createdAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 保存提现记录
|
|||
|
|
const withdrawals = getAllWithdrawals();
|
|||
|
|
withdrawals.push(withdrawal);
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.WITHDRAWALS, JSON.stringify(withdrawals));
|
|||
|
|
|
|||
|
|
// 扣除待提现金额
|
|||
|
|
deductDistributorPendingEarnings(params.userId, params.amount);
|
|||
|
|
|
|||
|
|
return { success: true, withdrawal };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 扣除分销商待提现金额
|
|||
|
|
*/
|
|||
|
|
function deductDistributorPendingEarnings(userId: string, amount: number): void {
|
|||
|
|
const distributors = JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]') as Distributor[];
|
|||
|
|
const index = distributors.findIndex(d => d.userId === userId);
|
|||
|
|
|
|||
|
|
if (index !== -1) {
|
|||
|
|
distributors[index].pendingEarnings -= amount;
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.DISTRIBUTORS, JSON.stringify(distributors));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 检查并执行自动提现
|
|||
|
|
*/
|
|||
|
|
function checkAutoWithdraw(distributor: Distributor): void {
|
|||
|
|
if (!distributor.autoWithdraw || !distributor.autoWithdrawAccount) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (distributor.pendingEarnings >= distributor.autoWithdrawThreshold) {
|
|||
|
|
// 执行自动提现
|
|||
|
|
const result = executeAutoWithdraw(distributor);
|
|||
|
|
if (result.success) {
|
|||
|
|
console.log(`自动提现成功: ${distributor.nickname}, 金额: ${distributor.pendingEarnings}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 执行自动提现
|
|||
|
|
*/
|
|||
|
|
export function executeAutoWithdraw(distributor: Distributor): { success: boolean; withdrawal?: WithdrawRecord; error?: string } {
|
|||
|
|
if (!distributor.autoWithdrawAccount) {
|
|||
|
|
return { success: false, error: '未配置自动提现账户' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const config = getDistributionConfig();
|
|||
|
|
const amount = distributor.pendingEarnings;
|
|||
|
|
const fee = amount * config.withdrawFeeRate;
|
|||
|
|
const actualAmount = amount - fee;
|
|||
|
|
|
|||
|
|
const withdrawal: WithdrawRecord = {
|
|||
|
|
id: generateId('withdraw_'),
|
|||
|
|
distributorId: distributor.id,
|
|||
|
|
userId: distributor.userId,
|
|||
|
|
userName: distributor.nickname,
|
|||
|
|
amount,
|
|||
|
|
fee,
|
|||
|
|
actualAmount,
|
|||
|
|
method: distributor.autoWithdrawAccount.type,
|
|||
|
|
account: distributor.autoWithdrawAccount.account,
|
|||
|
|
accountName: distributor.autoWithdrawAccount.name,
|
|||
|
|
status: 'processing', // 自动提现直接进入处理状态
|
|||
|
|
isAuto: true,
|
|||
|
|
createdAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 保存提现记录
|
|||
|
|
const withdrawals = getAllWithdrawals();
|
|||
|
|
withdrawals.push(withdrawal);
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.WITHDRAWALS, JSON.stringify(withdrawals));
|
|||
|
|
|
|||
|
|
// 扣除待提现金额
|
|||
|
|
deductDistributorPendingEarnings(distributor.userId, amount);
|
|||
|
|
|
|||
|
|
// 这里应该调用实际的支付接口进行打款
|
|||
|
|
// 实际项目中需要对接微信/支付宝的企业付款接口
|
|||
|
|
processWithdrawalPayment(withdrawal.id);
|
|||
|
|
|
|||
|
|
return { success: true, withdrawal };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理提现打款(模拟)
|
|||
|
|
* 实际项目中需要对接支付接口
|
|||
|
|
*/
|
|||
|
|
export async function processWithdrawalPayment(withdrawalId: string): Promise<{ success: boolean; error?: string }> {
|
|||
|
|
const withdrawals = getAllWithdrawals();
|
|||
|
|
const index = withdrawals.findIndex(w => w.id === withdrawalId);
|
|||
|
|
|
|||
|
|
if (index === -1) {
|
|||
|
|
return { success: false, error: '提现记录不存在' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const withdrawal = withdrawals[index];
|
|||
|
|
|
|||
|
|
// 模拟支付接口调用
|
|||
|
|
// 实际项目中应该调用:
|
|||
|
|
// - 微信:企业付款到零钱 API
|
|||
|
|
// - 支付宝:单笔转账到支付宝账户 API
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 模拟网络延迟
|
|||
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|||
|
|
|
|||
|
|
// 更新提现状态为成功
|
|||
|
|
withdrawals[index] = {
|
|||
|
|
...withdrawal,
|
|||
|
|
status: 'completed',
|
|||
|
|
paymentNo: `PAY${Date.now()}`,
|
|||
|
|
paymentTime: new Date().toISOString(),
|
|||
|
|
completedAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.WITHDRAWALS, JSON.stringify(withdrawals));
|
|||
|
|
|
|||
|
|
// 更新分销商已提现金额
|
|||
|
|
const distributors = JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]') as Distributor[];
|
|||
|
|
const distIndex = distributors.findIndex(d => d.userId === withdrawal.userId);
|
|||
|
|
|
|||
|
|
if (distIndex !== -1) {
|
|||
|
|
distributors[distIndex].withdrawnEarnings += withdrawal.amount;
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.DISTRIBUTORS, JSON.stringify(distributors));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { success: true };
|
|||
|
|
} catch (error) {
|
|||
|
|
// 打款失败
|
|||
|
|
withdrawals[index] = {
|
|||
|
|
...withdrawal,
|
|||
|
|
status: 'failed',
|
|||
|
|
paymentError: error instanceof Error ? error.message : '打款失败',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.WITHDRAWALS, JSON.stringify(withdrawals));
|
|||
|
|
|
|||
|
|
// 退还金额到待提现余额
|
|||
|
|
const distributors = JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]') as Distributor[];
|
|||
|
|
const distIndex = distributors.findIndex(d => d.userId === withdrawal.userId);
|
|||
|
|
|
|||
|
|
if (distIndex !== -1) {
|
|||
|
|
distributors[distIndex].pendingEarnings += withdrawal.amount;
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.DISTRIBUTORS, JSON.stringify(distributors));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { success: false, error: '打款失败' };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 审核通过并打款
|
|||
|
|
*/
|
|||
|
|
export async function approveWithdrawal(withdrawalId: string, reviewedBy?: string): Promise<{ success: boolean; error?: string }> {
|
|||
|
|
const withdrawals = getAllWithdrawals();
|
|||
|
|
const index = withdrawals.findIndex(w => w.id === withdrawalId);
|
|||
|
|
|
|||
|
|
if (index === -1) {
|
|||
|
|
return { success: false, error: '提现记录不存在' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (withdrawals[index].status !== 'pending') {
|
|||
|
|
return { success: false, error: '该提现申请已处理' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
withdrawals[index] = {
|
|||
|
|
...withdrawals[index],
|
|||
|
|
status: 'processing',
|
|||
|
|
reviewedBy,
|
|||
|
|
reviewedAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.WITHDRAWALS, JSON.stringify(withdrawals));
|
|||
|
|
|
|||
|
|
// 执行打款
|
|||
|
|
return processWithdrawalPayment(withdrawalId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 拒绝提现
|
|||
|
|
*/
|
|||
|
|
export function rejectWithdrawal(withdrawalId: string, reason: string, reviewedBy?: string): { success: boolean; error?: string } {
|
|||
|
|
const withdrawals = getAllWithdrawals();
|
|||
|
|
const index = withdrawals.findIndex(w => w.id === withdrawalId);
|
|||
|
|
|
|||
|
|
if (index === -1) {
|
|||
|
|
return { success: false, error: '提现记录不存在' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const withdrawal = withdrawals[index];
|
|||
|
|
|
|||
|
|
if (withdrawal.status !== 'pending') {
|
|||
|
|
return { success: false, error: '该提现申请已处理' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
withdrawals[index] = {
|
|||
|
|
...withdrawal,
|
|||
|
|
status: 'rejected',
|
|||
|
|
reviewNote: reason,
|
|||
|
|
reviewedBy,
|
|||
|
|
reviewedAt: new Date().toISOString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.WITHDRAWALS, JSON.stringify(withdrawals));
|
|||
|
|
|
|||
|
|
// 退还金额到待提现余额
|
|||
|
|
const distributors = JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]') as Distributor[];
|
|||
|
|
const distIndex = distributors.findIndex(d => d.userId === withdrawal.userId);
|
|||
|
|
|
|||
|
|
if (distIndex !== -1) {
|
|||
|
|
distributors[distIndex].pendingEarnings += withdrawal.amount;
|
|||
|
|
localStorage.setItem(STORAGE_KEYS.DISTRIBUTORS, JSON.stringify(distributors));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { success: true };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============== 统计概览 ==============
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取分销统计概览
|
|||
|
|
*/
|
|||
|
|
export function getDistributionOverview(): DistributionOverview {
|
|||
|
|
const bindings = getAllBindings();
|
|||
|
|
const clicks = JSON.parse(localStorage.getItem(STORAGE_KEYS.CLICKS) || '[]') as ClickRecord[];
|
|||
|
|
const withdrawals = getAllWithdrawals();
|
|||
|
|
|
|||
|
|
const now = new Date();
|
|||
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|||
|
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|||
|
|
const weekFromNow = new Date();
|
|||
|
|
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
|||
|
|
|
|||
|
|
// 今日数据
|
|||
|
|
const todayClicks = clicks.filter(c => new Date(c.clickTime) >= today).length;
|
|||
|
|
const todayBindings = bindings.filter(b => new Date(b.createdAt) >= today).length;
|
|||
|
|
const todayConversions = bindings.filter(b =>
|
|||
|
|
b.status === 'converted' && b.convertedAt && new Date(b.convertedAt) >= today
|
|||
|
|
).length;
|
|||
|
|
const todayEarnings = bindings
|
|||
|
|
.filter(b => b.status === 'converted' && b.convertedAt && new Date(b.convertedAt) >= today)
|
|||
|
|
.reduce((sum, b) => sum + (b.commission || 0), 0);
|
|||
|
|
|
|||
|
|
// 本月数据
|
|||
|
|
const monthClicks = clicks.filter(c => new Date(c.clickTime) >= monthStart).length;
|
|||
|
|
const monthBindings = bindings.filter(b => new Date(b.createdAt) >= monthStart).length;
|
|||
|
|
const monthConversions = bindings.filter(b =>
|
|||
|
|
b.status === 'converted' && b.convertedAt && new Date(b.convertedAt) >= monthStart
|
|||
|
|
).length;
|
|||
|
|
const monthEarnings = bindings
|
|||
|
|
.filter(b => b.status === 'converted' && b.convertedAt && new Date(b.convertedAt) >= monthStart)
|
|||
|
|
.reduce((sum, b) => sum + (b.commission || 0), 0);
|
|||
|
|
|
|||
|
|
// 总计数据
|
|||
|
|
const totalConversions = bindings.filter(b => b.status === 'converted').length;
|
|||
|
|
const totalEarnings = bindings
|
|||
|
|
.filter(b => b.status === 'converted')
|
|||
|
|
.reduce((sum, b) => sum + (b.commission || 0), 0);
|
|||
|
|
|
|||
|
|
// 即将过期数据
|
|||
|
|
const expiringBindings = bindings.filter(b =>
|
|||
|
|
b.status === 'active' &&
|
|||
|
|
new Date(b.expireTime) <= weekFromNow &&
|
|||
|
|
new Date(b.expireTime) > now
|
|||
|
|
).length;
|
|||
|
|
|
|||
|
|
const expiredToday = bindings.filter(b =>
|
|||
|
|
b.status === 'expired' &&
|
|||
|
|
b.updatedAt && new Date(b.updatedAt) >= today
|
|||
|
|
).length;
|
|||
|
|
|
|||
|
|
// 提现数据
|
|||
|
|
const pendingWithdrawals = withdrawals.filter(w => w.status === 'pending').length;
|
|||
|
|
const pendingWithdrawAmount = withdrawals
|
|||
|
|
.filter(w => w.status === 'pending')
|
|||
|
|
.reduce((sum, w) => sum + w.amount, 0);
|
|||
|
|
|
|||
|
|
// 转化率
|
|||
|
|
const conversionRate = clicks.length > 0 ? (totalConversions / clicks.length) * 100 : 0;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
todayClicks,
|
|||
|
|
todayBindings,
|
|||
|
|
todayConversions,
|
|||
|
|
todayEarnings,
|
|||
|
|
monthClicks,
|
|||
|
|
monthBindings,
|
|||
|
|
monthConversions,
|
|||
|
|
monthEarnings,
|
|||
|
|
totalClicks: clicks.length,
|
|||
|
|
totalBindings: bindings.length,
|
|||
|
|
totalConversions,
|
|||
|
|
totalEarnings,
|
|||
|
|
expiringBindings,
|
|||
|
|
expiredToday,
|
|||
|
|
pendingWithdrawals,
|
|||
|
|
pendingWithdrawAmount,
|
|||
|
|
conversionRate,
|
|||
|
|
lastUpdated: now.toISOString(),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取分销排行榜
|
|||
|
|
*/
|
|||
|
|
export function getDistributionRanking(limit: number = 10): Distributor[] {
|
|||
|
|
const distributors = JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]') as Distributor[];
|
|||
|
|
return distributors
|
|||
|
|
.filter(d => d.status === 'active')
|
|||
|
|
.sort((a, b) => b.totalEarnings - a.totalEarnings)
|
|||
|
|
.slice(0, limit);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取所有分销商
|
|||
|
|
*/
|
|||
|
|
export function getAllDistributors(): Distributor[] {
|
|||
|
|
if (typeof window === 'undefined') return [];
|
|||
|
|
return JSON.parse(localStorage.getItem(STORAGE_KEYS.DISTRIBUTORS) || '[]');
|
|||
|
|
}
|