Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
d32ea05
feat: JpaAuditingConfig ์ถ”๊ฐ€
zzuhannn Apr 30, 2026
4602f09
chore: ArchUnit ์˜์กด์„ฑ ์ถ”๊ฐ€
zzuhannn Apr 30, 2026
78384b7
feat: WordEntry ์—”ํ‹ฐํ‹ฐ ์ถ”๊ฐ€
zzuhannn Apr 30, 2026
9532216
feat: WordEntryRepository ์ถ”๊ฐ€
zzuhannn Apr 30, 2026
da8b575
feat: VocabErrorCode ๋ฐ ์˜ˆ์™ธ ํด๋ž˜์Šค ์ถ”๊ฐ€
zzuhannn Apr 30, 2026
89baf44
feat: VocabExtractionResult ์„œ๋น„์Šค DTO ์ถ”๊ฐ€
zzuhannn Apr 30, 2026
84c0e66
refactor: ParagraphRepository์— findByFairytaleIdAndPage ์ถ”๊ฐ€
zzuhannn Apr 30, 2026
8aae78c
refactor: RestTemplateConfig์— vocabRestTemplate ๋นˆ ์ถ”๊ฐ€
zzuhannn Apr 30, 2026
719be99
feat: VocabExtractClient ์ถ”๊ฐ€ (FastAPI /vocab/extract ํ˜ธ์ถœ)
zzuhannn Apr 30, 2026
c940070
feat: VocabService ์ธํ„ฐํŽ˜์ด์Šค ๋ฐ ๊ตฌํ˜„์ฒด ์ถ”๊ฐ€
zzuhannn Apr 30, 2026
1ffe403
feat: VocabController ์ถ”๊ฐ€ (๋‹จ์–ด์žฅ ์กฐํšŒ API)
zzuhannn Apr 30, 2026
b5f0559
feat: InternalApiSecurityConfig ์ถ”๊ฐ€ (dev ํ”„๋กœํ•„ ํ•œ์ •)
zzuhannn Apr 30, 2026
a5d5477
feat: InternalVocabController ์ถ”๊ฐ€ (dev ํ”„๋กœํ•„ ์‹œ์—ฐ์šฉ API)
zzuhannn Apr 30, 2026
8fd9fa3
test: VocabServiceArchTest ์ถ”๊ฐ€ (carrier-agnostic ๊ฒ€์ฆ)
zzuhannn Apr 30, 2026
f3b3a8a
chore: application-dev.properties ์ถ”๊ฐ€ (dev ํ”„๋กœํ•„ ์•ˆ๋‚ด ์ฃผ์„)
zzuhannn Apr 30, 2026
34d4c42
Docs: Spring Kafka ์˜์กด์„ฑ ์ถ”๊ฐ€
Joonseok-Lee May 4, 2026
773b1b4
Feat: ๋™ํ™” ์ƒ์„ฑ ์š”์ฒญ์ด ๋“ค์–ด์˜ฌ ๋•Œ, ํ•ด๋‹น ์š”์ฒญ์„ DTO๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๊ฒŒ๋” ๊ฐ์ฒด ์„ ์–ธ
Joonseok-Lee May 4, 2026
fc45113
Feat: Kafka Producer ์„ค์ •, key - String, value - Json์œผ๋กœ ํŒŒ์‹ฑํ•˜๋„๋ก ํ•จ. ๋˜ํ•œ, Kaโ€ฆ
Joonseok-Lee May 4, 2026
0c56107
Feat: KafkaTemplate์˜ ๋ฉ”์„ธ์ง€ ๊ฐ์ฒด๋ฅผ ๋™์  ๋‹คํ˜•์„ฑ์œผ๋กœ ์„ค๊ณ„ํ•˜๊ธฐ ์œ„ํ•ด MessageInterface ์ธํ„ฐํŽ˜์ด์Šค โ€ฆ
Joonseok-Lee May 4, 2026
838f4ba
Feat: ๋™ํ™” ์ƒ์„ฑ ์š”์ฒญ์„ ๋กœ์ปฌ FastAPI๋กœ ์ „ํŒŒํ•˜๊ธฐ ์œ„ํ•ด ํ•„์š”ํ•œ ๊ฐ’์„ ๋ฉ”์„ธ์ง€๋กœ ์ „ํŒŒํ•˜๋„๋ก ๋ฉ”์„ธ์ง€ ๊ฐ์ฒด ์„ค๊ณ„
Joonseok-Lee May 4, 2026
1fcde03
Feat: Kafka์— ๋ฉ”์„ธ์ง€๋ฅผ ๋“ฑ๋กํ•˜๋„๋ก ํ•˜๋Š” ์„œ๋น„์Šค ๊ฐ์ฒด์™€ ๋™ํ™” ์ƒ์„ฑ ์ด๋ฒคํŠธ๋ฅผ ์ „ํŒŒํ•˜๋„๋ก ํ•˜๋Š” ๋ฉ”์†Œ๋“œ ์ž‘์„ฑ
Joonseok-Lee May 4, 2026
df6059c
Docs: Kafka ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด .env์— Kafka ์‹ค์ œ ํ†ต์‹  ์„œ๋ฒ„ ์ฃผ์†Œ ๊ธฐ๋ก ๋ฐ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํ†ตํ•ด .env๋ฅผ ์ง์ ‘ ์ฝโ€ฆ
Joonseok-Lee May 6, 2026
d228a43
Test: Kafka ํ†ต์‹  ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค, ๋ฉ”์†Œ๋“œ ์ž‘์„ฑ
Joonseok-Lee May 6, 2026
383bcc3
Fix: kakfa ํ…œํ”Œ๋ฆฟ ์ œ๋„ค๋ฆญ์„ ์„ ์–ธํ•œ Bean ํƒ€์ž…๊ณผ ์ผ์น˜ํ•˜๋„๋ก ์ˆ˜์ •
Joonseok-Lee May 6, 2026
60de216
feat: JpaAuditingConfig ์ถ”๊ฐ€
LgE02 May 7, 2026
2a477d3
Docs: Kafka ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด .env ๋‚ด์˜ Kafka ๊ฒฝ๋กœ๋ฅผ ์ง์ ‘ ์ฃผ์ž…๋ฐ›๊ฒŒ ํ•˜๋Š” ์˜์กด์„ฑ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ๋“ฑ๋ก
Joonseok-Lee May 7, 2026
5002fd1
Fix: DI๋ฐ›๋˜ kafkaTemplate์˜ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ FairytaleGenerateReq์—์„œ MessageInterfacโ€ฆ
Joonseok-Lee May 7, 2026
8bab140
Feat: ๋™ํ™” ์ƒ์„ฑ ์š”์ฒญ์ด ๋“ค์–ด์˜ฌ ์‹œ, Kafka์— ๋™ํ™” ์ƒ์„ฑ ๋ฉ”์„ธ์ง€ ๋“ฑ๋กํ•˜๋Š” ์ปจํŠธ๋กค๋Ÿฌ ๋ฉ”์†Œ๋“œ๋ฅผ ๊ตฌํ˜„
Joonseok-Lee May 7, 2026
603562e
Docs: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” import๋ฌธ ์ตœ์ ํ™”
Joonseok-Lee May 7, 2026
848d193
feat: redis ์—ฐ๊ฒฐ ์„ค์ •
LgE02 May 9, 2026
2c256bb
feat: redis ๋„์ปค ์„ค์ •
LgE02 May 9, 2026
9f3f775
Merge remote-tracking branch 'origin/feat/#31' into feat/#34
LgE02 May 9, 2026
881ba16
feat: ๊ธฐ๋ณธ SSEํ™˜๊ฒฝ ๊ตฌ์„ฑ
LgE02 May 9, 2026
3217099
feat: Paragraph ๋„๋ฉ”์ธ์— imageurl ์ปฌ๋Ÿผ ์ถ”๊ฐ€
LgE02 May 9, 2026
46b89de
feat: SSE์—ฐ๊ฒฐ ์ปจํŠธ๋กค๋Ÿฌ
LgE02 May 9, 2026
f394ff7
feat: ๋‹จ์–ด์ฒดํฌ&BothCheck์‹œ ์ „์†ก๋กœ์ง
LgE02 May 9, 2026
48c9bff
feat: ์ „์†กํ›„ redis ๋‚ด์—ญ ์‚ญ์ œ
LgE02 May 9, 2026
589813f
chore: ArchUnit ์˜์กด์„ฑ ๋ฐ VocabServiceArchTest ์ œ๊ฑฐ
zzuhannn May 9, 2026
2fbfce0
refactor: VocabServiceImpl์—์„œ EntityManager ์ œ๊ฑฐํ•˜๊ณ  FairytaleRepository๋กœ ํ†ต์ผ
zzuhannn May 9, 2026
4c3f547
refactor: VocabService.getVocab ๋ฐ˜ํ™˜ ํƒ€์ž…์„ List<WordEntryRes>๋กœ ๋ณ€๊ฒฝ
zzuhannn May 9, 2026
75d504d
feat: InternalVocabProcessReq.pageNo์— @Min(1) ๊ฒ€์ฆ ์ถ”๊ฐ€
zzuhannn May 9, 2026
5271579
merge: feat/#31 ๋ณ‘ํ•ฉ ๋ฐ ์ถฉ๋Œ ํ•ด๊ฒฐ
LgE02 May 9, 2026
f95d643
fix: paragraph์™€ ์ผ๋Œ€๋‹ค ๊ด€๊ณ„ ๋งค์นญ
LgE02 May 10, 2026
adf43e9
fix: 3๋ฌธ์žฅ์€ ํ•œ๋ฒˆ์— ์ €์žฅํ•˜๋ฉด์„œ db์—์„œ ๊บผ๋‚ด๋Š” ๋กœ์ง ์ˆ˜์ •
LgE02 May 10, 2026
f64100d
chore:๋ฌธ์žฅ์„ ๋ฆฌ์ŠคํŠธ๋กœ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์ˆ˜์ •
LgE02 May 10, 2026
6284822
fix: ๋‹จ์–ด์ถ”์ถœ ์–ด๋А๊ฒฝ์šฐ์—์„œ๋„ redis์ฒดํŠธ๋ฅผ ํ•˜๋„๋ก ์ˆ˜์ •
LgE02 May 10, 2026
5882757
ํŒŒ์ผ ์‚ญ์ œ
LgE02 May 10, 2026
67b3f19
Merge pull request #32 from R-Goodday/feat/#31
zzuhannn May 10, 2026
4e39c82
Merge pull request #36 from R-Goodday/feat/#33
zzuhannn May 10, 2026
354bb25
fix: FairytaleController RequestMapping์ด /api ๊ฒฝ๋กœ๋ฅผ ํ•˜๋“œ ํ”ฝ์Šคํ•ด๋‘” ๊ฒƒ์„ ์ˆ˜์ •
Joonseok-Lee May 10, 2026
2a9d5d1
Merge branch 'develop' into feat/#34
LgE02 May 10, 2026
81dc901
Feat: SSE ๊ธฐ๋ฐ˜ ๋™ํ™” ํŽ˜์ด์ง€ ์ŠคํŠธ๋ฆฌ๋ฐ
LgE02 May 10, 2026
5d6f5c0
Merge pull request #38 from R-Goodday/feat/#33
Joonseok-Lee May 10, 2026
3e7e7c8
feat: vocab_extracted ํ† ํ”ฝ์šฉ Consumer ์ „์šฉ DTO ์ถ”๊ฐ€
zzuhannn May 10, 2026
c541178
feat: KafkaConsumerConfig ์ถ”๊ฐ€ ๋ฐ Producer/Consumer ํ”„๋กœํ•„ ๋ถ„๋ฆฌ
zzuhannn May 10, 2026
821cd50
feat: VocabExtractedListener ๋ฐ image-side ํด๋ฐฑ์œผ๋กœ SSE ํ๋ฆ„ ๋ณด์žฅ
zzuhannn May 10, 2026
3c5f228
feat: application-dev.properties ์ถ”๊ฐ€ํ•˜์—ฌ dev ํ”„๋กœํ•„ ๋ถ„๋ฆฌ
zzuhannn May 10, 2026
7956ef2
test: vocab Kafka ๋‹จ์œ„/ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ ๋ฐ broker ์˜์กด ํ…Œ์ŠคํŠธ ๊ฒฉ๋ฆฌ
zzuhannn May 10, 2026
2873844
feat: Consumer ์„ค์ •
LgE02 May 10, 2026
a44d138
feat: Consumer ์ด๋ฏธ์ง€ ์ €์žฅ
LgE02 May 10, 2026
cb29b39
feat: Consumer done ๋กœ์ง
LgE02 May 10, 2026
c2fde46
Feat: ๋™ํ™” ์ „๋ฌธ์„ ์ €์žฅํ•˜๋Š” content ํ•„๋“œ๋ฅผ ์ถ”๊ฐ€
Joonseok-Lee May 10, 2026
9764a53
Feat: Fairytale์„ ์ƒ์„ฑํ•˜๋Š” ๋กœ์ง์„ FastAPI ์„œ๋ฒ„๋กœ ๋ณด๋‚ผ ๋•Œ, ์ƒ์„ฑ๋œ fairytaleId๋ฅผ ์ „๋‹ฌํ•˜๋„๋ก DTโ€ฆ
Joonseok-Lee May 10, 2026
6bf5b8a
Feat: not null ํ•„๋“œ์— ์“ฐ๋ ˆ๊ธฐ๊ฐ’์„ ์ฑ„์›Œ DB์— ์ €์žฅํ•˜๊ณ , ID๊ฐ’์„ DTO์— ๋‹ด์•„ Kafka ๋ฉ”์‹œ์ง€๋กœ ์ž‘์„ฑํ•˜๊ฒŒ ํ•˜๋ฉฐโ€ฆ
Joonseok-Lee May 10, 2026
b9f6202
Feat: ๋™ํ™” ์ƒ์„ฑ ์š”์ฒญ์˜ ์‘๋‹ต ์ค‘, FE๊ฐ€ ๋™ํ™” ์ƒ์„ฑ SSE๋ฅผ ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ๋„๋ก fairytaleId๋ฅผ ์‘๋‹ต data ํ•„โ€ฆ
Joonseok-Lee May 10, 2026
dc0362c
Feat: image&done kafka Consumer ์ถ”๊ฐ€
LgE02 May 10, 2026
ab35361
Merge pull request #44 from R-Goodday/feat/#43
zzuhannn May 10, 2026
25b2926
merge: develop (image-kafka, fairytale ์ง„ํ–‰๋ฅ  ์ถ”์ ) ์ถฉ๋Œ ํ•ด๊ฒฐ
zzuhannn May 10, 2026
913cba5
Merge pull request #45 from R-Goodday/feat/#40
zzuhannn May 10, 2026
c4ce34a
fix: snail_case consumerํ˜•์‹ ์ˆ˜์ •
LgE02 May 10, 2026
c91eeac
fix: dev ์„ค์ • ์‚ญ์ œ
LgE02 May 10, 2026
b099a4f
fix: log ๋ฐ ๋‚ด์šฉ ์ˆ˜์ •
LgE02 May 10, 2026
5bd7824
feat: ๊ธฐ๋ณธ RestTemplate์— connect/read timeout ์„ค์ •
zzuhannn May 14, 2026
e75a55a
refactor: GraphPersister ๋นˆ ๋ถ„๋ฆฌ๋กœ ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„์™€ ์™ธ๋ถ€ I/O ๊ฒฉ๋ฆฌ
zzuhannn May 14, 2026
2ea4919
fix: startGame ๋™๊ธฐ ๊ทธ๋ž˜ํ”„ ์ถ”์ถœ ํด๋ฐฑ ์ œ๊ฑฐ
zzuhannn May 14, 2026
2e76b75
feat: ๊ทธ๋ž˜ํ”„ ์ถ”์ถœ ์ „์šฉ Async Executor ๋นˆ ๋“ฑ๋ก
zzuhannn May 14, 2026
cfaa01e
feat: fairytale_done ์ˆ˜์‹  ์‹œ ๋น„๋™๊ธฐ๋กœ ๊ทธ๋ž˜ํ”„ ์ถ”์ถœ ํŠธ๋ฆฌ๊ฑฐ
zzuhannn May 14, 2026
c42f7d3
feat: ๊ฒŒ์ž„ ์™„๋ฃŒ ์—ฌ๋ถ€ ์กฐํšŒ API ์ถ”๊ฐ€
zzuhannn May 14, 2026
6c0d320
feat: GameForbiddenException ๋ฐ GAME_403_1 ์—๋Ÿฌ์ฝ”๋“œ ์ถ”๊ฐ€
zzuhannn May 14, 2026
3cb1265
refactor: ๊ฒŒ์ž„ ์™„๋ฃŒ ๊ฒ€์ฆ helper ๋ฅผ ์†Œ์œ ๊ถŒ์šฉ/๊ทธ๋ž˜ํ”„์šฉ ๋‘ ๊ฐˆ๋ž˜๋กœ ๋ถ„๋ฆฌ
zzuhannn May 14, 2026
3cbe7b5
fix: game_results ๋™์‹œ INSERT race ํก์ˆ˜
zzuhannn May 14, 2026
6335a1c
refactor: GameSession ์บก์Аํ™” ๋ฐ SessionEdge description ์บ์‹ฑ
zzuhannn May 14, 2026
98b1440
refactor: answerQuiz ์ •๋‹ต ๋ถ„๊ธฐ์—์„œ ์„ธ์…˜ ์บ์‹œ ์‚ฌ์šฉ ๋ฐ import ์ •๋ฆฌ
zzuhannn May 14, 2026
5b092ce
refactor: ๊ทธ๋ž˜ํ”„ ์ถ”์ถœ RuntimeException ์„ ์ปค์Šคํ…€ ์˜ˆ์™ธ๋กœ ๊ต์ฒด
zzuhannn May 14, 2026
9f23e2d
refactor: GameSession wildcard import ๋ช…์‹œ์  import ๋กœ ๊ต์ฒด
zzuhannn May 14, 2026
2479d88
refactor: GameServiceImpl ๋ฉ”์„œ๋“œ javadoc ์Šฌ๋ฆผํ™” ๋ฐ ์ •๋‹ต ๋ถ„๊ธฐ ๋‹จ์ผํ™”
zzuhannn May 14, 2026
777d8ff
Merge pull request #51 from R-Goodday/feat/#49
zzuhannn May 14, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,5 @@ src/main/resources/application.properties

### Claude Code ###
.omc
.claude/

29 changes: 28 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,46 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.kafka:spring-kafka'
testImplementation 'org.springframework.kafka:spring-kafka-test'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.awaitility:awaitility:4.2.2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// ArchUnit
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'

// .env ํŒŒ์ผ ๋กœ๋”ฉ
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
}

tasks.named('test') {
useJUnitPlatform()
useJUnitPlatform {
excludeTags 'kafka-broker'
}
}

tasks.register('kafkaBrokerTest', Test) {
description = 'Run kafka-broker tagged tests (EmbeddedKafka ๋˜๋Š” ์‹ค์ œ broker ์˜์กด)'
group = 'verification'
useJUnitPlatform {
includeTags 'kafka-broker'
}
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
shouldRunAfter test
}
16 changes: 16 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ services:
- "8080"
depends_on:
- fastapi
- redis
networks:
- kkumteul

redis:
image: redis:latest
container_name: redis
restart: always
command: redis-server --appendonly yes
volumes:
- redis-data:/data
expose:
- "6379"
networks:
- kkumteul

Expand Down Expand Up @@ -42,3 +55,6 @@ services:
networks:
kkumteul:
driver: bridge

volumes:
redis-data:
2 changes: 2 additions & 0 deletions src/main/java/com/capstone/kkumteul/KkumteulApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
@EnableCaching
public class KkumteulApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import jakarta.persistence.*;
import lombok.*;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Builder
Expand Down Expand Up @@ -36,6 +39,11 @@ public class Fairytale extends BaseEntity {
@Column(nullable = false)
private Background background;

@Column(columnDefinition = "TEXT")
@Column(columnDefinition = "TEXT", nullable = false)
private String content;

@Builder.Default
@OneToMany(mappedBy = "fairytale", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Paragraph> paragraphs = new ArrayList<>();

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,12 @@ public class Paragraph extends BaseEntity {

@Column(nullable = false, columnDefinition = "TEXT")
private String text;

//nullable ์ œ์•ฝ์€ ์ถ”ํ›„
@Column
private String imageUrl;

public void updateImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import lombok.AllArgsConstructor;
import lombok.Getter;

import static com.capstone.kkumteul.global.constant.StaticValue.*;
import static com.capstone.kkumteul.global.constant.StaticValue.NOT_FOUND;

@Getter
@AllArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@
public interface ParagraphRepository extends JpaRepository<Paragraph, Long> {

List<Paragraph> findByFairytaleIdOrderByPageAsc(Long fairytaleId);

/** ํŠน์ • ํŽ˜์ด์ง€์˜ ๋ฌธ์žฅ๋“ค ์กฐํšŒ โ€” ๋‹จ์–ด์žฅ ์ถ”์ถœ ์‹œ ํŽ˜์ด์ง€ ๋‹จ์œ„ ๋ณธ๋ฌธ ๋กœ๋“œ */
List<Paragraph> findByFairytaleIdAndPage(Long fairytaleId, int page);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.capstone.kkumteul.domain.fairytale.service;

public interface FairytaleCheckService {

void markVocabDone(Long fairytaleId, int page);

void markImageDone(Long fairytaleId, int page);

boolean isBothDone(Long fairytaleId, int page);

void markTotalPages(Long fairytaleId, int totalPages);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.capstone.kkumteul.domain.fairytale.service;

import com.capstone.kkumteul.domain.fairytale.entity.Paragraph;
import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository;
import com.capstone.kkumteul.domain.fairytale.service.sse.SseService;
import com.capstone.kkumteul.domain.fairytale.web.dto.SseEventRes;
import com.capstone.kkumteul.domain.vocab.entity.WordEntry;
import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
public class FairytaleCheckServiceImpl implements FairytaleCheckService {

private final RedisTemplate<String, String> redisTemplate;
private final SseService sseService;
private final WordEntryRepository wordEntryRepository;
private final ParagraphRepository paragraphRepository;

@Value("${vocab.fallback-threshold-seconds:300}")
private long vocabFallbackThresholdSeconds;

private static final String VOCAB_KEY = "vocab:%d:%d";
private static final String IMAGE_KEY = "image:%d:%d";
private static final String TOTAL_KEY = "total:%d";
private static final String SENT_KEY = "sent:%d";
private static final String DONE = "done";

@Override
public void markVocabDone(Long fairytaleId, int page) {
redisTemplate.opsForValue().set(String.format(VOCAB_KEY, fairytaleId, page), DONE);
log.info("[VOCAB DONE] fairytaleId={}, page={}", fairytaleId, page);
checkAndSend(fairytaleId, page);
}

@Override
public void markImageDone(Long fairytaleId, int page) {
redisTemplate.opsForValue().set(String.format(IMAGE_KEY, fairytaleId, page), DONE);
log.info("[IMAGE DONE] fairytaleId={}, page={}", fairytaleId, page);
forceVocabIfStale(fairytaleId, page);
checkAndSend(fairytaleId, page);
}

/**
* image done ์‹œ์ ์— vocab ๋งˆ์ปค๊ฐ€ ์—†๊ณ  paragraph ์ƒ์„ฑ ํ›„ ์ž„๊ณ„ ์ดˆ๊ณผ๋ฉด ๋นˆ vocab์œผ๋กœ ๊ฐ•์ œ mark.
* AI Producer๊ฐ€ vocab_extracted๋ฅผ ๋ˆ„๋ฝํ•œ ๊ฒฝ์šฐ์˜ SSE hang์„ ๋ฐฉ์ง€ํ•œ๋‹ค.
*/
private void forceVocabIfStale(Long fairytaleId, int page) {
String vocabKey = String.format(VOCAB_KEY, fairytaleId, page);
if (redisTemplate.opsForValue().get(vocabKey) != null) return;

List<Paragraph> paragraphs = paragraphRepository.findByFairytaleIdAndPage(fairytaleId, page);
if (paragraphs.isEmpty()) return;

LocalDateTime created = paragraphs.getFirst().getCreatedAt();
if (created == null) return;
long ageSeconds = Duration.between(created, LocalDateTime.now()).getSeconds();
if (ageSeconds < vocabFallbackThresholdSeconds) return;

log.warn("vocab fallback fired fairytaleId={}, page={}, ageSeconds={}", fairytaleId, page, ageSeconds);
redisTemplate.opsForValue().set(vocabKey, DONE);
}

@Override
public boolean isBothDone(Long fairytaleId, int page) {
String vocabStatus = redisTemplate.opsForValue().get(String.format(VOCAB_KEY, fairytaleId, page));
String imageStatus = redisTemplate.opsForValue().get(String.format(IMAGE_KEY, fairytaleId, page));
return DONE.equals(vocabStatus) && DONE.equals(imageStatus);
}

//sse์ „์†ก
private void checkAndSend(Long fairytaleId, int page) {
boolean both = isBothDone(fairytaleId, page);
log.info("[CHECK] fairytaleId={}, page={}, isBothDone={}", fairytaleId, page, both);
if (!both) return;

Optional<WordEntry> wordEntry = wordEntryRepository.findByFairytaleIdAndPageNo(fairytaleId, page);
List<Paragraph> paragraphs = paragraphRepository.findByFairytaleIdAndPage(fairytaleId, page);

if (paragraphs.isEmpty()) {
sseService.sendToClient(fairytaleId, "error", "๋ฌธ๋‹จ ๋ฐ์ดํ„ฐ ์—†์Œ");
log.warn("SSE ๋ฐœ์†ก ์‹คํŒจ - ๋ฌธ๋‹จ ์—†์Œ fairytaleId={}, page={}", fairytaleId, page);
return;
}

Paragraph paragraph = paragraphs.getFirst();
List<String> sentences = List.of(paragraph.getText().split("\n"));
SseEventRes.Vocabulary vocab = wordEntry
.map(w -> new SseEventRes.Vocabulary(w.getWord(), w.getMeaning()))
.orElse(null);

SseEventRes event = new SseEventRes(
fairytaleId,
page,
sentences,
vocab,
paragraph.getImageUrl()
);

log.info("[PAGE_CONTENT SEND] fairytaleId={}, page={}", fairytaleId, page);
sseService.sendToClient(fairytaleId, "page_content", event);

redisTemplate.delete(String.format(VOCAB_KEY, fairytaleId, page));
redisTemplate.delete(String.format(IMAGE_KEY, fairytaleId, page));

Long sent = redisTemplate.opsForValue().increment(String.format(SENT_KEY, fairytaleId));
log.info("[SENT COUNT] fairytaleId={}, sent={}", fairytaleId, sent);
checkAndSendDone(fairytaleId, sent);
}

@Override
public void markTotalPages(Long fairytaleId, int totalPages) {
redisTemplate.opsForValue().set(String.format(TOTAL_KEY, fairytaleId), String.valueOf(totalPages));
log.info("[TOTAL SET] fairytaleId={}, totalPages={}", fairytaleId, totalPages);
String sentStr = redisTemplate.opsForValue().get(String.format(SENT_KEY, fairytaleId));
long sent = sentStr == null ? 0L : Long.parseLong(sentStr);
checkAndSendDone(fairytaleId, sent);
}

private void checkAndSendDone(Long fairytaleId, Long sent) {
String totalStr = redisTemplate.opsForValue().get(String.format(TOTAL_KEY, fairytaleId));
if (totalStr == null) return;

if (sent >= Long.parseLong(totalStr)) {
sseService.sendToClient(fairytaleId, "done", String.valueOf(fairytaleId));
redisTemplate.delete(String.format(TOTAL_KEY, fairytaleId));
redisTemplate.delete(String.format(SENT_KEY, fairytaleId));
log.info("SSE done ์ „์†ก fairytaleId={}", fairytaleId);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.capstone.kkumteul.domain.fairytale.service.sse;

import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

public interface SseService {
SseEmitter subscribe(Long fairytaleId);
void sendToClient(Long fairytaleId, String eventName, Object data);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.capstone.kkumteul.domain.fairytale.service.sse;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Service
public class SseServiceImpl implements SseService {
private final Map<Long, SseEmitter> sseEmittersMap = new ConcurrentHashMap<>();

public SseEmitter subscribe(Long fairytaleId) {
long timeout = 1000L * 60 * 60;

SseEmitter emitter = new SseEmitter(timeout);
sseEmittersMap.put(fairytaleId, emitter);

emitter.onCompletion(() -> sseEmittersMap.remove(fairytaleId)); //complete์‹œ ์ฝœ๋ฐฑํ•จ์ˆ˜
emitter.onTimeout(() -> sseEmittersMap.remove(fairytaleId)); //ํƒ€์ž„์•„์›ƒ์‹œ ์‚ญ์ œ
emitter.onError(e -> {
log.error("SSE ์—๋Ÿฌ fairytaleId={}", fairytaleId, e);
sseEmittersMap.remove(fairytaleId);
}); //์ „์†ก์ค‘ ์—๋Ÿฌ์‹œ ์‚ญ์ œ

//์—ฐ๊ฒฐ ์„ฑ๊ณต์‹œ
sendToClient(fairytaleId, "connect", "sse connect...");

return emitter;
}

public void sendToClient(Long fairytaleId, String eventName, Object data) {
SseEmitter emitter = sseEmittersMap.get(fairytaleId);
if (emitter == null) return;
try {
emitter.send(SseEmitter.event()
.name(eventName)
.data(data));
if ("done".equals(eventName)) {
emitter.complete(); //sse ์ŠคํŠธ๋ฆผ ์ข…๋ฃŒ
}
} catch (IOException e) {
log.error("SSE ์ „์†ก ์‹คํŒจ fairytaleId={}", fairytaleId, e);
sseEmittersMap.remove(fairytaleId);
}
}
}
Loading
Loading