diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 1fc0aa2..661100c 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -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) # ============================================================ diff --git a/backend/migrations/047_create_referral_profiles.sql b/backend/migrations/047_create_referral_profiles.sql new file mode 100644 index 0000000..7f28930 --- /dev/null +++ b/backend/migrations/047_create_referral_profiles.sql @@ -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(); diff --git a/backend/services/auth-service/src/application/services/auth.service.ts b/backend/services/auth-service/src/application/services/auth.service.ts index 960ec70..3767934 100644 --- a/backend/services/auth-service/src/application/services/auth.service.ts +++ b/backend/services/auth-service/src/application/services/auth.service.ts @@ -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(), }); diff --git a/backend/services/auth-service/src/application/services/event-publisher.service.ts b/backend/services/auth-service/src/application/services/event-publisher.service.ts index 069bdcf..8de67f3 100644 --- a/backend/services/auth-service/src/application/services/event-publisher.service.ts +++ b/backend/services/auth-service/src/application/services/event-publisher.service.ts @@ -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 { - // 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 { - await this.publishToOutbox('genex.user.logged-in', 'User', event.userId, 'user.logged_in', event); - } - - async publishUserLoggedOut(event: UserLoggedOutEvent): Promise { - await this.publishToOutbox('genex.user.logged-out', 'User', event.userId, 'user.logged_out', event); - } - - async publishPasswordChanged(event: PasswordChangedEvent): Promise { - await this.publishToOutbox('genex.user.password-changed', 'User', event.userId, 'user.password_changed', event); - } - - async publishAccountLocked(event: AccountLockedEvent): Promise { - await this.publishToOutbox('genex.user.locked', 'User', event.userId, 'user.account_locked', event); - } - - async publishPhoneChanged(event: PhoneChangedEvent): Promise { - await this.publishToOutbox('genex.user.phone-changed', 'User', event.userId, 'user.phone_changed', event); - } - - async publishPasswordReset(event: PasswordResetEvent): Promise { - 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 { - // 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 { + await this.publish('genex.user.registered', event.userId, 'user.registered', event); + } + + async publishUserLoggedIn(event: UserLoggedInEvent): Promise { + await this.publish('genex.user.logged-in', event.userId, 'user.logged_in', event); + } + + async publishUserLoggedOut(event: UserLoggedOutEvent): Promise { + await this.publish('genex.user.logged-out', event.userId, 'user.logged_out', event); + } + + async publishPasswordChanged(event: PasswordChangedEvent): Promise { + await this.publish('genex.user.password-changed', event.userId, 'user.password_changed', event); + } + + async publishAccountLocked(event: AccountLockedEvent): Promise { + await this.publish('genex.user.locked', event.userId, 'user.account_locked', event); + } + + async publishPhoneChanged(event: PhoneChangedEvent): Promise { + await this.publish('genex.user.phone-changed', event.userId, 'user.phone_changed', event); + } + + async publishPasswordReset(event: PasswordResetEvent): Promise { + 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 { + 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}`); + } } } diff --git a/backend/services/auth-service/src/domain/events/auth.events.ts b/backend/services/auth-service/src/domain/events/auth.events.ts index 467cd86..de5006b 100644 --- a/backend/services/auth-service/src/domain/events/auth.events.ts +++ b/backend/services/auth-service/src/domain/events/auth.events.ts @@ -3,6 +3,7 @@ export interface UserRegisteredEvent { phone: string | null; email: string | null; role: string; + referralCode: string | null; // 注册时填写的推荐码(推荐人的码) timestamp: string; } diff --git a/backend/services/auth-service/src/interface/http/dto/register.dto.ts b/backend/services/auth-service/src/interface/http/dto/register.dto.ts index f4d3464..a4fdb2f 100644 --- a/backend/services/auth-service/src/interface/http/dto/register.dto.ts +++ b/backend/services/auth-service/src/interface/http/dto/register.dto.ts @@ -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; } diff --git a/backend/services/referral-service/Dockerfile b/backend/services/referral-service/Dockerfile new file mode 100644 index 0000000..55fc6be --- /dev/null +++ b/backend/services/referral-service/Dockerfile @@ -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"] diff --git a/backend/services/referral-service/nest-cli.json b/backend/services/referral-service/nest-cli.json new file mode 100644 index 0000000..2566481 --- /dev/null +++ b/backend/services/referral-service/nest-cli.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/backend/services/referral-service/package.json b/backend/services/referral-service/package.json new file mode 100644 index 0000000..c3d2ff9 --- /dev/null +++ b/backend/services/referral-service/package.json @@ -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" + } +} diff --git a/backend/services/referral-service/src/app.module.ts b/backend/services/referral-service/src/app.module.ts new file mode 100644 index 0000000..007b2db --- /dev/null +++ b/backend/services/referral-service/src/app.module.ts @@ -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 {} diff --git a/backend/services/referral-service/src/application/event-handlers/user-registered.handler.ts b/backend/services/referral-service/src/application/event-handlers/user-registered.handler.ts new file mode 100644 index 0000000..bdc9972 --- /dev/null +++ b/backend/services/referral-service/src/application/event-handlers/user-registered.handler.ts @@ -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 { + 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 + } + } +} diff --git a/backend/services/referral-service/src/application/services/referral.service.ts b/backend/services/referral-service/src/application/services/referral.service.ts new file mode 100644 index 0000000..087dee3 --- /dev/null +++ b/backend/services/referral-service/src/application/services/referral.service.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/backend/services/referral-service/src/domain/entities/referral-profile.entity.ts b/backend/services/referral-service/src/domain/entities/referral-profile.entity.ts new file mode 100644 index 0000000..3347520 --- /dev/null +++ b/backend/services/referral-service/src/domain/entities/referral-profile.entity.ts @@ -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); + } +} diff --git a/backend/services/referral-service/src/domain/repositories/referral-profile.repository.interface.ts b/backend/services/referral-service/src/domain/repositories/referral-profile.repository.interface.ts new file mode 100644 index 0000000..9f8b933 --- /dev/null +++ b/backend/services/referral-service/src/domain/repositories/referral-profile.repository.interface.ts @@ -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; + findByCode(code: string): Promise; + existsByCode(code: string): Promise; + existsByUserId(userId: string): Promise; + findDirectReferrals(referrerId: string, offset: number, limit: number): Promise<[ReferralProfile[], number]>; + save(profile: ReferralProfile): Promise; +} diff --git a/backend/services/referral-service/src/domain/value-objects/referral-chain.vo.ts b/backend/services/referral-service/src/domain/value-objects/referral-chain.vo.ts new file mode 100644 index 0000000..1c38c31 --- /dev/null +++ b/backend/services/referral-service/src/domain/value-objects/referral-chain.vo.ts @@ -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); + } +} diff --git a/backend/services/referral-service/src/domain/value-objects/referral-code.vo.ts b/backend/services/referral-service/src/domain/value-objects/referral-code.vo.ts new file mode 100644 index 0000000..102dc4f --- /dev/null +++ b/backend/services/referral-service/src/domain/value-objects/referral-code.vo.ts @@ -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; + } +} diff --git a/backend/services/referral-service/src/infrastructure/persistence/referral-profile.repository.ts b/backend/services/referral-service/src/infrastructure/persistence/referral-profile.repository.ts new file mode 100644 index 0000000..30dc313 --- /dev/null +++ b/backend/services/referral-service/src/infrastructure/persistence/referral-profile.repository.ts @@ -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, + ) {} + + async findByUserId(userId: string): Promise { + return this.repo.findOne({ where: { userId } }); + } + + async findByCode(code: string): Promise { + return this.repo.findOne({ where: { referralCode: code.toUpperCase() } }); + } + + async existsByCode(code: string): Promise { + const count = await this.repo.count({ where: { referralCode: code.toUpperCase() } }); + return count > 0; + } + + async existsByUserId(userId: string): Promise { + 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 { + return this.repo.save(profile); + } +} diff --git a/backend/services/referral-service/src/infrastructure/strategies/jwt.strategy.ts b/backend/services/referral-service/src/infrastructure/strategies/jwt.strategy.ts new file mode 100644 index 0000000..08d1c50 --- /dev/null +++ b/backend/services/referral-service/src/infrastructure/strategies/jwt.strategy.ts @@ -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 }; + } +} diff --git a/backend/services/referral-service/src/interface/http/controllers/health.controller.ts b/backend/services/referral-service/src/interface/http/controllers/health.controller.ts new file mode 100644 index 0000000..c119fa5 --- /dev/null +++ b/backend/services/referral-service/src/interface/http/controllers/health.controller.ts @@ -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' }; + } +} diff --git a/backend/services/referral-service/src/interface/http/controllers/internal-referral.controller.ts b/backend/services/referral-service/src/interface/http/controllers/internal-referral.controller.ts new file mode 100644 index 0000000..f3963a8 --- /dev/null +++ b/backend/services/referral-service/src/interface/http/controllers/internal-referral.controller.ts @@ -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 }; + } +} diff --git a/backend/services/referral-service/src/interface/http/controllers/referral.controller.ts b/backend/services/referral-service/src/interface/http/controllers/referral.controller.ts new file mode 100644 index 0000000..8b4adba --- /dev/null +++ b/backend/services/referral-service/src/interface/http/controllers/referral.controller.ts @@ -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 }; + } +} diff --git a/backend/services/referral-service/src/main.ts b/backend/services/referral-service/src/main.ts new file mode 100644 index 0000000..f512d15 --- /dev/null +++ b/backend/services/referral-service/src/main.ts @@ -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(); diff --git a/backend/services/referral-service/src/referral.module.ts b/backend/services/referral-service/src/referral.module.ts new file mode 100644 index 0000000..40d112f --- /dev/null +++ b/backend/services/referral-service/src/referral.module.ts @@ -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 {} diff --git a/backend/services/referral-service/tsconfig.json b/backend/services/referral-service/tsconfig.json new file mode 100644 index 0000000..86c3af3 --- /dev/null +++ b/backend/services/referral-service/tsconfig.json @@ -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/**/*"] +} diff --git a/frontend/genex-mobile/lib/app/i18n/strings/en.dart b/frontend/genex-mobile/lib/app/i18n/strings/en.dart index 8239133..0b1f945 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/en.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/en.dart @@ -85,6 +85,10 @@ const Map 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', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart index 4ef42e2..755cd2d 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart @@ -85,6 +85,10 @@ const Map ja = { 'register.errorCodeInvalid': '6桁の認証コードを入力してください', 'register.errorPasswordWeak': 'パスワードは8文字以上で英字と数字を含む必要があります', 'register.errorTermsRequired': '利用規約に同意してください', + 'register.referralCode': '紹介コード(任意)', + 'register.referralCodeHint': '招待者の紹介コードを入力', + 'register.referralCodeValid': '紹介コードは有効です', + 'register.referralCodeInvalid': '紹介コードが無効です', 'forgot.title': 'パスワード再設定', 'forgot.inputAccount': '電話番号またはメールを入力', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart index 7648528..a5c146f 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart @@ -85,6 +85,10 @@ const Map zhCN = { 'register.errorCodeInvalid': '请输入6位验证码', 'register.errorPasswordWeak': '密码需要8位以上且包含字母和数字', 'register.errorTermsRequired': '请先阅读并同意用户协议', + 'register.referralCode': '推荐码(选填)', + 'register.referralCodeHint': '输入邀请人的推荐码', + 'register.referralCodeValid': '推荐码有效', + 'register.referralCodeInvalid': '推荐码无效', 'forgot.title': '找回密码', 'forgot.inputAccount': '输入手机号或邮箱', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart index 9f1cf95..7696cdb 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart @@ -85,6 +85,10 @@ const Map zhTW = { 'register.errorCodeInvalid': '請輸入6位驗證碼', 'register.errorPasswordWeak': '密碼需要8位以上且包含字母和數字', 'register.errorTermsRequired': '請先閱讀並同意使用者協議', + 'register.referralCode': '推薦碼(選填)', + 'register.referralCodeHint': '輸入邀請人的推薦碼', + 'register.referralCodeValid': '推薦碼有效', + 'register.referralCodeInvalid': '推薦碼無效', 'forgot.title': '找回密碼', 'forgot.inputAccount': '輸入手機號或信箱', diff --git a/frontend/genex-mobile/lib/core/services/auth_service.dart b/frontend/genex-mobile/lib/core/services/auth_service.dart index b09de09..8ce926e 100644 --- a/frontend/genex-mobile/lib/core/services/auth_service.dart +++ b/frontend/genex-mobile/lib/core/services/auth_service.dart @@ -62,6 +62,19 @@ class AuthService { return data['expiresIn'] as int; } + /* ── 推荐码 ── */ + + /// 验证推荐码是否有效 (调用 referral-service) + Future validateReferralCode(String code) async { + try { + final resp = await _api.get('/api/v1/referral/validate', queryParameters: {'code': code}); + final data = resp.data['data'] as Map; + 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); diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart index 0570f64..fffc421 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart @@ -24,10 +24,13 @@ class _RegisterPageState extends State { 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 { _accountController.dispose(); _codeController.dispose(); _passwordController.dispose(); + _referralCodeController.dispose(); super.dispose(); } + Future _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 { 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 { ), 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(