Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,7 @@ AWS_SECRET_ACCESS_KEY=<aws-secret-access-key>

# S3 bucket
CLOUD_AWS_S3_BUCKET=<cloud-aws-s3-bucket>

# 프로필 이미지 등 공개 파일 URL을 조합할 CDN 또는 공개 S3 base URL
# 예: https://cdn.slatto.cloud
CLOUD_AWS_S3_PUBLIC_BASE_URL=<cloud-aws-s3-public-base-url>
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,14 @@ List<ActivityLog> findRecentActivitiesByCursor(
);

Optional<ActivityLog> findByIdAndProjectId(Long activityId, Long projectId);

@Query("""
select al.project.id as projectId, max(al.createdAt) as lastActivityAt
from ActivityLog al
where al.project.id in :projectIds
group by al.project.id
""")
List<ProjectLatestActivityProjection> findLatestActivityAtByProjectIds(
@Param("projectIds") List<Long> projectIds
);
}
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
Expand Up @@ -17,6 +17,8 @@
import com.slatto.domain.project.repository.ProjectPinRepository;
import com.slatto.domain.project.repository.ProjectRepository;
import com.slatto.domain.project.repository.ProjectUserRoleRepository;
import com.slatto.domain.notification.repository.ActivityLogRepository;
import com.slatto.domain.notification.repository.ProjectLatestActivityProjection;
import com.slatto.domain.notification.service.ActivityLogService;
import com.slatto.domain.user.entity.Users;
import com.slatto.domain.user.enums.RoleName;
Expand Down Expand Up @@ -54,6 +56,7 @@ public class ProjectService {
private final ProjectConverter projectConverter;
private final ProjectAccessValidator projectAccessValidator;
private final ActivityLogService activityLogService;
private final ActivityLogRepository activityLogRepository;

@Transactional
public ProjectResponse createProject(Long ownerUserId, ProjectCreateRequest request) {
Expand Down Expand Up @@ -97,6 +100,7 @@ public ProjectListResponse getProjects(
Map<Long, List<RoleName>> roleNamesByMemberId = getRoleNamesByMemberId(currentPageMembers);
Map<Long, String> previewImageUrlByProjectId = getPreviewImageUrlByProjectId(currentPageMembers);
Map<Long, LocalDateTime> pinnedAtByProjectId = getPinnedAtByProjectId(currentUserId, currentPageMembers);
Map<Long, LocalDateTime> lastActivityAtByProjectId = getLastActivityAtByProjectId(currentPageMembers);

Long nextCursor = hasNext && !currentPageMembers.isEmpty()
? currentPageMembers.get(currentPageMembers.size() - 1).getProject().getId()
Expand All @@ -111,7 +115,7 @@ public ProjectListResponse getProjects(
previewImageUrlByProjectId.get(projectMember.getProject().getId()),
pinnedAtByProjectId.get(projectMember.getProject().getId()),
projectMember.getPermission(),
resolveLastActivityAt(projectMember.getProject())
lastActivityAtByProjectId.get(projectMember.getProject().getId())
))
.toList();

Expand Down Expand Up @@ -311,6 +315,24 @@ private Map<Long, LocalDateTime> getPinnedAtByProjectId(Long userId, List<Projec
));
}

private Map<Long, LocalDateTime> getLastActivityAtByProjectId(List<ProjectMember> projectMembers) {
List<Long> projectIds = projectMembers.stream()
.map(ProjectMember::getProject)
.map(Project::getId)
.toList();

if (projectIds.isEmpty()) {
return Map.of();
}

return activityLogRepository.findLatestActivityAtByProjectIds(projectIds)
.stream()
.collect(Collectors.toMap(
ProjectLatestActivityProjection::getProjectId,
ProjectLatestActivityProjection::getLastActivityAt
));
}

private LocalDateTime getProjectCursorPinnedAt(Long userId, Long cursor) {
if (cursor == null) {
return null;
Expand All @@ -321,10 +343,6 @@ private LocalDateTime getProjectCursorPinnedAt(Long userId, Long cursor) {
.orElse(null);
}

private LocalDateTime resolveLastActivityAt(Project project) {
return project.getUpdatedAt() != null ? project.getUpdatedAt() : project.getCreatedAt();
}

private int normalizePageSize(int size) {
if (size <= 0) {
return DEFAULT_PAGE_SIZE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.service.UserService;
import com.slatto.global.response.ApiResponse;
Expand All @@ -20,7 +21,11 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.multipart.MultipartFile;

@Tag(name = "User", description = "유저 API")
@RestController
Expand Down Expand Up @@ -60,6 +65,17 @@ public ApiResponse<UserProfileUpdateResponse> updateProfile(
return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(summary = "프로필 이미지 업로드", description = "프로필 이미지를 S3에 업로드하고 CDN 공개 URL로 교체한다.")
@PutMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<UserProfileImageResponse> uploadProfileImage(
@AuthenticationPrincipal Long userId,
@RequestPart("file") MultipartFile file
) {
UserProfileImageResponse response = userService.uploadProfileImage(userId, file);

return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(summary = "공개 프로필 조회", description = "다른 유저의 공개 프로필을 조회한다. 이메일 등 비공개 필드는 제외된다.")
@GetMapping("/{userId}")
public ApiResponse<UserPublicProfileResponse> getPublicProfile(@PathVariable Long userId) {
Expand Down
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;
}
6 changes: 5 additions & 1 deletion src/main/java/com/slatto/domain/user/entity/Users.java
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,8 @@ public void completeOnboarding(String nickname, String bio, String profileImageU
this.onboardingCompleted = true;
}

}
public void updateProfileImage(String profileImageUrl) {
this.profileImageUrl = profileImageUrl;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
@RequiredArgsConstructor
public enum UserErrorCode implements BaseCode {

ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "ONBOARDING409", "이미 온보딩을 완료한 유저입니다.");
ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "ONBOARDING409", "이미 온보딩을 완료한 유저입니다."),
PROFILE_IMAGE_EMPTY(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_EMPTY400", "업로드할 프로필 이미지가 비어 있습니다."),
PROFILE_IMAGE_INVALID_TYPE(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_INVALID_TYPE400", "지원하지 않는 프로필 이미지 형식입니다."),
PROFILE_IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_SIZE400", "프로필 이미지는 최대 10MB까지 업로드할 수 있습니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
146 changes: 146 additions & 0 deletions src/main/java/com/slatto/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
);
Comment on lines +47 to +53

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

rg -n -C 3 'multipart|max-file-size|max-request-size' \
  src/main/resources/application.yml src/test/resources/application.yml

rg -n -C 3 'PROFILE_IMAGE_SIZE_EXCEEDED|10 \* 1024 \* 1024|uploadProfileImage' \
  src/test/java

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 2886


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Service outline =="
ast-grep outline src/main/java/com/slatto/domain/user/service/UserService.java --match UserProfileImageService --view expanded || true

echo "== Relevant service lines =="
sed -n '1,120p' src/main/java/com/slatto/domain/user/service/UserService.java | cat -n

echo "== Relevant test file outline and lines =="
ast-grep outline src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java --view expanded || true
sed -n '1,140p' src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java | cat -n

echo "== Search size boundary tests =="
rg -n -C 3 'size|Size|contentLength|1024|10 .* 1024|10MB|10MiB|PROFILE_IMAGE_SIZE_EXPIRED|PROFILE_IMAGE_SIZE_EXCEEDED|uploadProfileImage' src/test-java src/test/java || true

echo "== Search multipart config files =="
git ls-files | rg '(^|/)(application.*\.ya?ml|application.*\.properties|web.*\.ya?ml|web.*\.properties)$' | while read -r f; do
  echo "--- $f"
  rg -n -C 2 'multipart|max-file-size|max-request-size' "$f" || true
done

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 19985


프로필 이미지 10 MiB 한도와 multipart 허용 범위를 일치시키십시오.

spring.servlet.multipart.max-file-size: 100MB, max-request-size: 105MBMAX_PROFILE_IMAGE_SIZE = 10L * 1024 * 1024보다 큽니다. validateProfileImage가 Spring multipart 파싱 이후에 실행되므로, 10 MiB 초과 파일이 이 체크까지 도달합니다. 허용 범위를 10 MiB 경계에 맞추거나, multipart 설정은 그대로 두고 업로드 요청에서 파일 크기를 직접 거절하도록 조정하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/user/service/UserService.java` around lines
47 - 53, 프로필 이미지의 multipart 허용 크기를 MAX_PROFILE_IMAGE_SIZE인 10 MiB와 일치시키십시오.
spring.servlet.multipart의 max-file-size와 max-request-size를 10 MiB 기준으로 조정하거나, 해당
설정을 유지해야 한다면 업로드 요청에서 validateProfileImage 이전에 파일 크기를 직접 거절하도록 변경하십시오.


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);
Expand Down Expand Up @@ -185,6 +210,33 @@ 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());

try {
storageService.upload(file, storageKey);
} catch (RuntimeException exception) {
deleteStorageObjectQuietly(storageKey, "profile image upload");
throw exception;
}
registerUploadedFileCleanupOnRollback(storageKey);

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);

Expand Down Expand Up @@ -215,6 +267,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));
Expand Down
Loading
Loading