Skip to content

Commit e5d6738

Browse files
authored
Merge pull request #19 from ccaaffee/feat/profile
Feat/profile: 유저 프로필 업로드 기능 추가
2 parents 13b30eb + 8501374 commit e5d6738

File tree

10 files changed

+205
-28
lines changed

10 files changed

+205
-28
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE `User` ADD COLUMN `profileImage` VARCHAR(191) NULL;

prisma/schema.prisma

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ datasource db {
1414
}
1515

1616
model User {
17-
uuid String @id @default(uuid())
18-
kakaoId String @unique
19-
nickname String? @unique
20-
createdAt DateTime @default(now())
17+
uuid String @id @default(uuid())
18+
kakaoId String @unique
19+
nickname String? @unique
20+
profileImage String?
21+
createdAt DateTime @default(now())
2122
2223
userCafes UserCafe[]
2324
}

src/auth/strategy/jwt.strategy.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { Injectable, UnauthorizedException } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import { PassportStrategy } from '@nestjs/passport';
4+
import { User } from '@prisma/client';
45

56
import { ExtractJwt, Strategy } from 'passport-jwt';
67
import { UserService } from 'src/user/user.service';
7-
import { UserInfo } from '../types/userInfo.type';
88

99
@Injectable()
1010
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
@@ -18,11 +18,12 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
1818
});
1919
}
2020

21-
async validate(payload: any): Promise<UserInfo> {
21+
async validate(payload: any): Promise<User> {
2222
const { sub: uuid } = payload;
23-
const user = this.userService.findById(uuid).catch((error) => {
24-
throw new UnauthorizedException(`User not found: ${error}`);
25-
});
23+
const user = await this.userService.findById(uuid);
24+
if (!user) {
25+
throw new UnauthorizedException(`User not found`);
26+
}
2627

2728
return user;
2829
}

src/auth/types/userInfo.type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export type UserInfo = {
22
uuid: string;
33
kakaoId: string;
44
nickname: string;
5+
profileImageUrl?: string;
56
createdAt: Date;
67
};

src/image/image.service.ts

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@ import { ConfigService } from '@nestjs/config';
1717

1818
import sharp from 'sharp';
1919

20+
export enum ImageType {
21+
CAFE = 'cafe',
22+
PROFILE = 'profile',
23+
}
24+
2025
@Injectable()
2126
export class ImageService {
22-
bucketName: string;
27+
private readonly bucketName: string;
28+
private readonly environment: string;
2329

2430
constructor(
2531
private readonly configService: ConfigService,
@@ -35,28 +41,46 @@ export class ImageService {
3541
},
3642
});
3743
this.bucketName = this.configService.get<string>('AWS_S3_BUCKET_NAME');
44+
this.environment =
45+
this.configService.get<string>('NODE_ENV') || 'development';
3846
}
3947

4048
/**
4149
* 이미지 파일을 여러 개 업로드
4250
* @param files Express.Multer.File[]
51+
* @param type ImageType
4352
* @returns string[]
4453
*/
45-
async uploadImages(files: Express.Multer.File[]): Promise<string[]> {
46-
return Promise.all(files.map((file) => this.uploadImage(file)));
54+
async uploadImages(
55+
files: Express.Multer.File[],
56+
type: ImageType = ImageType.CAFE,
57+
): Promise<string[]> {
58+
return Promise.all(files.map((file) => this.uploadImage(file, type)));
59+
}
60+
61+
/**
62+
* 프로필 이미지 업로드 (1:1 비율로 크롭)
63+
* @param file Express.Multer.File
64+
* @returns string
65+
*/
66+
async uploadProfileImage(file: Express.Multer.File): Promise<string> {
67+
const processedFile = await this.processProfileImage(file);
68+
return this.uploadImage(processedFile, ImageType.PROFILE);
4769
}
4870

4971
/**
5072
* S3에 단일 이미지 업로드
5173
* @param file Express.Multer.File
74+
* @param type ImageType
5275
* @returns string
5376
*/
54-
private async uploadImage(file: Express.Multer.File): Promise<string> {
55-
const key = `staging/${Date.now()}-${Math.random().toString(36).substring(2)}${file.originalname}`;
77+
private async uploadImage(
78+
file: Express.Multer.File,
79+
type: ImageType,
80+
): Promise<string> {
81+
const key = `${this.environment}/${type}/${Date.now()}-${Math.random().toString(36).substring(2)}${file.originalname}`;
5682

57-
const webpFile = await this.convertToWebp({
58-
...file,
59-
});
83+
const webpFile = await this.convertToWebp(file);
6084

6185
const command = new PutObjectCommand({
6286
Bucket: this.bucketName,
@@ -77,6 +101,35 @@ export class ImageService {
77101
}
78102
}
79103

104+
/**
105+
* 프로필 이미지 처리 (1:1 비율로 크롭)
106+
* @param file Express.Multer.File
107+
* @returns Express.Multer.File
108+
*/
109+
private async processProfileImage(
110+
file: Express.Multer.File,
111+
): Promise<Express.Multer.File> {
112+
try {
113+
const metadata = await sharp(file.buffer).metadata();
114+
const size = Math.min(metadata.width, metadata.height);
115+
116+
file.buffer = await sharp(file.buffer)
117+
.resize(size, size, {
118+
fit: 'cover',
119+
position: 'center',
120+
})
121+
.toBuffer();
122+
123+
return file;
124+
} catch (error) {
125+
console.log(error);
126+
if (error.message.includes('unsupported image format')) {
127+
throw new BadRequestException('Unsupported image format');
128+
}
129+
throw new InternalServerErrorException('Failed to process profile image');
130+
}
131+
}
132+
80133
/**
81134
* S3에서 단일 이미지 삭제
82135
* @param key string

src/user/dto/res/userInfo.dto.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ export class UserInfoDto {
2222
})
2323
nickname: string;
2424

25+
@ApiProperty({
26+
type: String,
27+
description: "User's profile image signed URL",
28+
example:
29+
'https://your-bucket.s3.region.amazonaws.com/production/profile/1234567890-abcdef.webp?signed-params',
30+
required: false,
31+
})
32+
profileImageUrl?: string;
33+
2534
@ApiProperty({
2635
type: Date,
2736
description: 'Account created Date',

src/user/user.controller.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
1-
import { Controller, Get, UseGuards, Body, Patch, Query } from '@nestjs/common';
1+
import {
2+
Controller,
3+
Get,
4+
UseGuards,
5+
Body,
6+
Patch,
7+
Query,
8+
Post,
9+
UseInterceptors,
10+
UploadedFile,
11+
BadRequestException,
12+
} from '@nestjs/common';
213
import { UserService } from './user.service';
314
import { JwtAuthGuard } from 'src/auth/jwt.auth.strategy';
415
import { GetUser } from './decorator/get-user.decorator';
516
import { UserInfo } from 'src/auth/types/userInfo.type';
617
import {
718
ApiBearerAuth,
19+
ApiConsumes,
820
ApiInternalServerErrorResponse,
921
ApiOkResponse,
1022
ApiOperation,
1123
ApiQuery,
1224
ApiTags,
1325
ApiUnauthorizedResponse,
26+
ApiBody,
1427
} from '@nestjs/swagger';
1528
import { UserInfoDto } from './dto/res/userInfo.dto';
1629
import { UpdateNicknameDto } from './dto/req/updateNickname.dto';
1730
import { NicknameDuplicateCheckDto } from './dto/res/nicknameDuplicateCheck.dto';
31+
import { FileInterceptor } from '@nestjs/platform-express';
32+
import { User } from '@prisma/client';
1833

1934
@ApiTags('User')
2035
@Controller('user')
@@ -35,8 +50,8 @@ export class UserController {
3550
@ApiBearerAuth('JWT')
3651
@Get('profile')
3752
@UseGuards(JwtAuthGuard)
38-
async getProfile(@GetUser() user: UserInfo): Promise<UserInfo> {
39-
return user;
53+
async getProfile(@GetUser() user: User): Promise<UserInfo> {
54+
return this.userService.formatUserForResponse(user);
4055
}
4156

4257
@ApiOperation({
@@ -75,12 +90,67 @@ export class UserController {
7590
@Patch('nickname')
7691
@UseGuards(JwtAuthGuard)
7792
async updateNickname(
78-
@GetUser() user: UserInfo,
93+
@GetUser() user: User,
7994
@Body() updateNicknameDto: UpdateNicknameDto,
8095
): Promise<UserInfo> {
81-
return this.userService.updateNickname(
96+
const updatedUser = await this.userService.updateNickname(
8297
user.uuid,
8398
updateNicknameDto.nickname,
8499
);
100+
return this.userService.formatUserForResponse(updatedUser);
101+
}
102+
103+
@ApiOperation({
104+
summary: 'upload profile image',
105+
description:
106+
'프로필 이미지를 업로드합니다. 이미지는 1:1 비율로 자동 크롭됩니다.',
107+
})
108+
@ApiConsumes('multipart/form-data')
109+
@ApiBody({
110+
schema: {
111+
type: 'object',
112+
properties: {
113+
image: {
114+
type: 'string',
115+
format: 'binary',
116+
description: '프로필 이미지 파일 (jpg, jpeg, png만 가능)',
117+
},
118+
},
119+
},
120+
})
121+
@ApiOkResponse({
122+
type: UserInfoDto,
123+
description: 'Return updated profile',
124+
})
125+
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
126+
@ApiInternalServerErrorResponse({
127+
description: 'Internal Server Error',
128+
})
129+
@ApiBearerAuth('JWT')
130+
@Post('profile-image')
131+
@UseGuards(JwtAuthGuard)
132+
@UseInterceptors(
133+
FileInterceptor('image', {
134+
fileFilter: (req, file, cb) => {
135+
if (!file.mimetype.match(/^image\/(jpg|jpeg|png)$/)) {
136+
cb(new BadRequestException('Only image files are allowed'), false);
137+
}
138+
cb(null, true);
139+
},
140+
}),
141+
)
142+
async uploadProfileImage(
143+
@GetUser() user: User,
144+
@UploadedFile() file: Express.Multer.File,
145+
): Promise<UserInfo> {
146+
if (!file) {
147+
throw new BadRequestException('No file uploaded');
148+
}
149+
150+
const updatedUser = await this.userService.updateProfileImage(
151+
user.uuid,
152+
file,
153+
);
154+
return this.userService.formatUserForResponse(updatedUser);
85155
}
86156
}

src/user/user.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { UserController } from './user.controller';
33
import { UserService } from './user.service';
44
import { PrismaModule } from 'src/prisma/prisma.module';
55
import { UserRepository } from './user.repository';
6+
import { ImageModule } from 'src/image/image.module';
67

78
@Module({
8-
imports: [PrismaModule],
9+
imports: [PrismaModule, ImageModule],
910
controllers: [UserController],
1011
providers: [UserService, UserRepository],
1112
exports: [UserService],

src/user/user.repository.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,12 @@ export class UserRepository {
4949
data: { nickname },
5050
});
5151
}
52+
53+
// 프로필 이미지 업데이트
54+
async updateProfileImage(uuid: string, profileImage: string) {
55+
return this.prismaService.user.update({
56+
where: { uuid },
57+
data: { profileImage },
58+
});
59+
}
5260
}

src/user/user.service.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,52 @@
11
import { ConflictException, Injectable } from '@nestjs/common';
22
import { UserRepository } from './user.repository';
3+
import { ImageService } from 'src/image/image.service';
4+
import { UserInfo } from 'src/auth/types/userInfo.type';
5+
import { User } from '@prisma/client';
36

47
@Injectable()
58
export class UserService {
6-
constructor(private readonly userRepository: UserRepository) {}
9+
constructor(
10+
private readonly userRepository: UserRepository,
11+
private readonly imageService: ImageService,
12+
) {}
713

8-
async createUser(kakaoId: string) {
14+
async createUser(kakaoId: string): Promise<User> {
915
return this.userRepository.createUser(kakaoId);
1016
}
1117

12-
async findByKakaoId(kakaoId: string) {
18+
async findByKakaoId(kakaoId: string): Promise<User | null> {
1319
return this.userRepository.findByKakaoId(kakaoId);
1420
}
1521

16-
async findById(uuid: string) {
22+
async findById(uuid: string): Promise<User | null> {
1723
return this.userRepository.findByUuid(uuid);
1824
}
1925

26+
async formatUserForResponse(user: User): Promise<UserInfo> {
27+
if (!user) {
28+
return null;
29+
}
30+
31+
const { profileImage, ...userWithoutProfileImage } = user;
32+
const responseUser: UserInfo = userWithoutProfileImage;
33+
34+
if (profileImage) {
35+
responseUser.profileImageUrl =
36+
await this.imageService.generateSignedUrl(profileImage);
37+
}
38+
39+
return responseUser;
40+
}
41+
2042
// 닉네임 중복 확인
2143
async checkNicknameDuplicate(nickname: string): Promise<boolean> {
2244
const existingUser = await this.userRepository.findByNickname(nickname);
2345
return !!existingUser;
2446
}
2547

2648
// 닉네임 업데이트
27-
async updateNickname(uuid: string, nickname: string) {
49+
async updateNickname(uuid: string, nickname: string): Promise<User> {
2850
// 닉네임 중복 확인
2951
const isDuplicate = await this.checkNicknameDuplicate(nickname);
3052
if (isDuplicate) {
@@ -33,4 +55,13 @@ export class UserService {
3355

3456
return this.userRepository.updateNickname(uuid, nickname);
3557
}
58+
59+
// 프로필 이미지 업데이트
60+
async updateProfileImage(
61+
uuid: string,
62+
file: Express.Multer.File,
63+
): Promise<User> {
64+
const imageKey = await this.imageService.uploadProfileImage(file);
65+
return this.userRepository.updateProfileImage(uuid, imageKey);
66+
}
3667
}

0 commit comments

Comments
 (0)