diff --git a/build.gradle b/build.gradle index a19b379b..3a9cd779 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,9 @@ dependencies { // javax.xml.bind implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359' + // object storage + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/nexters/goalpanzi/application/auth/AuthService.java b/src/main/java/com/nexters/goalpanzi/application/auth/AuthService.java index 570a33d6..472e453b 100644 --- a/src/main/java/com/nexters/goalpanzi/application/auth/AuthService.java +++ b/src/main/java/com/nexters/goalpanzi/application/auth/AuthService.java @@ -6,10 +6,10 @@ import com.nexters.goalpanzi.application.auth.dto.TokenResponse; import com.nexters.goalpanzi.common.jwt.Jwt; import com.nexters.goalpanzi.common.jwt.JwtProvider; -import com.nexters.goalpanzi.domain.auth.RefreshTokenRepository; +import com.nexters.goalpanzi.domain.auth.repository.RefreshTokenRepository; import com.nexters.goalpanzi.domain.member.Member; -import com.nexters.goalpanzi.domain.member.repository.MemberRepository; import com.nexters.goalpanzi.domain.member.SocialType; +import com.nexters.goalpanzi.domain.member.repository.MemberRepository; import com.nexters.goalpanzi.exception.ErrorCode; import com.nexters.goalpanzi.exception.UnauthorizedException; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/nexters/goalpanzi/application/mission/MissionVerificationService.java b/src/main/java/com/nexters/goalpanzi/application/mission/MissionVerificationService.java new file mode 100644 index 00000000..68ca2e9f --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/application/mission/MissionVerificationService.java @@ -0,0 +1,113 @@ +package com.nexters.goalpanzi.application.mission; + +import com.nexters.goalpanzi.application.mission.dto.CreateMissionVerificationCommand; +import com.nexters.goalpanzi.application.mission.dto.MissionVerificationCommand; +import com.nexters.goalpanzi.application.mission.dto.MissionVerificationResponse; +import com.nexters.goalpanzi.application.mission.dto.MyMissionVerificationCommand; +import com.nexters.goalpanzi.domain.member.Member; +import com.nexters.goalpanzi.domain.member.repository.MemberRepository; +import com.nexters.goalpanzi.domain.mission.Mission; +import com.nexters.goalpanzi.domain.mission.MissionMember; +import com.nexters.goalpanzi.domain.mission.MissionVerification; +import com.nexters.goalpanzi.domain.mission.repository.MissionMemberRepository; +import com.nexters.goalpanzi.domain.mission.repository.MissionRepository; +import com.nexters.goalpanzi.domain.mission.repository.MissionVerificationRepository; +import com.nexters.goalpanzi.exception.BadRequestException; +import com.nexters.goalpanzi.exception.ErrorCode; +import com.nexters.goalpanzi.exception.NotFoundException; +import com.nexters.goalpanzi.infrastructure.ncp.ObjectStorageManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +public class MissionVerificationService { + + private final MissionRepository missionRepository; + private final MissionVerificationRepository missionVerificationRepository; + private final MissionMemberRepository missionMemberRepository; + private final MemberRepository memberRepository; + + private final ObjectStorageManager objectStorageManager; + + @Transactional(readOnly = true) + public List getVerifications(final MissionVerificationCommand command) { + LocalDate date = command.date() != null ? command.date() : LocalDate.now(); + Member member = + memberRepository.findById(command.memberId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER)); + List missionMembers = missionMemberRepository.findAllByMissionId(command.missionId()); + List verifications = missionVerificationRepository.findAllByMissionIdAndDate(command.missionId(), date); + + Map verificationMap = verifications.stream() + .collect(Collectors.toMap(v -> v.getMember().getId(), v -> v)); + + return missionMembers.stream() + .map(m -> { + MissionVerification v = verificationMap.get(m.getMember().getId()); + return v != null + ? MissionVerificationResponse.verified(m.getMember(), v) + : MissionVerificationResponse.notVerified(m.getMember()); + }) + .sorted(Comparator.comparing((MissionVerificationResponse r) -> r.nickname().equals(member.getNickname())).reversed() + .thenComparing(MissionVerificationResponse::verifiedAt, Comparator.nullsLast(Comparator.reverseOrder()))) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public MissionVerificationResponse getMyVerification(final MyMissionVerificationCommand command) { + Member member = + memberRepository.findById(command.memberId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER)); + MissionVerification verification = + missionVerificationRepository.findByMemberIdAndMissionIdAndBoardNumber(command.memberId(), command.missionId(), command.number()) + .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_VERIFICATION)); + + return MissionVerificationResponse.verified(member, verification); + } + + @Transactional + public void createVerification(final CreateMissionVerificationCommand command) { + Member member = + memberRepository.findById(command.memberId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER)); + Mission mission = + missionRepository.findById(command.missionId()) + .orElseThrow(() -> new NotFoundException("TODO")); + MissionMember missionMember = + missionMemberRepository.findByMemberIdAndMissionId(command.memberId(), command.missionId()) + .orElseThrow(() -> new NotFoundException("TODO")); + + checkVerificationValidation(command.memberId(), command.missionId(), mission, missionMember); + + String imageUrl = objectStorageManager.uploadFile(command.imageFile()); + missionVerificationRepository.save(new MissionVerification(member, mission, imageUrl)); + missionMember.verify(); + } + + private void checkVerificationValidation(final Long memberId, final Long missionId, final Mission mission, final MissionMember missionMember) { + if (isCompletedMission(mission, missionMember)) { + throw new BadRequestException(ErrorCode.ALREADY_COMPLETED_MISSION); + } + if (isDuplicatedVerification(memberId, missionId)) { + throw new BadRequestException(ErrorCode.DUPLICATE_VERIFICATION); + } + } + + private boolean isCompletedMission(final Mission mission, final MissionMember missionMember) { + return missionMember.getVerificationCount() >= mission.getBoardCount(); + } + + private boolean isDuplicatedVerification(final Long memberId, final Long missionId) { + LocalDate today = LocalDateTime.now().toLocalDate(); + return missionVerificationRepository.findByMemberIdAndMissionIdAndDate(memberId, missionId, today).isPresent(); + } +} diff --git a/src/main/java/com/nexters/goalpanzi/application/mission/dto/CreateMissionVerificationCommand.java b/src/main/java/com/nexters/goalpanzi/application/mission/dto/CreateMissionVerificationCommand.java new file mode 100644 index 00000000..0c0b9efd --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/application/mission/dto/CreateMissionVerificationCommand.java @@ -0,0 +1,10 @@ +package com.nexters.goalpanzi.application.mission.dto; + +import org.springframework.web.multipart.MultipartFile; + +public record CreateMissionVerificationCommand( + Long memberId, + Long missionId, + MultipartFile imageFile +) { +} diff --git a/src/main/java/com/nexters/goalpanzi/application/mission/dto/MissionVerificationCommand.java b/src/main/java/com/nexters/goalpanzi/application/mission/dto/MissionVerificationCommand.java new file mode 100644 index 00000000..0ea23319 --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/application/mission/dto/MissionVerificationCommand.java @@ -0,0 +1,10 @@ +package com.nexters.goalpanzi.application.mission.dto; + +import java.time.LocalDate; + +public record MissionVerificationCommand( + Long memberId, + Long missionId, + LocalDate date +) { +} diff --git a/src/main/java/com/nexters/goalpanzi/application/mission/dto/MissionVerificationResponse.java b/src/main/java/com/nexters/goalpanzi/application/mission/dto/MissionVerificationResponse.java new file mode 100644 index 00000000..2a2ff5cc --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/application/mission/dto/MissionVerificationResponse.java @@ -0,0 +1,30 @@ +package com.nexters.goalpanzi.application.mission.dto; + +import com.nexters.goalpanzi.domain.member.Member; +import com.nexters.goalpanzi.domain.mission.MissionVerification; +import io.swagger.v3.oas.annotations.media.Schema; +import org.hibernate.validator.constraints.NotEmpty; + +import java.time.LocalDateTime; + +public record MissionVerificationResponse( + @Schema(description = "닉네임", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty String nickname, + + // TODO 캐릭터 이미지 + + @Schema(description = "인증 이미지 URL", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + String imageUrl, + + @Schema(description = "인증 시간", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + LocalDateTime verifiedAt +) { + + public static MissionVerificationResponse verified(Member member, MissionVerification verification) { + return new MissionVerificationResponse(member.getNickname(), verification.getImageUrl(), verification.getCreatedAt()); + } + + public static MissionVerificationResponse notVerified(Member member) { + return new MissionVerificationResponse(member.getNickname(), "", null); + } +} diff --git a/src/main/java/com/nexters/goalpanzi/application/mission/dto/MyMissionVerificationCommand.java b/src/main/java/com/nexters/goalpanzi/application/mission/dto/MyMissionVerificationCommand.java new file mode 100644 index 00000000..7e1e0f1e --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/application/mission/dto/MyMissionVerificationCommand.java @@ -0,0 +1,8 @@ +package com.nexters.goalpanzi.application.mission.dto; + +public record MyMissionVerificationCommand( + Long memberId, + Long missionId, + Integer number +) { +} diff --git a/src/main/java/com/nexters/goalpanzi/config/NcpConfig.java b/src/main/java/com/nexters/goalpanzi/config/NcpConfig.java new file mode 100644 index 00000000..0b73c197 --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/config/NcpConfig.java @@ -0,0 +1,36 @@ +package com.nexters.goalpanzi.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class NcpConfig { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Value("${cloud.aws.s3.endpoint}") + private String endPoint; + + @Bean + public AmazonS3 s3Client() { + BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder + .standard() + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endPoint, region)) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} diff --git a/src/main/java/com/nexters/goalpanzi/domain/auth/RefreshTokenRepository.java b/src/main/java/com/nexters/goalpanzi/domain/auth/repository/RefreshTokenRepository.java similarity index 71% rename from src/main/java/com/nexters/goalpanzi/domain/auth/RefreshTokenRepository.java rename to src/main/java/com/nexters/goalpanzi/domain/auth/repository/RefreshTokenRepository.java index 8bf06eee..0d5281a2 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/auth/RefreshTokenRepository.java +++ b/src/main/java/com/nexters/goalpanzi/domain/auth/repository/RefreshTokenRepository.java @@ -1,4 +1,4 @@ -package com.nexters.goalpanzi.domain.auth; +package com.nexters.goalpanzi.domain.auth.repository; import com.nexters.goalpanzi.infrastructure.common.RedisRepository; diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/MissionMember.java b/src/main/java/com/nexters/goalpanzi/domain/mission/MissionMember.java new file mode 100644 index 00000000..bb65da38 --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/MissionMember.java @@ -0,0 +1,35 @@ +package com.nexters.goalpanzi.domain.mission; + +import com.nexters.goalpanzi.domain.common.BaseEntity; +import com.nexters.goalpanzi.domain.member.Member; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mission_member") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class MissionMember extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "mission_member_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mission_id", nullable = false) + private Mission mission; + + @Column(name = "verification_count") + private Integer verificationCount; + + public void verify() { + this.verificationCount++; + } +} diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/MissionVerification.java b/src/main/java/com/nexters/goalpanzi/domain/mission/MissionVerification.java new file mode 100644 index 00000000..89263305 --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/MissionVerification.java @@ -0,0 +1,40 @@ +package com.nexters.goalpanzi.domain.mission; + +import com.nexters.goalpanzi.domain.common.BaseEntity; +import com.nexters.goalpanzi.domain.member.Member; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mission_verification") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class MissionVerification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "mission_verification_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mission_id", nullable = false) + private Mission mission; + + @Column(name = "image_url") + private String imageUrl; + + @Column(name = "board_number") + private Integer boardNumber; + + public MissionVerification(final Member member, final Mission mission, final String imageUrl) { + this.member = member; + this.mission = mission; + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionMemberRepository.java b/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionMemberRepository.java new file mode 100644 index 00000000..400e11fa --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionMemberRepository.java @@ -0,0 +1,15 @@ +package com.nexters.goalpanzi.domain.mission.repository; + +import com.nexters.goalpanzi.domain.mission.MissionMember; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface MissionMemberRepository extends JpaRepository { + Optional findByMemberIdAndMissionId(final Long memberId, final Long missionId); + + List findAllByMissionId(final Long MissionId); +} diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java b/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java new file mode 100644 index 00000000..41dfd26b --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java @@ -0,0 +1,21 @@ +package com.nexters.goalpanzi.domain.mission.repository; + +import com.nexters.goalpanzi.domain.mission.MissionVerification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface MissionVerificationRepository extends JpaRepository { + Optional findByMemberIdAndMissionIdAndBoardNumber(final Long memberId, final Long missionId, final Integer boardNumber); + + @Query(value = "SELECT * FROM mission_verification mv WHERE mv.mission_id = :missionId AND DATE(mv.created_at) = :date", nativeQuery = true) + List findAllByMissionIdAndDate(final Long missionId, final LocalDate date); + + @Query(value = "SELECT * FROM mission_verification mv WHERE mv.member_id = :memberId AND mv.mission_id = :missionId AND DATE(mv.created_at) = :date", nativeQuery = true) + Optional findByMemberIdAndMissionIdAndDate(final Long memberId, final Long missionId, final LocalDate date); +} \ No newline at end of file diff --git a/src/main/java/com/nexters/goalpanzi/exception/BadRequestException.java b/src/main/java/com/nexters/goalpanzi/exception/BadRequestException.java index 0f8fc1d0..bc85c52b 100644 --- a/src/main/java/com/nexters/goalpanzi/exception/BadRequestException.java +++ b/src/main/java/com/nexters/goalpanzi/exception/BadRequestException.java @@ -1,6 +1,11 @@ package com.nexters.goalpanzi.exception; public class BadRequestException extends BaseException { + + public BadRequestException(final ErrorCode errorCode) { + super(errorCode.getMessage()); + } + public BadRequestException(final String message) { super(message); } diff --git a/src/main/java/com/nexters/goalpanzi/exception/ErrorCode.java b/src/main/java/com/nexters/goalpanzi/exception/ErrorCode.java index aea0db76..c1bc27a7 100644 --- a/src/main/java/com/nexters/goalpanzi/exception/ErrorCode.java +++ b/src/main/java/com/nexters/goalpanzi/exception/ErrorCode.java @@ -18,7 +18,16 @@ public enum ErrorCode { // MEMBER NOT_FOUND_MEMBER("존재하지 않는 회원입니다"), - ALREADY_EXIST_NICKNAME("이미 존재하는 회원 닉네임입니다"); + ALREADY_EXIST_NICKNAME("이미 존재하는 회원 닉네임입니다"), + + // MISSION VERIFICATION + NOT_FOUND_VERIFICATION("존재하지 않는 미션 인증입니다."), + DUPLICATE_VERIFICATION("이미 인증한 미션이므로 더 이상 인증할 수 없습니다."), + ALREADY_COMPLETED_MISSION("이미 완료된 미션이므로 더 이상 인증할 수 없습니다."), + + // FILE UPLOAD + INVALID_FILE("유효하지 않은 파일입니다."), + FILE_UPLOAD_FAILED("파일 업로드에 실패했습니다."); private String message; } diff --git a/src/main/java/com/nexters/goalpanzi/infrastructure/auth/RefreshTokenRepositoryImpl.java b/src/main/java/com/nexters/goalpanzi/infrastructure/auth/RefreshTokenRepositoryImpl.java index 12864bde..16fbf8be 100644 --- a/src/main/java/com/nexters/goalpanzi/infrastructure/auth/RefreshTokenRepositoryImpl.java +++ b/src/main/java/com/nexters/goalpanzi/infrastructure/auth/RefreshTokenRepositoryImpl.java @@ -1,6 +1,6 @@ package com.nexters.goalpanzi.infrastructure.auth; -import com.nexters.goalpanzi.domain.auth.RefreshTokenRepository; +import com.nexters.goalpanzi.domain.auth.repository.RefreshTokenRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; diff --git a/src/main/java/com/nexters/goalpanzi/infrastructure/ncp/ObjectStorageManager.java b/src/main/java/com/nexters/goalpanzi/infrastructure/ncp/ObjectStorageManager.java new file mode 100644 index 00000000..dfc248d1 --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/infrastructure/ncp/ObjectStorageManager.java @@ -0,0 +1,67 @@ +package com.nexters.goalpanzi.infrastructure.ncp; + +import com.amazonaws.SdkClientException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.transfer.TransferManager; +import com.amazonaws.services.s3.transfer.TransferManagerBuilder; +import com.amazonaws.services.s3.transfer.Upload; +import com.nexters.goalpanzi.exception.BadRequestException; +import com.nexters.goalpanzi.exception.BaseException; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.util.UUID; + +import static com.nexters.goalpanzi.exception.ErrorCode.FILE_UPLOAD_FAILED; +import static com.nexters.goalpanzi.exception.ErrorCode.INVALID_FILE; + +@RequiredArgsConstructor +@Component +public class ObjectStorageManager { + + @Value("${cloud.aws.credentials.bucket}") + private String bucketName; + + private final AmazonS3 s3Client; + + private TransferManager tm; + + @PostConstruct + private void buildTransferManager() { + tm = TransferManagerBuilder.standard().withS3Client(s3Client).build(); + } + + public String uploadFile(final MultipartFile file) { + String fileObjKeyName = UUID.randomUUID().toString(); + try { + Upload upload = tm.upload(bucketName, fileObjKeyName, convert(file)); + upload.waitForCompletion(); + + return s3Client.getUrl(bucketName, fileObjKeyName).toString(); + } catch (SdkClientException | InterruptedException e) { + throw new BaseException(FILE_UPLOAD_FAILED); + } + } + + public void deleteFile(final String uploadedFileUrl) { + if (uploadedFileUrl != null) { + String fileObjKeyName = uploadedFileUrl.substring(uploadedFileUrl.lastIndexOf("/") + 1); + s3Client.deleteObject(bucketName, fileObjKeyName); + } + } + + private File convert(final MultipartFile originalFile) { + try { + File tempFile = File.createTempFile("temp", null); + originalFile.transferTo(tempFile); + return tempFile; + } catch (IOException e) { + throw new BadRequestException(INVALID_FILE); + } + } +} diff --git a/src/main/java/com/nexters/goalpanzi/presentation/auth/AuthControllerDocs.java b/src/main/java/com/nexters/goalpanzi/presentation/auth/AuthControllerDocs.java index cb9c36fb..979efbfd 100644 --- a/src/main/java/com/nexters/goalpanzi/presentation/auth/AuthControllerDocs.java +++ b/src/main/java/com/nexters/goalpanzi/presentation/auth/AuthControllerDocs.java @@ -4,6 +4,8 @@ import com.nexters.goalpanzi.common.argumentresolver.LoginMemberId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -30,9 +32,6 @@ ResponseEntity loginApple( ); @Operation(summary = "Google 로그인", description = "Google 로그인을 처리합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그인 성공"), - }) @PostMapping("/login/google") ResponseEntity loginGoogle( @RequestBody @Valid final GoogleLoginCommand googleLoginCommand @@ -40,17 +39,16 @@ ResponseEntity loginGoogle( @Operation(summary = "로그아웃", description = "로그아웃합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그아웃 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401"), }) @PostMapping("/logout") ResponseEntity logout(@Parameter(hidden = true) @LoginMemberId final String userKey); @Operation(summary = "토큰 재발급", description = "access 토큰과 refresh 토큰을 재발급합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "토큰 재발급 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패 - 갱신되어 유효하지 않은 refresh 토큰"), - @ApiResponse(responseCode = "401", description = "인증 실패 - 만료된 refresh 토큰") + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", description = "Unauthorized - 만료된 refresh 토큰이거나 갱신되어 더 이상 유효하지 않은 refresh 토큰", content = @Content(schema = @Schema(hidden = true))), }) @PostMapping("/token:reissue") ResponseEntity reissueToken( diff --git a/src/main/java/com/nexters/goalpanzi/presentation/mission/MissionVerificationController.java b/src/main/java/com/nexters/goalpanzi/presentation/mission/MissionVerificationController.java new file mode 100644 index 00000000..a7f8b9e4 --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/presentation/mission/MissionVerificationController.java @@ -0,0 +1,56 @@ +package com.nexters.goalpanzi.presentation.mission; + +import com.nexters.goalpanzi.application.mission.MissionVerificationService; +import com.nexters.goalpanzi.application.mission.dto.MissionVerificationCommand; +import com.nexters.goalpanzi.application.mission.dto.MissionVerificationResponse; +import com.nexters.goalpanzi.application.mission.dto.MyMissionVerificationCommand; +import com.nexters.goalpanzi.common.argumentresolver.LoginMemberId; +import com.nexters.goalpanzi.presentation.mission.dto.CreateMissionVerificationRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@RequestMapping("/api/missions/{missionId}/verifications") +@RestController +public class MissionVerificationController implements MissionVerificationControllerDocs { + + private final MissionVerificationService missionVerificationService; + + @GetMapping + public ResponseEntity> getVerifications( + @LoginMemberId final Long memberId, + @PathVariable(name = "missionId") final Long missionId, + @RequestParam(name = "date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) final LocalDate date + ) { + List response = missionVerificationService.getVerifications(new MissionVerificationCommand(memberId, missionId, date)); + + return ResponseEntity.ok(response); + } + + @GetMapping("/me/{number}") + public ResponseEntity getMyVerification( + @LoginMemberId final Long memberId, + @PathVariable(name = "missionId") final Long missionId, + @PathVariable(name = "number") final Integer number) { + MissionVerificationResponse response = missionVerificationService.getMyVerification( + new MyMissionVerificationCommand(memberId, missionId, number)); + + return ResponseEntity.ok(response); + } + + @PostMapping("/me") + public ResponseEntity createVerification( + @LoginMemberId final Long memberId, + @PathVariable(name = "missionId") final Long missionId, + @RequestBody @Valid final CreateMissionVerificationRequest request) { + missionVerificationService.createVerification(request.toServiceDto(memberId, missionId)); + + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/nexters/goalpanzi/presentation/mission/MissionVerificationControllerDocs.java b/src/main/java/com/nexters/goalpanzi/presentation/mission/MissionVerificationControllerDocs.java new file mode 100644 index 00000000..379d583f --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/presentation/mission/MissionVerificationControllerDocs.java @@ -0,0 +1,72 @@ +package com.nexters.goalpanzi.presentation.mission; + +import com.nexters.goalpanzi.application.mission.dto.MissionVerificationResponse; +import com.nexters.goalpanzi.common.argumentresolver.LoginMemberId; +import com.nexters.goalpanzi.presentation.mission.dto.CreateMissionVerificationRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@Tag( + name = "미션 인증", + description = """ + 미션 인증과 관련된 그룹입니다. + + 미션 인증을 위한 사진을 업로드하고 조회합니다. + """ +) +public interface MissionVerificationControllerDocs { + + @Operation(summary = "미션 인증 현황 조회", description = "해당 일자의 미션 인증 현황을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + }) + @GetMapping + ResponseEntity> getVerifications( + @Parameter(hidden = true) @LoginMemberId final Long memberId, + @Schema(description = "미션 아이디", type = "integer", format = "int64", requiredMode = Schema.RequiredMode.REQUIRED) + @PathVariable(name = "missionId") final Long missionId, + @Schema(description = "미션 인증 일자", type = "string", format = "date", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @RequestParam(name = "date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) final LocalDate date + ); + + @Operation(summary = "나의 미션 인증 현황 조회", description = "보드판에 해당하는 미션 인증 현황을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", description = "Not Found - 정보에 해당하는 이미지가 존재하지 않음", content = @Content(schema = @Schema(hidden = true))), + }) + @GetMapping("/me/{number}") + ResponseEntity getMyVerification( + @Parameter(hidden = true) @LoginMemberId final Long memberId, + @Schema(description = "미션 아이디", type = "integer", format = "int64", requiredMode = Schema.RequiredMode.REQUIRED) + @PathVariable(name = "missionId") final Long missionId, + @Schema(description = "보드판 번호", type = "integer", format = "int32", requiredMode = Schema.RequiredMode.REQUIRED) + @PathVariable(name = "number") final Integer number); + + @Operation(summary = "미션 인증", description = "미션 인증을 위해 이미지를 업로드합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", description = "Bad Request - 이미 완료한 미션이거나 오늘 인증을 마쳤음"), + @ApiResponse(responseCode = "401"), + @ApiResponse(responseCode = "404", description = "Not Found - 정보에 해당하는 미션이 존재하지 않음"), + }) + @PostMapping("/me") + ResponseEntity createVerification( + @Parameter(hidden = true) @LoginMemberId final Long memberId, + @Schema(description = "미션 아이디", type = "integer", format = "int64", requiredMode = Schema.RequiredMode.REQUIRED) + @PathVariable(name = "missionId") final Long missionId, + @RequestBody @Valid final CreateMissionVerificationRequest request); +} \ No newline at end of file diff --git a/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/CreateMissionVerificationRequest.java b/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/CreateMissionVerificationRequest.java new file mode 100644 index 00000000..1bb47783 --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/presentation/mission/dto/CreateMissionVerificationRequest.java @@ -0,0 +1,16 @@ +package com.nexters.goalpanzi.presentation.mission.dto; + +import com.nexters.goalpanzi.application.mission.dto.CreateMissionVerificationCommand; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import org.springframework.web.multipart.MultipartFile; + +public record CreateMissionVerificationRequest( + @Schema(description = "인증 이미지 파일", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull MultipartFile imageFile +) { + + public CreateMissionVerificationCommand toServiceDto(final Long memberId, final Long missionId) { + return new CreateMissionVerificationCommand(memberId, missionId, imageFile); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0976b76a..0ac9d074 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,4 +30,18 @@ springdoc: oauth: apple: iss: https://appleid.apple.com - client-id: ${OAUTH_APPLE_CLIENT_ID} \ No newline at end of file + client-id: ${OAUTH_APPLE_CLIENT_ID} + +cloud: + aws: + credentials: + access-key: ${OBJECT_STORAGE_ACCESS_KEY_ID} + secret-key: ${OBJECT_STORAGE_SECRET_KEY} + bucket: ${OBJECT_STORAGE_BUCKET_NAME} + region: + static: ap-southeast-2 + auto: false + stack: + auto: false + s3: + endpoint: https://kr.object.ncloudstorage.com \ No newline at end of file diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index d8eb1a9e..1a781241 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -30,4 +30,45 @@ create table if not exists mission mission_start_date datetime(6) not null, upload_end_time varchar(255) not null, upload_start_time varchar(255) not null -); \ No newline at end of file +); + +create table if not exists mission_verification +( + mission_verification_id bigint auto_increment + primary key, + created_at datetime(6), + deleted_at datetime(6), + updated_at datetime(6), + board_number int, + image_url varchar(255), + member_id bigint not null, + mission_id bigint not null, + constraint fk_mission_verification__member_id + foreign key (member_id) + references member (member_id) + on delete cascade, + constraint fk_mission_verification__mission_id + foreign key (mission_id) + references mission (mission_id) + on delete cascade +); + +create table if not exists mission_member +( + mission_member_id bigint auto_increment + primary key, + created_at datetime(6), + deleted_at datetime(6), + updated_at datetime(6), + member_id bigint not null, + mission_id bigint not null, + verification_count int, + constraint fk_mission_member__member_id + foreign key (member_id) + references member (member_id) + on delete cascade, + constraint fk_mission_member__mission_id + foreign key (mission_id) + references mission (mission_id) + on delete cascade +); diff --git a/src/test/java/com/nexters/goalpanzi/config/jwt/JwtProviderTest.java b/src/test/java/com/nexters/goalpanzi/config/jwt/JwtProviderTest.java index ef9444ff..c013a269 100644 --- a/src/test/java/com/nexters/goalpanzi/config/jwt/JwtProviderTest.java +++ b/src/test/java/com/nexters/goalpanzi/config/jwt/JwtProviderTest.java @@ -29,7 +29,7 @@ public class JwtProviderTest { Jwt jwt = jwtProvider.generateTokens(MEMBER_ID.toString()); System.out.println(jwt.accessToken()); - + assertThat(jwt).isNotNull(); } diff --git a/src/test/java/com/nexters/goalpanzi/infrastructure/auth/RefreshTokenRepositoryImplTest.java b/src/test/java/com/nexters/goalpanzi/infrastructure/auth/RefreshTokenRepositoryImplTest.java index 3e3c83ee..5aa73d5f 100644 --- a/src/test/java/com/nexters/goalpanzi/infrastructure/auth/RefreshTokenRepositoryImplTest.java +++ b/src/test/java/com/nexters/goalpanzi/infrastructure/auth/RefreshTokenRepositoryImplTest.java @@ -1,6 +1,6 @@ //package com.nexters.goalpanzi.infrastructure.auth; // -//import com.nexters.goalpanzi.domain.auth.RefreshTokenRepository; +//import com.nexters.goalpanzi.domain.auth.repository.RefreshTokenRepository; //import org.junit.jupiter.api.BeforeEach; //import org.junit.jupiter.api.Test; //import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/test/java/com/nexters/goalpanzi/infrastructure/ncp/ObjectStorageManagerTest.java b/src/test/java/com/nexters/goalpanzi/infrastructure/ncp/ObjectStorageManagerTest.java new file mode 100644 index 00000000..d09d788b --- /dev/null +++ b/src/test/java/com/nexters/goalpanzi/infrastructure/ncp/ObjectStorageManagerTest.java @@ -0,0 +1,61 @@ +//package com.nexters.goalpanzi.infrastructure.ncp; +// +//import org.junit.jupiter.api.AfterEach; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.mock.web.MockMultipartFile; +//import org.springframework.web.multipart.MultipartFile; +// +//import java.io.File; +//import java.io.FileInputStream; +//import java.io.FileOutputStream; +//import java.io.IOException; +// +//import static org.assertj.core.api.Assertions.assertThat; +// +//@SpringBootTest +//class ObjectStorageManagerTest { +// +// @Autowired +// private ObjectStorageManager objectStorageManager; +// +// private File tempFile; +// private MultipartFile imageFile; +// private String uploadedFileUrl; +// +// @BeforeEach +// void 테스트를_위한_이미지_파일을_생성한다() throws IOException { +// tempFile = File.createTempFile("test-image", ".jpg"); +// +// try (FileOutputStream fos = new FileOutputStream(tempFile)) { +// fos.write("이미지 파일 내용".getBytes()); +// } +// +// imageFile = new MockMultipartFile( +// "file", +// tempFile.getName(), +// "image/jpeg", +// new FileInputStream(tempFile) +// ); +// } +// +// @AfterEach +// public void 테스트_후_업로드된_이미지_파일을_삭제한다() { +// objectStorageManager.deleteFile(uploadedFileUrl); +// +// if (tempFile != null && tempFile.exists()) { +// tempFile.delete(); +// } +// } +// +// @Test +// public void 파일을_업로드한다() { +// uploadedFileUrl = objectStorageManager.uploadFile(imageFile); +// +// assertThat(uploadedFileUrl).isNotNull(); +// +// System.out.println(uploadedFileUrl); +// } +//} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 2065130a..da4102da 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -20,4 +20,18 @@ jwt: oauth: apple: iss: iss - client-id: aud \ No newline at end of file + client-id: aud + +cloud: + aws: + credentials: + access-key: ${OBJECT_STORAGE_ACCESS_KEY_ID} + secret-key: ${OBJECT_STORAGE_SECRET_KEY} + bucket: ${OBJECT_STORAGE_BUCKET_NAME} + region: + static: ap-southeast-2 + auto: false + stack: + auto: false + s3: + endpoint: https://kr.object.ncloudstorage.com \ No newline at end of file diff --git a/src/test/resources/truncate.sql b/src/test/resources/truncate.sql deleted file mode 100644 index b1a6bfb3..00000000 --- a/src/test/resources/truncate.sql +++ /dev/null @@ -1,2 +0,0 @@ -TRUNCATE TABLE member; -TRUNCATE TABLE mission; \ No newline at end of file