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:
|
networks:
|
||||||
- genex-network
|
- 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)
|
# 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;
|
smsCode: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
|
referralCode?: string; // 推荐人的推荐码(可选)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginDto {
|
export interface LoginDto {
|
||||||
|
|
@ -103,6 +104,7 @@ export class AuthService {
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
referralCode: dto.referralCode?.toUpperCase() ?? null,
|
||||||
timestamp: new Date().toISOString(),
|
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 {
|
import {
|
||||||
UserRegisteredEvent,
|
UserRegisteredEvent,
|
||||||
UserLoggedInEvent,
|
UserLoggedInEvent,
|
||||||
|
|
@ -10,66 +11,75 @@ import {
|
||||||
} from '../../domain/events/auth.events';
|
} from '../../domain/events/auth.events';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event publisher using Outbox pattern.
|
* Event publisher — 直接发布到 Kafka (fire-and-forget, Kafka 不可用时静默丢弃)
|
||||||
* Events are written to the outbox table within the same DB transaction,
|
* 生产环境可改为 Outbox pattern 保证 at-least-once 语义
|
||||||
* then published to Kafka by the OutboxRelay or Debezium CDC.
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EventPublisherService {
|
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(EventPublisherService.name);
|
||||||
|
private producer: Producer | null = null;
|
||||||
|
private connected = false;
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.connected && this.producer) {
|
||||||
|
await this.producer.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async publishUserRegistered(event: UserRegisteredEvent): Promise<void> {
|
async publishUserRegistered(event: UserRegisteredEvent): Promise<void> {
|
||||||
// In Phase 1, we use direct Kafka publish.
|
await this.publish('genex.user.registered', event.userId, 'user.registered', event);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async publishUserLoggedIn(event: UserLoggedInEvent): Promise<void> {
|
async publishUserLoggedIn(event: UserLoggedInEvent): Promise<void> {
|
||||||
await this.publishToOutbox('genex.user.logged-in', 'User', event.userId, 'user.logged_in', event);
|
await this.publish('genex.user.logged-in', event.userId, 'user.logged_in', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
async publishUserLoggedOut(event: UserLoggedOutEvent): Promise<void> {
|
async publishUserLoggedOut(event: UserLoggedOutEvent): Promise<void> {
|
||||||
await this.publishToOutbox('genex.user.logged-out', 'User', event.userId, 'user.logged_out', event);
|
await this.publish('genex.user.logged-out', event.userId, 'user.logged_out', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
async publishPasswordChanged(event: PasswordChangedEvent): Promise<void> {
|
async publishPasswordChanged(event: PasswordChangedEvent): Promise<void> {
|
||||||
await this.publishToOutbox('genex.user.password-changed', 'User', event.userId, 'user.password_changed', event);
|
await this.publish('genex.user.password-changed', event.userId, 'user.password_changed', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
async publishAccountLocked(event: AccountLockedEvent): Promise<void> {
|
async publishAccountLocked(event: AccountLockedEvent): Promise<void> {
|
||||||
await this.publishToOutbox('genex.user.locked', 'User', event.userId, 'user.account_locked', event);
|
await this.publish('genex.user.locked', event.userId, 'user.account_locked', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
async publishPhoneChanged(event: PhoneChangedEvent): Promise<void> {
|
async publishPhoneChanged(event: PhoneChangedEvent): Promise<void> {
|
||||||
await this.publishToOutbox('genex.user.phone-changed', 'User', event.userId, 'user.phone_changed', event);
|
await this.publish('genex.user.phone-changed', event.userId, 'user.phone_changed', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
async publishPasswordReset(event: PasswordResetEvent): Promise<void> {
|
async publishPasswordReset(event: PasswordResetEvent): Promise<void> {
|
||||||
await this.publishToOutbox('genex.user.password-reset', 'User', event.userId, 'user.password_reset', event);
|
await this.publish('genex.user.password-reset', event.userId, 'user.password_reset', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async publishToOutbox(
|
private async publish(topic: string, key: string, eventType: string, payload: any): Promise<void> {
|
||||||
topic: string,
|
if (!this.connected || !this.producer) return;
|
||||||
aggregateType: string,
|
try {
|
||||||
aggregateId: string,
|
await this.producer.send({
|
||||||
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,
|
topic,
|
||||||
payload,
|
messages: [{
|
||||||
|
key,
|
||||||
|
value: JSON.stringify(payload),
|
||||||
|
headers: { eventType, timestamp: new Date().toISOString(), source: 'auth-service' },
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`事件发布失败 topic=${topic}: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private outboxService: any;
|
|
||||||
|
|
||||||
setOutboxService(outboxService: any): void {
|
|
||||||
this.outboxService = outboxService;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export interface UserRegisteredEvent {
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
|
referralCode: string | null; // 注册时填写的推荐码(推荐人的码)
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,10 @@ export class RegisterDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
nickname?: string;
|
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.errorCodeInvalid': 'Please enter a 6-digit code',
|
||||||
'register.errorPasswordWeak': 'Password must be 8+ characters with letters and numbers',
|
'register.errorPasswordWeak': 'Password must be 8+ characters with letters and numbers',
|
||||||
'register.errorTermsRequired': 'Please agree to the Terms of Service',
|
'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.title': 'Reset Password',
|
||||||
'forgot.inputAccount': 'Enter phone or email',
|
'forgot.inputAccount': 'Enter phone or email',
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ const Map<String, String> ja = {
|
||||||
'register.errorCodeInvalid': '6桁の認証コードを入力してください',
|
'register.errorCodeInvalid': '6桁の認証コードを入力してください',
|
||||||
'register.errorPasswordWeak': 'パスワードは8文字以上で英字と数字を含む必要があります',
|
'register.errorPasswordWeak': 'パスワードは8文字以上で英字と数字を含む必要があります',
|
||||||
'register.errorTermsRequired': '利用規約に同意してください',
|
'register.errorTermsRequired': '利用規約に同意してください',
|
||||||
|
'register.referralCode': '紹介コード(任意)',
|
||||||
|
'register.referralCodeHint': '招待者の紹介コードを入力',
|
||||||
|
'register.referralCodeValid': '紹介コードは有効です',
|
||||||
|
'register.referralCodeInvalid': '紹介コードが無効です',
|
||||||
|
|
||||||
'forgot.title': 'パスワード再設定',
|
'forgot.title': 'パスワード再設定',
|
||||||
'forgot.inputAccount': '電話番号またはメールを入力',
|
'forgot.inputAccount': '電話番号またはメールを入力',
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ const Map<String, String> zhCN = {
|
||||||
'register.errorCodeInvalid': '请输入6位验证码',
|
'register.errorCodeInvalid': '请输入6位验证码',
|
||||||
'register.errorPasswordWeak': '密码需要8位以上且包含字母和数字',
|
'register.errorPasswordWeak': '密码需要8位以上且包含字母和数字',
|
||||||
'register.errorTermsRequired': '请先阅读并同意用户协议',
|
'register.errorTermsRequired': '请先阅读并同意用户协议',
|
||||||
|
'register.referralCode': '推荐码(选填)',
|
||||||
|
'register.referralCodeHint': '输入邀请人的推荐码',
|
||||||
|
'register.referralCodeValid': '推荐码有效',
|
||||||
|
'register.referralCodeInvalid': '推荐码无效',
|
||||||
|
|
||||||
'forgot.title': '找回密码',
|
'forgot.title': '找回密码',
|
||||||
'forgot.inputAccount': '输入手机号或邮箱',
|
'forgot.inputAccount': '输入手机号或邮箱',
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ const Map<String, String> zhTW = {
|
||||||
'register.errorCodeInvalid': '請輸入6位驗證碼',
|
'register.errorCodeInvalid': '請輸入6位驗證碼',
|
||||||
'register.errorPasswordWeak': '密碼需要8位以上且包含字母和數字',
|
'register.errorPasswordWeak': '密碼需要8位以上且包含字母和數字',
|
||||||
'register.errorTermsRequired': '請先閱讀並同意使用者協議',
|
'register.errorTermsRequired': '請先閱讀並同意使用者協議',
|
||||||
|
'register.referralCode': '推薦碼(選填)',
|
||||||
|
'register.referralCodeHint': '輸入邀請人的推薦碼',
|
||||||
|
'register.referralCodeValid': '推薦碼有效',
|
||||||
|
'register.referralCodeInvalid': '推薦碼無效',
|
||||||
|
|
||||||
'forgot.title': '找回密碼',
|
'forgot.title': '找回密碼',
|
||||||
'forgot.inputAccount': '輸入手機號或信箱',
|
'forgot.inputAccount': '輸入手機號或信箱',
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,19 @@ class AuthService {
|
||||||
return data['expiresIn'] as int;
|
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 类型验证码)
|
/// 手机号注册 (需先获取 REGISTER 类型验证码)
|
||||||
|
|
@ -70,12 +83,15 @@ class AuthService {
|
||||||
required String smsCode,
|
required String smsCode,
|
||||||
required String password,
|
required String password,
|
||||||
String? nickname,
|
String? nickname,
|
||||||
|
String? referralCode,
|
||||||
}) async {
|
}) async {
|
||||||
final resp = await _api.post('/api/v1/auth/register', data: {
|
final resp = await _api.post('/api/v1/auth/register', data: {
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
'smsCode': smsCode,
|
'smsCode': smsCode,
|
||||||
'password': password,
|
'password': password,
|
||||||
if (nickname != null) 'nickname': nickname,
|
if (nickname != null) 'nickname': nickname,
|
||||||
|
if (referralCode != null && referralCode.isNotEmpty)
|
||||||
|
'referralCode': referralCode.toUpperCase(),
|
||||||
});
|
});
|
||||||
final result = AuthResult.fromJson(resp.data['data']);
|
final result = AuthResult.fromJson(resp.data['data']);
|
||||||
_setAuth(result);
|
_setAuth(result);
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,13 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||||
final _accountController = TextEditingController();
|
final _accountController = TextEditingController();
|
||||||
final _codeController = TextEditingController();
|
final _codeController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
final _referralCodeController = TextEditingController();
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
bool _agreeTerms = false;
|
bool _agreeTerms = false;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
// null=未验证, true=有效, false=无效
|
||||||
|
bool? _referralCodeValid;
|
||||||
|
|
||||||
final _authService = AuthService.instance;
|
final _authService = AuthService.instance;
|
||||||
|
|
||||||
|
|
@ -42,9 +45,23 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||||
_accountController.dispose();
|
_accountController.dispose();
|
||||||
_codeController.dispose();
|
_codeController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_referralCodeController.dispose();
|
||||||
super.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) {
|
String _extractError(DioException e) {
|
||||||
final data = e.response?.data;
|
final data = e.response?.data;
|
||||||
if (data is Map && data['message'] != null) {
|
if (data is Map && data['message'] != null) {
|
||||||
|
|
@ -96,10 +113,12 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||||
|
|
||||||
setState(() { _loading = true; _errorMessage = null; });
|
setState(() { _loading = true; _errorMessage = null; });
|
||||||
try {
|
try {
|
||||||
|
final referralCode = _referralCodeController.text.trim();
|
||||||
await _authService.register(
|
await _authService.register(
|
||||||
phone: phone,
|
phone: phone,
|
||||||
smsCode: code,
|
smsCode: code,
|
||||||
password: password,
|
password: password,
|
||||||
|
referralCode: referralCode.isNotEmpty ? referralCode : null,
|
||||||
);
|
);
|
||||||
if (mounted) Navigator.pushReplacementNamed(context, '/main');
|
if (mounted) Navigator.pushReplacementNamed(context, '/main');
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
|
|
@ -238,7 +257,40 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildPasswordStrength(),
|
_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
|
// Step 3: Terms + Submit
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue