feat(referral): 推荐服务全链路实现

Backend:
- referral-service: 全新微服务 (NestJS + DDD + Clean Architecture)
  - ReferralProfile 聚合根 (TypeORM entity)
  - ReferralCode / ReferralChain 值对象
  - 仓储接口 + 实现
  - ReferralService 应用服务: 创建档案/验证码/查询直推
  - UserRegisteredHandler: 订阅 genex.user.registered Kafka 事件
  - REST: GET /referral/my, GET /referral/validate, GET /referral/direct
  - 内网: POST /internal/referral/profiles
  - 端口 3013 (外部 4013)
- auth-service: RegisterDto 增加 referralCode 字段
- auth-service: UserRegisteredEvent 携带 referralCode
- auth-service: EventPublisherService 实际接入 Kafka (之前是 stub)
- migrations: 047_create_referral_profiles.sql
- docker-compose: 新增 referral-service 服务块

Frontend (genex-mobile):
- 注册页添加推荐码输入框 (可选),输入时实时验证推荐码有效性
- AuthService.register() 增加 referralCode 参数
- AuthService.validateReferralCode() 新增方法
- i18n: zh_CN/zh_TW/en/ja 各新增 4 个推荐码相关 key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-04 00:58:28 -08:00
parent ec0af6c47e
commit 0ecf295c35
30 changed files with 1057 additions and 56 deletions

View File

@ -403,6 +403,30 @@ services:
networks:
- genex-network
referral-service:
build:
context: .
dockerfile: services/referral-service/Dockerfile
container_name: genex-referral-service
ports:
- "4013:3013"
environment:
- NODE_ENV=development
- PORT=3013
- SERVICE_NAME=referral-service
- DB_HOST=postgres
- DB_PORT=5432
- DB_USERNAME=genex
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=genex
- JWT_ACCESS_SECRET=dev-access-secret-change-in-production
- KAFKA_BROKERS=kafka:9092
depends_on:
postgres:
condition: service_healthy
networks:
- genex-network
# ============================================================
# Go Services (3)
# ============================================================

View File

@ -0,0 +1,51 @@
-- Migration 047: Create referral_profiles table
-- 推荐关系表: 每个用户一条记录,记录推荐码、推荐人、推荐链
CREATE TABLE IF NOT EXISTS referral_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- 用户 ID (对应 users.id)
user_id UUID NOT NULL,
-- 此用户的推荐码 (用于邀请他人注册)
referral_code VARCHAR(20) NOT NULL,
-- 推荐人 userId (注册时使用了他人推荐码时才有值)
referrer_id UUID,
-- 注册时填写的推荐码 (即推荐人的推荐码)
used_code VARCHAR(20),
-- 推荐链: 从直接推荐人到顶层的 userId 数组 (PostgreSQL 原生 text[])
referral_chain TEXT[] NOT NULL DEFAULT '{}',
-- 直推下级人数
direct_referral_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 唯一约束
CREATE UNIQUE INDEX IF NOT EXISTS idx_referral_profiles_user
ON referral_profiles(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_referral_profiles_code
ON referral_profiles(referral_code);
-- 推荐人查询加速
CREATE INDEX IF NOT EXISTS idx_referral_profiles_referrer
ON referral_profiles(referrer_id);
-- updated_at 自动更新触发器
CREATE OR REPLACE FUNCTION update_referral_profiles_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_referral_profiles_updated_at
BEFORE UPDATE ON referral_profiles
FOR EACH ROW EXECUTE FUNCTION update_referral_profiles_updated_at();

View File

@ -22,6 +22,7 @@ export interface RegisterDto {
smsCode: string;
password?: string;
nickname?: string;
referralCode?: string; // 推荐人的推荐码(可选)
}
export interface LoginDto {
@ -103,6 +104,7 @@ export class AuthService {
phone: user.phone,
email: user.email,
role: user.role,
referralCode: dto.referralCode?.toUpperCase() ?? null,
timestamp: new Date().toISOString(),
});

View File

@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { Kafka, Producer } from 'kafkajs';
import {
UserRegisteredEvent,
UserLoggedInEvent,
@ -10,66 +11,75 @@ import {
} from '../../domain/events/auth.events';
/**
* Event publisher using Outbox pattern.
* Events are written to the outbox table within the same DB transaction,
* then published to Kafka by the OutboxRelay or Debezium CDC.
* Event publisher Kafka (fire-and-forget, Kafka )
* Outbox pattern at-least-once
*/
@Injectable()
export class EventPublisherService {
async publishUserRegistered(event: UserRegisteredEvent): Promise<void> {
// In Phase 1, we use direct Kafka publish.
// In production, this would use OutboxService.publishWithinTransaction()
// to ensure atomicity with the user creation.
await this.publishToOutbox('genex.user.registered', 'User', event.userId, 'user.registered', event);
}
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(EventPublisherService.name);
private producer: Producer | null = null;
private connected = false;
async publishUserLoggedIn(event: UserLoggedInEvent): Promise<void> {
await this.publishToOutbox('genex.user.logged-in', 'User', event.userId, 'user.logged_in', event);
}
async publishUserLoggedOut(event: UserLoggedOutEvent): Promise<void> {
await this.publishToOutbox('genex.user.logged-out', 'User', event.userId, 'user.logged_out', event);
}
async publishPasswordChanged(event: PasswordChangedEvent): Promise<void> {
await this.publishToOutbox('genex.user.password-changed', 'User', event.userId, 'user.password_changed', event);
}
async publishAccountLocked(event: AccountLockedEvent): Promise<void> {
await this.publishToOutbox('genex.user.locked', 'User', event.userId, 'user.account_locked', event);
}
async publishPhoneChanged(event: PhoneChangedEvent): Promise<void> {
await this.publishToOutbox('genex.user.phone-changed', 'User', event.userId, 'user.phone_changed', event);
}
async publishPasswordReset(event: PasswordResetEvent): Promise<void> {
await this.publishToOutbox('genex.user.password-reset', 'User', event.userId, 'user.password_reset', event);
}
private async publishToOutbox(
topic: string,
aggregateType: string,
aggregateId: string,
eventType: string,
payload: any,
): Promise<void> {
// This will be wired to OutboxService in the module setup
// For now, stores the event intent. The actual Kafka publish happens via OutboxRelay.
if (this.outboxService) {
await this.outboxService.publish({
aggregateType,
aggregateId,
eventType,
topic,
payload,
});
async onModuleInit() {
const brokers = (process.env.KAFKA_BROKERS || 'localhost:9092').split(',').map((b) => b.trim());
try {
const kafka = new Kafka({ clientId: 'auth-service', brokers, retry: { retries: 3 } });
this.producer = kafka.producer({ idempotent: false });
await this.producer.connect();
this.connected = true;
this.logger.log(`Kafka producer connected [${brokers.join(', ')}]`);
} catch (err) {
this.logger.warn(`Kafka 不可用,事件将被丢弃: ${err.message}`);
}
}
private outboxService: any;
async onModuleDestroy() {
if (this.connected && this.producer) {
await this.producer.disconnect();
}
}
setOutboxService(outboxService: any): void {
this.outboxService = outboxService;
async publishUserRegistered(event: UserRegisteredEvent): Promise<void> {
await this.publish('genex.user.registered', event.userId, 'user.registered', event);
}
async publishUserLoggedIn(event: UserLoggedInEvent): Promise<void> {
await this.publish('genex.user.logged-in', event.userId, 'user.logged_in', event);
}
async publishUserLoggedOut(event: UserLoggedOutEvent): Promise<void> {
await this.publish('genex.user.logged-out', event.userId, 'user.logged_out', event);
}
async publishPasswordChanged(event: PasswordChangedEvent): Promise<void> {
await this.publish('genex.user.password-changed', event.userId, 'user.password_changed', event);
}
async publishAccountLocked(event: AccountLockedEvent): Promise<void> {
await this.publish('genex.user.locked', event.userId, 'user.account_locked', event);
}
async publishPhoneChanged(event: PhoneChangedEvent): Promise<void> {
await this.publish('genex.user.phone-changed', event.userId, 'user.phone_changed', event);
}
async publishPasswordReset(event: PasswordResetEvent): Promise<void> {
await this.publish('genex.user.password-reset', event.userId, 'user.password_reset', event);
}
private async publish(topic: string, key: string, eventType: string, payload: any): Promise<void> {
if (!this.connected || !this.producer) return;
try {
await this.producer.send({
topic,
messages: [{
key,
value: JSON.stringify(payload),
headers: { eventType, timestamp: new Date().toISOString(), source: 'auth-service' },
}],
});
} catch (err) {
this.logger.warn(`事件发布失败 topic=${topic}: ${err.message}`);
}
}
}

View File

@ -3,6 +3,7 @@ export interface UserRegisteredEvent {
phone: string | null;
email: string | null;
role: string;
referralCode: string | null; // 注册时填写的推荐码(推荐人的码)
timestamp: string;
}

View File

@ -24,4 +24,10 @@ export class RegisterDto {
@IsString()
@MaxLength(50)
nickname?: string;
@ApiPropertyOptional({ description: '推荐码(可选)', example: 'GNX1A2B3' })
@IsOptional()
@IsString()
@Matches(/^[A-Z0-9]{6,20}$/i, { message: '推荐码格式无效' })
referralCode?: string;
}

View File

@ -0,0 +1,23 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Install service dependencies
COPY services/referral-service/package*.json ./services/referral-service/
WORKDIR /app/services/referral-service
RUN npm install
# Copy service source and build
COPY services/referral-service/ ./
RUN npm run build
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache dumb-init
COPY --from=builder /app/services/referral-service/dist ./dist
COPY --from=builder /app/services/referral-service/node_modules ./node_modules
COPY --from=builder /app/services/referral-service/package.json ./
USER node
EXPOSE 3013
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3013/api/v1/health || exit 1
CMD ["dumb-init", "node", "dist/main"]

View File

@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

View File

@ -0,0 +1,39 @@
{
"name": "@genex/referral-service",
"version": "1.0.0",
"description": "Genex Referral Service - referral code generation, relationship tracking, chain management",
"scripts": {
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"build": "nest build",
"test": "jest"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/typeorm": "^10.0.1",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/swagger": "^7.2.0",
"@nestjs/throttler": "^5.1.0",
"typeorm": "^0.3.19",
"pg": "^8.11.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"kafkajs": "^2.2.4",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.1",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/testing": "^10.3.0",
"@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.1",
"typescript": "^5.3.0",
"ts-node": "^10.9.0"
}
}

View File

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ReferralModule } from './referral.module';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME || 'genex',
password: process.env.DB_PASSWORD || 'genex_dev_password',
database: process.env.DB_NAME || 'genex',
autoLoadEntities: true,
synchronize: false,
logging: process.env.NODE_ENV === 'development',
extra: {
max: parseInt(process.env.DB_POOL_MAX || '10', 10),
min: parseInt(process.env.DB_POOL_MIN || '2', 10),
},
}),
ReferralModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,90 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
import { ReferralService } from '../services/referral.service';
interface UserRegisteredPayload {
userId: string;
phone: string | null;
email: string | null;
role: string;
referralCode: string | null; // 注册时填写的推荐码 (推荐人的码)
timestamp: string;
}
/**
* auth-service genex.user.registered
*
*/
@Injectable()
export class UserRegisteredHandler implements OnModuleInit {
private readonly logger = new Logger(UserRegisteredHandler.name);
private consumer: Consumer;
constructor(private readonly referralService: ReferralService) {}
async onModuleInit() {
const brokers = (process.env.KAFKA_BROKERS || 'localhost:9092').split(',').map((b) => b.trim());
const kafka = new Kafka({
clientId: 'referral-service',
brokers,
retry: { retries: 5 },
});
this.consumer = kafka.consumer({ groupId: 'genex-referral-service' });
try {
await this.consumer.connect();
await this.consumer.subscribe({
topic: 'genex.user.registered',
fromBeginning: false,
});
await this.consumer.run({
eachMessage: async (payload: EachMessagePayload) => {
await this.handleMessage(payload);
},
});
this.logger.log('已订阅 genex.user.registered');
} catch (err) {
// Kafka 不可用时不阻断服务启动
this.logger.warn(`Kafka 连接失败 (将跳过事件监听): ${err.message}`);
}
}
private async handleMessage(payload: EachMessagePayload): Promise<void> {
const raw = payload.message.value?.toString();
if (!raw) return;
let event: UserRegisteredPayload;
try {
event = JSON.parse(raw);
} catch {
this.logger.error('事件消息解析失败');
return;
}
if (!event.userId) {
this.logger.warn('无效事件: 缺少 userId');
return;
}
try {
await this.referralService.createProfile({
userId: event.userId,
usedCode: event.referralCode ?? null,
});
} catch (err) {
this.logger.error(`创建推荐档案失败 userId=${event.userId}: ${err.message}`);
// 不 rethrow — 避免死锁,允许 Kafka 继续消费
}
}
async onModuleDestroy() {
try {
await this.consumer?.disconnect();
} catch {
// ignore
}
}
}

View File

@ -0,0 +1,166 @@
import {
Injectable,
Logger,
Inject,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { ReferralProfile } from '../../domain/entities/referral-profile.entity';
import { ReferralCode } from '../../domain/value-objects/referral-code.vo';
import { ReferralChain } from '../../domain/value-objects/referral-chain.vo';
import {
REFERRAL_PROFILE_REPOSITORY,
IReferralProfileRepository,
} from '../../domain/repositories/referral-profile.repository.interface';
export interface CreateReferralCommand {
userId: string;
usedCode: string | null; // 注册时填写的推荐码 (推荐人的码)
}
export interface ReferralInfo {
userId: string;
referralCode: string;
referrerId: string | null;
usedCode: string | null;
referralChain: string[];
directReferralCount: number;
createdAt: Date;
}
export interface DirectReferralItem {
userId: string;
referralCode: string;
joinedAt: Date;
}
@Injectable()
export class ReferralService {
private readonly logger = new Logger(ReferralService.name);
constructor(
@Inject(REFERRAL_PROFILE_REPOSITORY)
private readonly profileRepo: IReferralProfileRepository,
) {}
/**
*
* UserRegisteredHandler Kafka
*/
async createProfile(command: CreateReferralCommand): Promise<{ referralCode: string }> {
if (await this.profileRepo.existsByUserId(command.userId)) {
this.logger.warn(`推荐档案已存在, userId=${command.userId}`);
const existing = await this.profileRepo.findByUserId(command.userId);
return { referralCode: existing!.referralCode };
}
// 生成唯一推荐码
const referralCode = await this.generateUniqueCode();
// 查找推荐人
let referrerId: string | null = null;
let referralChain: string[] = [];
if (command.usedCode) {
const referrerProfile = await this.profileRepo.findByCode(command.usedCode);
if (!referrerProfile) {
// 推荐码无效,静默忽略 (不阻止注册)
this.logger.warn(`无效推荐码 ${command.usedCode}, userId=${command.userId}, 忽略推荐关系`);
} else {
// 防止自引用
if (referrerProfile.userId === command.userId) {
throw new BadRequestException('不能使用自己的推荐码');
}
// 防止循环推荐
const chain = ReferralChain.fromArray(referrerProfile.referralChain);
if (chain.contains(command.userId)) {
this.logger.warn(`检测到循环推荐, userId=${command.userId}`);
} else {
referrerId = referrerProfile.userId;
referralChain = ReferralChain.buildFromReferrer(
referrerProfile.userId,
referrerProfile.referralChain,
).toArray();
// 递增推荐人的直推计数
referrerProfile.incrementDirectReferral();
await this.profileRepo.save(referrerProfile);
}
}
}
const profile = ReferralProfile.create({
userId: command.userId,
referralCode,
referrerId,
usedCode: command.usedCode ? command.usedCode.toUpperCase() : null,
referralChain,
});
const saved = await this.profileRepo.save(profile);
this.logger.log(
`推荐档案已创建: userId=${command.userId} code=${referralCode} referrer=${referrerId ?? 'none'}`,
);
return { referralCode: saved.referralCode };
}
/** 获取我的推荐信息 */
async getMyInfo(userId: string): Promise<ReferralInfo> {
const profile = await this.profileRepo.findByUserId(userId);
if (!profile) {
throw new NotFoundException('推荐档案不存在');
}
return this.toInfo(profile);
}
/** 验证推荐码是否有效 */
async validateCode(code: string): Promise<{ valid: boolean; referrerId?: string }> {
const profile = await this.profileRepo.findByCode(code);
if (!profile) return { valid: false };
return { valid: true, referrerId: profile.userId };
}
/** 获取直推列表 */
async getDirectReferrals(
userId: string,
offset = 0,
limit = 20,
): Promise<{ items: DirectReferralItem[]; total: number }> {
const [profiles, total] = await this.profileRepo.findDirectReferrals(userId, offset, limit);
return {
items: profiles.map((p) => ({
userId: p.userId,
referralCode: p.referralCode,
joinedAt: p.createdAt,
})),
total,
};
}
/* ── Private ── */
private async generateUniqueCode(maxRetries = 10): Promise<string> {
for (let i = 0; i < maxRetries; i++) {
const code = ReferralCode.generate().value;
if (!(await this.profileRepo.existsByCode(code))) {
return code;
}
}
throw new Error('推荐码生成失败,请重试');
}
private toInfo(profile: ReferralProfile): ReferralInfo {
return {
userId: profile.userId,
referralCode: profile.referralCode,
referrerId: profile.referrerId,
usedCode: profile.usedCode,
referralChain: profile.referralChain,
directReferralCount: profile.directReferralCount,
createdAt: profile.createdAt,
};
}
}

View File

@ -0,0 +1,98 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { ReferralCode } from '../value-objects/referral-code.vo';
import { ReferralChain } from '../value-objects/referral-chain.vo';
/**
* ReferralProfile
*
* :
* -
* - (usedCode / referrerId)
* - (referralChain)
* -
*/
@Entity('referral_profiles')
@Index('idx_referral_profiles_code', ['referralCode'], { unique: true })
@Index('idx_referral_profiles_user', ['userId'], { unique: true })
@Index('idx_referral_profiles_referrer', ['referrerId'])
export class ReferralProfile {
@PrimaryGeneratedColumn('uuid')
id: string;
/** 用户 ID (UUID, 外键指向 users 表) */
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
/** 此用户的推荐码 (用于邀请他人) */
@Column({ name: 'referral_code', type: 'varchar', length: 20 })
referralCode: string;
/** 推荐人 userId (注册时填写的推荐码对应的用户) */
@Column({ name: 'referrer_id', type: 'uuid', nullable: true })
referrerId: string | null;
/** 注册时填写的推荐码 (即推荐人的推荐码) */
@Column({ name: 'used_code', type: 'varchar', length: 20, nullable: true })
usedCode: string | null;
/**
* : [ userId, userId, ...]
* PostgreSQL text[]
*/
@Column({ name: 'referral_chain', type: 'text', array: true, default: [] })
referralChain: string[];
/** 直推下级人数 */
@Column({ name: 'direct_referral_count', type: 'int', default: 0 })
directReferralCount: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
/* ── Domain Logic ── */
/**
* 工厂方法: 创建新推荐档案
*/
static create(params: {
userId: string;
referralCode: string;
referrerId: string | null;
usedCode: string | null;
referralChain: string[];
}): ReferralProfile {
const profile = new ReferralProfile();
profile.userId = params.userId;
profile.referralCode = params.referralCode;
profile.referrerId = params.referrerId;
profile.usedCode = params.usedCode;
profile.referralChain = params.referralChain;
profile.directReferralCount = 0;
return profile;
}
/** 新用户加入后递增直推计数 */
incrementDirectReferral(): void {
this.directReferralCount += 1;
}
/** 获取推荐链值对象 */
getChain(): ReferralChain {
return ReferralChain.fromArray(this.referralChain);
}
/** 获取推荐码值对象 */
getCode(): ReferralCode {
return ReferralCode.create(this.referralCode);
}
}

View File

@ -0,0 +1,12 @@
import { ReferralProfile } from '../entities/referral-profile.entity';
export const REFERRAL_PROFILE_REPOSITORY = 'REFERRAL_PROFILE_REPOSITORY';
export interface IReferralProfileRepository {
findByUserId(userId: string): Promise<ReferralProfile | null>;
findByCode(code: string): Promise<ReferralProfile | null>;
existsByCode(code: string): Promise<boolean>;
existsByUserId(userId: string): Promise<boolean>;
findDirectReferrals(referrerId: string, offset: number, limit: number): Promise<[ReferralProfile[], number]>;
save(profile: ReferralProfile): Promise<ReferralProfile>;
}

View File

@ -0,0 +1,58 @@
/**
* ReferralChain Value Object
* 推荐链: 存储从直接推荐人到顶层的有序 userId
* chain[0] = , chain[1] = , ...
*/
export class ReferralChain {
private static readonly MAX_DEPTH = 50;
private constructor(private readonly _chain: string[]) {
if (_chain.length > ReferralChain.MAX_DEPTH) {
throw new Error(`推荐链深度不能超过 ${ReferralChain.MAX_DEPTH}`);
}
}
static empty(): ReferralChain {
return new ReferralChain([]);
}
static fromArray(chain: string[]): ReferralChain {
return new ReferralChain([...chain]);
}
/**
* 构建新用户的推荐链: 将推荐人 prepend
*/
static buildFromReferrer(referrerId: string, referrerChain: string[]): ReferralChain {
const chain = [referrerId, ...referrerChain];
return new ReferralChain(chain.slice(0, ReferralChain.MAX_DEPTH));
}
get depth(): number {
return this._chain.length;
}
/** 直接推荐人 (chain[0]) */
get directReferrer(): string | null {
return this._chain[0] ?? null;
}
/** 获取第 N 级上级 (1-based, 1 = 直接推荐人) */
getReferrerAtLevel(level: number): string | null {
return this._chain[level - 1] ?? null;
}
/** 所有祖先 */
getAllAncestors(): string[] {
return [...this._chain];
}
toArray(): string[] {
return [...this._chain];
}
/** 检查 userId 是否已在链中 (防止循环推荐) */
contains(userId: string): boolean {
return this._chain.includes(userId);
}
}

View File

@ -0,0 +1,39 @@
/**
* ReferralCode Value Object
* 格式: GNX + 5 (8, 0/O/I/1)
*/
export class ReferralCode {
private static readonly ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
private static readonly PATTERN = /^[A-Z0-9]{6,20}$/;
private constructor(public readonly value: string) {
if (!ReferralCode.PATTERN.test(value)) {
throw new Error(`推荐码格式无效: ${value}`);
}
}
static create(raw: string): ReferralCode {
return new ReferralCode(raw.toUpperCase().trim());
}
/**
* userId (GNX + 5)
*
*/
static generate(): ReferralCode {
const prefix = 'GNX';
let suffix = '';
for (let i = 0; i < 5; i++) {
suffix += ReferralCode.ALPHABET[Math.floor(Math.random() * ReferralCode.ALPHABET.length)];
}
return new ReferralCode(`${prefix}${suffix}`);
}
equals(other: ReferralCode): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}

View File

@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ReferralProfile } from '../../domain/entities/referral-profile.entity';
import { IReferralProfileRepository } from '../../domain/repositories/referral-profile.repository.interface';
@Injectable()
export class ReferralProfileRepository implements IReferralProfileRepository {
constructor(
@InjectRepository(ReferralProfile)
private readonly repo: Repository<ReferralProfile>,
) {}
async findByUserId(userId: string): Promise<ReferralProfile | null> {
return this.repo.findOne({ where: { userId } });
}
async findByCode(code: string): Promise<ReferralProfile | null> {
return this.repo.findOne({ where: { referralCode: code.toUpperCase() } });
}
async existsByCode(code: string): Promise<boolean> {
const count = await this.repo.count({ where: { referralCode: code.toUpperCase() } });
return count > 0;
}
async existsByUserId(userId: string): Promise<boolean> {
const count = await this.repo.count({ where: { userId } });
return count > 0;
}
async findDirectReferrals(
referrerId: string,
offset: number,
limit: number,
): Promise<[ReferralProfile[], number]> {
return this.repo.findAndCount({
where: { referrerId },
order: { createdAt: 'DESC' },
skip: offset,
take: limit,
});
}
async save(profile: ReferralProfile): Promise<ReferralProfile> {
return this.repo.save(profile);
}
}

View File

@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
});
}
async validate(payload: any) {
return { sub: payload.sub, role: payload.role, kycLevel: payload.kycLevel };
}
}

View File

@ -0,0 +1,11 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('Health')
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok', service: 'referral-service' };
}
}

View File

@ -0,0 +1,41 @@
import { Controller, Post, Body, Get, Param, HttpCode } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { IsString, IsOptional } from 'class-validator';
import { ReferralService } from '../../../application/services/referral.service';
class CreateProfileDto {
@IsString()
userId: string;
@IsOptional()
@IsString()
usedCode?: string;
}
/**
* ( Kong )
* auth-service Kafka
*/
@ApiTags('Internal - Referral')
@Controller('internal/referral')
export class InternalReferralController {
constructor(private readonly referralService: ReferralService) {}
@Post('profiles')
@HttpCode(201)
@ApiOperation({ summary: '[内网] 创建推荐档案 (注册成功后调用)' })
async createProfile(@Body() dto: CreateProfileDto) {
const result = await this.referralService.createProfile({
userId: dto.userId,
usedCode: dto.usedCode ?? null,
});
return { code: 0, data: result };
}
@Get('profiles/:userId')
@ApiOperation({ summary: '[内网] 获取用户推荐档案' })
async getProfile(@Param('userId') userId: string) {
const info = await this.referralService.getMyInfo(userId);
return { code: 0, data: info };
}
}

View File

@ -0,0 +1,55 @@
import {
Controller,
Get,
Query,
Req,
UseGuards,
ParseIntPipe,
DefaultValuePipe,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { ReferralService } from '../../../application/services/referral.service';
@ApiTags('Referral')
@Controller('referral')
export class ReferralController {
constructor(private readonly referralService: ReferralService) {}
/** 获取我的推荐信息 (推荐码 + 推荐人 + 直推人数) */
@Get('my')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: '获取我的推荐信息' })
async getMyInfo(@Req() req: any) {
const userId: string = req.user.sub;
const info = await this.referralService.getMyInfo(userId);
return { code: 0, data: info };
}
/** 验证推荐码是否有效 (公开接口,注册页面调用) */
@Get('validate')
@ApiOperation({ summary: '验证推荐码' })
@ApiQuery({ name: 'code', description: '推荐码', example: 'GNXAB123' })
async validateCode(@Query('code') code: string) {
if (!code) throw new BadRequestException('缺少推荐码参数');
const result = await this.referralService.validateCode(code);
return { code: 0, data: result };
}
/** 获取我的直推列表 */
@Get('direct')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: '获取直推下级列表' })
async getDirectReferrals(
@Req() req: any,
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
) {
const userId: string = req.user.sub;
const result = await this.referralService.getDirectReferrals(userId, offset, Math.min(limit, 100));
return { code: 0, data: result };
}
}

View File

@ -0,0 +1,37 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const logger = new Logger('ReferralService');
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
app.enableCors({
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
});
const swaggerConfig = new DocumentBuilder()
.setTitle('Genex Referral Service')
.setDescription('推荐码管理 — 推荐关系追踪、推荐链维护、直推统计')
.setVersion('1.0')
.addBearerAuth()
.addTag('Referral')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, document);
app.enableShutdownHooks();
const port = process.env.PORT || 3013;
await app.listen(port);
logger.log(`Referral Service running on port ${port}`);
logger.log(`Swagger docs: http://localhost:${port}/docs`);
}
bootstrap();

View File

@ -0,0 +1,41 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
// Domain
import { ReferralProfile } from './domain/entities/referral-profile.entity';
import { REFERRAL_PROFILE_REPOSITORY } from './domain/repositories/referral-profile.repository.interface';
// Infrastructure
import { ReferralProfileRepository } from './infrastructure/persistence/referral-profile.repository';
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
// Application
import { ReferralService } from './application/services/referral.service';
import { UserRegisteredHandler } from './application/event-handlers/user-registered.handler';
// Interface
import { ReferralController } from './interface/http/controllers/referral.controller';
import { InternalReferralController } from './interface/http/controllers/internal-referral.controller';
import { HealthController } from './interface/http/controllers/health.controller';
@Module({
imports: [
TypeOrmModule.forFeature([ReferralProfile]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
signOptions: { expiresIn: '15m' },
}),
],
controllers: [HealthController, ReferralController, InternalReferralController],
providers: [
{ provide: REFERRAL_PROFILE_REPOSITORY, useClass: ReferralProfileRepository },
JwtStrategy,
ReferralService,
UserRegisteredHandler,
],
exports: [ReferralService],
})
export class ReferralModule {}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2021",
"lib": ["ES2021"],
"outDir": "./dist",
"rootDir": "./src",
"strict": false,
"declaration": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}

View File

@ -85,6 +85,10 @@ const Map<String, String> en = {
'register.errorCodeInvalid': 'Please enter a 6-digit code',
'register.errorPasswordWeak': 'Password must be 8+ characters with letters and numbers',
'register.errorTermsRequired': 'Please agree to the Terms of Service',
'register.referralCode': 'Referral Code (optional)',
'register.referralCodeHint': 'Enter inviter\'s referral code',
'register.referralCodeValid': 'Referral code is valid',
'register.referralCodeInvalid': 'Invalid referral code',
'forgot.title': 'Reset Password',
'forgot.inputAccount': 'Enter phone or email',

View File

@ -85,6 +85,10 @@ const Map<String, String> ja = {
'register.errorCodeInvalid': '6桁の認証コードを入力してください',
'register.errorPasswordWeak': 'パスワードは8文字以上で英字と数字を含む必要があります',
'register.errorTermsRequired': '利用規約に同意してください',
'register.referralCode': '紹介コード(任意)',
'register.referralCodeHint': '招待者の紹介コードを入力',
'register.referralCodeValid': '紹介コードは有効です',
'register.referralCodeInvalid': '紹介コードが無効です',
'forgot.title': 'パスワード再設定',
'forgot.inputAccount': '電話番号またはメールを入力',

View File

@ -85,6 +85,10 @@ const Map<String, String> zhCN = {
'register.errorCodeInvalid': '请输入6位验证码',
'register.errorPasswordWeak': '密码需要8位以上且包含字母和数字',
'register.errorTermsRequired': '请先阅读并同意用户协议',
'register.referralCode': '推荐码(选填)',
'register.referralCodeHint': '输入邀请人的推荐码',
'register.referralCodeValid': '推荐码有效',
'register.referralCodeInvalid': '推荐码无效',
'forgot.title': '找回密码',
'forgot.inputAccount': '输入手机号或邮箱',

View File

@ -85,6 +85,10 @@ const Map<String, String> zhTW = {
'register.errorCodeInvalid': '請輸入6位驗證碼',
'register.errorPasswordWeak': '密碼需要8位以上且包含字母和數字',
'register.errorTermsRequired': '請先閱讀並同意使用者協議',
'register.referralCode': '推薦碼(選填)',
'register.referralCodeHint': '輸入邀請人的推薦碼',
'register.referralCodeValid': '推薦碼有效',
'register.referralCodeInvalid': '推薦碼無效',
'forgot.title': '找回密碼',
'forgot.inputAccount': '輸入手機號或信箱',

View File

@ -62,6 +62,19 @@ class AuthService {
return data['expiresIn'] as int;
}
/* ── 推荐码 ── */
/// ( referral-service)
Future<bool> validateReferralCode(String code) async {
try {
final resp = await _api.get('/api/v1/referral/validate', queryParameters: {'code': code});
final data = resp.data['data'] as Map<String, dynamic>;
return data['valid'] == true;
} catch (_) {
return false;
}
}
/* ── 注册 ── */
/// ( REGISTER )
@ -70,12 +83,15 @@ class AuthService {
required String smsCode,
required String password,
String? nickname,
String? referralCode,
}) async {
final resp = await _api.post('/api/v1/auth/register', data: {
'phone': phone,
'smsCode': smsCode,
'password': password,
if (nickname != null) 'nickname': nickname,
if (referralCode != null && referralCode.isNotEmpty)
'referralCode': referralCode.toUpperCase(),
});
final result = AuthResult.fromJson(resp.data['data']);
_setAuth(result);

View File

@ -24,10 +24,13 @@ class _RegisterPageState extends State<RegisterPage> {
final _accountController = TextEditingController();
final _codeController = TextEditingController();
final _passwordController = TextEditingController();
final _referralCodeController = TextEditingController();
bool _obscurePassword = true;
bool _agreeTerms = false;
bool _loading = false;
String? _errorMessage;
// null=, true=, false=
bool? _referralCodeValid;
final _authService = AuthService.instance;
@ -42,9 +45,23 @@ class _RegisterPageState extends State<RegisterPage> {
_accountController.dispose();
_codeController.dispose();
_passwordController.dispose();
_referralCodeController.dispose();
super.dispose();
}
Future<void> _validateReferralCode(String code) async {
if (code.isEmpty) {
setState(() => _referralCodeValid = null);
return;
}
try {
final resp = await _authService.validateReferralCode(code);
setState(() => _referralCodeValid = resp);
} catch (_) {
setState(() => _referralCodeValid = false);
}
}
String _extractError(DioException e) {
final data = e.response?.data;
if (data is Map && data['message'] != null) {
@ -96,10 +113,12 @@ class _RegisterPageState extends State<RegisterPage> {
setState(() { _loading = true; _errorMessage = null; });
try {
final referralCode = _referralCodeController.text.trim();
await _authService.register(
phone: phone,
smsCode: code,
password: password,
referralCode: referralCode.isNotEmpty ? referralCode : null,
);
if (mounted) Navigator.pushReplacementNamed(context, '/main');
} on DioException catch (e) {
@ -238,7 +257,40 @@ class _RegisterPageState extends State<RegisterPage> {
),
const SizedBox(height: 8),
_buildPasswordStrength(),
const SizedBox(height: 32),
const SizedBox(height: 20),
// Referral code (optional)
Text(context.t('register.referralCode'), style: AppTypography.labelMedium),
const SizedBox(height: 8),
TextField(
controller: _referralCodeController,
textCapitalization: TextCapitalization.characters,
onChanged: (v) {
setState(() => _referralCodeValid = null);
if (v.length >= 6) _validateReferralCode(v.trim());
},
decoration: InputDecoration(
hintText: context.t('register.referralCodeHint'),
prefixIcon: const Icon(Icons.card_giftcard_rounded, color: AppColors.textTertiary),
suffixIcon: _referralCodeValid == null
? null
: Icon(
_referralCodeValid! ? Icons.check_circle_rounded : Icons.cancel_rounded,
color: _referralCodeValid! ? AppColors.success : AppColors.error,
size: 20,
),
helperText: _referralCodeValid == null
? null
: _referralCodeValid!
? context.t('register.referralCodeValid')
: context.t('register.referralCodeInvalid'),
helperStyle: TextStyle(
color: _referralCodeValid == true ? AppColors.success : AppColors.error,
fontSize: 12,
),
),
),
const SizedBox(height: 24),
// Step 3: Terms + Submit
Row(