-
Notifications
You must be signed in to change notification settings - Fork 0
fix: 프로젝트 카드 활동 시각 및 프로필 이미지 업로드 지원 #125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
8a93ad7
086d0a7
bebbfb7
75cd92a
e8b9c95
38b58c3
dfddb7b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.slatto.domain.notification.repository; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| public interface ProjectLatestActivityProjection { | ||
|
|
||
| Long getProjectId(); | ||
|
|
||
| LocalDateTime getLastActivityAt(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.slatto.domain.user.dto; | ||
|
|
||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| public class UserProfileImageResponse { | ||
|
|
||
| private String profileImageUrl; | ||
|
|
||
| private LocalDateTime updatedAt; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| import com.slatto.domain.user.dto.UserOnboardingResponse; | ||
| import com.slatto.domain.user.dto.UserProfileUpdateRequest; | ||
| import com.slatto.domain.user.dto.UserProfileUpdateResponse; | ||
| import com.slatto.domain.user.dto.UserProfileImageResponse; | ||
| import com.slatto.domain.user.dto.UserPublicProfileResponse; | ||
| import com.slatto.domain.user.entity.Location; | ||
| import com.slatto.domain.user.entity.UserCategory; | ||
|
|
@@ -20,21 +21,45 @@ | |
| import com.slatto.domain.user.repository.UserRoleRepository; | ||
| import com.slatto.global.exception.BaseException; | ||
| import com.slatto.global.response.code.CommonErrorCode; | ||
| import com.slatto.global.storage.StorageService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import org.springframework.transaction.support.TransactionSynchronization; | ||
| import org.springframework.transaction.support.TransactionSynchronizationManager; | ||
| import org.springframework.util.StringUtils; | ||
| import org.springframework.web.multipart.MultipartFile; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Locale; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.UUID; | ||
|
|
||
| @Service | ||
| @Slf4j | ||
| @RequiredArgsConstructor | ||
| @Transactional(readOnly = true) | ||
| public class UserService { | ||
|
|
||
| private static final long MAX_PROFILE_IMAGE_SIZE = 10L * 1024 * 1024; | ||
| private static final String PROFILE_IMAGE_STORAGE_KEY_FORMAT = "users/%d/profile-images/%s.%s"; | ||
| private static final Map<String, Set<String>> ALLOWED_EXTENSIONS_BY_CONTENT_TYPE = Map.of( | ||
| "image/jpeg", Set.of("jpg", "jpeg"), | ||
| "image/png", Set.of("png"), | ||
| "image/webp", Set.of("webp") | ||
| ); | ||
|
|
||
| private final UserRepository userRepository; | ||
| private final UserRoleRepository userRoleRepository; | ||
| private final UserCategoryRepository userCategoryRepository; | ||
| private final LocationRepository locationRepository; | ||
| private final StorageService storageService; | ||
|
|
||
| @Value("${cloud.aws.s3.public-base-url:}") | ||
| private String publicBaseUrl; | ||
|
|
||
| public UserMeResponse getMyInfo(Long userId) { | ||
| Users user = getUserOrThrow(userId); | ||
|
|
@@ -185,6 +210,28 @@ public UserProfileUpdateResponse updateProfile(Long userId, UserProfileUpdateReq | |
| .build(); | ||
| } | ||
|
|
||
| @Transactional | ||
| public UserProfileImageResponse uploadProfileImage(Long userId, MultipartFile file) { | ||
| Users user = getUserOrThrow(userId); | ||
| validateProfileImage(file); | ||
|
|
||
| String storageKey = createProfileImageStorageKey(userId, file.getOriginalFilename()); | ||
| String profileImageUrl = createProfileImageUrl(storageKey); | ||
| String previousStorageKey = extractManagedStorageKey(user.getProfileImageUrl()); | ||
|
|
||
| storageService.upload(file, storageKey); | ||
| registerUploadedFileCleanupOnRollback(storageKey); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== locate UserService"
fd -a 'UserService.java$' . || true
echo "== outline UserService"
FILE="$(fd 'UserService.java$' . | head -n1 || true)"
if [ -n "${FILE:-}" ]; then
echo "FILE=$FILE"
wc -l "$FILE"
ast-grep outline "$FILE" --view expanded | sed -n '1,220p' || true
echo "== relevant snippets"
sed -n '180,250p' "$FILE" | nl -ba -v180
echo "== storage upload usages"
rg -n "storageService\.upload|registerUploadedFileCleanupOnRollback|upload\(" "$FILE"
fiRepository: SLAT-TO/SLATE-TO-BE Length of output: 1486 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="./src/main/java/com/slatto/domain/user/service/UserService.java"
echo "== lines 200-235"
awk 'NR>=200 && NR<=235 { printf "%6d %s\n", NR, $0 }' "$FILE"
echo "== lines 303-350"
awk 'NR>=303 && NR<=350 { printf "%6d %s\n", NR, $0 }' "$FILE"
echo "== storageService definition/usages"
rg -n "storageService|registerUploadedFileCleanupOnRollback|registerPreviousFileDeletionAfterCommit|deleteStorageObjectQuietly|`@Transactional`" "$FILE"Repository: SLAT-TO/SLATE-TO-BE Length of output: 5043 업로드 실패 후에도 새 객체를 삭제하십시오.
🤖 Prompt for AI Agents |
||
|
|
||
| user.updateProfileImage(profileImageUrl); | ||
| userRepository.flush(); | ||
| registerPreviousFileDeletionAfterCommit(previousStorageKey); | ||
|
|
||
| return UserProfileImageResponse.builder() | ||
| .profileImageUrl(profileImageUrl) | ||
| .updatedAt(user.getUpdatedAt()) | ||
| .build(); | ||
| } | ||
|
|
||
| public UserPublicProfileResponse getPublicProfile(Long userId) { | ||
| Users user = getUserOrThrow(userId); | ||
|
|
||
|
|
@@ -215,6 +262,100 @@ public UserPublicProfileResponse getPublicProfile(Long userId) { | |
| .build(); | ||
| } | ||
|
|
||
| private void validateProfileImage(MultipartFile file) { | ||
| if (file == null || file.isEmpty()) { | ||
| throw new BaseException(UserErrorCode.PROFILE_IMAGE_EMPTY); | ||
| } | ||
|
|
||
| if (file.getSize() > MAX_PROFILE_IMAGE_SIZE) { | ||
| throw new BaseException(UserErrorCode.PROFILE_IMAGE_SIZE_EXCEEDED); | ||
| } | ||
|
|
||
| String extension = getExtension(file.getOriginalFilename()); | ||
| String contentType = file.getContentType(); | ||
| if (!isAllowedProfileImage(contentType, extension)) { | ||
| throw new BaseException(UserErrorCode.PROFILE_IMAGE_INVALID_TYPE); | ||
| } | ||
| } | ||
|
|
||
| private boolean isAllowedProfileImage(String contentType, String extension) { | ||
| if (!StringUtils.hasText(contentType) || !StringUtils.hasText(extension)) { | ||
| return false; | ||
| } | ||
|
|
||
| return ALLOWED_EXTENSIONS_BY_CONTENT_TYPE | ||
| .getOrDefault(contentType.toLowerCase(Locale.ROOT), Set.of()) | ||
| .contains(extension); | ||
| } | ||
|
|
||
| private String createProfileImageStorageKey(Long userId, String originalFilename) { | ||
| return PROFILE_IMAGE_STORAGE_KEY_FORMAT.formatted(userId, UUID.randomUUID(), getExtension(originalFilename)); | ||
| } | ||
|
|
||
| private String createProfileImageUrl(String storageKey) { | ||
| if (!StringUtils.hasText(publicBaseUrl)) { | ||
| throw new BaseException(CommonErrorCode.INTERNAL_SERVER_ERROR); | ||
| } | ||
|
|
||
| return publicBaseUrl.replaceAll("/+$", "") + "/" + storageKey; | ||
| } | ||
|
|
||
| private String extractManagedStorageKey(String profileImageUrl) { | ||
| if (!StringUtils.hasText(publicBaseUrl) || !StringUtils.hasText(profileImageUrl)) { | ||
| return null; | ||
| } | ||
|
|
||
| String normalizedBaseUrl = publicBaseUrl.replaceAll("/+$", "") + "/"; | ||
| if (!profileImageUrl.startsWith(normalizedBaseUrl)) { | ||
| return null; | ||
| } | ||
|
|
||
| return profileImageUrl.substring(normalizedBaseUrl.length()); | ||
| } | ||
|
|
||
| private void registerUploadedFileCleanupOnRollback(String storageKey) { | ||
| if (!TransactionSynchronizationManager.isSynchronizationActive()) { | ||
| return; | ||
| } | ||
|
|
||
| TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { | ||
| @Override | ||
| public void afterCompletion(int status) { | ||
| if (status != STATUS_COMMITTED) { | ||
| deleteStorageObjectQuietly(storageKey, "profile image upload rollback"); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private void registerPreviousFileDeletionAfterCommit(String previousStorageKey) { | ||
| if (!StringUtils.hasText(previousStorageKey) || !TransactionSynchronizationManager.isSynchronizationActive()) { | ||
| return; | ||
| } | ||
|
|
||
| TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { | ||
| @Override | ||
| public void afterCompletion(int status) { | ||
| if (status == STATUS_COMMITTED) { | ||
| deleteStorageObjectQuietly(previousStorageKey, "profile image replacement"); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private void deleteStorageObjectQuietly(String storageKey, String context) { | ||
| try { | ||
| storageService.delete(storageKey); | ||
| } catch (RuntimeException exception) { | ||
| log.warn("Failed to delete S3 object after {}. storageKey={}", context, storageKey, exception); | ||
| } | ||
| } | ||
|
|
||
| private String getExtension(String fileName) { | ||
| String extension = StringUtils.getFilenameExtension(fileName); | ||
| return StringUtils.hasText(extension) ? extension.toLowerCase(Locale.ROOT) : ""; | ||
| } | ||
|
|
||
| private Users getUserOrThrow(Long userId) { | ||
| return userRepository.findByIdAndDeletedAtIsNull(userId) | ||
| .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: SLAT-TO/SLATE-TO-BE
Length of output: 2886
🏁 Script executed:
Repository: SLAT-TO/SLATE-TO-BE
Length of output: 19985
프로필 이미지 10 MiB 한도와 multipart 허용 범위를 일치시키십시오.
spring.servlet.multipart.max-file-size: 100MB,max-request-size: 105MB는MAX_PROFILE_IMAGE_SIZE = 10L * 1024 * 1024보다 큽니다.validateProfileImage가 Spring multipart 파싱 이후에 실행되므로, 10 MiB 초과 파일이 이 체크까지 도달합니다. 허용 범위를 10 MiB 경계에 맞추거나, multipart 설정은 그대로 두고 업로드 요청에서 파일 크기를 직접 거절하도록 조정하십시오.🤖 Prompt for AI Agents