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:
parent
ec0af6c47e
commit
0ecf295c35
|
|
@ -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)
|
||||
# ============================================================
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export interface UserRegisteredEvent {
|
|||
phone: string | null;
|
||||
email: string | null;
|
||||
role: string;
|
||||
referralCode: string | null; // 注册时填写的推荐码(推荐人的码)
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '電話番号またはメールを入力',
|
||||
|
|
|
|||
|
|
@ -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': '输入手机号或邮箱',
|
||||
|
|
|
|||
|
|
@ -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': '輸入手機號或信箱',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue