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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ interface NotificationHistoryApi {
"`refId` 로 이동 대상을 정하고, `id` 로 단건 읽음 처리(`POST /read`)를 한다.\n\n" +
"**알림 타입 카탈로그 (전 10종)**\n\n" +
"`type` 으로 화면을 분기하고 `refId` 로 이동 대상을 정한다. `kind` 는 **전 알림 공통 필드**로 항상 실리며 카드 라벨·아이콘(위시/토너먼트/시스템)이 된다. " +
"`body` 는 현재 전 타입 빈 문자열(`\"\"`).\n\n" +
"`body` 는 **`ITEM_PARSING_COMPLETED` 와 `ANNOUNCEMENT` 만 값이 있고 나머지 타입은 빈 문자열(`\"\"`)** 이다. " +
"전자는 `title` 에 아이템 이름을, `body` 에 상태 문구를 나눠 싣는다 — OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, " +
"이름과 상태를 한 줄에 담으면 이름이 길 때 상태가 사라지기 때문이다. 후자는 관리자가 입력한 공지 본문이 그대로 `body` 에 실린다. " +
"클라이언트는 `body` 가 비어 있을 수 있음을 전제로 그린다.\n\n" +
"| `type` | 트리거 | `kind` | `refId` | `title` 예시 |\n" +
"|---|---|---|---|---|\n" +
"| `TOURNAMENT_JOINED` | 토너먼트 참가 | `TOURNAMENT` | tournamentId | {참가자}님이 참가했어요 |\n" +
Expand All @@ -43,9 +46,11 @@ interface NotificationHistoryApi {
"| `TOURNAMENT_PLAYED_FROM_LINK` | 플레이링크로 플레이 시작 | `TOURNAMENT` | ROOT 토너먼트 id | {플레이어}님이 회원님 토너먼트를 플레이했어요 |\n" +
"| `TOURNAMENT_COMPLETED` | 멤버가 클론 완료 | `TOURNAMENT` | ROOT 토너먼트 id | {멤버}님이 회원님 토너먼트를 완료했어요 |\n" +
"| `TOURNAMENT_RESULT_READY` | 주최자가 ROOT 완료 | `TOURNAMENT` | ROOT 토너먼트 id | 참여하신 {주최자}님의 토너먼트 결과가 나왔어요 |\n" +
"| `ITEM_PARSING_COMPLETED` | 상품 추출 성공 | 출처에 따라 `WISH` 또는 `TOURNAMENT` | itemId | {아이템 이름} 파싱이 완료되었어요 |\n" +
"| `ITEM_PARSING_COMPLETED` | 상품 추출 성공 | 출처에 따라 `WISH` 또는 `TOURNAMENT` | itemId | {아이템 이름} (+ `body` = 파싱이 완료되었어요) |\n" +
"| `ITEM_PARSING_FAILED` | 상품 추출 실패 | 출처에 따라 `WISH` 또는 `TOURNAMENT` | itemId | 상품 정보를 가져오지 못했어요 |\n" +
"| `ANNOUNCEMENT` | 관리자 공지(후속) | `SYSTEM` | 공지 id/0 | (관리자 입력) |\n\n" +
"> `ITEM_PARSING_COMPLETED` 의 `body` 는 `kind` 와 무관하게 하나다 — 위시로 담았든 토너먼트로 올렸든 같은 문구가 온다. " +
"화면 분기는 `kind` 로 하고 `body` 문구에 기대지 않는다.\n\n" +
"> 파싱 알림(`ITEM_PARSING_*`)만 `kind` 가 발행 출처(위시 등록 / 토너먼트 추가)에 따라 갈린다 — 같은 `type` 이 두 플로우에서 발행되기 때문. " +
"나머지 타입은 위 표의 값 하나로 고정이다.\n\n" +
"> 아이템 좌표(`tournamentId`·`tournamentItemId`)가 추가로 실리는 타입: 토너먼트 출처 파싱 알림(`ITEM_PARSING_*` + `kind`=TOURNAMENT)과 " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,10 @@ class NotificationHistoryApiExamples(
id = 1025,
type = NotificationType.ITEM_PARSING_COMPLETED,
kind = NotificationKind.of(NotificationType.ITEM_PARSING_COMPLETED, NotificationKind.WISH),
title = "에어 조던 1 미드 파싱이 완료되었어요",
body = "",
// 파싱 완료만 title=이름 / body=상태 로 나뉜다(#913). 두 값 다 템플릿이 렌더한 실제 모양으로 둔다 —
// body 문구는 DB 템플릿(V20260811010101)이 소유하므로 referenceItem 의 title 과 같이 리터럴로 적는다.
title = "에어 조던 1 미드",
body = PARSING_COMPLETED_BODY,
refId = 512,
isRead = true,
createdAt = LocalDateTime.of(2026, 6, 8, 10, 5, 0),
Expand All @@ -165,8 +167,8 @@ class NotificationHistoryApiExamples(
id = 1024,
type = NotificationType.ITEM_PARSING_COMPLETED,
kind = NotificationKind.of(NotificationType.ITEM_PARSING_COMPLETED, NotificationKind.TOURNAMENT),
title = "나이키 덩크 로우 파싱이 완료되었어요",
body = "",
title = "나이키 덩크 로우",
body = PARSING_COMPLETED_BODY,
refId = 513,
isRead = false,
createdAt = LocalDateTime.of(2026, 6, 8, 10, 0, 0),
Expand All @@ -175,4 +177,9 @@ class NotificationHistoryApiExamples(
)

private val sampleItems = listOf(referenceItem, wishParsingItem, tournamentParsingItem)

companion object {
// 파싱 완료 body — 출처(위시/토너먼트)와 무관하게 하나다. 두 example 이 같은 값을 쓰는 게 계약이라 상수로 묶는다.
private const val PARSING_COMPLETED_BODY = "파싱이 완료되었어요"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.depromeet.piki.notification.handler

import java.text.BreakIterator

// 알림 문구에 실을 상품명을 표시용으로 다듬는다. 파싱 완료(#913)와 아이템 삭제 두 알림이 공유한다.
//
// 표시 글자 절단은 **선택**이다 — 두 알림의 사정이 다르다.
// - 파싱 완료: title 이 이름뿐이라 OS 가 잘라도 의미가 안 사라진다. 절단하지 않는다(maxGraphemes = null).
// - 아이템 삭제: "OO님이 '{이름}'을(를) 삭제했어요" 처럼 이름 뒤에 문장이 붙어, 이름이 길면 정작 무슨 일인지가
// OS 절단에 먹힌다. 그래서 이쪽은 캡이 필요하다.
object ItemDisplayName {
// 이름이 없거나 공백뿐일 때. 파싱 전(PENDING·PROCESSING)이나 추출 실패로 name 이 비는 경우가 실재한다.
const val FALLBACK = "상품"

// 표시 글자 수와 무관하게 거는 UTF-16 char 상한. grapheme cluster 1개가 조합 부호로 수십~수백 char 일 수 있어
// "N 글자" 가 곧 짧은 문자열을 뜻하지 않는다. 그대로 두면 Notification 생성자의 require(title.length <= 255)에
// 걸리고, dispatcher 의 수신자별 runCatching 이 그 예외를 삼켜 알림이 전 수신자에게 조용히 누락된다.
// 문구의 나머지 부분(닉네임·고정 문장)을 감안해 넉넉히 남긴다.
private const val MAX_CHARS = 100

private const val ELLIPSIS = "…"
private val WHITESPACE = Regex("\\s+")

/**
* @param maxGraphemes 표시 글자(grapheme cluster) 상한. null 이면 절단하지 않고 char 안전망만 건다.
*/
fun of(
rawName: String?,
maxGraphemes: Int? = null,
): String {
// 공백을 먼저 접는다 — 추출 이름은 상류(ProductSnapshot)에서 trim 되지 않아 앞뒤 공백·개행이 그대로 온다.
// 앞 공백이 글자 예산을 먹어 이름이 일찍 잘리고, 개행이 섞이면 알림·푸시가 두 줄이 된다.
val name = rawName?.replace(WHITESPACE, " ")?.trim()?.takeIf { it.isNotEmpty() } ?: return FALLBACK
maxGraphemes ?: return name.capChars()
return name.truncateGraphemes(maxGraphemes)
}

// 절단 단위는 grapheme cluster(BreakIterator) 다 — String.length·take 는 UTF-16 코드 단위라, 이모지(surrogate
// pair)·ZWJ 시퀀스·조합문자가 경계에 걸치면 반쪽만 남아 깨진 문자로 노출된다. 사용자가 보는 "글자" 로 자른다.
private fun String.truncateGraphemes(limit: Int): String {
val boundary = BreakIterator.getCharacterInstance().apply { setText(this@truncateGraphemes) }
var end = boundary.first()
repeat(limit) {
val next = boundary.next()
if (next == BreakIterator.DONE) return capChars() // 표시 글자 수가 한도 이하 — 자를 필요 없음
end = next
}
// 한도째 경계까지 왔다. 그 뒤에 글자가 더 있으면(다음 경계가 DONE 이 아니면) 잘라서 말줄임표를 붙인다.
return if (boundary.next() == BreakIterator.DONE) capChars() else substring(0, end).capChars() + ELLIPSIS
}

// 마지막 안전망 — grapheme 경계와 무관하게 char 길이를 자른다. 여기 걸리는 입력은 정상 상품명이 아니라
// 조합 부호를 쌓아 만든 비정상 값이라, 글자 깨짐보다 알림 누락을 막는 쪽을 택한다.
private fun String.capChars(): String = if (length <= MAX_CHARS) this else take(MAX_CHARS) + ELLIPSIS
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ import com.depromeet.piki.item.repository.ItemSnapshotRepository
import com.depromeet.piki.notification.domain.NotificationRouting
import com.depromeet.piki.notification.domain.NotificationType
import org.springframework.stereotype.Component
import java.text.BreakIterator
import java.util.UUID

// 아이템 파싱 완료 알림. 위시·토너먼트 어느 쪽으로 올린 아이템이든 동일하게 "{이름} 파싱이 완료되었어요" 를 알린다 —
// 수신자는 snapshotId(버전)를 역조회해 위시 주인 ∪ 토너먼트 참가자로 모은다(ItemParsingRecipientResolver, 실패 알림과 공유).
// 문구에 어떤 아이템인지 담으려고(#895) 그 버전(snapshot)의 name 을 조회해 itemName 변수로 채운다 — 긴 이름은 displayName 이 자른다.
// 아이템 파싱 완료 알림. 수신자는 snapshotId(버전)를 역조회해 위시 주인 ∪ 토너먼트 참가자로 모은다
// (ItemParsingRecipientResolver, 실패 알림과 공유).
//
// 문구는 title=아이템 이름 / body=상태 로 나뉜다(#913). OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, 이름과 상태를 한
// 줄에 담으면 이름이 길 때 정작 무슨 일인지가 사라진다. 이름만 제목에 두면 잘려도 잃는 게 없고, body 는 두 줄까지
// 보이므로 상태 문구가 온전히 남는다. 그래서 이름은 표시 글자 절단을 하지 않는다(char 안전망만).
//
// body 문구는 출처(위시/토너먼트)와 무관하게 하나다. 출처별로 가르려면 수신자마다 달라져야 하는데, dispatcher 는
// 라우팅·문구를 수신자 루프 **밖에서 한 번** 해석해 전원에게 같은 값을 박는다. 한 snapshot 에 위시 주인과
// 토너먼트 등록자가 함께 붙을 수 있어(공유 #825 의 "진행 중 합류"), 그 상태로 가르면 위시 주인이 토너먼트 문구를
// 받는다. 출처별 문구는 수신자별 라우팅 해석과 함께 후속(#933)에서 다룬다.
@Component
class ItemParsingCompletedHandler(
private val recipientResolver: ItemParsingRecipientResolver,
Expand All @@ -22,47 +29,10 @@ class ItemParsingCompletedHandler(

override fun resolveRouting(event: ItemParsingCompleted): NotificationRouting = recipientResolver.resolveRouting(event.snapshotId)

// 문구 변수 itemName — 그 버전(snapshot)의 name 을 조회해 표시용으로 자른다.
// 문구 변수는 itemName(제목) 하나다. body 는 변수 없는 고정 문구라 템플릿이 통째로 소유한다 —
// 백오피스(#252)에서 title·body 둘 다 그대로 편집된다.
// best-effort: 버전이 없거나 이름이 비어도 이름 하나 때문에 알림 전체를 떨구지 않고 기본값을 쓴다
// (토너먼트 알림의 tournamentName fallback 과 같은 결).
override fun resolveActorContext(event: ItemParsingCompleted): ActorContext =
ActorContext(variables = mapOf("itemName" to displayName(itemSnapshotRepository.findById(event.snapshotId)?.name)))

companion object {
const val MAX_NAME_LENGTH = 10

// grapheme 절단 뒤 한 번 더 거는 UTF-16 char 상한. 클러스터 1개가 조합 부호로 수십~수백 char 일 수 있어
// "10글자" 가 곧 짧은 문자열을 뜻하지 않는다. 그대로 두면 Notification 생성자의 require(title.length <= 255)에
// 걸리고, dispatcher 의 수신자별 runCatching 이 그 예외를 삼켜 완료 알림이 전 수신자에게 조용히 누락된다.
// 문구 나머지("… 파싱이 완료되었어요")를 감안해 넉넉히 남긴다.
private const val MAX_NAME_CHARS = 100
private const val ELLIPSIS = "…"
private const val FALLBACK_NAME = "상품"

// 알림·푸시 한 줄을 유지하도록 10자 초과면 앞 10자 + … 로 자른다. 이름이 없거나 공백뿐이면 기본값.
// 절단 단위는 grapheme cluster(BreakIterator) 다 — String.length·take 는 UTF-16 코드 단위라, 이모지(surrogate
// pair)·조합문자가 10번째 경계에 걸치면 반쪽만 남아 깨진 문자로 노출된다(#896 CodeRabbit). 사용자가 보는 "글자"
// 경계로 잘라 깨짐을 막는다.
//
// 공백은 먼저 한 칸으로 접는다 — 추출 이름은 상류(ProductSnapshot)에서 trim 되지 않아 앞뒤 공백·개행이 그대로
// 온다. 앞 공백이 10글자 예산을 먹어 이름이 일찍 잘리고, 개행이 섞이면 "한 줄 유지" 목표 자체가 깨진다.
fun displayName(rawName: String?): String {
val name = rawName?.replace(WHITESPACE, " ")?.trim()?.takeIf { it.isNotEmpty() } ?: return FALLBACK_NAME
val boundary = BreakIterator.getCharacterInstance().apply { setText(name) }
var end = boundary.first()
repeat(MAX_NAME_LENGTH) {
val next = boundary.next()
if (next == BreakIterator.DONE) return name.capChars() // 표시 글자 수가 한도 이하 — 자를 필요 없음
end = next
}
// 한도째 경계까지 왔다. 그 뒤에 글자가 더 있으면(다음 경계가 DONE 이 아니면) 잘라서 말줄임표를 붙인다.
return if (boundary.next() == BreakIterator.DONE) name.capChars() else name.substring(0, end).capChars() + ELLIPSIS
}

// 마지막 안전망 — grapheme 경계와 무관하게 char 길이를 자른다. 여기 걸리는 입력은 정상 상품명이 아니라
// 조합 부호를 쌓아 만든 비정상 값이라, 글자 깨짐보다 알림 누락을 막는 쪽을 택한다.
private fun String.capChars(): String = if (length <= MAX_NAME_CHARS) this else take(MAX_NAME_CHARS) + ELLIPSIS

private val WHITESPACE = Regex("\\s+")
}
ActorContext(variables = mapOf("itemName" to ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name)))
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ class TournamentItemDeletedHandler(

override fun resolveActorContext(event: TournamentItemDeleted): ActorContext {
val base = tournamentVariables.context(event.tournamentId, event.actorId)
val itemName = itemSnapshotRepository.findById(event.snapshotId)?.name ?: FALLBACK_ITEM_NAME
val itemName = ItemDisplayName.of(itemSnapshotRepository.findById(event.snapshotId)?.name, MAX_NAME_LENGTH)
return base.copy(variables = base.variables + ("itemName" to itemName))
}

companion object {
// 삭제 시점에 상품명이 아직 없을 때(파싱 전 PENDING/PROCESSING 아이템 삭제)의 대체 문구.
private const val FALLBACK_ITEM_NAME = "상품"
// 이 문구는 이름 뒤에 "…을(를) 삭제했어요" 가 붙고 앞에는 닉네임이 온다. 이름을 안 자르면 OS 푸시 제목 절단에
// 정작 무슨 일인지가 먹히고, 길면 엔티티 불변식(require(title.length <= 255))까지 걸려 dispatcher 의
// runCatching 이 예외를 삼켜 삭제 알림이 전 수신자에게 조용히 누락된다(상품명은 512자까지 허용된다).
// 파싱 완료 알림은 제목이 이름뿐이라 절단하지 않지만(#913) 이쪽은 캡이 필요하다.
private const val MAX_NAME_LENGTH = 10
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ object NotificationTemplateVariables {
NotificationType.TOURNAMENT_PLAYED_FROM_LINK to TOURNAMENT,
NotificationType.TOURNAMENT_COMPLETED to TOURNAMENT,
NotificationType.TOURNAMENT_RESULT_READY to TOURNAMENT,
// title 만 변수를 쓴다(#913) — body 는 변수 없는 고정 문구라 백오피스가 통째로 편집한다.
NotificationType.ITEM_PARSING_COMPLETED to listOf(TemplateVariable("itemName", "나이키 에어맥스")),
NotificationType.ITEM_PARSING_FAILED to emptyList(),
NotificationType.ANNOUNCEMENT to
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package db.migration

import org.flywaydb.core.api.migration.BaseJavaMigration
import org.flywaydb.core.api.migration.Context

// ITEM_PARSING_COMPLETED 문구를 title=아이템 이름 / body=상태 로 나눈다(#913).
// 전: title "${itemName} 파싱이 완료되었어요" · body ""
// 후: title "${itemName}" · body "파싱이 완료되었어요"
//
// OS 푸시 제목은 줄바꿈 없이 뒤가 잘려서, 이름과 상태를 한 줄에 담으면 이름이 길 때 정작 무슨 일인지가 사라진다.
// body 는 두 줄까지 보이므로 상태를 그쪽으로 옮긴다.
//
// body 는 변수 없는 고정 문구다 — 백오피스(#252)가 title·body 를 그대로 편집한다. 등록 출처(위시/토너먼트)별로
// 문구를 가르는 건 수신자별 라우팅 해석이 먼저라 후속(#933)으로 미뤘다.
//
// 리터럴 dollar-brace(${...})를 SQL 마이그레이션에 두면 Flyway 가 placeholder 로 오인해 파싱이 깨지므로,
// seed(V20260615015148)·직전 변경(V20260806230101)과 같이 JDBC 로 직접 UPDATE 한다.
@Suppress("ClassName")
class V20260811010101__split_item_parsing_completed_template_title_body : BaseJavaMigration() {
override fun migrate(context: Context) {
context.connection
.prepareStatement(
"UPDATE notification_templates SET title_template = ?, body_template = ?, updated_at = NOW(6) " +
"WHERE type = 'ITEM_PARSING_COMPLETED'",
).use { statement ->
statement.setString(1, "\${itemName}")
statement.setString(2, "파싱이 완료되었어요")
statement.executeUpdate()
}
}
}
Loading
Loading