From 8375dfcf77a172a813efd347c062e82b77365744 Mon Sep 17 00:00:00 2001 From: junyong Date: Wed, 15 Apr 2026 01:59:37 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=95=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=AA=A9=EB=A1=9D=EC=A1=B0=ED=9A=8C,=20=ED=8C=80?= =?UTF-8?q?=EC=B4=88=EB=8C=80=20api=20=EA=B5=AC=ED=98=84,=20=ED=8C=80?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20api=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/decision/dto/DecisionResponse.java | 2 +- .../domain/decision/entity/Decision.java | 4 +- .../team/controller/TeamController.java | 36 ++++++++++--- .../server/domain/team/dto/TeamRequest.java | 2 +- .../server/domain/team/dto/TeamResponse.java | 3 ++ .../server/domain/team/entity/Team.java | 9 +++- .../domain/team/exception/TeamErrorCode.java | 3 ++ .../team/repository/TeamMemberRepository.java | 3 +- .../team/repository/TeamRepository.java | 14 ++++++ .../team/service/TeamCommandService.java | 50 +++++++++++++++++-- .../domain/team/service/TeamQueryService.java | 32 ++++++++++++ .../domain/user/service/MemberUseCase.java | 5 ++ .../server/global/external/s3/ImageType.java | 16 ++++++ .../server/global/external/s3/S3Client.java | 17 +++++++ .../global/external/s3/S3KeyGenerator.java | 40 +++++++++++++++ 15 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/team/service/TeamQueryService.java create mode 100644 src/main/java/com/whylog/server/global/external/s3/ImageType.java create mode 100644 src/main/java/com/whylog/server/global/external/s3/S3Client.java create mode 100644 src/main/java/com/whylog/server/global/external/s3/S3KeyGenerator.java 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..37e0056 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,6 +4,7 @@ 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.Operation; @@ -11,12 +12,17 @@ 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 +31,49 @@ 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 ) """) public ApiResponse createTeam( @Parameter(hidden = true) @CurrentMember Long memberId, - @Valid @RequestBody TeamRequest.TeamCreateDTO request + @Valid @RequestPart("request") TeamRequest.TeamCreateDTO request, + @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..94d9126 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; } 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..9bbd80d 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,13 @@ 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 com.whylog.server.global.external.s3.S3KeyGenerator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -22,9 +26,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 +42,10 @@ public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest. throw new ErrorHandler(TeamErrorCode.TEAM_NAME_LENGTH); } + String imageKey = uploadTeamImage(image); + // 팀 생성 및 저장 - Team team = Team.create(request); + Team team = Team.create(request, imageKey); teamRepository.save(team); // 팀원으로 등록 @@ -47,12 +55,46 @@ public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest. return TeamResponse.TeamCreateResponseDTO.builder() .teamId(team.getId()) .name(team.getName()) + .imageUrl(team.getImage()) + .build(); + } + + @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 void addMember(Team team, Member member, TeamRole role){ + private TeamMember addMember(Team team, Member member, TeamRole role){ TeamMember teamMember = TeamMember.create(team, member, role); - teamMemberRepository.save(teamMember); + return teamMemberRepository.save(teamMember); + } + + private String uploadTeamImage(MultipartFile image) { + if (image == null || image.isEmpty()) { + return null; + } + + return s3Client.uploadFile(createTeamImageFileName(image), image); + } + + private String createTeamImageFileName(MultipartFile image) { + return S3KeyGenerator.makeImageKey(image.getOriginalFilename(), ImageType.TEAM_IMAGE); } } 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/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/external/s3/ImageType.java b/src/main/java/com/whylog/server/global/external/s3/ImageType.java new file mode 100644 index 0000000..2a81fd9 --- /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_"), + 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..53e9fac --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/s3/S3Client.java @@ -0,0 +1,17 @@ +package com.whylog.server.global.external.s3; + +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +public class S3Client { + + public String uploadFile(String fileName, MultipartFile file){ + return null; + } + + public String getFileUrl(String fileName){ + return null; + } + +} \ No newline at end of file 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(".")); + } + +} From 321a225557341c1a02052f7866e0fccd3eceff20 Mon Sep 17 00:00:00 2001 From: junyong Date: Wed, 15 Apr 2026 03:41:50 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20s3=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../team/controller/TeamController.java | 2 +- .../team/service/TeamCommandService.java | 18 ++-- .../whylog/server/global/config/S3Config.java | 26 ++++++ .../server/global/external/s3/ImageType.java | 4 +- .../server/global/external/s3/S3Client.java | 83 +++++++++++++++++-- .../global/external/s3/S3ErrorCode.java | 41 +++++++++ .../global/external/s3/S3Exception.java | 10 +++ src/main/resources/application.yaml | 7 ++ 9 files changed, 173 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/whylog/server/global/config/S3Config.java create mode 100644 src/main/java/com/whylog/server/global/external/s3/S3ErrorCode.java create mode 100644 src/main/java/com/whylog/server/global/external/s3/S3Exception.java diff --git a/build.gradle b/build.gradle index 3a0f8e7..a3c21f5 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' + } tasks.named('test') { 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 37e0056..30df477 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 @@ -69,7 +69,7 @@ public ApiResponse sendInvitation( 팀 생성과 동시에 팀에 참여합니다. ( 따로 호출 X ) """) public ApiResponse createTeam( - @Parameter(hidden = true) @CurrentMember Long memberId, + @CurrentMember Long memberId, @Valid @RequestPart("request") TeamRequest.TeamCreateDTO request, @RequestPart(value = "image", required = false) MultipartFile image ) { 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 9bbd80d..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 @@ -13,7 +13,6 @@ 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 com.whylog.server.global.external.s3.S3KeyGenerator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,7 +41,10 @@ public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest. throw new ErrorHandler(TeamErrorCode.TEAM_NAME_LENGTH); } - String imageKey = uploadTeamImage(image); + String imageKey = null; + if( image != null && !image.isEmpty()){ + imageKey = s3Client.uploadFile(image, ImageType.TEAM_IMAGE); + } // 팀 생성 및 저장 Team team = Team.create(request, imageKey); @@ -55,7 +57,7 @@ public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest. return TeamResponse.TeamCreateResponseDTO.builder() .teamId(team.getId()) .name(team.getName()) - .imageUrl(team.getImage()) + .imageUrl(s3Client.getFileUrl(team.getImage())) .build(); } @@ -85,16 +87,6 @@ private TeamMember addMember(Team team, Member member, TeamRole role){ return teamMemberRepository.save(teamMember); } - private String uploadTeamImage(MultipartFile image) { - if (image == null || image.isEmpty()) { - return null; - } - return s3Client.uploadFile(createTeamImageFileName(image), image); - } - - private String createTeamImageFileName(MultipartFile image) { - return S3KeyGenerator.makeImageKey(image.getOriginalFilename(), ImageType.TEAM_IMAGE); - } } 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/external/s3/ImageType.java b/src/main/java/com/whylog/server/global/external/s3/ImageType.java index 2a81fd9..2fd858e 100644 --- a/src/main/java/com/whylog/server/global/external/s3/ImageType.java +++ b/src/main/java/com/whylog/server/global/external/s3/ImageType.java @@ -7,8 +7,8 @@ @AllArgsConstructor public enum ImageType { - TEAM_IMAGE("team_image_"), - MEMBER_PROFILE("member_profile_image_"), + 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 index 53e9fac..7c123af 100644 --- a/src/main/java/com/whylog/server/global/external/s3/S3Client.java +++ b/src/main/java/com/whylog/server/global/external/s3/S3Client.java @@ -1,17 +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 { - public String uploadFile(String fileName, MultipartFile file){ - return null; + 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); } - public String getFileUrl(String fileName){ - return null; + private String encodeS3Key(String key) { + return URLEncoder.encode(key, StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("%2F", "/"); } -} \ No newline at end of file +} 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/resources/application.yaml b/src/main/resources/application.yaml index ee8d47f..87e8819 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -50,3 +50,10 @@ livekit: api-key: ${LIVEKIT_API_KEY:devkey} api-secret: ${LIVEKIT_API_SECRET:01234567890123456789012345678901} token-expire-time: ${LIVEKIT_TOKEN_EXPIRE_TIME:3600000} + +aws: + s3: + bucket: ${AWS_S3_BUCKET} + region: ${AWS_REGION:ap-northeast-2} + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} From 9bc6fbd831ba9b7e015474fedf1336d1ea2a9180 Mon Sep 17 00:00:00 2001 From: junyong Date: Wed, 15 Apr 2026 03:47:39 +0900 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20camm?= =?UTF-8?q?el=20->=20snake=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/SwaggerSchemaNamingConfig.java | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/main/java/com/whylog/server/global/config/SwaggerSchemaNamingConfig.java 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); + } +} From 2ba2f70a512ae3abc5d46c356602570c6630f2b0 Mon Sep 17 00:00:00 2001 From: junyong Date: Wed, 15 Apr 2026 03:53:58 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/MemberController.java | 27 ++++++++++++++++ .../domain/user/dto/MemberResponse.java | 24 ++++++++++++++ .../server/domain/user/entity/Member.java | 4 +++ .../user/service/MemberCommandService.java | 31 +++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java create mode 100644 src/main/java/com/whylog/server/domain/user/service/MemberCommandService.java 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..16dc624 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,34 @@ 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.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 = "현재 로그인한 멤버의 프로필 이미지를 업로드합니다.") + public ApiResponse uploadProfileImage( + @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 4729058..d1e9b53 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 @@ -61,4 +61,8 @@ public static Member create(AuthRequest.SignUpDTO dto, String password, Role rol .build(); } + public void updateProfileImage(String profileImage) { + this.profileImage = profileImage; + } + } 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(); + } +} From 7aca3cf866ea1efdbb2073612886f4b330de57e1 Mon Sep 17 00:00:00 2001 From: junyong Date: Wed, 15 Apr 2026 04:00:30 +0900 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=ED=95=98=EB=8A=94=20api=EC=97=90=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=20=EC=83=81=EC=84=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/controller/TeamController.java | 28 ++++++++++++++++--- .../user/controller/MemberController.java | 20 ++++++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) 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 30df477..aef507b 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 @@ -63,10 +63,30 @@ public ApiResponse sendInvitation( @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` 값을 자동으로 생성해야 합니다. + """) public ApiResponse createTeam( @CurrentMember Long memberId, 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 16dc624..52af13c 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 @@ -23,7 +23,25 @@ public class MemberController { private final MemberCommandService memberCommandService; @PostMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "멤버 프로필 이미지 업로드 API", description = "현재 로그인한 멤버의 프로필 이미지를 업로드합니다.") + @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( @CurrentMember Long memberId, @RequestPart("image") MultipartFile image From 2950f02f4d6cc4884ecd47b03b52689f5b0e1cab Mon Sep 17 00:00:00 2001 From: junyong Date: Wed, 15 Apr 2026 04:23:23 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=8C=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=ED=95=B4=EC=84=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/controller/TeamController.java | 19 +++++++++++++++---- .../server/domain/team/dto/TeamRequest.java | 14 ++++++++++++++ .../user/controller/MemberController.java | 3 ++- 3 files changed, 31 insertions(+), 5 deletions(-) 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 aef507b..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 @@ -7,6 +7,9 @@ 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; @@ -87,11 +90,19 @@ public ApiResponse sendInvitation( `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( - @CurrentMember Long memberId, - @Valid @RequestPart("request") TeamRequest.TeamCreateDTO request, - @RequestPart(value = "image", required = false) MultipartFile image + @Parameter(hidden = true) @CurrentMember Long memberId, + @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, 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 94d9126..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 @@ -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/user/controller/MemberController.java b/src/main/java/com/whylog/server/domain/user/controller/MemberController.java index 52af13c..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 @@ -5,6 +5,7 @@ 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; @@ -43,7 +44,7 @@ public class MemberController { """) public ApiResponse uploadProfileImage( - @CurrentMember Long memberId, + @Parameter(hidden = true) @CurrentMember Long memberId, @RequestPart("image") MultipartFile image ) { return ApiResponse.onSuccess(memberCommandService.uploadProfileImage(memberId, image));