[+] Delete account

This commit is contained in:
Azalea
2026-08-01 22:25:47 +00:00
parent 4aba8d8642
commit 95df22bfd6
11 changed files with 413 additions and 17 deletions

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { slide, fade } from "svelte/transition";
import type { AquaNetUser } from "../../libs/generalTypes";
import type { AquaNetUser, ConfirmProps } from "../../libs/generalTypes";
import { CARD, USER } 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,41 @@
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 data = await USER.exportAccountData();
download(
JSON.stringify(data, null, 2),
`AquaDX_account_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 +202,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 +275,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

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.",

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': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置',

View File

@@ -203,6 +203,10 @@ export const USER = {
ensureLoggedIn,
changeRegion: (regionId: number) =>
post('/api/v2/user/change-region', { regionId }),
exportAccountData: (): Promise<Record<string, any>> =>
post('/api/v2/user/export-data'),
deleteAccount: () =>
post('/api/v2/user/delete-account'),
}
export const USERBOX = {

View File

@@ -0,0 +1,312 @@
package icu.samnyan.aqua.net
import ext.*
import icu.samnyan.aqua.net.components.JWT
import icu.samnyan.aqua.net.db.*
import icu.samnyan.aqua.net.utils.PathProps
import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.sega.allnet.KeychipSessionRepo
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.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.ongeki.OngekiUserRepos
import icu.samnyan.aqua.sega.wacca.model.db.WaccaRepos
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.RestController
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
data class AccountGameDataExport(
val formatVersion: Int = 1,
val exportedAt: Instant = Instant.now(),
val username: String,
val gameOptions: AquaGameOptions?,
val games: Map<String, Any>,
)
data class AccountFileCleanup(
val profilePicture: String?,
val gameUserId: Long,
val divaScreenshots: List<String>,
)
@RestController
@API("/api/v2/user")
class AccountController(
val jwt: JWT,
val accountData: AccountDataService,
) {
@API("/export-data")
@Doc("Export all game data associated with the current user's account.", "All account game data")
fun exportData(@RP token: Str) = jwt.auth(token) { accountData.export(it) }
@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 = accountData.deleteDatabaseData(user.auId)
accountData.deleteUploadedFiles(cleanup)
SUCCESS
}
}
@Service
class AccountDataService(
val userRepo: AquaNetUserRepo,
val cardRepo: CardRepository,
val gameOptionsRepo: AquaGameOptionsRepo,
val sessionRepo: SessionTokenRepo,
val confirmationRepo: EmailConfirmationRepo,
val resetPasswordRepo: ResetPasswordRepo,
val userKeychipRepo: UserKeychipRepo,
val keychipSessionRepo: KeychipSessionRepo,
val mai2: Mai2Repos,
val chu3: Chu3Repos,
val ongeki: OngekiUserRepos,
val wacca: WaccaRepos,
val diva: DivaRepos,
val paths: PathProps,
) {
companion object {
val log = logger()
}
fun export(user: AquaNetUser): AccountGameDataExport {
val card = user.ghostCard
val games = linkedMapOf<String, Any>()
exportMai2(card)?.let { games["mai2"] = it }
exportChu3(card)?.let { games["chu3"] = it }
exportOngeki(card)?.let { games["ongeki"] = it }
exportWacca(card)?.let { games["wacca"] = it }
exportDiva(card)?.let { games["diva"] = it }
log.info("Exported all account game data for user ${user.auId}")
return AccountGameDataExport(username = user.username, gameOptions = user.gameOptions, games = games)
}
private fun exportMai2(card: Card): Map<String, Any>? {
val user = mai2.userData.findByCard(card) ?: return null
return linkedMapOf(
"gameId" to "SDEZ",
"userData" to user,
"mapEncountNpcList" to mai2.mapEncountNpc.findByUser(user),
"userActList" to mai2.userAct.findByUser(user),
"userCardList" to mai2.userCard.findByUser(user),
"userCharacterList" to mai2.userCharacter.findByUser(user),
"userChargeList" to mai2.userCharge.findByUser(user),
"userCourseList" to mai2.userCourse.findByUser(user),
"userExtendList" to mai2.userExtend.findByUser(user),
"userFavoriteList" to mai2.userFavorite.findByUser(user),
"userFriendSeasonRankingList" to mai2.userFriendSeasonRanking.findByUser(user),
"userGeneralDataList" to mai2.userGeneralData.findByUser(user),
"userItemList" to mai2.userItem.findByUser(user),
"userLoginBonusList" to mai2.userLoginBonus.findByUser(user),
"userMapList" to mai2.userMap.findByUser(user),
"userMusicDetailList" to mai2.userMusicDetail.findByUser(user),
"userOptionList" to mai2.userOption.findByUser(user),
"userPlaylogList" to mai2.userPlaylog.findByUser(user),
"userPrintDetailList" to mai2.userPrintDetail.findByUser(user),
"userUdemaeList" to mai2.userUdemae.findByUser(user),
"userKaleidxScopeList" to mai2.userKaleidx.findByUser(user),
"userIntimateList" to mai2.userIntimate.findByUser(user),
"userRegionsList" to mai2.userRegions.findByUser(user),
)
}
private fun exportChu3(card: Card): Map<String, Any>? {
val user = chu3.userData.findByCard(card) ?: return null
return linkedMapOf(
"gameId" to "SDHD",
"userData" to user,
"userActivityList" to chu3.userActivity.findByUser(user),
"userCardPrintStateList" to chu3.userCardPrintState.findByUser(user),
"userCharacterList" to chu3.userCharacter.findByUser(user),
"userChargeList" to chu3.userCharge.findByUser(user),
"userCourseList" to chu3.userCourse.findByUser(user),
"userDuelList" to chu3.userDuel.findByUser(user),
"userGachaList" to chu3.userGacha.findByUser(user),
"userGameOptionList" to chu3.userGameOption.findByUser(user),
"userGeneralDataList" to chu3.userGeneralData.findByUser(user),
"userItemList" to chu3.userItem.findByUser(user),
"userMapList" to chu3.userMap.findByUser(user),
"userMusicDetailList" to chu3.userMusicDetail.findByUser(user),
"userPlaylogList" to chu3.userPlaylog.findByUser(user),
"userRegionsList" to chu3.userRegions.findByUser(user),
"userCMissionList" to chu3.userCMission.findByUser(user),
"userCMissionProgressList" to chu3.userCMissionProgress.findByUser(user),
"netBattleLogList" to chu3.netBattleLog.findByUser(user),
"userMiscList" to chu3.userMisc.findByUser(user),
"userChallengeList" to chu3.userChallenge.findByUser(user),
"userLinkedVerseList" to chu3.userLinkedVerse.findByUser(user),
"userMateList" to chu3.userMate.findByUser(user),
"userVoteList" to chu3.userVote.findByUser(user),
"userLoginBonusList" to chu3.userLoginBonus.findByUser(card.extId.toInt()),
)
}
private fun exportOngeki(card: Card): Map<String, Any>? {
val user = ongeki.data.findByCard(card) ?: return null
return linkedMapOf(
"gameId" to "SDDT",
"userData" to user,
"userActivityList" to ongeki.activity.findByUser(user),
"userBossList" to ongeki.boss.findByUser(user),
"userCardList" to ongeki.card.findByUser(user),
"userChapterList" to ongeki.chapter.findByUser(user),
"userCharacterList" to ongeki.character.findByUser(user),
"userDeckList" to ongeki.deck.findByUser(user),
"userEventMusicList" to ongeki.eventMusic.findByUser(user),
"userEventPointList" to ongeki.eventPoint.findByUser(user),
"userGeneralDataList" to ongeki.generalData.findByUser(user),
"userItemList" to ongeki.item.findByUser(user),
"userKopList" to ongeki.kop.findByUser(user),
"userLoginBonusList" to ongeki.loginBonus.findByUser(user),
"userMemoryChapterList" to ongeki.memoryChapter.findByUser(user),
"userMissionPointList" to ongeki.missionPoint.findByUser(user),
"userMusicDetailList" to ongeki.musicDetail.findByUser(user),
"userMusicItemList" to ongeki.musicItem.findByUser(user),
"userOptionList" to ongeki.option.findByUser(user),
"userPlaylogList" to ongeki.playlog.findByUser(user),
"userRivalList" to ongeki.rival.findByUser(user),
"userScenarioList" to ongeki.scenario.findByUser(user),
"userStoryList" to ongeki.story.findByUser(user),
"userTechCountList" to ongeki.techCount.findByUser(user),
"userTechEventList" to ongeki.techEvent.findByUser(user),
"userTradeItemList" to ongeki.tradeItem.findByUser(user),
"userTrainingRoomList" to ongeki.trainingRoom.findByUser(user),
"userEventMapList" to ongeki.eventMap.findByUser(user),
"userSkinList" to ongeki.skin.findByUser(user),
"userRegionsList" to ongeki.regions.findByUser(user),
"userGachaList" to ongeki.gacha.findByUser(user),
)
}
private fun exportWacca(card: Card): Map<String, Any>? {
val user = wacca.user.findByCard(card) ?: return null
return linkedMapOf(
"gameId" to "SDFE",
"userData" to user,
"userOptionList" to wacca.option.findByUser(user),
"userBingoList" to wacca.bingo.findByUser(user),
"userFriendList" to wacca.friend.findByUser(user).map {
mapOf("withUserId" to it.with.id, "isAccepted" to it.isAccepted)
},
"userGateList" to wacca.gate.findByUser(user),
"userItemList" to wacca.item.findByUser(user),
"userBestScoreList" to wacca.bestScore.findByUser(user),
"userPlaylogList" to wacca.playLog.findByUser(user),
"userStageUpList" to wacca.stageUp.findByUser(user),
)
}
private fun exportDiva(card: Card): Map<String, Any>? {
val profile = diva.profile.findByPdId(card.extId).orElse(null) ?: return null
return linkedMapOf(
"gameId" to "SBZV",
"userData" to profile,
"gameSession" to diva.gameSession.findByPdId(profile).orElse(null),
"playLogList" to diva.playLog.findByPdId(profile),
"playerContestList" to diva.contest.findByPdId(profile),
"playerCustomizeList" to diva.customize.findByPdId(profile),
"playerInventoryList" to diva.inventory.findByPdId(profile),
"playerModuleList" to diva.module.findByPdId(profile),
"playerPvCustomizeList" to diva.pvCustomize.findByPdId(profile),
"playerPvRecordList" to diva.pvRecord.findByPdId(profile),
"playerScreenShotList" to diva.screenShot.findByPdId(profile),
)
}
@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()))
sessionRepo.deleteAll(sessionRepo.findByAquaNetUserAuId(auId))
confirmationRepo.deleteAll(confirmationRepo.findByAquaNetUserAuId(auId))
resetPasswordRepo.deleteAll(resetPasswordRepo.findByAquaNetUserAuId(auId))
userKeychipRepo.deleteAll(userKeychipRepo.findAllByUserAuId(auId))
keychipSessionRepo.deleteAll(keychipSessionRepo.findAllByUserAuId(auId))
val linkedCards = (cardRepo.findAllByAquaUserAuId(auId) + ghostCard).distinctBy { it.id }
linkedCards.forEach { it.aquaUser = null }
cardRepo.saveAll(linkedCards)
cardRepo.flush()
user.cards.clear()
user.keychips.clear()
user.keychipSessions.clear()
val gameOptions = user.gameOptions
user.gameOptions = null
userRepo.delete(user)
userRepo.flush()
cardRepo.delete(ghostCard)
cardRepo.flush()
gameOptions?.let { gameOptionsRepo.delete(it) }
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.gameSession.findByPdId(profile).ifPresent { diva.gameSession.delete(it) }
diva.playLog.deleteAll(diva.playLog.findByPdId(profile))
diva.contest.deleteAll(diva.contest.findByPdId(profile))
diva.customize.deleteAll(diva.customize.findByPdId(profile))
diva.inventory.deleteAll(diva.inventory.findByPdId(profile))
diva.module.deleteAll(diva.module.findByPdId(profile))
diva.pvCustomize.deleteAll(diva.pvCustomize.findByPdId(profile))
diva.pvRecord.deleteAll(diva.pvRecord.findByPdId(profile))
diva.screenShot.deleteAll(screenshots)
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) {
// Database deletion has already committed. Leave a useful trail for administrators
// rather than turning a successful account deletion into a client-visible failure.
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() }
}
}
}

View File

@@ -69,12 +69,12 @@ 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)

View File

@@ -45,12 +45,13 @@ 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?
fun findAllByUserAuId(auId: Long): List<KeychipSession>
@Transactional
fun deleteAllByLastUseBefore(expire: Long)
}
@Service
class KeychipSessionService(

View File

@@ -27,8 +27,10 @@ interface Chu3UserLinked<T> : 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
)

View File

@@ -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>
}
}

View File

@@ -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>
}

View File

@@ -107,7 +107,9 @@ interface Mai2UserPlaylogRepo : GenericPlaylogRepo<Mai2UserPlaylog>, Mai2UserLin
fun findByUserAndUserPlayDateAfter(user: Mai2UserDetail, userPlayDate: String): List<Mai2UserPlaylog>
}
interface Mai2UserPrintDetailRepo : JpaRepository<Mai2UserPrintDetail, Long>
interface Mai2UserPrintDetailRepo : JpaRepository<Mai2UserPrintDetail, Long> {
fun findByUser(user: Mai2UserDetail): List<Mai2UserPrintDetail>
}
interface Mai2UserUdemaeRepo : Mai2UserLinked<Mai2UserUdemae>