From 596ec6070d13bc3482cdd8de3fd3d3805945b900 Mon Sep 17 00:00:00 2001 From: kymin Date: Sun, 9 Aug 2026 15:20:25 +0900 Subject: [PATCH 01/49] =?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 114869dd..c41f0584 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 7d99da63..99248eda 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 00000000..ce3d6b09 --- /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 fc8c4808..64210cce 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 efaf9497..18507a82 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 00000000..445a081f --- /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/49] =?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 d819a0a6..ef73249a 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 c41f0584..f0c955dd 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 99248eda..2c72b65c 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 00000000..0d6f8d5e --- /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 00000000..2dfc428a --- /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 f02dc171..a14f59b1 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 64210cce..f1446c73 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 18507a82..62ef5c3e 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 00000000..77899bed --- /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 00000000..d8205cc7 --- /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/49] =?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 31fc2034..1f764d87 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 6120e569..fa3f46fa 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 f2c48e1a..affb594c 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 f6081d05..8c993270 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 4e907294..3a916439 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 b88d4ad8..0ee4b8cf 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 d908e93d..a434d0ce 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 ef73249a..12609331 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 f0c955dd..1eda8ad1 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 2c72b65c..635f013b 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 00000000..3bc046b3 --- /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 00000000..dcbbb477 --- /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 00000000..28af2fa1 --- /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 00000000..b5a69b6c --- /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 a14f59b1..a1acbadc 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 00000000..fd7e417d --- /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 f1446c73..01a31245 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 62ef5c3e..66be3f51 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 00000000..ce325f4c --- /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 00000000..8e079d0b --- /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 00000000..36e6ec08 --- /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 00000000..84bbade4 --- /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 00000000..49b4e1d0 --- /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/49] =?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 e07c1c28..3895eba0 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 00000000..bf5bd824 --- /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 0ee4b8cf..b52275a5 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 49b4e1d0..2c854092 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/49] =?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 147479bc..dd426289 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 81e6c9c3..d76bde5b 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 c969a810..a655a4e9 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 22e2d92d..5dba8ecf 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 00000000..59336854 --- /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 cee00932..48abb3d3 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 06027838..f76b749a 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/49] =?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 1eda8ad1..81842b3c 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/49] =?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 43411ddc..2a1d368a 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 00000000..8ca00870 --- /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 00000000..29d28ca6 --- /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 f76b749a..29605644 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 00000000..e6f7f29c --- /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 00000000..4f0f123b --- /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 00000000..495ab86d --- /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 00000000..82eb10af --- /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 00000000..5d355eb0 --- /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 664f1834..090ce540 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 a2421818..301651fa 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 00000000..f2ef3db1 --- /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 00000000..9d9260aa --- /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/49] =?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 d76bde5b..9f5f985e 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 a655a4e9..be406b6b 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 5dba8ecf..c6934bac 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 00000000..73587f59 --- /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 040da511..e6d32c82 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 29605644..794fa554 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 00000000..0680d5ec --- /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 4f0f123b..6caab7d2 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/49] =?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 00000000..f5dd65be --- /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/49] =?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 48abb3d3..c5768dd7 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 794fa554..f0eb6721 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 6caab7d2..72d6825d 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/49] =?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 be406b6b..9f547863 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 c6934bac..cbcfc30c 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 00000000..29773ac5 --- /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 f0eb6721..dbaaf0be 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 00000000..59b30e7b --- /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/49] =?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 9f547863..14fd6da8 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 cbcfc30c..b64eeb9b 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 00000000..7699f26c --- /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 0057437e..2877915e 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 dbaaf0be..6b717389 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 00000000..098a1054 --- /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 72d6825d..d512aaa8 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/49] =?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 29d28ca6..c7f56a02 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 e6f7f29c..0a7bf73a 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 5d355eb0..6e5fedca 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 f2ef3db1..3c5bd13d 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 9d9260aa..193442f9 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/49] =?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 3a916439..d746cf91 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/49] =?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 b52275a5..29878436 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 26a24f5b..f654a2c3 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/49] =?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 c7f56a02..898bb6fa 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 0a7bf73a..2617f02f 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 f5dd65be..00000000 --- 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/49] =?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 8ca00870..00000000 --- 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 898bb6fa..00000000 --- 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 6b717389..7119c56d 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 2617f02f..00000000 --- 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 d512aaa8..4d9e390c 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/49] =?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 6e5fedca..8845e524 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/49] =?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 bbb2779b..29cb9fda 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 3d9dfa5b..1cacfaa1 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 c5768dd7..abc630fe 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 c9e084d0..766dc040 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/49] =?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 4cd632cc..536dfa99 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 00000000..d6cb219e --- /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 00000000..218b0048 --- /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/49] =?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 abc630fe..979a70dc 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 766dc040..11a80abb 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 00000000..d7544aa7 --- /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 00000000..02521252 --- /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/49] =?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 218b0048..7e8cffe6 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/49] =?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 7e8cffe6..218b0048 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/49] =?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 77391a9e..f2312c93 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/49] =?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 8d14aa18..6bbd2d49 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/49] =?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 979a70dc..b764ad68 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 00000000..75d7e841 --- /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 11a80abb..32907b61 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 d7544aa7..d6becc33 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 02521252..00000000 --- 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 6bbd2d49..dbec626a 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/49] =?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 1cacfaa1..72c4a779 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 32907b61..e5a2016c 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/49] =?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 dd426289..e6bb14f9 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 b40b8dbe..00000000 --- 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/49] =?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 e5a2016c..9410acd2 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 7119c56d..5ea36c87 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 f2312c93..bea5b4f6 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 dbec626a..def85ddd 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 4d9e390c..ade6111d 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 495ab86d..7f12c6bf 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 1f764d87..659be245 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 fa3f46fa..962ea2f9 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 8845e524..3221d476 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 8c993270..2a3fa88d 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 d746cf91..e9c5ef43 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 29878436..c14eb189 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 193442f9..129c4a60 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 00000000..e6591c58 --- /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/49] =?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 29cb9fda..75571425 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 4fb49d9a..81f48b4b 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 8134f8e9..d1e5e424 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 b764ad68..2a87e08c 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 75d7e841..00000000 --- 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 9410acd2..eb49513d 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 5ea36c87..6b243a52 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 f486d97a..83d71ae9 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 d6becc33..b3578914 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 def85ddd..c27e464b 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 ade6111d..b278272b 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 00000000..30cab9ef --- /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 82eb10af..be7868f9 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 3221d476..6b7b5ba3 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 e9c5ef43..3e180dd9 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 129c4a60..ac4a306c 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 e6591c58..ce7fd94f 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/49] =?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 a1acbadc..73c402c3 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 01a31245..06daad54 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 66be3f51..4e0a3147 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/49] =?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 7f12c6bf..e196356c 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 30cab9ef..682a5ca8 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 6b7b5ba3..9fb08611 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 3e180dd9..53600f28 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 4e0a3147..e61601fe 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/49] =?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 75571425..9f7919fe 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 81f48b4b..460dbd44 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 d1e5e424..e88c6120 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 c1d74cc2..f2582c17 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 eb49513d..1c0cfa27 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 83d71ae9..55694bdd 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/49] =?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 e88c6120..24e3b9b2 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 381922d4..573cc3ad 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 645e5c5d..b18e4b02 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 6043a8b9..c3b0d678 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 72c4a779..17363a99 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 ace41012..3ee492c1 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 1c0cfa27..805e4ee3 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 6b243a52..9ab74278 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 c27e464b..1be6922b 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/49] =?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 c3b0d678..3eb8e901 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 805e4ee3..22dafa64 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/49] =?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 e61601fe..f9e2e1e4 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/49] =?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 319d0fd3..6c501624 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/49] =?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 17363a99..962c5bb0 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 9ab74278..032caba6 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/49] =?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 6c501624..f431fb64 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/49] =?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 aa68caa2..7d05dd88 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 22dafa64..7f29612e 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/49] =?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 b74128fa..14d26d2a 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/49] =?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 14d26d2a..ef49cb9c 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/49] =?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 00000000..41810831 --- /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/49] =?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 12609331..42f14ce7 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 81842b3c..f8266b95 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 635f013b..89e66566 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 f9e2e1e4..3ca770ce 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/49] =?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 89e66566..1eac7922 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 0d6f8d5e..af74fdfa 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 84a99428..59e52800 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/49] =?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 a434d0ce..b1831731 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 81842b3c..5c0ae980 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 635f013b..475508e1 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 00000000..6e3cbc8b --- /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 84a99428..6c2e0bcc 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 00000000..c3c861de --- /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 06daad54..f3e243a7 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 00000000..4bc99d20 --- /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 f9e2e1e4..5465f9c6 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 00000000..602549de --- /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 b433fb24..278ab781 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/49] =?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 ce54f64f..8f49b0cc 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/49] =?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 41810831..ef027bf6 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 f3e243a7..50661e8f 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 ced21c25..fd2f4995 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/49] =?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 536dfa99..1190662a 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 19754fb2..72fcfff6 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 f431fb64..9cca9580 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}