Initialize projecet
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
10
src/main/resources/application.yaml
Normal file
10
src/main/resources/application.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
spring:
|
||||
application:
|
||||
name: mongodb-nanosec
|
||||
|
||||
event:
|
||||
config:
|
||||
persistence:
|
||||
mongodb:
|
||||
uri: mongodb://fancommerce:fancommerce@localhost:27017/fan_event?authSource=admin
|
||||
auto-index-creation: true
|
||||
326
src/main/resources/static/index.html
Normal file
326
src/main/resources/static/index.html
Normal file
@@ -0,0 +1,326 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Event Participation - Timing Test</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card h2 {
|
||||
color: #555;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.timing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
.timing-item {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.timing-item .label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.timing-item .value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
.timing-item .epoch {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.diff-section {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background: #e8f4fd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.diff-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.diff-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.diff-label {
|
||||
color: #555;
|
||||
}
|
||||
.diff-value {
|
||||
font-weight: 600;
|
||||
color: #007bff;
|
||||
}
|
||||
.response-section {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.response-json {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.error {
|
||||
color: #dc3545;
|
||||
background: #f8d7da;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.success {
|
||||
color: #155724;
|
||||
background: #d4edda;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
#result {
|
||||
display: none;
|
||||
}
|
||||
#result.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Event Participation - Timing Test</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Participation Form</h2>
|
||||
<form id="participationForm">
|
||||
<div class="form-group">
|
||||
<label for="eventId">Event ID</label>
|
||||
<input type="text" id="eventId" name="eventId" value="event-001" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="userId">User ID</label>
|
||||
<input type="text" id="userId" name="userId" value="user-001" required>
|
||||
</div>
|
||||
<button type="submit" id="submitBtn">Submit Participation</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="result" class="card">
|
||||
<h2>Result</h2>
|
||||
<div id="successMsg" class="success"></div>
|
||||
|
||||
<h3 style="margin-bottom: 10px; color: #555;">Timing Information</h3>
|
||||
<div class="timing-grid">
|
||||
<div class="timing-item">
|
||||
<div class="label">Client Request Time</div>
|
||||
<div class="value" id="clientTime">-</div>
|
||||
<div class="epoch" id="clientTimeEpoch">-</div>
|
||||
</div>
|
||||
<div class="timing-item">
|
||||
<div class="label">Server Filter Time</div>
|
||||
<div class="value" id="filterTime">-</div>
|
||||
<div class="epoch" id="filterTimeEpoch">-</div>
|
||||
</div>
|
||||
<div class="timing-item">
|
||||
<div class="label">Server Controller Time</div>
|
||||
<div class="value" id="controllerTime">-</div>
|
||||
<div class="epoch" id="controllerTimeEpoch">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diff-section">
|
||||
<div class="diff-item">
|
||||
<span class="diff-label">Client → Filter</span>
|
||||
<span class="diff-value" id="clientToFilterDiff">-</span>
|
||||
</div>
|
||||
<div class="diff-item">
|
||||
<span class="diff-label">Filter → Controller</span>
|
||||
<span class="diff-value" id="filterToControllerDiff">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="response-section">
|
||||
<h3 style="margin-bottom: 10px; color: #555;">Full Response</h3>
|
||||
<pre class="response-json" id="responseJson"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="errorSection" class="card" style="display: none;">
|
||||
<h2>Error</h2>
|
||||
<div id="errorMsg" class="error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('participationForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const resultSection = document.getElementById('result');
|
||||
const errorSection = document.getElementById('errorSection');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset UI
|
||||
resultSection.classList.remove('show');
|
||||
errorSection.style.display = 'none';
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Submitting...';
|
||||
|
||||
const eventId = document.getElementById('eventId').value;
|
||||
const userId = document.getElementById('userId').value;
|
||||
|
||||
// Capture client time right before the request
|
||||
const clientTimestamp = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/event/participation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Timezone': 'Asia/Seoul',
|
||||
'X-Client-Timestamp': clientTimestamp.toString()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
eventId: eventId,
|
||||
userId: userId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
|
||||
// Display success
|
||||
document.getElementById('successMsg').textContent =
|
||||
`Participation registered! ID: ${data.participation.id}`;
|
||||
|
||||
// Display timing info
|
||||
const timing = data.timing;
|
||||
|
||||
// Client time
|
||||
if (timing.clientRequestTime) {
|
||||
document.getElementById('clientTime').textContent = formatTime(timing.clientRequestTime);
|
||||
document.getElementById('clientTimeEpoch').textContent = `Epoch: ${timing.clientRequestTimeEpochMilli}`;
|
||||
} else {
|
||||
document.getElementById('clientTime').textContent = 'Not captured';
|
||||
document.getElementById('clientTimeEpoch').textContent = '-';
|
||||
}
|
||||
|
||||
// Filter time
|
||||
document.getElementById('filterTime').textContent = formatTime(timing.serverFilterTime);
|
||||
document.getElementById('filterTimeEpoch').textContent = `Epoch: ${timing.serverFilterTimeEpochMilli}`;
|
||||
|
||||
// Controller time
|
||||
document.getElementById('controllerTime').textContent = formatTime(timing.serverControllerTime);
|
||||
document.getElementById('controllerTimeEpoch').textContent = `Epoch: ${timing.serverControllerTimeEpochMilli}`;
|
||||
|
||||
// Differences
|
||||
document.getElementById('clientToFilterDiff').textContent =
|
||||
timing.clientToFilterDiffMs !== null ? `${timing.clientToFilterDiffMs} ms` : 'N/A';
|
||||
document.getElementById('filterToControllerDiff').textContent =
|
||||
`${timing.filterToControllerDiffMs} ms`;
|
||||
|
||||
// Full response JSON
|
||||
document.getElementById('responseJson').textContent = JSON.stringify(data, null, 2);
|
||||
|
||||
resultSection.classList.add('show');
|
||||
|
||||
// Auto-increment userId for next test
|
||||
const currentUserId = document.getElementById('userId').value;
|
||||
const match = currentUserId.match(/^(.+-)(\d+)$/);
|
||||
if (match) {
|
||||
document.getElementById('userId').value = match[1] + (parseInt(match[2]) + 1);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('errorMsg').textContent = error.message;
|
||||
errorSection.style.display = 'block';
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Submit Participation';
|
||||
}
|
||||
});
|
||||
|
||||
function formatTime(isoString) {
|
||||
if (!isoString) return '-';
|
||||
// Convert ISO string to readable format
|
||||
const date = new Date(isoString);
|
||||
return date.toISOString().replace('T', ' ').replace('Z', ' UTC');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user