Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ dependencies {
testImplementation 'org.testcontainers:mysql'

// Metrics Collection (Prometheus)
implementation 'io.micrometer:micrometer-registry-prometheus'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
implementation 'io.github.mweirauch:micrometer-jvm-extras:0.2.2'

// HTML 파싱 (TIL 본문 경험치 산정 시 태그 제외 후 순수 텍스트 길이 계산)
implementation 'org.jsoup:jsoup:1.18.1'
Expand Down
33 changes: 24 additions & 9 deletions src/main/java/com/Rootin/domain/til/controller/TilController.java
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// TIL 컨트롤러: TIL 작성·수정·삭제·임시저장 REST API 처리 (작성·임시저장은 multipart/form-data로 이미지 업로드 지원)
package com.Rootin.domain.til.controller;

import com.Rootin.domain.til.dto.request.DraftSaveRequest;
Expand All @@ -11,9 +12,11 @@
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/v1/tils")
Expand All @@ -22,12 +25,22 @@ public class TilController {

private final TilService tilService;

@PostMapping
/**
* TIL 작성 (POST /api/v1/tils)
*
* multipart/form-data 요청:
* - data (application/json, required): TIL 본문 JSON
* - image (image/*, optional) : 썸네일 이미지 파일 → S3 업로드 후 thumbnailUrl 저장
*
* 이미지 없이 JSON만 전송하는 경우에도 정상 동작합니다.
*/
@PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<ApiResponse<TilResponse>> create(
@AuthenticationPrincipal JwtUserDetails userDetails,
@Valid @RequestBody TilCreateRequest request
@RequestPart("data") @Valid TilCreateRequest request,
@RequestPart(value = "image", required = false) MultipartFile thumbnailImage
) {
TilResponse response = tilService.create(userDetails.getUserId(), request);
TilResponse response = tilService.create(userDetails.getUserId(), request, thumbnailImage);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
}

Expand All @@ -53,13 +66,14 @@ public ResponseEntity<ApiResponse<Page<TilResponse>>> findMyTils(
tilService.findMyTils(userDetails.getUserId(), potId, page, size, sort, keyword, tag)));
}

@PutMapping("/{tilId}")
@PutMapping(value = "/{tilId}", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<ApiResponse<TilResponse>> update(
@PathVariable Long tilId,
@AuthenticationPrincipal JwtUserDetails userDetails,
@Valid @RequestBody TilUpdateRequest request
@RequestPart("data") @Valid TilUpdateRequest request,
@RequestPart(value = "image", required = false) MultipartFile thumbnailImage
) {
return ResponseEntity.ok(ApiResponse.success(tilService.update(tilId, userDetails.getUserId(), request)));
return ResponseEntity.ok(ApiResponse.success(tilService.update(tilId, userDetails.getUserId(), request, thumbnailImage)));
}

@DeleteMapping("/{tilId}")
Expand All @@ -71,12 +85,13 @@ public ResponseEntity<Void> delete(
return ResponseEntity.noContent().build();
}

@PostMapping("/draft")
@PostMapping(value = "/draft", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<ApiResponse<TilResponse>> saveDraft(
@AuthenticationPrincipal JwtUserDetails userDetails,
@Valid @RequestBody DraftSaveRequest request
@RequestPart("data") @Valid DraftSaveRequest request,
@RequestPart(value = "image", required = false) MultipartFile thumbnailImage
) {
return ResponseEntity.ok(ApiResponse.success(tilService.saveDraft(userDetails.getUserId(), request)));
return ResponseEntity.ok(ApiResponse.success(tilService.saveDraft(userDetails.getUserId(), request, thumbnailImage)));
}

@GetMapping("/draft")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// TIL 응답 DTO: TIL 조회·작성 시 클라이언트로 반환하는 데이터 구조 (thumbnailUrl 필드 추가)
package com.Rootin.domain.til.dto.response;

import com.Rootin.domain.til.entity.Til;
Expand All @@ -9,6 +10,7 @@ public record TilResponse(
Long tilId,
String title,
String content,
String thumbnailUrl,
List<String> tags,
AuthorInfo author,
Long potId,
Expand All @@ -33,6 +35,7 @@ public static TilResponse from(Til til) {
til.getId(),
til.getTitle(),
til.getContent(),
til.getThumbnailUrl(),
tags,
author,
til.getPot().getId(),
Expand Down
31 changes: 27 additions & 4 deletions src/main/java/com/Rootin/domain/til/entity/Til.java
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// TIL 엔티티: S3에 업로드된 썸네일 이미지 URL(thumbnailUrl)을 저장하는 컬럼과 팩토리 메서드를 포함한다
package com.Rootin.domain.til.entity;

import com.Rootin.domain.garden.entity.Pot;
Expand All @@ -23,26 +24,48 @@ public class Til extends Post {

private LocalDateTime publishedAt;

/** TIL 작성 시 업로드된 썸네일 이미지의 S3 URL */
@Column(name = "thumbnail_url")
private String thumbnailUrl;

@OneToMany(mappedBy = "til", cascade = CascadeType.ALL, orphanRemoval = true)
private List<TilTag> tilTags = new ArrayList<>();

protected Til(User user, String title, String content, Pot pot) {
protected Til(User user, String title, String content, Pot pot, String thumbnailUrl) {
super(user, title, content, PostStatus.PUBLISHED);
this.pot = pot;
this.publishedAt = LocalDateTime.now();
this.thumbnailUrl = thumbnailUrl;
}

protected Til(User user, String title, String content, Pot pot, PostStatus status) {
protected Til(User user, String title, String content, Pot pot, PostStatus status, String thumbnailUrl) {
super(user, title, content, status);
this.pot = pot;
this.thumbnailUrl = thumbnailUrl;
}

/** 썸네일 없이 TIL 생성 (하위 호환용) */
public static Til create(User user, String title, String content, Pot pot) {
return new Til(user, title, content, pot);
return new Til(user, title, content, pot, null);
}

/** 썸네일 포함 TIL 생성 */
public static Til create(User user, String title, String content, Pot pot, String thumbnailUrl) {
return new Til(user, title, content, pot, thumbnailUrl);
}

/** 썸네일 없이 임시저장 생성 (하위 호환용) */
public static Til createDraft(User user, String title, String content, Pot pot) {
return new Til(user, title, content, pot, PostStatus.DRAFT);
return new Til(user, title, content, pot, PostStatus.DRAFT, null);
}

/** 썸네일 포함 임시저장 생성 */
public static Til createDraft(User user, String title, String content, Pot pot, String thumbnailUrl) {
return new Til(user, title, content, pot, PostStatus.DRAFT, thumbnailUrl);
}

public void updateThumbnailUrl(String thumbnailUrl) {
this.thumbnailUrl = thumbnailUrl;
}

}
48 changes: 43 additions & 5 deletions src/main/java/com/Rootin/domain/til/service/TilService.java
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// TIL 비즈니스 로직: TIL 작성 시 썸네일 이미지를 S3에 업로드하고 URL을 저장하는 흐름을 담당한다
package com.Rootin.domain.til.service;

import com.Rootin.domain.ai.repository.AiResultTilRepository;
Expand All @@ -16,15 +17,18 @@
import com.Rootin.domain.user.entity.User;
import com.Rootin.domain.user.repository.UserRepository;
import com.Rootin.global.exception.CustomException;
import com.Rootin.global.s3.S3Service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;
import java.util.UUID;

@Service
@RequiredArgsConstructor
Expand All @@ -37,9 +41,10 @@ public class TilService {
private final ExperienceService experienceService;
private final AiResultTilRepository aiResultTilRepository;
private final WateringLogRepository wateringLogRepository;
private final S3Service s3Service;

@Transactional
public TilResponse create(Long userId, TilCreateRequest request) {
public TilResponse create(Long userId, TilCreateRequest request, MultipartFile thumbnailImage) {
User user = userRepository.findById(userId)
.orElseThrow(() -> CustomException.notFound("사용자를 찾을 수 없습니다."));

Expand All @@ -50,9 +55,12 @@ public TilResponse create(Long userId, TilCreateRequest request) {
.orElseThrow(() -> CustomException.notFound("화분을 찾을 수 없습니다."));
validatePotOwner(pot, userId);

// 썸네일 이미지가 첨부된 경우 S3에 업로드하고 URL을 저장합니다.
String thumbnailUrl = uploadThumbnailIfPresent(thumbnailImage, userId, request.potId());

// Til.create()는 PUBLISHED 상태의 TIL을 만드는 도메인 생성 메서드입니다.
// 저장 후 til.getId()가 필요하므로 먼저 save()를 호출한 뒤 물주기 로직을 실행합니다.
Til til = Til.create(user, request.title(), request.content(), pot);
Til til = Til.create(user, request.title(), request.content(), pot, thumbnailUrl);
tilRepository.save(til);

syncTags(til, request.tags());
Expand Down Expand Up @@ -101,13 +109,18 @@ public Page<TilResponse> findMyTils(Long userId, Long potId, int page, int size,
}

@Transactional
public TilResponse update(Long tilId, Long userId, TilUpdateRequest request) {
public TilResponse update(Long tilId, Long userId, TilUpdateRequest request, MultipartFile thumbnailImage) {
Til til = getTilOrThrow(tilId);
validateOwner(til, userId);

til.update(request.title(), request.content());
syncTags(til, request.tags());

if (thumbnailImage != null && !thumbnailImage.isEmpty()) {
String newThumbnailUrl = uploadThumbnailIfPresent(thumbnailImage, userId, til.getPot().getId());
til.updateThumbnailUrl(newThumbnailUrl);
}

return TilResponse.from(til);
}

Expand All @@ -124,21 +137,27 @@ public void delete(Long tilId, Long userId) {
}

@Transactional
public TilResponse saveDraft(Long userId, DraftSaveRequest request) {
public TilResponse saveDraft(Long userId, DraftSaveRequest request, MultipartFile thumbnailImage) {
User user = userRepository.findById(userId)
.orElseThrow(() -> CustomException.notFound("사용자를 찾을 수 없습니다."));
Pot pot = potRepository.findById(request.potId())
.orElseThrow(() -> CustomException.notFound("화분을 찾을 수 없습니다."));
validatePotOwner(pot, userId);

// 썸네일 이미지가 첨부된 경우 S3에 업로드하고 URL을 저장합니다.
String thumbnailUrl = uploadThumbnailIfPresent(thumbnailImage, userId, request.potId());

// 임시저장은 DRAFT 상태이므로 경험치/물주기를 발생시키지 않습니다.
// 이미 같은 화분에 임시저장 글이 있으면 새로 만들지 않고 기존 글을 갱신합니다.
Til til = tilRepository.findFirstByUserIdAndPotIdAndStatus(userId, request.potId(), PostStatus.DRAFT)
.map(existing -> {
existing.update(request.title(), request.content());
if (thumbnailUrl != null) {
existing.updateThumbnailUrl(thumbnailUrl);
}
return existing;
})
.orElseGet(() -> tilRepository.save(Til.createDraft(user, request.title(), request.content(), pot)));
.orElseGet(() -> tilRepository.save(Til.createDraft(user, request.title(), request.content(), pot, thumbnailUrl)));

syncTags(til, request.tags());

Expand All @@ -162,6 +181,25 @@ public void deleteDraft(Long userId, Long potId) {
tilRepository.delete(til);
}

/**
* 썸네일 이미지가 존재하면 S3에 업로드하고 URL을 반환합니다.
* 이미지가 없거나 비어있으면 null을 반환합니다.
*/
private String uploadThumbnailIfPresent(MultipartFile image, Long userId, Long potId) {
if (image == null || image.isEmpty()) {
return null;
}
String contentType = image.getContentType();
String ext = switch (contentType != null ? contentType : "") {
case "image/png" -> "png";
case "image/jpeg" -> "jpg";
case "image/webp" -> "webp";
default -> throw CustomException.badRequest("지원하지 않는 이미지 형식입니다: " + contentType);
};
String objectKey = String.format("til-images/%d/%d/%s.%s", userId, potId, UUID.randomUUID(), ext);
return s3Service.uploadFile(image, objectKey);
}

private Til getTilOrThrow(Long tilId) {
return tilRepository.findById(tilId)
.orElseThrow(() -> CustomException.notFound("TIL을 찾을 수 없습니다."));
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/com/Rootin/global/config/MetricsConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.Rootin.global.config;

import io.github.mweirauch.micrometer.jvm.extras.ProcessMemoryMetrics;
import io.github.mweirauch.micrometer.jvm.extras.ProcessThreadMetrics;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MetricsConfig {
@Bean
public ProcessMemoryMetrics processMemoryMetrics() {
return new ProcessMemoryMetrics();
}

@Bean
public ProcessThreadMetrics processThreadMetrics() {
return new ProcessThreadMetrics();
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// 공통 예외 클래스: HTTP 상태코드와 메시지를 함께 담아 던지는 RuntimeException (internalServerError 팩토리 추가)
package com.Rootin.global.exception;

import lombok.Getter;
Expand Down Expand Up @@ -51,4 +52,8 @@ public static CustomException badRequest(String message) {
public static CustomException paymentRequired(String message) {
return new CustomException(HttpStatus.PAYMENT_REQUIRED, message);
}

public static CustomException internalServerError(String message) {
return new CustomException(HttpStatus.INTERNAL_SERVER_ERROR, message);
}
}
30 changes: 30 additions & 0 deletions src/main/java/com/Rootin/global/s3/S3Config.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
// S3 연결 설정: Presigned URL 발급용 S3Presigner와 서버 직접 업로드용 S3Client 빈을 등록한다
package com.Rootin.global.s3;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3ClientBuilder;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

Expand Down Expand Up @@ -47,4 +51,30 @@ public S3Presigner s3Presigner(S3Properties props) {

return builder.build();
}

@Bean
public S3Client s3Client(S3Properties props) {
String region = props.getRegion();
String accessKey = props.getCredentials().getAccessKey();
String secretKey = props.getCredentials().getSecretKey();
String endpoint = props.getS3().getEndpoint();

AwsCredentialsProvider credentialsProvider =
(accessKey != null && !accessKey.isBlank() && secretKey != null && !secretKey.isBlank())
? StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))
: DefaultCredentialsProvider.create();

S3ClientBuilder builder = S3Client.builder()
.region(Region.of(region))
.credentialsProvider(credentialsProvider);

if (endpoint != null && !endpoint.isBlank()) {
builder.endpointOverride(URI.create(endpoint))
.serviceConfiguration(S3Configuration.builder()
.pathStyleAccessEnabled(true)
.build());
}

return builder.build();
}
}
Loading
Loading