Skip to content

Commit aecb209

Browse files
committed
Merge branch 'featur/user-profile-image' into develop
2 parents b1beb6c + 24df360 commit aecb209

5 files changed

Lines changed: 155 additions & 10 deletions

File tree

mysql/migrations/V1.1.3__init.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- users 테이블에 프로필 이미지 URL 저장을 위한 image 컬럼 추가
2+
ALTER TABLE users ADD COLUMN profile_image TEXT;

nginx/react-frontpage/src/Component/Profile/Profile.tsx

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect } from 'react';
1+
import React, { useState, useEffect, useRef } from 'react';
22

33
// 쿠키에서 값을 읽어오는 함수
44
const getCookieValue = (name: string): string => {
@@ -16,7 +16,7 @@ const getCookieValue = (name: string): string => {
1616
type MembershipType = 'BASIC' | 'VIP';
1717

1818
const Profile: React.FC = () => {
19-
const [userInfo, setUserInfo] = useState({ name: '', email: '', userid: '' });
19+
const [userInfo, setUserInfo] = useState({ name: '', email: '', userid: '', profileImage: '' });
2020
const [editedInfo, setEditedInfo] = useState({ name: '', email: '', userid: '', pw: '' });
2121
const [membership, setMembership] = useState<MembershipType>('BASIC');
2222
const [isLoading, setIsLoading] = useState(true);
@@ -26,6 +26,10 @@ const Profile: React.FC = () => {
2626
const [isEmailSent, setIsEmailSent] = useState(false);
2727
const [emailVerifyStatus, setEmailVerifyStatus] = useState<'idle' | 'success' | 'error' | 'expired' | 'notfound'>('idle');
2828
const [emailVerifyMessage, setEmailVerifyMessage] = useState('');
29+
const [profileImageUrl, setProfileImageUrl] = useState<string | null>(null);
30+
const [profileImagePreview, setProfileImagePreview] = useState<string | null>(null);
31+
const [uploading, setUploading] = useState(false);
32+
const fileInputRef = useRef<HTMLInputElement>(null);
2933

3034
useEffect(() => {
3135
// 사용자 정보와 멤버십 정보 모두 가져오기
@@ -81,8 +85,11 @@ const Profile: React.FC = () => {
8185

8286
const data = await response.json();
8387
console.log('User Info Fetched:', data);
84-
setUserInfo(data);
88+
setUserInfo({ ...data, profileImage: data.profileImage || data.image || '' });
8589
setEditedInfo({ ...data, pw: '' }); // data에 userid 포함
90+
// 프로필 이미지 URL 세팅 (백엔드에서 profileImage 필드로 내려줘야 함)
91+
setProfileImageUrl(data.profileImage || null);
92+
setProfileImagePreview(null); // 새로고침 시 미리보기 초기화
8693

8794
// DB의 users 테이블에 membership 필드가 있으므로 이 정보를 사용
8895
if (data.membership) {
@@ -96,6 +103,59 @@ const Profile: React.FC = () => {
96103
}
97104
};
98105

106+
107+
const handleProfileImageClick = () => {
108+
if (fileInputRef.current) fileInputRef.current.click();
109+
};
110+
111+
// 파일 선택 시 미리보기 및 업로드
112+
const handleProfileImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
113+
if (!e.target.files || e.target.files.length === 0) return;
114+
const file = e.target.files[0];
115+
116+
// 파일 크기 제한 (예: 5MB)
117+
const maxSize = 5 * 1024 * 1024;
118+
if (file.size > maxSize) {
119+
alert('파일 크기가 5MB를 초과할 수 없습니다.');
120+
return;
121+
}
122+
123+
// 미리보기
124+
const reader = new FileReader();
125+
reader.onloadend = () => setProfileImagePreview(reader.result as string);
126+
reader.readAsDataURL(file);
127+
128+
// 업로드
129+
try {
130+
setUploading(true);
131+
const jwtToken = getCookieValue('jwt-token');
132+
const formData = new FormData();
133+
formData.append('file', file);
134+
135+
const res = await fetch('/server/user/profile/image', {
136+
method: 'POST',
137+
headers: {
138+
'Authorization': jwtToken || ''
139+
},
140+
body: formData,
141+
});
142+
const data = await res.json();
143+
if (res.ok && data.url) {
144+
setProfileImageUrl(data.url);
145+
setProfileImagePreview(null);
146+
// userInfo에도 반영
147+
setUserInfo(prev => ({ ...prev, profileImage: data.url }));
148+
alert('프로필 이미지가 성공적으로 업로드되었습니다.');
149+
} else {
150+
alert(data.message || '프로필 이미지 업로드에 실패했습니다.');
151+
}
152+
} catch (err) {
153+
alert('프로필 이미지 업로드 중 오류가 발생했습니다.');
154+
} finally {
155+
setUploading(false);
156+
}
157+
};
158+
99159
const handleUpdate = async () => {
100160
try {
101161
console.log('Updating User Info:', editedInfo);
@@ -244,9 +304,41 @@ const Profile: React.FC = () => {
244304
<div className="bg-[#2a2928] rounded-lg p-8 shadow-lg">
245305
{/* 프로필 상단 영역: 이미지와 멤버십 배지 */}
246306
<div className="flex flex-col items-center mb-8">
247-
{/* 프로필 이미지 */}
248-
<div className="w-32 h-32 bg-[#3f3f3f] rounded-full flex items-center justify-center shadow-md mb-4 border-2 border-[#3b7cc9]">
249-
<span className="text-6xl">👤</span>
307+
<div
308+
className="w-32 h-32 bg-[#3f3f3f] rounded-full flex items-center justify-center shadow-md mb-4 border-2 border-[#3b7cc9] cursor-pointer relative overflow-hidden"
309+
title="프로필 이미지 업로드"
310+
onClick={handleProfileImageClick}
311+
style={{ position: 'relative' }}
312+
>
313+
{profileImagePreview ? (
314+
<img
315+
src={profileImagePreview}
316+
alt="프로필 미리보기"
317+
className="w-full h-full object-cover rounded-full"
318+
/>
319+
) : userInfo.profileImage ? (
320+
<img
321+
src={userInfo.profileImage}
322+
alt="프로필"
323+
className="w-full h-full object-cover rounded-full"
324+
/>
325+
) : (
326+
<span className="text-6xl">👤</span>
327+
)}
328+
{uploading && (
329+
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center">
330+
<div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-[#3b7cc9]"></div>
331+
</div>
332+
)}
333+
<input
334+
type="file"
335+
accept="image/png,image/jpeg"
336+
ref={fileInputRef}
337+
style={{ display: 'none' }}
338+
onChange={handleProfileImageChange}
339+
aria-label="프로필 이미지 업로드"
340+
/>
341+
<span className="absolute bottom-2 right-2 bg-[#3b7cc9] text-white text-xs px-2 py-1 rounded shadow">변경</span>
250342
</div>
251343

252344
{/* 멤버십 배지 */}

springboot/src/main/java/com/TreeNut/ChatBot_Backend/controller/UserController.kt

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.TreeNut.ChatBot_Backend.controller
33
import com.TreeNut.ChatBot_Backend.model.User
44
import com.TreeNut.ChatBot_Backend.service.UserService
55
import com.TreeNut.ChatBot_Backend.middleware.TokenAuth
6+
import com.TreeNut.ChatBot_Backend.service.GoogleDriveService
67
import org.springframework.http.HttpHeaders
78
import org.springframework.http.HttpStatus
89
import org.springframework.http.ResponseEntity
@@ -17,13 +18,16 @@ import java.net.URLEncoder
1718
import java.nio.charset.StandardCharsets
1819
import org.springframework.web.reactive.function.client.WebClientResponseException
1920
import org.slf4j.LoggerFactory
21+
import org.springframework.web.multipart.MultipartFile
22+
import java.nio.file.Files
2023

2124
@RestController
2225
@RequestMapping("/server/user")
2326
class UserController(
2427
private val userService: UserService,
2528
private val tokenAuth: TokenAuth,
2629
private val webClientBuilder: WebClient.Builder,
30+
private val googleDriveService: GoogleDriveService,
2731
@Value("\${spring.security.oauth2.client.registration.google.client-id}") private val googleClientId: String,
2832
@Value("\${spring.security.oauth2.client.registration.google.client-secret}") private val googleClientSecret: String,
2933
@Value("\${spring.security.oauth2.client.registration.google.redirect-uri}") private val googleRedirectUri: String,
@@ -181,9 +185,16 @@ class UserController(
181185
@GetMapping("/findmyinfo")
182186
fun findUserNameandEmail(@RequestHeader("Authorization") userToken:String): ResponseEntity<Map<String, Any>> {
183187
val userid = userService.getUserid(userToken)
184-
val name = userService.getUsername(userid)
185-
val email = userService.getUseremail(userid)
186-
return ResponseEntity.ok(mapOf("name" to name, "userid" to userid,"email" to email))
188+
val user = userService.findUserByUserid(userid)
189+
?: return ResponseEntity.status(404).body(mapOf("status" to 404, "message" to "User not found"))
190+
return ResponseEntity.ok(
191+
mapOf(
192+
"name" to user.username,
193+
"userid" to user.userid,
194+
"email" to user.email,
195+
"profileImage" to (user.profileImage ?: "")
196+
)
197+
)
187198
}
188199

189200
@PostMapping("/changeUsername")
@@ -228,4 +239,31 @@ class UserController(
228239
val result = userService.verifyEmailCode(userid, email, code)
229240
return ResponseEntity.ok(result)
230241
}
242+
243+
@PostMapping("/profile/image")
244+
fun uploadProfileImage(
245+
@RequestParam("file") file: MultipartFile,
246+
@RequestHeader("Authorization") userToken: String
247+
): ResponseEntity<Map<String, Any>> {
248+
return try {
249+
// JWT에서 사용자 ID 추출
250+
val userid = tokenAuth.authGuard(userToken)
251+
?: return ResponseEntity.status(401).body(mapOf("status" to 401, "message" to "유효한 토큰이 필요합니다."))
252+
253+
// 임시 파일로 저장
254+
val tempFile = Files.createTempFile("profile_", ".png").toFile()
255+
file.transferTo(tempFile)
256+
257+
// 구글 드라이브 업로드
258+
val imageUrl = googleDriveService.uploadImageAndGetLink(tempFile.absolutePath)
259+
tempFile.delete() // 임시 파일 삭제
260+
261+
// DB에 프로필 이미지 URL 저장
262+
userService.updateProfileImage(userid, imageUrl)
263+
264+
ResponseEntity.ok(mapOf("status" to "success", "url" to imageUrl))
265+
} catch (e: Exception) {
266+
ResponseEntity.status(500).body(mapOf("status" to "fail", "message" to (e.message ?: "프로필 이미지 업로드 실패")))
267+
}
268+
}
231269
}

springboot/src/main/java/com/TreeNut/ChatBot_Backend/model/User.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,17 @@ data class User(
4545
// @Column(name = "user_setting_agree", nullable = false)
4646
// val user_setting_agree: Boolean = true,
4747

48+
@Column(name = "profile_image", columnDefinition = "TEXT")
49+
val profileImage: String? = null,
50+
4851
@Column(name = "created_at")
4952
val createdAt: LocalDateTime = LocalDateTime.now(),
5053

5154
@Column(name = "updated_at")
5255
var updatedAt: LocalDateTime = LocalDateTime.now()
5356
) {
5457
// constructor() : this(null, "", "", "", null, null, null, LoginType.LOCAL, false, MembershipType.BASIC, true, true, LocalDateTime.now(), LocalDateTime.now())
55-
constructor() : this(null, "", "", "", null, null, null, LoginType.LOCAL, false, MembershipType.BASIC, LocalDateTime.now(), LocalDateTime.now())
58+
constructor() : this(null, "", "", "", null, null, null, LoginType.LOCAL, false, MembershipType.BASIC, null, LocalDateTime.now(), LocalDateTime.now())
5659
@PreUpdate
5760
fun onUpdate() {
5861
updatedAt = LocalDateTime.now()

springboot/src/main/java/com/TreeNut/ChatBot_Backend/service/UserService.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,4 +351,14 @@ class UserService(
351351
mapOf("status" to "exception", "message" to "FastAPI 연동 오류: ${e.message}")
352352
}
353353
}
354+
355+
@Transactional
356+
fun updateProfileImage(userid: String, imageUrl: String): User {
357+
val user = userRepository.findByUserid(userid)
358+
?: throw RuntimeException("User not found")
359+
val updatedUser = user.copy(
360+
profileImage = imageUrl
361+
)
362+
return userRepository.save(updatedUser)
363+
}
354364
}

0 commit comments

Comments
 (0)