diff --git a/photo-service/build.gradle.kts b/photo-service/build.gradle.kts index cecff716..500fe29a 100644 --- a/photo-service/build.gradle.kts +++ b/photo-service/build.gradle.kts @@ -25,12 +25,16 @@ dependencies { implementation("org.flywaydb:flyway-mysql") implementation("org.springframework:spring-jdbc") implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:2.5.0") - runtimeOnly("org.mariadb:r2dbc-mariadb:1.1.3") - runtimeOnly("org.mariadb.jdbc:mariadb-java-client") + implementation("com.mysql:mysql-connector-j:8.4.0") + implementation("io.asyncer:r2dbc-mysql:1.1.0") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.projectreactor:reactor-test") testImplementation("org.springframework.security:spring-security-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation("org.projectlombok:lombok:1.18.32") + annotationProcessor("org.projectlombok:lombok:1.18.32") + implementation("com.github.f4b6a3:ulid-creator:5.2.3") + } tasks.withType { diff --git a/photo-service/src/main/java/kr/mafoo/photo/annotation/RequestMemberId.java b/photo-service/src/main/java/kr/mafoo/photo/annotation/RequestMemberId.java new file mode 100644 index 00000000..25d6ed4c --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/annotation/RequestMemberId.java @@ -0,0 +1,11 @@ +package kr.mafoo.photo.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER, ElementType.TYPE_PARAMETER}) +public @interface RequestMemberId { +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/api/AlbumApi.java b/photo-service/src/main/java/kr/mafoo/photo/api/AlbumApi.java index 42c6a4f8..7db4be04 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/api/AlbumApi.java +++ b/photo-service/src/main/java/kr/mafoo/photo/api/AlbumApi.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import kr.mafoo.photo.annotation.RequestMemberId; import kr.mafoo.photo.controller.dto.request.AlbumCreateRequest; import kr.mafoo.photo.controller.dto.request.AlbumUpdateRequest; import kr.mafoo.photo.controller.dto.response.AlbumResponse; @@ -16,18 +17,26 @@ public interface AlbumApi { @Operation(summary = "앨범 조회", description = "앨범 목록을 조회합니다.") @GetMapping Flux getAlbums( + @RequestMemberId + String memberId ); @Operation(summary = "앨범 생성", description = "앨범을 생성합니다.") @PostMapping Mono createAlbum( + @RequestMemberId + String memberId, + @RequestBody AlbumCreateRequest request ); - @Operation(summary = "앨범 수정", description = "앨범의 이름 및 종류를 수정합니다.") + @Operation(summary = "앨범 변경", description = "앨범의 속성을 변경합니다.") @PutMapping("/{albumId}") Mono updateAlbum( + @RequestMemberId + String memberId, + @Parameter(description = "앨범 ID", example = "test_album_id") @PathVariable String albumId, @@ -39,6 +48,9 @@ Mono updateAlbum( @Operation(summary = "앨범 삭제", description = "앨범을 삭제합니다.") @DeleteMapping("/{albumId}") Mono deleteAlbum( + @RequestMemberId + String memberId, + @Parameter(description = "앨범 ID", example = "test_album_id") @PathVariable String albumId diff --git a/photo-service/src/main/java/kr/mafoo/photo/config/MemberIdParameterResolver.java b/photo-service/src/main/java/kr/mafoo/photo/config/MemberIdParameterResolver.java new file mode 100644 index 00000000..3b61094c --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/config/MemberIdParameterResolver.java @@ -0,0 +1,21 @@ +package kr.mafoo.photo.config; + +import kr.mafoo.photo.annotation.RequestMemberId; +import org.springframework.core.MethodParameter; +import org.springframework.web.reactive.BindingContext; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +public class MemberIdParameterResolver implements HandlerMethodArgumentResolver { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterAnnotation(RequestMemberId.class) != null; + } + + @Override + public Mono resolveArgument(MethodParameter parameter, BindingContext bindingContext, ServerWebExchange exchange) { + String memberId = exchange.getRequest().getHeaders().getFirst("X-MEMBER-ID"); + return Mono.justOrEmpty(memberId); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/config/WebExceptionHandler.java b/photo-service/src/main/java/kr/mafoo/photo/config/WebExceptionHandler.java new file mode 100644 index 00000000..46707a7f --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/config/WebExceptionHandler.java @@ -0,0 +1,17 @@ +package kr.mafoo.photo.config; + +import kr.mafoo.photo.controller.dto.response.ErrorResponse; +import kr.mafoo.photo.exception.DomainException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class WebExceptionHandler { + @ExceptionHandler(DomainException.class) + public ResponseEntity handleDomainException(DomainException exception) { + return ResponseEntity + .badRequest() + .body(ErrorResponse.fromErrorCode(exception.getErrorCode())); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/config/WebFluxConfig.java b/photo-service/src/main/java/kr/mafoo/photo/config/WebFluxConfig.java new file mode 100644 index 00000000..75497651 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/config/WebFluxConfig.java @@ -0,0 +1,15 @@ +package kr.mafoo.photo.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.reactive.config.WebFluxConfigurer; +import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; + +@EnableWebFlux +@Configuration +public class WebFluxConfig implements WebFluxConfigurer { + @Override + public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) { + configurer.addCustomResolver(new MemberIdParameterResolver()); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/controller/AlbumController.java b/photo-service/src/main/java/kr/mafoo/photo/controller/AlbumController.java index bd3e6436..981518d0 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/controller/AlbumController.java +++ b/photo-service/src/main/java/kr/mafoo/photo/controller/AlbumController.java @@ -4,52 +4,54 @@ import kr.mafoo.photo.controller.dto.request.AlbumCreateRequest; import kr.mafoo.photo.controller.dto.request.AlbumUpdateRequest; import kr.mafoo.photo.controller.dto.response.AlbumResponse; -import org.springframework.web.bind.annotation.*; +import kr.mafoo.photo.domain.AlbumType; +import kr.mafoo.photo.service.AlbumService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import static kr.mafoo.photo.domain.AlbumType.*; - +@RequiredArgsConstructor @RestController public class AlbumController implements AlbumApi { + private final AlbumService albumService; @Override public Flux getAlbums( + String memberId ) { - return Flux.just( - new AlbumResponse("test_album_id_a", "단짝이랑", TYPE_A, "1"), - new AlbumResponse("test_album_id_b", "야뿌들", TYPE_B, "5"), - new AlbumResponse("test_album_id_c", "농구팟", TYPE_C, "2"), - new AlbumResponse("test_album_id_d", "화사사람들", TYPE_D, "12"), - new AlbumResponse("test_album_id_e", "기념일", TYPE_E, "4"), - new AlbumResponse("test_album_id_f", "친구들이랑", TYPE_F, "9") - ); + return albumService + .findAllByOwnerMemberId(memberId) + .map(AlbumResponse::fromEntity); } @Override public Mono createAlbum( + String memberId, AlbumCreateRequest request ){ - return Mono.just( - new AlbumResponse("test_album_id", "시금치파슷하", TYPE_A, "0") - ); + AlbumType type = AlbumType.valueOf(request.type()); + return albumService + .createNewAlbum(memberId, request.name(), type) + .map(AlbumResponse::fromEntity); } @Override - public Mono updateAlbum( - String albumId, - AlbumUpdateRequest request - ){ - return Mono.just( - new AlbumResponse("test_album_id", "시금치파슷하", TYPE_A, "0") - ); + public Mono updateAlbum(String memberId, String albumId, AlbumUpdateRequest request) { + return albumService + .updateAlbumName(albumId, request.name(), memberId) + .then(albumService.updateAlbumType(albumId, request.type(), memberId)) + .map(AlbumResponse::fromEntity); } + @Override public Mono deleteAlbum( + String memberId, String albumId ){ - return Mono.empty(); + return albumService + .deleteAlbumById(albumId, memberId); } } diff --git a/photo-service/src/main/java/kr/mafoo/photo/controller/dto/request/AlbumUpdateRequest.java b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/request/AlbumUpdateRequest.java index ae32784b..8ec636a2 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/controller/dto/request/AlbumUpdateRequest.java +++ b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/request/AlbumUpdateRequest.java @@ -1,6 +1,7 @@ package kr.mafoo.photo.controller.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import kr.mafoo.photo.domain.AlbumType; @Schema(description = "앨범 수정 요청") public record AlbumUpdateRequest( @@ -8,6 +9,6 @@ public record AlbumUpdateRequest( String name, @Schema(description = "앨범 타입", example = "TYPE_A") - String type + AlbumType type ) { } diff --git a/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/AlbumResponse.java b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/AlbumResponse.java index a7d6949a..c665edfe 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/AlbumResponse.java +++ b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/AlbumResponse.java @@ -1,6 +1,7 @@ package kr.mafoo.photo.controller.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import kr.mafoo.photo.domain.AlbumEntity; import kr.mafoo.photo.domain.AlbumType; @Schema(description = "앨범 응답") @@ -17,4 +18,12 @@ public record AlbumResponse( @Schema(description = "앨범 내 사진 수", example = "6") String photoCount ) { + public static AlbumResponse fromEntity(AlbumEntity entity) { + return new AlbumResponse( + entity.getAlbumId(), + entity.getName(), + entity.getType(), + "0" + ); + } } diff --git a/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/ErrorResponse.java b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/ErrorResponse.java new file mode 100644 index 00000000..48042837 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/ErrorResponse.java @@ -0,0 +1,20 @@ +package kr.mafoo.photo.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.mafoo.photo.exception.ErrorCode; + +@Schema(description = "에러 응답") +public record ErrorResponse( + @Schema(description = "에러 코드", example = "ME0001") + String code, + + @Schema(description = "에러 메시지", example = "사용자를 찾을 수 없습니다") + String message +) { + public static ErrorResponse fromErrorCode(ErrorCode errorCode) { + return new ErrorResponse( + errorCode.getCode(), + errorCode.getMessage() + ); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/domain/AlbumEntity.java b/photo-service/src/main/java/kr/mafoo/photo/domain/AlbumEntity.java new file mode 100644 index 00000000..245f22dd --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/domain/AlbumEntity.java @@ -0,0 +1,83 @@ +package kr.mafoo.photo.domain; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Transient; +import org.springframework.data.domain.Persistable; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@Table("album") +public class AlbumEntity implements Persistable { + @Id + @Column("id") + private String albumId; + + @Column("name") + private String name; + + @Column("type") + private AlbumType type; + + @Column("owner_member_id") + private String ownerMemberId; + + @CreatedDate + @Column("created_at") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column("updated_at") + private LocalDateTime updatedAt; + + @Transient + private boolean isNew = false; + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + AlbumEntity that = (AlbumEntity) obj; + return albumId.equals(that.albumId); + } + + @Override + public int hashCode() { + return albumId.hashCode(); + } + + @Override + public String getId() { + return albumId; + } + + public AlbumEntity updateName(String newName) { + this.name = newName; + return this; + } + + public AlbumEntity updateType(AlbumType newType) { + this.type = newType; + return this; + } + + public static AlbumEntity newAlbum(String albumId, String albumName, AlbumType albumType, String ownerMemberId) { + AlbumEntity album = new AlbumEntity(); + album.albumId = albumId; + album.name = albumName; + album.type = albumType; + album.ownerMemberId = ownerMemberId; + album.isNew = true; + return album; + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/domain/AlbumType.java b/photo-service/src/main/java/kr/mafoo/photo/domain/AlbumType.java index 8f3ec093..31f1a91c 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/domain/AlbumType.java +++ b/photo-service/src/main/java/kr/mafoo/photo/domain/AlbumType.java @@ -1,10 +1,10 @@ package kr.mafoo.photo.domain; public enum AlbumType { - TYPE_A, - TYPE_B, - TYPE_C, - TYPE_D, - TYPE_E, - TYPE_F, + HEART, + FIRE, + BASKETBALL, + BUILDING, + STARFALL, + SMILE_FACE } diff --git a/photo-service/src/main/java/kr/mafoo/photo/exception/AlbumNotFoundException.java b/photo-service/src/main/java/kr/mafoo/photo/exception/AlbumNotFoundException.java new file mode 100644 index 00000000..44ed2ebe --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/exception/AlbumNotFoundException.java @@ -0,0 +1,7 @@ +package kr.mafoo.photo.exception; + +public class AlbumNotFoundException extends DomainException { + public AlbumNotFoundException() { + super(ErrorCode.ALBUM_NOT_FOUND); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/exception/DomainException.java b/photo-service/src/main/java/kr/mafoo/photo/exception/DomainException.java new file mode 100644 index 00000000..ffd091de --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/exception/DomainException.java @@ -0,0 +1,13 @@ +package kr.mafoo.photo.exception; + +import lombok.Getter; + +@Getter +public class DomainException extends RuntimeException { + private final ErrorCode errorCode; + + public DomainException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/exception/ErrorCode.java b/photo-service/src/main/java/kr/mafoo/photo/exception/ErrorCode.java new file mode 100644 index 00000000..8852dac8 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/exception/ErrorCode.java @@ -0,0 +1,14 @@ +package kr.mafoo.photo.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + ALBUM_NOT_FOUND("AE0001", "앨범을 찾을 수 없습니다"), + + ; + private final String code; + private final String message; +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/repository/AlbumRepository.java b/photo-service/src/main/java/kr/mafoo/photo/repository/AlbumRepository.java new file mode 100644 index 00000000..40aadb4b --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/repository/AlbumRepository.java @@ -0,0 +1,9 @@ +package kr.mafoo.photo.repository; + +import kr.mafoo.photo.domain.AlbumEntity; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Flux; + +public interface AlbumRepository extends R2dbcRepository { + Flux findAllByOwnerMemberId(String ownerMemberId); +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/AlbumService.java b/photo-service/src/main/java/kr/mafoo/photo/service/AlbumService.java new file mode 100644 index 00000000..3790036a --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/service/AlbumService.java @@ -0,0 +1,68 @@ +package kr.mafoo.photo.service; + +import kr.mafoo.photo.domain.AlbumEntity; +import kr.mafoo.photo.domain.AlbumType; +import kr.mafoo.photo.exception.AlbumNotFoundException; +import kr.mafoo.photo.repository.AlbumRepository; +import kr.mafoo.photo.util.IdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@RequiredArgsConstructor +@Service +public class AlbumService { + private final AlbumRepository albumRepository; + + public Mono createNewAlbum(String ownerMemberId, String albumName, AlbumType albumType) { + AlbumEntity albumEntity = AlbumEntity.newAlbum(IdGenerator.generate(), albumName, albumType, ownerMemberId); + return albumRepository.save(albumEntity); + } + + public Flux findAllByOwnerMemberId(String ownerMemberId) { + return albumRepository.findAllByOwnerMemberId(ownerMemberId); + } + + public Mono deleteAlbumById(String albumId, String requestMemberId) { + return albumRepository + .findById(albumId) + .switchIfEmpty(Mono.error(new AlbumNotFoundException())) + .flatMap(albumEntity -> { + if(!albumEntity.getOwnerMemberId().equals(requestMemberId)) { + // 내 앨범이 아니면 그냥 없는 앨범 처리 + return Mono.error(new AlbumNotFoundException()); + } else { + return albumRepository.deleteById(albumId); + } + }); + } + + public Mono updateAlbumName(String albumId, String albumName, String requestMemberId) { + return albumRepository + .findById(albumId) + .switchIfEmpty(Mono.error(new AlbumNotFoundException())) + .flatMap(albumEntity -> { + if(!albumEntity.getOwnerMemberId().equals(requestMemberId)) { + // 내 앨범이 아니면 그냥 없는 앨범 처리 + return Mono.error(new AlbumNotFoundException()); + } else { + return albumRepository.save(albumEntity.updateName(albumName)); + } + }); + } + + public Mono updateAlbumType(String albumId, AlbumType albumType, String requestMemberId) { + return albumRepository + .findById(albumId) + .switchIfEmpty(Mono.error(new AlbumNotFoundException())) + .flatMap(albumEntity -> { + if(!albumEntity.getOwnerMemberId().equals(requestMemberId)) { + // 내 앨범이 아니면 그냥 없는 앨범 처리 + return Mono.error(new AlbumNotFoundException()); + } else { + return albumRepository.save(albumEntity.updateType(albumType)); + } + }); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/util/IdGenerator.java b/photo-service/src/main/java/kr/mafoo/photo/util/IdGenerator.java new file mode 100644 index 00000000..117c229d --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/util/IdGenerator.java @@ -0,0 +1,9 @@ +package kr.mafoo.photo.util; + +import com.github.f4b6a3.ulid.UlidCreator; + +public class IdGenerator { + public static String generate() { + return UlidCreator.getMonotonicUlid().toString(); + } +} diff --git a/photo-service/src/main/resources/application.yaml b/photo-service/src/main/resources/application.yaml index c902558e..e96c8d71 100644 --- a/photo-service/src/main/resources/application.yaml +++ b/photo-service/src/main/resources/application.yaml @@ -5,3 +5,9 @@ spring: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} password: ${MYSQL_PASSWORD} + flyway: + url: ${FLYWAY_URL} + baseline-on-migrate: true + enabled: true + user: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} diff --git a/photo-service/src/main/resources/db/migration/V1__init.sql b/photo-service/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 00000000..6e560fdf --- /dev/null +++ b/photo-service/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,9 @@ +CREATE TABLE album( + `id` CHAR(26) PRIMARY KEY NOT NULL COMMENT '앨범아이디', + `name` VARCHAR(255) NOT NULL COMMENT '앨범이름', + `type` VARCHAR(255) NOT NULL COMMENT '앨범타입', + `owner_member_id` CHAR(26) NOT NULL COMMENT '앨범소유자아이디', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `album_idx1` (`owner_member_id`) +);