diff --git a/alloy/config.alloy b/alloy/config.alloy index db20ad8..c395853 100644 --- a/alloy/config.alloy +++ b/alloy/config.alloy @@ -36,8 +36,7 @@ prometheus.scrape "node" { local.file_match "local_files" { path_targets = [ { - __path__ = "/var/log/app/*.json", - env = "dev", + __path__ = "/app/log/*.log", app = "recycle-study-server", }, ] @@ -85,6 +84,6 @@ otelcol.processor.memory_limiter "default" { otelcol.exporter.otlphttp "default" { client { - endpoint = sys.env("TEMPO_HOST") + "/v1/traces" + endpoint = sys.env("TEMPO_HOST") } } diff --git a/alloy/docker-compose.yml b/alloy/docker-compose.yml deleted file mode 100644 index 58ee940..0000000 --- a/alloy/docker-compose.yml +++ /dev/null @@ -1,31 +0,0 @@ -services: - alloy: - image: grafana/alloy:latest - container_name: alloy - restart: unless-stopped - env_file: - - ../.env - volumes: - - ./config.alloy:/etc/alloy/config.alloy - - /app/log:/var/log/app - command: - - "run" - - "--server.http.listen-addr=0.0.0.0:12345" - - "--storage.path=/var/lib/alloy" - - "--stability.level=experimental" - - "/etc/alloy/config.alloy" - ports: - - "12345:12345" # Alloy UI - - "4317:4317" # OTLP gRPC - networks: - - observability - - node-exporter: - image: quay.io/prometheus/node-exporter:latest - container_name: node-exporter - restart: unless-stopped - networks: - - observability - -networks: - observability: diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index 4bec73e..00718ac 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -9,3 +9,36 @@ services: - .env volumes: - /app/log:/app/log + networks: + - observability + + alloy: + image: grafana/alloy:latest + container_name: alloy + restart: unless-stopped + env_file: + - .env + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy + - /app/log:/app/log + command: + - "run" + - "--server.http.listen-addr=0.0.0.0:12345" + - "--storage.path=/var/lib/alloy" + - "--stability.level=experimental" + - "/etc/alloy/config.alloy" + ports: + - "12345:12345" # Alloy UI + - "4317:4317" # OTLP gRPC + networks: + - observability + + node-exporter: + image: quay.io/prometheus/node-exporter:latest + container_name: node-exporter + restart: unless-stopped + networks: + - observability + +networks: + observability: diff --git a/docker-compose.yaml b/docker-compose.yaml index 892f0cd..5e9575f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,10 +7,42 @@ services: - "8080:8080" env_file: - .env + volumes: + - app_log:/app/log + networks: + - observability depends_on: mysql: condition: service_healthy + alloy: + image: grafana/alloy:latest + container_name: alloy + restart: unless-stopped + env_file: + - .env + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy + - app_log:/app/log + command: + - "run" + - "--server.http.listen-addr=0.0.0.0:12345" + - "--storage.path=/var/lib/alloy" + - "--stability.level=experimental" + - "/etc/alloy/config.alloy" + ports: + - "12345:12345" # Alloy UI + - "14317:4317" # OTLP gRPC + networks: + - observability + + node-exporter: + image: quay.io/prometheus/node-exporter:latest + container_name: node-exporter + restart: unless-stopped + networks: + - observability + mysql: container_name: recycle-study-mysql image: mysql:8.4 @@ -29,6 +61,12 @@ services: interval: 10s timeout: 5s retries: 5 + networks: + - observability volumes: mysql_volume: + app_log: + +networks: + observability: diff --git a/src/main/java/com/recyclestudy/common/annotation/AuthDevice.java b/src/main/java/com/recyclestudy/common/annotation/AuthDevice.java new file mode 100644 index 0000000..74370ed --- /dev/null +++ b/src/main/java/com/recyclestudy/common/annotation/AuthDevice.java @@ -0,0 +1,11 @@ +package com.recyclestudy.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthDevice { +} diff --git a/src/main/java/com/recyclestudy/common/config/WebMvcConfig.java b/src/main/java/com/recyclestudy/common/config/WebMvcConfig.java new file mode 100644 index 0000000..3ed94dd --- /dev/null +++ b/src/main/java/com/recyclestudy/common/config/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.recyclestudy.common.config; + +import com.recyclestudy.common.resolver.DeviceAuthArgumentResolver; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final DeviceAuthArgumentResolver deviceAuthArgumentResolver; + + @Override + public void addArgumentResolvers(final List resolvers) { + resolvers.add(deviceAuthArgumentResolver); + } +} diff --git a/src/main/java/com/recyclestudy/common/resolver/DeviceAuthArgumentResolver.java b/src/main/java/com/recyclestudy/common/resolver/DeviceAuthArgumentResolver.java new file mode 100644 index 0000000..3410610 --- /dev/null +++ b/src/main/java/com/recyclestudy/common/resolver/DeviceAuthArgumentResolver.java @@ -0,0 +1,48 @@ +package com.recyclestudy.common.resolver; + +import com.recyclestudy.common.annotation.AuthDevice; +import com.recyclestudy.exception.UnauthorizedException; +import com.recyclestudy.member.domain.Device; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.repository.DeviceRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class DeviceAuthArgumentResolver implements HandlerMethodArgumentResolver { + + private final DeviceRepository deviceRepository; + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthDevice.class); + } + + @Override + public Object resolveArgument( + final MethodParameter parameter, + final ModelAndViewContainer mavContainer, + final NativeWebRequest webRequest, + final WebDataBinderFactory binderFactory + ) { + final String headerIdentifier = Optional.ofNullable(webRequest.getHeader("X-device-Id")) + .orElseThrow(() -> new UnauthorizedException("디바이스 인증 헤더가 누락되었습니다")); + + final DeviceIdentifier identifier = DeviceIdentifier.from(headerIdentifier); + final Device device = deviceRepository.findByIdentifier(identifier) + .orElseThrow(() -> new UnauthorizedException("유효하지 않은 디바이스입니다")); + + if (!device.isActive()) { + throw new UnauthorizedException("인증되지 않은 디바이스입니다"); + } + + return identifier; + } +} diff --git a/src/main/java/com/recyclestudy/member/controller/DeviceController.java b/src/main/java/com/recyclestudy/member/controller/DeviceController.java index 400b329..6114714 100644 --- a/src/main/java/com/recyclestudy/member/controller/DeviceController.java +++ b/src/main/java/com/recyclestudy/member/controller/DeviceController.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @@ -33,10 +34,22 @@ public String authenticateDevice( @DeleteMapping @ResponseBody - public ResponseEntity deleteDevice(@RequestBody final DeviceDeleteRequest request) { - final DeviceDeleteInput input = DeviceDeleteInput.from(request.email(), request.deviceIdentifier(), + public ResponseEntity deleteDevice( + @RequestHeader(value = "X-Device-Id", required = false) String headerIdentifier, + @RequestBody final DeviceDeleteRequest request + ) { + final String resolvedIdentifier = getResolvedIdentifier(request.deviceIdentifier(), headerIdentifier); + + final DeviceDeleteInput input = DeviceDeleteInput.from(request.email(), resolvedIdentifier, request.targetDeviceIdentifier()); memberService.deleteDevice(input); return ResponseEntity.noContent().build(); } + + private String getResolvedIdentifier(final String identifier, final String headerIdentifier) { + if (headerIdentifier == null) { + return identifier; + } + return headerIdentifier; + } } diff --git a/src/main/java/com/recyclestudy/member/controller/MemberController.java b/src/main/java/com/recyclestudy/member/controller/MemberController.java index a805f51..dc2679f 100644 --- a/src/main/java/com/recyclestudy/member/controller/MemberController.java +++ b/src/main/java/com/recyclestudy/member/controller/MemberController.java @@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -41,11 +42,21 @@ public ResponseEntity saveMember(@RequestBody final MemberSa @GetMapping public ResponseEntity findAllMemberDevices( @RequestParam(name = "email") final String email, - @RequestParam(name = "identifier") final String identifier + @RequestParam(name = "identifier", required = false) final String identifier, + @RequestHeader(value = "X-Device-Id", required = false) String headerIdentifier ) { - final MemberFindInput input = MemberFindInput.from(email, identifier); + final String resolvedIdentifier = getResolvedIdentifier(identifier, headerIdentifier); + + final MemberFindInput input = MemberFindInput.from(email, resolvedIdentifier); final MemberFindOutput output = memberService.findAllMemberDevices(input); final MemberFindResponse response = MemberFindResponse.from(output); return ResponseEntity.ok(response); } + + private String getResolvedIdentifier(final String identifier, final String headerIdentifier) { + if (headerIdentifier == null) { + return identifier; + } + return headerIdentifier; + } } diff --git a/src/main/java/com/recyclestudy/review/controller/ReviewController.java b/src/main/java/com/recyclestudy/review/controller/ReviewController.java index bb4d5cd..4950784 100644 --- a/src/main/java/com/recyclestudy/review/controller/ReviewController.java +++ b/src/main/java/com/recyclestudy/review/controller/ReviewController.java @@ -10,6 +10,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -21,10 +22,22 @@ public class ReviewController { private final ReviewService reviewService; @PostMapping - public ResponseEntity saveReview(@RequestBody ReviewSaveRequest request) { - final ReviewSaveInput input = request.toInput(); + public ResponseEntity saveReview( + @RequestHeader(value = "X-Device-Id", required = false) String headerIdentifier, + @RequestBody ReviewSaveRequest request + ) { + final String resolvedIdentifier = getResolvedIdentifier(request.identifier(), headerIdentifier); + + final ReviewSaveInput input = ReviewSaveInput.of(resolvedIdentifier, request.targetUrl()); final ReviewSaveOutput output = reviewService.saveReview(input); ReviewSaveResponse response = ReviewSaveResponse.of(output.url(), output.scheduledAts()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } + + private String getResolvedIdentifier(final String identifier, final String headerIdentifier) { + if (headerIdentifier == null) { + return identifier; + } + return headerIdentifier; + } } diff --git a/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java b/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java index b8c1939..5c49563 100644 --- a/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java +++ b/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java @@ -545,4 +545,41 @@ void deleteDevice_NullTargetIdentifier() { .statusCode(HttpStatus.BAD_REQUEST.value()) .body("message", equalTo("null이 될 수 없습니다: value")); } + + @Test + @DisplayName("헤더로 디바이스 인증하여 삭제 시 204 응답을 반환한다") + void deleteDevice_WithHeader() { + // given + final String headerIdentifier = "device-id"; + final DeviceDeleteRequest request = new DeviceDeleteRequest("test@test.com", null, "target-id"); + + doNothing().when(memberService).deleteDevice(any()); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Device") + .summary("디바이스 삭제") + .description("헤더로 디바이스 인증하여 삭제 시 204 응답을 반환한다") + .requestHeaders( + headerWithName("X-Device-Id").description("디바이스 식별자") + ) + .requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("identifier").type(JsonFieldType.STRING) + .description("디바이스 식별자 (deprecated, 헤더 사용 권장)").optional(), + fieldWithPath("targetIdentifier").type(JsonFieldType.STRING) + .description("삭제할 디바이스 식별자") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header("X-Device-Id", headerIdentifier) + .body(request) + .when() + .delete("/api/v1/device") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()); + } } diff --git a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java index 37e9f5e..22feb3c 100644 --- a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java +++ b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java @@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; @@ -410,4 +411,63 @@ void findAllMemberDevices_NullIdentifier() { .then() .statusCode(HttpStatus.BAD_REQUEST.value()); } + + @Test + @DisplayName("헤더로 디바이스 인증하여 멤버의 모든 디바이스 정보를 조회한다") + void findAllMemberDevices_WithHeader() { + // given + final String email = "test@test.com"; + final String headerIdentifier = "device-id-1"; + + final MemberFindOutput.MemberFindElement device1 = new MemberFindOutput.MemberFindElement( + DeviceIdentifier.from(headerIdentifier), + LocalDateTime.now().minusDays(1) + ); + final MemberFindOutput.MemberFindElement device2 = new MemberFindOutput.MemberFindElement( + DeviceIdentifier.from("device-id-2"), + LocalDateTime.now() + ); + + final MemberFindOutput output = new MemberFindOutput( + Email.from(email), + List.of(device1, device2) + ); + + given(memberService.findAllMemberDevices(any())).willReturn(output); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 디바이스 조회") + .description("헤더로 디바이스 인증하여 멤버의 모든 디바이스 정보를 조회한다") + .requestHeaders( + headerWithName("X-Device-Id").description("디바이스 식별자") + ) + .queryParameters( + parameterWithName("email").description("이메일"), + parameterWithName("identifier").description("디바이스 식별자 (deprecated, 헤더 사용 권장)").optional() + ) + .responseFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("devices").type(JsonFieldType.ARRAY).description("디바이스 목록"), + fieldWithPath("devices[].identifier").type(JsonFieldType.STRING) + .description("디바이스 식별자 값"), + fieldWithPath("devices[].createdAt").type(JsonFieldType.STRING) + .description("디바이스 생성일") + ), + queryParameters( + parameterWithName("email").description("이메일") + ) + )) + .header("X-Device-Id", headerIdentifier) + .param("email", email) + .when() + .get("/api/v1/members") + .then() + .statusCode(HttpStatus.OK.value()) + .body("devices", hasSize(2)); + } } diff --git a/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java b/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java index 514a75f..061a4a9 100644 --- a/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java @@ -21,6 +21,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; class ReviewControllerTest extends APIBaseTest { @@ -139,4 +140,48 @@ void saveReview_InactiveDevice() { .statusCode(HttpStatus.UNAUTHORIZED.value()) .body("message", equalTo("인증되지 않은 디바이스입니다")); } + + @Test + @DisplayName("헤더로 디바이스 인증하여 리뷰를 저장하면 201 응답을 반환한다") + void saveReview_WithHeader() { + // given + final String identifier = "device-id"; + final String url = "https://test.com"; + final ReviewSaveRequest request = new ReviewSaveRequest(null, url); + final ReviewSaveOutput output = ReviewSaveOutput.of(ReviewURL.from(url), List.of(LocalDateTime.now())); + + given(reviewService.saveReview(any())).willReturn(output); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Review") + .summary("리뷰 저장") + .description("헤더로 디바이스 인증하여 리뷰를 저장하면 201 응답을 반환한다") + .requestHeaders( + headerWithName("X-Device-Id").description("디바이스 식별자") + ) + .requestFields( + fieldWithPath("identifier").type(JsonFieldType.STRING) + .description("디바이스 식별자 (deprecated, 헤더 사용 권장)").optional(), + fieldWithPath("url").type(JsonFieldType.STRING) + .description("리뷰할 URL") + ) + .responseFields( + fieldWithPath("url").type(JsonFieldType.STRING).description("리뷰할 URL"), + fieldWithPath("scheduledAts").type(JsonFieldType.ARRAY) + .description("복습 예정 일시 목록") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header("X-Device-Id", identifier) + .body(request) + .when() + .post("/api/v1/reviews") + .then() + .statusCode(HttpStatus.CREATED.value()) + .body("url", equalTo(url)); + } } diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 8a79bce..4d324cb 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -15,6 +15,7 @@ spring: hibernate: format_sql: true dialect: org.hibernate.dialect.H2Dialect + open-in-view: false mail: host: localhost