Initialize projecet

This commit is contained in:
woozu.shin
2026-01-23 11:45:18 +09:00
commit 0f5d513b06
23 changed files with 2058 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
package com.myoauniverse.lab.mongodb
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
@SpringBootApplication
@EnableConfigurationProperties
class MongodbNanosecApplication
fun main(args: Array<String>) {
runApplication<MongodbNanosecApplication>(*args)
}

View File

@@ -0,0 +1,189 @@
package com.myoauniverse.lab.mongodb.config
import com.mongodb.ConnectionString
import com.mongodb.MongoClientSettings
import com.mongodb.client.MongoClient
import com.mongodb.client.MongoClients
import com.mongodb.connection.ConnectionPoolSettings
import com.mongodb.connection.SocketSettings
import com.mongodb.management.JMXConnectionPoolListener
import org.bson.types.Decimal128
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.data.convert.ReadingConverter
import org.springframework.data.convert.WritingConverter
import org.springframework.data.mongodb.MongoDatabaseFactory
import org.springframework.data.mongodb.MongoTransactionManager
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory
import org.springframework.data.mongodb.core.convert.*
import org.springframework.data.mongodb.core.mapping.MongoMappingContext
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.*
import java.util.concurrent.TimeUnit
/**
* MongoDB configuration with custom converters.
*
* Features:
* - Removes _class field from documents (DefaultMongoTypeMapper(null))
* - BigDecimal ↔ Decimal128 conversion
* - ZonedDateTime ↔ Date conversion (for compatibility)
*
* Note: For nanosecond precision, use Event entity with nanoAdjustment field
* instead of relying on ZonedDateTime converter (which loses nanoseconds).
*/
@Configuration
@EnableConfigurationProperties(MongoDBProperties::class)
class MongoConfig(
private val mongoDBProperties: MongoDBProperties
) {
companion object {
private const val CONNECTION_POOL_MAX_SIZE = 100
private const val CONNECTION_POOL_MIN_SIZE = 10
private const val CONNECTION_POOL_MAX_LIFE_TIME = 30L
private const val CONNECTION_POOL_TIMEOUT = 2000L
/**
* Default timezone for ZonedDateTime conversion.
* Used when reading Date from MongoDB back to ZonedDateTime.
*/
val DEFAULT_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
}
@Bean("mongoTransactionManager")
fun mongoTransactionManager(mongoDatabaseFactory: MongoDatabaseFactory): MongoTransactionManager {
return MongoTransactionManager(mongoDatabaseFactory)
}
@Bean
fun mongoTemplate(
mongoDatabaseFactory: MongoDatabaseFactory,
mongoCustomConversions: MongoCustomConversions,
mongoMappingContext: MongoMappingContext
): MongoTemplate {
val resolver: DbRefResolver = DefaultDbRefResolver(mongoDatabaseFactory)
val converter = MappingMongoConverter(resolver, mongoMappingContext)
converter.setTypeMapper(DefaultMongoTypeMapper(null))
converter.customConversions = mongoCustomConversions
converter.afterPropertiesSet()
return MongoTemplate(mongoDatabaseFactory, converter)
}
@Bean
fun mongoDatabaseFactory(
mongoClient: MongoClient,
connectionString: ConnectionString
): MongoDatabaseFactory {
return SimpleMongoClientDatabaseFactory(mongoClient, connectionString.database!!)
}
@Bean
fun mongoClient(connectionString: ConnectionString): MongoClient {
val clientSettings = MongoClientSettings.builder()
.retryWrites(true)
.applyConnectionString(connectionString)
.applyToConnectionPoolSettings { builder: ConnectionPoolSettings.Builder ->
builder.maxSize(CONNECTION_POOL_MAX_SIZE)
.minSize(CONNECTION_POOL_MIN_SIZE)
.maxConnectionLifeTime(CONNECTION_POOL_MAX_LIFE_TIME, TimeUnit.MINUTES)
.addConnectionPoolListener(JMXConnectionPoolListener())
}
.applyToSocketSettings { builder: SocketSettings.Builder ->
builder.connectTimeout(CONNECTION_POOL_TIMEOUT, TimeUnit.MILLISECONDS)
}
.build()
return MongoClients.create(clientSettings)
}
@Bean
fun connectionString(): ConnectionString {
return ConnectionString(mongoDBProperties.uri)
}
// ==================== Custom Converters ====================
@Bean
fun mongoCustomConversions(): MongoCustomConversions {
val converters = listOf(
BigDecimalToDecimal128Converter(),
Decimal128ToBigDecimalConverter(),
ZonedDateTimeToDateConverter(),
DateToZonedDateTimeConverter()
)
return MongoCustomConversions(converters)
}
@Bean
fun mappingMongoConverter(
mongoDatabaseFactory: MongoDatabaseFactory,
mongoCustomConversions: MongoCustomConversions
): MappingMongoConverter {
val dbRefResolver: DbRefResolver = DefaultDbRefResolver(mongoDatabaseFactory)
val mappingContext = MongoMappingContext()
mappingContext.setSimpleTypeHolder(mongoCustomConversions.simpleTypeHolder)
mappingContext.afterPropertiesSet()
val converter = MappingMongoConverter(dbRefResolver, mappingContext)
converter.setTypeMapper(DefaultMongoTypeMapper(null)) // Remove _class field
converter.customConversions = mongoCustomConversions
converter.afterPropertiesSet()
return converter
}
/**
* BigDecimal → Decimal128 (Writing)
*/
@WritingConverter
class BigDecimalToDecimal128Converter : Converter<BigDecimal, Decimal128> {
override fun convert(source: BigDecimal): Decimal128 {
return Decimal128(source)
}
}
/**
* Decimal128 → BigDecimal (Reading)
*/
@ReadingConverter
class Decimal128ToBigDecimalConverter : Converter<Decimal128, BigDecimal> {
override fun convert(source: Decimal128): BigDecimal {
return source.bigDecimalValue()
}
}
/**
* ZonedDateTime → Date (Writing)
*
* WARNING: This loses nanosecond precision (only milliseconds preserved).
* For nanosecond precision, use Event entity with nanoAdjustment field.
*/
@WritingConverter
class ZonedDateTimeToDateConverter : Converter<ZonedDateTime, Date> {
override fun convert(source: ZonedDateTime): Date {
return Date.from(source.toInstant())
}
}
/**
* Date → ZonedDateTime (Reading)
*
* Uses DEFAULT_ZONE_ID since BSON Date doesn't store timezone info.
* WARNING: Original timezone is lost; uses configured default.
*/
@ReadingConverter
class DateToZonedDateTimeConverter : Converter<Date, ZonedDateTime> {
override fun convert(source: Date): ZonedDateTime {
return ZonedDateTime.ofInstant(
Instant.ofEpochMilli(source.time),
DEFAULT_ZONE_ID
)
}
}
}

View File

@@ -0,0 +1,9 @@
package com.myoauniverse.lab.mongodb.config
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "event.config.persistence.mongodb")
data class MongoDBProperties(
var uri: String = "",
var autoIndexCreation: Boolean = false
)

View File

@@ -0,0 +1,163 @@
package com.myoauniverse.lab.mongodb.controller
import com.myoauniverse.lab.mongodb.controller.dto.*
import com.myoauniverse.lab.mongodb.filter.RequestTimestampHolder
import com.myoauniverse.lab.mongodb.service.EventParticipationService
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val logger = KotlinLogging.logger {}
@RestController
@RequestMapping("/api/v1/event/participation")
class EventParticipationController(
private val eventParticipationService: EventParticipationService
) {
companion object {
private val isoFormatter = DateTimeFormatter.ISO_INSTANT
}
/**
* POST /api/v1/event/participation
* 이벤트 참여 등록
*/
@PostMapping
fun participate(
@RequestBody request: EventParticipationRequest,
@RequestHeader("X-Timezone", defaultValue = "Asia/Seoul") timezone: String
): ResponseEntity<EventParticipationWithTimingResponse> {
// Controller 진입 시간 캡처
val controllerTime = Instant.now()
val filterTime = RequestTimestampHolder.get()
val clientTime = RequestTimestampHolder.getClientTime()
logger.info {
"Controller timing - filterTime: $filterTime, controllerTime: $controllerTime, " +
"diff: ${controllerTime.toEpochMilli() - filterTime.toEpochMilli()}ms"
}
val participation = eventParticipationService.participate(
eventId = request.eventId,
userId = request.userId,
metadata = request.metadata
)
val zoneId = ZoneId.of(timezone)
val participationResponse = EventParticipationResponse.from(participation, zoneId)
val timing = TimingInfo(
clientRequestTime = clientTime?.let { isoFormatter.format(it) },
clientRequestTimeEpochMilli = clientTime?.toEpochMilli(),
serverFilterTime = isoFormatter.format(filterTime),
serverFilterTimeEpochMilli = filterTime.toEpochMilli(),
serverControllerTime = isoFormatter.format(controllerTime),
serverControllerTimeEpochMilli = controllerTime.toEpochMilli(),
filterToControllerDiffMs = controllerTime.toEpochMilli() - filterTime.toEpochMilli(),
clientToFilterDiffMs = clientTime?.let { filterTime.toEpochMilli() - it.toEpochMilli() }
)
val response = EventParticipationWithTimingResponse(participationResponse, timing)
return ResponseEntity.status(HttpStatus.CREATED).body(response)
}
/**
* GET /api/v1/event/participation/{id}
* 참여 정보 조회
*/
@GetMapping("/{id}")
fun getById(
@PathVariable id: String,
@RequestHeader("X-Timezone", defaultValue = "Asia/Seoul") timezone: String
): ResponseEntity<EventParticipationResponse> {
val participation = eventParticipationService.findById(id)
?: return ResponseEntity.notFound().build()
val zoneId = ZoneId.of(timezone)
return ResponseEntity.ok(EventParticipationResponse.from(participation, zoneId))
}
/**
* GET /api/v1/event/participation/event/{eventId}
* 이벤트별 참여 목록 조회
*/
@GetMapping("/event/{eventId}")
fun getByEventId(
@PathVariable eventId: String,
@RequestHeader("X-Timezone", defaultValue = "Asia/Seoul") timezone: String
): ResponseEntity<List<EventParticipationResponse>> {
val participations = eventParticipationService.findByEventId(eventId)
val zoneId = ZoneId.of(timezone)
val responses = participations.map { EventParticipationResponse.from(it, zoneId) }
return ResponseEntity.ok(responses)
}
/**
* GET /api/v1/event/participation/user/{userId}
* 사용자별 참여 목록 조회
*/
@GetMapping("/user/{userId}")
fun getByUserId(
@PathVariable userId: String,
@RequestHeader("X-Timezone", defaultValue = "Asia/Seoul") timezone: String
): ResponseEntity<List<EventParticipationResponse>> {
val participations = eventParticipationService.findByUserId(userId)
val zoneId = ZoneId.of(timezone)
val responses = participations.map { EventParticipationResponse.from(it, zoneId) }
return ResponseEntity.ok(responses)
}
/**
* GET /api/v1/event/participation/check
* 참여 여부 확인
*/
@GetMapping("/check")
fun checkParticipation(
@RequestParam eventId: String,
@RequestParam userId: String,
@RequestHeader("X-Timezone", defaultValue = "Asia/Seoul") timezone: String
): ResponseEntity<ParticipationCheckResponse> {
val participation = eventParticipationService.findByEventIdAndUserId(eventId, userId)
val zoneId = ZoneId.of(timezone)
val response = ParticipationCheckResponse(
eventId = eventId,
userId = userId,
hasParticipated = participation != null,
participation = participation?.let { EventParticipationResponse.from(it, zoneId) }
)
return ResponseEntity.ok(response)
}
/**
* GET /api/v1/event/participation/count/{eventId}
* 이벤트 참여자 수 조회
*/
@GetMapping("/count/{eventId}")
fun getParticipationCount(
@PathVariable eventId: String
): ResponseEntity<ParticipationCountResponse> {
val count = eventParticipationService.countByEventId(eventId)
return ResponseEntity.ok(ParticipationCountResponse(eventId, count))
}
/**
* Exception handler for duplicate participation
*/
@ExceptionHandler(IllegalStateException::class)
fun handleIllegalState(e: IllegalStateException): ResponseEntity<Map<String, String>> {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(mapOf("error" to (e.message ?: "Conflict")))
}
}

View File

@@ -0,0 +1,90 @@
package com.myoauniverse.lab.mongodb.controller.dto
import com.myoauniverse.lab.mongodb.domain.EventParticipation
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Request DTO for event participation.
*/
data class EventParticipationRequest(
val eventId: String,
val userId: String,
val metadata: Map<String, Any>? = null
)
/**
* Response DTO for event participation.
* Includes formatted participatedAt with full nanosecond precision.
*/
data class EventParticipationResponse(
val id: String,
val eventId: String,
val userId: String,
val participatedAt: String, // ISO8601 with nanoseconds
val participatedAtEpochMilli: Long,
val nanoAdjustment: Int,
val metadata: Map<String, Any>?
) {
companion object {
private val formatter = DateTimeFormatter.ISO_ZONED_DATE_TIME
fun from(
participation: EventParticipation,
zoneId: ZoneId = ZoneId.of("Asia/Seoul")
): EventParticipationResponse {
val zonedDateTime = participation.toZonedDateTime(zoneId)
return EventParticipationResponse(
id = participation.id!!,
eventId = participation.eventId,
userId = participation.userId,
participatedAt = zonedDateTime.format(formatter),
participatedAtEpochMilli = participation.participatedAt.toEpochMilli(),
nanoAdjustment = participation.nanoAdjustment,
metadata = participation.metadata
)
}
}
}
/**
* Response for participation check.
*/
data class ParticipationCheckResponse(
val eventId: String,
val userId: String,
val hasParticipated: Boolean,
val participation: EventParticipationResponse?
)
/**
* Response for participation count.
*/
data class ParticipationCountResponse(
val eventId: String,
val count: Long
)
/**
* Timing information for debugging/analysis.
* Shows the time difference between client, filter, and controller.
*/
data class TimingInfo(
val clientRequestTime: String?, // Client에서 보낸 시간 (ISO8601)
val clientRequestTimeEpochMilli: Long?, // Client epoch millis
val serverFilterTime: String, // Filter에서 캡처한 시간
val serverFilterTimeEpochMilli: Long,
val serverControllerTime: String, // Controller에서 캡처한 시간
val serverControllerTimeEpochMilli: Long,
val filterToControllerDiffMs: Long, // Filter → Controller 차이 (ms)
val clientToFilterDiffMs: Long? // Client → Filter 차이 (ms)
)
/**
* Response DTO with timing information.
*/
data class EventParticipationWithTimingResponse(
val participation: EventParticipationResponse,
val timing: TimingInfo
)

View File

@@ -0,0 +1,108 @@
package com.myoauniverse.lab.mongodb.domain
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
/**
* EventParticipation entity with nanosecond precision.
*
* Storage format in MongoDB:
* {
* "_id": "...",
* "eventId": "event-123",
* "userId": "user-456",
* "participatedAt": ISODate("2024-01-21T06:30:45.123Z"), // BSON Date (ms)
* "nanoAdjustment": 456789, // 0-999999 (sub-ms nanos)
* "metadata": { ... }
* }
*
* Note: Instant is used instead of Date for cleaner domain model.
* Spring Data MongoDB automatically converts Instant ↔ BSON Date.
* However, BSON Date only stores milliseconds, so nanoAdjustment is still needed.
*/
@Document(collection = "event_participations")
data class EventParticipation(
@Id
val id: String? = null,
@Indexed
val eventId: String,
@Indexed
val userId: String,
@Indexed
val participatedAt: Instant, // Spring Data MongoDB가 자동으로 BSON Date로 변환
val nanoAdjustment: Int, // 0-999999 (나노초 중 밀리초 이하 부분 보존)
val metadata: Map<String, Any>? = null
) {
init {
require(nanoAdjustment in 0..999_999) {
"nanoAdjustment must be between 0 and 999999, was: $nanoAdjustment"
}
}
/**
* Reconstructs Instant with full nanosecond precision.
*
* participatedAt에서 ms 정보 + nanoAdjustment에서 sub-ms 정보를 합침
*/
fun toFullPrecisionInstant(): Instant {
// participatedAt은 ms 정밀도로 저장되어 있으므로, nano 부분을 다시 더해줌
val truncatedToMillis = Instant.ofEpochMilli(participatedAt.toEpochMilli())
return truncatedToMillis.plusNanos(nanoAdjustment.toLong())
}
/**
* Converts to ZonedDateTime with specified zone and full nanosecond precision.
*/
fun toZonedDateTime(zoneId: ZoneId): ZonedDateTime {
return ZonedDateTime.ofInstant(toFullPrecisionInstant(), zoneId)
}
/**
* Returns full nanoseconds (0-999999999).
*/
fun getNano(): Int {
val msNanos = (participatedAt.toEpochMilli() % 1000).toInt() * 1_000_000
return msNanos + nanoAdjustment
}
companion object {
/**
* Creates EventParticipation from Instant with nanosecond precision preserved.
*/
fun create(
eventId: String,
userId: String,
participatedAt: Instant = Instant.now(),
metadata: Map<String, Any>? = null,
id: String? = null
): EventParticipation {
// Instant을 ms로 truncate하고, sub-ms 나노초는 별도 저장
val truncatedToMillis = Instant.ofEpochMilli(participatedAt.toEpochMilli())
val nanoAdjustment = participatedAt.nano % 1_000_000
return EventParticipation(
id = id,
eventId = eventId,
userId = userId,
participatedAt = truncatedToMillis, // ms 정밀도로 저장
nanoAdjustment = nanoAdjustment, // sub-ms 나노초 보존
metadata = metadata
)
}
/**
* Creates EventParticipation from ZonedDateTime.
*/
fun create(
eventId: String,
userId: String,
participatedAt: ZonedDateTime,
metadata: Map<String, Any>? = null,
id: String? = null
): EventParticipation = create(eventId, userId, participatedAt.toInstant(), metadata, id)
}
}

View File

@@ -0,0 +1,156 @@
package com.myoauniverse.lab.mongodb.filter
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.Filter
import jakarta.servlet.FilterChain
import jakarta.servlet.ServletRequest
import jakarta.servlet.ServletResponse
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.MDC
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.stereotype.Component
import java.time.Instant
private val logger = KotlinLogging.logger {}
/**
* Servlet Filter to capture request timestamp at the earliest application level.
*
* Execution order:
* Request → Filter.doFilter() → DispatcherServlet → Interceptor → Controller
*
* This filter:
* 1. Checks for upstream timestamp header (from Ingress/LB)
* 2. Falls back to capturing time at filter entry
* 3. Stores in both MDC and ThreadLocal for access anywhere
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 가장 먼저 실행
class RequestTimestampFilter : Filter {
override fun doFilter(
request: ServletRequest,
response: ServletResponse,
chain: FilterChain
) {
val httpRequest = request as HttpServletRequest
try {
// 1. Ingress/LB에서 주입한 헤더 확인 (가장 정확)
val upstreamTimestamp = httpRequest.getHeader(HEADER_REQUEST_START_TIME)
?: httpRequest.getHeader(HEADER_X_REQUEST_TIMESTAMP)
// Client에서 보낸 시간 (JavaScript Date.now())
val clientTimestamp = httpRequest.getHeader(HEADER_CLIENT_TIMESTAMP)
val clientInstant = clientTimestamp?.let { parseClientTimestamp(it) }
val requestInstant = if (upstreamTimestamp != null) {
parseUpstreamTimestamp(upstreamTimestamp)
} else {
// 2. Filter 진입 시점 캡처 (차선책)
Instant.now()
}
logger.info { "Filter captured: filterTime=$requestInstant, clientTime=$clientInstant" }
// 3. ThreadLocal과 MDC에 저장
RequestTimestampHolder.set(requestInstant)
clientInstant?.let { RequestTimestampHolder.setClientTime(it) }
MDC.put(MDC_KEY_REQUEST_TIMESTAMP, requestInstant.toString())
MDC.put(MDC_KEY_REQUEST_EPOCH_MILLI, requestInstant.toEpochMilli().toString())
MDC.put(MDC_KEY_REQUEST_NANO, requestInstant.nano.toString())
// Request attribute에도 저장 (JSP 등에서 접근용)
request.setAttribute(ATTR_REQUEST_TIMESTAMP, requestInstant)
chain.doFilter(request, response)
} finally {
// 4. 정리
RequestTimestampHolder.clear()
MDC.remove(MDC_KEY_REQUEST_TIMESTAMP)
MDC.remove(MDC_KEY_REQUEST_EPOCH_MILLI)
MDC.remove(MDC_KEY_REQUEST_NANO)
}
}
private fun parseUpstreamTimestamp(value: String): Instant {
return try {
// Nginx $msec 형식: "1705819845.123" (초.밀리초)
if (value.contains(".")) {
val parts = value.split(".")
val seconds = parts[0].toLong()
val millis = parts.getOrElse(1) { "0" }.take(3).padEnd(3, '0').toInt()
Instant.ofEpochSecond(seconds, millis * 1_000_000L)
} else {
// epoch millis 형식: "1705819845123"
Instant.ofEpochMilli(value.toLong())
}
} catch (e: Exception) {
// 파싱 실패 시 현재 시간
Instant.now()
}
}
private fun parseClientTimestamp(value: String): Instant? {
return try {
// JavaScript Date.now() epoch millis 형식
Instant.ofEpochMilli(value.toLong())
} catch (e: Exception) {
logger.warn { "Failed to parse client timestamp: $value" }
null
}
}
companion object {
// Upstream headers (Ingress/LB에서 설정)
const val HEADER_REQUEST_START_TIME = "X-Request-Start-Time"
const val HEADER_X_REQUEST_TIMESTAMP = "X-Request-Timestamp"
// Client timestamp header (JavaScript Date.now())
const val HEADER_CLIENT_TIMESTAMP = "X-Client-Timestamp"
// MDC keys
const val MDC_KEY_REQUEST_TIMESTAMP = "requestTimestamp"
const val MDC_KEY_REQUEST_EPOCH_MILLI = "requestEpochMilli"
const val MDC_KEY_REQUEST_NANO = "requestNano"
// Request attribute
const val ATTR_REQUEST_TIMESTAMP = "requestTimestamp"
}
}
/**
* ThreadLocal holder for request timestamp.
* Use this to access the timestamp from anywhere in the request context.
*/
object RequestTimestampHolder {
private val filterTimeHolder = ThreadLocal<Instant>()
private val clientTimeHolder = ThreadLocal<Instant>()
fun set(instant: Instant) {
filterTimeHolder.set(instant)
}
fun setClientTime(instant: Instant) {
clientTimeHolder.set(instant)
}
fun get(): Instant {
return filterTimeHolder.get() ?: Instant.now()
}
fun getClientTime(): Instant? {
return clientTimeHolder.get()
}
fun getOrNull(): Instant? {
return filterTimeHolder.get()
}
fun clear() {
filterTimeHolder.remove()
clientTimeHolder.remove()
}
}

View File

@@ -0,0 +1,34 @@
package com.myoauniverse.lab.mongodb.repository
import com.myoauniverse.lab.mongodb.domain.EventParticipation
import org.springframework.data.mongodb.repository.MongoRepository
import java.time.Instant
/**
* Repository for EventParticipation entity.
*
* MongoDB Shell query examples:
* db.event_participations.find({ eventId: "event-123" })
* db.event_participations.find({ userId: "user-456" })
* db.event_participations.find({ participatedAt: { $gte: ISODate("2024-01-21") } })
*/
interface EventParticipationRepository : MongoRepository<EventParticipation, String> {
fun findByEventId(eventId: String): List<EventParticipation>
fun findByUserId(userId: String): List<EventParticipation>
fun findByEventIdAndUserId(eventId: String, userId: String): EventParticipation?
fun findByParticipatedAtBetween(start: Instant, end: Instant): List<EventParticipation>
fun findByEventIdAndParticipatedAtBetween(
eventId: String,
start: Instant,
end: Instant
): List<EventParticipation>
fun countByEventId(eventId: String): Long
fun existsByEventIdAndUserId(eventId: String, userId: String): Boolean
}

View File

@@ -0,0 +1,67 @@
package com.myoauniverse.lab.mongodb.service
import com.myoauniverse.lab.mongodb.domain.EventParticipation
import com.myoauniverse.lab.mongodb.filter.RequestTimestampHolder
import com.myoauniverse.lab.mongodb.repository.EventParticipationRepository
import org.springframework.stereotype.Service
@Service
class EventParticipationService(
private val eventParticipationRepository: EventParticipationRepository
) {
/**
* 이벤트 참여 등록
*
* 참여 시간은 RequestTimestampHolder에서 가져옴:
* 1. Ingress/LB에서 주입한 시간 (가장 정확)
* 2. Filter 진입 시점 (차선책)
*/
fun participate(
eventId: String,
userId: String,
metadata: Map<String, Any>? = null
): EventParticipation {
// 중복 참여 체크
if (eventParticipationRepository.existsByEventIdAndUserId(eventId, userId)) {
throw IllegalStateException("User $userId already participated in event $eventId")
}
// RequestTimestampHolder에서 요청 시간 가져오기
// (Filter에서 Ingress 헤더 또는 Filter 진입 시점으로 설정됨)
val participatedAt = RequestTimestampHolder.get()
val participation = EventParticipation.create(
eventId = eventId,
userId = userId,
participatedAt = participatedAt,
metadata = metadata
)
return eventParticipationRepository.save(participation)
}
fun findById(id: String): EventParticipation? {
return eventParticipationRepository.findById(id).orElse(null)
}
fun findByEventId(eventId: String): List<EventParticipation> {
return eventParticipationRepository.findByEventId(eventId)
}
fun findByUserId(userId: String): List<EventParticipation> {
return eventParticipationRepository.findByUserId(userId)
}
fun findByEventIdAndUserId(eventId: String, userId: String): EventParticipation? {
return eventParticipationRepository.findByEventIdAndUserId(eventId, userId)
}
fun countByEventId(eventId: String): Long {
return eventParticipationRepository.countByEventId(eventId)
}
fun hasParticipated(eventId: String, userId: String): Boolean {
return eventParticipationRepository.existsByEventIdAndUserId(eventId, userId)
}
}