Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
141 changes: 141 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,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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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"
fi

Repository: 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


업로드 실패 후에도 새 객체를 삭제하십시오.

storageService.upload(file, storageKey)가 S3 저장 후 예외를 던지면 registerUploadedFileCleanupOnRollback(storageKey)가 호출되지 않아 롤백에서 새 이미지가 삭제되지 않습니다. 해당 호출을 try/catch로 감싸 예외 상황에서 deleteStorageObjectQuietly(storageKey, ...)를 먼저 수행하고 다시 예외를 던지십시오.

🤖 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
222 - 223, Update the upload flow in UserService so storageService.upload is
wrapped in try/catch; if it throws after creating the object, call
deleteStorageObjectQuietly(storageKey, ...) before rethrowing the original
exception, while preserving registerUploadedFileCleanupOnRollback(storageKey)
for successful uploads.


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 +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));
Expand Down
8 changes: 8 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ spring:
hibernate:
format_sql: true

flyway:
# 기존 운영/개발 DB는 수동 반영된 V8 스키마를 기준점으로 등록한 뒤 이후 변경만 적용한다.
baseline-on-migrate: true
baseline-version: 8
baseline-description: legacy-schema
validate-on-migrate: true

server:
forward-headers-strategy: framework

Expand All @@ -36,6 +43,7 @@ cloud:
aws:
s3:
bucket: ${CLOUD_AWS_S3_BUCKET}
public-base-url: ${CLOUD_AWS_S3_PUBLIC_BASE_URL:}

youtube:
api:
Expand Down
Loading
Loading