diff --git a/build.gradle b/build.gradle index 1bba461..0af49f5 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // AWS + implementation 'software.amazon.awssdk:s3:2.25.4' + // GitHub API implementation 'org.kohsuke:github-api:1.321' diff --git a/src/main/java/com/whylog/server/domain/decision/dto/DecisionResponse.java b/src/main/java/com/whylog/server/domain/decision/dto/DecisionResponse.java index cb3f87f..bf2f046 100644 --- a/src/main/java/com/whylog/server/domain/decision/dto/DecisionResponse.java +++ b/src/main/java/com/whylog/server/domain/decision/dto/DecisionResponse.java @@ -22,7 +22,7 @@ public static class DecisionListDTO { private String name; @Schema(description = "적용사항 개수", example = "3") - private Long applicationCount; + private Integer applicationCount; } @Getter diff --git a/src/main/java/com/whylog/server/domain/decision/entity/Decision.java b/src/main/java/com/whylog/server/domain/decision/entity/Decision.java index ad38923..114aafc 100644 --- a/src/main/java/com/whylog/server/domain/decision/entity/Decision.java +++ b/src/main/java/com/whylog/server/domain/decision/entity/Decision.java @@ -37,8 +37,8 @@ public class Decision extends BaseEntity { @Column(name = "is_created") private Boolean isCreated; -// @OneToMany(mappedBy = "decision", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List applications = new ArrayList<>(); + @OneToMany(mappedBy = "decision", cascade = CascadeType.ALL, orphanRemoval = true) + private final List applications = new ArrayList<>(); // // @OneToMany(mappedBy = "decision", cascade = CascadeType.ALL, orphanRemoval = true) // private final List decisionTimelines = new ArrayList<>(); diff --git a/src/main/java/com/whylog/server/domain/team/controller/TeamController.java b/src/main/java/com/whylog/server/domain/team/controller/TeamController.java index c88fa56..c063e2c 100644 --- a/src/main/java/com/whylog/server/domain/team/controller/TeamController.java +++ b/src/main/java/com/whylog/server/domain/team/controller/TeamController.java @@ -4,19 +4,28 @@ import com.whylog.server.domain.team.dto.TeamRequest; import com.whylog.server.domain.team.dto.TeamResponse; import com.whylog.server.domain.team.service.TeamCommandService; +import com.whylog.server.domain.team.service.TeamQueryService; import com.whylog.server.global.apiPayload.ApiResponse; import com.whylog.server.global.auth.annotation.CurrentMember; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Encoding; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; @RestController @RequestMapping("/api/teams") @@ -25,33 +34,77 @@ public class TeamController { private final TeamCommandService teamCommandService; + private final TeamQueryService teamQueryService; @GetMapping("/{teamId}/decisions") @Operation(summary = "결정사항 목록 조회 API", description = "특정 팀의 결정사항 목록을 조회하는 API입니다.") - public ApiResponse getDecisions( + public ApiResponse> getDecisions( @PathVariable Long teamId) { - return ApiResponse.onSuccess(null); + List decisions = teamQueryService.decisions(teamId); + return ApiResponse.onSuccess(decisions); } @PostMapping("/{teamId}/invitations") - @Operation(summary = "팀 초대 API", description = "특정 팀에 사용자를 초대하는 API입니다.") + @Operation(summary = "팀 초대 API", description = """ + + 특정 사용자를 팀에 초대합니다. 팀 초대를 하면 상대는 즉시 팀원으로 추가됩니다. + + | 상황 | HTTP Status | Code | Message | + | --- | --- | --- | --- | + | 성공 | 200 OK | COMMON200 | 성공입니다. | + | 이미 팀에 속한 사용자 | 409 Conflict | TEAM_409 | 이미 팀에 속한 사용자입니다. | + | 팀 없음 | 404 Not Found | TEAM_404 | 존재하지 않는 팀입니다. | + | 사용자 없음 | 404 Not Found | MEMBER_404 | 찾을 수 없는 유저입니다. | + + """) public ApiResponse sendInvitation( @PathVariable Long teamId, @Valid @RequestBody TeamRequest.InvitationDTO request) { - return ApiResponse.onSuccess(null); + TeamResponse.InvitationResponseDTO result = teamCommandService.invite(teamId, request); + return ApiResponse.onSuccess(result); } - @PostMapping + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "팀 생성 API", description = """ - 팀을 생성합니다. - 팀명은 50글자 미만입니다. - 팀 생성과 동시에 팀에 참여합니다. ( 따로 호출 X ) - """) + + ## 설명 + 팀을 생성합니다. 해당 api를 호출한 사람은 팀에 참여합니다. + + ## API 호출 방법 + + `multipart/form-data`로 요청합니다. + `request` 파트는 JSON Blob으로 추가하고, `image` 파트는 선택값입니다. + + | Part name | Required | Value | + | --- | --- | --- | + | `request` | O | `application/json` 타입의 JSON Blob | + | `image` | X | 이미지 파일 | + + ### request JSON + + ```json + { + "name": "팀명이어떻게다마고치" + } + ``` + + `Content-Type`은 직접 지정하지 않습니다. 브라우저가 boundary를 포함한 `multipart/form-data` 값을 자동으로 생성해야 합니다. + + """, + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(implementation = TeamRequest.TeamCreateMultipartDTO.class), + encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE) + ) + ) + ) public ApiResponse createTeam( @Parameter(hidden = true) @CurrentMember Long memberId, - @Valid @RequestBody TeamRequest.TeamCreateDTO request + @Parameter(hidden = true) @Valid @RequestPart("request") TeamRequest.TeamCreateDTO request, + @Parameter(hidden = true) @RequestPart(value = "image", required = false) MultipartFile image ) { - return ApiResponse.onSuccess(teamCommandService.createTeam(memberId, request)); + return ApiResponse.onSuccess(teamCommandService.createTeam(memberId, request, image)); } } diff --git a/src/main/java/com/whylog/server/domain/team/dto/TeamRequest.java b/src/main/java/com/whylog/server/domain/team/dto/TeamRequest.java index f2a6d43..6e62abd 100644 --- a/src/main/java/com/whylog/server/domain/team/dto/TeamRequest.java +++ b/src/main/java/com/whylog/server/domain/team/dto/TeamRequest.java @@ -16,7 +16,7 @@ public class TeamRequest { public static class InvitationDTO { @Schema(description = "초대받을 사용자 이메일", example = "member@example.com") - @NotBlank @Email + @Email @NotBlank private String memberEmail; } @@ -33,4 +33,18 @@ public static class TeamCreateDTO { } + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "팀 생성 multipart 요청") + public static class TeamCreateMultipartDTO { + + @Schema(description = "팀 생성 요청 JSON") + private TeamCreateDTO request; + + @Schema(description = "팀 이미지 파일", type = "string", format = "binary") + private String image; + } + } diff --git a/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java b/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java index 201feb0..f44da01 100644 --- a/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java +++ b/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java @@ -35,6 +35,9 @@ public static class TeamCreateResponseDTO { @Schema(description = "팀명", example = "팀명이 어떻게 다마고치") private String name; + @Schema(description = "팀 이미지 URL", example = "https://cdn.whylog.com/teams/team-image.png") + private String imageUrl; + } } diff --git a/src/main/java/com/whylog/server/domain/team/entity/Team.java b/src/main/java/com/whylog/server/domain/team/entity/Team.java index 06be70e..e226dc8 100644 --- a/src/main/java/com/whylog/server/domain/team/entity/Team.java +++ b/src/main/java/com/whylog/server/domain/team/entity/Team.java @@ -31,14 +31,19 @@ public class Team extends BaseEntity { @Column(length = 50, nullable = false, unique = true) private String name; + @Column(name = "image") + private String image; // s3 key + @Builder - private Team(String name) { + private Team(String name, String image) { this.name = name; + this.image = image; } - public static Team create(TeamRequest.TeamCreateDTO dto) { + public static Team create(TeamRequest.TeamCreateDTO dto, String image) { return Team.builder() .name(dto.getName()) + .image(image) .build(); } diff --git a/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java b/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java index 01f53cc..bbec6e2 100644 --- a/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java +++ b/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java @@ -15,6 +15,9 @@ public enum TeamErrorCode implements BaseErrorCode { // 400 Bad Request TEAM_NAME_LENGTH(HttpStatus.BAD_REQUEST, "TEAM_400", "팀명 길이는 50글자 미만이어야 합니다."), + // 409 Conflict + TEAM_MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "TEAM_409", "이미 팀에 속한 사용자입니다."), + // 422 Unprocessable Entity TEAM_NAME_ALREADY_EXISTS(HttpStatus.UNPROCESSABLE_ENTITY, "TEAM_420", "이미 존재하는 팀명입니다.") diff --git a/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java b/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java index 1cf2528..a90496d 100644 --- a/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java +++ b/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java @@ -5,5 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface TeamMemberRepository extends JpaRepository { - boolean existsByMemberIdAndTeamIdAndActiveTrue(Long memberId, Long teamId); + + boolean existsByTeamIdAndMemberIdAndActiveTrue(Long teamId, Long memberId); } diff --git a/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java b/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java index 9221589..f8b3912 100644 --- a/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java +++ b/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java @@ -1,10 +1,24 @@ package com.whylog.server.domain.team.repository; +import com.whylog.server.domain.decision.entity.Decision; import com.whylog.server.domain.team.entity.Team; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface TeamRepository extends JpaRepository { Boolean existsByName(String teamName); + @Query(""" + SELECT DISTINCT d + FROM Decision d + JOIN FETCH d.meeting m + LEFT JOIN FETCH d.applications + WHERE m.team.id = :teamId + """) + List findDecisions(@Param("teamId") Long teamId); + } diff --git a/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java b/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java index e372ff9..0c51a88 100644 --- a/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java +++ b/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java @@ -11,9 +11,12 @@ import com.whylog.server.domain.user.entity.Member; import com.whylog.server.domain.user.service.MemberUseCase; import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import com.whylog.server.global.external.s3.ImageType; +import com.whylog.server.global.external.s3.S3Client; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -22,9 +25,11 @@ public class TeamCommandService { private final TeamRepository teamRepository; private final TeamMemberRepository teamMemberRepository; private final MemberUseCase memberUseCase; + private final TeamUseCase teamUseCase; + private final S3Client s3Client; @Transactional - public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest.TeamCreateDTO request){ + public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest.TeamCreateDTO request, MultipartFile image){ // 팀명 이미 존재하면 예외 발생 if(teamRepository.existsByName(request.getName())){ @@ -36,8 +41,13 @@ public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest. throw new ErrorHandler(TeamErrorCode.TEAM_NAME_LENGTH); } + String imageKey = null; + if( image != null && !image.isEmpty()){ + imageKey = s3Client.uploadFile(image, ImageType.TEAM_IMAGE); + } + // 팀 생성 및 저장 - Team team = Team.create(request); + Team team = Team.create(request, imageKey); teamRepository.save(team); // 팀원으로 등록 @@ -47,12 +57,36 @@ public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest. return TeamResponse.TeamCreateResponseDTO.builder() .teamId(team.getId()) .name(team.getName()) + .imageUrl(s3Client.getFileUrl(team.getImage())) .build(); } - private void addMember(Team team, Member member, TeamRole role){ + @Transactional + public TeamResponse.InvitationResponseDTO invite(Long teamId, TeamRequest.InvitationDTO request){ + + // 데이터 조회 + Member member = memberUseCase.findMemberByEmail(request.getMemberEmail()); + Team team = teamUseCase.findTeamById(teamId); + + // 이미 초대된 경우는 예외처리 + if (teamMemberRepository.existsByTeamIdAndMemberIdAndActiveTrue(team.getId(), member.getId())) { + throw new ErrorHandler(TeamErrorCode.TEAM_MEMBER_ALREADY_EXISTS); + } + + // 팀원 추가 + TeamMember teamMember = addMember(team, member, TeamRole.MEMBER); + + return TeamResponse.InvitationResponseDTO.builder() + .teamId(teamMember.getTeam().getId()) + .memberEmail(teamMember.getMember().getEmail()) + .build(); + } + + private TeamMember addMember(Team team, Member member, TeamRole role){ TeamMember teamMember = TeamMember.create(team, member, role); - teamMemberRepository.save(teamMember); + return teamMemberRepository.save(teamMember); } + + } diff --git a/src/main/java/com/whylog/server/domain/team/service/TeamQueryService.java b/src/main/java/com/whylog/server/domain/team/service/TeamQueryService.java new file mode 100644 index 0000000..364483a --- /dev/null +++ b/src/main/java/com/whylog/server/domain/team/service/TeamQueryService.java @@ -0,0 +1,32 @@ +package com.whylog.server.domain.team.service; + +import com.whylog.server.domain.decision.dto.DecisionResponse; +import com.whylog.server.domain.decision.entity.Decision; +import com.whylog.server.domain.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TeamQueryService { + + private final TeamRepository teamRepository; + + @Transactional(readOnly = true) + public List decisions( Long teamId ){ + + List decisions = teamRepository.findDecisions(teamId); + + return decisions.stream() + .map(d -> DecisionResponse.DecisionListDTO.builder() + .decisionId(d.getId()) + .name(d.getMeeting().getName()) + .applicationCount( d.getApplications().size() ) + .build() + ).toList(); + } + +} diff --git a/src/main/java/com/whylog/server/domain/user/controller/MemberController.java b/src/main/java/com/whylog/server/domain/user/controller/MemberController.java index 69d3b2b..afbbd4c 100644 --- a/src/main/java/com/whylog/server/domain/user/controller/MemberController.java +++ b/src/main/java/com/whylog/server/domain/user/controller/MemberController.java @@ -1,7 +1,53 @@ package com.whylog.server.domain.user.controller; +import com.whylog.server.domain.user.dto.MemberResponse; +import com.whylog.server.domain.user.service.MemberCommandService; +import com.whylog.server.global.apiPayload.ApiResponse; +import com.whylog.server.global.auth.annotation.CurrentMember; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController +@RequestMapping("/api/members") +@RequiredArgsConstructor +@Tag(name = "Member", description = "멤버 관련 API") public class MemberController { + + private final MemberCommandService memberCommandService; + + @PostMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "멤버 프로필 이미지 업로드 API", description = """ + + ## 설명 + 현재 로그인한 멤버의 프로필 이미지를 업로드합니다. + + ## API 호출 방법 + + `multipart/form-data`로 요청합니다. + `image` 파트에 업로드할 이미지 파일을 추가합니다. + + | Part name | Required | Value | + | --- | --- | --- | + | `image` | O | 이미지 파일 | + + ### Web FormData 예시 + + `Content-Type`은 직접 지정하지 않습니다. 브라우저가 boundary를 포함한 `multipart/form-data` 값을 자동으로 생성해야 합니다. + + """) + public ApiResponse uploadProfileImage( + @Parameter(hidden = true) @CurrentMember Long memberId, + @RequestPart("image") MultipartFile image + ) { + return ApiResponse.onSuccess(memberCommandService.uploadProfileImage(memberId, image)); + } + } diff --git a/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java b/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java new file mode 100644 index 0000000..b8f29c5 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java @@ -0,0 +1,24 @@ +package com.whylog.server.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class MemberResponse { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "멤버 프로필 이미지 업로드 응답") + public static class ProfileImageUploadResponseDTO { + + @Schema(description = "멤버 ID", example = "1") + private Long memberId; + + @Schema(description = "프로필 이미지 URL", example = "https://server-images-437659978683-ap-northeast-2-an.s3.ap-northeast-2.amazonaws.com/member_profile/member_profile_image_2026-04-15-03-23-22-262.png") + private String profileImageUrl; + } +} diff --git a/src/main/java/com/whylog/server/domain/user/entity/Member.java b/src/main/java/com/whylog/server/domain/user/entity/Member.java index 45ce284..d527331 100644 --- a/src/main/java/com/whylog/server/domain/user/entity/Member.java +++ b/src/main/java/com/whylog/server/domain/user/entity/Member.java @@ -59,6 +59,10 @@ public static Member create(AuthRequest.SignUpDTO dto, String password, Role rol .build(); } + public void updateProfileImage(String profileImage) { + this.profileImage = profileImage; + } + public void setGithubAccessToken(String token) { this.githubAccessToken = token; } diff --git a/src/main/java/com/whylog/server/domain/user/service/MemberCommandService.java b/src/main/java/com/whylog/server/domain/user/service/MemberCommandService.java new file mode 100644 index 0000000..75bb068 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/service/MemberCommandService.java @@ -0,0 +1,31 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.user.dto.MemberResponse; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.global.external.s3.ImageType; +import com.whylog.server.global.external.s3.S3Client; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class MemberCommandService { + + private final MemberUseCase memberUseCase; + private final S3Client s3Client; + + @Transactional + public MemberResponse.ProfileImageUploadResponseDTO uploadProfileImage(Long memberId, MultipartFile image) { + + Member member = memberUseCase.findMemberById(memberId); + String imageKey = s3Client.uploadFile(image, ImageType.MEMBER_PROFILE); + member.updateProfileImage(imageKey); + + return MemberResponse.ProfileImageUploadResponseDTO.builder() + .memberId(member.getId()) + .profileImageUrl(s3Client.getFileUrl(imageKey)) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java b/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java index 8d821a2..bb2aa84 100644 --- a/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java +++ b/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java @@ -18,4 +18,9 @@ public Member findMemberById(Long id){ .orElseThrow(MemberNotFoundException::new); } + public Member findMemberByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(MemberNotFoundException::new); + } + } diff --git a/src/main/java/com/whylog/server/global/config/S3Config.java b/src/main/java/com/whylog/server/global/config/S3Config.java new file mode 100644 index 0000000..07774cd --- /dev/null +++ b/src/main/java/com/whylog/server/global/config/S3Config.java @@ -0,0 +1,26 @@ +package com.whylog.server.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; + +@Configuration +public class S3Config { + + @Bean + public software.amazon.awssdk.services.s3.S3Client amazonS3Client( + @Value("${aws.s3.region}") String region, + @Value("${aws.s3.access-key}") String accessKey, + @Value("${aws.s3.secret-key}") String secretKey + ) { + return software.amazon.awssdk.services.s3.S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/global/config/SwaggerSchemaNamingConfig.java b/src/main/java/com/whylog/server/global/config/SwaggerSchemaNamingConfig.java new file mode 100644 index 0000000..677d540 --- /dev/null +++ b/src/main/java/com/whylog/server/global/config/SwaggerSchemaNamingConfig.java @@ -0,0 +1,148 @@ +package com.whylog.server.global.config; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerSchemaNamingConfig { + + private static final PropertyNamingStrategies.SnakeCaseStrategy SNAKE_CASE = + new PropertyNamingStrategies.SnakeCaseStrategy(); + + @Bean + public OpenApiCustomizer snakeCaseSchemaCustomizer() { + return openApi -> { + IdentityHashMap, Boolean> visited = new IdentityHashMap<>(); + applyToComponentSchemas(openApi, visited); + applyToPathSchemas(openApi, visited); + }; + } + + private void applyToComponentSchemas(OpenAPI openApi, IdentityHashMap, Boolean> visited) { + if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) { + return; + } + + for (Schema schema : openApi.getComponents().getSchemas().values()) { + renameSchemaProperties(schema, visited); + } + } + + private void applyToPathSchemas(OpenAPI openApi, IdentityHashMap, Boolean> visited) { + if (openApi.getPaths() == null) { + return; + } + + openApi.getPaths().values().forEach(pathItem -> pathItem.readOperations().forEach(operation -> { + if (operation.getRequestBody() != null) { + renameRequestBodySchema(operation.getRequestBody(), visited); + } + + if (operation.getResponses() != null) { + for (ApiResponse response : operation.getResponses().values()) { + renameResponseSchema(response, visited); + } + } + })); + } + + private void renameRequestBodySchema(RequestBody requestBody, IdentityHashMap, Boolean> visited) { + if (requestBody.getContent() == null) { + return; + } + + for (MediaType mediaType : requestBody.getContent().values()) { + renameSchemaProperties(mediaType.getSchema(), visited); + } + } + + private void renameResponseSchema(ApiResponse response, IdentityHashMap, Boolean> visited) { + if (response.getContent() == null) { + return; + } + + for (MediaType mediaType : response.getContent().values()) { + renameSchemaProperties(mediaType.getSchema(), visited); + } + } + + private void renameSchemaProperties(Schema schema, IdentityHashMap, Boolean> visited) { + if (schema == null || visited.containsKey(schema)) { + return; + } + visited.put(schema, Boolean.TRUE); + + renameObjectProperties(schema); + renameRequiredFields(schema); + + if (schema.getItems() != null) { + renameSchemaProperties(schema.getItems(), visited); + } + + Object additionalProperties = schema.getAdditionalProperties(); + if (additionalProperties instanceof Schema additionalSchema) { + renameSchemaProperties(additionalSchema, visited); + } + + if (schema.getAllOf() != null) { + for (Schema child : schema.getAllOf()) { + renameSchemaProperties(child, visited); + } + } + if (schema.getOneOf() != null) { + for (Schema child : schema.getOneOf()) { + renameSchemaProperties(child, visited); + } + } + if (schema.getAnyOf() != null) { + for (Schema child : schema.getAnyOf()) { + renameSchemaProperties(child, visited); + } + } + if (schema.getNot() != null) { + renameSchemaProperties(schema.getNot(), visited); + } + } + + private void renameObjectProperties(Schema schema) { + Map properties = schema.getProperties(); + if (properties == null || properties.isEmpty()) { + return; + } + + LinkedHashMap renamed = new LinkedHashMap<>(); + for (Map.Entry entry : properties.entrySet()) { + renamed.put(toSnakeCase(entry.getKey()), entry.getValue()); + } + schema.setProperties(renamed); + } + + private void renameRequiredFields(Schema schema) { + List required = schema.getRequired(); + if (required == null || required.isEmpty()) { + return; + } + + List renamedRequired = new ArrayList<>(required.size()); + for (String name : required) { + renamedRequired.add(toSnakeCase(name)); + } + schema.setRequired(renamedRequired); + } + + private String toSnakeCase(String value) { + return SNAKE_CASE.translate(value); + } +} diff --git a/src/main/java/com/whylog/server/global/external/s3/ImageType.java b/src/main/java/com/whylog/server/global/external/s3/ImageType.java new file mode 100644 index 0000000..2fd858e --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/s3/ImageType.java @@ -0,0 +1,16 @@ +package com.whylog.server.global.external.s3; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageType { + + TEAM_IMAGE("team_image/team_image_"), + MEMBER_PROFILE("member_profile/member_profile_image_"), + ; + + private final String prefix; + +} diff --git a/src/main/java/com/whylog/server/global/external/s3/S3Client.java b/src/main/java/com/whylog/server/global/external/s3/S3Client.java new file mode 100644 index 0000000..7c123af --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/s3/S3Client.java @@ -0,0 +1,90 @@ +package com.whylog.server.global.external.s3; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Slf4j +@Component +@RequiredArgsConstructor +public class S3Client { + + private final software.amazon.awssdk.services.s3.S3Client s3Client; + + @Value("${aws.s3.bucket}") + private String bucket; + + @Value("${aws.s3.region}") + private String region; + + public String uploadFile(MultipartFile file, ImageType imageType) { + + if (file == null || file.isEmpty()) { + throw new S3Exception(S3ErrorCode.S3_FILE_EMPTY); + } + + if (!StringUtils.hasText(bucket)) { + throw new S3Exception(S3ErrorCode.S3_BUCKET_NOT_CONFIGURED); + } + + if (file.isEmpty()) { + throw new S3Exception(S3ErrorCode.S3_FILE_EMPTY); + } + + String key = getImageFileName(file, imageType); + + if (!StringUtils.hasText(key)) { + throw new S3Exception(S3ErrorCode.S3_FILE_NAME_EMPTY); + } + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + + try { + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + } catch (IOException | software.amazon.awssdk.services.s3.model.S3Exception | SdkClientException e) { + log.error("S3 에러 발생: {}", e.getMessage()); + throw new S3Exception(S3ErrorCode.S3_UPLOAD_FAILED); + } + + return key; + } + + + // 이미지 가져옴 + public String getFileUrl(String fileName) { + if (!StringUtils.hasText(fileName)) { + return null; + } + + String encodedFileName = encodeS3Key(fileName); + return "https://" + bucket + ".s3." + region + ".amazonaws.com/" + encodedFileName; + } + + // ------------------------------------------------------------------------------------------------------------------------------ + + // 파일 이름 생성 + private String getImageFileName(MultipartFile image, ImageType imageType) { + return S3KeyGenerator.makeImageKey(image.getOriginalFilename(), imageType); + } + + private String encodeS3Key(String key) { + return URLEncoder.encode(key, StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("%2F", "/"); + } + +} diff --git a/src/main/java/com/whylog/server/global/external/s3/S3ErrorCode.java b/src/main/java/com/whylog/server/global/external/s3/S3ErrorCode.java new file mode 100644 index 0000000..a52e385 --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/s3/S3ErrorCode.java @@ -0,0 +1,41 @@ +package com.whylog.server.global.external.s3; + +import com.whylog.server.global.apiPayload.code.BaseErrorCode; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum S3ErrorCode implements BaseErrorCode { + + S3_FILE_EMPTY(HttpStatus.BAD_REQUEST, "S3_400_1", "업로드할 파일이 비어있습니다."), + S3_FILE_NAME_EMPTY(HttpStatus.BAD_REQUEST, "S3_400_2", "S3 파일명이 비어있습니다."), + S3_BUCKET_NOT_CONFIGURED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500_1", "S3 버킷 설정이 필요합니다."), + S3_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500_2", "S3 파일 업로드에 실패했습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/global/external/s3/S3Exception.java b/src/main/java/com/whylog/server/global/external/s3/S3Exception.java new file mode 100644 index 0000000..67626b6 --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/s3/S3Exception.java @@ -0,0 +1,10 @@ +package com.whylog.server.global.external.s3; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class S3Exception extends GeneralException { + + public S3Exception(S3ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/whylog/server/global/external/s3/S3KeyGenerator.java b/src/main/java/com/whylog/server/global/external/s3/S3KeyGenerator.java new file mode 100644 index 0000000..f0b5e2f --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/s3/S3KeyGenerator.java @@ -0,0 +1,40 @@ +package com.whylog.server.global.external.s3; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class S3KeyGenerator { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss-SSS"); + + private S3KeyGenerator() { + } + + // 이미지 유형 접두사 + 현재날짜시각( xxxx-xx-xx ) + public static String makeImageKey(String fileName, ImageType imageType) { + return makeImageKey(fileName, imageType, null); + } + + // 이미지 유형 접두사 + 객체id(선택) + 현재날짜시각( xxxx-xx-xx ) + public static String makeImageKey(String fileName, ImageType imageType, Long objectId) { + StringBuilder key = new StringBuilder(imageType.getPrefix()); + + if (objectId != null) { + key.append(objectId).append("_"); + } + + key.append(LocalDateTime.now().format(DATE_TIME_FORMATTER)); + key.append(extractExtension(fileName)); + + return key.toString(); + } + + private static String extractExtension(String fileName) { + if (fileName == null || !fileName.contains(".")) { + return ""; + } + + return fileName.substring(fileName.lastIndexOf(".")); + } + +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f553e00..90aab4c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -54,4 +54,10 @@ livekit: github: token: encryption: - key: ${GITHUB_TOKEN_ENCRYPTION_KEY} \ No newline at end of file + key: ${GITHUB_TOKEN_ENCRYPTION_KEY} +aws: + s3: + bucket: ${AWS_S3_BUCKET} + region: ${AWS_REGION:ap-northeast-2} + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY}