[+] Card Timestamp

This commit is contained in:
Menci
2025-12-10 04:27:54 +08:00
committed by Azalea
parent 5eee6505f9
commit 7c72348016
25 changed files with 158 additions and 63 deletions

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.withContext
import org.apache.tika.Tika import org.apache.tika.Tika
import org.apache.tika.mime.MimeTypes import org.apache.tika.mime.MimeTypes
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationContext
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity.BodyBuilder import org.springframework.http.ResponseEntity.BodyBuilder
@@ -264,3 +265,6 @@ val <S> Pair<*, S>.r get() = component2()
val Query.exec get() = resultList.map { (it as Array<*>).toList() } val Query.exec get() = resultList.map { (it as Array<*>).toList() }
fun List<List<Any?>>.numCsv(vararg head: Str) = head.joinToString(",") + "\n" + fun List<List<Any?>>.numCsv(vararg head: Str) = head.joinToString(",") + "\n" +
joinToString("\n") { it.joinToString(",") } joinToString("\n") { it.joinToString(",") }
// DI
inline fun <reified T> ApplicationContext.lazy() = lazy { getBean(T::class.java) }

View File

@@ -21,15 +21,15 @@ val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, obj
else -> 400 - "Invalid boolean value ${parser.text}" else -> 400 - "Invalid boolean value ${parser.text}"
} }
}) })
val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<java.time.LocalDateTime>() { val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<LocalDateTime>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) = override fun deserialize(parser: JsonParser, context: DeserializationContext) =
// First try standard formats via asDateTime() method // 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) // Try maimai2 format (yyyy-MM-dd HH:mm:ss.0)
LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0")) LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
} catch (e: Exception) { } catch (e: Exception) {
400 - "Invalid date time value ${parser.text}" 400 - "Invalid date time value ${parser.text}"
} } }
}) })
val JACKSON = jacksonObjectMapper().apply { val JACKSON = jacksonObjectMapper().apply {
setSerializationInclusion(JsonInclude.Include.NON_NULL) setSerializationInclusion(JsonInclude.Include.NON_NULL)

View File

@@ -101,8 +101,7 @@ class CardController(
val games = migrate.split(',') val games = migrate.split(',')
cardGameService.migrate(card, games) cardGameService.migrate(card, games)
fedy.onCardLinked(card.luid, oldExtId = card.extId, ghostExtId = u.ghostCard.extId, fedy.onCardLinked(card.luid, oldExtId = card.extId, ghostExtId = u.ghostCard.extId, games)
games.map { Fedy.getGameName(it) }.filterNotNull())
log.info("Net /card/link : Linked card ${card.id} to user ${u.username} and migrated data to ${games.joinToString()}") 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 diva: icu.samnyan.aqua.sega.diva.dao.userdata.PlayerProfileRepository,
val safety: AquaNetSafetyService, val safety: AquaNetSafetyService,
val cardRepo: CardRepository, val cardRepo: CardRepository,
val em: EntityManager val em: EntityManager,
val cardService: CardService
) { ) {
companion object { companion object {
val log = logger() val log = logger()
@@ -225,7 +225,9 @@ class CardGameService(
val remainingGames = dataRepos.keys.toMutableSet() val remainingGames = dataRepos.keys.toMutableSet()
games.forEach { game -> games.forEach { game ->
val dataRepo = dataRepos[game] ?: return@forEach 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) remainingGames.remove(game)
} }
// For remaining games, orphan the data by assigning them to a dummy card // For remaining games, orphan the data by assigning them to a dummy card

View File

@@ -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.general.service.CardService
import icu.samnyan.aqua.sega.ongeki.OgkUserDataRepo import icu.samnyan.aqua.sega.ongeki.OgkUserDataRepo
import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo
import org.springframework.context.ApplicationContext
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.time.Instant
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import kotlin.io.path.getLastModifiedTime import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.isRegularFile import kotlin.io.path.isRegularFile
@@ -41,7 +43,7 @@ class FedyProps {
var remote: String = "" var remote: String = ""
} }
data class UserProfilePicture(val url: Str, val lastUpdatedMs: Long) data class UserProfilePicture(val url: Str, val updatedAtMs: Long)
data class UserBasicInfo( data class UserBasicInfo(
val auId: Long, val ghostExtId: Long, val registrationTimeMs: Long, val auId: Long, val ghostExtId: Long, val registrationTimeMs: Long,
val username: Str, val displayName: Str, val email: Str, val passwordHash: Str, val profileBio: Str, 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"]) @API("/api/v2/fedy", consumes = ["multipart/form-data"])
class Fedy( class Fedy(
val jwt: JWT, val jwt: JWT,
val us: AquaUserServices,
val emailProps: EmailProperties, val emailProps: EmailProperties,
val cardRepo: CardRepository, val cardRepo: CardRepository,
val cardService: CardService,
val mai2Import: Mai2Import,
val mai2UserDataRepo: Mai2UserDataRepo, val mai2UserDataRepo: Mai2UserDataRepo,
val mai2UploadUserPlaylog: Mai2UploadUserPlaylogHandler,
val mai2UpsertUserAll: Mai2UpsertUserAllHandler,
val chu3UserDataRepo: Chu3UserDataRepo, val chu3UserDataRepo: Chu3UserDataRepo,
val ongekiUserDataRepo: OgkUserDataRepo, val ongekiUserDataRepo: OgkUserDataRepo,
val waccaUserDataRepo: WcUserRepo, val waccaUserDataRepo: WcUserRepo,
val props: FedyProps, val props: FedyProps,
val paths: PathProps, val paths: PathProps,
val transactionManager: PlatformTransactionManager val transactionManager: PlatformTransactionManager,
ctx: ApplicationContext
) { ) {
val us by ctx.lazy<AquaUserServices>()
val cardService by ctx.lazy<CardService>()
val mai2Import by ctx.lazy<Mai2Import>()
val mai2UploadUserPlaylog by ctx.lazy<Mai2UploadUserPlaylogHandler>()
val mai2UpsertUserAll by ctx.lazy<Mai2UpsertUserAllHandler>()
val transaction by lazy { TransactionTemplate(transactionManager) } val transaction by lazy { TransactionTemplate(transactionManager) }
private fun Str.checkKey() { private fun Str.checkKey() {
@@ -157,25 +161,30 @@ class Fedy(
?.let { paths.aquaNetPortrait.path() / it }?.takeIf { it.isRegularFile() } ?.let { paths.aquaNetPortrait.path() / it }?.takeIf { it.isRegularFile() }
?.let { UserProfilePicture( ?.let { UserProfilePicture(
url = "/uploads/net/portrait/${profilePicture}", 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 DataPullReq(val extId: Long, val game: Str, val createdAtMs: Long, val updatedAtMs: Long, val exportOptions: ExportOptions)
data class DataPullRes(val error: FedyErr? = null, val result: Any? = null) 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") @API("/data/pull")
fun handleDataPull(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: DataPullReq): DataPullRes = handleFedy(key) { fun handleDataPull(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: DataPullReq): DataPullRes = handleFedy(key) {
val card = cardRepo.findByExtId(req.extId).orElse(null) val card = cardRepo.findByExtId(req.extId).orElse(null)
?: (404 - "Card with extId ${req.extId} not found") ?: (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) { DataPullRes(result = DataPullResult(data = when (req.game) {
"mai2" -> mai2Import.export(card, req.exportOptions) "mai2" -> mai2Import.export(card, exportOptions)
else -> 406 - "Unsupported game" else -> 406 - "Unsupported game"
}) }, createdAtMs = cardTimestamp.createdAt.toEpochMilli(), updatedAtMs = cardTimestamp.updatedAt.toEpochMilli(), isRebased = isRebased))
} caught { DataPullRes(error = it) } } 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") @Suppress("UNCHECKED_CAST")
@API("/data/push") @API("/data/push")
fun handleDataPush(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: DataPushReq): Any = handleFedy(key) { fun handleDataPush(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: DataPushReq): Any = handleFedy(key) {
@@ -188,6 +197,7 @@ class Fedy(
repo.flush() repo.flush()
} }
} }
val card = cardRepo.findByExtId(extId).orElse(null) ?: (404 - "Card not found")
transaction.execute { when (req.game) { transaction.execute { when (req.game) {
"mai2" -> { "mai2" -> {
if (req.removeOldData) { removeOldData(mai2UserDataRepo) } if (req.removeOldData) { removeOldData(mai2UserDataRepo) }
@@ -198,7 +208,7 @@ class Fedy(
} }
else -> 406 - "Unsupported game" else -> 406 - "Unsupported game"
} } } }
cardService.updateCardTimestamp(card, req.game, now = Instant.ofEpochMilli(req.updatedAtMs), resetCreatedAt = req.removeOldData)
SUCCESS SUCCESS
} }
@@ -221,9 +231,9 @@ class Fedy(
var pairedCard = cardService.tryLookup(req.pairedLuid)?.maybeGhost() var pairedCard = cardService.tryLookup(req.pairedLuid)?.maybeGhost()
if (pairedCard?.extId != card?.extId) { if (pairedCard?.extId != card?.extId) {
var isGhost = pairedCard?.isGhost == true var isGhost = pairedCard?.isGhost == true
var isFresh = pairedCard != null && isCardFresh(pairedCard) var isNonFresh = pairedCard != null && !isCardFresh(pairedCard)
if (isGhost && isFresh) isPairedLuidDiverged = true if (isGhost || isNonFresh) isPairedLuidDiverged = true
else if (!isGhost && card?.isGhost == true) { else if (card?.isGhost == true) {
// Ensure paired card is linked, if the main card is linked // 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 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) } 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. // Apparently existing cards could possibly be fresh and never used in any game. Treat them as new cards.
private fun isCardFresh(c: Card): Bool { private fun isCardFresh(c: Card): Bool {
fun <T : IUserData> checkForGame(repo: GenericUserDataRepo<T>, card: Card): Bool = repo.findByCard(card) == null fun <T : IUserData> checkForGame(repo: GenericUserDataRepo<T>, card: Card): Bool = repo.findByCard(card) != null
return when { return when {
checkForGame(mai2UserDataRepo, c) -> false checkForGame(mai2UserDataRepo, c) -> false
checkForGame(chu3UserDataRepo, c) -> false checkForGame(chu3UserDataRepo, c) -> false
@@ -346,17 +356,5 @@ class Fedy(
const val REQ_PART = "request" const val REQ_PART = "request"
const val PFP_PART = "profilePicture" const val PFP_PART = "profilePicture"
val log = logger() 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
}
} }
} }

View File

@@ -3,22 +3,15 @@ package icu.samnyan.aqua.net
import ext.* import ext.*
import icu.samnyan.aqua.net.components.* import icu.samnyan.aqua.net.components.*
import icu.samnyan.aqua.net.db.* 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.PathProps
import icu.samnyan.aqua.net.utils.SUCCESS import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.sega.general.dao.CardRepository 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 jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory 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.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime
import kotlin.io.path.writeBytes import kotlin.io.path.writeBytes
@RestController @RestController
@@ -28,6 +21,7 @@ class UserRegistrar(
val hasher: PasswordEncoder, val hasher: PasswordEncoder,
val turnstileService: TurnstileService, val turnstileService: TurnstileService,
val emailService: EmailService, val emailService: EmailService,
val fedy: Fedy,
val geoIP: GeoIP, val geoIP: GeoIP,
val jwt: JWT, val jwt: JWT,
val confirmationRepo: EmailConfirmationRepo, val confirmationRepo: EmailConfirmationRepo,
@@ -37,7 +31,6 @@ class UserRegistrar(
val emailProps: EmailProperties, val emailProps: EmailProperties,
final val paths: PathProps final val paths: PathProps
) { ) {
@Autowired @Lazy lateinit var fedy: Fedy
val portraitPath = paths.aquaNetPortrait.path() val portraitPath = paths.aquaNetPortrait.path()
companion object { companion object {

View File

@@ -5,6 +5,7 @@ import icu.samnyan.aqua.net.BotProps
import icu.samnyan.aqua.net.db.AquaUserServices import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.utils.SUCCESS import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.sega.general.model.Card import icu.samnyan.aqua.sega.general.model.Card
import icu.samnyan.aqua.sega.general.service.CardService
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@@ -29,6 +30,8 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
abstract val settableFields: Map<String, (T, String) -> Unit> abstract val settableFields: Map<String, (T, String) -> Unit>
open val gettableFields: Set<String> = setOf() open val gettableFields: Set<String> = setOf()
@Autowired lateinit var cardService: CardService
@API("trend") @API("trend")
abstract suspend fun trend(@RP username: String): List<TrendOut> abstract suspend fun trend(@RP username: String): List<TrendOut>
@API("user-summary") @API("user-summary")
@@ -138,6 +141,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
val user = async { userDataRepo.findByCard(u.ghostCard) } ?: (404 - "User not found") val user = async { userDataRepo.findByCard(u.ghostCard) } ?: (404 - "User not found")
prop(user, value) prop(user, value)
async { userDataRepo.save(user) } async { userDataRepo.save(user) }
cardService.updateCardTimestamp(u.ghostCard, name)
SUCCESS SUCCESS
} }
} }

View File

@@ -7,6 +7,7 @@ import icu.samnyan.aqua.net.Fedy
import icu.samnyan.aqua.net.utils.AquaNetProps import icu.samnyan.aqua.net.utils.AquaNetProps
import icu.samnyan.aqua.net.utils.SUCCESS import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.sega.general.model.Card 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.beans.factory.annotation.Autowired
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.NoRepositoryBean import org.springframework.data.repository.NoRepositoryBean
@@ -17,7 +18,6 @@ import java.util.*
import kotlin.io.path.Path import kotlin.io.path.Path
import kotlin.io.path.writeText import kotlin.io.path.writeText
import kotlin.reflect.KClass import kotlin.reflect.KClass
import org.springframework.context.annotation.Lazy
data class ExportOptions( data class ExportOptions(
val playlogAfter: String? = null val playlogAfter: String? = null
@@ -50,6 +50,7 @@ interface IUserRepo<UserModel, ThisModel>: JpaRepository<ThisModel, Long> {
* Import controller for a game * Import controller for a game
* *
* @param game: 4-letter Game ID * @param game: 4-letter Game ID
* @param gameName: mai2/chu3/ongeki
* @param exportFields: Mapping of type names to variables in the export model * @param exportFields: Mapping of type names to variables in the export model
* (e.g. "Mai2UserCharacter" -> Mai2DataExport::userCharacterList) * (e.g. "Mai2UserCharacter" -> Mai2DataExport::userCharacterList)
* @param exportRepos: Mapping of variables to repositories that can be used to find the data * @param exportRepos: Mapping of variables to repositories that can be used to find the data
@@ -57,6 +58,7 @@ interface IUserRepo<UserModel, ThisModel>: JpaRepository<ThisModel, Long> {
*/ */
abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel: IUserData>( abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel: IUserData>(
val game: String, val game: String,
val gameName: String,
val exportClass: KClass<ExportModel>, val exportClass: KClass<ExportModel>,
val exportFields: Map<String, Var<ExportModel, Any>>, val exportFields: Map<String, Var<ExportModel, Any>>,
val exportRepos: Map<Var<ExportModel, Any>, IUserRepo<UserModel, *>>, val exportRepos: Map<Var<ExportModel, Any>, IUserRepo<UserModel, *>>,
@@ -71,7 +73,7 @@ abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel:
@Autowired lateinit var netProps: AquaNetProps @Autowired lateinit var netProps: AquaNetProps
@Autowired lateinit var transManager: PlatformTransactionManager @Autowired lateinit var transManager: PlatformTransactionManager
val trans by lazy { TransactionTemplate(transManager) } val trans by lazy { TransactionTemplate(transManager) }
@Autowired @Lazy lateinit var fedy: Fedy @Autowired lateinit var cardService: CardService
init { init {
artemisRenames.values.forEach { artemisRenames.values.forEach {
@@ -148,7 +150,7 @@ abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel:
} }
} }
Fedy.getGameName(game)?.let { fedy.onDataUpdated(u.ghostCard.extId, it, true) } cardService.updateCardTimestamp(u.ghostCard, gameName, resetCreatedAt = true)
SUCCESS SUCCESS
} }

View File

@@ -17,7 +17,7 @@ import kotlin.reflect.full.declaredMembers
class Chu3Import( class Chu3Import(
val repos: Chu3Repos, val repos: Chu3Repos,
) : ImportController<Chu3DataExport, Chu3UserData>( ) : ImportController<Chu3DataExport, Chu3UserData>(
"SDHD", Chu3DataExport::class, "SDHD", "chu3", Chu3DataExport::class,
exportFields = Chu3DataExport::class.vars().associateBy { exportFields = Chu3DataExport::class.vars().associateBy {
it.name.replace("List", "").lowercase() it.name.replace("List", "").lowercase()
}, },

View File

@@ -19,7 +19,7 @@ import kotlin.reflect.full.declaredMembers
class Mai2Import( class Mai2Import(
val repos: Mai2Repos, val repos: Mai2Repos,
) : ImportController<Maimai2DataExport, Mai2UserDetail>( ) : ImportController<Maimai2DataExport, Mai2UserDetail>(
"SDEZ", Maimai2DataExport::class, "SDEZ", "mai2", Maimai2DataExport::class,
exportFields = Maimai2DataExport::class.vars().associateBy { exportFields = Maimai2DataExport::class.vars().associateBy {
it.name.replace("List", "").lowercase() it.name.replace("List", "").lowercase()
}, },

View File

@@ -3,6 +3,7 @@ package icu.samnyan.aqua.net.games.mai2
import ext.* import ext.*
import icu.samnyan.aqua.net.db.AquaUserServices import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.utils.SUCCESS 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.Mai2Repos
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserMusicDetail import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserMusicDetail
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
@@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController
class Mai2MusicDetailImport( class Mai2MusicDetailImport(
val us: AquaUserServices, val us: AquaUserServices,
val repos: Mai2Repos, val repos: Mai2Repos,
val cardService: CardService,
) { ) {
@PostMapping("import-music-detail") @PostMapping("import-music-detail")
suspend fun importMusicDetail(@RP token: String, @RB data: List<Mai2UserMusicDetail>) = us.jwt.auth(token) { u -> suspend fun importMusicDetail(@RP token: String, @RB data: List<Mai2UserMusicDetail>) = us.jwt.auth(token) { u ->
@@ -39,6 +41,7 @@ class Mai2MusicDetailImport(
} }
} }
repos.userMusicDetail.saveAll(data) repos.userMusicDetail.saveAll(data)
cardService.updateCardTimestamp(card, "mai2")
SUCCESS SUCCESS
} }
} }

View File

@@ -110,6 +110,7 @@ class Maimai2(
val user = userDataRepo.findByCard(card) ?: (404 - "User not found") val user = userDataRepo.findByCard(card) ?: (404 - "User not found")
user.userName = newNameFull user.userName = newNameFull
userDataRepo.save(user) userDataRepo.save(user)
cardService.updateCardTimestamp(card, "mai2")
} }
mapOf("newName" to newNameFull) mapOf("newName" to newNameFull)
} }
@@ -139,6 +140,7 @@ class Maimai2(
loginBonus.add(newBonus) loginBonus.add(newBonus)
} }
repos.userLoginBonus.saveAll(loginBonus) repos.userLoginBonus.saveAll(loginBonus)
cardService.updateCardTimestamp(card, "mai2")
} }
SUCCESS SUCCESS
} }
@@ -172,6 +174,7 @@ class Maimai2(
myRival.propertyValue = myRivalList.joinToString(",") myRival.propertyValue = myRivalList.joinToString(",")
repos.userGeneralData.save(myRival) repos.userGeneralData.save(myRival)
cardService.updateCardTimestamp(myCard, "mai2")
} }
SUCCESS SUCCESS
} }

View File

@@ -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.GameMusicPopularity
import icu.samnyan.aqua.sega.general.MeowApi import icu.samnyan.aqua.sega.general.MeowApi
import icu.samnyan.aqua.sega.general.RequestContext 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.BasicMapper
import icu.samnyan.aqua.sega.util.jackson.StringMapper import icu.samnyan.aqua.sega.util.jackson.StringMapper
import icu.samnyan.aqua.spring.Metrics import icu.samnyan.aqua.spring.Metrics
@@ -29,6 +30,7 @@ class ChusanController(
val cmMapper: BasicMapper, val cmMapper: BasicMapper,
val db: Chu3Repos, val db: Chu3Repos,
val us: AquaUserServices, val us: AquaUserServices,
val cardService: CardService,
val versionHelper: ChusanVersionHelper, val versionHelper: ChusanVersionHelper,
val props: ChusanProps, val props: ChusanProps,
val pop: GameMusicPopularity, val pop: GameMusicPopularity,

View File

@@ -75,6 +75,8 @@ fun ChusanController.cmApiInit() {
) )
} }
u.card?.let { cardService.updateCardTimestamp(it, "chu3") }
mapOf( mapOf(
"returnCode" to 1, "returnCode" to 1,
"apiName" to "CMUpsertUserGachaApi", "apiName" to "CMUpsertUserGachaApi",
@@ -85,13 +87,15 @@ fun ChusanController.cmApiInit() {
"CMUpsertUserPrintCancel" { "CMUpsertUserPrintCancel" {
val orderIdList: List<Long> = cmMapper.convert<List<Long>>(parsing { data["orderIdList"]!! }) val orderIdList: List<Long> = cmMapper.convert<List<Long>>(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... // TODO: The original code by Eori writes findById but I don't think that is correct...
db.userCardPrintState.findById(it)()?.apply { db.userCardPrintState.findById(it)()?.apply {
hasCompleted = true hasCompleted = true
} }
}) })
states.firstOrNull()?.user?.card?.let { cardService.updateCardTimestamp(it, "chu3") }
mapOf("returnCode" to 1, "apiName" to "CMUpsertUserPrintCancelApi") mapOf("returnCode" to 1, "apiName" to "CMUpsertUserPrintCancelApi")
} }
@@ -114,6 +118,8 @@ fun ChusanController.cmApiInit() {
db.userCardPrintState.save(this) db.userCardPrintState.save(this)
} }
u.card?.let { cardService.updateCardTimestamp(it, "chu3") }
mapOf("returnCode" to 1, "apiName" to "CMUpsertUserPrintSubtractApi") mapOf("returnCode" to 1, "apiName" to "CMUpsertUserPrintSubtractApi")
} }
} }

View File

@@ -14,6 +14,7 @@ fun ChusanController.upsertApiInit() {
charge.user = db.userData.findByCard_ExtId(uid)() ?: (400 - "User not found") charge.user = db.userData.findByCard_ExtId(uid)() ?: (400 - "User not found")
charge.id = db.userCharge.findByUser_Card_ExtIdAndChargeId(uid, charge.chargeId)?.id ?: 0 charge.id = db.userCharge.findByUser_Card_ExtIdAndChargeId(uid, charge.chargeId)?.id ?: 0
db.userCharge.save(charge) db.userCharge.save(charge)
charge.user.card?.let { cardService.updateCardTimestamp(it, "chu3") }
"""{"returnCode":"1"}""" """{"returnCode":"1"}"""
} }
@@ -192,8 +193,10 @@ fun ChusanController.upsertApiInit() {
}.also { db.userCMissionProgress.save(it) } }.also { db.userCMissionProgress.save(it) }
} }
} }
u.card?.let { cardService.updateCardTimestamp(it, "chu3") }
} }
"""{"returnCode":1}""" """{"returnCode":1}"""
} }
} }

View File

@@ -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<CardTimestamp, Long> {
fun findByCardIdAndGame(cardId: Long, game: Str): CardTimestamp?
}

View File

@@ -1,10 +1,16 @@
package icu.samnyan.aqua.sega.general.service package icu.samnyan.aqua.sega.general.service
import ext.Bool
import ext.Str
import ext.minus import ext.minus
import icu.samnyan.aqua.net.Fedy
import icu.samnyan.aqua.net.db.AquaNetUser import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.sega.general.dao.CardRepository import icu.samnyan.aqua.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.model.Card 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 org.springframework.stereotype.Service
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.* import java.util.*
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
@@ -14,7 +20,7 @@ import kotlin.jvm.optionals.getOrNull
* @author samnyan (privateamusement@protonmail.com) * @author samnyan (privateamusement@protonmail.com)
*/ */
@Service @Service
class CardService(val cardRepo: CardRepository) class CardService(val cardRepo: CardRepository, val cardTimestampRepo: CardTimestampRepo, val fedy: Fedy)
{ {
/** /**
* Find a card by External ID * Find a card by External ID
@@ -106,4 +112,13 @@ class CardService(val cardRepo: CardRepository)
} }
return eid 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)
}
} }

View File

@@ -143,6 +143,7 @@ fun Maimai2ServletController.initApis() {
regionId = region regionId = region
} }
db.userRegions.save(region) db.userRegions.save(region)
// d.card?.let { cardService.updateCardTimestamp(it, "mai2") } // TODO: why save regions on login?
} }
res res

View File

@@ -40,9 +40,6 @@ class Maimai2ServletController(
val db: Mai2Repos, val db: Mai2Repos,
val net: Maimai2, val net: Maimai2,
): MeowApi(serialize = { _, resp -> if (resp is String) resp else resp.toJson() }) { ): MeowApi(serialize = { _, resp -> if (resp is String) resp else resp.toJson() }) {
@Autowired @Lazy lateinit var fedy: Fedy
companion object { companion object {
private val log = logger() private val log = logger()
private val empty = listOf<Any>() private val empty = listOf<Any>()
@@ -95,7 +92,6 @@ class Maimai2ServletController(
val ctx = RequestContext(req, data.mut) val ctx = RequestContext(req, data.mut)
serialize(api, handlers[api]!!(ctx) ?: noop).also { serialize(api, handlers[api]!!(ctx) ?: noop).also {
log.info("$token : $api > ${it.truncate(500)}") log.info("$token : $api > ${it.truncate(500)}")
if (api == "UpsertUserAllApi") { data["userId"]?.long?.let { fedy.onDataUpdated(it, "mai2", false) } }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -6,6 +6,7 @@ import ext.millis
import ext.parsing import ext.parsing
import icu.samnyan.aqua.sega.allnet.TokenChecker import icu.samnyan.aqua.sega.allnet.TokenChecker
import icu.samnyan.aqua.sega.general.BaseHandler 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.Mai2UserDataRepo
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserPlaylogRepo import icu.samnyan.aqua.sega.maimai2.model.Mai2UserPlaylogRepo
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPlaylog import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPlaylog
@@ -22,7 +23,8 @@ import kotlin.jvm.optionals.getOrNull
class UploadUserPlaylogHandler( class UploadUserPlaylogHandler(
private val userDataRepository: Mai2UserDataRepo, private val userDataRepository: Mai2UserDataRepo,
private val playlogRepo: Mai2UserPlaylogRepo, private val playlogRepo: Mai2UserPlaylogRepo,
private val mapper: BasicMapper private val mapper: BasicMapper,
private val cardService: CardService
) : BaseHandler { ) : BaseHandler {
data class BacklogEntry(val time: Long, val playlog: Mai2UserPlaylog) data class BacklogEntry(val time: Long, val playlog: Mai2UserPlaylog)
companion object { companion object {
@@ -60,7 +62,10 @@ class UploadUserPlaylogHandler(
// Save if the user is registered // Save if the user is registered
val u = userDataRepository.findByCardExtId(uid).getOrNull() 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 // If the user hasn't registered (first play), save the playlog to a backlog
else { else {

View File

@@ -28,7 +28,6 @@ class UpsertUserAllHandler(
val cardService: CardService, val cardService: CardService,
val repos: Mai2Repos val repos: Mai2Repos
) : BaseHandler { ) : BaseHandler {
fun String.isValidUsername() = isNotBlank() && length <= 8 fun String.isValidUsername() = isNotBlank() && length <= 8
@Throws(JsonProcessingException::class) @Throws(JsonProcessingException::class)
@@ -172,6 +171,8 @@ class UpsertUserAllHandler(
}) })
} }
u.card?.let { cardService.updateCardTimestamp(it, "mai2") }
return SUCCESS return SUCCESS
} }

View File

@@ -5,6 +5,7 @@ import ext.logger
import ext.long import ext.long
import ext.parsing import ext.parsing
import icu.samnyan.aqua.sega.general.BaseHandler 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.Mai2Repos
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPrintDetail import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPrintDetail
import icu.samnyan.aqua.sega.util.jackson.BasicMapper import icu.samnyan.aqua.sega.util.jackson.BasicMapper
@@ -18,6 +19,7 @@ import java.util.concurrent.ThreadLocalRandom
class UpsertUserPrintHandler( class UpsertUserPrintHandler(
val mapper: BasicMapper, val mapper: BasicMapper,
val db: Mai2Repos, val db: Mai2Repos,
val cardService: CardService,
@param:Value("\${game.cardmaker.card.expiration:15}") val expirationTime: Long, @param:Value("\${game.cardmaker.card.expiration:15}") val expirationTime: Long,
) : BaseHandler { ) : BaseHandler {
val log = logger() val log = logger()
@@ -43,6 +45,8 @@ class UpsertUserPrintHandler(
} }
db.userPrintDetail.save(userPrint) db.userPrintDetail.save(userPrint)
userData.card?.let { cardService.updateCardTimestamp(it, "mai2") }
return mapOf( return mapOf(
"returnCode" to 1, "returnCode" to 1,
"orderId" to 0, "orderId" to 0,

View File

@@ -197,6 +197,8 @@ fun OngekiController.cmApiInit() {
} }
} }
u.card?.let { cardService.updateCardTimestamp(it, "ongeki") }
null null
} }
@@ -250,6 +252,8 @@ fun OngekiController.cmApiInit() {
} }
} }
u.card?.let { cardService.updateCardTimestamp(it, "ongeki") }
null null
} }
@@ -313,6 +317,8 @@ fun OngekiController.cmApiInit() {
} }
} }
u.card?.let { cardService.updateCardTimestamp(it, "ongeki") }
null null
} }
@@ -342,4 +348,4 @@ fun OngekiController.cmApiInit() {
mapOf("length" to 0, "gameTheaterList" to emptyList<Any>(), "registIdList" to emptyList<Any>()) mapOf("length" to 0, "gameTheaterList" to emptyList<Any>(), "registIdList" to emptyList<Any>())
} }
} }

View File

@@ -7,6 +7,7 @@ import icu.samnyan.aqua.sega.allnet.TokenChecker
import icu.samnyan.aqua.sega.general.GameMusicPopularity import icu.samnyan.aqua.sega.general.GameMusicPopularity
import icu.samnyan.aqua.sega.general.MeowApi import icu.samnyan.aqua.sega.general.MeowApi
import icu.samnyan.aqua.sega.general.RequestContext 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.BasicMapper
import icu.samnyan.aqua.spring.Metrics import icu.samnyan.aqua.spring.Metrics
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
@@ -22,6 +23,7 @@ class OngekiController(
val gdb: OngekiGameRepos, val gdb: OngekiGameRepos,
val us: AquaUserServices, val us: AquaUserServices,
val pop: GameMusicPopularity, val pop: GameMusicPopularity,
val cardService: CardService,
): MeowApi({ _, resp -> if (resp is String) resp else mapper.write(resp) }) { ): MeowApi({ _, resp -> if (resp is String) resp else mapper.write(resp) }) {
val log = logger() val log = logger()

View File

@@ -209,6 +209,8 @@ fun OngekiController.initUpsertAll() {
id = db.kop.findByUserAndKopIdAndAreaId(u, kopId, areaId)()?.id ?: 0 }) } id = db.kop.findByUserAndKopIdAndAreaId(u, kopId, areaId)()?.id ?: 0 }) }
} }
u.card?.let { cardService.updateCardTimestamp(it, "ongeki") }
null null
} }
} }

View File

@@ -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)
);