[F] Fix import/export disparity

This commit is contained in:
Azalea
2026-08-04 05:14:30 +00:00
parent 30139cae8d
commit cb13949f51
4 changed files with 152 additions and 10 deletions
+54 -10
View File
@@ -2,8 +2,10 @@ package ext
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.databind.*
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
@@ -21,16 +23,58 @@ val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, obj
else -> 400 - "Invalid boolean value ${parser.text}"
}
})
val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<LocalDateTime>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
// First try standard formats via asDateTime() method
parser.text.takeIf { it.isNotEmpty() }?.run { asDateTime() ?: try {
// Try maimai2 format (yyyy-MM-dd HH:mm:ss.0)
LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
} catch (e: Exception) {
400 - "Invalid date time value ${parser.text}"
} }
})
val JSON_DATETIME = SimpleModule()
.addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
.addDeserializer(LocalDateTime::class.java, object : JsonDeserializer<LocalDateTime>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext): LocalDateTime? {
parser.readLocalDateTimeArray(context)?.let { return it }
// First try standard formats via asDateTime() method
return parser.text.takeIf { it.isNotEmpty() }?.run { asDateTime() ?: try {
// Try maimai2 format (yyyy-MM-dd HH:mm:ss.0)
LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
} catch (e: Exception) {
400 - "Invalid date time value ${parser.text}"
} }
}
})
fun JsonParser.readLocalDateTimeArray(context: DeserializationContext): LocalDateTime? {
if (!isExpectedStartArrayToken) return null
val parts = mutableListOf<Int>()
while (nextToken() != JsonToken.END_ARRAY) {
if (currentToken() != JsonToken.VALUE_NUMBER_INT) {
throw context.weirdStringException(
parts.toString(),
LocalDateTime::class.java,
"Invalid legacy date-time array",
)
}
parts += intValue
}
if (parts.size !in 5..7) {
throw context.weirdStringException(
parts.toString(),
LocalDateTime::class.java,
"Invalid legacy date-time array; expected 5 to 7 integer components",
)
}
return try {
LocalDateTime.of(
parts[0], parts[1], parts[2], parts[3], parts[4],
parts.getOrElse(5) { 0 }, parts.getOrElse(6) { 0 },
)
} catch (e: Exception) {
throw context.weirdStringException(
parts.toString(),
LocalDateTime::class.java,
"Invalid legacy date-time array: ${e.message}",
)
}
}
val JACKSON = jacksonObjectMapper().apply {
setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL)
findAndRegisterModules()
@@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import ext.readLocalDateTimeArray
import icu.samnyan.aqua.net.games.BaseEntity
import icu.samnyan.aqua.net.games.IUserData
import icu.samnyan.aqua.sega.chusan.model.request.UserEmoney
@@ -24,6 +25,8 @@ import java.time.temporal.ChronoField
class FlexibleDateTimeDeserializer : JsonDeserializer<LocalDateTime?>() {
@Throws(IOException::class)
public override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime {
p.readLocalDateTimeArray(ctxt)?.let { return it }
val value = p.text
return FORMATTERS.firstNotNullOfOrNull { formatter ->
runCatching { LocalDateTime.parse(value, formatter) }.getOrNull()
@@ -3,6 +3,7 @@ package test
import com.fasterxml.jackson.databind.JsonMappingException
import ext.JACKSON
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserData
import icu.samnyan.aqua.sega.chusan.model.userdata.UserCharge
import icu.samnyan.aqua.sega.chusan.model.userdata.UserCMissionProgress
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
@@ -44,6 +45,26 @@ class Chu3ImportJsonTest : StringSpec({
imported.lastPlayDate shouldBe LocalDateTime.of(2026, 8, 4, 1, 2, 3)
}
"legacy backup date arrays remain importable" {
val userData = JACKSON.readValue(
"""{
"eventWatchedDate":[2026,8,4,1,2],
"firstPlayDate":[2026,8,4,1,2,3],
"lastPlayDate":[2026,8,4,1,2,3,123456000]
}""",
Chu3UserData::class.java,
)
val charge = JACKSON.readValue(
"""{"purchaseDate":[2026,8,4,1,2,3,123456000]}""",
UserCharge::class.java,
)
userData.eventWatchedDate shouldBe LocalDateTime.of(2026, 8, 4, 1, 2)
userData.firstPlayDate shouldBe LocalDateTime.of(2026, 8, 4, 1, 2, 3)
userData.lastPlayDate shouldBe LocalDateTime.of(2026, 8, 4, 1, 2, 3, 123456000)
charge.purchaseDate shouldBe LocalDateTime.of(2026, 8, 4, 1, 2, 3, 123456000)
}
"invalid CHUNITHM dates are rejected with a useful message" {
val error = shouldThrow<JsonMappingException> {
JACKSON.readValue(
@@ -0,0 +1,74 @@
package test
import ext.JACKSON
import ext.parseJackson
import icu.samnyan.aqua.net.games.chu3.Chu3DataExport
import icu.samnyan.aqua.net.games.mai2.Maimai2DataExport
import icu.samnyan.aqua.net.games.ongeki.OngekiDataExport
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserLinkedVerse
import icu.samnyan.aqua.sega.chusan.model.userdata.UserCharge
import icu.samnyan.aqua.sega.chusan.model.userdata.UserCourse
import icu.samnyan.aqua.sega.chusan.model.userdata.UserDuel
import icu.samnyan.aqua.sega.chusan.model.userdata.UserGacha
import icu.samnyan.aqua.sega.chusan.model.userdata.UserLoginBonus
import icu.samnyan.aqua.sega.chusan.model.userdata.UserPlaylog
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserKaleidx
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.time.LocalDateTime
class ImportExportParityTest : StringSpec({
val timestamp = LocalDateTime.of(2026, 8, 4, 1, 2, 3, 123_456_000)
"CHUNITHM export JSON can be imported without changing data" {
val data = Chu3DataExport().apply {
userData.eventWatchedDate = timestamp
userData.firstPlayDate = timestamp
userData.lastPlayDate = timestamp
userChargeList = listOf(UserCharge().apply {
purchaseDate = timestamp
validDate = timestamp
paramDate = timestamp
})
userCourseList = listOf(UserCourse().apply { lastPlayDate = timestamp })
userDuelList = listOf(UserDuel().apply { lastPlayDate = timestamp })
userPlaylogList = listOf(UserPlaylog().apply {
playDate = timestamp
userPlayDate = timestamp
})
userGachaList = listOf(UserGacha().apply { dailyGachaDate = timestamp })
userLinkedVerseList = listOf(Chu3UserLinkedVerse().apply { clearDate = timestamp })
userLoginBonusList = listOf(UserLoginBonus(lastUpdateDate = timestamp))
}
assertJsonRoundTrip(data)
val tree = JACKSON.readTree(JACKSON.writeValueAsString(data))
tree.at("/userData/firstPlayDate").isTextual shouldBe true
tree.at("/userChargeList/0/purchaseDate").isTextual shouldBe true
}
"maimai DX export JSON can be imported without changing data" {
val data = Maimai2DataExport().apply {
userKaleidxScopeList = listOf(Mai2UserKaleidx().apply {
bestAchievementDate = timestamp
bestDeluxscoreDate = timestamp
clearDate = timestamp
lastPlayDate = timestamp
})
}
assertJsonRoundTrip(data)
JACKSON.readTree(JACKSON.writeValueAsString(data))
.at("/userKaleidxScopeList/0/lastPlayDate").isTextual shouldBe true
}
"ONGEKI export JSON can be imported without changing data" {
assertJsonRoundTrip(OngekiDataExport())
}
})
private inline fun <reified T : Any> assertJsonRoundTrip(value: T) {
val exportedJson = JACKSON.writeValueAsString(value)
val imported = exportedJson.parseJackson(T::class.java)
JACKSON.readTree(JACKSON.writeValueAsString(imported)) shouldBe JACKSON.readTree(exportedJson)
}