|
|
@@ -0,0 +1,220 @@
|
|
|
+/**
|
|
|
+ * 邀请奖励服务 - 邀请码 / 绑定 / 记录 / 奖励发放 / 策略配置
|
|
|
+ * 规则:付费转介绍双方各 10%(单次上限 $5);邀请注册可得注册奖励(可多次);新客户首充奖励仅一次
|
|
|
+ * 策略经 SystemConfig 可配(管理后台调整)
|
|
|
+ */
|
|
|
+import { Injectable, BadRequestException } from '@nestjs/common';
|
|
|
+import { PrismaService } from '../prisma/prisma.service';
|
|
|
+
|
|
|
+/** 默认策略(SystemConfig 未设置时生效) */
|
|
|
+const DEFAULT_CONFIG = {
|
|
|
+ register_reward_cents: '100', // 邀请人:好友注册奖励($1,0=关闭)
|
|
|
+ rate_pct: '10', // 付费转介绍奖励比例(%)
|
|
|
+ cap_cents: '500', // 单次奖励上限($5)
|
|
|
+} as const;
|
|
|
+
|
|
|
+const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉易混淆字符
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class ReferralService {
|
|
|
+ constructor(private readonly prisma: PrismaService) {}
|
|
|
+
|
|
|
+ /** 生成唯一 8 位邀请码(避开已存在) */
|
|
|
+ private async generateCode(): Promise<string> {
|
|
|
+ for (let i = 0; i < 10; i++) {
|
|
|
+ const code = Array.from({ length: 8 }, () =>
|
|
|
+ CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)],
|
|
|
+ ).join('');
|
|
|
+ const exists = await this.prisma.user.findUnique({ where: { referralCode: code } });
|
|
|
+ if (!exists) return code;
|
|
|
+ }
|
|
|
+ throw new Error('邀请码生成失败,请重试');
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 确保用户有邀请码(首次调用生成) */
|
|
|
+ async ensureCode(userId: string): Promise<string> {
|
|
|
+ const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
|
+ if (!user) throw new BadRequestException('用户不存在');
|
|
|
+ if (user.referralCode) return user.referralCode;
|
|
|
+ const code = await this.generateCode();
|
|
|
+ await this.prisma.user.update({ where: { id: userId }, data: { referralCode: code } });
|
|
|
+ return code;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 读取策略(SystemConfig 覆盖默认值) */
|
|
|
+ async getConfig() {
|
|
|
+ const rows = await this.prisma.systemConfig.findMany();
|
|
|
+ const map = new Map(rows.map((r) => [r.key, r.value]));
|
|
|
+ const num = (key: string, def: string) => {
|
|
|
+ const v = map.get(key);
|
|
|
+ return v !== undefined && !Number.isNaN(Number(v)) ? Number(v) : Number(def);
|
|
|
+ };
|
|
|
+ return {
|
|
|
+ registerRewardCents: num('referral.register_reward_cents', DEFAULT_CONFIG.register_reward_cents),
|
|
|
+ ratePct: num('referral.rate_pct', DEFAULT_CONFIG.rate_pct),
|
|
|
+ capCents: num('referral.cap_cents', DEFAULT_CONFIG.cap_cents),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 我的邀请信息(码 + 链接 + 策略) */
|
|
|
+ async getMyReferral(userId: string) {
|
|
|
+ const [code, config] = await Promise.all([this.ensureCode(userId), this.getConfig()]);
|
|
|
+ const site = process.env['NEXT_PUBLIC_SITE_URL'] || 'http://localhost:3000';
|
|
|
+ return {
|
|
|
+ code,
|
|
|
+ link: `${site}/vi/register?ref=${code}`,
|
|
|
+ config,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 公开策略(官网/控制台展示) */
|
|
|
+ async getPublicConfig() {
|
|
|
+ return this.getConfig();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 管理端更新策略 */
|
|
|
+ async updateConfig(updates: { registerRewardCents?: number; ratePct?: number; capCents?: number }) {
|
|
|
+ const map: Array<[string, number]> = [
|
|
|
+ ['referral.register_reward_cents', updates.registerRewardCents as number],
|
|
|
+ ['referral.rate_pct', updates.ratePct as number],
|
|
|
+ ['referral.cap_cents', updates.capCents as number],
|
|
|
+ ];
|
|
|
+ for (const [key, value] of map) {
|
|
|
+ if (value === undefined) continue;
|
|
|
+ if (!Number.isFinite(value) || value < 0) throw new BadRequestException(`非法配置: ${key}`);
|
|
|
+ await this.prisma.systemConfig.upsert({
|
|
|
+ where: { key },
|
|
|
+ update: { value: String(Math.round(value)) },
|
|
|
+ create: { key, value: String(Math.round(value)) },
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return this.getConfig();
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /** 奖励入账(事务:余额增加 + 流水 + 奖励记录) */
|
|
|
+ private async creditReward(userId: string, amountCents: bigint, recordId: string, type: string) {
|
|
|
+ return this.prisma.$transaction([
|
|
|
+ this.prisma.user.update({
|
|
|
+ where: { id: userId },
|
|
|
+ data: { balanceCents: { increment: amountCents } },
|
|
|
+ }),
|
|
|
+ this.prisma.balanceTransaction.create({
|
|
|
+ data: { userId, amountCents, type: 'referral', refId: recordId },
|
|
|
+ }),
|
|
|
+ this.prisma.referralReward.create({
|
|
|
+ data: { userId, recordId, type, amountCents },
|
|
|
+ }),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 注册绑定:通过邀请码建立邀请关系 + 发注册奖励(邀请人,可多次) */
|
|
|
+ async bindOnRegister(refereeId: string, refereeEmail: string, referralCode?: string) {
|
|
|
+ if (!referralCode) return null;
|
|
|
+ const referrer = await this.prisma.user.findUnique({
|
|
|
+ where: { referralCode: referralCode.trim().toUpperCase() },
|
|
|
+ });
|
|
|
+ if (!referrer) return null; // 邀请码无效则静默忽略(不阻塞注册)
|
|
|
+
|
|
|
+ const record = await this.prisma.referralRecord.create({
|
|
|
+ data: {
|
|
|
+ referrerId: referrer.id,
|
|
|
+ refereeId,
|
|
|
+ refereeEmail,
|
|
|
+ status: 'registered',
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // 记录被邀请关系(onFirstPaid 依赖 referredById 定位邀请人)
|
|
|
+ await this.prisma.user.update({
|
|
|
+ where: { id: refereeId },
|
|
|
+ data: { referredById: referrer.id },
|
|
|
+ });
|
|
|
+
|
|
|
+ // 邀请人注册奖励(每次成功邀请一次;0 或未开启则跳过)
|
|
|
+ const config = await this.getConfig();
|
|
|
+ if (config.registerRewardCents > 0) {
|
|
|
+ await this.creditReward(
|
|
|
+ referrer.id,
|
|
|
+ BigInt(config.registerRewardCents),
|
|
|
+ record.id,
|
|
|
+ 'register',
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return record;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 首充触发:被邀请人首充奖励(仅一次)+ 邀请人付费奖励(每好友一次) */
|
|
|
+ async onFirstPaid(userId: string, amountCents: bigint) {
|
|
|
+ const user = await this.prisma.user.findUnique({
|
|
|
+ where: { id: userId },
|
|
|
+ include: { referredBy: true },
|
|
|
+ });
|
|
|
+ if (!user?.referredById) return null;
|
|
|
+
|
|
|
+ const record = await this.prisma.referralRecord.findFirst({
|
|
|
+ where: { refereeId: userId, referrerId: user.referredById },
|
|
|
+ });
|
|
|
+ if (!record || record.status !== 'registered') return null; // 幂等:仅注册态触发一次
|
|
|
+
|
|
|
+ const config = await this.getConfig();
|
|
|
+ const rate = config.ratePct / 100;
|
|
|
+ const raw = Number(amountCents) * rate;
|
|
|
+ const reward = BigInt(Math.min(Math.round(raw), config.capCents));
|
|
|
+
|
|
|
+ if (reward <= 0n) {
|
|
|
+ // 金额过小无奖励也标记已处理
|
|
|
+ await this.prisma.referralRecord.update({
|
|
|
+ where: { id: record.id },
|
|
|
+ data: { status: 'rewarded', firstPaidAt: new Date(), firstPaidCents: amountCents },
|
|
|
+ });
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 被邀请人首充奖励(仅一次)
|
|
|
+ await this.creditReward(user.id, reward, record.id, 'referee_first_pay');
|
|
|
+
|
|
|
+ // 邀请人付费奖励(每好友首充一次)
|
|
|
+ await this.creditReward(record.referrerId, reward, record.id, 'referrer_pay');
|
|
|
+
|
|
|
+ await this.prisma.referralRecord.update({
|
|
|
+ where: { id: record.id },
|
|
|
+ data: { status: 'rewarded', firstPaidAt: new Date(), firstPaidCents: amountCents },
|
|
|
+ });
|
|
|
+
|
|
|
+ return { recordId: record.id, refereeReward: reward, referrerReward: reward };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 我的邀请记录(好友 + 状态 + 付费 + 奖励) */
|
|
|
+ async getRecords(userId: string) {
|
|
|
+ const records = await this.prisma.referralRecord.findMany({
|
|
|
+ where: { referrerId: userId },
|
|
|
+ orderBy: { createdAt: 'desc' },
|
|
|
+ take: 50,
|
|
|
+ });
|
|
|
+ return records.map((r) => ({
|
|
|
+ id: r.id,
|
|
|
+ refereeEmail: r.refereeEmail,
|
|
|
+ status: r.status,
|
|
|
+ registerAt: r.registerAt.toISOString(),
|
|
|
+ firstPaidAt: r.firstPaidAt?.toISOString() ?? null,
|
|
|
+ firstPaidCents: r.firstPaidCents ? Number(r.firstPaidCents) : null,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 我的奖励记录 */
|
|
|
+ async getRewards(userId: string) {
|
|
|
+ const rewards = await this.prisma.referralReward.findMany({
|
|
|
+ where: { userId },
|
|
|
+ orderBy: { createdAt: 'desc' },
|
|
|
+ take: 50,
|
|
|
+ });
|
|
|
+ return rewards.map((r) => ({
|
|
|
+ id: r.id,
|
|
|
+ type: r.type,
|
|
|
+ amountCents: Number(r.amountCents),
|
|
|
+ status: r.status,
|
|
|
+ createdAt: r.createdAt.toISOString(),
|
|
|
+ }));
|
|
|
+ }
|
|
|
+}
|