From 5bfada83fcd45d10942115b2d396a3f8fbb8813c Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 17 May 2026 20:37:58 +0900 Subject: [PATCH 1/6] Protect DB-backed trip photo persistence Shared photo album P0 stores image bytes in PostgreSQL bytea, so the repository test covers visible-photo filtering, soft-delete exclusion, and byte-array round trips. The entity mapping now binds content as binary data instead of a PostgreSQL large-object OID. Constraint: P0 stores photo files in DB bytea while object storage is deferred Rejected: Keep @Lob mapping | Hibernate persisted an OID/bigint and failed against bytea Confidence: high Scope-risk: narrow Tested: ./gradlew testClasses --no-daemon Tested: ./gradlew test --tests com.tripsync.domain.repository.TripPhotoRepositoryTest --no-daemon Not-tested: Full ./gradlew test suite due leader instruction to finish current slice with focused evidence --- .../com/tripsync/domain/entity/TripPhoto.kt | 80 +++++++ .../com/tripsync/domain/enums/PhotoStatus.kt | 7 + .../domain/repository/TripPhotoRepository.kt | 25 +++ .../db/migration/V5__add_trip_photos.sql | 25 +++ .../repository/TripPhotoRepositoryTest.kt | 210 ++++++++++++++++++ 5 files changed, 347 insertions(+) create mode 100644 src/main/kotlin/com/tripsync/domain/entity/TripPhoto.kt create mode 100644 src/main/kotlin/com/tripsync/domain/enums/PhotoStatus.kt create mode 100644 src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt create mode 100644 src/main/resources/db/migration/V5__add_trip_photos.sql create mode 100644 src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt diff --git a/src/main/kotlin/com/tripsync/domain/entity/TripPhoto.kt b/src/main/kotlin/com/tripsync/domain/entity/TripPhoto.kt new file mode 100644 index 0000000..3d3cb6d --- /dev/null +++ b/src/main/kotlin/com/tripsync/domain/entity/TripPhoto.kt @@ -0,0 +1,80 @@ +package com.tripsync.domain.entity + +import com.tripsync.domain.enums.PhotoStatus +import jakarta.persistence.* +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.annotations.UpdateTimestamp +import org.hibernate.type.SqlTypes +import java.time.Instant + +@Entity +@Table( + name = "trip_photos", + indexes = [ + Index(name = "idx_trip_photos_schedule_slot", columnList = "schedule_id, schedule_slot_id"), + Index(name = "idx_trip_photos_room", columnList = "room_id"), + Index(name = "idx_trip_photos_uploader", columnList = "uploader_user_id"), + ] +) +class TripPhoto( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + var room: TripRoom, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "schedule_id", nullable = false) + var schedule: Schedule, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "schedule_slot_id", nullable = false) + var scheduleSlot: ScheduleSlot, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_id", nullable = false) + var place: Place, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "uploader_user_id", nullable = false) + var uploader: User, + + @Column(name = "original_filename", nullable = false, length = 255) + var originalFilename: String, + + @Column(name = "content_type", nullable = false, length = 100) + var contentType: String, + + @Column(name = "file_size", nullable = false) + var fileSize: Long, + + @JdbcTypeCode(SqlTypes.VARBINARY) + @Column(name = "content", nullable = false, columnDefinition = "bytea") + var content: ByteArray, + + @Column(name = "width") + var width: Int? = null, + + @Column(name = "height") + var height: Int? = null, + + @Column(name = "caption", length = 500) + var caption: String? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + var status: PhotoStatus = PhotoStatus.ACTIVE, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "deleted_by_user_id") + var deletedBy: User? = null, + + @Column(name = "deleted_at") + var deletedAt: Instant? = null, +) : BaseEntity() { + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant = Instant.now() +} diff --git a/src/main/kotlin/com/tripsync/domain/enums/PhotoStatus.kt b/src/main/kotlin/com/tripsync/domain/enums/PhotoStatus.kt new file mode 100644 index 0000000..3996941 --- /dev/null +++ b/src/main/kotlin/com/tripsync/domain/enums/PhotoStatus.kt @@ -0,0 +1,7 @@ +package com.tripsync.domain.enums + +enum class PhotoStatus { + ACTIVE, + HIDDEN, + DELETED, +} diff --git a/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt new file mode 100644 index 0000000..38a8564 --- /dev/null +++ b/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt @@ -0,0 +1,25 @@ +package com.tripsync.domain.repository + +import com.tripsync.domain.entity.TripPhoto +import com.tripsync.domain.enums.PhotoStatus +import com.tripsync.domain.enums.YnFlag +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface TripPhotoRepository : JpaRepository { + fun findAllByScheduleIdAndDelYnAndStatusOrderByScheduleSlotOrderIndexAscCreatedAtAsc( + scheduleId: Long, + delYn: YnFlag, + status: PhotoStatus, + ): List + + fun findByIdAndDelYn(id: Long, delYn: YnFlag): TripPhoto? + fun findByIdAndScheduleIdAndDelYn(id: Long, scheduleId: Long, delYn: YnFlag): TripPhoto? + fun findByIdAndScheduleIdAndDelYnAndStatus( + id: Long, + scheduleId: Long, + delYn: YnFlag, + status: PhotoStatus, + ): TripPhoto? +} diff --git a/src/main/resources/db/migration/V5__add_trip_photos.sql b/src/main/resources/db/migration/V5__add_trip_photos.sql new file mode 100644 index 0000000..5ea51ba --- /dev/null +++ b/src/main/resources/db/migration/V5__add_trip_photos.sql @@ -0,0 +1,25 @@ +CREATE TABLE trip_photos ( + id BIGSERIAL PRIMARY KEY, + room_id BIGINT NOT NULL REFERENCES trip_rooms(id) ON DELETE CASCADE, + schedule_id BIGINT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE, + schedule_slot_id BIGINT NOT NULL REFERENCES schedule_slots(id) ON DELETE CASCADE, + place_id BIGINT NOT NULL REFERENCES places(id) ON DELETE RESTRICT, + uploader_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + original_filename VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + file_size BIGINT NOT NULL, + content BYTEA NOT NULL, + width INT, + height INT, + caption VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + deleted_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + deleted_at TIMESTAMPTZ, + del_yn VARCHAR(1) NOT NULL DEFAULT 'N', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_trip_photos_schedule_slot ON trip_photos(schedule_id, schedule_slot_id); +CREATE INDEX idx_trip_photos_room ON trip_photos(room_id); +CREATE INDEX idx_trip_photos_uploader ON trip_photos(uploader_user_id); diff --git a/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt b/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt new file mode 100644 index 0000000..3cdcd7b --- /dev/null +++ b/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt @@ -0,0 +1,210 @@ +package com.tripsync.domain.repository + +import com.tripsync.domain.entity.Place +import com.tripsync.domain.entity.RoomMember +import com.tripsync.domain.entity.Schedule +import com.tripsync.domain.entity.ScheduleSlot +import com.tripsync.domain.entity.TripPhoto +import com.tripsync.domain.entity.TripRoom +import com.tripsync.domain.entity.User +import com.tripsync.domain.enums.AuthProvider +import com.tripsync.domain.enums.PhotoStatus +import com.tripsync.domain.enums.ReasonAxis +import com.tripsync.domain.enums.RoomMemberRole +import com.tripsync.domain.enums.ScheduleOptionType +import com.tripsync.domain.enums.SlotType +import com.tripsync.domain.enums.TripRoomStatus +import com.tripsync.domain.enums.YnFlag +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDate + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class TripPhotoRepositoryTest( + @Autowired private val tripPhotoRepository: TripPhotoRepository, + @Autowired private val userRepository: UserRepository, + @Autowired private val tripRoomRepository: TripRoomRepository, + @Autowired private val roomMemberRepository: RoomMemberRepository, + @Autowired private val scheduleRepository: ScheduleRepository, + @Autowired private val scheduleSlotRepository: ScheduleSlotRepository, + @Autowired private val placeRepository: PlaceRepository, + @Autowired private val entityManager: EntityManager, +) { + @Test + fun `active photo query returns only visible not deleted photos in slot order`() { + val fixture = createFixture() + val activeLaterSlot = tripPhotoRepository.save( + photo(fixture, fixture.secondSlot, "second.webp", "image/webp", byteArrayOf(2), status = PhotoStatus.ACTIVE) + ) + val activeFirstSlot = tripPhotoRepository.save( + photo(fixture, fixture.firstSlot, "first.jpg", "image/jpeg", byteArrayOf(1), status = PhotoStatus.ACTIVE) + ) + tripPhotoRepository.save( + photo(fixture, fixture.firstSlot, "hidden.png", "image/png", byteArrayOf(3), status = PhotoStatus.HIDDEN) + ) + val softDeleted = tripPhotoRepository.save( + photo(fixture, fixture.secondSlot, "deleted.jpg", "image/jpeg", byteArrayOf(4), status = PhotoStatus.ACTIVE) + ) + softDeleted.delYn = YnFlag.Y + tripPhotoRepository.saveAndFlush(softDeleted) + + val visible = tripPhotoRepository.findAllByScheduleIdAndDelYnAndStatusOrderByScheduleSlotOrderIndexAscCreatedAtAsc( + fixture.schedule.id, + YnFlag.N, + PhotoStatus.ACTIVE, + ) + + assertEquals(listOf(activeFirstSlot.id, activeLaterSlot.id), visible.map { it.id }) + assertEquals(listOf("first.jpg", "second.webp"), visible.map { it.originalFilename }) + } + + @Test + fun `photo content is persisted in the database with MIME and size metadata`() { + val fixture = createFixture() + val content = byteArrayOf(0x49, 0x4d, 0x47, 0x01, 0x02) + val saved = tripPhotoRepository.saveAndFlush( + photo( + fixture = fixture, + slot = fixture.firstSlot, + filename = "memory.png", + contentType = "image/png", + content = content, + ) + ) + entityManager.clear() + + val reloaded = tripPhotoRepository.findByIdAndDelYn(saved.id, YnFlag.N) + + assertNotNull(reloaded) + assertEquals("image/png", reloaded!!.contentType) + assertEquals(content.size.toLong(), reloaded.fileSize) + assertArrayEquals(content, reloaded.content) + } + + @Test + fun `deleted photos are not returned by active id lookup`() { + val fixture = createFixture() + val saved = tripPhotoRepository.saveAndFlush( + photo(fixture, fixture.firstSlot, "soft-deleted.jpg", "image/jpeg", byteArrayOf(7)) + ) + saved.delYn = YnFlag.Y + tripPhotoRepository.saveAndFlush(saved) + entityManager.clear() + + assertNull(tripPhotoRepository.findByIdAndDelYn(saved.id, YnFlag.N)) + } + + private fun createFixture(): Fixture { + val suffix = System.nanoTime() + val host = userRepository.save( + User( + nickname = "photo-host-$suffix", + email = "photo-host-$suffix@example.com", + authProvider = AuthProvider.LOCAL, + passwordHash = "password", + ) + ) + val room = tripRoomRepository.save( + TripRoom( + hostUser = host, + shareCode = "P${suffix.toString().takeLast(10)}", + destination = "충남", + tripDate = LocalDate.now().minusDays(1), + status = TripRoomStatus.COMPLETED, + ) + ) + roomMemberRepository.save(RoomMember(room = room, user = host, role = RoomMemberRole.HOST)) + val firstPlace = placeRepository.save(place("photo-first-$suffix", "첫 장소")) + val secondPlace = placeRepository.save(place("photo-second-$suffix", "둘째 장소")) + val schedule = scheduleRepository.save( + Schedule( + room = room, + version = 1, + optionType = ScheduleOptionType.BALANCED, + isConfirmed = true, + generationInput = mapOf("destination" to "충남"), + summary = "확정 일정", + groupSatisfaction = 90, + ) + ) + val firstSlot = scheduleSlotRepository.save( + slot(schedule, firstPlace, orderIndex = 1, start = "2026-06-01T00:00:00Z", end = "2026-06-01T01:00:00Z") + ) + val secondSlot = scheduleSlotRepository.save( + slot(schedule, secondPlace, orderIndex = 2, start = "2026-06-01T01:00:00Z", end = "2026-06-01T02:00:00Z") + ) + return Fixture(host, room, schedule, firstSlot, secondSlot) + } + + private fun photo( + fixture: Fixture, + slot: ScheduleSlot, + filename: String, + contentType: String, + content: ByteArray, + status: PhotoStatus = PhotoStatus.ACTIVE, + ): TripPhoto { + return TripPhoto( + room = fixture.room, + schedule = fixture.schedule, + scheduleSlot = slot, + place = slot.place, + uploader = fixture.host, + originalFilename = filename, + contentType = contentType, + fileSize = content.size.toLong(), + content = content, + caption = "사진 설명", + status = status, + ) + } + + private fun slot(schedule: Schedule, place: Place, orderIndex: Int, start: String, end: String): ScheduleSlot { + return ScheduleSlot( + schedule = schedule, + startTime = Instant.parse(start), + endTime = Instant.parse(end), + place = place, + slotType = SlotType.COMMON, + reasonAxis = ReasonAxis.COMMON, + reasonText = "장소 $orderIndex", + orderIndex = orderIndex, + ) + } + + private fun place(tourApiId: String, name: String): Place { + return Place( + tourApiId = tourApiId, + name = name, + address = "충청남도 보령시", + latitude = BigDecimal("36.5000000"), + longitude = BigDecimal("126.5000000"), + category = "관광지", + mobilityScore = 50, + photoScore = 80, + budgetScore = 60, + themeScore = 40, + metadataTags = mapOf("populationDeclineArea" to true), + ) + } + + private data class Fixture( + val host: User, + val room: TripRoom, + val schedule: Schedule, + val firstSlot: ScheduleSlot, + val secondSlot: ScheduleSlot, + ) +} From 437d41956de50aaad897eb5a1aaf563a9f6f5f4e Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 17 May 2026 20:40:37 +0900 Subject: [PATCH 2/6] Expose member-only trip photo album APIs The album UI needs authenticated endpoints for listing slot-grouped memories, uploading DB-backed image bytes, returning inline image content, and applying uploader/host moderation rules. Constraint: P0 stores files in DB bytea and excludes public album sharing Constraint: S3/object storage remains a future extension boundary rather than a P0 dependency Rejected: Public /share photo endpoints | photo consent and moderation model are not ready for P0 Confidence: high Scope-risk: moderate Tested: ./gradlew compileKotlin --no-daemon Tested: ./gradlew test --tests com.tripsync.domain.repository.TripPhotoRepositoryTest --no-daemon Not-tested: Full end-to-end multipart upload through browser --- .../application/photo/PhotoService.kt | 286 ++++++++++++++++++ .../repository/ScheduleSlotRepository.kt | 1 + .../kotlin/com/tripsync/web/dto/AuthDto.kt | 5 + .../com/tripsync/web/photo/PhotoController.kt | 88 ++++++ 4 files changed, 380 insertions(+) create mode 100644 src/main/kotlin/com/tripsync/application/photo/PhotoService.kt create mode 100644 src/main/kotlin/com/tripsync/web/photo/PhotoController.kt diff --git a/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt b/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt new file mode 100644 index 0000000..7b2a518 --- /dev/null +++ b/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt @@ -0,0 +1,286 @@ +package com.tripsync.application.photo + +import com.tripsync.common.dto.ApiResponse +import com.tripsync.common.exception.DomainException +import com.tripsync.domain.entity.Schedule +import com.tripsync.domain.entity.ScheduleSlot +import com.tripsync.domain.entity.TripPhoto +import com.tripsync.domain.entity.User +import com.tripsync.domain.enums.PhotoStatus +import com.tripsync.domain.enums.RoomMemberRole +import com.tripsync.domain.enums.YnFlag +import com.tripsync.domain.repository.RoomMemberRepository +import com.tripsync.domain.repository.ScheduleSlotRepository +import com.tripsync.domain.repository.TripPhotoRepository +import com.tripsync.domain.repository.UserRepository +import com.tripsync.application.schedule.ScheduleAccessPolicy +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import java.time.Instant + +@Service +class PhotoService( + private val tripPhotoRepository: TripPhotoRepository, + private val scheduleSlotRepository: ScheduleSlotRepository, + private val roomMemberRepository: RoomMemberRepository, + private val userRepository: UserRepository, + private val accessPolicy: ScheduleAccessPolicy, +) { + @Transactional(readOnly = true) + fun getAlbum(scheduleId: Long, userId: Long): ApiResponse> { + val schedule = requireConfirmedMemberSchedule(scheduleId, userId) + val slots = scheduleSlotRepository.findAllByScheduleIdAndDelYn(schedule.id, YnFlag.N) + .sortedBy { it.orderIndex } + val photosBySlotId = tripPhotoRepository + .findAllByScheduleIdAndDelYnAndStatusOrderByScheduleSlotOrderIndexAscCreatedAtAsc( + schedule.id, + YnFlag.N, + PhotoStatus.ACTIVE, + ) + .groupBy { it.scheduleSlot.id } + + return ApiResponse.ok( + mapOf( + "scheduleId" to schedule.id, + "roomId" to schedule.room.id, + "destination" to schedule.room.destination, + "tripDate" to schedule.room.tripDate.toString(), + "isConfirmed" to schedule.isConfirmed, + "totalPhotoCount" to photosBySlotId.values.sumOf { it.size }, + "slots" to slots.map { slot -> formatSlot(slot, photosBySlotId[slot.id].orEmpty()) }, + ) + ) + } + + @Transactional + fun uploadPhoto( + scheduleId: Long, + slotId: Long, + userId: Long, + file: MultipartFile, + caption: String?, + ): ApiResponse> { + val schedule = requireConfirmedMemberSchedule(scheduleId, userId) + val slot = requireActiveSlot(schedule.id, slotId) + val uploader = requireActiveUser(userId) + val content = validateAndRead(file) + val normalizedCaption = normalizeCaption(caption) + val originalFilename = normalizeFilename(file.originalFilename) + val contentType = normalizeContentType(file.contentType) + + val photo = tripPhotoRepository.save( + TripPhoto( + room = schedule.room, + schedule = schedule, + scheduleSlot = slot, + place = slot.place, + uploader = uploader, + originalFilename = originalFilename, + contentType = contentType, + fileSize = content.size.toLong(), + content = content, + caption = normalizedCaption, + status = PhotoStatus.ACTIVE, + ) + ) + + return ApiResponse.ok(mapOf("photo" to formatPhoto(photo))) + } + + @Transactional(readOnly = true) + fun getPhotoContent(scheduleId: Long, photoId: Long, userId: Long): PhotoContent { + val schedule = requireConfirmedMemberSchedule(scheduleId, userId) + val photo = tripPhotoRepository.findByIdAndScheduleIdAndDelYnAndStatus( + photoId, + schedule.id, + YnFlag.N, + PhotoStatus.ACTIVE, + ) ?: throw DomainException(HttpStatus.NOT_FOUND, "PHOTO_NOT_FOUND", "사진을 찾을 수 없습니다.") + + return PhotoContent( + content = photo.content, + contentType = photo.contentType, + filename = photo.originalFilename, + size = photo.fileSize, + ) + } + + @Transactional + fun updateCaption(scheduleId: Long, photoId: Long, userId: Long, caption: String?): ApiResponse> { + val schedule = requireConfirmedMemberSchedule(scheduleId, userId) + val photo = requireMutablePhoto(schedule.id, photoId) + validateUploader(photo, userId) + photo.caption = normalizeCaption(caption) + return ApiResponse.ok(mapOf("photo" to formatPhoto(photo))) + } + + @Transactional + fun hidePhoto(scheduleId: Long, photoId: Long, userId: Long): ApiResponse> { + val schedule = requireConfirmedMemberSchedule(scheduleId, userId) + validateHost(schedule.room.id, userId) + val photo = requireMutablePhoto(schedule.id, photoId) + photo.status = PhotoStatus.HIDDEN + return ApiResponse.ok(mapOf("photoId" to photo.id, "status" to photo.status.name.lowercase())) + } + + @Transactional + fun deletePhoto(scheduleId: Long, photoId: Long, userId: Long): ApiResponse> { + val schedule = requireConfirmedMemberSchedule(scheduleId, userId) + val photo = requireMutablePhoto(schedule.id, photoId) + validateUploaderOrHost(photo, userId) + val deleter = requireActiveUser(userId) + photo.status = PhotoStatus.DELETED + photo.delYn = YnFlag.Y + photo.deletedBy = deleter + photo.deletedAt = Instant.now() + return ApiResponse.ok(mapOf("photoId" to photo.id, "status" to "deleted")) + } + + private fun requireConfirmedMemberSchedule(scheduleId: Long, userId: Long): Schedule { + val schedule = accessPolicy.getActiveSchedule(scheduleId) + accessPolicy.validateRoomMember(schedule.room.id, userId) + accessPolicy.validateConfirmedSchedule(schedule) + return schedule + } + + private fun requireActiveSlot(scheduleId: Long, slotId: Long): ScheduleSlot { + val slot = scheduleSlotRepository.findByIdAndDelYn(slotId, YnFlag.N) + ?: throw DomainException(HttpStatus.NOT_FOUND, "SLOT_NOT_FOUND", "일정 슬롯을 찾을 수 없습니다.") + if (slot.schedule.id != scheduleId) { + throw DomainException(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "해당 일정에 포함되지 않은 슬롯입니다.") + } + return slot + } + + private fun requireActiveUser(userId: Long): User { + val user = userRepository.findById(userId) + .orElseThrow { DomainException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다.") } + if (user.delYn != YnFlag.N) { + throw DomainException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다.") + } + return user + } + + private fun requireMutablePhoto(scheduleId: Long, photoId: Long): TripPhoto { + val photo = tripPhotoRepository.findByIdAndScheduleIdAndDelYn(photoId, scheduleId, YnFlag.N) + ?: throw DomainException(HttpStatus.NOT_FOUND, "PHOTO_NOT_FOUND", "사진을 찾을 수 없습니다.") + if (photo.status == PhotoStatus.DELETED) { + throw DomainException(HttpStatus.NOT_FOUND, "PHOTO_NOT_FOUND", "사진을 찾을 수 없습니다.") + } + return photo + } + + private fun validateHost(roomId: Long, userId: Long) { + val member = roomMemberRepository.findByRoomIdAndUserIdAndDelYn(roomId, userId, YnFlag.N) + ?: throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "방 멤버만 접근할 수 있습니다.") + if (member.role != RoomMemberRole.HOST) { + throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "방장만 사진을 숨김 처리할 수 있습니다.") + } + } + + private fun validateUploader(photo: TripPhoto, userId: Long) { + if (photo.uploader.id != userId) { + throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "업로더만 사진 설명을 수정할 수 있습니다.") + } + } + + private fun validateUploaderOrHost(photo: TripPhoto, userId: Long) { + if (photo.uploader.id == userId) return + val member = roomMemberRepository.findByRoomIdAndUserIdAndDelYn(photo.room.id, userId, YnFlag.N) + ?: throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "방 멤버만 접근할 수 있습니다.") + if (member.role != RoomMemberRole.HOST) { + throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "업로더 또는 방장만 사진을 삭제할 수 있습니다.") + } + } + + private fun validateAndRead(file: MultipartFile): ByteArray { + if (file.isEmpty) { + throw DomainException(HttpStatus.BAD_REQUEST, "INVALID_PHOTO", "사진 파일을 첨부해야 합니다.") + } + if (file.size > MAX_FILE_SIZE_BYTES) { + throw DomainException(HttpStatus.BAD_REQUEST, "PHOTO_TOO_LARGE", "사진은 최대 10MB까지 업로드할 수 있습니다.") + } + normalizeContentType(file.contentType) + return file.bytes + } + + private fun normalizeContentType(contentType: String?): String { + val normalized = contentType?.lowercase()?.trim().orEmpty() + if (normalized !in ALLOWED_CONTENT_TYPES) { + throw DomainException(HttpStatus.BAD_REQUEST, "UNSUPPORTED_PHOTO_TYPE", "jpeg, png, webp 형식만 업로드할 수 있습니다.") + } + return normalized + } + + private fun normalizeFilename(filename: String?): String { + val cleaned = filename + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.takeIf { it.isNotBlank() } + ?: "photo" + return cleaned.take(MAX_FILENAME_LENGTH) + } + + private fun normalizeCaption(caption: String?): String? { + val normalized = caption?.trim()?.takeIf { it.isNotEmpty() } + if (normalized != null && normalized.length > MAX_CAPTION_LENGTH) { + throw DomainException(HttpStatus.BAD_REQUEST, "INVALID_CAPTION", "사진 설명은 최대 500자까지 입력할 수 있습니다.") + } + return normalized + } + + private fun formatSlot(slot: ScheduleSlot, photos: List): Map = mapOf( + "slotId" to slot.id, + "scheduleSlotId" to slot.id, + "orderIndex" to slot.orderIndex, + "startTime" to slot.startTime.toString(), + "endTime" to slot.endTime.toString(), + "place" to mapOf( + "id" to slot.place.id, + "name" to slot.place.name, + "address" to slot.place.address, + "imageUrl" to slot.place.imageUrl, + ), + "photos" to photos.map { formatPhoto(it) }, + ) + + private fun formatPhoto(photo: TripPhoto): Map = mapOf( + "id" to photo.id, + "photoId" to photo.id, + "scheduleId" to photo.schedule.id, + "slotId" to photo.scheduleSlot.id, + "scheduleSlotId" to photo.scheduleSlot.id, + "placeId" to photo.place.id, + "uploader" to mapOf( + "id" to photo.uploader.id, + "nickname" to photo.uploader.nickname, + ), + "uploaderUserId" to photo.uploader.id, + "uploaderNickname" to photo.uploader.nickname, + "originalFilename" to photo.originalFilename, + "contentType" to photo.contentType, + "fileSize" to photo.fileSize, + "sizeBytes" to photo.fileSize, + "caption" to photo.caption, + "status" to photo.status.name.lowercase(), + "contentUrl" to "/api/schedules/${photo.schedule.id}/album/photos/${photo.id}/content", + "createdAt" to photo.createdAt.toString(), + "updatedAt" to photo.updatedAt.toString(), + ) + + companion object { + const val MAX_FILE_SIZE_BYTES = 10L * 1024L * 1024L + private const val MAX_FILENAME_LENGTH = 255 + private const val MAX_CAPTION_LENGTH = 500 + private val ALLOWED_CONTENT_TYPES = setOf("image/jpeg", "image/png", "image/webp") + } +} + +data class PhotoContent( + val content: ByteArray, + val contentType: String, + val filename: String, + val size: Long, +) diff --git a/src/main/kotlin/com/tripsync/domain/repository/ScheduleSlotRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/ScheduleSlotRepository.kt index 1f9b636..c0cb4a1 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/ScheduleSlotRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/ScheduleSlotRepository.kt @@ -9,6 +9,7 @@ import org.springframework.stereotype.Repository @Repository interface ScheduleSlotRepository : JpaRepository { fun findAllByScheduleIdAndDelYn(scheduleId: Long, delYn: YnFlag): List + fun findByIdAndDelYn(id: Long, delYn: YnFlag): ScheduleSlot? @Query("select slot.place.id from ScheduleSlot slot where slot.schedule.id = :scheduleId and slot.delYn = :delYn") fun findActivePlaceIdsByScheduleId(scheduleId: Long, delYn: YnFlag = YnFlag.N): List diff --git a/src/main/kotlin/com/tripsync/web/dto/AuthDto.kt b/src/main/kotlin/com/tripsync/web/dto/AuthDto.kt index aed57ad..f8661b5 100644 --- a/src/main/kotlin/com/tripsync/web/dto/AuthDto.kt +++ b/src/main/kotlin/com/tripsync/web/dto/AuthDto.kt @@ -124,3 +124,8 @@ data class ReorderScheduleSlotsDto( data class AddScheduleSlotDto( val placeId: Long, ) + +data class UpdatePhotoCaptionDto( + @field:Size(max = 500) + val caption: String? = null, +) diff --git a/src/main/kotlin/com/tripsync/web/photo/PhotoController.kt b/src/main/kotlin/com/tripsync/web/photo/PhotoController.kt new file mode 100644 index 0000000..bb2a6fc --- /dev/null +++ b/src/main/kotlin/com/tripsync/web/photo/PhotoController.kt @@ -0,0 +1,88 @@ +package com.tripsync.web.photo + +import com.tripsync.application.photo.PhotoService +import com.tripsync.common.dto.ApiResponse +import com.tripsync.common.security.CurrentUser +import com.tripsync.domain.entity.User +import com.tripsync.web.dto.UpdatePhotoCaptionDto +import jakarta.validation.Valid +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +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.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import java.nio.charset.StandardCharsets + +@RestController +class PhotoController( + private val photoService: PhotoService, +) { + @GetMapping("/schedules/{scheduleId}/photos", "/schedules/{scheduleId}/album") + fun getAlbum( + @PathVariable scheduleId: Long, + @CurrentUser user: User, + ): ApiResponse> = photoService.getAlbum(scheduleId, user.id) + + @PostMapping( + "/schedules/{scheduleId}/slots/{slotId}/photos", + "/schedules/{scheduleId}/album/slots/{slotId}/photos", + consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], + ) + @ResponseStatus(HttpStatus.CREATED) + fun uploadPhoto( + @PathVariable scheduleId: Long, + @PathVariable slotId: Long, + @RequestParam("file") file: MultipartFile, + @RequestParam("caption", required = false) caption: String?, + @CurrentUser user: User, + ): ApiResponse> = photoService.uploadPhoto(scheduleId, slotId, user.id, file, caption) + + @GetMapping("/schedules/{scheduleId}/photos/{photoId}/content", "/schedules/{scheduleId}/album/photos/{photoId}/content") + fun getPhotoContent( + @PathVariable scheduleId: Long, + @PathVariable photoId: Long, + @CurrentUser user: User, + ): ResponseEntity { + val content = photoService.getPhotoContent(scheduleId, photoId, user.id) + val disposition = ContentDisposition.inline() + .filename(content.filename, StandardCharsets.UTF_8) + .build() + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(content.contentType)) + .contentLength(content.size) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .body(content.content) + } + + @PatchMapping("/schedules/{scheduleId}/photos/{photoId}", "/schedules/{scheduleId}/album/photos/{photoId}") + fun updateCaption( + @PathVariable scheduleId: Long, + @PathVariable photoId: Long, + @Valid @RequestBody dto: UpdatePhotoCaptionDto, + @CurrentUser user: User, + ): ApiResponse> = photoService.updateCaption(scheduleId, photoId, user.id, dto.caption) + + @PatchMapping("/schedules/{scheduleId}/photos/{photoId}/hide") + fun hidePhoto( + @PathVariable scheduleId: Long, + @PathVariable photoId: Long, + @CurrentUser user: User, + ): ApiResponse> = photoService.hidePhoto(scheduleId, photoId, user.id) + + @DeleteMapping("/schedules/{scheduleId}/photos/{photoId}", "/schedules/{scheduleId}/album/photos/{photoId}") + fun deletePhoto( + @PathVariable scheduleId: Long, + @PathVariable photoId: Long, + @CurrentUser user: User, + ): ApiResponse> = photoService.deletePhoto(scheduleId, photoId, user.id) +} From 2f91e9a18ac950f6f02fd226e56330feb873c871 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 17 May 2026 20:59:40 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EA=B3=B5=EC=9C=A0=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=EC=B2=A9=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=B0=8F=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/photo/PhotoService.kt | 90 +++++++++++++++++-- .../domain/repository/TripPhotoRepository.kt | 54 +++++++++++ .../com/tripsync/web/photo/PhotoController.kt | 3 +- src/main/resources/application-local.yml | 5 ++ src/main/resources/application.yml | 5 ++ .../db/migration/V5__add_trip_photos.sql | 8 +- .../repository/TripPhotoRepositoryTest.kt | 39 ++++++++ 7 files changed, 197 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt b/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt index 7b2a518..3062416 100644 --- a/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt +++ b/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt @@ -11,6 +11,7 @@ import com.tripsync.domain.enums.RoomMemberRole import com.tripsync.domain.enums.YnFlag import com.tripsync.domain.repository.RoomMemberRepository import com.tripsync.domain.repository.ScheduleSlotRepository +import com.tripsync.domain.repository.TripPhotoAlbumRow import com.tripsync.domain.repository.TripPhotoRepository import com.tripsync.domain.repository.UserRepository import com.tripsync.application.schedule.ScheduleAccessPolicy @@ -34,12 +35,12 @@ class PhotoService( val slots = scheduleSlotRepository.findAllByScheduleIdAndDelYn(schedule.id, YnFlag.N) .sortedBy { it.orderIndex } val photosBySlotId = tripPhotoRepository - .findAllByScheduleIdAndDelYnAndStatusOrderByScheduleSlotOrderIndexAscCreatedAtAsc( + .findAlbumRowsByScheduleIdAndDelYnAndStatus( schedule.id, YnFlag.N, PhotoStatus.ACTIVE, ) - .groupBy { it.scheduleSlot.id } + .groupBy { it.scheduleSlotId } return ApiResponse.ok( mapOf( @@ -65,6 +66,7 @@ class PhotoService( val schedule = requireConfirmedMemberSchedule(scheduleId, userId) val slot = requireActiveSlot(schedule.id, slotId) val uploader = requireActiveUser(userId) + validateAlbumQuota(schedule.id, slot.id, uploader.id) val content = validateAndRead(file) val normalizedCaption = normalizeCaption(caption) val originalFilename = normalizeFilename(file.originalFilename) @@ -195,6 +197,18 @@ class PhotoService( } } + private fun validateAlbumQuota(scheduleId: Long, slotId: Long, uploaderId: Long) { + if (tripPhotoRepository.countByScheduleIdAndDelYnAndStatus(scheduleId, YnFlag.N, PhotoStatus.ACTIVE) >= MAX_PHOTOS_PER_SCHEDULE) { + throw DomainException(HttpStatus.BAD_REQUEST, "PHOTO_ALBUM_LIMIT_EXCEEDED", "일정 사진첩에는 최대 ${MAX_PHOTOS_PER_SCHEDULE}장까지 업로드할 수 있습니다.") + } + if (tripPhotoRepository.countByScheduleSlotIdAndDelYnAndStatus(slotId, YnFlag.N, PhotoStatus.ACTIVE) >= MAX_PHOTOS_PER_SLOT) { + throw DomainException(HttpStatus.BAD_REQUEST, "PHOTO_SLOT_LIMIT_EXCEEDED", "장소별 사진은 최대 ${MAX_PHOTOS_PER_SLOT}장까지 업로드할 수 있습니다.") + } + if (tripPhotoRepository.countByScheduleIdAndUploaderIdAndDelYnAndStatus(scheduleId, uploaderId, YnFlag.N, PhotoStatus.ACTIVE) >= MAX_PHOTOS_PER_USER_PER_SCHEDULE) { + throw DomainException(HttpStatus.BAD_REQUEST, "PHOTO_USER_LIMIT_EXCEEDED", "한 사용자는 일정당 최대 ${MAX_PHOTOS_PER_USER_PER_SCHEDULE}장까지 업로드할 수 있습니다.") + } + } + private fun validateAndRead(file: MultipartFile): ByteArray { if (file.isEmpty) { throw DomainException(HttpStatus.BAD_REQUEST, "INVALID_PHOTO", "사진 파일을 첨부해야 합니다.") @@ -202,8 +216,12 @@ class PhotoService( if (file.size > MAX_FILE_SIZE_BYTES) { throw DomainException(HttpStatus.BAD_REQUEST, "PHOTO_TOO_LARGE", "사진은 최대 10MB까지 업로드할 수 있습니다.") } - normalizeContentType(file.contentType) - return file.bytes + val contentType = normalizeContentType(file.contentType) + val content = file.bytes + if (!matchesContentTypeSignature(content, contentType)) { + throw DomainException(HttpStatus.BAD_REQUEST, "UNSUPPORTED_PHOTO_TYPE", "파일 내용과 사진 형식이 일치하지 않습니다.") + } + return content } private fun normalizeContentType(contentType: String?): String { @@ -214,6 +232,32 @@ class PhotoService( return normalized } + private fun matchesContentTypeSignature(content: ByteArray, contentType: String): Boolean = when (contentType) { + "image/jpeg" -> content.size >= 3 && + content[0] == 0xFF.toByte() && + content[1] == 0xD8.toByte() && + content[2] == 0xFF.toByte() + "image/png" -> content.size >= 8 && + content[0] == 0x89.toByte() && + content[1] == 0x50.toByte() && + content[2] == 0x4E.toByte() && + content[3] == 0x47.toByte() && + content[4] == 0x0D.toByte() && + content[5] == 0x0A.toByte() && + content[6] == 0x1A.toByte() && + content[7] == 0x0A.toByte() + "image/webp" -> content.size >= 12 && + content[0] == 'R'.code.toByte() && + content[1] == 'I'.code.toByte() && + content[2] == 'F'.code.toByte() && + content[3] == 'F'.code.toByte() && + content[8] == 'W'.code.toByte() && + content[9] == 'E'.code.toByte() && + content[10] == 'B'.code.toByte() && + content[11] == 'P'.code.toByte() + else -> false + } + private fun normalizeFilename(filename: String?): String { val cleaned = filename ?.substringAfterLast('/') @@ -231,7 +275,7 @@ class PhotoService( return normalized } - private fun formatSlot(slot: ScheduleSlot, photos: List): Map = mapOf( + private fun formatSlot(slot: ScheduleSlot, photos: List): Map = mapOf( "slotId" to slot.id, "scheduleSlotId" to slot.id, "orderIndex" to slot.orderIndex, @@ -242,6 +286,10 @@ class PhotoService( "name" to slot.place.name, "address" to slot.place.address, "imageUrl" to slot.place.imageUrl, + "category" to slot.place.category, + "latitude" to slot.place.latitude.toDouble(), + "longitude" to slot.place.longitude.toDouble(), + "isDepopulationArea" to isDepopulationArea(slot.place.metadataTags), ), "photos" to photos.map { formatPhoto(it) }, ) @@ -270,8 +318,40 @@ class PhotoService( "updatedAt" to photo.updatedAt.toString(), ) + private fun formatPhoto(photo: TripPhotoAlbumRow): Map = mapOf( + "id" to photo.id, + "photoId" to photo.id, + "scheduleId" to photo.scheduleId, + "slotId" to photo.scheduleSlotId, + "scheduleSlotId" to photo.scheduleSlotId, + "placeId" to photo.placeId, + "uploader" to mapOf( + "id" to photo.uploaderUserId, + "nickname" to photo.uploaderNickname, + ), + "uploaderUserId" to photo.uploaderUserId, + "uploaderNickname" to photo.uploaderNickname, + "originalFilename" to photo.originalFilename, + "contentType" to photo.contentType, + "fileSize" to photo.fileSize, + "sizeBytes" to photo.fileSize, + "caption" to photo.caption, + "status" to photo.status.name.lowercase(), + "contentUrl" to "/api/schedules/${photo.scheduleId}/album/photos/${photo.id}/content", + "createdAt" to photo.createdAt.toString(), + "updatedAt" to photo.updatedAt.toString(), + ) + + private fun isDepopulationArea(metadataTags: Map?): Boolean { + return metadataTags?.get("populationDeclineArea") == true || metadataTags?.get("regionType") == "population_decline" + } + + companion object { const val MAX_FILE_SIZE_BYTES = 10L * 1024L * 1024L + private const val MAX_PHOTOS_PER_SCHEDULE = 200L + private const val MAX_PHOTOS_PER_SLOT = 50L + private const val MAX_PHOTOS_PER_USER_PER_SCHEDULE = 100L private const val MAX_FILENAME_LENGTH = 255 private const val MAX_CAPTION_LENGTH = 500 private val ALLOWED_CONTENT_TYPES = setOf("image/jpeg", "image/png", "image/webp") diff --git a/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt index 38a8564..cd8169c 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt @@ -4,16 +4,53 @@ import com.tripsync.domain.entity.TripPhoto import com.tripsync.domain.enums.PhotoStatus import com.tripsync.domain.enums.YnFlag import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository +import java.time.Instant @Repository interface TripPhotoRepository : JpaRepository { + @Query( + """ + select new com.tripsync.domain.repository.TripPhotoAlbumRow( + photo.id, + photo.schedule.id, + photo.scheduleSlot.id, + photo.place.id, + photo.uploader.id, + photo.uploader.nickname, + photo.originalFilename, + photo.contentType, + photo.fileSize, + photo.caption, + photo.status, + photo.createdAt, + photo.updatedAt + ) + from TripPhoto photo + where photo.schedule.id = :scheduleId + and photo.delYn = :delYn + and photo.status = :status + order by photo.scheduleSlot.orderIndex asc, photo.createdAt asc + """ + ) + fun findAlbumRowsByScheduleIdAndDelYnAndStatus( + @Param("scheduleId") scheduleId: Long, + @Param("delYn") delYn: YnFlag, + @Param("status") status: PhotoStatus, + ): List + fun findAllByScheduleIdAndDelYnAndStatusOrderByScheduleSlotOrderIndexAscCreatedAtAsc( scheduleId: Long, delYn: YnFlag, status: PhotoStatus, ): List + fun countByScheduleIdAndDelYnAndStatus(scheduleId: Long, delYn: YnFlag, status: PhotoStatus): Long + fun countByScheduleSlotIdAndDelYnAndStatus(scheduleSlotId: Long, delYn: YnFlag, status: PhotoStatus): Long + fun countByScheduleIdAndUploaderIdAndDelYnAndStatus(scheduleId: Long, uploaderId: Long, delYn: YnFlag, status: PhotoStatus): Long + fun findByIdAndDelYn(id: Long, delYn: YnFlag): TripPhoto? fun findByIdAndScheduleIdAndDelYn(id: Long, scheduleId: Long, delYn: YnFlag): TripPhoto? fun findByIdAndScheduleIdAndDelYnAndStatus( @@ -23,3 +60,20 @@ interface TripPhotoRepository : JpaRepository { status: PhotoStatus, ): TripPhoto? } + + +data class TripPhotoAlbumRow( + val id: Long, + val scheduleId: Long, + val scheduleSlotId: Long, + val placeId: Long, + val uploaderUserId: Long, + val uploaderNickname: String, + val originalFilename: String, + val contentType: String, + val fileSize: Long, + val caption: String?, + val status: PhotoStatus, + val createdAt: Instant, + val updatedAt: Instant, +) diff --git a/src/main/kotlin/com/tripsync/web/photo/PhotoController.kt b/src/main/kotlin/com/tripsync/web/photo/PhotoController.kt index bb2a6fc..711a429 100644 --- a/src/main/kotlin/com/tripsync/web/photo/PhotoController.kt +++ b/src/main/kotlin/com/tripsync/web/photo/PhotoController.kt @@ -61,6 +61,7 @@ class PhotoController( .contentType(MediaType.parseMediaType(content.contentType)) .contentLength(content.size) .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .header("X-Content-Type-Options", "nosniff") .body(content.content) } @@ -72,7 +73,7 @@ class PhotoController( @CurrentUser user: User, ): ApiResponse> = photoService.updateCaption(scheduleId, photoId, user.id, dto.caption) - @PatchMapping("/schedules/{scheduleId}/photos/{photoId}/hide") + @PatchMapping("/schedules/{scheduleId}/photos/{photoId}/hide", "/schedules/{scheduleId}/album/photos/{photoId}/hide") fun hidePhoto( @PathVariable scheduleId: Long, @PathVariable photoId: Long, diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index b41b828..10a82b7 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -32,6 +32,11 @@ spring: locations: classpath:db/migration baseline-on-migrate: true + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + jackson: serialization: write-dates-as-timestamps: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6463faf..7f88add 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,6 +35,11 @@ spring: locations: classpath:db/migration baseline-on-migrate: true + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + jackson: serialization: write-dates-as-timestamps: false diff --git a/src/main/resources/db/migration/V5__add_trip_photos.sql b/src/main/resources/db/migration/V5__add_trip_photos.sql index 5ea51ba..001f01e 100644 --- a/src/main/resources/db/migration/V5__add_trip_photos.sql +++ b/src/main/resources/db/migration/V5__add_trip_photos.sql @@ -17,9 +17,15 @@ CREATE TABLE trip_photos ( deleted_at TIMESTAMPTZ, del_yn VARCHAR(1) NOT NULL DEFAULT 'N', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_trip_photos_status CHECK (status IN ('ACTIVE', 'HIDDEN', 'DELETED')), + CONSTRAINT chk_trip_photos_del_yn CHECK (del_yn IN ('Y', 'N')), + CONSTRAINT chk_trip_photos_file_size CHECK (file_size > 0 AND file_size <= 10485760) ); CREATE INDEX idx_trip_photos_schedule_slot ON trip_photos(schedule_id, schedule_slot_id); CREATE INDEX idx_trip_photos_room ON trip_photos(room_id); CREATE INDEX idx_trip_photos_uploader ON trip_photos(uploader_user_id); +CREATE INDEX idx_trip_photos_album_active ON trip_photos(schedule_id, del_yn, status, schedule_slot_id, created_at); +CREATE INDEX idx_trip_photos_slot_active ON trip_photos(schedule_slot_id, del_yn, status); +CREATE INDEX idx_trip_photos_schedule_uploader_active ON trip_photos(schedule_id, uploader_user_id, del_yn, status); diff --git a/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt b/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt index 3cdcd7b..36f5126 100644 --- a/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt +++ b/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt @@ -93,6 +93,45 @@ class TripPhotoRepositoryTest( assertArrayEquals(content, reloaded.content) } + + @Test + fun `album row query returns metadata without requiring photo content in response shape`() { + val fixture = createFixture() + val content = byteArrayOf(0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50) + val saved = tripPhotoRepository.saveAndFlush( + photo(fixture, fixture.firstSlot, "album.webp", "image/webp", content) + ) + entityManager.clear() + + val rows = tripPhotoRepository.findAlbumRowsByScheduleIdAndDelYnAndStatus( + fixture.schedule.id, + YnFlag.N, + PhotoStatus.ACTIVE, + ) + + assertEquals(1, rows.size) + assertEquals(saved.id, rows.single().id) + assertEquals(fixture.firstSlot.id, rows.single().scheduleSlotId) + assertEquals(fixture.host.id, rows.single().uploaderUserId) + assertEquals("photo-host-", rows.single().uploaderNickname.take("photo-host-".length)) + assertEquals("album.webp", rows.single().originalFilename) + assertEquals(content.size.toLong(), rows.single().fileSize) + } + + @Test + fun `active quota counters ignore hidden and soft deleted photos`() { + val fixture = createFixture() + tripPhotoRepository.save(photo(fixture, fixture.firstSlot, "active.jpg", "image/jpeg", byteArrayOf(1), status = PhotoStatus.ACTIVE)) + tripPhotoRepository.save(photo(fixture, fixture.firstSlot, "hidden.jpg", "image/jpeg", byteArrayOf(2), status = PhotoStatus.HIDDEN)) + val deleted = tripPhotoRepository.save(photo(fixture, fixture.firstSlot, "deleted.jpg", "image/jpeg", byteArrayOf(3), status = PhotoStatus.ACTIVE)) + deleted.delYn = YnFlag.Y + tripPhotoRepository.saveAndFlush(deleted) + + assertEquals(1, tripPhotoRepository.countByScheduleIdAndDelYnAndStatus(fixture.schedule.id, YnFlag.N, PhotoStatus.ACTIVE)) + assertEquals(1, tripPhotoRepository.countByScheduleSlotIdAndDelYnAndStatus(fixture.firstSlot.id, YnFlag.N, PhotoStatus.ACTIVE)) + assertEquals(1, tripPhotoRepository.countByScheduleIdAndUploaderIdAndDelYnAndStatus(fixture.schedule.id, fixture.host.id, YnFlag.N, PhotoStatus.ACTIVE)) + } + @Test fun `deleted photos are not returned by active id lookup`() { val fixture = createFixture() From 0cce5acbecf5604dc05188af74ab591e5b25160a Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 17 May 2026 21:05:51 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=ED=99=95=EC=A0=95=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=9E=AC=EC=A7=84=EC=9E=85=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/schedule/ScheduleService.kt | 8 +++++++ .../domain/repository/ScheduleRepository.kt | 17 +++++++++++++ .../web/schedule/ScheduleController.kt | 5 ++++ .../schedule/ScheduleServiceTest.kt | 24 +++++++++++++++++++ 4 files changed, 54 insertions(+) diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt index d96d45a..ebf532b 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt @@ -82,6 +82,14 @@ class ScheduleService( return ApiResponse.ok(responseMapper.formatStoredSchedule(schedule)) } + @Transactional(readOnly = true) + fun getConfirmedSchedule(roomId: Long, userId: Long): ApiResponse> { + accessPolicy.validateRoomMember(roomId, userId) + val schedule = scheduleRepository.findConfirmedByRoomId(roomId, YnFlag.N).firstOrNull() + ?: throw DomainException(HttpStatus.NOT_FOUND, "SCHEDULE_NOT_FOUND", "확정된 일정이 없습니다.") + return ApiResponse.ok(responseMapper.formatStoredSchedule(schedule)) + } + @Transactional(readOnly = true) fun searchPlacesForSchedule(scheduleId: Long, userId: Long, query: String): ApiResponse> { diff --git a/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt index ee69aa6..de4ba4e 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt @@ -3,10 +3,27 @@ package com.tripsync.domain.repository import com.tripsync.domain.entity.Schedule import com.tripsync.domain.enums.YnFlag import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @Repository interface ScheduleRepository : JpaRepository { fun findByRoomIdAndDelYn(roomId: Long, delYn: YnFlag): List fun findTopByRoomIdAndDelYnOrderByVersionDesc(roomId: Long, delYn: YnFlag): Schedule? + + @Query( + """ + select schedule + from Schedule schedule + where schedule.room.id = :roomId + and schedule.delYn = :delYn + and schedule.isConfirmed = true + order by schedule.version desc, schedule.id desc + """ + ) + fun findConfirmedByRoomId( + @Param("roomId") roomId: Long, + @Param("delYn") delYn: YnFlag, + ): List } diff --git a/src/main/kotlin/com/tripsync/web/schedule/ScheduleController.kt b/src/main/kotlin/com/tripsync/web/schedule/ScheduleController.kt index 0c108e7..0520109 100644 --- a/src/main/kotlin/com/tripsync/web/schedule/ScheduleController.kt +++ b/src/main/kotlin/com/tripsync/web/schedule/ScheduleController.kt @@ -58,6 +58,11 @@ class ScheduleController( return scheduleService.getSchedule(scheduleId, user.id) } + @GetMapping("/rooms/{roomId}/confirmed-schedule") + fun getConfirmedSchedule(@PathVariable roomId: Long, @CurrentUser user: User): ApiResponse> { + return scheduleService.getConfirmedSchedule(roomId, user.id) + } + @GetMapping("/schedules/{scheduleId}/places/search") fun searchSchedulePlaces( @PathVariable scheduleId: Long, diff --git a/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt b/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt index a194f51..21e9c35 100644 --- a/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt @@ -74,6 +74,30 @@ class ScheduleServiceTest( } + + @Test + fun `confirmed schedule can be loaded by room for returning users`() { + val fixture = createFixture() + + val response = scheduleService.getConfirmedSchedule(fixture.schedule.room.id, fixture.host.id).data!! + + assertEquals(fixture.schedule.id, response["id"]) + assertEquals(true, response["isConfirmed"]) + assertEquals("balanced", response["optionType"]) + assertEquals("확정 일정", response["summary"]) + } + + @Test + fun `room without confirmed schedule returns not found`() { + val fixture = createFixture(isConfirmed = false) + + val error = assertThrows(DomainException::class.java) { + scheduleService.getConfirmedSchedule(fixture.schedule.room.id, fixture.host.id) + } + + assertEquals("SCHEDULE_NOT_FOUND", error.code) + } + @Test fun `unconfirmed schedule cannot be searched or edited`() { val fixture = createFixture(isConfirmed = false) From 4d8470dc9769c73d4d23b9c7943f91e97e6779f0 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 17 May 2026 21:16:33 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EB=B0=A9=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=97=90=20=EC=9D=BC=EC=A0=95=20=EB=B3=B5=EC=9B=90=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tripsync/application/room/RoomService.kt | 79 ++++++++++++++----- .../ScheduleGenerationPersistenceService.kt | 1 - .../application/schedule/ScheduleService.kt | 1 + .../com/tripsync/web/room/RoomController.kt | 14 ++-- 4 files changed, 66 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/room/RoomService.kt b/src/main/kotlin/com/tripsync/application/room/RoomService.kt index 8828eca..9cebba8 100644 --- a/src/main/kotlin/com/tripsync/application/room/RoomService.kt +++ b/src/main/kotlin/com/tripsync/application/room/RoomService.kt @@ -1,5 +1,6 @@ package com.tripsync.application.room +import com.tripsync.application.schedule.ScheduleResponseMapper import com.tripsync.common.dto.ApiResponse import com.tripsync.common.exception.DomainException import com.tripsync.domain.entity.RoomMember @@ -22,10 +23,12 @@ class RoomService( private val roomMemberRepository: RoomMemberRepository, private val roomMemberProfileRepository: RoomMemberProfileRepository, private val tptiResultRepository: TptiResultRepository, + private val scheduleRepository: ScheduleRepository, + private val scheduleResponseMapper: ScheduleResponseMapper, ) { @Transactional - fun createRoom(host: User, destination: String, tripDate: LocalDate): ApiResponse> { + fun createRoom(host: User, destination: String, tripDate: LocalDate): ApiResponse> { if (host.isGuest) { throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "방장 권한이 필요합니다.") } @@ -64,16 +67,16 @@ class RoomService( } @Transactional(readOnly = true) - fun getRoom(roomId: Long, user: User): ApiResponse> { + fun getRoom(roomId: Long, user: User): ApiResponse> { validateRoomMember(roomId, user.id) val room = getActiveRoom(roomId) val memberCount = roomMemberRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).size - return ApiResponse.ok(roomSummary(room, memberCount)) + return ApiResponse.ok(roomSummary(room, memberCount, includeScheduleState = true)) } @Transactional(readOnly = true) - fun getMyRooms(user: User): ApiResponse> { + fun getMyRooms(user: User): ApiResponse> { if (user.isGuest) { throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "방장 계정으로 로그인해주세요.") } @@ -85,24 +88,24 @@ class RoomService( .sortedWith(compareByDescending { it.createdAt }.thenByDescending { it.id }) .map { room -> val memberCount = roomMemberRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).size - roomSummary(room, memberCount) + roomSummary(room, memberCount, includeScheduleState = false) } return ApiResponse.ok(mapOf("rooms" to rooms)) } @Transactional(readOnly = true) - fun getShareRoom(shareCode: String): ApiResponse> { + fun getShareRoom(shareCode: String): ApiResponse> { val room = tripRoomRepository.findByShareCodeAndDelYn(shareCode, YnFlag.N) ?: throw DomainException(HttpStatus.NOT_FOUND, "INVALID_SHARE_CODE", "유효하지 않은 공유 코드입니다.") val memberCount = roomMemberRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).size return ApiResponse.ok( - roomSummary(room, memberCount) + mapOf("hostNickname" to room.hostUser.nickname) + roomSummary(room, memberCount, includeScheduleState = false) + mapOf("hostNickname" to room.hostUser.nickname) ) } @Transactional - fun joinRoom(shareCode: String, tptiResultId: Long?, user: User): ApiResponse> { + fun joinRoom(shareCode: String, tptiResultId: Long?, user: User): ApiResponse> { val room = tripRoomRepository.findByShareCodeAndDelYn(shareCode, YnFlag.N) ?: throw DomainException(HttpStatus.NOT_FOUND, "INVALID_SHARE_CODE", "유효하지 않은 공유 코드입니다.") @@ -137,7 +140,7 @@ class RoomService( } @Transactional(readOnly = true) - fun getMembers(roomId: Long, user: User): ApiResponse> { + fun getMembers(roomId: Long, user: User): ApiResponse> { validateRoomMember(roomId, user.id) val members = roomMemberRepository.findAllByRoomIdAndDelYn(roomId, YnFlag.N) val profilesByUserId = roomMemberProfileRepository.findAllByRoomIdAndDelYn(roomId, YnFlag.N) @@ -219,18 +222,52 @@ class RoomService( } } - private fun roomSummary(room: TripRoom, memberCount: Int): Map = mapOf( - "roomId" to room.id, - "destination" to room.destination, - "tripDate" to room.tripDate.toString(), - "tripStartDate" to room.tripDate.toString(), - "tripEndDate" to room.tripDate.toString(), - "shareCode" to room.shareCode, - "status" to room.status.name.lowercase(), - "hostUserId" to room.hostUser.id, - "memberCount" to memberCount, - "createdAt" to room.createdAt.toString(), - ) + private fun roomSummary(room: TripRoom, memberCount: Int, includeScheduleState: Boolean): Map { + val schedules = scheduleRepository.findByRoomIdAndDelYn(room.id, YnFlag.N) + val confirmed = schedules + .filter { it.isConfirmed } + .maxWithOrNull(compareBy { it.version }.thenBy { it.id }) + val latestVersion = schedules.maxOfOrNull { it.version } + val base = mapOf( + "roomId" to room.id, + "destination" to room.destination, + "tripDate" to room.tripDate.toString(), + "tripStartDate" to room.tripDate.toString(), + "tripEndDate" to room.tripDate.toString(), + "shareCode" to room.shareCode, + "status" to room.status.name.lowercase(), + "hostUserId" to room.hostUser.id, + "memberCount" to memberCount, + "createdAt" to room.createdAt.toString(), + "hasGeneratedSchedule" to schedules.isNotEmpty(), + "confirmedScheduleId" to confirmed?.id, + "latestScheduleVersion" to latestVersion, + ) + if (!includeScheduleState) return base + + val latestOptions = latestVersion + ?.let { version -> schedules.filter { it.version == version }.sortedBy { it.optionType.ordinal } } + .orEmpty() + val scheduleState = when { + confirmed != null -> mapOf( + "status" to "confirmed", + "confirmedSchedule" to scheduleResponseMapper.formatStoredSchedule(confirmed), + "options" to latestOptions.map { scheduleResponseMapper.formatStoredSchedule(it) }, + ) + latestOptions.isNotEmpty() -> mapOf( + "status" to "generated", + "confirmedSchedule" to null, + "options" to latestOptions.map { scheduleResponseMapper.formatStoredSchedule(it) }, + ) + else -> mapOf( + "status" to "empty", + "confirmedSchedule" to null, + "options" to emptyList>(), + ) + } + + return base + mapOf("scheduleState" to scheduleState) + } private fun generateShareCode(): String { val suffix = UUID.randomUUID().toString().replace("-", "").take(5).uppercase() diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt index ff2ac5b..adefed6 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt @@ -165,7 +165,6 @@ class ScheduleGenerationPersistenceService( personaValidation = personaValidation, ) } - room.status = TripRoomStatus.COMPLETED return SavedScheduleGeneration(version = version, options = saved) } diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt index ebf532b..ce70ea9 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt @@ -191,6 +191,7 @@ class ScheduleService( scheduleRepository.findByRoomIdAndDelYn(roomId, YnFlag.N).forEach { it.isConfirmed = false } target.isConfirmed = true + target.room.status = com.tripsync.domain.enums.TripRoomStatus.COMPLETED return ApiResponse.ok( mapOf( diff --git a/src/main/kotlin/com/tripsync/web/room/RoomController.kt b/src/main/kotlin/com/tripsync/web/room/RoomController.kt index d1804b1..ee7a86d 100644 --- a/src/main/kotlin/com/tripsync/web/room/RoomController.kt +++ b/src/main/kotlin/com/tripsync/web/room/RoomController.kt @@ -19,17 +19,17 @@ class RoomController( @PostMapping @ResponseStatus(HttpStatus.CREATED) - fun createRoom(@Valid @RequestBody dto: CreateRoomDto, @CurrentUser user: User): ApiResponse> { + fun createRoom(@Valid @RequestBody dto: CreateRoomDto, @CurrentUser user: User): ApiResponse> { return roomService.createRoom(user, dto.destination, LocalDate.parse(dto.tripDate)) } @GetMapping("/my") - fun getMyRooms(@CurrentUser user: User): ApiResponse> { + fun getMyRooms(@CurrentUser user: User): ApiResponse> { return roomService.getMyRooms(user) } @GetMapping("/share/{shareCode}") - fun getShareRoom(@PathVariable shareCode: String): ApiResponse> { + fun getShareRoom(@PathVariable shareCode: String): ApiResponse> { return roomService.getShareRoom(shareCode) } @@ -39,7 +39,7 @@ class RoomController( @PathVariable shareCode: String, @RequestBody(required = false) dto: JoinRoomDto?, @CurrentUser user: User, - ): ApiResponse> { + ): ApiResponse> { return roomService.joinRoom(shareCode, dto?.tptiResultId, user) } @@ -49,17 +49,17 @@ class RoomController( @PathVariable shareCode: String, @RequestBody(required = false) dto: JoinRoomDto?, @CurrentUser user: User, - ): ApiResponse> { + ): ApiResponse> { return joinRoom(shareCode, dto, user) } @GetMapping("/{roomId}/members") - fun getMembers(@PathVariable roomId: Long, @CurrentUser user: User): ApiResponse> { + fun getMembers(@PathVariable roomId: Long, @CurrentUser user: User): ApiResponse> { return roomService.getMembers(roomId, user) } @GetMapping("/{roomId}") - fun getRoom(@PathVariable roomId: Long, @CurrentUser user: User): ApiResponse> { + fun getRoom(@PathVariable roomId: Long, @CurrentUser user: User): ApiResponse> { return roomService.getRoom(roomId, user) } } From a4162e7a4001e6c7652a93c9637835ff9d561cd2 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 17 May 2026 21:17:10 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EC=82=AC=EC=A7=84=EC=B2=A9=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EC=84=AC=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/db/migration/V5__add_trip_photos.sql | 8 +------- .../db/migration/V6__harden_trip_photo_constraints.sql | 8 ++++++++ 2 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/db/migration/V6__harden_trip_photo_constraints.sql diff --git a/src/main/resources/db/migration/V5__add_trip_photos.sql b/src/main/resources/db/migration/V5__add_trip_photos.sql index 001f01e..5ea51ba 100644 --- a/src/main/resources/db/migration/V5__add_trip_photos.sql +++ b/src/main/resources/db/migration/V5__add_trip_photos.sql @@ -17,15 +17,9 @@ CREATE TABLE trip_photos ( deleted_at TIMESTAMPTZ, del_yn VARCHAR(1) NOT NULL DEFAULT 'N', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT chk_trip_photos_status CHECK (status IN ('ACTIVE', 'HIDDEN', 'DELETED')), - CONSTRAINT chk_trip_photos_del_yn CHECK (del_yn IN ('Y', 'N')), - CONSTRAINT chk_trip_photos_file_size CHECK (file_size > 0 AND file_size <= 10485760) + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_trip_photos_schedule_slot ON trip_photos(schedule_id, schedule_slot_id); CREATE INDEX idx_trip_photos_room ON trip_photos(room_id); CREATE INDEX idx_trip_photos_uploader ON trip_photos(uploader_user_id); -CREATE INDEX idx_trip_photos_album_active ON trip_photos(schedule_id, del_yn, status, schedule_slot_id, created_at); -CREATE INDEX idx_trip_photos_slot_active ON trip_photos(schedule_slot_id, del_yn, status); -CREATE INDEX idx_trip_photos_schedule_uploader_active ON trip_photos(schedule_id, uploader_user_id, del_yn, status); diff --git a/src/main/resources/db/migration/V6__harden_trip_photo_constraints.sql b/src/main/resources/db/migration/V6__harden_trip_photo_constraints.sql new file mode 100644 index 0000000..3be8c0c --- /dev/null +++ b/src/main/resources/db/migration/V6__harden_trip_photo_constraints.sql @@ -0,0 +1,8 @@ +ALTER TABLE trip_photos + ADD CONSTRAINT chk_trip_photos_status CHECK (status IN ('ACTIVE', 'HIDDEN', 'DELETED')), + ADD CONSTRAINT chk_trip_photos_del_yn CHECK (del_yn IN ('Y', 'N')), + ADD CONSTRAINT chk_trip_photos_file_size CHECK (file_size > 0 AND file_size <= 10485760); + +CREATE INDEX idx_trip_photos_album_active ON trip_photos(schedule_id, del_yn, status, schedule_slot_id, created_at); +CREATE INDEX idx_trip_photos_slot_active ON trip_photos(schedule_slot_id, del_yn, status); +CREATE INDEX idx_trip_photos_schedule_uploader_active ON trip_photos(schedule_id, uploader_user_id, del_yn, status);