[+] Delete account (#234)

This commit is contained in:
Azalea
2026-08-02 22:16:42 +08:00
committed by GitHub
parent d34aab37b2
commit 05e613d667
38 changed files with 562 additions and 69 deletions
@@ -1,10 +1,10 @@
<script lang="ts">
import { slide, fade } from "svelte/transition";
import type { AquaNetUser } from "../../libs/generalTypes";
import { CARD, USER } from "../../libs/sdk";
import type { AquaNetUser, ConfirmProps } from "../../libs/generalTypes";
import { CARD, GAME, USER, type ExportGameName } from "../../libs/sdk";
import StatusOverlays from "../../components/StatusOverlays.svelte";
import Icon from "@iconify/svelte";
import { pfp } from "../../libs/ui";
import { download, pfp } from "../../libs/ui";
import { t, ts } from "../../libs/i18n";
import Cropper from "svelte-easy-crop";
@@ -12,6 +12,7 @@
let error: string;
let submitting = ""
let loading = false;
let confirm: ConfirmProps | null = null;
const profileFields = [
[ 'displayName', t('settings.profile.name') ],
@@ -97,11 +98,48 @@
location.href = "/";
}
function requestAccountDeletion() {
if (submitting) return;
confirm = {
title: t('settings.profile.delete-confirm-title'),
message: t('settings.profile.delete-confirm-message'),
dangerous: true,
cancel: () => confirm = null,
confirm: deleteAccount,
};
}
async function deleteAccount() {
if (submitting) return;
submitting = "deleteAccount";
try {
const gameSummary = await CARD.userGames(me.username);
const games = (['mai2', 'chu3', 'ongeki', 'wacca', 'diva'] as ExportGameName[])
.filter(game => gameSummary[game] !== null);
const exports = await Promise.all(games.map(async game => ({
game,
data: await GAME.export(game),
})));
exports.forEach(({game, data}) => download(
JSON.stringify(data, null, 2),
`AquaDX_${game}_export_${me.username}.json`,
));
await USER.deleteAccount();
localStorage.removeItem("token");
location.href = "/";
} catch (e) {
error = (e as Error).message;
submitting = "";
}
}
const passwordAction = (node: HTMLInputElement, whether: boolean) => {
if (whether) node.type = 'password'
}
</script>
<StatusOverlays {error} loading={!!submitting || loading} />
<StatusOverlays bind:confirm={confirm} {error} loading={!!submitting || loading} />
{#if !submitting && !error && me}
<div>
<div class="fields">
@@ -171,6 +209,16 @@
<button on:click={logOut}>{ts(`settings.profile.logout`)}</button>
</div>
</div>
<div class="field danger-zone m-t">
<span class="name">{t('settings.profile.delete-title')}</span>
<span class="desc">{t('settings.profile.delete-description')}</span>
<div>
<button class="error" on:click={requestAccountDeletion}>
<Icon icon="tabler:trash-x-filled" />
{t('settings.profile.delete')}
</button>
</div>
</div>
</div>
</div>
{/if}
@@ -234,6 +282,19 @@
object-fit: cover
aspect-ratio: 1
.danger-zone
margin-top: 1.5rem
padding-top: 1.5rem
border-top: 1px solid vars.$ov-lighter
.desc
opacity: 0.6
button
display: flex
align-items: center
gap: 0.5rem
.cropper-container
+5
View File
@@ -274,6 +274,11 @@ export const EN_REF_SETTINGS = {
'settings.profile.unset': 'Unset',
'settings.profile.logout': 'Log out',
'settings.profile.unchanged': 'Unchanged',
'settings.profile.delete-title': 'Delete account',
'settings.profile.delete-description': 'Download a backup, then permanently delete your account and all associated game data.',
'settings.profile.delete': 'Delete account',
'settings.profile.delete-confirm-title': 'Permanently delete your account?',
'settings.profile.delete-confirm-message': 'A backup of all game data will be downloaded first. After that, your account and all associated game data will be permanently deleted. This cannot be undone.',
'settings.export': 'Export Player Data',
'settings.batchManualExport': "Export in Batch Manual (for Tachi)",
'settings.cabNotice': "These settings only apply for your keychip. If you're playing on someone else's setup, please ask them to change these settings.",
+5
View File
@@ -287,6 +287,11 @@ const zhSettings: typeof EN_REF_SETTINGS = {
'settings.profile.unset': '未设置',
'settings.profile.logout': '登出',
'settings.profile.unchanged': '未更改',
'settings.profile.delete-title': '删除账户',
'settings.profile.delete-description': '下载备份后,永久删除您的账户及所有关联游戏数据。',
'settings.profile.delete': '删除账户',
'settings.profile.delete-confirm-title': '永久删除您的账户?',
'settings.profile.delete-confirm-message': '系统会先下载所有游戏数据的备份,然后永久删除您的账户及所有关联游戏数据。此操作无法撤销。',
'settings.export': '导出玩家数据',
'settings.batchManualExport': "导出 Batch Manual 格式(用于 Tachi",
'settings.cabNotice': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置',
+5 -1
View File
@@ -14,6 +14,8 @@ import type {
} from './generalTypes'
import type { GameName } from './scoring'
export type ExportGameName = GameName | 'diva'
interface ExtReqInit extends RequestInit {
params?: { [index: string]: string }
json?: any
@@ -203,6 +205,8 @@ export const USER = {
ensureLoggedIn,
changeRegion: (regionId: number) =>
post('/api/v2/user/change-region', { regionId }),
deleteAccount: () =>
post('/api/v2/user/delete-account'),
}
export const USERBOX = {
@@ -236,7 +240,7 @@ export const GAME = {
post(`/api/v2/game/${game}/ranking`, typeof page === "number" ? { page } : {}),
changeName: (game: GameName, newName: string): Promise<{ newName: string }> =>
post(`/api/v2/game/${game}/change-name`, { newName }),
export: (game: GameName): Promise<Record<string, any>> =>
export: (game: ExportGameName): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/export`),
import: (game: GameName, data: any): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/import`, {}, { json: data }),
@@ -2,25 +2,40 @@ package icu.samnyan.aqua.net
import ext.*
import icu.samnyan.aqua.net.components.*
import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.db.EmailConfirmationRepo
import icu.samnyan.aqua.net.db.ResetPasswordRepo
import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.db.EmailConfirmationRepo
import icu.samnyan.aqua.net.db.ResetPasswordRepo
import icu.samnyan.aqua.net.utils.AquaNetProps
import icu.samnyan.aqua.net.utils.PathProps
import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.sega.allnet.UserKeychip
import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
import icu.samnyan.aqua.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.model.CardStatus
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
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 kotlin.io.path.writeBytes
import icu.samnyan.aqua.sega.allnet.UserKeychip
import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
import icu.samnyan.aqua.sega.chusan.model.Chu3Repos
import icu.samnyan.aqua.sega.diva.DivaRepos
import icu.samnyan.aqua.sega.diva.model.db.userdata.PlayerProfile
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.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.ongeki.OngekiUserRepos
import icu.samnyan.aqua.sega.wacca.model.db.WaccaRepos
import jakarta.servlet.http.HttpServletRequest
import jakarta.transaction.Transactional
import org.slf4j.LoggerFactory
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import java.nio.file.Files
import java.nio.file.Path
import java.time.Instant
import kotlin.io.path.Path
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.io.path.name
import kotlin.io.path.writeBytes
@RestController
@API("/api/v2/user")
@@ -39,7 +54,8 @@ class UserRegistrar(
val emailProps: EmailProperties,
val userKeychipRepo: UserKeychipRepo,
val aquaNetProps: AquaNetProps,
final val paths: PathProps
final val paths: PathProps,
val accountDeletion: AccountDeletionService,
) {
val portraitPath = paths.aquaNetPortrait.path()
@@ -327,14 +343,105 @@ class UserRegistrar(
@API("/change-region")
@Doc("Change the region of the user.", "Success message")
suspend fun changeRegion(@RP token: Str, @RP regionId: Str) = jwt.auth(token) { u ->
suspend fun changeRegion(@RP token: Str, @RP regionId: Str) = jwt.auth(token) { u ->
// Check if the region is valid (between 1 and 47)
val r = regionId.toIntOrNull() ?: (400 - "Invalid region")
if (r !in 1..47) 400 - "Invalid region"
async {
userRepo.save(u.apply { region = r.toString() })
}
SUCCESS
}
}
SUCCESS
}
@API("/delete-account")
@Doc("Permanently delete the current user's account and all associated game data.", "Success message")
fun deleteAccount(@RP token: Str) = jwt.auth(token) { user ->
val cleanup = accountDeletion.deleteDatabaseData(user.auId)
accountDeletion.deleteUploadedFiles(cleanup)
SUCCESS
}
}
data class AccountFileCleanup(
val profilePicture: String?,
val gameUserId: Long,
val divaScreenshots: List<String>,
)
@Service
class AccountDeletionService(
val userRepo: AquaNetUserRepo,
val cardRepo: CardRepository,
val mai2: Mai2Repos,
val chu3: Chu3Repos,
val ongeki: OngekiUserRepos,
val wacca: WaccaRepos,
val diva: DivaRepos,
val paths: PathProps,
) {
companion object {
val log = logger()
}
@Transactional
fun deleteDatabaseData(auId: Long): AccountFileCleanup {
val user = userRepo.findByAuId(auId) ?: (404 - "User not found")
val ghostCard = user.ghostCard
val divaScreenshots = deleteDiva(ghostCard)
mai2.userData.findByCard(ghostCard)?.let { mai2.userData.delete(it) }
chu3.userData.findByCard(ghostCard)?.let { chu3.userData.delete(it) }
ongeki.data.findByCard(ghostCard)?.let { ongeki.data.delete(it) }
wacca.user.findByCard(ghostCard)?.let { wacca.user.delete(it) }
chu3.userLoginBonus.deleteAll(chu3.userLoginBonus.findByUser(ghostCard.extId.toInt()))
// Keep Hibernate's managed state consistent with the database-level ON DELETE SET NULL.
// Hibernate 7 rejects the flush if a managed card still references the deleted user.
val linkedCards = cardRepo.findAllByAquaUserAuId(auId)
linkedCards.forEach { it.aquaUser = null }
cardRepo.saveAllAndFlush(linkedCards)
userRepo.delete(user)
userRepo.flush()
cardRepo.delete(ghostCard)
cardRepo.flush()
log.info("Deleted account and game data for user $auId")
return AccountFileCleanup(user.profilePicture, ghostCard.extId, divaScreenshots)
}
private fun deleteDiva(card: Card): List<String> {
val profile: PlayerProfile = diva.profile.findByPdId(card.extId).orElse(null) ?: return emptyList()
val screenshots = diva.screenShot.findByPdId(profile)
diva.profile.delete(profile)
return screenshots.map { it.fileName }
}
fun deleteUploadedFiles(cleanup: AccountFileCleanup) {
try {
cleanup.profilePicture
?.takeIf { it.isNotBlank() && Path(it).name == it }
?.let { (Path(paths.aquaNetPortrait) / it).deleteIfExists() }
deleteFilesStartingWith(Path(paths.mai2Portrait), "${cleanup.gameUserId}-")
deleteFilesStartingWith(Path(paths.mai2Plays), "${cleanup.gameUserId}-")
deleteFilesStartingWith(Path("data/tmp"), "${cleanup.gameUserId}-")
cleanup.divaScreenshots
.filter { Path(it).name == it }
.forEach { (Path("data") / it).deleteIfExists() }
} catch (e: Exception) {
log.error("Failed to delete one or more uploaded files for deleted user ${cleanup.gameUserId}", e)
}
}
private fun deleteFilesStartingWith(directory: Path, prefix: String) {
if (!directory.exists()) return
Files.list(directory).use { files ->
files.filter { it.fileName.toString().startsWith(prefix) }
.forEach { it.deleteIfExists() }
}
}
}
@@ -1,7 +1,9 @@
package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.JpaRepository
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
@@ -20,9 +22,10 @@ class EmailConfirmation(
@Column(nullable = false)
var createdAt: Instant = Instant.now(),
// Linking to the AquaNetUser
@ManyToOne
@JoinColumn(name = "auId", referencedColumnName = "auId")
// Linking to the AquaNetUser
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser = AquaNetUser()
) : Serializable
@@ -30,4 +33,4 @@ class EmailConfirmation(
interface EmailConfirmationRepo : JpaRepository<EmailConfirmation, Long> {
fun findByToken(token: String): EmailConfirmation?
fun findByAquaNetUserAuId(auId: Long): List<EmailConfirmation>
}
}
@@ -2,6 +2,8 @@ package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
@@ -22,6 +24,7 @@ class ResetPassword(
// Linking to the AquaNetUser
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser = AquaNetUser()
) : Serializable
@@ -30,4 +33,4 @@ class ResetPassword(
interface ResetPasswordRepo : JpaRepository<ResetPassword, Long> {
fun findByToken(token: String): ResetPassword?
fun findByAquaNetUserAuId(auId: Long): List<ResetPassword>
}
}
@@ -2,6 +2,8 @@ package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
@@ -22,6 +24,7 @@ class SessionToken(
// Linking to the AquaNetUser
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser = AquaNetUser()
) : Serializable
@@ -69,23 +69,23 @@ class AquaNetUser(
// Email confirmation
var emailConfirmed: Boolean = false,
@OneToOne(cascade = [CascadeType.ALL])
@OneToOne(cascade = [CascadeType.PERSIST, CascadeType.MERGE])
@JoinColumn(name = "ghostCard", unique = true, nullable = false)
var ghostCard: Card = Card(),
// One user can have multiple cards
@OneToMany(mappedBy = "aquaUser", cascade = [CascadeType.ALL])
@OneToMany(mappedBy = "aquaUser", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
var cards: MutableList<Card> = mutableListOf(),
// Each user can have multiple keychips (if the user owns cabinets)
@JsonIgnore
@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL])
var keychips: MutableList<UserKeychip> = mutableListOf(),
@OneToMany(mappedBy = "user", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
var keychips: MutableList<UserKeychip> = mutableListOf(),
// Each user's keychip can have multiple sessions
@JsonIgnore
@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL])
var keychipSessions: MutableList<KeychipSession> = mutableListOf(),
@OneToMany(mappedBy = "user", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
var keychipSessions: MutableList<KeychipSession> = mutableListOf(),
@Column(nullable = false)
var canModifyKeychips: Boolean = false,
@@ -3,6 +3,7 @@ package icu.samnyan.aqua.net.games.chu3
import ext.API
import ext.returns
import ext.vars
import icu.samnyan.aqua.net.games.ExportOptions
import icu.samnyan.aqua.net.games.IExportClass
import icu.samnyan.aqua.net.games.ImportClass
import icu.samnyan.aqua.net.games.ImportController
@@ -12,6 +13,7 @@ import icu.samnyan.aqua.sega.chusan.model.userdata.*
import org.springframework.web.bind.annotation.RestController
import kotlin.reflect.full.declaredMembers
@Suppress("UNCHECKED_CAST")
@RestController
@API("api/v2/game/chu3")
class Chu3Import(
@@ -22,7 +24,7 @@ class Chu3Import(
it.name.replace("List", "").lowercase()
},
exportRepos = Chu3DataExport::class.vars()
.filter { f -> f.name !in setOf("gameId", "userData") }
.filter { f -> f.name !in setOf("gameId", "userData", "userLoginBonusList") }
.associateWith { Chu3Repos::class.declaredMembers
.filter { f -> f returns Chu3UserLinked::class }
.firstOrNull { f -> f.name == it.name || f.name == it.name.replace("List", "") }
@@ -44,7 +46,22 @@ class Chu3Import(
// "chuni_profile_emoney" to ImportClass(UserEmoney::class),
// "chuni_profile_overpower" to ImportClass(UserOverpower::class),
// "chuni_profile_recent_rating" to ImportClass(UserRecentRating::class),
)
),
customExporters = mapOf(
Chu3DataExport::userLoginBonusList to { user: Chu3UserData, _: ExportOptions ->
repos.userLoginBonus.findByUser(user.card!!.extId.toInt())
},
) as Map<kotlin.reflect.KMutableProperty1<Chu3DataExport, Any>, (Chu3UserData, ExportOptions) -> Any?>,
customImporters = mapOf(
Chu3DataExport::userLoginBonusList to { export: Chu3DataExport, user: Chu3UserData ->
val userId = user.card!!.extId.toInt()
repos.userLoginBonus.deleteAll(repos.userLoginBonus.findByUser(userId))
repos.userLoginBonus.saveAll(export.userLoginBonusList.map { it.apply {
id = 0
this.user = userId
} })
},
) as Map<kotlin.reflect.KMutableProperty1<Chu3DataExport, Any>, (Chu3DataExport, Chu3UserData) -> Unit>,
) {
override fun createEmpty() = Chu3DataExport()
override val userDataRepo = repos.userData
@@ -67,6 +84,16 @@ data class Chu3DataExport(
var userMateList: List<UserMate> = emptyList(),
var userGeneralDataList: List<UserGeneralData> = emptyList(),
var userMiscList: Chu3UserMisc = Chu3UserMisc(),
var userCardPrintStateList: List<UserCardPrintState> = emptyList(),
var userGachaList: List<UserGacha> = emptyList(),
var userRegionsList: List<UserRegions> = emptyList(),
var userCMissionList: List<UserCMission> = emptyList(),
var userCMissionProgressList: List<UserCMissionProgress> = emptyList(),
var netBattleLogList: List<Chu3NetBattleLog> = emptyList(),
var userChallengeList: List<Chu3UserChallenge> = emptyList(),
var userLinkedVerseList: List<Chu3UserLinkedVerse> = emptyList(),
var userVoteList: List<UserVote> = emptyList(),
var userLoginBonusList: List<UserLoginBonus> = emptyList(),
): IExportClass<Chu3UserData> {
constructor() : this("SDHD",
Chu3UserData(), UserGameOption(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList(), Chu3UserMisc())
@@ -0,0 +1,33 @@
package icu.samnyan.aqua.net.games.diva
import ext.*
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.sega.diva.DivaRepos
import org.springframework.web.bind.annotation.RestController
@RestController
@API("api/v2/game/diva")
class DivaExport(
val us: AquaUserServices,
val repos: DivaRepos,
) {
@API("export")
fun exportUserData(@RP token: Str) = us.jwt.auth(token) { account ->
val profile = repos.profile.findByPdId(account.ghostCard.extId).orElse(null)
?: (404 - "User not found")
linkedMapOf(
"gameId" to "SBZV",
"userData" to profile,
"gameSession" to repos.gameSession.findByPdId(profile).orElse(null),
"playLogList" to repos.playLog.findByPdId(profile),
"playerContestList" to repos.contest.findByPdId(profile),
"playerCustomizeList" to repos.customize.findByPdId(profile),
"playerInventoryList" to repos.inventory.findByPdId(profile),
"playerModuleList" to repos.module.findByPdId(profile),
"playerPvCustomizeList" to repos.pvCustomize.findByPdId(profile),
"playerPvRecordList" to repos.pvRecord.findByPdId(profile),
"playerScreenShotList" to repos.screenShot.findByPdId(profile),
)
}
}
@@ -68,7 +68,7 @@ class Mai2Import(
?.split(",")
?.mapIndexed { index, id -> Mai2UserFavoriteItem().apply { orderId = index; this.id = id.toInt() } }
?: emptyList()
}
},
) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Mai2UserDetail, ExportOptions) -> Any?>,
customImporters = mapOf(
Maimai2DataExport::userPlaylogList to { export: Maimai2DataExport, user: Mai2UserDetail ->
@@ -85,7 +85,7 @@ class Mai2Import(
propertyValue = favoriteMusicList.sortedBy { it.orderId }.map { it.id }.joinToString(",")
})
}
}
},
) as Map<kotlin.reflect.KMutableProperty1<Maimai2DataExport, Any>, (Maimai2DataExport, Mai2UserDetail) -> Unit>
) {
override fun createEmpty() = Maimai2DataExport()
@@ -113,6 +113,9 @@ data class Maimai2DataExport(
var userFavoriteMusicList: List<Mai2UserFavoriteItem> = mutableListOf(),
var userKaleidxScopeList: List<Mai2UserKaleidx> = mutableListOf(),
var userPlaylogList: List<Mai2UserPlaylog> = mutableListOf(),
var userCardList: List<Mai2UserCard> = mutableListOf(),
var userPrintDetailList: List<Mai2UserPrintDetail> = mutableListOf(),
var userRegionsList: List<UserRegions> = mutableListOf(),
// Not supported yet:
// var userWeeklyData
// var userMissionDataList
@@ -1,16 +1,10 @@
package icu.samnyan.aqua.net.games.wacca
import ext.API
import ext.RP
import ext.isoDate
import ext.utc
import ext.*
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.games.*
import icu.samnyan.aqua.net.utils.waccaScores
import icu.samnyan.aqua.sega.wacca.model.db.WaccaUser
import icu.samnyan.aqua.sega.wacca.model.db.WcUserBestScoreRepo
import icu.samnyan.aqua.sega.wacca.model.db.WcUserPlayLogRepo
import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo
import icu.samnyan.aqua.sega.wacca.model.db.*
import org.springframework.web.bind.annotation.RestController
@RestController
@@ -20,6 +14,7 @@ class Wacca(
override val playlogRepo: WcUserPlayLogRepo,
override val userDataRepo: WcUserRepo,
override val userMusicRepo: WcUserBestScoreRepo,
val repos: WaccaRepos,
): GameApiController<WaccaUser>("wacca", WaccaUser::class) {
override val settableFields: Map<String, (WaccaUser, String) -> Unit> by lazy { mapOf(
"userName" to usernameCheck(true),
@@ -40,5 +35,38 @@ class Wacca(
genericUserSummary(card, mapOf(), null, data?.favoriteSongs)
}
@API("export")
fun exportUserData(@RP token: Str) = us.jwt.auth(token) { account ->
val user = repos.user.findByCard(account.ghostCard) ?: (404 - "User not found")
WaccaDataExport(
userData = user,
userOptionList = repos.option.findByUser(user),
userBingoList = repos.bingo.findByUser(user),
userFriendList = repos.friend.findByUser(user).map {
WaccaFriendExport(it.with.id, it.isAccepted)
},
userGateList = repos.gate.findByUser(user),
userItemList = repos.item.findByUser(user),
userBestScoreList = repos.bestScore.findByUser(user),
userPlaylogList = repos.playLog.findByUser(user),
userStageUpList = repos.stageUp.findByUser(user),
)
}
override val shownRanks: List<Pair<Int, String>> = waccaScores.filter { it.first > 85 * 10000 }
}
data class WaccaFriendExport(val withUserId: Long, val isAccepted: Boolean)
data class WaccaDataExport(
val gameId: String = "SDFE",
val userData: WaccaUser,
val userOptionList: List<WcUserOption>,
val userBingoList: List<WcUserBingo>,
val userFriendList: List<WaccaFriendExport>,
val userGateList: List<WcUserGate>,
val userItemList: List<WcUserItem>,
val userBestScoreList: List<WcUserScore>,
val userPlaylogList: List<WcUserPlayLog>,
val userStageUpList: List<WcUserStageUp>,
)
@@ -5,7 +5,9 @@ import icu.samnyan.aqua.net.db.AquaNetUser
import jakarta.persistence.*
import jakarta.transaction.Transactional
import org.slf4j.LoggerFactory
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.JpaRepository
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
@@ -18,9 +20,10 @@ import java.security.SecureRandom
@Table(name = "allnet_keychip_sessions", indexes = [
Index(name = "idx_last_use", columnList = "lastUse")
])
class KeychipSession(
@ManyToOne
@JoinColumn(name = "au_id")
class KeychipSession(
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "au_id")
var user: AquaNetUser? = null,
@Column(length = 4)
@@ -45,12 +48,12 @@ fun genUrlSafeToken(length: Int): String {
}
@Repository("KeychipSessionRepo")
interface KeychipSessionRepo : JpaRepository<KeychipSession, String> {
fun findByToken(token: String): KeychipSession?
@Transactional
fun deleteAllByLastUseBefore(expire: Long)
}
interface KeychipSessionRepo : JpaRepository<KeychipSession, String> {
fun findByToken(token: String): KeychipSession?
@Transactional
fun deleteAllByLastUseBefore(expire: Long)
}
@Service
class KeychipSessionService(
@@ -4,6 +4,8 @@ import icu.samnyan.aqua.net.db.AquaNetUser
import jakarta.persistence.*
import jakarta.transaction.Transactional
import org.springframework.data.jpa.repository.JpaRepository
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.stereotype.Repository
@Entity
@@ -13,6 +15,7 @@ class UserKeychip(
val id: Long = 0,
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "au_id", nullable = false)
var user: AquaNetUser,
@@ -27,8 +27,10 @@ interface Chu3UserLinked<T : Any> : IUserRepo<Chu3UserData, T> {
// This repo cannot be generalized as UserLinked because the entity stores user as an int
// TODO: Find a way to generalize this
interface Chu3UserLoginBonusRepo : JpaRepository<UserLoginBonus, Long> {
@Query(
interface Chu3UserLoginBonusRepo : JpaRepository<UserLoginBonus, Long> {
fun findByUser(userId: Int): List<UserLoginBonus>
@Query(
value = "select * from chusan_user_login_bonus where user = ?1 and version = ?2 and is_finished = ?3 order by last_update_date desc",
nativeQuery = true
)
@@ -6,11 +6,14 @@ import icu.samnyan.aqua.net.games.IUserEntity
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.MappedSuperclass
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
@MappedSuperclass
class Chu3UserEntity : BaseEntity(), IUserEntity<Chu3UserData> {
@JsonIgnore
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "user_id")
override var user: Chu3UserData = Chu3UserData()
}
}
@@ -67,6 +67,8 @@ interface PvEntryRepository : JpaRepository<PvEntry, Int> {
}
interface PlayerContestRepository : JpaRepository<PlayerContest, Long> {
fun findByPdId(pdId: PlayerProfile): MutableList<PlayerContest>
fun findByPdIdAndContestId(pdId: PlayerProfile, contestId: Int): Optional<PlayerContest>
fun findTop4ByPdIdOrderByLastUpdateTimeDesc(pdId: PlayerProfile): MutableList<PlayerContest>
@@ -86,6 +88,8 @@ interface PlayerCustomizeRepository : JpaRepository<PlayerCustomize, Long> {
@Repository
interface PlayerInventoryRepository : JpaRepository<PlayerInventory, Long> {
fun findByPdId(profile: PlayerProfile): MutableList<PlayerInventory>
fun findByPdIdAndTypeAndValue(profile: PlayerProfile, type: String, value: String): Optional<PlayerInventory>
}
@@ -96,6 +100,8 @@ interface PlayerProfileRepository : JpaRepository<PlayerProfile, Long> {
@Repository
interface PlayerPvCustomizeRepository : JpaRepository<PlayerPvCustomize, Long> {
fun findByPdId(profile: PlayerProfile): MutableList<PlayerPvCustomize>
fun findByPdIdAndPvId(profile: PlayerProfile, pvId: Int): Optional<PlayerPvCustomize>
}
@@ -139,12 +145,16 @@ interface PlayerPvRecordRepository : JpaRepository<PlayerPvRecord, Long> {
): MutableList<PlayerPvRecord>
}
interface PlayerScreenShotRepository : JpaRepository<PlayerScreenShot, Long>
interface PlayerScreenShotRepository : JpaRepository<PlayerScreenShot, Long> {
fun findByPdId(profile: PlayerProfile): MutableList<PlayerScreenShot>
}
@Repository
interface PlayLogRepository : JpaRepository<PlayLog, Long>
interface PlayLogRepository : JpaRepository<PlayLog, Long> {
fun findByPdId(profile: PlayerProfile): MutableList<PlayLog>
}
@Repository
interface PlayerModuleRepository : JpaRepository<PlayerModule, Long> {
fun findByPdId(profile: PlayerProfile): MutableList<PlayerModule>
}
}
@@ -2,6 +2,8 @@ package icu.samnyan.aqua.sega.diva.model.db.userdata
import icu.samnyan.aqua.sega.diva.model.common.StartMode
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.io.Serializable
import java.time.LocalDateTime
@@ -14,6 +16,7 @@ class GameSession : Serializable {
var acceptId = 0
@OneToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "pd_id", unique = true)
var pdId: PlayerProfile = PlayerProfile()
@@ -6,6 +6,8 @@ import icu.samnyan.aqua.sega.diva.model.common.ClearResult
import icu.samnyan.aqua.sega.diva.model.common.Difficulty
import icu.samnyan.aqua.sega.diva.model.common.Edition
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.io.Serializable
import java.time.LocalDateTime
@@ -17,6 +19,7 @@ class PlayLog : Serializable {
var id: Long = 0
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "pd_id")
@JsonIgnore
var pdId: PlayerProfile = PlayerProfile()
@@ -2,6 +2,8 @@ package icu.samnyan.aqua.sega.diva.model.db.userdata
import icu.samnyan.aqua.sega.diva.model.common.ContestBorder
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.io.Serializable
import java.time.LocalDateTime
@@ -13,6 +15,7 @@ class PlayerContest : Serializable {
var id: Long = 0
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "pd_id")
var pdId: PlayerProfile = PlayerProfile()
@@ -1,6 +1,8 @@
package icu.samnyan.aqua.sega.diva.model.db.userdata
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.io.Serializable
@Entity(name = "DivaPlayerCustomize")
@@ -11,6 +13,7 @@ class PlayerCustomize : Serializable {
var id: Long = 0
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "pd_id")
var pdId: PlayerProfile = PlayerProfile()
@@ -2,6 +2,8 @@ package icu.samnyan.aqua.sega.diva.model.db.userdata
import com.fasterxml.jackson.annotation.JsonIgnore
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.io.Serializable
@Entity(name = "DivaPlayerInventory")
@@ -12,6 +14,7 @@ class PlayerInventory : Serializable {
var id: Long = 0
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "pd_id")
@JsonIgnore
var pdId: PlayerProfile = PlayerProfile()
@@ -1,6 +1,8 @@
package icu.samnyan.aqua.sega.diva.model.db.userdata
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.io.Serializable
@Entity(name = "DivaPlayerModule")
@@ -11,6 +13,7 @@ class PlayerModule : Serializable {
var id: Long = 0
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "pd_id")
var pdId: PlayerProfile = PlayerProfile()
@@ -2,6 +2,8 @@ package icu.samnyan.aqua.sega.diva.model.db.userdata
import com.fasterxml.jackson.annotation.JsonIgnore
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.io.Serializable
@Entity(name = "DivaPlayerPvCustomize")
@@ -12,6 +14,7 @@ class PlayerPvCustomize : Serializable {
var id: Long = 0
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "pd_id")
@JsonIgnore
var pdId: PlayerProfile = PlayerProfile()
@@ -6,6 +6,8 @@ import icu.samnyan.aqua.sega.diva.model.common.ClearResult
import icu.samnyan.aqua.sega.diva.model.common.Difficulty
import icu.samnyan.aqua.sega.diva.model.common.Edition
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.io.Serializable
@Entity(name = "DivaPlayerPvRecord")
@@ -19,6 +21,7 @@ class PlayerPvRecord : Serializable {
var id: Long = 0
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "pd_id")
@JsonIgnore
var pdId: PlayerProfile = PlayerProfile()
@@ -1,6 +1,8 @@
package icu.samnyan.aqua.sega.diva.model.db.userdata
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
@Entity(name = "DivaPlayerScreenShot")
@Table(name = "diva_player_screen_shot")
@@ -10,6 +12,7 @@ class PlayerScreenShot {
var id: Long = 0
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "pd_id")
var pdId: PlayerProfile = PlayerProfile()
var pvId: Long = 0
@@ -11,4 +11,5 @@ import org.springframework.stereotype.Repository
interface CardRepository : JpaRepository<Card, Long> {
fun findByExtId(extId: Long): Card?
fun findByLuid(luid: String): Card?
fun findAllByAquaUserAuId(auId: Long): List<Card>
}
@@ -3,6 +3,8 @@ package icu.samnyan.aqua.sega.general.model
import com.fasterxml.jackson.annotation.JsonIgnore
import icu.samnyan.aqua.net.db.AquaNetUser
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.time.LocalDateTime
enum class CardStatus {
@@ -59,6 +61,7 @@ class Card(
// Defines the AquaNet user that this card is bound to
@ManyToOne
@OnDelete(action = OnDeleteAction.SET_NULL)
@JoinColumn(name = "net_user_id")
@JsonIgnore
var aquaUser: AquaNetUser? = null,
@@ -107,7 +107,7 @@ interface Mai2UserPlaylogRepo : GenericPlaylogRepo<Mai2UserPlaylog>, Mai2UserLin
fun findByUserAndUserPlayDateAfter(user: Mai2UserDetail, userPlayDate: String): List<Mai2UserPlaylog>
}
interface Mai2UserPrintDetailRepo : JpaRepository<Mai2UserPrintDetail, Long>
interface Mai2UserPrintDetailRepo : Mai2UserLinked<Mai2UserPrintDetail>
interface Mai2UserUdemaeRepo : Mai2UserLinked<Mai2UserUdemae>
@@ -3,6 +3,7 @@
package icu.samnyan.aqua.sega.maimai2.model.userdata
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.JsonGenerator
@@ -16,6 +17,8 @@ import icu.samnyan.aqua.net.games.IGenericUserMusic
import icu.samnyan.aqua.net.games.IUserEntity
import icu.samnyan.aqua.sega.general.IntegerListConverter
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@@ -24,6 +27,7 @@ import java.time.format.DateTimeFormatter
open class Mai2UserEntity : BaseEntity(), IUserEntity<Mai2UserDetail> {
@JsonIgnore
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "user_id")
override var user: Mai2UserDetail = Mai2UserDetail()
}
@@ -459,6 +463,7 @@ fun main(args: Array<String>) {
@Table(name = "maimai2_user_print_detail")
@Entity
@JsonIgnoreProperties(value = ["userCard"], allowSetters = true)
class Mai2UserPrintDetail : Mai2UserEntity() {
var orderId: Long = 0
var printNumber = 0
@@ -7,6 +7,8 @@ import icu.samnyan.aqua.net.games.*
import icu.samnyan.aqua.sega.general.model.Card
import icu.samnyan.aqua.sega.util.AccessCodeSerializer
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.time.LocalDate
import java.time.LocalDateTime
@@ -14,6 +16,7 @@ import java.time.LocalDateTime
class OngekiUserEntity : BaseEntity(), IUserEntity<UserData> {
@JsonIgnore
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "user_id")
override var user: UserData = UserData()
}
@@ -541,4 +544,4 @@ class UserGacha : OngekiUserEntity() {
var fiveGachaCnt = 0
var elevenGachaCnt = 0
var dailyGachaDate: String = LocalDateTime.now().toString()
}
}
@@ -9,6 +9,8 @@ import icu.samnyan.aqua.sega.general.IntegerListConverter
import icu.samnyan.aqua.sega.wacca.WaccaItemType
import icu.samnyan.aqua.sega.wacca.WaccaItemType.*
import jakarta.persistence.*
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import java.util.*
typealias UC = UniqueConstraint
@@ -20,6 +22,7 @@ typealias UC = UniqueConstraint
open class WaccaUserEntity : BaseEntity() {
@JsonIgnore
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "user_id")
open var user: WaccaUser = WaccaUser()
}
@@ -0,0 +1,66 @@
-- Diva's user foreign keys were removed in V1000.7 and never recreated.
-- Remove any rows that can no longer be associated with a profile before restoring them.
DELETE child FROM diva_game_session child
LEFT JOIN diva_player_profile profile ON child.pd_id = profile.id
WHERE profile.id IS NULL;
DELETE child FROM diva_play_log child
LEFT JOIN diva_player_profile profile ON child.pd_id = profile.id
WHERE profile.id IS NULL;
DELETE child FROM diva_player_contest child
LEFT JOIN diva_player_profile profile ON child.pd_id = profile.id
WHERE profile.id IS NULL;
DELETE child FROM diva_player_customize child
LEFT JOIN diva_player_profile profile ON child.pd_id = profile.id
WHERE profile.id IS NULL;
DELETE child FROM diva_player_inventory child
LEFT JOIN diva_player_profile profile ON child.pd_id = profile.id
WHERE profile.id IS NULL;
DELETE child FROM diva_player_module child
LEFT JOIN diva_player_profile profile ON child.pd_id = profile.id
WHERE profile.id IS NULL;
DELETE child FROM diva_player_pv_customize child
LEFT JOIN diva_player_profile profile ON child.pd_id = profile.id
WHERE profile.id IS NULL;
DELETE child FROM diva_player_pv_record child
LEFT JOIN diva_player_profile profile ON child.pd_id = profile.id
WHERE profile.id IS NULL;
DELETE child FROM diva_player_screen_shot child
LEFT JOIN diva_player_profile profile ON child.pd_id = profile.id
WHERE profile.id IS NULL;
ALTER TABLE diva_game_session
ADD CONSTRAINT fku_diva_game_session_profile
FOREIGN KEY (pd_id) REFERENCES diva_player_profile (id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE diva_play_log
ADD CONSTRAINT fku_diva_play_log_profile
FOREIGN KEY (pd_id) REFERENCES diva_player_profile (id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE diva_player_contest
ADD CONSTRAINT fku_diva_player_contest_profile
FOREIGN KEY (pd_id) REFERENCES diva_player_profile (id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE diva_player_customize
ADD CONSTRAINT fku_diva_player_customize_profile
FOREIGN KEY (pd_id) REFERENCES diva_player_profile (id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE diva_player_inventory
ADD CONSTRAINT fku_diva_player_inventory_profile
FOREIGN KEY (pd_id) REFERENCES diva_player_profile (id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE diva_player_module
ADD CONSTRAINT fku_diva_player_module_profile
FOREIGN KEY (pd_id) REFERENCES diva_player_profile (id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE diva_player_pv_customize
ADD CONSTRAINT fku_diva_player_pv_customize_profile
FOREIGN KEY (pd_id) REFERENCES diva_player_profile (id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE diva_player_pv_record
ADD CONSTRAINT fku_diva_player_pv_record_profile
FOREIGN KEY (pd_id) REFERENCES diva_player_profile (id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE diva_player_screen_shot
ADD CONSTRAINT fku_diva_player_screen_shot_profile
FOREIGN KEY (pd_id) REFERENCES diva_player_profile (id)
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,42 @@
-- Account-owned rows disappear with the account. Physical cards are only unlinked.
ALTER TABLE aqua_net_session
DROP FOREIGN KEY IF EXISTS FK_SESSION;
ALTER TABLE aqua_net_session
ADD CONSTRAINT FK_SESSION
FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE aqua_net_email_confirmation
DROP FOREIGN KEY IF EXISTS FK_EMAIL_CONFIRMATION_ON_AQUA_USER;
ALTER TABLE aqua_net_email_confirmation
ADD CONSTRAINT FK_EMAIL_CONFIRMATION_ON_AQUA_USER
FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE aqua_net_email_reset_password
DROP FOREIGN KEY IF EXISTS FK_EMAIL_RESET_PASSWORD_ON_AQUA_USER;
ALTER TABLE aqua_net_email_reset_password
ADD CONSTRAINT FK_EMAIL_RESET_PASSWORD_ON_AQUA_USER
FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE allnet_keychip_sessions
DROP FOREIGN KEY IF EXISTS FK_ALLNET_KEYCHIP_SESSIONS_ON_AU;
ALTER TABLE allnet_keychip_sessions
ADD CONSTRAINT FK_ALLNET_KEYCHIP_SESSIONS_ON_AU
FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE user_keychip
DROP FOREIGN KEY IF EXISTS fk_user_keychip_on_au;
ALTER TABLE user_keychip
ADD CONSTRAINT fk_user_keychip_on_au
FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE sega_card
DROP FOREIGN KEY IF EXISTS FK_SEGA_CARD_ON_NET_USER;
ALTER TABLE sega_card
ADD CONSTRAINT FK_SEGA_CARD_ON_NET_USER
FOREIGN KEY (net_user_id) REFERENCES aqua_net_user (au_id)
ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
ALTER TABLE ongeki_user_gacha
DROP FOREIGN KEY IF EXISTS FK_ONGEKI_USER_GACHA_ON_USER;
ALTER TABLE ongeki_user_gacha
ADD CONSTRAINT FK_ONGEKI_USER_GACHA_ON_USER
FOREIGN KEY (user_id) REFERENCES ongeki_user_data (id)
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- V1000.7 accidentally replaced the user foreign key with a second user-card
-- foreign key. Restore the direct ownership link so every print detail is
-- removed with its Mai2 user, including legacy rows without a user card.
DELETE detail FROM maimai2_user_print_detail detail
LEFT JOIN maimai2_user_detail user_data ON detail.user_id = user_data.id
WHERE user_data.id IS NULL;
ALTER TABLE maimai2_user_print_detail
ADD CONSTRAINT fku_maimai2_user_print_detail_user
FOREIGN KEY (user_id) REFERENCES maimai2_user_detail (id)
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,23 @@
package test
import ext.JACKSON
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserCard
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPrintDetail
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class Mai2UserPrintDetailJsonTest : StringSpec({
"userCard is accepted on input but omitted from output" {
val detail = Mai2UserPrintDetail().apply {
userCard = Mai2UserCard().apply { cardId = 42 }
}
JACKSON.writeValueAsString(detail).contains("\"userCard\"") shouldBe false
val imported = JACKSON.readValue(
"""{"userCard":{"cardId":42}}""",
Mai2UserPrintDetail::class.java,
)
imported.userCard?.cardId shouldBe 42
}
})