diff --git a/src/main/java/ddingdong/ddingdongBE/common/config/SecurityConfig.java b/src/main/java/ddingdong/ddingdongBE/common/config/SecurityConfig.java index 1d439f86a..635081a58 100644 --- a/src/main/java/ddingdong/ddingdongBE/common/config/SecurityConfig.java +++ b/src/main/java/ddingdong/ddingdongBE/common/config/SecurityConfig.java @@ -51,11 +51,13 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthService authSer API_PREFIX + "/questions/**", API_PREFIX + "/feeds/**", API_PREFIX + "/forms/**", - API_PREFIX + "/file/upload-url/form-application" + API_PREFIX + "/file/upload-url/form-application", + API_PREFIX + "/pair-game/**" ) .permitAll() .requestMatchers(POST, - API_PREFIX + "/forms/{formId}/applications" + API_PREFIX + "/forms/{formId}/applications", + API_PREFIX + "/pair-game/appliers" ) .permitAll() .requestMatchers(API_PREFIX + "/internal/**") diff --git a/src/main/java/ddingdong/ddingdongBE/common/exception/FileException.java b/src/main/java/ddingdong/ddingdongBE/common/exception/FileException.java new file mode 100644 index 000000000..3e9e31065 --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/common/exception/FileException.java @@ -0,0 +1,25 @@ +package ddingdong.ddingdongBE.common.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class FileException extends CustomException { + + private static final String UPLOADED_FILE_NOT_FOUND_MESSAGE = "업로드 할 파일이 없습니다."; + private static final String FILE_READING_ERROR_MESSAGE = "파일을 읽을 수 없습니다."; + + public FileException(String message, int errorCode) { super(message, errorCode); } + + public static final class UploadedFileNotFoundException extends FileException { + + public UploadedFileNotFoundException() { + super(UPLOADED_FILE_NOT_FOUND_MESSAGE, BAD_REQUEST.value()); + } + } + + public static final class FileReadingException extends FileException { + + public FileReadingException() { + super(FILE_READING_ERROR_MESSAGE, BAD_REQUEST.value()); + } + } +} diff --git a/src/main/java/ddingdong/ddingdongBE/common/exception/PairGameApplierException.java b/src/main/java/ddingdong/ddingdongBE/common/exception/PairGameApplierException.java new file mode 100644 index 000000000..f026994f2 --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/common/exception/PairGameApplierException.java @@ -0,0 +1,17 @@ +package ddingdong.ddingdongBE.common.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +public class PairGameApplierException extends CustomException { + + private static final String DUPLICATED_PAIR_GAME_APPLIER_MESSAGE = "이미 존재하는 응모자입니다."; + public PairGameApplierException(String message, int errorCode) { + super(message, errorCode); + } + + public static final class DuplicatedPairGameApplierException extends PairGameApplierException { + public DuplicatedPairGameApplierException() { + super(DUPLICATED_PAIR_GAME_APPLIER_MESSAGE, BAD_REQUEST.value()); + } + } +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/club/service/ClubService.java b/src/main/java/ddingdong/ddingdongBE/domain/club/service/ClubService.java index 0467014c3..c08fc4be9 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/club/service/ClubService.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/club/service/ClubService.java @@ -12,7 +12,9 @@ public interface ClubService { Club getByUserId(Long userId); - List findAll(); + List getAll(); + + List getAllByIds(List clubIds); void update(Club club, Club updatedClub); @@ -20,5 +22,5 @@ public interface ClubService { Club getByUserIdWithFetch(Long userId); - List findAllClubListInfo(); + List getAllClubListInfo(); } diff --git a/src/main/java/ddingdong/ddingdongBE/domain/club/service/FacadeAdminClubServiceImpl.java b/src/main/java/ddingdong/ddingdongBE/domain/club/service/FacadeAdminClubServiceImpl.java index 3fab31e2d..4b4bb3484 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/club/service/FacadeAdminClubServiceImpl.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/club/service/FacadeAdminClubServiceImpl.java @@ -38,7 +38,7 @@ public Long create(CreateClubCommand command) { @Override public List findAll() { - return clubService.findAll().stream() + return clubService.getAll().stream() .map(club -> { UploadedFileUrlAndNameQuery clubProfileImageQuery = fileMetaDataService.getCoupledAllByDomainTypeAndEntityId( DomainType.CLUB_PROFILE, club.getId()) diff --git a/src/main/java/ddingdong/ddingdongBE/domain/club/service/FacadeUserClubServiceImpl.java b/src/main/java/ddingdong/ddingdongBE/domain/club/service/FacadeUserClubServiceImpl.java index fef083442..16e86d148 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/club/service/FacadeUserClubServiceImpl.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/club/service/FacadeUserClubServiceImpl.java @@ -34,7 +34,7 @@ public class FacadeUserClubServiceImpl implements FacadeUserClubService { @Override public List findAllWithRecruitTimeCheckPoint(LocalDate now) { - List userClubListInfos = clubService.findAllClubListInfo(); + List userClubListInfos = clubService.getAllClubListInfo(); return userClubListInfos.stream() .map(info -> UserClubListQuery.of(info, checkRecruit(now, info.getStart(), info.getEnd()).getText())) .toList(); diff --git a/src/main/java/ddingdong/ddingdongBE/domain/club/service/GeneralClubService.java b/src/main/java/ddingdong/ddingdongBE/domain/club/service/GeneralClubService.java index 3a75f8f47..37b418b3f 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/club/service/GeneralClubService.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/club/service/GeneralClubService.java @@ -47,15 +47,20 @@ public Club getByUserIdWithFetch(Long userId) { } @Override - public List findAllClubListInfo() { + public List getAllClubListInfo() { return clubRepository.findAllClubListInfo(LocalDate.now()); } @Override - public List findAll() { + public List getAll() { return clubRepository.findAll(); } + @Override + public List getAllByIds(List clubIds) { + return clubRepository.findAllById(clubIds); + } + @Override @Transactional public void update(Club club, Club updatedClub) { diff --git a/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/repository/FileMetaDataRepository.java b/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/repository/FileMetaDataRepository.java index a10a419f5..7c8d4175c 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/repository/FileMetaDataRepository.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/repository/FileMetaDataRepository.java @@ -23,6 +23,16 @@ List findAllByDomainTypeAndEntityIdWithFileStatus( @Param("fileStatus") FileStatus fileStatus ); + @Query(""" + select fmd from FileMetaData fmd + where fmd.domainType = :domainType + and fmd.fileStatus = :fileStatus + """) + List findAllByDomainTypeWithFileStatus( + @Param("domainType") DomainType domainType, + @Param("fileStatus") FileStatus fileStatus + ); + @Query("select fmd from FileMetaData fmd where fmd.entityId = :entityId and fmd.fileStatus = :fileStatus") List findAllByEntityIdWithFileStatus( @Param("entityId") Long entityId, diff --git a/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/service/FileMetaDataService.java b/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/service/FileMetaDataService.java index 187030bef..a064a2d4d 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/service/FileMetaDataService.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/service/FileMetaDataService.java @@ -16,6 +16,8 @@ public interface FileMetaDataService { List getCoupledAllByDomainTypeAndEntityId(DomainType domainType, Long entityId); + List getCoupledAllByDomainType(DomainType domainType); + List getCoupledAllByEntityId(Long entityId); List getCoupledAllByDomainTypeAndEntityIdOrderedAsc(DomainType domainType, diff --git a/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/service/FileMetaDataServiceImpl.java b/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/service/FileMetaDataServiceImpl.java index 7b3f86843..6754cc604 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/service/FileMetaDataServiceImpl.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/filemetadata/service/FileMetaDataServiceImpl.java @@ -50,6 +50,11 @@ public List getCoupledAllByDomainTypeAndEntityId(DomainType domain return fileMetaDataRepository.findAllByDomainTypeAndEntityIdWithFileStatus(domainType, entityId, COUPLED); } + @Override + public List getCoupledAllByDomainType(DomainType domainType) { + return fileMetaDataRepository.findAllByDomainTypeWithFileStatus(domainType, COUPLED); + } + @Override public List getCoupledAllByEntityId(Long entityId) { return fileMetaDataRepository.findAllByEntityIdWithFileStatus(entityId, COUPLED); diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/api/UserPairGameApi.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/api/UserPairGameApi.java new file mode 100644 index 000000000..12a56903c --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/api/UserPairGameApi.java @@ -0,0 +1,38 @@ +package ddingdong.ddingdongBE.domain.pairgame.api; + +import ddingdong.ddingdongBE.domain.pairgame.controller.dto.request.CreatePairGameApplierRequest; +import ddingdong.ddingdongBE.domain.pairgame.controller.dto.response.PairGameApplierAmountResponse; +import ddingdong.ddingdongBE.domain.pairgame.controller.dto.response.PairGameMetaDataResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "PairGame - User", description = "User PairGame API") +@RequestMapping("/server") +public interface UserPairGameApi { + + @Operation(summary = "응모자 생성 API") + @ApiResponse(responseCode = "201", description = "응모자 생성 성공") + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/pair-game/appliers") + void createPairGameApplier( + @Valid @RequestPart("request") CreatePairGameApplierRequest request, + @RequestPart("file") MultipartFile file + ); + + @Operation(summary = "응모자 현황 조회 API") + @ApiResponse(responseCode = "200", description = "응모자 현황 조회 성공") + @ResponseStatus(HttpStatus.OK) + @GetMapping("/pair-game/appliers/amount") + PairGameApplierAmountResponse getPairGameApplierAmount(); + + @Operation(summary = "게임 메타데이터 조회 API") + @ApiResponse(responseCode = "200", description = "게임 메타데이터 조회 성공") + @ResponseStatus(HttpStatus.OK) + @GetMapping("/pair-game/metadata") + PairGameMetaDataResponse getPairGameMetaData(); +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/UserPairGameController.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/UserPairGameController.java new file mode 100644 index 000000000..9c0e4d557 --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/UserPairGameController.java @@ -0,0 +1,36 @@ +package ddingdong.ddingdongBE.domain.pairgame.controller; + +import ddingdong.ddingdongBE.domain.pairgame.api.UserPairGameApi; +import ddingdong.ddingdongBE.domain.pairgame.controller.dto.request.CreatePairGameApplierRequest; +import ddingdong.ddingdongBE.domain.pairgame.controller.dto.response.PairGameApplierAmountResponse; +import ddingdong.ddingdongBE.domain.pairgame.controller.dto.response.PairGameMetaDataResponse; +import ddingdong.ddingdongBE.domain.pairgame.service.FacadeUserPairGameService; +import ddingdong.ddingdongBE.domain.pairgame.service.dto.query.PairGameApplierAmountQuery; +import ddingdong.ddingdongBE.domain.pairgame.service.dto.query.PairGameMetaDataQuery; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class UserPairGameController implements UserPairGameApi { + + private final FacadeUserPairGameService facadeUserPairGameService; + + @Override + public void createPairGameApplier(CreatePairGameApplierRequest createPairGameApplierRequest, MultipartFile studentFeeImageFile) { + facadeUserPairGameService.createPairGameApplier(createPairGameApplierRequest.toCommand(studentFeeImageFile)); + } + + @Override + public PairGameApplierAmountResponse getPairGameApplierAmount() { + PairGameApplierAmountQuery query = facadeUserPairGameService.getPairGameApplierAmount(); + return PairGameApplierAmountResponse.from(query); + } + + @Override + public PairGameMetaDataResponse getPairGameMetaData() { + PairGameMetaDataQuery query = facadeUserPairGameService.getPairGameMetaData(); + return PairGameMetaDataResponse.from(query); + } +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/dto/request/CreatePairGameApplierRequest.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/dto/request/CreatePairGameApplierRequest.java new file mode 100644 index 000000000..3a1a8c019 --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/dto/request/CreatePairGameApplierRequest.java @@ -0,0 +1,35 @@ +package ddingdong.ddingdongBE.domain.pairgame.controller.dto.request; + +import ddingdong.ddingdongBE.domain.pairgame.service.dto.command.CreatePairGameApplierCommand; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import org.springframework.web.multipart.MultipartFile; + +public record CreatePairGameApplierRequest ( + + @NotNull(message = "응모자 이름은 필수 입력 사항입니다.") + @Schema(description = "응모자 이름", example = "김띵동") + String name, + + @NotNull(message = "응모자 학과는 필수 입력 사항입니다.") + @Schema(description = "응모자 학과", example = "융합소프트웨어학부") + String department, + + @NotNull(message = "응모자 학번은 필수 입력 사항입니다.") + @Schema(description = "응모자 학번", example = "60000000") + String studentNumber, + + @NotNull(message = "응모자 전화번호는 필수 입력 사항입니다.") + @Schema(description = "응모자 전화번호", example = "010-0000-0000") + String phoneNumber + ) { + public CreatePairGameApplierCommand toCommand(MultipartFile studentFeeImageFile) { + return CreatePairGameApplierCommand.builder() + .name(name) + .department(department) + .studentNumber(studentNumber) + .phoneNumber(phoneNumber) + .studentFeeImageFile(studentFeeImageFile) + .build(); + } +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/dto/response/PairGameApplierAmountResponse.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/dto/response/PairGameApplierAmountResponse.java new file mode 100644 index 000000000..de7c8de56 --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/dto/response/PairGameApplierAmountResponse.java @@ -0,0 +1,17 @@ +package ddingdong.ddingdongBE.domain.pairgame.controller.dto.response; + +import ddingdong.ddingdongBE.domain.pairgame.service.dto.query.PairGameApplierAmountQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +public record PairGameApplierAmountResponse( + @Schema(description = "총 응모자 수", example = "200") + int amount +) { + public static PairGameApplierAmountResponse from(PairGameApplierAmountQuery query) { + return PairGameApplierAmountResponse.builder() + .amount(query.amount()) + .build(); + } +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/dto/response/PairGameMetaDataResponse.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/dto/response/PairGameMetaDataResponse.java new file mode 100644 index 000000000..0bdfda8ea --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/controller/dto/response/PairGameMetaDataResponse.java @@ -0,0 +1,42 @@ +package ddingdong.ddingdongBE.domain.pairgame.controller.dto.response; + +import ddingdong.ddingdongBE.domain.pairgame.service.dto.query.PairGameMetaDataQuery.PairGameClubAndImageQuery; +import ddingdong.ddingdongBE.domain.pairgame.service.dto.query.PairGameMetaDataQuery; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +public record PairGameMetaDataResponse( + @ArraySchema(schema = @Schema(implementation = PairGameMetaDataResponse.PairGameClubAndImageResponse.class)) + List metaData +) { + @Builder + public record PairGameClubAndImageResponse( + @Schema(description = "동아리 이름", example = "COW") + String clubName, + @Schema(description = "동아리 분과", example = "사회연구") + String category, + @Schema(description = "동아리 로고 이미지 CDN URL", example = "https://cdn.com") + String imageUrl + ) { + public static PairGameClubAndImageResponse from(PairGameClubAndImageQuery query) { + return PairGameClubAndImageResponse.builder() + .clubName(query.clubName()) + .category(query.category()) + .imageUrl(query.imageUrl()) + .build(); + } + } + public static PairGameMetaDataResponse from(PairGameMetaDataQuery query) { + List responses = query.metaData().stream() + .map(PairGameClubAndImageResponse::from) + .toList(); + + return PairGameMetaDataResponse.builder() + .metaData(responses) + .build(); + } +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/entity/PairGameApplier.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/entity/PairGameApplier.java new file mode 100644 index 000000000..ccf4f34d8 --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/entity/PairGameApplier.java @@ -0,0 +1,52 @@ +package ddingdong.ddingdongBE.domain.pairgame.entity; + +import ddingdong.ddingdongBE.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@SQLDelete(sql = "update pair_game_applier set deleted_at = CURRENT_TIMESTAMP where id=?") +@SQLRestriction("deleted_at IS NULL") +public class PairGameApplier extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false, length = 50) + private String department; + + @Column(unique = true, nullable = false, length = 50) + private String studentNumber; + + @Column(nullable = false, length = 50) + private String phoneNumber; + + @Column(nullable = false) + private String studentFeeImageUrl; + + @Column(columnDefinition = "TIMESTAMP") + private LocalDateTime deletedAt; + + @Builder + private PairGameApplier(String name, String department, String studentNumber, String phoneNumber, String studentFeeImageUrl) { + this.name = name; + this.department = department; + this.studentNumber = studentNumber; + this.phoneNumber = phoneNumber; + this.studentFeeImageUrl = studentFeeImageUrl; + } + +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/repository/PairGameRepository.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/repository/PairGameRepository.java new file mode 100644 index 000000000..47d71275c --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/repository/PairGameRepository.java @@ -0,0 +1,9 @@ +package ddingdong.ddingdongBE.domain.pairgame.repository; + +import ddingdong.ddingdongBE.domain.pairgame.entity.PairGameApplier; +import org.springframework.data.jpa.repository.JpaRepository; + + +public interface PairGameRepository extends JpaRepository { + boolean existsByStudentNumber(String studentNumber); +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/FacadeUserPairGameService.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/FacadeUserPairGameService.java new file mode 100644 index 000000000..e3b9a5d25 --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/FacadeUserPairGameService.java @@ -0,0 +1,70 @@ +package ddingdong.ddingdongBE.domain.pairgame.service; + +import ddingdong.ddingdongBE.common.exception.FileException.UploadedFileNotFoundException; +import ddingdong.ddingdongBE.domain.club.entity.Club; +import ddingdong.ddingdongBE.domain.club.service.ClubService; +import ddingdong.ddingdongBE.domain.filemetadata.entity.DomainType; +import ddingdong.ddingdongBE.domain.filemetadata.entity.FileMetaData; +import ddingdong.ddingdongBE.domain.filemetadata.service.FileMetaDataService; +import ddingdong.ddingdongBE.domain.pairgame.service.dto.command.CreatePairGameApplierCommand; +import ddingdong.ddingdongBE.domain.pairgame.service.dto.query.PairGameApplierAmountQuery; +import ddingdong.ddingdongBE.domain.pairgame.service.dto.query.PairGameMetaDataQuery; +import ddingdong.ddingdongBE.domain.pairgame.service.dto.query.PairGameMetaDataQuery.PairGameClubAndImageQuery; +import ddingdong.ddingdongBE.file.service.S3FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FacadeUserPairGameService { + + private final PairGameService pairGameService; + private final S3FileService s3FileService; + private final FileMetaDataService fileMetaDataService; + private final ClubService clubService; + + @Transactional + public void createPairGameApplier(CreatePairGameApplierCommand createPairGameApplierCommand) { + MultipartFile studentFeeImageFile = createPairGameApplierCommand.studentFeeImageFile(); + if (studentFeeImageFile == null || studentFeeImageFile.isEmpty()) { + throw new UploadedFileNotFoundException(); + } + pairGameService.validateStudentNumberUnique(createPairGameApplierCommand.studentNumber()); + String key = s3FileService.uploadMultipartFile(studentFeeImageFile, LocalDateTime.now(), "pair-game"); + pairGameService.create(createPairGameApplierCommand.toEntity(key)); + } + + public PairGameApplierAmountQuery getPairGameApplierAmount() { + return pairGameService.getPairGameApplierAmount(); + } + + public PairGameMetaDataQuery getPairGameMetaData() { + List allClubProfileMetaData = fileMetaDataService.getCoupledAllByDomainType(DomainType.CLUB_PROFILE); + Collections.shuffle(allClubProfileMetaData); + List selectedMetaData = allClubProfileMetaData.stream().limit(18).toList(); + + List clubIds = selectedMetaData.stream().map(FileMetaData::getEntityId).toList(); + List clubs = clubService.getAllByIds(clubIds); + + Map clubMap = clubs.stream().collect(Collectors.toMap(Club::getId, club -> club)); + + List pairGameMetaData = selectedMetaData.stream().map(file -> { + Club club = clubMap.get(file.getEntityId()); + return PairGameClubAndImageQuery.of( + club.getName(), + club.getCategory(), + s3FileService.getUploadedFileUrl(file.getFileKey()).cdnUrl() + ); + }).toList(); + return PairGameMetaDataQuery.of(pairGameMetaData); + } +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/PairGameService.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/PairGameService.java new file mode 100644 index 000000000..11e07728e --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/PairGameService.java @@ -0,0 +1,33 @@ +package ddingdong.ddingdongBE.domain.pairgame.service; + +import ddingdong.ddingdongBE.common.exception.PairGameApplierException.DuplicatedPairGameApplierException; +import ddingdong.ddingdongBE.domain.pairgame.entity.PairGameApplier; +import ddingdong.ddingdongBE.domain.pairgame.repository.PairGameRepository; +import ddingdong.ddingdongBE.domain.pairgame.service.dto.query.PairGameApplierAmountQuery; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PairGameService { + + private final PairGameRepository pairGameRepository; + + @Transactional + public PairGameApplier create(PairGameApplier pairGameApplier) { + return pairGameRepository.save(pairGameApplier); + } + + public PairGameApplierAmountQuery getPairGameApplierAmount() { + int amount = (int) pairGameRepository.count(); + return PairGameApplierAmountQuery.of(amount); + } + + public void validateStudentNumberUnique(String studentNumber) { + if (pairGameRepository.existsByStudentNumber(studentNumber)) { + throw new DuplicatedPairGameApplierException(); + } + } +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/dto/command/CreatePairGameApplierCommand.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/dto/command/CreatePairGameApplierCommand.java new file mode 100644 index 000000000..8f1b88f14 --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/dto/command/CreatePairGameApplierCommand.java @@ -0,0 +1,24 @@ +package ddingdong.ddingdongBE.domain.pairgame.service.dto.command; + +import ddingdong.ddingdongBE.domain.pairgame.entity.PairGameApplier; +import lombok.Builder; +import org.springframework.web.multipart.MultipartFile; + +@Builder +public record CreatePairGameApplierCommand( + String name, + String department, + String studentNumber, + String phoneNumber, + MultipartFile studentFeeImageFile +){ + public PairGameApplier toEntity(String studentFeeImageUrl) { + return PairGameApplier.builder() + .name(name) + .department(department) + .studentNumber(studentNumber) + .phoneNumber(phoneNumber) + .studentFeeImageUrl(studentFeeImageUrl) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/dto/query/PairGameApplierAmountQuery.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/dto/query/PairGameApplierAmountQuery.java new file mode 100644 index 000000000..889a96a09 --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/dto/query/PairGameApplierAmountQuery.java @@ -0,0 +1,13 @@ +package ddingdong.ddingdongBE.domain.pairgame.service.dto.query; + +import lombok.Builder; + +@Builder +public record PairGameApplierAmountQuery( + int amount +) { + public static PairGameApplierAmountQuery of(int amount) { + return PairGameApplierAmountQuery.builder() + .amount(amount).build(); + } +} diff --git a/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/dto/query/PairGameMetaDataQuery.java b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/dto/query/PairGameMetaDataQuery.java new file mode 100644 index 000000000..e4bd8a8af --- /dev/null +++ b/src/main/java/ddingdong/ddingdongBE/domain/pairgame/service/dto/query/PairGameMetaDataQuery.java @@ -0,0 +1,30 @@ +package ddingdong.ddingdongBE.domain.pairgame.service.dto.query; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record PairGameMetaDataQuery( + List metaData +) { + @Builder + public record PairGameClubAndImageQuery( + String clubName, + String category, + String imageUrl + ) { + public static PairGameClubAndImageQuery of(String clubName, String category, String imageUrl) { + return PairGameClubAndImageQuery.builder() + .clubName(clubName) + .category(category) + .imageUrl(imageUrl) + .build(); + } + } + public static PairGameMetaDataQuery of(List metaData) { + return PairGameMetaDataQuery.builder() + .metaData(metaData) + .build(); + } +} diff --git a/src/main/java/ddingdong/ddingdongBE/file/service/S3FileService.java b/src/main/java/ddingdong/ddingdongBE/file/service/S3FileService.java index 2fc426573..588981b10 100644 --- a/src/main/java/ddingdong/ddingdongBE/file/service/S3FileService.java +++ b/src/main/java/ddingdong/ddingdongBE/file/service/S3FileService.java @@ -1,6 +1,10 @@ package ddingdong.ddingdongBE.file.service; import com.github.f4b6a3.uuid.UuidCreator; +import ddingdong.ddingdongBE.common.exception.FileException.FileReadingException; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @@ -17,6 +21,8 @@ import ddingdong.ddingdongBE.file.service.dto.query.UploadedFileUrlAndNameQuery; import ddingdong.ddingdongBE.file.service.dto.query.UploadedFileUrlQuery; import ddingdong.ddingdongBE.file.service.dto.query.UploadedVideoUrlQuery; + +import java.io.IOException; import java.net.URL; import java.time.LocalDateTime; import java.util.UUID; @@ -108,6 +114,29 @@ public UploadedVideoUrlQuery getUploadedVideoUrl(String key) { return new UploadedVideoUrlQuery(thumbnailOriginUrl, thumbnailCdnUrl, videoOriginUrl, videoCdnUrl); } + public String uploadMultipartFile(MultipartFile file, LocalDateTime dateTime, String directory) { + UUID fileName = UuidCreator.getTimeOrderedEpoch(); + String extension = extractFileExtension(file.getOriginalFilename()); + ContentType contentType = ContentType.fromExtension(extension); + + String key = generateKey(contentType, dateTime, directory, fileName); + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(inputBucket) + .key(key) + .contentType(file.getContentType()) + .build(); + s3Client.putObject(putObjectRequest, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + return key; + } catch (IOException e) { + throw new FileReadingException(); + } catch (SdkException e) { + log.error("AWS Service Error : {}", e.getMessage()); + throw new AwsService(); + } + } + private GeneratePreSignedUrlRequestQuery buildPresignedUrlRequest(GeneratePreSignedUrlRequestCommand command, ContentType contentType) { UUID id = UuidCreator.getTimeOrderedEpoch(); String key = generateKey(contentType, command, id); @@ -137,6 +166,15 @@ private String generateKey(ContentType contentType, GeneratePreSignedUrlRequestC uploadFileName.toString()); } + private String generateKey(ContentType contentType, LocalDateTime dateTime, String directory, UUID uploadFileName) { + return String.format("%s/%s/%s/%s/%s", + serverProfile, + contentType.getKeyMediaType(), + formatDate(dateTime), + directory, + uploadFileName.toString()); + } + private String formatDate(LocalDateTime dateTime) { return String.format("%d-%d-%d", dateTime.getYear(), dateTime.getMonthValue(), dateTime.getDayOfMonth()); } diff --git a/src/main/resources/db/migration/V51__create_pair_game_applier_table.sql b/src/main/resources/db/migration/V51__create_pair_game_applier_table.sql new file mode 100644 index 000000000..1ad464219 --- /dev/null +++ b/src/main/resources/db/migration/V51__create_pair_game_applier_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE pair_game_applier +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL, + department VARCHAR(50) NOT NULL, + student_number VARCHAR(50) NOT NULL UNIQUE, + phone_number VARCHAR(50) NOT NULL, + student_fee_image_url VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NULL, + deleted_at TIMESTAMP NULL DEFAULT NULL +); diff --git a/src/test/java/ddingdong/ddingdongBE/domain/club/service/FacadeAdminClubServiceImplTest.java b/src/test/java/ddingdong/ddingdongBE/domain/club/service/FacadeAdminClubServiceImplTest.java index 9f637213b..6bae3576d 100644 --- a/src/test/java/ddingdong/ddingdongBE/domain/club/service/FacadeAdminClubServiceImplTest.java +++ b/src/test/java/ddingdong/ddingdongBE/domain/club/service/FacadeAdminClubServiceImplTest.java @@ -66,7 +66,7 @@ void create() { @DisplayName("어드민: 동아리 목록 조회") @Test - void findAll() { + void getAll() { // given List clubs = new ArrayList<>(); diff --git a/src/test/java/ddingdong/ddingdongBE/domain/club/service/FacadeUserClubServiceImplTest.java b/src/test/java/ddingdong/ddingdongBE/domain/club/service/FacadeUserClubServiceImplTest.java index a3acf214b..4e7e56076 100644 --- a/src/test/java/ddingdong/ddingdongBE/domain/club/service/FacadeUserClubServiceImplTest.java +++ b/src/test/java/ddingdong/ddingdongBE/domain/club/service/FacadeUserClubServiceImplTest.java @@ -49,7 +49,7 @@ void tearDown() { @DisplayName("유저: 동아리 목록 조회 - 모집 전") @Test - void findAllWithRecruitTimeCheckPointBeforeRecruit() { + void getAllWithRecruitTimeCheckPointBeforeRecruit() { // given LocalDate startRecruitingDate = LocalDate.of(2025, 9, 1); LocalDate endRecruitingDate = LocalDate.of(2025, 12, 31); @@ -75,7 +75,7 @@ void findAllWithRecruitTimeCheckPointBeforeRecruit() { @DisplayName("유저: 동아리 목록 조회 - 모집 가능") @Test - void findAllWithRecruitTimeCheckPointRecruiting() { + void getAllWithRecruitTimeCheckPointRecruiting() { // given LocalDate startRecruitingDate = LocalDate.of(2025, 9, 1); LocalDate endRecruitingDate = LocalDate.of(2025, 12, 31); @@ -101,7 +101,7 @@ void findAllWithRecruitTimeCheckPointRecruiting() { @DisplayName("유저: 동아리 목록 조회 - 모집 마감") @Test - void findAllWithRecruitTimeCheckPointEndRecruit() { + void getAllWithRecruitTimeCheckPointEndRecruit() { // given LocalDate startRecruitingDate = LocalDate.of(2025, 9, 1); LocalDate endRecruitingDate = LocalDate.of(2025, 12, 1); diff --git a/src/test/java/ddingdong/ddingdongBE/domain/pairgame/service/FacadeUserPairGameServiceTest.java b/src/test/java/ddingdong/ddingdongBE/domain/pairgame/service/FacadeUserPairGameServiceTest.java new file mode 100644 index 000000000..1fa822e6e --- /dev/null +++ b/src/test/java/ddingdong/ddingdongBE/domain/pairgame/service/FacadeUserPairGameServiceTest.java @@ -0,0 +1,130 @@ +package ddingdong.ddingdongBE.domain.pairgame.service; + +import ddingdong.ddingdongBE.common.exception.FileException.UploadedFileNotFoundException; +import ddingdong.ddingdongBE.common.support.TestContainerSupport; +import ddingdong.ddingdongBE.domain.club.entity.Club; +import ddingdong.ddingdongBE.domain.club.repository.ClubRepository; +import ddingdong.ddingdongBE.domain.filemetadata.entity.DomainType; +import ddingdong.ddingdongBE.domain.filemetadata.entity.FileMetaData; +import ddingdong.ddingdongBE.domain.filemetadata.entity.FileStatus; +import ddingdong.ddingdongBE.domain.filemetadata.repository.FileMetaDataRepository; +import ddingdong.ddingdongBE.domain.pairgame.entity.PairGameApplier; +import ddingdong.ddingdongBE.domain.pairgame.repository.PairGameRepository; +import ddingdong.ddingdongBE.domain.pairgame.service.dto.command.CreatePairGameApplierCommand; +import ddingdong.ddingdongBE.domain.pairgame.service.dto.query.PairGameMetaDataQuery; +import ddingdong.ddingdongBE.file.service.S3FileService; +import ddingdong.ddingdongBE.file.service.dto.query.UploadedFileUrlQuery; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.mock.web.MockMultipartFile; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@SpringBootTest +class FacadeUserPairGameServiceTest extends TestContainerSupport { + + @Autowired + private FacadeUserPairGameService facadeUserPairGameService; + + @Autowired + private PairGameRepository pairGameRepository; + + @Autowired + private FileMetaDataRepository fileMetaDataRepository; + + @Autowired + private ClubRepository clubRepository; + + @MockitoBean + private S3FileService s3FileService; + + @Test + @DisplayName("유저: 학번 중복 검사 후 응모자를 생성할 수 있다.") + void createPairGameApplier_Success() { + // given + MockMultipartFile file = new MockMultipartFile("file", "test.jpg", "image/jpeg", "content".getBytes()); + CreatePairGameApplierCommand command = new CreatePairGameApplierCommand( + "김띵동", "융합소프트웨어학부", "60112233", "010-1234-5678", file + ); + given(s3FileService.uploadMultipartFile(eq(file), any(LocalDateTime.class), eq("pair-game"))) + .willReturn("s3-uploaded-file-key"); + + // when + facadeUserPairGameService.createPairGameApplier(command); + + // then + List appliers = pairGameRepository.findAll(); + assertThat(appliers).hasSize(1); + + PairGameApplier savedApplier = appliers.get(0); + assertThat(savedApplier.getName()).isEqualTo("김띵동"); + assertThat(savedApplier.getStudentNumber()).isEqualTo("60112233"); + } + + @Test + @DisplayName("유저: 응모자 생성 요청에 파일이 없으면 예외가 발생한다.") + void createPairGameApplier_Fail_NoFile() { + // given + CreatePairGameApplierCommand command = new CreatePairGameApplierCommand( + "김띵동", "융합소프트웨어학부", "60112233", "010-1234-5678", null + ); + + // when & then + assertThatThrownBy(() -> facadeUserPairGameService.createPairGameApplier(command)) + .isInstanceOf(UploadedFileNotFoundException.class); + } + + @Test + @DisplayName("유저: 동아리 로고 이미지 URL, 분과, 이름을 랜덤으로 18개 조회할 수 있다.") + void getPairGameMetaData_Integration() { + // given + List clubs = IntStream.range(0, 20) + .mapToObj(i -> Club.builder() + .name("동아리" + i) + .category("분과" + i) + .leader("회장" + i) + .build()) + .toList(); + clubRepository.saveAll(clubs); + + List metaDataList = IntStream.range(0, 20) + .mapToObj(i -> FileMetaData.builder() + .id(UUID.randomUUID()) + .domainType(DomainType.CLUB_PROFILE) + .entityId(clubs.get(i).getId()) + .fileKey("key" + i) + .fileName("test.jpg") + .fileStatus(FileStatus.COUPLED) + .build()) + .toList(); + fileMetaDataRepository.saveAll(metaDataList); + + given(s3FileService.getUploadedFileUrl(any())) + .willReturn(new UploadedFileUrlQuery("id", "originUrl", "cdnUrl")); + + // when + PairGameMetaDataQuery result = facadeUserPairGameService.getPairGameMetaData(); + + // then + assertThat(result.metaData()).hasSize(18); + String firstClubName = result.metaData().get(0).clubName(); + String firstClubCategory = result.metaData().get(0).category(); + String firstImageUrl = result.metaData().get(0).imageUrl(); + + assertThat(firstClubName).startsWith("동아리"); + assertThat(firstClubCategory).startsWith("분과"); + assertThat(firstImageUrl).isEqualTo("cdnUrl"); + } +} \ No newline at end of file diff --git a/src/test/resources/docker-java.properties b/src/test/resources/docker-java.properties new file mode 100644 index 000000000..e1af86b41 --- /dev/null +++ b/src/test/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 \ No newline at end of file