관리 메뉴

bright jazz music

[nestjs] 멤버 컨트롤러, 서비스, 리포지토리 본문

Framework/NestJS

[nestjs] 멤버 컨트롤러, 서비스, 리포지토리

bright jazz music 2024. 12. 25. 23:40

1. members.controller.ts(회원 관련 컨트롤러)

// src/members/members.controller.ts
// 멤버 컨트롤러: HTTP 요청을 처리하고 응답을 반환하는 컨트롤러
// RESTful API 엔드포인트 정의 및 요청/응답 처리

import { 
  Controller, 
  Get, 
  Post, 
  Put, 
  Delete, 
  Body, 
  Param, 
  Query,
  HttpStatus,
  HttpCode,
  ParseUUIDPipe
} from '@nestjs/common';
import { MembersService } from './members.service';
import { CreateMemberDto } from './dto/create-member.dto';
import { UpdateMemberDto } from './dto/update-member.dto';
import { MemberResponseDto } from './dto/member-response.dto';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';

@ApiTags('members')
@Controller('members')
export class MembersController {
  constructor(private readonly membersService: MembersService) {}

  /**
   * 회원 생성
   */
  @Post()
  @ApiOperation({
    summary: '회원 가입',
    description: '새로운 회원을 등록합니다.',
  })
  @ApiResponse({
    status: 201,
    description: '회원가입 성공',
    schema: {
      example: {
        id: 1,
        email: 'user@example.com',
        name: 'John Doe',
        createdAt: '2024-03-20T12:00:00Z',
      },
    },
  })
  @ApiResponse({
    status: 400,
    description: '잘못된 요청 (이메일 형식 오류, 비밀번호 불일치 등)',
  })
  @ApiResponse({
    status: 409,
    description: '이미 존재하는 이메일',
  })
  async create(@Body() createMemberDto: CreateMemberDto) {
    return this.membersService.create(createMemberDto);
  }

  /**
   * UUID로 회원 조회
   */
  @Get(':uuid')
  @ApiOperation({ summary: '회원 정보 조회' })
  @ApiResponse({ 
    status: HttpStatus.OK, 
    description: '회원 정보 조회 성공', 
    type: MemberResponseDto 
  })
  async findOne(
    @Param('uuid', ParseUUIDPipe) uuid: string
  ): Promise<MemberResponseDto> {
    return this.membersService.findOneByUuid(uuid);
  }

  /**
   * 회원 정보 수정
   */
  @Put(':uuid')
  @ApiOperation({ summary: '회원 정보 수정' })
  @ApiResponse({ 
    status: HttpStatus.OK, 
    description: '회원 정보 수정 성공', 
    type: MemberResponseDto 
  })
  async update(
    @Param('uuid', ParseUUIDPipe) uuid: string,
    @Body() updateMemberDto: UpdateMemberDto
  ): Promise<MemberResponseDto> {
    return this.membersService.update(uuid, updateMemberDto);
  }

  /**
   * 회원 삭제 (소프트 삭제)
   */
  @Delete(':uuid')
  @ApiOperation({ summary: '회원 탈퇴' })
  @ApiResponse({ 
    status: HttpStatus.NO_CONTENT, 
    description: '회원 탈퇴 성공' 
  })
  @HttpCode(HttpStatus.NO_CONTENT)
  async remove(@Param('uuid', ParseUUIDPipe) uuid: string): Promise<void> {
    await this.membersService.softDelete(uuid);
  }

  /**
   * 이메일 인증
   */
  @Post('verify-email')
  @ApiOperation({ summary: '이메일 인증' })
  @ApiResponse({ 
    status: HttpStatus.OK, 
    description: '이메일 인증 성공', 
    type: MemberResponseDto 
  })
  async verifyEmail(
    @Query('token') token: string
  ): Promise<MemberResponseDto> {
    return this.membersService.verifyEmail(token);
  }

  /**
   * 비밀번호 재설정 토큰 생성
   */
  @Post('password-reset')
  @ApiOperation({ summary: '비밀번호 재설정 토큰 발급' })
  @ApiResponse({ 
    status: HttpStatus.OK, 
    description: '비밀번호 재설정 토큰 발급 성공', 
    type: MemberResponseDto 
  })
  async createPasswordResetToken(
    @Body('email') email: string
  ): Promise<MemberResponseDto> {
    return this.membersService.createPasswordResetToken(email);
  }

  /**
   * 포인트 업데이트
   */
  @Put(':uuid/points')
  @ApiOperation({ summary: '포인트 수정' })
  @ApiResponse({ 
    status: HttpStatus.OK, 
    description: '포인트 수정 성공', 
    type: MemberResponseDto 
  })
  async updatePoints(
    @Param('uuid', ParseUUIDPipe) uuid: string,
    @Body('pointType') pointType: 'purchase' | 'reward',
    @Body('amount') amount: number
  ): Promise<MemberResponseDto> {
    return this.membersService.updatePoints(uuid, pointType, amount);
  }
}

 

2. members.service.ts(회원 관련 서비스)

설치한 패키지 많으므로 주의.

이메일 부분은 아래에서 설명. 

// src/members/members.service.ts
// 회원 관리 서비스
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; // Repository 타입 가져오기:제네릭 타입
import { Member } from './entities/member.entity';
import { CreateMemberDto } from './dto/create-member.dto';
import { UpdateMemberDto } from './dto/update-member.dto';
import { MemberResponseDto } from './dto/member-response.dto';
import { MemberMapper } from './mappers/member.mapper';
import { AuthProvider, MemberStatus } from '@common/enums';
import * as bcrypt from 'bcrypt';  // 비밀번호 해시화를 위한 패키지. pnpm add bcrypt @types/bcrypt
import { v4 as uuidv4 } from 'uuid';  // 범용 고유 식별자(UUID) 생성을 위한 패키지. pnpm add uuid @types/uuid
import { EmailService } from '@common/services/email.service';

@Injectable()
export class MembersService {
  constructor(
    @InjectRepository(Member)
    private readonly membersRepository: Repository<Member>, // typeorm Repository 제네릭타입 사용
    private readonly emailService: EmailService,
  ) {}

  /**
   * 새로운 회원 생성
   */
  async create(createMemberDto: CreateMemberDto): Promise<MemberResponseDto> {
    // 약관 동의 검증
    if (!createMemberDto.termsAgreed || !createMemberDto.privacyAgreed) {
      throw new BadRequestException('필수 약관��� 동의해주세요.');
    }

    // 연령 확인 필수인 경우
    // if (!createMemberDto.ageVerified) {
    //   throw new BadRequestException('연령 확인이 필요합니다.');
    // }

    const existingMember = await this.membersRepository.findOne({
      where: { email: createMemberDto.email }
    });

    if (existingMember) {
      throw new ConflictException('이미 존재하는 이메일입니다.');
    }

    const memberEntity = MemberMapper.toEntity(createMemberDto);
    
    // 추가 필드 설정
    memberEntity.password = await bcrypt.hash(createMemberDto.password, 10);
    memberEntity.verificationToken = uuidv4();
    memberEntity.verificationTokenExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
    memberEntity.status = MemberStatus.PENDING;
    
    // 약관 동의 시간 기록
    memberEntity.termsAgreed = createMemberDto.termsAgreed;
    memberEntity.termsAgreedAt = new Date();
    memberEntity.privacyAgreed = createMemberDto.privacyAgreed;
    memberEntity.privacyAgreedAt = new Date();
    
    if (createMemberDto.marketingAgreed) {
      memberEntity.marketingAgreed = true;
      memberEntity.marketingAgreedAt = new Date();
    }

    // // 연령 확인 기록
    // memberEntity.ageVerified = createMemberDto.ageVerified;
    // memberEntity.ageVerifiedAt = new Date();

    const savedMember = await this.membersRepository.save(memberEntity);
    
    // 인증 이메일 발송
    await this.sendVerificationEmail(savedMember);
    
    return MemberMapper.toDto(savedMember);
  }

  /**
   * 이메일로 회원 찾기
   */
  async findOneByEmailWithPassword(email: string): Promise<Member | null> {
    return this.membersRepository.findOne({
      where: { email },
      select: ['id', 'uuid', 'email', 'status', 'loginAttempts', 'lockoutUntil']
    });
  }

  /**
   * UUID로 회원 찾기
   */
  async findOneByUuid(uuid: string): Promise<MemberResponseDto> {
    const member = await this.membersRepository.findOne({
      where: { uuid }
    });

    if (!member) {
      throw new NotFoundException('회원을 찾을 수 없습니다.');
    }

    return MemberMapper.toDto(member);
  }

  /**
   * 회원 정보 업데이트
   */
  async update(uuid: string, updateMemberDto: UpdateMemberDto): Promise<MemberResponseDto> {
    const member = await this.membersRepository.findOne({ where: { uuid } });
    if (!member) {
      throw new NotFoundException('회원을 찾을 수 없습니다.');
    }

    // 비밀번호 변경 처리
    if (updateMemberDto.password) {
      updateMemberDto.password = await bcrypt.hash(updateMemberDto.password, 10);
      member.passwordChangedAt = new Date();
      member.tokenVersion += 1; // 기존 토큰 무효화
    }

    // 약관 동의 처리
    if (updateMemberDto.termsAgreed !== undefined) {
      member.termsAgreed = updateMemberDto.termsAgreed;
      member.termsAgreedAt = new Date();
    }

    if (updateMemberDto.marketingAgreed !== undefined) {
      member.marketingAgreed = updateMemberDto.marketingAgreed;
      member.marketingAgreedAt = new Date();
    }

    const updatedMember = await this.membersRepository.save({
      ...member,
      ...MemberMapper.toEntity(updateMemberDto),
    });

    return MemberMapper.toDto(updatedMember);
  }

  /**
   * 회원가입 시 인증 이메일 발송
   */
  async sendVerificationEmail(member: Member): Promise<void> {
    const verificationLink = `${process.env.CLIENT_URL}/verify-email?token=${member.verificationToken}`;
    
    await this.emailService.send({
      to: member.email,
      subject: '회원가입 인증을 완료해주세요',
      template: 'email-verification',
      context: {
        name: member.name || member.email,
        verificationLink,
        expiresIn: '24시간',
        termsAgreedAt: member.termsAgreedAt,
        marketingAgreed: member.marketingAgreed
      }
    });
  }

  /**
   * 이��일 인증 처리
   */
  async verifyEmail(token: string): Promise<MemberResponseDto> {
    const member = await this.membersRepository.findOne({
      where: { 
        verificationToken: token,
        status: MemberStatus.PENDING
      }
    });

    if (!member) {
      throw new NotFoundException('유효하지 않은 인증 토큰입니다.');
    }

    if (member.verificationTokenExpiresAt < new Date()) {
      // 만료된 토큰인 경우 새로운 토큰 발급
      member.verificationToken = uuidv4();
      member.verificationTokenExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
      await this.membersRepository.save(member);
      await this.sendVerificationEmail(member);
      
      throw new BadRequestException('만료된 인증 토큰입니다. 새로운 인증 메일을 발송했습니다.');
    }

    // 인증 처리
    member.emailVerified = true;
    member.emailVerifiedAt = new Date();
    member.status = MemberStatus.ACTIVE;
    member.verificationToken = null;
    member.verificationTokenExpiresAt = null;
    member.notificationSettings.email = true;

    const updatedMember = await this.membersRepository.save(member);
    return MemberMapper.toDto(updatedMember);
  }

  /**
   * 비밀번호 재설정 토큰 생성
   */
  async createPasswordResetToken(email: string): Promise<MemberResponseDto> {
    const member = await this.findOneByEmailWithPassword(email);
    if (!member) {
      throw new NotFoundException('회원을 찾을 수 없습니다.');
    }

    // 이전 토큰이 있다면 무효화
    if (member.passwordResetToken && member.passwordResetTokenExpiresAt > new Date()) {
      throw new BadRequestException('이미 유효한 비밀번호 재설정 토큰이 존재합니다.');
    }

    member.passwordResetToken = uuidv4();
    member.passwordResetTokenExpiresAt = new Date(Date.now() + 3600000);
    member.tokenVersion += 1; // 토큰 버전 증가로 기존 토큰 무효화

    const updatedMember = await this.membersRepository.save(member);
    return MemberMapper.toDto(updatedMember);
  }

  /**
   * 회원 소프트 삭제
   */
  async softDelete(uuid: string): Promise<void> {
    const result = await this.membersRepository.softDelete({ uuid });
    if (result.affected === 0) {
      throw new NotFoundException('회원을 찾을 수 없습니다.');
    }
  }

  /**
   * 로그인 시도 횟수 증가 및 계정 잠금 처리
   */
  async incrementLoginAttempts(email: string): Promise<void> {
    const member = await this.findOneByEmailWithPassword(email);
    if (!member) return;

    member.loginAttempts += 1;
    
    if (member.loginAttempts >= 5) {
      member.lockoutUntil = new Date(Date.now() + 30 * 60 * 1000); // 30분 잠금
    }

    await this.membersRepository.save(member);
  }

  /**
   * 로그인 성공 시 로그인 시도 횟수 초기화
   */
  async resetLoginAttempts(email: string): Promise<void> {
    await this.membersRepository.update(
      { email },
      { 
        loginAttempts: 0,
        lockoutUntil: null,
        lastLoginAt: new Date()
      }
    );
  }

  /**
   * 포인트 적립/차감
   */
  async updatePoints(uuid: string, pointType: 'purchase' | 'reward', amount: number): Promise<MemberResponseDto> {
    const member = await this.membersRepository.findOne({ where: { uuid } });
    if (!member) {
      throw new NotFoundException('회원을 찾을 수 없습니다.');
    }
    
    // 음수 포인트 검증
    if (amount < 0 && Math.abs(amount) > member.points[pointType]) {
      throw new BadRequestException('차감할 포인트가 보유 포인트보다 많습니다.');
    }
    
    member.points[pointType] += amount;
    member.points.total = member.points.purchase + member.points.reward;

    const updatedMember = await this.membersRepository.save(member);
    return MemberMapper.toDto(updatedMember);
  }
}

 

3. email.service.ts (멤버 서비스에서 사용되는 모듈)

/common/service/를 만들고 거기에 작성했음

// src/common/services/email.service.ts
// 이메일 서비스
import { Injectable } from '@nestjs/common';
import * as nodemailer from 'nodemailer';  // 이메일 전송을 위한 패키지. pnpm add nodemailer @types/nodemailer

interface EmailOptions {
  to: string;
  subject: string;
  template: string;
  context: Record<string, any>;
}

@Injectable()
export class EmailService {
  private transporter: nodemailer.Transporter;

  constructor() {
    // 개발 환경용 테스트 계정 생성
    nodemailer.createTestAccount().then(account => {
      this.transporter = nodemailer.createTransport({
        host: account.smtp.host,
        port: account.smtp.port,
        secure: account.smtp.secure,
        auth: {
          user: account.user,
          pass: account.pass,
        },
      });
    });
  }

  async send(options: EmailOptions): Promise<void> {
    // 템플릿 렌더링 (예시)
    const html = `
      <h1>이메일 인증</h1>
      <p>안녕하세요 ${options.context.name}님,</p>
      <p>아래 링크를 클릭하여 이메일 인증을 완료해주세요:</p>
      <a href="${options.context.verificationLink}">이메일 인증하기</a>
      <p>이 링크는 ${options.context.expiresIn} 동안 유효합니다.</p>
    `;

    // 이메일 발송
    const info = await this.transporter.sendMail({
      from: '"My App" <noreply@myapp.com>',
      to: options.to,
      subject: options.subject,
      html: html,
    });

    // 개발 환경에서 이메일 확인용 URL 출력
    console.log('Preview URL: %s', nodemailer.getTestMessageUrl(info));
  }
}

 

4. members.repository.ts

파일명과 클래스 명을 복수로 바꿨다. member.repository.ts --> members.repository.ts

// src/members/repositories/members.repository.ts
// 멤버 리포지토리: 데이터베이스 접근을 담당하는 클래스
// TypeORM의 Repository를 래핑하여 도메인에 특화된 데이터 접근 메서드 제공

import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm'; 
import { Member } from '../entities/member.entity';

@Injectable()
export class MembersRepository {
  private repository: Repository<Member>;

  constructor(private dataSource: DataSource) {
    this.repository = this.dataSource.getRepository(Member);
  }

  // UUID로 단일 멤버 조회
  async findOne(uuid: string): Promise<Member | null> {
    return this.repository.findOne({ where: { uuid } });
  }

  // 내부용: ID로 단일 멤버 조회 (시스템 내부에서만 사용)
  async findOneById(id: number): Promise<Member | null> {
    return this.repository.findOne({ where: { id } });
  }

  // 이메일로 멤버 조회 (로그인, 중복 확인 등에 사용)
  async findByEmail(email: string): Promise<Member | null> {
    return this.repository.findOne({ where: { email } });
  }

  // 모든 멤버 목록 조회
  async findAll(): Promise<Member[]> {
    return this.repository.find();
  }

  // 새 멤버 생성
  async create(member: Partial<Member>): Promise<Member> {
    const newMember = this.repository.create(member);
    return this.repository.save(newMember);
  }

  // 멤버 정보 업데이트
  async save(member: Member): Promise<Member> {
    return this.repository.save(member);
  }

  // 멤버 삭제 (Soft Delete)
  async delete(uuid: string): Promise<{ affected?: number }> {
    return this.repository.softDelete({ uuid });
  }
}

 

5. members.module.ts

전과 달라진 것은 없는 것 같지만 확인차 내용을 여기에 적는다.

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Member } from './entities/member.entity';
import { MembersService } from './members.service';
import { MembersController } from './members.controller';
import { MembersRepository } from './repositories/members.repository';
import { EmailService } from '@common/services/email.service';

@Module({
  imports: [TypeOrmModule.forFeature([Member])],
  controllers: [MembersController],
  providers: [MembersService, MembersRepository, EmailService],
  exports: [MembersService],
})
export class MembersModule {}

 

이제 띄워보자

 

ENV_NODE=local pnpm start:dev

 

 

 

 

이로서 회원 추가/이메일 인증/수정/삭제(소프트삭제)가 얼추 마무리되었다. 우선은 기능구현이 급하므로 인증 부분(auth)을 끝내고 정리/수정하자.

Comments