Skip to content
Open
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에 암묵적으로 포함하면 실패 지점이 커질 수 있습니다. 이미지 경로를 새 이름 기준으로 맞추려면 새 이미지를 함께 업로드하도록 운영 정책을 두거나 별도 마이그레이션/정리 작업으로 다루는 편이 안전하다고 봅니다.


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만 교체하고 이전 이미지는 남습니다. 기존 정책이 “고아 파일 허용”이면 괜찮지만, 비용/정리 관점에서는 누수입니다.
이 부분 확인 부탁드립니다!

} 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