Skip to content

Commit 9d827a7

Browse files
authored
[BACKEND] 전문가 인증 관련 기능 및 이미지 업로드 기능 추가 (#99)
## 📝작업 내용 - S3 파일 업로드 기능 추가 (presigned_url) - Certification 관련 저장/승인/거절/조회 기능 추가
1 parent 691cb4d commit 9d827a7

File tree

20 files changed

+500
-11
lines changed

20 files changed

+500
-11
lines changed

backend/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ dependencies {
4646
implementation 'com.github.ben-manes.caffeine:caffeine'
4747

4848
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
49+
50+
implementation 'software.amazon.awssdk:s3:2.25.27'
4951
}
5052

5153
tasks.named('test') {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.cmg.comtogether.certification.controller;
2+
3+
import com.cmg.comtogether.certification.dto.CertificationCreateRequestDto;
4+
import com.cmg.comtogether.certification.dto.CertificationResponseDto;
5+
import com.cmg.comtogether.certification.service.CertificationService;
6+
import com.cmg.comtogether.common.response.ApiResponse;
7+
import com.cmg.comtogether.common.security.CustomUserDetails;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
12+
import org.springframework.web.bind.annotation.*;
13+
14+
import java.util.List;
15+
16+
@RestController
17+
@RequestMapping("/certification")
18+
@RequiredArgsConstructor
19+
public class CertificationController {
20+
21+
private final CertificationService certificationService;
22+
23+
@PostMapping
24+
public ResponseEntity<ApiResponse<CertificationResponseDto>> createCertification(
25+
@AuthenticationPrincipal CustomUserDetails userDetails,
26+
@Valid @RequestBody CertificationCreateRequestDto requestDto
27+
) {
28+
CertificationResponseDto responseDto = certificationService.createCertification(userDetails.getUser().getUserId(), requestDto.getFileKey());
29+
return ResponseEntity.ok(ApiResponse.success(responseDto));
30+
}
31+
32+
@GetMapping("/me")
33+
public ResponseEntity<ApiResponse<List<CertificationResponseDto>>> getMyCertifications(
34+
@AuthenticationPrincipal CustomUserDetails userDetails
35+
) {
36+
Long userId = userDetails.getUser().getUserId();
37+
return ResponseEntity.ok(ApiResponse.success(
38+
certificationService.getCertifications(userId)
39+
));
40+
}
41+
42+
@GetMapping("/all")
43+
public ResponseEntity<ApiResponse<List<CertificationResponseDto>>> getAllCertifications() {
44+
return ResponseEntity.ok(ApiResponse.success(
45+
certificationService.getAllCertifications()
46+
));
47+
}
48+
49+
@PatchMapping("/{certId}/approve")
50+
public ResponseEntity<ApiResponse<Void>> approveCertification(@PathVariable Long certId) {
51+
certificationService.approveCertification(certId);
52+
return ResponseEntity.ok(ApiResponse.success("인증이 승인되었습니다.", null));
53+
}
54+
55+
@PatchMapping("/{certId}/reject")
56+
public ResponseEntity<ApiResponse<Void>> rejectCertification(
57+
@PathVariable Long certId,
58+
@RequestParam(required = false) String reason
59+
) {
60+
certificationService.rejectCertification(certId, reason);
61+
return ResponseEntity.ok(ApiResponse.success("인증이 거절되었습니다.", null));
62+
}
63+
64+
@DeleteMapping("/{certId}")
65+
public ApiResponse<Void> deleteCertification(
66+
@PathVariable Long certId,
67+
@AuthenticationPrincipal CustomUserDetails userDetails
68+
) {
69+
certificationService.deleteCertification(certId, userDetails.getUser().getUserId());
70+
return ApiResponse.success(null);
71+
}
72+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.cmg.comtogether.certification.dto;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import jakarta.validation.constraints.NotBlank;
5+
import lombok.Getter;
6+
7+
@Getter
8+
public class CertificationCreateRequestDto {
9+
10+
@NotBlank
11+
@JsonProperty("file_key")
12+
private String fileKey;
13+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.cmg.comtogether.certification.dto;
2+
3+
import com.cmg.comtogether.certification.entity.Certification;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
8+
import java.time.LocalDateTime;
9+
10+
@Getter
11+
@Builder
12+
@AllArgsConstructor
13+
public class CertificationResponseDto {
14+
private Long certId;
15+
private Long userId;
16+
private String fileUrl;
17+
private String status;
18+
private LocalDateTime requestedAt;
19+
private String reason;
20+
21+
public static CertificationResponseDto fromEntity(Certification cert, String publicUrl) {
22+
return CertificationResponseDto.builder()
23+
.certId(cert.getCertId())
24+
.userId(cert.getUserId())
25+
.fileUrl(publicUrl)
26+
.status(cert.getStatus().name())
27+
.requestedAt(cert.getRequestedAt())
28+
.build();
29+
}
30+
}
31+
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.cmg.comtogether.certification.entity;
2+
3+
import com.cmg.comtogether.common.exception.BusinessException;
4+
import com.cmg.comtogether.common.exception.ErrorCode;
5+
import jakarta.persistence.*;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Builder;
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
11+
import java.time.LocalDateTime;
12+
13+
@Entity
14+
@Getter
15+
@Builder
16+
@NoArgsConstructor
17+
@AllArgsConstructor
18+
public class Certification {
19+
20+
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
21+
private Long certId;
22+
23+
private Long userId;
24+
25+
private String fileKey;
26+
27+
@Enumerated(EnumType.STRING)
28+
private Status status;
29+
30+
private String rejectionReason;
31+
32+
private LocalDateTime requestedAt;
33+
34+
public enum Status {
35+
PENDING, APPROVED, REJECTED
36+
}
37+
38+
public void approve() {
39+
if (this.status == Status.APPROVED)
40+
throw new BusinessException(ErrorCode.CERTIFICATION_ALREADY_APPROVED);
41+
this.status = Status.APPROVED;
42+
this.rejectionReason = null;
43+
}
44+
45+
public void reject(String reason) {
46+
if (this.status == Status.REJECTED)
47+
throw new BusinessException(ErrorCode.CERTIFICATION_ALREADY_REJECTED);
48+
this.status = Status.REJECTED;
49+
this.rejectionReason = reason;
50+
}
51+
}
52+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.cmg.comtogether.certification.repository;
2+
3+
import com.cmg.comtogether.certification.entity.Certification;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.stereotype.Repository;
6+
7+
import java.util.List;
8+
9+
@Repository
10+
public interface CertificationRepository extends JpaRepository<Certification, Long> {
11+
List<Certification> findAllByUserIdOrderByRequestedAtDesc(Long userId);
12+
List<Certification> findAllByOrderByRequestedAtDesc();
13+
}
14+
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.cmg.comtogether.certification.service;
2+
3+
import com.cmg.comtogether.certification.repository.CertificationRepository;
4+
import com.cmg.comtogether.certification.dto.CertificationResponseDto;
5+
import com.cmg.comtogether.certification.entity.Certification;
6+
import com.cmg.comtogether.common.exception.BusinessException;
7+
import com.cmg.comtogether.common.exception.ErrorCode;
8+
import com.cmg.comtogether.common.s3.service.S3Service;
9+
import com.cmg.comtogether.user.service.UserService;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
import java.time.LocalDateTime;
15+
import java.util.List;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
public class CertificationService {
20+
21+
private final CertificationRepository certificationRepository;
22+
private final UserService userService;
23+
private final S3Service s3Service;
24+
25+
public CertificationResponseDto createCertification(Long userId, String fileKey) {
26+
String publicUrl = s3Service.getPublicUrl(fileKey);
27+
28+
Certification cert = Certification.builder()
29+
.userId(userId)
30+
.fileKey(fileKey)
31+
.status(Certification.Status.PENDING)
32+
.requestedAt(LocalDateTime.now())
33+
.build();
34+
35+
certificationRepository.save(cert);
36+
37+
return CertificationResponseDto.fromEntity(cert, publicUrl);
38+
}
39+
40+
public List<CertificationResponseDto> getCertifications(Long userId) {
41+
return certificationRepository.findAllByUserIdOrderByRequestedAtDesc(userId)
42+
.stream()
43+
.map(c -> CertificationResponseDto.fromEntity(
44+
c,
45+
s3Service.getPublicUrl(c.getFileKey())
46+
))
47+
.toList();
48+
}
49+
50+
public List<CertificationResponseDto> getAllCertifications() {
51+
return certificationRepository.findAllByOrderByRequestedAtDesc()
52+
.stream()
53+
.map(c -> CertificationResponseDto.fromEntity(
54+
c,
55+
s3Service.getPublicUrl(c.getFileKey())
56+
))
57+
.toList();
58+
}
59+
60+
@Transactional
61+
public CertificationResponseDto approveCertification(Long certId) {
62+
Certification cert = certificationRepository.findById(certId)
63+
.orElseThrow(() -> new BusinessException(ErrorCode.CERTIFICATION_NOT_FOUND));
64+
cert.approve();
65+
userService.updateRoleToExpert(cert.getUserId());
66+
String publicUrl = s3Service.getPublicUrl(cert.getFileKey());
67+
68+
return CertificationResponseDto.fromEntity(cert, publicUrl);
69+
}
70+
71+
@Transactional
72+
public CertificationResponseDto rejectCertification(Long certId, String reason) {
73+
Certification cert = certificationRepository.findById(certId)
74+
.orElseThrow(() -> new BusinessException(ErrorCode.CERTIFICATION_NOT_FOUND));
75+
cert.reject(reason);
76+
String publicUrl = s3Service.getPublicUrl(cert.getFileKey());
77+
78+
return CertificationResponseDto.fromEntity(cert, publicUrl);
79+
}
80+
81+
@Transactional
82+
public void deleteCertification(Long certificationId, Long userId) {
83+
Certification c = certificationRepository.findById(certificationId)
84+
.orElseThrow(() -> new BusinessException(ErrorCode.CERTIFICATION_NOT_FOUND));
85+
86+
if (!c.getUserId().equals(userId)) {
87+
throw new BusinessException(ErrorCode.CERTIFICATION_ACCESS_DENIED);
88+
}
89+
90+
if (c.getFileKey() != null) {
91+
s3Service.deleteObject(c.getFileKey());
92+
}
93+
94+
certificationRepository.delete(c);
95+
}
96+
}

backend/src/main/java/com/cmg/comtogether/cache/CacheMonitorService.java renamed to backend/src/main/java/com/cmg/comtogether/common/cache/CacheMonitorService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.cmg.comtogether.cache;
1+
package com.cmg.comtogether.common.cache;
22

33
import com.github.benmanes.caffeine.cache.stats.CacheStats;
44
import lombok.RequiredArgsConstructor;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.cmg.comtogether.common.config;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
7+
import software.amazon.awssdk.regions.Region;
8+
import software.amazon.awssdk.services.s3.S3Client;
9+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
10+
11+
@Configuration
12+
public class S3Config {
13+
14+
@Value("${aws.s3.region:ap-southeast-2}")
15+
private String region;
16+
17+
@Bean
18+
public S3Presigner s3Presigner() {
19+
return S3Presigner.builder()
20+
.region(Region.of(region))
21+
.build();
22+
}
23+
24+
@Bean
25+
public S3Client s3Client() {
26+
return S3Client.builder()
27+
.region(Region.of(region))
28+
.credentialsProvider(DefaultCredentialsProvider.create())
29+
.build();
30+
}
31+
}

backend/src/main/java/com/cmg/comtogether/common/exception/ErrorCode.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public enum ErrorCode {
2828

2929
// 네이버 상품 API
3030
NAVER_API_ERROR(502, "NAVER-999", "네이버 서버와 통신 중 오류가 발생했습니다."),
31-
31+
3232
// 가이드
3333
GUIDE_NOT_FOUND(404, "GUIDE-001", "가이드를 찾을 수 없습니다"),
3434

@@ -44,8 +44,17 @@ public enum ErrorCode {
4444

4545
// 검색 기록
4646
HISTORY_NOT_FOUND(404, "HISTORY-001", "검색 기록을 찾을 수 없습니다."),
47-
HISTORY_ACCESS_DENIED(403, "HISTORY-002", "검색 기록에 대한 접근 권한이 없습니다.");
48-
47+
HISTORY_ACCESS_DENIED(403, "HISTORY-002", "검색 기록에 대한 접근 권한이 없습니다."),
48+
49+
// 전문가 인증
50+
CERTIFICATION_NOT_FOUND(404, "CERTIFICATION-001" , "해당 인증을 찾을 수 없습니다." ),
51+
CERTIFICATION_ALREADY_APPROVED(400, "CERTIFICATION-002", "이미 승인된 인증입니다."),
52+
CERTIFICATION_ALREADY_REJECTED(400, "CERTIFICATION-003", "이미 거절된 인증입니다."),
53+
CERTIFICATION_ACCESS_DENIED(403, "CERTIFICATION-004", "해당 인증서에 대한 접근 권한이 없습니다."),
54+
55+
// 업로드
56+
INVALID_UPLOAD_TYPE(400,"UPLOAD-001", "올바르지 않은 업로드 타입입니다.");
57+
4958

5059
private final int status;
5160
private final String code;

0 commit comments

Comments
 (0)