From 83c45fbc3d6612ccea06906df1f573b6b76a2bb7 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 17 May 2026 22:05:15 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/tripsync/application/room/RoomService.kt | 14 +++++++++++++- .../kotlin/com/tripsync/domain/entity/TripRoom.kt | 3 +++ src/main/kotlin/com/tripsync/web/dto/AuthDto.kt | 1 + .../kotlin/com/tripsync/web/room/RoomController.kt | 2 +- .../db/migration/V7__add_trip_room_name.sql | 7 +++++++ src/test/kotlin/com/tripsync/AuthContractTests.kt | 4 +++- .../schedule/ScheduleResponseMapperTest.kt | 1 + .../application/schedule/ScheduleServiceTest.kt | 1 + .../domain/repository/TripPhotoRepositoryTest.kt | 1 + 9 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/migration/V7__add_trip_room_name.sql diff --git a/src/main/kotlin/com/tripsync/application/room/RoomService.kt b/src/main/kotlin/com/tripsync/application/room/RoomService.kt index 9cebba8..ef896e1 100644 --- a/src/main/kotlin/com/tripsync/application/room/RoomService.kt +++ b/src/main/kotlin/com/tripsync/application/room/RoomService.kt @@ -28,7 +28,7 @@ class RoomService( ) { @Transactional - fun createRoom(host: User, destination: String, tripDate: LocalDate): ApiResponse> { + fun createRoom(host: User, destination: String, tripDate: LocalDate, roomName: String? = null): ApiResponse> { if (host.isGuest) { throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "방장 권한이 필요합니다.") } @@ -36,11 +36,13 @@ class RoomService( throw DomainException(HttpStatus.UNPROCESSABLE_ENTITY, "INVALID_REQUEST", "tripDate는 오늘 이후여야 합니다.") } + val normalizedRoomName = normalizeRoomName(roomName, destination) val room = tripRoomRepository.save( TripRoom( hostUser = host, shareCode = generateShareCode(), destination = destination, + roomName = normalizedRoomName, tripDate = tripDate, status = TripRoomStatus.WAITING, ) @@ -60,6 +62,7 @@ class RoomService( return ApiResponse.ok( mapOf( "roomId" to room.id, + "roomName" to room.roomName, "shareCode" to room.shareCode, "status" to room.status.name.lowercase(), ) @@ -230,6 +233,7 @@ class RoomService( val latestVersion = schedules.maxOfOrNull { it.version } val base = mapOf( "roomId" to room.id, + "roomName" to room.roomName, "destination" to room.destination, "tripDate" to room.tripDate.toString(), "tripStartDate" to room.tripDate.toString(), @@ -269,6 +273,14 @@ class RoomService( return base + mapOf("scheduleState" to scheduleState) } + private fun normalizeRoomName(roomName: String?, destination: String): String { + val normalized = roomName?.trim()?.takeIf { it.isNotBlank() } ?: "${destination.trim()} 여행 계획" + if (normalized.length > 100) { + throw DomainException(HttpStatus.UNPROCESSABLE_ENTITY, "INVALID_REQUEST", "방 이름은 100자 이하여야 합니다.") + } + return normalized + } + private fun generateShareCode(): String { val suffix = UUID.randomUUID().toString().replace("-", "").take(5).uppercase() return "CNAM${LocalDate.now().year.toString().takeLast(2)}$suffix" diff --git a/src/main/kotlin/com/tripsync/domain/entity/TripRoom.kt b/src/main/kotlin/com/tripsync/domain/entity/TripRoom.kt index addf823..57812ae 100644 --- a/src/main/kotlin/com/tripsync/domain/entity/TripRoom.kt +++ b/src/main/kotlin/com/tripsync/domain/entity/TripRoom.kt @@ -21,6 +21,9 @@ class TripRoom( @Column(nullable = false, length = 100) var destination: String, + @Column(name = "room_name", nullable = false, length = 100) + var roomName: String, + @Column(name = "trip_date", nullable = false) var tripDate: LocalDate, diff --git a/src/main/kotlin/com/tripsync/web/dto/AuthDto.kt b/src/main/kotlin/com/tripsync/web/dto/AuthDto.kt index f8661b5..a3dcd90 100644 --- a/src/main/kotlin/com/tripsync/web/dto/AuthDto.kt +++ b/src/main/kotlin/com/tripsync/web/dto/AuthDto.kt @@ -78,6 +78,7 @@ data class CreateRoomDto( val tripDate: String, val tripStartDate: String? = null, val tripEndDate: String? = null, + val roomName: String? = null, ) data class JoinRoomDto( diff --git a/src/main/kotlin/com/tripsync/web/room/RoomController.kt b/src/main/kotlin/com/tripsync/web/room/RoomController.kt index ee7a86d..7639e13 100644 --- a/src/main/kotlin/com/tripsync/web/room/RoomController.kt +++ b/src/main/kotlin/com/tripsync/web/room/RoomController.kt @@ -20,7 +20,7 @@ class RoomController( @PostMapping @ResponseStatus(HttpStatus.CREATED) fun createRoom(@Valid @RequestBody dto: CreateRoomDto, @CurrentUser user: User): ApiResponse> { - return roomService.createRoom(user, dto.destination, LocalDate.parse(dto.tripDate)) + return roomService.createRoom(user, dto.destination, LocalDate.parse(dto.tripDate), dto.roomName) } @GetMapping("/my") diff --git a/src/main/resources/db/migration/V7__add_trip_room_name.sql b/src/main/resources/db/migration/V7__add_trip_room_name.sql new file mode 100644 index 0000000..a2f98c6 --- /dev/null +++ b/src/main/resources/db/migration/V7__add_trip_room_name.sql @@ -0,0 +1,7 @@ +ALTER TABLE trip_rooms ADD COLUMN room_name VARCHAR(100); + +UPDATE trip_rooms +SET room_name = destination || ' 여행 계획' +WHERE room_name IS NULL; + +ALTER TABLE trip_rooms ALTER COLUMN room_name SET NOT NULL; diff --git a/src/test/kotlin/com/tripsync/AuthContractTests.kt b/src/test/kotlin/com/tripsync/AuthContractTests.kt index b087b75..15f9253 100644 --- a/src/test/kotlin/com/tripsync/AuthContractTests.kt +++ b/src/test/kotlin/com/tripsync/AuthContractTests.kt @@ -186,6 +186,7 @@ class AuthContractTests( jsonPath("$.data.rooms[0].roomId") { value(secondRoomId.toInt()) } jsonPath("$.data.rooms[1].roomId") { value(firstRoomId.toInt()) } jsonPath("$.data.rooms[0].destination") { value("충청남도") } + jsonPath("$.data.rooms[0].roomName") { value("충남 봄 여행") } jsonPath("$.data.rooms[0].memberCount") { value(1) } } } @@ -229,10 +230,11 @@ class AuthContractTests( val response = mockMvc.post("/rooms") { cookie(session) contentType = MediaType.APPLICATION_JSON - content = """{"destination":"충청남도","tripDate":"${LocalDate.now().plusDays(7)}"}""" + content = """{"destination":"충청남도","tripDate":"${LocalDate.now().plusDays(7)}","roomName":"충남 봄 여행"}""" }.andExpect { status { isCreated() } jsonPath("$.data.roomId") { value(notNullValue()) } + jsonPath("$.data.roomName") { value("충남 봄 여행") } }.andReturn().response.contentAsString return Regex("""\"roomId\":(\d+)""").find(response)!!.groupValues[1].toLong() diff --git a/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt b/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt index ffd6331..a0f809e 100644 --- a/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt +++ b/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt @@ -58,6 +58,7 @@ class ScheduleResponseMapperTest { hostUser = host, shareCode = "ABC123456789", destination = "충남", + roomName = "충남 여행 계획", tripDate = LocalDate.parse("2026-06-01"), status = TripRoomStatus.COMPLETED, ) diff --git a/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt b/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt index 21e9c35..a6ac5f5 100644 --- a/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt @@ -140,6 +140,7 @@ class ScheduleServiceTest( hostUser = host, shareCode = "S${suffix.toString().takeLast(10)}", destination = "충남", + roomName = "충남 여행 계획", tripDate = LocalDate.now().plusDays(7), status = TripRoomStatus.COMPLETED, ) diff --git a/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt b/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt index 36f5126..69dc6d1 100644 --- a/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt +++ b/src/test/kotlin/com/tripsync/domain/repository/TripPhotoRepositoryTest.kt @@ -160,6 +160,7 @@ class TripPhotoRepositoryTest( hostUser = host, shareCode = "P${suffix.toString().takeLast(10)}", destination = "충남", + roomName = "충남 여행 계획", tripDate = LocalDate.now().minusDays(1), status = TripRoomStatus.COMPLETED, ) From 041f8b3889345494189714a4cf8cfd78a0aa8059 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 17 May 2026 22:14:54 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=B0=A9=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EA=B8=B8=EC=9D=B4=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tripsync/application/room/RoomService.kt | 14 ++++++-- .../db/migration/V7__add_trip_room_name.sql | 2 +- .../kotlin/com/tripsync/AuthContractTests.kt | 33 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/room/RoomService.kt b/src/main/kotlin/com/tripsync/application/room/RoomService.kt index ef896e1..90adcc2 100644 --- a/src/main/kotlin/com/tripsync/application/room/RoomService.kt +++ b/src/main/kotlin/com/tripsync/application/room/RoomService.kt @@ -274,15 +274,25 @@ class RoomService( } private fun normalizeRoomName(roomName: String?, destination: String): String { - val normalized = roomName?.trim()?.takeIf { it.isNotBlank() } ?: "${destination.trim()} 여행 계획" - if (normalized.length > 100) { + val normalized = roomName?.trim()?.takeIf { it.isNotBlank() } ?: defaultRoomName(destination) + if (normalized.length > ROOM_NAME_MAX_LENGTH) { throw DomainException(HttpStatus.UNPROCESSABLE_ENTITY, "INVALID_REQUEST", "방 이름은 100자 이하여야 합니다.") } return normalized } + private fun defaultRoomName(destination: String): String { + val destinationLimit = ROOM_NAME_MAX_LENGTH - ROOM_NAME_SUFFIX.length + return destination.trim().take(destinationLimit) + ROOM_NAME_SUFFIX + } + private fun generateShareCode(): String { val suffix = UUID.randomUUID().toString().replace("-", "").take(5).uppercase() return "CNAM${LocalDate.now().year.toString().takeLast(2)}$suffix" } + + private companion object { + const val ROOM_NAME_MAX_LENGTH = 100 + const val ROOM_NAME_SUFFIX = " 여행 계획" + } } diff --git a/src/main/resources/db/migration/V7__add_trip_room_name.sql b/src/main/resources/db/migration/V7__add_trip_room_name.sql index a2f98c6..77fa88b 100644 --- a/src/main/resources/db/migration/V7__add_trip_room_name.sql +++ b/src/main/resources/db/migration/V7__add_trip_room_name.sql @@ -1,7 +1,7 @@ ALTER TABLE trip_rooms ADD COLUMN room_name VARCHAR(100); UPDATE trip_rooms -SET room_name = destination || ' 여행 계획' +SET room_name = LEFT(TRIM(destination), 100 - CHAR_LENGTH(' 여행 계획')) || ' 여행 계획' WHERE room_name IS NULL; ALTER TABLE trip_rooms ALTER COLUMN room_name SET NOT NULL; diff --git a/src/test/kotlin/com/tripsync/AuthContractTests.kt b/src/test/kotlin/com/tripsync/AuthContractTests.kt index 15f9253..390841c 100644 --- a/src/test/kotlin/com/tripsync/AuthContractTests.kt +++ b/src/test/kotlin/com/tripsync/AuthContractTests.kt @@ -191,6 +191,39 @@ class AuthContractTests( } } + + @Test + fun `room name fallback preserves suffix within database limit`() { + val hostSession = registerSession("host-room-name-fallback@example.com", "방이름-fallback") + val destination = "가".repeat(100) + val expectedRoomName = "가".repeat(94) + " 여행 계획" + + mockMvc.post("/rooms") { + cookie(hostSession) + contentType = MediaType.APPLICATION_JSON + content = """{"destination":"$destination","tripDate":"${LocalDate.now().plusDays(7)}"}""" + }.andExpect { + status { isCreated() } + jsonPath("$.data.roomName") { value(expectedRoomName) } + } + } + + @Test + fun `explicit room name over database limit is rejected`() { + val hostSession = registerSession("host-room-name-too-long@example.com", "방이름-long") + val roomName = "나".repeat(101) + + mockMvc.post("/rooms") { + cookie(hostSession) + contentType = MediaType.APPLICATION_JSON + content = """{"destination":"충청남도","tripDate":"${LocalDate.now().plusDays(7)}","roomName":"$roomName"}""" + }.andExpect { + status { isUnprocessableEntity() } + jsonPath("$.success") { value(false) } + jsonPath("$.error.code") { value("INVALID_REQUEST") } + } + } + @Test fun `oauth start sets state cookie and local callback creates session`() { val start = mockMvc.get("/auth/google") {