From d79a4e5499c5c4fe64954c608d47b4e981cfb7a7 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 4 Jul 2025 12:01:32 +0800 Subject: [PATCH] [+] Data support APIs (#151) --- AquaNet/src/libs/sdk.ts | 11 +- config/application.properties | 5 + src/main/java/icu/samnyan/aqua/net/Fedy.kt | 203 ++++++++++++++++++ .../icu/samnyan/aqua/net/db/AquaNetUser.kt | 1 + .../samnyan/aqua/net/db/AquaNetUserFedy.kt | 29 +++ .../aqua/net/games/ImportController.kt | 7 +- .../samnyan/aqua/net/games/mai2/Mai2Import.kt | 4 +- .../sega/maimai2/Maimai2ServletController.kt | 8 + .../db/40/V1000_41__add_aquanet_user_fedy.sql | 9 + 9 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 src/main/java/icu/samnyan/aqua/net/Fedy.kt create mode 100644 src/main/java/icu/samnyan/aqua/net/db/AquaNetUserFedy.kt create mode 100644 src/main/resources/db/40/V1000_41__add_aquanet_user_fedy.sql diff --git a/AquaNet/src/libs/sdk.ts b/AquaNet/src/libs/sdk.ts index 9cf7d173..3904313e 100644 --- a/AquaNet/src/libs/sdk.ts +++ b/AquaNet/src/libs/sdk.ts @@ -254,5 +254,14 @@ export const TRANSFER = { post('/api/v2/transfer/push', {}, { json: { client: d, data } }), } +export const FEDY = { + status: (): Promise<{ linkedAt: number }> => + post('/api/v2/fedy/status'), + link: (nonce: string): Promise<{ linkedAt: number }> => + post('/api/v2/fedy/link', { nonce }), + unlink: () => + post('/api/v2/fedy/unlink'), +} + // @ts-ignore -window.sdk = { USER, USERBOX, CARD, GAME, DATA, SETTING, TRANSFER } +window.sdk = { USER, USERBOX, CARD, GAME, DATA, SETTING, TRANSFER, FEDY } diff --git a/config/application.properties b/config/application.properties index b191d170..bcda3649 100644 --- a/config/application.properties +++ b/config/application.properties @@ -131,6 +131,11 @@ server.error.whitelabel.enabled=false aqua-net.frontier.enabled=false aqua-net.frontier.ftk=0x00 +## Fedy Settings +aqua-net.fedy.enabled=false +aqua-net.fedy.key=maigo +aqua-net.fedy.remote=http://localhost:2528/api/fedy + ## APIs for bot management aqua-net.bot.enabled=true aqua-net.bot.secret=hunter2 diff --git a/src/main/java/icu/samnyan/aqua/net/Fedy.kt b/src/main/java/icu/samnyan/aqua/net/Fedy.kt new file mode 100644 index 00000000..c09039b7 --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/net/Fedy.kt @@ -0,0 +1,203 @@ +package icu.samnyan.aqua.net + +import ext.* +import icu.samnyan.aqua.sega.general.service.CardService +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration +import org.springframework.web.bind.annotation.RestController +import java.security.MessageDigest +import icu.samnyan.aqua.net.db.AquaNetUserRepo +import icu.samnyan.aqua.net.db.AquaNetUserFedyRepo +import icu.samnyan.aqua.net.utils.SUCCESS +import icu.samnyan.aqua.net.components.JWT +import icu.samnyan.aqua.net.db.AquaNetUserFedy +import icu.samnyan.aqua.net.db.AquaNetUser +import icu.samnyan.aqua.net.games.ImportController +import icu.samnyan.aqua.net.games.mai2.Mai2Import +import icu.samnyan.aqua.net.games.ExportOptions +import icu.samnyan.aqua.sega.maimai2.handler.UploadUserPlaylogHandler as Mai2UploadUserPlaylogHandler +import icu.samnyan.aqua.sega.maimai2.handler.UpsertUserAllHandler as Mai2UpsertUserAllHandler +import icu.samnyan.aqua.net.utils.ApiException +import java.util.Arrays +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo +import icu.samnyan.aqua.net.games.GenericUserDataRepo +import icu.samnyan.aqua.net.games.IUserData + +@Configuration +@ConfigurationProperties(prefix = "aqua-net.fedy") +class FedyProps { + var enabled: Boolean = false + var key: String = "" + var remote: String = "" +} + +enum class FedyEvent { + Linked, + Unlinked, + Upserted, + Imported, +} + +@RestController +@ConditionalOnProperty("aqua-net.fedy.enabled", havingValue = "true") +@API("/api/v2/fedy") +class Fedy( + val jwt: JWT, + val userRepo: AquaNetUserRepo, + val userFedyRepo: AquaNetUserFedyRepo, + val mai2Import: Mai2Import, + val mai2UserDataRepo: Mai2UserDataRepo, + val mai2UploadUserPlaylog: Mai2UploadUserPlaylogHandler, + val mai2UpsertUserAll: Mai2UpsertUserAllHandler, + val props: FedyProps, + val transactionManager: PlatformTransactionManager +) { + val transaction by lazy { TransactionTemplate(transactionManager) } + + private fun Str.checkKey() { + if (!MessageDigest.isEqual(this.toByteArray(), props.key.toByteArray())) 403 - "Invalid Key" + } + + @API("/status") + fun handleStatus(@RP token: Str): Any { + val user = jwt.auth(token) + val userFedy = userFedyRepo.findByAquaNetUserAuId(user.auId) + return mapOf("linkedAt" to (userFedy?.createdAt?.toEpochMilli() ?: 0)) + } + + @API("/link") + fun handleLink(@RP token: Str, @RP nonce: Str): Any { + val user = jwt.auth(token) + + if (userFedyRepo.findByAquaNetUserAuId(user.auId) != null) 412 - "User already linked" + val userFedy = AquaNetUserFedy(aquaNetUser = user) + userFedyRepo.save(userFedy) + + notifyRemote(FedyEvent.Linked, mapOf("auId" to user.auId, "nonce" to nonce)) + return mapOf("linkedAt" to userFedy.createdAt.toEpochMilli()) + } + + @API("/unlink") + fun handleUnlink(@RP token: Str): Any { + val user = jwt.auth(token) + + val userFedy = userFedyRepo.findByAquaNetUserAuId(user.auId) ?: 412 - "User not linked" + userFedyRepo.delete(userFedy) + + notifyRemote(FedyEvent.Unlinked, mapOf("auId" to user.auId)) + return SUCCESS + } + + private fun ensureUser(auId: Long): AquaNetUser { + val userFedy = userFedyRepo.findByAquaNetUserAuId(auId) ?: 404 - "User not linked" + val user = userRepo.findByAuId(auId) ?: 404 - "User not found" + return user + } + + data class UnlinkByRemoteReq(val auId: Long) + @API("/unlink-by-remote") + fun handleUnlinkByRemote(@RH(KEY_HEADER) key: Str, @RB req: UnlinkByRemoteReq): Any { + key.checkKey() + val user = ensureUser(req.auId) + userFedyRepo.deleteByAquaNetUserAuId(user.auId) + // No need to notify remote, because initiated by remote + return SUCCESS + } + + data class PullReq(val auId: Long, val game: Str, val exportOptions: ExportOptions) + @API("/pull") + fun handlePull(@RH(KEY_HEADER) key: Str, @RB req: PullReq): Any { + key.checkKey() + val user = ensureUser(req.auId) + fun catched(block: () -> Any) = + try { mapOf("result" to block()) } + catch (e: ApiException) { mapOf("error" to mapOf("code" to e.code, "message" to e.message.toString())) } + return when (req.game) { + "mai2" -> catched { mai2Import.export(user, req.exportOptions) } + else -> 406 - "Unsupported game" + } + } + + data class PushReq(val auId: Long, val game: Str, val data: JDict, val removeOldData: Bool) + @Suppress("UNCHECKED_CAST") + @API("/push") + fun handlePush(@RH(KEY_HEADER) key: Str, @RB req: PushReq): Any { + key.checkKey() + val user = ensureUser(req.auId) + val extId = user.ghostCard.extId + fun> removeOldData(repo: UserRepo) { + val oldData = repo.findByCard_ExtId(extId) + if (oldData.isPresent) { + log.info("Fedy: Deleting old data for $extId (${req.game})") + repo.delete(oldData.get()); + repo.flush() + } + } + transaction.execute { when (req.game) { + "mai2" -> { + if (req.removeOldData) { removeOldData(mai2UserDataRepo) } + val playlogs = req.data["userPlaylogList"] as List + playlogs.forEach { mai2UploadUserPlaylog.handle(mapOf("userId" to extId, "userPlaylog" to it)) } + val userAll = req.data["upsertUserAll"] as JDict + mai2UpsertUserAll.handle(mapOf("userId" to extId, "upsertUserAll" to userAll)) + } + else -> 406 - "Unsupported game" + } } + + return SUCCESS + } + + fun onUpserted(game: Str, maybeExtId: Any?) = notifyRemote(FedyEvent.Upserted, game, maybeExtId) + fun onImported(game: Str, maybeExtId: Any?) = notifyRemote(FedyEvent.Imported, game, maybeExtId) + + private fun notifyRemote(event: FedyEvent, game: Str, maybeExtId: Any?) { try { + val extId = maybeExtId?.long ?: return + val user = userRepo.findByGhostCardExtId(extId) ?: return + val userFedy = userFedyRepo.findByAquaNetUserAuId(user.auId) ?: return + notifyRemote(event, mapOf("auId" to user.auId, "game" to game)) + } catch (e: Exception) { + log.error("Error handling Fedy on notifyRemote($event, $game, $maybeExtId)", e) + } } + + private fun notifyRemote(event: FedyEvent, body: Any?) { + val MAX_RETRY = 3 + val body = body?.toJson() ?: "{}" + var retry = 0 + var shouldRetry = true + while (retry < MAX_RETRY) { + try { + val response = "${props.remote.trimEnd('/')}/notify/${event.name}".request() + .header("Content-Type" to "application/json") + .header(KEY_HEADER to props.key) + .post(body) + val statusCodeStr = response.statusCode().toString() + val hasError = !statusCodeStr.startsWith("2") + // Check for non-transient errors + if (hasError) { + if (!statusCodeStr.startsWith("5")) { shouldRetry = false } + throw Exception("Failed to notify Fedy event $event with body $body, status code $statusCodeStr") + } + return + } catch (e: Exception) { + retry++ + if (retry >= MAX_RETRY || !shouldRetry) throw e + log.error("Error notifying Fedy event $event with body $body, retrying ($retry/$MAX_RETRY)", e) + } + } + } + + companion object + { + const val KEY_HEADER = "X-Fedy-Key" + val log = logger() + + fun getGameName(gameId: Str) = when (gameId) { + "SDEZ" -> "mai2" + else -> null // Not supported + } + } +} 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 70ff544c..17b90ed6 100644 --- a/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt +++ b/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt @@ -98,6 +98,7 @@ interface AquaNetUserRepo : JpaRepository { fun findByEmailIgnoreCase(email: String): AquaNetUser? fun findByUsernameIgnoreCase(username: String): AquaNetUser? fun findByKeychip(keychip: String): AquaNetUser? + fun findByGhostCardExtId(extId: Long): AquaNetUser? } data class SettingField( diff --git a/src/main/java/icu/samnyan/aqua/net/db/AquaNetUserFedy.kt b/src/main/java/icu/samnyan/aqua/net/db/AquaNetUserFedy.kt new file mode 100644 index 00000000..b7833f7b --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/net/db/AquaNetUserFedy.kt @@ -0,0 +1,29 @@ +package icu.samnyan.aqua.net.db + +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.io.Serializable +import java.time.Instant + +@Entity +@Table(name = "aqua_net_user_fedy") +class AquaNetUserFedy( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0, + + @Column(nullable = false) + var createdAt: Instant = Instant.now(), + + // Linking to the AquaNetUser + @OneToOne + @JoinColumn(name = "auId", referencedColumnName = "auId") + var aquaNetUser: AquaNetUser, +) : Serializable + +@Repository +interface AquaNetUserFedyRepo : JpaRepository { + fun findByAquaNetUserAuId(auId: Long): AquaNetUserFedy? + fun deleteByAquaNetUserAuId(auId: Long): Unit +} diff --git a/src/main/java/icu/samnyan/aqua/net/games/ImportController.kt b/src/main/java/icu/samnyan/aqua/net/games/ImportController.kt index 69d9b8e2..a7d6e697 100644 --- a/src/main/java/icu/samnyan/aqua/net/games/ImportController.kt +++ b/src/main/java/icu/samnyan/aqua/net/games/ImportController.kt @@ -3,6 +3,7 @@ package icu.samnyan.aqua.net.games import ext.* import icu.samnyan.aqua.net.db.AquaNetUser import icu.samnyan.aqua.net.db.AquaUserServices +import icu.samnyan.aqua.net.Fedy import icu.samnyan.aqua.net.utils.AquaNetProps import icu.samnyan.aqua.net.utils.SUCCESS import org.springframework.beans.factory.annotation.Autowired @@ -15,9 +16,10 @@ import java.util.* import kotlin.io.path.Path import kotlin.io.path.writeText import kotlin.reflect.KClass +import org.springframework.context.annotation.Lazy data class ExportOptions( - val playlogSince: String? = null + val playlogAfter: String? = null ) // Import class with renaming @@ -68,6 +70,7 @@ abstract class ImportController, UserModel: @Autowired lateinit var netProps: AquaNetProps @Autowired lateinit var transManager: PlatformTransactionManager val trans by lazy { TransactionTemplate(transManager) } + @Autowired(required = false) @Lazy var fedy: Fedy? = null init { artemisRenames.values.forEach { @@ -144,6 +147,8 @@ abstract class ImportController, UserModel: } } + Fedy.getGameName(game)?.let { fedy?.onImported(it, u.ghostCard.extId) } + SUCCESS } diff --git a/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2Import.kt b/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2Import.kt index fd7a642a..f53da34c 100644 --- a/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2Import.kt +++ b/src/main/java/icu/samnyan/aqua/net/games/mai2/Mai2Import.kt @@ -54,8 +54,8 @@ class Mai2Import( ), customExporters = mapOf( Maimai2DataExport::userPlaylogList to { user: Mai2UserDetail, options: ExportOptions -> - if (options.playlogSince != null) { - repos.userPlaylog.findByUserAndUserPlayDateAfter(user, options.playlogSince) + if (options.playlogAfter != null) { + repos.userPlaylog.findByUserAndUserPlayDateAfter(user, options.playlogAfter) } else { repos.userPlaylog.findByUser(user) } diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt index abf15797..e88f7371 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt @@ -14,6 +14,9 @@ import jakarta.servlet.http.HttpServletRequest import org.springframework.web.bind.annotation.* import java.time.format.DateTimeFormatter import kotlin.reflect.full.declaredMemberProperties +import icu.samnyan.aqua.net.Fedy +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy /** * @author samnyan (privateamusement@protonmail.com) @@ -37,6 +40,8 @@ class Maimai2ServletController( val net: Maimai2, ): MeowApi(serialize = { _, resp -> if (resp is String) resp else resp.toJson() }) { + @Autowired(required = false) @Lazy var fedy: Fedy? = null + companion object { private val log = logger() private val empty = listOf() @@ -89,6 +94,9 @@ class Maimai2ServletController( val ctx = RequestContext(req, data.mut) serialize(api, handlers[api]!!(ctx) ?: noop).also { log.info("$token : $api > ${it.truncate(500)}") + if (api == "UpsertUserAllApi") { + fedy?.onUpserted("mai2", data["userId"]) + } } } } catch (e: Exception) { diff --git a/src/main/resources/db/40/V1000_41__add_aquanet_user_fedy.sql b/src/main/resources/db/40/V1000_41__add_aquanet_user_fedy.sql new file mode 100644 index 00000000..14b4578f --- /dev/null +++ b/src/main/resources/db/40/V1000_41__add_aquanet_user_fedy.sql @@ -0,0 +1,9 @@ +CREATE TABLE aqua_net_user_fedy +( + id BIGINT AUTO_INCREMENT NOT NULL, + created_at datetime NOT NULL, + au_id BIGINT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_fedy_on_aqua_net_user FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT unq_fedy_on_aqua_net_user UNIQUE (au_id) +);