diff --git a/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/ClosedPlaceCandidate.kt b/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/ClosedPlaceCandidate.kt index 979979b99..58b444633 100644 --- a/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/ClosedPlaceCandidate.kt +++ b/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/ClosedPlaceCandidate.kt @@ -10,7 +10,7 @@ import java.time.Instant @Entity class ClosedPlaceCandidate( @Id - val id: String, + override val id: String, @Column(nullable = false) val placeId: String, @@ -40,22 +40,4 @@ class ClosedPlaceCandidate( fun ignore() { ignoredAt = SccClock.instant() } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ClosedPlaceCandidate - - return id == other.id - } - - override fun hashCode(): Int { - return id.hashCode() - } - - override fun toString(): String { - return "ClosedPlaceCandidate(id='$id', placeId='$placeId', externalId='$externalId', " + - "acceptedAt='$acceptedAt', ignoredAt='$ignoredAt' createdAt=$createdAt, updatedAt=$updatedAt)" - } } diff --git a/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/Place.kt b/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/Place.kt index 42098a446..776610422 100644 --- a/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/Place.kt +++ b/app-server/subprojects/bounded_context/place/domain/src/main/kotlin/club/staircrusher/place/domain/model/Place.kt @@ -19,7 +19,7 @@ import org.locationtech.jts.geom.Point @Entity class Place private constructor( @Id - val id: String, + override val id: String, val name: String, @AttributeOverrides( AttributeOverride(name = "lng", column = Column(name = "location_x")), @@ -61,24 +61,6 @@ class Place private constructor( return this.locationForQuery == null && maybeUpdated.locationForQuery != null } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Place - - return id == other.id - } - - override fun hashCode(): Int { - return id.hashCode() - } - - override fun toString(): String { - return "Place(id='$id', name='$name', location=$location, building=$building, siGunGuId=$siGunGuId, " + - "eupMyeonDongId=$eupMyeonDongId, category=$category, isClosed=$isClosed, isNotAccessible=$isNotAccessible)" - } - companion object { private val geometryFactory = GeometryFactory() fun of( diff --git a/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/AbstractDomainModel.kt b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/AbstractDomainModel.kt new file mode 100644 index 000000000..bea0834d9 --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/AbstractDomainModel.kt @@ -0,0 +1,64 @@ +package club.staircrusher.stdlib.persistence + +import org.hibernate.Hibernate +import org.hibernate.proxy.HibernateProxy +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible + +abstract class AbstractDomainModel { + abstract val id: String + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AbstractDomainModel + + return this.id == other.id + } + + override fun hashCode(): Int { + return id.hashCode() + } + + + override fun toString(): String { + val properties = this::class.memberProperties + .filter { it.name != "id" } + .mapNotNull { prop -> + try { + prop.isAccessible = true + val value = prop.getter.call(this) + + // Exclude uninitialized Hibernate proxy collections + if (!isInitialized(value)) return@mapNotNull null + + val displayValue = if (prop.findAnnotation() != null || prop.name in sensitiveFields) { + "" + } else { + value.toString() + } + + "${prop.name}='$displayValue'" + } catch (t: Throwable) { + null + } + } + + return "${this::class.simpleName}(id='$id', ${properties.joinToString(", ")})" + } + + private fun isInitialized(value: Any?): Boolean { + return when (value) { + null -> true + is Collection<*> -> Hibernate.isInitialized(value) + is HibernateProxy -> !value.hibernateLazyInitializer.isUninitialized + else -> true + } + } + + companion object { + private val sensitiveFields = setOf("password", "secretKey", "apiKey", "token") + } +} diff --git a/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/Sensitive.kt b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/Sensitive.kt new file mode 100644 index 000000000..cdcd2dde0 --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/Sensitive.kt @@ -0,0 +1,5 @@ +package club.staircrusher.stdlib.persistence + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class Sensitive diff --git a/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/jpa/TimeAuditingBaseEntity.kt b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/jpa/TimeAuditingBaseEntity.kt index 85f86ed7d..ec86a77f0 100644 --- a/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/jpa/TimeAuditingBaseEntity.kt +++ b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/jpa/TimeAuditingBaseEntity.kt @@ -1,5 +1,6 @@ package club.staircrusher.stdlib.persistence.jpa +import club.staircrusher.stdlib.persistence.AbstractDomainModel import jakarta.persistence.Column import jakarta.persistence.MappedSuperclass import org.hibernate.annotations.CreationTimestamp @@ -7,7 +8,7 @@ import org.hibernate.annotations.UpdateTimestamp import java.time.Instant @MappedSuperclass -class TimeAuditingBaseEntity { +abstract class TimeAuditingBaseEntity : AbstractDomainModel() { @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) lateinit var createdAt: Instant diff --git a/app-server/subprojects/cross_cutting_concern/stdlib/src/unitTest/kotlin/club/staircrusher/stdlib/persistence/AbstractDomainModelUT.kt b/app-server/subprojects/cross_cutting_concern/stdlib/src/unitTest/kotlin/club/staircrusher/stdlib/persistence/AbstractDomainModelUT.kt new file mode 100644 index 000000000..338974048 --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/stdlib/src/unitTest/kotlin/club/staircrusher/stdlib/persistence/AbstractDomainModelUT.kt @@ -0,0 +1,83 @@ +package club.staircrusher.stdlib.persistence + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AbstractDomainModelUT { + @Test + fun `정의된 필드가 toString 메소드에 포함된다`() { + val some = SomeDomainModel( + id = "id", + name = "name", + password = "password", + age = 10, + someSensitiveField = "sensitive", + isClosed = false + ) + + val str = some.toString() + assertEquals(true, str.contains("id=")) + assertEquals(true, str.contains("name=")) + assertEquals(true, str.contains("password=")) + assertEquals(true, str.contains("age=")) + assertEquals(true, str.contains("someSensitiveField=")) + } + + @Test + fun `확실한 민감정보는 toString 에서 마스킹된다`() { + val some = SomeDomainModel( + id = "id", + name = "name", + password = "password", + age = 10, + someSensitiveField = "sensitive", + isClosed = false, + ) + + val str = some.toString() + assertEquals(true, str.contains("password=''")) + } + + @Test + fun `Sensitive 어노테이션이 붙은 필드는 toString 에서 마스킹된다`() { + val some = SomeDomainModel( + id = "id", + name = "name", + password = "password", + age = 10, + someSensitiveField = "sensitive", + isClosed = false, + ) + + val str = some.toString() + assertEquals(true, str.contains("someSensitiveField=''")) + } + + @Test + fun `contructor 에 정의되지 않은 필드도 toString 에 포함된다`() { + val some = SomeDomainModel( + id = "id", + name = "name", + password = "password", + age = 10, + someSensitiveField = "sensitive", + isClosed = false + ) + + val str = some.toString() + assertEquals(true, str.contains("isClosed='false'")) + } + + class SomeDomainModel( + override val id: String, + val name: String, + val password: String, + val age: Int, + @Sensitive + val someSensitiveField: String, + isClosed: Boolean + ) : AbstractDomainModel() { + var isClosed: Boolean = isClosed + private set + } +}