Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 이터레이터
- GIT
- 목록처리
- 네트워크 설정
- 알파회계
- Kernighan의 C언어 프로그래밍
- 선형대수
- 티스토리 쿠키 삭제
- 리눅스
- ㅒ
- 친절한SQL튜닝
- 코드로배우는스프링부트웹프로젝트
- d
- 데비안
- 처음 만나는 AI 수학 with Python
- network configuration
- 처음 만나는 AI수학 with Python
- 스프링 시큐리티
- 서버설정
- resttemplate
- baeldung
- 자료구조와 함께 배우는 알고리즘 입문
- iterator
- 자바편
- 코드로배우는스프링웹프로젝트
- /etc/network/interfaces
- 구멍가게코딩단
- 스프링부트핵심가이드
- 페이징
- 자료구조와함께배우는알고리즘입문
Archives
- Today
- Total
bright jazz music
[nestjs] 멤버 컨트롤러, 서비스, 리포지토리 본문
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)을 끝내고 정리/수정하자.
'Framework > NestJS' 카테고리의 다른 글
[nestjs] 인증 기능(auth) 작성하기 (0) | 2024.12.26 |
---|---|
[nestjs] jest를 사용한 테스트 코드 작성(단위, e2e) (0) | 2024.12.26 |
[nestjs] 스웨거 설치 및 DTO 에 적용 (0) | 2024.12.25 |
[nestjs] 회원 관련 엔티티와(member.entity.ts) 과 관련 파일 작성(enum등) (0) | 2024.12.23 |
[nestjs] TypeORM을 사용해 DB에 테이블 생성 (0) | 2024.12.21 |
Comments