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
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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.web.multipart.MultipartFile;

@RequiredArgsConstructor
@RequestMapping("/admin/host-universities")
Expand All @@ -44,20 +46,33 @@ public ResponseEntity<AdminHostUniversityDetailResponse> getHostUniversity(
return ResponseEntity.ok(response);
}

@PostMapping
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<AdminHostUniversityDetailResponse> createHostUniversity(
@Valid @RequestBody AdminHostUniversityCreateRequest request
@Valid @RequestPart("request") AdminHostUniversityCreateRequest request,
@RequestPart("logoFile") MultipartFile logoFile,
@RequestPart("backgroundFile") MultipartFile backgroundFile
Comment on lines +52 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.

P2 Badge Raise multipart request limit for paired image uploads

This create endpoint now requires logoFile and backgroundFile in the same multipart request, but application.yml still caps spring.servlet.multipart.max-request-size at 10MB while each individual file is allowed up to 10MB. A logo and background that are each valid on their own, e.g. two ~6MB images, will be rejected by the servlet container before reaching this controller, so admins cannot create a university with two otherwise acceptable images unless the aggregate request limit is raised or uploads remain separate.

Useful? React with 👍 / 👎.

) {
AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity(request);
AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity(
request,
logoFile,
backgroundFile
);
return ResponseEntity.ok(response);
}

@PutMapping("/{host-university-id}")
@PutMapping(value = "/{host-university-id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<AdminHostUniversityDetailResponse> updateHostUniversity(
@PathVariable("host-university-id") Long hostUniversityId,
@Valid @RequestBody AdminHostUniversityUpdateRequest request
@Valid @RequestPart("request") AdminHostUniversityUpdateRequest request,
@RequestPart(value = "logoFile", required = false) MultipartFile logoFile,
@RequestPart(value = "backgroundFile", required = false) MultipartFile backgroundFile
) {
AdminHostUniversityDetailResponse response = adminHostUniversityService.updateHostUniversity(hostUniversityId, request);
AdminHostUniversityDetailResponse response = adminHostUniversityService.updateHostUniversity(
hostUniversityId,
request,
logoFile,
backgroundFile
);
return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,6 @@ public record AdminHostUniversityCreateRequest(
@Size(max = 500, message = "숙소 URL은 500자 이하여야 합니다")
String accommodationUrl,

@NotBlank(message = "로고 이미지 URL은 필수입니다")
@Size(max = 500, message = "로고 이미지 URL은 500자 이하여야 합니다")
String logoImageUrl,

@NotBlank(message = "배경 이미지 URL은 필수입니다")
@Size(max = 500, message = "배경 이미지 URL은 500자 이하여야 합니다")
String backgroundImageUrl,

@Size(max = 1000, message = "상세 정보는 1000자 이하여야 합니다")
String detailsForLocal,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,6 @@ public record AdminHostUniversityUpdateRequest(
@Size(max = 500, message = "숙소 URL은 500자 이하여야 합니다")
String accommodationUrl,

@NotBlank(message = "로고 이미지 URL은 필수입니다")
@Size(max = 500, message = "로고 이미지 URL은 500자 이하여야 합니다")
String logoImageUrl,

@NotBlank(message = "배경 이미지 URL은 필수입니다")
@Size(max = 500, message = "배경 이미지 URL은 500자 이하여야 합니다")
String backgroundImageUrl,

@Size(max = 1000, message = "상세 정보는 1000자 이하여야 합니다")
String detailsForLocal,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,33 @@
import com.example.solidconnection.location.country.repository.CountryRepository;
import com.example.solidconnection.location.region.domain.Region;
import com.example.solidconnection.location.region.repository.RegionRepository;
import com.example.solidconnection.s3.domain.UploadDirectoryName;
import com.example.solidconnection.s3.domain.UploadPath;
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
import com.example.solidconnection.s3.service.S3Service;
import com.example.solidconnection.university.domain.HostUniversity;
import com.example.solidconnection.university.repository.HostUniversityRepository;
import com.example.solidconnection.university.repository.UnivApplyInfoRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Service
@RequiredArgsConstructor
@Slf4j
public class AdminHostUniversityService {

private final HostUniversityRepository hostUniversityRepository;
private final CountryRepository countryRepository;
private final RegionRepository regionRepository;
private final UnivApplyInfoRepository univApplyInfoRepository;
private final CustomCacheManager cacheManager;
private final S3Service s3Service;

@Transactional(readOnly = true)
public Page<AdminHostUniversityResponse> getHostUniversities(
Expand Down Expand Up @@ -65,29 +73,51 @@ public AdminHostUniversityDetailResponse getHostUniversity(Long id) {
cacheManager = "customCacheManager",
prefix = true
)
public AdminHostUniversityDetailResponse createHostUniversity(AdminHostUniversityCreateRequest request) {
public AdminHostUniversityDetailResponse createHostUniversity(
AdminHostUniversityCreateRequest request,
MultipartFile logoFile,
MultipartFile backgroundFile
) {
validateKoreanNameNotExists(request.koreanName());

Country country = findCountryByCode(request.countryCode());
Region region = findRegionByCode(request.regionCode());
String directoryName = UploadDirectoryName.fromUniversityNames(request.englishName(), request.koreanName());
UploadedFileUrlResponse logoImage = null;
UploadedFileUrlResponse backgroundImage = null;

HostUniversity hostUniversity = new HostUniversity(
null,
request.koreanName(),
request.englishName(),
request.formatName(),
request.homepageUrl(),
request.englishCourseUrl(),
request.accommodationUrl(),
request.logoImageUrl(),
request.backgroundImageUrl(),
request.detailsForLocal(),
country,
region
);
try {
logoImage = uploadUniversityImage(
logoFile,
UploadPath.ADMIN_UNIVERSITY_LOGO,
directoryName
);
backgroundImage = uploadUniversityImage(
backgroundFile,
UploadPath.ADMIN_UNIVERSITY_BACKGROUND,
directoryName
);

HostUniversity savedHostUniversity = hostUniversityRepository.save(hostUniversity);
return AdminHostUniversityDetailResponse.from(savedHostUniversity);
HostUniversity hostUniversity = new HostUniversity(
null,
request.koreanName(),
request.englishName(),
request.formatName(),
request.homepageUrl(),
request.englishCourseUrl(),
request.accommodationUrl(),
logoImage.fileUrl(),
backgroundImage.fileUrl(),
request.detailsForLocal(),
country,
region
);
HostUniversity savedHostUniversity = hostUniversityRepository.saveAndFlush(hostUniversity);
return AdminHostUniversityDetailResponse.from(savedHostUniversity);
} catch (RuntimeException e) {
deleteUploadedImages(logoImage, backgroundImage);
throw e;
}
}

private void validateKoreanNameNotExists(String koreanName) {
Expand All @@ -103,32 +133,97 @@ private void validateKoreanNameNotExists(String koreanName) {
cacheManager = "customCacheManager",
prefix = true
)
public AdminHostUniversityDetailResponse updateHostUniversity(Long id, AdminHostUniversityUpdateRequest request) {
public AdminHostUniversityDetailResponse updateHostUniversity(
Long id,
AdminHostUniversityUpdateRequest request,
MultipartFile logoFile,
MultipartFile backgroundFile
) {
HostUniversity hostUniversity = hostUniversityRepository.findById(id)
.orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND));

validateKoreanNameNotDuplicated(request.koreanName(), id);

Country country = findCountryByCode(request.countryCode());
Region region = findRegionByCode(request.regionCode());
String directoryName = UploadDirectoryName.fromUniversityNames(request.englishName(), request.koreanName());
UploadedFileUrlResponse logoImage = null;
UploadedFileUrlResponse backgroundImage = null;

hostUniversity.update(
request.koreanName(),
request.englishName(),
request.formatName(),
request.homepageUrl(),
request.englishCourseUrl(),
request.accommodationUrl(),
request.logoImageUrl(),
request.backgroundImageUrl(),
request.detailsForLocal(),
country,
region
);
try {
logoImage = uploadUniversityImageIfExists(
logoFile,
UploadPath.ADMIN_UNIVERSITY_LOGO,
directoryName
);
backgroundImage = uploadUniversityImageIfExists(
backgroundFile,
UploadPath.ADMIN_UNIVERSITY_BACKGROUND,
directoryName
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이름 변경만 하고 이미지를 안 올리면 URL 경로와 새 이름 기반 디렉터리가 불일치할 수 있을 것 같습니다!
수정 시 이미지가 없으면 기존 URL을 유지합니다. 기능적으로는 자연스럽지만, 이번 PR 제목처럼 “경로 식별자 개선”이 목적이면 운영 데이터에서 같은 대학의 이름과 이미지 경로 식별자가 엇갈릴 수 있을 것으로 보이네요.. 확인 부탁드립니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다. 현재 경로 식별자는 이미지 업로드 시점의 저장 경로를 안정적으로 분리하기 위한 값이고 대학명 변경 시 기존 이미지를 자동 이동하지는 않습니다. 이름만 수정하는 경우 기존 이미지 URL을 유지하는 것이 의도된 동작입니다. S3 객체 rename은 실제로 copy+delete 작업이고 DB 커밋 이후 처리해야 하므로 수정 API에 암묵적으로 포함하면 실패 지점이 커질 수 있습니다. 이미지 경로를 새 이름 기준으로 맞추려면 새 이미지를 함께 업로드하도록 운영 정책을 두거나 별도 마이그레이션/정리 작업으로 다루는 편이 안전하다고 봅니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다! 그럼 단순 이름 변경에 대해서는 해당 API를 요청하지 않도록 방어로직을 짤 필요는 없는 걸까요? 정책으로 다룬다면 해당 부분에 대해서 명확히 FE 쪽과 협의가 필요해 보이긴 합니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀하신 것처럼 FE/운영 정책은 명확히 맞춰야 할 것 같습니다. FE에서는 이름만 변경하는 경우 이미지 파일 없이 수정 API를 호출해도 되고 이미지까지 교체하려는 경우에만 새 파일을 함께 전달하는 방향으로 협의하겠습니다. PR 특이사항에도 이 정책을 명시하겠습니다.


evictUnivApplyInfoDetailCaches(id);
hostUniversity.update(
request.koreanName(),
request.englishName(),
request.formatName(),
request.homepageUrl(),
request.englishCourseUrl(),
request.accommodationUrl(),
getImageUrlOrDefault(logoImage, hostUniversity.getLogoImageUrl()),
getImageUrlOrDefault(backgroundImage, hostUniversity.getBackgroundImageUrl()),
request.detailsForLocal(),
country,
region
);
hostUniversityRepository.flush();
evictUnivApplyInfoDetailCaches(id);
return AdminHostUniversityDetailResponse.from(hostUniversity);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미지 교체 성공 시 기존 S3 객체는 삭제하지 않는 걸로 보입니다!
실패 보상 삭제는 잘 들어갔지만, 수정 API에서 새 로고/배경 업로드가 성공하면 DB URL만 교체하고 이전 이미지는 남습니다. 기존 정책이 “고아 파일 허용”이면 괜찮지만, 비용/정리 관점에서는 누수입니다.
이 부분 확인 부탁드립니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다. 기존 이미지 교체 성공 후 old S3 객체 삭제는 현재 대학 이미지에는 적용되어 있지 않습니다.

다만 기존 로직을 확인해보니 프로필, 뉴스, 게시글 쪽은 old image 삭제를 수행하고 있지만, 모두 DB 트랜잭션 커밋 전 삭제하고 있습니다. 이 경우 DB 업데이트가 rollback되면 DB는 기존 URL을 유지하는데 S3 객체는 이미 삭제되는 문제가 생길 수 있습니다.

따라서 이 부분은 대학 이미지에만 즉시 추가하기보다, S3 객체 삭제 정책을 공통으로 정리하는 별도 PR에서 처리하는 것이 맞다고 판단했습니다. 후속 작업에서는 기존 이미지 삭제를 AFTER_COMMIT 기준으로 수행하도록 프로필/뉴스/게시글/대학 이미지 교체 로직을 함께 정리하겠습니다.

} catch (RuntimeException e) {
deleteUploadedImages(logoImage, backgroundImage);
throw e;
}
}

return AdminHostUniversityDetailResponse.from(hostUniversity);
private UploadedFileUrlResponse uploadUniversityImage(
MultipartFile imageFile,
UploadPath uploadPath,
String directoryName
) {
return s3Service.uploadFile(imageFile, uploadPath, directoryName);
}

private UploadedFileUrlResponse uploadUniversityImageIfExists(
MultipartFile imageFile,
UploadPath uploadPath,
String directoryName
) {
if (imageFile == null || imageFile.isEmpty()) {
return null;
}
return uploadUniversityImage(imageFile, uploadPath, directoryName);
}

private String getImageUrlOrDefault(UploadedFileUrlResponse uploadedImage, String defaultImageUrl) {
if (uploadedImage == null) {
return defaultImageUrl;
}
return uploadedImage.fileUrl();
}

private void deleteUploadedImages(UploadedFileUrlResponse... uploadedImages) {
for (UploadedFileUrlResponse uploadedImage : uploadedImages) {
if (uploadedImage != null) {
try {
s3Service.deleteUploadedFile(uploadedImage);
} catch (RuntimeException deleteException) {
log.warn(
"Failed to delete uploaded university image. fileUrl={}",
uploadedImage.fileUrl(),
deleteException
);
}
}
}
}

private void validateKoreanNameNotDuplicated(String koreanName, Long excludeId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
package com.example.solidconnection.s3.controller;

import com.example.solidconnection.common.resolver.AuthorizedUser;
import com.example.solidconnection.s3.domain.UploadDirectoryName;
import com.example.solidconnection.s3.domain.UploadPath;
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
import com.example.solidconnection.s3.dto.UrlPrefixResponse;
import com.example.solidconnection.s3.service.S3Service;
import com.example.solidconnection.security.annotation.RequireRoleAccess;
import com.example.solidconnection.siteuser.domain.Role;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand Down Expand Up @@ -80,38 +77,6 @@ public ResponseEntity<List<UploadedFileUrlResponse>> uploadChatFile(
return ResponseEntity.ok(chatImageUrls);
}

@RequireRoleAccess(roles = Role.ADMIN)
@PostMapping("/admin/university/logo")
public ResponseEntity<UploadedFileUrlResponse> uploadAdminUniversityLogo(
@AuthorizedUser long adminId,
@RequestParam("file") MultipartFile imageFile,
@RequestParam("englishName") String englishName
) {
String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName);
UploadedFileUrlResponse logoImageUrl = s3Service.uploadFile(
imageFile,
UploadPath.ADMIN_UNIVERSITY_LOGO,
directoryName
);
return ResponseEntity.ok(logoImageUrl);
}

@RequireRoleAccess(roles = Role.ADMIN)
@PostMapping("/admin/university/background")
public ResponseEntity<UploadedFileUrlResponse> uploadAdminUniversityBackground(
@AuthorizedUser long adminId,
@RequestParam("file") MultipartFile imageFile,
@RequestParam("englishName") String englishName
) {
String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName);
UploadedFileUrlResponse backgroundImageUrl = s3Service.uploadFile(
imageFile,
UploadPath.ADMIN_UNIVERSITY_BACKGROUND,
directoryName
);
return ResponseEntity.ok(backgroundImageUrl);
}

@GetMapping("/s3-url-prefix")
public ResponseEntity<UrlPrefixResponse> getS3UrlPrefix() {
return ResponseEntity.ok(new UrlPrefixResponse(s3Default, s3Uploaded, cloudFrontDefault, cloudFrontUploaded));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ public final class UploadDirectoryName {
private UploadDirectoryName() {
}

public static String fromUniversityEnglishName(String englishName) {
public static String fromUniversityNames(String englishName, String koreanName) {
if (englishName == null || englishName.isBlank()) {
throw new CustomException(ErrorCode.INVALID_INPUT);
}
if (koreanName == null || koreanName.isBlank()) {
throw new CustomException(ErrorCode.INVALID_INPUT);
}

String directoryName = englishName.trim()
.toLowerCase()
Expand All @@ -30,7 +33,7 @@ public static String fromUniversityEnglishName(String englishName) {
throw new CustomException(ErrorCode.INVALID_INPUT);
}

return directoryName + "_" + hash(englishName.trim());
return directoryName + "_" + hash(koreanName.trim());
}

private static String hash(String value) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package com.example.solidconnection.s3.dto;

import com.fasterxml.jackson.annotation.JsonIgnore;

public record UploadedFileUrlResponse(
String fileUrl) {
String fileUrl,
@JsonIgnore String deletionKey) {

public UploadedFileUrlResponse(String fileUrl) {
this(fileUrl, fileUrl);
}
}
Loading
Loading