From 102d1f7dc86973736602785e1dc6c4a657c474e2 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 29 Oct 2026 00:29:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[FEAT]=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EB=8B=A8=EC=B2=B4=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=9B=90=EB=B3=B8=C2=B7ZIP=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 어드민 비밀번호 검증을 인터셉터로 옮겨 파라미터 바인딩 전에 401을 반환한다 - 필수 요청 값 누락·읽을 수 없는 요청 본문을 500 대신 400으로 응답한다 - 아이콘 전체를 원본 그대로 ZIP으로 내려받는 API를 추가한다 - 어드민 단체 목록에 아이콘 원본 다운로드와 전체 ZIP 다운로드 버튼을 추가한다 Co-Authored-By: Claude Opus 5.5 --- .../debatetimer/client/storage/IconFile.java | 4 ++ .../client/storage/IconFileStorage.java | 31 ++++++++- .../com/debatetimer/config/WebConfig.java | 9 +++ .../admin/AdminAuthInterceptor.java | 31 +++++++++ .../controller/admin/AdminIconController.java | 23 ++++--- .../admin/AdminOrganizationController.java | 11 +--- .../admin/AdminSharingController.java | 6 -- .../admin/AdminStatsController.java | 6 -- .../admin/AdminTableController.java | 10 +-- .../OrganizationIconDomainRepository.java | 5 ++ .../exception/errorcode/ClientErrorCode.java | 2 + .../handler/GlobalExceptionHandler.java | 22 +++++++ .../service/admin/AdminIconService.java | 24 +++++++ src/main/resources/static/admin.html | 64 +++++++++++++++++-- .../admin/AdminIconControllerTest.java | 58 +++++++++++++++-- .../AdminOrganizationControllerTest.java | 12 ++-- .../admin/AdminSharingControllerTest.java | 6 +- .../admin/AdminStatsControllerTest.java | 37 +++++++++-- .../admin/AdminTableControllerTest.java | 8 +-- 19 files changed, 300 insertions(+), 69 deletions(-) create mode 100644 src/main/java/com/debatetimer/client/storage/IconFile.java create mode 100644 src/main/java/com/debatetimer/controller/admin/AdminAuthInterceptor.java diff --git a/src/main/java/com/debatetimer/client/storage/IconFile.java b/src/main/java/com/debatetimer/client/storage/IconFile.java new file mode 100644 index 00000000..c42a0a20 --- /dev/null +++ b/src/main/java/com/debatetimer/client/storage/IconFile.java @@ -0,0 +1,4 @@ +package com.debatetimer.client.storage; + +public record IconFile(String fileName, byte[] content) { +} diff --git a/src/main/java/com/debatetimer/client/storage/IconFileStorage.java b/src/main/java/com/debatetimer/client/storage/IconFileStorage.java index 06837e58..2314a09f 100644 --- a/src/main/java/com/debatetimer/client/storage/IconFileStorage.java +++ b/src/main/java/com/debatetimer/client/storage/IconFileStorage.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Objects; import java.util.stream.Stream; +import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.stereotype.Component; @@ -22,7 +23,8 @@ @Component public class IconFileStorage { - private static final String BUNDLED_ICON_PATTERN = "classpath:/static/icon/*"; + private static final String BUNDLED_ICON_LOCATION = "static/icon/"; + private static final String BUNDLED_ICON_PATTERN = "classpath:/" + BUNDLED_ICON_LOCATION + "*"; private final Path uploadDir; private final List bundledFileNames; @@ -86,4 +88,31 @@ public boolean saveIfAbsent(String fileName, byte[] content) { throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR); } } + + /** + * 기본 아이콘과 업로드한 아이콘을 원본 그대로 모두 읽는다. + */ + public List findAll() { + return Stream.concat( + bundledFileNames.stream().map(this::readBundled), + findUploadedFileNames().stream().map(this::readUploaded) + ).toList(); + } + + private IconFile readBundled(String fileName) { + try { + byte[] content = new ClassPathResource(BUNDLED_ICON_LOCATION + fileName).getContentAsByteArray(); + return new IconFile(fileName, content); + } catch (IOException exception) { + throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR); + } + } + + private IconFile readUploaded(String fileName) { + try { + return new IconFile(fileName, Files.readAllBytes(uploadDir.resolve(fileName))); + } catch (IOException exception) { + throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR); + } + } } diff --git a/src/main/java/com/debatetimer/config/WebConfig.java b/src/main/java/com/debatetimer/config/WebConfig.java index 7cbec365..cbcdb620 100644 --- a/src/main/java/com/debatetimer/config/WebConfig.java +++ b/src/main/java/com/debatetimer/config/WebConfig.java @@ -1,6 +1,7 @@ package com.debatetimer.config; import com.debatetimer.client.storage.IconStorageProperties; +import com.debatetimer.controller.admin.AdminAuthInterceptor; import com.debatetimer.controller.tool.jwt.AuthManager; import com.debatetimer.domain.organization.OrganizationIcon; import com.debatetimer.service.auth.AuthService; @@ -8,6 +9,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -18,12 +20,19 @@ public class WebConfig implements WebMvcConfigurer { private final AuthManager authManager; private final AuthService authService; private final IconStorageProperties iconStorageProperties; + private final AdminAuthInterceptor adminAuthInterceptor; @Override public void addArgumentResolvers(List argumentResolvers) { argumentResolvers.add(new AuthMemberArgumentResolver(authManager, authService)); } + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns(AdminAuthInterceptor.ADMIN_API_PATTERN); + } + // 업로드한 아이콘을 먼저 찾고, 없으면 배포에 포함된 기본 아이콘을 찾는다 @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { diff --git a/src/main/java/com/debatetimer/controller/admin/AdminAuthInterceptor.java b/src/main/java/com/debatetimer/controller/admin/AdminAuthInterceptor.java new file mode 100644 index 00000000..4bd02c4b --- /dev/null +++ b/src/main/java/com/debatetimer/controller/admin/AdminAuthInterceptor.java @@ -0,0 +1,31 @@ +package com.debatetimer.controller.admin; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 어드민 API 의 비밀번호를 요청 파라미터 바인딩보다 먼저 확인한다. + * 비밀번호 없이 필수 파라미터까지 빠진 요청도 400 이 아니라 401 로 거절하기 위함이다. + */ +@Component +@RequiredArgsConstructor +public class AdminAuthInterceptor implements HandlerInterceptor { + + public static final String ADMIN_API_PATTERN = "/api/admin/**"; + public static final String ADMIN_PASSWORD_HEADER = "X-Admin-Password"; + + private final AdminAuthorizer adminAuthorizer; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (CorsUtils.isPreFlightRequest(request)) { + return true; + } + adminAuthorizer.authorize(request.getHeader(ADMIN_PASSWORD_HEADER)); + return true; + } +} diff --git a/src/main/java/com/debatetimer/controller/admin/AdminIconController.java b/src/main/java/com/debatetimer/controller/admin/AdminIconController.java index 6b3003ad..2d39f42e 100644 --- a/src/main/java/com/debatetimer/controller/admin/AdminIconController.java +++ b/src/main/java/com/debatetimer/controller/admin/AdminIconController.java @@ -7,11 +7,13 @@ import com.debatetimer.service.admin.AdminIconService; import java.io.IOException; import lombok.RequiredArgsConstructor; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.ResponseStatus; @@ -22,26 +24,31 @@ @RequiredArgsConstructor public class AdminIconController { - private final AdminAuthorizer adminAuthorizer; + private static final String ICON_ZIP_FILE_NAME = "organization-icons.zip"; + private final AdminIconService adminIconService; @GetMapping("/api/admin/icons") @ResponseStatus(HttpStatus.OK) - public IconResponses getIcons( - @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password - ) { - adminAuthorizer.authorize(password); + public IconResponses getIcons() { return adminIconService.findAll(); } + @GetMapping("/api/admin/icons/download") + public ResponseEntity downloadIcons() { + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/zip")) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(ICON_ZIP_FILE_NAME).build().toString()) + .body(adminIconService.zipAll()); + } + @PostMapping(value = "/api/admin/icons", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) public IconResponse uploadIcon( - @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, @RequestPart("file") MultipartFile file, @RequestParam(name = "name", required = false) String name ) { - adminAuthorizer.authorize(password); String fileName = (name == null || name.isBlank()) ? file.getOriginalFilename() : name; return adminIconService.upload(fileName, readBytes(file)); } diff --git a/src/main/java/com/debatetimer/controller/admin/AdminOrganizationController.java b/src/main/java/com/debatetimer/controller/admin/AdminOrganizationController.java index 74024ac5..41729608 100644 --- a/src/main/java/com/debatetimer/controller/admin/AdminOrganizationController.java +++ b/src/main/java/com/debatetimer/controller/admin/AdminOrganizationController.java @@ -13,7 +13,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -21,36 +20,28 @@ @RequiredArgsConstructor public class AdminOrganizationController { - private final AdminAuthorizer adminAuthorizer; private final AdminOrganizationService adminOrganizationService; @GetMapping("/api/admin/organizations") @ResponseStatus(HttpStatus.OK) - public AdminOrganizationsResponse getOrganizations( - @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password - ) { - adminAuthorizer.authorize(password); + public AdminOrganizationsResponse getOrganizations() { return adminOrganizationService.findAll(); } @PostMapping("/api/admin/organizations") @ResponseStatus(HttpStatus.CREATED) public AdminOrganizationResponse createOrganization( - @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, @Valid @RequestBody OrganizationCreateRequest request ) { - adminAuthorizer.authorize(password); return adminOrganizationService.create(request); } @PostMapping("/api/admin/organizations/{organizationId}/templates") @ResponseStatus(HttpStatus.CREATED) public AdminOrganizationTemplateResponse createTemplate( - @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, @PathVariable long organizationId, @Valid @RequestBody OrganizationTemplateCreateRequest request ) { - adminAuthorizer.authorize(password); return adminOrganizationService.createTemplate(organizationId, request); } } diff --git a/src/main/java/com/debatetimer/controller/admin/AdminSharingController.java b/src/main/java/com/debatetimer/controller/admin/AdminSharingController.java index d83aa29e..e5dd62df 100644 --- a/src/main/java/com/debatetimer/controller/admin/AdminSharingController.java +++ b/src/main/java/com/debatetimer/controller/admin/AdminSharingController.java @@ -7,7 +7,6 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -16,19 +15,14 @@ @RequiredArgsConstructor public class AdminSharingController { - public static final String ADMIN_PASSWORD_HEADER = "X-Admin-Password"; - - private final AdminAuthorizer adminAuthorizer; private final SharingStatsService sharingStatsService; @GetMapping("/api/admin/sharing/stats") @ResponseStatus(HttpStatus.OK) public SharingStatsResponse getSharingStats( - @RequestHeader(name = ADMIN_PASSWORD_HEADER, required = false) String password, @RequestParam("from") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam("to") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to ) { - adminAuthorizer.authorize(password); return sharingStatsService.getDailyStats(from, to); } } diff --git a/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java b/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java index 56fd3761..b8927825 100644 --- a/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java +++ b/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java @@ -10,7 +10,6 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -19,31 +18,26 @@ @RequiredArgsConstructor public class AdminStatsController { - private final AdminAuthorizer adminAuthorizer; private final TableUsageStatsService tableUsageStatsService; private final MemberSignupStatsService memberSignupStatsService; @GetMapping("/api/admin/tables/stats") @ResponseStatus(HttpStatus.OK) public TableUsageStatsResponse getTableUsageStats( - @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, @RequestParam("unit") StatUnit unit, @RequestParam("from") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam("to") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to ) { - adminAuthorizer.authorize(password); return tableUsageStatsService.getStats(unit, from, to); } @GetMapping("/api/admin/members/stats") @ResponseStatus(HttpStatus.OK) public MemberSignupStatsResponse getMemberSignupStats( - @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, @RequestParam("unit") StatUnit unit, @RequestParam("from") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam("to") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to ) { - adminAuthorizer.authorize(password); return memberSignupStatsService.getStats(unit, from, to); } } diff --git a/src/main/java/com/debatetimer/controller/admin/AdminTableController.java b/src/main/java/com/debatetimer/controller/admin/AdminTableController.java index 7322b665..a6817f1c 100644 --- a/src/main/java/com/debatetimer/controller/admin/AdminTableController.java +++ b/src/main/java/com/debatetimer/controller/admin/AdminTableController.java @@ -8,7 +8,6 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -17,28 +16,21 @@ @RequiredArgsConstructor public class AdminTableController { - private final AdminAuthorizer adminAuthorizer; private final AdminTableService adminTableService; @GetMapping("/api/admin/tables") @ResponseStatus(HttpStatus.OK) public AdminTablePageResponse getTables( - @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, @RequestParam(name = "sort", defaultValue = "CREATED_AT") CustomizeTableSortType sort, @RequestParam(name = "page", defaultValue = "0") int page, @RequestParam(name = "size", defaultValue = "20") int size ) { - adminAuthorizer.authorize(password); return adminTableService.getTables(sort, page, size); } @GetMapping("/api/admin/tables/{tableId}") @ResponseStatus(HttpStatus.OK) - public AdminTableDetailResponse getTable( - @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, - @PathVariable long tableId - ) { - adminAuthorizer.authorize(password); + public AdminTableDetailResponse getTable(@PathVariable long tableId) { return adminTableService.getTable(tableId); } } diff --git a/src/main/java/com/debatetimer/domainrepository/organization/OrganizationIconDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationIconDomainRepository.java index a2c48260..cc08c03b 100644 --- a/src/main/java/com/debatetimer/domainrepository/organization/OrganizationIconDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationIconDomainRepository.java @@ -1,5 +1,6 @@ package com.debatetimer.domainrepository.organization; +import com.debatetimer.client.storage.IconFile; import com.debatetimer.client.storage.IconFileStorage; import com.debatetimer.domain.organization.OrganizationIcon; import com.debatetimer.exception.custom.DTClientErrorException; @@ -22,6 +23,10 @@ public List findUploadedPaths() { return toPaths(iconFileStorage.findUploadedFileNames()); } + public List findAllFiles() { + return iconFileStorage.findAll(); + } + private List toPaths(List fileNames) { return fileNames.stream() .map(fileName -> OrganizationIcon.PATH_PREFIX + fileName) diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index faca720e..3c30567a 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -84,6 +84,8 @@ public enum ClientErrorCode implements ResponseErrorCode { FIELD_ERROR(HttpStatus.BAD_REQUEST, "입력이 잘못되었습니다."), URL_PARAMETER_ERROR(HttpStatus.BAD_REQUEST, "입력이 잘못되었습니다."), METHOD_ARGUMENT_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "입력한 값의 타입이 잘못되었습니다."), + MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "필수 요청 값이 누락되었습니다."), + MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST, "요청 본문을 읽을 수 없습니다."), NO_RESOURCE_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), METHOD_NOT_SUPPORTED(HttpStatus.METHOD_NOT_ALLOWED, "허용되지 않은 메서드입니다."), MEDIA_TYPE_NOT_SUPPORTED(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "허용되지 않은 미디어 타입입니다."), diff --git a/src/main/java/com/debatetimer/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/debatetimer/exception/handler/GlobalExceptionHandler.java index 7b46a750..c5b67408 100644 --- a/src/main/java/com/debatetimer/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/debatetimer/exception/handler/GlobalExceptionHandler.java @@ -15,16 +15,20 @@ import org.apache.catalina.connector.ClientAbortException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.validation.BindException; import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MissingRequestCookieException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.multipart.MultipartException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @@ -53,6 +57,24 @@ public ResponseEntity handleMethodArgumentTypeMismatchException( return toResponse(ClientErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH); } + @ExceptionHandler({ + MissingServletRequestParameterException.class, + MissingRequestHeaderException.class, + MissingServletRequestPartException.class + }) + public ResponseEntity handleMissingRequestValueException(Exception exception) { + logClientError(exception); + return toResponse(ClientErrorCode.MISSING_REQUEST_PARAMETER); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException exception + ) { + logClientError(exception); + return toResponse(ClientErrorCode.MESSAGE_NOT_READABLE); + } + @ExceptionHandler(ClientAbortException.class) public ResponseEntity handleClientAbortException(ClientAbortException exception) { logClientError(exception); diff --git a/src/main/java/com/debatetimer/service/admin/AdminIconService.java b/src/main/java/com/debatetimer/service/admin/AdminIconService.java index bb5be337..1769c939 100644 --- a/src/main/java/com/debatetimer/service/admin/AdminIconService.java +++ b/src/main/java/com/debatetimer/service/admin/AdminIconService.java @@ -1,10 +1,17 @@ package com.debatetimer.service.admin; +import com.debatetimer.client.storage.IconFile; import com.debatetimer.domain.organization.OrganizationIcon; import com.debatetimer.domainrepository.organization.OrganizationIconDomainRepository; import com.debatetimer.dto.admin.IconResponse; import com.debatetimer.dto.admin.IconResponses; +import com.debatetimer.exception.custom.DTServerErrorException; +import com.debatetimer.exception.errorcode.ServerErrorCode; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -27,4 +34,21 @@ public IconResponse upload(String name, byte[] content) { String path = organizationIconDomainRepository.save(new OrganizationIcon(name, content)); return new IconResponse(path, true); } + + /** + * 모든 아이콘을 원본 그대로 하나의 ZIP 으로 묶는다. + */ + public byte[] zipAll() { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + for (IconFile file : organizationIconDomainRepository.findAllFiles()) { + zip.putNextEntry(new ZipEntry(file.fileName())); + zip.write(file.content()); + zip.closeEntry(); + } + } catch (IOException exception) { + throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR); + } + return bytes.toByteArray(); + } } diff --git a/src/main/resources/static/admin.html b/src/main/resources/static/admin.html index c65581c6..9fdbb5df 100644 --- a/src/main/resources/static/admin.html +++ b/src/main/resources/static/admin.html @@ -101,6 +101,8 @@ .error { color: var(--error); font-size: 13px; min-height: 18px; margin-top: 8px; } .success { color: var(--success); } .hint { color: var(--muted); font-size: 12px; margin-top: 6px; } + .panel-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; } + .panel-head h2 { margin: 0; } .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; } .stat { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 12px; } .stat .label { font-size: 12px; color: var(--muted); } @@ -307,11 +309,14 @@

전체 테이블