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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ out/
### application-local.yml
/src/main/resources/application-local.yml
.serena

### Uploaded icons (local) ###
uploads/
8 changes: 8 additions & 0 deletions docker/application/docker-compose.application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ services:
- MANAGEMENT_STATSD_METRICS_EXPORT_PORT=8125
- MANAGEMENT_STATSD_METRICS_EXPORT_PROTOCOL=UDP

# 어드민에서 업로드한 단체 아이콘을 배포 간에 유지한다
volumes:
- icon-upload:/workspace/icon-upload

networks:
- debate-timer-net

Expand Down Expand Up @@ -52,3 +56,7 @@ services:
- "com.datadoghq.ad.logs=[{\"source\": \"java\", \"service\": \"debate-timer\"}]"

restart: unless-stopped

volumes:
icon-upload:
name: debate-timer-icon-upload
89 changes: 89 additions & 0 deletions src/main/java/com/debatetimer/client/storage/IconFileStorage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.debatetimer.client.storage;

import com.debatetimer.exception.custom.DTServerErrorException;
import com.debatetimer.exception.errorcode.ServerErrorCode;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;

/**
* 아이콘 파일을 다룬다. 배포에 포함된 기본 아이콘(classpath:/static/icon)은 읽기만 하고,
* 업로드한 아이콘은 jar 밖의 업로드 디렉터리에 저장한다. 두 위치 모두 /icon/** 로 제공된다.
*/
@Component
public class IconFileStorage {

private static final String BUNDLED_ICON_PATTERN = "classpath:/static/icon/*";

private final Path uploadDir;
private final List<String> bundledFileNames;

public IconFileStorage(IconStorageProperties properties) {
this.uploadDir = properties.getUploadDir();
this.bundledFileNames = loadBundledFileNames();
createUploadDir();
}

private List<String> loadBundledFileNames() {
try {
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(BUNDLED_ICON_PATTERN);
return Arrays.stream(resources)
.map(Resource::getFilename)
.filter(Objects::nonNull)
.sorted()
.toList();
} catch (IOException exception) {
return List.of();
}
}

private void createUploadDir() {
try {
Files.createDirectories(uploadDir);
} catch (IOException exception) {
throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR);
}
}

public List<String> findBundledFileNames() {
return bundledFileNames;
}

public List<String> findUploadedFileNames() {
try (Stream<Path> files = Files.list(uploadDir)) {
return files.filter(Files::isRegularFile)
.map(path -> path.getFileName().toString())
.sorted()
.toList();
} catch (IOException exception) {
throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR);
}
}

public boolean exists(String fileName) {
return bundledFileNames.contains(fileName) || Files.exists(uploadDir.resolve(fileName));
}

/**
* 같은 이름의 파일이 이미 있으면 덮어쓰지 않고 false를 반환한다.
*/
public boolean saveIfAbsent(String fileName, byte[] content) {
try {
Files.write(uploadDir.resolve(fileName), content, StandardOpenOption.CREATE_NEW);
return true;
} catch (FileAlreadyExistsException exception) {
return false;
} catch (IOException exception) {
throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.debatetimer.client.storage;

import com.debatetimer.exception.custom.DTInitializationException;
import com.debatetimer.exception.errorcode.InitializationErrorCode;
import java.nio.file.Path;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Getter
@ConfigurationProperties(prefix = "icon")
public class IconStorageProperties {

private final Path uploadDir;

public IconStorageProperties(String uploadDir) {
if (uploadDir == null || uploadDir.isBlank()) {
throw new DTInitializationException(InitializationErrorCode.ICON_UPLOAD_DIR_EMPTY);
}
this.uploadDir = Path.of(uploadDir).toAbsolutePath().normalize();
}

public String getResourceLocation() {
return uploadDir.toUri().toString().replaceAll("/?$", "/");
}
}
11 changes: 11 additions & 0 deletions src/main/java/com/debatetimer/config/StorageConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.debatetimer.config;

import com.debatetimer.client.storage.IconStorageProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(IconStorageProperties.class)
public class StorageConfig {

}
11 changes: 11 additions & 0 deletions src/main/java/com/debatetimer/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.debatetimer.config;

import com.debatetimer.client.storage.IconStorageProperties;
import com.debatetimer.controller.tool.jwt.AuthManager;
import com.debatetimer.domain.organization.OrganizationIcon;
import com.debatetimer.service.auth.AuthService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
Expand All @@ -14,9 +17,17 @@ public class WebConfig implements WebMvcConfigurer {

private final AuthManager authManager;
private final AuthService authService;
private final IconStorageProperties iconStorageProperties;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new AuthMemberArgumentResolver(authManager, authService));
}

// 업로드한 아이콘을 먼저 찾고, 없으면 배포에 포함된 기본 아이콘을 찾는다
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler(OrganizationIcon.PATH_PREFIX + "**")
.addResourceLocations(iconStorageProperties.getResourceLocation(), "classpath:/static/icon/");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.debatetimer.controller.admin;

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 com.debatetimer.service.admin.AdminIconService;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequiredArgsConstructor
public class AdminIconController {

private final AdminAuthorizer adminAuthorizer;
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);
return adminIconService.findAll();
}

@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));
}

private byte[] readBytes(MultipartFile file) {
try {
return file.getBytes();
} catch (IOException exception) {
throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.debatetimer.controller.admin;

import com.debatetimer.dto.admin.AdminOrganizationResponse;
import com.debatetimer.dto.admin.AdminOrganizationTemplateResponse;
import com.debatetimer.dto.admin.AdminOrganizationsResponse;
import com.debatetimer.dto.admin.request.OrganizationCreateRequest;
import com.debatetimer.dto.admin.request.OrganizationTemplateCreateRequest;
import com.debatetimer.service.admin.AdminOrganizationService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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.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;

@RestController
@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);
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.debatetimer.controller.admin;

import com.debatetimer.domain.stats.StatUnit;
import com.debatetimer.dto.admin.MemberSignupStatsResponse;
import com.debatetimer.dto.admin.TableUsageStatsResponse;
import com.debatetimer.service.admin.MemberSignupStatsService;
import com.debatetimer.service.admin.TableUsageStatsService;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
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;

@RestController
@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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.debatetimer.controller.admin;

import com.debatetimer.domain.customize.CustomizeTableSortType;
import com.debatetimer.dto.admin.AdminTableDetailResponse;
import com.debatetimer.dto.admin.AdminTablePageResponse;
import com.debatetimer.service.admin.AdminTableService;
import lombok.RequiredArgsConstructor;
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;

@RestController
@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);
return adminTableService.getTable(tableId);
}
}
Loading
Loading