diff --git a/src/main/java/kr/kro/photoliner/domain/album/controller/AlbumController.java b/src/main/java/kr/kro/photoliner/domain/album/controller/AlbumController.java new file mode 100644 index 0000000..f4c3c89 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/album/controller/AlbumController.java @@ -0,0 +1,53 @@ +package kr.kro.photoliner.domain.album.controller; + +import jakarta.validation.Valid; +import kr.kro.photoliner.domain.album.dto.request.AlbumCreateRequest; +import kr.kro.photoliner.domain.album.dto.request.AlbumDeleteRequest; +import kr.kro.photoliner.domain.album.dto.response.AlbumCreateResponse; +import kr.kro.photoliner.domain.album.dto.response.AlbumsResponse; +import kr.kro.photoliner.domain.album.service.AlbumService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/albums") +public class AlbumController { + + private final AlbumService albumService; + + @PostMapping + public ResponseEntity createAlbum( + @Valid @RequestBody AlbumCreateRequest request + ) { + AlbumCreateResponse response = albumService.createAlbum(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping + public ResponseEntity getAlbums( + @RequestParam Long userId, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + return ResponseEntity.ok(albumService.getAlbums(userId, pageable)); + } + + @DeleteMapping + public ResponseEntity deletePhoto( + @Valid @RequestBody AlbumDeleteRequest request + ) { + albumService.deleteAlbums(request); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/kr/kro/photoliner/domain/album/dto/request/AlbumCreateRequest.java b/src/main/java/kr/kro/photoliner/domain/album/dto/request/AlbumCreateRequest.java new file mode 100644 index 0000000..87476fd --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/album/dto/request/AlbumCreateRequest.java @@ -0,0 +1,12 @@ +package kr.kro.photoliner.domain.album.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record AlbumCreateRequest( + @NotNull + Long userId, + @NotEmpty + String name +) { +} diff --git a/src/main/java/kr/kro/photoliner/domain/album/dto/request/AlbumDeleteRequest.java b/src/main/java/kr/kro/photoliner/domain/album/dto/request/AlbumDeleteRequest.java new file mode 100644 index 0000000..5698ac3 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/album/dto/request/AlbumDeleteRequest.java @@ -0,0 +1,8 @@ +package kr.kro.photoliner.domain.album.dto.request; + +import java.util.List; + +public record AlbumDeleteRequest( + List ids +) { +} diff --git a/src/main/java/kr/kro/photoliner/domain/album/dto/response/AlbumCreateResponse.java b/src/main/java/kr/kro/photoliner/domain/album/dto/response/AlbumCreateResponse.java new file mode 100644 index 0000000..019d19f --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/album/dto/response/AlbumCreateResponse.java @@ -0,0 +1,24 @@ +package kr.kro.photoliner.domain.album.dto.response; + +import kr.kro.photoliner.domain.album.model.Album; + +public record AlbumCreateResponse( + InnerAlbum album +) { + + public static AlbumCreateResponse from(Album album) { + return new AlbumCreateResponse( + new InnerAlbum( + album.getId(), + album.getName() + ) + ); + } + + public record InnerAlbum( + Long id, + String name + ) { + + } +} diff --git a/src/main/java/kr/kro/photoliner/domain/album/dto/response/AlbumsResponse.java b/src/main/java/kr/kro/photoliner/domain/album/dto/response/AlbumsResponse.java new file mode 100644 index 0000000..bb33ce3 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/album/dto/response/AlbumsResponse.java @@ -0,0 +1,54 @@ +package kr.kro.photoliner.domain.album.dto.response; + +import java.util.List; +import kr.kro.photoliner.domain.album.model.Album; +import org.springframework.data.domain.Page; + +public record AlbumsResponse( + List albums, + InnerPageInfo pageInfo +) { + + public static AlbumsResponse from(Page albumPage) { + return new AlbumsResponse( + albumPage.getContent().stream() + .map(InnerAlbum::from) + .toList(), + InnerPageInfo.from(albumPage) + ); + } + + public record InnerAlbum( + Long id, + String name + ) { + + public static InnerAlbum from(Album album) { + return new InnerAlbum( + album.getId(), + album.getName() + ); + } + } + + public record InnerPageInfo( + long totalElements, + int totalPages, + int currentPage, + int size, + boolean hasNext, + boolean hasPrevious + ) { + + public static InnerPageInfo from(Page page) { + return new InnerPageInfo( + page.getTotalElements(), + page.getTotalPages(), + page.getNumber(), + page.getSize(), + page.hasNext(), + page.hasPrevious() + ); + } + } +} diff --git a/src/main/java/kr/kro/photoliner/domain/album/model/Album.java b/src/main/java/kr/kro/photoliner/domain/album/model/Album.java new file mode 100644 index 0000000..1156163 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/album/model/Album.java @@ -0,0 +1,40 @@ +package kr.kro.photoliner.domain.album.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import kr.kro.photoliner.common.model.BaseEntity; +import kr.kro.photoliner.domain.user.model.User; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "albums") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +public class Album extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @Column(name = "name", nullable = false) + private String name; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", referencedColumnName = "id") + private User user; +} diff --git a/src/main/java/kr/kro/photoliner/domain/album/model/Albums.java b/src/main/java/kr/kro/photoliner/domain/album/model/Albums.java new file mode 100644 index 0000000..0309497 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/album/model/Albums.java @@ -0,0 +1,12 @@ +package kr.kro.photoliner.domain.album.model; + +import java.util.List; + +public record Albums( + List albums +) { + + public int count() { + return albums.size(); + } +} diff --git a/src/main/java/kr/kro/photoliner/domain/album/repository/AlbumRepository.java b/src/main/java/kr/kro/photoliner/domain/album/repository/AlbumRepository.java new file mode 100644 index 0000000..861939c --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/album/repository/AlbumRepository.java @@ -0,0 +1,13 @@ +package kr.kro.photoliner.domain.album.repository; + +import kr.kro.photoliner.domain.album.model.Album; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AlbumRepository extends JpaRepository { + + Album save(Album album); + + Page findByUserId(Long userId, Pageable pageable); +} diff --git a/src/main/java/kr/kro/photoliner/domain/album/service/AlbumService.java b/src/main/java/kr/kro/photoliner/domain/album/service/AlbumService.java new file mode 100644 index 0000000..5c1df17 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/album/service/AlbumService.java @@ -0,0 +1,48 @@ +package kr.kro.photoliner.domain.album.service; + +import kr.kro.photoliner.domain.album.dto.request.AlbumCreateRequest; +import kr.kro.photoliner.domain.album.dto.request.AlbumDeleteRequest; +import kr.kro.photoliner.domain.album.dto.response.AlbumCreateResponse; +import kr.kro.photoliner.domain.album.dto.response.AlbumsResponse; +import kr.kro.photoliner.domain.album.model.Album; +import kr.kro.photoliner.domain.album.repository.AlbumRepository; +import kr.kro.photoliner.domain.user.model.User; +import kr.kro.photoliner.domain.user.repository.UserRepository; +import kr.kro.photoliner.global.code.ApiResponseCode; +import kr.kro.photoliner.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AlbumService { + + private final UserRepository userRepository; + private final AlbumRepository albumRepository; + + @Transactional + public AlbumCreateResponse createAlbum(AlbumCreateRequest request) { + User user = userRepository.findUserById(request.userId()) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user id: " + request.userId())); + Album album = Album.builder() + .name(request.name()) + .user(user) + .build(); + Album savedAlbum = albumRepository.save(album); + return AlbumCreateResponse.from(savedAlbum); + } + + @Transactional(readOnly = true) + public AlbumsResponse getAlbums(Long userId, Pageable pageable) { + Page albums = albumRepository.findByUserId(userId, pageable); + return AlbumsResponse.from(albums); + } + + @Transactional + public void deleteAlbums(AlbumDeleteRequest request) { + albumRepository.deleteAllByIdInBatch(request.ids()); + } +} diff --git a/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java b/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java index 1d5bc24..9e61d68 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java @@ -39,11 +39,20 @@ public class PhotoController { private final PhotoUploadService photoUploadService; @GetMapping - public ResponseEntity getPhotoList( + public ResponseEntity getPhotos( @RequestParam Long userId, @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable ) { - return ResponseEntity.ok(photoService.getPhotoList(userId, pageable)); + return ResponseEntity.ok(photoService.getPhotos(userId, pageable)); + } + + @GetMapping("/albums/{albumId}") + public ResponseEntity getPhotosByAlbumId( + @PathVariable Long albumId, + @RequestParam Long userId, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + return ResponseEntity.ok(photoService.getPhotosByAlbumId(userId, albumId, pageable)); } @GetMapping("/markers") diff --git a/src/main/java/kr/kro/photoliner/domain/photo/dto/request/MapMarkersRequest.java b/src/main/java/kr/kro/photoliner/domain/photo/dto/request/MapMarkersRequest.java index 0ef284a..0e5deac 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/dto/request/MapMarkersRequest.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/dto/request/MapMarkersRequest.java @@ -3,19 +3,14 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; import org.locationtech.jts.geom.Coordinate; -import org.springframework.format.annotation.DateTimeFormat; public record MapMarkersRequest( @NotNull @Min(0) Long userId, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - LocalDate from, - - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - LocalDate to, + @NotNull @Min(0) + Long albumId, @Min(0) @Max(90) double swLat, diff --git a/src/main/java/kr/kro/photoliner/domain/photo/model/AlbumPhoto.java b/src/main/java/kr/kro/photoliner/domain/photo/model/AlbumPhoto.java new file mode 100644 index 0000000..14c3a57 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/photo/model/AlbumPhoto.java @@ -0,0 +1,39 @@ +package kr.kro.photoliner.domain.photo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.Objects; +import kr.kro.photoliner.domain.album.model.Album; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor +@Table(name = "albums_photos") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AlbumPhoto { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "album_id", nullable = false) + private Album album; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "photo_id", nullable = false) + private Photo photo; + + public boolean isIncludedInAlbum(Long albumId) { + return Objects.equals(album.getId(), albumId); + } +} diff --git a/src/main/java/kr/kro/photoliner/domain/photo/model/AlbumPhotos.java b/src/main/java/kr/kro/photoliner/domain/photo/model/AlbumPhotos.java new file mode 100644 index 0000000..4385cdd --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/photo/model/AlbumPhotos.java @@ -0,0 +1,22 @@ +package kr.kro.photoliner.domain.photo.model; + +import java.util.List; + +public record AlbumPhotos( + List albumPhotos +) { + public List getPhotoIncludedAlbum(Long albumId) { + return albumPhotos.stream() + .filter(albumPhoto -> albumPhoto.isIncludedInAlbum(albumId)) + .map(AlbumPhoto::getPhoto) + .toList(); + } + + public List getPhotoNotIncludedAlbum(Long albumId) { + return albumPhotos.stream() + .filter(albumPhoto -> albumPhoto.isIncludedInAlbum(albumId)) + .map(AlbumPhoto::getPhoto) + .toList(); + } + +} diff --git a/src/main/java/kr/kro/photoliner/domain/photo/model/Photos.java b/src/main/java/kr/kro/photoliner/domain/photo/model/Photos.java index 7532fb9..9b97f8a 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/model/Photos.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/model/Photos.java @@ -1,6 +1,5 @@ package kr.kro.photoliner.domain.photo.model; -import java.time.LocalDate; import java.util.List; public record Photos( @@ -11,15 +10,9 @@ public int count() { return photos.size(); } - public List filterInDate(LocalDate from, LocalDate to) { + public List getPhotoIds() { return photos.stream() - .filter(photo -> photo.isBetween(from, to)) - .toList(); - } - - public List filterOutOfDate(LocalDate from, LocalDate to) { - return photos.stream() - .filter(photo -> !photo.isBetween(from, to)) + .map(Photo::getId) .toList(); } } diff --git a/src/main/java/kr/kro/photoliner/domain/photo/repository/AlbumPhotoRepository.java b/src/main/java/kr/kro/photoliner/domain/photo/repository/AlbumPhotoRepository.java new file mode 100644 index 0000000..a376104 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/photo/repository/AlbumPhotoRepository.java @@ -0,0 +1,17 @@ +package kr.kro.photoliner.domain.photo.repository; + +import java.util.List; +import kr.kro.photoliner.domain.photo.model.AlbumPhoto; +import kr.kro.photoliner.domain.photo.model.AlbumPhotos; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AlbumPhotoRepository extends JpaRepository { + + List findByPhotoIdIn(List ids); + + default AlbumPhotos getByPhotoIdIn(List ids) { + return new AlbumPhotos( + findByPhotoIdIn(ids) + ); + } +} diff --git a/src/main/java/kr/kro/photoliner/domain/photo/repository/PhotoRepository.java b/src/main/java/kr/kro/photoliner/domain/photo/repository/PhotoRepository.java index e05d07c..0e65278 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/repository/PhotoRepository.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/repository/PhotoRepository.java @@ -17,6 +17,14 @@ Page findByUserId( Pageable pageable ); + @Query(""" + select p + from Photo p + inner join AlbumPhoto ap on ap.photo.id = p.id + where ap.album.id = :albumId + """) + Page findByUserIdAndAlbumId(Long userId, Long albumId, Pageable pageable); + @Query(""" select p from Photo p diff --git a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java b/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java index 8b59e83..6c623c8 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java @@ -6,8 +6,10 @@ import kr.kro.photoliner.domain.photo.dto.request.PhotoLocationUpdateRequest; import kr.kro.photoliner.domain.photo.dto.response.MapMarkersResponse; import kr.kro.photoliner.domain.photo.dto.response.PhotosResponse; +import kr.kro.photoliner.domain.photo.model.AlbumPhotos; import kr.kro.photoliner.domain.photo.model.Photo; import kr.kro.photoliner.domain.photo.model.Photos; +import kr.kro.photoliner.domain.photo.repository.AlbumPhotoRepository; import kr.kro.photoliner.domain.photo.repository.PhotoRepository; import kr.kro.photoliner.global.code.ApiResponseCode; import kr.kro.photoliner.global.exception.CustomException; @@ -23,24 +25,31 @@ @RequiredArgsConstructor public class PhotoService { + private final AlbumPhotoRepository albumPhotoRepository; private final PhotoRepository photoRepository; private final GeometryFactory geometryFactory; @Transactional(readOnly = true) - public PhotosResponse getPhotoList(Long userId, Pageable pageable) { + public PhotosResponse getPhotos(Long userId, Pageable pageable) { return PhotosResponse.from(photoRepository.findByUserId(userId, pageable)); } + @Transactional(readOnly = true) + public PhotosResponse getPhotosByAlbumId(Long userId, Long albumId, Pageable pageable) { + return PhotosResponse.from(photoRepository.findByUserIdAndAlbumId(userId, albumId, pageable)); + } + @Transactional(readOnly = true) public MapMarkersResponse getMarkersInViewport(MapMarkersRequest request) { Point sw = geometryFactory.createPoint(request.getSouthWestCoordinate()); Point ne = geometryFactory.createPoint(request.getNorthEastCoordinate()); Photos photos = photoRepository.getPhotosByUserIdInBox(request.userId(), sw, ne); + AlbumPhotos albumPhotos = albumPhotoRepository.getByPhotoIdIn(photos.getPhotoIds()); return MapMarkersResponse.of( - photos.filterInDate(request.from(), request.to()), - photos.filterOutOfDate(request.from(), request.to()) + albumPhotos.getPhotoIncludedAlbum(request.albumId()), + albumPhotos.getPhotoNotIncludedAlbum(request.albumId()) ); } diff --git a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoUploadService.java b/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoUploadService.java index 57ba0e0..1314251 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoUploadService.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoUploadService.java @@ -37,7 +37,6 @@ public PhotoUploadResponse uploadPhotos(Long userId, List files) .orElseThrow( () -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user id: " + userId)); Photo photo = createPhoto(user, exifData, filePath, fileName); - .orElseThrow(RuntimeException::new); Photo savedPhoto = photoRepository.save(photo); return InnerUploadedPhotoInfo.from(savedPhoto); }) diff --git a/src/main/resources/db/migration/V4__add_albums_table.sql b/src/main/resources/db/migration/V4__add_albums_table.sql new file mode 100644 index 0000000..136ee37 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_albums_table.sql @@ -0,0 +1,17 @@ +create table if not exists albums +( + id bigint unsigned auto_increment comment '앨범 고유 ID' + primary key, + name varchar(20) not null, + created_at datetime not null default current_timestamp, + updated_at datetime not null default current_timestamp, + user_id bigint not null +); + +create table if not exists albums_photos +( + id bigint unsigned auto_increment + primary key, + album_id bigint, + photo_id bigint +)