Преглед изворни кода

feat: referral reward system v1.5.0

- 规则:转介绍双方 10%(上限)/ 注册奖励可多次(默认可配)/ 新客首充仅一次
- 后端 ReferralModule:邀请码/绑定/记录/奖励/策略配置(SystemConfig)+ 首充挂接
- 前端:官网邀请区块 + 控制台「邀请奖励」tab + 管理后台策略配置
- 端到端验证:注册 + 双方首充10% + 幂等
DoTouch Dev пре 2 недеља
родитељ
комит
519dc780e6

+ 2 - 2
.omo/run-continuation/ses_0229f653bffedHZYFR3ILxjlpg.json

@@ -1,10 +1,10 @@
 {
   "sessionID": "ses_0229f653bffedHZYFR3ILxjlpg",
-  "updatedAt": "2026-08-09T01:35:57.874Z",
+  "updatedAt": "2026-08-09T06:14:25.913Z",
   "sources": {
     "background-task": {
       "state": "idle",
-      "updatedAt": "2026-08-09T01:35:57.874Z"
+      "updatedAt": "2026-08-09T06:14:25.913Z"
     }
   }
 }

+ 1 - 1
VERSION

@@ -1 +1 @@
-v1.4.0 (social-login)
+v1.5.0 (referral)

+ 34 - 0
apps/api/prisma/migrations/20260809150107_referral/migration.sql

@@ -0,0 +1,34 @@
+-- 邀请奖励:用户邀请码/被邀请关系 + 记录表 + 系统配置
+ALTER TABLE "users" ADD COLUMN "referral_code" TEXT;
+ALTER TABLE "users" ADD COLUMN "referred_by_id" TEXT;
+CREATE UNIQUE INDEX "users_referral_code_key" ON "users"("referral_code");
+CREATE TABLE "referral_records" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "referrer_id" TEXT NOT NULL,
+    "referee_id" TEXT NOT NULL,
+    "referee_email" TEXT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'registered',
+    "register_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "first_paid_at" TIMESTAMP(3),
+    "first_paid_cents" BIGINT,
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    CONSTRAINT "referral_records_referrer_id_fkey" FOREIGN KEY ("referrer_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
+    CONSTRAINT "referral_records_referee_id_fkey" FOREIGN KEY ("referee_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+CREATE TABLE "referral_rewards" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "user_id" TEXT NOT NULL,
+    "record_id" TEXT NOT NULL,
+    "type" TEXT NOT NULL,
+    "amount_cents" BIGINT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'credited',
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    CONSTRAINT "referral_rewards_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
+    CONSTRAINT "referral_rewards_record_id_fkey" FOREIGN KEY ("record_id") REFERENCES "referral_records"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+CREATE TABLE "system_configs" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "key" TEXT NOT NULL UNIQUE,
+    "value" TEXT NOT NULL,
+    "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
+);

+ 51 - 0
apps/api/prisma/schema.prisma

@@ -17,6 +17,14 @@ model User {
   // 社交登录:provider = local | google | facebook;providerId 为平台唯一 ID(同邮箱绑定后记录)
   provider     String   @default("local")
   providerId   String?  @unique @map("provider_id")
+  // 邀请奖励:唯一邀请码 + 被谁邀请
+  referralCode String?  @unique @map("referral_code")
+  referredById String?  @map("referred_by_id")
+  referredBy   User?    @relation("Referrals", fields: [referredById], references: [id])
+  referrals    User[]   @relation("Referrals")
+  referralRecords ReferralRecord[] @relation("RecordsAsReferrer")
+  referredRecords ReferralRecord[]
+  referralRewards  ReferralReward[]
   role         String   @default("user") // user | admin
   currency     String   @default("USD")
   balanceCents BigInt   @default(0) @map("balance_cents")
@@ -170,3 +178,46 @@ model Order {
 
   @@map("orders")
 }
+
+// 邀请记录:每对被邀请关系一条
+model ReferralRecord {
+  id             String    @id @default(cuid())
+  referrerId     String    @map("referrer_id")
+  refereeId      String    @map("referee_id")
+  refereeEmail   String    @map("referee_email")
+  status         String    @default("registered") // registered | paid | rewarded
+  registerAt     DateTime  @default(now()) @map("register_at")
+  firstPaidAt    DateTime? @map("first_paid_at")
+  firstPaidCents BigInt?   @map("first_paid_cents")
+  createdAt      DateTime  @default(now()) @map("created_at")
+  referrer       User      @relation("RecordsAsReferrer", fields: [referrerId], references: [id])
+  referee        User      @relation(fields: [refereeId], references: [id])
+  rewards        ReferralReward[]
+
+  @@map("referral_records")
+}
+
+// 奖励记录:注册奖励 / 被邀请人首充奖励 / 邀请人付费奖励
+model ReferralReward {
+  id          String   @id @default(cuid())
+  userId      String   @map("user_id")
+  recordId    String   @map("record_id")
+  type        String   // register | referee_first_pay | referrer_pay
+  amountCents BigInt   @map("amount_cents")
+  status      String   @default("credited") // pending | credited
+  createdAt   DateTime @default(now()) @map("created_at")
+  user        User     @relation(fields: [userId], references: [id])
+  record      ReferralRecord @relation(fields: [recordId], references: [id])
+
+  @@map("referral_rewards")
+}
+
+// 系统配置(邀请奖励策略等,管理后台可调)
+model SystemConfig {
+  id        String   @id @default(cuid())
+  key       String   @unique
+  value     String
+  updatedAt DateTime @updatedAt @map("updated_at")
+
+  @@map("system_configs")
+}

+ 2 - 0
apps/api/src/app.module.ts

@@ -9,6 +9,7 @@ import { RedisModule } from './redis/redis.module';
 import { AuthModule } from './auth/auth.module';
 import { KeysModule } from './keys/keys.module';
 import { WalletModule } from './wallet/wallet.module';
+import { ReferralModule } from './referral/referral.module';
 import { ModelsModule } from './models/models.module';
 import { GatewayModule } from './gateway/gateway.module';
 import { UsageModule } from './usage/usage.module';
@@ -25,6 +26,7 @@ import { PaymentsModule } from './payments/payments.module';
     AuthModule,
     KeysModule,
     WalletModule,
+    ReferralModule,
     PaymentsModule,
     ModelsModule,
     GatewayModule,

+ 2 - 1
apps/api/src/auth/auth.module.ts

@@ -9,9 +9,10 @@ import { AuthService } from './auth.service';
 import { AuthController } from './auth.controller';
 import { JwtStrategy } from './jwt.strategy';
 import { EmailModule } from '../email/email.module';
+import { ReferralModule } from '../referral/referral.module';
 
 @Module({
-  imports: [
+  imports: [ReferralModule, 
     EmailModule,
     PassportModule.register({ defaultStrategy: 'jwt' }),
     JwtModule.registerAsync({

+ 7 - 0
apps/api/src/auth/auth.service.ts

@@ -11,6 +11,7 @@ import {
 } from '@nestjs/common';
 import { JwtService } from '@nestjs/jwt';
 import { PrismaService } from '../prisma/prisma.service';
+import { ReferralService } from '../referral/referral.service';
 import { RedisService } from '../redis/redis.service';
 import { EmailVerificationService } from '../email/email-verification.service';
 import { getOAuthProvider, type OAuthUserInfo } from './oauth.providers';
@@ -25,6 +26,7 @@ const LOGIN_FAIL_WINDOW = 900; // 15 分钟
 @Injectable()
 export class AuthService {
   constructor(
+    private readonly referral: ReferralService,
     private readonly prisma: PrismaService,
     private readonly jwt: JwtService,
     private readonly redis: RedisService,
@@ -65,6 +67,11 @@ export class AuthService {
       data: { email: dto.email, passwordHash },
     });
 
+    // 邀请绑定(邀请码有效则建立关系并发放注册奖励)
+    if (dto.referralCode) {
+      await this.referral.bindOnRegister(user.id, user.email, dto.referralCode);
+    }
+
     return {
       id: user.id,
       provider: user.provider,

+ 6 - 1
apps/api/src/auth/dto/register.dto.ts

@@ -1,7 +1,7 @@
 /**
  * 注册请求 DTO - 邮箱 + 密码 + 验证码
  */
-import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
+import { IsEmail, IsOptional, IsString, MinLength, MaxLength, Matches } from 'class-validator';
 
 export class RegisterDto {
   @IsEmail({}, { message: '邮箱格式不正确' })
@@ -17,4 +17,9 @@ export class RegisterDto {
 
   @IsString()
   code!: string;
+
+  /** 邀请码(可选:被邀请人注册时填写) */
+  @IsOptional()
+  @IsString()
+  referralCode?: string;
 }

+ 2 - 1
apps/api/src/payments/payments.module.ts

@@ -6,6 +6,7 @@ import { Module } from '@nestjs/common';
 import { PaymentsService } from './payments.service';
 import { PaymentsController } from './payments.controller';
 import { AuthModule } from '../auth/auth.module';
+import { ReferralModule } from '../referral/referral.module';
 // 支付渠道适配器
 import { MockPaymentProvider } from './providers/mock.provider';
 import { StripePaymentProvider } from './providers/stripe.provider';
@@ -15,7 +16,7 @@ import { ZaloPayPaymentProvider } from './providers/zalopay.provider';
 import { AlipayPaymentProvider } from './providers/alipay.provider';
 
 @Module({
-  imports: [AuthModule],
+  imports: [AuthModule, ReferralModule],
   controllers: [PaymentsController],
   providers: [
     PaymentsService,

+ 7 - 0
apps/api/src/payments/payments.service.ts

@@ -10,6 +10,7 @@ import {
 } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { PrismaService } from '../prisma/prisma.service';
+import { ReferralService } from '../referral/referral.service';
 import type {
   PaymentProvider,
   WebhookContext,
@@ -67,6 +68,7 @@ export class PaymentsService {
   private readonly providers = new Map<string, PaymentProvider>();
 
   constructor(
+    private readonly referral: ReferralService,
     private readonly prisma: PrismaService,
     private readonly config: ConfigService,
     mock: MockPaymentProvider,
@@ -276,6 +278,11 @@ export class PaymentsService {
       `Webhook processed: order=${orderId} user=${order.userId} amount=${order.amountCents} provider=${providerName}`,
     );
 
+    // 邀请奖励:首充触发(被邀请人 + 邀请人各 10%,上限 $5;失败不阻塞支付入账)
+    this.referral
+      .onFirstPaid(order.userId, order.amountCents)
+      .catch((e: unknown) => this.logger.warn(`Referral reward failed: ${String(e)}`));
+
     return { ok: true, message: '支付成功,余额已到账' };
   }
 

+ 50 - 0
apps/api/src/referral/referral.controller.ts

@@ -0,0 +1,50 @@
+/**
+ * 邀请奖励控制器 - 用户邀请信息/记录/奖励 + 公开策略 + 管理端配置
+ */
+import { Controller, Get, Put, Body, UseGuards } from '@nestjs/common';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { AdminGuard } from '../admin/admin.guard';
+import { CurrentUser, type JwtPayload } from '../auth/current-user.decorator';
+import { ReferralService } from './referral.service';
+
+@Controller()
+export class ReferralController {
+  constructor(private readonly referralService: ReferralService) {}
+
+  /** 我的邀请信息(码 + 链接 + 策略) */
+  @UseGuards(JwtAuthGuard)
+  @Get('referral/me')
+  me(@CurrentUser() user: JwtPayload) {
+    return this.referralService.getMyReferral(user.sub);
+  }
+
+  /** 我的邀请记录 */
+  @UseGuards(JwtAuthGuard)
+  @Get('referral/records')
+  records(@CurrentUser() user: JwtPayload) {
+    return this.referralService.getRecords(user.sub);
+  }
+
+  /** 我的奖励记录 */
+  @UseGuards(JwtAuthGuard)
+  @Get('referral/rewards')
+  rewards(@CurrentUser() user: JwtPayload) {
+    return this.referralService.getRewards(user.sub);
+  }
+
+  /** 公开策略(官网/控制台展示,无需登录) */
+  @Get('referral/config')
+  config() {
+    return this.referralService.getPublicConfig();
+  }
+
+  /** 管理端更新策略 */
+  @UseGuards(JwtAuthGuard, AdminGuard)
+  @Put('admin/referral/config')
+  updateConfig(
+    @Body()
+    body: { registerRewardCents?: number; ratePct?: number; capCents?: number },
+  ) {
+    return this.referralService.updateConfig(body);
+  }
+}

+ 12 - 0
apps/api/src/referral/referral.module.ts

@@ -0,0 +1,12 @@
+/**
+ * 邀请奖励模块 - 依赖钱包(奖励入账)
+ */
+import { Module } from '@nestjs/common';
+import { ReferralService } from './referral.service';
+import { ReferralController } from './referral.controller';
+@Module({
+  providers: [ReferralService],
+  controllers: [ReferralController],
+  exports: [ReferralService],
+})
+export class ReferralModule {}

+ 220 - 0
apps/api/src/referral/referral.service.ts

@@ -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(),
+    }));
+  }
+}

+ 29 - 1
apps/web/messages/en.json

@@ -382,5 +382,33 @@
   "tag_best": "Best Value",
   "tag_enterprise": "Enterprise",
   "tag_flagship": "Flagship",
-  "tag_reasoning": "Reasoning"
+  "tag_reasoning": "Reasoning",
+  "referral_kicker": "Referral Program",
+  "referral_h2": "Invite Friends, Both Get Rewarded",
+  "referral_p": "Invite new users via your link: you and your friend each get 10% of their first top-up (max $5).",
+  "referral_r1_t": "10% Referral",
+  "referral_r1_d": "Both sides get 10% of the friend's first top-up, up to $5 each time.",
+  "referral_r2_t": "Rewarded Repeatedly",
+  "referral_r2_d": "Invite more friends to sign up, earn rewards every time.",
+  "referral_r3_t": "New Friend Bonus",
+  "referral_r3_d": "Friends get a bonus on their first top-up (once only).",
+  "referral_cta": "Get My Referral Link",
+  "referral_link": "My referral link",
+  "referral_copy": "Copy",
+  "referral_copied": "✓ Copied",
+  "referral_policy": "Reward policy",
+  "referral_records": "Invitations",
+  "referral_rewards": "Rewards",
+  "referral_col_friend": "Friend",
+  "referral_col_status": "Status",
+  "referral_col_amount": "First top-up",
+  "referral_col_reward": "Reward",
+  "referral_col_time": "Time",
+  "referral_status_registered": "Registered",
+  "referral_status_paid": "Paid",
+  "referral_status_rewarded": "Rewarded",
+  "referral_type_register": "Register bonus",
+  "referral_type_referee_first_pay": "First top-up bonus",
+  "referral_type_referrer_pay": "Referral bonus",
+  "c_referral": "Referrals"
 }

+ 29 - 1
apps/web/messages/vi.json

@@ -382,5 +382,33 @@
   "tag_best": "Đáng giá",
   "tag_enterprise": "Doanh nghiệp",
   "tag_flagship": "Cao cấp",
-  "tag_reasoning": "Suy luận"
+  "tag_reasoning": "Suy luận",
+  "referral_kicker": "Giới thiệu bạn bè",
+  "referral_h2": "Mời bạn bè, cả hai cùng có thưởng",
+  "referral_p": "Giới thiệu người dùng mới qua liên kết của bạn: bạn và bạn của bạn mỗi người nhận 10% khoản nạp đầu tiên (tối đa $5).",
+  "referral_r1_t": "Giới thiệu 10%",
+  "referral_r1_d": "Cả hai bên nhận 10% khoản nạp đầu tiên của bạn bè, mỗi lần tối đa $5.",
+  "referral_r2_t": "Nhận thưởng nhiều lần",
+  "referral_r2_d": "Mời càng nhiều bạn bè đăng ký, nhận thưởng càng nhiều lần.",
+  "referral_r3_t": "Khách mới có thưởng ngay",
+  "referral_r3_d": "Bạn bè nhận thưởng ngay khi nạp lần đầu (chỉ một lần).",
+  "referral_cta": "Nhận liên kết giới thiệu của tôi",
+  "referral_link": "Liên kết giới thiệu của tôi",
+  "referral_copy": "Sao chép",
+  "referral_copied": "✓ Đã sao chép",
+  "referral_policy": "Chính sách thưởng",
+  "referral_records": "Lịch sử mời",
+  "referral_rewards": "Lịch sử thưởng",
+  "referral_col_friend": "Bạn bè",
+  "referral_col_status": "Trạng thái",
+  "referral_col_amount": "Nạp đầu",
+  "referral_col_reward": "Thưởng",
+  "referral_col_time": "Thời gian",
+  "referral_status_registered": "Đã đăng ký",
+  "referral_status_paid": "Đã nạp",
+  "referral_status_rewarded": "Đã thưởng",
+  "referral_type_register": "Thưởng đăng ký",
+  "referral_type_referee_first_pay": "Thưởng nạp đầu",
+  "referral_type_referrer_pay": "Thưởng giới thiệu",
+  "c_referral": "Giới thiệu bạn bè"
 }

+ 29 - 1
apps/web/messages/zh-CN.json

@@ -382,5 +382,33 @@
   "tag_best": "性价比之王",
   "tag_enterprise": "企业级",
   "tag_flagship": "旗舰",
-  "tag_reasoning": "深度推理"
+  "tag_reasoning": "深度推理",
+  "referral_kicker": "邀请奖励",
+  "referral_h2": "邀请好友,双方都有奖",
+  "referral_p": "通过你的专属链接邀请新用户:你与好友各得好友首充金额的 10%(单次上限 $5)。",
+  "referral_r1_t": "转介绍 10%",
+  "referral_r1_d": "双方各得好友首充的 10%,单次上限 $5。",
+  "referral_r2_t": "奖励可多次",
+  "referral_r2_d": "邀请越多好友注册,奖励拿得越多。",
+  "referral_r3_t": "新客首充有礼",
+  "referral_r3_d": "好友首次充值即得奖励(仅一次)。",
+  "referral_cta": "领取我的邀请链接",
+  "referral_link": "我的邀请链接",
+  "referral_copy": "复制",
+  "referral_copied": "✓ 已复制",
+  "referral_policy": "奖励规则",
+  "referral_records": "邀请记录",
+  "referral_rewards": "奖励记录",
+  "referral_col_friend": "好友",
+  "referral_col_status": "状态",
+  "referral_col_amount": "首充金额",
+  "referral_col_reward": "奖励",
+  "referral_col_time": "时间",
+  "referral_status_registered": "已注册",
+  "referral_status_paid": "已充值",
+  "referral_status_rewarded": "已奖励",
+  "referral_type_register": "注册奖励",
+  "referral_type_referee_first_pay": "首充奖励",
+  "referral_type_referrer_pay": "转介绍奖励",
+  "c_referral": "邀请奖励"
 }

+ 29 - 1
apps/web/messages/zh-TW.json

@@ -382,5 +382,33 @@
   "tag_best": "性價比之王",
   "tag_enterprise": "企業級",
   "tag_flagship": "旗艦",
-  "tag_reasoning": "深度推理"
+  "tag_reasoning": "深度推理",
+  "referral_kicker": "邀請獎勵",
+  "referral_h2": "邀請好友,雙方都有獎",
+  "referral_p": "透過你的專屬連結邀請新用戶:你與好友各得好友首儲金額的 10%(單次上限 $5)。",
+  "referral_r1_t": "轉介紹 10%",
+  "referral_r1_d": "雙方各得好友首儲的 10%,單次上限 $5。",
+  "referral_r2_t": "獎勵可多次",
+  "referral_r2_d": "邀請越多好友註冊,獎勵拿得越多。",
+  "referral_r3_t": "新客首儲有禮",
+  "referral_r3_d": "好友首次儲值即得獎勵(僅一次)。",
+  "referral_cta": "領取我的邀請連結",
+  "referral_link": "我的邀請連結",
+  "referral_copy": "複製",
+  "referral_copied": "✓ 已複製",
+  "referral_policy": "獎勵規則",
+  "referral_records": "邀請記錄",
+  "referral_rewards": "獎勵記錄",
+  "referral_col_friend": "好友",
+  "referral_col_status": "狀態",
+  "referral_col_amount": "首儲金額",
+  "referral_col_reward": "獎勵",
+  "referral_col_time": "時間",
+  "referral_status_registered": "已註冊",
+  "referral_status_paid": "已儲值",
+  "referral_status_rewarded": "已獎勵",
+  "referral_type_register": "註冊獎勵",
+  "referral_type_referee_first_pay": "首儲獎勵",
+  "referral_type_referrer_pay": "轉介紹獎勵",
+  "c_referral": "邀請獎勵"
 }

+ 2 - 0
apps/web/src/app/[locale]/page.tsx

@@ -12,6 +12,7 @@ import { PaymentsRow } from '@/components/PaymentsRow';
 import { HowItWorks } from '@/components/HowItWorks';
 import { FaqSection } from '@/components/FaqSection';
 import { CtaSection } from '@/components/CtaSection';
+import { ReferralSection } from '@/components/ReferralSection';
 import { SiteFooter } from '@/components/SiteFooter';
 import { SupportFab } from '@/components/SupportFab';
 import { AuthModal } from '@/components/AuthModal';
@@ -29,6 +30,7 @@ export default function HomePage() {
         <HowItWorks />
         <FaqSection />
         <CtaSection />
+      <ReferralSection />
       </main>
       <SiteFooter />
       <SupportFab />

+ 46 - 0
apps/web/src/components/ReferralSection.tsx

@@ -0,0 +1,46 @@
+/**
+ * 邀请奖励区块 - 官网展示(转介绍 10% / 多次奖励 / 新客首充)
+ */
+import { useTranslations } from 'next-intl';
+import Link from 'next/link';
+import { useLocale } from 'next-intl';
+
+export function ReferralSection() {
+  const t = useTranslations();
+  const locale = useLocale();
+  const rules = [
+    { icon: '🤝', tKey: 'referral_r1_t', dKey: 'referral_r1_d' },
+    { icon: '♾️', tKey: 'referral_r2_t', dKey: 'referral_r2_d' },
+    { icon: '🎁', tKey: 'referral_r3_t', dKey: 'referral_r3_d' },
+  ];
+  return (
+    <section className="border-y border-white/[0.08] bg-brand-bg2 py-16">
+      <div className="mx-auto max-w-[1160px] px-6">
+        <div className="mb-10 text-center">
+          <div className="text-[12.5px] font-bold uppercase tracking-[2px] text-brand-purple-l">
+            {t('referral_kicker')}
+          </div>
+          <h2 className="mt-2.5 text-[26px] font-extrabold">{t('referral_h2')}</h2>
+          <p className="mx-auto mt-3 max-w-[640px] text-[15px] text-brand-muted">{t('referral_p')}</p>
+        </div>
+        <div className="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(260px,1fr))]">
+          {rules.map((r) => (
+            <div key={r.tKey} className="rounded-2xl border border-white/[0.08] bg-brand-card p-6">
+              <div className="text-[24px]">{r.icon}</div>
+              <h3 className="mt-3 text-[16px] font-bold">{t(r.tKey)}</h3>
+              <p className="mt-1.5 text-[13.5px] text-brand-muted">{t(r.dKey)}</p>
+            </div>
+          ))}
+        </div>
+        <div className="mt-8 text-center">
+          <Link
+            href={`/${locale}?auth=login`}
+            className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-to-br from-brand-purple to-[#5C3DF0] px-6 py-2.5 text-sm font-semibold text-white shadow-[0_4px_20px_rgba(124,92,255,.35)] transition-all hover:-translate-y-px"
+          >
+            {t('referral_cta')}
+          </Link>
+        </div>
+      </div>
+    </section>
+  );
+}

+ 34 - 0
apps/web/src/components/admin/panels/SettingsPanel.tsx

@@ -85,6 +85,40 @@ export function SettingsPanel() {
         </SettingRow>
       </SettingCard>
 
+      {/* 邀请奖励策略 */}
+      <SettingCard title="邀请奖励策略">
+        <div className="setting-row">
+          <div>
+            <b>注册奖励</b>
+            <div className="desc">邀请人:好友注册奖励($,0=关闭)</div>
+          </div>
+          <SettingInput defaultValue="1" />
+        </div>
+        <div className="setting-row">
+          <div>
+            <b>转介绍比例</b>
+            <div className="desc">付费转介绍双方各得(%)</div>
+          </div>
+          <SettingInput defaultValue="10" />
+        </div>
+        <div className="setting-row">
+          <div>
+            <b>单次奖励上限</b>
+            <div className="desc">每笔转介绍奖励上限($)</div>
+          </div>
+          <SettingInput defaultValue="5" />
+        </div>
+        <div className="setting-row">
+          <div>
+            <b>保存</b>
+            <div className="desc">PUT /v1/admin/referral/config 生效(演示数据)</div>
+          </div>
+          <button type="button" className="op-btn" style={{ borderColor: 'rgba(124,92,255,.5)', color: '#B9A8FF' }}>
+            保存配置
+          </button>
+        </div>
+      </SettingCard>
+
       {/* 支付渠道 */}
       <SettingCard title="支付渠道" last>
         <SettingRow title="Stripe" desc="国际卡 / PayPal">

+ 3 - 0
apps/web/src/components/console/ConsoleShell.tsx

@@ -12,6 +12,7 @@ import { OverviewPanel } from './OverviewPanel';
 import { KeysPanel } from './KeysPanel';
 import { WalletPanel } from './WalletPanel';
 import { UsagePanel } from './UsagePanel';
+import { ReferralPanel } from './ReferralPanel';
 import { cls } from './ui';
 
 /** 演示环境 API 地址(生产替换为真实网关域名) */
@@ -23,6 +24,7 @@ const tabs = [
   { id: 'keys', icon: '🔑', labelKey: 'c_keys' },
   { id: 'wallet', icon: '💳', labelKey: 'c_wallet' },
   { id: 'usage', icon: '📈', labelKey: 'c_usage' },
+  { id: 'referral', icon: '🤝', labelKey: 'c_referral' },
 ] as const;
 
 type Tab = (typeof tabs)[number]['id'];
@@ -157,6 +159,7 @@ export function ConsoleShell() {
         {activeTab === 'keys' && <KeysPanel />}
         {activeTab === 'wallet' && <WalletPanel />}
         {activeTab === 'usage' && <UsagePanel />}
+        {activeTab === 'referral' && <ReferralPanel />}
       </main>
     </div>
   );

+ 178 - 0
apps/web/src/components/console/ReferralPanel.tsx

@@ -0,0 +1,178 @@
+/**
+ * 邀请奖励面板 - 我的邀请链接 + 策略 + 邀请记录 + 奖励记录(对接 /v1/referral/*)
+ */
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { SectionTitle } from '../admin/ui';
+
+const API_BASE = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:8081/v1';
+
+type Reward = { id: string; type: string; amountCents: number; status: string; createdAt: string };
+type Record = {
+  id: string;
+  refereeEmail: string;
+  status: string;
+  registerAt: string;
+  firstPaidAt: string | null;
+  firstPaidCents: number | null;
+};
+
+export function ReferralPanel() {
+  const t = useTranslations();
+  const [link, setLink] = useState('');
+  const [config, setConfig] = useState<{ registerRewardCents: number; ratePct: number; capCents: number } | null>(null);
+  const [records, setRecords] = useState<Record[]>([]);
+  const [rewards, setRewards] = useState<Reward[]>([]);
+  const [copied, setCopied] = useState(false);
+
+  useEffect(() => {
+    let token = '';
+    try {
+      token = localStorage.getItem('dotouch_token') || '';
+    } catch { /* 忽略 */ }
+    if (!token) return;
+    const h = { Authorization: `Bearer ${token}` };
+    fetch(`${API_BASE}/referral/me`, { headers: h })
+      .then((r) => r.json().catch(() => null))
+      .then((d) => d?.link && setLink(d.link))
+      .catch(() => {});
+    fetch(`${API_BASE}/referral/config`)
+      .then((r) => r.json().catch(() => null))
+      .then((d) => d && setConfig(d))
+      .catch(() => {});
+    fetch(`${API_BASE}/referral/records`, { headers: h })
+      .then((r) => r.json().catch(() => []))
+      .then((d) => Array.isArray(d) && setRecords(d))
+      .catch(() => {});
+    fetch(`${API_BASE}/referral/rewards`, { headers: h })
+      .then((r) => r.json().catch(() => []))
+      .then((d) => Array.isArray(d) && setRewards(d))
+      .catch(() => {});
+  }, []);
+
+  const copyLink = async () => {
+    try {
+      await navigator.clipboard.writeText(link);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } catch { /* 剪贴板不可用时忽略 */ }
+  };
+
+  const statusLabel = (s: string) =>
+    t(`referral_status_${s}` as never) || s;
+  const typeLabel = (ty: string) =>
+    t(`referral_type_${ty}` as never) || ty;
+
+  return (
+    <div>
+      {/* 邀请链接 + 策略 */}
+      <div className="mb-5 rounded-[14px] border border-white/[0.08] bg-brand-card p-5">
+        <div className="text-[15px] font-bold">{t('referral_link')}</div>
+        <div className="mt-3 flex flex-wrap items-center gap-3">
+          <input
+            readOnly
+            value={link}
+            className="min-w-[280px] flex-1 rounded-[10px] border border-white/[0.08] bg-brand-card2 px-[14px] py-[10px] font-mono text-[13px] text-brand-text outline-none focus:border-brand-purple"
+          />
+          <button
+            type="button"
+            onClick={copyLink}
+            className="rounded-[10px] bg-gradient-to-br from-brand-purple to-[#5C3DF0] px-4 py-[10px] text-[13px] font-semibold text-white transition-all hover:-translate-y-px"
+          >
+            {copied ? t('referral_copied') : t('referral_copy')}
+          </button>
+        </div>
+        <div className="mt-3 flex flex-wrap gap-2 text-[12px] text-brand-muted">
+          <span className="rounded-full border border-white/[0.08] bg-brand-card2 px-3 py-1">
+            {t('referral_policy')}: {config?.ratePct ?? 10}% · cap ${(config?.capCents ?? 500) / 100} ·{' '}
+            {t('referral_type_register')} ${(config?.registerRewardCents ?? 100) / 100}
+          </span>
+        </div>
+      </div>
+
+      {/* 邀请记录 */}
+      <SectionTitle>{t('referral_records')}</SectionTitle>
+      <div className="overflow-x-auto rounded-[14px] border border-white/[0.08] bg-brand-card">
+        <table className="w-full min-w-[560px] border-collapse">
+          <thead>
+            <tr>
+              {[t('referral_col_friend'), t('referral_col_status'), t('referral_col_amount'), t('referral_col_time')].map(
+                (h) => (
+                  <th
+                    key={h}
+                    className="whitespace-nowrap bg-[rgba(124,92,255,.12)] px-4 py-[13px] text-left text-[12.5px] tracking-[0.4px] text-brand-purple-l"
+                  >
+                    {h}
+                  </th>
+                ),
+              )}
+            </tr>
+          </thead>
+          <tbody>
+            {records.length === 0 && (
+              <tr>
+                <td colSpan={4} className="border-t border-white/[0.08] px-4 py-[13px] text-[13px] text-brand-muted">
+                  —
+                </td>
+              </tr>
+            )}
+            {records.map((r) => (
+              <tr key={r.id}>
+                <td className="border-t border-white/[0.08] px-4 py-[13px] text-[13px]">{r.refereeEmail}</td>
+                <td className="border-t border-white/[0.08] px-4 py-[13px] text-[13px]">{statusLabel(r.status)}</td>
+                <td className="border-t border-white/[0.08] px-4 py-[13px] font-mono text-[13px]">
+                  {r.firstPaidCents ? `$${(r.firstPaidCents / 100).toFixed(2)}` : '—'}
+                </td>
+                <td className="border-t border-white/[0.08] px-4 py-[13px] text-[12.5px] text-brand-muted">
+                  {r.registerAt.slice(0, 16).replace('T', ' ')}
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+
+      {/* 奖励记录 */}
+      <SectionTitle>{t('referral_rewards')}</SectionTitle>
+      <div className="overflow-x-auto rounded-[14px] border border-white/[0.08] bg-brand-card">
+        <table className="w-full min-w-[560px] border-collapse">
+          <thead>
+            <tr>
+              {[t('referral_col_reward'), t('referral_col_status'), t('referral_col_time')].map((h) => (
+                <th
+                  key={h}
+                  className="whitespace-nowrap bg-[rgba(124,92,255,.12)] px-4 py-[13px] text-left text-[12.5px] tracking-[0.4px] text-brand-purple-l"
+                >
+                  {h}
+                </th>
+              ))}
+            </tr>
+          </thead>
+          <tbody>
+            {rewards.length === 0 && (
+              <tr>
+                <td colSpan={3} className="border-t border-white/[0.08] px-4 py-[13px] text-[13px] text-brand-muted">
+                  —
+                </td>
+              </tr>
+            )}
+            {rewards.map((r) => (
+              <tr key={r.id}>
+                <td className="border-t border-white/[0.08] px-4 py-[13px] text-[13px]">
+                  {typeLabel(r.type)}{' '}
+                  <span className="font-mono text-brand-green-l">+${(r.amountCents / 100).toFixed(2)}</span>
+                </td>
+                <td className="border-t border-white/[0.08] px-4 py-[13px] text-[13px]">{r.status}</td>
+                <td className="border-t border-white/[0.08] px-4 py-[13px] text-[12.5px] text-brand-muted">
+                  {r.createdAt.slice(0, 16).replace('T', ' ')}
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+    </div>
+  );
+}

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
apps/web/tsconfig.tsbuildinfo


+ 8 - 0
docs/CHANGELOG.md

@@ -2,6 +2,14 @@
 
 # DoTouch.AI Changelog
 
+## v1.5.0 (referral) — 2026-08-09
+**邀请奖励系统**
+- 规则:付费转介绍双方各 10%(单次上限 $5);邀请注册奖励(可多次,默认 $1 可配);新客首充奖励仅一次
+- 后端:ReferralModule(邀请码生成/绑定/记录/奖励发放/策略配置)+ SystemConfig 表 + 首充挂接(支付成功触发)
+- 端点:GET /v1/referral/me|records|rewards|config、PUT /v1/admin/referral/config;注册支持 referralCode
+- 前端:官网邀请奖励区块(3 规则卡+CTA);控制台新增「邀请奖励」tab(链接复制/邀请记录/奖励记录);管理后台策略配置卡
+- 演示:A 邀请 B → A 得注册 $1 + 双方各得首充 10%(幂等验证通过)
+
 ## v1.4.0 (social-login) — 2026-08-09
 **Google / Facebook 社交登录(OAuth,类似 GitHub 登录)**
 - 后端:OAuth 适配器(google/facebook 真实实现 + mock 演示),授权码→用户信息→JWT

Неке датотеке нису приказане због велике количине промена