Skip to content

Commit f14b384

Browse files
authored
refactor: 관리자 대학 이미지 업로드 경로 식별자 개선 (#782)
* feat: 관리자 대학 이미지 업로드를 생성 수정에 통합 - 관리자 대학 생성/수정 API에서 multipart로 대학 정보와 이미지 파일을 함께 받도록 변경 - 이미지 URL을 요청 DTO에서 제거하고 서버에서 S3 업로드 결과를 저장하도록 변경 - 영문명 slug와 한글명 hash를 조합해 중복 영문명 간 S3 경로 충돌을 방지 - 업로드 또는 DB 저장 실패 시 새로 업로드된 이미지가 남지 않도록 보상 삭제 처리 * feat: 관리자 대학 이미지 업로드 테스트 보강 - 대학 생성/수정 시 이미지 업로드 URL 저장과 기존 이미지 유지 동작 검증 - 한글명 기반 S3 경로 식별자와 중복 영문명 충돌 방지 검증 - 업로드 실패 보상 삭제와 삭제용 key 사용 여부 검증
1 parent da6c9ce commit f14b384

11 files changed

Lines changed: 422 additions & 134 deletions

File tree

src/main/java/com/example/solidconnection/admin/university/controller/AdminHostUniversityController.java

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@
1111
import lombok.RequiredArgsConstructor;
1212
import org.springframework.data.domain.Pageable;
1313
import org.springframework.data.web.PageableDefault;
14+
import org.springframework.http.MediaType;
1415
import org.springframework.http.ResponseEntity;
1516
import org.springframework.web.bind.annotation.DeleteMapping;
1617
import org.springframework.web.bind.annotation.GetMapping;
1718
import org.springframework.web.bind.annotation.PathVariable;
1819
import org.springframework.web.bind.annotation.PostMapping;
1920
import org.springframework.web.bind.annotation.PutMapping;
20-
import org.springframework.web.bind.annotation.RequestBody;
2121
import org.springframework.web.bind.annotation.RequestMapping;
22+
import org.springframework.web.bind.annotation.RequestPart;
2223
import org.springframework.web.bind.annotation.RestController;
24+
import org.springframework.web.multipart.MultipartFile;
2325

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

47-
@PostMapping
49+
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
4850
public ResponseEntity<AdminHostUniversityDetailResponse> createHostUniversity(
49-
@Valid @RequestBody AdminHostUniversityCreateRequest request
51+
@Valid @RequestPart("request") AdminHostUniversityCreateRequest request,
52+
@RequestPart("logoFile") MultipartFile logoFile,
53+
@RequestPart("backgroundFile") MultipartFile backgroundFile
5054
) {
51-
AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity(request);
55+
AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity(
56+
request,
57+
logoFile,
58+
backgroundFile
59+
);
5260
return ResponseEntity.ok(response);
5361
}
5462

55-
@PutMapping("/{host-university-id}")
63+
@PutMapping(value = "/{host-university-id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
5664
public ResponseEntity<AdminHostUniversityDetailResponse> updateHostUniversity(
5765
@PathVariable("host-university-id") Long hostUniversityId,
58-
@Valid @RequestBody AdminHostUniversityUpdateRequest request
66+
@Valid @RequestPart("request") AdminHostUniversityUpdateRequest request,
67+
@RequestPart(value = "logoFile", required = false) MultipartFile logoFile,
68+
@RequestPart(value = "backgroundFile", required = false) MultipartFile backgroundFile
5969
) {
60-
AdminHostUniversityDetailResponse response = adminHostUniversityService.updateHostUniversity(hostUniversityId, request);
70+
AdminHostUniversityDetailResponse response = adminHostUniversityService.updateHostUniversity(
71+
hostUniversityId,
72+
request,
73+
logoFile,
74+
backgroundFile
75+
);
6176
return ResponseEntity.ok(response);
6277
}
6378

src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityCreateRequest.java

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,6 @@ public record AdminHostUniversityCreateRequest(
2525
@Size(max = 500, message = "숙소 URL은 500자 이하여야 합니다")
2626
String accommodationUrl,
2727

28-
@NotBlank(message = "로고 이미지 URL은 필수입니다")
29-
@Size(max = 500, message = "로고 이미지 URL은 500자 이하여야 합니다")
30-
String logoImageUrl,
31-
32-
@NotBlank(message = "배경 이미지 URL은 필수입니다")
33-
@Size(max = 500, message = "배경 이미지 URL은 500자 이하여야 합니다")
34-
String backgroundImageUrl,
35-
3628
@Size(max = 1000, message = "상세 정보는 1000자 이하여야 합니다")
3729
String detailsForLocal,
3830

src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityUpdateRequest.java

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,6 @@ public record AdminHostUniversityUpdateRequest(
2525
@Size(max = 500, message = "숙소 URL은 500자 이하여야 합니다")
2626
String accommodationUrl,
2727

28-
@NotBlank(message = "로고 이미지 URL은 필수입니다")
29-
@Size(max = 500, message = "로고 이미지 URL은 500자 이하여야 합니다")
30-
String logoImageUrl,
31-
32-
@NotBlank(message = "배경 이미지 URL은 필수입니다")
33-
@Size(max = 500, message = "배경 이미지 URL은 500자 이하여야 합니다")
34-
String backgroundImageUrl,
35-
3628
@Size(max = 1000, message = "상세 정보는 1000자 이하여야 합니다")
3729
String detailsForLocal,
3830

src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java

Lines changed: 128 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,33 @@
1818
import com.example.solidconnection.location.country.repository.CountryRepository;
1919
import com.example.solidconnection.location.region.domain.Region;
2020
import com.example.solidconnection.location.region.repository.RegionRepository;
21+
import com.example.solidconnection.s3.domain.UploadDirectoryName;
22+
import com.example.solidconnection.s3.domain.UploadPath;
23+
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
24+
import com.example.solidconnection.s3.service.S3Service;
2125
import com.example.solidconnection.university.domain.HostUniversity;
2226
import com.example.solidconnection.university.repository.HostUniversityRepository;
2327
import com.example.solidconnection.university.repository.UnivApplyInfoRepository;
2428
import java.util.List;
2529
import lombok.RequiredArgsConstructor;
30+
import lombok.extern.slf4j.Slf4j;
2631
import org.springframework.data.domain.Page;
2732
import org.springframework.data.domain.Pageable;
2833
import org.springframework.stereotype.Service;
2934
import org.springframework.transaction.annotation.Transactional;
35+
import org.springframework.web.multipart.MultipartFile;
3036

3137
@Service
3238
@RequiredArgsConstructor
39+
@Slf4j
3340
public class AdminHostUniversityService {
3441

3542
private final HostUniversityRepository hostUniversityRepository;
3643
private final CountryRepository countryRepository;
3744
private final RegionRepository regionRepository;
3845
private final UnivApplyInfoRepository univApplyInfoRepository;
3946
private final CustomCacheManager cacheManager;
47+
private final S3Service s3Service;
4048

4149
@Transactional(readOnly = true)
4250
public Page<AdminHostUniversityResponse> getHostUniversities(
@@ -65,29 +73,51 @@ public AdminHostUniversityDetailResponse getHostUniversity(Long id) {
6573
cacheManager = "customCacheManager",
6674
prefix = true
6775
)
68-
public AdminHostUniversityDetailResponse createHostUniversity(AdminHostUniversityCreateRequest request) {
76+
public AdminHostUniversityDetailResponse createHostUniversity(
77+
AdminHostUniversityCreateRequest request,
78+
MultipartFile logoFile,
79+
MultipartFile backgroundFile
80+
) {
6981
validateKoreanNameNotExists(request.koreanName());
7082

7183
Country country = findCountryByCode(request.countryCode());
7284
Region region = findRegionByCode(request.regionCode());
85+
String directoryName = UploadDirectoryName.fromUniversityNames(request.englishName(), request.koreanName());
86+
UploadedFileUrlResponse logoImage = null;
87+
UploadedFileUrlResponse backgroundImage = null;
7388

74-
HostUniversity hostUniversity = new HostUniversity(
75-
null,
76-
request.koreanName(),
77-
request.englishName(),
78-
request.formatName(),
79-
request.homepageUrl(),
80-
request.englishCourseUrl(),
81-
request.accommodationUrl(),
82-
request.logoImageUrl(),
83-
request.backgroundImageUrl(),
84-
request.detailsForLocal(),
85-
country,
86-
region
87-
);
89+
try {
90+
logoImage = uploadUniversityImage(
91+
logoFile,
92+
UploadPath.ADMIN_UNIVERSITY_LOGO,
93+
directoryName
94+
);
95+
backgroundImage = uploadUniversityImage(
96+
backgroundFile,
97+
UploadPath.ADMIN_UNIVERSITY_BACKGROUND,
98+
directoryName
99+
);
88100

89-
HostUniversity savedHostUniversity = hostUniversityRepository.save(hostUniversity);
90-
return AdminHostUniversityDetailResponse.from(savedHostUniversity);
101+
HostUniversity hostUniversity = new HostUniversity(
102+
null,
103+
request.koreanName(),
104+
request.englishName(),
105+
request.formatName(),
106+
request.homepageUrl(),
107+
request.englishCourseUrl(),
108+
request.accommodationUrl(),
109+
logoImage.fileUrl(),
110+
backgroundImage.fileUrl(),
111+
request.detailsForLocal(),
112+
country,
113+
region
114+
);
115+
HostUniversity savedHostUniversity = hostUniversityRepository.saveAndFlush(hostUniversity);
116+
return AdminHostUniversityDetailResponse.from(savedHostUniversity);
117+
} catch (RuntimeException e) {
118+
deleteUploadedImages(logoImage, backgroundImage);
119+
throw e;
120+
}
91121
}
92122

93123
private void validateKoreanNameNotExists(String koreanName) {
@@ -103,32 +133,97 @@ private void validateKoreanNameNotExists(String koreanName) {
103133
cacheManager = "customCacheManager",
104134
prefix = true
105135
)
106-
public AdminHostUniversityDetailResponse updateHostUniversity(Long id, AdminHostUniversityUpdateRequest request) {
136+
public AdminHostUniversityDetailResponse updateHostUniversity(
137+
Long id,
138+
AdminHostUniversityUpdateRequest request,
139+
MultipartFile logoFile,
140+
MultipartFile backgroundFile
141+
) {
107142
HostUniversity hostUniversity = hostUniversityRepository.findById(id)
108143
.orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND));
109144

110145
validateKoreanNameNotDuplicated(request.koreanName(), id);
111146

112147
Country country = findCountryByCode(request.countryCode());
113148
Region region = findRegionByCode(request.regionCode());
149+
String directoryName = UploadDirectoryName.fromUniversityNames(request.englishName(), request.koreanName());
150+
UploadedFileUrlResponse logoImage = null;
151+
UploadedFileUrlResponse backgroundImage = null;
114152

115-
hostUniversity.update(
116-
request.koreanName(),
117-
request.englishName(),
118-
request.formatName(),
119-
request.homepageUrl(),
120-
request.englishCourseUrl(),
121-
request.accommodationUrl(),
122-
request.logoImageUrl(),
123-
request.backgroundImageUrl(),
124-
request.detailsForLocal(),
125-
country,
126-
region
127-
);
153+
try {
154+
logoImage = uploadUniversityImageIfExists(
155+
logoFile,
156+
UploadPath.ADMIN_UNIVERSITY_LOGO,
157+
directoryName
158+
);
159+
backgroundImage = uploadUniversityImageIfExists(
160+
backgroundFile,
161+
UploadPath.ADMIN_UNIVERSITY_BACKGROUND,
162+
directoryName
163+
);
128164

129-
evictUnivApplyInfoDetailCaches(id);
165+
hostUniversity.update(
166+
request.koreanName(),
167+
request.englishName(),
168+
request.formatName(),
169+
request.homepageUrl(),
170+
request.englishCourseUrl(),
171+
request.accommodationUrl(),
172+
getImageUrlOrDefault(logoImage, hostUniversity.getLogoImageUrl()),
173+
getImageUrlOrDefault(backgroundImage, hostUniversity.getBackgroundImageUrl()),
174+
request.detailsForLocal(),
175+
country,
176+
region
177+
);
178+
hostUniversityRepository.flush();
179+
evictUnivApplyInfoDetailCaches(id);
180+
return AdminHostUniversityDetailResponse.from(hostUniversity);
181+
} catch (RuntimeException e) {
182+
deleteUploadedImages(logoImage, backgroundImage);
183+
throw e;
184+
}
185+
}
130186

131-
return AdminHostUniversityDetailResponse.from(hostUniversity);
187+
private UploadedFileUrlResponse uploadUniversityImage(
188+
MultipartFile imageFile,
189+
UploadPath uploadPath,
190+
String directoryName
191+
) {
192+
return s3Service.uploadFile(imageFile, uploadPath, directoryName);
193+
}
194+
195+
private UploadedFileUrlResponse uploadUniversityImageIfExists(
196+
MultipartFile imageFile,
197+
UploadPath uploadPath,
198+
String directoryName
199+
) {
200+
if (imageFile == null || imageFile.isEmpty()) {
201+
return null;
202+
}
203+
return uploadUniversityImage(imageFile, uploadPath, directoryName);
204+
}
205+
206+
private String getImageUrlOrDefault(UploadedFileUrlResponse uploadedImage, String defaultImageUrl) {
207+
if (uploadedImage == null) {
208+
return defaultImageUrl;
209+
}
210+
return uploadedImage.fileUrl();
211+
}
212+
213+
private void deleteUploadedImages(UploadedFileUrlResponse... uploadedImages) {
214+
for (UploadedFileUrlResponse uploadedImage : uploadedImages) {
215+
if (uploadedImage != null) {
216+
try {
217+
s3Service.deleteUploadedFile(uploadedImage);
218+
} catch (RuntimeException deleteException) {
219+
log.warn(
220+
"Failed to delete uploaded university image. fileUrl={}",
221+
uploadedImage.fileUrl(),
222+
deleteException
223+
);
224+
}
225+
}
226+
}
132227
}
133228

134229
private void validateKoreanNameNotDuplicated(String koreanName, Long excludeId) {

src/main/java/com/example/solidconnection/s3/controller/S3Controller.java

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
package com.example.solidconnection.s3.controller;
22

33
import com.example.solidconnection.common.resolver.AuthorizedUser;
4-
import com.example.solidconnection.s3.domain.UploadDirectoryName;
54
import com.example.solidconnection.s3.domain.UploadPath;
65
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
76
import com.example.solidconnection.s3.dto.UrlPrefixResponse;
87
import com.example.solidconnection.s3.service.S3Service;
9-
import com.example.solidconnection.security.annotation.RequireRoleAccess;
10-
import com.example.solidconnection.siteuser.domain.Role;
118
import java.util.List;
129
import lombok.RequiredArgsConstructor;
1310
import org.springframework.beans.factory.annotation.Value;
@@ -80,38 +77,6 @@ public ResponseEntity<List<UploadedFileUrlResponse>> uploadChatFile(
8077
return ResponseEntity.ok(chatImageUrls);
8178
}
8279

83-
@RequireRoleAccess(roles = Role.ADMIN)
84-
@PostMapping("/admin/university/logo")
85-
public ResponseEntity<UploadedFileUrlResponse> uploadAdminUniversityLogo(
86-
@AuthorizedUser long adminId,
87-
@RequestParam("file") MultipartFile imageFile,
88-
@RequestParam("englishName") String englishName
89-
) {
90-
String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName);
91-
UploadedFileUrlResponse logoImageUrl = s3Service.uploadFile(
92-
imageFile,
93-
UploadPath.ADMIN_UNIVERSITY_LOGO,
94-
directoryName
95-
);
96-
return ResponseEntity.ok(logoImageUrl);
97-
}
98-
99-
@RequireRoleAccess(roles = Role.ADMIN)
100-
@PostMapping("/admin/university/background")
101-
public ResponseEntity<UploadedFileUrlResponse> uploadAdminUniversityBackground(
102-
@AuthorizedUser long adminId,
103-
@RequestParam("file") MultipartFile imageFile,
104-
@RequestParam("englishName") String englishName
105-
) {
106-
String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName);
107-
UploadedFileUrlResponse backgroundImageUrl = s3Service.uploadFile(
108-
imageFile,
109-
UploadPath.ADMIN_UNIVERSITY_BACKGROUND,
110-
directoryName
111-
);
112-
return ResponseEntity.ok(backgroundImageUrl);
113-
}
114-
11580
@GetMapping("/s3-url-prefix")
11681
public ResponseEntity<UrlPrefixResponse> getS3UrlPrefix() {
11782
return ResponseEntity.ok(new UrlPrefixResponse(s3Default, s3Uploaded, cloudFrontDefault, cloudFrontUploaded));

src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ public final class UploadDirectoryName {
1414
private UploadDirectoryName() {
1515
}
1616

17-
public static String fromUniversityEnglishName(String englishName) {
17+
public static String fromUniversityNames(String englishName, String koreanName) {
1818
if (englishName == null || englishName.isBlank()) {
1919
throw new CustomException(ErrorCode.INVALID_INPUT);
2020
}
21+
if (koreanName == null || koreanName.isBlank()) {
22+
throw new CustomException(ErrorCode.INVALID_INPUT);
23+
}
2124

2225
String directoryName = englishName.trim()
2326
.toLowerCase()
@@ -30,7 +33,7 @@ public static String fromUniversityEnglishName(String englishName) {
3033
throw new CustomException(ErrorCode.INVALID_INPUT);
3134
}
3235

33-
return directoryName + "_" + hash(englishName.trim());
36+
return directoryName + "_" + hash(koreanName.trim());
3437
}
3538

3639
private static String hash(String value) {
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
package com.example.solidconnection.s3.dto;
22

3+
import com.fasterxml.jackson.annotation.JsonIgnore;
4+
35
public record UploadedFileUrlResponse(
4-
String fileUrl) {
6+
String fileUrl,
7+
@JsonIgnore String deletionKey) {
58

9+
public UploadedFileUrlResponse(String fileUrl) {
10+
this(fileUrl, fileUrl);
11+
}
612
}

0 commit comments

Comments
 (0)