diff --git a/AquaNet/src/components/settings/UserSettings.svelte b/AquaNet/src/components/settings/UserSettings.svelte index 29faee9f..5968223b 100644 --- a/AquaNet/src/components/settings/UserSettings.svelte +++ b/AquaNet/src/components/settings/UserSettings.svelte @@ -1,10 +1,10 @@ - + {#if !submitting && !error && me}
@@ -171,6 +202,16 @@
+
+ {t('settings.profile.delete-title')} + {t('settings.profile.delete-description')} +
+ +
+
{/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 diff --git a/AquaNet/src/libs/i18n/en_ref.ts b/AquaNet/src/libs/i18n/en_ref.ts index 11b296fb..8e115ae9 100644 --- a/AquaNet/src/libs/i18n/en_ref.ts +++ b/AquaNet/src/libs/i18n/en_ref.ts @@ -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.", diff --git a/AquaNet/src/libs/i18n/zh.ts b/AquaNet/src/libs/i18n/zh.ts index ca416d36..33b8555b 100644 --- a/AquaNet/src/libs/i18n/zh.ts +++ b/AquaNet/src/libs/i18n/zh.ts @@ -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': '注意:下面这些设置只会影响你自己的机器,如果你是在其他人的机器上玩的话,请联系机主来改设置', diff --git a/AquaNet/src/libs/sdk.ts b/AquaNet/src/libs/sdk.ts index 7b1e41da..a931fa3e 100644 --- a/AquaNet/src/libs/sdk.ts +++ b/AquaNet/src/libs/sdk.ts @@ -203,6 +203,10 @@ export const USER = { ensureLoggedIn, changeRegion: (regionId: number) => post('/api/v2/user/change-region', { regionId }), + exportAccountData: (): Promise> => + post('/api/v2/user/export-data'), + deleteAccount: () => + post('/api/v2/user/delete-account'), } export const USERBOX = { diff --git a/src/main/java/icu/samnyan/aqua/net/AccountController.kt b/src/main/java/icu/samnyan/aqua/net/AccountController.kt new file mode 100644 index 00000000..80f049c1 --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/net/AccountController.kt @@ -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, +) + +data class AccountFileCleanup( + val profilePicture: String?, + val gameUserId: Long, + val divaScreenshots: List, +) + +@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() + + 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? { + 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? { + 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? { + 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? { + 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? { + 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 { + 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() } + } + } +} diff --git a/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt b/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt index 8b4bd7c4..620bcbc8 100644 --- a/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt +++ b/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt @@ -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 = mutableListOf(), // Each user can have multiple keychips (if the user owns cabinets) diff --git a/src/main/java/icu/samnyan/aqua/sega/allnet/KeychipSession.kt b/src/main/java/icu/samnyan/aqua/sega/allnet/KeychipSession.kt index 143e55d8..62cb6646 100644 --- a/src/main/java/icu/samnyan/aqua/sega/allnet/KeychipSession.kt +++ b/src/main/java/icu/samnyan/aqua/sega/allnet/KeychipSession.kt @@ -45,12 +45,13 @@ fun genUrlSafeToken(length: Int): String { } @Repository("KeychipSessionRepo") -interface KeychipSessionRepo : JpaRepository { - fun findByToken(token: String): KeychipSession? - - @Transactional - fun deleteAllByLastUseBefore(expire: Long) -} +interface KeychipSessionRepo : JpaRepository { + fun findByToken(token: String): KeychipSession? + fun findAllByUserAuId(auId: Long): List + + @Transactional + fun deleteAllByLastUseBefore(expire: Long) +} @Service class KeychipSessionService( diff --git a/src/main/java/icu/samnyan/aqua/sega/chusan/model/Chu3Repos.kt b/src/main/java/icu/samnyan/aqua/sega/chusan/model/Chu3Repos.kt index b5944b52..8bc14641 100644 --- a/src/main/java/icu/samnyan/aqua/sega/chusan/model/Chu3Repos.kt +++ b/src/main/java/icu/samnyan/aqua/sega/chusan/model/Chu3Repos.kt @@ -27,8 +27,10 @@ interface Chu3UserLinked : IUserRepo { // 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 { - @Query( +interface Chu3UserLoginBonusRepo : JpaRepository { + fun findByUser(userId: Int): List + + @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 ) diff --git a/src/main/java/icu/samnyan/aqua/sega/diva/DivaRepos.kt b/src/main/java/icu/samnyan/aqua/sega/diva/DivaRepos.kt index c1d19284..d22776a1 100644 --- a/src/main/java/icu/samnyan/aqua/sega/diva/DivaRepos.kt +++ b/src/main/java/icu/samnyan/aqua/sega/diva/DivaRepos.kt @@ -67,6 +67,8 @@ interface PvEntryRepository : JpaRepository { } interface PlayerContestRepository : JpaRepository { + fun findByPdId(pdId: PlayerProfile): MutableList + fun findByPdIdAndContestId(pdId: PlayerProfile, contestId: Int): Optional fun findTop4ByPdIdOrderByLastUpdateTimeDesc(pdId: PlayerProfile): MutableList @@ -86,6 +88,8 @@ interface PlayerCustomizeRepository : JpaRepository { @Repository interface PlayerInventoryRepository : JpaRepository { + fun findByPdId(profile: PlayerProfile): MutableList + fun findByPdIdAndTypeAndValue(profile: PlayerProfile, type: String, value: String): Optional } @@ -96,6 +100,8 @@ interface PlayerProfileRepository : JpaRepository { @Repository interface PlayerPvCustomizeRepository : JpaRepository { + fun findByPdId(profile: PlayerProfile): MutableList + fun findByPdIdAndPvId(profile: PlayerProfile, pvId: Int): Optional } @@ -139,12 +145,16 @@ interface PlayerPvRecordRepository : JpaRepository { ): MutableList } -interface PlayerScreenShotRepository : JpaRepository +interface PlayerScreenShotRepository : JpaRepository { + fun findByPdId(profile: PlayerProfile): MutableList +} @Repository -interface PlayLogRepository : JpaRepository +interface PlayLogRepository : JpaRepository { + fun findByPdId(profile: PlayerProfile): MutableList +} @Repository interface PlayerModuleRepository : JpaRepository { fun findByPdId(profile: PlayerProfile): MutableList -} \ No newline at end of file +} diff --git a/src/main/java/icu/samnyan/aqua/sega/general/dao/CardRepository.kt b/src/main/java/icu/samnyan/aqua/sega/general/dao/CardRepository.kt index 538afe78..3307f061 100644 --- a/src/main/java/icu/samnyan/aqua/sega/general/dao/CardRepository.kt +++ b/src/main/java/icu/samnyan/aqua/sega/general/dao/CardRepository.kt @@ -11,4 +11,5 @@ import org.springframework.stereotype.Repository interface CardRepository : JpaRepository { fun findByExtId(extId: Long): Card? fun findByLuid(luid: String): Card? + fun findAllByAquaUserAuId(auId: Long): List } diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/model/Repos.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/model/Repos.kt index b828c7a1..028f8ba4 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/model/Repos.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/model/Repos.kt @@ -107,7 +107,9 @@ interface Mai2UserPlaylogRepo : GenericPlaylogRepo, Mai2UserLin fun findByUserAndUserPlayDateAfter(user: Mai2UserDetail, userPlayDate: String): List } -interface Mai2UserPrintDetailRepo : JpaRepository +interface Mai2UserPrintDetailRepo : JpaRepository { + fun findByUser(user: Mai2UserDetail): List +} interface Mai2UserUdemaeRepo : Mai2UserLinked