Merge branch 'main' of https://github.com/hwanny1128/HGZero into feat/meeting

This commit is contained in:
cyjadela 2025-10-24 15:46:22 +09:00
commit 6db366ac86
68 changed files with 11966 additions and 5321 deletions

11
.gitignore vendored
View File

@ -1,8 +1,12 @@
# Build outputs # Build outputs
build/ build/*
build/*/*
build/*/*/*
**/build/ **/build/
.gradle/ .gradle/
**/.gradle/ **/.gradle/
.vscode/
**/.vscode/
# Serena # Serena
serena/ serena/
@ -37,3 +41,8 @@ examples/
# Claude settings # Claude settings
.claude/settings.local.json .claude/settings.local.json
# Backup files
design/*/*backup.md
design/*backup.md
backup/

View File

@ -296,3 +296,485 @@ This generated password is for development use only. Your security configuration
2025-10-23 16:27:13 [SpringApplicationShutdownHook] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Closing JPA EntityManagerFactory for persistence unit 'default' 2025-10-23 16:27:13 [SpringApplicationShutdownHook] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Closing JPA EntityManagerFactory for persistence unit 'default'
2025-10-23 16:27:13 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... 2025-10-23 16:27:13 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated...
2025-10-23 16:27:13 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. 2025-10-23 16:27:13 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed.
2025-10-23 17:10:12 [main] INFO com.unicorn.hgzero.ai.AiApplication - Starting AiApplication using Java 23.0.2 with PID 43825 (/Users/jominseo/HGZero/ai/build/classes/java/main started by jominseo in /Users/jominseo/HGZero/ai)
2025-10-23 17:10:12 [main] DEBUG com.unicorn.hgzero.ai.AiApplication - Running with Spring Boot v3.3.0, Spring v6.1.8
2025-10-23 17:10:12 [main] INFO com.unicorn.hgzero.ai.AiApplication - The following 1 profile is active: "dev"
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 3 ms. Found 0 JPA repository interfaces.
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode.
2025-10-23 17:10:12 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 0 ms. Found 0 Redis repository interfaces.
2025-10-23 17:10:13 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8083 (http)
2025-10-23 17:10:13 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
2025-10-23 17:10:13 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.24]
2025-10-23 17:10:13 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
2025-10-23 17:10:13 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 668 ms
2025-10-23 17:10:13 [main] INFO o.h.jpa.internal.util.LogHelper - HHH000204: Processing PersistenceUnitInfo [name: default]
2025-10-23 17:10:13 [main] INFO org.hibernate.Version - HHH000412: Hibernate ORM core version 6.5.2.Final
2025-10-23 17:10:13 [main] INFO o.h.c.i.RegionFactoryInitiator - HHH000026: Second-level cache disabled
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@66716959
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@66716959
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Boolean -> org.hibernate.type.BasicTypeReference@66716959
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration numeric_boolean -> org.hibernate.type.BasicTypeReference@34e07e65
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.NumericBooleanConverter -> org.hibernate.type.BasicTypeReference@34e07e65
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration true_false -> org.hibernate.type.BasicTypeReference@7ca0166c
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.TrueFalseConverter -> org.hibernate.type.BasicTypeReference@7ca0166c
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration yes_no -> org.hibernate.type.BasicTypeReference@1dcad16f
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.YesNoConverter -> org.hibernate.type.BasicTypeReference@1dcad16f
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@701c482e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@701c482e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Byte -> org.hibernate.type.BasicTypeReference@701c482e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary -> org.hibernate.type.BasicTypeReference@4738131e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte[] -> org.hibernate.type.BasicTypeReference@4738131e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [B -> org.hibernate.type.BasicTypeReference@4738131e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary_wrapper -> org.hibernate.type.BasicTypeReference@3b576ee3
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-binary -> org.hibernate.type.BasicTypeReference@3b576ee3
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration image -> org.hibernate.type.BasicTypeReference@705d914f
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration blob -> org.hibernate.type.BasicTypeReference@6212ea52
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Blob -> org.hibernate.type.BasicTypeReference@6212ea52
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob -> org.hibernate.type.BasicTypeReference@65b5b5ed
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob_wrapper -> org.hibernate.type.BasicTypeReference@6595ffce
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@795eddda
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@795eddda
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Short -> org.hibernate.type.BasicTypeReference@795eddda
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration integer -> org.hibernate.type.BasicTypeReference@c6bf8d9
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration int -> org.hibernate.type.BasicTypeReference@c6bf8d9
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Integer -> org.hibernate.type.BasicTypeReference@c6bf8d9
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@44392e64
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@44392e64
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Long -> org.hibernate.type.BasicTypeReference@44392e64
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@e18d2a2
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@e18d2a2
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Float -> org.hibernate.type.BasicTypeReference@e18d2a2
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@1a77eb6
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@1a77eb6
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Double -> org.hibernate.type.BasicTypeReference@1a77eb6
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_integer -> org.hibernate.type.BasicTypeReference@52d9f36b
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigInteger -> org.hibernate.type.BasicTypeReference@52d9f36b
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_decimal -> org.hibernate.type.BasicTypeReference@5f9ebd5a
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigDecimal -> org.hibernate.type.BasicTypeReference@5f9ebd5a
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character -> org.hibernate.type.BasicTypeReference@175bf9c9
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char -> org.hibernate.type.BasicTypeReference@175bf9c9
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Character -> org.hibernate.type.BasicTypeReference@175bf9c9
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character_nchar -> org.hibernate.type.BasicTypeReference@2db3675a
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration string -> org.hibernate.type.BasicTypeReference@306c9b2c
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.String -> org.hibernate.type.BasicTypeReference@306c9b2c
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nstring -> org.hibernate.type.BasicTypeReference@1ab28416
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration characters -> org.hibernate.type.BasicTypeReference@52efb338
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char[] -> org.hibernate.type.BasicTypeReference@52efb338
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [C -> org.hibernate.type.BasicTypeReference@52efb338
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-characters -> org.hibernate.type.BasicTypeReference@64508788
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration text -> org.hibernate.type.BasicTypeReference@30b1c5d5
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ntext -> org.hibernate.type.BasicTypeReference@3e2d65e1
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration clob -> org.hibernate.type.BasicTypeReference@1174676f
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Clob -> org.hibernate.type.BasicTypeReference@1174676f
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nclob -> org.hibernate.type.BasicTypeReference@71f8ce0e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.NClob -> org.hibernate.type.BasicTypeReference@71f8ce0e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob -> org.hibernate.type.BasicTypeReference@4fd92289
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_char_array -> org.hibernate.type.BasicTypeReference@1a8e44fe
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_character_array -> org.hibernate.type.BasicTypeReference@287317df
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob -> org.hibernate.type.BasicTypeReference@1fcc3461
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_character_array -> org.hibernate.type.BasicTypeReference@1987807b
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_char_array -> org.hibernate.type.BasicTypeReference@71469e01
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Duration -> org.hibernate.type.BasicTypeReference@41bbb219
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Duration -> org.hibernate.type.BasicTypeReference@41bbb219
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDateTime -> org.hibernate.type.BasicTypeReference@3f2ae973
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDateTime -> org.hibernate.type.BasicTypeReference@3f2ae973
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDate -> org.hibernate.type.BasicTypeReference@1a8b22b5
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDate -> org.hibernate.type.BasicTypeReference@1a8b22b5
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalTime -> org.hibernate.type.BasicTypeReference@5f781173
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalTime -> org.hibernate.type.BasicTypeReference@5f781173
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTime -> org.hibernate.type.BasicTypeReference@43cf5bff
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetDateTime -> org.hibernate.type.BasicTypeReference@43cf5bff
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@2b464384
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@681b42d3
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTime -> org.hibernate.type.BasicTypeReference@77f7352a
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetTime -> org.hibernate.type.BasicTypeReference@77f7352a
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeUtc -> org.hibernate.type.BasicTypeReference@4ede8888
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithTimezone -> org.hibernate.type.BasicTypeReference@571db8b4
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@65a2755e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTime -> org.hibernate.type.BasicTypeReference@2b3242a5
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZonedDateTime -> org.hibernate.type.BasicTypeReference@2b3242a5
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@11120583
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@2bf0c70d
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration date -> org.hibernate.type.BasicTypeReference@5d8e4fa8
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Date -> org.hibernate.type.BasicTypeReference@5d8e4fa8
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration time -> org.hibernate.type.BasicTypeReference@649009d6
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Time -> org.hibernate.type.BasicTypeReference@649009d6
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timestamp -> org.hibernate.type.BasicTypeReference@652f26da
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Timestamp -> org.hibernate.type.BasicTypeReference@652f26da
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Date -> org.hibernate.type.BasicTypeReference@652f26da
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar -> org.hibernate.type.BasicTypeReference@484a5ddd
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Calendar -> org.hibernate.type.BasicTypeReference@484a5ddd
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.GregorianCalendar -> org.hibernate.type.BasicTypeReference@484a5ddd
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_date -> org.hibernate.type.BasicTypeReference@6796a873
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_time -> org.hibernate.type.BasicTypeReference@3acc3ee
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration instant -> org.hibernate.type.BasicTypeReference@1f293cb7
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Instant -> org.hibernate.type.BasicTypeReference@1f293cb7
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid -> org.hibernate.type.BasicTypeReference@5972e3a
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.UUID -> org.hibernate.type.BasicTypeReference@5972e3a
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration pg-uuid -> org.hibernate.type.BasicTypeReference@5972e3a
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-binary -> org.hibernate.type.BasicTypeReference@5790cbcb
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-char -> org.hibernate.type.BasicTypeReference@32c6d164
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration class -> org.hibernate.type.BasicTypeReference@645c9f0f
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Class -> org.hibernate.type.BasicTypeReference@645c9f0f
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration currency -> org.hibernate.type.BasicTypeReference@58068b40
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Currency -> org.hibernate.type.BasicTypeReference@58068b40
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Currency -> org.hibernate.type.BasicTypeReference@58068b40
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration locale -> org.hibernate.type.BasicTypeReference@999cd18
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Locale -> org.hibernate.type.BasicTypeReference@999cd18
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration serializable -> org.hibernate.type.BasicTypeReference@dd060be
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.io.Serializable -> org.hibernate.type.BasicTypeReference@dd060be
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timezone -> org.hibernate.type.BasicTypeReference@df432ec
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.TimeZone -> org.hibernate.type.BasicTypeReference@df432ec
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZoneOffset -> org.hibernate.type.BasicTypeReference@6144e499
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZoneOffset -> org.hibernate.type.BasicTypeReference@6144e499
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration url -> org.hibernate.type.BasicTypeReference@26f204a4
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.net.URL -> org.hibernate.type.BasicTypeReference@26f204a4
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration vector -> org.hibernate.type.BasicTypeReference@28295554
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration row_version -> org.hibernate.type.BasicTypeReference@4e671ef
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration object -> org.hibernate.type.JavaObjectType@2aac6fa7
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Object -> org.hibernate.type.JavaObjectType@2aac6fa7
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration null -> org.hibernate.type.NullType@2358443e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_date -> org.hibernate.type.BasicTypeReference@25e796fe
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_time -> org.hibernate.type.BasicTypeReference@29ba63f0
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_timestamp -> org.hibernate.type.BasicTypeReference@4822ab4d
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar -> org.hibernate.type.BasicTypeReference@516b84d1
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_date -> org.hibernate.type.BasicTypeReference@1ad1f167
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_time -> org.hibernate.type.BasicTypeReference@608eb42e
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_binary -> org.hibernate.type.BasicTypeReference@3d2b13b1
2025-10-23 17:10:13 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_serializable -> org.hibernate.type.BasicTypeReference@30eb55c9
2025-10-23 17:10:13 [main] INFO o.s.o.j.p.SpringPersistenceUnitInfo - No LoadTimeWeaver setup: ignoring JPA class transformer
2025-10-23 17:10:13 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2025-10-23 17:10:14 [main] WARN o.h.e.j.e.i.JdbcEnvironmentInitiator - HHH000342: Could not obtain connection to query metadata
java.lang.NullPointerException: Cannot invoke "org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(java.sql.SQLException, String)" because the return value of "org.hibernate.resource.transaction.backend.jdbc.internal.JdbcIsolationDelegate.sqlExceptionHelper()" is null
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcIsolationDelegate.delegateWork(JdbcIsolationDelegate.java:116)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentUsingJdbcMetadata(JdbcEnvironmentInitiator.java:290)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:123)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:77)
at org.hibernate.boot.registry.internal.StandardServiceRegistryImpl.initiateService(StandardServiceRegistryImpl.java:130)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:263)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.initializeService(AbstractServiceRegistryImpl.java:238)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.getService(AbstractServiceRegistryImpl.java:215)
at org.hibernate.boot.model.relational.Database.<init>(Database.java:45)
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.getDatabase(InFlightMetadataCollectorImpl.java:221)
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.<init>(InFlightMetadataCollectorImpl.java:189)
at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:171)
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1431)
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1502)
at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:75)
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:390)
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:366)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1835)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1784)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:205)
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:952)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:624)
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
at com.unicorn.hgzero.ai.AiApplication.main(AiApplication.java:20)
2025-10-23 17:10:14 [main] ERROR o.s.o.j.LocalContainerEntityManagerFactoryBean - Failed to initialize JPA EntityManagerFactory: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
2025-10-23 17:10:14 [main] WARN o.s.b.w.s.c.AnnotationConfigServletWebServerApplicationContext - Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
2025-10-23 17:10:14 [main] INFO o.a.catalina.core.StandardService - Stopping service [Tomcat]
2025-10-23 17:10:14 [main] INFO o.s.b.a.l.ConditionEvaluationReportLogger -
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2025-10-23 17:10:14 [main] ERROR o.s.boot.SpringApplication - Application run failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1788)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:205)
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:952)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:624)
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
at com.unicorn.hgzero.ai.AiApplication.main(AiApplication.java:20)
Caused by: org.hibernate.service.spi.ServiceException: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:276)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.initializeService(AbstractServiceRegistryImpl.java:238)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.getService(AbstractServiceRegistryImpl.java:215)
at org.hibernate.boot.model.relational.Database.<init>(Database.java:45)
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.getDatabase(InFlightMetadataCollectorImpl.java:221)
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.<init>(InFlightMetadataCollectorImpl.java:189)
at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:171)
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1431)
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1502)
at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:75)
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:390)
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:366)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1835)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1784)
... 15 common frames omitted
Caused by: org.hibernate.HibernateException: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.determineDialect(DialectFactoryImpl.java:191)
at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.buildDialect(DialectFactoryImpl.java:87)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentWithDefaults(JdbcEnvironmentInitiator.java:152)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentUsingJdbcMetadata(JdbcEnvironmentInitiator.java:362)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:123)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:77)
at org.hibernate.boot.registry.internal.StandardServiceRegistryImpl.initiateService(StandardServiceRegistryImpl.java:130)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:263)
... 30 common frames omitted
2025-10-23 17:38:09 [main] INFO com.unicorn.hgzero.ai.AiApplication - Starting AiApplication using Java 23.0.2 with PID 49971 (/Users/jominseo/HGZero/ai/build/classes/java/main started by jominseo in /Users/jominseo/HGZero/ai)
2025-10-23 17:38:09 [main] DEBUG com.unicorn.hgzero.ai.AiApplication - Running with Spring Boot v3.3.0, Spring v6.1.8
2025-10-23 17:38:09 [main] INFO com.unicorn.hgzero.ai.AiApplication - The following 1 profile is active: "dev"
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 4 ms. Found 0 JPA repository interfaces.
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode.
2025-10-23 17:38:09 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 0 ms. Found 0 Redis repository interfaces.
2025-10-23 17:38:09 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8083 (http)
2025-10-23 17:38:09 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
2025-10-23 17:38:09 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.24]
2025-10-23 17:38:09 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
2025-10-23 17:38:09 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 679 ms
2025-10-23 17:38:10 [main] INFO o.h.jpa.internal.util.LogHelper - HHH000204: Processing PersistenceUnitInfo [name: default]
2025-10-23 17:38:10 [main] INFO org.hibernate.Version - HHH000412: Hibernate ORM core version 6.5.2.Final
2025-10-23 17:38:10 [main] INFO o.h.c.i.RegionFactoryInitiator - HHH000026: Second-level cache disabled
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@306c9b2c
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@306c9b2c
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Boolean -> org.hibernate.type.BasicTypeReference@306c9b2c
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration numeric_boolean -> org.hibernate.type.BasicTypeReference@1ab28416
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.NumericBooleanConverter -> org.hibernate.type.BasicTypeReference@1ab28416
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration true_false -> org.hibernate.type.BasicTypeReference@52efb338
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.TrueFalseConverter -> org.hibernate.type.BasicTypeReference@52efb338
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration yes_no -> org.hibernate.type.BasicTypeReference@64508788
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.YesNoConverter -> org.hibernate.type.BasicTypeReference@64508788
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@30b1c5d5
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@30b1c5d5
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Byte -> org.hibernate.type.BasicTypeReference@30b1c5d5
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary -> org.hibernate.type.BasicTypeReference@3e2d65e1
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte[] -> org.hibernate.type.BasicTypeReference@3e2d65e1
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [B -> org.hibernate.type.BasicTypeReference@3e2d65e1
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary_wrapper -> org.hibernate.type.BasicTypeReference@1174676f
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-binary -> org.hibernate.type.BasicTypeReference@1174676f
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration image -> org.hibernate.type.BasicTypeReference@71f8ce0e
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration blob -> org.hibernate.type.BasicTypeReference@4fd92289
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Blob -> org.hibernate.type.BasicTypeReference@4fd92289
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob -> org.hibernate.type.BasicTypeReference@1a8e44fe
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob_wrapper -> org.hibernate.type.BasicTypeReference@287317df
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@1fcc3461
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@1fcc3461
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Short -> org.hibernate.type.BasicTypeReference@1fcc3461
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration integer -> org.hibernate.type.BasicTypeReference@1987807b
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration int -> org.hibernate.type.BasicTypeReference@1987807b
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Integer -> org.hibernate.type.BasicTypeReference@1987807b
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@71469e01
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@71469e01
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Long -> org.hibernate.type.BasicTypeReference@71469e01
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@41bbb219
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@41bbb219
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Float -> org.hibernate.type.BasicTypeReference@41bbb219
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@3f2ae973
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@3f2ae973
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Double -> org.hibernate.type.BasicTypeReference@3f2ae973
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_integer -> org.hibernate.type.BasicTypeReference@1a8b22b5
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigInteger -> org.hibernate.type.BasicTypeReference@1a8b22b5
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_decimal -> org.hibernate.type.BasicTypeReference@5f781173
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigDecimal -> org.hibernate.type.BasicTypeReference@5f781173
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character -> org.hibernate.type.BasicTypeReference@43cf5bff
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char -> org.hibernate.type.BasicTypeReference@43cf5bff
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Character -> org.hibernate.type.BasicTypeReference@43cf5bff
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character_nchar -> org.hibernate.type.BasicTypeReference@2b464384
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration string -> org.hibernate.type.BasicTypeReference@681b42d3
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.String -> org.hibernate.type.BasicTypeReference@681b42d3
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nstring -> org.hibernate.type.BasicTypeReference@77f7352a
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration characters -> org.hibernate.type.BasicTypeReference@4ede8888
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char[] -> org.hibernate.type.BasicTypeReference@4ede8888
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [C -> org.hibernate.type.BasicTypeReference@4ede8888
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-characters -> org.hibernate.type.BasicTypeReference@571db8b4
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration text -> org.hibernate.type.BasicTypeReference@65a2755e
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ntext -> org.hibernate.type.BasicTypeReference@2b3242a5
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration clob -> org.hibernate.type.BasicTypeReference@11120583
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Clob -> org.hibernate.type.BasicTypeReference@11120583
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nclob -> org.hibernate.type.BasicTypeReference@2bf0c70d
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.NClob -> org.hibernate.type.BasicTypeReference@2bf0c70d
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob -> org.hibernate.type.BasicTypeReference@5d8e4fa8
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_char_array -> org.hibernate.type.BasicTypeReference@649009d6
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_character_array -> org.hibernate.type.BasicTypeReference@652f26da
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob -> org.hibernate.type.BasicTypeReference@484a5ddd
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_character_array -> org.hibernate.type.BasicTypeReference@6796a873
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_char_array -> org.hibernate.type.BasicTypeReference@3acc3ee
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Duration -> org.hibernate.type.BasicTypeReference@1f293cb7
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Duration -> org.hibernate.type.BasicTypeReference@1f293cb7
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDateTime -> org.hibernate.type.BasicTypeReference@5972e3a
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDateTime -> org.hibernate.type.BasicTypeReference@5972e3a
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDate -> org.hibernate.type.BasicTypeReference@5790cbcb
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDate -> org.hibernate.type.BasicTypeReference@5790cbcb
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalTime -> org.hibernate.type.BasicTypeReference@32c6d164
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalTime -> org.hibernate.type.BasicTypeReference@32c6d164
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTime -> org.hibernate.type.BasicTypeReference@645c9f0f
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetDateTime -> org.hibernate.type.BasicTypeReference@645c9f0f
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@58068b40
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@999cd18
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTime -> org.hibernate.type.BasicTypeReference@dd060be
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetTime -> org.hibernate.type.BasicTypeReference@dd060be
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeUtc -> org.hibernate.type.BasicTypeReference@df432ec
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithTimezone -> org.hibernate.type.BasicTypeReference@6144e499
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@26f204a4
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTime -> org.hibernate.type.BasicTypeReference@28295554
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZonedDateTime -> org.hibernate.type.BasicTypeReference@28295554
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@4e671ef
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@42403dc6
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration date -> org.hibernate.type.BasicTypeReference@74a1d60e
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Date -> org.hibernate.type.BasicTypeReference@74a1d60e
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration time -> org.hibernate.type.BasicTypeReference@16c0be3b
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Time -> org.hibernate.type.BasicTypeReference@16c0be3b
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timestamp -> org.hibernate.type.BasicTypeReference@219edc05
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Timestamp -> org.hibernate.type.BasicTypeReference@219edc05
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Date -> org.hibernate.type.BasicTypeReference@219edc05
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar -> org.hibernate.type.BasicTypeReference@62f37bfd
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Calendar -> org.hibernate.type.BasicTypeReference@62f37bfd
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.GregorianCalendar -> org.hibernate.type.BasicTypeReference@62f37bfd
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_date -> org.hibernate.type.BasicTypeReference@1818d00b
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_time -> org.hibernate.type.BasicTypeReference@b3a8455
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration instant -> org.hibernate.type.BasicTypeReference@5c930fc3
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Instant -> org.hibernate.type.BasicTypeReference@5c930fc3
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid -> org.hibernate.type.BasicTypeReference@25c6ab3f
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.UUID -> org.hibernate.type.BasicTypeReference@25c6ab3f
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration pg-uuid -> org.hibernate.type.BasicTypeReference@25c6ab3f
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-binary -> org.hibernate.type.BasicTypeReference@7b80af04
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-char -> org.hibernate.type.BasicTypeReference@2447940d
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration class -> org.hibernate.type.BasicTypeReference@60ee7a51
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Class -> org.hibernate.type.BasicTypeReference@60ee7a51
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration currency -> org.hibernate.type.BasicTypeReference@70e1aa20
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Currency -> org.hibernate.type.BasicTypeReference@70e1aa20
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Currency -> org.hibernate.type.BasicTypeReference@70e1aa20
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration locale -> org.hibernate.type.BasicTypeReference@e67d3b7
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Locale -> org.hibernate.type.BasicTypeReference@e67d3b7
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration serializable -> org.hibernate.type.BasicTypeReference@1618c98a
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.io.Serializable -> org.hibernate.type.BasicTypeReference@1618c98a
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timezone -> org.hibernate.type.BasicTypeReference@5b715ea
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.TimeZone -> org.hibernate.type.BasicTypeReference@5b715ea
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZoneOffset -> org.hibernate.type.BasicTypeReference@787a0fd6
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZoneOffset -> org.hibernate.type.BasicTypeReference@787a0fd6
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration url -> org.hibernate.type.BasicTypeReference@48b09105
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.net.URL -> org.hibernate.type.BasicTypeReference@48b09105
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration vector -> org.hibernate.type.BasicTypeReference@18b45500
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration row_version -> org.hibernate.type.BasicTypeReference@25110bb9
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration object -> org.hibernate.type.JavaObjectType@30eb55c9
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Object -> org.hibernate.type.JavaObjectType@30eb55c9
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration null -> org.hibernate.type.NullType@5badeda0
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_date -> org.hibernate.type.BasicTypeReference@56a9a7b5
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_time -> org.hibernate.type.BasicTypeReference@338270ea
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_timestamp -> org.hibernate.type.BasicTypeReference@7f64bd7
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar -> org.hibernate.type.BasicTypeReference@1c79d093
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_date -> org.hibernate.type.BasicTypeReference@746fd19b
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_time -> org.hibernate.type.BasicTypeReference@54caeadc
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_binary -> org.hibernate.type.BasicTypeReference@61d7bb61
2025-10-23 17:38:10 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_serializable -> org.hibernate.type.BasicTypeReference@33f81280
2025-10-23 17:38:10 [main] INFO o.s.o.j.p.SpringPersistenceUnitInfo - No LoadTimeWeaver setup: ignoring JPA class transformer
2025-10-23 17:38:10 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2025-10-23 17:38:11 [main] WARN o.h.e.j.e.i.JdbcEnvironmentInitiator - HHH000342: Could not obtain connection to query metadata
java.lang.NullPointerException: Cannot invoke "org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(java.sql.SQLException, String)" because the return value of "org.hibernate.resource.transaction.backend.jdbc.internal.JdbcIsolationDelegate.sqlExceptionHelper()" is null
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcIsolationDelegate.delegateWork(JdbcIsolationDelegate.java:116)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentUsingJdbcMetadata(JdbcEnvironmentInitiator.java:290)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:123)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:77)
at org.hibernate.boot.registry.internal.StandardServiceRegistryImpl.initiateService(StandardServiceRegistryImpl.java:130)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:263)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.initializeService(AbstractServiceRegistryImpl.java:238)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.getService(AbstractServiceRegistryImpl.java:215)
at org.hibernate.boot.model.relational.Database.<init>(Database.java:45)
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.getDatabase(InFlightMetadataCollectorImpl.java:221)
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.<init>(InFlightMetadataCollectorImpl.java:189)
at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:171)
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1431)
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1502)
at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:75)
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:390)
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:366)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1835)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1784)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:205)
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:952)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:624)
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
at com.unicorn.hgzero.ai.AiApplication.main(AiApplication.java:20)
2025-10-23 17:38:11 [main] ERROR o.s.o.j.LocalContainerEntityManagerFactoryBean - Failed to initialize JPA EntityManagerFactory: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
2025-10-23 17:38:11 [main] WARN o.s.b.w.s.c.AnnotationConfigServletWebServerApplicationContext - Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
2025-10-23 17:38:11 [main] INFO o.a.catalina.core.StandardService - Stopping service [Tomcat]
2025-10-23 17:38:11 [main] INFO o.s.b.a.l.ConditionEvaluationReportLogger -
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2025-10-23 17:38:11 [main] ERROR o.s.boot.SpringApplication - Application run failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1788)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:205)
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:952)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:624)
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
at com.unicorn.hgzero.ai.AiApplication.main(AiApplication.java:20)
Caused by: org.hibernate.service.spi.ServiceException: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:276)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.initializeService(AbstractServiceRegistryImpl.java:238)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.getService(AbstractServiceRegistryImpl.java:215)
at org.hibernate.boot.model.relational.Database.<init>(Database.java:45)
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.getDatabase(InFlightMetadataCollectorImpl.java:221)
at org.hibernate.boot.internal.InFlightMetadataCollectorImpl.<init>(InFlightMetadataCollectorImpl.java:189)
at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:171)
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1431)
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1502)
at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:75)
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:390)
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:366)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1835)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1784)
... 15 common frames omitted
Caused by: org.hibernate.HibernateException: Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' for common cases or 'hibernate.dialect' when a custom Dialect implementation must be provided)
at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.determineDialect(DialectFactoryImpl.java:191)
at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.buildDialect(DialectFactoryImpl.java:87)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentWithDefaults(JdbcEnvironmentInitiator.java:152)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.getJdbcEnvironmentUsingJdbcMetadata(JdbcEnvironmentInitiator.java:362)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:123)
at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator.initiateService(JdbcEnvironmentInitiator.java:77)
at org.hibernate.boot.registry.internal.StandardServiceRegistryImpl.initiateService(StandardServiceRegistryImpl.java:130)
at org.hibernate.service.internal.AbstractServiceRegistryImpl.createService(AbstractServiceRegistryImpl.java:263)
... 30 common frames omitted

View File

@ -0,0 +1,545 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의 종료 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
/* 페이지 특화 스타일 */
.page-header {
text-align: center;
padding: var(--space-lg) 0;
background: var(--primary-light);
margin: calc(var(--space-md) * -1) calc(var(--space-md) * -1) var(--space-lg);
}
.page-header h1 {
font-size: var(--font-h2);
color: var(--primary);
margin-bottom: var(--space-sm);
}
.page-header .meeting-title {
font-size: var(--font-body);
color: var(--gray-700);
}
/* 통계 카드 그리드 - 정보 표시용 (인터랙션 없음) */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
}
.stat-card {
min-height: 80px;
padding: var(--space-md);
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.stat-value {
font-size: var(--font-h1);
font-weight: var(--font-weight-bold);
color: var(--gray-900);
margin-bottom: var(--space-xs);
line-height: 1.2;
}
.stat-label {
font-size: var(--font-small);
color: var(--gray-600);
font-weight: var(--font-weight-medium);
}
/* 키워드 클라우드 */
.keyword-cloud {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-md);
}
.keyword-tag {
display: inline-block;
padding: 6px 12px;
background: var(--primary-light);
color: var(--primary-dark);
border-radius: 16px;
font-size: var(--font-small);
font-weight: var(--font-weight-medium);
}
/* 안건 카드 */
.agenda-card {
background: var(--white);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-md);
overflow: hidden;
border: 1px solid var(--gray-200);
}
.agenda-header {
padding: var(--space-md);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--gray-50);
}
.agenda-header:hover {
background: var(--gray-100);
}
.agenda-title {
font-size: var(--font-h4);
font-weight: var(--font-weight-bold);
color: var(--gray-900);
margin-bottom: var(--space-xs);
}
.ai-summary-short {
background: var(--gray-100);
border-left: 4px solid var(--primary);
padding: var(--space-sm) var(--space-md);
margin-top: var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--font-small);
color: var(--gray-700);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.ai-summary-short .lock-icon {
color: var(--gray-500);
font-size: 16px;
}
.expand-icon {
font-size: 20px;
color: var(--gray-500);
transition: transform 0.3s ease;
}
.agenda-card.expanded .expand-icon {
transform: rotate(180deg);
}
.agenda-content {
display: none;
padding: var(--space-md);
border-top: 1px solid var(--gray-200);
}
.agenda-card.expanded .agenda-content {
display: block;
}
.agenda-section {
margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--gray-200);
}
.agenda-section:last-child {
border-bottom: none;
padding-bottom: 0;
}
.agenda-section-title {
font-size: var(--font-small);
font-weight: var(--font-weight-bold);
color: var(--gray-900);
margin-bottom: var(--space-sm);
display: flex;
align-items: center;
gap: var(--space-xs);
}
.agenda-section-title::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
background: var(--primary);
border-radius: 2px;
}
.agenda-section-content {
font-size: var(--font-body);
color: var(--gray-700);
line-height: 1.6;
padding-left: var(--space-md);
}
.agenda-section-content ul {
margin: 0;
padding-left: var(--space-lg);
}
.agenda-section-content li {
margin-bottom: var(--space-xs);
}
/* 하단 액션 바 - 3개 버튼 배치 */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--white);
padding: var(--space-md);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
display: flex;
gap: var(--space-sm);
z-index: 100;
}
.action-bar .btn {
flex: 1;
}
.action-bar .btn-primary {
flex: 2; /* 바로 최종 확정 버튼 강조 */
}
@media (min-width: 768px) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
.action-bar {
position: static;
box-shadow: none;
justify-content: center;
gap: var(--space-md);
}
.action-bar .btn {
flex: 0 1 200px;
}
.action-bar .btn-primary {
flex: 0 1 250px;
}
}
.readonly-notice {
background: var(--warning-light);
border: 1px solid var(--warning);
border-radius: var(--radius-md);
padding: var(--space-md);
margin-bottom: var(--space-lg);
font-size: var(--font-small);
color: var(--warning-dark);
text-align: center;
}
</style>
</head>
<body>
<div class="page">
<div class="container has-action-bar">
<!-- 페이지 헤더 -->
<div class="page-header">
<h1>✅ 회의가 종료되었습니다</h1>
<p class="meeting-title">2025년 1분기 제품 기획 회의</p>
</div>
<!-- 읽기 전용 안내 -->
<div class="readonly-notice">
🔒 이 화면은 <strong>확인 전용</strong>입니다. 내용을 수정하려면 "회의록 수정" 버튼을 클릭하세요.
</div>
<!-- 통계 카드 그리드 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="durationValue">0</div>
<div class="stat-label">회의 시간 (분)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="participantsValue">0</div>
<div class="stat-label">참석자</div>
</div>
<div class="stat-card">
<div class="stat-value" id="agendasValue">0</div>
<div class="stat-label">안건</div>
</div>
<div class="stat-card">
<div class="stat-value" id="todosValue">0</div>
<div class="stat-label">Todo</div>
</div>
</div>
<!-- 주요 키워드 -->
<div class="card mb-md">
<h3 class="card-title">주요 키워드</h3>
<div class="keyword-cloud">
<span class="keyword-tag">신제품 기획</span>
<span class="keyword-tag">예산 편성</span>
<span class="keyword-tag">일정 조율</span>
<span class="keyword-tag">시장 조사</span>
<span class="keyword-tag">UI/UX</span>
<span class="keyword-tag">개발 스펙</span>
</div>
</div>
<!-- 안건별 AI 요약 -->
<div class="card mb-md">
<h3 class="card-title">안건별 AI 요약</h3>
<div id="agendaList"></div>
</div>
</div>
<!-- 하단 액션 바 (3가지 선택 옵션) -->
<div class="action-bar">
<button class="btn btn-ghost" onclick="navigateTo('02-대시보드.html')">
대시보드
</button>
<button class="btn btn-secondary" onclick="navigateTo('11-회의록수정.html')">
회의록 수정
</button>
<button class="btn btn-primary" onclick="confirmMeetingDirectly()">
바로 최종 확정
</button>
</div>
</div>
<script src="common.js"></script>
<script>
// 샘플 안건 데이터
const SAMPLE_AGENDAS = [
{
id: 'agenda-1',
title: '1. 신제품 기획 방향성',
aiSummaryShort: '타겟 고객을 20-30대로 설정, UI/UX 개선 집중',
details: {
discussion: '신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고, 기존 제품 대비 UI/UX를 대폭 개선하기로 함',
opinions: [
{ speaker: '김민준', opinion: '타겟 고객층을 명확히 설정하여 마케팅 전략 수립 필요' },
{ speaker: '박서연', opinion: 'UI/UX 개선에 AI 기술 적용 검토' }
],
decisions: ['타겟 고객: 20-30대 직장인', 'UI/UX 개선을 최우선 과제로 설정'],
pending: []
},
todos: [
{
title: '시장 조사 보고서 작성',
assignee: SAMPLE_MEETINGS[0].participants[0],
dueDate: '2025-11-01',
priority: 'high'
},
{
title: 'UI/UX 개선안 초안 작성',
assignee: SAMPLE_MEETINGS[0].participants[1],
dueDate: '2025-11-05',
priority: 'medium'
}
]
},
{
id: 'agenda-2',
title: '2. 예산 편성 및 일정',
aiSummaryShort: '총 예산 5억, 개발 기간 6개월 확정',
details: {
discussion: '신제품 개발을 위한 총 예산을 5억원으로 책정하고, 개발 기간은 6개월로 확정함',
opinions: [
{ speaker: '이준호', opinion: '개발 기간 6개월은 타이트하므로 우선순위 명확화 필요' },
{ speaker: '최유진', opinion: '예산 배분은 개발 60%, 마케팅 40%로 제안' }
],
decisions: ['총 예산: 5억원', '개발 기간: 6개월', '예산 배분: 개발 60%, 마케팅 40%'],
pending: ['세부 일정 확정은 다음 회의에서 논의']
},
todos: [
{
title: '세부 개발 일정 수립',
assignee: SAMPLE_MEETINGS[0].participants[2],
dueDate: '2025-10-28',
priority: 'high'
}
]
},
{
id: 'agenda-3',
title: '3. 기술 스택 및 개발 방향',
aiSummaryShort: 'React 기반 프론트엔드, AI 챗봇 기능 추가',
details: {
discussion: '프론트엔드는 React 기반으로 개발하고, 고객 지원을 위한 AI 챗봇 기능을 추가하기로 함',
opinions: [
{ speaker: '박서연', opinion: 'AI 챗봇은 GPT-4 기반으로 개발 제안' },
{ speaker: '이준호', opinion: 'React 외에 Next.js 도입 검토 필요' }
],
decisions: ['프론트엔드: React 기반', 'AI 챗봇 기능 추가', 'Next.js 도입 검토'],
pending: ['AI 챗봇 학습 데이터 확보 방안']
},
todos: [
{
title: 'AI 챗봇 프로토타입 개발',
assignee: SAMPLE_MEETINGS[0].participants[1],
dueDate: '2025-11-10',
priority: 'medium'
},
{
title: 'Next.js 도입 검토 보고서',
assignee: SAMPLE_MEETINGS[0].participants[3],
dueDate: '2025-11-03',
priority: 'low'
}
]
}
];
// 페이지 초기화
function initPage() {
// 통계 카운트 애니메이션
animateCounter('durationValue', 90);
animateCounter('participantsValue', 4);
animateCounter('agendasValue', SAMPLE_AGENDAS.length);
// Todo 전체 개수 계산
const totalTodos = SAMPLE_AGENDAS.reduce((sum, agenda) => sum + (agenda.todos?.length || 0), 0);
animateCounter('todosValue', totalTodos);
// 안건 리스트 렌더링
renderAgendaList();
}
// 카운터 애니메이션
function animateCounter(elementId, target) {
const element = $(`#${elementId}`);
let current = 0;
const increment = target / 30;
const timer = setInterval(() => {
current += increment;
if (current >= target) {
element.textContent = target;
clearInterval(timer);
} else {
element.textContent = Math.floor(current);
}
}, 30);
}
// 안건 리스트 렌더링
function renderAgendaList() {
const container = $('#agendaList');
SAMPLE_AGENDAS.forEach(agenda => {
// 안건 카드
const card = createElement('div', {
className: 'agenda-card',
id: `agenda-${agenda.id}`,
onclick: `toggleAgenda('${agenda.id}')`
});
// 헤더
const header = createElement('div', { className: 'agenda-header' }, `
<div>
<div class="agenda-title">${agenda.title}</div>
<div class="ai-summary-short">
<span class="lock-icon">🔒</span>
<span>${agenda.aiSummaryShort}</span>
</div>
</div>
<div class="expand-icon"></div>
`);
card.appendChild(header);
// 상세 내용
const content = createElement('div', { className: 'agenda-content' });
// 논의 주제
if (agenda.details.discussion) {
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">논의 주제</div>
<div class="agenda-section-content">${agenda.details.discussion}</div>
`));
}
// 결정 사항
if (agenda.details.decisions && agenda.details.decisions.length > 0) {
const decisionsHtml = agenda.details.decisions.map(d => `<li>✓ ${d}</li>`).join('');
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">결정 사항</div>
<div class="agenda-section-content"><ul>${decisionsHtml}</ul></div>
`));
}
// 보류 사항
if (agenda.details.pending && agenda.details.pending.length > 0) {
const pendingHtml = agenda.details.pending.map(p => `<li>⏸ ${p}</li>`).join('');
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">보류 사항</div>
<div class="agenda-section-content"><ul>${pendingHtml}</ul></div>
`));
}
// Todo 목록 - 제목만 간단히 표시
if (agenda.todos && agenda.todos.length > 0) {
const todosSection = createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">Todo 자동 추출 결과</div>
`);
const todoList = createElement('ul', {
style: 'list-style: none; padding: 0; margin: 0;'
});
agenda.todos.forEach(todo => {
const todoItem = createElement('li', {
style: 'display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm) 0; font-size: var(--font-body); color: var(--gray-700);'
}, `
<span style="color: var(--gray-500);"></span>
<span>${todo.title}</span>
`);
todoList.appendChild(todoItem);
});
todosSection.appendChild(todoList);
// 안내 문구 추가
const notice = createElement('p', {
style: 'font-size: var(--font-small); color: var(--gray-500); margin-top: var(--space-md); padding-top: var(--space-sm); border-top: 1px solid var(--gray-200);'
}, '💡 담당자 및 마감일은 회의록 수정 화면에서 지정할 수 있습니다.');
todosSection.appendChild(notice);
content.appendChild(todosSection);
}
card.appendChild(content);
container.appendChild(card);
});
}
// 안건 카드 확장/축소
function toggleAgenda(agendaId) {
const card = $(`#agenda-${agendaId}`);
card.classList.toggle('expanded');
}
// 바로 최종 확정
function confirmMeetingDirectly() {
if (confirm('AI가 정리한 내용 그대로 최종 확정하시겠습니까?\n\n모든 안건이 자동으로 검증 완료 처리되며, 참석자에게 확정 알림이 발송됩니다.')) {
showToast('회의록이 최종 확정되었습니다', 'success');
setTimeout(() => {
navigateTo('02-대시보드.html');
}, 1000);
}
}
// 페이지 로드 시 초기화
initPage();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -36,6 +36,12 @@ public enum ErrorCode {
OPERATION_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "B004", "허용되지 않은 작업입니다."), OPERATION_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "B004", "허용되지 않은 작업입니다."),
BUSINESS_RULE_VIOLATION(HttpStatus.BAD_REQUEST, "B005", "비즈니스 규칙 위반입니다."), BUSINESS_RULE_VIOLATION(HttpStatus.BAD_REQUEST, "B005", "비즈니스 규칙 위반입니다."),
// 회의 관련 에러
MEETING_TIME_CONFLICT(HttpStatus.CONFLICT, "M001", "해당 시간대에 이미 예약된 회의가 있습니다."),
INVALID_MEETING_TIME(HttpStatus.BAD_REQUEST, "M002", "회의 시작 시간이 종료 시간보다 늦습니다."),
MEETING_NOT_FOUND(HttpStatus.NOT_FOUND, "M003", "회의를 찾을 수 없습니다."),
INVALID_MEETING_STATUS(HttpStatus.BAD_REQUEST, "M004", "유효하지 않은 회의 상태입니다."),
// 외부 시스템 에러 (4xxx) // 외부 시스템 에러 (4xxx)
EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "외부 API 호출 중 오류가 발생했습니다."), EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "외부 API 호출 중 오류가 발생했습니다."),
DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E002", "데이터베이스 오류가 발생했습니다."), DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E002", "데이터베이스 오류가 발생했습니다."),

View File

@ -7,14 +7,12 @@ info:
**핵심 기능:** **핵심 기능:**
- 음성 녹음 시작/중지 관리 - 음성 녹음 시작/중지 관리
- 실시간 음성-텍스트 변환 (스트리밍) - 실시간 음성-텍스트 변환 (스트리밍)
- 배치 음성-텍스트 변환
- 화자 식별 및 관리
- Azure Speech Service 통합 - Azure Speech Service 통합
**차별화 포인트:** **차별화 포인트:**
- 기본 기능 (Hygiene Factor) - 경쟁사 대부분 제공 - 기본 기능 (Hygiene Factor) - 경쟁사 대부분 제공
- 실시간 스트리밍 처리로 즉각적인 자막 제공 - 실시간 스트리밍 처리로 즉각적인 자막 제공
- 화자 자동 식별 (90% 이상 정확도) - **단순화**: 배치 처리 및 화자 식별 제거, 실시간 전용 기능
version: 1.0.0 version: 1.0.0
contact: contact:
name: STT Service Team name: STT Service Team
@ -25,7 +23,7 @@ servers:
description: Production Server description: Production Server
- url: https://dev-api.example.com/stt/v1 - url: https://dev-api.example.com/stt/v1
description: Development Server description: Development Server
- url: http://localhost:8083/api/v1 - url: http://localhost:8084/api/v1
description: Local Development Server description: Local Development Server
tags: tags:
@ -33,8 +31,6 @@ tags:
description: 음성 녹음 관리 API description: 음성 녹음 관리 API
- name: Transcription - name: Transcription
description: 음성-텍스트 변환 API description: 음성-텍스트 변환 API
- name: Speaker
description: 화자 식별 및 관리 API
paths: paths:
/recordings/prepare: /recordings/prepare:
@ -50,7 +46,7 @@ paths:
2. DB에 녹음 정보 생성 2. DB에 녹음 정보 생성
3. Azure Speech 인식기 초기화 3. Azure Speech 인식기 초기화
4. Blob Storage 저장 경로 생성 4. Blob Storage 저장 경로 생성
5. RecordingStarted 이벤트 발행 5. RecordingStarted 이벤트 발행 (Kafka)
operationId: prepareRecording operationId: prepareRecording
x-user-story: UFR-STT-010 x-user-story: UFR-STT-010
x-controller: RecordingController x-controller: RecordingController
@ -243,15 +239,14 @@ paths:
**처리 흐름:** **처리 흐름:**
1. 음성 데이터 스트림 수신 1. 음성 데이터 스트림 수신
2. Azure Speech Service 실시간 인식 2. Azure Speech Service 실시간 인식
3. 화자 식별 3. 신뢰도 검증 (70% threshold)
4. 신뢰도 검증 (70% threshold) 4. DB에 세그먼트 저장
5. DB에 세그먼트 저장 5. TranscriptSegmentReady 이벤트 발행 (Kafka)
6. TranscriptSegmentReady 이벤트 발행 6. WebSocket으로 실시간 자막 전송
7. WebSocket으로 실시간 자막 전송
**성능:** **성능:**
- 실시간 인식 지연: < 1초 - 실시간 인식 지연: < 1초
- 처리 시간: 1-4 - 처리 시간: 1-3
operationId: streamTranscription operationId: streamTranscription
x-user-story: UFR-STT-020 x-user-story: UFR-STT-020
x-controller: TranscriptController x-controller: TranscriptController
@ -277,8 +272,6 @@ paths:
transcriptId: "TRS-SEG-001" transcriptId: "TRS-SEG-001"
recordingId: "REC-20250123-001" recordingId: "REC-20250123-001"
text: "안녕하세요, 오늘 회의를 시작하겠습니다." text: "안녕하세요, 오늘 회의를 시작하겠습니다."
speakerId: "SPK-001"
speakerName: "김철수"
timestamp: 1234567890 timestamp: 1234567890
duration: 3.5 duration: 3.5
confidence: 0.92 confidence: 0.92
@ -290,94 +283,6 @@ paths:
security: security:
- BearerAuth: [] - BearerAuth: []
/transcripts/batch:
post:
tags:
- Transcription
summary: 배치 음성-텍스트 변환
description: |
전체 오디오 파일을 배치로 변환 (비동기 처리)
**처리 흐름:**
1. 전체 오디오 파일 업로드
2. Azure Batch Transcription Job 생성
3. 비동기 처리 시작
4. Job ID 반환 (202 Accepted)
5. 처리 완료 시 Callback으로 결과 수신
**처리 시간:**
- 파일 업로드: 1-2초
- Azure 배치 처리: 5-30초 (파일 크기 따라)
- 총 처리 시간: 7-33초
operationId: batchTranscription
x-user-story: UFR-STT-020
x-controller: TranscriptController
requestBody:
required: true
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/BatchTranscriptionRequest'
responses:
'202':
description: 배치 작업 접수됨
content:
application/json:
schema:
$ref: '#/components/schemas/BatchTranscriptionResponse'
example:
jobId: "JOB-20250123-001"
recordingId: "REC-20250123-001"
status: "PROCESSING"
estimatedCompletionTime: "2025-01-23T10:31:00Z"
callbackUrl: "https://api.example.com/stt/v1/transcripts/callback"
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalServerError'
security:
- BearerAuth: []
/transcripts/callback:
post:
tags:
- Transcription
summary: 배치 변환 완료 콜백
description: |
Azure Speech Service로부터 배치 변환 완료 콜백 수신
**처리 흐름:**
1. 배치 결과 수신
2. 세그먼트별 DB 저장
3. 전체 텍스트 병합
4. TranscriptionCompleted 이벤트 발행
operationId: batchTranscriptionCallback
x-user-story: UFR-STT-020
x-controller: TranscriptController
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BatchCallbackRequest'
responses:
'200':
description: 콜백 처리 성공
content:
application/json:
schema:
$ref: '#/components/schemas/TranscriptionCompleteResponse'
example:
jobId: "JOB-20250123-001"
recordingId: "REC-20250123-001"
status: "COMPLETED"
segmentCount: 120
totalDuration: 1800
averageConfidence: 0.88
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalServerError'
/transcripts/{recordingId}: /transcripts/{recordingId}:
get: get:
@ -389,7 +294,7 @@ paths:
**응답 데이터:** **응답 데이터:**
- 전체 텍스트 - 전체 텍스트
- 화자별 세그먼트 목록 - 세그먼트 목록
- 타임스탬프 정보 - 타임스탬프 정보
- 신뢰도 점수 - 신뢰도 점수
operationId: getTranscription operationId: getTranscription
@ -404,13 +309,6 @@ paths:
schema: schema:
type: boolean type: boolean
default: false default: false
- name: speakerId
in: query
description: 특정 화자의 발언만 필터링
required: false
schema:
type: string
example: "SPK-001"
responses: responses:
'200': '200':
description: 변환 텍스트 조회 성공 description: 변환 텍스트 조회 성공
@ -420,16 +318,13 @@ paths:
$ref: '#/components/schemas/TranscriptionResponse' $ref: '#/components/schemas/TranscriptionResponse'
example: example:
recordingId: "REC-20250123-001" recordingId: "REC-20250123-001"
fullText: "김철수: 안녕하세요...\n이영희: 네, 안녕하세요..." fullText: "안녕하세요, 오늘 회의를 시작하겠습니다..."
segmentCount: 120 segmentCount: 120
totalDuration: 1800 totalDuration: 1800
averageConfidence: 0.88 averageConfidence: 0.88
speakerCount: 3
segments: segments:
- transcriptId: "TRS-SEG-001" - transcriptId: "TRS-SEG-001"
text: "안녕하세요, 오늘 회의를 시작하겠습니다." text: "안녕하세요, 오늘 회의를 시작하겠습니다."
speakerId: "SPK-001"
speakerName: "김철수"
timestamp: 0 timestamp: 0
duration: 3.5 duration: 3.5
confidence: 0.92 confidence: 0.92
@ -440,179 +335,6 @@ paths:
security: security:
- BearerAuth: [] - BearerAuth: []
/speakers/identify:
post:
tags:
- Speaker
summary: 화자 식별
description: |
음성 데이터로부터 화자 식별
**처리 흐름:**
1. Voice signature 생성
2. 기존 프로필과 매칭
3. 신규 화자 자동 등록
4. 화자 정보 반환
**정확도:**
- 화자 식별 정확도: > 90%
- 처리 시간: ~300ms
operationId: identifySpeaker
x-user-story: UFR-STT-010
x-controller: SpeakerController
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/IdentifySpeakerRequest'
example:
recordingId: "REC-20250123-001"
audioFrame: "base64_encoded_audio_frame"
timestamp: 1234567890
responses:
'200':
description: 화자 식별 성공
content:
application/json:
schema:
$ref: '#/components/schemas/SpeakerIdentificationResponse'
example:
speakerId: "SPK-001"
speakerName: "김철수"
confidence: 0.95
isNewSpeaker: false
profileId: "PROFILE-12345"
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalServerError'
security:
- BearerAuth: []
/speakers/{speakerId}:
get:
tags:
- Speaker
summary: 화자 정보 조회
description: 특정 화자의 상세 정보 조회
operationId: getSpeaker
x-user-story: UFR-STT-010
x-controller: SpeakerController
parameters:
- name: speakerId
in: path
description: 화자 ID
required: true
schema:
type: string
example: "SPK-001"
responses:
'200':
description: 화자 정보 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/SpeakerDetailResponse'
example:
speakerId: "SPK-001"
speakerName: "김철수"
profileId: "PROFILE-12345"
totalSegments: 45
totalDuration: 450
averageConfidence: 0.92
firstAppeared: "2025-01-23T10:30:15Z"
lastAppeared: "2025-01-23T11:00:00Z"
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
security:
- BearerAuth: []
put:
tags:
- Speaker
summary: 화자 정보 업데이트
description: 화자 이름 등 정보 수정
operationId: updateSpeaker
x-user-story: UFR-STT-010
x-controller: SpeakerController
parameters:
- name: speakerId
in: path
description: 화자 ID
required: true
schema:
type: string
example: "SPK-001"
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateSpeakerRequest'
example:
speakerName: "김철수 팀장"
userId: "USER-123"
responses:
'200':
description: 화자 정보 업데이트 성공
content:
application/json:
schema:
$ref: '#/components/schemas/SpeakerDetailResponse'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
security:
- BearerAuth: []
/recordings/{recordingId}/speakers:
get:
tags:
- Speaker
summary: 녹음의 화자 목록 조회
description: 특정 녹음에 참여한 모든 화자 목록 조회
operationId: getRecordingSpeakers
x-user-story: UFR-STT-010
x-controller: SpeakerController
parameters:
- $ref: '#/components/parameters/RecordingIdParam'
responses:
'200':
description: 화자 목록 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/SpeakerListResponse'
example:
recordingId: "REC-20250123-001"
speakerCount: 3
speakers:
- speakerId: "SPK-001"
speakerName: "김철수"
segmentCount: 45
totalDuration: 450
speakingRatio: 0.45
- speakerId: "SPK-002"
speakerName: "이영희"
segmentCount: 38
totalDuration: 380
speakingRatio: 0.38
- speakerId: "SPK-003"
speakerName: "박민수"
segmentCount: 17
totalDuration: 170
speakingRatio: 0.17
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
security:
- BearerAuth: []
components: components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
@ -657,7 +379,7 @@ components:
example: "ko-KR" example: "ko-KR"
attendeeCount: attendeeCount:
type: integer type: integer
description: 참석자 수 (화자 식별 최적화용) description: 참석자 수
minimum: 1 minimum: 1
maximum: 50 maximum: 50
example: 5 example: 5
@ -809,10 +531,6 @@ components:
type: integer type: integer
description: 녹음 시간 (초) description: 녹음 시간 (초)
example: 300 example: 300
speakerCount:
type: integer
description: 화자 수
example: 3
segmentCount: segmentCount:
type: integer type: integer
description: 세그먼트 수 description: 세그먼트 수
@ -866,14 +584,6 @@ components:
type: string type: string
description: 변환된 텍스트 description: 변환된 텍스트
example: "안녕하세요, 오늘 회의를 시작하겠습니다." example: "안녕하세요, 오늘 회의를 시작하겠습니다."
speakerId:
type: string
description: 화자 ID
example: "SPK-001"
speakerName:
type: string
description: 화자 이름
example: "김철수"
timestamp: timestamp:
type: integer type: integer
description: 타임스탬프 (ms) description: 타임스탬프 (ms)
@ -895,89 +605,6 @@ components:
description: 낮은 신뢰도 경고 플래그 (< 60%) description: 낮은 신뢰도 경고 플래그 (< 60%)
example: false example: false
BatchTranscriptionRequest:
type: object
required:
- recordingId
- audioFile
properties:
recordingId:
type: string
description: 녹음 ID
example: "REC-20250123-001"
audioFile:
type: string
format: binary
description: 오디오 파일 (WAV, MP3 등)
language:
type: string
description: 음성 인식 언어
default: "ko-KR"
example: "ko-KR"
callbackUrl:
type: string
format: uri
description: 처리 완료 콜백 URL
example: "https://api.example.com/stt/v1/transcripts/callback"
BatchTranscriptionResponse:
type: object
properties:
jobId:
type: string
description: 배치 작업 ID
example: "JOB-20250123-001"
recordingId:
type: string
description: 녹음 ID
example: "REC-20250123-001"
status:
type: string
description: 작업 상태
enum:
- QUEUED
- PROCESSING
- COMPLETED
- FAILED
example: "PROCESSING"
estimatedCompletionTime:
type: string
format: date-time
description: 예상 완료 시간
example: "2025-01-23T10:31:00Z"
callbackUrl:
type: string
format: uri
description: 콜백 URL
example: "https://api.example.com/stt/v1/transcripts/callback"
BatchCallbackRequest:
type: object
required:
- jobId
- status
- segments
properties:
jobId:
type: string
description: 배치 작업 ID
example: "JOB-20250123-001"
status:
type: string
description: 작업 상태
enum:
- COMPLETED
- FAILED
example: "COMPLETED"
segments:
type: array
description: 변환 세그먼트 목록
items:
$ref: '#/components/schemas/TranscriptionSegment'
error:
type: string
description: 오류 메시지 (실패 시)
example: "Audio file format not supported"
TranscriptionSegment: TranscriptionSegment:
type: object type: object
@ -986,10 +613,6 @@ components:
type: string type: string
description: 변환된 텍스트 description: 변환된 텍스트
example: "안녕하세요, 오늘 회의를 시작하겠습니다." example: "안녕하세요, 오늘 회의를 시작하겠습니다."
speakerId:
type: string
description: 화자 ID
example: "SPK-001"
timestamp: timestamp:
type: integer type: integer
description: 시작 타임스탬프 (ms) description: 시작 타임스탬프 (ms)
@ -1061,10 +684,6 @@ components:
format: float format: float
description: 평균 신뢰도 점수 description: 평균 신뢰도 점수
example: 0.88 example: 0.88
speakerCount:
type: integer
description: 화자 수
example: 3
segments: segments:
type: array type: array
description: 세그먼트 목록 description: 세그먼트 목록
@ -1082,14 +701,6 @@ components:
type: string type: string
description: 변환된 텍스트 description: 변환된 텍스트
example: "안녕하세요, 오늘 회의를 시작하겠습니다." example: "안녕하세요, 오늘 회의를 시작하겠습니다."
speakerId:
type: string
description: 화자 ID
example: "SPK-001"
speakerName:
type: string
description: 화자 이름
example: "김철수"
timestamp: timestamp:
type: integer type: integer
description: 타임스탬프 (ms) description: 타임스탬프 (ms)
@ -1105,151 +716,6 @@ components:
description: 신뢰도 점수 description: 신뢰도 점수
example: 0.92 example: 0.92
IdentifySpeakerRequest:
type: object
required:
- recordingId
- audioFrame
- timestamp
properties:
recordingId:
type: string
description: 녹음 ID
example: "REC-20250123-001"
audioFrame:
type: string
format: byte
description: Base64 인코딩된 오디오 프레임
example: "UklGRiQAAABXQVZFZm10IBAAAAABA..."
timestamp:
type: integer
description: 타임스탬프 (ms)
example: 1234567890
SpeakerIdentificationResponse:
type: object
properties:
speakerId:
type: string
description: 화자 ID
example: "SPK-001"
speakerName:
type: string
description: 화자 이름
example: "김철수"
confidence:
type: number
format: float
description: 식별 신뢰도 (0-1)
minimum: 0
maximum: 1
example: 0.95
isNewSpeaker:
type: boolean
description: 신규 화자 여부
example: false
profileId:
type: string
description: Azure Speaker Profile ID
example: "PROFILE-12345"
SpeakerDetailResponse:
type: object
properties:
speakerId:
type: string
description: 화자 ID
example: "SPK-001"
speakerName:
type: string
description: 화자 이름
example: "김철수"
profileId:
type: string
description: Azure Speaker Profile ID
example: "PROFILE-12345"
userId:
type: string
description: 연결된 사용자 ID
example: "USER-123"
totalSegments:
type: integer
description: 총 발언 세그먼트 수
example: 45
totalDuration:
type: integer
description: 총 발언 시간 (초)
example: 450
averageConfidence:
type: number
format: float
description: 평균 식별 신뢰도
example: 0.92
firstAppeared:
type: string
format: date-time
description: 최초 등장 시간
example: "2025-01-23T10:30:15Z"
lastAppeared:
type: string
format: date-time
description: 최근 등장 시간
example: "2025-01-23T11:00:00Z"
UpdateSpeakerRequest:
type: object
properties:
speakerName:
type: string
description: 화자 이름
example: "김철수 팀장"
userId:
type: string
description: 연결할 사용자 ID
example: "USER-123"
SpeakerListResponse:
type: object
properties:
recordingId:
type: string
description: 녹음 ID
example: "REC-20250123-001"
speakerCount:
type: integer
description: 화자 수
example: 3
speakers:
type: array
description: 화자 목록
items:
$ref: '#/components/schemas/SpeakerSummary'
SpeakerSummary:
type: object
properties:
speakerId:
type: string
description: 화자 ID
example: "SPK-001"
speakerName:
type: string
description: 화자 이름
example: "김철수"
segmentCount:
type: integer
description: 발언 세그먼트 수
example: 45
totalDuration:
type: integer
description: 총 발언 시간 (초)
example: 450
speakingRatio:
type: number
format: float
description: 발언 비율 (0-1)
example: 0.45
ErrorResponse: ErrorResponse:
type: object type: object
properties: properties:

View File

@ -1,14 +1,13 @@
@startuml @startuml
!theme mono !theme mono
title STT Service - 음성 녹음 시작 및 화자 인식 (통합) title STT Service - 음성 녹음 시작 및 실시간 인식
participant "Frontend<<E>>" as Frontend participant "Frontend<<E>>" as Frontend
participant "API Gateway<<E>>" as Gateway participant "API Gateway<<E>>" as Gateway
participant "RecordingController" as Controller participant "RecordingController" as Controller
participant "RecordingService" as Service participant "RecordingService" as Service
participant "AudioStreamManager" as StreamManager participant "AudioStreamManager" as StreamManager
participant "SpeakerIdentifier" as Speaker
participant "RecordingRepository" as Repository participant "RecordingRepository" as Repository
participant "AzureSpeechClient" as AzureClient participant "AzureSpeechClient" as AzureClient
database "STT DB" as DB database "STT DB" as DB
@ -51,7 +50,6 @@ note right
- 언어: ko-KR - 언어: ko-KR
- Format: PCM 16kHz - Format: PCM 16kHz
- 샘플레이트: 16kHz - 샘플레이트: 16kHz
- 화자 식별 활성화
- 실시간 스트리밍 모드 - 실시간 스트리밍 모드
- Continuous recognition - Continuous recognition
end note end note
@ -108,32 +106,12 @@ deactivate AzureClient
StreamManager --> Service: recognized text StreamManager --> Service: recognized text
deactivate StreamManager deactivate StreamManager
== 화자 식별 == == 세그먼트 저장 ==
Service -> Speaker: identifySpeaker(audioFrame)
activate Speaker
Speaker -> AzureClient: analyzeSpeakerProfile()\n(Speaker Recognition API)
activate AzureClient
note right
화자 식별:
- Voice signature 생성
- 기존 프로필과 매칭
- 신규 화자 자동 등록
end note
AzureClient --> Speaker: speakerId
deactivate AzureClient
Speaker --> Service: speaker info
deactivate Speaker
== 화자별 세그먼트 저장 ==
Service -> Repository: saveSttSegment(segment) Service -> Repository: saveSttSegment(segment)
activate Repository activate Repository
Repository -> DB: STT 세그먼트 저장\n(세션ID, 텍스트, 화자ID, 타임스탬프, 신뢰도) Repository -> DB: STT 세그먼트 저장\n(세션ID, 텍스트, 타임스탬프, 신뢰도)
activate DB activate DB
DB --> Repository: segment saved DB --> Repository: segment saved
deactivate DB deactivate DB
@ -141,24 +119,13 @@ deactivate DB
Repository --> Service: saved Repository --> Service: saved
deactivate Repository deactivate Repository
Service -> Repository: updateSpeakerInfo(recordingId, speakerId) Service --> Controller: streaming response\n{text, timestamp, confidence}
activate Repository
Repository -> DB: 화자 정보 저장/업데이트\n(녹음ID, 화자ID, 세그먼트수)
activate DB
DB --> Repository: 업데이트 완료
deactivate DB
Repository --> Service: 완료
deactivate Repository
Service --> Controller: streaming response\n{text, speaker, timestamp, confidence}
deactivate Service deactivate Service
Controller --> Gateway: WebSocket message Controller --> Gateway: WebSocket message
deactivate Controller deactivate Controller
Gateway --> Frontend: 실시간 자막 전송\n{text, speaker, timestamp} Gateway --> Frontend: 실시간 자막 전송\n{text, timestamp}
deactivate Gateway deactivate Gateway
note over Frontend, EventHub note over Frontend, EventHub
@ -166,12 +133,10 @@ note over Frontend, EventHub
- DB 녹음 생성: ~100ms - DB 녹음 생성: ~100ms
- Azure 인식기 초기화: ~500ms - Azure 인식기 초기화: ~500ms
- Blob 경로 생성: ~200ms - Blob 경로 생성: ~200ms
- 화자 식별: ~300ms
- 실시간 인식 지연: < 1초 - 실시간 인식 지연: < 1초
- 총 초기화 시간: ~1.1 - 총 초기화 시간: ~0.8
정확도: 정확도:
- 화자 식별 정확도: > 90%
- 음성 인식 정확도: 60-95% - 음성 인식 정확도: 60-95%
end note end note

View File

@ -1,7 +1,7 @@
@startuml @startuml
!theme mono !theme mono
title STT Service - 음성-텍스트 변환 (실시간/배치 통합) title STT Service - 음성-텍스트 변환 (실시간 전용)
participant "Frontend<<E>>" as Frontend participant "Frontend<<E>>" as Frontend
participant "API Gateway<<E>>" as Gateway participant "API Gateway<<E>>" as Gateway
@ -15,7 +15,7 @@ database "STT DB" as DB
database "Azure Blob Storage<<E>>" as BlobStorage database "Azure Blob Storage<<E>>" as BlobStorage
queue "Azure Event Hubs<<E>>" as EventHub queue "Azure Event Hubs<<E>>" as EventHub
== 음성 데이터 스트리밍 수신 (실시간 모드) == == 음성 데이터 스트리밍 수신 ==
Frontend -> Gateway: POST /api/transcripts/stream\n(audioData, recordingId, timestamp) Frontend -> Gateway: POST /api/transcripts/stream\n(audioData, recordingId, timestamp)
activate Gateway activate Gateway
@ -26,9 +26,8 @@ activate Controller
Controller -> Service: processAudioStream(audioData, recordingId) Controller -> Service: processAudioStream(audioData, recordingId)
activate Service activate Service
alt 실시간 변환 모드 Service -> Engine: streamingTranscribe(audioData)
Service -> Engine: streamingTranscribe(audioData) activate Engine
activate Engine
Engine -> AzureClient: recognizeAsync(audioData) Engine -> AzureClient: recognizeAsync(audioData)
activate AzureClient activate AzureClient
@ -38,7 +37,6 @@ alt 실시간 변환 모드
Azure Speech 설정: Azure Speech 설정:
- Mode: Continuous - Mode: Continuous
- 언어: ko-KR - 언어: ko-KR
- 화자 식별 활성화
- 타임스탬프 자동 기록 - 타임스탬프 자동 기록
- 신뢰도 점수 계산 - 신뢰도 점수 계산
- Profanity filter - Profanity filter
@ -49,7 +47,7 @@ alt 실시간 변환 모드
BlobStorage --> AzureClient: 저장 완료 BlobStorage --> AzureClient: 저장 완료
deactivate BlobStorage deactivate BlobStorage
AzureClient --> Engine: RecognitionResult\n(text, speakerId, confidence, timestamp, duration) AzureClient --> Engine: RecognitionResult\n(text, confidence, timestamp, duration)
deactivate AzureClient deactivate AzureClient
== 정확도 검증 및 처리 == == 정확도 검증 및 처리 ==
@ -71,7 +69,7 @@ alt 실시간 변환 모드
Service -> TranscriptRepo: createTranscript(recordingId, segment) Service -> TranscriptRepo: createTranscript(recordingId, segment)
activate TranscriptRepo activate TranscriptRepo
TranscriptRepo -> DB: 변환 결과 저장\n(텍스트ID, 녹음ID, 화자ID, 텍스트, 신뢰도, 타임스탬프, 경고플래그) TranscriptRepo -> DB: 변환 결과 저장\n(텍스트ID, 녹음ID, 텍스트, 신뢰도, 타임스탬프, 경고플래그)
activate DB activate DB
DB --> TranscriptRepo: transcriptId 반환 DB --> TranscriptRepo: transcriptId 반환
deactivate DB deactivate DB
@ -79,19 +77,6 @@ alt 실시간 변환 모드
TranscriptRepo --> Service: TranscriptEntity 반환 TranscriptRepo --> Service: TranscriptEntity 반환
deactivate TranscriptRepo deactivate TranscriptRepo
== 화자 정보 업데이트 ==
Service -> RecordingRepo: updateSpeakerInfo(recordingId, speakerId)
activate RecordingRepo
RecordingRepo -> DB: 화자 정보 저장/업데이트\n(녹음ID, 화자ID, 세그먼트수)
activate DB
DB --> RecordingRepo: 업데이트 완료
deactivate DB
RecordingRepo --> Service: 완료
deactivate RecordingRepo
== 이벤트 발행 == == 이벤트 발행 ==
Service -> EventHub: TranscriptSegmentReady 이벤트 발행 Service -> EventHub: TranscriptSegmentReady 이벤트 발행
@ -102,7 +87,6 @@ alt 실시간 변환 모드
- recordingId - recordingId
- meetingId - meetingId
- text - text
- speakerId
- timestamp - timestamp
- confidence - confidence
end note end note
@ -112,128 +96,18 @@ alt 실시간 변환 모드
Service --> Controller: TranscriptResponse\n(transcriptId, text, confidence, warningFlag) Service --> Controller: TranscriptResponse\n(transcriptId, text, confidence, warningFlag)
deactivate Service deactivate Service
Controller --> Gateway: 200 OK\n(transcriptId, text, speakerId, timestamp, confidence) Controller --> Gateway: 200 OK\n(transcriptId, text, timestamp, confidence)
deactivate Controller deactivate Controller
Gateway --> Frontend: 실시간 자막 응답 Gateway --> Frontend: 실시간 자막 응답
deactivate Gateway deactivate Gateway
else 배치 변환 모드
Gateway -> Controller: POST /api/v1/stt/transcribe\n{sessionId, audioFile}
activate Controller
Controller -> Service: transcribeAudio(sessionId, audioFile)
activate Service
Service -> RecordingRepo: findSessionById(sessionId)
activate RecordingRepo
RecordingRepo -> DB: STT 세션 조회\n(세션ID 기준)
DB --> RecordingRepo: session data
RecordingRepo --> Service: RecordingEntity
deactivate RecordingRepo
Service -> Engine: batchTranscribe(audioFile)
activate Engine
Engine -> AzureClient: batchTranscriptionAsync(audioUrl)
activate AzureClient
note right
배치 처리:
- 전체 파일 업로드
- 백그라운드 처리
- Callback URL 제공
- 화자별 그룹화
- 문장 경계 보정
end note
AzureClient --> Engine: transcription job ID
deactivate AzureClient
Engine --> Service: job submitted
deactivate Engine
Service -> RecordingRepo: updateSessionStatus(sessionId, "PROCESSING")
activate RecordingRepo
RecordingRepo -> DB: 세션 상태 업데이트\n(상태='처리중')
DB --> RecordingRepo: updated
RecordingRepo --> Service: updated
deactivate RecordingRepo
Service --> Controller: 202 Accepted\n{jobId, status}
deactivate Service
Controller --> Gateway: 202 Accepted
deactivate Controller
== 배치 처리 완료 (Callback) ==
AzureClient -> Controller: POST /api/v1/stt/callback\n{jobId, segments}
activate Controller
Controller -> Service: processBatchResult(jobId, segments)
activate Service
loop 각 세그먼트 처리
Service -> TranscriptRepo: createTranscript(recordingId, segment)
activate TranscriptRepo
TranscriptRepo -> DB: 변환 결과 저장
DB --> TranscriptRepo: saved
TranscriptRepo --> Service: saved
deactivate TranscriptRepo
end
== 전체 텍스트 통합 ==
Service -> TranscriptRepo: aggregateTranscription(sessionId)
activate TranscriptRepo
TranscriptRepo -> DB: 세그먼트 목록 조회\n(세션ID 기준, 타임스탬프 순 정렬)
DB --> TranscriptRepo: ordered segments
TranscriptRepo --> Service: segments
deactivate TranscriptRepo
Service -> Service: mergeSegments(segments)
note right
세그먼트 병합:
- 화자별 그룹화
- 시간 순서 정렬
- 문장 경계 보정
end note
Service -> RecordingRepo: saveTranscription(fullText)
activate RecordingRepo
RecordingRepo -> DB: 전체 텍스트 저장 및 상태 업데이트\n(전체텍스트, 상태='완료')
DB --> RecordingRepo: saved
RecordingRepo --> Service: updated session
deactivate RecordingRepo
Service -> EventHub: TranscriptionCompletedEvent 발행
note right
Event:
- sessionId
- meetingId
- fullText
- completedAt
end note
Service --> Controller: TranscriptionResponse\n{sessionId, text, segments}
deactivate Service
Controller --> Gateway: 200 OK\n{transcription, metadata}
deactivate Controller
end
note over Frontend, EventHub note over Frontend, EventHub
**실시간 모드 처리 시간:** **처리 시간:**
- Azure STT 처리: 1-3초 - Azure STT 처리: 1-3초
- DB 저장: ~100ms - DB 저장: ~100ms
- Event 발행: ~50ms - Event 발행: ~50ms
- 총 처리 시간: 1-4초 - 총 처리 시간: 1-3초
**배치 모드 처리 시간:**
- 파일 업로드: ~1-2초
- Azure 배치 처리: 5-30초 (파일 크기에 따라)
- DB 저장: ~500ms
- 총 처리 시간: 7-33초
**정확도 경고 기준:** **정확도 경고 기준:**
- < 60%: 수동 수정 권장 (경고 플래그) - < 60%: 수동 수정 권장 (경고 플래그)

View File

@ -1,527 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>검증 완료 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
/* 페이지별 커스텀 스타일만 유지 */
/* 공통 스타일(헤더, 메인콘텐츠, 액션바)은 common.css 사용 */
.progress-container {
margin-bottom: var(--space-lg);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-sm);
}
.progress-percentage {
font-size: var(--font-h2);
font-weight: var(--font-weight-bold);
color: var(--primary);
}
.verification-card {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
margin-bottom: var(--space-md);
transition: all var(--transition-normal);
}
.verification-card:hover {
box-shadow: var(--shadow-lg);
}
.verification-card.verified {
border-left: 4px solid var(--success);
}
.verification-card.unverified {
border-left: 4px solid var(--gray-300);
}
.verification-card.locked {
background: var(--gray-100);
}
.verify-icon {
font-size: 32px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.verify-icon.verified {
color: var(--success);
}
.verify-icon.unverified {
color: var(--gray-300);
}
.lock-icon {
font-size: 20px;
color: var(--gray-500);
}
/* 편집 모드 스타일 */
.edit-field {
width: 100%;
padding: var(--space-sm);
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
font-size: var(--font-small);
line-height: 1.6;
margin-bottom: var(--space-sm);
transition: border-color var(--transition-normal);
}
.edit-field:focus {
outline: none;
border-color: var(--primary);
}
.edit-label {
display: block;
font-weight: var(--font-weight-medium);
margin-bottom: var(--space-xs);
color: var(--text-primary);
}
</style>
</head>
<body>
<div class="page">
<!-- Header -->
<header class="header">
<div class="header-left">
<button class="back-btn" onclick="history.back()"></button>
<h1 class="header-title">검증 완료</h1>
</div>
</header>
<!-- Main Content -->
<main class="main-content has-action-bar">
<!-- Progress Bar -->
<div class="progress-container">
<div class="progress-header">
<h2 class="text-small font-bold">전체 진행률</h2>
<span class="progress-percentage" id="progressText">50%</span>
</div>
<div class="progress" style="height: 12px;">
<div class="progress-bar progress-bar-success" id="progressBar" style="width: 50%;"></div>
</div>
<p class="text-small text-muted mt-sm">4개 섹션 중 2개 검증 완료</p>
</div>
<!-- Meeting Info -->
<div class="card mb-lg">
<div class="card-header">
<h3 class="card-title">2025년 1분기 제품 기획 회의</h3>
</div>
<div class="card-body">
<div class="text-small text-muted">
<div style="display: flex; gap: var(--space-md); margin-bottom: var(--space-xs);">
<span>📅 2025-10-25 14:00</span>
<span>⏱️ 90분</span>
</div>
<div>👥 김민준, 박서연, 이준호, 최유진</div>
</div>
</div>
</div>
<!-- Section List -->
<div>
<h2 class="text-small font-bold mb-md">섹션별 검증 상태</h2>
<!-- 섹션 1 - 검증 완료 -->
<div class="verification-card verified">
<div class="verify-icon verified"></div>
<div style="flex: 1;">
<h3 class="text-small font-bold" style="margin: 0 0 4px 0;">회의 개요</h3>
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<div class="avatar-group">
<div class="avatar avatar-green avatar-sm"></div>
<div class="avatar avatar-blue avatar-sm"></div>
</div>
<span class="text-caption text-muted">2명 검증 완료</span>
</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="viewSection(0)">보기</button>
</div>
<!-- 섹션 2 - 검증 완료 + 잠금 -->
<div class="verification-card verified locked">
<div class="verify-icon verified"></div>
<div style="flex: 1;">
<h3 class="text-small font-bold" style="margin: 0 0 4px 0;">
논의 사항
<span class="lock-icon">🔒</span>
</h3>
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<div class="avatar-group">
<div class="avatar avatar-green avatar-sm"></div>
<div class="avatar avatar-blue avatar-sm"></div>
<div class="avatar avatar-yellow avatar-sm"></div>
</div>
<span class="text-caption text-muted">3명 검증 완료 · 잠금됨</span>
</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="unlockSection(1)">잠금해제</button>
</div>
<!-- 섹션 3 - 미검증 -->
<div class="verification-card unverified">
<div class="verify-icon unverified"></div>
<div style="flex: 1;">
<h3 class="text-small font-bold" style="margin: 0 0 4px 0;">결정 사항</h3>
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<div class="avatar-group">
<div class="avatar avatar-blue avatar-sm"></div>
</div>
<span class="text-caption text-muted">1명 검증 완료</span>
</div>
</div>
<button class="btn btn-primary btn-sm" onclick="verifySection(2)">검증하기</button>
</div>
<!-- 섹션 4 - 미검증 -->
<div class="verification-card unverified">
<div class="verify-icon unverified"></div>
<div style="flex: 1;">
<h3 class="text-small font-bold" style="margin: 0 0 4px 0;">액션 아이템</h3>
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<span class="text-caption text-muted">아직 검증되지 않음</span>
</div>
</div>
<button class="btn btn-primary btn-sm" onclick="verifySection(3)">검증하기</button>
</div>
</div>
</main>
<!-- 하단 액션 바 -->
<div class="action-bar">
<button class="btn btn-secondary" onclick="saveLater()">나중에 하기</button>
<button class="btn btn-primary" id="completeBtn" disabled onclick="completeAllVerification()">
모두 검증 완료
</button>
</div>
</div>
<!-- Section View Modal -->
<div id="sectionModal" class="modal-overlay">
<div class="modal" style="max-height: 80vh; overflow-y: auto;">
<div class="modal-header">
<h2 class="modal-title" id="sectionTitle">회의 개요</h2>
<button class="modal-close" onclick="closeModal('sectionModal')"></button>
</div>
<div class="modal-body">
<div id="sectionContent">
<!-- 섹션 내용이 여기에 표시됨 -->
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" onclick="closeModal('sectionModal')">닫기</button>
<button class="btn btn-primary btn-sm" id="editBtn" onclick="toggleEditMode()">편집</button>
<button class="btn btn-primary btn-sm" id="saveBtn" style="display: none;" onclick="saveSection()">저장</button>
</div>
</div>
</div>
<!-- Verification Confirm Modal -->
<div id="verifyModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">섹션 검증</h2>
<button class="modal-close" onclick="closeModal('verifyModal')"></button>
</div>
<div class="modal-body">
<p class="text-small mb-md" id="verifyMessage">이 섹션의 내용을 검증하시겠습니까?</p>
<div class="card" style="background: var(--primary-light);">
<p class="text-small font-medium">검증 후에는 다른 참석자들도 이 섹션이 확인되었음을 알 수 있습니다.</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" onclick="closeModal('verifyModal')">취소</button>
<button class="btn btn-primary btn-sm" onclick="confirmVerification()">검증 완료</button>
</div>
</div>
</div>
<!-- Unlock Confirm Modal -->
<div id="unlockModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">섹션 잠금 해제</h2>
<button class="modal-close" onclick="closeModal('unlockModal')"></button>
</div>
<div class="modal-body">
<p class="text-small mb-md">이 섹션의 잠금을 해제하시겠습니까?</p>
<div class="card" style="background: transparent; color: var(--error); border: 1px solid var(--error);">
<p class="text-small font-medium">⚠️ 잠금 해제 시 다른 참석자들이 내용을 수정할 수 있습니다.</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm" style="background: transparent; color: var(--error); border: 1px solid var(--error);" onclick="closeModal('unlockModal')">취소</button>
<button class="btn btn-error btn-sm" onclick="confirmUnlock()">잠금 해제</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// 섹션 검증 상태
const sectionVerifications = [
{ name: '회의 개요', verified: true, locked: false, verifiers: ['김민준', '박서연'] },
{ name: '논의 사항', verified: true, locked: true, verifiers: ['김민준', '박서연', '이준호'] },
{ name: '결정 사항', verified: false, locked: false, verifiers: ['박서연'] },
{ name: '액션 아이템', verified: false, locked: false, verifiers: [] }
];
let currentSectionIndex = -1;
let isEditMode = false;
let originalContent = '';
// 섹션 데이터 (편집 가능한 필드)
const sectionData = [
{
purpose: '2025년 1분기 신제품 개발 방향 수립',
attendees: '김민준(PM), 박서연(AI), 이준호(Backend), 최유진(Frontend)',
datetime: '2025년 10월 25일 14:00 - 15:30',
location: '본사 2층 대회의실'
},
{
topic1: 'AI 모델 정확도',
detail1: '현재 STT 정확도: 92%, 목표 정확도: 95% 이상',
topic2: '사용자 인터페이스',
detail2: 'Mobile First 디자인 채택, 실시간 협업 기능 필수'
}
];
// 진행률 업데이트
function updateProgress() {
const totalSections = sectionVerifications.length;
const verifiedCount = sectionVerifications.filter(s => s.verified).length;
const percentage = Math.round((verifiedCount / totalSections) * 100);
$('#progressBar').style.width = percentage + '%';
$('#progressText').textContent = percentage + '%';
const completeBtn = $('#completeBtn');
if (percentage === 100) {
completeBtn.disabled = false;
completeBtn.style.opacity = '1';
} else {
completeBtn.disabled = true;
completeBtn.style.opacity = '0.5';
}
}
// 섹션 보기
function viewSection(index) {
currentSectionIndex = index;
isEditMode = false;
const section = sectionVerifications[index];
$('#sectionTitle').textContent = section.name;
// 편집 모드 초기화
$('#editBtn').style.display = 'inline-block';
$('#saveBtn').style.display = 'none';
// 회의 개요만 편집 가능
if (index === 0) {
renderSectionContent(index, false);
} else {
// 다른 섹션은 읽기 전용
const sampleContent = `
<p><strong>1. AI 모델 정확도</strong></p>
<p>- 현재 STT 정확도: 92%</p>
<p>- 목표 정확도: 95% 이상</p>
<br>
<p><strong>2. 사용자 인터페이스</strong></p>
<p>- Mobile First 디자인 채택</p>
<p>- 실시간 협업 기능 필수</p>
`;
$('#sectionContent').innerHTML = sampleContent;
}
openModal('sectionModal');
}
// 섹션 내용 렌더링
function renderSectionContent(index, editMode) {
const data = sectionData[index];
const contentDiv = $('#sectionContent');
if (editMode && index === 0) {
// 회의 개요 편집 모드
contentDiv.innerHTML = `
<div>
<label class="edit-label">회의 목적</label>
<input type="text" class="edit-field" id="edit_purpose" value="${data.purpose}">
</div>
<div>
<label class="edit-label">참석자</label>
<input type="text" class="edit-field" id="edit_attendees" value="${data.attendees}">
</div>
<div>
<label class="edit-label">일시</label>
<input type="text" class="edit-field" id="edit_datetime" value="${data.datetime}">
</div>
<div>
<label class="edit-label">장소</label>
<input type="text" class="edit-field" id="edit_location" value="${data.location}">
</div>
`;
} else if (index === 0) {
// 회의 개요 보기 모드
contentDiv.innerHTML = `
<p><strong>회의 목적:</strong> ${data.purpose}</p>
<p><strong>참석자:</strong> ${data.attendees}</p>
<p><strong>일시:</strong> ${data.datetime}</p>
<p><strong>장소:</strong> ${data.location}</p>
`;
}
}
// 섹션 검증
function verifySection(index) {
currentSectionIndex = index;
const section = sectionVerifications[index];
$('#verifyMessage').textContent = `"${section.name}" 섹션의 내용을 검증하시겠습니까?`;
openModal('verifyModal');
}
// 검증 확인
function confirmVerification() {
const section = sectionVerifications[currentSectionIndex];
section.verified = true;
if (!section.verifiers.includes('김민준')) {
section.verifiers.push('김민준');
}
closeModal('verifyModal');
showToast(`"${section.name}" 섹션이 검증되었습니다`, 'success');
// 화면 새로고침 시뮬레이션
setTimeout(() => {
location.reload();
}, 1000);
}
// 섹션 잠금 해제
function unlockSection(index) {
currentSectionIndex = index;
openModal('unlockModal');
}
// 잠금 해제 확인
function confirmUnlock() {
const section = sectionVerifications[currentSectionIndex];
section.locked = false;
closeModal('unlockModal');
showToast(`"${section.name}" 섹션의 잠금이 해제되었습니다`, 'success');
// 화면 새로고침 시뮬레이션
setTimeout(() => {
location.reload();
}, 1000);
}
// 편집 모드 토글
function toggleEditMode() {
if (currentSectionIndex !== 0) {
// 회의 개요가 아닌 경우
closeModal('sectionModal');
showToast('이 섹션은 회의록수정 화면에서 수정할 수 있습니다', 'info');
return;
}
isEditMode = !isEditMode;
if (isEditMode) {
// 편집 모드로 전환
renderSectionContent(currentSectionIndex, true);
$('#editBtn').style.display = 'none';
$('#saveBtn').style.display = 'inline-block';
} else {
// 보기 모드로 전환
renderSectionContent(currentSectionIndex, false);
$('#editBtn').style.display = 'inline-block';
$('#saveBtn').style.display = 'none';
}
}
// 섹션 저장
function saveSection() {
if (currentSectionIndex !== 0) return;
// 편집된 값 가져오기
const updatedData = {
purpose: $('#edit_purpose').value,
attendees: $('#edit_attendees').value,
datetime: $('#edit_datetime').value,
location: $('#edit_location').value
};
// 데이터 업데이트
sectionData[currentSectionIndex] = updatedData;
// 보기 모드로 전환
isEditMode = false;
renderSectionContent(currentSectionIndex, false);
$('#editBtn').style.display = 'inline-block';
$('#saveBtn').style.display = 'none';
// 성공 메시지
showToast('회의 개요가 저장되었습니다', 'success');
}
// 모두 검증 완료
function completeAllVerification() {
if (confirm('모든 섹션 검증을 완료하고 회의록을 확정하시겠습니까?')) {
showToast('회의록이 최종 확정되었습니다', 'success');
// 회의 종료 화면 또는 대시보드로 이동
setTimeout(() => {
alert('회의록이 확정되었습니다.\n참석자들에게 알림이 전송되었습니다.');
// navigateTo('01-대시보드.html');
}, 1500);
}
}
// 나중에 하기
function saveLater() {
if (confirm('검증을 나중에 완료하시겠습니까?\n회의록은 임시 저장됩니다.')) {
// 회의록 상태를 '작성중'으로 저장
// 실제로는 Meeting Service API 호출하여 임시 저장
showToast('회의록이 임시 저장되었습니다', 'info');
// 대시보드로 이동
setTimeout(() => {
navigateTo('02-대시보드.html');
}, 1000);
}
}
// 초기 진행률 업데이트
updateProgress();
</script>
</body>
</html>

View File

@ -25,92 +25,85 @@
color: var(--gray-700); color: var(--gray-700);
} }
/* 통계 카드 그리드 */ /* 통계 카드 그리드 - 10-회의록상세조회와 동일한 디자인 */
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(4, 1fr);
gap: var(--space-md); gap: var(--space-md);
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
.stat-card { /* 모바일에서 gap 축소 */
background: var(--white); @media (max-width: 600px) {
padding: var(--space-md); .stats-grid {
border-radius: var(--radius-lg); gap: var(--space-xs);
box-shadow: var(--shadow-md); }
.stat-item {
padding: var(--space-sm);
}
.stat-value {
font-size: var(--font-base);
}
.stat-label {
font-size: var(--font-xs);
}
}
.stat-item {
text-align: center; text-align: center;
padding: var(--space-lg);
background: var(--gray-100);
border-radius: var(--radius-md);
} }
.stat-value { .stat-value {
font-size: var(--font-h1); font-size: var(--font-h1);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--primary); color: var(--primary);
margin-bottom: var(--space-xs); margin-bottom: var(--space-sm);
} }
.stat-label { .stat-label {
font-size: var(--font-small); font-size: var(--font-body);
color: var(--gray-500); color: var(--gray-600);
}
/* 키워드 클라우드 */
.keyword-cloud {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-md);
}
.keyword-tag {
display: inline-block;
padding: 6px 12px;
background: var(--primary-light);
color: var(--primary-dark);
border-radius: 16px;
font-size: var(--font-small);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
/* 발언 통계 바 차트 */ /* 키워드 섹션 - 10-회의록상세조회와 동일한 디자인 */
.speaker-stats { .keywords-section {
padding: var(--space-md) 0; margin-bottom: 0;
} }
.speaker-item { .keywords-title {
display: flex; font-size: var(--font-h4);
align-items: center; font-weight: var(--font-weight-bold);
gap: var(--space-md); color: var(--gray-900);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
.speaker-info { .keyword-tags {
display: flex; display: flex;
align-items: center; flex-wrap: wrap;
gap: var(--space-sm); gap: var(--space-sm);
min-width: 120px; margin: var(--space-md) 0;
} }
.speaker-bar-container { .keyword-tag {
flex: 1; padding: 6px 12px;
height: 32px; background: var(--primary-light);
background: var(--gray-100); color: var(--primary);
border-radius: 16px; border-radius: 16px;
overflow: hidden; font-size: var(--font-small);
position: relative; cursor: pointer;
transition: all var(--transition-fast);
} }
.speaker-bar { .keyword-tag:hover {
height: 100%;
background: var(--primary); background: var(--primary);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: var(--space-sm);
color: var(--white); color: var(--white);
font-size: var(--font-caption);
font-weight: var(--font-weight-bold);
transition: width 1s ease;
} }
/* 안건 카드 */ /* 안건 카드 */
@ -182,99 +175,69 @@
} }
.agenda-section { .agenda-section {
margin-bottom: var(--space-md); margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--gray-200);
}
.agenda-section:last-child {
border-bottom: none;
padding-bottom: 0;
} }
.agenda-section-title { .agenda-section-title {
font-size: var(--font-small); font-size: var(--font-small);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--gray-500); color: var(--gray-900);
margin-bottom: var(--space-xs); margin-bottom: var(--space-sm);
text-transform: uppercase; display: flex;
align-items: center;
gap: var(--space-xs);
}
.agenda-section-title::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
background: var(--primary);
border-radius: 2px;
} }
.agenda-section-content { .agenda-section-content {
font-size: var(--font-body); font-size: var(--font-body);
color: var(--gray-700); color: var(--gray-700);
line-height: 1.6; line-height: 1.6;
padding-left: var(--space-md);
} }
/* Todo 리스트 (읽기 전용) */ .agenda-section-content ul {
.todo-list-item { margin: 0;
padding: var(--space-md); padding-left: var(--space-lg);
background: var(--gray-50);
border-radius: var(--radius-md);
margin-bottom: var(--space-sm);
opacity: 0.8;
} }
.todo-header { .agenda-section-content li {
display: flex; margin-bottom: var(--space-xs);
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-sm);
} }
.todo-checkbox { /* 하단 버튼 비율 조정 (1:2:1) */
margin-right: var(--space-sm); .action-bar .btn:nth-child(1) {
cursor: not-allowed;
}
.todo-content {
flex: 1;
font-weight: var(--font-weight-medium);
color: var(--gray-700);
}
.todo-meta {
display: flex;
align-items: center;
gap: var(--space-md);
font-size: var(--font-small);
color: var(--gray-500);
}
/* 하단 액션 바 - 3개 버튼 배치 */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--white);
padding: var(--space-md);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
display: flex;
gap: var(--space-sm);
z-index: 100;
}
.action-bar .btn {
flex: 1; flex: 1;
} }
.action-bar .btn-primary { .action-bar .btn:nth-child(2) {
flex: 2; /* 바로 최종 확정 버튼 강조 */ flex: 2;
} }
.action-bar .btn:nth-child(3) {
flex: 1;
}
/* 데스크톱 반응형 */
@media (min-width: 768px) { @media (min-width: 768px) {
.stats-grid { .stats-grid {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
} }
.action-bar {
position: static;
box-shadow: none;
justify-content: center;
gap: var(--space-md);
}
.action-bar .btn {
flex: 0 1 200px;
}
.action-bar .btn-primary {
flex: 0 1 250px;
}
} }
.readonly-notice { .readonly-notice {
@ -303,44 +266,42 @@
🔒 이 화면은 <strong>확인 전용</strong>입니다. 내용을 수정하려면 "회의록 수정" 버튼을 클릭하세요. 🔒 이 화면은 <strong>확인 전용</strong>입니다. 내용을 수정하려면 "회의록 수정" 버튼을 클릭하세요.
</div> </div>
<!-- 회의 통계 및 키워드 카드 -->
<div class="card mb-lg">
<!-- 통계 카드 그리드 --> <!-- 통계 카드 그리드 -->
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-item">
<div class="stat-value" id="durationValue">0</div> <div class="stat-value" id="participantsValue">4명</div>
<div class="stat-label">회의 시간 (분)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="participantsValue">0</div>
<div class="stat-label">참석자</div> <div class="stat-label">참석자</div>
</div> </div>
<div class="stat-card"> <div class="stat-item">
<div class="stat-value" id="agendasValue">0</div> <div class="stat-value" id="durationValue">90분</div>
<div class="stat-label">안건</div> <div class="stat-label">회의 시간</div>
</div> </div>
<div class="stat-card"> <div class="stat-item">
<div class="stat-value" id="todosValue">0</div> <div class="stat-value" id="agendasValue">3개</div>
<div class="stat-label">Todo</div> <div class="stat-label">주요 안건</div>
</div>
<div class="stat-item">
<div class="stat-value" id="todosValue">5개</div>
<div class="stat-label">Todo 생성</div>
</div> </div>
</div> </div>
<!-- 주요 키워드 --> <!-- 주요 키워드 -->
<div class="card mb-md"> <div class="keywords-section">
<h3 class="card-title">주요 키워드</h3> <h3 class="keywords-title">주요 키워드</h3>
<div class="keyword-cloud"> <div class="keyword-tags">
<span class="keyword-tag">신제품 기획</span> <span class="keyword-tag">#신제품기획</span>
<span class="keyword-tag">예산 편성</span> <span class="keyword-tag">#예산편성</span>
<span class="keyword-tag">일정 조율</span> <span class="keyword-tag">#일정조율</span>
<span class="keyword-tag">시장 조사</span> <span class="keyword-tag">#시장조사</span>
<span class="keyword-tag">UI/UX</span> <span class="keyword-tag">#UI/UX</span>
<span class="keyword-tag">개발 스펙</span> <span class="keyword-tag">#개발스펙</span>
</div>
</div> </div>
</div> </div>
<!-- 발언 통계 -->
<div class="card mb-md">
<h3 class="card-title">발언 통계</h3>
<div class="speaker-stats" id="speakerStats"></div>
</div>
<!-- 안건별 AI 요약 --> <!-- 안건별 AI 요약 -->
<div class="card mb-md"> <div class="card mb-md">
@ -352,7 +313,7 @@
<!-- 하단 액션 바 (3가지 선택 옵션) --> <!-- 하단 액션 바 (3가지 선택 옵션) -->
<div class="action-bar"> <div class="action-bar">
<button class="btn btn-ghost" onclick="navigateTo('02-대시보드.html')"> <button class="btn btn-neutral" onclick="navigateTo('02-대시보드.html')">
대시보드 대시보드
</button> </button>
<button class="btn btn-secondary" onclick="navigateTo('11-회의록수정.html')"> <button class="btn btn-secondary" onclick="navigateTo('11-회의록수정.html')">
@ -459,9 +420,6 @@
const totalTodos = SAMPLE_AGENDAS.reduce((sum, agenda) => sum + (agenda.todos?.length || 0), 0); const totalTodos = SAMPLE_AGENDAS.reduce((sum, agenda) => sum + (agenda.todos?.length || 0), 0);
animateCounter('todosValue', totalTodos); animateCounter('todosValue', totalTodos);
// 발언 통계 렌더링
renderSpeakerStats();
// 안건 리스트 렌더링 // 안건 리스트 렌더링
renderAgendaList(); renderAgendaList();
} }
@ -482,42 +440,6 @@
}, 30); }, 30);
} }
// 발언 통계 렌더링
function renderSpeakerStats() {
const stats = [
{ user: SAMPLE_MEETINGS[0].participants[0], count: 15, duration: 35 },
{ user: SAMPLE_MEETINGS[0].participants[1], count: 12, duration: 28 },
{ user: SAMPLE_MEETINGS[0].participants[2], count: 10, duration: 20 },
{ user: SAMPLE_MEETINGS[0].participants[3], count: 8, duration: 17 }
];
const maxDuration = Math.max(...stats.map(s => s.duration));
const container = $('#speakerStats');
stats.forEach(stat => {
const percentage = (stat.duration / maxDuration) * 100;
const item = createElement('div', { className: 'speaker-item' }, `
<div class="speaker-info">
${createAvatar(stat.user, 'sm')}
<span class="text-small">${stat.user.name}</span>
</div>
<div class="speaker-bar-container">
<div class="speaker-bar" style="width: 0%;" data-width="${percentage}">
${stat.duration}분
</div>
</div>
`);
container.appendChild(item);
});
// 애니메이션 시작
setTimeout(() => {
$$('.speaker-bar').forEach(bar => {
bar.style.width = bar.dataset.width + '%';
});
}, 100);
}
// 안건 리스트 렌더링 // 안건 리스트 렌더링
function renderAgendaList() { function renderAgendaList() {
const container = $('#agendaList'); const container = $('#agendaList');
@ -554,59 +476,85 @@
`)); `));
} }
// 발언자별 의견
if (agenda.details.opinions && agenda.details.opinions.length > 0) {
const opinionsHtml = agenda.details.opinions.map(op =>
`<li><strong>${op.speaker}:</strong> ${op.opinion}</li>`
).join('');
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">발언자별 의견</div>
<div class="agenda-section-content"><ul>${opinionsHtml}</ul></div>
`));
}
// 결정 사항 // 결정 사항
if (agenda.details.decisions && agenda.details.decisions.length > 0) { if (agenda.details.decisions && agenda.details.decisions.length > 0) {
const decisionsHtml = agenda.details.decisions.map(d => `<li>✓ ${d}</li>`).join(''); const decisionsSection = createElement('div', { className: 'agenda-section' }, `
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">결정 사항</div> <div class="agenda-section-title">결정 사항</div>
<div class="agenda-section-content"><ul>${decisionsHtml}</ul></div> `);
`));
const decisionsList = createElement('ul', {
className: 'agenda-section-content',
style: 'list-style: none; padding: 0; margin: 0; padding-left: var(--space-md);'
});
agenda.details.decisions.forEach(decision => {
const decisionItem = createElement('li', {
style: 'display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-xs) 0; font-size: var(--font-body); color: var(--gray-700);'
}, `
<span style="color: var(--gray-500); margin-top: 2px;"></span>
<span style="flex: 1;">${decision}</span>
`);
decisionsList.appendChild(decisionItem);
});
decisionsSection.appendChild(decisionsList);
content.appendChild(decisionsSection);
} }
// 보류 사항 // 보류 사항
if (agenda.details.pending && agenda.details.pending.length > 0) { if (agenda.details.pending && agenda.details.pending.length > 0) {
const pendingHtml = agenda.details.pending.map(p => `<li>⏸ ${p}</li>`).join(''); const pendingSection = createElement('div', { className: 'agenda-section' }, `
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">보류 사항</div> <div class="agenda-section-title">보류 사항</div>
<div class="agenda-section-content"><ul>${pendingHtml}</ul></div> `);
`));
const pendingList = createElement('ul', {
className: 'agenda-section-content',
style: 'list-style: none; padding: 0; margin: 0; padding-left: var(--space-md);'
});
agenda.details.pending.forEach(pending => {
const pendingItem = createElement('li', {
style: 'display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-xs) 0; font-size: var(--font-body); color: var(--gray-700);'
}, `
<span style="color: var(--gray-500); margin-top: 2px;"></span>
<span style="flex: 1;">${pending}</span>
`);
pendingList.appendChild(pendingItem);
});
pendingSection.appendChild(pendingList);
content.appendChild(pendingSection);
} }
// Todo 목록 // Todo 목록 - 제목만 간단히 표시
if (agenda.todos && agenda.todos.length > 0) { if (agenda.todos && agenda.todos.length > 0) {
const todosSection = createElement('div', { className: 'agenda-section' }, ` const todosSection = createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">Todo 자동 추출 결과</div> <div class="agenda-section-title">Todo 자동 추출 결과</div>
`); `);
agenda.todos.forEach(todo => { const todoList = createElement('ul', {
const todoItem = createElement('div', { className: 'todo-list-item' }, ` className: 'agenda-section-content',
<div class="todo-header"> style: 'list-style: none; padding: 0; margin: 0; padding-left: var(--space-md);'
<input type="checkbox" class="todo-checkbox" disabled>
<div class="todo-content">${todo.title}</div>
${createBadge(todo.priority === 'high' ? '높음' : todo.priority === 'medium' ? '보통' : '낮음',
`priority-${todo.priority}`)}
</div>
<div class="todo-meta">
${createAvatar(todo.assignee, 'sm')}
<span>${todo.assignee.name}</span>
<span></span>
<span>${formatDate(todo.dueDate)}</span>
</div>
`);
todosSection.appendChild(todoItem);
}); });
agenda.todos.forEach(todo => {
const todoItem = createElement('li', {
style: 'display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-xs) 0; font-size: var(--font-body); color: var(--gray-700);'
}, `
<span style="color: var(--gray-500); margin-top: 2px;"></span>
<span style="flex: 1;">${todo.title}</span>
`);
todoList.appendChild(todoItem);
});
todosSection.appendChild(todoList);
// 안내 문구 추가
const notice = createElement('p', {
style: 'font-size: var(--font-small); color: var(--gray-500); margin-top: var(--space-md); padding-top: var(--space-sm); border-top: 1px solid var(--gray-200);'
}, '💡 담당자 및 마감일은 회의록 수정 화면에서 지정할 수 있습니다.');
todosSection.appendChild(notice);
content.appendChild(todosSection); content.appendChild(todosSection);
} }

View File

@ -165,6 +165,23 @@
.participant { .participant {
width: calc(50% - var(--space-md) / 2); width: calc(50% - var(--space-md) / 2);
} }
/* 통계 그리드: 모바일에서도 4열 유지, gap만 축소 */
.stats-grid {
gap: var(--space-xs);
}
.stat-item {
padding: var(--space-sm);
}
.stat-value {
font-size: var(--font-base);
}
.stat-label {
font-size: var(--font-xs);
}
} }
/* 회의록 섹션 */ /* 회의록 섹션 */
@ -333,9 +350,6 @@
} }
/* 대시보드 탭 콘텐츠 */ /* 대시보드 탭 콘텐츠 */
.dashboard-section {
margin-bottom: var(--space-lg);
}
.key-points { .key-points {
list-style: none; list-style: none;
@ -393,7 +407,7 @@
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(4, 1fr);
gap: var(--space-md); gap: var(--space-md);
margin-top: var(--space-md); margin-top: var(--space-md);
} }
@ -724,7 +738,11 @@
<!-- 기본 정보 카드 --> <!-- 기본 정보 카드 -->
<div class="info-card"> <div class="info-card">
<div class="meeting-basic-info"> <div class="meeting-basic-info">
<h2>2025년 1분기 제품 기획 회의</h2> <div id="meeting-title-container" style="display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-sm);">
<span class="badge badge-complete">확정완료</span>
<!-- 생성자일 경우 👑 아이콘이 JavaScript로 추가됨 -->
<h2 style="margin: 0;">2025년 1분기 제품 기획 회의</h2>
</div>
<div class="info-row"> <div class="info-row">
<span class="info-icon">📅</span> <span class="info-icon">📅</span>
<span>2025년 10월 25일 14:00 (90분)</span> <span>2025년 10월 25일 14:00 (90분)</span>
@ -733,10 +751,6 @@
<span class="info-icon">📍</span> <span class="info-icon">📍</span>
<span>본사 2층 대회의실</span> <span>본사 2층 대회의실</span>
</div> </div>
<div class="info-row">
<span class="info-icon"></span>
<span class="badge badge-complete">확정완료</span>
</div>
</div> </div>
<div class="participants-list"> <div class="participants-list">
@ -951,10 +965,8 @@
<!-- 대시보드 탭 (기본 노출 탭 - 유저스토리 UFR-MEET-047 요구사항) --> <!-- 대시보드 탭 (기본 노출 탭 - 유저스토리 UFR-MEET-047 요구사항) -->
<div id="dashboard-content" class="tab-content active"> <div id="dashboard-content" class="tab-content active">
<!-- 핵심내용 --> <!-- 핵심내용 -->
<div class="section dashboard-section"> <div class="card mb-lg">
<div class="section-header"> <h3 class="card-title">💡 핵심내용</h3>
<h3 class="section-title">💡 핵심내용</h3>
</div>
<ol class="key-points"> <ol class="key-points">
<li class="key-point"> <li class="key-point">
@ -1004,10 +1016,8 @@
</div> </div>
<!-- 결정사항 --> <!-- 결정사항 -->
<div class="section dashboard-section"> <div class="card mb-lg">
<div class="section-header"> <h3 class="card-title">✅ 결정사항</h3>
<h3 class="section-title">✅ 결정사항</h3>
</div>
<div class="decision-card"> <div class="decision-card">
<div class="decision-content">베타 버전 출시일: 2025년 12월 1일</div> <div class="decision-content">베타 버전 출시일: 2025년 12월 1일</div>
@ -1033,10 +1043,8 @@
</div> </div>
<!-- Todo 진행상황 --> <!-- Todo 진행상황 -->
<div class="section dashboard-section"> <div class="card mb-lg">
<div class="section-header"> <h3 class="card-title">📋 Todo 진행상황</h3>
<h3 class="section-title">📋 Todo 진행상황</h3>
</div>
<!-- 전체 진행률 --> <!-- 전체 진행률 -->
<div style="margin-bottom: var(--space-lg);"> <div style="margin-bottom: var(--space-lg);">
@ -1182,10 +1190,8 @@
</div> </div>
<!-- 관련회의록 --> <!-- 관련회의록 -->
<div class="section dashboard-section"> <div class="card mb-lg">
<div class="section-header"> <h3 class="card-title">📚 관련회의록 (3건)</h3>
<h3 class="section-title">📚 관련회의록 (3건)</h3>
</div>
<div class="reference-item" onclick="window.open('10-회의록상세조회.html', '_blank')"> <div class="reference-item" onclick="window.open('10-회의록상세조회.html', '_blank')">
<div class="reference-header"> <div class="reference-header">
@ -1729,6 +1735,20 @@
* 페이지 초기화 * 페이지 초기화
*/ */
function initPage() { function initPage() {
// 회의 생성자 확인 후 👑 표시
const currentUser = '김민준'; // 현재 로그인 사용자
const isCreator = checkIfUserIsCreator(CURRENT_MEETING_ID, currentUser);
if (isCreator) {
const titleContainer = document.getElementById('meeting-title-container');
const badge = titleContainer.querySelector('.badge');
const crownIcon = document.createElement('span');
crownIcon.textContent = '👑';
crownIcon.style.fontSize = '24px';
// badge 다음에 👑 삽입
badge.insertAdjacentElement('afterend', crownIcon);
}
updateTodoProgress(); updateTodoProgress();
updateFilterCounts(); updateFilterCounts();
} }

View File

@ -604,7 +604,7 @@
const statusBadge = minute.status === 'complete' ? const statusBadge = minute.status === 'complete' ?
'<span class="badge badge-complete">확정완료</span>' : '<span class="badge badge-complete">확정완료</span>' :
'<span class="badge badge-draft">작성중</span>'; '<span class="badge badge-draft">작성중</span>';
const crownEmoji = isCreator ? '<span style="font-size: 16px; flex-shrink: 0;" title="생성자">👑</span>' : ''; const creatorBadge = isCreator ? '<span class="creator-badge" title="생성자">👑</span>' : '';
// 검증완료율 실시간 계산 (작성중 상태일 때만 표시) // 검증완료율 실시간 계산 (작성중 상태일 때만 표시)
const completionRate = minute.status === 'draft' const completionRate = minute.status === 'draft'
@ -615,7 +615,7 @@
<div class="meeting-item" data-status="${minute.status}" data-type="${participationType}" data-date="${minute.date}" onclick="navigateTo('10-회의록상세조회.html')"> <div class="meeting-item" data-status="${minute.status}" data-type="${participationType}" data-date="${minute.date}" onclick="navigateTo('10-회의록상세조회.html')">
<div class="meeting-header"> <div class="meeting-header">
${statusBadge} ${statusBadge}
${crownEmoji} ${creatorBadge}
<h3 class="meeting-title">${minute.title}</h3> <h3 class="meeting-title">${minute.title}</h3>
</div> </div>
<div class="meeting-meta"> <div class="meeting-meta">

View File

@ -179,6 +179,7 @@ a:hover {
font-size: var(--font-h3); font-size: var(--font-h3);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--gray-900); color: var(--gray-900);
margin-bottom: var(--space-md);
} }
.card-body { .card-body {
@ -258,6 +259,28 @@ a:hover {
background: var(--gray-100); background: var(--gray-100);
} }
/* Outline Button (회색 테두리) */
.btn-outline {
background: var(--white);
color: var(--gray-700);
border: 1px solid var(--gray-500);
}
.btn-outline:hover:not(:disabled) {
background: var(--gray-50);
border-color: var(--gray-600);
}
/* Neutral Button (진한 회색 배경) */
.btn-neutral {
background: #424242;
color: var(--white);
}
.btn-neutral:hover:not(:disabled) {
background: var(--gray-900);
}
/* Error Button */ /* Error Button */
.btn-error { .btn-error {
background: var(--error); background: var(--error);
@ -372,6 +395,21 @@ a:hover {
color: var(--gray-700); color: var(--gray-700);
} }
/* Creator Badge (생성자 표시) */
.creator-badge {
font-size: 16px;
flex-shrink: 0;
margin-left: 4px;
cursor: default;
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.creator-badge[title] {
cursor: help;
}
/* Priority Badges */ /* Priority Badges */
.badge-high { .badge-high {
background: #FFEBEE; background: #FFEBEE;
@ -1238,9 +1276,9 @@ input[type="date"]::-webkit-calendar-picker-indicator {
} }
/* ======================================== /* ========================================
22. 섹션 카드 컴포넌트 22. 안건 카드 컴포넌트
======================================== */ ======================================== */
.section { .agenda {
background: var(--white); background: var(--white);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
@ -1248,14 +1286,14 @@ input[type="date"]::-webkit-calendar-picker-indicator {
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
.section-header { .agenda-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
.section-title { .agenda-title {
font-size: var(--font-h3); font-size: var(--font-h3);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--gray-900); color: var(--gray-900);
@ -1264,7 +1302,7 @@ input[type="date"]::-webkit-calendar-picker-indicator {
gap: var(--space-sm); gap: var(--space-sm);
} }
.section-content { .agenda-content {
color: var(--gray-700); color: var(--gray-700);
line-height: 1.6; line-height: 1.6;
} }

View File

@ -333,6 +333,41 @@
} }
``` ```
### 생성자 배지
회의 생성자를 나타내는 크라운 아이콘 배지입니다.
```css
/* 생성자 배지 (👑 아이콘) */
.creator-badge {
font-size: 16px;
flex-shrink: 0;
margin-left: 4px;
cursor: default;
display: inline-flex;
align-items: center;
vertical-align: middle;
}
/* 툴팁 제공 시 */
.creator-badge[title] {
cursor: help;
}
```
**사용 예시**:
```html
<div class="meeting-header">
<span class="badge badge-complete">확정완료</span>
<span class="creator-badge" title="생성자">👑</span>
<h3 class="meeting-title">2024년 4분기 제품 기획 회의</h3>
</div>
```
**사용 위치**:
- 12-회의록목록조회: 회의록 카드 헤더
- 02-대시보드: 최근 회의 카드, 내 회의록 카드
- 10-회의록상세조회: 회의록 정보 섹션
### D-day 배지 ### D-day 배지
Todo 마감일 표시를 위한 D-day 배지 스타일입니다. Todo 마감일 표시를 위한 D-day 배지 스타일입니다.

View File

@ -4,7 +4,7 @@
- **작성일**: 2025-10-21 - **작성일**: 2025-10-21
- **최종 수정일**: 2025-10-24 - **최종 수정일**: 2025-10-24
- **작성자**: 이미준 (서비스 기획자) - **작성자**: 이미준 (서비스 기획자)
- **버전**: 1.4.16 - **버전**: 1.4.18
- **설계 철학**: Mobile First Design - **설계 철학**: Mobile First Design
--- ---
@ -847,7 +847,15 @@ graph TD
- 상태 표시 없음 (발언 중/온라인 등 제거) - 상태 표시 없음 (발언 중/온라인 등 제거)
- 참석자 수 동적 업데이트 (초대 성공 시) - 참석자 수 동적 업데이트 (초대 성공 시)
- **AI 제안 탭**: AI가 생성한 회의록 개선 제안 (3가지 유형) - **AI 제안 탭**: AI가 생성한 회의록 개선 제안
- **실시간 주요 메모 추천** (UFR-MEET-030):
- 음성→텍스트 변환 후 AI가 실시간 분석
- **중요한 내용으로 판단된 경우에만** 주요 메모 항목 추천
- 추천 빈도는 중요 내용 발생에 따라 가변적 (고정 간격 아님)
- 각 추천 항목에 "주요 메모에 추가" 버튼 제공
- 클릭 시 해당 안건의 주요 메모에 자동 저장
- 실시간 업데이트: 새로운 추천은 상단에 표시
- **논의사항 제안 카드**: 제안 내용 + "논의사항에 적용" 버튼 - **논의사항 제안 카드**: 제안 내용 + "논의사항에 적용" 버튼
- 제안 구조: - 제안 구조:
@ -976,7 +984,6 @@ graph TD
- 회의 총 시간 - 회의 총 시간
- 참석자 수 - 참석자 수
- 주요 키워드 (태그 클라우드) - 주요 키워드 (태그 클라우드)
- 발언 통계 (화자별 발언 횟수 및 시간 - 바 차트)
- **안건별 AI 요약 섹션** (신규) - **안건별 AI 요약 섹션** (신규)
- **안건 카드** (안건 개수만큼 반복): - **안건 카드** (안건 개수만큼 반복):
@ -986,7 +993,6 @@ graph TD
- 🔒 "편집 불가" 아이콘 표시 - 🔒 "편집 불가" 아이콘 표시
- 민트 그린 좌측 액센트 라인 - 민트 그린 좌측 액센트 라인
- **상세 요약 정리** (읽기 전용) - **상세 요약 정리** (읽기 전용)
- 논의 주제
- 발언자별 의견 - 발언자별 의견
- 결정 사항 - 결정 사항
- 보류 사항 - 보류 사항
@ -1237,9 +1243,9 @@ graph TD
#### 주요 기능 #### 주요 기능
1. 회의 기본 정보 표시 1. 회의 기본 정보 표시
2. **섹션별 AI 요약 표시** (섹션 최상단) 2. **안건별 AI 요약 표시** (안건 최상단)
3. 섹션별 상세 내용 표시 3. 안건별 상세 내용 표시
4. **참고자료 표시** (섹션 하단) 4. **참고자료 표시** (안건 하단)
5. Todo 항목 및 진행 상황 표시 5. Todo 항목 및 진행 상황 표시
6. 첨부파일 다운로드 6. 첨부파일 다운로드
7. 회의록 수정/공유 액션 7. 회의록 수정/공유 액션
@ -1263,17 +1269,17 @@ graph TD
- "대시보드" 탭 (기본 활성) - "대시보드" 탭 (기본 활성)
- "회의록" 탭 - "회의록" 탭
- **회의록 탭 콘텐츠** (섹션별 구조) - **회의록 탭 콘텐츠** (안건별 구조)
- 각 섹션: - 각 안건:
- 섹션 제목 - 안건 제목
- 검증 완료 배지 (검증된 경우) - 검증 완료 배지 (검증된 경우)
- **AI 회의 내용 요약 영역** (섹션 최상단, 강조 박스) - **AI 회의 내용 요약 영역** (안건 최상단, 강조 박스)
- 요약 아이콘 (💡) - 요약 아이콘 (💡)
- AI 자동 생성 요약 (2-3문장) - AI 자동 생성 요약 (2-3문장)
- 요약 생성/수정 시간 - 요약 생성/수정 시간
- "수정" 버튼 (권한 있는 경우) - "수정" 버튼 (권한 있는 경우)
- 섹션 내용 (마크다운 렌더링) - 안건 내용 (마크다운 렌더링)
- **참고자료 영역** (섹션 하단, 별도 영역) - **참고자료 영역** (안건 하단, 별도 영역)
- "참고자료" 라벨 - "참고자료" 라벨
- 관련 회의록 링크 리스트 (최대 3개): - 관련 회의록 링크 리스트 (최대 3개):
- 링크 아이콘 (📄) - 링크 아이콘 (📄)
@ -1331,17 +1337,17 @@ graph TD
- 대시보드 (기본 활성) - 대시보드 (기본 활성)
- 회의록 - 회의록
- **메인 영역**: - **메인 영역**:
- 회의록 탭: 전체 회의록 내용 (섹션별 구조) - 회의록 탭: 전체 회의록 내용 (안건별 구조)
- 대시보드 탭: 핵심내용, 결정사항, Todo 진행상황, 참고자료 (11-회의록대시보드.html 구조 참조) - 대시보드 탭: 핵심내용, 결정사항, Todo 진행상황, 참고자료 (11-회의록대시보드.html 구조 참조)
#### 인터랙션 #### 인터랙션
1. **탭 전환** 1. **탭 전환**
- "회의록" 탭: 전체 회의록 내용 표시 (섹션별 구조) - "회의록" 탭: 전체 회의록 내용 표시 (안건별 구조)
- "대시보드" 탭: 핵심내용, 결정사항, Todo, 참고자료 요약 표시 - "대시보드" 탭: 핵심내용, 결정사항, Todo, 참고자료 요약 표시
- 탭 전환 시 URL 변경 없이 클라이언트 사이드 렌더링 - 탭 전환 시 URL 변경 없이 클라이언트 사이드 렌더링
2. **회의록 탭 인터랙션** 2. **회의록 탭 인터랙션**
- **섹션 네비게이션**: 섹션 제목 클릭 → 해당 섹션으로 스크롤 - **안건 네비게이션**: 안건 제목 클릭 → 해당 안건으로 스크롤
- **접기/펼치기**: 긴 내용은 초기 접힌 상태, 클릭으로 펼침 - **접기/펼치기**: 긴 내용은 초기 접힌 상태, 클릭으로 펼침
- **AI 요약 편집**: - **AI 요약 편집**:
- "수정" 버튼 클릭 (권한 있는 경우) → 인라인 편집 모드 - "수정" 버튼 클릭 (권한 있는 경우) → 인라인 편집 모드
@ -1354,10 +1360,10 @@ graph TD
3. **대시보드 탭 인터랙션** 3. **대시보드 탭 인터랙션**
- **핵심내용 섹션**: - **핵심내용 섹션**:
- 키워드 태그 클릭 → 해당 키워드 관련 섹션으로 스크롤 - 키워드 태그 클릭 → 해당 키워드 관련 안건으로 스크롤
- 통계 항목 클릭 → 상세 정보 툴팁 표시 - 통계 항목 클릭 → 상세 정보 툴팁 표시
- **결정사항 섹션**: - **결정사항 섹션**:
- 결정사항 카드 클릭 → 회의록 탭의 해당 섹션으로 이동 - 결정사항 카드 클릭 → 회의록 탭의 해당 안건으로 이동
- 배경 설명 접기/펼치기 - 배경 설명 접기/펼치기
- **Todo 진행상황**: - **Todo 진행상황**:
- 필터 탭 클릭 → 해당 상태의 Todo만 표시 - 필터 탭 클릭 → 해당 상태의 Todo만 표시
@ -1384,8 +1390,8 @@ graph TD
- **입력**: 회의록 ID, 활성 탭 (회의록/대시보드/타임라인) - **입력**: 회의록 ID, 활성 탭 (회의록/대시보드/타임라인)
- **출력**: - **출력**:
- **회의 기본 정보**: 제목, 일시, 참석자, 장소, 상태, 작성자, 수정 시간 - **회의 기본 정보**: 제목, 일시, 참석자, 장소, 상태, 작성자, 수정 시간
- **섹션별 AI 요약**: 자동 생성 요약, 수정 이력 - **안건별 AI 요약**: 자동 생성 요약, 수정 이력
- **섹션별 내용**: 마크다운 형식 - **안건별 내용**: 마크다운 형식
- **참고자료 목록**: - **참고자료 목록**:
- 관련 회의록 (제목, 날짜, 관련도, 요약) - 관련 회의록 (제목, 날짜, 관련도, 요약)
- 프로젝트 문서 (제목, 작성자, 관련도) - 프로젝트 문서 (제목, 작성자, 관련도)
@ -1403,7 +1409,7 @@ graph TD
#### 에러 처리 #### 에러 처리
- **회의록 로딩 실패**: "회의록을 불러올 수 없습니다" + 재시도 버튼 - **회의록 로딩 실패**: "회의록을 불러올 수 없습니다" + 재시도 버튼
- **AI 요약 로딩 실패**: "요약을 불러올 수 없습니다" (섹션 내용은 정상 표시) - **AI 요약 로딩 실패**: "요약을 불러올 수 없습니다" (안건 내용은 정상 표시)
- **참고자료 로딩 실패**: "참고자료를 불러올 수 없습니다" (빈 상태 표시) - **참고자료 로딩 실패**: "참고자료를 불러올 수 없습니다" (빈 상태 표시)
- **대시보드 데이터 로딩 실패**: "대시보드를 불러올 수 없습니다" + 재시도 버튼 - **대시보드 데이터 로딩 실패**: "대시보드를 불러올 수 없습니다" + 재시도 버튼
- **권한 없음**: "수정" 버튼 비활성화, "조회 권한만 있습니다" 메시지 - **권한 없음**: "수정" 버튼 비활성화, "조회 권한만 있습니다" 메시지
@ -1708,8 +1714,10 @@ graph TD
- 각 회의록 항목 (meeting-item): - 각 회의록 항목 (meeting-item):
- **좌측 영역**: - **좌측 영역**:
- 회의 제목 (H5, 볼드) - 회의 제목 (H5, 볼드)
- **생성자 표시**: 현재 사용자가 회의 생성자인 경우 👑 아이콘 표시 (16px, title="생성자")
- 메타정보 (Caption, 회색): - 메타정보 (Caption, 회색):
- 회의 일시 (날짜 + 시간) · 참석자 수 - 회의 일시 (날짜 + 시간) · 참석자 수
- 검증완료율 (작성중 상태일 때만): "✓ {completionRate}% 검증완료" 배지
- 최종 수정 시간 (Caption, 회색): - 최종 수정 시간 (Caption, 회색):
- 상대 시간 표시 ("1시간 전", "어제", "3일 전") - 상대 시간 표시 ("1시간 전", "어제", "3일 전")
- **우측 영역**: - **우측 영역**:
@ -2130,6 +2138,8 @@ graph TD
| 1.4.14 | 2025-10-24 | 이미준 | 12-회의록목록조회 화면 데이터 아키텍처 문서화<br>- **데이터 아키텍처 섹션 추가**: 데이터/뷰 레이어 분리 구조 설명<br> - 데이터 레이어: common.js → SAMPLE_MINUTES 배열 (30개 샘플)<br> - 뷰 레이어: 12-회의록목록조회.html → renderMeetings(), createMeetingCard() 함수<br> - 렌더링 방식: 동적 렌더링, 초기 10개 표시, "10개 더보기" 버튼으로 추가 로딩<br>- **정렬 옵션 레이블 변경**: "최신순" → "최근수정순", "회의일시순" → "최근회의순"<br>- **페이지네이션 기능 문서화**: 초기 10개 표시, "10개 더보기" 버튼 기능 설명<br>- **샘플 데이터 분포 명시**: 총 30개 (작성중 13개, 확정완료 17개)<br>- **프로토타입 파일 경로 추가**: design/uiux/prototype/12-회의록목록조회.html<br>- **스타일 가이드 버전 동기화**: v1.2.4 | | 1.4.14 | 2025-10-24 | 이미준 | 12-회의록목록조회 화면 데이터 아키텍처 문서화<br>- **데이터 아키텍처 섹션 추가**: 데이터/뷰 레이어 분리 구조 설명<br> - 데이터 레이어: common.js → SAMPLE_MINUTES 배열 (30개 샘플)<br> - 뷰 레이어: 12-회의록목록조회.html → renderMeetings(), createMeetingCard() 함수<br> - 렌더링 방식: 동적 렌더링, 초기 10개 표시, "10개 더보기" 버튼으로 추가 로딩<br>- **정렬 옵션 레이블 변경**: "최신순" → "최근수정순", "회의일시순" → "최근회의순"<br>- **페이지네이션 기능 문서화**: 초기 10개 표시, "10개 더보기" 버튼 기능 설명<br>- **샘플 데이터 분포 명시**: 총 30개 (작성중 13개, 확정완료 17개)<br>- **프로토타입 파일 경로 추가**: design/uiux/prototype/12-회의록목록조회.html<br>- **스타일 가이드 버전 동기화**: v1.2.4 |
| 1.4.15 | 2025-10-24 | 이미준 | 06-검증완료 화면 삭제 (유저스토리 v2.1.2 변경사항 반영)<br>- **화면 삭제**: 06-검증완료 화면 전체 삭제<br> - 안건별 검증 기능이 11-회의록수정 화면으로 통합됨<br> - 섹션별 검증 방식에서 안건별 검증 방식으로 변경 (유저스토리 UFR-COLLAB-030 → 안건 기반 구조로 전환)<br>- **유저스토리 매핑 업데이트**:<br> - Collaboration 서비스: UFR-COLLAB-010 ~ UFR-COLLAB-030 → UFR-COLLAB-010 ~ UFR-COLLAB-020로 변경<br> - 프로토타입 화면 목록 테이블에서 06-검증완료 행 제거<br>- **화면 번호 유지**: 다른 화면 번호는 변경하지 않음 (프로토타입 파일명 유지)<br> - 07-회의종료, 09-Todo관리, 10-회의록상세조회, 11-회의록수정, 12-회의록목록조회 번호 유지<br>- **변경 이력**: 과거 버전의 UFR-COLLAB-030 언급은 역사적 맥락으로 유지 | | 1.4.15 | 2025-10-24 | 이미준 | 06-검증완료 화면 삭제 (유저스토리 v2.1.2 변경사항 반영)<br>- **화면 삭제**: 06-검증완료 화면 전체 삭제<br> - 안건별 검증 기능이 11-회의록수정 화면으로 통합됨<br> - 섹션별 검증 방식에서 안건별 검증 방식으로 변경 (유저스토리 UFR-COLLAB-030 → 안건 기반 구조로 전환)<br>- **유저스토리 매핑 업데이트**:<br> - Collaboration 서비스: UFR-COLLAB-010 ~ UFR-COLLAB-030 → UFR-COLLAB-010 ~ UFR-COLLAB-020로 변경<br> - 프로토타입 화면 목록 테이블에서 06-검증완료 행 제거<br>- **화면 번호 유지**: 다른 화면 번호는 변경하지 않음 (프로토타입 파일명 유지)<br> - 07-회의종료, 09-Todo관리, 10-회의록상세조회, 11-회의록수정, 12-회의록목록조회 번호 유지<br>- **변경 이력**: 과거 버전의 UFR-COLLAB-030 언급은 역사적 맥락으로 유지 |
| 1.4.16 | 2025-10-24 | 이미준 | 사용자 역할 용어 통일 (유저스토리 v2.1.2 반영)<br>- **용어 정의 명확화**: "회의 생성자"와 "회의 참석자" 용어로 통일<br> - 설계 목표: "회의록 작성자" → "회의 참석자"로 수정<br>- **화면별 권한 정보 추가**:<br> - 03-회의예약: 모든 사용자 (예약 생성 시 자동으로 회의 생성자가 됨)<br> - 04-템플릿선택: 회의 생성자 전용<br> - 05-회의진행: 회의 시작/종료는 회의 생성자 전용, 회의록 편집은 모든 참석자<br> - 07-회의종료: 회의 생성자 전용<br> - 09-Todo관리: 모든 회의 참석자 (본인이 담당자인 Todo만 조회/수정 가능)<br> - 10-회의록상세조회: 모든 회의 참석자 (조회 전용)<br> - 11-회의록수정: 검증완료 전(모든 참석자), 검증완료 후(회의 생성자만) - 기존 권한 제어 유지<br> - 12-회의록목록조회: 모든 회의 참석자 (본인이 참석한 회의록만 조회)<br>- **스타일 가이드 동기화**: design/uiux/style-guide.md v1.2.5 (용어 정의 섹션 추가)<br>- **통일성 달성**: 유저스토리, 화면설계서, 스타일 가이드 간 용어 완전 통일 | | 1.4.16 | 2025-10-24 | 이미준 | 사용자 역할 용어 통일 (유저스토리 v2.1.2 반영)<br>- **용어 정의 명확화**: "회의 생성자"와 "회의 참석자" 용어로 통일<br> - 설계 목표: "회의록 작성자" → "회의 참석자"로 수정<br>- **화면별 권한 정보 추가**:<br> - 03-회의예약: 모든 사용자 (예약 생성 시 자동으로 회의 생성자가 됨)<br> - 04-템플릿선택: 회의 생성자 전용<br> - 05-회의진행: 회의 시작/종료는 회의 생성자 전용, 회의록 편집은 모든 참석자<br> - 07-회의종료: 회의 생성자 전용<br> - 09-Todo관리: 모든 회의 참석자 (본인이 담당자인 Todo만 조회/수정 가능)<br> - 10-회의록상세조회: 모든 회의 참석자 (조회 전용)<br> - 11-회의록수정: 검증완료 전(모든 참석자), 검증완료 후(회의 생성자만) - 기존 권한 제어 유지<br> - 12-회의록목록조회: 모든 회의 참석자 (본인이 참석한 회의록만 조회)<br>- **스타일 가이드 동기화**: design/uiux/style-guide.md v1.2.5 (용어 정의 섹션 추가)<br>- **통일성 달성**: 유저스토리, 화면설계서, 스타일 가이드 간 용어 완전 통일 |
| 1.4.17 | 2025-10-24 | 강지수 | 10-회의록상세조회 화면 용어 통일 (섹션 → 안건)<br>- **용어 변경 (요구사항설계검토-report-V1.2.md 반영)**:<br> - 모든 "섹션별" → "안건별"로 용어 통일<br> - 주요 기능, UI 구성요소, 인터랙션, 데이터 요구사항, 에러 처리 섹션 전체 업데이트<br>- **CSS 클래스명 변경 (공통 스타일 + 프로토타입)**:<br> - common.css: `.section``.agenda`, `.section-header``.agenda-header`, `.section-title``.agenda-title`, `.section-content``.agenda-content`<br> - 10-회의록상세조회.html: 모든 section 클래스를 agenda 클래스로 일괄 변경<br>- **HTML 주석 업데이트**: "회의록 섹션" → "회의록 안건", "섹션 내용" → "안건 내용"<br>- **일관성 달성**: 유저스토리 v2.1.2의 안건 기반 구조와 완전히 일치 |
| 1.4.18 | 2025-10-24 | 강지수 | 12-회의록목록조회 화면 생성자 표시 기능 추가 (유저스토리 v2.1.3 반영)<br>- **목록 표시 정보 추가**: 회의 생성자 표시 (👑 아이콘)<br> - 현재 사용자가 회의 생성자인 경우 회의록 카드 헤더에 👑 아이콘 표시<br> - 위치: 상태 배지와 회의 제목 사이<br> - 스타일: font-size 16px, title="생성자" 툴팁 제공<br>- **UI 구성요소 업데이트**: 회의록 목록 섹션 명세 수정<br> - 좌측 영역에 "생성자 표시" 항목 추가<br> - 검증완료율 표시 조건 명시 (작성중 상태일 때만)<br>- **프로토타입 수정**: design/uiux/prototype/12-회의록목록조회.html<br> - createMeetingCard() 함수: crownEmoji 변수를 creatorBadge로 변경 및 .creator-badge 클래스 적용<br> - common.css: .creator-badge 스타일 추가 (inline-flex, 16px, margin-left 4px, cursor help)<br>- **스타일 가이드 업데이트**: design/uiux/style-guide.md v1.2.6<br> - 생성자 배지 섹션 추가 (배지 시스템 내 우선순위 배지 다음)<br> - 사용 예시 및 사용 위치 명시 (12-회의록목록조회, 02-대시보드, 10-회의록상세조회) |
--- ---
@ -2145,3 +2155,5 @@ graph TD
- 타이포그래피 - 타이포그래피
- 컴포넌트 라이브러리 - 컴포넌트 라이브러리
- 아이콘 세트 - 아이콘 세트
| 1.4.17 | 2025-10-24 | 강지수 | 07-회의종료 화면 STT 한계 반영 (유저스토리 v2.1.2)<br>- **STT 화자 식별 불가 반영**: STT는 화자를 식별할 수 없으므로 화자 관련 기능 제거<br> - 발언 통계 섹션 삭제<br> - 안건별 "발언자별 의견" 섹션 삭제<br>- **통계 영역 디자인 개선**: 정보성 디자인으로 명확화<br> - 배경색: var(--white) → var(--gray-50)<br> - 숫자 색상: var(--primary) → var(--gray-900)<br> - 라벨 색상: var(--gray-500) → var(--gray-600)<br> - 정보 표시 전용으로 시각적 구분 명확화<br>- **안건 섹션 구분 개선**:<br> - 안건 간 하단 보더 추가 (1px solid var(--gray-200))<br> - 섹션 제목에 primary 색상 세로 바 추가 (::before pseudo-element)<br> - 콘텐츠 영역 좌측 패딩 추가로 계층 구조 명확화<br>- **연관 문서 업데이트**:<br> - 유저스토리 UFR-MEET-040: "발언 횟수 (화자별)" 항목 제거<br> - UI/UX 설계서 07-회의종료: 발언 통계 및 발언자별 의견 항목 제거 |
| 1.4.18 | 2025-10-24 | 강지수 | 05-회의진행 실시간 주요 메모 추천 기능 명확화 (유저스토리 v2.1.1)<br>- **AI 제안 탭 기능 상세화**: 실시간 주요 메모 추천 기능 명시 추가<br> - UFR-MEET-030: 실시간 AI 주요 메모 추천<br> - 음성→텍스트 변환 후 AI가 실시간 분석<br> - **중요한 내용으로 판단된 경우에만** 주요 메모 항목 추천<br> - 추천 빈도는 중요 내용 발생에 따라 가변적 (3-5초 고정 간격 아님)<br> - 각 추천 항목에 "주요 메모에 추가" 버튼 제공<br> - 실시간 업데이트: 새로운 추천은 상단에 표시<br>- **프로토타입 확인**: 05-회의진행.html의 AI 제안 탭이 실시간 주요 메모 추천 기능을 포함하고 있음을 확인<br>- **참조**: design/uiux/요구사항설계검토-report-V1.2.md (실시간 주요 메모 추천 명시 부족 개선) |

View File

@ -0,0 +1,766 @@
# 유저스토리 v2.1.2 vs UI/UX 설계 크로스 체크 리포트
**작성일**: 2025-10-24
**작성자**: AI Assistant (Claude)
**버전**: 1.0
---
## 1. 주요 발견사항 요약
### 전체 요약
- **분석 대상**: 유저스토리 v2.1.2, UI/UX 설계서 v1.4.14, 프로토타입 파일 13개
- **불일치 항목 수**: 총 12개 (🔴 높음 4개, 🟡 중간 5개, 🟢 낮음 3개)
- **주요 이슈**: 07-회의종료 화면 기능 불일치, 06-검증완료 화면 존재 여부, 용어 사용 불일치
### 중요도별 분류
#### 🔴 높음 (즉시 수정 필요)
1. **07-회의종료 화면 편집 불가 정책 미반영**
2. **07-회의종료 화면 3가지 선택 옵션 미반영**
3. **07-회의종료 화면 안건별 AI 요약 표시 미반영**
4. **06-검증완료 화면 삭제 필요**
#### 🟡 중간 (우선 수정 권장)
5. **11-회의록수정 화면 안건 기반 구조 미반영**
6. **11-회의록수정 화면 안건별 AI 한줄 요약 미표시**
7. **11-회의록수정 화면 안건별 검증 UI 미구현**
8. **용어 통일 필요: "작성자" → "생성자"/"참석자"**
9. **회의록목록조회 화면 "생성자" 표시 미반영**
#### 🟢 낮음 (검토 후 수정)
10. **05-회의진행 화면 실시간 주요 메모 추천 기능 명시 부족**
11. **10-회의록상세조회 화면 안건별 표시 명시 부족**
12. **스타일 가이드 안건 관련 컴포넌트 누락**
---
## 2. 화면별 상세 분석
### 07-회의종료
#### 유저스토리 v2.1.2 요구사항
- **UFR-MEET-040 (회의종료)**
- 회의 종료 화면은 **확인 전용 (편집 불가)**
- **안건별 AI 요약 전체 표시**:
- 안건별 AI 한줄 요약 (편집 불가)
- 안건별 상세 요약 (확인만 가능)
- Todo 자동 추출 결과 (확인만 가능)
- 사용자에게 **3가지 선택 옵션** 제공:
- 옵션 1: 회의록 수정 화면으로 이동
- 옵션 2: 바로 최종 확정 (모든 안건 자동 검증 완료)
- 옵션 3: 대시보드로 이동
- 회의록 상태: 옵션 1, 3 선택 시 "작성중", 옵션 2 선택 시 "확정완료"
#### UI/UX 설계서 내용
- **화면 목적**: 회의 통계 표시 및 최종 회의록 확정
- 주요 기능:
1. 회의 통계 표시
2. 주요 키워드 클라우드
3. AI 자동 추출된 Todo 항목 확인
4. **최종 회의록 확정** ← 편집 불가 정책 미반영
5. 다음 액션 선택 (공유, 수정, 대시보드 복귀) ← 3가지 옵션 불일치
- **AI Todo 추출 결과**:
- "AI가 추출한 Todo" 섹션
- Todo 항목 리스트 (담당자, 마감일)
- **"Todo 수정" 버튼** ← 편집 불가 정책 위반
- **하단 액션**:
- "회의록 공유하기" 버튼 ← v2.1.2에 없음
- "회의록 수정하기" 버튼
- "대시보드로 돌아가기" 버튼
#### 프로토타입 구현 상태 (07-회의종료.html)
```html
<!-- AI Todo 추출 결과 -->
<button class="btn btn-ghost btn-sm" onclick="openModal('todoEditModal')">수정</button>
<!-- ← 편집 불가 정책 위반 -->
<!-- 하단 액션 바 -->
<button class="btn btn-secondary" onclick="navigateTo('11-회의록수정.html')">수정</button>
<button class="btn btn-primary" onclick="navigateTo('02-대시보드.html')">대시보드로 이동</button>
<!-- ← 3가지 옵션 미구현, "바로 최종 확정" 옵션 없음 -->
```
#### 불일치 사항
1. 🔴 **편집 불가 정책 미반영**
- 유저스토리: 확인 전용, 편집 불가
- UI/UX 설계서 & 프로토타입: "Todo 수정" 버튼 존재
- **수정 필요**: "수정" 버튼 제거, 확인만 가능하도록 변경
2. 🔴 **3가지 선택 옵션 미반영**
- 유저스토리: 회의록 수정 / 바로 최종 확정 / 대시보드 이동
- 프로토타입: 수정 / 대시보드 이동만 있음
- **수정 필요**: "바로 최종 확정" 버튼 추가, 선택 시 모든 안건 자동 검증 처리
3. 🔴 **안건별 AI 요약 표시 미반영**
- 유저스토리: 안건별 AI 한줄 요약 + 상세 요약 전체 표시
- UI/UX 설계서 & 프로토타입: Todo만 표시, 안건 구조 없음
- **수정 필요**: 안건별 섹션으로 구조화, 각 안건의 AI 요약 표시
4. 🟢 **"회의록 공유하기" 버튼 존재**
- UI/UX 설계서: "회의록 공유하기" 버튼 있음
- 유저스토리 v2.1.2: 공유 기능 제거됨 (v2.0.1에서)
- **수정 필요**: "공유" 버튼 제거
#### 권장 수정사항
```
[하단 액션 바 수정안]
- "회의록 수정" 버튼 (옵션 1)
- "바로 최종 확정" 버튼 (옵션 2, Primary)
- "대시보드로 이동" 버튼 (옵션 3)
[AI Todo 추출 결과 섹션 수정안]
- "Todo 수정" 버튼 제거
- 확인만 가능하도록 readonly 처리
[안건별 AI 요약 표시 추가]
- 각 안건별 카드로 표시
- 안건 제목
- AI 한줄 요약 (읽기 전용)
- 상세 요약 (읽기 전용)
- Todo 목록 (읽기 전용)
```
---
### 11-회의록수정
#### 유저스토리 v2.1.2 요구사항
- **UFR-MEET-055 (회의록수정)**
- 진입 경로: 10-회의록상세조회 → "수정" 버튼 클릭
- **안건 기반 회의록 구조**:
- 각 안건별 섹션
- 안건별 AI 한줄 요약 (편집 불가)
- 안건별 상세 요약 (편집 가능)
- 안건별 검증 상태 (체크박스)
- 수정 가능 항목:
- ✅ 회의 제목
- ❌ 회의 일시/장소 (readonly)
- ✅ 참석자 목록 (회의 생성자만)
- ✅ 안건별 AI 요약 (AI 재생성)
- ✅ 안건별 내용
- ✅ 관련회의록
- 검증완료 안건: 회의 생성자만 잠금 해제 후 수정 가능
- **UFR-AI-036 (AI한줄요약)**
- 각 안건마다 편집 불가능한 AI 한줄 요약 제공
- 30자 이내 간결한 표현
- 회의 종료 시 1회 생성, 생성 후 편집 불가
- **UFR-COLLAB-030 (검증완료)**
- 안건별 검증 완료 처리
- 11-회의록수정 화면에서 안건별 검증 처리
- 별도의 06-검증완료 화면 불필요
#### UI/UX 설계서 내용
- **주요 기능**:
1. 회의 기본 정보 표시 및 수정
2. 회의록 내용 수정 **(섹션별)** ← 안건별이 아닌 섹션별
3. AI 요약 수정 (섹션별)
4. 참고자료 편집
5. Todo 수정 (회의 생성자만)
6. 자동 저장
- **섹션 구조**:
- 섹션 1 편집: "1. 신제품 기획 방향"
- 섹션 2 편집: "2. 개발 일정 및 리소스"
- 섹션 3 편집: "3. 마케팅 전략"
- ← "섹션" 용어 사용, "안건" 용어 없음
- **AI 요약 편집**:
- AI 요약 텍스트 필드 (편집 가능)
- "AI 재생성" 버튼
- ← AI 한줄 요약 (편집 불가) 항목 없음
- **검증 완료 표시**:
- 체크박스 (검증 완료, disabled)
- 🔒 읽기 전용 배지
- ← 섹션별 검증, 안건별 검증 아님
#### 프로토타입 구현 상태 (11-회의록수정.html)
```html
<!-- 섹션 1 편집 -->
<div class="section">
<div class="section-header">
<h3 class="section-title">
1. 신제품 기획 방향
<span class="badge badge-complete">검증완료</span>
</h3>
</div>
<!-- AI 요약 편집 -->
<div class="ai-summary-edit">
<div class="ai-summary-header">
<span class="ai-summary-label">💡 AI 요약</span>
<button class="btn-secondary btn-sm" onclick="regenerateSummary(1)">AI 재생성</button>
</div>
<textarea class="ai-summary-textarea" readonly>
신제품은 AI 기반 회의록 자동화 서비스로 결정...
</textarea>
<!-- ← AI 한줄 요약 (편집 불가) 항목 없음 -->
</div>
<!-- 검증 완료 (읽기 전용) -->
<div class="verification-lock">
<input type="checkbox" class="checkbox" id="verify-1" checked disabled>
<label for="verify-1">
<span class="font-medium">검증 완료</span>
<span class="text-caption text-muted"> (잠금됨 · 회의 생성자만 수정 가능)</span>
</label>
<span class="readonly-badge">🔒 읽기 전용</span>
</div>
</div>
```
#### 불일치 사항
1. 🟡 **안건 기반 구조 미반영**
- 유저스토리: "안건별" 회의록 구조
- UI/UX 설계서 & 프로토타입: "섹션별" 구조
- **수정 필요**: "섹션" 용어를 "안건"으로 통일
2. 🟡 **안건별 AI 한줄 요약 미표시**
- 유저스토리: 각 안건마다 편집 불가능한 AI 한줄 요약 (30자 이내)
- 프로토타입: AI 요약은 있지만 "한줄 요약"과 "상세 요약" 구분 없음
- **수정 필요**:
- AI 한줄 요약 (읽기 전용, 30자) 추가
- 기존 AI 요약을 "상세 요약"으로 명칭 변경
3. 🟡 **안건별 검증 UI 구현 상태**
- 유저스토리: 11-회의록수정 화면에서 안건별 검증 처리
- 프로토타입: 검증완료 체크박스 있으나 disabled (수정 불가)
- **검토 필요**:
- 회의 생성자일 때 체크박스 활성화 필요
- 잠금 해제 버튼 추가 고려
#### 권장 수정사항
```
[안건 구조 수정안]
<!-- 안건 1 편집 -->
<div class="agenda-section">
<div class="agenda-header">
<h3 class="agenda-title">
안건 1. 신제품 기획 방향
<span class="badge badge-complete">검증완료</span>
</h3>
</div>
<!-- AI 한줄 요약 (편집 불가) -->
<div class="ai-oneline-summary-readonly">
<span class="ai-icon"></span>
<span class="summary-text">AI 기반 회의록 서비스 개발 방향 결정</span>
</div>
<!-- AI 상세 요약 (편집 가능) -->
<div class="ai-summary-edit">
<div class="ai-summary-header">
<span class="ai-summary-label">💡 AI 상세 요약</span>
<button class="btn-secondary btn-sm">AI 재생성</button>
</div>
<textarea class="ai-summary-textarea">...</textarea>
</div>
<!-- 안건별 검증 (회의 생성자만 활성화) -->
<div class="agenda-verification">
<input type="checkbox" id="verify-agenda-1" checked>
<label>안건 검증 완료</label>
<button class="btn-ghost btn-sm" v-if="isCreator && isLocked">잠금 해제</button>
</div>
</div>
```
---
### 06-검증완료
#### 유저스토리 v2.1.2 요구사항
- **UFR-COLLAB-030 (검증완료)**:
- 11-회의록수정 화면에서 안건별 검증 처리
- **별도의 06-검증완료 화면 불필요**
#### UI/UX 설계서 내용
- **화면 존재**: 06-검증완료 화면 정의됨
- 주요 기능:
1. 섹션별 검증 상태 표시
2. 검증 완료 체크 (참석자별)
3. 미검증 섹션 안내
4. 섹션 잠금 (회의 생성자만)
#### 프로토타입 구현 상태
- **파일 존재**: `06-검증완료.html` (528줄)
- 주요 기능 구현:
- 섹션별 검증 카드
- 검증 완료 버튼
- 잠금 해제 버튼
- 진행률 표시
#### 불일치 사항
1. 🔴 **06-검증완료 화면 삭제 필요**
- 유저스토리: 별도 화면 불필요, 11-회의록수정에 통합
- UI/UX 설계서 & 프로토타입: 06-검증완료 화면 존재
- **수정 필요**:
- 06-검증완료.html 파일 삭제
- UI/UX 설계서에서 해당 화면 설명 제거
- 모든 링크 및 내비게이션에서 제거
#### 권장 수정사항
```
1. 프로토타입 파일 삭제
- design/uiux/prototype/06-검증완료.html 삭제
2. UI/UX 설계서 수정
- "### 06-검증완료" 섹션 전체 삭제
- 프로토타입 화면 목록 테이블에서 제거
3. 11-회의록수정 화면에 검증 기능 통합
- 각 안건별 검증 체크박스 추가
- 회의 생성자는 검증 상태 변경 가능
- 참석자는 자신의 검증만 처리 가능
```
---
### 10-회의록상세조회
#### 유저스토리 v2.1.2 요구사항
- **UFR-MEET-047 (회의록상세조회)**
- 회의 기본 정보 표시 (제목, 일시, 참석자, 장소, 상태)
- **안건별 상세 내용 표시** (섹션별 → 안건별)
- AI 요약 섹션 (안건별)
- 상세 내용 섹션 (논의 사항, 결정 사항 등)
- 관련 회의록 섹션
#### UI/UX 설계서 내용
- 탭 구성: 대시보드 / 회의록 (기본: 대시보드)
- **대시보드 탭**:
- 핵심내용 카드 (AI 요약)
- 결정사항 카드
- Todo 진행상황 카드
- 참고자료 카드
- **회의록 탭**:
- 회의 기본 정보
- 섹션별 AI 요약 및 내용 ← 안건별이 아님
#### 프로토타입 구현 상태 (10-회의록상세조회.html)
- 탭: 대시보드 / 회의록
- 섹션 구조 (회의록 탭):
```html
<div class="section">
<h3 class="section-title">1. 신제품 기획 방향</h3>
<div class="ai-summary">...</div>
<div class="section-content">...</div>
</div>
```
#### 불일치 사항
1. 🟢 **안건별 표시 명시 부족**
- 유저스토리: "안건별 상세 내용 표시"
- UI/UX 설계서 & 프로토타입: "섹션별" 용어 사용
- **수정 필요**: "섹션"을 "안건"으로 명칭 변경
#### 권장 수정사항
```
[UI/UX 설계서 수정]
- "섹션별 상세 내용 표시" → "안건별 상세 내용 표시"
- "섹션별 AI 요약" → "안건별 AI 요약"
[프로토타입 수정]
- class="section" → class="agenda"
- class="section-title" → class="agenda-title"
- HTML 주석 및 변수명 일괄 변경
```
---
### 05-회의진행
#### 유저스토리 v2.1.2 요구사항
- **UFR-AI-010 (회의록자동작성) - 시나리오 1**:
- **실시간 AI 주요 메모 작성**
- 텍스트 변환되면 자동으로 주요 메모 항목 추천
- 실시간 업데이트 (3-5초 간격)
- 참석자가 필요한 항목만 선택하여 저장
#### UI/UX 설계서 내용
- 주요 기능:
1. 음성 녹음 및 STT
2. 회의록 실시간 편집
3. 참석자 목록 관리
4. AI 제안 기능 (우측 탭)
- **데이터 출력**:
- 실시간 텍스트 변환 결과 (STT)
- 편집된 회의록 내용
- AI 제안 목록 (회의록 개선 제안) ← 주요 메모 추천과 차이
#### 프로토타입 구현 상태 (05-회의진행.html)
- 우측 탭:
- 참석자
- AI 제안 (논의사항 제안, 결정사항 제안, 액션아이템 제안)
- 용어 사전
- 관련 자료
- ← 실시간 주요 메모 추천 기능 명시 없음
#### 불일치 사항
1. 🟢 **실시간 주요 메모 추천 기능 명시 부족**
- 유저스토리: 실시간 AI 주요 메모 항목 추천 (3-5초 간격)
- UI/UX 설계서: "AI 제안 목록 (회의록 개선 제안)"
- 프로토타입: 논의사항/결정사항/액션아이템 제안만 있음
- **검토 필요**:
- 실시간 주요 메모 추천 기능이 "AI 제안"에 포함된 것인지 명확화
- 별도 UI 필요 여부 검토
#### 권장 수정사항
```
[UI/UX 설계서 명확화]
- "AI 제안 기능" 섹션에 다음 추가:
"실시간 AI 주요 메모 추천:
- 텍스트 변환 후 3-5초 간격으로 주요 메모 항목 자동 추천
- 참석자가 선택하여 저장
- 우측 'AI 제안' 탭에서 확인 가능"
[프로토타입 검토]
- 현재 AI 제안 탭 기능이 실시간 주요 메모 추천인지 확인
- 필요 시 별도 UI 컴포넌트 추가
```
---
### 12-회의록목록조회
#### 유저스토리 v2.1.2 요구사항
- **UFR-MEET-046 (회의록목록조회)**
- 필터: 참여 유형(참석한/생성한), 상태(전체/작성중/확정완료)
- 목록 표시 정보:
- 회의 제목
- 회의 일시
- 참석자 수
- 회의록 상태
- 검증 완료율
- **생성자 표시 (👑 아이콘)** ← v2.1.2에서 추가됨
- 마지막 수정 시간
#### UI/UX 설계서 내용
- 필터 및 정렬:
- 참여 유형: 참석한 회의 / 생성한 회의
- 상태: 전체 / 작성중 / 확정완료
- 목록 카드 정보:
- 회의 제목
- 날짜/시간
- 참석자 수
- 상태 배지
- 검증률 (작성중인 경우)
- ← 생성자 표시 명시 없음
#### 프로토타입 구현 상태 (12-회의록목록조회.html)
- 필터 및 정렬 구현됨
- 회의록 카드:
```javascript
// common.js - renderMinuteCard 함수
<div class="minute-card">
<div class="minute-title">${minute.title}</div>
<!-- 생성자 표시 로직 없음 -->
</div>
```
#### 불일치 사항
1. 🟡 **생성자 표시 미반영**
- 유저스토리: 생성자 표시 (👑 아이콘)
- UI/UX 설계서: 명시 없음
- 프로토타입: 구현 없음
- **수정 필요**:
- 생성자 표시 UI 추가
- 현재 사용자가 생성자일 경우 👑 아이콘 표시
#### 권장 수정사항
```
[UI/UX 설계서 수정]
"목록 표시 정보" 섹션에 추가:
- 생성자 표시: 현재 사용자가 회의 생성자인 경우 👑 아이콘 표시
[프로토타입 수정 - common.js]
function renderMinuteCard(minute, currentUserId) {
const isCreator = minute.creatorId === currentUserId;
return `
<div class="minute-card">
<div class="minute-header">
<h3 class="minute-title">
${minute.title}
${isCreator ? '<span class="creator-badge">👑</span>' : ''}
</h3>
</div>
...
</div>
`;
}
```
---
## 3. 용어 사용 불일치
### "작성자" vs "생성자"/"참석자"
#### 유저스토리 v2.1.2 용어 정책
- **v2.1.2 주요 변경사항**: 역할 용어 통일
- "작성자" → "회의 생성자" 또는 "참석자"
- 회의를 만든 사람: **회의 생성자** (creator)
- 회의에 참여한 사람: **참석자** (attendee)
#### UI/UX 설계서 용어 사용 현황
- **일관성 있는 곳**: 대부분 "회의 생성자", "참석자" 사용
- **"작성자" 사용 위치**:
1. UFR-TODO-020 (Todo완료): "Todo 작성자에게 완료 알림 발송"
2. UFR-TODO-040 (Todo관리): "담당자 본인 OR 회의 작성자인 경우에만 노출"
3. 일부 화면 설명에서 혼용
#### 프로토타입 용어 사용 현황
- 대부분 "회의 생성자" 사용
- 일부 주석에서 "작성자" 사용
#### 불일치 사항
1. 🟡 **용어 통일 필요**
- 유저스토리: "회의 생성자" 일관 사용
- UI/UX 설계서: 일부 "작성자" 혼재
- **수정 필요**: 모든 "작성자"를 "회의 생성자"로 변경
#### 권장 수정사항
```
[UI/UX 설계서 일괄 변경]
1. 검색 및 치환:
- "Todo 작성자" → "Todo 담당자" 또는 "회의 생성자"
- "회의 작성자" → "회의 생성자"
- "회의록 작성자" → "회의 생성자"
2. 컨텍스트별 명확화:
- 회의를 만든 사람: "회의 생성자"
- Todo를 만든 사람: "Todo 담당자"
- 회의에 참여한 사람: "참석자"
[프로토타입 수정]
- 주석 및 변수명에서 "작성자" → "생성자" 변경
- 예: creator, isCreator 등으로 통일
```
---
## 4. 권장 수정사항 우선순위
### Phase 1: 즉시 수정 (🔴 높음)
#### 1.1 07-회의종료 화면 전면 개편
**담당**: 강지수 (Product Designer)
**공수**: 2일
**작업 내역**:
1. 프로토타입 수정 (07-회의종료.html):
- 안건별 AI 요약 표시 추가
- "Todo 수정" 버튼 제거
- 하단 액션 3가지 옵션 구현:
- "회의록 수정" (옵션 1)
- "바로 최종 확정" (옵션 2, Primary)
- "대시보드로 이동" (옵션 3)
- "바로 최종 확정" 버튼 클릭 시 모든 안건 자동 검증 처리 로직 추가
2. UI/UX 설계서 수정 (uiux.md):
- "07-회의종료" 섹션 전체 재작성
- 편집 불가 정책 명시
- 안건별 AI 요약 표시 설명 추가
- 3가지 선택 옵션 상세 설명
- 각 옵션별 회의록 상태 변경 로직 명시
#### 1.2 06-검증완료 화면 삭제
**담당**: 강지수 (Product Designer)
**공수**: 0.5일
**작업 내역**:
1. 파일 삭제:
- design/uiux/prototype/06-검증완료.html 삭제
2. UI/UX 설계서 수정:
- "### 06-검증완료" 섹션 전체 삭제
- 프로토타입 화면 목록 테이블에서 제거
- 모든 화면에서 06-검증완료 링크 제거
3. 11-회의록수정 화면에 검증 기능 통합:
- 각 안건별 검증 체크박스 UI 추가
- 권한별 활성화 로직 구현
---
### Phase 2: 우선 수정 (🟡 중간)
#### 2.1 11-회의록수정 화면 안건 기반 구조 전환
**담당**: 강지수 (Product Designer)
**공수**: 3일
**작업 내역**:
1. 프로토타입 수정 (11-회의록수정.html):
- "섹션" → "안건" 용어 변경
- AI 한줄 요약 (읽기 전용) UI 추가
- AI 상세 요약 (편집 가능) 명칭 변경
- 안건별 검증 체크박스 추가
- 잠금 해제 버튼 추가 (회의 생성자만)
2. UI/UX 설계서 수정:
- "### 11-회의록수정" 섹션 재작성
- 안건 기반 구조 설명 추가
- AI 한줄 요약 vs 상세 요약 구분 설명
- 안건별 검증 UI 설명 추가
3. 스타일 가이드 업데이트 (style-guide.md):
- 안건 카드 컴포넌트 추가
- AI 한줄 요약 스타일 정의
- 안건별 검증 UI 스타일 정의
#### 2.2 용어 통일 ("작성자" → "생성자")
**담당**: 강지수 (Product Designer)
**공수**: 1일
**작업 내역**:
1. UI/UX 설계서 일괄 변경:
- "작성자" 검색 및 컨텍스트 확인
- "회의 생성자" 또는 "Todo 담당자"로 변경
2. 프로토타입 수정:
- 주석 및 변수명 일괄 변경
- creator, isCreator 등으로 통일
3. 용어 사전 추가 (uiux.md):
- "회의 생성자": 회의를 생성한 사람
- "참석자": 회의에 참여한 사람
- "Todo 담당자": Todo를 담당하는 사람
#### 2.3 회의록목록조회 생성자 표시 추가
**담당**: 강지수 (Product Designer)
**공수**: 0.5일
**작업 내역**:
1. 프로토타입 수정 (common.js):
- renderMinuteCard 함수에 생성자 표시 로직 추가
- 👑 아이콘 추가
2. UI/UX 설계서 수정:
- 목록 표시 정보에 "생성자 표시" 항목 추가
---
### Phase 3: 검토 후 수정 (🟢 낮음)
#### 3.1 05-회의진행 화면 실시간 주요 메모 추천 명확화
**담당**: 강지수, 도그냥
**공수**: 1일
**작업 내역**:
1. 현재 구현 검토:
- AI 제안 탭 기능이 실시간 주요 메모 추천인지 확인
- 유저스토리와 일치하는지 검증
2. UI/UX 설계서 명확화:
- 실시간 주요 메모 추천 기능 설명 추가
- AI 제안 탭과의 관계 명시
3. 필요 시 프로토타입 수정:
- 별도 UI 컴포넌트 추가
- 3-5초 간격 업데이트 로직 구현
#### 3.2 10-회의록상세조회 안건별 표시 명칭 변경
**담당**: 강지수
**공수**: 0.5일
**작업 내역**:
1. UI/UX 설계서 수정:
- "섹션별" → "안건별" 용어 변경
2. 프로토타입 수정:
- class 명칭 변경
- 주석 및 변수명 변경
#### 3.3 스타일 가이드 안건 컴포넌트 추가
**담당**: 강지수
**공수**: 1일
**작업 내역**:
1. style-guide.md 수정:
- 안건 카드 컴포넌트 스타일 정의
- AI 한줄 요약 스타일 추가
- 안건별 검증 UI 스타일 추가
- 예시 코드 작성
---
## 5. 총 작업 공수 및 일정
### 공수 요약
- **Phase 1 (즉시 수정)**: 2.5일
- 07-회의종료 화면 전면 개편: 2일
- 06-검증완료 화면 삭제: 0.5일
- **Phase 2 (우선 수정)**: 4.5일
- 11-회의록수정 안건 기반 구조 전환: 3일
- 용어 통일: 1일
- 생성자 표시 추가: 0.5일
- **Phase 3 (검토 후 수정)**: 2.5일
- 실시간 주요 메모 추천 명확화: 1일
- 안건별 표시 명칭 변경: 0.5일
- 스타일 가이드 업데이트: 1일
**총 공수**: 9.5일
### 권장 일정
- **Week 1 (5일)**: Phase 1 완료 + Phase 2 시작
- Day 1-2: 07-회의종료 전면 개편
- Day 3: 06-검증완료 삭제 + 용어 통일
- Day 4-5: 11-회의록수정 안건 구조 전환 (50%)
- **Week 2 (4.5일)**: Phase 2 완료 + Phase 3
- Day 6-7: 11-회의록수정 안건 구조 전환 완료
- Day 8: 생성자 표시 추가 + Phase 3 검토
- Day 9-10: Phase 3 수정 작업
---
## 6. 체크리스트
### Phase 1 완료 체크리스트
- [ ] 07-회의종료.html 안건별 AI 요약 표시 구현
- [ ] 07-회의종료.html "Todo 수정" 버튼 제거
- [ ] 07-회의종료.html 3가지 선택 옵션 구현
- [ ] 07-회의종료.html "바로 최종 확정" 로직 구현
- [ ] uiux.md "07-회의종료" 섹션 재작성
- [ ] 06-검증완료.html 파일 삭제
- [ ] uiux.md "06-검증완료" 섹션 삭제
- [ ] 프로토타입 화면 목록 테이블 업데이트
### Phase 2 완료 체크리스트
- [ ] 11-회의록수정.html "섹션" → "안건" 용어 변경
- [ ] 11-회의록수정.html AI 한줄 요약 UI 추가
- [ ] 11-회의록수정.html 안건별 검증 체크박스 추가
- [ ] uiux.md "11-회의록수정" 섹션 재작성
- [ ] uiux.md 전체 "작성자" → "생성자" 변경
- [ ] 프로토타입 용어 통일 (creator, isCreator)
- [ ] common.js 생성자 표시 로직 추가
- [ ] uiux.md "12-회의록목록조회" 생성자 표시 설명 추가
### Phase 3 완료 체크리스트
- [ ] 05-회의진행 실시간 주요 메모 추천 검토 완료
- [ ] uiux.md 실시간 주요 메모 추천 설명 추가
- [ ] 10-회의록상세조회 "섹션" → "안건" 변경
- [ ] style-guide.md 안건 컴포넌트 스타일 추가
---
## 7. 결론
### 주요 발견사항
1. 유저스토리 v2.1.2의 핵심 변경사항인 **"안건 기반 회의록 구조"**가 UI/UX 설계서와 프로토타입에 충분히 반영되지 않았습니다.
2. **07-회의종료 화면**의 "확인 전용" 정책과 "3가지 선택 옵션"이 구현되지 않아, 사용자 경험에 큰 영향을 미칠 수 있습니다.
3. **06-검증완료 화면**이 여전히 존재하여, 유저스토리의 "11-회의록수정 통합" 방침과 불일치합니다.
4. **용어 사용**이 일부 혼재되어 있어, 전체적인 일관성 개선이 필요합니다.
### 권장사항
1. **Phase 1 (즉시 수정)** 항목을 최우선으로 처리하여 핵심 사용자 플로우를 유저스토리와 일치시켜야 합니다.
2. **Phase 2 (우선 수정)** 항목은 전체적인 일관성과 정확성을 위해 2주 내 완료를 권장합니다.
3. **Phase 3 (검토 후 수정)** 항목은 검토 과정에서 실제 불일치 여부를 확인한 후 수정 여부를 결정하시기 바랍니다.
4. 모든 수정 작업 후 **통합 테스트**를 통해 유저스토리, UI/UX 설계서, 프로토타입 간 완전한 일관성을 확보해야 합니다.
---
**보고서 종료**

View File

@ -49,7 +49,7 @@
- 실시간 협업: WebSocket 기반 실시간 동기화, 버전 관리, 충돌 해결 - 실시간 협업: WebSocket 기반 실시간 동기화, 버전 관리, 충돌 해결
- 템플릿 관리: 회의록 템플릿 관리 - 템플릿 관리: 회의록 템플릿 관리
- 통계 생성: 회의 및 Todo 통계 - 통계 생성: 회의 및 Todo 통계
3. **STT** - 음성 녹음 관리, 음성-텍스트 변환, 화자 식별 (기본 기능) 3. **STT** - 음성 스트리밍 처리, 실시간 음성-텍스트 변환 (기본 기능)
4. **AI** - AI 기반 회의록 자동화, Todo 추출, 지능형 검색 (RAG 통합) 4. **AI** - AI 기반 회의록 자동화, Todo 추출, 지능형 검색 (RAG 통합)
- LLM 기반 회의록 자동 작성 - LLM 기반 회의록 자동 작성
- Todo 자동 추출 및 담당자 식별 - Todo 자동 추출 및 담당자 식별
@ -216,7 +216,6 @@ UFR-MEET-040: [회의종료] 회의 생성자로서 | 나는, 회의를 종료
- 회의 통계 자동 생성 - 회의 통계 자동 생성
- 회의 총 시간 - 회의 총 시간
- 참석자 수 - 참석자 수
- 발언 횟수 (화자별)
- 주요 키워드 - 주요 키워드
[처리 결과] [처리 결과]
@ -478,30 +477,29 @@ UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완
3. STT 서비스 (음성 인식 및 변환 - 기본 기능) 3. STT 서비스 (음성 인식 및 변환 - 기본 기능)
1) 음성 인식 및 변환 1) 음성 인식 및 변환
UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다. UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다.
- 시나리오: 음성 녹음 및 발언 인식 - 시나리오: 음성 실시간 인식
회의가 시작된 상황에서 | 참석자가 발언을 시작하면 | 음성이 자동으로 녹음되고 화자가 식별되며 발언이 인식된다. 회의가 시작된 상황에서 | 참석자가 발언을 시작하면 | 음성이 실시간으로 텍스트로 변환된다.
[음성 녹음 처리] [음성 스트리밍 처리]
- 오디오 스트림 실시간 캡처 - 오디오 스트림 실시간 캡처
- 회의 ID와 연결 - 회의 ID와 연결
- 음성 데이터 저장 (Azure 스토리지) - **음성 파일은 저장하지 않음** (실시간 스트리밍만 처리)
[발언 인식 처리] [음성 인식 처리]
- AI 음성인식 엔진 연동 (Azure Speech 등) - AI 음성인식 엔진 연동 (Azure Speech 등)
- 화자 자동 식별 - 실시간 텍스트 변환
- 참석자 목록 매칭
- 음성 특징 분석
- 타임스탬프 기록 - 타임스탬프 기록
- 발언 구간 구분
[처리 결과] [처리 결과]
- 음성 녹음이 시작됨 (녹음 ID) - 음성 스트리밍이 시작됨 (세션 ID)
- 발언이 인식됨 (발언 ID, 화자, 타임스탬프) - 텍스트가 변환됨 (세그먼트 ID, 텍스트, 타임스탬프)
- 실시간으로 텍스트 변환 요청 (UFR-STT-020 연동) - 실시간으로 텍스트 변환 요청 (UFR-STT-020 연동)
- **음성 파일은 저장되지 않고 스트리밍만 처리됨**
- **화자 식별 기능 없음** (단순 텍스트 변환만)
[성능 요구사항] [성능 요구사항]
- 발언 인식 지연 시간: 1초 이내 - 음성 인식 지연 시간: 1초 이내
- 화자 식별 정확도: 90% 이상 - 변환 정확도: 85% 이상
[비고] [비고]
- STT는 기본 기능으로 경쟁사 대부분이 제공하는 기능임 - STT는 기본 기능으로 경쟁사 대부분이 제공하는 기능임
@ -1271,5 +1269,6 @@ UFR-TODO-040: [Todo관리] Todo 담당자로서 | 나는, 나의 Todo를 효율
| 2.1.0 | 2025-10-24 | 강지수 (Product Designer) | 회의 종료 후 워크플로우 개선 및 안건 기반 회의록 구조 도입<br>- **UFR-MEET-040 (회의종료)**: 회의 종료 시 사용자 선택 옵션 제공<br> - AI가 STT 텍스트를 주요 안건으로 요약 정리 (템플릿 및 메모 항목 반영)<br> - 종료 후 선택: 회의록 수정 화면 이동 OR 대시보드 이동<br> - 회의록 상태: 작성중으로 저장<br>- **UFR-AI-010 (회의록자동작성)**: 실시간 + 종료 시 이중 처리 방식<br> - 시나리오 1: 실시간 AI 주요 메모 작성 (회의 진행 중)<br> - 시나리오 2: 회의 종료 시 전체 안건 요약 (AI 한줄 요약 + 상세 요약)<br>- **UFR-AI-020 (Todo자동추출)**: Todo 기본값 정책 추가<br> - 담당자 기본값: 회의록 생성자<br> - 마감일 기본값: 다음 회의 날짜 OR 오늘<br> - 우선순위 기본값: 보통<br> - Todo 독립성: 회의록 확정 상태와 무관하게 완료 처리 가능<br>- **UFR-AI-036 (AI한줄요약)**: 신규 유저스토리 추가<br> - 각 안건별 편집 불가능한 AI 한줄 요약 (30자 이내)<br> - 편집 가능한 상세 요약과 함께 제공<br>- **UFR-MEET-050 (최종확정)**: 안건 검증 요구사항 추가<br> - 모든 안건 검증 완료 시 최종 확정 가능<br> - 검증률 = 검증 완료된 안건 수 / 전체 안건 수<br>- **UFR-COLLAB-030 (검증완료)**: 안건별 검증으로 변경<br> - 섹션 검증 → 안건별 검증<br> - 11-회의록수정 화면에서 안건별 검증 처리<br> - 06-검증완료 화면 불필요 (11-회의록수정에 통합) | | 2.1.0 | 2025-10-24 | 강지수 (Product Designer) | 회의 종료 후 워크플로우 개선 및 안건 기반 회의록 구조 도입<br>- **UFR-MEET-040 (회의종료)**: 회의 종료 시 사용자 선택 옵션 제공<br> - AI가 STT 텍스트를 주요 안건으로 요약 정리 (템플릿 및 메모 항목 반영)<br> - 종료 후 선택: 회의록 수정 화면 이동 OR 대시보드 이동<br> - 회의록 상태: 작성중으로 저장<br>- **UFR-AI-010 (회의록자동작성)**: 실시간 + 종료 시 이중 처리 방식<br> - 시나리오 1: 실시간 AI 주요 메모 작성 (회의 진행 중)<br> - 시나리오 2: 회의 종료 시 전체 안건 요약 (AI 한줄 요약 + 상세 요약)<br>- **UFR-AI-020 (Todo자동추출)**: Todo 기본값 정책 추가<br> - 담당자 기본값: 회의록 생성자<br> - 마감일 기본값: 다음 회의 날짜 OR 오늘<br> - 우선순위 기본값: 보통<br> - Todo 독립성: 회의록 확정 상태와 무관하게 완료 처리 가능<br>- **UFR-AI-036 (AI한줄요약)**: 신규 유저스토리 추가<br> - 각 안건별 편집 불가능한 AI 한줄 요약 (30자 이내)<br> - 편집 가능한 상세 요약과 함께 제공<br>- **UFR-MEET-050 (최종확정)**: 안건 검증 요구사항 추가<br> - 모든 안건 검증 완료 시 최종 확정 가능<br> - 검증률 = 검증 완료된 안건 수 / 전체 안건 수<br>- **UFR-COLLAB-030 (검증완료)**: 안건별 검증으로 변경<br> - 섹션 검증 → 안건별 검증<br> - 11-회의록수정 화면에서 안건별 검증 처리<br> - 06-검증완료 화면 불필요 (11-회의록수정에 통합) |
| 2.1.1 | 2025-10-24 | 강지수 (Product Designer) | 회의 종료 화면 정책 명확화 및 실시간 협업 충돌 방지 개선<br>- **UFR-MEET-040 (회의종료)**: 회의 종료 화면 정책 및 옵션 추가<br> - 회의 종료 화면은 확인 전용 (편집 불가) 명시<br> - 안건별 AI 요약 전체 표시 (한줄 요약 + 상세 요약 + Todo)<br> - 옵션 추가: "바로 최종 확정" (옵션 2)<br> - 3가지 선택 옵션: 회의록 수정 / 바로 최종 확정 / 대시보드 이동<br>- **UFR-COLLAB-020 (충돌해결)**: 안건 기반 충돌 방지 메커니즘 강화<br> - 안건 단위 독립 편집으로 충돌 최소화<br> - 다른 안건 동시 편집 시 충돌 없음<br> - 동일 안건 내 다른 필드 편집 시 자동 병합<br> - 동일 필드 동시 수정 시에만 Last Write Wins 적용<br> - 편집 중 안건 실시간 표시 (편집자 이름 및 아이콘)<br>- **UFR-MEET-050 (최종확정)**: 회의 종료 화면 바로 확정 시나리오 추가<br> - 시나리오 2: 회의 종료 화면에서 바로 최종 확정<br> - 바로 확정 시 모든 안건 자동 검증 완료 처리<br> - 필수 항목 자동 충족 (AI 생성 내용 활용)<br> - 회의록 수정 화면 거치지 않고 바로 확정 완료 | | 2.1.1 | 2025-10-24 | 강지수 (Product Designer) | 회의 종료 화면 정책 명확화 및 실시간 협업 충돌 방지 개선<br>- **UFR-MEET-040 (회의종료)**: 회의 종료 화면 정책 및 옵션 추가<br> - 회의 종료 화면은 확인 전용 (편집 불가) 명시<br> - 안건별 AI 요약 전체 표시 (한줄 요약 + 상세 요약 + Todo)<br> - 옵션 추가: "바로 최종 확정" (옵션 2)<br> - 3가지 선택 옵션: 회의록 수정 / 바로 최종 확정 / 대시보드 이동<br>- **UFR-COLLAB-020 (충돌해결)**: 안건 기반 충돌 방지 메커니즘 강화<br> - 안건 단위 독립 편집으로 충돌 최소화<br> - 다른 안건 동시 편집 시 충돌 없음<br> - 동일 안건 내 다른 필드 편집 시 자동 병합<br> - 동일 필드 동시 수정 시에만 Last Write Wins 적용<br> - 편집 중 안건 실시간 표시 (편집자 이름 및 아이콘)<br>- **UFR-MEET-050 (최종확정)**: 회의 종료 화면 바로 확정 시나리오 추가<br> - 시나리오 2: 회의 종료 화면에서 바로 최종 확정<br> - 바로 확정 시 모든 안건 자동 검증 완료 처리<br> - 필수 항목 자동 충족 (AI 생성 내용 활용)<br> - 회의록 수정 화면 거치지 않고 바로 확정 완료 |
| 2.1.2 | 2025-10-24 | 강지수 (Product Designer) | 역할 용어 통일 및 권한 체계 명확화<br>- **용어 통일**: "회의록 작성자" → "회의 생성자" 또는 "회의 참석자"로 명확히 구분<br> - 생성자 권한 필요: UFR-MEET-010 (회의예약), UFR-MEET-020 (템플릿선택), UFR-MEET-030 (회의시작), UFR-MEET-040 (회의종료), UFR-MEET-050 (최종확정)<br> - 참석자 권한: UFR-MEET-046 (목록조회), UFR-MEET-047 (상세조회), UFR-AI-010~040 (AI 기능), UFR-RAG-010~020 (RAG 기능)<br>- **역할 정의**:<br> - 생성자: 회의 예약을 생성한 사람 (특별 권한: 참석자 관리, 회의 시작/종료, 최종 확정)<br> - 참석자: 회의에 참여하는 전체 인원 (생성자 포함, 기본 권한: 안건 편집, 검증, Todo 관리)<br>- **권한 체계 명확화**:<br> - 회의록 상세 조회 화면: 역할 표시 "생성자/참석자"로 변경<br> - Todo 편집 권한: 담당자 본인 OR 회의 생성자 | | 2.1.2 | 2025-10-24 | 강지수 (Product Designer) | 역할 용어 통일 및 권한 체계 명확화<br>- **용어 통일**: "회의록 작성자" → "회의 생성자" 또는 "회의 참석자"로 명확히 구분<br> - 생성자 권한 필요: UFR-MEET-010 (회의예약), UFR-MEET-020 (템플릿선택), UFR-MEET-030 (회의시작), UFR-MEET-040 (회의종료), UFR-MEET-050 (최종확정)<br> - 참석자 권한: UFR-MEET-046 (목록조회), UFR-MEET-047 (상세조회), UFR-AI-010~040 (AI 기능), UFR-RAG-010~020 (RAG 기능)<br>- **역할 정의**:<br> - 생성자: 회의 예약을 생성한 사람 (특별 권한: 참석자 관리, 회의 시작/종료, 최종 확정)<br> - 참석자: 회의에 참여하는 전체 인원 (생성자 포함, 기본 권한: 안건 편집, 검증, Todo 관리)<br>- **권한 체계 명확화**:<br> - 회의록 상세 조회 화면: 역할 표시 "생성자/참석자"로 변경<br> - Todo 편집 권한: 담당자 본인 OR 회의 생성자 |
| 2.1.3 | 2025-10-24 | 강지수 (Product Designer) | 회의록 목록 조회 화면 생성자 표시 기능 추가 (UFR-MEET-046)<br>- **목록 표시 정보 추가**: 생성자 표시 (👑 아이콘)<br> - 현재 사용자가 회의 생성자인 경우 회의록 카드에 👑 아이콘 표시<br> - 아이콘 크기: 16px, title 속성 "생성자"로 툴팁 제공<br>- **UI/UX 설계서 업데이트**: 12-회의록목록조회 화면 UI 구성요소 명세 추가<br>- **스타일 가이드 업데이트**: creator-badge 스타일 추가 (배지 섹션)<br>- **프로토타입 수정**: 12-회의록목록조회.html, common.css<br> - createMeetingCard() 함수: creatorBadge 변수 추가 및 렌더링<br> - common.css: .creator-badge 스타일 정의 |
--- ---

46
docker-compose.test.yml Normal file
View File

@ -0,0 +1,46 @@
version: '3.8'
services:
# Test Database
postgres-test:
image: postgres:15-alpine
environment:
POSTGRES_DB: sttdb_test
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- "5433:5432"
volumes:
- postgres_test_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser -d sttdb_test"]
interval: 5s
timeout: 5s
retries: 5
# Test Redis
redis-test:
image: redis:7-alpine
ports:
- "6380:6379"
command: redis-server --requirepass testpass
healthcheck:
test: ["CMD", "redis-cli", "-a", "testpass", "ping"]
interval: 5s
timeout: 3s
retries: 5
# Test Azure Storage Emulator (Azurite)
azurite-test:
image: mcr.microsoft.com/azure-storage/azurite
ports:
- "10000:10000" # Blob service
- "10001:10001" # Queue service
- "10002:10002" # Table service
command: azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0
volumes:
- azurite_data:/data
volumes:
postgres_test_data:
azurite_data:

View File

@ -1438,3 +1438,282 @@ Caused by: io.lettuce.core.RedisCommandExecutionException: NOAUTH HELLO must be
2025-10-23 16:37:29 [SpringApplicationShutdownHook] DEBUG o.h.type.spi.TypeConfiguration$Scope - Un-scoping TypeConfiguration [org.hibernate.type.spi.TypeConfiguration$Scope@1d9c4491] from SessionFactory [org.hibernate.internal.SessionFactoryImpl@20505460] 2025-10-23 16:37:29 [SpringApplicationShutdownHook] DEBUG o.h.type.spi.TypeConfiguration$Scope - Un-scoping TypeConfiguration [org.hibernate.type.spi.TypeConfiguration$Scope@1d9c4491] from SessionFactory [org.hibernate.internal.SessionFactoryImpl@20505460]
2025-10-23 16:37:29 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... 2025-10-23 16:37:29 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated...
2025-10-23 16:37:29 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. 2025-10-23 16:37:29 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed.
2025-10-23 17:10:40 [main] INFO com.unicorn.hgzero.ai.AiApplication - Starting AiApplication using Java 23.0.2 with PID 43872 (/Users/jominseo/HGZero/ai/build/classes/java/main started by jominseo in /Users/jominseo/HGZero)
2025-10-23 17:10:40 [main] DEBUG com.unicorn.hgzero.ai.AiApplication - Running with Spring Boot v3.3.0, Spring v6.1.8
2025-10-23 17:10:40 [main] INFO com.unicorn.hgzero.ai.AiApplication - No active profile set, falling back to 1 default profile: "default"
2025-10-23 17:10:40 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
2025-10-23 17:10:40 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2025-10-23 17:10:40 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 3 ms. Found 0 JPA repository interfaces.
2025-10-23 17:10:40 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
2025-10-23 17:10:40 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode.
2025-10-23 17:10:40 [main] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 0 ms. Found 0 Redis repository interfaces.
2025-10-23 17:10:41 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8084 (http)
2025-10-23 17:10:41 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
2025-10-23 17:10:41 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.24]
2025-10-23 17:10:41 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
2025-10-23 17:10:41 [main] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 776 ms
2025-10-23 17:10:41 [main] INFO o.h.jpa.internal.util.LogHelper - HHH000204: Processing PersistenceUnitInfo [name: default]
2025-10-23 17:10:41 [main] INFO org.hibernate.Version - HHH000412: Hibernate ORM core version 6.5.2.Final
2025-10-23 17:10:41 [main] INFO o.h.c.i.RegionFactoryInitiator - HHH000026: Second-level cache disabled
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@3e2d65e1
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration boolean -> org.hibernate.type.BasicTypeReference@3e2d65e1
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Boolean -> org.hibernate.type.BasicTypeReference@3e2d65e1
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration numeric_boolean -> org.hibernate.type.BasicTypeReference@1174676f
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.NumericBooleanConverter -> org.hibernate.type.BasicTypeReference@1174676f
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration true_false -> org.hibernate.type.BasicTypeReference@71f8ce0e
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.TrueFalseConverter -> org.hibernate.type.BasicTypeReference@71f8ce0e
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration yes_no -> org.hibernate.type.BasicTypeReference@4fd92289
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.YesNoConverter -> org.hibernate.type.BasicTypeReference@4fd92289
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@1a8e44fe
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte -> org.hibernate.type.BasicTypeReference@1a8e44fe
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Byte -> org.hibernate.type.BasicTypeReference@1a8e44fe
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary -> org.hibernate.type.BasicTypeReference@287317df
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration byte[] -> org.hibernate.type.BasicTypeReference@287317df
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [B -> org.hibernate.type.BasicTypeReference@287317df
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration binary_wrapper -> org.hibernate.type.BasicTypeReference@1fcc3461
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-binary -> org.hibernate.type.BasicTypeReference@1fcc3461
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration image -> org.hibernate.type.BasicTypeReference@1987807b
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration blob -> org.hibernate.type.BasicTypeReference@71469e01
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Blob -> org.hibernate.type.BasicTypeReference@71469e01
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob -> org.hibernate.type.BasicTypeReference@41bbb219
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_blob_wrapper -> org.hibernate.type.BasicTypeReference@3f2ae973
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@1a8b22b5
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration short -> org.hibernate.type.BasicTypeReference@1a8b22b5
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Short -> org.hibernate.type.BasicTypeReference@1a8b22b5
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration integer -> org.hibernate.type.BasicTypeReference@5f781173
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration int -> org.hibernate.type.BasicTypeReference@5f781173
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Integer -> org.hibernate.type.BasicTypeReference@5f781173
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@43cf5bff
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration long -> org.hibernate.type.BasicTypeReference@43cf5bff
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Long -> org.hibernate.type.BasicTypeReference@43cf5bff
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@2b464384
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration float -> org.hibernate.type.BasicTypeReference@2b464384
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Float -> org.hibernate.type.BasicTypeReference@2b464384
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@681b42d3
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration double -> org.hibernate.type.BasicTypeReference@681b42d3
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Double -> org.hibernate.type.BasicTypeReference@681b42d3
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_integer -> org.hibernate.type.BasicTypeReference@77f7352a
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigInteger -> org.hibernate.type.BasicTypeReference@77f7352a
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration big_decimal -> org.hibernate.type.BasicTypeReference@4ede8888
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.math.BigDecimal -> org.hibernate.type.BasicTypeReference@4ede8888
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character -> org.hibernate.type.BasicTypeReference@571db8b4
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char -> org.hibernate.type.BasicTypeReference@571db8b4
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Character -> org.hibernate.type.BasicTypeReference@571db8b4
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration character_nchar -> org.hibernate.type.BasicTypeReference@65a2755e
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration string -> org.hibernate.type.BasicTypeReference@2b3242a5
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.String -> org.hibernate.type.BasicTypeReference@2b3242a5
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nstring -> org.hibernate.type.BasicTypeReference@11120583
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration characters -> org.hibernate.type.BasicTypeReference@2bf0c70d
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration char[] -> org.hibernate.type.BasicTypeReference@2bf0c70d
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration [C -> org.hibernate.type.BasicTypeReference@2bf0c70d
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration wrapper-characters -> org.hibernate.type.BasicTypeReference@5d8e4fa8
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration text -> org.hibernate.type.BasicTypeReference@649009d6
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ntext -> org.hibernate.type.BasicTypeReference@652f26da
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration clob -> org.hibernate.type.BasicTypeReference@484a5ddd
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Clob -> org.hibernate.type.BasicTypeReference@484a5ddd
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration nclob -> org.hibernate.type.BasicTypeReference@6796a873
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.NClob -> org.hibernate.type.BasicTypeReference@6796a873
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob -> org.hibernate.type.BasicTypeReference@3acc3ee
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_char_array -> org.hibernate.type.BasicTypeReference@1f293cb7
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_clob_character_array -> org.hibernate.type.BasicTypeReference@5972e3a
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob -> org.hibernate.type.BasicTypeReference@5790cbcb
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_character_array -> org.hibernate.type.BasicTypeReference@32c6d164
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration materialized_nclob_char_array -> org.hibernate.type.BasicTypeReference@645c9f0f
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Duration -> org.hibernate.type.BasicTypeReference@58068b40
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Duration -> org.hibernate.type.BasicTypeReference@58068b40
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDateTime -> org.hibernate.type.BasicTypeReference@999cd18
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDateTime -> org.hibernate.type.BasicTypeReference@999cd18
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalDate -> org.hibernate.type.BasicTypeReference@dd060be
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalDate -> org.hibernate.type.BasicTypeReference@dd060be
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration LocalTime -> org.hibernate.type.BasicTypeReference@df432ec
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.LocalTime -> org.hibernate.type.BasicTypeReference@df432ec
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTime -> org.hibernate.type.BasicTypeReference@6144e499
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetDateTime -> org.hibernate.type.BasicTypeReference@6144e499
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@26f204a4
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@28295554
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTime -> org.hibernate.type.BasicTypeReference@4e671ef
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetTime -> org.hibernate.type.BasicTypeReference@4e671ef
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeUtc -> org.hibernate.type.BasicTypeReference@42403dc6
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithTimezone -> org.hibernate.type.BasicTypeReference@74a1d60e
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@16c0be3b
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTime -> org.hibernate.type.BasicTypeReference@219edc05
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZonedDateTime -> org.hibernate.type.BasicTypeReference@219edc05
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithTimezone -> org.hibernate.type.BasicTypeReference@62f37bfd
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTimeWithoutTimezone -> org.hibernate.type.BasicTypeReference@1818d00b
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration date -> org.hibernate.type.BasicTypeReference@b3a8455
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Date -> org.hibernate.type.BasicTypeReference@b3a8455
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration time -> org.hibernate.type.BasicTypeReference@5c930fc3
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Time -> org.hibernate.type.BasicTypeReference@5c930fc3
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timestamp -> org.hibernate.type.BasicTypeReference@25c6ab3f
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.sql.Timestamp -> org.hibernate.type.BasicTypeReference@25c6ab3f
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Date -> org.hibernate.type.BasicTypeReference@25c6ab3f
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar -> org.hibernate.type.BasicTypeReference@7b80af04
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Calendar -> org.hibernate.type.BasicTypeReference@7b80af04
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.GregorianCalendar -> org.hibernate.type.BasicTypeReference@7b80af04
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_date -> org.hibernate.type.BasicTypeReference@2447940d
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration calendar_time -> org.hibernate.type.BasicTypeReference@60ee7a51
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration instant -> org.hibernate.type.BasicTypeReference@70e1aa20
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Instant -> org.hibernate.type.BasicTypeReference@70e1aa20
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid -> org.hibernate.type.BasicTypeReference@e67d3b7
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.UUID -> org.hibernate.type.BasicTypeReference@e67d3b7
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration pg-uuid -> org.hibernate.type.BasicTypeReference@e67d3b7
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-binary -> org.hibernate.type.BasicTypeReference@1618c98a
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration uuid-char -> org.hibernate.type.BasicTypeReference@5b715ea
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration class -> org.hibernate.type.BasicTypeReference@787a0fd6
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Class -> org.hibernate.type.BasicTypeReference@787a0fd6
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration currency -> org.hibernate.type.BasicTypeReference@48b09105
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Currency -> org.hibernate.type.BasicTypeReference@48b09105
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Currency -> org.hibernate.type.BasicTypeReference@48b09105
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration locale -> org.hibernate.type.BasicTypeReference@18b45500
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.Locale -> org.hibernate.type.BasicTypeReference@18b45500
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration serializable -> org.hibernate.type.BasicTypeReference@25110bb9
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.io.Serializable -> org.hibernate.type.BasicTypeReference@25110bb9
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration timezone -> org.hibernate.type.BasicTypeReference@dbda472
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.util.TimeZone -> org.hibernate.type.BasicTypeReference@dbda472
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZoneOffset -> org.hibernate.type.BasicTypeReference@41492479
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZoneOffset -> org.hibernate.type.BasicTypeReference@41492479
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration url -> org.hibernate.type.BasicTypeReference@7bef7505
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.net.URL -> org.hibernate.type.BasicTypeReference@7bef7505
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration vector -> org.hibernate.type.BasicTypeReference@568ef502
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration row_version -> org.hibernate.type.BasicTypeReference@36f05595
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration object -> org.hibernate.type.JavaObjectType@3c46e6f6
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Object -> org.hibernate.type.JavaObjectType@3c46e6f6
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration null -> org.hibernate.type.NullType@1c79d093
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_date -> org.hibernate.type.BasicTypeReference@746fd19b
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_time -> org.hibernate.type.BasicTypeReference@61d7bb61
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_timestamp -> org.hibernate.type.BasicTypeReference@33f81280
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar -> org.hibernate.type.BasicTypeReference@3991fe6d
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_date -> org.hibernate.type.BasicTypeReference@3a0e7f89
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_calendar_time -> org.hibernate.type.BasicTypeReference@665ed71a
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_binary -> org.hibernate.type.BasicTypeReference@15c1b543
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration imm_serializable -> org.hibernate.type.BasicTypeReference@23954300
2025-10-23 17:10:41 [main] INFO o.s.o.j.p.SpringPersistenceUnitInfo - No LoadTimeWeaver setup: ignoring JPA class transformer
2025-10-23 17:10:41 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2025-10-23 17:10:41 [main] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@5f5c0eda
2025-10-23 17:10:41 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
2025-10-23 17:10:41 [main] DEBUG o.h.t.d.sql.spi.DdlTypeRegistry - addDescriptor(2003, org.hibernate.type.descriptor.sql.internal.ArrayDdlTypeImpl@31b7112d) replaced previous registration(org.hibernate.type.descriptor.sql.internal.ArrayDdlTypeImpl@47fc9ce)
2025-10-23 17:10:41 [main] DEBUG o.h.t.d.sql.spi.DdlTypeRegistry - addDescriptor(6, org.hibernate.type.descriptor.sql.internal.CapacityDependentDdlType@1b5d1d9) replaced previous registration(org.hibernate.type.descriptor.sql.internal.DdlTypeImpl@703a2bc9)
2025-10-23 17:10:41 [main] DEBUG o.h.t.d.jdbc.spi.JdbcTypeRegistry - addDescriptor(2004, BlobTypeDescriptor(BLOB_BINDING)) replaced previous registration(BlobTypeDescriptor(DEFAULT))
2025-10-23 17:10:41 [main] DEBUG o.h.t.d.jdbc.spi.JdbcTypeRegistry - addDescriptor(2005, ClobTypeDescriptor(CLOB_BINDING)) replaced previous registration(ClobTypeDescriptor(DEFAULT))
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration JAVA_OBJECT -> org.hibernate.type.JavaObjectType@e460ca1
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.lang.Object -> org.hibernate.type.JavaObjectType@e460ca1
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Type registration key [java.lang.Object] overrode previous entry : `org.hibernate.type.JavaObjectType@3c46e6f6`
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.DurationType -> basicType@1(java.time.Duration,3015)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration Duration -> basicType@1(java.time.Duration,3015)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.Duration -> basicType@1(java.time.Duration,3015)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.OffsetDateTimeType -> basicType@2(java.time.OffsetDateTime,3003)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetDateTime -> basicType@2(java.time.OffsetDateTime,3003)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetDateTime -> basicType@2(java.time.OffsetDateTime,3003)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.ZonedDateTimeType -> basicType@3(java.time.ZonedDateTime,3003)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration ZonedDateTime -> basicType@3(java.time.ZonedDateTime,3003)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.ZonedDateTime -> basicType@3(java.time.ZonedDateTime,3003)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration org.hibernate.type.OffsetTimeType -> basicType@4(java.time.OffsetTime,3007)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration OffsetTime -> basicType@4(java.time.OffsetTime,3007)
2025-10-23 17:10:41 [main] DEBUG o.hibernate.type.BasicTypeRegistry - Adding type registration java.time.OffsetTime -> basicType@4(java.time.OffsetTime,3007)
2025-10-23 17:10:41 [main] DEBUG o.h.type.spi.TypeConfiguration$Scope - Scoping TypeConfiguration [org.hibernate.type.spi.TypeConfiguration@268e30d4] to MetadataBuildingContext [org.hibernate.boot.internal.MetadataBuildingContextRootImpl@7c50709a]
2025-10-23 17:10:41 [main] INFO o.h.e.t.j.p.i.JtaPlatformInitiator - HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2025-10-23 17:10:41 [main] DEBUG o.h.type.spi.TypeConfiguration$Scope - Scoping TypeConfiguration [org.hibernate.type.spi.TypeConfiguration@268e30d4] to SessionFactoryImplementor [org.hibernate.internal.SessionFactoryImpl@263f6e96]
2025-10-23 17:10:41 [main] TRACE o.h.type.spi.TypeConfiguration$Scope - Handling #sessionFactoryCreated from [org.hibernate.internal.SessionFactoryImpl@263f6e96] for TypeConfiguration
2025-10-23 17:10:41 [main] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Initialized JPA EntityManagerFactory for persistence unit 'default'
2025-10-23 17:10:41 [main] WARN o.s.b.a.o.j.JpaBaseConfiguration$JpaWebConfiguration - spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2025-10-23 17:10:42 [main] ERROR i.n.r.d.DnsServerAddressStreamProviders - Unable to load io.netty.resolver.dns.macos.MacOSDnsServerAddressStreamProvider, fallback to system defaults. This may result in incorrect DNS resolutions on MacOS. Check whether you have a dependency on 'io.netty:netty-resolver-dns-native-macos'. Use DEBUG level to see the full stack: java.lang.UnsatisfiedLinkError: failed to load the required native library
2025-10-23 17:10:42 [main] WARN o.s.b.a.s.s.UserDetailsServiceAutoConfiguration -
Using generated security password: 4d9e182a-5838-4fd3-8e57-70219ebe9076
This generated password is for development use only. Your security configuration must be updated before running your application in production.
2025-10-23 17:10:42 [main] INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer - Global AuthenticationManager configured with UserDetailsService bean with name inMemoryUserDetailsManager
2025-10-23 17:10:42 [main] INFO o.s.b.a.e.web.EndpointLinksResolver - Exposing 3 endpoints beneath base path '/actuator'
2025-10-23 17:10:42 [main] INFO o.s.s.web.DefaultSecurityFilterChain - Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@23d1090, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@46c6541f, org.springframework.security.web.context.SecurityContextHolderFilter@67744663, org.springframework.security.web.header.HeaderWriterFilter@1a2bcce1, org.springframework.web.filter.CorsFilter@aba3735, org.springframework.security.web.csrf.CsrfFilter@417c9b17, org.springframework.security.web.authentication.logout.LogoutFilter@75b598a5, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@401b7109, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@24a024c8, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@16bba8ae, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@7f36b021, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@387cd426, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@6295cc30, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@7c689379, org.springframework.security.web.access.ExceptionTranslationFilter@ae5eeee, org.springframework.security.web.access.intercept.AuthorizationFilter@431bf770]
2025-10-23 17:10:42 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8084 (http) with context path '/'
2025-10-23 17:10:42 [main] INFO com.unicorn.hgzero.ai.AiApplication - Started AiApplication in 2.293 seconds (process running for 2.491)
2025-10-23 17:10:42 [RMI TCP Connection(1)-127.0.0.1] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring DispatcherServlet 'dispatcherServlet'
2025-10-23 17:10:42 [RMI TCP Connection(1)-127.0.0.1] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet'
2025-10-23 17:10:42 [RMI TCP Connection(1)-127.0.0.1] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 1 ms
2025-10-23 17:10:42 [boundedElastic-1] WARN o.s.b.a.d.r.RedisReactiveHealthIndicator - Redis health check failed
org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory$ExceptionTranslatingConnectionProvider.translateException(LettuceConnectionFactory.java:1847)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory$ExceptionTranslatingConnectionProvider.getConnection(LettuceConnectionFactory.java:1778)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory$SharedConnection.getNativeConnection(LettuceConnectionFactory.java:1580)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory$SharedConnection.lambda$getConnection$0(LettuceConnectionFactory.java:1560)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory.doInLock(LettuceConnectionFactory.java:1521)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory$SharedConnection.getConnection(LettuceConnectionFactory.java:1557)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory.getSharedReactiveConnection(LettuceConnectionFactory.java:1268)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory.getReactiveConnection(LettuceConnectionFactory.java:1143)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory.getReactiveConnection(LettuceConnectionFactory.java:119)
at reactor.core.publisher.MonoSupplier.call(MonoSupplier.java:67)
at reactor.core.publisher.FluxSubscribeOnCallable$CallableSubscribeOnSubscription.run(FluxSubscribeOnCallable.java:228)
at reactor.core.scheduler.SchedulerTask.call(SchedulerTask.java:68)
at reactor.core.scheduler.SchedulerTask.call(SchedulerTask.java:28)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:304)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1575)
Caused by: io.lettuce.core.RedisConnectionException: Unable to connect to 20.249.177.114/<unresolved>:6379
at io.lettuce.core.RedisConnectionException.create(RedisConnectionException.java:78)
at io.lettuce.core.RedisConnectionException.create(RedisConnectionException.java:56)
at io.lettuce.core.AbstractRedisClient.getConnection(AbstractRedisClient.java:350)
at io.lettuce.core.RedisClient.connect(RedisClient.java:215)
at org.springframework.data.redis.connection.lettuce.StandaloneConnectionProvider.lambda$getConnection$1(StandaloneConnectionProvider.java:112)
at java.base/java.util.Optional.orElseGet(Optional.java:364)
at org.springframework.data.redis.connection.lettuce.StandaloneConnectionProvider.getConnection(StandaloneConnectionProvider.java:112)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory$ExceptionTranslatingConnectionProvider.getConnection(LettuceConnectionFactory.java:1776)
... 16 common frames omitted
Caused by: io.lettuce.core.RedisCommandExecutionException: NOAUTH HELLO must be called with the client already authenticated, otherwise the HELLO <proto> AUTH <user> <pass> option can be used to authenticate the client and select the RESP protocol version at the same time
at io.lettuce.core.internal.ExceptionFactory.createExecutionException(ExceptionFactory.java:147)
at io.lettuce.core.internal.ExceptionFactory.createExecutionException(ExceptionFactory.java:116)
at io.lettuce.core.protocol.AsyncCommand.completeResult(AsyncCommand.java:120)
at io.lettuce.core.protocol.AsyncCommand.complete(AsyncCommand.java:111)
at io.lettuce.core.protocol.CommandWrapper.complete(CommandWrapper.java:63)
at io.lettuce.core.protocol.CommandHandler.complete(CommandHandler.java:745)
at io.lettuce.core.protocol.CommandHandler.decode(CommandHandler.java:680)
at io.lettuce.core.protocol.CommandHandler.channelRead(CommandHandler.java:597)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:442)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:420)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:412)
at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1407)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:440)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:420)
at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:918)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166)
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788)
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724)
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562)
at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
... 1 common frames omitted
2025-10-23 17:11:28 [http-nio-8084-exec-1] DEBUG o.s.security.web.FilterChainProxy - Securing GET /swagger-ui.html
2025-10-23 17:11:28 [http-nio-8084-exec-1] DEBUG o.s.s.w.a.AnonymousAuthenticationFilter - Set SecurityContextHolder to anonymous SecurityContext
2025-10-23 17:11:28 [http-nio-8084-exec-1] DEBUG o.s.s.w.s.HttpSessionRequestCache - Saved request http://localhost:8084/swagger-ui.html?continue to session
2025-10-23 17:11:28 [http-nio-8084-exec-1] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@49c72fb7, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]
2025-10-23 17:11:28 [http-nio-8084-exec-1] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@7d91e9c9
2025-10-23 17:11:28 [http-nio-8084-exec-1] DEBUG o.s.s.web.DefaultRedirectStrategy - Redirecting to http://localhost:8084/login
2025-10-23 17:11:28 [http-nio-8084-exec-2] DEBUG o.s.security.web.FilterChainProxy - Securing GET /login
2025-10-23 17:11:28 [http-nio-8084-exec-3] DEBUG o.s.security.web.FilterChainProxy - Securing GET /favicon.ico
2025-10-23 17:11:28 [http-nio-8084-exec-3] DEBUG o.s.s.w.a.AnonymousAuthenticationFilter - Set SecurityContextHolder to anonymous SecurityContext
2025-10-23 17:11:28 [http-nio-8084-exec-3] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@49c72fb7, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]
2025-10-23 17:11:28 [http-nio-8084-exec-3] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@7d91e9c9
2025-10-23 17:11:28 [http-nio-8084-exec-3] DEBUG o.s.s.web.DefaultRedirectStrategy - Redirecting to http://localhost:8084/login
2025-10-23 17:11:28 [http-nio-8084-exec-4] DEBUG o.s.security.web.FilterChainProxy - Securing GET /login
2025-10-23 17:11:43 [http-nio-8084-exec-5] DEBUG o.s.security.web.FilterChainProxy - Securing GET /swagger-ui.html
2025-10-23 17:11:43 [http-nio-8084-exec-5] DEBUG o.s.s.w.a.AnonymousAuthenticationFilter - Set SecurityContextHolder to anonymous SecurityContext
2025-10-23 17:11:43 [http-nio-8084-exec-5] DEBUG o.s.s.w.s.HttpSessionRequestCache - Saved request http://localhost:8084/swagger-ui.html?continue to session
2025-10-23 17:11:43 [http-nio-8084-exec-5] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@49c72fb7, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]
2025-10-23 17:11:43 [http-nio-8084-exec-5] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@7d91e9c9
2025-10-23 17:11:43 [http-nio-8084-exec-5] DEBUG o.s.s.web.DefaultRedirectStrategy - Redirecting to http://localhost:8084/login
2025-10-23 17:11:43 [http-nio-8084-exec-6] DEBUG o.s.security.web.FilterChainProxy - Securing GET /login
2025-10-23 17:11:43 [http-nio-8084-exec-7] DEBUG o.s.security.web.FilterChainProxy - Securing GET /favicon.ico
2025-10-23 17:11:43 [http-nio-8084-exec-7] DEBUG o.s.s.w.a.AnonymousAuthenticationFilter - Set SecurityContextHolder to anonymous SecurityContext
2025-10-23 17:11:43 [http-nio-8084-exec-7] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@49c72fb7, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]
2025-10-23 17:11:43 [http-nio-8084-exec-7] DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint - Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@7d91e9c9
2025-10-23 17:11:43 [http-nio-8084-exec-7] DEBUG o.s.s.web.DefaultRedirectStrategy - Redirecting to http://localhost:8084/login
2025-10-23 17:11:43 [http-nio-8084-exec-8] DEBUG o.s.security.web.FilterChainProxy - Securing GET /login
2025-10-23 17:12:37 [SpringApplicationShutdownHook] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Closing JPA EntityManagerFactory for persistence unit 'default'
2025-10-23 17:12:37 [SpringApplicationShutdownHook] TRACE o.h.type.spi.TypeConfiguration$Scope - Handling #sessionFactoryClosed from [org.hibernate.internal.SessionFactoryImpl@263f6e96] for TypeConfiguration
2025-10-23 17:12:37 [SpringApplicationShutdownHook] DEBUG o.h.type.spi.TypeConfiguration$Scope - Un-scoping TypeConfiguration [org.hibernate.type.spi.TypeConfiguration$Scope@7aa91cdd] from SessionFactory [org.hibernate.internal.SessionFactoryImpl@263f6e96]
2025-10-23 17:12:37 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated...
2025-10-23 17:12:37 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
nohup: ./gradlew: No such file or directory

1
meeting/logs/meeting.log Normal file
View File

@ -0,0 +1 @@
nohup: ./gradlew: No such file or directory

View File

@ -38,16 +38,21 @@ public class Meeting {
*/ */
private String purpose; private String purpose;
/**
* 회의 장소
*/
private String location;
/** /**
* 회의 일시 * 회의 일시
*/ */
private LocalDateTime scheduledAt; private LocalDateTime scheduledAt;
/**
* 회의 종료 예정 일시
*/
private LocalDateTime endTime;
/**
* 회의 장소
*/
private String location;
/** /**
* 회의 시작 일시 * 회의 시작 일시
*/ */

View File

@ -83,8 +83,8 @@ public class MeetingDTO {
.meetingId(meeting.getMeetingId()) .meetingId(meeting.getMeetingId())
.title(meeting.getTitle()) .title(meeting.getTitle())
.startTime(meeting.getStartedAt() != null ? meeting.getStartedAt() : meeting.getScheduledAt()) .startTime(meeting.getStartedAt() != null ? meeting.getStartedAt() : meeting.getScheduledAt())
.endTime(meeting.getEndedAt())
.purpose(meeting.getPurpose()) .purpose(meeting.getPurpose())
.endTime(meeting.getEndedAt() != null ? meeting.getEndedAt() : meeting.getEndTime())
.location(meeting.getLocation()) .location(meeting.getLocation())
.agenda(meeting.getDescription()) .agenda(meeting.getDescription())
.participants(meeting.getParticipants().stream() .participants(meeting.getParticipants().stream()

View File

@ -6,6 +6,8 @@ import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.*; import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.*;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader; import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter; import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter;
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import com.unicorn.hgzero.meeting.infra.event.publisher.EventPublisher;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -32,6 +34,8 @@ public class MeetingService implements
private final MeetingReader meetingReader; private final MeetingReader meetingReader;
private final MeetingWriter meetingWriter; private final MeetingWriter meetingWriter;
private final CacheService cacheService;
private final EventPublisher eventPublisher;
/** /**
* 회의 생성 * 회의 생성
@ -41,26 +45,75 @@ public class MeetingService implements
public Meeting createMeeting(CreateMeetingCommand command) { public Meeting createMeeting(CreateMeetingCommand command) {
log.info("Creating meeting: {}", command.title()); log.info("Creating meeting: {}", command.title());
// 회의 ID 생성 // 1. 회의 시간 유효성 검사
if (command.scheduledAt().isAfter(command.endTime()) ||
command.scheduledAt().isEqual(command.endTime())) {
log.warn("Invalid meeting time: start={}, end={}",
command.scheduledAt(), command.endTime());
throw new BusinessException(ErrorCode.INVALID_MEETING_TIME);
}
// 2. 중복 회의 체크
long conflictCount = meetingReader.countConflictingMeetings(
command.organizerId(),
command.scheduledAt(),
command.endTime()
);
if (conflictCount > 0) {
log.warn("Meeting time conflict detected: organizerId={}, count={}",
command.organizerId(), conflictCount);
throw new BusinessException(ErrorCode.MEETING_TIME_CONFLICT);
}
// 3. 회의 ID 생성
String meetingId = UUID.randomUUID().toString(); String meetingId = UUID.randomUUID().toString();
// 회의 도메인 객체 생성 // 4. 회의 도메인 객체 생성
Meeting meeting = Meeting.builder() Meeting meeting = Meeting.builder()
.meetingId(meetingId) .meetingId(meetingId)
.title(command.title()) .title(command.title())
.purpose(command.purpose()) .purpose(command.purpose())
.description(command.description()) .description(command.agenda())
.location(command.location()) .location(command.location())
.scheduledAt(command.scheduledAt()) .scheduledAt(command.scheduledAt())
.endTime(command.endTime())
.status("SCHEDULED") .status("SCHEDULED")
.organizerId(command.organizerId()) .organizerId(command.organizerId())
.participants(command.participants()) .participants(command.participants())
.templateId(command.templateId()) .templateId(command.templateId())
.build(); .build();
// 회의 저장 // 5. 회의 저장
Meeting savedMeeting = meetingWriter.save(meeting); Meeting savedMeeting = meetingWriter.save(meeting);
// 6. 캐시 저장 (TTL: 10분)
try {
cacheService.cacheMeeting(meetingId, savedMeeting, 600);
log.debug("Meeting cached: meetingId={}", meetingId);
} catch (Exception e) {
log.warn("Failed to cache meeting: meetingId={}", meetingId, e);
// 캐시 실패는 비즈니스 로직에 영향을 주지 않으므로 계속 진행
}
// 7. 참석자 초대 이벤트 발행 (비동기)
try {
eventPublisher.publishMeetingCreated(
meetingId,
command.title(),
command.scheduledAt(),
command.location(),
command.participants(),
command.organizerId(),
command.organizerId() // organizerName은 나중에 User 서비스 연동 개선
);
log.debug("Meeting invitation events published: meetingId={}, participants={}",
meetingId, command.participants().size());
} catch (Exception e) {
log.error("Failed to publish meeting invitation events: meetingId={}", meetingId, e);
// 이벤트 발행 실패는 비즈니스 로직에 영향을 주지 않으므로 계속 진행
}
log.info("Meeting created successfully: {}", savedMeeting.getMeetingId()); log.info("Meeting created successfully: {}", savedMeeting.getMeetingId());
return savedMeeting; return savedMeeting;
} }
@ -73,21 +126,31 @@ public class MeetingService implements
public Meeting startMeeting(String meetingId) { public Meeting startMeeting(String meetingId) {
log.info("Starting meeting: {}", meetingId); log.info("Starting meeting: {}", meetingId);
// Redis 캐시 조회 기능 필요
// 회의 조회 // 회의 조회
Meeting meeting = meetingReader.findById(meetingId) Meeting meeting = meetingReader.findById(meetingId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
// 권한 검증 (생성자 or 참석자)
// 회의 상태 검증 // 회의 상태 검증
if (!"SCHEDULED".equals(meeting.getStatus())) { if (!"SCHEDULED".equals(meeting.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
} }
// 세션 생성 기능 필요
// 회의 시작 // 회의 시작
meeting.start(); meeting.start();
// 저장 // 저장
Meeting updatedMeeting = meetingWriter.save(meeting); Meeting updatedMeeting = meetingWriter.save(meeting);
// 회의록 초안 생성 필요
// 이벤트 발행 필요
log.info("Meeting started successfully: {}", meetingId); log.info("Meeting started successfully: {}", meetingId);
return updatedMeeting; return updatedMeeting;
} }

View File

@ -21,8 +21,9 @@ public interface CreateMeetingUseCase {
record CreateMeetingCommand( record CreateMeetingCommand(
String title, String title,
String purpose, String purpose,
String description, String agenda,
LocalDateTime scheduledAt, LocalDateTime scheduledAt,
LocalDateTime endTime,
String location, String location,
String organizerId, String organizerId,
List<String> participants, List<String> participants,

View File

@ -40,4 +40,14 @@ public interface MeetingReader {
* 템플릿 ID로 회의 목록 조회 * 템플릿 ID로 회의 목록 조회
*/ */
List<Meeting> findByTemplateId(String templateId); List<Meeting> findByTemplateId(String templateId);
/**
* 주최자의 특정 시간대 중복 회의 개수 조회
*
* @param organizerId 주최자 ID
* @param startTime 회의 시작 시간
* @param endTime 회의 종료 시간
* @return 중복 회의 개수
*/
long countConflictingMeetings(String organizerId, LocalDateTime startTime, LocalDateTime endTime);
} }

View File

@ -23,6 +23,9 @@ public class CacheConfig {
@Value("${spring.data.redis.port:6379}") @Value("${spring.data.redis.port:6379}")
private int redisPort; private int redisPort;
@Value("${spring.data.redis.password:}")
private String redisPassword;
@Value("${spring.data.redis.database:1}") @Value("${spring.data.redis.database:1}")
private int database; private int database;
@ -33,7 +36,15 @@ public class CacheConfig {
public RedisConnectionFactory redisConnectionFactory() { public RedisConnectionFactory redisConnectionFactory() {
var factory = new LettuceConnectionFactory(redisHost, redisPort); var factory = new LettuceConnectionFactory(redisHost, redisPort);
factory.setDatabase(database); factory.setDatabase(database);
// 비밀번호가 설정된 경우에만 적용
if (redisPassword != null && !redisPassword.isEmpty()) {
factory.setPassword(redisPassword);
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}, password: ****", redisHost, redisPort, database);
} else {
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}", redisHost, redisPort, database); log.info("Redis 연결 설정 - host: {}, port: {}, database: {}", redisHost, redisPort, database);
}
return factory; return factory;
} }

View File

@ -5,6 +5,7 @@ import com.azure.messaging.eventhubs.EventHubProducerClient;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -14,6 +15,7 @@ import org.springframework.context.annotation.Configuration;
*/ */
@Configuration @Configuration
@Slf4j @Slf4j
@org.springframework.boot.autoconfigure.condition.ConditionalOnExpression("'${eventhub.connection-string:}'.length() > 0")
public class EventHubConfig { public class EventHubConfig {
@Value("${eventhub.connection-string}") @Value("${eventhub.connection-string}")

View File

@ -12,11 +12,15 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.StrictHttpFirewall;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
/** /**
* Spring Security 설정 * Spring Security 설정
@ -50,8 +54,8 @@ public class SecurityConfig {
// Meeting API endpoints (for testing) // Meeting API endpoints (for testing)
.requestMatchers("/api/meetings/**").permitAll() .requestMatchers("/api/meetings/**").permitAll()
// All other requests require authentication // All other requests require authentication
.requestMatchers("/api/templates/**").permitAll() // .anyRequest().authenticated()
.anyRequest().authenticated() .anyRequest().permitAll()
) )
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class) UsernamePasswordAuthenticationFilter.class)
@ -72,7 +76,8 @@ public class SecurityConfig {
// 허용할 헤더 // 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList( configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept", "Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers" "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers",
"X-User-Id", "X-User-Name", "X-User-Email"
)); ));
// 자격 증명 허용 // 자격 증명 허용
@ -85,4 +90,24 @@ public class SecurityConfig {
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
return source; return source;
} }
/**
* HttpFirewall 설정
* 한글을 포함한 모든 문자를 헤더 값으로 허용
*/
@Bean
public HttpFirewall allowUrlEncodedSlashHttpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
// 한글을 포함한 모든 문자를 허용하도록 설정
firewall.setAllowedHeaderValues(header -> true);
// URL 인코딩된 슬래시 허용
firewall.setAllowUrlEncodedSlash(true);
// 세미콜론 허용
firewall.setAllowSemicolon(true);
return firewall;
}
} }

View File

@ -32,6 +32,36 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
HttpServletResponse response, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
// 1. X-User-* 헤더를 통한 인증 (개발/테스트용)
String headerUserId = request.getHeader("X-User-Id");
String headerUserName = request.getHeader("X-User-Name");
String headerUserEmail = request.getHeader("X-User-Email");
if (StringUtils.hasText(headerUserId)) {
// X-User-* 헤더가 있으면 이를 사용하여 인증
UserPrincipal userPrincipal = UserPrincipal.builder()
.userId(headerUserId)
.username(headerUserName != null ? headerUserName : "unknown")
.email(headerUserEmail)
.authority("USER")
.build();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userPrincipal,
null,
Collections.singletonList(new SimpleGrantedAuthority("USER"))
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("헤더 기반 인증된 사용자: {} ({})", userPrincipal.getUsername(), headerUserId);
filterChain.doFilter(request, response);
return;
}
// 2. JWT 토큰을 통한 인증
String token = jwtTokenProvider.resolveToken(request); String token = jwtTokenProvider.resolveToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
@ -69,7 +99,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("인증된 사용자: {} ({})", userPrincipal.getUsername(), userId); log.debug("JWT 기반 인증된 사용자: {} ({})", userPrincipal.getUsername(), userId);
} }
} }

View File

@ -23,6 +23,11 @@ public class UserPrincipal {
*/ */
private final String username; private final String username;
/**
* 사용자 이메일
*/
private final String email;
/** /**
* 사용자 권한 * 사용자 권한
*/ */

View File

@ -70,6 +70,7 @@ public class MeetingController {
request.getPurpose(), request.getPurpose(),
request.getAgenda(), request.getAgenda(),
request.getStartTime(), request.getStartTime(),
request.getEndTime(),
request.getLocation(), request.getLocation(),
userId, userId,
request.getParticipants(), request.getParticipants(),
@ -153,6 +154,8 @@ public class MeetingController {
log.info("회의 시작 요청 - meetingId: {}, userId: {}", meetingId, userId); log.info("회의 시작 요청 - meetingId: {}, userId: {}", meetingId, userId);
// meeting id 유효성 검증 필요
var sessionData = startMeetingUseCase.startMeeting(meetingId); var sessionData = startMeetingUseCase.startMeeting(meetingId);
var response = SessionResponse.from(sessionData); var response = SessionResponse.from(sessionData);

View File

@ -21,6 +21,7 @@ import java.util.List;
public class CreateMeetingRequest { public class CreateMeetingRequest {
@NotBlank(message = "회의 제목은 필수입니다") @NotBlank(message = "회의 제목은 필수입니다")
@jakarta.validation.constraints.Size(max = 100, message = "회의 제목은 100자를 초과할 수 없습니다")
@Schema(description = "회의 제목", example = "Q1 전략 회의", required = true) @Schema(description = "회의 제목", example = "Q1 전략 회의", required = true)
private String title; private String title;

View File

@ -22,6 +22,7 @@ import org.springframework.stereotype.Component;
*/ */
@Component @Component
@Slf4j @Slf4j
@org.springframework.boot.autoconfigure.condition.ConditionalOnBean(name = "eventProducer")
public class EventHubPublisher implements EventPublisher { public class EventHubPublisher implements EventPublisher {
private final EventHubProducerClient eventProducer; private final EventHubProducerClient eventProducer;
@ -120,6 +121,34 @@ public class EventHubPublisher implements EventPublisher {
EventHubConstants.EVENT_TYPE_MINUTES_FINALIZED); EventHubConstants.EVENT_TYPE_MINUTES_FINALIZED);
} }
@Override
public void publishMeetingCreated(String meetingId, String title, LocalDateTime startTime,
String location, java.util.List<String> participants,
String organizerId, String organizerName) {
// 참석자에게 개별 알림 이벤트 발행
for (String participantEmail : participants) {
NotificationRequestEvent event = NotificationRequestEvent.builder()
.notificationType("MEETING_INVITATION")
.recipientEmail(participantEmail)
.recipientId(participantEmail)
.recipientName(participantEmail)
.title("회의 초대")
.message(String.format("'%s' 회의에 초대되었습니다. 일시: %s, 장소: %s",
title, startTime, location))
.relatedEntityId(meetingId)
.relatedEntityType("MEETING")
.requestedBy(organizerId)
.requestedByName(organizerName)
.eventTime(LocalDateTime.now())
.build();
publishNotificationRequest(event);
}
log.info("회의 생성 알림 발행 완료 - meetingId: {}, participants count: {}",
meetingId, participants.size());
}
/** /**
* 이벤트 발행 공통 메서드 * 이벤트 발행 공통 메서드
* *

View File

@ -6,6 +6,7 @@ import com.unicorn.hgzero.meeting.infra.event.dto.TodoAssignedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent; import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 이벤트 발행 인터페이스 * 이벤트 발행 인터페이스
@ -59,4 +60,19 @@ public interface EventPublisher {
* 회의록 확정 이벤트 발행 (편의 메서드) * 회의록 확정 이벤트 발행 (편의 메서드)
*/ */
void publishMinutesFinalized(String minutesId, String title, String finalizedBy, String finalizedByName); void publishMinutesFinalized(String minutesId, String title, String finalizedBy, String finalizedByName);
/**
* 회의 생성 알림 발행 (편의 메서드)
* 참석자에게 회의 초대 이메일 발송
*
* @param meetingId 회의 ID
* @param title 회의 제목
* @param startTime 회의 시작 시간
* @param location 회의 장소
* @param participants 참석자 이메일 목록
* @param organizerId 주최자 ID
* @param organizerName 주최자 이름
*/
void publishMeetingCreated(String meetingId, String title, LocalDateTime startTime,
String location, List<String> participants, String organizerId, String organizerName);
} }

View File

@ -0,0 +1,69 @@
package com.unicorn.hgzero.meeting.infra.event.publisher;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingStartedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingEndedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.TodoAssignedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* No-Op EventPublisher 구현체
* EventHub가 설정되지 않은 경우 사용되는 더미 구현체
*/
@Component
@Primary
@ConditionalOnMissingBean(name = "eventProducer")
@Slf4j
public class NoOpEventPublisher implements EventPublisher {
@Override
public void publishMeetingStarted(MeetingStartedEvent event) {
log.debug("[NoOp] Meeting started event: {}", event.getMeetingId());
}
@Override
public void publishMeetingEnded(MeetingEndedEvent event) {
log.debug("[NoOp] Meeting ended event: {}", event.getMeetingId());
}
@Override
public void publishTodoAssigned(TodoAssignedEvent event) {
log.debug("[NoOp] Todo assigned event: {}", event.getTodoId());
}
@Override
public void publishNotificationRequest(NotificationRequestEvent event) {
log.debug("[NoOp] Notification request event: {}", event.getRecipientId());
}
@Override
public void publishTodoAssigned(String todoId, String title, String assigneeId, String assigneeName,
String assignedBy, String assignedByName, LocalDate dueDate) {
log.debug("[NoOp] Todo assigned: todoId={}, title={}", todoId, title);
}
@Override
public void publishTodoCompleted(String todoId, String title, String assigneeId, String assigneeName,
String completedBy, String completedByName) {
log.debug("[NoOp] Todo completed: todoId={}, title={}", todoId, title);
}
@Override
public void publishMinutesFinalized(String minutesId, String title, String finalizedBy, String finalizedByName) {
log.debug("[NoOp] Minutes finalized: minutesId={}, title={}", minutesId, title);
}
@Override
public void publishMeetingCreated(String meetingId, String title, LocalDateTime startTime,
String location, List<String> participants, String organizerId, String organizerName) {
log.debug("[NoOp] Meeting created: meetingId={}, title={}, participants={}",
meetingId, title, participants.size());
}
}

View File

@ -77,4 +77,9 @@ public class MeetingGateway implements MeetingReader, MeetingWriter {
public void delete(String meetingId) { public void delete(String meetingId) {
meetingJpaRepository.deleteById(meetingId); meetingJpaRepository.deleteById(meetingId);
} }
@Override
public long countConflictingMeetings(String organizerId, LocalDateTime startTime, LocalDateTime endTime) {
return meetingJpaRepository.countConflictingMeetings(organizerId, startTime, endTime);
}
} }

View File

@ -31,12 +31,21 @@ public class MeetingEntity extends BaseTimeEntity {
@Column(name = "title", length = 200, nullable = false) @Column(name = "title", length = 200, nullable = false)
private String title; private String title;
@Column(name = "purpose", length = 500)
private String purpose;
@Column(name = "description", columnDefinition = "TEXT") @Column(name = "description", columnDefinition = "TEXT")
private String description; private String description;
@Column(name = "scheduled_at", nullable = false) @Column(name = "scheduled_at", nullable = false)
private LocalDateTime scheduledAt; private LocalDateTime scheduledAt;
@Column(name = "end_time")
private LocalDateTime endTime;
@Column(name = "location", length = 200)
private String location;
@Column(name = "started_at") @Column(name = "started_at")
private LocalDateTime startedAt; private LocalDateTime startedAt;
@ -60,8 +69,11 @@ public class MeetingEntity extends BaseTimeEntity {
return Meeting.builder() return Meeting.builder()
.meetingId(this.meetingId) .meetingId(this.meetingId)
.title(this.title) .title(this.title)
.purpose(this.purpose)
.description(this.description) .description(this.description)
.scheduledAt(this.scheduledAt) .scheduledAt(this.scheduledAt)
.endTime(this.endTime)
.location(this.location)
.startedAt(this.startedAt) .startedAt(this.startedAt)
.endedAt(this.endedAt) .endedAt(this.endedAt)
.status(this.status) .status(this.status)
@ -75,8 +87,11 @@ public class MeetingEntity extends BaseTimeEntity {
return MeetingEntity.builder() return MeetingEntity.builder()
.meetingId(meeting.getMeetingId()) .meetingId(meeting.getMeetingId())
.title(meeting.getTitle()) .title(meeting.getTitle())
.purpose(meeting.getPurpose())
.description(meeting.getDescription()) .description(meeting.getDescription())
.scheduledAt(meeting.getScheduledAt()) .scheduledAt(meeting.getScheduledAt())
.endTime(meeting.getEndTime())
.location(meeting.getLocation())
.startedAt(meeting.getStartedAt()) .startedAt(meeting.getStartedAt())
.endedAt(meeting.getEndedAt()) .endedAt(meeting.getEndedAt())
.status(meeting.getStatus()) .status(meeting.getStatus())

View File

@ -37,4 +37,26 @@ public interface MeetingJpaRepository extends JpaRepository<MeetingEntity, Strin
* 템플릿 ID로 회의 목록 조회 * 템플릿 ID로 회의 목록 조회
*/ */
List<MeetingEntity> findByTemplateId(String templateId); List<MeetingEntity> findByTemplateId(String templateId);
/**
* 주최자의 특정 시간대 중복 회의 개수 조회
* 회의 상태가 SCHEDULED 또는 IN_PROGRESS이고,
* 시간이 겹치는 회의가 있는지 확인
*
* @param organizerId 주최자 ID
* @param startTime 회의 시작 시간
* @param endTime 회의 종료 시간
* @return 중복 회의 개수
*/
@org.springframework.data.jpa.repository.Query(
"SELECT COUNT(m) FROM MeetingEntity m WHERE " +
"m.organizerId = :organizerId AND " +
"m.status IN ('SCHEDULED', 'IN_PROGRESS') AND " +
"((m.scheduledAt < :endTime AND m.endTime > :startTime))"
)
long countConflictingMeetings(
@org.springframework.data.repository.query.Param("organizerId") String organizerId,
@org.springframework.data.repository.query.Param("startTime") LocalDateTime startTime,
@org.springframework.data.repository.query.Param("endTime") LocalDateTime endTime
);
} }

View File

@ -8,7 +8,7 @@ spring:
datasource: datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:4.230.48.72}:${DB_PORT:5432}/${DB_NAME:meetingdb} url: jdbc:${DB_KIND:postgresql}://${DB_HOST:4.230.48.72}:${DB_PORT:5432}/${DB_NAME:meetingdb}
username: ${DB_USERNAME:hgzerouser} username: ${DB_USERNAME:hgzerouser}
password: ${DB_PASSWORD:} password: ${DB_PASSWORD:Hi5Jessica!}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
hikari: hikari:
maximum-pool-size: 20 maximum-pool-size: 20
@ -35,7 +35,7 @@ spring:
redis: redis:
host: ${REDIS_HOST:20.249.177.114} host: ${REDIS_HOST:20.249.177.114}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:Hi5Jessica!}
timeout: 2000ms timeout: 2000ms
lettuce: lettuce:
pool: pool:
@ -51,7 +51,7 @@ server:
# JWT Configuration # JWT Configuration
jwt: jwt:
secret: ${JWT_SECRET:} secret: ${JWT_SECRET:hgzero-jwt-secret-key-for-dev-environment-only-do-not-use-in-production-minimum-256-bits}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600} access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800} refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800}

View File

@ -3,6 +3,18 @@ bootJar {
} }
dependencies { dependencies {
// Common module
implementation project(':common')
// Spring Boot starters
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Database
runtimeOnly 'org.postgresql:postgresql'
// Azure Speech SDK // Azure Speech SDK
implementation "com.microsoft.cognitiveservices.speech:client-sdk:${azureSpeechVersion}" implementation "com.microsoft.cognitiveservices.speech:client-sdk:${azureSpeechVersion}"
@ -14,4 +26,11 @@ dependencies {
// WebSocket // WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-websocket'
// Test dependencies
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'com.h2database:h2'
testImplementation('it.ozimov:embedded-redis:0.7.3') {
exclude group: 'org.slf4j', module: 'slf4j-simple'
}
} }

View File

@ -1,146 +0,0 @@
package com.unicorn.hgzero.stt.controller;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.stt.dto.SpeakerDto;
import com.unicorn.hgzero.stt.service.SpeakerService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 화자 관리 컨트롤러
* 화자 식별 관리 기능을 제공
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/stt/speakers")
@RequiredArgsConstructor
@Validated
@Tag(name = "화자 관리", description = "화자 식별 및 관리 API")
public class SpeakerController {
private final SpeakerService speakerService;
@Operation(
summary = "화자 식별",
description = "음성 데이터로부터 화자를 식별합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "화자 식별 성공",
content = @Content(schema = @Schema(implementation = SpeakerDto.IdentificationResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 요청"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "녹음을 찾을 수 없음"
)
})
@PostMapping("/identify")
public ResponseEntity<ApiResponse<SpeakerDto.IdentificationResponse>> identifySpeaker(
@Valid @RequestBody SpeakerDto.IdentifyRequest request) {
log.info("화자 식별 요청 - recordingId: {}", request.getRecordingId());
SpeakerDto.IdentificationResponse response = speakerService.identifySpeaker(request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "화자 정보 조회",
description = "화자의 상세 정보를 조회합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = SpeakerDto.DetailResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "화자를 찾을 수 없음"
)
})
@GetMapping("/{speakerId}")
public ResponseEntity<ApiResponse<SpeakerDto.DetailResponse>> getSpeaker(
@Parameter(description = "화자 ID", required = true)
@PathVariable String speakerId) {
log.debug("화자 조회 요청 - speakerId: {}", speakerId);
SpeakerDto.DetailResponse response = speakerService.getSpeaker(speakerId);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "화자 정보 수정",
description = "화자의 이름 및 사용자 연결 정보를 수정합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "수정 성공",
content = @Content(schema = @Schema(implementation = SpeakerDto.DetailResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 요청"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "화자를 찾을 수 없음"
)
})
@PutMapping("/{speakerId}")
public ResponseEntity<ApiResponse<SpeakerDto.DetailResponse>> updateSpeaker(
@Parameter(description = "화자 ID", required = true)
@PathVariable String speakerId,
@Valid @RequestBody SpeakerDto.UpdateRequest request) {
log.info("화자 정보 수정 요청 - speakerId: {}, speakerName: {}",
speakerId, request.getSpeakerName());
SpeakerDto.DetailResponse response = speakerService.updateSpeaker(speakerId, request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "녹음별 화자 목록 조회",
description = "특정 녹음에 참여한 화자 목록과 통계를 조회합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = SpeakerDto.ListResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "녹음을 찾을 수 없음"
)
})
@GetMapping("/recordings/{recordingId}")
public ResponseEntity<ApiResponse<SpeakerDto.ListResponse>> getRecordingSpeakers(
@Parameter(description = "녹음 ID", required = true)
@PathVariable String recordingId) {
log.debug("녹음별 화자 목록 조회 요청 - recordingId: {}", recordingId);
SpeakerDto.ListResponse response = speakerService.getRecordingSpeakers(recordingId);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -12,11 +12,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@ -64,64 +62,7 @@ public class TranscriptionController {
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }
@Operation(
summary = "배치 음성 변환",
description = "오디오 파일을 업로드하여 배치 변환 작업을 시작합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "배치 작업 시작 성공",
content = @Content(schema = @Schema(implementation = TranscriptionDto.BatchResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 파일 형식 또는 요청"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "녹음 세션을 찾을 수 없음"
)
})
@PostMapping(value = "/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<TranscriptionDto.BatchResponse>> transcribeAudioBatch(
@Parameter(description = "배치 변환 요청 정보")
@Valid @RequestPart("request") TranscriptionDto.BatchRequest request,
@Parameter(description = "변환할 오디오 파일")
@RequestPart("audioFile") MultipartFile audioFile) {
log.info("배치 음성 변환 요청 - recordingId: {}, fileSize: {}",
request.getRecordingId(), audioFile.getSize());
TranscriptionDto.BatchResponse response = transcriptionService.transcribeAudioBatch(request, audioFile);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "배치 변환 완료 콜백",
description = "Azure 배치 변환 완료 시 호출되는 콜백 엔드포인트입니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "콜백 처리 성공",
content = @Content(schema = @Schema(implementation = TranscriptionDto.CompleteResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 콜백 데이터"
)
})
@PostMapping("/batch/callback")
public ResponseEntity<ApiResponse<TranscriptionDto.CompleteResponse>> processBatchCallback(
@Valid @RequestBody TranscriptionDto.BatchCallbackRequest request) {
log.info("배치 변환 콜백 처리 - jobId: {}, status: {}",
request.getJobId(), request.getStatus());
TranscriptionDto.CompleteResponse response = transcriptionService.processBatchCallback(request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation( @Operation(
summary = "변환 결과 조회", summary = "변환 결과 조회",
@ -141,15 +82,10 @@ public class TranscriptionController {
@GetMapping("/{recordingId}") @GetMapping("/{recordingId}")
public ResponseEntity<ApiResponse<TranscriptionDto.Response>> getTranscription( public ResponseEntity<ApiResponse<TranscriptionDto.Response>> getTranscription(
@Parameter(description = "녹음 ID", required = true) @Parameter(description = "녹음 ID", required = true)
@PathVariable String recordingId, @PathVariable String recordingId) {
@Parameter(description = "세그먼트 정보 포함 여부") log.debug("변환 결과 조회 요청 - recordingId: {}", recordingId);
@RequestParam(required = false, defaultValue = "false") Boolean includeSegments,
@Parameter(description = "특정 화자 필터")
@RequestParam(required = false) String speakerId) {
log.debug("변환 결과 조회 요청 - recordingId: {}, includeSegments: {}, speakerId: {}",
recordingId, includeSegments, speakerId);
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId, includeSegments, speakerId); TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId);
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }

View File

@ -50,16 +50,6 @@ public class Recording {
*/ */
private final Integer duration; private final Integer duration;
/**
* 파일 크기 (bytes)
*/
private final Long fileSize;
/**
* 저장 경로
*/
private final String storagePath;
/** /**
* 언어 설정 * 언어 설정
*/ */

View File

@ -1,72 +0,0 @@
package com.unicorn.hgzero.stt.domain;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* 화자 도메인 모델
* 음성 화자 정보를 나타내는 도메인 객체
*/
@Getter
@Builder
@ToString
public class Speaker {
/**
* 화자 ID
*/
private final String speakerId;
/**
* 화자 이름
*/
private final String speakerName;
/**
* Azure Speaker Profile ID
*/
private final String profileId;
/**
* 연결된 사용자 ID
*/
private final String userId;
/**
* 발언 세그먼트
*/
private final Integer totalSegments;
/**
* 발언 시간 ()
*/
private final Integer totalDuration;
/**
* 평균 식별 신뢰도
*/
private final Double averageConfidence;
/**
* 최초 등장 시간
*/
private final LocalDateTime firstAppeared;
/**
* 최근 등장 시간
*/
private final LocalDateTime lastAppeared;
/**
* 생성 시간
*/
private final LocalDateTime createdAt;
/**
* 수정 시간
*/
private final LocalDateTime updatedAt;
}

View File

@ -50,7 +50,6 @@ public class RecordingDto {
private final String sessionId; private final String sessionId;
private final String status; private final String status;
private final String streamUrl; private final String streamUrl;
private final String storagePath;
private final Integer estimatedInitTime; private final Integer estimatedInitTime;
} }
@ -95,8 +94,6 @@ public class RecordingDto {
private final LocalDateTime startTime; private final LocalDateTime startTime;
private final LocalDateTime endTime; private final LocalDateTime endTime;
private final Integer duration; private final Integer duration;
private final Long fileSize;
private final String storagePath;
} }
/** /**
@ -116,7 +113,6 @@ public class RecordingDto {
private final Integer duration; private final Integer duration;
private final Integer speakerCount; private final Integer speakerCount;
private final Integer segmentCount; private final Integer segmentCount;
private final String storagePath;
private final String language; private final String language;
} }
} }

View File

@ -1,109 +0,0 @@
package com.unicorn.hgzero.stt.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import java.time.LocalDateTime;
import java.util.List;
/**
* 화자 관련 DTO 클래스들
*/
public class SpeakerDto {
/**
* 화자 식별 요청 DTO
*/
@Getter
@Builder
@ToString
public static class IdentifyRequest {
@NotBlank(message = "녹음 ID는 필수입니다")
private final String recordingId;
@NotBlank(message = "오디오 프레임은 필수입니다")
private final String audioFrame;
private final Long timestamp;
}
/**
* 화자 식별 응답 DTO
*/
@Getter
@Builder
@ToString
public static class IdentificationResponse {
private final String speakerId;
private final String speakerName;
private final Double confidence;
private final Boolean isNewSpeaker;
private final String profileId;
}
/**
* 화자 상세 응답 DTO
*/
@Getter
@Builder
@ToString
public static class DetailResponse {
private final String speakerId;
private final String speakerName;
private final String profileId;
private final String userId;
private final Integer totalSegments;
private final Integer totalDuration;
private final Double averageConfidence;
private final LocalDateTime firstAppeared;
private final LocalDateTime lastAppeared;
}
/**
* 화자 정보 업데이트 요청 DTO
*/
@Getter
@Builder
@ToString
public static class UpdateRequest {
private final String speakerName;
private final String userId;
}
/**
* 화자 목록 응답 DTO
*/
@Getter
@Builder
@ToString
public static class ListResponse {
private final String recordingId;
private final Integer speakerCount;
private final List<SpeakerSummary> speakers;
}
/**
* 화자 요약 DTO
*/
@Getter
@Builder
@ToString
public static class SpeakerSummary {
private final String speakerId;
private final String speakerName;
private final Integer segmentCount;
private final Integer totalDuration;
private final Double speakingRatio;
}
}

View File

@ -62,12 +62,10 @@ public class RecordingEvent {
private LocalDateTime startTime; private LocalDateTime startTime;
private LocalDateTime endTime; private LocalDateTime endTime;
private Integer duration; private Integer duration;
private Long fileSize;
private String storagePath;
private LocalDateTime eventTime; private LocalDateTime eventTime;
public static RecordingStopped of(String recordingId, String meetingId, String stoppedBy, public static RecordingStopped of(String recordingId, String meetingId, String stoppedBy,
LocalDateTime startTime, Integer duration, Long fileSize, String storagePath) { LocalDateTime startTime, Integer duration) {
return RecordingStopped.builder() return RecordingStopped.builder()
.eventId(java.util.UUID.randomUUID().toString()) .eventId(java.util.UUID.randomUUID().toString())
.eventType("RecordingStopped") .eventType("RecordingStopped")
@ -77,8 +75,6 @@ public class RecordingEvent {
.startTime(startTime) .startTime(startTime)
.endTime(LocalDateTime.now()) .endTime(LocalDateTime.now())
.duration(duration) .duration(duration)
.fileSize(fileSize)
.storagePath(storagePath)
.eventTime(LocalDateTime.now()) .eventTime(LocalDateTime.now())
.build(); .build();
} }

View File

@ -1,120 +0,0 @@
package com.unicorn.hgzero.stt.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 화자 관련 이벤트 정의
*/
public class SpeakerEvent {
/**
* 신규 화자 식별 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class NewSpeakerIdentified {
private String eventId;
private String eventType;
private String speakerId;
private String speakerName;
private String profileId;
private String recordingId;
private String meetingId;
private Double confidence;
private LocalDateTime firstAppeared;
private LocalDateTime eventTime;
public static NewSpeakerIdentified of(String speakerId, String speakerName, String profileId,
String recordingId, String meetingId, Double confidence) {
return NewSpeakerIdentified.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("NewSpeakerIdentified")
.speakerId(speakerId)
.speakerName(speakerName)
.profileId(profileId)
.recordingId(recordingId)
.meetingId(meetingId)
.confidence(confidence)
.firstAppeared(LocalDateTime.now())
.eventTime(LocalDateTime.now())
.build();
}
}
/**
* 화자 정보 업데이트 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SpeakerUpdated {
private String eventId;
private String eventType;
private String speakerId;
private String oldSpeakerName;
private String newSpeakerName;
private String oldUserId;
private String newUserId;
private String updatedBy;
private LocalDateTime eventTime;
public static SpeakerUpdated of(String speakerId, String oldSpeakerName, String newSpeakerName,
String oldUserId, String newUserId, String updatedBy) {
return SpeakerUpdated.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("SpeakerUpdated")
.speakerId(speakerId)
.oldSpeakerName(oldSpeakerName)
.newSpeakerName(newSpeakerName)
.oldUserId(oldUserId)
.newUserId(newUserId)
.updatedBy(updatedBy)
.eventTime(LocalDateTime.now())
.build();
}
}
/**
* 화자 통계 업데이트 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SpeakerStatisticsUpdated {
private String eventId;
private String eventType;
private String speakerId;
private String recordingId;
private String meetingId;
private Integer totalSegments;
private Integer totalDuration;
private Double averageConfidence;
private LocalDateTime lastAppeared;
private LocalDateTime eventTime;
public static SpeakerStatisticsUpdated of(String speakerId, String recordingId, String meetingId,
Integer totalSegments, Integer totalDuration, Double averageConfidence) {
return SpeakerStatisticsUpdated.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("SpeakerStatisticsUpdated")
.speakerId(speakerId)
.recordingId(recordingId)
.meetingId(meetingId)
.totalSegments(totalSegments)
.totalDuration(totalDuration)
.averageConfidence(averageConfidence)
.lastAppeared(LocalDateTime.now())
.eventTime(LocalDateTime.now())
.build();
}
}
}

View File

@ -43,12 +43,6 @@ public class RecordingEntity extends BaseTimeEntity {
@Column(name = "duration") @Column(name = "duration")
private Integer duration; private Integer duration;
@Column(name = "file_size")
private Long fileSize;
@Column(name = "storage_path", length = 500)
private String storagePath;
@Column(name = "language", length = 10, nullable = false) @Column(name = "language", length = 10, nullable = false)
private String language; private String language;
@ -70,8 +64,6 @@ public class RecordingEntity extends BaseTimeEntity {
.startTime(startTime) .startTime(startTime)
.endTime(endTime) .endTime(endTime)
.duration(duration) .duration(duration)
.fileSize(fileSize)
.storagePath(storagePath)
.language(language) .language(language)
.speakerCount(speakerCount) .speakerCount(speakerCount)
.segmentCount(segmentCount) .segmentCount(segmentCount)
@ -90,8 +82,6 @@ public class RecordingEntity extends BaseTimeEntity {
.startTime(recording.getStartTime()) .startTime(recording.getStartTime())
.endTime(recording.getEndTime()) .endTime(recording.getEndTime())
.duration(recording.getDuration()) .duration(recording.getDuration())
.fileSize(recording.getFileSize())
.storagePath(recording.getStoragePath())
.language(recording.getLanguage()) .language(recording.getLanguage())
.speakerCount(recording.getSpeakerCount()) .speakerCount(recording.getSpeakerCount())
.segmentCount(recording.getSegmentCount()) .segmentCount(recording.getSegmentCount())

View File

@ -1,104 +0,0 @@
package com.unicorn.hgzero.stt.repository.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.stt.domain.Speaker;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* 화자 엔티티
* 음성 화자 정보를 저장하는 JPA 엔티티
*/
@Entity
@Table(name = "speakers")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString
public class SpeakerEntity extends BaseTimeEntity {
@Id
@Column(name = "speaker_id", length = 50)
private String speakerId;
@Column(name = "speaker_name", length = 100)
private String speakerName;
@Column(name = "profile_id", length = 100)
private String profileId;
@Column(name = "user_id", length = 50)
private String userId;
@Column(name = "total_segments")
private Integer totalSegments;
@Column(name = "total_duration")
private Integer totalDuration;
@Column(name = "average_confidence")
private Double averageConfidence;
@Column(name = "first_appeared")
private LocalDateTime firstAppeared;
@Column(name = "last_appeared")
private LocalDateTime lastAppeared;
/**
* 도메인 객체로 변환
*/
public Speaker toDomain() {
return Speaker.builder()
.speakerId(speakerId)
.speakerName(speakerName)
.profileId(profileId)
.userId(userId)
.totalSegments(totalSegments)
.totalDuration(totalDuration)
.averageConfidence(averageConfidence)
.firstAppeared(firstAppeared)
.lastAppeared(lastAppeared)
.createdAt(getCreatedAt())
.updatedAt(getUpdatedAt())
.build();
}
/**
* 도메인 객체에서 엔티티 생성
*/
public static SpeakerEntity fromDomain(Speaker speaker) {
return SpeakerEntity.builder()
.speakerId(speaker.getSpeakerId())
.speakerName(speaker.getSpeakerName())
.profileId(speaker.getProfileId())
.userId(speaker.getUserId())
.totalSegments(speaker.getTotalSegments())
.totalDuration(speaker.getTotalDuration())
.averageConfidence(speaker.getAverageConfidence())
.firstAppeared(speaker.getFirstAppeared())
.lastAppeared(speaker.getLastAppeared())
.build();
}
/**
* 화자 정보 업데이트
*/
public void updateSpeakerInfo(String speakerName, String userId) {
this.speakerName = speakerName;
this.userId = userId;
}
/**
* 통계 정보 업데이트
*/
public void updateStatistics(Integer totalSegments, Integer totalDuration, Double averageConfidence) {
this.totalSegments = totalSegments;
this.totalDuration = totalDuration;
this.averageConfidence = averageConfidence;
this.lastAppeared = LocalDateTime.now();
}
}

View File

@ -1,51 +0,0 @@
package com.unicorn.hgzero.stt.repository.jpa;
import com.unicorn.hgzero.stt.repository.entity.SpeakerEntity;
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.util.List;
import java.util.Optional;
/**
* 화자 JPA Repository
* 화자 정보에 대한 데이터베이스 접근을 담당
*/
@Repository
public interface SpeakerRepository extends JpaRepository<SpeakerEntity, String> {
/**
* 사용자 ID로 화자 조회
*/
Optional<SpeakerEntity> findByUserId(String userId);
/**
* Azure Profile ID로 화자 조회
*/
Optional<SpeakerEntity> findByProfileId(String profileId);
/**
* 화자명으로 검색
*/
List<SpeakerEntity> findBySpeakerNameContaining(String speakerName);
/**
* 발언 비중이 높은 화자 조회
*/
@Query("SELECT s FROM SpeakerEntity s WHERE s.totalDuration > :minDuration ORDER BY s.totalDuration DESC")
List<SpeakerEntity> findActiveSpeakers(@Param("minDuration") Integer minDuration);
/**
* 신뢰도가 낮은 화자 조회
*/
@Query("SELECT s FROM SpeakerEntity s WHERE s.averageConfidence < :threshold ORDER BY s.averageConfidence ASC")
List<SpeakerEntity> findLowConfidenceSpeakers(@Param("threshold") Double threshold);
/**
* 사용자 ID 미지정 화자 조회
*/
@Query("SELECT s FROM SpeakerEntity s WHERE s.userId IS NULL ORDER BY s.totalDuration DESC")
List<SpeakerEntity> findUnassignedSpeakers();
}

View File

@ -50,7 +50,6 @@ public class RecordingServiceImpl implements RecordingService {
.language(request.getLanguage() != null ? request.getLanguage() : "ko-KR") .language(request.getLanguage() != null ? request.getLanguage() : "ko-KR")
.speakerCount(0) .speakerCount(0)
.segmentCount(0) .segmentCount(0)
.storagePath(generateStoragePath(request.getMeetingId(), request.getSessionId()))
.build(); .build();
recordingRepository.save(recording); recordingRepository.save(recording);
@ -65,7 +64,6 @@ public class RecordingServiceImpl implements RecordingService {
.sessionId(request.getSessionId()) .sessionId(request.getSessionId())
.status("READY") .status("READY")
.streamUrl(streamUrl) .streamUrl(streamUrl)
.storagePath(recording.getStoragePath())
.estimatedInitTime(1100) .estimatedInitTime(1100)
.build(); .build();
} }
@ -119,14 +117,13 @@ public class RecordingServiceImpl implements RecordingService {
// 녹음 시간 계산 (임시로 30분으로 설정) // 녹음 시간 계산 (임시로 30분으로 설정)
Integer duration = 1800; // 실제로는 startTime과 endTime 차이 계산 Integer duration = 1800; // 실제로는 startTime과 endTime 차이 계산
Long fileSize = 172800000L; // 실제로는 Azure Blob에서 파일 크기 조회
RecordingEntity savedRecording = recordingRepository.save(recording); RecordingEntity savedRecording = recordingRepository.save(recording);
// 녹음 중지 이벤트 발행 // 녹음 중지 이벤트 발행 (음성 파일 저장하지 않으므로 파일 정보 제외)
RecordingEvent.RecordingStopped event = RecordingEvent.RecordingStopped.of( RecordingEvent.RecordingStopped event = RecordingEvent.RecordingStopped.of(
recordingId, recording.getMeetingId(), request.getStoppedBy(), recordingId, recording.getMeetingId(), request.getStoppedBy(),
recording.getStartTime(), duration, fileSize, recording.getStoragePath() recording.getStartTime(), duration
); );
eventPublisher.publishAsync("recording-events", event); eventPublisher.publishAsync("recording-events", event);
@ -138,8 +135,6 @@ public class RecordingServiceImpl implements RecordingService {
.startTime(recording.getStartTime()) .startTime(recording.getStartTime())
.endTime(endTime) .endTime(endTime)
.duration(duration) .duration(duration)
.fileSize(fileSize)
.storagePath(recording.getStoragePath())
.build(); .build();
} }
@ -160,7 +155,6 @@ public class RecordingServiceImpl implements RecordingService {
.duration(recording.getDuration()) .duration(recording.getDuration())
.speakerCount(recording.getSpeakerCount()) .speakerCount(recording.getSpeakerCount())
.segmentCount(recording.getSegmentCount()) .segmentCount(recording.getSegmentCount())
.storagePath(recording.getStoragePath())
.language(recording.getLanguage()) .language(recording.getLanguage())
.build(); .build();
} }
@ -181,10 +175,4 @@ public class RecordingServiceImpl implements RecordingService {
String.format("%03d", (int)(Math.random() * 1000)); String.format("%03d", (int)(Math.random() * 1000));
} }
/**
* 저장 경로 생성
*/
private String generateStoragePath(String meetingId, String sessionId) {
return String.format("recordings/%s/%s.wav", meetingId, sessionId);
}
} }

View File

@ -1,43 +0,0 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.stt.dto.SpeakerDto;
/**
* 화자 서비스 인터페이스
* 화자 식별 관리 기능을 정의
*/
public interface SpeakerService {
/**
* 화자 식별
*
* @param request 화자 식별 요청
* @return 화자 식별 응답
*/
SpeakerDto.IdentificationResponse identifySpeaker(SpeakerDto.IdentifyRequest request);
/**
* 화자 정보 조회
*
* @param speakerId 화자 ID
* @return 화자 상세 응답
*/
SpeakerDto.DetailResponse getSpeaker(String speakerId);
/**
* 화자 정보 업데이트
*
* @param speakerId 화자 ID
* @param request 화자 업데이트 요청
* @return 화자 상세 응답
*/
SpeakerDto.DetailResponse updateSpeaker(String speakerId, SpeakerDto.UpdateRequest request);
/**
* 녹음의 화자 목록 조회
*
* @param recordingId 녹음 ID
* @return 화자 목록 응답
*/
SpeakerDto.ListResponse getRecordingSpeakers(String recordingId);
}

View File

@ -1,218 +0,0 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.stt.dto.SpeakerDto;
import com.unicorn.hgzero.stt.event.SpeakerEvent;
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
import com.unicorn.hgzero.stt.repository.entity.SpeakerEntity;
import com.unicorn.hgzero.stt.repository.jpa.SpeakerRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 화자 서비스 구현체
* 화자 식별 관리 기능을 구현
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class SpeakerServiceImpl implements SpeakerService {
private final SpeakerRepository speakerRepository;
private final TranscriptSegmentRepository segmentRepository;
private final EventPublisher eventPublisher;
@Override
public SpeakerDto.IdentificationResponse identifySpeaker(SpeakerDto.IdentifyRequest request) {
log.info("화자 식별 시작 - recordingId: {}", request.getRecordingId());
// Azure Speaker Recognition API 호출 시뮬레이션
String profileId = simulateAzureSpeakerRecognition(request.getAudioFrame());
Double confidence = simulateIdentificationConfidence();
// 기존 화자 조회
SpeakerEntity speaker = speakerRepository.findByProfileId(profileId).orElse(null);
boolean isNewSpeaker = false;
if (speaker == null) {
// 신규 화자 등록
String speakerId = generateSpeakerId();
String speakerName = "화자-" + speakerId.substring(4);
speaker = SpeakerEntity.builder()
.speakerId(speakerId)
.speakerName(speakerName)
.profileId(profileId)
.totalSegments(0)
.totalDuration(0)
.averageConfidence(confidence)
.firstAppeared(LocalDateTime.now())
.lastAppeared(LocalDateTime.now())
.build();
speakerRepository.save(speaker);
isNewSpeaker = true;
// 신규 화자 식별 이벤트 발행
SpeakerEvent.NewSpeakerIdentified event = SpeakerEvent.NewSpeakerIdentified.of(
speakerId, speakerName, profileId, request.getRecordingId(),
extractMeetingIdFromRecordingId(request.getRecordingId()), confidence
);
eventPublisher.publishAsync("speaker-events", event);
log.info("신규 화자 등록 완료 - speakerId: {}, confidence: {}", speakerId, confidence);
} else {
log.debug("기존 화자 식별 - speakerId: {}, confidence: {}", speaker.getSpeakerId(), confidence);
}
return SpeakerDto.IdentificationResponse.builder()
.speakerId(speaker.getSpeakerId())
.speakerName(speaker.getSpeakerName())
.confidence(confidence)
.isNewSpeaker(isNewSpeaker)
.profileId(profileId)
.build();
}
@Override
@Transactional(readOnly = true)
public SpeakerDto.DetailResponse getSpeaker(String speakerId) {
log.debug("화자 정보 조회 - speakerId: {}", speakerId);
SpeakerEntity speaker = findSpeakerById(speakerId);
return SpeakerDto.DetailResponse.builder()
.speakerId(speaker.getSpeakerId())
.speakerName(speaker.getSpeakerName())
.profileId(speaker.getProfileId())
.userId(speaker.getUserId())
.totalSegments(speaker.getTotalSegments())
.totalDuration(speaker.getTotalDuration())
.averageConfidence(speaker.getAverageConfidence())
.firstAppeared(speaker.getFirstAppeared())
.lastAppeared(speaker.getLastAppeared())
.build();
}
@Override
public SpeakerDto.DetailResponse updateSpeaker(String speakerId, SpeakerDto.UpdateRequest request) {
log.info("화자 정보 업데이트 - speakerId: {}", speakerId);
SpeakerEntity speaker = findSpeakerById(speakerId);
// 화자 정보 업데이트
String oldSpeakerName = speaker.getSpeakerName();
String oldUserId = speaker.getUserId();
speaker.updateSpeakerInfo(request.getSpeakerName(), request.getUserId());
SpeakerEntity savedSpeaker = speakerRepository.save(speaker);
// 화자 정보 업데이트 이벤트 발행
SpeakerEvent.SpeakerUpdated event = SpeakerEvent.SpeakerUpdated.of(
speakerId, oldSpeakerName, request.getSpeakerName(),
oldUserId, request.getUserId(), "SYSTEM"
);
eventPublisher.publishAsync("speaker-events", event);
log.info("화자 정보 업데이트 완료 - speakerId: {}, speakerName: {}",
speakerId, request.getSpeakerName());
return getSpeaker(speakerId);
}
@Override
@Transactional(readOnly = true)
public SpeakerDto.ListResponse getRecordingSpeakers(String recordingId) {
log.debug("녹음 화자 목록 조회 - recordingId: {}", recordingId);
// 녹음별 화자 통계 조회
List<Object[]> speakerStats = segmentRepository.getSpeakerStatisticsByRecording(recordingId);
List<SpeakerDto.SpeakerSummary> speakers = speakerStats.stream()
.map(stat -> {
String speakerId = (String) stat[0];
Long segmentCount = (Long) stat[1];
Double totalDuration = (Double) stat[2];
Double averageConfidence = (Double) stat[3];
// 화자 정보 조회
SpeakerEntity speaker = speakerRepository.findById(speakerId).orElse(null);
String speakerName = speaker != null ? speaker.getSpeakerName() : "알 수 없는 화자";
// 발언 비율 계산 (전체 시간 대비)
Double speakingRatio = calculateSpeakingRatio(recordingId, totalDuration);
return SpeakerDto.SpeakerSummary.builder()
.speakerId(speakerId)
.speakerName(speakerName)
.segmentCount(segmentCount.intValue())
.totalDuration(totalDuration.intValue())
.speakingRatio(speakingRatio)
.build();
})
.collect(Collectors.toList());
return SpeakerDto.ListResponse.builder()
.recordingId(recordingId)
.speakerCount(speakers.size())
.speakers(speakers)
.build();
}
/**
* 화자 ID로 엔티티 조회
*/
private SpeakerEntity findSpeakerById(String speakerId) {
return speakerRepository.findById(speakerId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "화자를 찾을 수 없습니다"));
}
/**
* 화자 ID 생성
*/
private String generateSpeakerId() {
return "SPK-" + String.format("%03d", (int)(Math.random() * 999) + 1);
}
/**
* Azure Speaker Recognition 시뮬레이션
*/
private String simulateAzureSpeakerRecognition(String audioFrame) {
// 실제로는 Azure Cognitive Services Speaker Recognition API 호출
return "PROFILE-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
/**
* 화자 식별 신뢰도 시뮬레이션
*/
private Double simulateIdentificationConfidence() {
// 실제로는 Azure Speaker Recognition API에서 반환
return 0.90 + (Math.random() * 0.10); // 0.90 ~ 1.0
}
/**
* 발언 비율 계산
*/
private Double calculateSpeakingRatio(String recordingId, Double speakerDuration) {
// 전체 녹음 시간 조회 (임시로 1800초로 설정)
Double totalDuration = 1800.0;
return speakerDuration / totalDuration;
}
/**
* 녹음 ID에서 회의 ID 추출
*/
private String extractMeetingIdFromRecordingId(String recordingId) {
// 실제로는 Recording 엔티티에서 조회
return "MEETING-" + recordingId.substring(4, 12);
}
}

View File

@ -2,7 +2,6 @@ package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.stt.dto.TranscriptionDto; import com.unicorn.hgzero.stt.dto.TranscriptionDto;
import com.unicorn.hgzero.stt.dto.TranscriptSegmentDto; import com.unicorn.hgzero.stt.dto.TranscriptSegmentDto;
import org.springframework.web.multipart.MultipartFile;
/** /**
* 음성 변환 서비스 인터페이스 * 음성 변환 서비스 인터페이스
@ -18,30 +17,13 @@ public interface TranscriptionService {
*/ */
TranscriptSegmentDto.Response processAudioStream(TranscriptionDto.StreamRequest request); TranscriptSegmentDto.Response processAudioStream(TranscriptionDto.StreamRequest request);
/**
* 배치 음성-텍스트 변환
*
* @param request 배치 변환 요청
* @param audioFile 오디오 파일
* @return 배치 변환 응답
*/
TranscriptionDto.BatchResponse transcribeAudioBatch(TranscriptionDto.BatchRequest request, MultipartFile audioFile);
/**
* 배치 변환 완료 콜백 처리
*
* @param request 배치 콜백 요청
* @return 변환 완료 응답
*/
TranscriptionDto.CompleteResponse processBatchCallback(TranscriptionDto.BatchCallbackRequest request);
/** /**
* 변환 텍스트 전체 조회 * 변환 텍스트 전체 조회
* *
* @param recordingId 녹음 ID * @param recordingId 녹음 ID
* @param includeSegments 세그먼트 포함 여부
* @param speakerId 화자 ID 필터
* @return 변환 결과 응답 * @return 변환 결과 응답
*/ */
TranscriptionDto.Response getTranscription(String recordingId, Boolean includeSegments, String speakerId); TranscriptionDto.Response getTranscription(String recordingId);
} }

View File

@ -16,9 +16,10 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -78,12 +79,14 @@ public class TranscriptionServiceImpl implements TranscriptionService {
updateRecordingStatistics(request.getRecordingId()); updateRecordingStatistics(request.getRecordingId());
// 세그먼트 생성 이벤트 발행 // 세그먼트 생성 이벤트 발행
LocalDateTime timestampAsLocalDateTime = java.time.Instant.ofEpochMilli(request.getTimestamp()) LocalDateTime timestampAsDateTime = LocalDateTime.ofInstant(
.atZone(java.time.ZoneId.systemDefault()).toLocalDateTime(); Instant.ofEpochMilli(request.getTimestamp()),
ZoneId.systemDefault()
);
TranscriptionEvent.SegmentCreated event = TranscriptionEvent.SegmentCreated.of( TranscriptionEvent.SegmentCreated event = TranscriptionEvent.SegmentCreated.of(
segmentId, request.getRecordingId(), recording.getMeetingId(), segmentId, request.getRecordingId(), recording.getMeetingId(),
recognizedText, speakerId, "화자-" + speakerId.substring(4), recognizedText, speakerId, "화자-" + speakerId.substring(4),
timestampAsLocalDateTime, 3.5, confidence, warningFlag timestampAsDateTime, 3.5, confidence, warningFlag
); );
eventPublisher.publishAsync("transcription-events", event); eventPublisher.publishAsync("transcription-events", event);
@ -111,93 +114,21 @@ public class TranscriptionServiceImpl implements TranscriptionService {
.build(); .build();
} }
@Override
public TranscriptionDto.BatchResponse transcribeAudioBatch(TranscriptionDto.BatchRequest request, MultipartFile audioFile) {
log.info("배치 음성 변환 시작 - recordingId: {}, fileSize: {}",
request.getRecordingId(), audioFile.getSize());
// 녹음 존재 확인
RecordingEntity recording = findRecordingById(request.getRecordingId());
// 배치 작업 ID 생성
String jobId = generateJobId();
// Azure Batch Transcription Job 생성 (실제로는 Azure Speech SDK 사용)
// 여기서는 시뮬레이션
log.info("배치 음성 변환 작업 생성 완료 - jobId: {}", jobId);
return TranscriptionDto.BatchResponse.builder()
.jobId(jobId)
.recordingId(request.getRecordingId())
.status("PROCESSING")
.estimatedCompletionTime(LocalDateTime.now().plusSeconds(30))
.callbackUrl(request.getCallbackUrl())
.build();
}
@Override
public TranscriptionDto.CompleteResponse processBatchCallback(TranscriptionDto.BatchCallbackRequest request) {
log.info("배치 변환 콜백 처리 - jobId: {}, status: {}", request.getJobId(), request.getStatus());
if ("COMPLETED".equals(request.getStatus()) && request.getSegments() != null) {
// 세그먼트 저장
for (TranscriptSegmentDto.Detail segmentDto : request.getSegments()) {
String segmentId = generateSegmentId();
TranscriptSegmentEntity segment = TranscriptSegmentEntity.builder()
.segmentId(segmentId)
.transcriptId(segmentDto.getTranscriptId())
.text(segmentDto.getText())
.speakerId(segmentDto.getSpeakerId())
.speakerName(segmentDto.getSpeakerName())
.timestamp(segmentDto.getTimestamp())
.duration(segmentDto.getDuration())
.confidence(segmentDto.getConfidence())
.warningFlag(segmentDto.getConfidence() < 0.6)
.build();
segmentRepository.save(segment);
}
// 전체 텍스트 통합 변환 결과 저장
String recordingId = extractRecordingIdFromJobId(request.getJobId());
aggregateAndSaveTranscription(recordingId, request.getSegments());
}
return TranscriptionDto.CompleteResponse.builder()
.jobId(request.getJobId())
.status(request.getStatus())
.segmentCount(request.getSegments() != null ? request.getSegments().size() : 0)
.totalDuration(calculateTotalDuration(request.getSegments()))
.averageConfidence(calculateAverageConfidence(request.getSegments()))
.build();
}
@Override @Override
@Transactional(readOnly = true) @Transactional(readOnly = true)
public TranscriptionDto.Response getTranscription(String recordingId, Boolean includeSegments, String speakerId) { public TranscriptionDto.Response getTranscription(String recordingId) {
log.debug("변환 텍스트 조회 - recordingId: {}, includeSegments: {}", recordingId, includeSegments); log.debug("변환 텍스트 조회 - recordingId: {}", recordingId);
// 변환 결과 조회 // 변환 결과 조회
TranscriptionEntity transcription = transcriptionRepository.findByRecordingId(recordingId) TranscriptionEntity transcription = transcriptionRepository.findByRecordingId(recordingId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "변환 결과를 찾을 수 없습니다")); .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "변환 결과를 찾을 수 없습니다"));
List<TranscriptSegmentDto.Detail> segments = null; // 세그먼트 정보 포함
List<TranscriptSegmentEntity> segmentEntities = segmentRepository.findByRecordingIdOrderByTimestamp(recordingId);
if (Boolean.TRUE.equals(includeSegments)) { List<TranscriptSegmentDto.Detail> segments = segmentEntities.stream()
List<TranscriptSegmentEntity> segmentEntities;
if (speakerId != null) {
segmentEntities = segmentRepository.findByRecordingIdAndSpeakerIdOrderByTimestamp(recordingId, speakerId);
} else {
segmentEntities = segmentRepository.findByRecordingIdOrderByTimestamp(recordingId);
}
segments = segmentEntities.stream()
.map(this::convertToSegmentDetail) .map(this::convertToSegmentDetail)
.collect(Collectors.toList()); .collect(Collectors.toList());
}
return TranscriptionDto.Response.builder() return TranscriptionDto.Response.builder()
.recordingId(recordingId) .recordingId(recordingId)

View File

@ -4,8 +4,8 @@ spring:
# Database Configuration # Database Configuration
datasource: datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:4.230.65.89}:${DB_PORT:5432}/${DB_NAME:sttdb} url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:sttdb}
username: ${DB_USERNAME:hgzerouser} username: ${DB_USERNAME:stt_user}
password: ${DB_PASSWORD:} password: ${DB_PASSWORD:}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
hikari: hikari:
@ -21,6 +21,7 @@ spring:
show-sql: ${SHOW_SQL:true} show-sql: ${SHOW_SQL:true}
properties: properties:
hibernate: hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true format_sql: true
use_sql_comments: true use_sql_comments: true
hibernate: hibernate:
@ -29,7 +30,7 @@ spring:
# Redis Configuration # Redis Configuration
data: data:
redis: redis:
host: ${REDIS_HOST:20.249.177.114} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
timeout: 2000ms timeout: 2000ms
@ -39,7 +40,7 @@ spring:
max-idle: 8 max-idle: 8
min-idle: 0 min-idle: 0
max-wait: -1ms max-wait: -1ms
database: ${REDIS_DATABASE:2} database: ${REDIS_DATABASE:3}
# Server Configuration # Server Configuration
server: server:
@ -47,9 +48,9 @@ server:
# JWT Configuration # JWT Configuration
jwt: jwt:
secret: ${JWT_SECRET:} secret: ${JWT_SECRET:HGZero_Secret_Key_For_Development_Only}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600} access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800} refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
# CORS Configuration # CORS Configuration
cors: cors:

View File

@ -1,16 +1,19 @@
package com.unicorn.hgzero.stt; package com.unicorn.hgzero.stt;
import com.unicorn.hgzero.stt.config.TestConfig;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
/** /**
* STT 애플리케이션 통합 테스트 * STT 애플리케이션 통합 테스트
* 전체 애플리케이션 컨텍스트 로딩 기본 설정 검증 * 전체 애플리케이션 컨텍스트 로딩 기본 설정 검증
*/ */
@SpringBootTest @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test") @ActiveProfiles("test")
@Import(TestConfig.class)
@DisplayName("STT 애플리케이션 테스트") @DisplayName("STT 애플리케이션 테스트")
class SttApplicationTest { class SttApplicationTest {

View File

@ -0,0 +1,82 @@
package com.unicorn.hgzero.stt.config;
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
import com.unicorn.hgzero.stt.repository.jpa.RecordingRepository;
import com.unicorn.hgzero.stt.repository.jpa.SpeakerRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptionRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
import com.unicorn.hgzero.stt.service.RecordingService;
import com.unicorn.hgzero.stt.service.SpeakerService;
import com.unicorn.hgzero.stt.service.TranscriptionService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import redis.embedded.RedisServer;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import static org.mockito.Mockito.mock;
/**
* 테스트용 설정 클래스
* Embedded Redis 서버와 Mock Bean들을 제공
*/
@TestConfiguration
@EnableJpaRepositories(basePackages = "com.unicorn.hgzero.stt.repository.jpa")
@EntityScan(basePackages = "com.unicorn.hgzero.stt.repository.entity")
@ComponentScan(basePackages = {"com.unicorn.hgzero.stt.service", "com.unicorn.hgzero.stt.config"})
public class TestConfig {
private RedisServer redisServer;
@PostConstruct
public void startRedis() {
try {
redisServer = new RedisServer(6370);
redisServer.start();
} catch (Exception e) {
// Redis 서버 시작 실패 로그만 남기고 계속 진행
System.err.println("Failed to start embedded Redis server: " + e.getMessage());
}
}
@PreDestroy
public void stopRedis() {
if (redisServer != null && redisServer.isActive()) {
redisServer.stop();
}
}
@Bean
@Primary
public EventPublisher eventPublisher() {
return mock(EventPublisher.class);
}
@Bean
@Primary
@ConditionalOnMissingBean
public RedisConnectionFactory redisConnectionFactory() {
// Embedded Redis에 연결하는 실제 ConnectionFactory
return new LettuceConnectionFactory("localhost", 6370);
}
@Bean
@Primary
@ConditionalOnMissingBean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
return template;
}
}

View File

@ -0,0 +1,55 @@
package com.unicorn.hgzero.stt.config;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import redis.embedded.RedisServer;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
/**
* WebMvcTest용 최소한의 설정 클래스
* Repository는 MockBean으로 별도 처리
*/
@TestConfiguration
@ComponentScan(basePackages = "com.unicorn.hgzero.stt.config")
public class WebMvcTestConfig {
private RedisServer redisServer;
@PostConstruct
public void startRedis() {
try {
redisServer = new RedisServer(6371); // 다른 포트 사용
redisServer.start();
} catch (Exception e) {
System.err.println("Failed to start embedded Redis server: " + e.getMessage());
}
}
@PreDestroy
public void stopRedis() {
if (redisServer != null && redisServer.isActive()) {
redisServer.stop();
}
}
@Bean
@Primary
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory("localhost", 6371);
}
@Bean
@Primary
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
return template;
}
}

View File

@ -1,6 +1,7 @@
package com.unicorn.hgzero.stt.controller; package com.unicorn.hgzero.stt.controller;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.stt.config.WebMvcTestConfig;
import com.unicorn.hgzero.stt.dto.RecordingDto; import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.service.RecordingService; import com.unicorn.hgzero.stt.service.RecordingService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -9,6 +10,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;

View File

@ -0,0 +1,109 @@
package com.unicorn.hgzero.stt.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.service.RecordingService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* 간단한 녹음 컨트롤러 테스트
*/
@WebMvcTest(RecordingController.class)
@DisplayName("간단한 녹음 컨트롤러 테스트")
class SimpleRecordingControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private RecordingService recordingService;
private RecordingDto.PrepareRequest prepareRequest;
private RecordingDto.PrepareResponse prepareResponse;
@BeforeEach
void setUp() {
prepareRequest = RecordingDto.PrepareRequest.builder()
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.language("ko-KR")
.build();
prepareResponse = RecordingDto.PrepareResponse.builder()
.recordingId("REC-20250123-001")
.sessionId("SESSION-001")
.status("READY")
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-001")
.storagePath("recordings/MEETING-001/SESSION-001.wav")
.estimatedInitTime(1100)
.build();
}
@Test
@DisplayName("녹음 준비 API 성공")
void prepareRecording_Success() throws Exception {
// Given
when(recordingService.prepareRecording(any(RecordingDto.PrepareRequest.class)))
.thenReturn(prepareResponse);
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/prepare")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(prepareRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value("REC-20250123-001"))
.andExpect(jsonPath("$.data.sessionId").value("SESSION-001"))
.andExpect(jsonPath("$.data.status").value("READY"));
verify(recordingService).prepareRecording(any(RecordingDto.PrepareRequest.class));
}
@Test
@DisplayName("녹음 시작 API 성공")
void startRecording_Success() throws Exception {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("user001")
.build();
RecordingDto.StatusResponse statusResponse = RecordingDto.StatusResponse.builder()
.recordingId(recordingId)
.status("RECORDING")
.startTime(LocalDateTime.now())
.duration(0)
.build();
when(recordingService.startRecording(eq(recordingId), any(RecordingDto.StartRequest.class)))
.thenReturn(statusResponse);
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/{recordingId}/start", recordingId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(startRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.status").value("RECORDING"));
verify(recordingService).startRecording(eq(recordingId), any(RecordingDto.StartRequest.class));
}
}

View File

@ -1,14 +1,21 @@
package com.unicorn.hgzero.stt.integration; package com.unicorn.hgzero.stt.integration;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.stt.config.TestConfig;
import com.unicorn.hgzero.stt.dto.RecordingDto; import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.dto.TranscriptionDto; import com.unicorn.hgzero.stt.dto.TranscriptionDto;
import com.unicorn.hgzero.stt.dto.SpeakerDto; import com.unicorn.hgzero.stt.dto.SpeakerDto;
import com.unicorn.hgzero.stt.service.RecordingService;
import com.unicorn.hgzero.stt.service.SpeakerService;
import com.unicorn.hgzero.stt.service.TranscriptionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@ -16,6 +23,8 @@ import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@ -23,10 +32,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* STT API 통합 테스트 * STT API 통합 테스트
* 전체 워크플로우 시나리오 테스트 * 전체 워크플로우 시나리오 테스트
*/ */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureWebMvc @AutoConfigureWebMvc
@ActiveProfiles("test") @ActiveProfiles("test")
@Transactional
@DisplayName("STT API 통합 테스트") @DisplayName("STT API 통합 테스트")
class SttApiIntegrationTest { class SttApiIntegrationTest {
@ -36,6 +44,99 @@ class SttApiIntegrationTest {
@Autowired @Autowired
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@MockBean
private RecordingService recordingService;
@MockBean
private SpeakerService speakerService;
@MockBean
private TranscriptionService transcriptionService;
@BeforeEach
void setUp() {
// RecordingService Mock 설정
when(recordingService.prepareRecording(any(RecordingDto.PrepareRequest.class)))
.thenReturn(RecordingDto.PrepareResponse.builder()
.recordingId("REC-20250123-001")
.sessionId("SESSION-INTEGRATION-001")
.status("READY")
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-INTEGRATION-001")
.storagePath("recordings/MEETING-INTEGRATION-001/SESSION-INTEGRATION-001.wav")
.estimatedInitTime(1100)
.build());
when(recordingService.startRecording(anyString(), any(RecordingDto.StartRequest.class)))
.thenReturn(RecordingDto.StatusResponse.builder()
.recordingId("REC-20250123-001")
.status("RECORDING")
.startTime(java.time.LocalDateTime.now())
.duration(0)
.build());
when(recordingService.stopRecording(anyString(), any(RecordingDto.StopRequest.class)))
.thenReturn(RecordingDto.StatusResponse.builder()
.recordingId("REC-20250123-001")
.status("STOPPED")
.startTime(java.time.LocalDateTime.now().minusMinutes(30))
.endTime(java.time.LocalDateTime.now())
.duration(1800)
.fileSize(172800000L)
.storagePath("recordings/MEETING-INTEGRATION-001/SESSION-INTEGRATION-001.wav")
.build());
when(recordingService.getRecording(anyString()))
.thenReturn(RecordingDto.DetailResponse.builder()
.recordingId("REC-20250123-001")
.meetingId("MEETING-INTEGRATION-001")
.sessionId("SESSION-INTEGRATION-001")
.status("STOPPED")
.startTime(java.time.LocalDateTime.now().minusMinutes(30))
.endTime(java.time.LocalDateTime.now())
.duration(1800)
.speakerCount(3)
.segmentCount(45)
.storagePath("recordings/MEETING-INTEGRATION-001/SESSION-INTEGRATION-001.wav")
.language("ko-KR")
.build());
// TranscriptionService Mock 설정
when(transcriptionService.processAudioStream(any(TranscriptionDto.StreamRequest.class)))
.thenReturn(com.unicorn.hgzero.stt.dto.TranscriptSegmentDto.Response.builder()
.recordingId("REC-20250123-001")
.transcriptId("TRANS-001")
.text("안녕하세요")
.confidence(0.95)
.timestamp(System.currentTimeMillis())
.speakerId("SPK-001")
.duration(2.5)
.build());
when(transcriptionService.getTranscription(anyString(), any(), any()))
.thenReturn(TranscriptionDto.Response.builder()
.recordingId("REC-20250123-001")
.fullText("안녕하세요. 오늘 회의를 시작하겠습니다.")
.segmentCount(45)
.speakerCount(3)
.totalDuration(1800)
.averageConfidence(0.92)
.build());
// SpeakerService Mock 설정
when(speakerService.identifySpeaker(any(SpeakerDto.IdentifyRequest.class)))
.thenReturn(SpeakerDto.IdentificationResponse.builder()
.speakerId("SPK-001")
.confidence(0.95)
.isNewSpeaker(false)
.build());
when(speakerService.getRecordingSpeakers(anyString()))
.thenReturn(SpeakerDto.ListResponse.builder()
.recordingId("REC-20250123-001")
.speakerCount(3)
.build());
}
@Test @Test
@DisplayName("전체 STT 워크플로우 통합 테스트") @DisplayName("전체 STT 워크플로우 통합 테스트")
void fullSttWorkflowIntegrationTest() throws Exception { void fullSttWorkflowIntegrationTest() throws Exception {
@ -141,6 +242,17 @@ class SttApiIntegrationTest {
@Test @Test
@DisplayName("에러 케이스 통합 테스트") @DisplayName("에러 케이스 통합 테스트")
void errorCasesIntegrationTest() throws Exception { void errorCasesIntegrationTest() throws Exception {
// 존재하지 않는 녹음에 대한 Mock 설정
when(recordingService.startRecording(eq("NONEXISTENT-001"), any(RecordingDto.StartRequest.class)))
.thenThrow(new com.unicorn.hgzero.common.exception.BusinessException(
com.unicorn.hgzero.common.exception.ErrorCode.ENTITY_NOT_FOUND,
"녹음을 찾을 수 없습니다"));
when(transcriptionService.getTranscription(eq("NONEXISTENT-001"), any(), any()))
.thenThrow(new com.unicorn.hgzero.common.exception.BusinessException(
com.unicorn.hgzero.common.exception.ErrorCode.ENTITY_NOT_FOUND,
"변환 결과를 찾을 수 없습니다"));
// 존재하지 않는 녹음 시작 시도 // 존재하지 않는 녹음 시작 시도
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder() RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("test-user") .startedBy("test-user")

View File

@ -50,6 +50,8 @@ class TranscriptionServiceTest {
private TranscriptionServiceImpl transcriptionService; private TranscriptionServiceImpl transcriptionService;
private RecordingEntity recordingEntity; private RecordingEntity recordingEntity;
private TranscriptSegmentEntity segmentEntity;
private TranscriptionEntity transcriptionEntity;
private TranscriptionDto.StreamRequest streamRequest; private TranscriptionDto.StreamRequest streamRequest;
@BeforeEach @BeforeEach
@ -60,6 +62,22 @@ class TranscriptionServiceTest {
.sessionId("SESSION-001") .sessionId("SESSION-001")
.build(); .build();
segmentEntity = TranscriptSegmentEntity.builder()
.segmentId("SEG-001")
.recordingId("REC-20250123-001")
.text("테스트 음성 변환 결과")
.timestamp(System.currentTimeMillis())
.confidence(0.95)
.build();
transcriptionEntity = TranscriptionEntity.builder()
.transcriptId("TRANS-001")
.recordingId("REC-20250123-001")
.fullText("전체 음성 변환 결과")
.segmentCount(1)
.averageConfidence(0.95)
.build();
streamRequest = TranscriptionDto.StreamRequest.builder() streamRequest = TranscriptionDto.StreamRequest.builder()
.recordingId("REC-20250123-001") .recordingId("REC-20250123-001")
.audioData("base64-encoded-audio-data") .audioData("base64-encoded-audio-data")
@ -73,7 +91,7 @@ class TranscriptionServiceTest {
void processAudioStream_Success() { void processAudioStream_Success() {
// Given // Given
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity)); when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any()); when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(segmentEntity);
when(segmentRepository.getSpeakerStatisticsByRecording(anyString())).thenReturn(List.of()); when(segmentRepository.getSpeakerStatisticsByRecording(anyString())).thenReturn(List.of());
when(segmentRepository.countByRecordingId(anyString())).thenReturn(1L); when(segmentRepository.countByRecordingId(anyString())).thenReturn(1L);
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity); when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
@ -88,9 +106,9 @@ class TranscriptionServiceTest {
assertThat(response.getConfidence()).isGreaterThan(0.8); assertThat(response.getConfidence()).isGreaterThan(0.8);
assertThat(response.getSpeakerId()).isNotEmpty(); assertThat(response.getSpeakerId()).isNotEmpty();
verify(recordingRepository).findById("REC-20250123-001"); verify(recordingRepository, atLeastOnce()).findById("REC-20250123-001");
verify(segmentRepository).save(any(TranscriptSegmentEntity.class)); verify(segmentRepository).save(any(TranscriptSegmentEntity.class));
verify(eventPublisher).publishAsync(eq("transcription-events"), any()); verify(eventPublisher, atLeastOnce()).publishAsync(eq("transcription-events"), any());
} }
@Test @Test
@ -114,7 +132,7 @@ class TranscriptionServiceTest {
void processAudioStream_LowConfidenceWarning() { void processAudioStream_LowConfidenceWarning() {
// Given // Given
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity)); when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any()); when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(segmentEntity);
when(segmentRepository.getSpeakerStatisticsByRecording(anyString())).thenReturn(List.of()); when(segmentRepository.getSpeakerStatisticsByRecording(anyString())).thenReturn(List.of());
when(segmentRepository.countByRecordingId(anyString())).thenReturn(1L); when(segmentRepository.countByRecordingId(anyString())).thenReturn(1L);
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity); when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
@ -191,8 +209,8 @@ class TranscriptionServiceTest {
.segments(segments) .segments(segments)
.build(); .build();
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any()); when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(segmentEntity);
when(transcriptionRepository.save(any(TranscriptionEntity.class))).thenReturn(any()); when(transcriptionRepository.save(any(TranscriptionEntity.class))).thenReturn(transcriptionEntity);
// When // When
TranscriptionDto.CompleteResponse response = transcriptionService.processBatchCallback(callbackRequest); TranscriptionDto.CompleteResponse response = transcriptionService.processBatchCallback(callbackRequest);

View File

@ -1,55 +1,131 @@
# STT 서비스 테스트 설정 # 테스트 환경별 설정 선택
spring: # 1. 단위 테스트용 (기본)
profiles: # 2. Docker 통합 테스트용 (integration-test profile 활성화 시)
active: test
# 데이터베이스 설정 (H2 인메모리) spring:
application:
name: stt-test
# Bean Override 허용
main:
allow-bean-definition-overriding: true
# In-Memory Database (기본값)
datasource: datasource:
url: jdbc:h2:mem:testdb url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa username: sa
password: password:
driver-class-name: org.h2.Driver
# JPA 설정
jpa: jpa:
show-sql: false
hibernate: hibernate:
ddl-auto: create-drop ddl-auto: create-drop
show-sql: true
properties: properties:
hibernate: hibernate:
dialect: org.hibernate.dialect.H2Dialect dialect: org.hibernate.dialect.H2Dialect
format_sql: true
# Redis 설정 (임베디드) # Mock Redis (handled by TestConfig)
data:
redis: redis:
host: localhost host: localhost
port: 6370 port: 6370
timeout: 2000ms password:
database: 0
# JWT 설정 # Test Server
security: server:
jwt: port: 0
secret: test-secret-key-for-jwt-token-generation-test
expiration: 86400
# Azure 서비스 설정 (테스트용 더미) # Mock Azure Services
azure: azure:
speech: speech:
subscription-key: test-key subscription-key: test-key
region: koreacentral region: eastus
endpoint: https://test.cognitiveservices.azure.com/ language: ko-KR
blob:
storage: connection-string: DefaultEndpointsProtocol=https;AccountName=test;AccountKey=test;EndpointSuffix=core.windows.net
connection-string: DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net
container-name: test-recordings container-name: test-recordings
eventhub:
event-hubs: connection-string: Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test
connection-string: Endpoint=sb://test-eventhub.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test name: test-events
consumer-group: test-group consumer-group: test-group
# 로깅 설정 ---
# Docker 통합 테스트용 설정
spring:
config:
activate:
on-profile: integration-test
# Real PostgreSQL (via Docker)
datasource:
url: jdbc:postgresql://localhost:5433/sttdb_test
username: testuser
password: testpass
driver-class-name: org.postgresql.Driver
jpa:
show-sql: true
hibernate:
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
# Real Redis (via Docker)
data:
redis:
host: localhost
port: 6380
password: testpass
database: 0
# Real Server
server:
port: 8083
# Azure Emulator (Azurite)
azure:
speech:
subscription-key: test-key
region: eastus
language: ko-KR
blob:
connection-string: DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;
container-name: test-recordings
eventhub:
connection-string: Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test
name: test-events
consumer-group: test-group
---
# 공통 설정
jwt:
secret: test-secret-key-for-testing-purposes-only-not-for-production-use
access-token-validity: 3600
refresh-token-validity: 604800
cors:
allowed-origins: "*"
management:
endpoints:
enabled-by-default: false
endpoint:
health:
enabled: true
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
logging: logging:
level: level:
com.unicorn.hgzero.stt: DEBUG com.unicorn.hgzero.stt: INFO
org.springframework.web: DEBUG org.springframework: WARN
org.hibernate.SQL: DEBUG org.hibernate: WARN
pattern:
console: "%d{HH:mm:ss} %-5level %logger{36} - %msg%n"

View File

@ -0,0 +1,50 @@
#!/bin/bash
# STT 서비스 통합 테스트 실행 스크립트
set -e
echo "🚀 STT 서비스 통합 테스트 시작"
# 1. Docker 테스트 환경 시작
echo "📦 Docker 테스트 환경 시작..."
docker-compose -f docker-compose.test.yml up -d
# 2. 서비스 준비 대기
echo "⏳ 서비스 준비 대기 중..."
sleep 15
# 3. 데이터베이스 준비 확인
echo "🔍 PostgreSQL 연결 확인..."
until docker-compose -f docker-compose.test.yml exec -T postgres-test pg_isready -U testuser -d sttdb_test; do
echo "PostgreSQL 준비 중..."
sleep 2
done
# 4. Redis 연결 확인
echo "🔍 Redis 연결 확인..."
until docker-compose -f docker-compose.test.yml exec -T redis-test redis-cli -a testpass ping | grep PONG; do
echo "Redis 준비 중..."
sleep 2
done
# 5. 통합 테스트 실행
echo "🧪 통합 테스트 실행..."
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-23.0.2.jdk/Contents/Home
./gradlew :stt:test -Dspring.profiles.active=integration-test
# 6. 결과 출력
if [ $? -eq 0 ]; then
echo "✅ 통합 테스트 성공!"
else
echo "❌ 통합 테스트 실패!"
fi
# 7. Docker 환경 정리 (선택)
read -p "Docker 테스트 환경을 정리하시겠습니까? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "🧹 Docker 테스트 환경 정리 중..."
docker-compose -f docker-compose.test.yml down -v
echo "✅ 정리 완료"
fi