diff --git a/.env.example b/.env.example index 5912fa3f..bcb0e090 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ JWT_SECRET= # OIDC APPLE_CLIENT_ID=com.Routee-iOS +# OAuth revoke +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= +GOOGLE_CLIENT_ID= + # Redis REDIS_HOST= REDIS_PORT= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy-common.yml similarity index 61% rename from .github/workflows/deploy.yml rename to .github/workflows/deploy-common.yml index 4cd632cc..b9762914 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,12 @@ 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)] }}" + export APPLE_CLIENT_ID="${{ secrets[format('{0}APPLE_CLIENT_ID', inputs.secret_prefix)] }}" + export APPLE_TEAM_ID="${{ secrets[format('{0}APPLE_TEAM_ID', inputs.secret_prefix)] }}" + export APPLE_KEY_ID="${{ secrets[format('{0}APPLE_KEY_ID', inputs.secret_prefix)] }}" + export APPLE_PRIVATE_KEY="${{ secrets[format('{0}APPLE_PRIVATE_KEY', inputs.secret_prefix)] }}" + export GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" - cd ~/app && bash scripts/deploy.sh ${{ secrets.DOCKER_USERNAME }}/routee-api:${{ 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 diff --git a/docker-compose.yml b/docker-compose.yml index 19754fb2..ca3aea27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,11 @@ services: S3_REGION: ${S3_REGION} S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} + APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} + APPLE_TEAM_ID: ${APPLE_TEAM_ID} + APPLE_KEY_ID: ${APPLE_KEY_ID} + APPLE_PRIVATE_KEY: ${APPLE_PRIVATE_KEY} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: redis: condition: service_healthy @@ -57,6 +62,11 @@ services: S3_REGION: ${S3_REGION} S3_BUCKET: ${S3_BUCKET} S3_ENDPOINT: ${S3_ENDPOINT} + APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} + APPLE_TEAM_ID: ${APPLE_TEAM_ID} + APPLE_KEY_ID: ${APPLE_KEY_ID} + APPLE_PRIVATE_KEY: ${APPLE_PRIVATE_KEY} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: redis: condition: service_healthy 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/code/ErrorCode.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/ErrorCode.java index 147479bc..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 @@ -15,10 +15,10 @@ 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, "이미 동일한 활동 상태입니다."), - 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/code/SuccessCode.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java index 81e6c9c3..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 @@ -20,7 +20,9 @@ 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, "타임라인 목록 조회에 성공했습니다."), + 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/ActivityControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java index bbb2779b..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 @@ -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, @@ -256,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,"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/RouteControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java index 42b249d6..c13f9dd5 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java @@ -32,7 +32,9 @@ public interface RouteControllerDocs { @ExampleObject(name = "ROUTES_EMPTY", value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"루트 목록 생성 시 최소 1개 이상의 루트를 입력해야 합니다.\"}"), @ExampleObject(name = "ROUTES_TOO_MANY", - value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"루트는 최대 12개까지 등록할 수 있습니다.\"}") + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"루트는 최대 20개까지 등록할 수 있습니다.\"}"), + @ExampleObject(name = "ROUTE_NAME_TOO_LONG", + value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"name은 16자 이하여야 합니다.\"}") })), @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))), diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/TimelineController.java index c969a810..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 @@ -5,15 +5,22 @@ 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.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; 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.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -32,16 +39,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") @@ -54,4 +61,27 @@ public ResponseEntity> getTimelines( return ResponseEntity.ok( 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 + ) { + UpdateTimelineTitleResult result = timelineService.updateTitle(request.toCommand(activityId, timelineId, memberId)); + + return ResponseEntity.ok(ApiResponse.success(SuccessCode.TIMELINE_TITLE_UPDATED, TimelineTitleResponse.from(result))); + } + + @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..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 @@ -3,7 +3,10 @@ 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.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; @@ -29,8 +32,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 = { @@ -46,7 +49,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) @@ -76,4 +79,66 @@ ResponseEntity> getTimelines( @Parameter(description = "활동 기록 식별자", example = "1", required = true) @PathVariable(name = "activityId") Long activityId ); + + @Operation(summary = "타임라인 제목 수정", description = "인증된 사용자의 활동 기록에 속한 타임라인 제목을 수정합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "타임라인 제목 수정 성공", + 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 = { + @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 = "타임라인 삭제 성공", + 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/controller/dto/request/ActivityCompleteRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/ActivityCompleteRequest.java index 4fb49d9a..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,10 +34,8 @@ public record ActivityCompleteRequest( @Max(value = 9000, message = "maxElevation은 9000 이하여야 합니다.") Integer maxElevation, - @NotBlank(message = "mapImageUrl은 필수입니다.") - String mapImageUrl, - - String coverImageObjectKey, + @NotBlank(message = "mapImageObjectKey는 필수입니다.") + String mapImageObjectKey, @NotEmpty(message = "track은 필수입니다.") @Valid @@ -55,8 +53,7 @@ public CompleteActivityCommand toCommand(Long activityId, Long memberId, ZoneId distance, durationSec, maxElevation, - mapImageUrl, - coverImageObjectKey, + mapImageObjectKey, track.stream().map(ActivityTrackPoint::toTrackPoint).toList(), endedAt ); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java index af085aa3..05ccaa30 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java @@ -11,7 +11,7 @@ public record CreateRoutesRequest( @Valid @NotEmpty(message = "루트 목록 생성 시 최소 1개 이상의 루트를 입력해야 합니다.") - @Size(max = 12, message = "루트는 최대 12개까지 등록할 수 있습니다.") + @Size(max = 20, message = "루트는 최대 20개까지 등록할 수 있습니다.") List routes ) { public List toCommands() { diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RouteRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RouteRequest.java index e5bc2c47..e757138f 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RouteRequest.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RouteRequest.java @@ -5,9 +5,11 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; public record RouteRequest( @NotBlank(message = "name은 필수입니다.") + @Size(max = 16, message = "name은 16자 이하여야 합니다.") String name, @NotNull(message = "sequence는 필수입니다.") diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/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/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/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/entity/activity/Activity.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/activity/Activity.java index 8134f8e9..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; @@ -58,8 +59,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; @@ -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; @@ -87,24 +91,30 @@ public void updateTitle(String title) { this.title = title; } + public void updateCoverImageObjectKey(String coverImageObjectKey) { + this.coverImageObjectKey = coverImageObjectKey; + } + public void updateCompletedData( String title, Integer distance, Integer durationSec, Integer maxElevation, - String mapImageUrl, + String mapImageObjectKey, String coverImageObjectKey, LineString track, - Instant endedAt + Instant endedAt, + LocalDate activityDateWithTimezone ) { this.title = title; this.activityStatus = ActivityStatus.ACTIVITY_COMPLETED; this.distance = distance; this.durationSec = durationSec; this.maxElevation = maxElevation; - this.mapImageUrl = mapImageUrl; + this.mapImageObjectKey = mapImageObjectKey; 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/route/Route.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/route/Route.java index 431cabfa..125b60b1 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/route/Route.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/entity/route/Route.java @@ -36,7 +36,7 @@ public class Route { @Tsid private Long id; - @Column(name = "name", nullable = false) + @Column(name = "name", nullable = false, length = 16) private String name; @Column(name = "sequence", nullable = false) 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/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/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); - } -} 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/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/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/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/repository/ActivityDailySummaryRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java index 6043a8b9..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 @@ -18,13 +18,16 @@ 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 = 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( @@ -32,10 +35,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 3d9dfa5b..a33badb3 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; @@ -15,25 +16,37 @@ 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); - List findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedAtAsc( + List findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedAtDesc( Long memberId, ActivityStatus activityStatus, Instant startedAtFrom, Instant startedAtTo ); - List findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedAtDesc( + Optional findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( Long memberId, - ActivityStatus activityStatus, - Instant startedAtFrom, - Instant startedAtTo + LocalDate activityDateWithTimezone, + ActivityStatus activityStatus ); @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); + + @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/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/repository/TimelineRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/TimelineRepository.java index cee00932..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 @@ -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,13 +13,24 @@ public interface TimelineRepository extends JpaRepository { boolean existsByActivityIdAndTrackPointIndex(Long activityId, Integer trackPointIndex); + 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); + + Optional findFirstByActivityIdAndTimelineStatusOrderByTrackPointIndexAsc(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") 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/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 c9e084d0..f5bc6ac6 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,37 +19,40 @@ 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; 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.TrackPoint; import org.sopt.routee.activity.internal.service.validator.ActivityImageFileNameValidator; +import org.sopt.routee.exception.BaseException; +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; @@ -61,6 +64,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; @@ -83,34 +87,60 @@ 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) { - if (activityRepository.existsByMemberIdAndActivityStatusIn( + ActivityCreationTransactionResult transactionResult = transactionTemplate.execute( + status -> createInTransaction(command)); + + log.info("Activity created. activityId={}, memberId={}", transactionResult.result().activityId(), + command.memberId()); + + if (!transactionResult.deletedActivityIds().isEmpty()) { + Thread.startVirtualThread( + () -> deleteActivityImageDirectories(command.memberId(), transactionResult.deletedActivityIds())); + } + + return transactionResult.result(); + } + + private ActivityCreationTransactionResult createInTransaction(CreateActivityCommand command) { + activityRepository.acquireCreationLock(command.memberId()); + + List activeActivityIds = activityRepository.findIdsByMemberIdAndActivityStatusIn( command.memberId(), ACTIVE_STATUSES - )) { - throw new AlreadyInProgressActivityException(); + ); + + if (!activeActivityIds.isEmpty()) { + routeRepository.deleteByActivityIdIn(activeActivityIds); + timelineRepository.deleteByActivityIdIn(activeActivityIds); + activityRepository.deleteByIdIn(activeActivityIds); } - Instant startedAt = command.startedAt() - .atZone(command.timeZone()) - .toInstant(); + 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); + Activity savedActivity = activityRepository.save(ActivityMapper.toEntity(command, title, startedAt)); - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - log.info("Activity created. activityId={}, memberId={}", savedActivity.getId(), command.memberId()); - } - }); + return new ActivityCreationTransactionResult( + new CreateActivityResult(savedActivity.getId(), title), + activeActivityIds + ); + } - return new CreateActivityResult(savedActivity.getId(), title); + private void deleteActivityImageDirectories(Long memberId, List activityIds) { + for (Long activityId : activityIds) { + try { + fileDeletePort.deleteDirectory( + new FileDeleteDirectoryCommand(memberId.toString(), activityId.toString())); + } catch (BaseException e) { + log.warn("Activity image directory delete failed. activityId={}", activityId, e); + } + } } @Transactional(readOnly = true) @@ -126,6 +156,7 @@ public ImageUrlResult generateImageUploadUrl(ImageUploadUrlCommand command) { FileUploadPresignCommand presignCommand = new FileUploadPresignCommand( command.directory(), command.imageSize(), + command.memberId().toString(), command.activityId().toString(), command.fileName() ); @@ -169,23 +200,29 @@ public void complete(CompleteActivityCommand command) { Activity activity = activityRepository.findByIdAndMemberId(command.activityId(), command.memberId()) .orElseThrow(ActivityNotFoundException::new); + if (activity.getActivityStatus().isCompleted()) { + throw new ActivityAlreadyCompletedException(); + } + Instant endedAt = TimeZoneUtils.toUtcInstantTime(command.endedAt(), command.timeZone()); + String coverImageObjectKey = resolveCoverImageObjectKey(command.activityId()); + LocalDate activityDate = TimeZoneUtils.toLocalDate(activity.getStartedAt(), command.timeZone()); activity.updateCompletedData( command.title(), command.distance(), command.durationSec(), command.maxElevation(), - command.mapImageUrl(), - command.coverImageObjectKey(), + command.mapImageObjectKey(), + coverImageObjectKey, ActivityMapper.toLineString(command.track()), - endedAt + endedAt, + activityDate ); - LocalDate activityDate = TimeZoneUtils.toLocalDate(activity.getStartedAt(), command.timeZone()); - String coverImageUrl = generateThumbnailUrl(activity); + Long coverActivityId = coverImageObjectKey == null ? null : activity.getId(); activityDailySummaryService.recordActivity( - command.memberId(), activityDate, command.durationSec(), coverImageUrl + command.memberId(), activityDate, command.durationSec(), coverActivityId, coverImageObjectKey ); applicationEventPublisher.publishEvent(new ActivityCompletedEvent(command.memberId())); @@ -214,17 +251,33 @@ 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); Instant startedAtTo = TimeZoneUtils.toUtcInstant(date.plusDays(1), timeZone).minusNanos(1); List activities = activityRepository - .findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedAtAsc( + .findByMemberIdAndActivityStatusAndStartedAtBetweenOrderByStartedAtDesc( memberId, ActivityStatus.ACTIVITY_COMPLETED, startedAtFrom, startedAtTo ) .stream() @@ -260,7 +313,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); @@ -285,13 +339,20 @@ 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(); 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; @@ -300,6 +361,7 @@ private String generateThumbnailUrl(Activity activity) { FileImageAccessUrlCommand command = new FileImageAccessUrlCommand( FileUploadDirectory.TIMELINE, FileUploadImageSize.SMALL, + activity.getMemberId().toString(), activity.getId().toString(), activity.getCoverImageObjectKey() ); @@ -311,10 +373,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 06027838..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 @@ -1,42 +1,60 @@ 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; +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; 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.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.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 { private final ActivityRepository activityRepository; private final TimelineRepository timelineRepository; + private final ActivityDailySummaryService activityDailySummaryService; private final FileImageAccessUrlPort fileImageAccessUrlPort; + private final FileDeletePort fileDeletePort; + private final TransactionTemplate transactionTemplate; @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) @@ -46,23 +64,97 @@ 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(); } + public void delete(Long activityId, Long timelineId, Long memberId) { + Timeline timeline = transactionTemplate.execute(status -> { + Timeline ownedTimeline = findOwnedTimeline(activityId, timelineId, memberId); + + timelineRepository.delete(ownedTimeline); + refreshCoverImageIfDeleted(ownedTimeline); + + return ownedTimeline; + }); + + String objectKey = timeline.getTimelineImageObjectKey(); + + Thread.startVirtualThread(() -> deleteTimelineImage(memberId, activityId, objectKey)); + } + + @Transactional + public UpdateTimelineTitleResult updateTitle(UpdateTimelineTitleCommand command) { + Timeline timeline = findOwnedTimeline(command.activityId(), command.timelineId(), command.memberId()); + + timeline.updateTitle(command.title()); + + return TimelineMapper.toTitleUpdateResult(timeline); + } + @Transactional 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() ); 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); + refreshDailySummaryCoverIfNeeded(activity); + } + + private void refreshDailySummaryCoverIfNeeded(Activity activity) { + LocalDate activityDate = activity.getActivityDateWithTimezone(); + if (activityDate == null) { + return; + } + + Activity firstActivityWithCover = activityRepository + .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( + activity.getMemberId(), activityDate, ActivityStatus.ACTIVITY_COMPLETED) + .orElse(null); + + Long coverActivityId = firstActivityWithCover == null ? null : firstActivityWithCover.getId(); + String coverImageObjectKey = firstActivityWithCover == null ? null : firstActivityWithCover.getCoverImageObjectKey(); + + activityDailySummaryService.refreshCoverImage(activity.getMemberId(), activityDate, coverActivityId, coverImageObjectKey); + } + + 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()); + } + + private void deleteTimelineImage(Long memberId, Long activityId, String objectKey) { + try { + fileDeletePort.deleteImage( + 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/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..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,8 +14,7 @@ public record CompleteActivityCommand( Integer distance, Integer durationSec, Integer maxElevation, - String mapImageUrl, - String coverImageObjectKey, + String mapImageObjectKey, List track, LocalDateTime endedAt ) { 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/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..b3578914 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/result/ActivityCreationTransactionResult.java @@ -0,0 +1,9 @@ +package org.sopt.routee.activity.internal.service.dto.result; + +import java.util.List; + +public record ActivityCreationTransactionResult( + CreateActivityResult result, + List deletedActivityIds +) { +} 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 +) { +} 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/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-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..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 @@ -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,9 @@ 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, + MEMBER_ID.toString(), ACTIVITY_ID.toString(), "hike.jpg" ); @@ -63,7 +100,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 +114,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 +125,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); 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..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 @@ -8,26 +8,48 @@ import java.time.LocalDateTime; 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; +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.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.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.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.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) 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 +58,106 @@ 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); - - 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) + 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 + 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(); + + 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 +169,123 @@ 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()).deleteByActivityIdIn(anyList()); + verify(activityRepository, never()).deleteByIdIn(anyList()); + verifyNoInteractions(fileDeletePort); } @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_활성_활동이_있으면_기존_활동을_폐기하고_새_활동을_생성한_뒤_이미지_디렉터리_삭제를_요청한다() throws InterruptedException { + List activeActivityIds = List.of(10L, 11L); + when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) + .thenReturn(activeActivityIds); + when(activityRepository.save(any(Activity.class))).thenReturn(savedActivity(20L, ActivityType.RUNNING)); + stubTransactionTemplateToRunCallback(); + + CountDownLatch latch = new CountDownLatch(activeActivityIds.size()); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(fileDeletePort).deleteDirectory(any()); + + 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).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(FileDeleteDirectoryCommand.class); + verify(fileDeletePort, times(2)).deleteDirectory(commandCaptor.capture()); + assertThat(commandCaptor.getAllValues()).containsExactly( + new FileDeleteDirectoryCommand(MEMBER_ID.toString(), "10"), + new FileDeleteDirectoryCommand(MEMBER_ID.toString(), "11") ); + } + + @Test + void create_일부_S3_삭제가_실패해도_나머지_삭제를_계속하고_예외를_전파하지_않는다() throws InterruptedException { + List activeActivityIds = List.of(10L, 11L); + when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) + .thenReturn(activeActivityIds); + when(activityRepository.save(any(Activity.class))).thenReturn(savedActivity(20L, ActivityType.RUNNING)); + stubTransactionTemplateToRunCallback(); + + CountDownLatch latch = new CountDownLatch(activeActivityIds.size()); + AtomicInteger invocationCount = new AtomicInteger(); + doAnswer(invocation -> { + latch.countDown(); + if (invocationCount.getAndIncrement() == 0) { + throw new ActivityNotFoundException(); + } + return null; + }).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)).deleteDirectory(any()); + } + + @Test + void create_새_활동_저장에_실패하면_S3_삭제를_요청하지_않는다() { + List activeActivityIds = List.of(10L); + when(activityRepository.findIdsByMemberIdAndActivityStatusIn(MEMBER_ID, ACTIVE_STATUSES)) + .thenReturn(activeActivityIds); + when(activityRepository.save(any(Activity.class))).thenThrow(new ActivityNotFoundException()); + stubTransactionTemplateToRunCallback(); + + assertThatThrownBy(() -> activityService.create(createCommand(ActivityType.RUNNING))) + .isInstanceOf(ActivityNotFoundException.class); + + 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)); + + completeWithSynchronizationActive(completeCommand(activityId)); + + assertThat(activity.getCoverImageObjectKey()).isEqualTo("smallest.jpg"); + verifyNoInteractions(fileImageAccessUrlPort); + } + + @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); + } - verify(activityRepository, never()).save(any(Activity.class)); + 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 new file mode 100644 index 00000000..b278272b --- /dev/null +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/TimelineServiceTest.java @@ -0,0 +1,308 @@ +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 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.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; +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.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 { + + @Mock + private ActivityRepository activityRepository; + + @Mock + private TimelineRepository timelineRepository; + + @Mock + private FileImageAccessUrlPort fileImageAccessUrlPort; + + @Mock + 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)); + }); + } + + 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"; + 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)); + 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.memberId()).isEqualTo(memberId.toString()); + 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"; + 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)); + stubTransactionTemplateToRunCallback(); + + CountDownLatch latch = new CountDownLatch(1); + doAnswer(invocation -> { + try { + throw new ActivityNotFoundException(); + } finally { + latch.countDown(); + } + }).when(fileDeletePort).deleteImage(any()); + + assertThatCode(() -> timelineService.delete(activityId, timelineId, memberId)) + .doesNotThrowAnyException(); + + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void delete_활동이_없으면_예외를_던지고_이미지_삭제를_요청하지_않는다() { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + + 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(fileDeletePort, never()).deleteImage(any()); + } + + @Test + void delete_타임라인이_없으면_예외를_던지고_이미지_삭제를_요청하지_않는다() { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + + 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(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; + Long timelineId = 10L; + Long memberId = 100L; + String newTitle = "백운대 정상 도착"; + + Timeline timeline = Timeline.builder() + .id(timelineId) + .title("이전 제목") + .build(); + + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.of(timeline)); + + 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 + void updateTitle_활동이_없으면_예외를_던진다() { + Long activityId = 1L; + 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); + } + + @Test + void updateTitle_타임라인이_없으면_예외를_던진다() { + Long activityId = 1L; + Long timelineId = 10L; + Long memberId = 100L; + + when(timelineRepository.findByIdAndActivity_IdAndActivity_MemberId(timelineId, activityId, memberId)) + .thenReturn(Optional.empty()); + when(activityRepository.existsByIdAndMemberId(activityId, memberId)).thenReturn(true); + + assertThatThrownBy(() -> + timelineService.updateTitle(new UpdateTimelineTitleCommand(activityId, timelineId, memberId, "제목")) + ).isInstanceOf(TimelineNotFoundException.class); + } +} diff --git a/routee-app/src/main/resources/application.yml b/routee-app/src/main/resources/application.yml index 319d0fd3..062f310f 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} @@ -17,7 +20,7 @@ spring: format_sql: true jdbc: time_zone: UTC - show-sql: false + show-sql: true data: redis: @@ -30,7 +33,22 @@ 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} + google: + jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs + issuer: https://accounts.google.com + client-id: ${GOOGLE_CLIENT_ID} + +oauth: + providers: + apple: + token-uri: https://appleid.apple.com/auth/token + revoke-uri: https://appleid.apple.com/auth/revoke + audience: https://appleid.apple.com + client-id: ${APPLE_CLIENT_ID} + team-id: ${APPLE_TEAM_ID} + key-id: ${APPLE_KEY_ID} + private-key: ${APPLE_PRIVATE_KEY} jwt: secret: ${JWT_SECRET} diff --git a/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java b/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java index bb24727a..d89ed641 100644 --- a/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java +++ b/routee-auth/src/main/java/org/sopt/routee/auth/internal/listener/AuthMemberEventListener.java @@ -2,8 +2,9 @@ import org.sopt.routee.auth.internal.service.AuthService; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; -import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; import lombok.RequiredArgsConstructor; @@ -13,7 +14,8 @@ class AuthMemberEventListener { private final AuthService authService; - @ApplicationModuleListener + @Async + @TransactionalEventListener(fallbackExecution = true) void handleMemberWithdrawnEvent(MemberWithdrawnEvent event) { authService.revokeTokens(event.accessTokenHash(), event.refreshTokenHash()); } 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/build.gradle b/routee-external/build.gradle index c998db47..31f873e8 100644 --- a/routee-external/build.gradle +++ b/routee-external/build.gradle @@ -6,6 +6,9 @@ dependencies { // OAuth implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + // Apple client secret nimbus + implementation 'com.nimbusds:nimbus-jose-jwt' + // AWS implementation platform("software.amazon.awssdk:bom:${rootProject.ext['awsSdkVersion']}") implementation 'software.amazon.awssdk:s3' 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..e196356c --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteCommand.java @@ -0,0 +1,15 @@ +package org.sopt.routee.external.api.command; + +import org.sopt.routee.external.api.type.FileUploadDirectory; + +public record FileDeleteCommand( + FileUploadDirectory directory, + String memberId, + 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 new file mode 100644 index 00000000..682a5ca8 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileDeleteDirectoryCommand.java @@ -0,0 +1,11 @@ +package org.sopt.routee.external.api.command; + +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/api/command/FileImageAccessUrlCommand.java b/routee-external/src/main/java/org/sopt/routee/external/api/command/FileImageAccessUrlCommand.java index 31fc2034..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 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 6120e569..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 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/api/port/FileDeletePort.java b/routee-external/src/main/java/org/sopt/routee/external/api/port/FileDeletePort.java new file mode 100644 index 00000000..be7868f9 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/port/FileDeletePort.java @@ -0,0 +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/api/port/OAuthRevokePort.java b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java new file mode 100644 index 00000000..cd8776da --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/api/port/OAuthRevokePort.java @@ -0,0 +1,6 @@ +package org.sopt.routee.external.api.port; + +public interface OAuthRevokePort { + + void revoke(String authorizationCode); +} 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/api/type/OAuthProvider.java b/routee-external/src/main/java/org/sopt/routee/external/api/type/OAuthProvider.java index 3dea0b49..fbe65f2a 100644 --- a/routee-external/src/main/java/org/sopt/routee/external/api/type/OAuthProvider.java +++ b/routee-external/src/main/java/org/sopt/routee/external/api/type/OAuthProvider.java @@ -1,5 +1,6 @@ package org.sopt.routee.external.api.type; public enum OAuthProvider { - APPLE -} \ No newline at end of file + APPLE, + GOOGLE +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java new file mode 100644 index 00000000..7b32cd2d --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleClientSecretGenerator.java @@ -0,0 +1,76 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.concurrent.atomic.AtomicReference; + +import org.sopt.routee.external.internal.oauth.config.OAuthRevokeProperty; +import org.sopt.routee.external.internal.oauth.exception.AppleClientSecretException; +import org.springframework.stereotype.Component; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +@Component +class AppleClientSecretGenerator { + + private static final Duration TOKEN_EXPIRY = Duration.ofMinutes(30); + private static final Duration CACHE_TTL = Duration.ofMinutes(25); + + private final OAuthRevokeProperty property; + private final AtomicReference cache = new AtomicReference<>(); + + AppleClientSecretGenerator(OAuthRevokeProperty property) { + this.property = property; + } + + String generate() { + CachedSecret cached = cache.get(); + if (cached != null && cached.expiresAt().isAfter(Instant.now())) { + return cached.value(); + } + + String secret = sign(); + cache.set(new CachedSecret(secret, Instant.now().plus(CACHE_TTL))); + return secret; + } + + private String sign() { + try { + Instant now = Instant.now(); + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(property.keyId()).build(), + new JWTClaimsSet.Builder() + .issuer(property.teamId()) + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plus(TOKEN_EXPIRY))) + .audience(property.audience()) + .subject(property.clientId()) + .build() + ); + jwt.sign(new ECDSASigner(parsePrivateKey(property.privateKey()))); + return jwt.serialize(); + } catch (JOSEException | GeneralSecurityException | IllegalArgumentException e) { + throw new AppleClientSecretException(e); + } + } + + private ECPrivateKey parsePrivateKey(String privateKey) throws GeneralSecurityException { + byte[] der = Base64.getDecoder().decode(privateKey); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return (ECPrivateKey)keyFactory.generatePrivate(new PKCS8EncodedKeySpec(der)); + } + + private record CachedSecret(String value, Instant expiresAt) { + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java new file mode 100644 index 00000000..62f0ccc4 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java @@ -0,0 +1,81 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import org.sopt.routee.external.api.port.OAuthRevokePort; +import org.sopt.routee.external.internal.oauth.config.OAuthRevokeProperty; +import org.sopt.routee.external.internal.oauth.exception.OAuthRevokeException; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +class AppleOAuthRevokeAdapter implements OAuthRevokePort { + + private static final String REFRESH_TOKEN_HINT = "refresh_token"; + + private final RestClient restClient; + private final OAuthRevokeProperty property; + private final AppleClientSecretGenerator clientSecretGenerator; + + @Override + public void revoke(String authorizationCode) { + OAuthTokenResponse token = exchangeAuthorizationCode(authorizationCode); + + if (!StringUtils.hasText(token.refreshToken())) { + throw new OAuthRevokeException(); + } + + requestRevoke(token.refreshToken(), REFRESH_TOKEN_HINT); + } + + private OAuthTokenResponse exchangeAuthorizationCode(String authorizationCode) { + MultiValueMap form = credentialForm(); + + form.add("grant_type", "authorization_code"); + form.add("code", authorizationCode); + + OAuthTokenResponse response = post(property.tokenUri(), form, OAuthTokenResponse.class); + + if (response == null) { + throw new OAuthRevokeException(); + } + + return response; + } + + private void requestRevoke(String token, String tokenTypeHint) { + MultiValueMap form = credentialForm(); + form.add("token", token); + form.add("token_type_hint", tokenTypeHint); + + post(property.revokeUri(), form, Void.class); + } + + private T post(String uri, MultiValueMap form, Class responseType) { + try { + return restClient.post() + .uri(uri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(form) + .retrieve() + .body(responseType); + } catch (RestClientException e) { + throw new OAuthRevokeException(e); + } + } + + private MultiValueMap credentialForm() { + MultiValueMap form = new LinkedMultiValueMap<>(); + + form.add("client_id", property.clientId()); + form.add("client_secret", clientSecretGenerator.generate()); + + return form; + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java new file mode 100644 index 00000000..a33fb1a1 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/OAuthTokenResponse.java @@ -0,0 +1,8 @@ +package org.sopt.routee.external.internal.oauth.adapter; + +import com.fasterxml.jackson.annotation.JsonProperty; + +record OAuthTokenResponse( + @JsonProperty("refresh_token") String refreshToken +) { +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java new file mode 100644 index 00000000..65e68914 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/code/ErrorCode.java @@ -0,0 +1,18 @@ +package org.sopt.routee.external.internal.oauth.code; + +import org.sopt.routee.code.ErrorResultCode; +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode implements ErrorResultCode { + + OAUTH_REVOKE_FAILED(HttpStatus.BAD_GATEWAY, "소셜 로그인 연동 해제에 실패했습니다."), + APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple client secret 생성에 실패했습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java new file mode 100644 index 00000000..684d7f13 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeClientConfig.java @@ -0,0 +1,30 @@ +package org.sopt.routee.external.internal.oauth.config; + +import java.net.http.HttpClient; +import java.time.Duration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +@Configuration +class OAuthRevokeClientConfig { + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(10); + + @Bean + RestClient oauthRevokeRestClient() { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(READ_TIMEOUT); + + return RestClient.builder() + .requestFactory(requestFactory) + .build(); + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java new file mode 100644 index 00000000..b69d91a1 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/config/OAuthRevokeProperty.java @@ -0,0 +1,15 @@ +package org.sopt.routee.external.internal.oauth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.providers.apple") +public record OAuthRevokeProperty( + String tokenUri, + String revokeUri, + String audience, + String clientId, + String teamId, + String keyId, + String privateKey +) { +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java new file mode 100644 index 00000000..e2185b34 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/AppleClientSecretException.java @@ -0,0 +1,11 @@ +package org.sopt.routee.external.internal.oauth.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.internal.oauth.code.ErrorCode; + +public final class AppleClientSecretException extends BaseException { + + public AppleClientSecretException(Throwable cause) { + super(ErrorCode.APPLE_CLIENT_SECRET_GENERATION_FAILED, cause); + } +} diff --git a/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java new file mode 100644 index 00000000..9aced0bf --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/oauth/exception/OAuthRevokeException.java @@ -0,0 +1,15 @@ +package org.sopt.routee.external.internal.oauth.exception; + +import org.sopt.routee.exception.BaseException; +import org.sopt.routee.external.internal.oauth.code.ErrorCode; + +public final class OAuthRevokeException extends BaseException { + + public OAuthRevokeException() { + super(ErrorCode.OAUTH_REVOKE_FAILED); + } + + public OAuthRevokeException(Throwable cause) { + super(ErrorCode.OAUTH_REVOKE_FAILED, cause); + } +} 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..9fb08611 --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapter.java @@ -0,0 +1,107 @@ +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.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; +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.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 +@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(); + + deleteObjects(objectIdentifiers); + } + + @Override + public void deleteDirectory(FileDeleteDirectoryCommand command) { + String prefix = command.activityId() == null + ? S3ObjectKeyAssembler.assembleMemberDirectoryPrefix(command.memberId()) + : 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()) + .build(); + + DeleteObjectsResponse response; + try { + response = s3Client.deleteObjects(request); + } catch (RuntimeException e) { + throw new FileDeleteException(e); + } + + if (response.hasErrors()) { + throw new FileDeleteException(); + } + } + + private List resolveObjectKeys(FileDeleteCommand command) { + if (command.directory() != FileUploadDirectory.TIMELINE) { + return List.of(S3ObjectKeyAssembler.assemble( + command.directory(), null, command.memberId(), command.activityId(), command.objectKey() + )); + } + + return Arrays.stream(FileUploadImageSize.values()) + .map(imageSize -> S3ObjectKeyAssembler.assemble( + 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 f6081d05..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,6 +19,7 @@ public FileImageAccessUrlResult generateImageUrl(FileImageAccessUrlCommand comma String objectKey = S3ObjectKeyAssembler.assemble( command.directory(), command.imageSize(), + command.memberId(), command.activityId(), command.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..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 @@ -2,46 +2,56 @@ 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() { - } + private static final String MEMBER_ROOT_PATH = "member"; + private static final String ACTIVITY_SUB_PATH = "activity"; static String assemble( FileUploadDirectory directory, FileUploadImageSize imageSize, + String memberId, String activityId, String objectKey ) { return switch (directory) { - case TIMELINE -> "%s/%s/%s/%s".formatted( - activityId, + 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".formatted( - activityId, + 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, + memberId, directory.path(), objectKey ); }; } - static String assembleUploadObjectKey( - FileUploadDirectory directory, - FileUploadImageSize imageSize, - String activityId, - String objectKey - ) { - return "%s/%s".formatted( - ACTIVITY_ROOT_PATH, - assemble(directory, imageSize, activityId, objectKey) - ); + 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-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..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 @@ -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; @@ -28,9 +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.memberId(), command.activityId(), objectKey ); @@ -45,8 +46,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(FileUploadPresignException::new); } private String generatePutPresignedUrl(String objectKey) { 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..3c5bd13d --- /dev/null +++ b/routee-external/src/main/java/org/sopt/routee/external/internal/s3/exception/FileDeleteException.java @@ -0,0 +1,15 @@ +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() { + super(ErrorCode.FILE_DELETE_FAILED); + } + + public FileDeleteException(Throwable cause) { + super(ErrorCode.FILE_DELETE_FAILED, cause); + } +} 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); } 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..ac4a306c --- /dev/null +++ b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3FileDeleteAdapterTest.java @@ -0,0 +1,198 @@ +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.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; + +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 { + + @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, "9", "1", "timeline-image.jpg"); + when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))) + .thenReturn(DeleteObjectsResponse.builder().build()); + + 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( + "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, "9", "1", "recap-image.jpg"); + when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))) + .thenReturn(DeleteObjectsResponse.builder().build()); + + 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("member/9/activity/1/recap/recap-image.jpg"); + } + + @Test + void deleteImage_S3_삭제_요청이_실패하면_FileDeleteException으로_변환한다() { + 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)) + .isInstanceOf(FileDeleteException.class); + } + + @Test + void deleteImage_일부_객체_삭제가_실패하면_FileDeleteException을_던진다() { + FileDeleteCommand command = new FileDeleteCommand(FileUploadDirectory.RECAP, "9", "1", "recap-image.jpg"); + S3Error error = S3Error.builder() + .key("member/9/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); + } + + @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 new file mode 100644 index 00000000..ce7fd94f --- /dev/null +++ b/routee-external/src/test/java/org/sopt/routee/external/internal/s3/adapter/S3ObjectKeyAssemblerTest.java @@ -0,0 +1,61 @@ +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); + } + + @Test + void assembleActivityDirectoryPrefix_member_루트_아래_activity_디렉터리_프리픽스를_조립한다() { + String prefix = S3ObjectKeyAssembler.assembleActivityDirectoryPrefix("1", "2"); + + assertThat(prefix).isEqualTo("member/1/activity/2/"); + } +} 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..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,7 +12,9 @@ public enum ErrorCode implements ErrorResultCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자 정보가 존재하지 않습니다."), ALREADY_REGISTERED_MEMBER(HttpStatus.CONFLICT, "이미 가입된 회원입니다."), - INVALID_TIME_ZONE(HttpStatus.BAD_REQUEST, "Time-Zone 헤더 값이 올바르지 않습니다."); + REQUIRED_AGREEMENT_NOT_ACCEPTED(HttpStatus.BAD_REQUEST, "필수 약관에 모두 동의해야 합니다."), + 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 d819a0a6..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 @@ -12,6 +12,10 @@ public enum SuccessCode implements SuccessResultCode { MEMBER_FOUND(HttpStatus.OK, "사용자 정보 조회에 성공하였습니다."), 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, "프로필 이미지 변경이 완료되었습니다."), + 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 114869dd..51bb70d1 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,22 @@ 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.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; @@ -20,6 +31,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; @@ -41,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)); @@ -58,13 +71,13 @@ public ResponseEntity> withdraw( String accessTokenHash = TokenHasher.hash(TokenExtractor.extract(accessTokenWithBearer)); String refreshTokenHash = TokenHasher.hash(request.refreshToken()); - memberService.withdraw(memberId, accessTokenHash, refreshTokenHash); + memberService.withdraw(request.toCommand(memberId, accessTokenHash, refreshTokenHash)); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponse.success(SuccessCode.MEMBER_WITHDRAW)); } - @GetMapping(path = "/member/profile") + @GetMapping(path = "/member/summary") public ResponseEntity> getMemberInfo( @AuthenticationPrincipal Long memberId, @RequestHeader(name = "Time-Zone") ZoneId timeZone @@ -75,6 +88,59 @@ public ResponseEntity> getMemberInfo( .body(ApiResponse.success(SuccessCode.MEMBER_FOUND, MemberInfoResponse.from(result))); } + @GetMapping(path = "/member/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))); + } + + @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))); + } + + @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))); + } + + @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 7d99da63..b05f80ce 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,13 @@ 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.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; @@ -31,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 = "회원가입 성공"), @@ -39,7 +48,13 @@ 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 = "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\":\"요청 바디를 읽을 수 없습니다.\"}") })), @@ -58,9 +73,22 @@ 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 = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다.") + @Operation(summary = "회원 탈퇴", + description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다. refresh_token은 모든 탈퇴 요청에 필수입니다. " + + "Apple 로그인 회원은 authorization_code를 함께 전달해야 소셜 로그인 연동도 해제됩니다. " + + "authorization_code는 탈퇴 직전 재인증하여 발급받은 값이어야 하며, Apple 외 소셜 로그인 회원은 전달하지 않아도 됩니다. " + + "연동 해제에 실패하더라도 탈퇴 자체는 완료됩니다.") @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @ApiResponse(responseCode = "200", description = "탈퇴 성공"), @@ -84,6 +112,14 @@ public interface MemberControllerDocs { ResponseEntity> withdraw( Long memberId, @RequestHeader(name = "Authorization") String accessTokenWithBearer, + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, + content = @Content(schema = @Schema(implementation = WithdrawRequest.class), + examples = { + @ExampleObject(name = "APPLE_MEMBER", summary = "Apple 로그인 회원", + value = "{\"refreshToken\":\"eyJ...\",\"authorizationCode\":\"c1234...\"}"), + @ExampleObject(name = "OTHER_MEMBER", summary = "그 외 소셜 로그인 회원", + value = "{\"refreshToken\":\"eyJ...\"}") + })) @Valid @RequestBody WithdrawRequest request ); @@ -122,6 +158,162 @@ 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 = "인증된 회원의 닉네임을 변경합니다." + ) + @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\":\"닉네임은 한글, 영어, 숫자와 공백을 사용하여 1자 이상 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 = "프로필 이미지 업로드 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 = "회원의 프로필 이미지를 기본 이미지로 되돌립니다. 기존에 업로드된 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/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/NicknameUpdateRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/NicknameUpdateRequest.java new file mode 100644 index 00000000..af74fdfa --- /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 = "^(?=.{1,12}$)[가-힣a-zA-Z0-9]+(?: [가-힣a-zA-Z0-9]+)*$", message = "닉네임은 한글, 영어, 숫자와 공백을 사용하여 1자 이상 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/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/request/RegisterRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/RegisterRequest.java index 84a99428..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 @@ -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; @@ -13,10 +16,13 @@ public record RegisterRequest( @NotBlank String idToken, @NotBlank - @Pattern(regexp = "^[가-힣a-zA-Z0-9]{2,12}$", message = "닉네임은 한글, 영어, 숫자만 사용하여 2자 이상 12자 이하로 입력해야 합니다.") - String nickname + @Pattern(regexp = "^(?=.{1,12}$)[가-힣a-zA-Z0-9]+(?: [가-힣a-zA-Z0-9]+)*$", message = "닉네임은 한글, 영어, 숫자와 공백을 사용하여 1자 이상 12자 이하로 입력해야 하며, 공백은 연속될 수 없습니다.") + 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/controller/dto/request/WithdrawRequest.java b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java index 673d537a..45cf1d2d 100644 --- a/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/controller/dto/request/WithdrawRequest.java @@ -1,9 +1,20 @@ package org.sopt.routee.member.internal.controller.dto.request; +import org.sopt.routee.member.internal.service.dto.command.WithdrawCommand; + +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; public record WithdrawRequest( + @Schema(description = "탈퇴를 요청하는 회원의 리프레시 토큰. 모든 탈퇴 요청에 필수입니다.") @NotBlank(message = "refresh_token은 필수입니다.") - String refreshToken + String refreshToken, + + @Schema(description = "탈퇴 시점에 재인증하여 발급받은 Apple 인가 코드. Apple 계정 연동 해제에 사용되며, Apple 로그인 회원만 필요합니다. " + + "그 외 소셜 로그인 회원은 전달하지 않아도 됩니다.") + String authorizationCode ) { + public WithdrawCommand toCommand(Long memberId, String accessTokenHash, String refreshTokenHash) { + return new WithdrawCommand(memberId, accessTokenHash, refreshTokenHash, authorizationCode); + } } diff --git a/routee-member/src/main/java/org/sopt/routee/member/internal/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/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/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 f02dc171..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; @@ -58,4 +58,12 @@ public class Member extends BaseEntity { @Builder.Default @ColumnDefault("0") private Integer totalActivityCount = 0; + + public void updateNickname(String nickname) { + this.nickname = nickname; + } + + public void updateProfileImageObjectKey(String profileImageObjectKey) { + this.profileImageObjectKey = profileImageObjectKey; + } } 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..ef027bf6 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/entity/MemberAgreement.java @@ -0,0 +1,72 @@ +package org.sopt.routee.member.internal.entity; + +import java.time.Instant; +import java.time.ZoneId; + +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 = "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/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/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 fc8c4808..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 @@ -1,18 +1,24 @@ 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; 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; +import org.sopt.routee.member.internal.entity.MemberAgreement; import org.sopt.routee.util.TimeZoneUtils; import lombok.AccessLevel; @@ -31,23 +37,53 @@ public static Member toEntity(RegisterCommand command, String oauthId) { .build(); } + 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(); + } + 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, String profileImageUrl) { + return new MemberProfileResult(member.getNickname(), profileImageUrl); + } + + public static UpdateNicknameResult toUpdateNicknameResult(Member member) { + return new UpdateNicknameResult(member.getNickname()); + } + + public static UpdateProfileImageResult toUpdateProfileImageResult(String profileImageUrl) { + return new UpdateProfileImageResult(profileImageUrl); + } + public static ActivitySummaryResult toActivitySummaryResult( List summaries, int year, int month ) { 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 efaf9497..3c81d2aa 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,37 +1,75 @@ 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; 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; +import org.sopt.routee.external.api.type.FileUploadDirectory; import org.sopt.routee.external.api.type.OAuthProvider; +import org.sopt.routee.external.api.port.OAuthRevokePort; import org.sopt.routee.external.api.port.OidcVerifyPort; import org.sopt.routee.member.api.event.MemberWithdrawnEvent; +import org.sopt.routee.member.internal.service.dto.command.AgreementCommand; +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.command.WithdrawCommand; import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult; import org.sopt.routee.member.internal.service.dto.result.MemberInfoResult; +import org.sopt.routee.member.internal.service.dto.result.MemberProfileResult; +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.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.orm.ObjectOptimisticLockingFailureException; 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 { private final OidcVerifyPort oidcVerifyPort; + private final OAuthRevokePort oAuthRevokePort; private final ActivityUseCase activityUseCase; private final MemberRepository memberRepository; + private final MemberAgreementRepository memberAgreementRepository; 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) { @@ -48,24 +86,78 @@ 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, command.timeZone())); } - @Transactional - public void withdraw(long memberId, String accessTokenHash, String refreshTokenHash) { - Member member = memberRepository.findById(memberId) - .orElseThrow(MemberNotFoundException::new); + private void validateRequiredAgreements(AgreementCommand agreement) { + boolean allRequiredAccepted = agreement.serviceTerms() + && agreement.privacyPolicy() + && agreement.locationServiceTerms() + && agreement.over14(); + + if (!allRequiredAccepted) { + throw new RequiredAgreementNotAcceptedException(); + } + } + + public void withdraw(WithdrawCommand command) { + long memberId = command.memberId(); + + OAuthProvider oauthProvider; + try { + oauthProvider = transactionTemplate.execute(status -> { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + memberAgreementRepository.deleteByMember_Id(memberId); + memberRepository.delete(member); + + activityUseCase.deleteForMemberWithdrawal(memberId); + + return member.getOauthProvider(); + }); + } catch (ObjectOptimisticLockingFailureException e) { + throw new MemberNotFoundException(); + } + + revokeOAuthConnection(memberId, oauthProvider, command.authorizationCode()); - memberRepository.delete(member); + applicationEventPublisher.publishEvent( + new MemberWithdrawnEvent(memberId, command.accessTokenHash(), command.refreshTokenHash())); + + Thread.startVirtualThread(() -> deleteMemberImages(memberId)); + } - activityUseCase.deleteForMemberWithdrawal(memberId); + private void revokeOAuthConnection(long memberId, OAuthProvider oauthProvider, String authorizationCode) { + if (oauthProvider != OAuthProvider.APPLE) { + return; + } - applicationEventPublisher.publishEvent(new MemberWithdrawnEvent(memberId, accessTokenHash, refreshTokenHash)); + try { + oAuthRevokePort.revoke(authorizationCode); + } catch (BaseException e) { + log.warn("OAuth revoke failed. memberId={}, provider={}", memberId, oauthProvider, e); + } + } + + private void deleteMemberImages(long memberId) { + try { + fileDeletePort.deleteDirectory(new FileDeleteDirectoryCommand(Long.toString(memberId))); + } catch (BaseException e) { + log.warn("Member image directory delete failed. memberId={}", memberId, e); + } } @Transactional(readOnly = true) @@ -73,7 +165,98 @@ 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.getId(), member.getProfileImageObjectKey()), zoneId); + } + + @Transactional(readOnly = true) + public MemberProfileResult getMemberProfile(long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + return MemberMapper.toMemberProfileResult( + member, generateProfileImageUrl(member.getId(), member.getProfileImageObjectKey())); + } + + @Transactional + public UpdateNicknameResult updateNickname(UpdateNicknameCommand command) { + Member member = memberRepository.findById(command.memberId()) + .orElseThrow(MemberNotFoundException::new); + + member.updateNickname(command.nickname()); + 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()); + } + + public UpdateProfileImageResult updateProfileImage(UpdateProfileImageCommand command) { + 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 && !previousObjectKey.equals(command.objectKey())) { + Thread.startVirtualThread(() -> deleteProfileImage(command.memberId(), previousObjectKey)); + } + + 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)); + } catch (BaseException e) { + log.warn("Profile image delete failed. memberId={}, objectKey={}", memberId, objectKey, e); + } + } + + private String generateProfileImageUrl(Long memberId, String profileImageObjectKey) { + if (profileImageObjectKey == null) { + return null; + } + + FileImageAccessUrlCommand accessUrlCommand = new FileImageAccessUrlCommand( + FileUploadDirectory.PROFILE, + null, + memberId.toString(), + profileImageObjectKey + ); + return fileImageAccessUrlPort.generateImageUrl(accessUrlCommand).imageUrl(); } @Transactional 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/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/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 ) { } 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/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/command/WithdrawCommand.java b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/WithdrawCommand.java new file mode 100644 index 00000000..a2f4166b --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/dto/command/WithdrawCommand.java @@ -0,0 +1,9 @@ +package org.sopt.routee.member.internal.service.dto.command; + +public record WithdrawCommand( + Long memberId, + String accessTokenHash, + String refreshTokenHash, + String authorizationCode +) { +} 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 +) { +} 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/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 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..2c854092 --- /dev/null +++ b/routee-member/src/main/java/org/sopt/routee/member/internal/service/validator/ProfileImageFileNameValidator.java @@ -0,0 +1,18 @@ +package org.sopt.routee.member.internal.service.validator; + +import java.util.Set; + +import org.sopt.routee.util.FileExtensionExtractor; +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) { + return FileExtensionExtractor.extract(fileName) + .map(SUPPORTED_EXTENSIONS::contains) + .orElse(false); + } +} \ No newline at end of file diff --git a/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java b/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java new file mode 100644 index 00000000..c4163706 --- /dev/null +++ b/routee-member/src/test/java/org/sopt/routee/member/internal/service/MemberServiceTest.java @@ -0,0 +1,193 @@ +package org.sopt.routee.member.internal.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.routee.activity.api.usecase.ActivityUseCase; +import org.sopt.routee.external.api.port.FileDeletePort; +import org.sopt.routee.external.api.port.FileImageAccessUrlPort; +import org.sopt.routee.external.api.port.FileUploadPresignPort; +import org.sopt.routee.external.api.port.OAuthRevokePort; +import org.sopt.routee.external.api.port.OidcVerifyPort; +import org.sopt.routee.external.api.type.OAuthProvider; +import org.sopt.routee.member.api.event.MemberWithdrawnEvent; +import org.sopt.routee.member.internal.entity.Member; +import org.sopt.routee.member.internal.exception.MemberNotFoundException; +import org.sopt.routee.member.internal.repository.MemberAgreementRepository; +import org.sopt.routee.member.internal.repository.MemberRepository; +import org.sopt.routee.member.internal.service.dto.command.WithdrawCommand; +import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + private static final long MEMBER_ID = 1L; + private static final String ACCESS_TOKEN_HASH = "access-hash"; + private static final String REFRESH_TOKEN_HASH = "refresh-hash"; + private static final String AUTHORIZATION_CODE = "auth-code"; + private static final WithdrawCommand WITHDRAW_COMMAND = + new WithdrawCommand(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH, AUTHORIZATION_CODE); + + @Mock + private OidcVerifyPort oidcVerifyPort; + + @Mock + private OAuthRevokePort oAuthRevokePort; + + @Mock + private ActivityUseCase activityUseCase; + + @Mock + private MemberRepository memberRepository; + + @Mock + private MemberAgreementRepository memberAgreementRepository; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Mock + private FileUploadPresignPort fileUploadPresignPort; + + @Mock + private FileImageAccessUrlPort fileImageAccessUrlPort; + + @Mock + private FileDeletePort fileDeletePort; + + @Mock + private ProfileImageFileNameValidator profileImageFileNameValidator; + + @Mock + private TransactionTemplate transactionTemplate; + + private MemberService memberService; + + @BeforeEach + void setUp() { + memberService = new MemberService( + oidcVerifyPort, + oAuthRevokePort, + activityUseCase, + memberRepository, + memberAgreementRepository, + applicationEventPublisher, + fileUploadPresignPort, + fileImageAccessUrlPort, + fileDeletePort, + profileImageFileNameValidator, + transactionTemplate + ); + } + + @SuppressWarnings("unchecked") + private void stubTransactionTemplateToRunCallback() { + when(transactionTemplate.execute(any(TransactionCallback.class))).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); + } + + @Test + @DisplayName("withdraw: 회원을 탈퇴시키고 소셜 연동을 해제한 뒤 토큰 무효화 이벤트를 발행한다") + void withdraw_success_revokesOAuthAndPublishesEvent() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(member.getOauthProvider()).thenReturn(OAuthProvider.APPLE); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + + // when + memberService.withdraw(WITHDRAW_COMMAND); + + // then + verify(memberAgreementRepository).deleteByMember_Id(MEMBER_ID); + verify(memberRepository).delete(member); + verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + verify(oAuthRevokePort).revoke(AUTHORIZATION_CODE); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(MemberWithdrawnEvent.class); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue()) + .extracting( + MemberWithdrawnEvent::memberId, + MemberWithdrawnEvent::accessTokenHash, + MemberWithdrawnEvent::refreshTokenHash) + .containsExactly(MEMBER_ID, ACCESS_TOKEN_HASH, REFRESH_TOKEN_HASH); + } + + @Test + @DisplayName("withdraw: 소셜 연동 해제가 실패해도 회원 탈퇴와 이벤트 발행은 정상 처리된다") + void withdraw_oauthRevokeFails_stillCompletesWithdrawal() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(member.getOauthProvider()).thenReturn(OAuthProvider.APPLE); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + // 임의의 BaseException — revoke 어댑터가 던지는 예외를 흉내낸다 + doThrow(new MemberNotFoundException()).when(oAuthRevokePort).revoke(AUTHORIZATION_CODE); + + // when + memberService.withdraw(WITHDRAW_COMMAND); + + // then + verify(memberRepository).delete(member); + verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + verify(applicationEventPublisher).publishEvent(any(MemberWithdrawnEvent.class)); + } + + @Test + @DisplayName("withdraw: Apple이 아닌 provider는 OAuth revoke를 호출하지 않는다") + void withdraw_nonAppleProvider_doesNotCallOAuthRevoke() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(member.getOauthProvider()).thenReturn(OAuthProvider.GOOGLE); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + + // when + memberService.withdraw(WITHDRAW_COMMAND); + + // then + verify(memberRepository).delete(member); + verify(activityUseCase).deleteForMemberWithdrawal(MEMBER_ID); + verify(oAuthRevokePort, never()).revoke(any()); + verify(applicationEventPublisher).publishEvent(any(MemberWithdrawnEvent.class)); + } + + @Test + @DisplayName("withdraw: 동시 탈퇴 요청으로 낙관적 락 예외가 발생하면 회원 없음 예외로 변환한다") + void withdraw_concurrentWithdrawal_throwsMemberNotFoundException() { + // given + stubTransactionTemplateToRunCallback(); + Member member = mock(Member.class); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + doThrow(new ObjectOptimisticLockingFailureException(Member.class, MEMBER_ID)) + .when(memberAgreementRepository).deleteByMember_Id(MEMBER_ID); + + // when + Throwable thrown = catchThrowable( + () -> memberService.withdraw(WITHDRAW_COMMAND)); + + // then + assertThat(thrown).isInstanceOf(MemberNotFoundException.class); + verify(activityUseCase, never()).deleteForMemberWithdrawal(anyLong()); + verify(oAuthRevokePort, never()).revoke(any()); + verify(applicationEventPublisher, never()).publishEvent(any()); + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index b74128fa..ef49cb9c 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -57,20 +57,19 @@ 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/60)" 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 + if [ "$i" -eq 60 ]; 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