From 596ec6070d13bc3482cdd8de3fd3d3805945b900 Mon Sep 17 00:00:00 2001 From: kymin Date: Sun, 9 Aug 2026 15:20:25 +0900 Subject: [PATCH 01/68] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/controller/MemberController.java | 12 ++++++++++ .../controller/MemberControllerDocs.java | 24 +++++++++++++++++++ .../dto/response/MemberProfileResponse.java | 15 ++++++++++++ .../member/internal/mapper/MemberMapper.java | 5 ++++ .../internal/service/MemberService.java | 9 +++++++ .../dto/result/MemberProfileResult.java | 7 ++++++ 6 files changed, 72 insertions(+) create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/MemberProfileResponse.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/MemberProfileResult.java diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java index 114869d..c41f058 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java @@ -5,11 +5,13 @@ import org.sopt.routee.member.internal.code.SuccessCode; import org.sopt.routee.member.internal.controller.dto.response.ActivitySummaryResponse; import org.sopt.routee.member.internal.controller.dto.response.MemberInfoResponse; +import org.sopt.routee.member.internal.controller.dto.response.MemberProfileResponse; import org.sopt.routee.member.internal.controller.dto.request.RegisterRequest; import org.sopt.routee.member.internal.controller.dto.request.WithdrawRequest; import org.sopt.routee.member.internal.service.MemberService; import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; +import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; import org.sopt.routee.response.ApiResponse; import org.sopt.routee.response.SuccessResponse; import org.sopt.routee.util.TokenExtractor; @@ -75,6 +77,16 @@ public ResponseEntity> getMemberInfo( .body(ApiResponse.success(SuccessCode.MEMBER_FOUND, MemberInfoResponse.from(result))); } + @GetMapping(path = "/member/settings/profile") + public ResponseEntity> getMemberProfile( + @AuthenticationPrincipal Long memberId + ) { + MemberProfileResult result = memberService.getMemberProfile(memberId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.MEMBER_FOUND, MemberProfileResponse.from(result))); + } + @GetMapping(path = "/archive/activity-summary") public ResponseEntity> getActivitySummary( @AuthenticationPrincipal Long memberId, diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index 7d99da6..99248ed 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -4,6 +4,7 @@ import org.sopt.routee.member.internal.controller.dto.response.ActivitySummaryResponse; import org.sopt.routee.member.internal.controller.dto.response.MemberInfoResponse; +import org.sopt.routee.member.internal.controller.dto.response.MemberProfileResponse; import org.sopt.routee.member.internal.controller.dto.request.RegisterRequest; import org.sopt.routee.member.internal.controller.dto.request.WithdrawRequest; import org.sopt.routee.response.FailureResponse; @@ -122,6 +123,29 @@ ResponseEntity> getMemberInfo( @RequestHeader("Time-Zone") ZoneId timeZone ); + @Operation( + summary = "설정 페이지 회원 정보 조회", + description = "인증된 회원의 닉네임과 프로필 이미지 URL을 조회합니다." + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = MemberProfileResponse.class))), + @ApiResponse(responseCode = "401", description = "만료되었거나 유효하지 않은 액세스 토큰", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "INVALID_TOKEN", + value = "{\"status\":401,\"code\":\"INVALID_TOKEN\",\"message\":\"유효하지 않은 토큰입니다.\"}"), + @ExampleObject(name = "TOKEN_EXPIRED", + value = "{\"status\":401,\"code\":\"TOKEN_EXPIRED\",\"message\":\"만료된 토큰입니다.\"}") + })), + @ApiResponse(responseCode = "404", description = "가입된 회원 없음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = @ExampleObject(name = "MEMBER_NOT_FOUND", + value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}"))) + }) + ResponseEntity> getMemberProfile(Long memberId); + @Operation( summary = "월별 활동 요약 조회", description = "인증된 회원의 특정 연/월에 대한 일자별 활동 요약(총 활동 시간, 활동 횟수, 커버 이미지)을 조회합니다." diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/MemberProfileResponse.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/MemberProfileResponse.java new file mode 100644 index 0000000..ce3d6b0 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/MemberProfileResponse.java @@ -0,0 +1,15 @@ +package org.sopt.routee.member.internal.controller.dto.response; + +import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; + +public record MemberProfileResponse( + String nickname, + String profileImageUrl +) { + public static MemberProfileResponse from(MemberProfileResult result) { + return new MemberProfileResponse( + result.nickname(), + result.profileImageUrl() + ); + } +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java index fc8c480..64210cc 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java @@ -10,6 +10,7 @@ import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.DailySummary; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; +import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; import org.sopt.routee.member.api.result.TokenClaimsResult; import org.sopt.routee.member.api.type.MemberRole; import org.sopt.routee.member.internal.entity.Member; @@ -48,6 +49,10 @@ public static MemberInfoResult toMemberInfoResult(Member member, ZoneId zoneId) ); } + public static MemberProfileResult toMemberProfileResult(Member member) { + return new MemberProfileResult(member.getNickname(), member.getProfileImageUrl()); + } + public static ActivitySummaryResult toActivitySummaryResult( List summaries, int year, int month ) { diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index efaf949..18507a8 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -12,6 +12,7 @@ import org.sopt.routee.member.internal.service.dto.command.RegisterCommand; import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; +import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; import org.sopt.routee.member.api.result.TokenClaimsResult; import org.sopt.routee.member.internal.entity.Member; import org.sopt.routee.member.internal.exception.AlreadyRegisteredMemberException; @@ -76,6 +77,14 @@ public MemberInfoResult getMemberInfo(long memberId, ZoneId zoneId) { return MemberMapper.toMemberInfoResult(member, zoneId); } + @Transactional(readOnly = true) + public MemberProfileResult getMemberProfile(long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + return MemberMapper.toMemberProfileResult(member); + } + @Transactional public void incrementTotalActivityCount(long memberId) { memberRepository.incrementTotalActivityCount(memberId); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/MemberProfileResult.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/MemberProfileResult.java new file mode 100644 index 0000000..445a081 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/MemberProfileResult.java @@ -0,0 +1,7 @@ +package org.sopt.routee.member.internal.service.dto.result; + +public record MemberProfileResult( + String nickname, + String profileImageUrl +) { +} From 501ee6ea104573767cf93a1ac80e36e3ea073462 Mon Sep 17 00:00:00 2001 From: kymin Date: Mon, 10 Aug 2026 14:58:46 +0900 Subject: [PATCH 02/68] =?UTF-8?q?feat:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/internal/code/SuccessCode.java | 1 + .../internal/controller/MemberController.java | 15 +++++++ .../controller/MemberControllerDocs.java | 39 +++++++++++++++++++ .../dto/request/NicknameUpdateRequest.java | 16 ++++++++ .../dto/response/NicknameResponse.java | 11 ++++++ .../routee/member/internal/entity/Member.java | 4 ++ .../member/internal/mapper/MemberMapper.java | 5 +++ .../internal/service/MemberService.java | 11 ++++++ .../dto/command/UpdateNicknameCommand.java | 7 ++++ .../dto/result/UpdateNicknameResult.java | 6 +++ 10 files changed, 115 insertions(+) create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/NicknameUpdateRequest.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/NicknameResponse.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/UpdateNicknameCommand.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/UpdateNicknameResult.java diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java b/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java index d819a0a..ef73249 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java @@ -12,6 +12,7 @@ public enum SuccessCode implements SuccessResultCode { MEMBER_FOUND(HttpStatus.OK, "사용자 정보 조회에 성공하였습니다."), MEMBER_REGISTER(HttpStatus.CREATED, "회원가입이 완료되었습니다."), MEMBER_WITHDRAW(HttpStatus.OK, "회원 탈퇴가 완료되었습니다."), + MEMBER_NICKNAME_UPDATED(HttpStatus.OK, "닉네임 변경이 완료되었습니다."), ACTIVITY_SUMMARY_FOUND(HttpStatus.OK, "월간 활동 요약 조회에 성공하였습니다."); private final HttpStatus status; diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java index c41f058..f0c955d 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java @@ -6,12 +6,15 @@ import org.sopt.routee.member.internal.controller.dto.response.ActivitySummaryResponse; import org.sopt.routee.member.internal.controller.dto.response.MemberInfoResponse; import org.sopt.routee.member.internal.controller.dto.response.MemberProfileResponse; +import org.sopt.routee.member.internal.controller.dto.response.NicknameResponse; +import org.sopt.routee.member.internal.controller.dto.request.NicknameUpdateRequest; import org.sopt.routee.member.internal.controller.dto.request.RegisterRequest; import org.sopt.routee.member.internal.controller.dto.request.WithdrawRequest; import org.sopt.routee.member.internal.service.MemberService; import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; +import org.sopt.routee.member.internal.service.dto.result.UpdateNicknameResult; import org.sopt.routee.response.ApiResponse; import org.sopt.routee.response.SuccessResponse; import org.sopt.routee.util.TokenExtractor; @@ -22,6 +25,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -87,6 +91,17 @@ public ResponseEntity> getMemberProfile( .body(ApiResponse.success(SuccessCode.MEMBER_FOUND, MemberProfileResponse.from(result))); } + @PatchMapping(path = "/member/nickname") + public ResponseEntity> updateNickname( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody NicknameUpdateRequest request + ) { + UpdateNicknameResult result = memberService.updateNickname(request.toCommand(memberId)); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.MEMBER_NICKNAME_UPDATED, NicknameResponse.from(result))); + } + @GetMapping(path = "/archive/activity-summary") public ResponseEntity> getActivitySummary( @AuthenticationPrincipal Long memberId, diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index 99248ed..2c72b65 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -5,6 +5,8 @@ import org.sopt.routee.member.internal.controller.dto.response.ActivitySummaryResponse; import org.sopt.routee.member.internal.controller.dto.response.MemberInfoResponse; import org.sopt.routee.member.internal.controller.dto.response.MemberProfileResponse; +import org.sopt.routee.member.internal.controller.dto.response.NicknameResponse; +import org.sopt.routee.member.internal.controller.dto.request.NicknameUpdateRequest; import org.sopt.routee.member.internal.controller.dto.request.RegisterRequest; import org.sopt.routee.member.internal.controller.dto.request.WithdrawRequest; import org.sopt.routee.response.FailureResponse; @@ -146,6 +148,43 @@ ResponseEntity> getMemberInfo( }) ResponseEntity> getMemberProfile(Long memberId); + @Operation( + summary = "닉네임 변경", + description = "인증된 회원의 닉네임을 변경합니다." + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "변경 성공", + content = @Content(schema = @Schema(implementation = NicknameResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "INVALID_NICKNAME_FORMAT", + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"닉네임은 한글, 영어, 숫자만 사용하여 2자 이상 12자 이하로 입력해야 합니다.\"}"), + @ExampleObject(name = "INVALID_REQUEST_BODY", + value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}") + })), + @ApiResponse(responseCode = "401", description = "만료되었거나 유효하지 않은 액세스 토큰", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "INVALID_TOKEN", + value = "{\"status\":401,\"code\":\"INVALID_TOKEN\",\"message\":\"유효하지 않은 토큰입니다.\"}"), + @ExampleObject(name = "TOKEN_EXPIRED", + value = "{\"status\":401,\"code\":\"TOKEN_EXPIRED\",\"message\":\"만료된 토큰입니다.\"}") + })), + @ApiResponse(responseCode = "404", description = "가입된 회원 없음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = @ExampleObject(name = "MEMBER_NOT_FOUND", + value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}"))) + }) + ResponseEntity> updateNickname( + Long memberId, + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, + content = @Content(schema = @Schema(implementation = NicknameUpdateRequest.class), + examples = @ExampleObject(value = "{\"nickname\":\"루티\"}"))) + @Valid @RequestBody NicknameUpdateRequest request + ); + @Operation( summary = "월별 활동 요약 조회", description = "인증된 회원의 특정 연/월에 대한 일자별 활동 요약(총 활동 시간, 활동 횟수, 커버 이미지)을 조회합니다." diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/NicknameUpdateRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/NicknameUpdateRequest.java new file mode 100644 index 0000000..0d6f8d5 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/NicknameUpdateRequest.java @@ -0,0 +1,16 @@ +package org.sopt.routee.member.internal.controller.dto.request; + +import org.sopt.routee.member.internal.service.dto.command.UpdateNicknameCommand; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record NicknameUpdateRequest( + @NotBlank + @Pattern(regexp = "^[가-힣a-zA-Z0-9]{2,12}$", message = "닉네임은 한글, 영어, 숫자만 사용하여 2자 이상 12자 이하로 입력해야 합니다.") + String nickname +) { + public UpdateNicknameCommand toCommand(Long memberId) { + return new UpdateNicknameCommand(memberId, nickname); + } +} \ No newline at end of file diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/NicknameResponse.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/NicknameResponse.java new file mode 100644 index 0000000..2dfc428 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/NicknameResponse.java @@ -0,0 +1,11 @@ +package org.sopt.routee.member.internal.controller.dto.response; + +import org.sopt.routee.member.internal.service.dto.result.UpdateNicknameResult; + +public record NicknameResponse( + String nickname +) { + public static NicknameResponse from(UpdateNicknameResult result) { + return new NicknameResponse(result.nickname()); + } +} \ No newline at end of file diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java index f02dc17..a14f59b 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java @@ -58,4 +58,8 @@ public class Member extends BaseEntity { @Builder.Default @ColumnDefault("0") private Integer totalActivityCount = 0; + + public void updateNickname(String nickname) { + this.nickname = nickname; + } } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java index 64210cc..f1446c7 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java @@ -11,6 +11,7 @@ import org.sopt.routee.member.internal.service.dto.result.DailySummary; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; +import org.sopt.routee.member.internal.service.dto.result.UpdateNicknameResult; import org.sopt.routee.member.api.result.TokenClaimsResult; import org.sopt.routee.member.api.type.MemberRole; import org.sopt.routee.member.internal.entity.Member; @@ -53,6 +54,10 @@ public static MemberProfileResult toMemberProfileResult(Member member) { return new MemberProfileResult(member.getNickname(), member.getProfileImageUrl()); } + public static UpdateNicknameResult toUpdateNicknameResult(Member member) { + return new UpdateNicknameResult(member.getNickname()); + } + public static ActivitySummaryResult toActivitySummaryResult( List summaries, int year, int month ) { diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index 18507a8..62ef5c3 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -10,9 +10,11 @@ import org.sopt.routee.external.api.port.OidcVerifyPort; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; import org.sopt.routee.member.internal.service.dto.command.RegisterCommand; +import org.sopt.routee.member.internal.service.dto.command.UpdateNicknameCommand; import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; +import org.sopt.routee.member.internal.service.dto.result.UpdateNicknameResult; import org.sopt.routee.member.api.result.TokenClaimsResult; import org.sopt.routee.member.internal.entity.Member; import org.sopt.routee.member.internal.exception.AlreadyRegisteredMemberException; @@ -85,6 +87,15 @@ public MemberProfileResult getMemberProfile(long memberId) { return MemberMapper.toMemberProfileResult(member); } + @Transactional + public UpdateNicknameResult updateNickname(UpdateNicknameCommand command) { + Member member = memberRepository.findById(command.memberId()) + .orElseThrow(MemberNotFoundException::new); + + member.updateNickname(command.nickname()); + return MemberMapper.toUpdateNicknameResult(member); + } + @Transactional public void incrementTotalActivityCount(long memberId) { memberRepository.incrementTotalActivityCount(memberId); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/UpdateNicknameCommand.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/UpdateNicknameCommand.java new file mode 100644 index 0000000..77899be --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/UpdateNicknameCommand.java @@ -0,0 +1,7 @@ +package org.sopt.routee.member.internal.service.dto.command; + +public record UpdateNicknameCommand( + Long memberId, + String nickname +) { +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/UpdateNicknameResult.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/UpdateNicknameResult.java new file mode 100644 index 0000000..d8205cc --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/UpdateNicknameResult.java @@ -0,0 +1,6 @@ +package org.sopt.routee.member.internal.service.dto.result; + +public record UpdateNicknameResult( + String nickname +) { +} \ No newline at end of file From 128a65c58bb69d0d932cd1e9c951ca89b7046042 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 14 Aug 2026 15:01:17 +0900 Subject: [PATCH 03/68] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20url=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/FileImageAccessUrlCommand.java | 2 +- .../api/command/FileUploadPresignCommand.java | 2 +- .../api/type/FileUploadDirectory.java | 3 +- .../adapter/S3FileImageAccessUrlAdapter.java | 2 +- .../s3/adapter/S3ObjectKeyAssembler.java | 35 ++++----- .../internal/s3/adapter/S3PresignAdapter.java | 4 +- .../member/internal/code/ErrorCode.java | 3 +- .../member/internal/code/SuccessCode.java | 2 + .../internal/controller/MemberController.java | 28 +++++++ .../controller/MemberControllerDocs.java | 77 +++++++++++++++++++ .../request/ProfileImageUpdateRequest.java | 14 ++++ .../request/ProfileImageUploadUrlRequest.java | 14 ++++ .../dto/response/ProfileImageResponse.java | 11 +++ .../ProfileImageUploadUrlResponse.java | 12 +++ .../routee/member/internal/entity/Member.java | 4 + ...nsupportedImageFileExtensionException.java | 10 +++ .../member/internal/mapper/MemberMapper.java | 5 ++ .../internal/service/MemberService.java | 48 ++++++++++++ .../command/ProfileImageUploadUrlCommand.java | 7 ++ .../command/UpdateProfileImageCommand.java | 7 ++ .../result/ProfileImageUploadUrlResult.java | 7 ++ .../dto/result/UpdateProfileImageResult.java | 6 ++ .../ProfileImageFileNameValidator.java | 27 +++++++ 23 files changed, 303 insertions(+), 27 deletions(-) create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/ProfileImageUpdateRequest.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/ProfileImageUploadUrlRequest.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/ProfileImageResponse.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/ProfileImageUploadUrlResponse.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/exception/UnsupportedImageFileExtensionException.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/ProfileImageUploadUrlCommand.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/UpdateProfileImageCommand.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/ProfileImageUploadUrlResult.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/UpdateProfileImageResult.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/validator/ProfileImageFileNameValidator.java diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileImageAccessUrlCommand.java b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileImageAccessUrlCommand.java index 31fc203..1f764d8 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileImageAccessUrlCommand.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileImageAccessUrlCommand.java @@ -6,7 +6,7 @@ public record FileImageAccessUrlCommand( FileUploadDirectory directory, FileUploadImageSize imageSize, - String activityId, + String ownerId, String objectKey ) { } diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileUploadPresignCommand.java b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileUploadPresignCommand.java index 6120e56..fa3f46f 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileUploadPresignCommand.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileUploadPresignCommand.java @@ -6,7 +6,7 @@ public record FileUploadPresignCommand( FileUploadDirectory directory, FileUploadImageSize imageSize, - String activityId, + String ownerId, String fileName ) { } diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/type/FileUploadDirectory.java b/routee-external/src/main/java/org/sopt/routee/external/api/type/FileUploadDirectory.java index f2c48e1..affb594 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/type/FileUploadDirectory.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/type/FileUploadDirectory.java @@ -2,7 +2,8 @@ public enum FileUploadDirectory { TIMELINE("timeline"), - RECAP("recap"); + RECAP("recap"), + PROFILE("profile"); private final String path; diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileImageAccessUrlAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileImageAccessUrlAdapter.java index f6081d0..8c99327 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileImageAccessUrlAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileImageAccessUrlAdapter.java @@ -19,7 +19,7 @@ public FileImageAccessUrlResult generateImageUrl(FileImageAccessUrlCommand comma String objectKey = S3ObjectKeyAssembler.assemble( command.directory(), command.imageSize(), - command.activityId(), + command.ownerId(), command.objectKey() ); return new FileImageAccessUrlResult("%s/%s".formatted(normalizeEndpoint(properties.endpoint()), objectKey)); diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java index 4e90729..3a91643 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java @@ -2,46 +2,41 @@ import java.util.Objects; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; import org.sopt.routee.external.api.type.FileUploadDirectory; import org.sopt.routee.external.api.type.FileUploadImageSize; +@NoArgsConstructor(access = AccessLevel.PRIVATE) final class S3ObjectKeyAssembler { private static final String ACTIVITY_ROOT_PATH = "activity"; - private S3ObjectKeyAssembler() { - } - static String assemble( FileUploadDirectory directory, FileUploadImageSize imageSize, - String activityId, + String ownerId, String objectKey ) { return switch (directory) { - case TIMELINE -> "%s/%s/%s/%s".formatted( - activityId, + case TIMELINE -> "%s/%s/%s/%s/%s".formatted( + ACTIVITY_ROOT_PATH, + ownerId, directory.path(), Objects.requireNonNull(imageSize, "imageSize must not be null for timeline").path(), objectKey ); - case RECAP -> "%s/%s/%s".formatted( - activityId, + case RECAP -> "%s/%s/%s/%s".formatted( + ACTIVITY_ROOT_PATH, + ownerId, + directory.path(), + objectKey + ); + case PROFILE -> "%s/%s/%s".formatted( directory.path(), + ownerId, objectKey ); }; } - - static String assembleUploadObjectKey( - FileUploadDirectory directory, - FileUploadImageSize imageSize, - String activityId, - String objectKey - ) { - return "%s/%s".formatted( - ACTIVITY_ROOT_PATH, - assemble(directory, imageSize, activityId, objectKey) - ); - } } diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java index b88d4ad..0ee4b8c 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java @@ -28,10 +28,10 @@ public FileUploadPresignResult generatePutPresignedUrl(FileUploadPresignCommand String objectKey = generateStoredObjectKey( parseExtension(command.fileName()) ); - String presignedObjectKey = S3ObjectKeyAssembler.assembleUploadObjectKey( + String presignedObjectKey = S3ObjectKeyAssembler.assemble( command.directory(), command.imageSize(), - command.activityId(), + command.ownerId(), objectKey ); String presignedUrl = generatePutPresignedUrl(presignedObjectKey); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java b/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java index d908e93..a434d0c 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java @@ -12,7 +12,8 @@ public enum ErrorCode implements ErrorResultCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자 정보가 존재하지 않습니다."), ALREADY_REGISTERED_MEMBER(HttpStatus.CONFLICT, "이미 가입된 회원입니다."), - INVALID_TIME_ZONE(HttpStatus.BAD_REQUEST, "Time-Zone 헤더 값이 올바르지 않습니다."); + INVALID_TIME_ZONE(HttpStatus.BAD_REQUEST, "Time-Zone 헤더 값이 올바르지 않습니다."), + UNSUPPORTED_IMAGE_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 파일 확장자입니다."); private final HttpStatus status; private final String message; diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java b/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java index ef73249..1260933 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java @@ -13,6 +13,8 @@ public enum SuccessCode implements SuccessResultCode { MEMBER_REGISTER(HttpStatus.CREATED, "회원가입이 완료되었습니다."), MEMBER_WITHDRAW(HttpStatus.OK, "회원 탈퇴가 완료되었습니다."), MEMBER_NICKNAME_UPDATED(HttpStatus.OK, "닉네임 변경이 완료되었습니다."), + PROFILE_IMAGE_UPLOAD_URL_ISSUED(HttpStatus.OK, "프로필 이미지 업로드 URL 발급에 성공했습니다."), + PROFILE_IMAGE_UPDATED(HttpStatus.OK, "프로필 이미지 변경이 완료되었습니다."), ACTIVITY_SUMMARY_FOUND(HttpStatus.OK, "월간 활동 요약 조회에 성공하였습니다."); private final HttpStatus status; diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java index f0c955d..1eda8ad 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java @@ -7,14 +7,20 @@ import org.sopt.routee.member.internal.controller.dto.response.MemberInfoResponse; import org.sopt.routee.member.internal.controller.dto.response.MemberProfileResponse; import org.sopt.routee.member.internal.controller.dto.response.NicknameResponse; +import org.sopt.routee.member.internal.controller.dto.response.ProfileImageResponse; +import org.sopt.routee.member.internal.controller.dto.response.ProfileImageUploadUrlResponse; import org.sopt.routee.member.internal.controller.dto.request.NicknameUpdateRequest; +import org.sopt.routee.member.internal.controller.dto.request.ProfileImageUpdateRequest; +import org.sopt.routee.member.internal.controller.dto.request.ProfileImageUploadUrlRequest; import org.sopt.routee.member.internal.controller.dto.request.RegisterRequest; import org.sopt.routee.member.internal.controller.dto.request.WithdrawRequest; import org.sopt.routee.member.internal.service.MemberService; import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; +import org.sopt.routee.member.internal.service.dto.result.ProfileImageUploadUrlResult; import org.sopt.routee.member.internal.service.dto.result.UpdateNicknameResult; +import org.sopt.routee.member.internal.service.dto.result.UpdateProfileImageResult; import org.sopt.routee.response.ApiResponse; import org.sopt.routee.response.SuccessResponse; import org.sopt.routee.util.TokenExtractor; @@ -102,6 +108,28 @@ public ResponseEntity> updateNickname( .body(ApiResponse.success(SuccessCode.MEMBER_NICKNAME_UPDATED, NicknameResponse.from(result))); } + @PostMapping(path = "/member/profile-image/upload-url") + public ResponseEntity> generateProfileImageUploadUrl( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody ProfileImageUploadUrlRequest request + ) { + ProfileImageUploadUrlResult result = memberService.generateProfileImageUploadUrl(request.toCommand(memberId)); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.PROFILE_IMAGE_UPLOAD_URL_ISSUED, ProfileImageUploadUrlResponse.from(result))); + } + + @PatchMapping(path = "/member/profile-image") + public ResponseEntity> updateProfileImage( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody ProfileImageUpdateRequest request + ) { + UpdateProfileImageResult result = memberService.updateProfileImage(request.toCommand(memberId)); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.PROFILE_IMAGE_UPDATED, ProfileImageResponse.from(result))); + } + @GetMapping(path = "/archive/activity-summary") public ResponseEntity> getActivitySummary( @AuthenticationPrincipal Long memberId, diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index 2c72b65..635f013 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -6,7 +6,11 @@ import org.sopt.routee.member.internal.controller.dto.response.MemberInfoResponse; import org.sopt.routee.member.internal.controller.dto.response.MemberProfileResponse; import org.sopt.routee.member.internal.controller.dto.response.NicknameResponse; +import org.sopt.routee.member.internal.controller.dto.response.ProfileImageResponse; +import org.sopt.routee.member.internal.controller.dto.response.ProfileImageUploadUrlResponse; import org.sopt.routee.member.internal.controller.dto.request.NicknameUpdateRequest; +import org.sopt.routee.member.internal.controller.dto.request.ProfileImageUpdateRequest; +import org.sopt.routee.member.internal.controller.dto.request.ProfileImageUploadUrlRequest; import org.sopt.routee.member.internal.controller.dto.request.RegisterRequest; import org.sopt.routee.member.internal.controller.dto.request.WithdrawRequest; import org.sopt.routee.response.FailureResponse; @@ -185,6 +189,79 @@ ResponseEntity> updateNickname( @Valid @RequestBody NicknameUpdateRequest request ); + @Operation( + summary = "프로필 이미지 업로드 URL 발급", + description = "S3에 프로필 이미지를 직접 업로드할 수 있는 presigned URL과 objectKey를 발급합니다. " + + "발급받은 URL로 이미지를 업로드한 뒤 objectKey를 PATCH /member/profile-image로 전달해 반영해야 합니다." + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "발급 성공", + content = @Content(schema = @Schema(implementation = ProfileImageUploadUrlResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "INVALID_INPUT_VALUE", + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"fileName은 필수입니다.\"}"), + @ExampleObject(name = "UNSUPPORTED_IMAGE_FILE_EXTENSION", + value = "{\"status\":400,\"code\":\"UNSUPPORTED_IMAGE_FILE_EXTENSION\",\"message\":\"지원하지 않는 이미지 파일 확장자입니다.\"}"), + @ExampleObject(name = "INVALID_REQUEST_BODY", + value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}") + })), + @ApiResponse(responseCode = "401", description = "만료되었거나 유효하지 않은 액세스 토큰", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "INVALID_TOKEN", + value = "{\"status\":401,\"code\":\"INVALID_TOKEN\",\"message\":\"유효하지 않은 토큰입니다.\"}"), + @ExampleObject(name = "TOKEN_EXPIRED", + value = "{\"status\":401,\"code\":\"TOKEN_EXPIRED\",\"message\":\"만료된 토큰입니다.\"}") + })) + }) + ResponseEntity> generateProfileImageUploadUrl( + Long memberId, + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, + content = @Content(schema = @Schema(implementation = ProfileImageUploadUrlRequest.class), + examples = @ExampleObject(value = "{\"fileName\":\"profile.jpg\"}"))) + @Valid @RequestBody ProfileImageUploadUrlRequest request + ); + + @Operation( + summary = "프로필 이미지 변경", + description = "presigned URL로 업로드한 이미지의 objectKey를 받아 회원의 프로필 이미지를 갱신합니다." + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "변경 성공", + content = @Content(schema = @Schema(implementation = ProfileImageResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "INVALID_INPUT_VALUE", + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"objectKey는 필수입니다.\"}"), + @ExampleObject(name = "INVALID_REQUEST_BODY", + value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}") + })), + @ApiResponse(responseCode = "401", description = "만료되었거나 유효하지 않은 액세스 토큰", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "INVALID_TOKEN", + value = "{\"status\":401,\"code\":\"INVALID_TOKEN\",\"message\":\"유효하지 않은 토큰입니다.\"}"), + @ExampleObject(name = "TOKEN_EXPIRED", + value = "{\"status\":401,\"code\":\"TOKEN_EXPIRED\",\"message\":\"만료된 토큰입니다.\"}") + })), + @ApiResponse(responseCode = "404", description = "가입된 회원 없음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = @ExampleObject(name = "MEMBER_NOT_FOUND", + value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}"))) + }) + ResponseEntity> updateProfileImage( + Long memberId, + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, + content = @Content(schema = @Schema(implementation = ProfileImageUpdateRequest.class), + examples = @ExampleObject(value = "{\"objectKey\":\"a1b2c3d4e5f6.jpg\"}"))) + @Valid @RequestBody ProfileImageUpdateRequest request + ); + @Operation( summary = "월별 활동 요약 조회", description = "인증된 회원의 특정 연/월에 대한 일자별 활동 요약(총 활동 시간, 활동 횟수, 커버 이미지)을 조회합니다." diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/ProfileImageUpdateRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/ProfileImageUpdateRequest.java new file mode 100644 index 0000000..3bc046b --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/ProfileImageUpdateRequest.java @@ -0,0 +1,14 @@ +package org.sopt.routee.member.internal.controller.dto.request; + +import org.sopt.routee.member.internal.service.dto.command.UpdateProfileImageCommand; + +import jakarta.validation.constraints.NotBlank; + +public record ProfileImageUpdateRequest( + @NotBlank(message = "objectKey는 필수입니다.") + String objectKey +) { + public UpdateProfileImageCommand toCommand(Long memberId) { + return new UpdateProfileImageCommand(memberId, objectKey); + } +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/ProfileImageUploadUrlRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/ProfileImageUploadUrlRequest.java new file mode 100644 index 0000000..dcbbb47 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/ProfileImageUploadUrlRequest.java @@ -0,0 +1,14 @@ +package org.sopt.routee.member.internal.controller.dto.request; + +import org.sopt.routee.member.internal.service.dto.command.ProfileImageUploadUrlCommand; + +import jakarta.validation.constraints.NotBlank; + +public record ProfileImageUploadUrlRequest( + @NotBlank(message = "fileName은 필수입니다.") + String fileName +) { + public ProfileImageUploadUrlCommand toCommand(Long memberId) { + return new ProfileImageUploadUrlCommand(memberId, fileName); + } +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/ProfileImageResponse.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/ProfileImageResponse.java new file mode 100644 index 0000000..28af2fa --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/ProfileImageResponse.java @@ -0,0 +1,11 @@ +package org.sopt.routee.member.internal.controller.dto.response; + +import org.sopt.routee.member.internal.service.dto.result.UpdateProfileImageResult; + +public record ProfileImageResponse( + String profileImageUrl +) { + public static ProfileImageResponse from(UpdateProfileImageResult result) { + return new ProfileImageResponse(result.profileImageUrl()); + } +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/ProfileImageUploadUrlResponse.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/ProfileImageUploadUrlResponse.java new file mode 100644 index 0000000..b5a69b6 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/response/ProfileImageUploadUrlResponse.java @@ -0,0 +1,12 @@ +package org.sopt.routee.member.internal.controller.dto.response; + +import org.sopt.routee.member.internal.service.dto.result.ProfileImageUploadUrlResult; + +public record ProfileImageUploadUrlResponse( + String presignedUrl, + String objectKey +) { + public static ProfileImageUploadUrlResponse from(ProfileImageUploadUrlResult result) { + return new ProfileImageUploadUrlResponse(result.presignedUrl(), result.objectKey()); + } +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java index a14f59b..a1acbad 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java @@ -62,4 +62,8 @@ public class Member extends BaseEntity { public void updateNickname(String nickname) { this.nickname = nickname; } + + public void updateProfileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/exception/UnsupportedImageFileExtensionException.java b/routee-member/src/main/java/org/sopt/routee/member/internal/exception/UnsupportedImageFileExtensionException.java new file mode 100644 index 0000000..fd7e417 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/exception/UnsupportedImageFileExtensionException.java @@ -0,0 +1,10 @@ +package org.sopt.routee.member.internal.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.member.internal.code.ErrorCode; + +public class UnsupportedImageFileExtensionException extends BaseException { + public UnsupportedImageFileExtensionException() { + super(ErrorCode.UNSUPPORTED_IMAGE_FILE_EXTENSION); + } +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java index f1446c7..01a3124 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java @@ -12,6 +12,7 @@ import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; import org.sopt.routee.member.internal.service.dto.result.UpdateNicknameResult; +import org.sopt.routee.member.internal.service.dto.result.UpdateProfileImageResult; import org.sopt.routee.member.api.result.TokenClaimsResult; import org.sopt.routee.member.api.type.MemberRole; import org.sopt.routee.member.internal.entity.Member; @@ -58,6 +59,10 @@ public static UpdateNicknameResult toUpdateNicknameResult(Member member) { return new UpdateNicknameResult(member.getNickname()); } + public static UpdateProfileImageResult toUpdateProfileImageResult(Member member) { + return new UpdateProfileImageResult(member.getProfileImageUrl()); + } + public static ActivitySummaryResult toActivitySummaryResult( List summaries, int year, int month ) { diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index 62ef5c3..66be3f5 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -6,21 +6,33 @@ import org.sopt.routee.activity.api.result.MonthlyActivityDailySummaryResult; import org.sopt.routee.activity.api.usecase.ActivityUseCase; +import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; +import org.sopt.routee.external.api.command.FileUploadPresignCommand; +import org.sopt.routee.external.api.port.FileImageAccessUrlPort; +import org.sopt.routee.external.api.port.FileUploadPresignPort; +import org.sopt.routee.external.api.result.FileUploadPresignResult; +import org.sopt.routee.external.api.type.FileUploadDirectory; import org.sopt.routee.external.api.type.OAuthProvider; import org.sopt.routee.external.api.port.OidcVerifyPort; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; +import org.sopt.routee.member.internal.service.dto.command.ProfileImageUploadUrlCommand; import org.sopt.routee.member.internal.service.dto.command.RegisterCommand; import org.sopt.routee.member.internal.service.dto.command.UpdateNicknameCommand; +import org.sopt.routee.member.internal.service.dto.command.UpdateProfileImageCommand; import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; +import org.sopt.routee.member.internal.service.dto.result.ProfileImageUploadUrlResult; import org.sopt.routee.member.internal.service.dto.result.UpdateNicknameResult; +import org.sopt.routee.member.internal.service.dto.result.UpdateProfileImageResult; import org.sopt.routee.member.api.result.TokenClaimsResult; import org.sopt.routee.member.internal.entity.Member; import org.sopt.routee.member.internal.exception.AlreadyRegisteredMemberException; import org.sopt.routee.member.internal.exception.MemberNotFoundException; +import org.sopt.routee.member.internal.exception.UnsupportedImageFileExtensionException; import org.sopt.routee.member.internal.mapper.MemberMapper; import org.sopt.routee.member.internal.repository.MemberRepository; +import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +47,9 @@ public class MemberService { private final ActivityUseCase activityUseCase; private final MemberRepository memberRepository; private final ApplicationEventPublisher applicationEventPublisher; + private final FileUploadPresignPort fileUploadPresignPort; + private final FileImageAccessUrlPort fileImageAccessUrlPort; + private final ProfileImageFileNameValidator profileImageFileNameValidator; @Transactional(readOnly = true) public TokenClaimsResult getTokenResult(String oauthId, OAuthProvider oauthProvider) { @@ -96,6 +111,39 @@ public UpdateNicknameResult updateNickname(UpdateNicknameCommand command) { return MemberMapper.toUpdateNicknameResult(member); } + public ProfileImageUploadUrlResult generateProfileImageUploadUrl(ProfileImageUploadUrlCommand command) { + if (!profileImageFileNameValidator.validate(command.fileName())) { + throw new UnsupportedImageFileExtensionException(); + } + + FileUploadPresignCommand presignCommand = new FileUploadPresignCommand( + FileUploadDirectory.PROFILE, + null, + command.memberId().toString(), + command.fileName() + ); + FileUploadPresignResult result = fileUploadPresignPort.generatePutPresignedUrl(presignCommand); + + return new ProfileImageUploadUrlResult(result.presignedUrl(), result.objectKey()); + } + + @Transactional + public UpdateProfileImageResult updateProfileImage(UpdateProfileImageCommand command) { + Member member = memberRepository.findById(command.memberId()) + .orElseThrow(MemberNotFoundException::new); + + FileImageAccessUrlCommand accessUrlCommand = new FileImageAccessUrlCommand( + FileUploadDirectory.PROFILE, + null, + command.memberId().toString(), + command.objectKey() + ); + String profileImageUrl = fileImageAccessUrlPort.generateImageUrl(accessUrlCommand).imageUrl(); + + member.updateProfileImageUrl(profileImageUrl); + return MemberMapper.toUpdateProfileImageResult(member); + } + @Transactional public void incrementTotalActivityCount(long memberId) { memberRepository.incrementTotalActivityCount(memberId); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/ProfileImageUploadUrlCommand.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/ProfileImageUploadUrlCommand.java new file mode 100644 index 0000000..ce325f4 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/ProfileImageUploadUrlCommand.java @@ -0,0 +1,7 @@ +package org.sopt.routee.member.internal.service.dto.command; + +public record ProfileImageUploadUrlCommand( + Long memberId, + String fileName +) { +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/UpdateProfileImageCommand.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/UpdateProfileImageCommand.java new file mode 100644 index 0000000..8e079d0 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/UpdateProfileImageCommand.java @@ -0,0 +1,7 @@ +package org.sopt.routee.member.internal.service.dto.command; + +public record UpdateProfileImageCommand( + Long memberId, + String objectKey +) { +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/ProfileImageUploadUrlResult.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/ProfileImageUploadUrlResult.java new file mode 100644 index 0000000..36e6ec0 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/ProfileImageUploadUrlResult.java @@ -0,0 +1,7 @@ +package org.sopt.routee.member.internal.service.dto.result; + +public record ProfileImageUploadUrlResult( + String presignedUrl, + String objectKey +) { +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/UpdateProfileImageResult.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/UpdateProfileImageResult.java new file mode 100644 index 0000000..84bbade --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/result/UpdateProfileImageResult.java @@ -0,0 +1,6 @@ +package org.sopt.routee.member.internal.service.dto.result; + +public record UpdateProfileImageResult( + String profileImageUrl +) { +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/validator/ProfileImageFileNameValidator.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/validator/ProfileImageFileNameValidator.java new file mode 100644 index 0000000..49b4e1d --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/validator/ProfileImageFileNameValidator.java @@ -0,0 +1,27 @@ +package org.sopt.routee.member.internal.service.validator; + +import java.util.Locale; +import java.util.Set; + +import org.springframework.stereotype.Component; + +@Component +public class ProfileImageFileNameValidator { + + private static final Set SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp", "heic"); + + public boolean validate(String fileName) { + int extensionSeparatorIndex = fileName.lastIndexOf('.'); + if (extensionSeparatorIndex <= 0 || extensionSeparatorIndex == fileName.length() - 1) { + return false; + } + + String extension = fileName.substring(extensionSeparatorIndex + 1) + .toLowerCase(Locale.ROOT); + if (!SUPPORTED_EXTENSIONS.contains(extension)) { + return false; + } + + return true; + } +} From 12a2d4553584ef551501cb0f45854ad1217f75eb Mon Sep 17 00:00:00 2001 From: kymin Date: Sat, 15 Aug 2026 10:35:58 +0900 Subject: [PATCH 04/68] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9E=90=20=EC=B6=94=EC=B6=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ActivityImageFileNameValidator.java | 19 +++++----------- .../routee/util/FileExtensionExtractor.java | 22 +++++++++++++++++++ .../internal/s3/adapter/S3PresignAdapter.java | 6 ++--- .../ProfileImageFileNameValidator.java | 19 +++++----------- 4 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 routee-common/src/main/java/org/sopt/routee/util/FileExtensionExtractor.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/validator/ActivityImageFileNameValidator.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/validator/ActivityImageFileNameValidator.java index e07c1c2..3895eba 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/validator/ActivityImageFileNameValidator.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/validator/ActivityImageFileNameValidator.java @@ -1,8 +1,8 @@ package org.sopt.routee.activity.internal.service.validator; -import java.util.Locale; import java.util.Set; +import org.sopt.routee.util.FileExtensionExtractor; import org.springframework.stereotype.Component; @Component @@ -11,17 +11,8 @@ public class ActivityImageFileNameValidator { private static final Set SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp", "heic"); public boolean validate(String fileName) { - int extensionSeparatorIndex = fileName.lastIndexOf('.'); - if (extensionSeparatorIndex <= 0 || extensionSeparatorIndex == fileName.length() - 1) { - return false; - } - - String extension = fileName.substring(extensionSeparatorIndex + 1) - .toLowerCase(Locale.ROOT); - if (!SUPPORTED_EXTENSIONS.contains(extension)) { - return false; - } - - return true; + return FileExtensionExtractor.extract(fileName) + .map(SUPPORTED_EXTENSIONS::contains) + .orElse(false); } -} +} \ No newline at end of file diff --git a/routee-common/src/main/java/org/sopt/routee/util/FileExtensionExtractor.java b/routee-common/src/main/java/org/sopt/routee/util/FileExtensionExtractor.java new file mode 100644 index 0000000..bf5bd82 --- /dev/null +++ b/routee-common/src/main/java/org/sopt/routee/util/FileExtensionExtractor.java @@ -0,0 +1,22 @@ +package org.sopt.routee.util; + +import java.util.Locale; +import java.util.Optional; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FileExtensionExtractor { + + public static Optional extract(String fileName) { + int extensionSeparatorIndex = fileName.lastIndexOf('.'); + if (extensionSeparatorIndex <= 0 || extensionSeparatorIndex == fileName.length() - 1) { + return Optional.empty(); + } + + return Optional.of( + fileName.substring(extensionSeparatorIndex + 1).toLowerCase(Locale.ROOT) + ); + } +} \ No newline at end of file diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java index 0ee4b8c..b52275a 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java @@ -1,7 +1,6 @@ package org.sopt.routee.external.internal.s3.adapter; import java.time.Duration; -import java.util.Locale; import java.util.UUID; import org.sopt.routee.external.api.command.FileUploadPresignCommand; @@ -9,6 +8,7 @@ import org.sopt.routee.external.api.result.FileUploadPresignResult; import org.sopt.routee.external.internal.s3.config.S3Properties; import org.sopt.routee.external.internal.s3.exception.FileUploadPresignException; +import org.sopt.routee.util.FileExtensionExtractor; import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; @@ -45,8 +45,8 @@ private String generateStoredObjectKey(String extension) { } private String parseExtension(String fileName) { - return fileName.substring(fileName.lastIndexOf('.') + 1) - .toLowerCase(Locale.ROOT); + return FileExtensionExtractor.extract(fileName) + .orElseThrow(); } private String generatePutPresignedUrl(String objectKey) { diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/validator/ProfileImageFileNameValidator.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/validator/ProfileImageFileNameValidator.java index 49b4e1d..2c85409 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/validator/ProfileImageFileNameValidator.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/validator/ProfileImageFileNameValidator.java @@ -1,8 +1,8 @@ package org.sopt.routee.member.internal.service.validator; -import java.util.Locale; import java.util.Set; +import org.sopt.routee.util.FileExtensionExtractor; import org.springframework.stereotype.Component; @Component @@ -11,17 +11,8 @@ public class ProfileImageFileNameValidator { private static final Set SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp", "heic"); public boolean validate(String fileName) { - int extensionSeparatorIndex = fileName.lastIndexOf('.'); - if (extensionSeparatorIndex <= 0 || extensionSeparatorIndex == fileName.length() - 1) { - return false; - } - - String extension = fileName.substring(extensionSeparatorIndex + 1) - .toLowerCase(Locale.ROOT); - if (!SUPPORTED_EXTENSIONS.contains(extension)) { - return false; - } - - return true; + return FileExtensionExtractor.extract(fileName) + .map(SUPPORTED_EXTENSIONS::contains) + .orElse(false); } -} +} \ No newline at end of file From 4f1e71ff59362b4b68ba19802da21f059e7b7728 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Sat, 15 Aug 2026 16:10:41 +0900 Subject: [PATCH 05/68] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../activity/internal/code/ErrorCode.java | 1 + .../activity/internal/code/SuccessCode.java | 1 + .../controller/TimelineController.java | 12 +++++++++ .../controller/TimelineControllerDocs.java | 25 +++++++++++++++++++ .../exception/TimelineNotFoundException.java | 11 ++++++++ .../repository/TimelineRepository.java | 3 +++ .../internal/service/TimelineService.java | 13 ++++++++++ 7 files changed, 66 insertions(+) create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/exception/TimelineNotFoundException.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/ErrorCode.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/ErrorCode.java index 147479b..dd42628 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/ErrorCode.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/ErrorCode.java @@ -15,6 +15,7 @@ public enum ErrorCode implements ErrorResultCode { UNSUPPORTED_IMAGE_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 파일 확장자입니다."), ACTIVITY_NOT_FOUND(HttpStatus.NOT_FOUND, "활동 기록이 존재하지 않습니다."), + TIMELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "타임라인이 존재하지 않습니다."), ACTIVITY_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 완료된 활동입니다."), ACTIVITY_STATUS_ALREADY_SAME(HttpStatus.CONFLICT, "이미 동일한 활동 상태입니다."), diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java index 81e6c9c..d76bde5 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java @@ -20,6 +20,7 @@ public enum SuccessCode implements SuccessResultCode { ARCHIVE_ACTIVITY_LIST_GET_SUCCESS(HttpStatus.OK, "특정 날짜의 활동 목록 조회에 성공했습니다."), IMAGE_UPLOAD_URL_CREATED(HttpStatus.OK, "이미지 업로드 URL 발급에 성공했습니다."), ROUTE_LIST_GET_SUCCESS(HttpStatus.OK, "루트 목록 조회에 성공했습니다."), + TIMELINE_DELETED(HttpStatus.OK, "타임라인 삭제에 성공했습니다."), TIMELINE_LIST_GET_SUCCESS(HttpStatus.OK, "타임라인 목록 조회에 성공했습니다."), ACTIVITY_CREATED(HttpStatus.CREATED, "활동 기록 생성에 성공했습니다."), diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java index c969a81..a655a4e 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java @@ -13,6 +13,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -54,4 +55,15 @@ public ResponseEntity> getTimelines( return ResponseEntity.ok( ApiResponse.success(SuccessCode.TIMELINE_LIST_GET_SUCCESS, TimelineListResponse.of(activityId, results))); } + + @DeleteMapping("/{activityId}/timeline/{timelineId}") + public ResponseEntity> delete( + @AuthenticationPrincipal Long memberId, + @PathVariable(name = "activityId") Long activityId, + @PathVariable(name = "timelineId") Long timelineId + ) { + timelineService.delete(activityId, timelineId, memberId); + + return ResponseEntity.ok(ApiResponse.success(SuccessCode.TIMELINE_DELETED)); + } } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java index 22e2d92..5dba8ec 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java @@ -76,4 +76,29 @@ ResponseEntity> getTimelines( @Parameter(description = "활동 기록 식별자", example = "1", required = true) @PathVariable(name = "activityId") Long activityId ); + + @Operation(summary = "타임라인 삭제", description = "인증된 사용자의 활동 기록에 속한 타임라인을 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "타임라인 삭제 성공", + content = @Content( + examples = @ExampleObject(value = "{\"status\":200,\"code\":\"TIMELINE_DELETED\",\"message\":\"타임라인 삭제에 성공했습니다.\",\"data\":null}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "404", description = "활동 기록 또는 타임라인이 존재하지 않음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "ACTIVITY_NOT_FOUND", + value = "{\"status\":404,\"code\":\"ACTIVITY_NOT_FOUND\",\"message\":\"활동 기록이 존재하지 않습니다.\"}"), + @ExampleObject(name = "TIMELINE_NOT_FOUND", + value = "{\"status\":404,\"code\":\"TIMELINE_NOT_FOUND\",\"message\":\"타임라인이 존재하지 않습니다.\"}") + })) + }) + ResponseEntity> delete( + @Parameter(hidden = true) + Long memberId, + @Parameter(description = "활동 기록 식별자", example = "1", required = true) + @PathVariable(name = "activityId") Long activityId, + @Parameter(description = "타임라인 식별자", example = "3", required = true) + @PathVariable(name = "timelineId") Long timelineId + ); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/exception/TimelineNotFoundException.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/exception/TimelineNotFoundException.java new file mode 100644 index 0000000..5933685 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/exception/TimelineNotFoundException.java @@ -0,0 +1,11 @@ +package org.sopt.routee.activity.internal.exception; + +import org.sopt.routee.activity.internal.code.ErrorCode; +import org.sopt.routee.exception.BaseException; + +public class TimelineNotFoundException extends BaseException { + + public TimelineNotFoundException() { + super(ErrorCode.TIMELINE_NOT_FOUND); + } +} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java index cee0093..48abb3d 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java @@ -1,6 +1,7 @@ package org.sopt.routee.activity.internal.repository; import java.util.List; +import java.util.Optional; import org.sopt.routee.activity.internal.entity.timeline.Timeline; import org.sopt.routee.activity.internal.entity.timeline.TimelineStatus; @@ -12,6 +13,8 @@ public interface TimelineRepository extends JpaRepository { boolean existsByActivityIdAndTrackPointIndex(Long activityId, Integer trackPointIndex); + Optional findByIdAndActivityId(Long id, Long activityId); + List findByActivityIdOrderByCreatedAtAsc(Long activityId); List findByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc(Long activityId, TimelineStatus timelineStatus); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index 0602783..f76b749 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -6,6 +6,7 @@ import org.sopt.routee.activity.internal.entity.activity.Activity; import org.sopt.routee.activity.internal.entity.timeline.Timeline; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; +import org.sopt.routee.activity.internal.exception.TimelineNotFoundException; import org.sopt.routee.activity.internal.mapper.TimelineMapper; import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.repository.TimelineRepository; @@ -50,6 +51,18 @@ public List getTimelines(Long activityId, Long memberId) { .toList(); } + @Transactional + public void delete(Long activityId, Long timelineId, Long memberId) { + if (!activityRepository.existsByIdAndMemberId(activityId, memberId)) { + throw new ActivityNotFoundException(); + } + + Timeline timeline = timelineRepository.findByIdAndActivityId(timelineId, activityId) + .orElseThrow(TimelineNotFoundException::new); + + timelineRepository.delete(timeline); + } + @Transactional public void deleteTimelinesByMemberId(long memberId) { timelineRepository.deleteTimelinesByMemberId(memberId); From 6b8b56d50e8ae5d5c1111f51d8718aba837174e1 Mon Sep 17 00:00:00 2001 From: kymin Date: Sun, 16 Aug 2026 09:51:36 +0900 Subject: [PATCH 06/68] =?UTF-8?q?refactor:=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routee/member/internal/controller/MemberController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java index 1eda8ad..81842b3 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java @@ -76,7 +76,7 @@ public ResponseEntity> withdraw( .body(ApiResponse.success(SuccessCode.MEMBER_WITHDRAW)); } - @GetMapping(path = "/member/profile") + @GetMapping(path = "/member/summary") public ResponseEntity> getMemberInfo( @AuthenticationPrincipal Long memberId, @RequestHeader(name = "Time-Zone") ZoneId timeZone @@ -87,7 +87,7 @@ public ResponseEntity> getMemberInfo( .body(ApiResponse.success(SuccessCode.MEMBER_FOUND, MemberInfoResponse.from(result))); } - @GetMapping(path = "/member/settings/profile") + @GetMapping(path = "/member/profile") public ResponseEntity> getMemberProfile( @AuthenticationPrincipal Long memberId ) { From d3a073f6531b9c6a1d8a851720118133014c548d Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Mon, 17 Aug 2026 03:21:05 +0900 Subject: [PATCH 07/68] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20S3=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routee-activity/build.gradle | 1 + .../internal/event/TimelineDeletedEvent.java | 7 ++ .../TimelineImageCleanupListener.java | 26 +++++ .../internal/service/TimelineService.java | 7 ++ .../TimelineImageCleanupListenerTest.java | 41 ++++++++ .../internal/service/TimelineServiceTest.java | 99 +++++++++++++++++++ .../api/command/FileDeleteCommand.java | 10 ++ .../external/api/port/FileDeletePort.java | 8 ++ .../s3/adapter/S3FileDeleteAdapter.java | 58 +++++++++++ .../external/internal/s3/code/ErrorCode.java | 3 +- .../external/internal/s3/config/S3Config.java | 8 ++ .../s3/exception/FileDeleteException.java | 11 +++ .../s3/adapter/S3FileDeleteAdapterTest.java | 86 ++++++++++++++++ 13 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/event/TimelineDeletedEvent.java create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java create mode 100644 routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java create mode 100644 routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/api/port/FileDeletePort.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileDeleteException.java create mode 100644 routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java diff --git a/routee-activity/build.gradle b/routee-activity/build.gradle index 43411dd..2a1d368 100644 --- a/routee-activity/build.gradle +++ b/routee-activity/build.gradle @@ -24,4 +24,5 @@ dependencies { // Modulith compileOnly 'org.springframework.modulith:spring-modulith-api' + implementation 'org.springframework.modulith:spring-modulith-events-api' } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/event/TimelineDeletedEvent.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/event/TimelineDeletedEvent.java new file mode 100644 index 0000000..8ca0087 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/event/TimelineDeletedEvent.java @@ -0,0 +1,7 @@ +package org.sopt.routee.activity.internal.event; + +public record TimelineDeletedEvent( + Long activityId, + String timelineImageObjectKey +) { +} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java new file mode 100644 index 0000000..29d28ca --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java @@ -0,0 +1,26 @@ +package org.sopt.routee.activity.internal.listener; + +import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; +import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.port.FileDeletePort; +import org.sopt.routee.external.api.type.FileUploadDirectory; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +class TimelineImageCleanupListener { + + private final FileDeletePort fileDeletePort; + + @ApplicationModuleListener + void handleTimelineDeleted(TimelineDeletedEvent event) { + fileDeletePort.deleteImage(new FileDeleteCommand( + FileUploadDirectory.TIMELINE, + event.activityId().toString(), + event.timelineImageObjectKey() + )); + } +} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index f76b749..2960564 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -5,6 +5,7 @@ import org.sopt.routee.activity.internal.entity.activity.Activity; import org.sopt.routee.activity.internal.entity.timeline.Timeline; +import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; import org.sopt.routee.activity.internal.exception.TimelineNotFoundException; import org.sopt.routee.activity.internal.mapper.TimelineMapper; @@ -17,6 +18,7 @@ import org.sopt.routee.external.api.type.FileUploadDirectory; import org.sopt.routee.external.api.type.FileUploadImageSize; import org.sopt.routee.util.TimeZoneUtils; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +31,7 @@ public class TimelineService { private final ActivityRepository activityRepository; private final TimelineRepository timelineRepository; private final FileImageAccessUrlPort fileImageAccessUrlPort; + private final ApplicationEventPublisher applicationEventPublisher; @Transactional public void create(CreateTimelineCommand command) { @@ -61,6 +64,10 @@ public void delete(Long activityId, Long timelineId, Long memberId) { .orElseThrow(TimelineNotFoundException::new); timelineRepository.delete(timeline); + + applicationEventPublisher.publishEvent( + new TimelineDeletedEvent(activityId, timeline.getTimelineImageObjectKey()) + ); } @Transactional diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java new file mode 100644 index 0000000..e6f7f29 --- /dev/null +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java @@ -0,0 +1,41 @@ +package org.sopt.routee.activity.internal.listener; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; +import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.port.FileDeletePort; +import org.sopt.routee.external.api.type.FileUploadDirectory; + +@ExtendWith(MockitoExtension.class) +class TimelineImageCleanupListenerTest { + + @Mock + private FileDeletePort fileDeletePort; + + @InjectMocks + private TimelineImageCleanupListener timelineImageCleanupListener; + + @Test + void handleTimelineDeleted_타임라인_삭제_이벤트를_받으면_TIMELINE_디렉토리_기준으로_이미지_삭제를_요청한다() { + Long activityId = 1L; + String objectKey = "timeline-image.jpg"; + + timelineImageCleanupListener.handleTimelineDeleted(new TimelineDeletedEvent(activityId, objectKey)); + + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(FileDeleteCommand.class); + verify(fileDeletePort).deleteImage(commandCaptor.capture()); + + FileDeleteCommand command = commandCaptor.getValue(); + assertThat(command.directory()).isEqualTo(FileUploadDirectory.TIMELINE); + assertThat(command.activityId()).isEqualTo(activityId.toString()); + assertThat(command.objectKey()).isEqualTo(objectKey); + } +} diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java new file mode 100644 index 0000000..4f0f123 --- /dev/null +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java @@ -0,0 +1,99 @@ +package org.sopt.routee.activity.internal.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.routee.activity.internal.entity.timeline.Timeline; +import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; +import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; +import org.sopt.routee.activity.internal.exception.TimelineNotFoundException; +import org.sopt.routee.activity.internal.repository.ActivityRepository; +import org.sopt.routee.activity.internal.repository.TimelineRepository; +import org.sopt.routee.external.api.port.FileImageAccessUrlPort; +import org.springframework.context.ApplicationEventPublisher; + +@ExtendWith(MockitoExtension.class) +class TimelineServiceTest { + + @Mock + private ActivityRepository activityRepository; + + @Mock + private TimelineRepository timelineRepository; + + @Mock + private FileImageAccessUrlPort fileImageAccessUrlPort; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private TimelineService timelineService; + + @Test + void delete_타임라인을_삭제하면_이미지_키를_담은_삭제_이벤트를_발행한다() { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + String objectKey = "timeline-image.jpg"; + + Timeline timeline = Timeline.builder() + .id(timelineId) + .timelineImageObjectKey(objectKey) + .build(); + + when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(true); + when(timelineRepository.findByIdAndActivityId(timelineId, activityId)).thenReturn(Optional.of(timeline)); + + timelineService.delete(activityId, timelineId, memberId); + + verify(timelineRepository).delete(timeline); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(TimelineDeletedEvent.class); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + + TimelineDeletedEvent event = eventCaptor.getValue(); + assertThat(event.activityId()).isEqualTo(activityId); + assertThat(event.timelineImageObjectKey()).isEqualTo(objectKey); + } + + @Test + void delete_활동이_없으면_예외를_던지고_이벤트를_발행하지_않는다() { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + + when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(false); + + assertThatThrownBy(() -> timelineService.delete(activityId, timelineId, memberId)) + .isInstanceOf(ActivityNotFoundException.class); + + verify(timelineRepository, never()).delete(any()); + verify(applicationEventPublisher, never()).publishEvent(any()); + } + + @Test + void delete_타임라인이_없으면_예외를_던지고_이벤트를_발행하지_않는다() { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + + when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(true); + when(timelineRepository.findByIdAndActivityId(timelineId, activityId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> timelineService.delete(activityId, timelineId, memberId)) + .isInstanceOf(TimelineNotFoundException.class); + + verify(timelineRepository, never()).delete(any()); + verify(applicationEventPublisher, never()).publishEvent(any()); + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java new file mode 100644 index 0000000..495ab86 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java @@ -0,0 +1,10 @@ +package org.sopt.routee.external.api.command; + +import org.sopt.routee.external.api.type.FileUploadDirectory; + +public record FileDeleteCommand( + FileUploadDirectory directory, + String activityId, + String objectKey +) { +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/port/FileDeletePort.java b/routee-external/src/main/java/org/sopt/routee/external/api/port/FileDeletePort.java new file mode 100644 index 0000000..82eb10a --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/port/FileDeletePort.java @@ -0,0 +1,8 @@ +package org.sopt.routee.external.api.port; + +import org.sopt.routee.external.api.command.FileDeleteCommand; + +public interface FileDeletePort { + + void deleteImage(FileDeleteCommand command); +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java new file mode 100644 index 0000000..5d355eb --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java @@ -0,0 +1,58 @@ +package org.sopt.routee.external.internal.s3.adapter; + +import java.util.Arrays; +import java.util.List; + +import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.port.FileDeletePort; +import org.sopt.routee.external.api.type.FileUploadDirectory; +import org.sopt.routee.external.api.type.FileUploadImageSize; +import org.sopt.routee.external.internal.s3.config.S3Properties; +import org.sopt.routee.external.internal.s3.exception.FileDeleteException; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.Delete; +import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; + +@Component +@RequiredArgsConstructor +public class S3FileDeleteAdapter implements FileDeletePort { + + private final S3Client s3Client; + private final S3Properties properties; + + @Override + public void deleteImage(FileDeleteCommand command) { + List objectIdentifiers = resolveObjectKeys(command).stream() + .map(objectKey -> ObjectIdentifier.builder().key(objectKey).build()) + .toList(); + + DeleteObjectsRequest request = DeleteObjectsRequest.builder() + .bucket(properties.bucket()) + .delete(Delete.builder().objects(objectIdentifiers).build()) + .build(); + + try { + s3Client.deleteObjects(request); + } catch (RuntimeException e) { + throw new FileDeleteException(e); + } + } + + private List resolveObjectKeys(FileDeleteCommand command) { + if (command.directory() != FileUploadDirectory.TIMELINE) { + return List.of(S3ObjectKeyAssembler.assembleUploadObjectKey( + command.directory(), null, command.activityId(), command.objectKey() + )); + } + + return Arrays.stream(FileUploadImageSize.values()) + .map(imageSize -> S3ObjectKeyAssembler.assembleUploadObjectKey( + command.directory(), imageSize, command.activityId(), command.objectKey() + )) + .toList(); + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/code/ErrorCode.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/code/ErrorCode.java index 664f183..090ce54 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/code/ErrorCode.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/code/ErrorCode.java @@ -10,7 +10,8 @@ @RequiredArgsConstructor public enum ErrorCode implements ErrorResultCode { - FILE_UPLOAD_PRESIGN_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 URL 생성에 실패했습니다."); + FILE_UPLOAD_PRESIGN_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 URL 생성에 실패했습니다."), + FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제에 실패했습니다."); private final HttpStatus status; private final String message; diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/config/S3Config.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/config/S3Config.java index a242181..301651f 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/config/S3Config.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/config/S3Config.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Configuration; import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration @@ -15,4 +16,11 @@ S3Presigner s3Presigner(S3Properties properties) { .region(Region.of(properties.region())) .build(); } + + @Bean + S3Client s3Client(S3Properties properties) { + return S3Client.builder() + .region(Region.of(properties.region())) + .build(); + } } diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileDeleteException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileDeleteException.java new file mode 100644 index 0000000..f2ef3db --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileDeleteException.java @@ -0,0 +1,11 @@ +package org.sopt.routee.external.internal.s3.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.internal.s3.code.ErrorCode; + +public final class FileDeleteException extends BaseException { + + public FileDeleteException(Throwable cause) { + super(ErrorCode.FILE_DELETE_FAILED, cause); + } +} diff --git a/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java new file mode 100644 index 0000000..9d9260a --- /dev/null +++ b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java @@ -0,0 +1,86 @@ +package org.sopt.routee.external.internal.s3.adapter; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.type.FileUploadDirectory; +import org.sopt.routee.external.internal.s3.config.S3Properties; +import org.sopt.routee.external.internal.s3.exception.FileDeleteException; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; + +@ExtendWith(MockitoExtension.class) +class S3FileDeleteAdapterTest { + + @Mock + private S3Client s3Client; + + private S3FileDeleteAdapter s3FileDeleteAdapter; + + @BeforeEach + void setUp() { + S3Properties properties = new S3Properties("routee-bucket", "ap-northeast-2", null, 10); + s3FileDeleteAdapter = new S3FileDeleteAdapter(s3Client, properties); + } + + @Test + void deleteImage_타임라인_이미지를_삭제하면_원본과_람다가_생성한_리사이징_사이즈_전부를_삭제_요청한다() { + FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.TIMELINE, "1", "timeline-image.jpg"); + + s3FileDeleteAdapter.deleteImage(command); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(DeleteObjectsRequest.class); + verify(s3Client).deleteObjects(requestCaptor.capture()); + + DeleteObjectsRequest request = requestCaptor.getValue(); + assertThat(request.bucket()).isEqualTo("routee-bucket"); + + List objectKeys = request.delete().objects().stream() + .map(ObjectIdentifier::key) + .toList(); + + assertThat(objectKeys).containsExactlyInAnyOrder( + "activity/1/timeline/original/timeline-image.jpg", + "activity/1/timeline/small/timeline-image.jpg", + "activity/1/timeline/medium/timeline-image.jpg", + "activity/1/timeline/large/timeline-image.jpg" + ); + } + + @Test + void deleteImage_RECAP_이미지를_삭제하면_사이즈_구분_없이_단일_키만_삭제_요청한다() { + FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "1", "recap-image.jpg"); + + s3FileDeleteAdapter.deleteImage(command); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(DeleteObjectsRequest.class); + verify(s3Client).deleteObjects(requestCaptor.capture()); + + List objectKeys = requestCaptor.getValue().delete().objects().stream() + .map(ObjectIdentifier::key) + .toList(); + + assertThat(objectKeys).containsExactly("activity/1/recap/recap-image.jpg"); + } + + @Test + void deleteImage_S3_삭제_요청이_실패하면_FileDeleteException으로_변환한다() { + FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "1", "recap-image.jpg"); + when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenThrow(new RuntimeException("boom")); + + assertThatThrownBy(() -> s3FileDeleteAdapter.deleteImage(command)) + .isInstanceOf(FileDeleteException.class); + } +} From 692350a0ea3146965a34eaa14f54aabd5d901abe Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Mon, 17 Aug 2026 03:31:00 +0900 Subject: [PATCH 08/68] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=A0=9C=EB=AA=A9=20=EC=88=98=EC=A0=95=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../activity/internal/code/SuccessCode.java | 1 + .../controller/TimelineController.java | 14 ++++++ .../controller/TimelineControllerDocs.java | 38 ++++++++++++++ .../request/TimelineTitleUpdateRequest.java | 16 ++++++ .../internal/entity/timeline/Timeline.java | 4 ++ .../internal/service/TimelineService.java | 13 +++++ .../command/UpdateTimelineTitleCommand.java | 9 ++++ .../internal/service/TimelineServiceTest.java | 50 +++++++++++++++++++ 8 files changed, 145 insertions(+) create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/TimelineTitleUpdateRequest.java create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/UpdateTimelineTitleCommand.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java index d76bde5..9f5f985 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java @@ -22,6 +22,7 @@ public enum SuccessCode implements SuccessResultCode { ROUTE_LIST_GET_SUCCESS(HttpStatus.OK, "루트 목록 조회에 성공했습니다."), TIMELINE_DELETED(HttpStatus.OK, "타임라인 삭제에 성공했습니다."), TIMELINE_LIST_GET_SUCCESS(HttpStatus.OK, "타임라인 목록 조회에 성공했습니다."), + TIMELINE_TITLE_UPDATED(HttpStatus.OK, "타임라인 제목 수정에 성공했습니다."), ACTIVITY_CREATED(HttpStatus.CREATED, "활동 기록 생성에 성공했습니다."), ROUTE_LIST_CREATE_SUCCESS(HttpStatus.CREATED, "루트 목록 생성에 성공했습니다."), diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java index a655a4e..be406b6 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java @@ -5,6 +5,7 @@ import org.sopt.routee.activity.internal.code.SuccessCode; import org.sopt.routee.activity.internal.controller.dto.request.CreateTimelineRequest; +import org.sopt.routee.activity.internal.controller.dto.request.TimelineTitleUpdateRequest; import org.sopt.routee.activity.internal.controller.dto.response.TimelineListResponse; import org.sopt.routee.activity.internal.service.TimelineService; import org.sopt.routee.activity.internal.service.dto.result.TimelineResult; @@ -15,6 +16,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -56,6 +58,18 @@ public ResponseEntity> getTimelines( ApiResponse.success(SuccessCode.TIMELINE_LIST_GET_SUCCESS, TimelineListResponse.of(activityId, results))); } + @PatchMapping("/{activityId}/timeline/{timelineId}") + public ResponseEntity> updateTitle( + @AuthenticationPrincipal Long memberId, + @PathVariable(name = "activityId") Long activityId, + @PathVariable(name = "timelineId") Long timelineId, + @Valid @RequestBody TimelineTitleUpdateRequest request + ) { + timelineService.updateTitle(request.toCommand(activityId, timelineId, memberId)); + + return ResponseEntity.ok(ApiResponse.success(SuccessCode.TIMELINE_TITLE_UPDATED)); + } + @DeleteMapping("/{activityId}/timeline/{timelineId}") public ResponseEntity> delete( @AuthenticationPrincipal Long memberId, diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java index 5dba8ec..c6934ba 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java @@ -3,6 +3,7 @@ import java.time.ZoneId; import org.sopt.routee.activity.internal.controller.dto.request.CreateTimelineRequest; +import org.sopt.routee.activity.internal.controller.dto.request.TimelineTitleUpdateRequest; import org.sopt.routee.activity.internal.controller.dto.response.TimelineListResponse; import org.sopt.routee.response.FailureResponse; import org.sopt.routee.response.SuccessResponse; @@ -77,6 +78,43 @@ ResponseEntity> getTimelines( @PathVariable(name = "activityId") Long activityId ); + @Operation(summary = "타임라인 제목 수정", description = "인증된 사용자의 활동 기록에 속한 타임라인 제목을 수정합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "타임라인 제목 수정 성공", + content = @Content( + examples = @ExampleObject(value = "{\"status\":200,\"code\":\"TIMELINE_TITLE_UPDATED\",\"message\":\"타임라인 제목 수정에 성공했습니다.\",\"data\":null}"))), + @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "TITLE_REQUIRED", + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"title은 필수입니다.\"}"), + @ExampleObject(name = "TITLE_TOO_LONG", + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"title은 16자 이하여야 합니다.\"}") + })), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "404", description = "활동 기록 또는 타임라인이 존재하지 않음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "ACTIVITY_NOT_FOUND", + value = "{\"status\":404,\"code\":\"ACTIVITY_NOT_FOUND\",\"message\":\"활동 기록이 존재하지 않습니다.\"}"), + @ExampleObject(name = "TIMELINE_NOT_FOUND", + value = "{\"status\":404,\"code\":\"TIMELINE_NOT_FOUND\",\"message\":\"타임라인이 존재하지 않습니다.\"}") + })) + }) + ResponseEntity> updateTitle( + @Parameter(hidden = true) + Long memberId, + @Parameter(description = "활동 기록 식별자", example = "1", required = true) + @PathVariable(name = "activityId") Long activityId, + @Parameter(description = "타임라인 식별자", example = "3", required = true) + @PathVariable(name = "timelineId") Long timelineId, + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, + content = @Content(schema = @Schema(implementation = TimelineTitleUpdateRequest.class), + examples = @ExampleObject(value = "{\"title\":\"백운대 정상 도착\"}"))) + @Valid @RequestBody TimelineTitleUpdateRequest request + ); + @Operation(summary = "타임라인 삭제", description = "인증된 사용자의 활동 기록에 속한 타임라인을 삭제합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "타임라인 삭제 성공", diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/TimelineTitleUpdateRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/TimelineTitleUpdateRequest.java new file mode 100644 index 0000000..73587f5 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/TimelineTitleUpdateRequest.java @@ -0,0 +1,16 @@ +package org.sopt.routee.activity.internal.controller.dto.request; + +import org.sopt.routee.activity.internal.service.dto.command.UpdateTimelineTitleCommand; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TimelineTitleUpdateRequest( + @NotBlank(message = "title은 필수입니다.") + @Size(max = 16, message = "title은 16자 이하여야 합니다.") + String title +) { + public UpdateTimelineTitleCommand toCommand(Long activityId, Long timelineId, Long memberId) { + return new UpdateTimelineTitleCommand(activityId, timelineId, memberId, title); + } +} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/timeline/Timeline.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/timeline/Timeline.java index 040da51..e6d32c8 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/timeline/Timeline.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/timeline/Timeline.java @@ -64,4 +64,8 @@ public class Timeline { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "activity_id", nullable = false) private Activity activity; + + public void updateTitle(String title) { + this.title = title; + } } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index 2960564..794fa55 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -12,6 +12,7 @@ import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.repository.TimelineRepository; import org.sopt.routee.activity.internal.service.dto.command.CreateTimelineCommand; +import org.sopt.routee.activity.internal.service.dto.command.UpdateTimelineTitleCommand; import org.sopt.routee.activity.internal.service.dto.result.TimelineResult; import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; @@ -70,6 +71,18 @@ public void delete(Long activityId, Long timelineId, Long memberId) { ); } + @Transactional + public void updateTitle(UpdateTimelineTitleCommand command) { + if (!activityRepository.existsByIdAndMemberId(command.activityId(), command.memberId())) { + throw new ActivityNotFoundException(); + } + + Timeline timeline = timelineRepository.findByIdAndActivityId(command.timelineId(), command.activityId()) + .orElseThrow(TimelineNotFoundException::new); + + timeline.updateTitle(command.title()); + } + @Transactional public void deleteTimelinesByMemberId(long memberId) { timelineRepository.deleteTimelinesByMemberId(memberId); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/UpdateTimelineTitleCommand.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/UpdateTimelineTitleCommand.java new file mode 100644 index 0000000..0680d5e --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/UpdateTimelineTitleCommand.java @@ -0,0 +1,9 @@ +package org.sopt.routee.activity.internal.service.dto.command; + +public record UpdateTimelineTitleCommand( + Long activityId, + Long timelineId, + Long memberId, + String title +) { +} diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java index 4f0f123..6caab7d 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java @@ -18,6 +18,7 @@ import org.sopt.routee.activity.internal.exception.TimelineNotFoundException; import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.repository.TimelineRepository; +import org.sopt.routee.activity.internal.service.dto.command.UpdateTimelineTitleCommand; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.springframework.context.ApplicationEventPublisher; @@ -96,4 +97,53 @@ class TimelineServiceTest { verify(timelineRepository, never()).delete(any()); verify(applicationEventPublisher, never()).publishEvent(any()); } + + @Test + void updateTitle_소유한_타임라인이면_제목을_수정한다() { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + String newTitle = "백운대 정상 도착"; + + Timeline timeline = Timeline.builder() + .id(timelineId) + .title("이전 제목") + .build(); + + when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(true); + when(timelineRepository.findByIdAndActivityId(timelineId, activityId)).thenReturn(Optional.of(timeline)); + + timelineService.updateTitle(new UpdateTimelineTitleCommand(activityId, timelineId, memberId, newTitle)); + + assertThat(timeline.getTitle()).isEqualTo(newTitle); + } + + @Test + void updateTitle_활동이_없으면_예외를_던진다() { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + + when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(false); + + assertThatThrownBy(() -> + timelineService.updateTitle(new UpdateTimelineTitleCommand(activityId, timelineId, memberId, "제목")) + ).isInstanceOf(ActivityNotFoundException.class); + + verify(timelineRepository, never()).findByIdAndActivityId(any(), any()); + } + + @Test + void updateTitle_타임라인이_없으면_예외를_던진다() { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + + when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(true); + when(timelineRepository.findByIdAndActivityId(timelineId, activityId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + timelineService.updateTitle(new UpdateTimelineTitleCommand(activityId, timelineId, memberId, "제목")) + ).isInstanceOf(TimelineNotFoundException.class); + } } From a3a66b04ccc9c4018938d98c403d5e98e008b101 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Mon, 17 Aug 2026 04:00:23 +0900 Subject: [PATCH 09/68] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=82=AD=EC=A0=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=EB=84=88=20@EnableAsync=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8F=99=EA=B8=B0=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/sopt/routee/config/AsyncConfig.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 routee-app/src/main/java/org/sopt/routee/config/AsyncConfig.java diff --git a/routee-app/src/main/java/org/sopt/routee/config/AsyncConfig.java b/routee-app/src/main/java/org/sopt/routee/config/AsyncConfig.java new file mode 100644 index 0000000..f5dd65b --- /dev/null +++ b/routee-app/src/main/java/org/sopt/routee/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package org.sopt.routee.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +class AsyncConfig { +} From bb6e0d9a1ff5b47406fd2f793752909a014a3f68 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Mon, 17 Aug 2026 04:05:31 +0900 Subject: [PATCH 10/68] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=86=8C=EC=9C=A0=20=EC=97=AC=EB=B6=80=EC=99=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=EB=A5=BC=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EC=BF=BC=EB=A6=AC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/TimelineRepository.java | 8 ++++--- .../internal/service/TimelineService.java | 21 ++++++++----------- .../internal/service/TimelineServiceTest.java | 20 +++++++++++------- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java index 48abb3d..c5768dd 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java @@ -13,13 +13,15 @@ public interface TimelineRepository extends JpaRepository { boolean existsByActivityIdAndTrackPointIndex(Long activityId, Integer trackPointIndex); - Optional findByIdAndActivityId(Long id, Long activityId); + Optional findByIdAndActivity_IdAndActivity_MemberId(Long id, Long activityId, Long memberId); List findByActivityIdOrderByCreatedAtAsc(Long activityId); - List findByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc(Long activityId, TimelineStatus timelineStatus); + List findByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc(Long activityId, + TimelineStatus timelineStatus); - List findByActivityIdInAndTimelineStatusOrderByCreatedAtAsc(List activityIds, TimelineStatus timelineStatus); + List findByActivityIdInAndTimelineStatusOrderByCreatedAtAsc(List activityIds, + TimelineStatus timelineStatus); @Modifying @Query("DELETE FROM Timeline t WHERE t.activity.memberId = :memberId") diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index 794fa55..f0eb672 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -57,12 +57,7 @@ public List getTimelines(Long activityId, Long memberId) { @Transactional public void delete(Long activityId, Long timelineId, Long memberId) { - if (!activityRepository.existsByIdAndMemberId(activityId, memberId)) { - throw new ActivityNotFoundException(); - } - - Timeline timeline = timelineRepository.findByIdAndActivityId(timelineId, activityId) - .orElseThrow(TimelineNotFoundException::new); + Timeline timeline = findOwnedTimeline(activityId, timelineId, memberId); timelineRepository.delete(timeline); @@ -73,12 +68,7 @@ public void delete(Long activityId, Long timelineId, Long memberId) { @Transactional public void updateTitle(UpdateTimelineTitleCommand command) { - if (!activityRepository.existsByIdAndMemberId(command.activityId(), command.memberId())) { - throw new ActivityNotFoundException(); - } - - Timeline timeline = timelineRepository.findByIdAndActivityId(command.timelineId(), command.activityId()) - .orElseThrow(TimelineNotFoundException::new); + Timeline timeline = findOwnedTimeline(command.activityId(), command.timelineId(), command.memberId()); timeline.updateTitle(command.title()); } @@ -98,4 +88,11 @@ private String generateImageUrl(Long activityId, Timeline timeline) { return fileImageAccessUrlPort.generateImageUrl(command).imageUrl(); } + + private Timeline findOwnedTimeline(Long activityId, Long timelineId, Long memberId) { + return timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId) + .orElseThrow(() -> activityRepository.existsByIdAndMemberId(activityId, memberId) + ? new TimelineNotFoundException() + : new ActivityNotFoundException()); + } } diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java index 6caab7d..72d6825 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java @@ -52,8 +52,8 @@ class TimelineServiceTest { .timelineImageObjectKey(objectKey) .build(); - when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(true); - when(timelineRepository.findByIdAndActivityId(timelineId, activityId)).thenReturn(Optional.of(timeline)); + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.of(timeline)); timelineService.delete(activityId, timelineId, memberId); @@ -73,6 +73,8 @@ class TimelineServiceTest { Long timelineId = 10L; Long memberId = 100L; + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.empty()); when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(false); assertThatThrownBy(() -> timelineService.delete(activityId, timelineId, memberId)) @@ -88,8 +90,9 @@ class TimelineServiceTest { Long timelineId = 10L; Long memberId = 100L; + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.empty()); when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(true); - when(timelineRepository.findByIdAndActivityId(timelineId, activityId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> timelineService.delete(activityId, timelineId, memberId)) .isInstanceOf(TimelineNotFoundException.class); @@ -110,8 +113,8 @@ class TimelineServiceTest { .title("이전 제목") .build(); - when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(true); - when(timelineRepository.findByIdAndActivityId(timelineId, activityId)).thenReturn(Optional.of(timeline)); + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.of(timeline)); timelineService.updateTitle(new UpdateTimelineTitleCommand(activityId, timelineId, memberId, newTitle)); @@ -124,13 +127,13 @@ class TimelineServiceTest { Long timelineId = 10L; Long memberId = 100L; + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.empty()); when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(false); assertThatThrownBy(() -> timelineService.updateTitle(new UpdateTimelineTitleCommand(activityId, timelineId, memberId, "제목")) ).isInstanceOf(ActivityNotFoundException.class); - - verify(timelineRepository, never()).findByIdAndActivityId(any(), any()); } @Test @@ -139,8 +142,9 @@ class TimelineServiceTest { Long timelineId = 10L; Long memberId = 100L; + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.empty()); when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(true); - when(timelineRepository.findByIdAndActivityId(timelineId, activityId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> timelineService.updateTitle(new UpdateTimelineTitleCommand(activityId, timelineId, memberId, "제목")) From b214826b808731964c32aa14ae5b116f87ef60be Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Mon, 17 Aug 2026 04:09:44 +0900 Subject: [PATCH 11/68] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=83=9D=EC=84=B1=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?timelineId=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/controller/TimelineController.java | 8 +++++--- .../internal/controller/TimelineControllerDocs.java | 7 ++++--- .../dto/response/TimelineCreateResponse.java | 11 +++++++++++ .../activity/internal/service/TimelineService.java | 7 +++++-- .../service/dto/result/CreateTimelineResult.java | 6 ++++++ 5 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/response/TimelineCreateResponse.java create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/CreateTimelineResult.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java index be406b6..9f54786 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java @@ -6,8 +6,10 @@ import org.sopt.routee.activity.internal.code.SuccessCode; import org.sopt.routee.activity.internal.controller.dto.request.CreateTimelineRequest; import org.sopt.routee.activity.internal.controller.dto.request.TimelineTitleUpdateRequest; +import org.sopt.routee.activity.internal.controller.dto.response.TimelineCreateResponse; import org.sopt.routee.activity.internal.controller.dto.response.TimelineListResponse; import org.sopt.routee.activity.internal.service.TimelineService; +import org.sopt.routee.activity.internal.service.dto.result.CreateTimelineResult; import org.sopt.routee.activity.internal.service.dto.result.TimelineResult; import org.sopt.routee.response.ApiResponse; import org.sopt.routee.response.SuccessResponse; @@ -35,16 +37,16 @@ public class TimelineController implements TimelineControllerDocs { private final TimelineService timelineService; @PostMapping("/{activityId}/timeline") - public ResponseEntity> create( + public ResponseEntity> create( @AuthenticationPrincipal Long memberId, @PathVariable(name = "activityId") Long activityId, @RequestHeader("Time-Zone") ZoneId timeZone, @Valid @RequestBody CreateTimelineRequest request ) { - timelineService.create(request.toCommand(memberId, activityId, timeZone)); + CreateTimelineResult result = timelineService.create(request.toCommand(memberId, activityId, timeZone)); return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.success(SuccessCode.TIMELINE_CREATED)); + .body(ApiResponse.success(SuccessCode.TIMELINE_CREATED, TimelineCreateResponse.from(result))); } @GetMapping("/{activityId}/timeline") diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java index c6934ba..cbcfc30 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java @@ -4,6 +4,7 @@ import org.sopt.routee.activity.internal.controller.dto.request.CreateTimelineRequest; import org.sopt.routee.activity.internal.controller.dto.request.TimelineTitleUpdateRequest; +import org.sopt.routee.activity.internal.controller.dto.response.TimelineCreateResponse; import org.sopt.routee.activity.internal.controller.dto.response.TimelineListResponse; import org.sopt.routee.response.FailureResponse; import org.sopt.routee.response.SuccessResponse; @@ -30,8 +31,8 @@ public interface TimelineControllerDocs { @Operation(summary = "타임라인 생성", description = "인증된 사용자의 활동 기록에 타임라인을 생성합니다.") @ApiResponses({ @ApiResponse(responseCode = "201", description = "타임라인 생성 성공", - content = @Content( - examples = @ExampleObject(value = "{\"status\":201,\"code\":\"TIMELINE_CREATED\",\"message\":\"타임라인 생성에 성공했습니다.\",\"data\":null}"))), + content = @Content(schema = @Schema(implementation = TimelineCreateResponse.class), + examples = @ExampleObject(value = "{\"status\":201,\"code\":\"TIMELINE_CREATED\",\"message\":\"타임라인 생성에 성공했습니다.\",\"data\":{\"timelineId\":3}}"))), @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { @@ -47,7 +48,7 @@ public interface TimelineControllerDocs { examples = @ExampleObject(name = "ACTIVITY_NOT_FOUND", value = "{\"status\":404,\"code\":\"ACTIVITY_NOT_FOUND\",\"message\":\"활동 기록이 존재하지 않습니다.\"}"))) }) - ResponseEntity> create( + ResponseEntity> create( @Parameter(hidden = true) Long memberId, @Parameter(description = "활동 기록 식별자", example = "1", required = true) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/response/TimelineCreateResponse.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/response/TimelineCreateResponse.java new file mode 100644 index 0000000..29773ac --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/response/TimelineCreateResponse.java @@ -0,0 +1,11 @@ +package org.sopt.routee.activity.internal.controller.dto.response; + +import org.sopt.routee.activity.internal.service.dto.result.CreateTimelineResult; + +public record TimelineCreateResponse( + Long timelineId +) { + public static TimelineCreateResponse from(CreateTimelineResult result) { + return new TimelineCreateResponse(result.timelineId()); + } +} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index f0eb672..dbaaf0b 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -13,6 +13,7 @@ import org.sopt.routee.activity.internal.repository.TimelineRepository; import org.sopt.routee.activity.internal.service.dto.command.CreateTimelineCommand; import org.sopt.routee.activity.internal.service.dto.command.UpdateTimelineTitleCommand; +import org.sopt.routee.activity.internal.service.dto.result.CreateTimelineResult; import org.sopt.routee.activity.internal.service.dto.result.TimelineResult; import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; @@ -35,13 +36,15 @@ public class TimelineService { private final ApplicationEventPublisher applicationEventPublisher; @Transactional - public void create(CreateTimelineCommand command) { + public CreateTimelineResult create(CreateTimelineCommand command) { Activity activity = activityRepository.findByIdAndMemberId(command.activityId(), command.memberId()) .orElseThrow(ActivityNotFoundException::new); Instant createdAt = TimeZoneUtils.toUtcInstantTime(command.createdAt(), command.timeZone()); - timelineRepository.save(TimelineMapper.toEntity(command, activity, createdAt)); + Timeline timeline = timelineRepository.save(TimelineMapper.toEntity(command, activity, createdAt)); + + return new CreateTimelineResult(timeline.getId()); } @Transactional(readOnly = true) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/CreateTimelineResult.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/CreateTimelineResult.java new file mode 100644 index 0000000..59b30e7 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/CreateTimelineResult.java @@ -0,0 +1,6 @@ +package org.sopt.routee.activity.internal.service.dto.result; + +public record CreateTimelineResult( + Long timelineId +) { +} From 754876dd3c794a94b197945c4140a7079480bbc5 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Mon, 17 Aug 2026 04:30:59 +0900 Subject: [PATCH 12/68] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=A0=9C=EB=AA=A9=20=EC=88=98=EC=A0=95=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=20timelineId,=20title=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/controller/TimelineController.java | 8 +++++--- .../internal/controller/TimelineControllerDocs.java | 7 ++++--- .../dto/response/TimelineTitleResponse.java | 12 ++++++++++++ .../activity/internal/mapper/TimelineMapper.java | 5 +++++ .../activity/internal/service/TimelineService.java | 5 ++++- .../dto/result/UpdateTimelineTitleResult.java | 7 +++++++ .../internal/service/TimelineServiceTest.java | 6 +++++- 7 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/response/TimelineTitleResponse.java create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/UpdateTimelineTitleResult.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java index 9f54786..14fd6da 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java @@ -8,9 +8,11 @@ import org.sopt.routee.activity.internal.controller.dto.request.TimelineTitleUpdateRequest; import org.sopt.routee.activity.internal.controller.dto.response.TimelineCreateResponse; import org.sopt.routee.activity.internal.controller.dto.response.TimelineListResponse; +import org.sopt.routee.activity.internal.controller.dto.response.TimelineTitleResponse; import org.sopt.routee.activity.internal.service.TimelineService; import org.sopt.routee.activity.internal.service.dto.result.CreateTimelineResult; import org.sopt.routee.activity.internal.service.dto.result.TimelineResult; +import org.sopt.routee.activity.internal.service.dto.result.UpdateTimelineTitleResult; import org.sopt.routee.response.ApiResponse; import org.sopt.routee.response.SuccessResponse; import org.springframework.http.HttpStatus; @@ -61,15 +63,15 @@ public ResponseEntity> getTimelines( } @PatchMapping("/{activityId}/timeline/{timelineId}") - public ResponseEntity> updateTitle( + public ResponseEntity> updateTitle( @AuthenticationPrincipal Long memberId, @PathVariable(name = "activityId") Long activityId, @PathVariable(name = "timelineId") Long timelineId, @Valid @RequestBody TimelineTitleUpdateRequest request ) { - timelineService.updateTitle(request.toCommand(activityId, timelineId, memberId)); + UpdateTimelineTitleResult result = timelineService.updateTitle(request.toCommand(activityId, timelineId, memberId)); - return ResponseEntity.ok(ApiResponse.success(SuccessCode.TIMELINE_TITLE_UPDATED)); + return ResponseEntity.ok(ApiResponse.success(SuccessCode.TIMELINE_TITLE_UPDATED, TimelineTitleResponse.from(result))); } @DeleteMapping("/{activityId}/timeline/{timelineId}") diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java index cbcfc30..b64eeb9 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineControllerDocs.java @@ -6,6 +6,7 @@ import org.sopt.routee.activity.internal.controller.dto.request.TimelineTitleUpdateRequest; import org.sopt.routee.activity.internal.controller.dto.response.TimelineCreateResponse; import org.sopt.routee.activity.internal.controller.dto.response.TimelineListResponse; +import org.sopt.routee.activity.internal.controller.dto.response.TimelineTitleResponse; import org.sopt.routee.response.FailureResponse; import org.sopt.routee.response.SuccessResponse; import org.springframework.http.ResponseEntity; @@ -82,8 +83,8 @@ ResponseEntity> getTimelines( @Operation(summary = "타임라인 제목 수정", description = "인증된 사용자의 활동 기록에 속한 타임라인 제목을 수정합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "타임라인 제목 수정 성공", - content = @Content( - examples = @ExampleObject(value = "{\"status\":200,\"code\":\"TIMELINE_TITLE_UPDATED\",\"message\":\"타임라인 제목 수정에 성공했습니다.\",\"data\":null}"))), + content = @Content(schema = @Schema(implementation = TimelineTitleResponse.class), + examples = @ExampleObject(value = "{\"status\":200,\"code\":\"TIMELINE_TITLE_UPDATED\",\"message\":\"타임라인 제목 수정에 성공했습니다.\",\"data\":{\"timelineId\":3,\"title\":\"백운대 정상 도착\"}}"))), @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { @@ -103,7 +104,7 @@ ResponseEntity> getTimelines( value = "{\"status\":404,\"code\":\"TIMELINE_NOT_FOUND\",\"message\":\"타임라인이 존재하지 않습니다.\"}") })) }) - ResponseEntity> updateTitle( + ResponseEntity> updateTitle( @Parameter(hidden = true) Long memberId, @Parameter(description = "활동 기록 식별자", example = "1", required = true) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/response/TimelineTitleResponse.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/response/TimelineTitleResponse.java new file mode 100644 index 0000000..7699f26 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/response/TimelineTitleResponse.java @@ -0,0 +1,12 @@ +package org.sopt.routee.activity.internal.controller.dto.response; + +import org.sopt.routee.activity.internal.service.dto.result.UpdateTimelineTitleResult; + +public record TimelineTitleResponse( + Long timelineId, + String title +) { + public static TimelineTitleResponse from(UpdateTimelineTitleResult result) { + return new TimelineTitleResponse(result.timelineId(), result.title()); + } +} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/TimelineMapper.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/TimelineMapper.java index 0057437..2877915 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/TimelineMapper.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/TimelineMapper.java @@ -10,6 +10,7 @@ import org.sopt.routee.activity.internal.entity.timeline.Timeline; import org.sopt.routee.activity.internal.service.dto.command.CreateTimelineCommand; import org.sopt.routee.activity.internal.service.dto.result.TimelineResult; +import org.sopt.routee.activity.internal.service.dto.result.UpdateTimelineTitleResult; public class TimelineMapper { @@ -37,6 +38,10 @@ public static TimelineResult toTimelineResult(Timeline timeline, String imageUrl ); } + public static UpdateTimelineTitleResult toTitleUpdateResult(Timeline timeline) { + return new UpdateTimelineTitleResult(timeline.getId(), timeline.getTitle()); + } + private static Point toPoint(CreateTimelineCommand command) { Point point = GEOMETRY_FACTORY.createPoint(new CoordinateXYZM( command.longitude(), diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index dbaaf0b..6b71738 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -15,6 +15,7 @@ import org.sopt.routee.activity.internal.service.dto.command.UpdateTimelineTitleCommand; import org.sopt.routee.activity.internal.service.dto.result.CreateTimelineResult; import org.sopt.routee.activity.internal.service.dto.result.TimelineResult; +import org.sopt.routee.activity.internal.service.dto.result.UpdateTimelineTitleResult; import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.sopt.routee.external.api.type.FileUploadDirectory; @@ -70,10 +71,12 @@ public void delete(Long activityId, Long timelineId, Long memberId) { } @Transactional - public void updateTitle(UpdateTimelineTitleCommand command) { + public UpdateTimelineTitleResult updateTitle(UpdateTimelineTitleCommand command) { Timeline timeline = findOwnedTimeline(command.activityId(), command.timelineId(), command.memberId()); timeline.updateTitle(command.title()); + + return TimelineMapper.toTitleUpdateResult(timeline); } @Transactional diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/UpdateTimelineTitleResult.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/UpdateTimelineTitleResult.java new file mode 100644 index 0000000..098a105 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/UpdateTimelineTitleResult.java @@ -0,0 +1,7 @@ +package org.sopt.routee.activity.internal.service.dto.result; + +public record UpdateTimelineTitleResult( + Long timelineId, + String title +) { +} diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java index 72d6825..d512aaa 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java @@ -19,6 +19,7 @@ import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.repository.TimelineRepository; import org.sopt.routee.activity.internal.service.dto.command.UpdateTimelineTitleCommand; +import org.sopt.routee.activity.internal.service.dto.result.UpdateTimelineTitleResult; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.springframework.context.ApplicationEventPublisher; @@ -116,9 +117,12 @@ class TimelineServiceTest { when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) .thenReturn(Optional.of(timeline)); - timelineService.updateTitle(new UpdateTimelineTitleCommand(activityId, timelineId, memberId, newTitle)); + UpdateTimelineTitleResult result = + timelineService.updateTitle(new UpdateTimelineTitleCommand(activityId, timelineId, memberId, newTitle)); assertThat(timeline.getTitle()).isEqualTo(newTitle); + assertThat(result.timelineId()).isEqualTo(timelineId); + assertThat(result.title()).isEqualTo(newTitle); } @Test From d2f766041b486e883af2b274ad1ff7bcb448bd2d Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Mon, 17 Aug 2026 15:17:46 +0900 Subject: [PATCH 13/68] =?UTF-8?q?fix:=20S3=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=9D=BC=EB=B6=80=20=EC=82=AD=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=EC=98=88=EC=99=B8=EB=A1=9C=20=EA=B0=90=EC=A7=80?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TimelineImageCleanupListener.java | 19 ++++++++++++----- .../TimelineImageCleanupListenerTest.java | 13 ++++++++++++ .../s3/adapter/S3FileDeleteAdapter.java | 8 ++++++- .../s3/exception/FileDeleteException.java | 4 ++++ .../s3/adapter/S3FileDeleteAdapterTest.java | 21 +++++++++++++++++++ 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java index 29d28ca..c7f56a0 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java @@ -1,6 +1,7 @@ package org.sopt.routee.activity.internal.listener; import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; +import org.sopt.routee.exception.BaseException; import org.sopt.routee.external.api.command.FileDeleteCommand; import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.type.FileUploadDirectory; @@ -8,7 +9,9 @@ import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Component @RequiredArgsConstructor class TimelineImageCleanupListener { @@ -17,10 +20,16 @@ class TimelineImageCleanupListener { @ApplicationModuleListener void handleTimelineDeleted(TimelineDeletedEvent event) { - fileDeletePort.deleteImage(new FileDeleteCommand( - FileUploadDirectory.TIMELINE, - event.activityId().toString(), - event.timelineImageObjectKey() - )); + try { + fileDeletePort.deleteImage(new FileDeleteCommand( + FileUploadDirectory.TIMELINE, + event.activityId().toString(), + event.timelineImageObjectKey() + )); + } catch (BaseException e) { + log.warn("Timeline image delete failed. activityId={}, objectKey={}", + event.activityId(), event.timelineImageObjectKey(), e); + throw e; + } } } diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java index e6f7f29..0a7bf73 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java @@ -10,6 +10,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; +import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; +import org.sopt.routee.exception.BaseException; import org.sopt.routee.external.api.command.FileDeleteCommand; import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.type.FileUploadDirectory; @@ -38,4 +40,15 @@ class TimelineImageCleanupListenerTest { assertThat(command.activityId()).isEqualTo(activityId.toString()); assertThat(command.objectKey()).isEqualTo(objectKey); } + + @Test + void handleTimelineDeleted_이미지_삭제가_실패하면_예외를_그대로_전파한다() { + Long activityId = 1L; + String objectKey = "timeline-image.jpg"; + doThrow(new ActivityNotFoundException()).when(fileDeletePort).deleteImage(any()); + + assertThatThrownBy(() -> + timelineImageCleanupListener.handleTimelineDeleted(new TimelineDeletedEvent(activityId, objectKey))) + .isInstanceOf(BaseException.class); + } } diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java index 5d355eb..6e5fedc 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java @@ -15,6 +15,7 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.Delete; import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; import software.amazon.awssdk.services.s3.model.ObjectIdentifier; @Component @@ -35,11 +36,16 @@ public void deleteImage(FileDeleteCommand command) { .delete(Delete.builder().objects(objectIdentifiers).build()) .build(); + DeleteObjectsResponse response; try { - s3Client.deleteObjects(request); + response = s3Client.deleteObjects(request); } catch (RuntimeException e) { throw new FileDeleteException(e); } + + if (response.hasErrors()) { + throw new FileDeleteException(); + } } private List resolveObjectKeys(FileDeleteCommand command) { diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileDeleteException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileDeleteException.java index f2ef3db..3c5bd13 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileDeleteException.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileDeleteException.java @@ -5,6 +5,10 @@ public final class FileDeleteException extends BaseException { + public FileDeleteException() { + super(ErrorCode.FILE_DELETE_FAILED); + } + public FileDeleteException(Throwable cause) { super(ErrorCode.FILE_DELETE_FAILED, cause); } diff --git a/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java index 9d9260a..193442f 100644 --- a/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java +++ b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java @@ -19,7 +19,9 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; import software.amazon.awssdk.services.s3.model.ObjectIdentifier; +import software.amazon.awssdk.services.s3.model.S3Error; @ExtendWith(MockitoExtension.class) class S3FileDeleteAdapterTest { @@ -38,6 +40,8 @@ void setUp() { @Test void deleteImage_타임라인_이미지를_삭제하면_원본과_람다가_생성한_리사이징_사이즈_전부를_삭제_요청한다() { FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.TIMELINE, "1", "timeline-image.jpg"); + when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))) + .thenReturn(DeleteObjectsResponse.builder().build()); s3FileDeleteAdapter.deleteImage(command); @@ -62,6 +66,8 @@ void setUp() { @Test void deleteImage_RECAP_이미지를_삭제하면_사이즈_구분_없이_단일_키만_삭제_요청한다() { FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "1", "recap-image.jpg"); + when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))) + .thenReturn(DeleteObjectsResponse.builder().build()); s3FileDeleteAdapter.deleteImage(command); @@ -83,4 +89,19 @@ void setUp() { assertThatThrownBy(() -> s3FileDeleteAdapter.deleteImage(command)) .isInstanceOf(FileDeleteException.class); } + + @Test + void deleteImage_일부_객체_삭제가_실패하면_FileDeleteException을_던진다() { + FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "1", "recap-image.jpg"); + S3Error error = S3Error.builder() + .key("activity/1/recap/recap-image.jpg") + .code("AccessDenied") + .message("Access Denied") + .build(); + when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))) + .thenReturn(DeleteObjectsResponse.builder().errors(error).build()); + + assertThatThrownBy(() -> s3FileDeleteAdapter.deleteImage(command)) + .isInstanceOf(FileDeleteException.class); + } } From 97d51f331c2692e7d6964cdb75815c98655af1c7 Mon Sep 17 00:00:00 2001 From: kymin Date: Tue, 18 Aug 2026 16:53:13 +0900 Subject: [PATCH 14/68] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B2=BD=EB=A1=9C=EC=97=90=20?= =?UTF-8?q?member=20prefix=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/internal/s3/adapter/S3ObjectKeyAssembler.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java index 3a91643..d746cf9 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java @@ -11,6 +11,7 @@ final class S3ObjectKeyAssembler { private static final String ACTIVITY_ROOT_PATH = "activity"; + private static final String MEMBER_ROOT_PATH = "member"; static String assemble( FileUploadDirectory directory, @@ -32,9 +33,10 @@ static String assemble( directory.path(), objectKey ); - case PROFILE -> "%s/%s/%s".formatted( - directory.path(), + case PROFILE -> "%s/%s/%s/%s".formatted( + MEMBER_ROOT_PATH, ownerId, + directory.path(), objectKey ); }; From 088be3075bcb1fce3b4868f0be23c0f1d9453664 Mon Sep 17 00:00:00 2001 From: kymin Date: Wed, 19 Aug 2026 14:56:23 +0900 Subject: [PATCH 15/68] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=EC=9E=90=20=EC=B6=94=EC=B6=9C=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=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 --- .../routee/external/internal/s3/adapter/S3PresignAdapter.java | 2 +- .../internal/s3/exception/FileUploadPresignException.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java index b52275a..2987843 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java @@ -46,7 +46,7 @@ private String generateStoredObjectKey(String extension) { private String parseExtension(String fileName) { return FileExtensionExtractor.extract(fileName) - .orElseThrow(); + .orElseThrow(FileUploadPresignException::new); } private String generatePutPresignedUrl(String objectKey) { diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileUploadPresignException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileUploadPresignException.java index 26a24f5..f654a2c 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileUploadPresignException.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileUploadPresignException.java @@ -5,6 +5,10 @@ public final class FileUploadPresignException extends BaseException { + public FileUploadPresignException() { + super(ErrorCode.FILE_UPLOAD_PRESIGN_FAILED); + } + public FileUploadPresignException(Throwable cause) { super(ErrorCode.FILE_UPLOAD_PRESIGN_FAILED, cause); } From a201341fc6c1f359e652c68923d623d8e00de9d1 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Thu, 20 Aug 2026 01:56:56 +0900 Subject: [PATCH 16/68] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=84=EC=97=AD=20@EnableAsync=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EA=B0=80=EC=83=81=20=EC=8A=A4=EB=A0=88=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20S3=20I/O=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TimelineImageCleanupListener.java | 5 +++- .../TimelineImageCleanupListenerTest.java | 30 +++++++++++++++---- .../org/sopt/routee/config/AsyncConfig.java | 9 ------ 3 files changed, 28 insertions(+), 16 deletions(-) delete mode 100644 routee-app/src/main/java/org/sopt/routee/config/AsyncConfig.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java index c7f56a0..898bb6f 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java @@ -20,6 +20,10 @@ class TimelineImageCleanupListener { @ApplicationModuleListener void handleTimelineDeleted(TimelineDeletedEvent event) { + Thread.startVirtualThread(() -> deleteImage(event)); + } + + private void deleteImage(TimelineDeletedEvent event) { try { fileDeletePort.deleteImage(new FileDeleteCommand( FileUploadDirectory.TIMELINE, @@ -29,7 +33,6 @@ void handleTimelineDeleted(TimelineDeletedEvent event) { } catch (BaseException e) { log.warn("Timeline image delete failed. activityId={}, objectKey={}", event.activityId(), event.timelineImageObjectKey(), e); - throw e; } } } diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java index 0a7bf73..2617f02 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java @@ -3,6 +3,9 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -11,7 +14,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; -import org.sopt.routee.exception.BaseException; import org.sopt.routee.external.api.command.FileDeleteCommand; import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.type.FileUploadDirectory; @@ -26,12 +28,19 @@ class TimelineImageCleanupListenerTest { private TimelineImageCleanupListener timelineImageCleanupListener; @Test - void handleTimelineDeleted_타임라인_삭제_이벤트를_받으면_TIMELINE_디렉토리_기준으로_이미지_삭제를_요청한다() { + void handleTimelineDeleted_타임라인_삭제_이벤트를_받으면_TIMELINE_디렉토리_기준으로_이미지_삭제를_요청한다() throws InterruptedException { Long activityId = 1L; String objectKey = "timeline-image.jpg"; + CountDownLatch latch = new CountDownLatch(1); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(fileDeletePort).deleteImage(any()); timelineImageCleanupListener.handleTimelineDeleted(new TimelineDeletedEvent(activityId, objectKey)); + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(FileDeleteCommand.class); verify(fileDeletePort).deleteImage(commandCaptor.capture()); @@ -42,13 +51,22 @@ class TimelineImageCleanupListenerTest { } @Test - void handleTimelineDeleted_이미지_삭제가_실패하면_예외를_그대로_전파한다() { + void handleTimelineDeleted_이미지_삭제가_실패해도_예외를_전파하지_않는다() throws InterruptedException { Long activityId = 1L; String objectKey = "timeline-image.jpg"; - doThrow(new ActivityNotFoundException()).when(fileDeletePort).deleteImage(any()); + CountDownLatch latch = new CountDownLatch(1); + doAnswer(invocation -> { + try { + throw new ActivityNotFoundException(); + } finally { + latch.countDown(); + } + }).when(fileDeletePort).deleteImage(any()); - assertThatThrownBy(() -> + assertThatCode(() -> timelineImageCleanupListener.handleTimelineDeleted(new TimelineDeletedEvent(activityId, objectKey))) - .isInstanceOf(BaseException.class); + .doesNotThrowAnyException(); + + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); } } diff --git a/routee-app/src/main/java/org/sopt/routee/config/AsyncConfig.java b/routee-app/src/main/java/org/sopt/routee/config/AsyncConfig.java deleted file mode 100644 index f5dd65b..0000000 --- a/routee-app/src/main/java/org/sopt/routee/config/AsyncConfig.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.sopt.routee.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.annotation.EnableAsync; - -@Configuration -@EnableAsync -class AsyncConfig { -} From 25796a8c429b82bcbec678b40b96b5eb59725a95 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Sat, 22 Aug 2026 01:02:49 +0900 Subject: [PATCH 17/68] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=82=AD=EC=A0=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9D=84=20TransactionTemplate=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/event/TimelineDeletedEvent.java | 7 -- .../TimelineImageCleanupListener.java | 38 --------- .../internal/service/TimelineService.java | 35 ++++++-- .../TimelineImageCleanupListenerTest.java | 72 ---------------- .../internal/service/TimelineServiceTest.java | 84 ++++++++++++++++--- 5 files changed, 97 insertions(+), 139 deletions(-) delete mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/event/TimelineDeletedEvent.java delete mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java delete mode 100644 routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/event/TimelineDeletedEvent.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/event/TimelineDeletedEvent.java deleted file mode 100644 index 8ca0087..0000000 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/event/TimelineDeletedEvent.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.sopt.routee.activity.internal.event; - -public record TimelineDeletedEvent( - Long activityId, - String timelineImageObjectKey -) { -} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java deleted file mode 100644 index 898bb6f..0000000 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListener.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.sopt.routee.activity.internal.listener; - -import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; -import org.sopt.routee.exception.BaseException; -import org.sopt.routee.external.api.command.FileDeleteCommand; -import org.sopt.routee.external.api.port.FileDeletePort; -import org.sopt.routee.external.api.type.FileUploadDirectory; -import org.springframework.modulith.events.ApplicationModuleListener; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -class TimelineImageCleanupListener { - - private final FileDeletePort fileDeletePort; - - @ApplicationModuleListener - void handleTimelineDeleted(TimelineDeletedEvent event) { - Thread.startVirtualThread(() -> deleteImage(event)); - } - - private void deleteImage(TimelineDeletedEvent event) { - try { - fileDeletePort.deleteImage(new FileDeleteCommand( - FileUploadDirectory.TIMELINE, - event.activityId().toString(), - event.timelineImageObjectKey() - )); - } catch (BaseException e) { - log.warn("Timeline image delete failed. activityId={}, objectKey={}", - event.activityId(), event.timelineImageObjectKey(), e); - } - } -} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index 6b71738..7119c56 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -5,7 +5,6 @@ import org.sopt.routee.activity.internal.entity.activity.Activity; import org.sopt.routee.activity.internal.entity.timeline.Timeline; -import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; import org.sopt.routee.activity.internal.exception.TimelineNotFoundException; import org.sopt.routee.activity.internal.mapper.TimelineMapper; @@ -16,17 +15,22 @@ import org.sopt.routee.activity.internal.service.dto.result.CreateTimelineResult; import org.sopt.routee.activity.internal.service.dto.result.TimelineResult; import org.sopt.routee.activity.internal.service.dto.result.UpdateTimelineTitleResult; +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.api.command.FileDeleteCommand; import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; +import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.sopt.routee.external.api.type.FileUploadDirectory; import org.sopt.routee.external.api.type.FileUploadImageSize; import org.sopt.routee.util.TimeZoneUtils; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class TimelineService { @@ -34,7 +38,8 @@ public class TimelineService { private final ActivityRepository activityRepository; private final TimelineRepository timelineRepository; private final FileImageAccessUrlPort fileImageAccessUrlPort; - private final ApplicationEventPublisher applicationEventPublisher; + private final FileDeletePort fileDeletePort; + private final TransactionTemplate transactionTemplate; @Transactional public CreateTimelineResult create(CreateTimelineCommand command) { @@ -59,15 +64,18 @@ public List getTimelines(Long activityId, Long memberId) { .toList(); } - @Transactional public void delete(Long activityId, Long timelineId, Long memberId) { - Timeline timeline = findOwnedTimeline(activityId, timelineId, memberId); + Timeline timeline = transactionTemplate.execute(status -> { + Timeline ownedTimeline = findOwnedTimeline(activityId, timelineId, memberId); - timelineRepository.delete(timeline); + timelineRepository.delete(ownedTimeline); - applicationEventPublisher.publishEvent( - new TimelineDeletedEvent(activityId, timeline.getTimelineImageObjectKey()) - ); + return ownedTimeline; + }); + + String objectKey = timeline.getTimelineImageObjectKey(); + + Thread.startVirtualThread(() -> deleteTimelineImage(activityId, objectKey)); } @Transactional @@ -101,4 +109,13 @@ private Timeline findOwnedTimeline(Long activityId, Long timelineId, Long member ? new TimelineNotFoundException() : new ActivityNotFoundException()); } + + private void deleteTimelineImage(Long activityId, String objectKey) { + try { + fileDeletePort.deleteImage( + new FileDeleteCommand(FileUploadDirectory.TIMELINE, activityId.toString(), objectKey)); + } catch (BaseException e) { + log.warn("Timeline image delete failed. activityId={}, objectKey={}", activityId, objectKey, e); + } + } } diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java deleted file mode 100644 index 2617f02..0000000 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/listener/TimelineImageCleanupListenerTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.sopt.routee.activity.internal.listener; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; -import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; -import org.sopt.routee.external.api.command.FileDeleteCommand; -import org.sopt.routee.external.api.port.FileDeletePort; -import org.sopt.routee.external.api.type.FileUploadDirectory; - -@ExtendWith(MockitoExtension.class) -class TimelineImageCleanupListenerTest { - - @Mock - private FileDeletePort fileDeletePort; - - @InjectMocks - private TimelineImageCleanupListener timelineImageCleanupListener; - - @Test - void handleTimelineDeleted_타임라인_삭제_이벤트를_받으면_TIMELINE_디렉토리_기준으로_이미지_삭제를_요청한다() throws InterruptedException { - Long activityId = 1L; - String objectKey = "timeline-image.jpg"; - CountDownLatch latch = new CountDownLatch(1); - doAnswer(invocation -> { - latch.countDown(); - return null; - }).when(fileDeletePort).deleteImage(any()); - - timelineImageCleanupListener.handleTimelineDeleted(new TimelineDeletedEvent(activityId, objectKey)); - - assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); - - ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(FileDeleteCommand.class); - verify(fileDeletePort).deleteImage(commandCaptor.capture()); - - FileDeleteCommand command = commandCaptor.getValue(); - assertThat(command.directory()).isEqualTo(FileUploadDirectory.TIMELINE); - assertThat(command.activityId()).isEqualTo(activityId.toString()); - assertThat(command.objectKey()).isEqualTo(objectKey); - } - - @Test - void handleTimelineDeleted_이미지_삭제가_실패해도_예외를_전파하지_않는다() throws InterruptedException { - Long activityId = 1L; - String objectKey = "timeline-image.jpg"; - CountDownLatch latch = new CountDownLatch(1); - doAnswer(invocation -> { - try { - throw new ActivityNotFoundException(); - } finally { - latch.countDown(); - } - }).when(fileDeletePort).deleteImage(any()); - - assertThatCode(() -> - timelineImageCleanupListener.handleTimelineDeleted(new TimelineDeletedEvent(activityId, objectKey))) - .doesNotThrowAnyException(); - - assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); - } -} diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java index d512aaa..4d9e390 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java @@ -5,6 +5,8 @@ import static org.mockito.Mockito.*; import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -13,15 +15,19 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.sopt.routee.activity.internal.entity.timeline.Timeline; -import org.sopt.routee.activity.internal.event.TimelineDeletedEvent; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; import org.sopt.routee.activity.internal.exception.TimelineNotFoundException; import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.repository.TimelineRepository; import org.sopt.routee.activity.internal.service.dto.command.UpdateTimelineTitleCommand; import org.sopt.routee.activity.internal.service.dto.result.UpdateTimelineTitleResult; +import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; -import org.springframework.context.ApplicationEventPublisher; +import org.sopt.routee.external.api.type.FileUploadDirectory; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; @ExtendWith(MockitoExtension.class) class TimelineServiceTest { @@ -36,13 +42,23 @@ class TimelineServiceTest { private FileImageAccessUrlPort fileImageAccessUrlPort; @Mock - private ApplicationEventPublisher applicationEventPublisher; + private FileDeletePort fileDeletePort; + + @Mock + private TransactionTemplate transactionTemplate; @InjectMocks private TimelineService timelineService; + private void stubTransactionTemplateToRunCallback() { + when(transactionTemplate.execute(any())).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); + } + @Test - void delete_타임라인을_삭제하면_이미지_키를_담은_삭제_이벤트를_발행한다() { + void delete_타임라인을_삭제하면_이미지_키로_S3_삭제를_요청한다() throws InterruptedException { Long activityId = 1L; Long timelineId = 10L; Long memberId = 100L; @@ -55,21 +71,61 @@ class TimelineServiceTest { when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) .thenReturn(Optional.of(timeline)); + stubTransactionTemplateToRunCallback(); + + CountDownLatch latch = new CountDownLatch(1); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(fileDeletePort).deleteImage(any()); timelineService.delete(activityId, timelineId, memberId); verify(timelineRepository).delete(timeline); + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(FileDeleteCommand.class); + verify(fileDeletePort).deleteImage(commandCaptor.capture()); + + FileDeleteCommand command = commandCaptor.getValue(); + assertThat(command.directory()).isEqualTo(FileUploadDirectory.TIMELINE); + assertThat(command.activityId()).isEqualTo(activityId.toString()); + assertThat(command.objectKey()).isEqualTo(objectKey); + } + + @Test + void delete_이미지_삭제가_실패해도_예외를_전파하지_않는다() throws InterruptedException { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + String objectKey = "timeline-image.jpg"; + + Timeline timeline = Timeline.builder() + .id(timelineId) + .timelineImageObjectKey(objectKey) + .build(); + + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.of(timeline)); + stubTransactionTemplateToRunCallback(); + + CountDownLatch latch = new CountDownLatch(1); + doAnswer(invocation -> { + try { + throw new ActivityNotFoundException(); + } finally { + latch.countDown(); + } + }).when(fileDeletePort).deleteImage(any()); - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(TimelineDeletedEvent.class); - verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + assertThatCode(() -> timelineService.delete(activityId, timelineId, memberId)) + .doesNotThrowAnyException(); - TimelineDeletedEvent event = eventCaptor.getValue(); - assertThat(event.activityId()).isEqualTo(activityId); - assertThat(event.timelineImageObjectKey()).isEqualTo(objectKey); + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); } @Test - void delete_활동이_없으면_예외를_던지고_이벤트를_발행하지_않는다() { + void delete_활동이_없으면_예외를_던지고_이미지_삭제를_요청하지_않는다() { Long activityId = 1L; Long timelineId = 10L; Long memberId = 100L; @@ -77,16 +133,17 @@ class TimelineServiceTest { when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) .thenReturn(Optional.empty()); when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(false); + stubTransactionTemplateToRunCallback(); assertThatThrownBy(() -> timelineService.delete(activityId, timelineId, memberId)) .isInstanceOf(ActivityNotFoundException.class); verify(timelineRepository, never()).delete(any()); - verify(applicationEventPublisher, never()).publishEvent(any()); + verify(fileDeletePort, never()).deleteImage(any()); } @Test - void delete_타임라인이_없으면_예외를_던지고_이벤트를_발행하지_않는다() { + void delete_타임라인이_없으면_예외를_던지고_이미지_삭제를_요청하지_않는다() { Long activityId = 1L; Long timelineId = 10L; Long memberId = 100L; @@ -94,12 +151,13 @@ class TimelineServiceTest { when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) .thenReturn(Optional.empty()); when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(true); + stubTransactionTemplateToRunCallback(); assertThatThrownBy(() -> timelineService.delete(activityId, timelineId, memberId)) .isInstanceOf(TimelineNotFoundException.class); verify(timelineRepository, never()).delete(any()); - verify(applicationEventPublisher, never()).publishEvent(any()); + verify(fileDeletePort, never()).deleteImage(any()); } @Test From 6946b097dbebccf5371df98eca1d53317f38e0cb Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Sat, 22 Aug 2026 01:19:20 +0900 Subject: [PATCH 18/68] =?UTF-8?q?fix:=20S3=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=96=B4=EB=8C=91=ED=84=B0=EA=B0=80=20assembleUploadObjectKey?= =?UTF-8?q?=20=EB=8C=80=EC=8B=A0=20assemble=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/internal/s3/adapter/S3FileDeleteAdapter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java index 6e5fedc..8845e52 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java @@ -50,13 +50,13 @@ public void deleteImage(FileDeleteCommand command) { private List resolveObjectKeys(FileDeleteCommand command) { if (command.directory() != FileUploadDirectory.TIMELINE) { - return List.of(S3ObjectKeyAssembler.assembleUploadObjectKey( + return List.of(S3ObjectKeyAssembler.assemble( command.directory(), null, command.activityId(), command.objectKey() )); } return Arrays.stream(FileUploadImageSize.values()) - .map(imageSize -> S3ObjectKeyAssembler.assembleUploadObjectKey( + .map(imageSize -> S3ObjectKeyAssembler.assemble( command.directory(), imageSize, command.activityId(), command.objectKey() )) .toList(); From 09c47ad2ac1e150da5548a2a260f34ea0bea7e9e Mon Sep 17 00:00:00 2001 From: khj011219 Date: Sat, 22 Aug 2026 22:40:08 +0900 Subject: [PATCH 19/68] =?UTF-8?q?feat:=20=ED=99=9C=EB=8F=99=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EB=B9=84=EC=A0=95=EC=83=81=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=ED=99=9C=EB=8F=99=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ActivityControllerDocs.java | 6 +---- .../repository/ActivityRepository.java | 10 +++++++++ .../repository/TimelineRepository.java | 4 ++++ .../internal/service/ActivityService.java | 22 +++++++++++++------ 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java index bbb2779..29cb9fd 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java @@ -58,11 +58,7 @@ public interface ActivityControllerDocs { value = "{\"status\":400,\"code\":\"INVALID_TIME_ZONE\",\"message\":\"Time-Zone 헤더 값이 올바르지 않습니다.\"}") })), @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = FailureResponse.class))), - @ApiResponse(responseCode = "409", description = "이미 진행 중인 활동이 있음", - content = @Content(schema = @Schema(implementation = FailureResponse.class), - examples = @ExampleObject(name = "ALREADY_IN_PROGRESS_ACTIVITY", - value = "{\"status\":409,\"code\":\"ALREADY_IN_PROGRESS_ACTIVITY\",\"message\":\"이미 진행 중이거나 일시정지된 활동이 있습니다.\"}"))) + content = @Content(schema = @Schema(implementation = FailureResponse.class))) }) ResponseEntity> create( Long memberId, diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java index 3d9dfa5..1cacfaa 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java @@ -15,6 +15,12 @@ public interface ActivityRepository extends JpaRepository { boolean existsByMemberIdAndActivityStatusIn(Long memberId, Collection activityStatuses); + @Query("SELECT a.id FROM Activity a WHERE a.memberId = :memberId AND a.activityStatus IN :activityStatuses") + List findIdsByMemberIdAndActivityStatusIn( + @Param("memberId") Long memberId, + @Param("activityStatuses") Collection activityStatuses + ); + boolean existsByIdAndMemberId(Long id, Long memberId); Optional findByIdAndMemberId(Long id, Long memberId); @@ -36,4 +42,8 @@ List findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedA @Modifying @Query("DELETE FROM Activity a WHERE a.memberId = :memberId") void deleteByMemberId(@Param("memberId") Long memberId); + + @Modifying + @Query("DELETE FROM Activity a WHERE a.id IN :activityIds") + void deleteByIdIn(@Param("activityIds") List activityIds); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java index c5768dd..abc630f 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java @@ -26,4 +26,8 @@ List findByActivityIdInAndTimelineStatusOrderByCreatedAtAsc(List @Modifying @Query("DELETE FROM Timeline t WHERE t.activity.memberId = :memberId") void deleteTimelinesByMemberId(@Param("memberId") Long memberId); + + @Modifying + @Query("DELETE FROM Timeline t WHERE t.activity.id IN :activityIds") + void deleteByActivityIdIn(@Param("activityIds") List activityIds); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index c9e084d..766dc04 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -19,7 +19,6 @@ import org.sopt.routee.activity.internal.exception.ActivityAlreadyCompletedException; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; import org.sopt.routee.activity.internal.exception.ActivityStatusAlreadySameException; -import org.sopt.routee.activity.internal.exception.AlreadyInProgressActivityException; import org.sopt.routee.activity.internal.exception.InvalidActivityStatusTransitionException; import org.sopt.routee.activity.internal.exception.UnsupportedImageFileExtensionException; import org.sopt.routee.activity.internal.mapper.ActivityMapper; @@ -88,12 +87,7 @@ public class ActivityService { @Transactional public CreateActivityResult create(CreateActivityCommand command) { - if (activityRepository.existsByMemberIdAndActivityStatusIn( - command.memberId(), - ACTIVE_STATUSES - )) { - throw new AlreadyInProgressActivityException(); - } + discardExistingActiveActivities(command.memberId()); Instant startedAt = command.startedAt() .atZone(command.timeZone()) @@ -113,6 +107,20 @@ public void afterCommit() { return new CreateActivityResult(savedActivity.getId(), title); } + private void discardExistingActiveActivities(Long memberId) { + List activeActivityIds = activityRepository.findIdsByMemberIdAndActivityStatusIn( + memberId, + ACTIVE_STATUSES + ); + + if (activeActivityIds.isEmpty()) { + return; + } + + timelineRepository.deleteByActivityIdIn(activeActivityIds); + activityRepository.deleteByIdIn(activeActivityIds); + } + @Transactional(readOnly = true) public ImageUrlResult generateImageUploadUrl(ImageUploadUrlCommand command) { if (!activityRepository.existsByIdAndMemberId(command.activityId(), command.memberId())) { From 69e1a022027b03e762ab1ab96fcd6a277974fd36 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Sun, 23 Aug 2026 00:02:26 +0900 Subject: [PATCH 20/68] =?UTF-8?q?deploy:=20=EC=9A=B4=EC=98=81/=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=84=9C=EB=B2=84=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{deploy.yml => deploy-common.yml} | 40 +++++++++++-------- .github/workflows/deploy-prod.yml | 13 ++++++ .github/workflows/deploy-test.yml | 14 +++++++ 3 files changed, 50 insertions(+), 17 deletions(-) rename .github/workflows/{deploy.yml => deploy-common.yml} (70%) create mode 100644 .github/workflows/deploy-prod.yml create mode 100644 .github/workflows/deploy-test.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy-common.yml similarity index 70% rename from .github/workflows/deploy.yml rename to .github/workflows/deploy-common.yml index 4cd632c..536dfa9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy-common.yml @@ -1,18 +1,24 @@ name: Deploy on: - push: - branches: - - main - -concurrency: - group: deploy - cancel-in-progress: false + workflow_call: + inputs: + environment: + required: true + type: string + secret_prefix: + required: false + type: string + default: "" jobs: deploy: runs-on: ubuntu-latest + concurrency: + group: deploy-${{ inputs.environment }} + cancel-in-progress: false + steps: - uses: actions/checkout@v4 @@ -33,15 +39,15 @@ jobs: - name: Build and Push Docker Image run: | - docker build -t ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ github.sha }} \ - -t ${{ secrets.DOCKER_USERNAME }}/routee-api:latest . - docker push ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ github.sha }} - docker push ${{ secrets.DOCKER_USERNAME }}/routee-api:latest + docker build -t ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ inputs.environment }}-${{ github.sha }} \ + -t ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ inputs.environment }}-latest . + docker push ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ inputs.environment }}-${{ github.sha }} + docker push ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ inputs.environment }}-latest - name: Copy config files to EC2 uses: appleboy/scp-action@v0.1.7 with: - host: ${{ secrets.EC2_HOST }} + host: ${{ secrets[format('{0}EC2_HOST', inputs.secret_prefix)] }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} source: "docker-compose.yml,nginx/default.conf,scripts/,tempo/,grafana/,prometheus/prometheus.yml" @@ -51,7 +57,7 @@ jobs: - name: Blue-Green Deploy uses: appleboy/ssh-action@v1.0.3 with: - host: ${{ secrets.EC2_HOST }} + host: ${{ secrets[format('{0}EC2_HOST', inputs.secret_prefix)] }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | @@ -61,7 +67,7 @@ jobs: export DB_HOST="${{ secrets.DB_HOST }}" export DB_PORT="${{ secrets.DB_PORT }}" - export DB_NAME="${{ secrets.DB_NAME }}" + export DB_NAME="${{ secrets[format('{0}DB_NAME', inputs.secret_prefix)] }}" export DB_USERNAME="${{ secrets.DB_USERNAME }}" export DB_PASSWORD="${{ secrets.DB_PASSWORD }}" export JWT_SECRET="${{ secrets.JWT_SECRET }}" @@ -70,7 +76,7 @@ jobs: export GRAFANA_ADMIN_USER="${{ secrets.GRAFANA_ADMIN_USER }}" export GRAFANA_ADMIN_PASSWORD="${{ secrets.GRAFANA_ADMIN_PASSWORD }}" export S3_REGION="${{ secrets.S3_REGION }}" - export S3_BUCKET="${{ secrets.S3_BUCKET }}" - export S3_ENDPOINT="${{ secrets.S3_ENDPOINT }}" + export S3_BUCKET="${{ secrets[format('{0}S3_BUCKET', inputs.secret_prefix)] }}" + export S3_ENDPOINT="${{ secrets[format('{0}S3_ENDPOINT', inputs.secret_prefix)] }}" - cd ~/app && bash scripts/deploy.sh ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ github.sha }} + cd ~/app && bash scripts/deploy.sh ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ inputs.environment }}-${{ github.sha }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..d6cb219 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,13 @@ +name: Deploy to Production + +on: + push: + branches: + - main + +jobs: + deploy: + uses: ./.github/workflows/deploy-common.yml + with: + environment: production + secrets: inherit diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml new file mode 100644 index 0000000..218b004 --- /dev/null +++ b/.github/workflows/deploy-test.yml @@ -0,0 +1,14 @@ +name: Deploy to Test + +on: + push: + branches: + - develop + +jobs: + deploy: + uses: ./.github/workflows/deploy-common.yml + with: + environment: test + secret_prefix: "TEST_" + secrets: inherit From 4ce615e1afa5d9c21ffb387f89e5a5c6e6879602 Mon Sep 17 00:00:00 2001 From: khj011219 Date: Sun, 23 Aug 2026 00:16:49 +0900 Subject: [PATCH 21/68] =?UTF-8?q?feat:=20=EB=B9=84=EC=A0=95=EC=83=81=20?= =?UTF-8?q?=ED=99=9C=EB=8F=99=20timeline=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/TimelineRepository.java | 10 +++ .../internal/service/ActivityService.java | 78 ++++++++++++------- .../ActivityCreationTransactionResult.java | 11 +++ .../dto/vo/TimelineImageDeleteTarget.java | 7 ++ 4 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/vo/TimelineImageDeleteTarget.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java index abc630f..979a70d 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java @@ -5,6 +5,7 @@ import org.sopt.routee.activity.internal.entity.timeline.Timeline; import org.sopt.routee.activity.internal.entity.timeline.TimelineStatus; +import org.sopt.routee.activity.internal.service.dto.vo.TimelineImageDeleteTarget; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -23,6 +24,15 @@ List findByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc(Long a List findByActivityIdInAndTimelineStatusOrderByCreatedAtAsc(List activityIds, TimelineStatus timelineStatus); + @Query(""" + SELECT t.activity.id AS activityId, t.timelineImageObjectKey AS timelineImageObjectKey + FROM Timeline t + WHERE t.activity.id IN :activityIds + """) + List findImageDeleteTargetsByActivityIdIn( + @Param("activityIds") List activityIds + ); + @Modifying @Query("DELETE FROM Timeline t WHERE t.activity.memberId = :memberId") void deleteTimelinesByMemberId(@Param("memberId") Long memberId); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index 766dc04..11a80ab 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -24,31 +24,36 @@ import org.sopt.routee.activity.internal.mapper.ActivityMapper; import org.sopt.routee.activity.internal.mapper.ActivityTrackMapper; import org.sopt.routee.activity.internal.repository.ActivityRepository; -import org.sopt.routee.activity.internal.repository.TimelineRepository; -import org.sopt.routee.activity.internal.service.dto.vo.TrackPoint; import org.sopt.routee.activity.internal.repository.RouteRepository; +import org.sopt.routee.activity.internal.repository.TimelineRepository; import org.sopt.routee.activity.internal.service.dto.command.CompleteActivityCommand; import org.sopt.routee.activity.internal.service.dto.command.CreateActivityCommand; import org.sopt.routee.activity.internal.service.dto.command.GetActivityRecapCommand; import org.sopt.routee.activity.internal.service.dto.command.ImageUploadUrlCommand; import org.sopt.routee.activity.internal.service.dto.command.UpdateActivityStatusCommand; import org.sopt.routee.activity.internal.service.dto.command.UpdateActivityTitleCommand; +import org.sopt.routee.activity.internal.service.dto.result.ActivitiesByDateResult; +import org.sopt.routee.activity.internal.service.dto.result.ActivityCreationTransactionResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityEditItemResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityEditListResult; +import org.sopt.routee.activity.internal.service.dto.result.ActivityPreviewResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityRecapResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityStatisticsResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityTrackResult; -import org.sopt.routee.activity.internal.service.dto.result.ActivitiesByDateResult; -import org.sopt.routee.activity.internal.service.dto.result.ActivityPreviewResult; import org.sopt.routee.activity.internal.service.dto.result.CreateActivityResult; import org.sopt.routee.activity.internal.service.dto.result.ImageUrlResult; import org.sopt.routee.activity.internal.service.dto.result.TimelineMarkerResult; import org.sopt.routee.activity.internal.service.dto.result.TrackPointResult; import org.sopt.routee.activity.internal.service.dto.result.UpdateActivityStatusResult; import org.sopt.routee.activity.internal.service.dto.result.UpdateActivityTitleResult; +import org.sopt.routee.activity.internal.service.dto.vo.TimelineImageDeleteTarget; +import org.sopt.routee.activity.internal.service.dto.vo.TrackPoint; import org.sopt.routee.activity.internal.service.validator.ActivityImageFileNameValidator; +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.api.command.FileDeleteCommand; import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; import org.sopt.routee.external.api.command.FileUploadPresignCommand; +import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.sopt.routee.external.api.port.FileUploadPresignPort; import org.sopt.routee.external.api.result.FileUploadPresignResult; @@ -60,6 +65,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -82,43 +88,63 @@ public class ActivityService { private final ActivityImageFileNameValidator activityImageFileNameValidator; private final FileUploadPresignPort fileUploadPresignPort; private final FileImageAccessUrlPort fileImageAccessUrlPort; + private final FileDeletePort fileDeletePort; private final RouteRepository routeRepository; private final ApplicationEventPublisher applicationEventPublisher; + private final TransactionTemplate transactionTemplate; - @Transactional public CreateActivityResult create(CreateActivityCommand command) { - discardExistingActiveActivities(command.memberId()); + ActivityCreationTransactionResult transactionResult = transactionTemplate.execute( + status -> createInTransaction(command)); - Instant startedAt = command.startedAt() - .atZone(command.timeZone()) - .toInstant(); - LocalDate activityDate = TimeZoneUtils.toLocalDate(startedAt, command.timeZone()); - String title = activityDate.format(TITLE_DATE_FORMATTER) + " 기록"; - Activity activity = ActivityMapper.toEntity(command, title, startedAt); - Activity savedActivity = activityRepository.save(activity); + log.info("Activity created. activityId={}, memberId={}", transactionResult.result().activityId(), + command.memberId()); - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - log.info("Activity created. activityId={}, memberId={}", savedActivity.getId(), command.memberId()); - } - }); + if (!transactionResult.imageDeleteTargets().isEmpty()) { + Thread.startVirtualThread(() -> deleteTimelineImages(transactionResult.imageDeleteTargets())); + } - return new CreateActivityResult(savedActivity.getId(), title); + return transactionResult.result(); } - private void discardExistingActiveActivities(Long memberId) { + private ActivityCreationTransactionResult createInTransaction(CreateActivityCommand command) { List activeActivityIds = activityRepository.findIdsByMemberIdAndActivityStatusIn( - memberId, + command.memberId(), ACTIVE_STATUSES ); - if (activeActivityIds.isEmpty()) { - return; + List imageDeleteTargets = List.of(); + if (!activeActivityIds.isEmpty()) { + imageDeleteTargets = timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds); + + timelineRepository.deleteByActivityIdIn(activeActivityIds); + activityRepository.deleteByIdIn(activeActivityIds); } - timelineRepository.deleteByActivityIdIn(activeActivityIds); - activityRepository.deleteByIdIn(activeActivityIds); + Instant startedAt = command.startedAt().atZone(command.timeZone()).toInstant(); + LocalDate activityDate = TimeZoneUtils.toLocalDate(startedAt, command.timeZone()); + String title = activityDate.format(TITLE_DATE_FORMATTER) + " 기록"; + Activity savedActivity = activityRepository.save(ActivityMapper.toEntity(command, title, startedAt)); + + return new ActivityCreationTransactionResult( + new CreateActivityResult(savedActivity.getId(), title), + imageDeleteTargets + ); + } + + private void deleteTimelineImages(List targets) { + for (TimelineImageDeleteTarget target : targets) { + try { + fileDeletePort.deleteImage(new FileDeleteCommand( + FileUploadDirectory.TIMELINE, + target.activityId().toString(), + target.objectKey() + )); + } catch (BaseException e) { + log.warn("Timeline image delete failed. activityId={}, objectKey={}", + target.activityId(), target.objectKey(), e); + } + } } @Transactional(readOnly = true) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java new file mode 100644 index 0000000..d7544aa --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java @@ -0,0 +1,11 @@ +package org.sopt.routee.activity.internal.service.dto.result; + +import java.util.List; + +import org.sopt.routee.activity.internal.service.dto.vo.TimelineImageDeleteTarget; + +public record ActivityCreationTransactionResult( + CreateActivityResult result, + List imageDeleteTargets +) { +} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/vo/TimelineImageDeleteTarget.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/vo/TimelineImageDeleteTarget.java new file mode 100644 index 0000000..0252125 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/vo/TimelineImageDeleteTarget.java @@ -0,0 +1,7 @@ +package org.sopt.routee.activity.internal.service.dto.vo; + +public record TimelineImageDeleteTarget( + Long activityId, + String objectKey +) { +} From 90f347988ca1e1956c964b08fa6d3f8321ddaa38 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Sun, 23 Aug 2026 00:23:30 +0900 Subject: [PATCH 22/68] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index 218b004..7e8cffe 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -4,6 +4,7 @@ on: push: branches: - develop + - feat/96/deploy-workflow jobs: deploy: From b183fa5ed1a912b0ce5b0e84efa116acaebc8482 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Sun, 23 Aug 2026 00:46:54 +0900 Subject: [PATCH 23/68] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=B0=ED=8F=AC=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index 7e8cffe..218b004 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -4,7 +4,6 @@ on: push: branches: - develop - - feat/96/deploy-workflow jobs: deploy: From dd15582dab86ff14f87a1909306c4627a18012d2 Mon Sep 17 00:00:00 2001 From: khj011219 Date: Sun, 23 Aug 2026 01:13:50 +0900 Subject: [PATCH 24/68] =?UTF-8?q?fix:=20ActivityImageUrlServiceTest=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ActivityImageUrlServiceTest.java | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityImageUrlServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityImageUrlServiceTest.java index 77391a9..f2312c9 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityImageUrlServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityImageUrlServiceTest.java @@ -11,38 +11,74 @@ import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; import org.sopt.routee.activity.internal.exception.UnsupportedImageFileExtensionException; import org.sopt.routee.activity.internal.repository.ActivityRepository; +import org.sopt.routee.activity.internal.repository.RouteRepository; +import org.sopt.routee.activity.internal.repository.TimelineRepository; import org.sopt.routee.activity.internal.service.dto.command.ImageUploadUrlCommand; import org.sopt.routee.activity.internal.service.dto.result.ImageUrlResult; import org.sopt.routee.activity.internal.service.validator.ActivityImageFileNameValidator; import org.sopt.routee.external.api.command.FileUploadPresignCommand; +import org.sopt.routee.external.api.port.FileDeletePort; +import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.sopt.routee.external.api.port.FileUploadPresignPort; import org.sopt.routee.external.api.result.FileUploadPresignResult; import org.sopt.routee.external.api.type.FileUploadDirectory; import org.sopt.routee.external.api.type.FileUploadImageSize; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.support.TransactionTemplate; @ExtendWith(MockitoExtension.class) class ActivityImageUrlServiceTest { private static final Long ACTIVITY_ID = 100L; private static final Long MEMBER_ID = 1L; + private static final FileUploadDirectory DIRECTORY = FileUploadDirectory.TIMELINE; + private static final FileUploadImageSize IMAGE_SIZE = FileUploadImageSize.ORIGINAL; @Mock private ActivityRepository activityRepository; + @Mock + private TimelineRepository timelineRepository; + + @Mock + private ActivityDailySummaryService activityDailySummaryService; + @Mock private ActivityImageFileNameValidator activityImageFileNameValidator; @Mock private FileUploadPresignPort fileUploadPresignPort; + @Mock + private FileImageAccessUrlPort fileImageAccessUrlPort; + + @Mock + private FileDeletePort fileDeletePort; + + @Mock + private RouteRepository routeRepository; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Mock + private TransactionTemplate transactionTemplate; + private ActivityService activityService; @BeforeEach void setUp() { activityService = new ActivityService( activityRepository, + timelineRepository, + activityDailySummaryService, activityImageFileNameValidator, - fileUploadPresignPort + fileUploadPresignPort, + fileImageAccessUrlPort, + fileDeletePort, + routeRepository, + applicationEventPublisher, + transactionTemplate ); } @@ -54,8 +90,8 @@ void setUp() { when(activityRepository.existsByIdAndMemberId(ACTIVITY_ID, MEMBER_ID)).thenReturn(true); when(activityImageFileNameValidator.validate("hike.jpg")).thenReturn(true); FileUploadPresignCommand command = new FileUploadPresignCommand( - FileUploadDirectory.ACTIVITY, - FileUploadImageSize.ORIGINAL, + DIRECTORY, + IMAGE_SIZE, ACTIVITY_ID.toString(), "hike.jpg" ); @@ -63,7 +99,7 @@ void setUp() { .thenReturn(presignResult); ImageUrlResult result = activityService.generateImageUploadUrl( - new ImageUploadUrlCommand(ACTIVITY_ID, MEMBER_ID, "hike.jpg") + new ImageUploadUrlCommand(ACTIVITY_ID, MEMBER_ID, "hike.jpg", DIRECTORY, IMAGE_SIZE) ); assertThat(result.presignedUrl()).isEqualTo("https://presigned-url"); @@ -77,7 +113,7 @@ void setUp() { when(activityImageFileNameValidator.validate("hike.gif")).thenReturn(false); assertThatThrownBy(() -> activityService.generateImageUploadUrl( - new ImageUploadUrlCommand(ACTIVITY_ID, MEMBER_ID, "hike.gif") + new ImageUploadUrlCommand(ACTIVITY_ID, MEMBER_ID, "hike.gif", DIRECTORY, IMAGE_SIZE) )) .isInstanceOf(UnsupportedImageFileExtensionException.class); verifyNoInteractions(fileUploadPresignPort); @@ -88,7 +124,7 @@ void setUp() { when(activityRepository.existsByIdAndMemberId(ACTIVITY_ID, MEMBER_ID)).thenReturn(false); assertThatThrownBy(() -> activityService.generateImageUploadUrl( - new ImageUploadUrlCommand(ACTIVITY_ID, MEMBER_ID, "hike.jpg") + new ImageUploadUrlCommand(ACTIVITY_ID, MEMBER_ID, "hike.jpg", DIRECTORY, IMAGE_SIZE) )) .isInstanceOf(ActivityNotFoundException.class); verifyNoInteractions(activityImageFileNameValidator, fileUploadPresignPort); From 8ef384abae57cf899f0547d26557b8b5fe415840 Mon Sep 17 00:00:00 2001 From: khj011219 Date: Sun, 23 Aug 2026 01:24:20 +0900 Subject: [PATCH 25/68] =?UTF-8?q?test:=20ActivityServiceTest=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/ActivityServiceTest.java | 237 ++++++++++++++---- 1 file changed, 195 insertions(+), 42 deletions(-) diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java index 8d14aa1..6bbd2d4 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java @@ -8,26 +8,44 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.sopt.routee.activity.internal.entity.activity.Activity; import org.sopt.routee.activity.internal.entity.activity.ActivityStatus; import org.sopt.routee.activity.internal.entity.activity.ActivityType; -import org.sopt.routee.activity.internal.exception.AlreadyInProgressActivityException; +import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; import org.sopt.routee.activity.internal.repository.ActivityRepository; +import org.sopt.routee.activity.internal.repository.RouteRepository; +import org.sopt.routee.activity.internal.repository.TimelineRepository; import org.sopt.routee.activity.internal.service.dto.command.CreateActivityCommand; +import org.sopt.routee.activity.internal.service.dto.result.ActivityCreationTransactionResult; import org.sopt.routee.activity.internal.service.dto.result.CreateActivityResult; +import org.sopt.routee.activity.internal.service.dto.vo.TimelineImageDeleteTarget; import org.sopt.routee.activity.internal.service.validator.ActivityImageFileNameValidator; +import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.port.FileDeletePort; +import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.sopt.routee.external.api.port.FileUploadPresignPort; +import org.sopt.routee.external.api.type.FileUploadDirectory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; @ExtendWith(MockitoExtension.class) class ActivityServiceTest { + private static final Long MEMBER_ID = 1L; private static final EnumSet ACTIVE_STATUSES = EnumSet.of( ActivityStatus.ACTIVITY_IN_PROGRESS, ActivityStatus.ACTIVITY_PAUSED @@ -36,37 +54,88 @@ class ActivityServiceTest { @Mock private ActivityRepository activityRepository; + @Mock + private TimelineRepository timelineRepository; + + @Mock + private ActivityDailySummaryService activityDailySummaryService; + @Mock private ActivityImageFileNameValidator activityImageFileNameValidator; @Mock private FileUploadPresignPort fileUploadPresignPort; - @InjectMocks + @Mock + private FileImageAccessUrlPort fileImageAccessUrlPort; + + @Mock + private FileDeletePort fileDeletePort; + + @Mock + private RouteRepository routeRepository; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Mock + private TransactionTemplate transactionTemplate; + private ActivityService activityService; - @Test - void create_진행중인_활동이_없으면_요청한_활동_유형으로_활동을_생성한다() { - Long memberId = 1L; - Activity savedActivity = Activity.builder() - .id(10L) - .title("2026.07.06 기록") - .activityType(ActivityType.RUNNING) + @BeforeEach + void setUp() { + activityService = new ActivityService( + activityRepository, + timelineRepository, + activityDailySummaryService, + activityImageFileNameValidator, + fileUploadPresignPort, + fileImageAccessUrlPort, + fileDeletePort, + routeRepository, + applicationEventPublisher, + transactionTemplate + ); + } + + private void stubTransactionTemplateToRunCallback() { + when(transactionTemplate.execute(any())).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); + } + + private CreateActivityCommand createCommand(ActivityType activityType) { + return new CreateActivityCommand( + MEMBER_ID, + activityType, + LocalDateTime.of(2026, 7, 7, 15, 30), + ZoneId.of("Pacific/Kiritimati") + ); + } + + private Activity savedActivity(Long activityId, ActivityType activityType) { + return Activity.builder() + .id(activityId) + .title("2026.07.07 기록") + .activityType(activityType) .activityStatus(ActivityStatus.ACTIVITY_IN_PROGRESS) - .memberId(memberId) + .memberId(MEMBER_ID) .build(); + } - when(activityRepository.existsByMemberIdAndActivityStatusIn(memberId, ACTIVE_STATUSES)) - .thenReturn(false); - when(activityRepository.save(any(Activity.class))) - .thenReturn(savedActivity); + @Test + void create_활성_활동이_없으면_기존_데이터를_삭제하지_않고_새_활동을_생성한다() { + Activity savedActivity = savedActivity(10L, ActivityType.RUNNING); + when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) + .thenReturn(List.of()); + when(activityRepository.save(any(Activity.class))).thenReturn(savedActivity); + stubTransactionTemplateToRunCallback(); - ZoneId timeZone = ZoneId.of("Pacific/Kiritimati"); - LocalDateTime startedAt = LocalDateTime.of(2026, 7, 7, 15, 30); - Instant expectedStartedAt = startedAt.atZone(timeZone).toInstant(); - CreateActivityResult result = activityService.create( - new CreateActivityCommand(memberId, ActivityType.RUNNING, startedAt, timeZone) - ); + CreateActivityCommand command = createCommand(ActivityType.RUNNING); + Instant expectedStartedAt = command.startedAt().atZone(command.timeZone()).toInstant(); + CreateActivityResult result = activityService.create(command); assertThat(result.activityId()).isEqualTo(10L); assertThat(result.title()).isEqualTo("2026.07.07 기록"); @@ -78,31 +147,115 @@ class ActivityServiceTest { assertThat(activity.getStartedAt()).isEqualTo(expectedStartedAt); assertThat(activity.getActivityType()).isEqualTo(ActivityType.RUNNING); assertThat(activity.getActivityStatus()).isEqualTo(ActivityStatus.ACTIVITY_IN_PROGRESS); - assertThat(activity.getMemberId()).isEqualTo(memberId); + assertThat(activity.getMemberId()).isEqualTo(MEMBER_ID); + + verify(timelineRepository, never()).findImageDeleteTargetsByActivityIdIn(anyList()); + verify(timelineRepository, never()).deleteByActivityIdIn(anyList()); + verify(activityRepository, never()).deleteByIdIn(anyList()); + verifyNoInteractions(fileDeletePort); + } + + @Test + void create_활성_활동에_타임라인이_없어도_기존_활동을_폐기하고_새_활동을_생성한다() { + List activeActivityIds = List.of(10L, 11L); + when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) + .thenReturn(activeActivityIds); + when(timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds)).thenReturn(List.of()); + when(activityRepository.save(any(Activity.class))).thenReturn(savedActivity(20L, ActivityType.HIKING)); + stubTransactionTemplateToRunCallback(); + + CreateActivityResult result = activityService.create(createCommand(ActivityType.HIKING)); + + assertThat(result.activityId()).isEqualTo(20L); + InOrder inOrder = inOrder(activityRepository, timelineRepository); + inOrder.verify(activityRepository).findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES); + inOrder.verify(timelineRepository).findImageDeleteTargetsByActivityIdIn(activeActivityIds); + inOrder.verify(timelineRepository).deleteByActivityIdIn(activeActivityIds); + inOrder.verify(activityRepository).deleteByIdIn(activeActivityIds); + inOrder.verify(activityRepository).save(any(Activity.class)); + verifyNoInteractions(fileDeletePort); + } + + @Test + void create_타임라인_이미지가_있으면_DB_삭제_후_S3_삭제를_요청한다() throws InterruptedException { + List activeActivityIds = List.of(10L, 11L); + List imageDeleteTargets = List.of( + new TimelineImageDeleteTarget(10L, "10/first.jpg"), + new TimelineImageDeleteTarget(11L, "11/second.jpg") + ); + when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) + .thenReturn(activeActivityIds); + when(timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds)).thenReturn(imageDeleteTargets); + when(activityRepository.save(any(Activity.class))).thenReturn(savedActivity(20L, ActivityType.RUNNING)); + stubTransactionTemplateToRunCallback(); + + CountDownLatch latch = new CountDownLatch(imageDeleteTargets.size()); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(fileDeletePort).deleteImage(any()); + + activityService.create(createCommand(ActivityType.RUNNING)); + + InOrder inOrder = inOrder(activityRepository, timelineRepository); + inOrder.verify(activityRepository).findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES); + inOrder.verify(timelineRepository).findImageDeleteTargetsByActivityIdIn(activeActivityIds); + inOrder.verify(timelineRepository).deleteByActivityIdIn(activeActivityIds); + inOrder.verify(activityRepository).deleteByIdIn(activeActivityIds); + inOrder.verify(activityRepository).save(any(Activity.class)); + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(FileDeleteCommand.class); + verify(fileDeletePort, times(2)).deleteImage(commandCaptor.capture()); + assertThat(commandCaptor.getAllValues()).containsExactly( + new FileDeleteCommand(FileUploadDirectory.TIMELINE, "10", "10/first.jpg"), + new FileDeleteCommand(FileUploadDirectory.TIMELINE, "11", "11/second.jpg") + ); } @Test - void create_진행중이거나_일시정지된_활동이_있으면_예외를_던진다() { - Long memberId = 1L; - when(activityRepository.existsByMemberIdAndActivityStatusIn(memberId, ACTIVE_STATUSES)) - .thenReturn(true); - - assertThatThrownBy(() -> activityService.create( - new CreateActivityCommand( - memberId, - ActivityType.HIKING, - LocalDateTime.of(2026, 7, 7, 15, 30), - ZoneId.of("Asia/Seoul") - ) - )) - .isInstanceOf(AlreadyInProgressActivityException.class); - - verify(activityRepository).existsByMemberIdAndActivityStatusIn(memberId, ACTIVE_STATUSES); - assertThat(ACTIVE_STATUSES).containsExactlyInAnyOrder( - ActivityStatus.ACTIVITY_IN_PROGRESS, - ActivityStatus.ACTIVITY_PAUSED + void create_일부_S3_삭제가_실패해도_나머지_삭제를_계속하고_예외를_전파하지_않는다() throws InterruptedException { + List activeActivityIds = List.of(10L, 11L); + List imageDeleteTargets = List.of( + new TimelineImageDeleteTarget(10L, "10/first.jpg"), + new TimelineImageDeleteTarget(11L, "11/second.jpg") ); + when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) + .thenReturn(activeActivityIds); + when(timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds)).thenReturn(imageDeleteTargets); + when(activityRepository.save(any(Activity.class))).thenReturn(savedActivity(20L, ActivityType.RUNNING)); + stubTransactionTemplateToRunCallback(); + + CountDownLatch latch = new CountDownLatch(imageDeleteTargets.size()); + AtomicInteger invocationCount = new AtomicInteger(); + doAnswer(invocation -> { + latch.countDown(); + if (invocationCount.getAndIncrement() == 0) { + throw new ActivityNotFoundException(); + } + return null; + }).when(fileDeletePort).deleteImage(any()); + + CreateActivityResult result = activityService.create(createCommand(ActivityType.RUNNING)); + + assertThat(result.activityId()).isEqualTo(20L); + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + verify(fileDeletePort, times(2)).deleteImage(any()); + } + + @Test + void create_새_활동_저장에_실패하면_S3_삭제를_요청하지_않는다() { + List activeActivityIds = List.of(10L); + when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) + .thenReturn(activeActivityIds); + when(timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds)) + .thenReturn(List.of(new TimelineImageDeleteTarget(10L, "10/first.jpg"))); + when(activityRepository.save(any(Activity.class))).thenThrow(new ActivityNotFoundException()); + stubTransactionTemplateToRunCallback(); + + assertThatThrownBy(() -> activityService.create(createCommand(ActivityType.RUNNING))) + .isInstanceOf(ActivityNotFoundException.class); - verify(activityRepository, never()).save(any(Activity.class)); + verify(fileDeletePort, never()).deleteImage(any()); } } From e6225766c76f70dc948db01c4d47a2306e2529d7 Mon Sep 17 00:00:00 2001 From: khj011219 Date: Sun, 23 Aug 2026 23:00:57 +0900 Subject: [PATCH 26/68] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=EC=9D=84=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=ED=94=84=EB=A1=9C=EC=A0=9D=EC=85=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/TimelineRepository.java | 6 ++--- .../TimelineImageDeleteTargetProjection.java | 8 +++++++ .../internal/service/ActivityService.java | 14 +++++------ .../ActivityCreationTransactionResult.java | 4 ++-- .../dto/vo/TimelineImageDeleteTarget.java | 7 ------ .../internal/service/ActivityServiceTest.java | 24 ++++++++++++------- 6 files changed, 36 insertions(+), 27 deletions(-) create mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/projection/TimelineImageDeleteTargetProjection.java delete mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/vo/TimelineImageDeleteTarget.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java index 979a70d..b764ad6 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java @@ -5,7 +5,7 @@ import org.sopt.routee.activity.internal.entity.timeline.Timeline; import org.sopt.routee.activity.internal.entity.timeline.TimelineStatus; -import org.sopt.routee.activity.internal.service.dto.vo.TimelineImageDeleteTarget; +import org.sopt.routee.activity.internal.repository.projection.TimelineImageDeleteTargetProjection; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -25,11 +25,11 @@ List findByActivityIdInAndTimelineStatusOrderByCreatedAtAsc(List TimelineStatus timelineStatus); @Query(""" - SELECT t.activity.id AS activityId, t.timelineImageObjectKey AS timelineImageObjectKey + SELECT t.activity.id AS activityId, t.timelineImageObjectKey AS objectKey FROM Timeline t WHERE t.activity.id IN :activityIds """) - List findImageDeleteTargetsByActivityIdIn( + List findImageDeleteTargetsByActivityIdIn( @Param("activityIds") List activityIds ); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/projection/TimelineImageDeleteTargetProjection.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/projection/TimelineImageDeleteTargetProjection.java new file mode 100644 index 0000000..75d7e84 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/projection/TimelineImageDeleteTargetProjection.java @@ -0,0 +1,8 @@ +package org.sopt.routee.activity.internal.repository.projection; + +public interface TimelineImageDeleteTargetProjection { + + Long getActivityId(); + + String getObjectKey(); +} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index 11a80ab..32907b6 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -26,6 +26,7 @@ import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.repository.RouteRepository; import org.sopt.routee.activity.internal.repository.TimelineRepository; +import org.sopt.routee.activity.internal.repository.projection.TimelineImageDeleteTargetProjection; import org.sopt.routee.activity.internal.service.dto.command.CompleteActivityCommand; import org.sopt.routee.activity.internal.service.dto.command.CreateActivityCommand; import org.sopt.routee.activity.internal.service.dto.command.GetActivityRecapCommand; @@ -46,7 +47,6 @@ import org.sopt.routee.activity.internal.service.dto.result.TrackPointResult; import org.sopt.routee.activity.internal.service.dto.result.UpdateActivityStatusResult; import org.sopt.routee.activity.internal.service.dto.result.UpdateActivityTitleResult; -import org.sopt.routee.activity.internal.service.dto.vo.TimelineImageDeleteTarget; import org.sopt.routee.activity.internal.service.dto.vo.TrackPoint; import org.sopt.routee.activity.internal.service.validator.ActivityImageFileNameValidator; import org.sopt.routee.exception.BaseException; @@ -113,7 +113,7 @@ private ActivityCreationTransactionResult createInTransaction(CreateActivityComm ACTIVE_STATUSES ); - List imageDeleteTargets = List.of(); + List imageDeleteTargets = List.of(); if (!activeActivityIds.isEmpty()) { imageDeleteTargets = timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds); @@ -132,17 +132,17 @@ private ActivityCreationTransactionResult createInTransaction(CreateActivityComm ); } - private void deleteTimelineImages(List targets) { - for (TimelineImageDeleteTarget target : targets) { + private void deleteTimelineImages(List targets) { + for (TimelineImageDeleteTargetProjection target : targets) { try { fileDeletePort.deleteImage(new FileDeleteCommand( FileUploadDirectory.TIMELINE, - target.activityId().toString(), - target.objectKey() + target.getActivityId().toString(), + target.getObjectKey() )); } catch (BaseException e) { log.warn("Timeline image delete failed. activityId={}, objectKey={}", - target.activityId(), target.objectKey(), e); + target.getActivityId(), target.getObjectKey(), e); } } } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java index d7544aa..d6becc3 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java @@ -2,10 +2,10 @@ import java.util.List; -import org.sopt.routee.activity.internal.service.dto.vo.TimelineImageDeleteTarget; +import org.sopt.routee.activity.internal.repository.projection.TimelineImageDeleteTargetProjection; public record ActivityCreationTransactionResult( CreateActivityResult result, - List imageDeleteTargets + List imageDeleteTargets ) { } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/vo/TimelineImageDeleteTarget.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/vo/TimelineImageDeleteTarget.java deleted file mode 100644 index 0252125..0000000 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/vo/TimelineImageDeleteTarget.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.sopt.routee.activity.internal.service.dto.vo; - -public record TimelineImageDeleteTarget( - Long activityId, - String objectKey -) { -} diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java index 6bbd2d4..dbec626 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java @@ -27,10 +27,10 @@ import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.repository.RouteRepository; import org.sopt.routee.activity.internal.repository.TimelineRepository; +import org.sopt.routee.activity.internal.repository.projection.TimelineImageDeleteTargetProjection; import org.sopt.routee.activity.internal.service.dto.command.CreateActivityCommand; import org.sopt.routee.activity.internal.service.dto.result.ActivityCreationTransactionResult; import org.sopt.routee.activity.internal.service.dto.result.CreateActivityResult; -import org.sopt.routee.activity.internal.service.dto.vo.TimelineImageDeleteTarget; import org.sopt.routee.activity.internal.service.validator.ActivityImageFileNameValidator; import org.sopt.routee.external.api.command.FileDeleteCommand; import org.sopt.routee.external.api.port.FileDeletePort; @@ -125,6 +125,13 @@ private Activity savedActivity(Long activityId, ActivityType activityType) { .build(); } + private TimelineImageDeleteTargetProjection imageDeleteTarget(Long activityId, String objectKey) { + TimelineImageDeleteTargetProjection projection = mock(TimelineImageDeleteTargetProjection.class); + when(projection.getActivityId()).thenReturn(activityId); + when(projection.getObjectKey()).thenReturn(objectKey); + return projection; + } + @Test void create_활성_활동이_없으면_기존_데이터를_삭제하지_않고_새_활동을_생성한다() { Activity savedActivity = savedActivity(10L, ActivityType.RUNNING); @@ -179,9 +186,9 @@ private Activity savedActivity(Long activityId, ActivityType activityType) { @Test void create_타임라인_이미지가_있으면_DB_삭제_후_S3_삭제를_요청한다() throws InterruptedException { List activeActivityIds = List.of(10L, 11L); - List imageDeleteTargets = List.of( - new TimelineImageDeleteTarget(10L, "10/first.jpg"), - new TimelineImageDeleteTarget(11L, "11/second.jpg") + List imageDeleteTargets = List.of( + imageDeleteTarget(10L, "10/first.jpg"), + imageDeleteTarget(11L, "11/second.jpg") ); when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) .thenReturn(activeActivityIds); @@ -216,9 +223,9 @@ private Activity savedActivity(Long activityId, ActivityType activityType) { @Test void create_일부_S3_삭제가_실패해도_나머지_삭제를_계속하고_예외를_전파하지_않는다() throws InterruptedException { List activeActivityIds = List.of(10L, 11L); - List imageDeleteTargets = List.of( - new TimelineImageDeleteTarget(10L, "10/first.jpg"), - new TimelineImageDeleteTarget(11L, "11/second.jpg") + List imageDeleteTargets = List.of( + imageDeleteTarget(10L, "10/first.jpg"), + imageDeleteTarget(11L, "11/second.jpg") ); when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) .thenReturn(activeActivityIds); @@ -246,10 +253,11 @@ private Activity savedActivity(Long activityId, ActivityType activityType) { @Test void create_새_활동_저장에_실패하면_S3_삭제를_요청하지_않는다() { List activeActivityIds = List.of(10L); + TimelineImageDeleteTargetProjection imageDeleteTarget = mock(TimelineImageDeleteTargetProjection.class); when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) .thenReturn(activeActivityIds); when(timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds)) - .thenReturn(List.of(new TimelineImageDeleteTarget(10L, "10/first.jpg"))); + .thenReturn(List.of(imageDeleteTarget)); when(activityRepository.save(any(Activity.class))).thenThrow(new ActivityNotFoundException()); stubTransactionTemplateToRunCallback(); From 2ce4b5f081c8fd3f6353b668ea22185a9d4bd216 Mon Sep 17 00:00:00 2001 From: khj011219 Date: Sun, 23 Aug 2026 23:21:45 +0900 Subject: [PATCH 27/68] =?UTF-8?q?refactor:=20=ED=99=9C=EB=8F=99=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9D=84=20=EC=A7=81=EB=A0=AC=ED=99=94?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=A4=91=EB=B3=B5=20=ED=99=9C=EB=8F=99=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../activity/internal/repository/ActivityRepository.java | 3 +++ .../sopt/routee/activity/internal/service/ActivityService.java | 2 ++ 2 files changed, 5 insertions(+) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java index 1cacfaa..72c4a77 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java @@ -46,4 +46,7 @@ List findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedA @Modifying @Query("DELETE FROM Activity a WHERE a.id IN :activityIds") void deleteByIdIn(@Param("activityIds") List activityIds); + + @Query(value = "SELECT pg_advisory_xact_lock(:memberId)", nativeQuery = true) + void acquireCreationLock(@Param("memberId") Long memberId); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index 32907b6..e5a2016 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -108,6 +108,8 @@ public CreateActivityResult create(CreateActivityCommand command) { } private ActivityCreationTransactionResult createInTransaction(CreateActivityCommand command) { + activityRepository.acquireCreationLock(command.memberId()); + List activeActivityIds = activityRepository.findIdsByMemberIdAndActivityStatusIn( command.memberId(), ACTIVE_STATUSES From 0f73e42395bee58c28a5b490dff54e7dbc51ad20 Mon Sep 17 00:00:00 2001 From: khj011219 Date: Mon, 24 Aug 2026 01:26:40 +0900 Subject: [PATCH 28/68] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/routee/activity/internal/code/ErrorCode.java | 1 - .../exception/AlreadyInProgressActivityException.java | 11 ----------- 2 files changed, 12 deletions(-) delete mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/exception/AlreadyInProgressActivityException.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/ErrorCode.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/ErrorCode.java index dd42628..e6bb14f 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/ErrorCode.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/ErrorCode.java @@ -19,7 +19,6 @@ public enum ErrorCode implements ErrorResultCode { ACTIVITY_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 완료된 활동입니다."), ACTIVITY_STATUS_ALREADY_SAME(HttpStatus.CONFLICT, "이미 동일한 활동 상태입니다."), - ALREADY_IN_PROGRESS_ACTIVITY(HttpStatus.CONFLICT, "이미 진행 중이거나 일시정지된 활동이 있습니다."), INVALID_ACTIVITY_STATUS_TRANSITION(HttpStatus.CONFLICT, "변경할 수 없는 활동 상태입니다."), ROUTE_ALREADY_EXISTS(HttpStatus.CONFLICT, "루트 목록이 이미 존재합니다."); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/exception/AlreadyInProgressActivityException.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/exception/AlreadyInProgressActivityException.java deleted file mode 100644 index b40b8db..0000000 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/exception/AlreadyInProgressActivityException.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.sopt.routee.activity.internal.exception; - -import org.sopt.routee.activity.internal.code.ErrorCode; -import org.sopt.routee.exception.BaseException; - -public class AlreadyInProgressActivityException extends BaseException { - - public AlreadyInProgressActivityException() { - super(ErrorCode.ALREADY_IN_PROGRESS_ACTIVITY); - } -} From 0992ea56c41f8d8e98f412dca60070363a9fe313 Mon Sep 17 00:00:00 2001 From: kymin Date: Tue, 25 Aug 2026 13:44:21 +0900 Subject: [PATCH 29/68] =?UTF-8?q?fix:=20memberId=20=EA=B8=B0=EB=B0=98=20pr?= =?UTF-8?q?efix=20=EC=B6=94=EA=B0=80=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/ActivityService.java | 17 ++++-- .../internal/service/TimelineService.java | 11 ++-- .../service/ActivityImageUrlServiceTest.java | 1 + .../internal/service/ActivityServiceTest.java | 4 +- .../internal/service/TimelineServiceTest.java | 1 + .../api/command/FileDeleteCommand.java | 1 + .../command/FileImageAccessUrlCommand.java | 12 ++++- .../api/command/FileUploadPresignCommand.java | 12 ++++- .../s3/adapter/S3FileDeleteAdapter.java | 4 +- .../adapter/S3FileImageAccessUrlAdapter.java | 3 +- .../s3/adapter/S3ObjectKeyAssembler.java | 23 ++++---- .../internal/s3/adapter/S3PresignAdapter.java | 3 +- .../s3/adapter/S3FileDeleteAdapterTest.java | 20 +++---- .../s3/adapter/S3ObjectKeyAssemblerTest.java | 54 +++++++++++++++++++ 14 files changed, 129 insertions(+), 37 deletions(-) create mode 100644 routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssemblerTest.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index e5a2016..9410acd 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -101,7 +101,8 @@ public CreateActivityResult create(CreateActivityCommand command) { command.memberId()); if (!transactionResult.imageDeleteTargets().isEmpty()) { - Thread.startVirtualThread(() -> deleteTimelineImages(transactionResult.imageDeleteTargets())); + Thread.startVirtualThread( + () -> deleteTimelineImages(command.memberId(), transactionResult.imageDeleteTargets())); } return transactionResult.result(); @@ -134,11 +135,12 @@ private ActivityCreationTransactionResult createInTransaction(CreateActivityComm ); } - private void deleteTimelineImages(List targets) { + private void deleteTimelineImages(Long memberId, List targets) { for (TimelineImageDeleteTargetProjection target : targets) { try { fileDeletePort.deleteImage(new FileDeleteCommand( FileUploadDirectory.TIMELINE, + memberId.toString(), target.getActivityId().toString(), target.getObjectKey() )); @@ -162,6 +164,7 @@ public ImageUrlResult generateImageUploadUrl(ImageUploadUrlCommand command) { FileUploadPresignCommand presignCommand = new FileUploadPresignCommand( command.directory(), command.imageSize(), + command.memberId().toString(), command.activityId().toString(), command.fileName() ); @@ -296,7 +299,8 @@ public ActivityEditListResult getActivityEditList(Long memberId, YearMonth yearM .getOrDefault(activity.getId(), List.of()) .stream() .limit(MAX_EDIT_LIST_TIMELINE_IMAGE_COUNT) - .map(timeline -> generateTimelineImageUrl(activity.getId(), timeline, FileUploadImageSize.MEDIUM)) + .map(timeline -> generateTimelineImageUrl(memberId, activity.getId(), timeline, + FileUploadImageSize.MEDIUM)) .toList(); LocalDate activityDate = TimeZoneUtils.toLocalDate(activity.getStartedAt(), timeZone); return ActivityMapper.toActivityEditItemResult(activity, activityDate, timelineImageUrls); @@ -321,7 +325,7 @@ public ActivityTrackResult getTrack(Long activityId, Long memberId) { ); List timelineMarkers = timelines.stream() .map(timeline -> ActivityTrackMapper.toTimelineMarker( - timeline, generateTimelineImageUrl(activityId, timeline, FileUploadImageSize.SMALL) + timeline, generateTimelineImageUrl(memberId, activityId, timeline, FileUploadImageSize.SMALL) )) .toList(); @@ -336,6 +340,7 @@ private String generateThumbnailUrl(Activity activity) { FileImageAccessUrlCommand command = new FileImageAccessUrlCommand( FileUploadDirectory.TIMELINE, FileUploadImageSize.SMALL, + activity.getMemberId().toString(), activity.getId().toString(), activity.getCoverImageObjectKey() ); @@ -347,10 +352,12 @@ public void deleteActivitiesByMemberId(long memberId) { activityRepository.deleteByMemberId(memberId); } - private String generateTimelineImageUrl(Long activityId, Timeline timeline, FileUploadImageSize imageSize) { + private String generateTimelineImageUrl(Long memberId, Long activityId, Timeline timeline, + FileUploadImageSize imageSize) { FileImageAccessUrlCommand command = new FileImageAccessUrlCommand( FileUploadDirectory.TIMELINE, imageSize, + memberId.toString(), activityId.toString(), timeline.getTimelineImageObjectKey() ); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index 7119c56..5ea36c8 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -60,7 +60,7 @@ public List getTimelines(Long activityId, Long memberId) { } return timelineRepository.findByActivityIdOrderByCreatedAtAsc(activityId).stream() - .map(timeline -> TimelineMapper.toTimelineResult(timeline, generateImageUrl(activityId, timeline))) + .map(timeline -> TimelineMapper.toTimelineResult(timeline, generateImageUrl(memberId, activityId, timeline))) .toList(); } @@ -75,7 +75,7 @@ public void delete(Long activityId, Long timelineId, Long memberId) { String objectKey = timeline.getTimelineImageObjectKey(); - Thread.startVirtualThread(() -> deleteTimelineImage(activityId, objectKey)); + Thread.startVirtualThread(() -> deleteTimelineImage(memberId, activityId, objectKey)); } @Transactional @@ -92,10 +92,11 @@ public void deleteTimelinesByMemberId(long memberId) { timelineRepository.deleteTimelinesByMemberId(memberId); } - private String generateImageUrl(Long activityId, Timeline timeline) { + private String generateImageUrl(Long memberId, Long activityId, Timeline timeline) { FileImageAccessUrlCommand command = new FileImageAccessUrlCommand( FileUploadDirectory.TIMELINE, FileUploadImageSize.LARGE, + memberId.toString(), activityId.toString(), timeline.getTimelineImageObjectKey() ); @@ -110,10 +111,10 @@ private Timeline findOwnedTimeline(Long activityId, Long timelineId, Long member : new ActivityNotFoundException()); } - private void deleteTimelineImage(Long activityId, String objectKey) { + private void deleteTimelineImage(Long memberId, Long activityId, String objectKey) { try { fileDeletePort.deleteImage( - new FileDeleteCommand(FileUploadDirectory.TIMELINE, activityId.toString(), objectKey)); + new FileDeleteCommand(FileUploadDirectory.TIMELINE, memberId.toString(), activityId.toString(), objectKey)); } catch (BaseException e) { log.warn("Timeline image delete failed. activityId={}, objectKey={}", activityId, objectKey, e); } diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityImageUrlServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityImageUrlServiceTest.java index f2312c9..bea5b4f 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityImageUrlServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityImageUrlServiceTest.java @@ -92,6 +92,7 @@ void setUp() { FileUploadPresignCommand command = new FileUploadPresignCommand( DIRECTORY, IMAGE_SIZE, + MEMBER_ID.toString(), ACTIVITY_ID.toString(), "hike.jpg" ); diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java index dbec626..def85dd 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java @@ -215,8 +215,8 @@ private TimelineImageDeleteTargetProjection imageDeleteTarget(Long activityId, S ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(FileDeleteCommand.class); verify(fileDeletePort, times(2)).deleteImage(commandCaptor.capture()); assertThat(commandCaptor.getAllValues()).containsExactly( - new FileDeleteCommand(FileUploadDirectory.TIMELINE, "10", "10/first.jpg"), - new FileDeleteCommand(FileUploadDirectory.TIMELINE, "11", "11/second.jpg") + new FileDeleteCommand(FileUploadDirectory.TIMELINE, MEMBER_ID.toString(), "10", "10/first.jpg"), + new FileDeleteCommand(FileUploadDirectory.TIMELINE, MEMBER_ID.toString(), "11", "11/second.jpg") ); } diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java index 4d9e390..ade6111 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java @@ -89,6 +89,7 @@ private void stubTransactionTemplateToRunCallback() { FileDeleteCommand command = commandCaptor.getValue(); assertThat(command.directory()).isEqualTo(FileUploadDirectory.TIMELINE); + assertThat(command.memberId()).isEqualTo(memberId.toString()); assertThat(command.activityId()).isEqualTo(activityId.toString()); assertThat(command.objectKey()).isEqualTo(objectKey); } diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java index 495ab86..7f12c6b 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java @@ -4,6 +4,7 @@ public record FileDeleteCommand( FileUploadDirectory directory, + String memberId, String activityId, String objectKey ) { diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileImageAccessUrlCommand.java b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileImageAccessUrlCommand.java index 1f764d8..659be24 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileImageAccessUrlCommand.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileImageAccessUrlCommand.java @@ -6,7 +6,17 @@ public record FileImageAccessUrlCommand( FileUploadDirectory directory, FileUploadImageSize imageSize, - String ownerId, + String memberId, + String activityId, String objectKey ) { + + public FileImageAccessUrlCommand( + FileUploadDirectory directory, + FileUploadImageSize imageSize, + String memberId, + String objectKey + ) { + this(directory, imageSize, memberId, null, objectKey); + } } diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileUploadPresignCommand.java b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileUploadPresignCommand.java index fa3f46f..962ea2f 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileUploadPresignCommand.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileUploadPresignCommand.java @@ -6,7 +6,17 @@ public record FileUploadPresignCommand( FileUploadDirectory directory, FileUploadImageSize imageSize, - String ownerId, + String memberId, + String activityId, String fileName ) { + + public FileUploadPresignCommand( + FileUploadDirectory directory, + FileUploadImageSize imageSize, + String memberId, + String fileName + ) { + this(directory, imageSize, memberId, null, fileName); + } } diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java index 8845e52..3221d47 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java @@ -51,13 +51,13 @@ public void deleteImage(FileDeleteCommand command) { private List resolveObjectKeys(FileDeleteCommand command) { if (command.directory() != FileUploadDirectory.TIMELINE) { return List.of(S3ObjectKeyAssembler.assemble( - command.directory(), null, command.activityId(), command.objectKey() + command.directory(), null, command.memberId(), command.activityId(), command.objectKey() )); } return Arrays.stream(FileUploadImageSize.values()) .map(imageSize -> S3ObjectKeyAssembler.assemble( - command.directory(), imageSize, command.activityId(), command.objectKey() + command.directory(), imageSize, command.memberId(), command.activityId(), command.objectKey() )) .toList(); } diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileImageAccessUrlAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileImageAccessUrlAdapter.java index 8c99327..2a3fa88 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileImageAccessUrlAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileImageAccessUrlAdapter.java @@ -19,7 +19,8 @@ public FileImageAccessUrlResult generateImageUrl(FileImageAccessUrlCommand comma String objectKey = S3ObjectKeyAssembler.assemble( command.directory(), command.imageSize(), - command.ownerId(), + command.memberId(), + command.activityId(), command.objectKey() ); return new FileImageAccessUrlResult("%s/%s".formatted(normalizeEndpoint(properties.endpoint()), objectKey)); diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java index d746cf9..e9c5ef4 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java @@ -10,32 +10,37 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) final class S3ObjectKeyAssembler { - private static final String ACTIVITY_ROOT_PATH = "activity"; private static final String MEMBER_ROOT_PATH = "member"; + private static final String ACTIVITY_SUB_PATH = "activity"; static String assemble( FileUploadDirectory directory, FileUploadImageSize imageSize, - String ownerId, + String memberId, + String activityId, String objectKey ) { return switch (directory) { - case TIMELINE -> "%s/%s/%s/%s/%s".formatted( - ACTIVITY_ROOT_PATH, - ownerId, + case TIMELINE -> "%s/%s/%s/%s/%s/%s/%s".formatted( + MEMBER_ROOT_PATH, + memberId, + ACTIVITY_SUB_PATH, + Objects.requireNonNull(activityId, "activityId must not be null for timeline"), directory.path(), Objects.requireNonNull(imageSize, "imageSize must not be null for timeline").path(), objectKey ); - case RECAP -> "%s/%s/%s/%s".formatted( - ACTIVITY_ROOT_PATH, - ownerId, + case RECAP -> "%s/%s/%s/%s/%s/%s".formatted( + MEMBER_ROOT_PATH, + memberId, + ACTIVITY_SUB_PATH, + Objects.requireNonNull(activityId, "activityId must not be null for recap"), directory.path(), objectKey ); case PROFILE -> "%s/%s/%s/%s".formatted( MEMBER_ROOT_PATH, - ownerId, + memberId, directory.path(), objectKey ); diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java index 2987843..c14eb18 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3PresignAdapter.java @@ -31,7 +31,8 @@ public FileUploadPresignResult generatePutPresignedUrl(FileUploadPresignCommand String presignedObjectKey = S3ObjectKeyAssembler.assemble( command.directory(), command.imageSize(), - command.ownerId(), + command.memberId(), + command.activityId(), objectKey ); String presignedUrl = generatePutPresignedUrl(presignedObjectKey); diff --git a/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java index 193442f..129c4a6 100644 --- a/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java +++ b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java @@ -39,7 +39,7 @@ void setUp() { @Test void deleteImage_타임라인_이미지를_삭제하면_원본과_람다가_생성한_리사이징_사이즈_전부를_삭제_요청한다() { - FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.TIMELINE, "1", "timeline-image.jpg"); + FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.TIMELINE, "9", "1", "timeline-image.jpg"); when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))) .thenReturn(DeleteObjectsResponse.builder().build()); @@ -56,16 +56,16 @@ void setUp() { .toList(); assertThat(objectKeys).containsExactlyInAnyOrder( - "activity/1/timeline/original/timeline-image.jpg", - "activity/1/timeline/small/timeline-image.jpg", - "activity/1/timeline/medium/timeline-image.jpg", - "activity/1/timeline/large/timeline-image.jpg" + "member/9/activity/1/timeline/original/timeline-image.jpg", + "member/9/activity/1/timeline/small/timeline-image.jpg", + "member/9/activity/1/timeline/medium/timeline-image.jpg", + "member/9/activity/1/timeline/large/timeline-image.jpg" ); } @Test void deleteImage_RECAP_이미지를_삭제하면_사이즈_구분_없이_단일_키만_삭제_요청한다() { - FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "1", "recap-image.jpg"); + FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "9", "1", "recap-image.jpg"); when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))) .thenReturn(DeleteObjectsResponse.builder().build()); @@ -78,12 +78,12 @@ void setUp() { .map(ObjectIdentifier::key) .toList(); - assertThat(objectKeys).containsExactly("activity/1/recap/recap-image.jpg"); + assertThat(objectKeys).containsExactly("member/9/activity/1/recap/recap-image.jpg"); } @Test void deleteImage_S3_삭제_요청이_실패하면_FileDeleteException으로_변환한다() { - FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "1", "recap-image.jpg"); + FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "9", "1", "recap-image.jpg"); when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenThrow(new RuntimeException("boom")); assertThatThrownBy(() -> s3FileDeleteAdapter.deleteImage(command)) @@ -92,9 +92,9 @@ void setUp() { @Test void deleteImage_일부_객체_삭제가_실패하면_FileDeleteException을_던진다() { - FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "1", "recap-image.jpg"); + FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "9", "1", "recap-image.jpg"); S3Error error = S3Error.builder() - .key("activity/1/recap/recap-image.jpg") + .key("member/9/activity/1/recap/recap-image.jpg") .code("AccessDenied") .message("Access Denied") .build(); diff --git a/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssemblerTest.java b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssemblerTest.java new file mode 100644 index 0000000..e6591c5 --- /dev/null +++ b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssemblerTest.java @@ -0,0 +1,54 @@ +package org.sopt.routee.external.internal.s3.adapter; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.sopt.routee.external.api.type.FileUploadDirectory; +import org.sopt.routee.external.api.type.FileUploadImageSize; + +class S3ObjectKeyAssemblerTest { + + @Test + void assemble_PROFILE은_member_루트_아래에_키를_조립한다() { + String key = S3ObjectKeyAssembler.assemble(FileUploadDirectory.PROFILE, null, "1", null, "profile.jpg"); + + assertThat(key).isEqualTo("member/1/profile/profile.jpg"); + } + + @Test + void assemble_TIMELINE은_member_루트_아래_activity_경로에_사이즈까지_포함해_키를_조립한다() { + String key = S3ObjectKeyAssembler.assemble( + FileUploadDirectory.TIMELINE, FileUploadImageSize.LARGE, "1", "2", "timeline.jpg" + ); + + assertThat(key).isEqualTo("member/1/activity/2/timeline/large/timeline.jpg"); + } + + @Test + void assemble_RECAP은_member_루트_아래_activity_경로에_키를_조립한다() { + String key = S3ObjectKeyAssembler.assemble(FileUploadDirectory.RECAP, null, "1", "2", "recap.jpg"); + + assertThat(key).isEqualTo("member/1/activity/2/recap/recap.jpg"); + } + + @Test + void assemble_TIMELINE은_imageSize가_없으면_예외를_던진다() { + assertThatThrownBy(() -> + S3ObjectKeyAssembler.assemble(FileUploadDirectory.TIMELINE, null, "1", "2", "timeline.jpg")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void assemble_TIMELINE은_activityId가_없으면_예외를_던진다() { + assertThatThrownBy(() -> + S3ObjectKeyAssembler.assemble(FileUploadDirectory.TIMELINE, FileUploadImageSize.LARGE, "1", null, "timeline.jpg")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void assemble_RECAP은_activityId가_없으면_예외를_던진다() { + assertThatThrownBy(() -> + S3ObjectKeyAssembler.assemble(FileUploadDirectory.RECAP, null, "1", null, "recap.jpg")) + .isInstanceOf(NullPointerException.class); + } +} From a54524faad32e0ffce44abfbcbe656db0d56dd7d Mon Sep 17 00:00:00 2001 From: kymin Date: Tue, 25 Aug 2026 14:59:00 +0900 Subject: [PATCH 30/68] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=EC=97=90=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=90=9C=20=ED=82=A4=EA=B4=80=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ActivityControllerDocs.java | 2 +- .../dto/request/ActivityCompleteRequest.java | 3 - .../internal/entity/activity/Activity.java | 4 + .../repository/TimelineRepository.java | 13 +- .../TimelineImageDeleteTargetProjection.java | 8 -- .../internal/service/ActivityService.java | 37 +++-- .../internal/service/TimelineService.java | 17 +++ .../dto/command/CompleteActivityCommand.java | 1 - .../ActivityCreationTransactionResult.java | 4 +- .../internal/service/ActivityServiceTest.java | 136 ++++++++++-------- .../internal/service/TimelineServiceTest.java | 112 +++++++++++++-- .../command/FileDeleteDirectoryCommand.java | 7 + .../external/api/port/FileDeletePort.java | 3 + .../s3/adapter/S3FileDeleteAdapter.java | 41 ++++++ .../s3/adapter/S3ObjectKeyAssembler.java | 4 + .../s3/adapter/S3FileDeleteAdapterTest.java | 91 ++++++++++++ .../s3/adapter/S3ObjectKeyAssemblerTest.java | 7 + 17 files changed, 379 insertions(+), 111 deletions(-) delete mode 100644 routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/projection/TimelineImageDeleteTargetProjection.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteDirectoryCommand.java diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java index 29cb9fd..7557142 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java @@ -252,7 +252,7 @@ ResponseEntity> complete( @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, content = @Content(schema = @Schema(implementation = ActivityCompleteRequest.class), examples = @ExampleObject(value = """ - {"title":"북한산 기록","distance":5400,"durationSec":3600,"maxElevation":836,"mapImageUrl":"https://example.com/map.png","coverImageObjectKey":"activity/1/cover.png","track":[{"latitude":37.566,"longitude":126.978,"elevation":20,"pointIndex":0},{"latitude":37.567,"longitude":126.979,"elevation":25,"pointIndex":10}],"endedAt":"2026-07-07T16:30:00"}"""))) + {"title":"북한산 기록","distance":5400,"durationSec":3600,"maxElevation":836,"mapImageUrl":"https://example.com/map.png","track":[{"latitude":37.566,"longitude":126.978,"elevation":20,"pointIndex":0},{"latitude":37.567,"longitude":126.979,"elevation":25,"pointIndex":10}],"endedAt":"2026-07-07T16:30:00"}"""))) @Valid @RequestBody ActivityCompleteRequest request ); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/ActivityCompleteRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/ActivityCompleteRequest.java index 4fb49d9..81f48b4 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/ActivityCompleteRequest.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/ActivityCompleteRequest.java @@ -37,8 +37,6 @@ public record ActivityCompleteRequest( @NotBlank(message = "mapImageUrl은 필수입니다.") String mapImageUrl, - String coverImageObjectKey, - @NotEmpty(message = "track은 필수입니다.") @Valid List track, @@ -56,7 +54,6 @@ public CompleteActivityCommand toCommand(Long activityId, Long memberId, ZoneId durationSec, maxElevation, mapImageUrl, - coverImageObjectKey, track.stream().map(ActivityTrackPoint::toTrackPoint).toList(), endedAt ); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java index 8134f8e..d1e5e42 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java @@ -87,6 +87,10 @@ public void updateTitle(String title) { this.title = title; } + public void updateCoverImageObjectKey(String coverImageObjectKey) { + this.coverImageObjectKey = coverImageObjectKey; + } + public void updateCompletedData( String title, Integer distance, diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java index b764ad6..2a87e08 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java @@ -5,7 +5,6 @@ import org.sopt.routee.activity.internal.entity.timeline.Timeline; import org.sopt.routee.activity.internal.entity.timeline.TimelineStatus; -import org.sopt.routee.activity.internal.repository.projection.TimelineImageDeleteTargetProjection; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -21,17 +20,11 @@ public interface TimelineRepository extends JpaRepository { List findByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc(Long activityId, TimelineStatus timelineStatus); - List findByActivityIdInAndTimelineStatusOrderByCreatedAtAsc(List activityIds, + Optional findFirstByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc(Long activityId, TimelineStatus timelineStatus); - @Query(""" - SELECT t.activity.id AS activityId, t.timelineImageObjectKey AS objectKey - FROM Timeline t - WHERE t.activity.id IN :activityIds - """) - List findImageDeleteTargetsByActivityIdIn( - @Param("activityIds") List activityIds - ); + List findByActivityIdInAndTimelineStatusOrderByCreatedAtAsc(List activityIds, + TimelineStatus timelineStatus); @Modifying @Query("DELETE FROM Timeline t WHERE t.activity.memberId = :memberId") diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/projection/TimelineImageDeleteTargetProjection.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/projection/TimelineImageDeleteTargetProjection.java deleted file mode 100644 index 75d7e84..0000000 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/projection/TimelineImageDeleteTargetProjection.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.sopt.routee.activity.internal.repository.projection; - -public interface TimelineImageDeleteTargetProjection { - - Long getActivityId(); - - String getObjectKey(); -} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index 9410acd..eb49513 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -26,7 +26,6 @@ import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.repository.RouteRepository; import org.sopt.routee.activity.internal.repository.TimelineRepository; -import org.sopt.routee.activity.internal.repository.projection.TimelineImageDeleteTargetProjection; import org.sopt.routee.activity.internal.service.dto.command.CompleteActivityCommand; import org.sopt.routee.activity.internal.service.dto.command.CreateActivityCommand; import org.sopt.routee.activity.internal.service.dto.command.GetActivityRecapCommand; @@ -50,7 +49,7 @@ import org.sopt.routee.activity.internal.service.dto.vo.TrackPoint; import org.sopt.routee.activity.internal.service.validator.ActivityImageFileNameValidator; import org.sopt.routee.exception.BaseException; -import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.command.FileDeleteDirectoryCommand; import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; import org.sopt.routee.external.api.command.FileUploadPresignCommand; import org.sopt.routee.external.api.port.FileDeletePort; @@ -100,9 +99,9 @@ public CreateActivityResult create(CreateActivityCommand command) { log.info("Activity created. activityId={}, memberId={}", transactionResult.result().activityId(), command.memberId()); - if (!transactionResult.imageDeleteTargets().isEmpty()) { + if (!transactionResult.deletedActivityIds().isEmpty()) { Thread.startVirtualThread( - () -> deleteTimelineImages(command.memberId(), transactionResult.imageDeleteTargets())); + () -> deleteActivityImageDirectories(command.memberId(), transactionResult.deletedActivityIds())); } return transactionResult.result(); @@ -116,10 +115,7 @@ private ActivityCreationTransactionResult createInTransaction(CreateActivityComm ACTIVE_STATUSES ); - List imageDeleteTargets = List.of(); if (!activeActivityIds.isEmpty()) { - imageDeleteTargets = timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds); - timelineRepository.deleteByActivityIdIn(activeActivityIds); activityRepository.deleteByIdIn(activeActivityIds); } @@ -131,22 +127,17 @@ private ActivityCreationTransactionResult createInTransaction(CreateActivityComm return new ActivityCreationTransactionResult( new CreateActivityResult(savedActivity.getId(), title), - imageDeleteTargets + activeActivityIds ); } - private void deleteTimelineImages(Long memberId, List targets) { - for (TimelineImageDeleteTargetProjection target : targets) { + private void deleteActivityImageDirectories(Long memberId, List activityIds) { + for (Long activityId : activityIds) { try { - fileDeletePort.deleteImage(new FileDeleteCommand( - FileUploadDirectory.TIMELINE, - memberId.toString(), - target.getActivityId().toString(), - target.getObjectKey() - )); + fileDeletePort.deleteDirectory( + new FileDeleteDirectoryCommand(memberId.toString(), activityId.toString())); } catch (BaseException e) { - log.warn("Timeline image delete failed. activityId={}, objectKey={}", - target.getActivityId(), target.getObjectKey(), e); + log.warn("Activity image directory delete failed. activityId={}", activityId, e); } } } @@ -209,6 +200,7 @@ public void complete(CompleteActivityCommand command) { .orElseThrow(ActivityNotFoundException::new); Instant endedAt = TimeZoneUtils.toUtcInstantTime(command.endedAt(), command.timeZone()); + String coverImageObjectKey = resolveCoverImageObjectKey(command.activityId()); activity.updateCompletedData( command.title(), @@ -216,7 +208,7 @@ public void complete(CompleteActivityCommand command) { command.durationSec(), command.maxElevation(), command.mapImageUrl(), - command.coverImageObjectKey(), + coverImageObjectKey, ActivityMapper.toLineString(command.track()), endedAt ); @@ -332,6 +324,13 @@ timeline, generateTimelineImageUrl(memberId, activityId, timeline, FileUploadIma return new ActivityTrackResult(activityId, trackPointResults, timelineMarkers); } + private String resolveCoverImageObjectKey(Long activityId) { + return timelineRepository + .findFirstByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc(activityId, TimelineStatus.SUCCESSFUL_CREATED) + .map(Timeline::getTimelineImageObjectKey) + .orElse(null); + } + private String generateThumbnailUrl(Activity activity) { if (activity.getCoverImageObjectKey() == null) { return null; diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index 5ea36c8..6b243a5 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -5,6 +5,7 @@ import org.sopt.routee.activity.internal.entity.activity.Activity; import org.sopt.routee.activity.internal.entity.timeline.Timeline; +import org.sopt.routee.activity.internal.entity.timeline.TimelineStatus; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; import org.sopt.routee.activity.internal.exception.TimelineNotFoundException; import org.sopt.routee.activity.internal.mapper.TimelineMapper; @@ -69,6 +70,7 @@ public void delete(Long activityId, Long timelineId, Long memberId) { Timeline ownedTimeline = findOwnedTimeline(activityId, timelineId, memberId); timelineRepository.delete(ownedTimeline); + refreshCoverImageIfDeleted(ownedTimeline); return ownedTimeline; }); @@ -104,6 +106,21 @@ private String generateImageUrl(Long memberId, Long activityId, Timeline timelin return fileImageAccessUrlPort.generateImageUrl(command).imageUrl(); } + private void refreshCoverImageIfDeleted(Timeline deletedTimeline) { + Activity activity = deletedTimeline.getActivity(); + if (!deletedTimeline.getTimelineImageObjectKey().equals(activity.getCoverImageObjectKey())) { + return; + } + + String newCoverImageObjectKey = timelineRepository + .findFirstByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc( + activity.getId(), TimelineStatus.SUCCESSFUL_CREATED) + .map(Timeline::getTimelineImageObjectKey) + .orElse(null); + + activity.updateCoverImageObjectKey(newCoverImageObjectKey); + } + private Timeline findOwnedTimeline(Long activityId, Long timelineId, Long memberId) { return timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId) .orElseThrow(() -> activityRepository.existsByIdAndMemberId(activityId, memberId) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/CompleteActivityCommand.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/CompleteActivityCommand.java index f486d97..83d71ae 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/CompleteActivityCommand.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/CompleteActivityCommand.java @@ -15,7 +15,6 @@ public record CompleteActivityCommand( Integer durationSec, Integer maxElevation, String mapImageUrl, - String coverImageObjectKey, List track, LocalDateTime endedAt ) { diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java index d6becc3..b357891 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java @@ -2,10 +2,8 @@ import java.util.List; -import org.sopt.routee.activity.internal.repository.projection.TimelineImageDeleteTargetProjection; - public record ActivityCreationTransactionResult( CreateActivityResult result, - List imageDeleteTargets + List deletedActivityIds ) { } diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java index def85dd..c27e464 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java @@ -9,6 +9,7 @@ import java.time.ZoneId; import java.util.EnumSet; import java.util.List; +import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -23,23 +24,27 @@ import org.sopt.routee.activity.internal.entity.activity.Activity; import org.sopt.routee.activity.internal.entity.activity.ActivityStatus; import org.sopt.routee.activity.internal.entity.activity.ActivityType; +import org.sopt.routee.activity.internal.entity.timeline.Timeline; +import org.sopt.routee.activity.internal.entity.timeline.TimelineStatus; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.repository.RouteRepository; import org.sopt.routee.activity.internal.repository.TimelineRepository; -import org.sopt.routee.activity.internal.repository.projection.TimelineImageDeleteTargetProjection; +import org.sopt.routee.activity.internal.service.dto.command.CompleteActivityCommand; import org.sopt.routee.activity.internal.service.dto.command.CreateActivityCommand; import org.sopt.routee.activity.internal.service.dto.result.ActivityCreationTransactionResult; import org.sopt.routee.activity.internal.service.dto.result.CreateActivityResult; +import org.sopt.routee.activity.internal.service.dto.vo.TrackPoint; import org.sopt.routee.activity.internal.service.validator.ActivityImageFileNameValidator; -import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.command.FileDeleteDirectoryCommand; import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.sopt.routee.external.api.port.FileUploadPresignPort; -import org.sopt.routee.external.api.type.FileUploadDirectory; +import org.sopt.routee.external.api.result.FileImageAccessUrlResult; import org.springframework.context.ApplicationEventPublisher; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; @ExtendWith(MockitoExtension.class) @@ -125,11 +130,22 @@ private Activity savedActivity(Long activityId, ActivityType activityType) { .build(); } - private TimelineImageDeleteTargetProjection imageDeleteTarget(Long activityId, String objectKey) { - TimelineImageDeleteTargetProjection projection = mock(TimelineImageDeleteTargetProjection.class); - when(projection.getActivityId()).thenReturn(activityId); - when(projection.getObjectKey()).thenReturn(objectKey); - return projection; + private CompleteActivityCommand completeCommand(Long activityId) { + return new CompleteActivityCommand( + activityId, + MEMBER_ID, + ZoneId.of("Asia/Seoul"), + "북한산 기록", + 5400, + 3600, + 836, + "https://example.com/map.png", + List.of( + new TrackPoint(37.566, 126.978, 20, 0), + new TrackPoint(37.567, 126.979, 25, 10) + ), + LocalDateTime.of(2026, 7, 7, 16, 30) + ); } @Test @@ -156,84 +172,52 @@ private TimelineImageDeleteTargetProjection imageDeleteTarget(Long activityId, S assertThat(activity.getActivityStatus()).isEqualTo(ActivityStatus.ACTIVITY_IN_PROGRESS); assertThat(activity.getMemberId()).isEqualTo(MEMBER_ID); - verify(timelineRepository, never()).findImageDeleteTargetsByActivityIdIn(anyList()); verify(timelineRepository, never()).deleteByActivityIdIn(anyList()); verify(activityRepository, never()).deleteByIdIn(anyList()); verifyNoInteractions(fileDeletePort); } @Test - void create_활성_활동에_타임라인이_없어도_기존_활동을_폐기하고_새_활동을_생성한다() { - List activeActivityIds = List.of(10L, 11L); - when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) - .thenReturn(activeActivityIds); - when(timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds)).thenReturn(List.of()); - when(activityRepository.save(any(Activity.class))).thenReturn(savedActivity(20L, ActivityType.HIKING)); - stubTransactionTemplateToRunCallback(); - - CreateActivityResult result = activityService.create(createCommand(ActivityType.HIKING)); - - assertThat(result.activityId()).isEqualTo(20L); - InOrder inOrder = inOrder(activityRepository, timelineRepository); - inOrder.verify(activityRepository).findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES); - inOrder.verify(timelineRepository).findImageDeleteTargetsByActivityIdIn(activeActivityIds); - inOrder.verify(timelineRepository).deleteByActivityIdIn(activeActivityIds); - inOrder.verify(activityRepository).deleteByIdIn(activeActivityIds); - inOrder.verify(activityRepository).save(any(Activity.class)); - verifyNoInteractions(fileDeletePort); - } - - @Test - void create_타임라인_이미지가_있으면_DB_삭제_후_S3_삭제를_요청한다() throws InterruptedException { + void create_활성_활동이_있으면_기존_활동을_폐기하고_새_활동을_생성한_뒤_이미지_디렉터리_삭제를_요청한다() throws InterruptedException { List activeActivityIds = List.of(10L, 11L); - List imageDeleteTargets = List.of( - imageDeleteTarget(10L, "10/first.jpg"), - imageDeleteTarget(11L, "11/second.jpg") - ); when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) .thenReturn(activeActivityIds); - when(timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds)).thenReturn(imageDeleteTargets); when(activityRepository.save(any(Activity.class))).thenReturn(savedActivity(20L, ActivityType.RUNNING)); stubTransactionTemplateToRunCallback(); - CountDownLatch latch = new CountDownLatch(imageDeleteTargets.size()); + CountDownLatch latch = new CountDownLatch(activeActivityIds.size()); doAnswer(invocation -> { latch.countDown(); return null; - }).when(fileDeletePort).deleteImage(any()); + }).when(fileDeletePort).deleteDirectory(any()); - activityService.create(createCommand(ActivityType.RUNNING)); + CreateActivityResult result = activityService.create(createCommand(ActivityType.RUNNING)); + assertThat(result.activityId()).isEqualTo(20L); InOrder inOrder = inOrder(activityRepository, timelineRepository); inOrder.verify(activityRepository).findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES); - inOrder.verify(timelineRepository).findImageDeleteTargetsByActivityIdIn(activeActivityIds); inOrder.verify(timelineRepository).deleteByActivityIdIn(activeActivityIds); inOrder.verify(activityRepository).deleteByIdIn(activeActivityIds); inOrder.verify(activityRepository).save(any(Activity.class)); assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); - ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(FileDeleteCommand.class); - verify(fileDeletePort, times(2)).deleteImage(commandCaptor.capture()); + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(FileDeleteDirectoryCommand.class); + verify(fileDeletePort, times(2)).deleteDirectory(commandCaptor.capture()); assertThat(commandCaptor.getAllValues()).containsExactly( - new FileDeleteCommand(FileUploadDirectory.TIMELINE, MEMBER_ID.toString(), "10", "10/first.jpg"), - new FileDeleteCommand(FileUploadDirectory.TIMELINE, MEMBER_ID.toString(), "11", "11/second.jpg") + new FileDeleteDirectoryCommand(MEMBER_ID.toString(), "10"), + new FileDeleteDirectoryCommand(MEMBER_ID.toString(), "11") ); } @Test void create_일부_S3_삭제가_실패해도_나머지_삭제를_계속하고_예외를_전파하지_않는다() throws InterruptedException { List activeActivityIds = List.of(10L, 11L); - List imageDeleteTargets = List.of( - imageDeleteTarget(10L, "10/first.jpg"), - imageDeleteTarget(11L, "11/second.jpg") - ); when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) .thenReturn(activeActivityIds); - when(timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds)).thenReturn(imageDeleteTargets); when(activityRepository.save(any(Activity.class))).thenReturn(savedActivity(20L, ActivityType.RUNNING)); stubTransactionTemplateToRunCallback(); - CountDownLatch latch = new CountDownLatch(imageDeleteTargets.size()); + CountDownLatch latch = new CountDownLatch(activeActivityIds.size()); AtomicInteger invocationCount = new AtomicInteger(); doAnswer(invocation -> { latch.countDown(); @@ -241,29 +225,69 @@ private TimelineImageDeleteTargetProjection imageDeleteTarget(Long activityId, S throw new ActivityNotFoundException(); } return null; - }).when(fileDeletePort).deleteImage(any()); + }).when(fileDeletePort).deleteDirectory(any()); CreateActivityResult result = activityService.create(createCommand(ActivityType.RUNNING)); assertThat(result.activityId()).isEqualTo(20L); assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); - verify(fileDeletePort, times(2)).deleteImage(any()); + verify(fileDeletePort, times(2)).deleteDirectory(any()); } @Test void create_새_활동_저장에_실패하면_S3_삭제를_요청하지_않는다() { List activeActivityIds = List.of(10L); - TimelineImageDeleteTargetProjection imageDeleteTarget = mock(TimelineImageDeleteTargetProjection.class); when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) .thenReturn(activeActivityIds); - when(timelineRepository.findImageDeleteTargetsByActivityIdIn(activeActivityIds)) - .thenReturn(List.of(imageDeleteTarget)); when(activityRepository.save(any(Activity.class))).thenThrow(new ActivityNotFoundException()); stubTransactionTemplateToRunCallback(); assertThatThrownBy(() -> activityService.create(createCommand(ActivityType.RUNNING))) .isInstanceOf(ActivityNotFoundException.class); - verify(fileDeletePort, never()).deleteImage(any()); + verify(fileDeletePort, never()).deleteDirectory(any()); + } + + @Test + void complete_성공적으로_생성된_타임라인이_있으면_트랙포인트가_가장_작은_이미지를_커버로_설정한다() { + Long activityId = 1L; + Activity activity = Activity.builder().id(activityId).memberId(MEMBER_ID).startedAt(Instant.now()).build(); + Timeline coverTimeline = Timeline.builder().id(100L).timelineImageObjectKey("smallest.jpg").build(); + + when(activityRepository.findByIdAndMemberId(activityId, MEMBER_ID)).thenReturn(Optional.of(activity)); + when(timelineRepository.findFirstByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc( + activityId, TimelineStatus.SUCCESSFUL_CREATED)) + .thenReturn(Optional.of(coverTimeline)); + when(fileImageAccessUrlPort.generateImageUrl(any())) + .thenReturn(new FileImageAccessUrlResult("https://image-url")); + + completeWithSynchronizationActive(completeCommand(activityId)); + + assertThat(activity.getCoverImageObjectKey()).isEqualTo("smallest.jpg"); + } + + @Test + void complete_성공적으로_생성된_타임라인이_없으면_커버이미지를_null로_설정한다() { + Long activityId = 1L; + Activity activity = Activity.builder().id(activityId).memberId(MEMBER_ID).startedAt(Instant.now()).build(); + + when(activityRepository.findByIdAndMemberId(activityId, MEMBER_ID)).thenReturn(Optional.of(activity)); + when(timelineRepository.findFirstByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc( + activityId, TimelineStatus.SUCCESSFUL_CREATED)) + .thenReturn(Optional.empty()); + + completeWithSynchronizationActive(completeCommand(activityId)); + + assertThat(activity.getCoverImageObjectKey()).isNull(); + verifyNoInteractions(fileImageAccessUrlPort); + } + + private void completeWithSynchronizationActive(CompleteActivityCommand command) { + TransactionSynchronizationManager.initSynchronization(); + try { + activityService.complete(command); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } } } diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java index ade6111..b278272 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java @@ -14,7 +14,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.routee.activity.internal.entity.activity.Activity; import org.sopt.routee.activity.internal.entity.timeline.Timeline; +import org.sopt.routee.activity.internal.entity.timeline.TimelineStatus; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; import org.sopt.routee.activity.internal.exception.TimelineNotFoundException; import org.sopt.routee.activity.internal.repository.ActivityRepository; @@ -57,17 +59,23 @@ private void stubTransactionTemplateToRunCallback() { }); } + private Timeline timelineWithActivity(Long timelineId, String objectKey, Activity activity) { + return Timeline.builder() + .id(timelineId) + .timelineImageObjectKey(objectKey) + .activity(activity) + .build(); + } + @Test void delete_타임라인을_삭제하면_이미지_키로_S3_삭제를_요청한다() throws InterruptedException { Long activityId = 1L; Long timelineId = 10L; Long memberId = 100L; String objectKey = "timeline-image.jpg"; - - Timeline timeline = Timeline.builder() - .id(timelineId) - .timelineImageObjectKey(objectKey) - .build(); + Activity activity = Activity.builder().id(activityId).memberId(memberId) + .coverImageObjectKey("other-cover.jpg").build(); + Timeline timeline = timelineWithActivity(timelineId, objectKey, activity); when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) .thenReturn(Optional.of(timeline)); @@ -100,11 +108,9 @@ private void stubTransactionTemplateToRunCallback() { Long timelineId = 10L; Long memberId = 100L; String objectKey = "timeline-image.jpg"; - - Timeline timeline = Timeline.builder() - .id(timelineId) - .timelineImageObjectKey(objectKey) - .build(); + Activity activity = Activity.builder().id(activityId).memberId(memberId) + .coverImageObjectKey("other-cover.jpg").build(); + Timeline timeline = timelineWithActivity(timelineId, objectKey, activity); when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) .thenReturn(Optional.of(timeline)); @@ -161,6 +167,92 @@ private void stubTransactionTemplateToRunCallback() { verify(fileDeletePort, never()).deleteImage(any()); } + @Test + void delete_삭제된_타임라인이_커버이미지가_아니면_커버이미지를_재계산하지_않는다() throws InterruptedException { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + Activity activity = Activity.builder().id(activityId).memberId(memberId) + .coverImageObjectKey("cover.jpg").build(); + Timeline timeline = timelineWithActivity(timelineId, "not-cover.jpg", activity); + + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.of(timeline)); + stubTransactionTemplateToRunCallback(); + + CountDownLatch latch = new CountDownLatch(1); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(fileDeletePort).deleteImage(any()); + + timelineService.delete(activityId, timelineId, memberId); + + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + verify(timelineRepository, never()) + .findFirstByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc(any(), any()); + assertThat(activity.getCoverImageObjectKey()).isEqualTo("cover.jpg"); + } + + @Test + void delete_삭제된_타임라인이_커버이미지면_남은_타임라인_중_트랙포인트가_가장_작은_것으로_갱신한다() throws InterruptedException { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + String deletedObjectKey = "cover.jpg"; + Activity activity = Activity.builder().id(activityId).memberId(memberId) + .coverImageObjectKey(deletedObjectKey).build(); + Timeline timeline = timelineWithActivity(timelineId, deletedObjectKey, activity); + Timeline remainingTimeline = Timeline.builder().id(11L).timelineImageObjectKey("next-smallest.jpg").build(); + + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.of(timeline)); + when(timelineRepository.findFirstByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc( + activityId, TimelineStatus.SUCCESSFUL_CREATED)) + .thenReturn(Optional.of(remainingTimeline)); + stubTransactionTemplateToRunCallback(); + + CountDownLatch latch = new CountDownLatch(1); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(fileDeletePort).deleteImage(any()); + + timelineService.delete(activityId, timelineId, memberId); + + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(activity.getCoverImageObjectKey()).isEqualTo("next-smallest.jpg"); + } + + @Test + void delete_삭제된_타임라인이_커버이미지이고_남은_타임라인이_없으면_커버이미지를_비운다() throws InterruptedException { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + String deletedObjectKey = "cover.jpg"; + Activity activity = Activity.builder().id(activityId).memberId(memberId) + .coverImageObjectKey(deletedObjectKey).build(); + Timeline timeline = timelineWithActivity(timelineId, deletedObjectKey, activity); + + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.of(timeline)); + when(timelineRepository.findFirstByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc( + activityId, TimelineStatus.SUCCESSFUL_CREATED)) + .thenReturn(Optional.empty()); + stubTransactionTemplateToRunCallback(); + + CountDownLatch latch = new CountDownLatch(1); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(fileDeletePort).deleteImage(any()); + + timelineService.delete(activityId, timelineId, memberId); + + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(activity.getCoverImageObjectKey()).isNull(); + } + @Test void updateTitle_소유한_타임라인이면_제목을_수정한다() { Long activityId = 1L; diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteDirectoryCommand.java b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteDirectoryCommand.java new file mode 100644 index 0000000..30cab9e --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteDirectoryCommand.java @@ -0,0 +1,7 @@ +package org.sopt.routee.external.api.command; + +public record FileDeleteDirectoryCommand( + String memberId, + String activityId +) { +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/port/FileDeletePort.java b/routee-external/src/main/java/org/sopt/routee/external/api/port/FileDeletePort.java index 82eb10a..be7868f 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/port/FileDeletePort.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/port/FileDeletePort.java @@ -1,8 +1,11 @@ package org.sopt.routee.external.api.port; import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.command.FileDeleteDirectoryCommand; public interface FileDeletePort { void deleteImage(FileDeleteCommand command); + + void deleteDirectory(FileDeleteDirectoryCommand command); } diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java index 3221d47..6b7b5ba 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java @@ -4,6 +4,7 @@ import java.util.List; import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.command.FileDeleteDirectoryCommand; import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.type.FileUploadDirectory; import org.sopt.routee.external.api.type.FileUploadImageSize; @@ -16,6 +17,8 @@ import software.amazon.awssdk.services.s3.model.Delete; import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; import software.amazon.awssdk.services.s3.model.ObjectIdentifier; @Component @@ -31,6 +34,44 @@ public void deleteImage(FileDeleteCommand command) { .map(objectKey -> ObjectIdentifier.builder().key(objectKey).build()) .toList(); + deleteObjects(objectIdentifiers); + } + + @Override + public void deleteDirectory(FileDeleteDirectoryCommand command) { + String prefix = S3ObjectKeyAssembler.assembleActivityDirectoryPrefix(command.memberId(), command.activityId()); + + String continuationToken = null; + do { + ListObjectsV2Response listResponse = listObjects(prefix, continuationToken); + + List objectIdentifiers = listResponse.contents().stream() + .map(s3Object -> ObjectIdentifier.builder().key(s3Object.key()).build()) + .toList(); + + if (!objectIdentifiers.isEmpty()) { + deleteObjects(objectIdentifiers); + } + + continuationToken = listResponse.isTruncated() ? listResponse.nextContinuationToken() : null; + } while (continuationToken != null); + } + + private ListObjectsV2Response listObjects(String prefix, String continuationToken) { + ListObjectsV2Request request = ListObjectsV2Request.builder() + .bucket(properties.bucket()) + .prefix(prefix) + .continuationToken(continuationToken) + .build(); + + try { + return s3Client.listObjectsV2(request); + } catch (RuntimeException e) { + throw new FileDeleteException(e); + } + } + + private void deleteObjects(List objectIdentifiers) { DeleteObjectsRequest request = DeleteObjectsRequest.builder() .bucket(properties.bucket()) .delete(Delete.builder().objects(objectIdentifiers).build()) diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java index e9c5ef4..3e180dd 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java @@ -46,4 +46,8 @@ static String assemble( ); }; } + + static String assembleActivityDirectoryPrefix(String memberId, String activityId) { + return "%s/%s/%s/%s/".formatted(MEMBER_ROOT_PATH, memberId, ACTIVITY_SUB_PATH, activityId); + } } diff --git a/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java index 129c4a6..ac4a306 100644 --- a/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java +++ b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java @@ -13,6 +13,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.command.FileDeleteDirectoryCommand; import org.sopt.routee.external.api.type.FileUploadDirectory; import org.sopt.routee.external.internal.s3.config.S3Properties; import org.sopt.routee.external.internal.s3.exception.FileDeleteException; @@ -20,8 +21,11 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; import software.amazon.awssdk.services.s3.model.ObjectIdentifier; import software.amazon.awssdk.services.s3.model.S3Error; +import software.amazon.awssdk.services.s3.model.S3Object; @ExtendWith(MockitoExtension.class) class S3FileDeleteAdapterTest { @@ -104,4 +108,91 @@ void setUp() { assertThatThrownBy(() -> s3FileDeleteAdapter.deleteImage(command)) .isInstanceOf(FileDeleteException.class); } + + @Test + void deleteDirectory_activity_디렉터리_아래_모든_객체를_삭제_요청한다() { + FileDeleteDirectoryCommand command = new FileDeleteDirectoryCommand("9", "1"); + when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))) + .thenReturn(ListObjectsV2Response.builder() + .contents( + S3Object.builder().key("member/9/activity/1/timeline/original/a.jpg").build(), + S3Object.builder().key("member/9/activity/1/timeline/large/a.jpg").build() + ) + .isTruncated(false) + .build()); + when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))) + .thenReturn(DeleteObjectsResponse.builder().build()); + + s3FileDeleteAdapter.deleteDirectory(command); + + ArgumentCaptor listRequestCaptor = ArgumentCaptor.forClass(ListObjectsV2Request.class); + verify(s3Client).listObjectsV2(listRequestCaptor.capture()); + assertThat(listRequestCaptor.getValue().prefix()).isEqualTo("member/9/activity/1/"); + + ArgumentCaptor deleteRequestCaptor = ArgumentCaptor.forClass(DeleteObjectsRequest.class); + verify(s3Client).deleteObjects(deleteRequestCaptor.capture()); + List deletedKeys = deleteRequestCaptor.getValue().delete().objects().stream() + .map(ObjectIdentifier::key) + .toList(); + assertThat(deletedKeys).containsExactlyInAnyOrder( + "member/9/activity/1/timeline/original/a.jpg", + "member/9/activity/1/timeline/large/a.jpg" + ); + } + + @Test + void deleteDirectory_대상_객체가_없으면_삭제_요청을_보내지_않는다() { + FileDeleteDirectoryCommand command = new FileDeleteDirectoryCommand("9", "1"); + when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))) + .thenReturn(ListObjectsV2Response.builder().isTruncated(false).build()); + + s3FileDeleteAdapter.deleteDirectory(command); + + verify(s3Client, never()).deleteObjects(any(DeleteObjectsRequest.class)); + } + + @Test + void deleteDirectory_결과가_여러_페이지면_모두_순회하며_삭제한다() { + FileDeleteDirectoryCommand command = new FileDeleteDirectoryCommand("9", "1"); + when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))) + .thenReturn(ListObjectsV2Response.builder() + .contents(S3Object.builder().key("member/9/activity/1/timeline/original/a.jpg").build()) + .isTruncated(true) + .nextContinuationToken("token-1") + .build()) + .thenReturn(ListObjectsV2Response.builder() + .contents(S3Object.builder().key("member/9/activity/1/timeline/original/b.jpg").build()) + .isTruncated(false) + .build()); + when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))) + .thenReturn(DeleteObjectsResponse.builder().build()); + + s3FileDeleteAdapter.deleteDirectory(command); + + verify(s3Client, times(2)).listObjectsV2(any(ListObjectsV2Request.class)); + verify(s3Client, times(2)).deleteObjects(any(DeleteObjectsRequest.class)); + } + + @Test + void deleteDirectory_목록_조회가_실패하면_FileDeleteException으로_변환한다() { + FileDeleteDirectoryCommand command = new FileDeleteDirectoryCommand("9", "1"); + when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenThrow(new RuntimeException("boom")); + + assertThatThrownBy(() -> s3FileDeleteAdapter.deleteDirectory(command)) + .isInstanceOf(FileDeleteException.class); + } + + @Test + void deleteDirectory_삭제_요청이_실패하면_FileDeleteException으로_변환한다() { + FileDeleteDirectoryCommand command = new FileDeleteDirectoryCommand("9", "1"); + when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))) + .thenReturn(ListObjectsV2Response.builder() + .contents(S3Object.builder().key("member/9/activity/1/timeline/original/a.jpg").build()) + .isTruncated(false) + .build()); + when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenThrow(new RuntimeException("boom")); + + assertThatThrownBy(() -> s3FileDeleteAdapter.deleteDirectory(command)) + .isInstanceOf(FileDeleteException.class); + } } diff --git a/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssemblerTest.java b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssemblerTest.java index e6591c5..ce7fd94 100644 --- a/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssemblerTest.java +++ b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssemblerTest.java @@ -51,4 +51,11 @@ class S3ObjectKeyAssemblerTest { S3ObjectKeyAssembler.assemble(FileUploadDirectory.RECAP, null, "1", null, "recap.jpg")) .isInstanceOf(NullPointerException.class); } + + @Test + void assembleActivityDirectoryPrefix_member_루트_아래_activity_디렉터리_프리픽스를_조립한다() { + String prefix = S3ObjectKeyAssembler.assembleActivityDirectoryPrefix("1", "2"); + + assertThat(prefix).isEqualTo("member/1/activity/2/"); + } } From 81ea16b2d29afae0253536cdb7afc08a576faea6 Mon Sep 17 00:00:00 2001 From: kymin Date: Tue, 25 Aug 2026 15:14:21 +0900 Subject: [PATCH 31/68] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20object=20key=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routee/member/internal/entity/Member.java | 8 +++---- .../member/internal/mapper/MemberMapper.java | 12 +++++----- .../internal/service/MemberService.java | 22 ++++++++++++------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java index a1acbad..73c402c 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/Member.java @@ -38,8 +38,8 @@ public class Member extends BaseEntity { @Column(name = "nickname", nullable = false) private String nickname; - @Column(name = "profile_image_url") - private String profileImageUrl; + @Column(name = "profile_image_object_key") + private String profileImageObjectKey; @Column(name = "oauth_id", nullable = false, updatable = false) private String oauthId; @@ -63,7 +63,7 @@ public void updateNickname(String nickname) { this.nickname = nickname; } - public void updateProfileImageUrl(String profileImageUrl) { - this.profileImageUrl = profileImageUrl; + public void updateProfileImageObjectKey(String profileImageObjectKey) { + this.profileImageObjectKey = profileImageObjectKey; } } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java index 01a3124..06daad5 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java @@ -38,29 +38,29 @@ public static TokenClaimsResult toTokenClaimsResult(Member member) { return new TokenClaimsResult(member.getId(), member.getRole().name()); } - public static MemberInfoResult toMemberInfoResult(Member member, ZoneId zoneId) { + public static MemberInfoResult toMemberInfoResult(Member member, String profileImageUrl, ZoneId zoneId) { LocalDate joinDate = TimeZoneUtils.toLocalDate(member.getCreatedAt(), zoneId); long daysSinceJoining = ChronoUnit.DAYS.between(joinDate, LocalDate.now(zoneId)); return new MemberInfoResult( member.getNickname(), - member.getProfileImageUrl(), + profileImageUrl, joinDate, daysSinceJoining + 1, member.getTotalActivityCount() ); } - public static MemberProfileResult toMemberProfileResult(Member member) { - return new MemberProfileResult(member.getNickname(), member.getProfileImageUrl()); + public static MemberProfileResult toMemberProfileResult(Member member, String profileImageUrl) { + return new MemberProfileResult(member.getNickname(), profileImageUrl); } public static UpdateNicknameResult toUpdateNicknameResult(Member member) { return new UpdateNicknameResult(member.getNickname()); } - public static UpdateProfileImageResult toUpdateProfileImageResult(Member member) { - return new UpdateProfileImageResult(member.getProfileImageUrl()); + public static UpdateProfileImageResult toUpdateProfileImageResult(String profileImageUrl) { + return new UpdateProfileImageResult(profileImageUrl); } public static ActivitySummaryResult toActivitySummaryResult( diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index 66be3f5..4e0a314 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -91,7 +91,7 @@ public MemberInfoResult getMemberInfo(long memberId, ZoneId zoneId) { Member member = memberRepository.findById(memberId) .orElseThrow(MemberNotFoundException::new); - return MemberMapper.toMemberInfoResult(member, zoneId); + return MemberMapper.toMemberInfoResult(member, generateProfileImageUrl(member), zoneId); } @Transactional(readOnly = true) @@ -99,7 +99,7 @@ public MemberProfileResult getMemberProfile(long memberId) { Member member = memberRepository.findById(memberId) .orElseThrow(MemberNotFoundException::new); - return MemberMapper.toMemberProfileResult(member); + return MemberMapper.toMemberProfileResult(member, generateProfileImageUrl(member)); } @Transactional @@ -132,16 +132,22 @@ public UpdateProfileImageResult updateProfileImage(UpdateProfileImageCommand com Member member = memberRepository.findById(command.memberId()) .orElseThrow(MemberNotFoundException::new); + member.updateProfileImageObjectKey(command.objectKey()); + return MemberMapper.toUpdateProfileImageResult(generateProfileImageUrl(member)); + } + + private String generateProfileImageUrl(Member member) { + if (member.getProfileImageObjectKey() == null) { + return null; + } + FileImageAccessUrlCommand accessUrlCommand = new FileImageAccessUrlCommand( FileUploadDirectory.PROFILE, null, - command.memberId().toString(), - command.objectKey() + member.getId().toString(), + member.getProfileImageObjectKey() ); - String profileImageUrl = fileImageAccessUrlPort.generateImageUrl(accessUrlCommand).imageUrl(); - - member.updateProfileImageUrl(profileImageUrl); - return MemberMapper.toUpdateProfileImageResult(member); + return fileImageAccessUrlPort.generateImageUrl(accessUrlCommand).imageUrl(); } @Transactional From c58660078f2045e8c11aa9ab3f1503130d2c8111 Mon Sep 17 00:00:00 2001 From: kymin Date: Tue, 25 Aug 2026 15:47:10 +0900 Subject: [PATCH 32/68] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=ED=83=88?= =?UTF-8?q?=ED=87=B4,=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20S3=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/command/FileDeleteCommand.java | 4 ++ .../command/FileDeleteDirectoryCommand.java | 4 ++ .../s3/adapter/S3FileDeleteAdapter.java | 4 +- .../s3/adapter/S3ObjectKeyAssembler.java | 4 ++ .../internal/service/MemberService.java | 72 ++++++++++++++----- 5 files changed, 70 insertions(+), 18 deletions(-) diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java index 7f12c6b..e196356 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java @@ -8,4 +8,8 @@ public record FileDeleteCommand( String activityId, String objectKey ) { + + public FileDeleteCommand(FileUploadDirectory directory, String memberId, String objectKey) { + this(directory, memberId, null, objectKey); + } } diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteDirectoryCommand.java b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteDirectoryCommand.java index 30cab9e..682a5ca 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteDirectoryCommand.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteDirectoryCommand.java @@ -4,4 +4,8 @@ public record FileDeleteDirectoryCommand( String memberId, String activityId ) { + + public FileDeleteDirectoryCommand(String memberId) { + this(memberId, null); + } } diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java index 6b7b5ba..9fb0861 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java @@ -39,7 +39,9 @@ public void deleteImage(FileDeleteCommand command) { @Override public void deleteDirectory(FileDeleteDirectoryCommand command) { - String prefix = S3ObjectKeyAssembler.assembleActivityDirectoryPrefix(command.memberId(), command.activityId()); + String prefix = command.activityId() == null + ? S3ObjectKeyAssembler.assembleMemberDirectoryPrefix(command.memberId()) + : S3ObjectKeyAssembler.assembleActivityDirectoryPrefix(command.memberId(), command.activityId()); String continuationToken = null; do { diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java index 3e180dd..53600f2 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssembler.java @@ -50,4 +50,8 @@ static String assemble( static String assembleActivityDirectoryPrefix(String memberId, String activityId) { return "%s/%s/%s/%s/".formatted(MEMBER_ROOT_PATH, memberId, ACTIVITY_SUB_PATH, activityId); } + + static String assembleMemberDirectoryPrefix(String memberId) { + return "%s/%s/".formatted(MEMBER_ROOT_PATH, memberId); + } } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index 4e0a314..e61601f 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -6,8 +6,12 @@ import org.sopt.routee.activity.api.result.MonthlyActivityDailySummaryResult; import org.sopt.routee.activity.api.usecase.ActivityUseCase; +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.api.command.FileDeleteCommand; +import org.sopt.routee.external.api.command.FileDeleteDirectoryCommand; import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; import org.sopt.routee.external.api.command.FileUploadPresignCommand; +import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.sopt.routee.external.api.port.FileUploadPresignPort; import org.sopt.routee.external.api.result.FileUploadPresignResult; @@ -36,9 +40,12 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class MemberService { @@ -49,7 +56,9 @@ public class MemberService { private final ApplicationEventPublisher applicationEventPublisher; private final FileUploadPresignPort fileUploadPresignPort; private final FileImageAccessUrlPort fileImageAccessUrlPort; + private final FileDeletePort fileDeletePort; private final ProfileImageFileNameValidator profileImageFileNameValidator; + private final TransactionTemplate transactionTemplate; @Transactional(readOnly = true) public TokenClaimsResult getTokenResult(String oauthId, OAuthProvider oauthProvider) { @@ -74,16 +83,27 @@ public void register(RegisterCommand command) { memberRepository.save(MemberMapper.toEntity(command, oauthId)); } - @Transactional public void withdraw(long memberId, String accessTokenHash, String refreshTokenHash) { - Member member = memberRepository.findById(memberId) - .orElseThrow(MemberNotFoundException::new); + transactionTemplate.executeWithoutResult(status -> { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + memberRepository.delete(member); - memberRepository.delete(member); + activityUseCase.deleteForMemberWithdrawal(memberId); - activityUseCase.deleteForMemberWithdrawal(memberId); + applicationEventPublisher.publishEvent(new MemberWithdrawnEvent(memberId, accessTokenHash, refreshTokenHash)); + }); - applicationEventPublisher.publishEvent(new MemberWithdrawnEvent(memberId, accessTokenHash, refreshTokenHash)); + Thread.startVirtualThread(() -> deleteMemberImages(memberId)); + } + + private void deleteMemberImages(long memberId) { + try { + fileDeletePort.deleteDirectory(new FileDeleteDirectoryCommand(Long.toString(memberId))); + } catch (BaseException e) { + log.warn("Member image directory delete failed. memberId={}", memberId, e); + } } @Transactional(readOnly = true) @@ -91,7 +111,8 @@ public MemberInfoResult getMemberInfo(long memberId, ZoneId zoneId) { Member member = memberRepository.findById(memberId) .orElseThrow(MemberNotFoundException::new); - return MemberMapper.toMemberInfoResult(member, generateProfileImageUrl(member), zoneId); + return MemberMapper.toMemberInfoResult( + member, generateProfileImageUrl(member.getId(), member.getProfileImageObjectKey()), zoneId); } @Transactional(readOnly = true) @@ -99,7 +120,8 @@ public MemberProfileResult getMemberProfile(long memberId) { Member member = memberRepository.findById(memberId) .orElseThrow(MemberNotFoundException::new); - return MemberMapper.toMemberProfileResult(member, generateProfileImageUrl(member)); + return MemberMapper.toMemberProfileResult( + member, generateProfileImageUrl(member.getId(), member.getProfileImageObjectKey())); } @Transactional @@ -127,25 +149,41 @@ public ProfileImageUploadUrlResult generateProfileImageUploadUrl(ProfileImageUpl return new ProfileImageUploadUrlResult(result.presignedUrl(), result.objectKey()); } - @Transactional public UpdateProfileImageResult updateProfileImage(UpdateProfileImageCommand command) { - Member member = memberRepository.findById(command.memberId()) - .orElseThrow(MemberNotFoundException::new); + String previousObjectKey = transactionTemplate.execute(status -> { + Member member = memberRepository.findById(command.memberId()) + .orElseThrow(MemberNotFoundException::new); + + String objectKey = member.getProfileImageObjectKey(); + member.updateProfileImageObjectKey(command.objectKey()); + return objectKey; + }); + + if (previousObjectKey != null) { + Thread.startVirtualThread(() -> deleteProfileImage(command.memberId(), previousObjectKey)); + } + + return MemberMapper.toUpdateProfileImageResult(generateProfileImageUrl(command.memberId(), command.objectKey())); + } - member.updateProfileImageObjectKey(command.objectKey()); - return MemberMapper.toUpdateProfileImageResult(generateProfileImageUrl(member)); + private void deleteProfileImage(Long memberId, String objectKey) { + try { + fileDeletePort.deleteImage(new FileDeleteCommand(FileUploadDirectory.PROFILE, memberId.toString(), objectKey)); + } catch (BaseException e) { + log.warn("Profile image delete failed. memberId={}, objectKey={}", memberId, objectKey, e); + } } - private String generateProfileImageUrl(Member member) { - if (member.getProfileImageObjectKey() == null) { + private String generateProfileImageUrl(Long memberId, String profileImageObjectKey) { + if (profileImageObjectKey == null) { return null; } FileImageAccessUrlCommand accessUrlCommand = new FileImageAccessUrlCommand( FileUploadDirectory.PROFILE, null, - member.getId().toString(), - member.getProfileImageObjectKey() + memberId.toString(), + profileImageObjectKey ); return fileImageAccessUrlPort.generateImageUrl(accessUrlCommand).imageUrl(); } From 84bb8e9ec8dc3caa59d89c5da090b6451eb74779 Mon Sep 17 00:00:00 2001 From: kymin Date: Wed, 26 Aug 2026 00:30:52 +0900 Subject: [PATCH 33/68] =?UTF-8?q?fix:=20=EC=A7=80=EB=8F=84=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20object=20key=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ActivityControllerDocs.java | 2 +- .../dto/request/ActivityCompleteRequest.java | 6 +++--- .../internal/entity/activity/Activity.java | 8 ++++---- .../internal/mapper/ActivityMapper.java | 4 ++-- .../internal/service/ActivityService.java | 18 +++++++++++++++++- .../dto/command/CompleteActivityCommand.java | 2 +- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java index 7557142..9f7919f 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java @@ -252,7 +252,7 @@ ResponseEntity> complete( @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, content = @Content(schema = @Schema(implementation = ActivityCompleteRequest.class), examples = @ExampleObject(value = """ - {"title":"북한산 기록","distance":5400,"durationSec":3600,"maxElevation":836,"mapImageUrl":"https://example.com/map.png","track":[{"latitude":37.566,"longitude":126.978,"elevation":20,"pointIndex":0},{"latitude":37.567,"longitude":126.979,"elevation":25,"pointIndex":10}],"endedAt":"2026-07-07T16:30:00"}"""))) + {"title":"북한산 기록","distance":5400,"durationSec":3600,"maxElevation":836,"mapImageObjectKey":"a1b2c3d4e5f6.jpg","track":[{"latitude":37.566,"longitude":126.978,"elevation":20,"pointIndex":0},{"latitude":37.567,"longitude":126.979,"elevation":25,"pointIndex":10}],"endedAt":"2026-07-07T16:30:00"}"""))) @Valid @RequestBody ActivityCompleteRequest request ); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/ActivityCompleteRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/ActivityCompleteRequest.java index 81f48b4..460dbd4 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/ActivityCompleteRequest.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/ActivityCompleteRequest.java @@ -34,8 +34,8 @@ public record ActivityCompleteRequest( @Max(value = 9000, message = "maxElevation은 9000 이하여야 합니다.") Integer maxElevation, - @NotBlank(message = "mapImageUrl은 필수입니다.") - String mapImageUrl, + @NotBlank(message = "mapImageObjectKey는 필수입니다.") + String mapImageObjectKey, @NotEmpty(message = "track은 필수입니다.") @Valid @@ -53,7 +53,7 @@ public CompleteActivityCommand toCommand(Long activityId, Long memberId, ZoneId distance, durationSec, maxElevation, - mapImageUrl, + mapImageObjectKey, track.stream().map(ActivityTrackPoint::toTrackPoint).toList(), endedAt ); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java index d1e5e42..e88c612 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java @@ -58,8 +58,8 @@ public class Activity { @Column(name = "max_elevation") private Integer maxElevation; - @Column(name = "map_image_url") - private String mapImageUrl; + @Column(name = "map_image_object_key") + private String mapImageObjectKey; @Column(name = "track_image_url") private String trackImageUrl; @@ -96,7 +96,7 @@ public void updateCompletedData( Integer distance, Integer durationSec, Integer maxElevation, - String mapImageUrl, + String mapImageObjectKey, String coverImageObjectKey, LineString track, Instant endedAt @@ -106,7 +106,7 @@ public void updateCompletedData( this.distance = distance; this.durationSec = durationSec; this.maxElevation = maxElevation; - this.mapImageUrl = mapImageUrl; + this.mapImageObjectKey = mapImageObjectKey; this.coverImageObjectKey = coverImageObjectKey; this.track = track; this.endedAt = endedAt; diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/ActivityMapper.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/ActivityMapper.java index c1d74cc..f2582c1 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/ActivityMapper.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/ActivityMapper.java @@ -71,7 +71,7 @@ public static ActivityEditItemResult toActivityEditItemResult( return new ActivityEditItemResult(activity.getId(), activity.getTitle(), activityDate, timelineImageUrls); } - public static ActivityRecapResult toRecapResult(Activity activity, List routes) { + public static ActivityRecapResult toRecapResult(Activity activity, String mapImageUrl, List routes) { List routeResults = routes.stream() .map(route -> new ActivityRecapRouteResult(route.getSequence(), route.getName())) .toList(); @@ -80,7 +80,7 @@ public static ActivityRecapResult toRecapResult(Activity activity, List r activity.getDistance(), activity.getDurationSec(), activity.getMaxElevation(), - activity.getMapImageUrl(), + mapImageUrl, routeResults ); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index eb49513..1c0cfa2 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -207,7 +207,7 @@ public void complete(CompleteActivityCommand command) { command.distance(), command.durationSec(), command.maxElevation(), - command.mapImageUrl(), + command.mapImageObjectKey(), coverImageObjectKey, ActivityMapper.toLineString(command.track()), endedAt @@ -245,10 +245,26 @@ public ActivityRecapResult getRecap(GetActivityRecapCommand command) { return ActivityMapper.toRecapResult( activity, + generateMapImageUrl(activity), routeRepository.findByActivityIdOrderBySequenceAsc(command.activityId()) ); } + private String generateMapImageUrl(Activity activity) { + if (activity.getMapImageObjectKey() == null) { + return null; + } + + FileImageAccessUrlCommand command = new FileImageAccessUrlCommand( + FileUploadDirectory.RECAP, + null, + activity.getMemberId().toString(), + activity.getId().toString(), + activity.getMapImageObjectKey() + ); + return fileImageAccessUrlPort.generateImageUrl(command).imageUrl(); + } + @Transactional(readOnly = true) public ActivitiesByDateResult getActivitiesByDate(Long memberId, LocalDate date, ZoneId timeZone) { Instant startedAtFrom = TimeZoneUtils.toUtcInstant(date, timeZone); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/CompleteActivityCommand.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/CompleteActivityCommand.java index 83d71ae..55694bd 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/CompleteActivityCommand.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/CompleteActivityCommand.java @@ -14,7 +14,7 @@ public record CompleteActivityCommand( Integer distance, Integer durationSec, Integer maxElevation, - String mapImageUrl, + String mapImageObjectKey, List track, LocalDateTime endedAt ) { From e4ae741d85f2f2aad6846ed9c3a141750fc52de0 Mon Sep 17 00:00:00 2001 From: kymin Date: Wed, 26 Aug 2026 01:35:45 +0900 Subject: [PATCH 34/68] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20summary=EC=9D=98?= =?UTF-8?q?=20=EC=BB=A4=EB=B2=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20object=20key=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/entity/activity/Activity.java | 8 ++++- .../entity/summary/ActivityDailySummary.java | 7 ++-- .../mapper/ActivityDailySummaryMapper.java | 4 +-- .../ActivityDailySummaryRepository.java | 22 +++++++++--- .../repository/ActivityRepository.java | 7 ++++ .../service/ActivityDailySummaryService.java | 34 +++++++++++++++++-- .../internal/service/ActivityService.java | 8 ++--- .../internal/service/TimelineService.java | 21 ++++++++++++ .../internal/service/ActivityServiceTest.java | 4 +-- 9 files changed, 96 insertions(+), 19 deletions(-) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java index e88c612..24e3b9b 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java @@ -1,6 +1,7 @@ package org.sopt.routee.activity.internal.entity.activity; import java.time.Instant; +import java.time.LocalDate; import org.hibernate.annotations.ColumnDefault; import org.locationtech.jts.geom.LineString; @@ -67,6 +68,9 @@ public class Activity { @Column(name = "cover_image_object_key") private String coverImageObjectKey; + @Column(name = "activity_date_with_timezone") + private LocalDate activityDateWithTimezone; + @Column(name = "track", columnDefinition = "geometry(LINESTRINGZM, 4326)") private LineString track; @@ -99,7 +103,8 @@ public void updateCompletedData( String mapImageObjectKey, String coverImageObjectKey, LineString track, - Instant endedAt + Instant endedAt, + LocalDate activityDateWithTimezone ) { this.title = title; this.activityStatus = ActivityStatus.ACTIVITY_COMPLETED; @@ -110,5 +115,6 @@ public void updateCompletedData( this.coverImageObjectKey = coverImageObjectKey; this.track = track; this.endedAt = endedAt; + this.activityDateWithTimezone = activityDateWithTimezone; } } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/summary/ActivityDailySummary.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/summary/ActivityDailySummary.java index 381922d..573cc3a 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/summary/ActivityDailySummary.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/summary/ActivityDailySummary.java @@ -41,8 +41,11 @@ public class ActivityDailySummary { @ColumnDefault("0") private Integer totalDurationSec = 0; - @Column(name = "cover_image_url") - private String coverImageUrl; + @Column(name = "cover_activity_id") + private Long coverActivityId; + + @Column(name = "cover_image_object_key") + private String coverImageObjectKey; @Builder.Default @Column(name = "activity_count", nullable = false) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/ActivityDailySummaryMapper.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/ActivityDailySummaryMapper.java index 645e5c5..b18e4b0 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/ActivityDailySummaryMapper.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/ActivityDailySummaryMapper.java @@ -9,12 +9,12 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ActivityDailySummaryMapper { - public static ActivityDailySummaryResult toResult(ActivityDailySummary summary) { + public static ActivityDailySummaryResult toResult(ActivityDailySummary summary, String coverImageUrl) { return new ActivityDailySummaryResult( summary.getActivityDate(), summary.getTotalDurationSec(), summary.getActivityCount(), - summary.getCoverImageUrl() + coverImageUrl ); } } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java index 6043a8b..c3b0d67 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java @@ -18,13 +18,14 @@ List findByMemberIdAndActivityDateBetweenOrderByActivityDa @Modifying @Query(value = """ INSERT INTO activity_daily_summary ( - id, member_id, activity_date, total_duration_sec, cover_image_url, activity_count + id, member_id, activity_date, total_duration_sec, cover_activity_id, cover_image_object_key, activity_count ) - VALUES (:id, :memberId, :activityDate, :durationSec, :coverImageUrl, 1) + VALUES (:id, :memberId, :activityDate, :durationSec, :coverActivityId, :coverImageObjectKey, 1) ON CONFLICT (member_id, activity_date) DO UPDATE SET total_duration_sec = activity_daily_summary.total_duration_sec + EXCLUDED.total_duration_sec, - cover_image_url = COALESCE(activity_daily_summary.cover_image_url, EXCLUDED.cover_image_url), + cover_activity_id = COALESCE(activity_daily_summary.cover_activity_id, EXCLUDED.cover_activity_id), + cover_image_object_key = COALESCE(activity_daily_summary.cover_image_object_key, EXCLUDED.cover_image_object_key), activity_count = activity_daily_summary.activity_count + 1 """, nativeQuery = true) void upsertDailySummary( @@ -32,10 +33,23 @@ void upsertDailySummary( @Param("memberId") Long memberId, @Param("activityDate") LocalDate activityDate, @Param("durationSec") Integer durationSec, - @Param("coverImageUrl") String coverImageUrl + @Param("coverActivityId") Long coverActivityId, + @Param("coverImageObjectKey") String coverImageObjectKey ); @Modifying @Query("DELETE FROM ActivityDailySummary ads WHERE ads.memberId = :memberId") void deleteByMemberId(@Param("memberId") Long memberId); + + @Modifying + @Query(""" + UPDATE ActivityDailySummary ads SET ads.coverActivityId = :coverActivityId, ads.coverImageObjectKey = :coverImageObjectKey + WHERE ads.memberId = :memberId AND ads.activityDate = :activityDate + """) + void updateCoverImage( + @Param("memberId") Long memberId, + @Param("activityDate") LocalDate activityDate, + @Param("coverActivityId") Long coverActivityId, + @Param("coverImageObjectKey") String coverImageObjectKey + ); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java index 72c4a77..17363a9 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java @@ -1,6 +1,7 @@ package org.sopt.routee.activity.internal.repository; import java.time.Instant; +import java.time.LocalDate; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -39,6 +40,12 @@ List findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedA Instant startedAtTo ); + Optional findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusOrderByStartedAtAsc( + Long memberId, + LocalDate activityDateWithTimezone, + ActivityStatus activityStatus + ); + @Modifying @Query("DELETE FROM Activity a WHERE a.memberId = :memberId") void deleteByMemberId(@Param("memberId") Long memberId); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java index ace4101..3ee492c 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java @@ -4,9 +4,14 @@ import java.time.YearMonth; import java.util.List; +import org.sopt.routee.activity.internal.entity.summary.ActivityDailySummary; import org.sopt.routee.activity.internal.mapper.ActivityDailySummaryMapper; import org.sopt.routee.activity.internal.repository.ActivityDailySummaryRepository; import org.sopt.routee.activity.internal.service.dto.result.ActivityDailySummaryResult; +import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; +import org.sopt.routee.external.api.port.FileImageAccessUrlPort; +import org.sopt.routee.external.api.type.FileUploadDirectory; +import org.sopt.routee.external.api.type.FileUploadImageSize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +23,7 @@ public class ActivityDailySummaryService { private final ActivityDailySummaryRepository activityDailySummaryRepository; + private final FileImageAccessUrlPort fileImageAccessUrlPort; @Transactional(readOnly = true) public List getMonthlySummaries(Long memberId, YearMonth yearMonth) { @@ -25,14 +31,16 @@ public List getMonthlySummaries(Long memberId, YearM memberId, yearMonth.atDay(1), yearMonth.atEndOfMonth() ) .stream() - .map(ActivityDailySummaryMapper::toResult) + .map(summary -> ActivityDailySummaryMapper.toResult(summary, generateCoverImageUrl(memberId, summary))) .toList(); } @Transactional - public void recordActivity(Long memberId, LocalDate activityDate, Integer durationSec, String coverImageUrl) { + public void recordActivity( + Long memberId, LocalDate activityDate, Integer durationSec, Long coverActivityId, String coverImageObjectKey + ) { activityDailySummaryRepository.upsertDailySummary( - TSID.Factory.getTsid().toLong(), memberId, activityDate, durationSec, coverImageUrl + TSID.Factory.getTsid().toLong(), memberId, activityDate, durationSec, coverActivityId, coverImageObjectKey ); } @@ -40,4 +48,24 @@ public void recordActivity(Long memberId, LocalDate activityDate, Integer durati public void deleteActivityDailySummariesByMemberId(long memberId) { activityDailySummaryRepository.deleteByMemberId(memberId); } + + @Transactional + public void refreshCoverImage(Long memberId, LocalDate activityDate, Long coverActivityId, String coverImageObjectKey) { + activityDailySummaryRepository.updateCoverImage(memberId, activityDate, coverActivityId, coverImageObjectKey); + } + + private String generateCoverImageUrl(Long memberId, ActivityDailySummary summary) { + if (summary.getCoverImageObjectKey() == null) { + return null; + } + + FileImageAccessUrlCommand command = new FileImageAccessUrlCommand( + FileUploadDirectory.TIMELINE, + FileUploadImageSize.SMALL, + memberId.toString(), + summary.getCoverActivityId().toString(), + summary.getCoverImageObjectKey() + ); + return fileImageAccessUrlPort.generateImageUrl(command).imageUrl(); + } } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index 1c0cfa2..805e4ee 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -201,6 +201,7 @@ public void complete(CompleteActivityCommand command) { Instant endedAt = TimeZoneUtils.toUtcInstantTime(command.endedAt(), command.timeZone()); String coverImageObjectKey = resolveCoverImageObjectKey(command.activityId()); + LocalDate activityDate = TimeZoneUtils.toLocalDate(activity.getStartedAt(), command.timeZone()); activity.updateCompletedData( command.title(), @@ -210,13 +211,12 @@ public void complete(CompleteActivityCommand command) { command.mapImageObjectKey(), coverImageObjectKey, ActivityMapper.toLineString(command.track()), - endedAt + endedAt, + activityDate ); - LocalDate activityDate = TimeZoneUtils.toLocalDate(activity.getStartedAt(), command.timeZone()); - String coverImageUrl = generateThumbnailUrl(activity); activityDailySummaryService.recordActivity( - command.memberId(), activityDate, command.durationSec(), coverImageUrl + command.memberId(), activityDate, command.durationSec(), activity.getId(), coverImageObjectKey ); applicationEventPublisher.publishEvent(new ActivityCompletedEvent(command.memberId())); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index 6b243a5..9ab7427 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -1,9 +1,11 @@ package org.sopt.routee.activity.internal.service; import java.time.Instant; +import java.time.LocalDate; import java.util.List; import org.sopt.routee.activity.internal.entity.activity.Activity; +import org.sopt.routee.activity.internal.entity.activity.ActivityStatus; import org.sopt.routee.activity.internal.entity.timeline.Timeline; import org.sopt.routee.activity.internal.entity.timeline.TimelineStatus; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; @@ -38,6 +40,7 @@ public class TimelineService { private final ActivityRepository activityRepository; private final TimelineRepository timelineRepository; + private final ActivityDailySummaryService activityDailySummaryService; private final FileImageAccessUrlPort fileImageAccessUrlPort; private final FileDeletePort fileDeletePort; private final TransactionTemplate transactionTemplate; @@ -119,6 +122,24 @@ private void refreshCoverImageIfDeleted(Timeline deletedTimeline) { .orElse(null); activity.updateCoverImageObjectKey(newCoverImageObjectKey); + refreshDailySummaryCoverIfNeeded(activity); + } + + private void refreshDailySummaryCoverIfNeeded(Activity activity) { + LocalDate activityDate = activity.getActivityDateWithTimezone(); + if (activityDate == null) { + return; + } + + Activity firstActivityOfDay = activityRepository + .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusOrderByStartedAtAsc( + activity.getMemberId(), activityDate, ActivityStatus.ACTIVITY_COMPLETED) + .orElse(null); + + Long coverActivityId = firstActivityOfDay == null ? null : firstActivityOfDay.getId(); + String coverImageObjectKey = firstActivityOfDay == null ? null : firstActivityOfDay.getCoverImageObjectKey(); + + activityDailySummaryService.refreshCoverImage(activity.getMemberId(), activityDate, coverActivityId, coverImageObjectKey); } private Timeline findOwnedTimeline(Long activityId, Long timelineId, Long memberId) { diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java index c27e464..1be6922 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java @@ -40,7 +40,6 @@ import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.sopt.routee.external.api.port.FileUploadPresignPort; -import org.sopt.routee.external.api.result.FileImageAccessUrlResult; import org.springframework.context.ApplicationEventPublisher; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.TransactionCallback; @@ -258,12 +257,11 @@ private CompleteActivityCommand completeCommand(Long activityId) { when(timelineRepository.findFirstByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc( activityId, TimelineStatus.SUCCESSFUL_CREATED)) .thenReturn(Optional.of(coverTimeline)); - when(fileImageAccessUrlPort.generateImageUrl(any())) - .thenReturn(new FileImageAccessUrlResult("https://image-url")); completeWithSynchronizationActive(completeCommand(activityId)); assertThat(activity.getCoverImageObjectKey()).isEqualTo("smallest.jpg"); + verifyNoInteractions(fileImageAccessUrlPort); } @Test From f19b63cc29167a278386192febdff2c55821fab6 Mon Sep 17 00:00:00 2001 From: kymin Date: Wed, 26 Aug 2026 02:16:11 +0900 Subject: [PATCH 35/68] =?UTF-8?q?fix:=20=EC=BB=A4=EB=B2=84=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=99=80=20activityId=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=ED=99=94=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/repository/ActivityDailySummaryRepository.java | 6 ++++-- .../routee/activity/internal/service/ActivityService.java | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java index c3b0d67..3eb8e90 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java @@ -24,8 +24,10 @@ INSERT INTO activity_daily_summary ( ON CONFLICT (member_id, activity_date) DO UPDATE SET total_duration_sec = activity_daily_summary.total_duration_sec + EXCLUDED.total_duration_sec, - cover_activity_id = COALESCE(activity_daily_summary.cover_activity_id, EXCLUDED.cover_activity_id), - cover_image_object_key = COALESCE(activity_daily_summary.cover_image_object_key, EXCLUDED.cover_image_object_key), + cover_activity_id = CASE WHEN activity_daily_summary.cover_image_object_key IS NULL + THEN EXCLUDED.cover_activity_id ELSE activity_daily_summary.cover_activity_id END, + cover_image_object_key = CASE WHEN activity_daily_summary.cover_image_object_key IS NULL + THEN EXCLUDED.cover_image_object_key ELSE activity_daily_summary.cover_image_object_key END, activity_count = activity_daily_summary.activity_count + 1 """, nativeQuery = true) void upsertDailySummary( diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index 805e4ee..22dafa6 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -215,8 +215,9 @@ public void complete(CompleteActivityCommand command) { activityDate ); + Long coverActivityId = coverImageObjectKey == null ? null : activity.getId(); activityDailySummaryService.recordActivity( - command.memberId(), activityDate, command.durationSec(), activity.getId(), coverImageObjectKey + command.memberId(), activityDate, command.durationSec(), coverActivityId, coverImageObjectKey ); applicationEventPublisher.publishEvent(new ActivityCompletedEvent(command.memberId())); From 7617cdbe7640b57774bceb62ad4885713cf88a59 Mon Sep 17 00:00:00 2001 From: kymin Date: Wed, 26 Aug 2026 02:17:38 +0900 Subject: [PATCH 36/68] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=8F=99=EB=93=B1=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/routee/member/internal/service/MemberService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index e61601f..f9e2e1e 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -159,7 +159,7 @@ public UpdateProfileImageResult updateProfileImage(UpdateProfileImageCommand com return objectKey; }); - if (previousObjectKey != null) { + if (previousObjectKey != null && !previousObjectKey.equals(command.objectKey())) { Thread.startVirtualThread(() -> deleteProfileImage(command.memberId(), previousObjectKey)); } From 8d6120f771470ab556696c8a92412fa9678f6043 Mon Sep 17 00:00:00 2001 From: kymin Date: Wed, 26 Aug 2026 13:47:00 +0900 Subject: [PATCH 37/68] =?UTF-8?q?fix:=20=EC=99=84=EB=A3=8C=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20=EC=84=A4=EC=A0=95=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 --- routee-app/src/main/resources/application.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routee-app/src/main/resources/application.yml b/routee-app/src/main/resources/application.yml index 319d0fd..6c50162 100644 --- a/routee-app/src/main/resources/application.yml +++ b/routee-app/src/main/resources/application.yml @@ -1,6 +1,9 @@ spring: application: name: routee + modulith: + events: + completion-mode: delete datasource: url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME} From 81a6bc1edaddccae988607f897e9874c859c6c26 Mon Sep 17 00:00:00 2001 From: kymin Date: Wed, 26 Aug 2026 22:57:39 +0900 Subject: [PATCH 38/68] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=ED=83=90=EC=83=89=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../activity/internal/repository/ActivityRepository.java | 2 +- .../routee/activity/internal/service/TimelineService.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java index 17363a9..962c5bb 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java @@ -40,7 +40,7 @@ List findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedA Instant startedAtTo ); - Optional findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusOrderByStartedAtAsc( + Optional findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( Long memberId, LocalDate activityDateWithTimezone, ActivityStatus activityStatus diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index 9ab7427..032caba 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -131,13 +131,13 @@ private void refreshDailySummaryCoverIfNeeded(Activity activity) { return; } - Activity firstActivityOfDay = activityRepository - .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusOrderByStartedAtAsc( + Activity firstActivityWithCover = activityRepository + .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( activity.getMemberId(), activityDate, ActivityStatus.ACTIVITY_COMPLETED) .orElse(null); - Long coverActivityId = firstActivityOfDay == null ? null : firstActivityOfDay.getId(); - String coverImageObjectKey = firstActivityOfDay == null ? null : firstActivityOfDay.getCoverImageObjectKey(); + Long coverActivityId = firstActivityWithCover == null ? null : firstActivityWithCover.getId(); + String coverImageObjectKey = firstActivityWithCover == null ? null : firstActivityWithCover.getCoverImageObjectKey(); activityDailySummaryService.refreshCoverImage(activity.getMemberId(), activityDate, coverActivityId, coverImageObjectKey); } From b35db9b2e27e215e1873c82fc6ecb8de4b451b65 Mon Sep 17 00:00:00 2001 From: kymin Date: Thu, 27 Aug 2026 12:48:47 +0900 Subject: [PATCH 39/68] =?UTF-8?q?[DEPLOY]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EC=9E=AC=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routee-app/src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routee-app/src/main/resources/application.yml b/routee-app/src/main/resources/application.yml index 6c50162..f431fb6 100644 --- a/routee-app/src/main/resources/application.yml +++ b/routee-app/src/main/resources/application.yml @@ -20,7 +20,7 @@ spring: format_sql: true jdbc: time_zone: UTC - show-sql: false + show-sql: true data: redis: From 189dfd7faa358dae4fb77039ed9aa5b453eeceb4 Mon Sep 17 00:00:00 2001 From: kymin Date: Thu, 27 Aug 2026 14:21:01 +0900 Subject: [PATCH 40/68] =?UTF-8?q?hotfix:=20=EB=B9=84=EC=A0=95=EC=83=81=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=ED=99=9C=EB=8F=99=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=99=B8=EB=9E=98=ED=82=A4=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routee/activity/internal/repository/RouteRepository.java | 4 ++++ .../routee/activity/internal/service/ActivityService.java | 1 + 2 files changed, 5 insertions(+) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/RouteRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/RouteRepository.java index aa68caa..7d05dd8 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/RouteRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/RouteRepository.java @@ -17,4 +17,8 @@ public interface RouteRepository extends JpaRepository { @Modifying @Query("DELETE FROM Route r WHERE r.activity.memberId = :memberId") void deleteByMemberId(@Param("memberId") Long memberId); + + @Modifying + @Query("DELETE FROM Route r WHERE r.activity.id IN :activityIds") + void deleteByActivityIdIn(@Param("activityIds") List activityIds); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index 22dafa6..7f29612 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -116,6 +116,7 @@ private ActivityCreationTransactionResult createInTransaction(CreateActivityComm ); if (!activeActivityIds.isEmpty()) { + routeRepository.deleteByActivityIdIn(activeActivityIds); timelineRepository.deleteByActivityIdIn(activeActivityIds); activityRepository.deleteByIdIn(activeActivityIds); } From 0d1a494333114ae6ceab7254b7b08ee93cc7a93e Mon Sep 17 00:00:00 2001 From: kymin Date: Thu, 27 Aug 2026 15:00:15 +0900 Subject: [PATCH 41/68] =?UTF-8?q?hotfix:=20=ED=97=AC=EC=8A=A4=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=A1=A4=EB=B0=B1=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy.sh | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index b74128f..14d26d2 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -59,18 +59,17 @@ docker compose -f docker-compose.yml up -d --no-deps $TARGET echo "Step 1: Waiting for $TARGET container healthcheck..." for i in {1..40}; do STATUS=$(docker inspect --format='{{.State.Health.Status}}' routee-$TARGET 2>/dev/null || echo "unknown") + echo "Health status: $STATUS ($i/40)" if [ "$STATUS" = "healthy" ]; then echo "$TARGET container is healthy" break fi - if [ "$STATUS" = "unhealthy" ]; then - echo "$TARGET container is unhealthy" - docker logs routee-$TARGET --tail 50 - exit 1 - fi if [ "$i" -eq 40 ]; then echo "$TARGET container healthcheck failed (status: $STATUS)" - docker logs routee-$TARGET --tail 50 + docker logs routee-$TARGET --tail 100 + echo "Cleaning up failed $TARGET container..." + docker compose -f docker-compose.yml stop $TARGET || true + docker compose -f docker-compose.yml rm -f $TARGET || true exit 1 fi sleep 3 @@ -85,6 +84,9 @@ for i in {1..10}; do if [ "$i" -eq 10 ]; then echo "$TARGET not reachable from nginx" docker logs routee-$TARGET --tail 50 + echo "Cleaning up failed $TARGET container..." + docker compose -f docker-compose.yml stop $TARGET || true + docker compose -f docker-compose.yml rm -f $TARGET || true exit 1 fi sleep 2 From fc9d69ec78cf652e06bda083c9abd176cd23a1f2 Mon Sep 17 00:00:00 2001 From: kymin Date: Thu, 27 Aug 2026 15:09:37 +0900 Subject: [PATCH 42/68] =?UTF-8?q?hotfix:=20=ED=97=AC=EC=8A=A4=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=A3=A8=ED=94=84=20?= =?UTF-8?q?=ED=9A=9F=EC=88=98=20=EC=A6=9D=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 14d26d2..ef49cb9 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -57,14 +57,14 @@ docker compose -f docker-compose.yml pull $TARGET docker compose -f docker-compose.yml up -d --no-deps $TARGET echo "Step 1: Waiting for $TARGET container healthcheck..." -for i in {1..40}; do +for i in {1..60}; do STATUS=$(docker inspect --format='{{.State.Health.Status}}' routee-$TARGET 2>/dev/null || echo "unknown") - echo "Health status: $STATUS ($i/40)" + echo "Health status: $STATUS ($i/60)" if [ "$STATUS" = "healthy" ]; then echo "$TARGET container is healthy" break fi - if [ "$i" -eq 40 ]; then + if [ "$i" -eq 60 ]; then echo "$TARGET container healthcheck failed (status: $STATUS)" docker logs routee-$TARGET --tail 100 echo "Cleaning up failed $TARGET container..." From 7432298de4d4900147d87818196cd82e4d42de02 Mon Sep 17 00:00:00 2001 From: kymin Date: Sat, 29 Aug 2026 21:46:51 +0900 Subject: [PATCH 43/68] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EC=97=AC=EB=B6=80=20=EA=B4=80=EB=A6=AC=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/entity/MemberAgreement.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberAgreement.java diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberAgreement.java b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberAgreement.java new file mode 100644 index 0000000..4181083 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberAgreement.java @@ -0,0 +1,56 @@ +package org.sopt.routee.member.internal.entity; + +import java.time.Instant; + +import org.sopt.routee.entity.BaseEntity; + +import io.hypersistence.utils.hibernate.id.Tsid; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@Table(name = "member_agreement", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_member_agreement_member_id", + columnNames = {"member_id"}) + }) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberAgreement extends BaseEntity { + @Id + @Tsid + private Long id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id", nullable = false, updatable = false) + private Member member; + + @Column(name = "service_terms_agreed_at") + private Instant serviceTermsAgreedAt; + + @Column(name = "privacy_policy_agreed_at") + private Instant privacyPolicyAgreedAt; + + @Column(name = "location_service_terms_agreed_at") + private Instant locationServiceTermsAgreedAt; + + @Column(name = "over14_confirmed_at") + private Instant over14ConfirmedAt; + + @Column(name = "marketing_consent_agreed_at") + private Instant marketingConsentAgreedAt; +} From cc9949b8c7ad46fdf7cd75da8d283e2445837e20 Mon Sep 17 00:00:00 2001 From: kymin Date: Mon, 31 Aug 2026 01:15:19 +0900 Subject: [PATCH 44/68] =?UTF-8?q?hotfix:=20=EA=B8=B0=EB=B3=B8=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=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/internal/code/SuccessCode.java | 1 + .../internal/controller/MemberController.java | 10 ++++++++ .../controller/MemberControllerDocs.java | 23 +++++++++++++++++++ .../internal/service/MemberService.java | 17 ++++++++++++++ 4 files changed, 51 insertions(+) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java b/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java index 1260933..42f14ce 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/code/SuccessCode.java @@ -15,6 +15,7 @@ public enum SuccessCode implements SuccessResultCode { MEMBER_NICKNAME_UPDATED(HttpStatus.OK, "닉네임 변경이 완료되었습니다."), PROFILE_IMAGE_UPLOAD_URL_ISSUED(HttpStatus.OK, "프로필 이미지 업로드 URL 발급에 성공했습니다."), PROFILE_IMAGE_UPDATED(HttpStatus.OK, "프로필 이미지 변경이 완료되었습니다."), + PROFILE_IMAGE_RESET(HttpStatus.OK, "프로필 이미지가 기본 이미지로 변경되었습니다."), ACTIVITY_SUMMARY_FOUND(HttpStatus.OK, "월간 활동 요약 조회에 성공하였습니다."); private final HttpStatus status; diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java index 81842b3..f8266b9 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java @@ -130,6 +130,16 @@ public ResponseEntity> updateProfileImage( .body(ApiResponse.success(SuccessCode.PROFILE_IMAGE_UPDATED, ProfileImageResponse.from(result))); } + @PatchMapping(path = "/member/profile-image/default") + public ResponseEntity> resetProfileImage( + @AuthenticationPrincipal Long memberId + ) { + UpdateProfileImageResult result = memberService.resetProfileImage(memberId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.PROFILE_IMAGE_RESET, ProfileImageResponse.from(result))); + } + @GetMapping(path = "/archive/activity-summary") public ResponseEntity> getActivitySummary( @AuthenticationPrincipal Long memberId, diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index 635f013..89e6656 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -262,6 +262,29 @@ ResponseEntity> updateProfileImage( @Valid @RequestBody ProfileImageUpdateRequest request ); + @Operation( + summary = "프로필 이미지 기본 이미지로 초기화", + description = "회원의 프로필 이미지를 기본 이미지로 되돌립니다. 기존에 업로드된 S3 이미지는 함께 삭제됩니다." + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "초기화 성공", + content = @Content(schema = @Schema(implementation = ProfileImageResponse.class))), + @ApiResponse(responseCode = "401", description = "만료되었거나 유효하지 않은 액세스 토큰", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = { + @ExampleObject(name = "INVALID_TOKEN", + value = "{\"status\":401,\"code\":\"INVALID_TOKEN\",\"message\":\"유효하지 않은 토큰입니다.\"}"), + @ExampleObject(name = "TOKEN_EXPIRED", + value = "{\"status\":401,\"code\":\"TOKEN_EXPIRED\",\"message\":\"만료된 토큰입니다.\"}") + })), + @ApiResponse(responseCode = "404", description = "가입된 회원 없음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = @ExampleObject(name = "MEMBER_NOT_FOUND", + value = "{\"status\":404,\"code\":\"MEMBER_NOT_FOUND\",\"message\":\"사용자 정보가 존재하지 않습니다.\"}"))) + }) + ResponseEntity> resetProfileImage(Long memberId); + @Operation( summary = "월별 활동 요약 조회", description = "인증된 회원의 특정 연/월에 대한 일자별 활동 요약(총 활동 시간, 활동 횟수, 커버 이미지)을 조회합니다." diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index f9e2e1e..3ca770c 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -166,6 +166,23 @@ public UpdateProfileImageResult updateProfileImage(UpdateProfileImageCommand com return MemberMapper.toUpdateProfileImageResult(generateProfileImageUrl(command.memberId(), command.objectKey())); } + public UpdateProfileImageResult resetProfileImage(long memberId) { + String previousObjectKey = transactionTemplate.execute(status -> { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + String objectKey = member.getProfileImageObjectKey(); + member.updateProfileImageObjectKey(null); + return objectKey; + }); + + if (previousObjectKey != null) { + Thread.startVirtualThread(() -> deleteProfileImage(memberId, previousObjectKey)); + } + + return MemberMapper.toUpdateProfileImageResult(null); + } + private void deleteProfileImage(Long memberId, String objectKey) { try { fileDeletePort.deleteImage(new FileDeleteCommand(FileUploadDirectory.PROFILE, memberId.toString(), objectKey)); From f65d2f4ea2ed3b734d112872d236fe1998473bab Mon Sep 17 00:00:00 2001 From: kymin Date: Mon, 31 Aug 2026 01:54:04 +0900 Subject: [PATCH 45/68] =?UTF-8?q?hotfix:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/internal/controller/MemberControllerDocs.java | 4 ++-- .../controller/dto/request/NicknameUpdateRequest.java | 2 +- .../internal/controller/dto/request/RegisterRequest.java | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index 89e6656..1eac792 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -46,7 +46,7 @@ public interface MemberControllerDocs { content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { @ExampleObject(name = "INVALID_NICKNAME_FORMAT", - value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"닉네임은 한글, 영어, 숫자만 사용하여 2자 이상 12자 이하로 입력해야 합니다.\"}"), + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"닉네임은 한글, 영어, 숫자와 공백을 사용하여 1자 이상 12자 이하로 입력해야 하며, 공백은 연속될 수 없습니다.\"}"), @ExampleObject(name = "INVALID_REQUEST_BODY", value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}") })), @@ -164,7 +164,7 @@ ResponseEntity> getMemberInfo( content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { @ExampleObject(name = "INVALID_NICKNAME_FORMAT", - value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"닉네임은 한글, 영어, 숫자만 사용하여 2자 이상 12자 이하로 입력해야 합니다.\"}"), + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"닉네임은 한글, 영어, 숫자와 공백을 사용하여 1자 이상 12자 이하로 입력해야 하며, 공백은 연속될 수 없습니다.\"}"), @ExampleObject(name = "INVALID_REQUEST_BODY", value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}") })), diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/NicknameUpdateRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/NicknameUpdateRequest.java index 0d6f8d5..af74fdf 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/NicknameUpdateRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/NicknameUpdateRequest.java @@ -7,7 +7,7 @@ public record NicknameUpdateRequest( @NotBlank - @Pattern(regexp = "^[가-힣a-zA-Z0-9]{2,12}$", message = "닉네임은 한글, 영어, 숫자만 사용하여 2자 이상 12자 이하로 입력해야 합니다.") + @Pattern(regexp = "^(?=.{1,12}$)[가-힣a-zA-Z0-9]+(?: [가-힣a-zA-Z0-9]+)*$", message = "닉네임은 한글, 영어, 숫자와 공백을 사용하여 1자 이상 12자 이하로 입력해야 하며, 공백은 연속될 수 없습니다.") String nickname ) { public UpdateNicknameCommand toCommand(Long memberId) { diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java index 84a9942..59e5280 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java @@ -13,7 +13,7 @@ public record RegisterRequest( @NotBlank String idToken, @NotBlank - @Pattern(regexp = "^[가-힣a-zA-Z0-9]{2,12}$", message = "닉네임은 한글, 영어, 숫자만 사용하여 2자 이상 12자 이하로 입력해야 합니다.") + @Pattern(regexp = "^(?=.{1,12}$)[가-힣a-zA-Z0-9]+(?: [가-힣a-zA-Z0-9]+)*$", message = "닉네임은 한글, 영어, 숫자와 공백을 사용하여 1자 이상 12자 이하로 입력해야 하며, 공백은 연속될 수 없습니다.") String nickname ) { public RegisterCommand toCommand(){ From b3f40fe1a5d1f50d3bfe58e80d14927fd556c51d Mon Sep 17 00:00:00 2001 From: kymin Date: Mon, 31 Aug 2026 02:52:20 +0900 Subject: [PATCH 46/68] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98?= =?UTF-8?q?=20=EC=97=AC=EB=B6=80=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/internal/code/ErrorCode.java | 1 + .../internal/controller/MemberController.java | 5 ++-- .../controller/MemberControllerDocs.java | 21 +++++++++++++-- .../dto/request/AgreementRequest.java | 22 +++++++++++++++ .../dto/request/RegisterRequest.java | 12 ++++++--- ...RequiredAgreementNotAcceptedException.java | 11 ++++++++ .../member/internal/mapper/MemberMapper.java | 14 ++++++++++ .../repository/MemberAgreementRepository.java | 16 +++++++++++ .../internal/service/MemberService.java | 27 ++++++++++++++++++- .../service/dto/command/AgreementCommand.java | 10 +++++++ .../service/dto/command/RegisterCommand.java | 6 ++++- 11 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/AgreementRequest.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/exception/RequiredAgreementNotAcceptedException.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/repository/MemberAgreementRepository.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/AgreementCommand.java diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java b/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java index a434d0c..b183173 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/code/ErrorCode.java @@ -12,6 +12,7 @@ public enum ErrorCode implements ErrorResultCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자 정보가 존재하지 않습니다."), ALREADY_REGISTERED_MEMBER(HttpStatus.CONFLICT, "이미 가입된 회원입니다."), + REQUIRED_AGREEMENT_NOT_ACCEPTED(HttpStatus.BAD_REQUEST, "필수 약관에 모두 동의해야 합니다."), INVALID_TIME_ZONE(HttpStatus.BAD_REQUEST, "Time-Zone 헤더 값이 올바르지 않습니다."), UNSUPPORTED_IMAGE_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 파일 확장자입니다."); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java index 81842b3..5c0ae98 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java @@ -53,9 +53,10 @@ public class MemberController implements MemberControllerDocs { @PostMapping(path = "/member/register") public ResponseEntity> register( - @Valid @RequestBody RegisterRequest request + @Valid @RequestBody RegisterRequest request, + @RequestHeader(name = "Time-Zone") ZoneId timeZone ) { - memberService.register(request.toCommand()); + memberService.register(request.toCommand(timeZone)); return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.success(SuccessCode.MEMBER_REGISTER)); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index 635f013..475508e 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -38,7 +38,9 @@ public interface MemberControllerDocs { @Operation( summary = "소셜 회원가입", - description = "OIDC ID 토큰과 닉네임으로 회원가입합니다. 완료 후 POST /auth/login으로 토큰을 발급받으세요." + description = "OIDC ID 토큰, 닉네임, 약관 동의 여부로 회원가입합니다. 필수 약관(서비스 이용약관, 개인정보 처리방침, " + + "위치기반 서비스 이용약관, 만 14세 이상 확인)에 모두 동의해야 하며, 동의 시점은 Time-Zone 헤더 기준으로 저장됩니다. " + + "완료 후 POST /auth/login으로 토큰을 발급받으세요." ) @ApiResponses({ @ApiResponse(responseCode = "201", description = "회원가입 성공"), @@ -47,6 +49,12 @@ public interface MemberControllerDocs { examples = { @ExampleObject(name = "INVALID_NICKNAME_FORMAT", value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"닉네임은 한글, 영어, 숫자만 사용하여 2자 이상 12자 이하로 입력해야 합니다.\"}"), + @ExampleObject(name = "REQUIRED_AGREEMENT_NOT_ACCEPTED", + value = "{\"status\":400,\"code\":\"REQUIRED_AGREEMENT_NOT_ACCEPTED\",\"message\":\"필수 약관에 모두 동의해야 합니다.\"}"), + @ExampleObject(name = "MISSING_REQUEST_HEADER", + value = "{\"status\":400,\"code\":\"MISSING_REQUEST_HEADER\",\"message\":\"필수 요청 헤더가 누락되었습니다.\"}"), + @ExampleObject(name = "INVALID_HEADER", + value = "{\"status\":400,\"code\":\"INVALID_HEADER\",\"message\":\"헤더값이 올바르지 않습니다.\"}"), @ExampleObject(name = "INVALID_REQUEST_BODY", value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}") })), @@ -65,7 +73,16 @@ public interface MemberControllerDocs { examples = @ExampleObject(name = "ALREADY_REGISTERED_MEMBER", value = "{\"status\":409,\"code\":\"ALREADY_REGISTERED_MEMBER\",\"message\":\"이미 가입된 회원입니다.\"}"))) }) - ResponseEntity> register(@Valid @RequestBody RegisterRequest request); + ResponseEntity> register( + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, + content = @Content(schema = @Schema(implementation = RegisterRequest.class), + examples = @ExampleObject(value = "{\"provider\":\"APPLE\",\"idToken\":\"eyJ...\",\"nickname\":\"루티\"," + + "\"agreements\":{\"serviceTerms\":true,\"privacyPolicy\":true,\"locationServiceTerms\":true," + + "\"over14\":true,\"marketingConsent\":false}}"))) + @Valid @RequestBody RegisterRequest request, + @Parameter(description = "IANA Time Zone ID", example = "Asia/Seoul", required = true) + @RequestHeader("Time-Zone") ZoneId timeZone + ); @Operation(summary = "회원 탈퇴", description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다.") @SecurityRequirement(name = "bearerAuth") diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/AgreementRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/AgreementRequest.java new file mode 100644 index 0000000..6e3cbc8 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/AgreementRequest.java @@ -0,0 +1,22 @@ +package org.sopt.routee.member.internal.controller.dto.request; + +import org.sopt.routee.member.internal.service.dto.command.AgreementCommand; + +import jakarta.validation.constraints.NotNull; + +public record AgreementRequest( + @NotNull // 서비스 이용 약관 + Boolean serviceTerms, + @NotNull // 개인정보 처리 방침 + Boolean privacyPolicy, + @NotNull // 위치기반 서비스 이용약관 + Boolean locationServiceTerms, + @NotNull // 만 14세 이상 여부 + Boolean over14, + @NotNull // 마케팅 활용 및 광고성 정보 수신 동의 + Boolean marketingConsent +) { + public AgreementCommand toCommand() { + return new AgreementCommand(serviceTerms, privacyPolicy, locationServiceTerms, over14, marketingConsent); + } +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java index 84a9942..6c2e0bc 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java @@ -1,8 +1,11 @@ package org.sopt.routee.member.internal.controller.dto.request; +import java.time.ZoneId; + import org.sopt.routee.external.api.type.OAuthProvider; import org.sopt.routee.member.internal.service.dto.command.RegisterCommand; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -14,9 +17,12 @@ public record RegisterRequest( String idToken, @NotBlank @Pattern(regexp = "^[가-힣a-zA-Z0-9]{2,12}$", message = "닉네임은 한글, 영어, 숫자만 사용하여 2자 이상 12자 이하로 입력해야 합니다.") - String nickname + String nickname, + @NotNull + @Valid + AgreementRequest agreements ) { - public RegisterCommand toCommand(){ - return new RegisterCommand(provider, idToken, nickname); + public RegisterCommand toCommand(ZoneId timeZone){ + return new RegisterCommand(provider, idToken, nickname, agreements.toCommand(), timeZone); } } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/exception/RequiredAgreementNotAcceptedException.java b/routee-member/src/main/java/org/sopt/routee/member/internal/exception/RequiredAgreementNotAcceptedException.java new file mode 100644 index 0000000..c3c861d --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/exception/RequiredAgreementNotAcceptedException.java @@ -0,0 +1,11 @@ +package org.sopt.routee.member.internal.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.member.internal.code.ErrorCode; + +public class RequiredAgreementNotAcceptedException extends BaseException { + + public RequiredAgreementNotAcceptedException() { + super(ErrorCode.REQUIRED_AGREEMENT_NOT_ACCEPTED); + } +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java index 06daad5..f3e243a 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java @@ -1,11 +1,13 @@ package org.sopt.routee.member.internal.mapper; +import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.List; import org.sopt.routee.activity.api.result.MonthlyActivityDailySummaryResult; +import org.sopt.routee.member.internal.service.dto.command.AgreementCommand; import org.sopt.routee.member.internal.service.dto.command.RegisterCommand; import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.DailySummary; @@ -16,6 +18,7 @@ import org.sopt.routee.member.api.result.TokenClaimsResult; import org.sopt.routee.member.api.type.MemberRole; import org.sopt.routee.member.internal.entity.Member; +import org.sopt.routee.member.internal.entity.MemberAgreement; import org.sopt.routee.util.TimeZoneUtils; import lombok.AccessLevel; @@ -34,6 +37,17 @@ public static Member toEntity(RegisterCommand command, String oauthId) { .build(); } + public static MemberAgreement toAgreementEntity(Member member, AgreementCommand agreement, Instant agreedAt) { + return MemberAgreement.builder() + .member(member) + .serviceTermsAgreedAt(agreement.serviceTerms() ? agreedAt : null) + .privacyPolicyAgreedAt(agreement.privacyPolicy() ? agreedAt : null) + .locationServiceTermsAgreedAt(agreement.locationServiceTerms() ? agreedAt : null) + .over14ConfirmedAt(agreement.over14() ? agreedAt : null) + .marketingConsentAgreedAt(agreement.marketingConsent() ? agreedAt : null) + .build(); + } + public static TokenClaimsResult toTokenClaimsResult(Member member) { return new TokenClaimsResult(member.getId(), member.getRole().name()); } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/repository/MemberAgreementRepository.java b/routee-member/src/main/java/org/sopt/routee/member/internal/repository/MemberAgreementRepository.java new file mode 100644 index 0000000..4bc99d2 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/repository/MemberAgreementRepository.java @@ -0,0 +1,16 @@ +package org.sopt.routee.member.internal.repository; + +import java.util.Optional; + +import org.sopt.routee.member.internal.entity.MemberAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberAgreementRepository extends JpaRepository { + + Optional findByMember_Id(Long memberId); + + boolean existsByMember_Id(Long memberId); + + void deleteByMember_Id(Long memberId); + +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index f9e2e1e..5465f9c 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -1,5 +1,7 @@ package org.sopt.routee.member.internal.service; +import java.time.Instant; +import java.time.LocalDateTime; import java.time.YearMonth; import java.time.ZoneId; import java.util.List; @@ -19,6 +21,7 @@ import org.sopt.routee.external.api.type.OAuthProvider; import org.sopt.routee.external.api.port.OidcVerifyPort; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; +import org.sopt.routee.member.internal.service.dto.command.AgreementCommand; import org.sopt.routee.member.internal.service.dto.command.ProfileImageUploadUrlCommand; import org.sopt.routee.member.internal.service.dto.command.RegisterCommand; import org.sopt.routee.member.internal.service.dto.command.UpdateNicknameCommand; @@ -33,10 +36,13 @@ import org.sopt.routee.member.internal.entity.Member; import org.sopt.routee.member.internal.exception.AlreadyRegisteredMemberException; import org.sopt.routee.member.internal.exception.MemberNotFoundException; +import org.sopt.routee.member.internal.exception.RequiredAgreementNotAcceptedException; import org.sopt.routee.member.internal.exception.UnsupportedImageFileExtensionException; import org.sopt.routee.member.internal.mapper.MemberMapper; +import org.sopt.routee.member.internal.repository.MemberAgreementRepository; import org.sopt.routee.member.internal.repository.MemberRepository; import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator; +import org.sopt.routee.util.TimeZoneUtils; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -53,6 +59,7 @@ public class MemberService { private final OidcVerifyPort oidcVerifyPort; private final ActivityUseCase activityUseCase; private final MemberRepository memberRepository; + private final MemberAgreementRepository memberAgreementRepository; private final ApplicationEventPublisher applicationEventPublisher; private final FileUploadPresignPort fileUploadPresignPort; private final FileImageAccessUrlPort fileImageAccessUrlPort; @@ -75,12 +82,29 @@ public boolean existsById(long memberId) { @Transactional public void register(RegisterCommand command) { + validateRequiredAgreements(command.agreement()); + String oauthId = oidcVerifyPort.extractSubject(command.provider(), command.idToken()); if (memberRepository.existsByOauthIdAndOauthProvider(oauthId, command.provider())) { throw new AlreadyRegisteredMemberException(); } - memberRepository.save(MemberMapper.toEntity(command, oauthId)); + + Member savedMember = memberRepository.save(MemberMapper.toEntity(command, oauthId)); + + Instant agreedAt = TimeZoneUtils.toUtcInstantTime(LocalDateTime.now(command.timeZone()), command.timeZone()); + memberAgreementRepository.save(MemberMapper.toAgreementEntity(savedMember, command.agreement(), agreedAt)); + } + + private void validateRequiredAgreements(AgreementCommand agreement) { + boolean allRequiredAccepted = agreement.serviceTerms() + && agreement.privacyPolicy() + && agreement.locationServiceTerms() + && agreement.over14(); + + if (!allRequiredAccepted) { + throw new RequiredAgreementNotAcceptedException(); + } } public void withdraw(long memberId, String accessTokenHash, String refreshTokenHash) { @@ -88,6 +112,7 @@ public void withdraw(long memberId, String accessTokenHash, String refreshTokenH Member member = memberRepository.findById(memberId) .orElseThrow(MemberNotFoundException::new); + memberAgreementRepository.deleteByMember_Id(memberId); memberRepository.delete(member); activityUseCase.deleteForMemberWithdrawal(memberId); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/AgreementCommand.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/AgreementCommand.java new file mode 100644 index 0000000..602549d --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/AgreementCommand.java @@ -0,0 +1,10 @@ +package org.sopt.routee.member.internal.service.dto.command; + +public record AgreementCommand( + boolean serviceTerms, + boolean privacyPolicy, + boolean locationServiceTerms, + boolean over14, + boolean marketingConsent +) { +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/RegisterCommand.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/RegisterCommand.java index b433fb2..278ab78 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/RegisterCommand.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/RegisterCommand.java @@ -1,10 +1,14 @@ package org.sopt.routee.member.internal.service.dto.command; +import java.time.ZoneId; + import org.sopt.routee.external.api.type.OAuthProvider; public record RegisterCommand( OAuthProvider provider, String idToken, - String nickname + String nickname, + AgreementCommand agreement, + ZoneId timeZone ) { } From 25852180ba93c1cac19dee367099de134d80e0a4 Mon Sep 17 00:00:00 2001 From: kymin Date: Mon, 31 Aug 2026 13:49:09 +0900 Subject: [PATCH 47/68] =?UTF-8?q?fix:=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/controller/dto/request/RegisterRequest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java index ce54f64..8f49b0c 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java @@ -17,9 +17,12 @@ public record RegisterRequest( String idToken, @NotBlank @Pattern(regexp = "^(?=.{1,12}$)[가-힣a-zA-Z0-9]+(?: [가-힣a-zA-Z0-9]+)*$", message = "닉네임은 한글, 영어, 숫자와 공백을 사용하여 1자 이상 12자 이하로 입력해야 하며, 공백은 연속될 수 없습니다.") - String nickname + String nickname, + @NotNull + @Valid + AgreementRequest agreements ) { - public RegisterCommand toCommand(ZoneId timeZone){ + public RegisterCommand toCommand(ZoneId timeZone) { return new RegisterCommand(provider, idToken, nickname, agreements.toCommand(), timeZone); } } From 6f13cca258e346575989da097261667fc06aa194 Mon Sep 17 00:00:00 2001 From: kymin Date: Mon, 31 Aug 2026 13:55:31 +0900 Subject: [PATCH 48/68] =?UTF-8?q?refactor:=20=ED=95=AD=EB=AA=A9=EB=B3=84?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=EB=8C=80=20=EB=B3=84=EB=8F=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/internal/entity/MemberAgreement.java | 16 ++++++++++++++++ .../member/internal/mapper/MemberMapper.java | 9 ++++++++- .../member/internal/service/MemberService.java | 3 ++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberAgreement.java b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberAgreement.java index 4181083..ef027bf 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberAgreement.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberAgreement.java @@ -1,6 +1,7 @@ package org.sopt.routee.member.internal.entity; import java.time.Instant; +import java.time.ZoneId; import org.sopt.routee.entity.BaseEntity; @@ -42,15 +43,30 @@ public class MemberAgreement extends BaseEntity { @Column(name = "service_terms_agreed_at") private Instant serviceTermsAgreedAt; + @Column(name = "service_terms_agreed_zone") + private ZoneId serviceTermsAgreedZone; + @Column(name = "privacy_policy_agreed_at") private Instant privacyPolicyAgreedAt; + @Column(name = "privacy_policy_agreed_zone") + private ZoneId privacyPolicyAgreedZone; + @Column(name = "location_service_terms_agreed_at") private Instant locationServiceTermsAgreedAt; + @Column(name = "location_service_terms_agreed_zone") + private ZoneId locationServiceTermsAgreedZone; + @Column(name = "over14_confirmed_at") private Instant over14ConfirmedAt; + @Column(name = "over14_confirmed_zone") + private ZoneId over14ConfirmedZone; + @Column(name = "marketing_consent_agreed_at") private Instant marketingConsentAgreedAt; + + @Column(name = "marketing_consent_agreed_zone") + private ZoneId marketingConsentAgreedZone; } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java index f3e243a..50661e8 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/mapper/MemberMapper.java @@ -37,14 +37,21 @@ public static Member toEntity(RegisterCommand command, String oauthId) { .build(); } - public static MemberAgreement toAgreementEntity(Member member, AgreementCommand agreement, Instant agreedAt) { + public static MemberAgreement toAgreementEntity( + Member member, AgreementCommand agreement, Instant agreedAt, ZoneId agreedZone + ) { return MemberAgreement.builder() .member(member) .serviceTermsAgreedAt(agreement.serviceTerms() ? agreedAt : null) + .serviceTermsAgreedZone(agreement.serviceTerms() ? agreedZone : null) .privacyPolicyAgreedAt(agreement.privacyPolicy() ? agreedAt : null) + .privacyPolicyAgreedZone(agreement.privacyPolicy() ? agreedZone : null) .locationServiceTermsAgreedAt(agreement.locationServiceTerms() ? agreedAt : null) + .locationServiceTermsAgreedZone(agreement.locationServiceTerms() ? agreedZone : null) .over14ConfirmedAt(agreement.over14() ? agreedAt : null) + .over14ConfirmedZone(agreement.over14() ? agreedZone : null) .marketingConsentAgreedAt(agreement.marketingConsent() ? agreedAt : null) + .marketingConsentAgreedZone(agreement.marketingConsent() ? agreedZone : null) .build(); } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index ced21c2..fd2f499 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -93,7 +93,8 @@ public void register(RegisterCommand command) { Member savedMember = memberRepository.save(MemberMapper.toEntity(command, oauthId)); Instant agreedAt = TimeZoneUtils.toUtcInstantTime(LocalDateTime.now(command.timeZone()), command.timeZone()); - memberAgreementRepository.save(MemberMapper.toAgreementEntity(savedMember, command.agreement(), agreedAt)); + memberAgreementRepository.save( + MemberMapper.toAgreementEntity(savedMember, command.agreement(), agreedAt, command.timeZone())); } private void validateRequiredAgreements(AgreementCommand agreement) { From 2706fccf4abf5aedcbca10481ccce19561a6b9cc Mon Sep 17 00:00:00 2001 From: kymin Date: Tue, 1 Sep 2026 11:48:05 +0900 Subject: [PATCH 49/68] =?UTF-8?q?hotfix:=20apple=20bundle=20id=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-common.yml | 1 + docker-compose.yml | 2 ++ routee-app/src/main/resources/application.yml | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-common.yml b/.github/workflows/deploy-common.yml index 536dfa9..1190662 100644 --- a/.github/workflows/deploy-common.yml +++ b/.github/workflows/deploy-common.yml @@ -78,5 +78,6 @@ jobs: export S3_REGION="${{ secrets.S3_REGION }}" export S3_BUCKET="${{ secrets[format('{0}S3_BUCKET', inputs.secret_prefix)] }}" export S3_ENDPOINT="${{ secrets[format('{0}S3_ENDPOINT', inputs.secret_prefix)] }}" + export APPLE_CLIENT_ID="${{ secrets[format('{0}APPLE_CLIENT_ID', inputs.secret_prefix)] }}" cd ~/app && bash scripts/deploy.sh ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ inputs.environment }}-${{ github.sha }} diff --git a/docker-compose.yml b/docker-compose.yml index 19754fb..72fcfff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: S3_REGION: ${S3_REGION} S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} + APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} depends_on: redis: condition: service_healthy @@ -57,6 +58,7 @@ services: S3_REGION: ${S3_REGION} S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} + APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} depends_on: redis: condition: service_healthy diff --git a/routee-app/src/main/resources/application.yml b/routee-app/src/main/resources/application.yml index f431fb6..9cca958 100644 --- a/routee-app/src/main/resources/application.yml +++ b/routee-app/src/main/resources/application.yml @@ -33,7 +33,7 @@ oidc: apple: jwk-set-uri: https://appleid.apple.com/auth/keys issuer: https://appleid.apple.com - client-id: ${APPLE_CLIENT_ID:com.Routee-iOS} + client-id: ${APPLE_CLIENT_ID} jwt: secret: ${JWT_SECRET} From a966c19a4e9ef1c6a1a86c8e39b2c2aa99d5dba0 Mon Sep 17 00:00:00 2001 From: kymin Date: Thu, 3 Sep 2026 14:42:03 +0900 Subject: [PATCH 50/68] =?UTF-8?q?hotfix:=20=ED=99=9C=EB=8F=99=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=A4=91=EB=B3=B5=20=EC=9A=94=EC=B2=AD=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routee/activity/internal/service/ActivityService.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index 7f29612..18f2897 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -200,6 +200,10 @@ public void complete(CompleteActivityCommand command) { Activity activity = activityRepository.findByIdAndMemberId(command.activityId(), command.memberId()) .orElseThrow(ActivityNotFoundException::new); + if (activity.getActivityStatus().isCompleted()) { + throw new ActivityAlreadyCompletedException(); + } + Instant endedAt = TimeZoneUtils.toUtcInstantTime(command.endedAt(), command.timeZone()); String coverImageObjectKey = resolveCoverImageObjectKey(command.activityId()); LocalDate activityDate = TimeZoneUtils.toLocalDate(activity.getStartedAt(), command.timeZone()); From 8acf51d9f1bc21023ae19b22031618806090ceeb Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Thu, 3 Sep 2026 21:03:50 +0900 Subject: [PATCH 51/68] =?UTF-8?q?fix:=20=EB=A3=A8=ED=8A=B8=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=A0=9C=EC=95=BD=20=EC=A1=B0=EA=B1=B4=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../activity/internal/controller/RouteControllerDocs.java | 6 +++--- .../controller/dto/request/CreateRoutesRequest.java | 6 +++--- .../internal/controller/dto/request/RouteRequest.java | 2 ++ .../sopt/routee/activity/internal/entity/route/Route.java | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java index 42b249d..19b386b 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java @@ -29,10 +29,10 @@ public interface RouteControllerDocs { @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { - @ExampleObject(name = "ROUTES_EMPTY", - value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"루트 목록 생성 시 최소 1개 이상의 루트를 입력해야 합니다.\"}"), @ExampleObject(name = "ROUTES_TOO_MANY", - value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"루트는 최대 12개까지 등록할 수 있습니다.\"}") + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"루트는 최대 20개까지 등록할 수 있습니다.\"}"), + @ExampleObject(name = "ROUTE_NAME_TOO_LONG", + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"name은 16자 이하여야 합니다.\"}") })), @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))), diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java index af085aa..72cea97 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java @@ -5,13 +5,13 @@ import org.sopt.routee.activity.internal.service.dto.command.CreateRouteCommand; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record CreateRoutesRequest( @Valid - @NotEmpty(message = "루트 목록 생성 시 최소 1개 이상의 루트를 입력해야 합니다.") - @Size(max = 12, message = "루트는 최대 12개까지 등록할 수 있습니다.") + @NotNull(message = "routes는 필수입니다.") + @Size(max = 20, message = "루트는 최대 20개까지 등록할 수 있습니다.") List routes ) { public List toCommands() { diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RouteRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RouteRequest.java index e5bc2c4..e757138 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RouteRequest.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RouteRequest.java @@ -5,9 +5,11 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; public record RouteRequest( @NotBlank(message = "name은 필수입니다.") + @Size(max = 16, message = "name은 16자 이하여야 합니다.") String name, @NotNull(message = "sequence는 필수입니다.") diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/route/Route.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/route/Route.java index 431cabf..125b60b 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/route/Route.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/route/Route.java @@ -36,7 +36,7 @@ public class Route { @Tsid private Long id; - @Column(name = "name", nullable = false) + @Column(name = "name", nullable = false, length = 16) private String name; @Column(name = "sequence", nullable = false) From 172fcc5233b327c3e6edc2113d920b1d22e8ff21 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Thu, 3 Sep 2026 21:20:44 +0900 Subject: [PATCH 52/68] =?UTF-8?q?fix:=20=EB=A3=A8=ED=8A=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B9=88=20=EB=B0=B0=EC=97=B4=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20=EB=B0=A9=EC=A7=80=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../activity/internal/controller/RouteControllerDocs.java | 2 ++ .../internal/controller/dto/request/CreateRoutesRequest.java | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java index 19b386b..c13f9dd 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java @@ -29,6 +29,8 @@ public interface RouteControllerDocs { @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { + @ExampleObject(name = "ROUTES_EMPTY", + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"루트 목록 생성 시 최소 1개 이상의 루트를 입력해야 합니다.\"}"), @ExampleObject(name = "ROUTES_TOO_MANY", value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"루트는 최대 20개까지 등록할 수 있습니다.\"}"), @ExampleObject(name = "ROUTE_NAME_TOO_LONG", diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java index 72cea97..05ccaa3 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java @@ -5,12 +5,12 @@ import org.sopt.routee.activity.internal.service.dto.command.CreateRouteCommand; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; public record CreateRoutesRequest( @Valid - @NotNull(message = "routes는 필수입니다.") + @NotEmpty(message = "루트 목록 생성 시 최소 1개 이상의 루트를 입력해야 합니다.") @Size(max = 20, message = "루트는 최대 20개까지 등록할 수 있습니다.") List routes ) { From 21f3c664dd38feaf6e2410fefa2266c0d5bf2093 Mon Sep 17 00:00:00 2001 From: youtheyeon Date: Fri, 4 Sep 2026 21:00:26 +0900 Subject: [PATCH 53/68] =?UTF-8?q?fix:=20=ED=8A=B9=EC=A0=95=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=ED=99=9C=EB=8F=99=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=B5=9C=EC=8B=A0=EC=88=9C=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../activity/internal/repository/ActivityRepository.java | 7 ------- .../routee/activity/internal/service/ActivityService.java | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java index 962c5bb..a33badb 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityRepository.java @@ -26,13 +26,6 @@ List findIdsByMemberIdAndActivityStatusIn( Optional findByIdAndMemberId(Long id, Long memberId); - List findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedAtAsc( - Long memberId, - ActivityStatus activityStatus, - Instant startedAtFrom, - Instant startedAtTo - ); - List findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedAtDesc( Long memberId, ActivityStatus activityStatus, diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index 18f2897..f5bc6ac 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -277,7 +277,7 @@ public ActivitiesByDateResult getActivitiesByDate(Long memberId, LocalDate date, Instant startedAtTo = TimeZoneUtils.toUtcInstant(date.plusDays(1), timeZone).minusNanos(1); List activities = activityRepository - .findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedAtAsc( + .findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedAtDesc( memberId, ActivityStatus.ACTIVITY_COMPLETED, startedAtFrom, startedAtTo ) .stream() From d23111c6b5525316a17495ab05a9ef076f0429c0 Mon Sep 17 00:00:00 2001 From: khj011219 Date: Sun, 6 Sep 2026 22:38:38 +0900 Subject: [PATCH 54/68] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-common.yml | 1 + docker-compose.yml | 2 ++ routee-app/src/main/resources/application.yml | 4 ++++ .../org/sopt/routee/external/api/type/OAuthProvider.java | 5 +++-- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-common.yml b/.github/workflows/deploy-common.yml index 1190662..b680e33 100644 --- a/.github/workflows/deploy-common.yml +++ b/.github/workflows/deploy-common.yml @@ -79,5 +79,6 @@ jobs: export S3_BUCKET="${{ secrets[format('{0}S3_BUCKET', inputs.secret_prefix)] }}" export S3_ENDPOINT="${{ secrets[format('{0}S3_ENDPOINT', inputs.secret_prefix)] }}" export APPLE_CLIENT_ID="${{ secrets[format('{0}APPLE_CLIENT_ID', inputs.secret_prefix)] }}" + export GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" cd ~/app && bash scripts/deploy.sh ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ inputs.environment }}-${{ github.sha }} diff --git a/docker-compose.yml b/docker-compose.yml index 72fcfff..8b2e554 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: redis: condition: service_healthy @@ -59,6 +60,7 @@ services: S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: redis: condition: service_healthy diff --git a/routee-app/src/main/resources/application.yml b/routee-app/src/main/resources/application.yml index 9cca958..b38d065 100644 --- a/routee-app/src/main/resources/application.yml +++ b/routee-app/src/main/resources/application.yml @@ -34,6 +34,10 @@ oidc: jwk-set-uri: https://appleid.apple.com/auth/keys issuer: https://appleid.apple.com client-id: ${APPLE_CLIENT_ID} + google: + jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs + issuer: https://accounts.google.com + client-id: ${GOOGLE_CLIENT_ID} jwt: secret: ${JWT_SECRET} diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/type/OAuthProvider.java b/routee-external/src/main/java/org/sopt/routee/external/api/type/OAuthProvider.java index 3dea0b4..fbe65f2 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/type/OAuthProvider.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/type/OAuthProvider.java @@ -1,5 +1,6 @@ package org.sopt.routee.external.api.type; public enum OAuthProvider { - APPLE -} \ No newline at end of file + APPLE, + GOOGLE +} From a3ddfb5bb62248dda8e4f6628bcd2ea3846ce34c Mon Sep 17 00:00:00 2001 From: kymin Date: Sun, 6 Sep 2026 22:48:54 +0900 Subject: [PATCH 55/68] =?UTF-8?q?fix:=20=EB=A6=AC=EC=8A=A4=EB=84=88=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/internal/listener/AuthMemberEventListener.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java index bb24727..d89ed64 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java @@ -2,8 +2,9 @@ import org.sopt.routee.auth.internal.service.AuthService; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; -import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; import lombok.RequiredArgsConstructor; @@ -13,7 +14,8 @@ class AuthMemberEventListener { private final AuthService authService; - @ApplicationModuleListener + @Async + @TransactionalEventListener(fallbackExecution = true) void handleMemberWithdrawnEvent(MemberWithdrawnEvent event) { authService.revokeTokens(event.accessTokenHash(), event.refreshTokenHash()); } From a0d385e07ff3f743082fae706dd70b0344e98dfa Mon Sep 17 00:00:00 2001 From: kymin Date: Sun, 6 Sep 2026 22:50:34 +0900 Subject: [PATCH 56/68] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=8B=9C=20=EB=82=99=EA=B4=80=EC=A0=81=20=EB=9D=BD?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/MemberService.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index fd2f499..421e7dc 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -44,6 +44,7 @@ import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator; import org.sopt.routee.util.TimeZoneUtils; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -109,17 +110,21 @@ private void validateRequiredAgreements(AgreementCommand agreement) { } public void withdraw(long memberId, String accessTokenHash, String refreshTokenHash) { - transactionTemplate.executeWithoutResult(status -> { - Member member = memberRepository.findById(memberId) - .orElseThrow(MemberNotFoundException::new); + try { + transactionTemplate.executeWithoutResult(status -> { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); - memberAgreementRepository.deleteByMember_Id(memberId); - memberRepository.delete(member); + memberAgreementRepository.deleteByMember_Id(memberId); + memberRepository.delete(member); - activityUseCase.deleteForMemberWithdrawal(memberId); + activityUseCase.deleteForMemberWithdrawal(memberId); + }); + } catch (ObjectOptimisticLockingFailureException e) { + throw new MemberNotFoundException(); + } - applicationEventPublisher.publishEvent(new MemberWithdrawnEvent(memberId, accessTokenHash, refreshTokenHash)); - }); + applicationEventPublisher.publishEvent(new MemberWithdrawnEvent(memberId, accessTokenHash, refreshTokenHash)); Thread.startVirtualThread(() -> deleteMemberImages(memberId)); } From c4f46d43a389892509555ae9aa78bb99d4241484 Mon Sep 17 00:00:00 2001 From: kymin Date: Tue, 8 Sep 2026 16:36:19 +0900 Subject: [PATCH 57/68] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EB=8F=99=EC=8B=9C=20=EC=9A=94=EC=B2=AD=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/MemberServiceTest.java | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java diff --git a/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java b/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java new file mode 100644 index 0000000..30ea186 --- /dev/null +++ b/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java @@ -0,0 +1,142 @@ +package org.sopt.routee.member.internal.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; +import java.util.function.Consumer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.routee.activity.api.usecase.ActivityUseCase; +import org.sopt.routee.external.api.port.FileDeletePort; +import org.sopt.routee.external.api.port.FileImageAccessUrlPort; +import org.sopt.routee.external.api.port.FileUploadPresignPort; +import org.sopt.routee.external.api.port.OidcVerifyPort; +import org.sopt.routee.member.api.event.MemberWithdrawnEvent; +import org.sopt.routee.member.internal.entity.Member; +import org.sopt.routee.member.internal.exception.MemberNotFoundException; +import org.sopt.routee.member.internal.repository.MemberAgreementRepository; +import org.sopt.routee.member.internal.repository.MemberRepository; +import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + private static final long MEMBER_ID = 1L; + private static final String ACCESS_TOKEN_HASH = "access-hash"; + private static final String REFRESH_TOKEN_HASH = "refresh-hash"; + + @Mock + private OidcVerifyPort oidcVerifyPort; + + @Mock + private ActivityUseCase activityUseCase; + + @Mock + private MemberRepository memberRepository; + + @Mock + private MemberAgreementRepository memberAgreementRepository; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Mock + private FileUploadPresignPort fileUploadPresignPort; + + @Mock + private FileImageAccessUrlPort fileImageAccessUrlPort; + + @Mock + private FileDeletePort fileDeletePort; + + @Mock + private ProfileImageFileNameValidator profileImageFileNameValidator; + + @Mock + private TransactionTemplate transactionTemplate; + + private MemberService memberService; + + @BeforeEach + void setUp() { + memberService = new MemberService( + oidcVerifyPort, + activityUseCase, + memberRepository, + memberAgreementRepository, + applicationEventPublisher, + fileUploadPresignPort, + fileImageAccessUrlPort, + fileDeletePort, + profileImageFileNameValidator, + transactionTemplate + ); + } + + @SuppressWarnings("unchecked") + private void stubTransactionTemplateToRunCallback() { + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); + callback.accept(mock(TransactionStatus.class)); + return null; + }).when(transactionTemplate).executeWithoutResult(any(Consumer.class)); + } + + @Test + @DisplayName("withdraw: 정상적으로 회원을 탈퇴시키고 토큰 무효화 이벤트를 발행한다") + void withdraw_success_publishesMemberWithdrawnEvent() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + + // when + memberService.withdraw(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH); + + // then + verify(memberAgreementRepository).deleteByMember_Id(MEMBER_ID); + verify(memberRepository).delete(member); + verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(MemberWithdrawnEvent.class); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue()) + .extracting( + MemberWithdrawnEvent::memberId, + MemberWithdrawnEvent::accessTokenHash, + MemberWithdrawnEvent::refreshTokenHash) + .containsExactly(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH); + } + + @Test + @DisplayName("withdraw: 동시 탈퇴 요청으로 낙관적 락 예외가 발생하면 회원 없음 예외로 변환한다") + void withdraw_concurrentWithdrawal_throwsMemberNotFoundException() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + doThrow(new ObjectOptimisticLockingFailureException(Member.class, MEMBER_ID)) + .when(memberAgreementRepository).deleteByMember_Id(MEMBER_ID); + + // when + Throwable thrown = catchThrowable( + () -> memberService.withdraw(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH)); + + // then + assertThat(thrown).isInstanceOf(MemberNotFoundException.class); + verify(activityUseCase, never()).deleteForMemberWithdrawal(anyLong()); + verify(applicationEventPublisher, never()).publishEvent(any()); + } +} From 71d7b8fc5b51cc72e2c14ba25a2daabbcdc9e1c5 Mon Sep 17 00:00:00 2001 From: kymin Date: Tue, 15 Sep 2026 21:13:35 +0900 Subject: [PATCH 58/68] =?UTF-8?q?feat:=20revoke=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=ED=98=B8=EC=B6=9C=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 --- .../external/api/port/OAuthRevokePort.java | 8 ++++++ .../internal/controller/MemberController.java | 2 +- .../controller/MemberControllerDocs.java | 7 ++++-- .../dto/request/WithdrawRequest.java | 12 ++++++++- .../internal/service/MemberService.java | 25 ++++++++++++++++--- .../service/dto/command/WithdrawCommand.java | 9 +++++++ 6 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java create mode 100644 routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/WithdrawCommand.java diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java new file mode 100644 index 0000000..a4de2ee --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java @@ -0,0 +1,8 @@ +package org.sopt.routee.external.api.port; + +import org.sopt.routee.external.api.type.OAuthProvider; + +public interface OAuthRevokePort { + + void revoke(OAuthProvider provider, String authorizationCode); +} diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java index 93defed..51bb70d 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberController.java @@ -71,7 +71,7 @@ public ResponseEntity> withdraw( String accessTokenHash = TokenHasher.hash(TokenExtractor.extract(accessTokenWithBearer)); String refreshTokenHash = TokenHasher.hash(request.refreshToken()); - memberService.withdraw(memberId, accessTokenHash, refreshTokenHash); + memberService.withdraw(request.toCommand(memberId, accessTokenHash, refreshTokenHash)); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponse.success(SuccessCode.MEMBER_WITHDRAW)); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index dd8870a..76878e4 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -84,14 +84,17 @@ ResponseEntity> register( @RequestHeader("Time-Zone") ZoneId timeZone ); - @Operation(summary = "회원 탈퇴", description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다.") + @Operation(summary = "회원 탈퇴", + description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. " + + "함께 전달한 authorization_code로 소셜 로그인(Apple/Google) 연동도 해제합니다. " + + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, 연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @ApiResponse(responseCode = "200", description = "탈퇴 성공"), @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = @ExampleObject(name = "INVALID_INPUT_VALUE", - value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"refresh_token은 필수입니다.\"}"))), + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"authorization_code는 필수입니다.\"}"))), @ApiResponse(responseCode = "401", description = "만료되었거나 유효하지 않은 액세스 토큰", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java index 673d537..23f10f0 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java @@ -1,9 +1,19 @@ package org.sopt.routee.member.internal.controller.dto.request; +import org.sopt.routee.member.internal.service.dto.command.WithdrawCommand; + +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; public record WithdrawRequest( @NotBlank(message = "refresh_token은 필수입니다.") - String refreshToken + String refreshToken, + + @Schema(description = "탈퇴 시점에 재인증하여 발급받은 소셜 로그인 인가 코드. provider와의 연동 해제에 사용됩니다.") + @NotBlank(message = "authorization_code는 필수입니다.") + String authorizationCode ) { + public WithdrawCommand toCommand(Long memberId, String accessTokenHash, String refreshTokenHash) { + return new WithdrawCommand(memberId, accessTokenHash, refreshTokenHash, authorizationCode); + } } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index 421e7dc..cc77937 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -19,6 +19,7 @@ import org.sopt.routee.external.api.result.FileUploadPresignResult; import org.sopt.routee.external.api.type.FileUploadDirectory; import org.sopt.routee.external.api.type.OAuthProvider; +import org.sopt.routee.external.api.port.OAuthRevokePort; import org.sopt.routee.external.api.port.OidcVerifyPort; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; import org.sopt.routee.member.internal.service.dto.command.AgreementCommand; @@ -26,6 +27,7 @@ import org.sopt.routee.member.internal.service.dto.command.RegisterCommand; import org.sopt.routee.member.internal.service.dto.command.UpdateNicknameCommand; import org.sopt.routee.member.internal.service.dto.command.UpdateProfileImageCommand; +import org.sopt.routee.member.internal.service.dto.command.WithdrawCommand; import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; @@ -58,6 +60,7 @@ public class MemberService { private final OidcVerifyPort oidcVerifyPort; + private final OAuthRevokePort oAuthRevokePort; private final ActivityUseCase activityUseCase; private final MemberRepository memberRepository; private final MemberAgreementRepository memberAgreementRepository; @@ -109,9 +112,12 @@ private void validateRequiredAgreements(AgreementCommand agreement) { } } - public void withdraw(long memberId, String accessTokenHash, String refreshTokenHash) { + public void withdraw(WithdrawCommand command) { + long memberId = command.memberId(); + + OAuthProvider oauthProvider; try { - transactionTemplate.executeWithoutResult(status -> { + oauthProvider = transactionTemplate.execute(status -> { Member member = memberRepository.findById(memberId) .orElseThrow(MemberNotFoundException::new); @@ -119,16 +125,29 @@ public void withdraw(long memberId, String accessTokenHash, String refreshTokenH memberRepository.delete(member); activityUseCase.deleteForMemberWithdrawal(memberId); + + return member.getOauthProvider(); }); } catch (ObjectOptimisticLockingFailureException e) { throw new MemberNotFoundException(); } - applicationEventPublisher.publishEvent(new MemberWithdrawnEvent(memberId, accessTokenHash, refreshTokenHash)); + revokeOAuthConnection(memberId, oauthProvider, command.authorizationCode()); + + applicationEventPublisher.publishEvent( + new MemberWithdrawnEvent(memberId, command.accessTokenHash(), command.refreshTokenHash())); Thread.startVirtualThread(() -> deleteMemberImages(memberId)); } + private void revokeOAuthConnection(long memberId, OAuthProvider oauthProvider, String authorizationCode) { + try { + oAuthRevokePort.revoke(oauthProvider, authorizationCode); + } catch (BaseException e) { + log.warn("OAuth revoke failed. memberId={}, provider={}", memberId, oauthProvider, e); + } + } + private void deleteMemberImages(long memberId) { try { fileDeletePort.deleteDirectory(new FileDeleteDirectoryCommand(Long.toString(memberId))); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/WithdrawCommand.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/WithdrawCommand.java new file mode 100644 index 0000000..a2f4166 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/WithdrawCommand.java @@ -0,0 +1,9 @@ +package org.sopt.routee.member.internal.service.dto.command; + +public record WithdrawCommand( + Long memberId, + String accessTokenHash, + String refreshTokenHash, + String authorizationCode +) { +} From b16b6e783d4f114259bd90e58bf5678c44bce324 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:14:55 +0900 Subject: [PATCH 59/68] =?UTF-8?q?chore:=20=EC=95=A0=ED=94=8C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=B0=9C=EA=B8=89=EC=9A=A9=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=9D=98=EC=A1=B4=EC=84=B1=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 --- routee-external/build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routee-external/build.gradle b/routee-external/build.gradle index c998db4..31f873e 100644 --- a/routee-external/build.gradle +++ b/routee-external/build.gradle @@ -6,6 +6,9 @@ dependencies { // OAuth implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + // Apple client secret nimbus + implementation 'com.nimbusds:nimbus-jose-jwt' + // AWS implementation platform("software.amazon.awssdk:bom:${rootProject.ext['awsSdkVersion']}") implementation 'software.amazon.awssdk:s3' From 0d66a33ef65646cefbffd89b29fb02e8ee357431 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:15:59 +0900 Subject: [PATCH 60/68] =?UTF-8?q?refactor:=20revoke=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=B0=8F=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/routee/external/api/port/OAuthRevokePort.java | 4 +--- .../sopt/routee/member/internal/service/MemberService.java | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java index a4de2ee..cd8776d 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java @@ -1,8 +1,6 @@ package org.sopt.routee.external.api.port; -import org.sopt.routee.external.api.type.OAuthProvider; - public interface OAuthRevokePort { - void revoke(OAuthProvider provider, String authorizationCode); + void revoke(String authorizationCode); } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java index cc77937..3c81d2a 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java @@ -141,8 +141,12 @@ public void withdraw(WithdrawCommand command) { } private void revokeOAuthConnection(long memberId, OAuthProvider oauthProvider, String authorizationCode) { + if (oauthProvider != OAuthProvider.APPLE) { + return; + } + try { - oAuthRevokePort.revoke(oauthProvider, authorizationCode); + oAuthRevokePort.revoke(authorizationCode); } catch (BaseException e) { log.warn("OAuth revoke failed. memberId={}, provider={}", memberId, oauthProvider, e); } From b15aebd5828281177447c72e82ebac0a9af1f418 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:17:07 +0900 Subject: [PATCH 61/68] =?UTF-8?q?feat:=20revoke=EC=9A=A9=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/controller/dto/request/WithdrawRequest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java index 23f10f0..51c4cd8 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java @@ -9,8 +9,7 @@ public record WithdrawRequest( @NotBlank(message = "refresh_token은 필수입니다.") String refreshToken, - @Schema(description = "탈퇴 시점에 재인증하여 발급받은 소셜 로그인 인가 코드. provider와의 연동 해제에 사용됩니다.") - @NotBlank(message = "authorization_code는 필수입니다.") + @Schema(description = "탈퇴 시점에 재인증하여 발급받은 Apple 인가 코드. Apple 계정 연동 해제에 사용되며, Apple 로그인 회원만 필요합니다.") String authorizationCode ) { public WithdrawCommand toCommand(Long memberId, String accessTokenHash, String refreshTokenHash) { From 45e4bb3a614de6019af568b8906e774498bcb98c Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:51:21 +0900 Subject: [PATCH 62/68] =?UTF-8?q?chore:=20revoke=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++++++ .github/workflows/deploy-common.yml | 3 +++ docker-compose.yml | 6 ++++++ routee-app/src/main/resources/application.yml | 11 +++++++++++ 4 files changed, 26 insertions(+) diff --git a/.env.example b/.env.example index 5912fa3..bcb0e09 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ JWT_SECRET= # OIDC APPLE_CLIENT_ID=com.Routee-iOS +# OAuth revoke +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= +GOOGLE_CLIENT_ID= + # Redis REDIS_HOST= REDIS_PORT= diff --git a/.github/workflows/deploy-common.yml b/.github/workflows/deploy-common.yml index b680e33..b976291 100644 --- a/.github/workflows/deploy-common.yml +++ b/.github/workflows/deploy-common.yml @@ -79,6 +79,9 @@ jobs: export S3_BUCKET="${{ secrets[format('{0}S3_BUCKET', inputs.secret_prefix)] }}" export S3_ENDPOINT="${{ secrets[format('{0}S3_ENDPOINT', inputs.secret_prefix)] }}" export APPLE_CLIENT_ID="${{ secrets[format('{0}APPLE_CLIENT_ID', inputs.secret_prefix)] }}" + export APPLE_TEAM_ID="${{ secrets[format('{0}APPLE_TEAM_ID', inputs.secret_prefix)] }}" + export APPLE_KEY_ID="${{ secrets[format('{0}APPLE_KEY_ID', inputs.secret_prefix)] }}" + export APPLE_PRIVATE_KEY="${{ secrets[format('{0}APPLE_PRIVATE_KEY', inputs.secret_prefix)] }}" export GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" cd ~/app && bash scripts/deploy.sh ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ inputs.environment }}-${{ github.sha }} diff --git a/docker-compose.yml b/docker-compose.yml index 8b2e554..ca3aea2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,9 @@ services: S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} + APPLE_TEAM_ID: ${APPLE_TEAM_ID} + APPLE_KEY_ID: ${APPLE_KEY_ID} + APPLE_PRIVATE_KEY: ${APPLE_PRIVATE_KEY} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: redis: @@ -60,6 +63,9 @@ services: S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} + APPLE_TEAM_ID: ${APPLE_TEAM_ID} + APPLE_KEY_ID: ${APPLE_KEY_ID} + APPLE_PRIVATE_KEY: ${APPLE_PRIVATE_KEY} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: redis: diff --git a/routee-app/src/main/resources/application.yml b/routee-app/src/main/resources/application.yml index b38d065..062f310 100644 --- a/routee-app/src/main/resources/application.yml +++ b/routee-app/src/main/resources/application.yml @@ -39,6 +39,17 @@ oidc: issuer: https://accounts.google.com client-id: ${GOOGLE_CLIENT_ID} +oauth: + providers: + apple: + token-uri: https://appleid.apple.com/auth/token + revoke-uri: https://appleid.apple.com/auth/revoke + audience: https://appleid.apple.com + client-id: ${APPLE_CLIENT_ID} + team-id: ${APPLE_TEAM_ID} + key-id: ${APPLE_KEY_ID} + private-key: ${APPLE_PRIVATE_KEY} + jwt: secret: ${JWT_SECRET} issuer: org.routee From 3d99bca1347eaa167f804c58eb4afce35de04805 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:58:10 +0900 Subject: [PATCH 63/68] =?UTF-8?q?feat:=20revoke=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/AppleClientSecretGenerator.java | 81 +++++++++++++++++++ .../adapter/AppleOAuthRevokeAdapter.java | 81 +++++++++++++++++++ .../oauth/adapter/OAuthTokenResponse.java | 8 ++ .../internal/oauth/code/ErrorCode.java | 18 +++++ .../oauth/config/OAuthRevokeClientConfig.java | 14 ++++ .../oauth/config/OAuthRevokeProperty.java | 21 +++++ .../exception/AppleClientSecretException.java | 11 +++ .../oauth/exception/OAuthRevokeException.java | 15 ++++ 8 files changed, 249 insertions(+) create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java create mode 100644 routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java new file mode 100644 index 0000000..78474f8 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java @@ -0,0 +1,81 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.concurrent.atomic.AtomicReference; + +import org.sopt.routee.external.internal.oauth.config.OAuthRevokeProperty; +import org.sopt.routee.external.internal.oauth.exception.AppleClientSecretException; +import org.springframework.stereotype.Component; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +/** + * Apple client secret(ES256로 서명한 JWT)을 생성한다. + *

+ * 서명 비용을 줄이기 위해 만료 전까지 캐시한다. Apple은 최대 6개월까지 허용하지만 보수적으로 짧게 잡는다. + */ +@Component +class AppleClientSecretGenerator { + + private static final Duration TOKEN_EXPIRY = Duration.ofMinutes(30); + private static final Duration CACHE_TTL = Duration.ofMinutes(25); + + private final OAuthRevokeProperty property; + private final AtomicReference cache = new AtomicReference<>(); + + AppleClientSecretGenerator(OAuthRevokeProperty property) { + this.property = property; + } + + String generate() { + CachedSecret cached = cache.get(); + if (cached != null && cached.expiresAt().isAfter(Instant.now())) { + return cached.value(); + } + + String secret = sign(); + cache.set(new CachedSecret(secret, Instant.now().plus(CACHE_TTL))); + return secret; + } + + private String sign() { + try { + Instant now = Instant.now(); + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(property.keyId()).build(), + new JWTClaimsSet.Builder() + .issuer(property.teamId()) + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plus(TOKEN_EXPIRY))) + .audience(property.audience()) + .subject(property.clientId()) + .build() + ); + jwt.sign(new ECDSASigner(parsePrivateKey(property.privateKey()))); + return jwt.serialize(); + } catch (JOSEException | GeneralSecurityException | IllegalArgumentException e) { + throw new AppleClientSecretException(e); + } + } + + private ECPrivateKey parsePrivateKey(String privateKey) throws GeneralSecurityException { + byte[] der = Base64.getDecoder().decode(privateKey); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return (ECPrivateKey)keyFactory.generatePrivate(new PKCS8EncodedKeySpec(der)); + } + + private record CachedSecret(String value, Instant expiresAt) { + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java new file mode 100644 index 0000000..62f0ccc --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java @@ -0,0 +1,81 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import org.sopt.routee.external.api.port.OAuthRevokePort; +import org.sopt.routee.external.internal.oauth.config.OAuthRevokeProperty; +import org.sopt.routee.external.internal.oauth.exception.OAuthRevokeException; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +class AppleOAuthRevokeAdapter implements OAuthRevokePort { + + private static final String REFRESH_TOKEN_HINT = "refresh_token"; + + private final RestClient restClient; + private final OAuthRevokeProperty property; + private final AppleClientSecretGenerator clientSecretGenerator; + + @Override + public void revoke(String authorizationCode) { + OAuthTokenResponse token = exchangeAuthorizationCode(authorizationCode); + + if (!StringUtils.hasText(token.refreshToken())) { + throw new OAuthRevokeException(); + } + + requestRevoke(token.refreshToken(), REFRESH_TOKEN_HINT); + } + + private OAuthTokenResponse exchangeAuthorizationCode(String authorizationCode) { + MultiValueMap form = credentialForm(); + + form.add("grant_type", "authorization_code"); + form.add("code", authorizationCode); + + OAuthTokenResponse response = post(property.tokenUri(), form, OAuthTokenResponse.class); + + if (response == null) { + throw new OAuthRevokeException(); + } + + return response; + } + + private void requestRevoke(String token, String tokenTypeHint) { + MultiValueMap form = credentialForm(); + form.add("token", token); + form.add("token_type_hint", tokenTypeHint); + + post(property.revokeUri(), form, Void.class); + } + + private T post(String uri, MultiValueMap form, Class responseType) { + try { + return restClient.post() + .uri(uri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(form) + .retrieve() + .body(responseType); + } catch (RestClientException e) { + throw new OAuthRevokeException(e); + } + } + + private MultiValueMap credentialForm() { + MultiValueMap form = new LinkedMultiValueMap<>(); + + form.add("client_id", property.clientId()); + form.add("client_secret", clientSecretGenerator.generate()); + + return form; + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java new file mode 100644 index 0000000..a33fb1a --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java @@ -0,0 +1,8 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import com.fasterxml.jackson.annotation.JsonProperty; + +record OAuthTokenResponse( + @JsonProperty("refresh_token") String refreshToken +) { +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java new file mode 100644 index 0000000..65e6891 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java @@ -0,0 +1,18 @@ +package org.sopt.routee.external.internal.oauth.code; + +import org.sopt.routee.code.ErrorResultCode; +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode implements ErrorResultCode { + + OAUTH_REVOKE_FAILED(HttpStatus.BAD_GATEWAY, "소셜 로그인 연동 해제에 실패했습니다."), + APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple client secret 생성에 실패했습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java new file mode 100644 index 0000000..ee1c6b3 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java @@ -0,0 +1,14 @@ +package org.sopt.routee.external.internal.oauth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +class OAuthRevokeClientConfig { + + @Bean + RestClient oauthRevokeRestClient() { + return RestClient.create(); + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java new file mode 100644 index 0000000..f5ac984 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java @@ -0,0 +1,21 @@ +package org.sopt.routee.external.internal.oauth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Apple OAuth 연동 해제(revoke) 설정. + *

+ * client secret은 정적 문자열이 아니라 {@code teamId}/{@code keyId}/{@code privateKey}로 + * client secret JWT(ES256)를 직접 서명해서 사용한다. + */ +@ConfigurationProperties(prefix = "oauth.providers.apple") +public record OAuthRevokeProperty( + String tokenUri, + String revokeUri, + String audience, + String clientId, + String teamId, + String keyId, + String privateKey +) { +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java new file mode 100644 index 0000000..e2185b3 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java @@ -0,0 +1,11 @@ +package org.sopt.routee.external.internal.oauth.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.internal.oauth.code.ErrorCode; + +public final class AppleClientSecretException extends BaseException { + + public AppleClientSecretException(Throwable cause) { + super(ErrorCode.APPLE_CLIENT_SECRET_GENERATION_FAILED, cause); + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java new file mode 100644 index 0000000..9aced0b --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java @@ -0,0 +1,15 @@ +package org.sopt.routee.external.internal.oauth.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.internal.oauth.code.ErrorCode; + +public final class OAuthRevokeException extends BaseException { + + public OAuthRevokeException() { + super(ErrorCode.OAUTH_REVOKE_FAILED); + } + + public OAuthRevokeException(Throwable cause) { + super(ErrorCode.OAUTH_REVOKE_FAILED, cause); + } +} From 93d8adfa0dee33ab34dd8b2c5374ac535e2a41ed Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 00:58:44 +0900 Subject: [PATCH 64/68] =?UTF-8?q?test:=20=EA=B8=B0=EC=A1=B4=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/MemberServiceTest.java | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java b/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java index 30ea186..c416370 100644 --- a/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java +++ b/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java @@ -5,7 +5,6 @@ import static org.mockito.Mockito.*; import java.util.Optional; -import java.util.function.Consumer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,16 +17,20 @@ import org.sopt.routee.external.api.port.FileDeletePort; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; import org.sopt.routee.external.api.port.FileUploadPresignPort; +import org.sopt.routee.external.api.port.OAuthRevokePort; import org.sopt.routee.external.api.port.OidcVerifyPort; +import org.sopt.routee.external.api.type.OAuthProvider; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; import org.sopt.routee.member.internal.entity.Member; import org.sopt.routee.member.internal.exception.MemberNotFoundException; import org.sopt.routee.member.internal.repository.MemberAgreementRepository; import org.sopt.routee.member.internal.repository.MemberRepository; +import org.sopt.routee.member.internal.service.dto.command.WithdrawCommand; import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator; import org.springframework.context.ApplicationEventPublisher; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; @ExtendWith(MockitoExtension.class) @@ -36,10 +39,16 @@ class MemberServiceTest { private static final long MEMBER_ID = 1L; private static final String ACCESS_TOKEN_HASH = "access-hash"; private static final String REFRESH_TOKEN_HASH = "refresh-hash"; + private static final String AUTHORIZATION_CODE = "auth-code"; + private static final WithdrawCommand WITHDRAW_COMMAND = + new WithdrawCommand(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH, AUTHORIZATION_CODE); @Mock private OidcVerifyPort oidcVerifyPort; + @Mock + private OAuthRevokePort oAuthRevokePort; + @Mock private ActivityUseCase activityUseCase; @@ -73,6 +82,7 @@ class MemberServiceTest { void setUp() { memberService = new MemberService( oidcVerifyPort, + oAuthRevokePort, activityUseCase, memberRepository, memberAgreementRepository, @@ -87,28 +97,29 @@ void setUp() { @SuppressWarnings("unchecked") private void stubTransactionTemplateToRunCallback() { - doAnswer(invocation -> { - Consumer callback = invocation.getArgument(0); - callback.accept(mock(TransactionStatus.class)); - return null; - }).when(transactionTemplate).executeWithoutResult(any(Consumer.class)); + when(transactionTemplate.execute(any(TransactionCallback.class))).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); } @Test - @DisplayName("withdraw: 정상적으로 회원을 탈퇴시키고 토큰 무효화 이벤트를 발행한다") - void withdraw_success_publishesMemberWithdrawnEvent() { + @DisplayName("withdraw: 회원을 탈퇴시키고 소셜 연동을 해제한 뒤 토큰 무효화 이벤트를 발행한다") + void withdraw_success_revokesOAuthAndPublishesEvent() { // given stubTransactionTemplateToRunCallback(); Member member = mock(Member.class); + when(member.getOauthProvider()).thenReturn(OAuthProvider.APPLE); when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); // when - memberService.withdraw(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH); + memberService.withdraw(WITHDRAW_COMMAND); // then verify(memberAgreementRepository).deleteByMember_Id(MEMBER_ID); verify(memberRepository).delete(member); verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + verify(oAuthRevokePort).revoke(AUTHORIZATION_CODE); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(MemberWithdrawnEvent.class); verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); @@ -120,6 +131,45 @@ void withdraw_success_publishesMemberWithdrawnEvent() { .containsExactly(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH); } + @Test + @DisplayName("withdraw: 소셜 연동 해제가 실패해도 회원 탈퇴와 이벤트 발행은 정상 처리된다") + void withdraw_oauthRevokeFails_stillCompletesWithdrawal() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(member.getOauthProvider()).thenReturn(OAuthProvider.APPLE); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + // 임의의 BaseException — revoke 어댑터가 던지는 예외를 흉내낸다 + doThrow(new MemberNotFoundException()).when(oAuthRevokePort).revoke(AUTHORIZATION_CODE); + + // when + memberService.withdraw(WITHDRAW_COMMAND); + + // then + verify(memberRepository).delete(member); + verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + verify(applicationEventPublisher).publishEvent(any(MemberWithdrawnEvent.class)); + } + + @Test + @DisplayName("withdraw: Apple이 아닌 provider는 OAuth revoke를 호출하지 않는다") + void withdraw_nonAppleProvider_doesNotCallOAuthRevoke() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(member.getOauthProvider()).thenReturn(OAuthProvider.GOOGLE); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + + // when + memberService.withdraw(WITHDRAW_COMMAND); + + // then + verify(memberRepository).delete(member); + verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + verify(oAuthRevokePort, never()).revoke(any()); + verify(applicationEventPublisher).publishEvent(any(MemberWithdrawnEvent.class)); + } + @Test @DisplayName("withdraw: 동시 탈퇴 요청으로 낙관적 락 예외가 발생하면 회원 없음 예외로 변환한다") void withdraw_concurrentWithdrawal_throwsMemberNotFoundException() { @@ -132,11 +182,12 @@ void withdraw_concurrentWithdrawal_throwsMemberNotFoundException() { // when Throwable thrown = catchThrowable( - () -> memberService.withdraw(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH)); + () -> memberService.withdraw(WITHDRAW_COMMAND)); // then assertThat(thrown).isInstanceOf(MemberNotFoundException.class); verify(activityUseCase, never()).deleteForMemberWithdrawal(anyLong()); + verify(oAuthRevokePort, never()).revoke(any()); verify(applicationEventPublisher, never()).publishEvent(any()); } } From 9d81c789cbebe603e0820b2f098030364b9ba5e6 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 01:39:11 +0900 Subject: [PATCH 65/68] =?UTF-8?q?fix:=20=EB=AF=B8=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EC=84=A4=EB=AA=85=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routee/member/internal/controller/MemberControllerDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index 76878e4..d2d411d 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -86,7 +86,7 @@ ResponseEntity> register( @Operation(summary = "회원 탈퇴", description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. " - + "함께 전달한 authorization_code로 소셜 로그인(Apple/Google) 연동도 해제합니다. " + + "함께 전달한 authorization_code로 소셜 로그인(Apple) 연동도 해제합니다. " + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, 연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ From 17a2ce62b10dc8ff175e1b09dc2604e213c29c65 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 01:47:58 +0900 Subject: [PATCH 66/68] =?UTF-8?q?refactor:=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20timeout=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/config/OAuthRevokeClientConfig.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java index ee1c6b3..684d7f1 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java @@ -1,14 +1,30 @@ package org.sopt.routee.external.internal.oauth.config; +import java.net.http.HttpClient; +import java.time.Duration; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; @Configuration class OAuthRevokeClientConfig { + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(10); + @Bean RestClient oauthRevokeRestClient() { - return RestClient.create(); + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(READ_TIMEOUT); + + return RestClient.builder() + .requestFactory(requestFactory) + .build(); } } From f08c8b5abf71f63d3571ae3deddb209f722157af Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 12:13:04 +0900 Subject: [PATCH 67/68] =?UTF-8?q?style:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/oauth/adapter/AppleClientSecretGenerator.java | 5 ----- .../external/internal/oauth/config/OAuthRevokeProperty.java | 6 ------ 2 files changed, 11 deletions(-) diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java index 78474f8..7b32cd2 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java @@ -21,11 +21,6 @@ import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; -/** - * Apple client secret(ES256로 서명한 JWT)을 생성한다. - *

- * 서명 비용을 줄이기 위해 만료 전까지 캐시한다. Apple은 최대 6개월까지 허용하지만 보수적으로 짧게 잡는다. - */ @Component class AppleClientSecretGenerator { diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java index f5ac984..b69d91a 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java @@ -2,12 +2,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties; -/** - * Apple OAuth 연동 해제(revoke) 설정. - *

- * client secret은 정적 문자열이 아니라 {@code teamId}/{@code keyId}/{@code privateKey}로 - * client secret JWT(ES256)를 직접 서명해서 사용한다. - */ @ConfigurationProperties(prefix = "oauth.providers.apple") public record OAuthRevokeProperty( String tokenUri, From 2d0b33159ab9f26da1c0bc252f3c7c63b7991191 Mon Sep 17 00:00:00 2001 From: kymin Date: Fri, 18 Sep 2026 13:04:46 +0900 Subject: [PATCH 68/68] =?UTF-8?q?style:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/MemberControllerDocs.java | 17 +++++++++++++---- .../controller/dto/request/WithdrawRequest.java | 4 +++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java index d2d411d..b05f80c 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/MemberControllerDocs.java @@ -85,16 +85,17 @@ ResponseEntity> register( ); @Operation(summary = "회원 탈퇴", - description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. " - + "함께 전달한 authorization_code로 소셜 로그인(Apple) 연동도 해제합니다. " - + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, 연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") + description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. refresh_token은 모든 탈퇴 요청에 필수입니다. " + + "Apple 로그인 회원은 authorization_code를 함께 전달해야 소셜 로그인 연동도 해제됩니다. " + + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, Apple 외 소셜 로그인 회원은 전달하지 않아도 됩니다. " + + "연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @ApiResponse(responseCode = "200", description = "탈퇴 성공"), @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = @ExampleObject(name = "INVALID_INPUT_VALUE", - value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"authorization_code는 필수입니다.\"}"))), + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"refresh_token은 필수입니다.\"}"))), @ApiResponse(responseCode = "401", description = "만료되었거나 유효하지 않은 액세스 토큰", content = @Content(schema = @Schema(implementation = FailureResponse.class), examples = { @@ -111,6 +112,14 @@ ResponseEntity> register( ResponseEntity> withdraw( Long memberId, @RequestHeader(name = "Authorization") String accessTokenWithBearer, + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, + content = @Content(schema = @Schema(implementation = WithdrawRequest.class), + examples = { + @ExampleObject(name = "APPLE_MEMBER", summary = "Apple 로그인 회원", + value = "{\"refreshToken\":\"eyJ...\",\"authorizationCode\":\"c1234...\"}"), + @ExampleObject(name = "OTHER_MEMBER", summary = "그 외 소셜 로그인 회원", + value = "{\"refreshToken\":\"eyJ...\"}") + })) @Valid @RequestBody WithdrawRequest request ); diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java index 51c4cd8..45cf1d2 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java @@ -6,10 +6,12 @@ import jakarta.validation.constraints.NotBlank; public record WithdrawRequest( + @Schema(description = "탈퇴를 요청하는 회원의 리프레시 토큰. 모든 탈퇴 요청에 필수입니다.") @NotBlank(message = "refresh_token은 필수입니다.") String refreshToken, - @Schema(description = "탈퇴 시점에 재인증하여 발급받은 Apple 인가 코드. Apple 계정 연동 해제에 사용되며, Apple 로그인 회원만 필요합니다.") + @Schema(description = "탈퇴 시점에 재인증하여 발급받은 Apple 인가 코드. Apple 계정 연동 해제에 사용되며, Apple 로그인 회원만 필요합니다. " + + "그 외 소셜 로그인 회원은 전달하지 않아도 됩니다.") String authorizationCode ) { public WithdrawCommand toCommand(Long memberId, String accessTokenHash, String refreshTokenHash) {