forked from Cookies_Github_mirror/AquaDX
[+] Generalize data import for chusan
This commit is contained in:
@@ -4,7 +4,7 @@ import ext.*
|
||||
import icu.samnyan.aqua.net.components.JWT
|
||||
import icu.samnyan.aqua.net.db.AquaUserServices
|
||||
import icu.samnyan.aqua.net.games.GenericUserDataRepo
|
||||
import icu.samnyan.aqua.net.games.IGenericUserData
|
||||
import icu.samnyan.aqua.net.games.IUserData
|
||||
import icu.samnyan.aqua.net.utils.AquaNetProps
|
||||
import icu.samnyan.aqua.net.utils.SUCCESS
|
||||
import icu.samnyan.aqua.sega.chusan.model.Chu3UserDataRepo
|
||||
@@ -116,7 +116,7 @@ class CardController(
|
||||
*
|
||||
* Assumption: The card is already linked to the user.
|
||||
*/
|
||||
suspend fun <T : IGenericUserData> migrateCard(repo: GenericUserDataRepo<T>, card: Card): Bool
|
||||
suspend fun <T : IUserData> migrateCard(repo: GenericUserDataRepo<T>, card: Card): Bool
|
||||
{
|
||||
// Check if data already exists in the user's ghost card
|
||||
async { repo.findByCard(card.aquaUser!!.ghostCard) }?.let {
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.slf4j.LoggerFactory
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
abstract class GameApiController<T : IGenericUserData>(name: String, userDataClass: KClass<T>) {
|
||||
abstract class GameApiController<T : IUserData>(name: String, userDataClass: KClass<T>) {
|
||||
val musicMapping = resJson<Map<String, GenericMusicMeta>>("/meta/$name/music.json")
|
||||
?.mapKeys { it.key.toInt() } ?: emptyMap()
|
||||
val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package icu.samnyan.aqua.net.games
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import ext.*
|
||||
import java.lang.reflect.Field
|
||||
import icu.samnyan.aqua.net.db.AquaNetUser
|
||||
import icu.samnyan.aqua.net.db.AquaUserServices
|
||||
import icu.samnyan.aqua.net.utils.AquaNetProps
|
||||
import icu.samnyan.aqua.net.utils.SUCCESS
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.repository.NoRepositoryBean
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
import java.util.*
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KMutableProperty1
|
||||
import kotlin.reflect.KProperty1
|
||||
|
||||
// Import class with renaming
|
||||
data class ImportClass<T : Any>(
|
||||
@@ -17,18 +23,114 @@ data class ImportClass<T : Any>(
|
||||
val name: String = type.simpleName!!.removePrefix("Mai2").lowercase()
|
||||
)
|
||||
|
||||
abstract class ImportController<T: Any>(
|
||||
val exportFields: Map<String, KMutableProperty1<T, Any>>,
|
||||
val renameTable: Map<String, ImportClass<*>>
|
||||
interface IUserEntity<UserModel: IUserData> {
|
||||
var id: Long
|
||||
var user: UserModel
|
||||
}
|
||||
|
||||
interface IExportClass<UserModel: IUserData> {
|
||||
var gameId: String
|
||||
var userData: UserModel
|
||||
}
|
||||
|
||||
@NoRepositoryBean
|
||||
interface IUserRepo<UserModel, ThisModel>: JpaRepository<ThisModel, Long> {
|
||||
fun findByUser(user: UserModel): List<ThisModel>
|
||||
fun findSingleByUser(user: UserModel): Optional<ThisModel>
|
||||
}
|
||||
|
||||
/**
|
||||
* Import controller for a game
|
||||
*
|
||||
* @param game: 4-letter Game ID
|
||||
* @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
|
||||
* @param artemisRenames: Mapping of Artemis table names to import classes
|
||||
*/
|
||||
abstract class ImportController<ExportModel: IExportClass<UserModel>, UserModel: IUserData>(
|
||||
val game: String,
|
||||
val exportClass: KClass<ExportModel>,
|
||||
val exportFields: Map<String, Var<ExportModel, Any>>,
|
||||
val exportRepos: Map<Var<ExportModel, Any>, IUserRepo<UserModel, *>>,
|
||||
val artemisRenames: Map<String, ImportClass<*>>,
|
||||
) {
|
||||
abstract fun createEmpty(): T
|
||||
abstract fun createEmpty(): ExportModel
|
||||
abstract val userDataRepo: GenericUserDataRepo<UserModel>
|
||||
|
||||
@Autowired lateinit var us: AquaUserServices
|
||||
@Autowired lateinit var netProps: AquaNetProps
|
||||
@Autowired lateinit var transManager: PlatformTransactionManager
|
||||
val trans by lazy { TransactionTemplate(transManager) }
|
||||
|
||||
init {
|
||||
renameTable.values.forEach {
|
||||
artemisRenames.values.forEach {
|
||||
if (it.name !in exportFields) error("Code error! Export fields incomplete: missing ${it.name}")
|
||||
}
|
||||
}
|
||||
|
||||
val listRepos = exportRepos.filter { it.key returns List::class }
|
||||
val singleRepos = exportRepos.filter { !(it.key returns List::class) }
|
||||
|
||||
fun export(u: AquaNetUser) = createEmpty().apply {
|
||||
gameId = game
|
||||
userData = userDataRepo.findByCard(u.ghostCard) ?: (404 - "User not found")
|
||||
exportRepos.forEach { (f, u) ->
|
||||
if (f returns List::class) f.set(this, u.findByUser(userData))
|
||||
else u.findSingleByUser(userData)()?.let { f.set(this, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@API("export")
|
||||
fun exportUserData(@RP token: Str) = us.jwt.auth(token) { u ->
|
||||
log.info("Exporting user data for ${u.auId}")
|
||||
export(u)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@API("import")
|
||||
fun importUserData(@RP token: Str, @RB json: Str) = us.jwt.auth(token) { u ->
|
||||
val export = json.parseJackson(exportClass.java)
|
||||
if (!export.gameId.equals(game, true)) 400 - "Invalid game ID"
|
||||
|
||||
val lists = listRepos.toList().associate { (f, r) -> r to f.get(export) as List<IUserEntity<UserModel>> }.vNotNull()
|
||||
val singles = singleRepos.toList().associate { (f, r) -> r to f.get(export) as IUserEntity<UserModel> }.vNotNull()
|
||||
|
||||
// Validate new user data
|
||||
// Check that all ids are 0 (this should be true since all ids are @JsonIgnore)
|
||||
if (export.userData.id != 0L) 400 - "User ID must be 0"
|
||||
lists.values.flatten().forEach { if (it.id != 0L) 400 - "ID must be 0" }
|
||||
singles.values.forEach { if (it.id != 0L) 400 - "ID must be 0" }
|
||||
|
||||
// Set user card
|
||||
export.userData.card = u.ghostCard
|
||||
|
||||
// Check existing data
|
||||
userDataRepo.findByCard(u.ghostCard)?.also { gu ->
|
||||
// Store a backup of the old data
|
||||
val fl = "mai2-backup-${u.auId}-${LocalDateTime.now().urlSafeStr()}.json"
|
||||
(Path(netProps.importBackupPath) / fl).writeText(export(u).toJson())
|
||||
|
||||
// Delete the old data (After migration v1000.7, all user-linked entities have ON DELETE CASCADE)
|
||||
log.info("Mai2 Import: Deleting old data for user ${u.auId}")
|
||||
userDataRepo.delete(gu)
|
||||
userDataRepo.flush()
|
||||
}
|
||||
|
||||
trans.execute {
|
||||
// Insert new data
|
||||
val nu = userDataRepo.save(export.userData)
|
||||
// Set user fields
|
||||
lists.values.flatten().forEach { it.user = nu }
|
||||
singles.values.forEach { it.user = nu }
|
||||
// Save new data
|
||||
singles.forEach { (repo, single) -> (repo as IUserRepo<UserModel, Any>).save(single) }
|
||||
lists.forEach { (repo, list) -> (repo as IUserRepo<UserModel, Any>).saveAll(list) }
|
||||
}
|
||||
|
||||
SUCCESS
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an artemis SQL dump file and return Aqua JSON
|
||||
*/
|
||||
@@ -52,7 +154,7 @@ abstract class ImportController<T: Any>(
|
||||
// For each insert statement, we will try to parse the values
|
||||
statements.forEachIndexed fi@{ i, insert ->
|
||||
// Try to map tables
|
||||
val tb = renameTable[insert.table] ?: return@fi warn("Unknown table ${insert.table} in insert $i")
|
||||
val tb = artemisRenames[insert.table] ?: return@fi warn("Unknown table ${insert.table} in insert $i")
|
||||
val field = exportFields[tb.name]!!
|
||||
val obj = tb.mapTo(insert.mapping)
|
||||
|
||||
@@ -77,43 +179,7 @@ abstract class ImportController<T: Any>(
|
||||
|
||||
return JACKSON_ARTEMIS.convertValue(dict, type.java)
|
||||
}
|
||||
|
||||
val log = logger()
|
||||
}
|
||||
}
|
||||
|
||||
// Read SQL dump and convert to dictionary
|
||||
val insertPattern = """INSERT INTO\s+(\w+)\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\);""".toRegex()
|
||||
data class SqlInsert(val table: String, val mapping: Map<String, String>)
|
||||
fun String.asSqlInsert(): SqlInsert {
|
||||
val match = insertPattern.matchEntire(this) ?: error("Does not match insert pattern")
|
||||
val (table, rawCols, rawVals) = match.destructured
|
||||
val cols = rawCols.split(',').map { it.trim(' ', '"') }
|
||||
|
||||
// Parse values with proper quote handling
|
||||
val vals = mutableListOf<String>()
|
||||
var startI = 0
|
||||
var insideQuote = false
|
||||
rawVals.forEachIndexed { i, c ->
|
||||
if (c == ',' && !insideQuote) {
|
||||
vals.add(rawVals.substring(startI, i).trim(' ', '"'))
|
||||
startI = i + 1
|
||||
} else if (c == '"') insideQuote = !insideQuote
|
||||
}
|
||||
|
||||
assert(cols.size == vals.size) { "Column and value count mismatch" }
|
||||
return SqlInsert(table, cols.zip(vals).toMap())
|
||||
}
|
||||
|
||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "UNCHECKED_CAST")
|
||||
val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object : JsonDeserializer<List<Integer>>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
|
||||
try {
|
||||
val text = parser.text.trim('[', ']')
|
||||
if (text.isEmpty()) emptyList()
|
||||
else text.split(',').map { it.trim().toInt() } as List<Integer>
|
||||
} catch (e: Exception) {
|
||||
400 - "Invalid list value ${parser.text}: $e" }
|
||||
})
|
||||
|
||||
val JACKSON_ARTEMIS = JACKSON.copy().apply {
|
||||
registerModule(JSON_INT_LIST_STR)
|
||||
}
|
||||
|
||||
46
src/main/java/icu/samnyan/aqua/net/games/ImportHelper.kt
Normal file
46
src/main/java/icu/samnyan/aqua/net/games/ImportHelper.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package icu.samnyan.aqua.net.games
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import ext.JACKSON
|
||||
import ext.minus
|
||||
|
||||
// Read SQL dump and convert to dictionary
|
||||
val insertPattern = """INSERT INTO\s+(\w+)\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\);""".toRegex()
|
||||
data class SqlInsert(val table: String, val mapping: Map<String, String>)
|
||||
fun String.asSqlInsert(): SqlInsert {
|
||||
val match = insertPattern.matchEntire(this) ?: error("Does not match insert pattern")
|
||||
val (table, rawCols, rawVals) = match.destructured
|
||||
val cols = rawCols.split(',').map { it.trim(' ', '"') }
|
||||
|
||||
// Parse values with proper quote handling
|
||||
val vals = mutableListOf<String>()
|
||||
var startI = 0
|
||||
var insideQuote = false
|
||||
rawVals.forEachIndexed { i, c ->
|
||||
if (c == ',' && !insideQuote) {
|
||||
vals.add(rawVals.substring(startI, i).trim(' ', '"'))
|
||||
startI = i + 1
|
||||
} else if (c == '"') insideQuote = !insideQuote
|
||||
}
|
||||
|
||||
assert(cols.size == vals.size) { "Column and value count mismatch" }
|
||||
return SqlInsert(table, cols.zip(vals).toMap())
|
||||
}
|
||||
|
||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "UNCHECKED_CAST")
|
||||
val JSON_INT_LIST_STR = SimpleModule().addDeserializer(List::class.java, object : JsonDeserializer<List<Integer>>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
|
||||
try {
|
||||
val text = parser.text.trim('[', ']')
|
||||
if (text.isEmpty()) emptyList()
|
||||
else text.split(',').map { it.trim().toInt() } as List<Integer>
|
||||
} catch (e: Exception) {
|
||||
400 - "Invalid list value ${parser.text}: $e" }
|
||||
})
|
||||
|
||||
val JACKSON_ARTEMIS = JACKSON.copy().apply {
|
||||
registerModule(JSON_INT_LIST_STR)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package icu.samnyan.aqua.net.games
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize
|
||||
import ext.JACKSON
|
||||
@@ -16,8 +15,6 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.NoRepositoryBean
|
||||
import java.util.*
|
||||
import kotlin.reflect.full.memberProperties
|
||||
import kotlin.reflect.jvm.isAccessible
|
||||
|
||||
data class TrendOut(val date: String, val rating: Int, val plays: Int)
|
||||
|
||||
@@ -81,7 +78,8 @@ data class GenericItemMeta(
|
||||
)
|
||||
|
||||
// Here are some interfaces to generalize across multiple games
|
||||
interface IGenericUserData {
|
||||
interface IUserData {
|
||||
val id: Long
|
||||
val userName: String
|
||||
val playerRating: Int
|
||||
val highestRating: Int
|
||||
@@ -124,7 +122,7 @@ open class UserDataEntity : BaseEntity() {
|
||||
}
|
||||
|
||||
@NoRepositoryBean
|
||||
interface GenericUserDataRepo<T : IGenericUserData> : JpaRepository<T, Long> {
|
||||
interface GenericUserDataRepo<T : IUserData> : JpaRepository<T, Long> {
|
||||
fun findByCard(card: Card): T?
|
||||
fun findByCard_ExtId(extId: Long): Optional<T>
|
||||
@Query("select count(*) from #{#entityName} where playerRating > :rating")
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
package icu.samnyan.aqua.net.games.chu3
|
||||
|
||||
import ext.API
|
||||
import ext.returns
|
||||
import ext.vars
|
||||
import icu.samnyan.aqua.api.model.resp.sega.chuni.v2.external.Chu3DataExport
|
||||
import icu.samnyan.aqua.net.games.ImportClass
|
||||
import icu.samnyan.aqua.net.games.ImportController
|
||||
import icu.samnyan.aqua.sega.chusan.model.Chu3Repos
|
||||
import icu.samnyan.aqua.sega.chusan.model.Chu3UserLinked
|
||||
import icu.samnyan.aqua.sega.chusan.model.userdata.*
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import kotlin.reflect.full.declaredMembers
|
||||
|
||||
@RestController
|
||||
@API("api/v2/game/chu3")
|
||||
class Chu3Import : ImportController<Chu3DataExport>(
|
||||
class Chu3Import(
|
||||
val repos: Chu3Repos,
|
||||
) : ImportController<Chu3DataExport, Chu3UserData>(
|
||||
"SDHD", Chu3DataExport::class,
|
||||
exportFields = Chu3DataExport::class.vars().associateBy {
|
||||
var name = it.name
|
||||
if (name == "userMapList") name = "userMapAreaList"
|
||||
name.replace("List", "").lowercase()
|
||||
},
|
||||
renameTable = mapOf(
|
||||
exportRepos = Chu3DataExport::class.vars()
|
||||
.filter { f -> f.name !in setOf("gameId", "userData") }
|
||||
.associateWith { Chu3Repos::class.declaredMembers
|
||||
.filter { f -> f returns Chu3UserLinked::class }
|
||||
.firstOrNull { f -> f.name == it.name || f.name == it.name.replace("List", "") }
|
||||
?.call(repos) as Chu3UserLinked<*>? ?: error("No matching field found for ${it.name}")
|
||||
},
|
||||
artemisRenames = mapOf(
|
||||
"chuni_item_character" to ImportClass(UserCharacter::class),
|
||||
"chuni_item_duel" to ImportClass(UserDuel::class),
|
||||
"chuni_item_item" to ImportClass(UserItem::class, mapOf("isValid" to "valid")),
|
||||
@@ -24,7 +38,7 @@ class Chu3Import : ImportController<Chu3DataExport>(
|
||||
"chuni_item_map_area" to ImportClass(UserMapArea::class),
|
||||
"chuni_profile_activity" to ImportClass(UserActivity::class, mapOf("activityId" to "id")),
|
||||
"chuni_profile_charge" to ImportClass(UserCharge::class),
|
||||
"chuni_profile_data" to ImportClass(UserData::class, mapOf("user" to null, "version" to null, "isNetMember" to null)),
|
||||
"chuni_profile_data" to ImportClass(Chu3UserData::class, mapOf("user" to null, "version" to null, "isNetMember" to null)),
|
||||
"chuni_profile_option" to ImportClass(UserGameOption::class, mapOf("version" to null)),
|
||||
"chuni_score_best" to ImportClass(UserMusicDetail::class),
|
||||
"chuni_score_playlog" to ImportClass(UserPlaylog::class),
|
||||
@@ -35,4 +49,5 @@ class Chu3Import : ImportController<Chu3DataExport>(
|
||||
)
|
||||
) {
|
||||
override fun createEmpty() = Chu3DataExport()
|
||||
override val userDataRepo = repos.userData
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import icu.samnyan.aqua.net.db.AquaUserServices
|
||||
import icu.samnyan.aqua.net.games.*
|
||||
import icu.samnyan.aqua.net.utils.*
|
||||
import icu.samnyan.aqua.sega.chusan.model.*
|
||||
import icu.samnyan.aqua.sega.chusan.model.userdata.UserData
|
||||
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserData
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@@ -18,7 +18,7 @@ class Chusan(
|
||||
override val playlogRepo: Chu3UserPlaylogRepo,
|
||||
override val userDataRepo: Chu3UserDataRepo,
|
||||
val userGeneralDataRepository: Chu3UserGeneralDataRepo,
|
||||
): GameApiController<UserData>("chu3", UserData::class) {
|
||||
): GameApiController<Chu3UserData>("chu3", Chu3UserData::class) {
|
||||
override suspend fun trend(@RP username: Str): List<TrendOut> = us.cardByName(username) { card ->
|
||||
findTrend(playlogRepo.findByUserCardExtId(card.extId)
|
||||
.map { TrendLog(it.playDate.toString(), it.playerRating) })
|
||||
@@ -27,7 +27,7 @@ class Chusan(
|
||||
|
||||
// Only show > AAA rank
|
||||
override val shownRanks = chu3Scores.filter { it.first >= 95 * 10000 }
|
||||
override val settableFields: Map<String, (UserData, String) -> Unit> by lazy { mapOf(
|
||||
override val settableFields: Map<String, (Chu3UserData, String) -> Unit> by lazy { mapOf(
|
||||
"userName" to { u, v -> u.setUserName(v)
|
||||
if (!v.all { it in USERNAME_CHARS }) { 400 - "Invalid character in username" }
|
||||
},
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
package icu.samnyan.aqua.net.games.mai2
|
||||
|
||||
import ext.API
|
||||
import ext.returns
|
||||
import ext.vars
|
||||
import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.Maimai2DataExport
|
||||
import icu.samnyan.aqua.net.games.ImportClass
|
||||
import icu.samnyan.aqua.net.games.ImportController
|
||||
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos
|
||||
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserLinked
|
||||
import icu.samnyan.aqua.sega.maimai2.model.userdata.*
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import kotlin.reflect.full.declaredMembers
|
||||
|
||||
@RestController
|
||||
@API("api/v2/game/mai2")
|
||||
class Mai2Import : ImportController<Maimai2DataExport>(
|
||||
class Mai2Import(
|
||||
val repos: Mai2Repos,
|
||||
) : ImportController<Maimai2DataExport, Mai2UserDetail>(
|
||||
"SDEZ", Maimai2DataExport::class,
|
||||
exportFields = Maimai2DataExport::class.vars().associateBy {
|
||||
it.name.replace("List", "").lowercase()
|
||||
},
|
||||
renameTable = mapOf(
|
||||
exportRepos = Maimai2DataExport::class.vars()
|
||||
.filter { f -> f.name !in setOf("gameId", "userData") }
|
||||
.associateWith { Mai2Repos::class.declaredMembers
|
||||
.filter { f -> f returns Mai2UserLinked::class }
|
||||
.firstOrNull { f -> f.name == it.name || f.name == it.name.replace("List", "") }
|
||||
?.call(repos) as Mai2UserLinked<*>? ?: error("No matching field found for ${it.name}")
|
||||
},
|
||||
artemisRenames = mapOf(
|
||||
"mai2_item_character" to ImportClass(Mai2UserCharacter::class),
|
||||
"mai2_item_charge" to ImportClass(Mai2UserCharge::class),
|
||||
"mai2_item_friend_season_ranking" to ImportClass(Mai2UserFriendSeasonRanking::class),
|
||||
@@ -30,10 +44,8 @@ class Mai2Import : ImportController<Maimai2DataExport>(
|
||||
"mai2_profile_option" to ImportClass(Mai2UserOption::class, mapOf("version" to null)),
|
||||
"mai2_score_best" to ImportClass(Mai2UserMusicDetail::class),
|
||||
"mai2_score_course" to ImportClass(Mai2UserCourse::class),
|
||||
// "mai2_profile_ghost" to ImportClass(UserGhost::class),
|
||||
// "mai2_profile_rating" to ImportClass(UserRating::class),
|
||||
// "mai2_profile_region" to ImportClass(UserRegion::class),
|
||||
)
|
||||
) {
|
||||
override fun createEmpty() = Maimai2DataExport()
|
||||
override val userDataRepo = repos.userData
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
package icu.samnyan.aqua.net.games.mai2
|
||||
|
||||
import ext.*
|
||||
import icu.samnyan.aqua.api.model.resp.sega.maimai2.external.Maimai2DataExport
|
||||
import icu.samnyan.aqua.net.db.AquaNetUser
|
||||
import icu.samnyan.aqua.net.db.AquaUserServices
|
||||
import icu.samnyan.aqua.net.games.*
|
||||
import icu.samnyan.aqua.net.utils.*
|
||||
import icu.samnyan.aqua.sega.maimai2.model.*
|
||||
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserDetail
|
||||
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserEntity
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import icu.samnyan.aqua.sega.maimai2.model.userdata.*
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.time.LocalDateTime
|
||||
import java.util.*
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.reflect.KMutableProperty1
|
||||
import kotlin.reflect.full.declaredMembers
|
||||
|
||||
@RestController
|
||||
@API("api/v2/game/mai2")
|
||||
@@ -25,13 +15,8 @@ class Maimai2(
|
||||
override val us: AquaUserServices,
|
||||
override val playlogRepo: Mai2UserPlaylogRepo,
|
||||
override val userDataRepo: Mai2UserDataRepo,
|
||||
val userGeneralDataRepository: Mai2UserGeneralDataRepo,
|
||||
val repos: Mai2Repos,
|
||||
val netProps: AquaNetProps,
|
||||
transManager: PlatformTransactionManager
|
||||
): GameApiController<Mai2UserDetail>("mai2", Mai2UserDetail::class) {
|
||||
val trans = TransactionTemplate(transManager)
|
||||
|
||||
override suspend fun trend(@RP username: Str): List<TrendOut> = us.cardByName(username) { card ->
|
||||
findTrend(playlogRepo.findByUserCardExtId(card.extId)
|
||||
.map { TrendLog(it.playDate, it.afterRating) })
|
||||
@@ -46,7 +31,7 @@ class Maimai2(
|
||||
) }
|
||||
|
||||
override suspend fun userSummary(@RP username: Str) = us.cardByName(username) { card ->
|
||||
val extra = userGeneralDataRepository.findByUser_Card_ExtId(card.extId)
|
||||
val extra = repos.userGeneralData.findByUser_Card_ExtId(card.extId)
|
||||
.associate { it.propertyKey to it.propertyValue }
|
||||
|
||||
val ratingComposition = mapOf(
|
||||
@@ -56,72 +41,4 @@ class Maimai2(
|
||||
|
||||
genericUserSummary(card, ratingComposition)
|
||||
}
|
||||
|
||||
// Use reflection to get all properties in Mai2Repos with matching names in Maimai2DataExport
|
||||
val exportFields: Map<KMutableProperty1<Maimai2DataExport, Any>, UserLinked<*>> = Maimai2DataExport::class.vars()
|
||||
.filter { f -> f.name !in setOf("gameId", "userData") }
|
||||
.associateWith { Mai2Repos::class.declaredMembers
|
||||
.filter { f -> f returns UserLinked::class }
|
||||
.firstOrNull { f -> f.name == it.name || f.name == it.name.replace("List", "") }
|
||||
?.call(repos) as UserLinked<*>? ?: error("No matching field found for ${it.name}")
|
||||
}
|
||||
|
||||
val listFields = exportFields.filter { it.key returns List::class }
|
||||
val singleFields = exportFields.filter { !(it.key returns List::class) }
|
||||
|
||||
fun export(u: AquaNetUser) = Maimai2DataExport().apply {
|
||||
gameId = "SDEZ"
|
||||
userData = repos.userData.findByCard(u.ghostCard) ?: (404 - "User not found")
|
||||
exportFields.forEach { (f, u) ->
|
||||
if (f returns List::class) f.set(this, u.findByUser(userData))
|
||||
else u.findSingleByUser(userData)()?.let { f.set(this, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@API("export")
|
||||
fun exportAllUserData(@RP token: Str) = us.jwt.auth(token) { u -> export(u) }
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@API("import")
|
||||
fun importUserData(@RP token: Str, @RB json: Str) = us.jwt.auth(token) { u ->
|
||||
val export = json.parseJackson<Maimai2DataExport>()
|
||||
if (!export.gameId.equals("SDEZ", true)) 400 - "Invalid game ID"
|
||||
|
||||
val lists = listFields.toList().associate { (f, r) -> r to f.get(export) as List<Mai2UserEntity> }.vNotNull()
|
||||
val singles = singleFields.toList().associate { (f, r) -> r to f.get(export) as Mai2UserEntity }.vNotNull()
|
||||
|
||||
// Validate new user data
|
||||
// Check that all ids are 0 (this should be true since all ids are @JsonIgnore)
|
||||
if (export.userData.id != 0L) 400 - "User ID must be 0"
|
||||
lists.values.flatten().forEach { if (it.id != 0L) 400 - "ID must be 0" }
|
||||
singles.values.forEach { if (it.id != 0L) 400 - "ID must be 0" }
|
||||
|
||||
// Set user card
|
||||
export.userData.card = u.ghostCard
|
||||
|
||||
// Check existing data
|
||||
repos.userData.findByCard(u.ghostCard)?.also { gu ->
|
||||
// Store a backup of the old data
|
||||
val fl = "mai2-backup-${u.auId}-${LocalDateTime.now().urlSafeStr()}.json"
|
||||
(Path(netProps.importBackupPath) / fl).writeText(export(u).toJson())
|
||||
|
||||
// Delete the old data (After migration v1000.7, all user-linked entities have ON DELETE CASCADE)
|
||||
logger.info("Mai2 Import: Deleting old data for user ${u.auId}")
|
||||
repos.userData.delete(gu)
|
||||
repos.userData.flush()
|
||||
}
|
||||
|
||||
trans.execute {
|
||||
// Insert new data
|
||||
val nu = repos.userData.save(export.userData)
|
||||
// Set user fields
|
||||
lists.values.flatten().forEach { it.user = nu }
|
||||
singles.values.forEach { it.user = nu }
|
||||
// Save new data
|
||||
singles.forEach { (repo, single) -> (repo as UserLinked<Any>).save(single) }
|
||||
lists.forEach { (repo, list) -> (repo as UserLinked<Any>).saveAll(list) }
|
||||
}
|
||||
|
||||
SUCCESS
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user