From 7c7234801636528c263ba61861c199f0e41749e1 Mon Sep 17 00:00:00 2001 From: Menci Date: Wed, 10 Dec 2025 04:27:54 +0800 Subject: [PATCH] [+] Card Timestamp --- src/main/java/ext/Ext.kt | 4 ++ src/main/java/ext/Json.kt | 6 +- .../icu/samnyan/aqua/net/CardController.kt | 10 ++-- src/main/java/icu/samnyan/aqua/net/Fedy.kt | 60 +++++++++---------- .../icu/samnyan/aqua/net/UserRegistrar.kt | 9 +-- .../aqua/net/games/GameApiController.kt | 4 ++ .../aqua/net/games/ImportController.kt | 8 ++- .../samnyan/aqua/net/games/chu3/Chu3Import.kt | 2 +- .../samnyan/aqua/net/games/mai2/Mai2Import.kt | 2 +- .../net/games/mai2/Mai2MusicDetailImport.kt | 3 + .../samnyan/aqua/net/games/mai2/Maimai2.kt | 3 + .../aqua/sega/chusan/ChusanController.kt | 2 + .../aqua/sega/chusan/handler/ChusanCMApis.kt | 10 +++- .../sega/chusan/handler/ChusanUpsertApis.kt | 5 +- .../aqua/sega/general/model/CardTimestamp.kt | 32 ++++++++++ .../aqua/sega/general/service/CardService.kt | 17 +++++- .../samnyan/aqua/sega/maimai2/Maimai2Apis.kt | 1 + .../sega/maimai2/Maimai2ServletController.kt | 4 -- .../handler/UploadUserPlaylogHandler.kt | 9 ++- .../maimai2/handler/UpsertUserAllHandler.kt | 3 +- .../maimai2/handler/UpsertUserPrintHandler.kt | 4 ++ .../samnyan/aqua/sega/ongeki/OngekiCMApis.kt | 8 ++- .../aqua/sega/ongeki/OngekiController.kt | 2 + .../aqua/sega/ongeki/OngekiUpsertAllApi.kt | 2 + .../db/80/V1000_62__sega_card_timestamp.sql | 11 ++++ 25 files changed, 158 insertions(+), 63 deletions(-) create mode 100644 src/main/java/icu/samnyan/aqua/sega/general/model/CardTimestamp.kt create mode 100644 src/main/resources/db/80/V1000_62__sega_card_timestamp.sql diff --git a/src/main/java/ext/Ext.kt b/src/main/java/ext/Ext.kt index f991d4b4..0e095090 100644 --- a/src/main/java/ext/Ext.kt +++ b/src/main/java/ext/Ext.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.withContext import org.apache.tika.Tika import org.apache.tika.mime.MimeTypes import org.slf4j.LoggerFactory +import org.springframework.context.ApplicationContext import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity.BodyBuilder @@ -264,3 +265,6 @@ val Pair<*, S>.r get() = component2() val Query.exec get() = resultList.map { (it as Array<*>).toList() } fun List>.numCsv(vararg head: Str) = head.joinToString(",") + "\n" + joinToString("\n") { it.joinToString(",") } + +// DI +inline fun ApplicationContext.lazy() = lazy { getBean(T::class.java) } \ No newline at end of file diff --git a/src/main/java/ext/Json.kt b/src/main/java/ext/Json.kt index d8a7e4c5..f7acf3ac 100644 --- a/src/main/java/ext/Json.kt +++ b/src/main/java/ext/Json.kt @@ -21,15 +21,15 @@ 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() { +val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer() { override fun deserialize(parser: JsonParser, context: DeserializationContext) = // First try standard formats via asDateTime() method - parser.text.asDateTime() ?: try { + 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 JACKSON = jacksonObjectMapper().apply { setSerializationInclusion(JsonInclude.Include.NON_NULL) diff --git a/src/main/java/icu/samnyan/aqua/net/CardController.kt b/src/main/java/icu/samnyan/aqua/net/CardController.kt index 304ede8d..b44bfb1e 100644 --- a/src/main/java/icu/samnyan/aqua/net/CardController.kt +++ b/src/main/java/icu/samnyan/aqua/net/CardController.kt @@ -101,8 +101,7 @@ class CardController( val games = migrate.split(',') cardGameService.migrate(card, games) - fedy.onCardLinked(card.luid, oldExtId = card.extId, ghostExtId = u.ghostCard.extId, - games.map { Fedy.getGameName(it) }.filterNotNull()) + fedy.onCardLinked(card.luid, oldExtId = card.extId, ghostExtId = u.ghostCard.extId, games) log.info("Net /card/link : Linked card ${card.id} to user ${u.username} and migrated data to ${games.joinToString()}") @@ -207,7 +206,8 @@ class CardGameService( val diva: icu.samnyan.aqua.sega.diva.dao.userdata.PlayerProfileRepository, val safety: AquaNetSafetyService, val cardRepo: CardRepository, - val em: EntityManager + val em: EntityManager, + val cardService: CardService ) { companion object { val log = logger() @@ -225,7 +225,9 @@ class CardGameService( val remainingGames = dataRepos.keys.toMutableSet() games.forEach { game -> val dataRepo = dataRepos[game] ?: return@forEach - migrateCard(game, dataRepo, cardRepo, crd) + if (migrateCard(game, dataRepo, cardRepo, crd)) + // Update timestamp for the ghost card (data migrated in) + cardService.updateCardTimestamp(crd.aquaUser!!.ghostCard, game, resetCreatedAt = true) remainingGames.remove(game) } // For remaining games, orphan the data by assigning them to a dummy card diff --git a/src/main/java/icu/samnyan/aqua/net/Fedy.kt b/src/main/java/icu/samnyan/aqua/net/Fedy.kt index 712d1133..6debed99 100644 --- a/src/main/java/icu/samnyan/aqua/net/Fedy.kt +++ b/src/main/java/icu/samnyan/aqua/net/Fedy.kt @@ -27,7 +27,9 @@ import icu.samnyan.aqua.sega.general.model.Card import icu.samnyan.aqua.sega.general.service.CardService import icu.samnyan.aqua.sega.ongeki.OgkUserDataRepo import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo +import org.springframework.context.ApplicationContext import org.springframework.web.multipart.MultipartFile +import java.time.Instant import java.util.concurrent.CompletableFuture import kotlin.io.path.getLastModifiedTime import kotlin.io.path.isRegularFile @@ -41,7 +43,7 @@ class FedyProps { var remote: String = "" } -data class UserProfilePicture(val url: Str, val lastUpdatedMs: Long) +data class UserProfilePicture(val url: Str, val updatedAtMs: Long) data class UserBasicInfo( val auId: Long, val ghostExtId: Long, val registrationTimeMs: Long, val username: Str, val displayName: Str, val email: Str, val passwordHash: Str, val profileBio: Str, @@ -66,21 +68,23 @@ private data class FedyEvent( @API("/api/v2/fedy", consumes = ["multipart/form-data"]) class Fedy( val jwt: JWT, - val us: AquaUserServices, val emailProps: EmailProperties, val cardRepo: CardRepository, - val cardService: CardService, - val mai2Import: Mai2Import, val mai2UserDataRepo: Mai2UserDataRepo, - val mai2UploadUserPlaylog: Mai2UploadUserPlaylogHandler, - val mai2UpsertUserAll: Mai2UpsertUserAllHandler, val chu3UserDataRepo: Chu3UserDataRepo, val ongekiUserDataRepo: OgkUserDataRepo, val waccaUserDataRepo: WcUserRepo, val props: FedyProps, val paths: PathProps, - val transactionManager: PlatformTransactionManager + val transactionManager: PlatformTransactionManager, + ctx: ApplicationContext ) { + val us by ctx.lazy() + val cardService by ctx.lazy() + val mai2Import by ctx.lazy() + val mai2UploadUserPlaylog by ctx.lazy() + val mai2UpsertUserAll by ctx.lazy() + val transaction by lazy { TransactionTemplate(transactionManager) } private fun Str.checkKey() { @@ -157,25 +161,30 @@ class Fedy( ?.let { paths.aquaNetPortrait.path() / it }?.takeIf { it.isRegularFile() } ?.let { UserProfilePicture( url = "/uploads/net/portrait/${profilePicture}", - lastUpdatedMs = it.getLastModifiedTime().toMillis() + updatedAtMs = it.getLastModifiedTime().toMillis() ) } ) - data class DataPullReq(val extId: Long, val game: Str, val exportOptions: ExportOptions) - data class DataPullRes(val error: FedyErr? = null, val result: Any? = null) + data class DataPullReq(val extId: Long, val game: Str, val createdAtMs: Long, val updatedAtMs: Long, val exportOptions: ExportOptions) + data class DataPullResult(val data: Any?, val createdAtMs: Long, val updatedAtMs: Long, val isRebased: Bool) + data class DataPullRes(val error: FedyErr? = null, val result: DataPullResult? = null) @API("/data/pull") fun handleDataPull(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: DataPullReq): DataPullRes = handleFedy(key) { val card = cardRepo.findByExtId(req.extId).orElse(null) ?: (404 - "Card with extId ${req.extId} not found") + val cardTimestamp = cardService.getCardTimestamp(card, req.game) + if (cardTimestamp.updatedAt.toEpochMilli() == req.updatedAtMs) return@handleFedy DataPullRes(error = null, result = null) // No changes + val isRebased = req.createdAtMs > 0 && cardTimestamp.createdAt.toEpochMilli() > req.createdAtMs + val exportOptions = if (!isRebased) { req.exportOptions } else { req.exportOptions.copy(playlogAfter = null) } { - DataPullRes(result = when (req.game) { - "mai2" -> mai2Import.export(card, req.exportOptions) + DataPullRes(result = DataPullResult(data = when (req.game) { + "mai2" -> mai2Import.export(card, exportOptions) else -> 406 - "Unsupported game" - }) + }, createdAtMs = cardTimestamp.createdAt.toEpochMilli(), updatedAtMs = cardTimestamp.updatedAt.toEpochMilli(), isRebased = isRebased)) } caught { DataPullRes(error = it) } } - data class DataPushReq(val extId: Long, val game: Str, val data: JDict, val removeOldData: Bool) + data class DataPushReq(val extId: Long, val game: Str, val data: JDict, val removeOldData: Bool, val updatedAtMs: Long) @Suppress("UNCHECKED_CAST") @API("/data/push") fun handleDataPush(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: DataPushReq): Any = handleFedy(key) { @@ -188,6 +197,7 @@ class Fedy( repo.flush() } } + val card = cardRepo.findByExtId(extId).orElse(null) ?: (404 - "Card not found") transaction.execute { when (req.game) { "mai2" -> { if (req.removeOldData) { removeOldData(mai2UserDataRepo) } @@ -198,7 +208,7 @@ class Fedy( } else -> 406 - "Unsupported game" } } - + cardService.updateCardTimestamp(card, req.game, now = Instant.ofEpochMilli(req.updatedAtMs), resetCreatedAt = req.removeOldData) SUCCESS } @@ -221,9 +231,9 @@ class Fedy( var pairedCard = cardService.tryLookup(req.pairedLuid)?.maybeGhost() if (pairedCard?.extId != card?.extId) { var isGhost = pairedCard?.isGhost == true - var isFresh = pairedCard != null && isCardFresh(pairedCard) - if (isGhost && isFresh) isPairedLuidDiverged = true - else if (!isGhost && card?.isGhost == true) { + var isNonFresh = pairedCard != null && !isCardFresh(pairedCard) + if (isGhost || isNonFresh) isPairedLuidDiverged = true + else if (card?.isGhost == true) { // Ensure paired card is linked, if the main card is linked // If the main card is not linked, there's nothing Fedy can do. It's Fedy's best effort. if (pairedCard == null) { pairedCard = cardService.registerByAccessCode(req.pairedLuid, card.aquaUser) } @@ -326,7 +336,7 @@ class Fedy( // Apparently existing cards could possibly be fresh and never used in any game. Treat them as new cards. private fun isCardFresh(c: Card): Bool { - fun checkForGame(repo: GenericUserDataRepo, card: Card): Bool = repo.findByCard(card) == null + fun checkForGame(repo: GenericUserDataRepo, card: Card): Bool = repo.findByCard(card) != null return when { checkForGame(mai2UserDataRepo, c) -> false checkForGame(chu3UserDataRepo, c) -> false @@ -346,17 +356,5 @@ class Fedy( const val REQ_PART = "request" const val PFP_PART = "profilePicture" val log = logger() - - fun getGameName(gameId: Str) = when (gameId) { - "mai2" -> "mai2" - "SDEZ" -> "mai2" - "chu3" -> "chu3" - "SDHD" -> "chu3" - "ongeki" -> "mu3" - "SDDT" -> "mu3" - "wacca" -> "wacca" - "SDFE" -> "wacca" - else -> null // Not supported - } } } diff --git a/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt b/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt index abdf1120..3cd6d2e4 100644 --- a/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt +++ b/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt @@ -3,22 +3,15 @@ package icu.samnyan.aqua.net import ext.* import icu.samnyan.aqua.net.components.* import icu.samnyan.aqua.net.db.* -import icu.samnyan.aqua.net.db.AquaUserServices.Companion.SETTING_FIELDS import icu.samnyan.aqua.net.utils.PathProps import icu.samnyan.aqua.net.utils.SUCCESS import icu.samnyan.aqua.sega.general.dao.CardRepository -import icu.samnyan.aqua.sega.general.model.Card -import icu.samnyan.aqua.sega.general.model.CardStatus -import icu.samnyan.aqua.sega.general.service.CardService import jakarta.servlet.http.HttpServletRequest import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.annotation.Lazy import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile import java.time.Instant -import java.time.LocalDateTime import kotlin.io.path.writeBytes @RestController @@ -28,6 +21,7 @@ class UserRegistrar( val hasher: PasswordEncoder, val turnstileService: TurnstileService, val emailService: EmailService, + val fedy: Fedy, val geoIP: GeoIP, val jwt: JWT, val confirmationRepo: EmailConfirmationRepo, @@ -37,7 +31,6 @@ class UserRegistrar( val emailProps: EmailProperties, final val paths: PathProps ) { - @Autowired @Lazy lateinit var fedy: Fedy val portraitPath = paths.aquaNetPortrait.path() companion object { diff --git a/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt b/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt index 714fbe56..6496b878 100644 --- a/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt +++ b/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt @@ -5,6 +5,7 @@ import icu.samnyan.aqua.net.BotProps import icu.samnyan.aqua.net.db.AquaUserServices import icu.samnyan.aqua.net.utils.SUCCESS import icu.samnyan.aqua.sega.general.model.Card +import icu.samnyan.aqua.sega.general.service.CardService import jakarta.annotation.PostConstruct import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -29,6 +30,8 @@ abstract class GameApiController(val name: String, userDataClass: abstract val settableFields: Map Unit> open val gettableFields: Set = setOf() + @Autowired lateinit var cardService: CardService + @API("trend") abstract suspend fun trend(@RP username: String): List @API("user-summary") @@ -138,6 +141,7 @@ abstract class GameApiController(val name: String, userDataClass: val user = async { userDataRepo.findByCard(u.ghostCard) } ?: (404 - "User not found") prop(user, value) async { userDataRepo.save(user) } + cardService.updateCardTimestamp(u.ghostCard, name) SUCCESS } } diff --git a/src/main/java/icu/samnyan/aqua/net/games/ImportController.kt b/src/main/java/icu/samnyan/aqua/net/games/ImportController.kt index 892ffeb4..3ede59f4 100644 --- a/src/main/java/icu/samnyan/aqua/net/games/ImportController.kt +++ b/src/main/java/icu/samnyan/aqua/net/games/ImportController.kt @@ -7,6 +7,7 @@ import icu.samnyan.aqua.net.Fedy import icu.samnyan.aqua.net.utils.AquaNetProps import icu.samnyan.aqua.net.utils.SUCCESS import icu.samnyan.aqua.sega.general.model.Card +import icu.samnyan.aqua.sega.general.service.CardService import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.repository.NoRepositoryBean @@ -17,7 +18,6 @@ import java.util.* import kotlin.io.path.Path import kotlin.io.path.writeText import kotlin.reflect.KClass -import org.springframework.context.annotation.Lazy data class ExportOptions( val playlogAfter: String? = null @@ -50,6 +50,7 @@ interface IUserRepo: JpaRepository { * Import controller for a game * * @param game: 4-letter Game ID + * @param gameName: mai2/chu3/ongeki * @param exportFields: Mapping of type names to variables in the export model * (e.g. "Mai2UserCharacter" -> Mai2DataExport::userCharacterList) * @param exportRepos: Mapping of variables to repositories that can be used to find the data @@ -57,6 +58,7 @@ interface IUserRepo: JpaRepository { */ abstract class ImportController, UserModel: IUserData>( val game: String, + val gameName: String, val exportClass: KClass, val exportFields: Map>, val exportRepos: Map, IUserRepo>, @@ -71,7 +73,7 @@ abstract class ImportController, UserModel: @Autowired lateinit var netProps: AquaNetProps @Autowired lateinit var transManager: PlatformTransactionManager val trans by lazy { TransactionTemplate(transManager) } - @Autowired @Lazy lateinit var fedy: Fedy + @Autowired lateinit var cardService: CardService init { artemisRenames.values.forEach { @@ -148,7 +150,7 @@ abstract class ImportController, UserModel: } } - Fedy.getGameName(game)?.let { fedy.onDataUpdated(u.ghostCard.extId, it, true) } + cardService.updateCardTimestamp(u.ghostCard, gameName, resetCreatedAt = true) SUCCESS } diff --git a/src/main/java/icu/samnyan/aqua/net/games/chu3/Chu3Import.kt b/src/main/java/icu/samnyan/aqua/net/games/chu3/Chu3Import.kt index be9a45eb..f6292dc0 100644 --- a/src/main/java/icu/samnyan/aqua/net/games/chu3/Chu3Import.kt +++ b/src/main/java/icu/samnyan/aqua/net/games/chu3/Chu3Import.kt @@ -17,7 +17,7 @@ import kotlin.reflect.full.declaredMembers class Chu3Import( val repos: Chu3Repos, ) : ImportController( - "SDHD", Chu3DataExport::class, + "SDHD", "chu3", Chu3DataExport::class, exportFields = Chu3DataExport::class.vars().associateBy { it.name.replace("List", "").lowercase() }, diff --git a/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2Import.kt b/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2Import.kt index f53da34c..78dc0d69 100644 --- a/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2Import.kt +++ b/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2Import.kt @@ -19,7 +19,7 @@ import kotlin.reflect.full.declaredMembers class Mai2Import( val repos: Mai2Repos, ) : ImportController( - "SDEZ", Maimai2DataExport::class, + "SDEZ", "mai2", Maimai2DataExport::class, exportFields = Maimai2DataExport::class.vars().associateBy { it.name.replace("List", "").lowercase() }, diff --git a/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2MusicDetailImport.kt b/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2MusicDetailImport.kt index d2a0ef8a..7b1cdf25 100644 --- a/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2MusicDetailImport.kt +++ b/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2MusicDetailImport.kt @@ -3,6 +3,7 @@ package icu.samnyan.aqua.net.games.mai2 import ext.* import icu.samnyan.aqua.net.db.AquaUserServices import icu.samnyan.aqua.net.utils.SUCCESS +import icu.samnyan.aqua.sega.general.service.CardService import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserMusicDetail import org.springframework.web.bind.annotation.PostMapping @@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController class Mai2MusicDetailImport( val us: AquaUserServices, val repos: Mai2Repos, + val cardService: CardService, ) { @PostMapping("import-music-detail") suspend fun importMusicDetail(@RP token: String, @RB data: List) = us.jwt.auth(token) { u -> @@ -39,6 +41,7 @@ class Mai2MusicDetailImport( } } repos.userMusicDetail.saveAll(data) + cardService.updateCardTimestamp(card, "mai2") SUCCESS } } diff --git a/src/main/java/icu/samnyan/aqua/net/games/mai2/Maimai2.kt b/src/main/java/icu/samnyan/aqua/net/games/mai2/Maimai2.kt index 1ef5d6c1..2a73eef4 100644 --- a/src/main/java/icu/samnyan/aqua/net/games/mai2/Maimai2.kt +++ b/src/main/java/icu/samnyan/aqua/net/games/mai2/Maimai2.kt @@ -110,6 +110,7 @@ class Maimai2( val user = userDataRepo.findByCard(card) ?: (404 - "User not found") user.userName = newNameFull userDataRepo.save(user) + cardService.updateCardTimestamp(card, "mai2") } mapOf("newName" to newNameFull) } @@ -139,6 +140,7 @@ class Maimai2( loginBonus.add(newBonus) } repos.userLoginBonus.saveAll(loginBonus) + cardService.updateCardTimestamp(card, "mai2") } SUCCESS } @@ -172,6 +174,7 @@ class Maimai2( myRival.propertyValue = myRivalList.joinToString(",") repos.userGeneralData.save(myRival) + cardService.updateCardTimestamp(myCard, "mai2") } SUCCESS } diff --git a/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanController.kt b/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanController.kt index 291758b6..c3957fe8 100644 --- a/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanController.kt @@ -10,6 +10,7 @@ import icu.samnyan.aqua.sega.chusan.model.Chu3Repos import icu.samnyan.aqua.sega.general.GameMusicPopularity import icu.samnyan.aqua.sega.general.MeowApi import icu.samnyan.aqua.sega.general.RequestContext +import icu.samnyan.aqua.sega.general.service.CardService import icu.samnyan.aqua.sega.util.jackson.BasicMapper import icu.samnyan.aqua.sega.util.jackson.StringMapper import icu.samnyan.aqua.spring.Metrics @@ -29,6 +30,7 @@ class ChusanController( val cmMapper: BasicMapper, val db: Chu3Repos, val us: AquaUserServices, + val cardService: CardService, val versionHelper: ChusanVersionHelper, val props: ChusanProps, val pop: GameMusicPopularity, diff --git a/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanCMApis.kt b/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanCMApis.kt index a398f189..4a7d3a1b 100644 --- a/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanCMApis.kt +++ b/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanCMApis.kt @@ -75,6 +75,8 @@ fun ChusanController.cmApiInit() { ) } + u.card?.let { cardService.updateCardTimestamp(it, "chu3") } + mapOf( "returnCode" to 1, "apiName" to "CMUpsertUserGachaApi", @@ -85,13 +87,15 @@ fun ChusanController.cmApiInit() { "CMUpsertUserPrintCancel" { val orderIdList: List = cmMapper.convert>(parsing { data["orderIdList"]!! }) - db.userCardPrintState.saveAll(orderIdList.mapNotNull { + val states = db.userCardPrintState.saveAll(orderIdList.mapNotNull { // TODO: The original code by Eori writes findById but I don't think that is correct... db.userCardPrintState.findById(it)()?.apply { hasCompleted = true } }) + states.firstOrNull()?.user?.card?.let { cardService.updateCardTimestamp(it, "chu3") } + mapOf("returnCode" to 1, "apiName" to "CMUpsertUserPrintCancelApi") } @@ -114,6 +118,8 @@ fun ChusanController.cmApiInit() { db.userCardPrintState.save(this) } + u.card?.let { cardService.updateCardTimestamp(it, "chu3") } + mapOf("returnCode" to 1, "apiName" to "CMUpsertUserPrintSubtractApi") } -} \ No newline at end of file +} diff --git a/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanUpsertApis.kt b/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanUpsertApis.kt index 55b940bf..f1329de2 100644 --- a/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanUpsertApis.kt +++ b/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanUpsertApis.kt @@ -14,6 +14,7 @@ fun ChusanController.upsertApiInit() { charge.user = db.userData.findByCard_ExtId(uid)() ?: (400 - "User not found") charge.id = db.userCharge.findByUser_Card_ExtIdAndChargeId(uid, charge.chargeId)?.id ?: 0 db.userCharge.save(charge) + charge.user.card?.let { cardService.updateCardTimestamp(it, "chu3") } """{"returnCode":"1"}""" } @@ -192,8 +193,10 @@ fun ChusanController.upsertApiInit() { }.also { db.userCMissionProgress.save(it) } } } + + u.card?.let { cardService.updateCardTimestamp(it, "chu3") } } """{"returnCode":1}""" } -} \ No newline at end of file +} diff --git a/src/main/java/icu/samnyan/aqua/sega/general/model/CardTimestamp.kt b/src/main/java/icu/samnyan/aqua/sega/general/model/CardTimestamp.kt new file mode 100644 index 00000000..31df69cd --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/sega/general/model/CardTimestamp.kt @@ -0,0 +1,32 @@ +package icu.samnyan.aqua.sega.general.model + +import ext.Str +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.Instant + +@Entity(name = "SegaCardTimestamp") +@Table(name = "sega_card_timestamp") +class CardTimestamp( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0, + + @Column(nullable = false) + var createdAt: Instant = Instant.now(), + + @Column(nullable = false) + var updatedAt: Instant = Instant.now(), + + var game: Str, + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "card_id", unique = true) + var card: Card? = null, +) + +@Repository +interface CardTimestampRepo : JpaRepository { + fun findByCardIdAndGame(cardId: Long, game: Str): CardTimestamp? +} diff --git a/src/main/java/icu/samnyan/aqua/sega/general/service/CardService.kt b/src/main/java/icu/samnyan/aqua/sega/general/service/CardService.kt index 7845fd18..0ecc4e8a 100644 --- a/src/main/java/icu/samnyan/aqua/sega/general/service/CardService.kt +++ b/src/main/java/icu/samnyan/aqua/sega/general/service/CardService.kt @@ -1,10 +1,16 @@ package icu.samnyan.aqua.sega.general.service +import ext.Bool +import ext.Str import ext.minus +import icu.samnyan.aqua.net.Fedy import icu.samnyan.aqua.net.db.AquaNetUser import icu.samnyan.aqua.sega.general.dao.CardRepository import icu.samnyan.aqua.sega.general.model.Card +import icu.samnyan.aqua.sega.general.model.CardTimestamp +import icu.samnyan.aqua.sega.general.model.CardTimestampRepo import org.springframework.stereotype.Service +import java.time.Instant import java.time.LocalDateTime import java.util.* import java.util.concurrent.ThreadLocalRandom @@ -14,7 +20,7 @@ import kotlin.jvm.optionals.getOrNull * @author samnyan (privateamusement@protonmail.com) */ @Service -class CardService(val cardRepo: CardRepository) +class CardService(val cardRepo: CardRepository, val cardTimestampRepo: CardTimestampRepo, val fedy: Fedy) { /** * Find a card by External ID @@ -106,4 +112,13 @@ class CardService(val cardRepo: CardRepository) } return eid } + + fun getCardTimestamp(card: Card, game: Str, now: Instant = Instant.now()) = + cardTimestampRepo.findByCardIdAndGame(card.id, game) ?: CardTimestamp(game = game, card = card, createdAt = now, updatedAt = now); + + fun updateCardTimestamp(card: Card, game: Str, now: Instant = Instant.now(), resetCreatedAt: Bool = false) { + cardTimestampRepo.save(getCardTimestamp(card, game, now).apply { updatedAt = now } + .apply { if (resetCreatedAt) createdAt = now }); + fedy.onDataUpdated(card.extId, game, resetCreatedAt) + } } diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2Apis.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2Apis.kt index 15505b37..ce67bb4a 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2Apis.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2Apis.kt @@ -143,6 +143,7 @@ fun Maimai2ServletController.initApis() { regionId = region } db.userRegions.save(region) + // d.card?.let { cardService.updateCardTimestamp(it, "mai2") } // TODO: why save regions on login? } res diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt index f4d45bc9..8b83cbab 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt @@ -40,9 +40,6 @@ class Maimai2ServletController( val db: Mai2Repos, val net: Maimai2, ): MeowApi(serialize = { _, resp -> if (resp is String) resp else resp.toJson() }) { - - @Autowired @Lazy lateinit var fedy: Fedy - companion object { private val log = logger() private val empty = listOf() @@ -95,7 +92,6 @@ class Maimai2ServletController( val ctx = RequestContext(req, data.mut) serialize(api, handlers[api]!!(ctx) ?: noop).also { log.info("$token : $api > ${it.truncate(500)}") - if (api == "UpsertUserAllApi") { data["userId"]?.long?.let { fedy.onDataUpdated(it, "mai2", false) } } } } } catch (e: Exception) { diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UploadUserPlaylogHandler.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UploadUserPlaylogHandler.kt index b5824cb5..92a89e5e 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UploadUserPlaylogHandler.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UploadUserPlaylogHandler.kt @@ -6,6 +6,7 @@ import ext.millis import ext.parsing import icu.samnyan.aqua.sega.allnet.TokenChecker import icu.samnyan.aqua.sega.general.BaseHandler +import icu.samnyan.aqua.sega.general.service.CardService import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo import icu.samnyan.aqua.sega.maimai2.model.Mai2UserPlaylogRepo import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPlaylog @@ -22,7 +23,8 @@ import kotlin.jvm.optionals.getOrNull class UploadUserPlaylogHandler( private val userDataRepository: Mai2UserDataRepo, private val playlogRepo: Mai2UserPlaylogRepo, - private val mapper: BasicMapper + private val mapper: BasicMapper, + private val cardService: CardService ) : BaseHandler { data class BacklogEntry(val time: Long, val playlog: Mai2UserPlaylog) companion object { @@ -60,7 +62,10 @@ class UploadUserPlaylogHandler( // Save if the user is registered val u = userDataRepository.findByCardExtId(uid).getOrNull() - if (u != null) playlogRepo.save(playlog.apply { user = u }) + if (u != null) { + playlogRepo.save(playlog.apply { user = u }) + // u.card?.let { cardService.updateCardTimestamp(it, "mai2") } // No need: always followed by an UpsertUserAll + } // If the user hasn't registered (first play), save the playlog to a backlog else { diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UpsertUserAllHandler.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UpsertUserAllHandler.kt index a368e62d..78ffcca6 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UpsertUserAllHandler.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UpsertUserAllHandler.kt @@ -28,7 +28,6 @@ class UpsertUserAllHandler( val cardService: CardService, val repos: Mai2Repos ) : BaseHandler { - fun String.isValidUsername() = isNotBlank() && length <= 8 @Throws(JsonProcessingException::class) @@ -172,6 +171,8 @@ class UpsertUserAllHandler( }) } + u.card?.let { cardService.updateCardTimestamp(it, "mai2") } + return SUCCESS } diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UpsertUserPrintHandler.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UpsertUserPrintHandler.kt index 6db707aa..12e8250f 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UpsertUserPrintHandler.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UpsertUserPrintHandler.kt @@ -5,6 +5,7 @@ import ext.logger import ext.long import ext.parsing import icu.samnyan.aqua.sega.general.BaseHandler +import icu.samnyan.aqua.sega.general.service.CardService import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPrintDetail import icu.samnyan.aqua.sega.util.jackson.BasicMapper @@ -18,6 +19,7 @@ import java.util.concurrent.ThreadLocalRandom class UpsertUserPrintHandler( val mapper: BasicMapper, val db: Mai2Repos, + val cardService: CardService, @param:Value("\${game.cardmaker.card.expiration:15}") val expirationTime: Long, ) : BaseHandler { val log = logger() @@ -43,6 +45,8 @@ class UpsertUserPrintHandler( } db.userPrintDetail.save(userPrint) + userData.card?.let { cardService.updateCardTimestamp(it, "mai2") } + return mapOf( "returnCode" to 1, "orderId" to 0, diff --git a/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiCMApis.kt b/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiCMApis.kt index 614f4013..3cbf992d 100644 --- a/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiCMApis.kt +++ b/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiCMApis.kt @@ -197,6 +197,8 @@ fun OngekiController.cmApiInit() { } } + u.card?.let { cardService.updateCardTimestamp(it, "ongeki") } + null } @@ -250,6 +252,8 @@ fun OngekiController.cmApiInit() { } } + u.card?.let { cardService.updateCardTimestamp(it, "ongeki") } + null } @@ -313,6 +317,8 @@ fun OngekiController.cmApiInit() { } } + u.card?.let { cardService.updateCardTimestamp(it, "ongeki") } + null } @@ -342,4 +348,4 @@ fun OngekiController.cmApiInit() { mapOf("length" to 0, "gameTheaterList" to emptyList(), "registIdList" to emptyList()) } -} \ No newline at end of file +} diff --git a/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiController.kt b/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiController.kt index a03861bc..09ad23bd 100644 --- a/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiController.kt @@ -7,6 +7,7 @@ import icu.samnyan.aqua.sega.allnet.TokenChecker import icu.samnyan.aqua.sega.general.GameMusicPopularity import icu.samnyan.aqua.sega.general.MeowApi import icu.samnyan.aqua.sega.general.RequestContext +import icu.samnyan.aqua.sega.general.service.CardService import icu.samnyan.aqua.sega.util.jackson.BasicMapper import icu.samnyan.aqua.spring.Metrics import jakarta.servlet.http.HttpServletRequest @@ -22,6 +23,7 @@ class OngekiController( val gdb: OngekiGameRepos, val us: AquaUserServices, val pop: GameMusicPopularity, + val cardService: CardService, ): MeowApi({ _, resp -> if (resp is String) resp else mapper.write(resp) }) { val log = logger() diff --git a/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiUpsertAllApi.kt b/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiUpsertAllApi.kt index 2b526adf..74d4c07c 100644 --- a/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiUpsertAllApi.kt +++ b/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiUpsertAllApi.kt @@ -209,6 +209,8 @@ fun OngekiController.initUpsertAll() { id = db.kop.findByUserAndKopIdAndAreaId(u, kopId, areaId)()?.id ?: 0 }) } } + u.card?.let { cardService.updateCardTimestamp(it, "ongeki") } + null } } diff --git a/src/main/resources/db/80/V1000_62__sega_card_timestamp.sql b/src/main/resources/db/80/V1000_62__sega_card_timestamp.sql new file mode 100644 index 00000000..edd9f71d --- /dev/null +++ b/src/main/resources/db/80/V1000_62__sega_card_timestamp.sql @@ -0,0 +1,11 @@ +CREATE TABLE sega_card_timestamp +( + id BIGINT AUTO_INCREMENT NOT NULL, + created_at datetime(3) NOT NULL, + updated_at datetime(3) NOT NULL, + game VARCHAR(255) NOT NULL, + card_id BIGINT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_sega_card_timestamp_on_sega_card FOREIGN KEY (card_id) REFERENCES sega_card (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT unq_sega_card_timestamp_on_game_card UNIQUE (game, card_id) +);