[+] Data support APIs (#151)

This commit is contained in:
Menci 2025-07-04 12:01:32 +08:00 committed by GitHub
parent 068b6179e5
commit d79a4e5499
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 273 additions and 4 deletions

View File

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

View File

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

View File

@ -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<UserData : IUserData, UserRepo : GenericUserDataRepo<UserData>> 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<JDict>
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
}
}
}

View File

@ -98,6 +98,7 @@ interface AquaNetUserRepo : JpaRepository<AquaNetUser, Long> {
fun findByEmailIgnoreCase(email: String): AquaNetUser?
fun findByUsernameIgnoreCase(username: String): AquaNetUser?
fun findByKeychip(keychip: String): AquaNetUser?
fun findByGhostCardExtId(extId: Long): AquaNetUser?
}
data class SettingField(

View File

@ -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<AquaNetUserFedy, Long> {
fun findByAquaNetUserAuId(auId: Long): AquaNetUserFedy?
fun deleteByAquaNetUserAuId(auId: Long): Unit
}

View File

@ -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<ExportModel: IExportClass<UserModel>, 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<ExportModel: IExportClass<UserModel>, UserModel:
}
}
Fedy.getGameName(game)?.let { fedy?.onImported(it, u.ghostCard.extId) }
SUCCESS
}

View File

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

View File

@ -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<Any>()
@ -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) {

View File

@ -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)
);