Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 미션 인증 생성/조회 구현 #19

Merged
merged 38 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5311453
feat: 미션 생성 API 구현
songyi00 Jul 27, 2024
57f4ea5
feat:wip mission dto 분리
songyi00 Jul 27, 2024
8a425d9
feat: dto 레이어별로 분리
songyi00 Jul 27, 2024
eb04a4f
refactor: dto 네이밍 변경
songyi00 Jul 28, 2024
b30aa5b
feat: 생성자 추가
kimyu0218 Jul 27, 2024
9703eac
feat: 미션 인증과 관련된 에러 코드 추가
kimyu0218 Jul 27, 2024
6428bc3
feat: 미션 인증 entity 정의
kimyu0218 Jul 27, 2024
3791c3f
feat: 미션 인증 repository 정의
kimyu0218 Jul 27, 2024
58ef30d
feat: 미션 인증 dto 정의
kimyu0218 Jul 27, 2024
c45e9ff
feat: 미션 인증 service 정의
kimyu0218 Jul 27, 2024
294791e
feat: 미션 인증 controller 정의
kimyu0218 Jul 27, 2024
a2ebcc8
docs: swagger 수정
kimyu0218 Jul 27, 2024
ea17cba
fix: param -> path variable
kimyu0218 Jul 27, 2024
17966be
move: domain 하위 repository 디렉토리로 이동
kimyu0218 Jul 30, 2024
b2dbfa3
refactor: dto 대신 필드에 의존하도록 수정
kimyu0218 Jul 30, 2024
b7cc0e9
fix: 변경된 디렉토리 위치 반영
kimyu0218 Jul 30, 2024
1bb8506
rename: dto 이름 변경
kimyu0218 Jul 30, 2024
5ea942d
feat: 서비스 계층 dto 정의
kimyu0218 Jul 30, 2024
74cb4e1
move: domain 하위 repository 디렉토리로 이동
kimyu0218 Jul 30, 2024
1b7b604
feat: 오늘 일자의 미션 인증 현황 조회 구현
kimyu0218 Jul 30, 2024
2c5c9f6
feat: 미션 인증 조회 응답 dto 정의
kimyu0218 Jul 30, 2024
c33782e
remove: merge 과정에서 잘못된 부분 제거
kimyu0218 Jul 30, 2024
150dd53
docs: 미션 인증 및 상태 ddl 작성
kimyu0218 Jul 30, 2024
ab738e7
docs: swagger 문서 보완
kimyu0218 Jul 30, 2024
1ce33f3
chore: object storage를 위한 설정 추가
kimyu0218 Aug 2, 2024
69545af
feat: 이미지 URL에서 multipart file로 변경
kimyu0218 Aug 2, 2024
13832c6
feat: 파일 업로드 시에 발생할 수 있는 에러 코드 정의
kimyu0218 Aug 2, 2024
7306c72
feat: ObjectStorageManager 구현
kimyu0218 Aug 2, 2024
d09b890
test: ObjectStorageManager 테스트 작성
kimyu0218 Aug 2, 2024
227568e
feat: NCP 관련 configuration 작성
kimyu0218 Aug 2, 2024
a6ccc9a
rename: mission_status -> mission_status
kimyu0218 Aug 2, 2024
9c80d4f
refactor: 미션 인증 일자를 위한 date 추가
kimyu0218 Aug 2, 2024
bf2b16a
refactor: 인증 여부를 나타내는 flag 추가
kimyu0218 Aug 2, 2024
d1aa961
feat: 정렬 기준 수정
kimyu0218 Aug 2, 2024
cc336e2
comment: object storage 테스트 주석 처리
kimyu0218 Aug 2, 2024
7824deb
docs: swagger 문서 보완
kimyu0218 Aug 2, 2024
73a458e
remove: 불필요한 필드 제거
kimyu0218 Aug 2, 2024
8cd72c2
remove: 불필요한 삼항 연산자 제거
kimyu0218 Aug 2, 2024
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 build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MissionVerificationResponse> 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<MissionMember> missionMembers = missionMemberRepository.findAllByMissionId(command.missionId());
List<MissionVerification> verifications = missionVerificationRepository.findAllByMissionIdAndDate(command.missionId(), date);

Map<Long, MissionVerification> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.nexters.goalpanzi.application.mission.dto;

import java.time.LocalDate;

public record MissionVerificationCommand(
Long memberId,
Long missionId,
LocalDate date
) {
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.nexters.goalpanzi.application.mission.dto;

public record MyMissionVerificationCommand(
Long memberId,
Long missionId,
Integer number
) {
}
36 changes: 36 additions & 0 deletions src/main/java/com/nexters/goalpanzi/config/NcpConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.nexters.goalpanzi.domain.auth;
package com.nexters.goalpanzi.domain.auth.repository;

import com.nexters.goalpanzi.infrastructure.common.RedisRepository;

Expand Down
Original file line number Diff line number Diff line change
@@ -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++;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<MissionMember, Long> {
Optional<MissionMember> findByMemberIdAndMissionId(final Long memberId, final Long missionId);

List<MissionMember> findAllByMissionId(final Long MissionId);
}
Original file line number Diff line number Diff line change
@@ -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<MissionVerification, Long> {
Optional<MissionVerification> 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<MissionVerification> 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<MissionVerification> findByMemberIdAndMissionIdAndDate(final Long memberId, final Long missionId, final LocalDate date);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down
11 changes: 10 additions & 1 deletion src/main/java/com/nexters/goalpanzi/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ public enum ErrorCode {

// MEMBER
NOT_FOUND_MEMBER("존재하지 않는 회원입니다"),
ALREADY_EXIST_NICKNAME("이미 존재하는 회원 닉네임입니다");
ALREADY_EXIST_NICKNAME("이미 존재하는 회원 닉네임입니다"),

// MISSION VERIFICATION
NOT_FOUND_VERIFICATION("존재하지 않는 미션 인증입니다."),
kimyu0218 marked this conversation as resolved.
Show resolved Hide resolved
DUPLICATE_VERIFICATION("이미 인증한 미션이므로 더 이상 인증할 수 없습니다."),
ALREADY_COMPLETED_MISSION("이미 완료된 미션이므로 더 이상 인증할 수 없습니다."),

// FILE UPLOAD
INVALID_FILE("유효하지 않은 파일입니다."),
FILE_UPLOAD_FAILED("파일 업로드에 실패했습니다.");

private String message;
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading