AquaTrans and stuff (#131)

This commit is contained in:
Azalea
2025-03-21 18:35:01 -04:00
committed by GitHub
82 changed files with 1481 additions and 1455 deletions

View File

@@ -11,6 +11,7 @@
import { pfp, tooltip } from "./libs/ui" import { pfp, tooltip } from "./libs/ui"
import { ANNOUNCEMENT } from "./libs/config"; import { ANNOUNCEMENT } from "./libs/config";
import { t } from "./libs/i18n"; import { t } from "./libs/i18n";
import Transfer from "./pages/Transfer/Transfer.svelte";
console.log(`%c console.log(`%c
┏━┓ ┳━┓━┓┏━ ┏━┓ ┳━┓━┓┏━
@@ -76,6 +77,7 @@
<Route path="/u/:username/:game" component={UserHome} /> <Route path="/u/:username/:game" component={UserHome} />
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
<Route path="/pictures" component={MaiPhoto} /> <Route path="/pictures" component={MaiPhoto} />
<Route path="/transfer" component={Transfer} />
</Router> </Router>
<style lang="sass"> <style lang="sass">

View File

@@ -12,6 +12,11 @@
export let confirm: ConfirmProps | null = null export let confirm: ConfirmProps | null = null
export let error: string | null export let error: string | null
export let loading: boolean = false export let loading: boolean = false
function doConfirm(fn?: () => void) {
confirm = null
fn && fn()
}
</script> </script>
{#if confirm} {#if confirm}
@@ -22,15 +27,9 @@
<div class="actions"> <div class="actions">
{#if confirm.cancel} {#if confirm.cancel}
<!-- Svelte LSP is very annoying here --> <button on:click={() => doConfirm(confirm?.cancel)}>{t('action.cancel')}</button>
<button on:click={() => {
confirm && confirm.cancel && confirm.cancel()
// Set to null
confirm = null
}}>{t('action.cancel')}</button>
{/if} {/if}
<button on:click={() => confirm && confirm.confirm()} class:error={confirm.dangerous}>{t('action.confirm')}</button> <button on:click={() => doConfirm(confirm?.confirm)} class:error={confirm.dangerous}>{t('action.confirm')}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@
import StatusOverlays from "../StatusOverlays.svelte"; import StatusOverlays from "../StatusOverlays.svelte";
import { GAME } from "../../libs/sdk"; import { GAME } from "../../libs/sdk";
import GameSettingFields from "./GameSettingFields.svelte"; import GameSettingFields from "./GameSettingFields.svelte";
import { download } from "../../libs/ui";
const profileFields = [ const profileFields = [
['name', t('settings.mai2.name')], ['name', t('settings.mai2.name')],
@@ -42,15 +43,6 @@
.catch(e => error = e.message) .catch(e => error = e.message)
.finally(() => submitting = "") .finally(() => submitting = "")
} }
function download(data: string, filename: string) {
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
}
</script> </script>
<div class="fields" out:fade={FADE_OUT} in:fade={FADE_IN}> <div class="fields" out:fade={FADE_OUT} in:fade={FADE_IN}>

View File

@@ -4,13 +4,16 @@
import { DISCORD_INVITE } from "../../libs/config"; import { DISCORD_INVITE } from "../../libs/config";
export let error: string; export let error: string;
export let expected: boolean = false;
</script> </script>
<div class="overlay" transition:fade> <div class="overlay" transition:fade>
<div> <div>
<h2 class="error">{t('status.error')}</h2> <h2 class="error">{t('status.error')}</h2>
<span>{t('status.error.hint')}<a href={DISCORD_INVITE}>{t('status.error.hint.link')}</a></span> {#if !expected}
<span>{t('status.detail', { detail: error })}</span> <span>{t('status.error.hint')}<a href={DISCORD_INVITE}>{t('status.error.hint.link')}</a></span>
{/if}
<span class="detail">{error}</span>
<div class="actions"> <div class="actions">
<button on:click={() => location.reload()} class="error"> <button on:click={() => location.reload()} class="error">
@@ -27,4 +30,10 @@
button button
width: 100% width: 100%
.detail
white-space: pre-line
font-size: 0.9em
line-height: 1.2
opacity: 0.8
</style> </style>

View File

@@ -1,3 +1,5 @@
export type Dict = Record<string, any>
export interface TrendEntry { export interface TrendEntry {
date: string date: string
rating: number rating: number
@@ -48,7 +50,7 @@ export interface CardSummary {
export interface ConfirmProps { export interface ConfirmProps {
title: string title: string
message: string message: string
confirm: () => void confirm?: () => void
cancel?: () => void cancel?: () => void
dangerous?: boolean dangerous?: boolean
} }

View File

@@ -8,13 +8,14 @@ import type {
TrendEntry, TrendEntry,
AquaNetUser, GameOption, AquaNetUser, GameOption,
UserBox, UserBox,
UserItem UserItem,
Dict
} from './generalTypes' } from './generalTypes'
import type { GameName } from './scoring' import type { GameName } from './scoring'
interface RequestInitWithParams extends RequestInit { interface ExtReqInit extends RequestInit {
params?: { [index: string]: string } params?: { [index: string]: string }
localCache?: boolean json?: any
} }
/** /**
@@ -37,188 +38,113 @@ export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) =>
/** /**
* Fetch with url parameters * Fetch with url parameters
*/ */
export function fetchWithParams(input: URL | RequestInfo, init?: RequestInitWithParams): Promise<Response> { export function fetchWithParams(input: URL | RequestInfo, init?: ExtReqInit): Promise<Response> {
return fetch(reconstructUrl(input, u => { return fetch(reconstructUrl(input, u => {
u.search = new URLSearchParams(init?.params ?? {}).toString() u.search = new URLSearchParams(init?.params ?? {}).toString()
}), init) }), init)
} }
const cache: { [index: string]: any } = {} /**
* Do something with the response when it's not ok
*
* @param res Response object
*/
async function ensureOk(res: Response) {
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
export async function post(endpoint: string, params: Record<string, any> = {}, init?: RequestInitWithParams): Promise<any> { // If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
}
/**
* Post to an endpoint and return the response in JSON while doing error checks
* and handling token (and token expiry) automatically.
*
* @param endpoint The endpoint to post to (e.g., '/pull')
* @param params An object containing the request body or any necessary parameters
* @param init Additional fetch/init configuration
* @returns The JSON response from the server
*/
export async function post(endpoint: string, params: Dict = {}, init?: ExtReqInit): Promise<any> {
return postHelper(endpoint, params, init).then(it => it.json())
}
/**
* Actual impl of post(). This does not return JSON but returns response object.
*/
async function postHelper(endpoint: string, params: Dict = {}, init?: ExtReqInit): Promise<any> {
// Add token if exists // Add token if exists
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token } if (token && !('token' in params)) params = { ...(params ?? {}), token }
if (init?.localCache) { if (init?.json) {
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] init.body = JSON.stringify(init.json)
if (cached) return cached init.headers = { 'Content-Type': 'application/json', ...init.headers }
init.json = undefined
} }
const res = await fetchWithParams(AQUA_HOST + endpoint, { const res = await fetchWithParams(AQUA_HOST + endpoint, { method: 'POST', params, ...init })
method: 'POST', .catch(e => { console.error(e); throw new Error("Network error") })
params, await ensureOk(res)
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) { return res
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
const ret = res.json()
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
return ret
} }
export async function get(endpoint: string, params:any,init?: RequestInitWithParams): Promise<any> { const decoder = new TextDecoder()
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
if (init?.localCache) { /**
const cached = cache[endpoint + JSON.stringify(init)] * Post with a stream response. Similar to post(), but the response will stream messages to onChunk.
if (cached) return cached */
export async function postStream(endpoint: string, params: Dict = {}, onChunk: (data: any) => void, init?: ExtReqInit): Promise<void> {
const res = await postHelper(endpoint, params, init)
if (!res.body) {
console.error('Response body is not a stream')
return
} }
const res = await fetchWithParams(AQUA_HOST + endpoint, { // The response body is a ReadableStream. We'll read chunks as they arrive.
method: 'GET', const reader = res.body?.getReader()
params, if (!reader) return
...init let buffer = ''
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) { try {
const text = await res.text() while (true) {
console.error(`${res.status}: ${text}`) const { done, value } = await reader.read()
if (done) break
// If 400 invalid token is caught, should invalidate the token and redirect to signin // Decode any new data, parse full lines, keep the rest in buffer
if (text === 'Invalid token') { buffer += decoder.decode(value, { stream: true })
localStorage.removeItem('token') let fullLines = buffer.split('\n')
window.location.href = '/' buffer = fullLines.pop() ?? ''
for (const line of fullLines) {
if (!line.trim()) continue // skip empty lines
onChunk(JSON.parse(line))
}
} }
// Try to parse as json // If there's leftover data in 'buffer' after stream ends, parse
let json if (buffer.trim())
try { onChunk(JSON.parse(buffer.trim()))
json = JSON.parse(text)
} catch (e) { } finally {
throw new Error(text) reader.releaseLock()
}
if (json.error) throw new Error(json.error)
} }
const ret = res.json()
cache[endpoint + JSON.stringify(init)] = ret
return ret
}
export async function put(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
if (cached) return cached
}
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'PUT',
body: JSON.stringify(params),
headers:{
'Content-Type':'application/json',
...init?.headers
},
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
const ret = res.json()
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
return ret
}
export async function realPost(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'POST',
body: JSON.stringify(params),
headers:{
'Content-Type':'application/json',
...init?.headers
},
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
return res.json()
} }
/** /**
@@ -229,6 +155,7 @@ export async function realPost(endpoint: string, params: any, init?: RequestInit
async function register(user: { username: string, email: string, password: string, turnstile: string }) { async function register(user: { username: string, email: string, password: string, turnstile: string }) {
return await post('/api/v2/user/register', user) return await post('/api/v2/user/register', user)
} }
async function login(user: { email: string, password: string, turnstile: string }) { async function login(user: { email: string, password: string, turnstile: string }) {
const data = await post('/api/v2/user/login', user) const data = await post('/api/v2/user/login', user)
@@ -263,11 +190,11 @@ export const USER = {
export const USERBOX = { export const USERBOX = {
getProfile: (): Promise<{ user: UserBox, items: UserItem[] }> => getProfile: (): Promise<{ user: UserBox, items: UserItem[] }> =>
get('/api/v2/game/chu3/user-box', {}), post('/api/v2/game/chu3/user-box', {}),
setUserBox: (d: { field: string, value: number | string }) => setUserBox: (d: { field: string, value: number | string }) =>
post(`/api/v2/game/chu3/user-detail-set`, d), post(`/api/v2/game/chu3/user-detail-set`, d),
getUserProfile: (username: string): Promise<UserBox> => getUserProfile: (username: string): Promise<UserBox> =>
get(`/api/v2/game/chu3/user-detail`, {username}) post(`/api/v2/game/chu3/user-detail`, {username})
} }
export const CARD = { export const CARD = {
@@ -295,9 +222,9 @@ export const GAME = {
export: (game: GameName): Promise<Record<string, any>> => export: (game: GameName): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/export`), post(`/api/v2/game/${game}/export`),
import: (game: GameName, data: any): Promise<Record<string, any>> => import: (game: GameName, data: any): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/import`, {}, { body: JSON.stringify(data) }), post(`/api/v2/game/${game}/import`, {}, { json: data }),
importMusicDetail: (game: GameName, data: any): Promise<Record<string, any>> => importMusicDetail: (game: GameName, data: any): Promise<Record<string, any>> =>
post(`/api/v2/game/${game}/import-music-detail`, {}, {body: JSON.stringify(data), headers: {'Content-Type': 'application/json'}}), post(`/api/v2/game/${game}/import-music-detail`, {}, { json: data }),
setRival: (game: GameName, rivalUserName: string, isAdd: boolean) => setRival: (game: GameName, rivalUserName: string, isAdd: boolean) =>
post(`/api/v2/game/${game}/set-rival`, { rivalUserName, isAdd }), post(`/api/v2/game/${game}/set-rival`, { rivalUserName, isAdd }),
} }
@@ -317,3 +244,12 @@ export const SETTING = {
detailSet: (game: string, field: string, value: any) => detailSet: (game: string, field: string, value: any) =>
post(`/api/v2/game/${game}/user-detail-set`, { field, value }), post(`/api/v2/game/${game}/user-detail-set`, { field, value }),
} }
export const TRANSFER = {
check: (d: AllNetClient): Promise<TrCheckGood> =>
post('/api/v2/transfer/check', {}, { json: d }),
pull: (d: AllNetClient, callback: (data: TrStreamMessage) => void) =>
postStream('/api/v2/transfer/pull', {}, callback, { json: d }),
push: (d: AllNetClient, data: string) =>
post('/api/v2/transfer/push', {}, { json: { client: d, data } }),
}

View File

@@ -212,3 +212,53 @@ export function pfp(node: HTMLImageElement, me?: AquaNetUser) {
node.src = me?.profilePicture ? `${AQUA_HOST}/uploads/net/portrait/${me.profilePicture}` : DEFAULT_PFP node.src = me?.profilePicture ? `${AQUA_HOST}/uploads/net/portrait/${me.profilePicture}` : DEFAULT_PFP
node.onerror = e => pfpNotFound(e as Event) node.onerror = e => pfpNotFound(e as Event)
} }
export function download(data: string, filename: string) {
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
}
export async function selectJsonFile(): Promise<any> {
return new Promise((resolve, reject) => {
// Create a hidden file input element
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.style.display = 'none';
// Listen for when the user selects a file
input.addEventListener('change', (event: Event) => {
const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) {
return reject(new Error("No file selected"));
}
const file = target.files[0];
const reader = new FileReader();
reader.onload = () => {
try {
const jsonData = JSON.parse(reader.result as string);
resolve(jsonData);
} catch (error) {
reject(new Error("Error parsing JSON: " + error));
}
};
reader.onerror = () => {
reject(new Error("Error reading file"));
};
reader.readAsText(file);
});
// Append the input to the DOM, trigger click, and then remove it
document.body.appendChild(input);
input.click();
document.body.removeChild(input);
});
}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
export let desc: string
export let value: string
export let placeholder: string
export let flex: number = 60
export let disabled: boolean = false
export let validate: (value: string) => boolean = () => true
</script>
<div class="field" style="flex: {flex}">
<label for={desc}>{desc}</label>
<input type="text" placeholder={placeholder} bind:value={value} id="{desc}" on:change
class:error={value && !validate(value)} {disabled}/>
</div>
<style lang="sass">
.field
display: inline-flex
flex-direction: column
gap: 0.5rem
label
font-weight: bold
</style>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { t, ts } from "../../libs/i18n";
import TransferServer from "./TransferServer.svelte";
import { DATA_HOST } from "../../libs/config";
import type { ConfirmProps } from "../../libs/generalTypes";
import StatusOverlays from "../../components/StatusOverlays.svelte";
let tabs = ['chu3', 'mai2', 'ongeki']
let game: Record<string, { game: string, version: string }> = {
'chu3': { game: "SDHD", version: "2.30" },
'mai2': { game: "SDGA", version: "1.50" },
'ongeki': { game: "SDDT", version: "1.45" }
}
let tab = 0
let src = JSON.parse(localStorage.getItem('src') ?? `{"dns": "", "card": "", "keychip": ""}`)
let dst = JSON.parse(localStorage.getItem('dst') ?? `{"dns": "", "card": "", "keychip": ""}`)
let [srcTested, dstTested] = [false, false]
let gameInfo = JSON.parse(localStorage.getItem('gameInfo') ?? `{"game": "", "version": ""}`)
let srcEl: TransferServer, dstEl: TransferServer
let srcExportedData: string
let [error, loading] = ["", false]
let confirm: ConfirmProps | null = null
function defaultGame() {
gameInfo.game = game[tabs[tab]].game
gameInfo.version = game[tabs[tab]].version
}
function onChange() {
localStorage.setItem('src', JSON.stringify(src))
localStorage.setItem('dst', JSON.stringify(dst))
localStorage.setItem('gameInfo', JSON.stringify(gameInfo))
}
function actuallyStartTransfer() {
srcEl.pull()
.then(() => dstEl.push(srcExportedData))
.then(() => confirm = {
title: "Done!",
message: `Transfer completed successfully! Your data on ${dst.dns} is overwritten with your data from ${src.dns}.`
})
.catch(e => error = e)
.finally(() => loading = false)
}
function startTransfer() {
if (!(srcTested && dstTested)) return alert("Please test both servers first!")
if (loading) return alert("Transfer already in progress!")
console.log("Starting transfer...")
loading = true
if (dstEl.exportedData) return actuallyStartTransfer()
// Ask user to make sure to backup their data
confirm = {
title: "Confirm transfer",
message: "It seems like you haven't backed up your destination data. Are you sure you want to proceed? (This will overwrite your destination server's data)",
dangerous: true,
confirm: actuallyStartTransfer,
cancel: () => { loading = false }
}
}
defaultGame()
</script>
<StatusOverlays bind:confirm={confirm} {error} />
<main class="content">
<div class="outer-title-options">
<h2>🏳️‍⚧️ AquaTrans™ Data Transfer?</h2>
<nav>
{#each tabs as tabName, i}
<div transition:slide={{axis: 'x'}} class:active={tab === i}
on:click={() => tab = i} on:keydown={e => e.key === 'Enter' && (tab = i)}
role="button" tabindex="0">
{ts(`settings.tabs.${tabName}`)}
</div>
{/each}
</nav>
</div>
<div class="prompt">
<p>👋 Welcome to the AquaTrans™ server data transfer tool!</p>
<p>You can use this to export data from any server, and input data into any server using the connection credentials (card number, server address, and keychip id).</p>
<p>This tool will simulate a game client and pull your data from the source server, and push your data to the destination server.</p>
<p>Please fill out the info below to get started!</p>
</div>
<TransferServer bind:src={src} bind:gameInfo={gameInfo} on:change={onChange}
bind:tested={srcTested} bind:this={srcEl} bind:exportedData={srcExportedData} />
<div class="arrow" class:disabled={!(srcTested && dstTested)}>
<img src="{DATA_HOST}/d/DownArrow.png" alt="arrow" on:click={startTransfer}>
</div>
<TransferServer bind:src={dst} bind:gameInfo={gameInfo} on:change={onChange}
bind:tested={dstTested} bind:this={dstEl} isSrc={false} />
</main>
<style lang="sass">
.arrow
width: 100%
display: flex
justify-content: center
margin-top: -40px
margin-bottom: -40px
z-index: 1
&.disabled
filter: grayscale(1)
// CSS animation to let the image opacity breathe
img
animation: breathe 1s infinite alternate
@keyframes breathe
0%
opacity: 0.5
100%
opacity: 1
</style>

View File

@@ -0,0 +1,21 @@
interface AllNetSrc {
card: string
dns: string
keychip: string
}
interface AllNetGame {
game: string
version: string
}
interface AllNetClient extends AllNetSrc, AllNetGame {}
interface TrCheckGood {
gameUrl: string
userId: number
}
type TrStreamMessage = { message: string } | { error: string } | { data: string }

View File

@@ -0,0 +1,198 @@
<script lang="ts">
import StatusOverlays from "../../components/StatusOverlays.svelte";
import { TRANSFER } from "../../libs/sdk";
import { download, selectJsonFile } from "../../libs/ui";
import InputTextShort from "./InputTextShort.svelte";
export let src: AllNetSrc
export let gameInfo: AllNetGame
export let isSrc: boolean = true
export let tested: boolean = false
let [loading, error, expectedError] = [false, "", ""]
function testConnection() {
if (loading) return
// Preliminiary checks
if (!src.dns || !src.keychip || !src.card || !gameInfo.game || !gameInfo.version) {
error = "Please fill out all fields"
return
}
loading = true
console.log("Testing connection...")
TRANSFER.check({...src, ...gameInfo}).then(res => {
console.log("Connection test result:", res)
tested = true
}).catch(err => expectedError = err.message).finally(() => loading = false)
}
let messages: string[] = []
export let exportedData: string = ""
export function pull(): Promise<string> {
return new Promise<string>((resolve, reject) => {
if (loading || !tested) return reject("Please test connection first")
if (exportedData) return resolve(exportedData)
console.log("Exporting data...")
TRANSFER.pull({...src, ...gameInfo}, (msg: TrStreamMessage) => {
console.log("Export progress: ", JSON.stringify(msg))
if ('message' in msg) messages = [...messages, msg.message]
if ('error' in msg) {
expectedError = msg.error
reject(msg.error)
}
if ('data' in msg) {
// file name: Export YYYY-MM-DD {server host} {game} {card last 6}.json
let date = new Date().toISOString().split('T')[0]
let host = new URL(src.dns).hostname
download(msg.data, `Export ${date} ${host} ${gameInfo.game} ${src.card.slice(-6)}.json`)
exportedData = msg.data
resolve(msg.data)
}
}).catch(err => { expectedError = err; reject(err) })
})
}
function pushBtn() {
if (loading || !tested) return
selectJsonFile().then(obj => push(JSON.stringify(obj)))
}
export function push(data: string) {
if (loading || !tested) return
console.log("Import data...")
loading = true
return TRANSFER.push({...src, ...gameInfo}, data).then(() => {
console.log("Data imported successfully")
messages = ["Data imported successfully"]
}).catch(err => expectedError = err.message).finally(() => loading = false)
}
</script>
<StatusOverlays {loading} {error} />
<div class="server source" class:src={isSrc} class:hasError={expectedError} class:tested={tested}>
<h3>{isSrc ? "Source" : "Target"} Server</h3>
{#if expectedError}
<blockquote class="error-msg">{expectedError}</blockquote>
{/if}
<!-- First input line -->
<div class="inputs">
<InputTextShort desc="Server Address" placeholder="e.g. http://aquadx.hydev.org"
bind:value={src.dns} on:change validate={v => /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(v)} disabled={tested} />
<InputTextShort desc="Keychip ID" placeholder="e.g. A0299792458"
bind:value={src.keychip} on:change validate={v => /^([A-Z0-9]{11}|[A-Z0-9]{4}-[A-Z0-9]{11})$/.test(v)} disabled={tested} />
</div>
<!-- Second input line -->
<div class="inputs">
<div class="game-version">
<InputTextShort desc="Game" placeholder="e.g. SDHD"
bind:value={gameInfo.game} on:change disabled={tested} />
<InputTextShort desc="Version" placeholder="e.g. 2.30"
bind:value={gameInfo.version} on:change disabled={tested} />
</div>
<InputTextShort desc="Card Number" placeholder="e.g. 27182818284590452353"
bind:value={src.card} on:change disabled={tested} />
</div>
<!-- Streaming messages -->
{#if messages.length > 0}
<div class="stream-messages">
{#each messages.slice(Math.max(messages.length - 5, 0), undefined) as msg}
<p>{msg}</p>
{/each}
</div>
{/if}
<!-- Buttons -->
<div class="inputs buttons">
{#if !tested}
<button class="flex-1" on:click={testConnection} disabled={loading}>Test Connection</button>
{:else}
<button class="flex-1" on:click={pull}>Export Data</button>
<button class="flex-1" on:click={pushBtn}>Import Data</button>
{/if}
</div>
</div>
<style lang="sass">
@use "../../vars"
@use "sass:color"
.error-msg
white-space: pre-wrap
margin: 0
.server
display: flex
flex-direction: column
gap: 1rem
// --c-src: 202, 168, 252
--c-src: 179, 198, 255
// animation: hue-rotate 10s infinite linear
// &.src
// --c-src: 173, 192, 247
// animation: hue-rotate 10s infinite linear reverse
&.tested
--c-src: 169, 255, 186
&.hasError
--c-src: 255, 174, 174
animation: none
padding: 1rem
border-radius: vars.$border-radius
// background-color: vars.$ov-light
background: #252525
// Pink outline
border: 1px solid rgba(var(--c-src), 0.5)
box-shadow: 0 0 1rem 0 rgba(var(--c-src), 0.25)
h3
margin: 0
font-size: 1.5rem
text-align: center
// @keyframes hue-rotate
// 0%
// filter: hue-rotate(0deg)
// 100%
// filter: hue-rotate(360deg)
.inputs
display: flex
flex-wrap: wrap
gap: 1rem
.game-version
flex: 60
display: flex
gap: 1rem
:global(> *)
width: 100px
&.buttons
margin-top: 0.5rem
.stream-messages
font-size: 0.8rem
opacity: 0.8
margin-top: 0.5rem
padding: 0 0.5rem
</style>

View File

@@ -4,7 +4,7 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
plugins { plugins {
val ktVer = "2.1.0" val ktVer = "2.1.10"
java java
kotlin("plugin.lombok") version ktVer kotlin("plugin.lombok") version ktVer
@@ -117,6 +117,10 @@ springBoot {
mainClass.set("icu.samnyan.aqua.EntryKt") mainClass.set("icu.samnyan.aqua.EntryKt")
} }
application {
mainClass = "icu.samnyan.aqua.EntryKt"
}
hibernate { hibernate {
enhancement { enhancement {
enableLazyInitialization = true enableLazyInitialization = true

View File

@@ -14,7 +14,7 @@ and change `allnet.server.host` to your LAN IP address (e.g. 192.168.0.?). You c
> [!NOTE] > [!NOTE]
> The guide above will create a new MariaDB database. > The guide above will create a new MariaDB database.
> If you were using SQLite Aqua before, it is not supported in AquaDX. Please export your data and import it to MariaDB. > If you were using SQLite Aqua before, it is not supported in AquaDX. Please export your data and import it to your new instance.
> If you were using MySQL Aqua before, you can migrate to MariaDB using [this guide here](docs/mysql_to_mariadb.md). > If you were using MySQL Aqua before, you can migrate to MariaDB using [this guide here](docs/mysql_to_mariadb.md).
### Configuration ### Configuration
@@ -42,3 +42,9 @@ docker compose up
### Building ### Building
You need to install JDK 21 on your system, then run `./gradlew clean build`. The jar file will be built into the `build/libs` folder. You need to install JDK 21 on your system, then run `./gradlew clean build`. The jar file will be built into the `build/libs` folder.
## Why drop SQLite support?
If you wonder why I dropped SQLite support, ask SQLite devs why they still haven't supported adding a single constraint to a table without all the hassle of creating a new one and migrating all data over and finally deleting the original.
![](sqlite-sucks.png)

BIN
docs/sqlite-sucks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

View File

@@ -192,8 +192,7 @@ val Any?.str get() = toString()
// Collections // Collections
fun <T> ls(vararg args: T) = args.toList() fun <T> ls(vararg args: T) = args.toList()
inline fun <reified T> arr(vararg args: T) = arrayOf(*args) inline fun <reified T> arr(vararg args: T) = arrayOf(*args)
operator fun <K, V> Map<K, V>.plus(map: Map<K, V>) = operator fun <K, V> Map<K, V>.plus(map: Map<K, V>) = mut.apply { putAll(map) }
(if (this is MutableMap) this else mut).apply { putAll(map) }
operator fun <K, V> MutableMap<K, V>.plusAssign(map: Map<K, V>) { putAll(map) } operator fun <K, V> MutableMap<K, V>.plusAssign(map: Map<K, V>) { putAll(map) }
fun <K, V: Any> Map<K, V?>.vNotNull(): Map<K, V> = filterValues { it != null }.mapValues { it.value!! } fun <K, V: Any> Map<K, V?>.vNotNull(): Map<K, V> = filterValues { it != null }.mapValues { it.value!! }
fun <T> MutableList<T>.popAll(list: List<T>) = list.also { removeAll(it) } fun <T> MutableList<T>.popAll(list: List<T>) = list.also { removeAll(it) }
@@ -238,6 +237,7 @@ fun Str.path() = Path.of(this)
operator fun Path.div(part: Str) = resolve(part) operator fun Path.div(part: Str) = resolve(part)
operator fun File.div(fileName: Str) = File(this, fileName) operator fun File.div(fileName: Str) = File(this, fileName)
fun Str.ensureEndingSlash() = if (endsWith('/')) this else "$this/" fun Str.ensureEndingSlash() = if (endsWith('/')) this else "$this/"
fun Str.ensureNoEndingSlash() = if (endsWith('/')) dropLast(1) else this
fun <T: Any> T.logger() = LoggerFactory.getLogger(this::class.java) fun <T: Any> T.logger() = LoggerFactory.getLogger(this::class.java)

34
src/main/java/ext/Http.kt Normal file
View File

@@ -0,0 +1,34 @@
package ext
import icu.samnyan.aqua.sega.util.ZLib
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
val client = HttpClient.newBuilder().build()
fun HttpRequest.Builder.send() = client.send(this.build(), HttpResponse.BodyHandlers.ofByteArray())
fun HttpRequest.Builder.header(pair: Pair<Any, Any>) = this.header(pair.first.toString(), pair.second.toString())
fun String.request() = HttpRequest.newBuilder(URI.create(this))
fun HttpRequest.Builder.post(body: Any? = null) = this.POST(when (body) {
is ByteArray -> HttpRequest.BodyPublishers.ofByteArray(body)
is String -> HttpRequest.BodyPublishers.ofString(body)
is HttpRequest.BodyPublisher -> body
else -> throw Exception("Unsupported body type")
}).send()
inline fun <reified T> HttpResponse<String>.json(): T? = body()?.json()
fun HttpRequest.Builder.postZ(body: String) = run {
header("Content-Type" to "application/json")
header("Content-Encoding" to "deflate")
post(ZLib.compress(body.toByteArray()))
}
fun <T> HttpResponse<T>.header(key: String) = headers().firstValue(key).orElse(null)
fun HttpResponse<ByteArray>.bodyString() = body()?.toString(Charsets.UTF_8)
fun HttpResponse<ByteArray>.bodyZ() = body()?.let { ZLib.decompress(it)?.decodeToString() }
fun HttpResponse<ByteArray>.bodyMaybeZ() = if (header("Content-Encoding") == "deflate") bodyZ() else bodyString()

View File

@@ -43,16 +43,18 @@ else JACKSON.readValue(this, cls)
fun <T> T.toJson() = JACKSON.writeValueAsString(this) fun <T> T.toJson() = JACKSON.writeValueAsString(this)
inline fun <reified T> String.json() = try { inline fun <reified T> String.json() = try {
JACKSON.readValue(this, T::class.java) if (isEmpty() || this == "null") null
else JACKSON.readValue(this, T::class.java)
} }
catch (e: Exception) { catch (e: Exception) {
println("Failed to parse JSON: $this") println("Failed to parse JSON: $this")
throw e throw e
} }
fun String.jsonMap(): Map<String, Any?> = json() fun String.jsonMap(): Map<String, Any?> = json() ?: emptyMap()
fun String.jsonArray(): List<Map<String, Any?>> = json() fun String.jsonArray(): List<Map<String, Any?>> = json() ?: emptyList()
fun String.jsonMaybeMap(): Map<String, Any?>? = json()
fun String.jsonMaybeArray(): List<Map<String, Any?>>? = json()
// KotlinX Serialization // KotlinX Serialization
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)

View File

@@ -1,9 +1,9 @@
package icu.samnyan.aqua package icu.samnyan.aqua
import icu.samnyan.aqua.sega.aimedb.AimeDbServer import icu.samnyan.aqua.sega.aimedb.AimeDbServer
import icu.samnyan.aqua.sega.maimai2.worldslink.MaimaiFutari
import icu.samnyan.aqua.spring.AutoChecker import icu.samnyan.aqua.spring.AutoChecker
import org.springframework.boot.SpringApplication import org.springframework.boot.SpringApplication
import org.springframework.boot.ansi.AnsiOutput
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.EnableScheduling
import java.io.File import java.io.File
@@ -13,10 +13,7 @@ import java.io.File
class Entry class Entry
fun main(args: Array<String>) { fun main(args: Array<String>) {
if (args.getOrNull(0) == "futari") { AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
// Run futari main
return MaimaiFutari().start()
}
// If data/ is not found, create it // If data/ is not found, create it
File("data").mkdirs() File("data").mkdirs()

View File

@@ -7,12 +7,12 @@ data class Chu3DataExport(
override var gameId: String = "SDHD", override var gameId: String = "SDHD",
override var userData: Chu3UserData, override var userData: Chu3UserData,
var userGameOption: UserGameOption, var userGameOption: UserGameOption,
var userActivityList: List<UserActivity>, var userActivityList: List<Chu3UserActivity>,
var userCharacterList: List<UserCharacter>, var userCharacterList: List<UserCharacter>,
var userChargeList: List<UserCharge>, var userChargeList: List<UserCharge>,
var userCourseList: List<UserCourse>, var userCourseList: List<UserCourse>,
var userDuelList: List<UserDuel>, var userDuelList: List<UserDuel>,
var userItemList: List<UserItem>, var userItemList: List<Chu3UserItem>,
var userMapList: List<UserMap>, var userMapList: List<UserMap>,
var userMusicDetailList: List<UserMusicDetail>, var userMusicDetailList: List<UserMusicDetail>,
var userPlaylogList: List<UserPlaylog>, var userPlaylogList: List<UserPlaylog>,

View File

@@ -17,13 +17,13 @@ import java.util.List;
public class ChuniDataImport { public class ChuniDataImport {
private String gameId; private String gameId;
private ExternalUserData userData; private ExternalUserData userData;
private List<UserActivity> userActivityList; private List<Chu3UserActivity> userActivityList;
private List<UserCharacter> userCharacterList; private List<UserCharacter> userCharacterList;
private List<UserCharge> userChargeList; private List<UserCharge> userChargeList;
private List<UserCourse> userCourseList; private List<UserCourse> userCourseList;
private List<UserDuel> userDuelList; private List<UserDuel> userDuelList;
private UserGameOption userGameOption; private UserGameOption userGameOption;
private List<UserItem> userItemList; private List<Chu3UserItem> userItemList;
private List<UserMap> userMapList; private List<UserMap> userMapList;
private List<UserMusicDetail> userMusicDetailList; private List<UserMusicDetail> userMusicDetailList;
private List<UserPlaylog> userPlaylogList; private List<UserPlaylog> userPlaylogList;

View File

@@ -31,10 +31,10 @@ class Chu3Import(
artemisRenames = mapOf( artemisRenames = mapOf(
"chuni_item_character" to ImportClass(UserCharacter::class), "chuni_item_character" to ImportClass(UserCharacter::class),
"chuni_item_duel" to ImportClass(UserDuel::class), "chuni_item_duel" to ImportClass(UserDuel::class),
"chuni_item_item" to ImportClass(UserItem::class, mapOf("isValid" to "valid")), "chuni_item_item" to ImportClass(Chu3UserItem::class, mapOf("isValid" to "valid")),
// "chuni_item_login_bonus" to ImportClass(UserLoginBonus::class, mapOf("isWatched" to "watched")), // "chuni_item_login_bonus" to ImportClass(UserLoginBonus::class, mapOf("isWatched" to "watched")),
"chuni_item_map_area" to ImportClass(UserMap::class), "chuni_item_map_area" to ImportClass(UserMap::class),
"chuni_profile_activity" to ImportClass(UserActivity::class, mapOf("activityId" to "id")), "chuni_profile_activity" to ImportClass(Chu3UserActivity::class, mapOf("activityId" to "id")),
"chuni_profile_charge" to ImportClass(UserCharge::class), "chuni_profile_charge" to ImportClass(UserCharge::class),
"chuni_profile_data" to ImportClass(Chu3UserData::class, mapOf("user" to null, "version" to null, "isNetMember" to null)), "chuni_profile_data" to ImportClass(Chu3UserData::class, mapOf("user" to null, "version" to null, "isNetMember" to null)),
"chuni_profile_option" to ImportClass(UserGameOption::class, mapOf("version" to null)), "chuni_profile_option" to ImportClass(UserGameOption::class, mapOf("version" to null)),

View File

@@ -0,0 +1,56 @@
package icu.samnyan.aqua.net.transfer
import ext.header
import ext.post
import ext.request
import icu.samnyan.aqua.sega.aimedb.AimeDbClient
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeAllNetResp
val keychipPattern = Regex("([A-Z\\d]{4}-[A-Z\\d]{11}|[A-Z\\d]{11})")
class AllNetClient(val dns: String, val keychip: String, val game: String, val version: String, val card: String) {
init {
// Check if keychip is valid
// TODO : Use a more appropriate exception
if (!keychipPattern.matches(keychip)) throw Exception("Invalid keychip")
}
override fun toString() = "AllNetClient($dns, $keychip, $game, $version, $card)"
val keychipShort by lazy {
// A123-45678901337 -> A1234567890
if (keychip.length == 11) keychip
else keychip.substring(0, 4) + keychip.substring(5, 12)
}
val aime by lazy { AimeDbClient(game, keychipShort, dns.substringAfter("://").substringBefore(":").substringBefore("/")) }
// Send AllNet PowerOn request to obtain game URL
val gameUrl by lazy {
"$dns/sys/servlet/PowerOn".request()
.header("User-Agent" to "AquaTrans/1.0")
.header("Content-Type" to "application/x-www-form-urlencoded")
.header("Pragma" to "DFI")
.post(AllNetBillingDecoder.encodeAllNet(mapOf(
"game_id" to game,
"ver" to version,
"serial" to keychipShort,
"ip" to "127.0.0.1", "firm_ver" to "60001", "boot_ver" to "0000",
"encode" to "UTF-8", "format_ver" to "3", "hops" to "1", "token" to "2864179931"
)))
?.also {
println(it)
}
?.decodeAllNetResp()?.get("uri")
?: throw Exception("PowerOn Failed: No game URL returned")
}
val userId by lazy { aime.execLookupOrRegister(card) }
fun findDataBroker(log: (String) -> Unit) = when (game) {
"SDHD" -> ChusanDataBroker(this, log)
"SDEZ", "SDGA" -> MaimaiDataBroker(this, log)
"SDDT" -> OngekiDataBroker(this, log)
else -> throw IllegalArgumentException("Unsupported game: $game")
}
}

View File

@@ -0,0 +1,169 @@
package icu.samnyan.aqua.net.transfer
import ext.*
import icu.samnyan.aqua.sega.chusan.model.request.Chu3UserAll
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserActivity
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserItem
import icu.samnyan.aqua.sega.chusan.model.userdata.UserMusicDetail
import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserAll
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserFavorite
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserItem
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserMusicDetail
import icu.samnyan.aqua.sega.ongeki.model.request.UpsertUserAll
import icu.samnyan.aqua.sega.ongeki.model.userdata.UserItem
import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import icu.samnyan.aqua.sega.util.jackson.IMapper
import icu.samnyan.aqua.sega.util.jackson.StringMapper
abstract class DataBroker(
val allNet: AllNetClient,
val log: (String) -> Unit,
) {
abstract val mapper: IMapper
abstract val url: String
inline fun <reified T> String.getNullable(key: String, data: JDict): T? = "$url/$this".request()
.postZ(mapper.write(data))
.bodyMaybeZ()
?.jsonMaybeMap()?.get(key)
?.let { mapper.convert<T>(it) }
?.also {
if (it is List<*>) log("$this: ${it.size}")
else log("$this")
}
inline fun <reified T> String.get(key: String, data: JDict): T = getNullable(key, data) ?: run {
log("$this")
if (this == "GetUserDataApi") 404 - "Failed to get $this (User not found?)"
else 417 - "Failed to get $this"
}
fun prePull(): Pair<Map<String, Long>, MutableMap<String, Long>> {
log("Game URL: ${allNet.gameUrl}")
log("User ID: ${allNet.userId}")
val userId = mapOf("userId" to allNet.userId)
val paged = userId + mapOf("nextIndex" to 0, "maxCount" to 10000000)
return userId to paged
}
abstract fun pull(): String
fun push(data: String) {
log("Pushing data")
"$url/UpsertUserAllApi".request().postZ(mapper.write(mapOf(
"userId" to allNet.userId,
"upsertUserAll" to data.jsonMap()
))).bodyMaybeZ()?.also { log(it) }
}
}
class ChusanDataBroker(allNet: AllNetClient, log: (String) -> Unit): DataBroker(allNet, log) {
override val mapper = StringMapper()
override val url by lazy { "${allNet.gameUrl.ensureEndingSlash()}ChuniServlet" }
class UserMusicWrapper(var userMusicDetailList: List<UserMusicDetail>)
override fun pull(): String {
val (userId, paged) = prePull()
return mapper.write(Chu3UserAll().apply {
userData = ls("GetUserDataApi".get("userData", userId))
userGameOption = ls("GetUserOptionApi".get("userGameOption", userId))
userCharacterList = "GetUserCharacterApi".get("userCharacterList", paged)
userActivityList = (1..5).flatMap {
"GetUserActivityApi".get<List<Chu3UserActivity>>("userActivityList", userId + mapOf("kind" to it))
}
userItemList = (1..12).flatMap {
"GetUserItemApi".get<List<Chu3UserItem>>("userItemList", paged + mapOf("nextIndex" to 10000000000 * it))
}
userRecentRatingList = "GetUserRecentRatingApi".get("userRecentRatingList", userId)
userMusicDetailList = "GetUserMusicApi".get<List<UserMusicWrapper>>("userMusicList", paged)
.flatMap { it.userMusicDetailList }
userCourseList = "GetUserCourseApi".get("userCourseList", paged)
userFavoriteMusicList = "GetUserFavoriteItemApi".get("userFavoriteItemList", paged + mapOf("kind" to 1))
// TODO userMapAreaList = "GetUserMapAreaApi"
// TODO userNetBattleData = ls("GetUserNetBattleDataApi".get("userNetBattleData", userId))
userUnlockChallengeList = "GetUserUCApi".get("userUnlockChallengeList", userId)
})
}
}
class MaimaiDataBroker(allNet: AllNetClient, log: (String) -> Unit): DataBroker(allNet, log) {
override val mapper = BasicMapper()
override val url by lazy { "${allNet.gameUrl.ensureEndingSlash()}Maimai2Servlet" }
class UserMusicWrapper(var userMusicDetailList: List<Mai2UserMusicDetail>)
override fun pull(): String {
val (userId, paged) = prePull()
return Mai2UserAll().apply {
userData = ls("GetUserDataApi".get("userData", userId))
userOption = ls("GetUserOptionApi".get("userOption", userId))
userExtend = ls("GetUserExtendApi".get("userExtend", userId))
userRatingList = ls("GetUserRatingApi".get("userRating", userId))
userActivityList = ls("GetUserActivityApi".get("userActivity", userId))
userMusicDetailList = "GetUserMusicApi".get<List<UserMusicWrapper>>("userMusicList", paged)
.flatMap { it.userMusicDetailList }
userFriendSeasonRankingList = "GetUserFriendSeasonRankingApi".get("userFriendSeasonRankingList", paged)
userCharacterList = "GetUserCharacterApi".get("userCharacterList", paged)
userItemList = (1..12).flatMap {
"GetUserItemApi".get<List<Mai2UserItem>>("userItemList", paged + mapOf("nextIndex" to 10000000000 * it))
}
userCourseList = "GetUserCourseApi".get("userCourseList", paged)
userFavoriteList = (1..5).mapNotNull {
"GetUserFavoriteApi".getNullable<Mai2UserFavorite>("userFavorite", userId + mapOf("itemKind" to it))
}
userGhost = "GetUserGhostApi".get("userGhostList", userId)
userMapList = "GetUserMapApi".get("userMapList", paged)
userLoginBonusList = "GetUserLoginBonusApi".get("userLoginBonusList", userId)
// TODO: userFavoriteMusicList
}.toJson()
}
}
class OngekiDataBroker(allNet: AllNetClient, log: (String) -> Unit): DataBroker(allNet, log) {
override val mapper = BasicMapper()
override val url by lazy { allNet.gameUrl.ensureNoEndingSlash() }
override fun pull(): String {
val (userId, paged) = prePull()
return UpsertUserAll().apply {
userData = ls("GetUserDataApi".get("userData", userId))
userOption = ls("GetUserOptionApi".get("userOption", userId))
userMusicItemList = "GetUserMusicItemApi".get("userMusicItemList", paged)
userBossList = "GetUserBossApi".get("userBossList", userId)
userMusicDetailList = "GetUserMusicApi".get("userMusicList", paged)
userTechCountList = "GetUserTechCountApi".get("userTechCountList", userId)
userCardList = "GetUserCardApi".get("userCardList", paged)
userCharacterList = "GetUserCharacterApi".get("userCharacterList", paged)
userStoryList = "GetUserStoryApi".get("userStoryList", userId)
userChapterList = "GetUserChapterApi".get("userChapterList", userId)
userMemoryChapterList = "GetUserMemoryChapterApi".get("userMemoryChapterList", userId)
userDeckList = "GetUserDeckByKeyApi".get("userDeckList", userId + mapOf("authKey" to ""))
userTrainingRoomList = "GetUserTrainingRoomByKeyApi".get("userTrainingRoomList", userId + mapOf("authKey" to ""))
userActivityList = "GetUserActivityApi".get("userActivityList", userId + mapOf("kind" to 1))
userRatinglogList = "GetUserRatinglogApi".get("userRatinglogList", userId)
userRecentRatingList = "GetUserRecentRatingApi".get("userRecentRatingList", userId)
userItemList = ls(2, 3, 4, 8, 9, 11, 12, 13, 14, 15, 16, 17, 19, 20).flatMap {
"GetUserItemApi".get<List<UserItem>>("userItemList", paged + mapOf("nextIndex" to 10000000000 * it))
}
userEventPointList = "GetUserEventPointApi".get("userEventPointList", userId)
userMissionPointList = "GetUserMissionPointApi".get("userMissionPointList", userId)
userLoginBonusList = "GetUserLoginBonusApi".get("userLoginBonusList", userId)
userScenarioList = "GetUserScenarioApi".get("userScenarioList", userId)
userTradeItemList = "GetUserTradeItemApi".get("userTradeItemList", userId + mapOf("startChapterId" to 0, "endChapterId" to 99999))
userEventMusicList = "GetUserEventMusicApi".get("userEventMusicList", userId)
userTechEventList = "GetUserTechEventRankingApi".get("userTechEventRankingList", userId)
userKopList = "GetUserKopApi".get("userKopList", userId)
}.toJson()
}
}
}

View File

@@ -0,0 +1,65 @@
package icu.samnyan.aqua.net.transfer
import ext.*
import jakarta.servlet.http.HttpServletResponse
import org.springframework.web.bind.annotation.RestController
import java.io.PrintWriter
@RestController
@API("/api/v2/transfer")
class TransferApis {
val log = logger()
@API("/check")
fun check(@RB allNet: AllNetClient) = try {
log.info("Transfer check: $allNet")
mapOf("gameUrl" to allNet.gameUrl, "userId" to allNet.userId)
} catch (e: Exception) {
400 - "Transfer check failed. Please check your host and keychip.\n${e.message}"
}
fun HttpServletResponse.initStream(): PrintWriter {
contentType = "text/event-stream; charset=utf-8"
characterEncoding = "UTF-8"
return writer
}
fun PrintWriter.sendJson(m: Any) {
println(m.toJson())
flush()
}
fun PrintWriter.log(m: String) = sendJson(mapOf("message" to m))
@API("/pull")
fun pull(@RB allNet: AllNetClient, response: HttpServletResponse) {
val stream = response.initStream()
try {
log.info("Transfer pull: $allNet")
stream.log("Starting pull...")
val broker = allNet.findDataBroker { stream.log(it) }
val out = broker.pull()
stream.log("Pull complete")
stream.sendJson(mapOf("data" to out))
} catch (e: Exception) {
log.error("Transfer pull error", e)
stream.sendJson(mapOf("error" to e.message))
} finally {
stream.close()
}
}
class PushReq(val client: AllNetClient, val data: String)
@API("/push")
fun push(@RB obj: PushReq) = try {
val allNet = obj.client
log.info("Transfer push: $allNet")
val broker = allNet.findDataBroker { log.info(it) }
broker.push(obj.data)
mapOf("status" to "ok")
} catch (e: Exception) {
log.error("Transfer push error", e)
400 - "Transfer push error: ${e.message}"
}
}

View File

@@ -12,10 +12,8 @@ import io.netty.buffer.Unpooled
import io.netty.channel.ChannelHandler import io.netty.channel.ChannelHandler
import io.netty.channel.ChannelHandlerContext import io.netty.channel.ChannelHandlerContext
import io.netty.channel.ChannelInboundHandlerAdapter import io.netty.channel.ChannelInboundHandlerAdapter
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets.US_ASCII
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
@@ -31,13 +29,24 @@ class AimeDB(
): ChannelInboundHandlerAdapter() { ): ChannelInboundHandlerAdapter() {
val logger = logger() val logger = logger()
data class AimeBaseInfo(val gameId: String, val keychipId: String) data class AimeBaseInfo(
val magic: UInt, val version: UInt, val responseCode: UInt, val length: UInt,
fun getBaseInfo(input: ByteBuf) = AimeBaseInfo( val status: UInt, val gameId: String, val storeId: UInt, val keychipId: String
gameId = input.toString(0x0a, 0x0e - 0x0a, StandardCharsets.US_ASCII),
keychipId = input.toString(0x14, 0x1f - 0x14, StandardCharsets.US_ASCII)
) )
fun ByteBuf.decodeHeader() = AimeBaseInfo(
magic = readShortLE().toUInt(), // 00 2b
version = readShortLE().toUInt(), // 02 2b
responseCode = readShortLE().toUInt(), // 04 2b
length = readShortLE().toUInt(), // 06 2b
status = readShortLE().toUInt(), // 08 2b
gameId = readPaddedString(6u), // 0a 6b
storeId = readIntLE().toUInt(), // 10 4b
keychipId = readPaddedString(12u) // 14 12b
)
fun ByteBuf.readPaddedString(maxLen: UInt) = readBytes(maxLen.toInt()).toString(US_ASCII).trimEnd('\u0000')
data class Handler(val name: String, val fn: (ByteBuf) -> ByteBuf?) data class Handler(val name: String, val fn: (ByteBuf) -> ByteBuf?)
final val handlers = mapOf( final val handlers = mapOf(
@@ -62,10 +71,10 @@ class AimeDB(
try { try {
val type = msg["type"] as Int val type = msg["type"] as Int
val data = msg["data"] as ByteBuf val data = msg["data"] as ByteBuf
val base = getBaseInfo(data) val base = data.decodeHeader()
val handler = handlers[type] ?: return logger.error("AimeDB: Unknown request type 0x${type.toString(16)}") val handler = handlers[type] ?: return logger.error("AimeDB: Unknown request type 0x${type.toString(16)}")
logger.info("AimeDB /${handler.name} : (game ${base.gameId}, keychip ${base.keychipId})") logger.info("AimeDB /${handler.name} : $base")
// Check keychip // Check keychip
// We do not check for type 0x13 because of a bug in duolinguo.dll // We do not check for type 0x13 because of a bug in duolinguo.dll
@@ -127,8 +136,7 @@ class AimeDB(
*/ */
fun doFelicaLookupV2(msg: ByteBuf): ByteBuf { fun doFelicaLookupV2(msg: ByteBuf): ByteBuf {
val idm = msg.slice(0x30, 0x38 - 0x30).getLong(0) val idm = msg.slice(0x30, 0x38 - 0x30).getLong(0)
val dfc = msg.slice(0x38, 0x40 - 0x38).getLong(0) logger.info("> Felica Lookup v2 (idm $idm)")
logger.info("> Felica Lookup v2 (idm $idm, dfc $dfc)")
// Get the decimal represent of the hex value, same from minime // Get the decimal represent of the hex value, same from minime
val accessCode = idm.toString().replace("-", "").padStart(20, '0') val accessCode = idm.toString().replace("-", "").padStart(20, '0')

View File

@@ -0,0 +1,97 @@
package icu.samnyan.aqua.sega.aimedb
import ext.minus
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufUtil
import io.netty.buffer.Unpooled
import java.net.Socket
import java.net.SocketTimeoutException
class AimeDbClient(val gameId: String, val keychipShort: String, val server: String) {
// https://sega.bsnk.me/allnet/aimedb/common/#packet-header
fun createRequest(type: UShort, writer: ByteBuf.() -> Unit) =
AimeDbEncryption.encrypt(Unpooled.buffer(1024).clear().run {
writeShortLE(0xa13e) // 00 2b: Magic
writeShortLE(0x3087) // 02 2b: Version
writeShortLE(type.toInt()) // 04 2b: Type
writeShortLE(0) // 06 2b: Length
writeShortLE(0) // 08 2b: Result
writeAscii(gameId, 6) // 0A 6b: Game ID
writeIntLE(299) // 10 4b: Store ID (Place ID)
writeAscii(keychipShort, 12) // 14 12b: Keychip ID
writer() // Write Payload
setShortLE(6, writerIndex()) // Update Length
copy(0, writerIndex()) // Trim unused bytes
})
private fun ByteBuf.writeAscii(value: String, length: Int) =
writeBytes(value.toByteArray(Charsets.US_ASCII).copyOf(length))
fun createReqLookupV2(accessCode: String) =
createRequest(0x0fu) {
// Access code is a 20-digit number, should be converted to a 10-byte array
writeBytes(ByteBufUtil.decodeHexDump(accessCode.padStart(20, '0')))
writeByte(0) // 0A 1b: Company code
writeByte(0) // 0B 1b: R/W Firmware version
writeIntLE(0) // 0C 4b: Serial number
}
fun createReqFelicaLookupV2(felicaIdm: String) =
createRequest(0x11u) {
writeBytes(ByteArray(16)) // 00 16b: Random Challenge
// 10 8b: Felica IDm
writeBytes(ByteBufUtil.decodeHexDump(felicaIdm.padStart(16, '0')))
writeBytes(ByteArray(8)) // 18 8b: Felica PMm
writeBytes(ByteArray(16)) // 20 16b: Card key version
writeBytes(ByteArray(16)) // 30 16b: Write count
writeBytes(ByteArray(8)) // 40 8b: MACA
writeByte(0) // 48 1b: Company code
writeByte(0) // 49 1b: R/W Firmware version
writeShortLE(0) // 4A 2b: DFC
writeIntLE(0) // 4C 4b: Unknown padding
}
fun createReqRegister(accessCode: String) =
createRequest(0x05u) {
// Access code is a 20-digit number, should be converted to a 10-byte array
writeBytes(ByteBufUtil.decodeHexDump(accessCode.padStart(20, '0')))
writeByte(0) // 0A 1b: Company code
writeByte(0) // 0B 1b: R/W Firmware version
writeIntLE(0) // 0C 4b: Serial number
}
fun send(buf: ByteBuf): ByteBuf = Socket(server, 22345).use {
it.soTimeout = 3000
it.getOutputStream().write(buf.array())
it.getInputStream().use { r ->
Unpooled.buffer().apply {
val buffer = ByteArray(1024)
try {
while (r.read(buffer) != -1) writeBytes(buffer)
} catch (_: SocketTimeoutException) { }
}
}
}.let { AimeDbEncryption.decrypt(it) }
fun execLookup(card: String) =
send(when (card.length) {
20 -> createReqLookupV2(card)
16 -> createReqFelicaLookupV2(card)
else -> 400 - "Invalid card. Please input either 20-digit numeric access code (e.g. 5010000...0) or 16-digit hex Felica ID (e.g. 012E123456789ABC)."
}).getUnsignedIntLE(0x20).let {
if (it == 0xffffffff) -1L else it
}
fun execRegister(card: String) =
when (card.length) {
20 -> card
16 -> ByteBufUtil.hexDump(send(createReqFelicaLookupV2(card)).slice(0x2c, 10))
else -> 400 - "Invalid card. Please input a 20-digit numeric access code (e.g. 5010000...0)."
}.let { send(createReqRegister(it)).getUnsignedIntLE(0x20) }
fun execLookupOrRegister(card: String) =
execLookup(card).let {
if (it == -1L) execRegister(card)
else it
}
}

View File

@@ -2,7 +2,7 @@ package icu.samnyan.aqua.sega.allnet
import ext.* import ext.*
import icu.samnyan.aqua.net.db.AquaNetUserRepo import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.sega.util.AllNetBillingDecoder.decodeAllNet import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeAllNet
import icu.samnyan.aqua.sega.util.AquaConst import icu.samnyan.aqua.sega.util.AquaConst
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse

View File

@@ -0,0 +1,50 @@
package icu.samnyan.aqua.sega.allnet
import ext.bodyString
import ext.header
import icu.samnyan.aqua.sega.util.ZLib
import java.net.http.HttpResponse
import java.util.*
import kotlin.text.Charsets.UTF_8
object AllNetBillingDecoder {
fun String.urlToMap() = split("&").map { it.split("=") }.filter { it.size == 2 }.associate { it[0] to it[1] }
/**
* Decode the input byte array from Base64 MIME encoding and decompress the decoded byte array
*/
fun decode(src: ByteArray, base64: Boolean, nowrap: Boolean): Map<String, String> {
// Decode the input byte array from Base64 MIME encoding
val bytes = if (!base64) src else Base64.getMimeDecoder().decode(src)
// Decompress the decoded byte array
val output = ZLib.decompress(bytes, nowrap).toString(UTF_8).trim()
// Split the string by '&' symbol to separate key-value pairs
return output.urlToMap()
}
fun encode(src: Map<String, String>, base64: Boolean): ByteArray {
// Join the key-value pairs with '&' symbol
val output = src.map { "${it.key}=${it.value}" }.joinToString("&")
// Compress the joined string
val bytes = ZLib.compress(output.toByteArray(UTF_8))
// Encode the compressed byte array to Base64 MIME encoding
return if (!base64) bytes else Base64.getEncoder().encode(bytes)
}
@JvmStatic
fun decodeAllNet(src: ByteArray) = decode(src, base64 = true, nowrap = false)
fun encodeAllNet(src: Map<String, String>) = encode(src, base64 = true)
@JvmStatic
fun decodeBilling(src: ByteArray) = decode(src, base64 = false, nowrap = true)
fun HttpResponse<ByteArray>.decodeAllNetResp() =
if (header("Pragma") == "DFI") decodeAllNet(body())
else bodyString()?.urlToMap()
}

View File

@@ -2,7 +2,7 @@ package icu.samnyan.aqua.sega.billing
import ext.logger import ext.logger
import ext.toUrl import ext.toUrl
import icu.samnyan.aqua.sega.util.AllNetBillingDecoder.decodeBilling import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeBilling
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.eclipse.jetty.http.HttpVersion import org.eclipse.jetty.http.HttpVersion

View File

@@ -62,21 +62,22 @@ class ChusanController(
val token = TokenChecker.getCurrentSession()?.token?.substring(0, 6) ?: "NO-TOKEN" val token = TokenChecker.getCurrentSession()?.token?.substring(0, 6) ?: "NO-TOKEN"
log.info("Chu3 < $api : ${data.toJson()} : [$token]") log.info("Chu3 < $api : ${data.toJson()} : [$token]")
val noop = """{"returnCode":"1","apiName":"$api"}"""
if (api !in noopEndpoint && !handlers.containsKey(api)) { if (api !in noopEndpoint && !handlers.containsKey(api)) {
log.warn("Chu3 > $api not found") log.warn("Chu3 > $api not found")
return """{"returnCode":"1","apiName":"$api"}""" return noop
} }
// Only record the counter metrics if the API is known. // Only record the counter metrics if the API is known.
Metrics.counter("aquadx_chusan_api_call", "api" to api).increment() Metrics.counter("aquadx_chusan_api_call", "api" to api).increment()
if (api in noopEndpoint) { if (api in noopEndpoint) {
log.info("Chu3 > $api no-op") log.info("Chu3 > $api no-op")
return """{"returnCode":"1"}""" return noop
} }
return try { return try {
Metrics.timer("aquadx_chusan_api_latency", "api" to api).recordCallable { Metrics.timer("aquadx_chusan_api_latency", "api" to api).recordCallable {
serialize(api, handlers[api]!!(ctx)).also { serialize(api, handlers[api]!!(ctx) ?: noop).also {
if (api !in setOf("GetUserItemApi", "GetGameEventApi")) if (api !in setOf("GetUserItemApi", "GetGameEventApi"))
log.info("Chu3 > $api : $it") log.info("Chu3 > $api : $it")
} }

View File

@@ -5,7 +5,7 @@ import icu.samnyan.aqua.sega.allnet.TokenChecker
import icu.samnyan.aqua.sega.chusan.ChusanController import icu.samnyan.aqua.sega.chusan.ChusanController
import icu.samnyan.aqua.sega.chusan.ChusanData import icu.samnyan.aqua.sega.chusan.ChusanData
import icu.samnyan.aqua.sega.chusan.model.request.UserCMissionResp import icu.samnyan.aqua.sega.chusan.model.request.UserCMissionResp
import icu.samnyan.aqua.sega.chusan.model.userdata.UserItem import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserItem
import icu.samnyan.aqua.sega.chusan.model.userdata.UserMusicDetail import icu.samnyan.aqua.sega.chusan.model.userdata.UserMusicDetail
import icu.samnyan.aqua.sega.general.model.response.UserRecentRating import icu.samnyan.aqua.sega.general.model.response.UserRecentRating
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -179,7 +179,7 @@ fun ChusanController.chusanInit() {
db.userData.findByCard_ExtId(uid)()?.card?.aquaUser?.gameOptions?.let { db.userData.findByCard_ExtId(uid)()?.card?.aquaUser?.gameOptions?.let {
if (it.chusanInfinitePenguins && kind == 5) { if (it.chusanInfinitePenguins && kind == 5) {
items.removeAll { it.itemId in penguins } items.removeAll { it.itemId in penguins }
items.addAll(penguins.map { UserItem(kind, it, 999, true) }) items.addAll(penguins.map { Chu3UserItem(kind, it, 999, true) })
} }
} }

View File

@@ -1,12 +1,11 @@
package icu.samnyan.aqua.sega.chusan.handler package icu.samnyan.aqua.sega.chusan.handler
import com.fasterxml.jackson.core.type.TypeReference
import ext.* import ext.*
import icu.samnyan.aqua.sega.chusan.ChusanController import icu.samnyan.aqua.sega.chusan.ChusanController
import icu.samnyan.aqua.sega.chusan.model.request.UpsertUserGacha import icu.samnyan.aqua.sega.chusan.model.request.UpsertUserGacha
import icu.samnyan.aqua.sega.chusan.model.request.UserEmoney import icu.samnyan.aqua.sega.chusan.model.request.UserEmoney
import icu.samnyan.aqua.sega.chusan.model.userdata.UserCardPrintState import icu.samnyan.aqua.sega.chusan.model.userdata.UserCardPrintState
import icu.samnyan.aqua.sega.chusan.model.userdata.UserItem import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserItem
import java.time.LocalDateTime import java.time.LocalDateTime
fun ChusanController.cmApiInit() { fun ChusanController.cmApiInit() {
@@ -29,7 +28,7 @@ fun ChusanController.cmApiInit() {
val (gachaId, placeId) = parsing { data["gachaId"]!!.int to data["placeId"]!!.int } val (gachaId, placeId) = parsing { data["gachaId"]!!.int to data["placeId"]!!.int }
val u = db.userData.findByCard_ExtId(uid)() ?: return@api null val u = db.userData.findByCard_ExtId(uid)() ?: return@api null
val upsertUserGacha = parsing { mapper.convert(data["cmUpsertUserGacha"], UpsertUserGacha::class.java) } val upsertUserGacha = parsing { mapper.convert<UpsertUserGacha>(data["cmUpsertUserGacha"]!!) }
upsertUserGacha.gameGachaCardList?.let { lst -> upsertUserGacha.gameGachaCardList?.let { lst ->
db.userCardPrintState.saveAll(lst.map { db.userCardPrintState.saveAll(lst.map {
@@ -63,7 +62,7 @@ fun ChusanController.cmApiInit() {
} }
"CMUpsertUserPrintCancel" { "CMUpsertUserPrintCancel" {
val orderIdList: List<Long> = cmMapper.convert(data["orderIdList"], object : TypeReference<List<Long>>() {}) val orderIdList: List<Long> = cmMapper.convert<List<Long>>(parsing { data["orderIdList"]!! })
db.userCardPrintState.saveAll(orderIdList.mapNotNull { db.userCardPrintState.saveAll(orderIdList.mapNotNull {
// TODO: The original code by Eori writes findById but I don't think that is correct... // TODO: The original code by Eori writes findById but I don't think that is correct...
@@ -76,8 +75,8 @@ fun ChusanController.cmApiInit() {
} }
"CMUpsertUserPrintSubtract" api@ { "CMUpsertUserPrintSubtract" api@ {
val userCardPrintState = cmMapper.convert(data["userCardPrintState"], UserCardPrintState::class.java) val userCardPrintState = cmMapper.convert<UserCardPrintState>(parsing { data["userCardPrintState"]!! })
val userItemList = cmMapper.convert(data["userItemList"], object : TypeReference<List<UserItem>>() {}) val userItemList = cmMapper.convert<List<Chu3UserItem>>(parsing { data["userItemList"]!! })
val u = db.userData.findByCard_ExtId(uid)() ?: return@api null val u = db.userData.findByCard_ExtId(uid)() ?: return@api null

View File

@@ -2,7 +2,7 @@ package icu.samnyan.aqua.sega.chusan.handler
import ext.* import ext.*
import icu.samnyan.aqua.sega.chusan.ChusanController import icu.samnyan.aqua.sega.chusan.ChusanController
import icu.samnyan.aqua.sega.chusan.model.request.UpsertUserAll import icu.samnyan.aqua.sega.chusan.model.request.Chu3UserAll
import icu.samnyan.aqua.sega.chusan.model.userdata.* import icu.samnyan.aqua.sega.chusan.model.userdata.*
import icu.samnyan.aqua.sega.general.model.response.UserRecentRating import icu.samnyan.aqua.sega.general.model.response.UserRecentRating
@@ -17,7 +17,7 @@ fun ChusanController.upsertApiInit() {
} }
"UpsertUserAll" api@ { "UpsertUserAll" api@ {
val req = mapper.convert(data["upsertUserAll"], UpsertUserAll::class.java) val req = parsing { mapper.convert<Chu3UserAll>(data["upsertUserAll"]!!) }
req.run { req.run {
// UserData // UserData

View File

@@ -40,11 +40,11 @@ interface Chu3UserLoginBonusRepo : JpaRepository<UserLoginBonus, Long> {
fun findLoginBonus(userId: Int, version: Int, presetId: Long): Optional<UserLoginBonus> fun findLoginBonus(userId: Int, version: Int, presetId: Long): Optional<UserLoginBonus>
} }
interface Chu3UserActivityRepo : Chu3UserLinked<UserActivity> { interface Chu3UserActivityRepo : Chu3UserLinked<Chu3UserActivity> {
fun findTopByUserAndActivityIdAndKindOrderByIdDesc(user: Chu3UserData, activityId: Int, kind: Int): Optional<UserActivity> fun findTopByUserAndActivityIdAndKindOrderByIdDesc(user: Chu3UserData, activityId: Int, kind: Int): Optional<Chu3UserActivity>
fun findByUserAndActivityIdAndKind(user: Chu3UserData, activityId: Int, kind: Int): UserActivity? fun findByUserAndActivityIdAndKind(user: Chu3UserData, activityId: Int, kind: Int): Chu3UserActivity?
fun findAllByUser_Card_ExtIdAndKind(extId: Long, kind: Int): List<UserActivity> fun findAllByUser_Card_ExtIdAndKind(extId: Long, kind: Int): List<Chu3UserActivity>
} }
interface Chu3UserCardPrintStateRepo : Chu3UserLinked<UserCardPrintState> { interface Chu3UserCardPrintStateRepo : Chu3UserLinked<UserCardPrintState> {
@@ -89,14 +89,14 @@ interface Chu3UserGeneralDataRepo : Chu3UserLinked<UserGeneralData> {
fun findByUser_Card_ExtIdAndPropertyKey(extId: Long, key: String): Optional<UserGeneralData> fun findByUser_Card_ExtIdAndPropertyKey(extId: Long, key: String): Optional<UserGeneralData>
} }
interface Chu3UserItemRepo : Chu3UserLinked<UserItem> { interface Chu3UserItemRepo : Chu3UserLinked<Chu3UserItem> {
fun findAllByUser(user: Chu3UserData): List<UserItem> fun findAllByUser(user: Chu3UserData): List<Chu3UserItem>
fun findTopByUserAndItemIdAndItemKindOrderByIdDesc(user: Chu3UserData, itemId: Int, itemKind: Int): Optional<UserItem> fun findTopByUserAndItemIdAndItemKindOrderByIdDesc(user: Chu3UserData, itemId: Int, itemKind: Int): Optional<Chu3UserItem>
fun findByUserAndItemIdAndItemKind(user: Chu3UserData, itemId: Int, itemKind: Int): UserItem? fun findByUserAndItemIdAndItemKind(user: Chu3UserData, itemId: Int, itemKind: Int): Chu3UserItem?
fun findAllByUser_Card_ExtIdAndItemKind(extId: Long, itemKind: Int, pageable: Pageable): Page<UserItem> fun findAllByUser_Card_ExtIdAndItemKind(extId: Long, itemKind: Int, pageable: Pageable): Page<Chu3UserItem>
fun findAllByUser_Card_ExtIdAndItemKind(extId: Long, itemKind: Int): List<UserItem> fun findAllByUser_Card_ExtIdAndItemKind(extId: Long, itemKind: Int): List<Chu3UserItem>
} }
interface Chu3UserMapRepo : Chu3UserLinked<UserMap> { interface Chu3UserMapRepo : Chu3UserLinked<UserMap> {

View File

@@ -37,13 +37,13 @@ data class MusicIdWrapper(
val musicId: Int = 0, val musicId: Int = 0,
) )
class UpsertUserAll( class Chu3UserAll(
var userData: List<Chu3UserData>? = null, var userData: List<Chu3UserData>? = null,
var userGameOption: List<UserGameOption>? = null, var userGameOption: List<UserGameOption>? = null,
var userCharacterList: List<UserCharacter>? = null, var userCharacterList: List<UserCharacter>? = null,
var userItemList: List<UserItem>? = null, var userItemList: List<Chu3UserItem>? = null,
var userMusicDetailList: List<UserMusicDetail>? = null, var userMusicDetailList: List<UserMusicDetail>? = null,
var userActivityList: List<UserActivity>? = null, var userActivityList: List<Chu3UserActivity>? = null,
var userRecentRatingList: List<UserRecentRating>? = null, var userRecentRatingList: List<UserRecentRating>? = null,
var userPlaylogList: List<UserPlaylog>? = null, var userPlaylogList: List<UserPlaylog>? = null,
var userChargeList: List<UserCharge>? = null, var userChargeList: List<UserCharge>? = null,

View File

@@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty
import icu.samnyan.aqua.sega.chusan.model.GameGachaCard import icu.samnyan.aqua.sega.chusan.model.GameGachaCard
import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserData import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserData
import icu.samnyan.aqua.sega.chusan.model.userdata.UserGacha import icu.samnyan.aqua.sega.chusan.model.userdata.UserGacha
import icu.samnyan.aqua.sega.chusan.model.userdata.UserItem import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserItem
import java.io.Serializable import java.io.Serializable
class UpsertUserGacha : Serializable { class UpsertUserGacha : Serializable {
@@ -13,7 +13,7 @@ class UpsertUserGacha : Serializable {
var userCharacterList: List<Any>? = null var userCharacterList: List<Any>? = null
var userCardList: List<Any>? = null var userCardList: List<Any>? = null
var gameGachaCardList: List<GameGachaCard>? = null var gameGachaCardList: List<GameGachaCard>? = null
var userItemList: List<UserItem>? = null var userItemList: List<Chu3UserItem>? = null
@JsonProperty("isNewCharacterList") @JsonProperty("isNewCharacterList")
var isNewCharacterList: String? = null var isNewCharacterList: String? = null

View File

@@ -8,7 +8,7 @@ import jakarta.persistence.UniqueConstraint
@Entity(name = "ChusanUserActivity") @Entity(name = "ChusanUserActivity")
@Table(name = "chusan_user_activity", uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "kind", "activity_id"])]) @Table(name = "chusan_user_activity", uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "kind", "activity_id"])])
class UserActivity : Chu3UserEntity() { class Chu3UserActivity : Chu3UserEntity() {
var kind = 0 var kind = 0
@JsonProperty("id") @JsonProperty("id")
@Column(name = "activity_id") @Column(name = "activity_id")

View File

@@ -7,7 +7,7 @@ import jakarta.persistence.UniqueConstraint
@Entity(name = "ChusanUserItem") @Entity(name = "ChusanUserItem")
@Table(name = "chusan_user_item", uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "item_id", "item_kind"])]) @Table(name = "chusan_user_item", uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "item_id", "item_kind"])])
class UserItem( class Chu3UserItem(
var itemKind: Int = 0, var itemKind: Int = 0,
var itemId: Int = 0, var itemId: Int = 0,
var stock: Int = 1, var stock: Int = 1,

View File

@@ -30,7 +30,7 @@ typealias PagePost = (MutJDict) -> Unit
data class PagedProcessor(val add: JDict?, val fn: PagedHandler, var post: PagePost? = null) data class PagedProcessor(val add: JDict?, val fn: PagedHandler, var post: PagePost? = null)
// A very :3 way of declaring APIs // A very :3 way of declaring APIs
abstract class MeowApi(val serialize: (String, Any?) -> String) { abstract class MeowApi(val serialize: (String, Any) -> String) {
val initH = mutableMapOf<String, SpecialHandler>() val initH = mutableMapOf<String, SpecialHandler>()
infix operator fun String.invoke(fn: SpecialHandler) = initH.set("${this}Api", fn) infix operator fun String.invoke(fn: SpecialHandler) = initH.set("${this}Api", fn)
infix fun String.static(fn: () -> Any) = serialize(this, fn()).let { resp -> this { resp } } infix fun String.static(fn: () -> Any) = serialize(this, fn()).let { resp -> this { resp } }

View File

@@ -4,12 +4,10 @@ package icu.samnyan.aqua.sega.maimai2
import ext.* import ext.*
import icu.samnyan.aqua.sega.general.PagedHandler import icu.samnyan.aqua.sega.general.PagedHandler
import icu.samnyan.aqua.sega.maimai2.model.response.data.UserRivalMusic import icu.samnyan.aqua.sega.maimai2.model.UserRivalMusic
import icu.samnyan.aqua.sega.maimai2.model.response.data.UserRivalMusicDetail import icu.samnyan.aqua.sega.maimai2.model.UserRivalMusicDetail
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserIntimate
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserKaleidx import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserKaleidx
import java.time.LocalDate import java.time.LocalDate
import java.util.*
fun Maimai2ServletController.initApis() { fun Maimai2ServletController.initApis() {
// Used because maimai does not actually require paging implementation // Used because maimai does not actually require paging implementation
@@ -157,7 +155,7 @@ fun Maimai2ServletController.initApis() {
val rivalId = parsing { data["rivalId"]!!.long } val rivalId = parsing { data["rivalId"]!!.long }
val lst = db.userMusicDetail.findByUserId(rivalId) val lst = db.userMusicDetail.findByUserId(rivalId)
val res = lst.associate { it.musicId to UserRivalMusic(it.musicId, LinkedList()) } val res = lst.associate { it.musicId to UserRivalMusic(it.musicId) }
lst.forEach { lst.forEach {
res[it.musicId]!!.userRivalMusicDetailList.add( res[it.musicId]!!.userRivalMusicDetailList.add(
@@ -195,6 +193,7 @@ fun Maimai2ServletController.initApis() {
} }
// Kaleidoscope, added on 1.50 // Kaleidoscope, added on 1.50
// [{gateId, phaseId}]
"GetGameKaleidxScope" { mapOf("gameKaleidxScopeList" to ls( "GetGameKaleidxScope" { mapOf("gameKaleidxScopeList" to ls(
mapOf("gateId" to 1, "phaseId" to findPhase(LocalDate.of(2025, 1, 18))), mapOf("gateId" to 1, "phaseId" to findPhase(LocalDate.of(2025, 1, 18))),
mapOf("gateId" to 2, "phaseId" to 2), mapOf("gateId" to 2, "phaseId" to 2),
@@ -203,6 +202,8 @@ fun Maimai2ServletController.initApis() {
mapOf("gateId" to 5, "phaseId" to 2), mapOf("gateId" to 5, "phaseId" to 2),
mapOf("gateId" to 6, "phaseId" to 2), mapOf("gateId" to 6, "phaseId" to 2),
)) } )) }
// Request: {userId}
// Response: {userId, userKaleidxScopeList}
"GetUserKaleidxScope".unpaged { "GetUserKaleidxScope".unpaged {
val u = db.userData.findByCardExtId(uid)() ?: (404 - "User not found") val u = db.userData.findByCardExtId(uid)() ?: (404 - "User not found")
val lst = db.userKaleidx.findByUser(u) val lst = db.userKaleidx.findByUser(u)
@@ -213,6 +214,8 @@ fun Maimai2ServletController.initApis() {
lst lst
} }
// Request: {userId, version, userData: [UserDetail], userPlaylogList: [UserPlaylog]}
// Response: {userId, userItemList: [UserItem]}
// Added on 1.50 // Added on 1.50
"GetUserNewItemList" { mapOf("userId" to uid, "userItemList" to empty) } "GetUserNewItemList" { mapOf("userId" to uid, "userItemList" to empty) }

View File

@@ -68,9 +68,10 @@ class Maimai2ServletController(
@API("/{api}") @API("/{api}")
fun handle(@PathVariable api: String, @RequestBody data: Map<String, Any>, req: HttpServletRequest): Any { fun handle(@PathVariable api: String, @RequestBody data: Map<String, Any>, req: HttpServletRequest): Any {
logger.info("Mai2 < $api : ${data.toJson()}") // TODO: Optimize logging logger.info("Mai2 < $api : ${data.toJson()}") // TODO: Optimize logging
val noop = """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}"""
if (api !in noopEndpoint && !handlers.containsKey(api)) { if (api !in noopEndpoint && !handlers.containsKey(api)) {
logger.warn("Mai2 > $api not found") logger.warn("Mai2 > $api not found")
return """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}""" return noop
} }
// Only record the counter metrics if the API is known. // Only record the counter metrics if the API is known.
@@ -78,13 +79,13 @@ class Maimai2ServletController(
if (api in noopEndpoint) { if (api in noopEndpoint) {
logger.info("Mai2 > $api no-op") logger.info("Mai2 > $api no-op")
return """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}""" return noop
} }
return try { return try {
Metrics.timer("aquadx_maimai2_api_latency", "api" to api).recordCallable { Metrics.timer("aquadx_maimai2_api_latency", "api" to api).recordCallable {
val ctx = RequestContext(req, data.mut) val ctx = RequestContext(req, data.mut)
serialize(api, handlers[api]!!(ctx)).also { serialize(api, handlers[api]!!(ctx) ?: noop).also {
logger.info("Mai2 > $api : ${it.truncate(1000)}") logger.info("Mai2 > $api : ${it.truncate(1000)}")
} }
} }

View File

@@ -1,98 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.handler;
import com.fasterxml.jackson.core.JsonProcessingException;
import icu.samnyan.aqua.net.db.AquaNetUser;
import icu.samnyan.aqua.net.utils.PathProps;
import icu.samnyan.aqua.sega.general.BaseHandler;
import icu.samnyan.aqua.sega.general.dao.CardRepository;
import icu.samnyan.aqua.sega.general.model.Card;
import icu.samnyan.aqua.sega.maimai2.model.request.data.UserPortrait;
import icu.samnyan.aqua.sega.util.jackson.BasicMapper;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.stereotype.Component;
import java.io.FileInputStream;
import java.nio.file.Paths;
import java.util.*;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Component("Maimai2GetUserPortraitHandler")
public class GetUserPortraitHandler implements BaseHandler {
private static final Logger logger = LoggerFactory.getLogger(GetUserPortraitHandler.class);
private final BasicMapper mapper;
private final boolean enable;
private final CardRepository cardRepo;
private final String portraitPath;
public GetUserPortraitHandler(BasicMapper mapper,
@Value("${game.maimai2.userPhoto.enable:true}") boolean enable,
CardRepository cardRepo,
PathProps paths) {
this.mapper = mapper;
this.enable = enable;
this.cardRepo = cardRepo;
this.portraitPath = paths.getAquaNetPortrait();
}
@Override
public String handle(Map<String, ?> request) throws JsonProcessingException {
if (enable) {
var userId = ((Number) request.get("userId")).longValue();
var list = new ArrayList<UserPortrait>();
var card = cardRepo.findByExtId(userId);
var user = card.map(Card::getAquaUser);
var profilePicture = user.map(AquaNetUser::getProfilePicture).orElse(null);
try {
if (!StringUtils.isEmpty(profilePicture)) {
var filePath = Paths.get(portraitPath, profilePicture);
var buffer = new byte[10240];
var stream = new FileInputStream(filePath.toFile());
while (stream.available() > 0) {
var read = stream.read(buffer, 0, 10240);
var encodeBuffer = read == 10240 ? buffer : Arrays.copyOfRange(buffer, 0, read);
var userPortrait = new UserPortrait();
userPortrait.setFileName("portrait.jpg");
userPortrait.setPlaceId(0);
userPortrait.setUserId(userId);
userPortrait.setClientId("");
userPortrait.setUploadDate("1970-01-01 09:00:00.0");
userPortrait.setDivData(Utf8.decode(Base64.getEncoder().encode(encodeBuffer)));
userPortrait.setDivNumber(list.size());
list.add(userPortrait);
}
stream.close();
for (var i = 0; i < list.size(); i++) {
var userPortrait = list.get(i);
userPortrait.setDivLength(list.size());
}
var map = new HashMap<String, Object>();
map.put("length", list.size());
map.put("userPortraitList", list);
var respJson = mapper.write(map);
return respJson;
}
} catch (Exception e) {
logger.error("Result: User photo save failed", e);
}
}
return "{\"length\":0,\"userPortraitList\":[]}";
}
}

View File

@@ -0,0 +1,63 @@
package icu.samnyan.aqua.sega.maimai2.handler
import ext.invoke
import ext.logger
import icu.samnyan.aqua.net.utils.PathProps
import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserPortrait
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.crypto.codec.Utf8
import org.springframework.stereotype.Component
import java.io.FileInputStream
import java.nio.file.Paths
import java.util.*
@Component("Maimai2GetUserPortraitHandler")
class GetUserPortraitHandler(
val cardRepo: CardRepository,
@param:Value("\${game.maimai2.userPhoto.enable:true}") val enable: Boolean,
paths: PathProps
) : BaseHandler {
val portraitPath = paths.aquaNetPortrait
val log = logger()
override fun handle(request: Map<String, Any>): Any? {
if (!enable) return """{"length":0,"userPortraitList":[]}"""
val uid = (request["userId"] as Number).toLong()
val list = ArrayList<Mai2UserPortrait>()
val profilePicture = cardRepo.findByExtId(uid)()?.aquaUser?.profilePicture?.ifBlank { null }
?: return """{"length":0,"userPortraitList":[]}"""
try {
val filePath = Paths.get(portraitPath, profilePicture)
val buffer = ByteArray(10240)
FileInputStream(filePath.toFile()).use { stream ->
while (stream.available() > 0) {
val read = stream.read(buffer, 0, 10240)
val buf = if (read == 10240) buffer else Arrays.copyOfRange(buffer, 0, read)
list.add(Mai2UserPortrait().apply {
userId = uid
divData = Utf8.decode(Base64.getEncoder().encode(buf))
divNumber = list.size
})
}
}
list.forEach { it.divLength = list.size }
return mapOf(
"length" to list.size,
"userPortraitList" to list
)
} catch (e: Exception) {
log.error("Result: User photo get failed", e)
return """{"length":0,"userPortraitList":[]}"""
}
}
}

View File

@@ -3,7 +3,7 @@ package icu.samnyan.aqua.sega.maimai2.handler
import ext.invoke import ext.invoke
import icu.samnyan.aqua.sega.general.BaseHandler import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.maimai2.model.response.data.UserRating import icu.samnyan.aqua.sega.maimai2.model.UserRating
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserRate import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserRate
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserUdemae import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserUdemae
import icu.samnyan.aqua.sega.util.jackson.BasicMapper import icu.samnyan.aqua.sega.util.jackson.BasicMapper

View File

@@ -1,11 +1,8 @@
package icu.samnyan.aqua.sega.maimai2.handler package icu.samnyan.aqua.sega.maimai2.handler
import ext.div import ext.*
import ext.isoDateTime
import ext.logger
import ext.path
import icu.samnyan.aqua.sega.general.BaseHandler import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.request.UploadUserPhoto import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserPhoto
import icu.samnyan.aqua.sega.util.jackson.BasicMapper import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.io.IOException import java.io.IOException
@@ -23,9 +20,7 @@ class UploadUserPhotoHandler(private val mapper: BasicMapper) :
// Maimai DX sends split base64 data for one jpeg image. // Maimai DX sends split base64 data for one jpeg image.
// So, make a temp file and keep append bytes until last part received. // So, make a temp file and keep append bytes until last part received.
// If finished, rename it to other name so user can keep save multiple scorecards in a single day. // If finished, rename it to other name so user can keep save multiple scorecards in a single day.
val up = parsing { mapper.convert(request["userPhoto"]!!, Mai2UserPhoto::class.java) }
val uploadUserPhoto = mapper.convert(request, UploadUserPhoto::class.java)
val up = uploadUserPhoto.userPhoto
try { try {
val tmpFile = tmpDir / "${up.userId}-${up.trackNo}.tmp" val tmpFile = tmpDir / "${up.userId}-${up.trackNo}.tmp"

View File

@@ -1,12 +1,13 @@
package icu.samnyan.aqua.sega.maimai2.handler package icu.samnyan.aqua.sega.maimai2.handler
import ext.logger import ext.logger
import ext.long
import ext.millis import ext.millis
import ext.parsing
import icu.samnyan.aqua.sega.allnet.TokenChecker import icu.samnyan.aqua.sega.allnet.TokenChecker
import icu.samnyan.aqua.sega.general.BaseHandler import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserPlaylogRepo import icu.samnyan.aqua.sega.maimai2.model.Mai2UserPlaylogRepo
import icu.samnyan.aqua.sega.maimai2.model.request.UploadUserPlaylog
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPlaylog import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPlaylog
import icu.samnyan.aqua.sega.util.jackson.BasicMapper import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import icu.samnyan.aqua.spring.Metrics import icu.samnyan.aqua.spring.Metrics
@@ -33,9 +34,10 @@ class UploadUserPlaylogHandler(
} }
override fun handle(request: Map<String, Any>): String { override fun handle(request: Map<String, Any>): String {
val req = mapper.convert(request, UploadUserPlaylog::class.java) val uid = parsing { request["userId"]!!.long }
val playlog = parsing { mapper.convert(request["userPlaylog"]!!, Mai2UserPlaylog::class.java) }
val version = tryParseGameVersion(req.userPlaylog.version) val version = tryParseGameVersion(playlog.version)
if (version != null) { if (version != null) {
val session = TokenChecker.getCurrentSession() val session = TokenChecker.getCurrentSession()
val gameId = if (session?.gameId in VALID_GAME_IDS) session!!.gameId else "" val gameId = if (session?.gameId in VALID_GAME_IDS) session!!.gameId else ""
@@ -47,9 +49,9 @@ class UploadUserPlaylogHandler(
// Check duplicate // Check duplicate
val isDup = playlogRepo.findByUser_Card_ExtIdAndMusicIdAndUserPlayDate( val isDup = playlogRepo.findByUser_Card_ExtIdAndMusicIdAndUserPlayDate(
req.userId, uid,
req.userPlaylog.musicId, playlog.musicId,
req.userPlaylog.userPlayDate playlog.userPlayDate
).size > 0 ).size > 0
if (isDup) { if (isDup) {
log.info("Duplicate playlog detected") log.info("Duplicate playlog detected")
@@ -57,14 +59,14 @@ class UploadUserPlaylogHandler(
} }
// Save if the user is registered // Save if the user is registered
val u = userDataRepository.findByCardExtId(req.userId).getOrNull() val u = userDataRepository.findByCardExtId(uid).getOrNull()
if (u != null) playlogRepo.save(req.userPlaylog.apply { user = u }) if (u != null) playlogRepo.save(playlog.apply { user = u })
// If the user hasn't registered (first play), save the playlog to a backlog // If the user hasn't registered (first play), save the playlog to a backlog
else { else {
playBacklog.putIfAbsent(req.userId, mutableListOf()) playBacklog.putIfAbsent(uid, mutableListOf())
playBacklog[req.userId]?.apply { playBacklog[uid]?.apply {
add(BacklogEntry(millis(), req.userPlaylog)) add(BacklogEntry(millis(), playlog))
if (size > 6) clear() // Prevent abuse if (size > 6) clear() // Prevent abuse
} }
} }

View File

@@ -2,10 +2,11 @@ package icu.samnyan.aqua.sega.maimai2.handler
import ext.div import ext.div
import ext.logger import ext.logger
import ext.parsing
import ext.path import ext.path
import icu.samnyan.aqua.net.utils.PathProps import icu.samnyan.aqua.net.utils.PathProps
import icu.samnyan.aqua.sega.general.BaseHandler import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.request.UploadUserPortrait import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UserPortrait
import icu.samnyan.aqua.sega.util.jackson.BasicMapper import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@@ -34,8 +35,7 @@ class UploadUserPortraitHandler(
// Maimai DX sends split base64 data for one jpeg image. // Maimai DX sends split base64 data for one jpeg image.
// So, make a temp file and keep append bytes until last part received. // So, make a temp file and keep append bytes until last part received.
// If finished, rename it to other name so user can keep save multiple scorecards in a single day. // If finished, rename it to other name so user can keep save multiple scorecards in a single day.
val up = parsing { mapper.convert(request["userPortrait"]!!, Mai2UserPortrait::class.java) }
val up = mapper.convert(request, UploadUserPortrait::class.java).userPortrait
val id = up.userId val id = up.userId
val num = up.divNumber val num = up.divNumber

View File

@@ -3,13 +3,12 @@ package icu.samnyan.aqua.sega.maimai2.handler
import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.core.JsonProcessingException
import ext.invoke import ext.invoke
import ext.mapApply import ext.mapApply
import ext.minus
import ext.unique import ext.unique
import icu.samnyan.aqua.sega.general.BaseHandler import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.general.service.CardService import icu.samnyan.aqua.sega.general.service.CardService
import icu.samnyan.aqua.sega.maimai2.handler.UploadUserPlaylogHandler.Companion.playBacklog import icu.samnyan.aqua.sega.maimai2.handler.UploadUserPlaylogHandler.Companion.playBacklog
import icu.samnyan.aqua.sega.maimai2.model.* import icu.samnyan.aqua.sega.maimai2.model.*
import icu.samnyan.aqua.sega.maimai2.model.request.UpsertUserAll import icu.samnyan.aqua.sega.maimai2.model.request.Mai2UpsertUserAll
import icu.samnyan.aqua.sega.maimai2.model.userdata.* import icu.samnyan.aqua.sega.maimai2.model.userdata.*
import icu.samnyan.aqua.sega.util.jackson.BasicMapper import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import lombok.AllArgsConstructor import lombok.AllArgsConstructor
@@ -31,16 +30,13 @@ class UpsertUserAllHandler(
@Throws(JsonProcessingException::class) @Throws(JsonProcessingException::class)
override fun handle(request: Map<String, Any>): Any? { override fun handle(request: Map<String, Any>): Any? {
val upsertUserAll = mapper.convert(request, UpsertUserAll::class.java) val upsertUserAll = mapper.convert(request, Mai2UpsertUserAll::class.java)
val userId = upsertUserAll.userId val userId = upsertUserAll.userId
val req = upsertUserAll.upsertUserAll val req = upsertUserAll.upsertUserAll
// If user is guest, just return OK response. // If user is guest, just return OK response.
if ((userId and 281474976710657L) == 281474976710657L) return SUCCESS if ((userId and 281474976710657L) == 281474976710657L) return SUCCESS
// UserData
if (req.userData == null) 400 - "Invalid Request"
val userData = repos.userData.findByCardExtId(userId)() val userData = repos.userData.findByCardExtId(userId)()
val u = repos.userData.saveAndFlush(req.userData[0].apply { val u = repos.userData.saveAndFlush(req.userData[0].apply {
id = userData?.id ?: 0 id = userData?.id ?: 0

View File

@@ -1,107 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.handler;
import com.fasterxml.jackson.core.JsonProcessingException;
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserCardRepo;
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo;
import icu.samnyan.aqua.sega.maimai2.model.Mai2UserPrintDetailRepo;
import icu.samnyan.aqua.sega.general.BaseHandler;
import icu.samnyan.aqua.sega.maimai2.model.request.UpsertUserPrint;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserCard;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserDetail;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPrintDetail;
import icu.samnyan.aqua.sega.util.jackson.BasicMapper;
import lombok.AllArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Component("Maimai2UpsertUserPrintHandler")
public class UpsertUserPrintHandler implements BaseHandler {
private static final Logger logger = LoggerFactory.getLogger(UpsertUserPrintHandler.class);
private final BasicMapper mapper;
private final Mai2UserCardRepo userCardRepository;
private final Mai2UserPrintDetailRepo userPrintDetailRepository;
private final Mai2UserDataRepo userDataRepository;
private long expirationTime;
public UpsertUserPrintHandler(BasicMapper mapper,
@Value("${game.cardmaker.card.expiration:15}") long expirationTime,
Mai2UserCardRepo userCardRepository,
Mai2UserPrintDetailRepo userPrintDetailRepository,
Mai2UserDataRepo userDataRepository
) {
this.mapper = mapper;
this.expirationTime = expirationTime;
this.userCardRepository = userCardRepository;
this.userPrintDetailRepository = userPrintDetailRepository;
this.userDataRepository = userDataRepository;
}
@Override
public String handle(Map<String, ?> request) throws JsonProcessingException {
long userId = ((Number) request.get("userId")).longValue();
Mai2UserDetail userData;
Optional<Mai2UserDetail> userOptional = userDataRepository.findByCardExtId(userId);
if (userOptional.isPresent()) {
userData = userOptional.get();
} else {
logger.error("User not found. userId: {}", userId);
return null;
}
UpsertUserPrint upsertUserPrint = mapper.convert(request, UpsertUserPrint.class);
Mai2UserPrintDetail userPrintDetail = upsertUserPrint.getUserPrintDetail();
Mai2UserCard newUserCard = userPrintDetail.getUserCard();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS");
String currentDateTime = LocalDateTime.now().format(formatter);
String expirationDateTime = LocalDateTime.now().plusDays(expirationTime).format(formatter);
String randomSerialId =
String.format("%010d", ThreadLocalRandom.current().nextLong(0L, 9999999999L)) +
String.format("%010d", ThreadLocalRandom.current().nextLong(0L, 9999999999L));
newUserCard.setUser(userData);
userPrintDetail.setUser(userData);
newUserCard.setStartDate(currentDateTime);
newUserCard.setEndDate(expirationDateTime);
userPrintDetail.setSerialId(randomSerialId);
Optional<Mai2UserCard> userCardOptional = userCardRepository.findByUserAndCardId(newUserCard.getUser(), newUserCard.getCardId());
if (userCardOptional.isPresent()) {
Mai2UserCard userCard = userCardOptional.get();
newUserCard.setId(userCard.getId());
}
userCardRepository.save(newUserCard);
userPrintDetailRepository.save(userPrintDetail);
Map<String, Object> resultMap = new LinkedHashMap<>();
resultMap.put("returnCode", 1);
resultMap.put("orderId", 0);
resultMap.put("serialId", randomSerialId);
resultMap.put("startDate", currentDateTime);
resultMap.put("endDate", expirationDateTime);
return mapper.write(resultMap);
}
}

View File

@@ -0,0 +1,54 @@
package icu.samnyan.aqua.sega.maimai2.handler
import ext.invoke
import ext.logger
import ext.long
import ext.parsing
import icu.samnyan.aqua.sega.general.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPrintDetail
import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.ThreadLocalRandom
@Component("Maimai2UpsertUserPrintHandler")
class UpsertUserPrintHandler(
val mapper: BasicMapper,
val db: Mai2Repos,
@param:Value("\${game.cardmaker.card.expiration:15}") val expirationTime: Long,
) : BaseHandler {
val log = logger()
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS")
override fun handle(request: Map<String, Any>): Any? {
val userId = parsing { request["userId"]!!.long }
val userData = db.userData.findByCardExtId(userId)() ?: return null
val userPrint = parsing { mapper.convert(request["userPrintDetail"]!!, Mai2UserPrintDetail::class.java) }
val newCard = userPrint.userCard ?: return null
newCard.user = userData
newCard.startDate = LocalDateTime.now().format(formatter)
newCard.endDate = LocalDateTime.now().plusDays(expirationTime).format(formatter)
newCard.id = db.userCard.findByUserAndCardId(newCard.user, newCard.cardId)()?.id ?: 0
db.userCard.save(newCard)
userPrint.user = userData
userPrint.serialId = buildString {
append(String.format("%010d", ThreadLocalRandom.current().nextLong(0L, 9999999999L)))
append(String.format("%010d", ThreadLocalRandom.current().nextLong(0L, 9999999999L)))
}
db.userPrintDetail.save(userPrint)
return mapOf(
"returnCode" to 1,
"orderId" to 0,
"serialId" to userPrint.serialId,
"startDate" to newCard.startDate,
"endDate" to newCard.endDate
)
}
}

View File

@@ -0,0 +1,13 @@
package icu.samnyan.aqua.sega.maimai2.model
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserRate
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserUdemae
class UserRating {
var rating = 0
var ratingList: List<Mai2UserRate> = emptyList()
var newRatingList: List<Mai2UserRate> = emptyList()
var nextRatingList: List<Mai2UserRate> = emptyList()
var nextNewRatingList: List<Mai2UserRate> = emptyList()
var udemae: Mai2UserUdemae = Mai2UserUdemae()
}

View File

@@ -0,0 +1,12 @@
package icu.samnyan.aqua.sega.maimai2.model
class UserRivalMusic(
var musicId: Int,
var userRivalMusicDetailList: MutableList<UserRivalMusicDetail> = mutableListOf()
)
class UserRivalMusicDetail(
var level: Int,
var achievement: Int,
var deluxscoreMax: Int
)

View File

@@ -0,0 +1,53 @@
package icu.samnyan.aqua.sega.maimai2.model.request
import icu.samnyan.aqua.sega.maimai2.model.UserRating
import icu.samnyan.aqua.sega.maimai2.model.userdata.*
class Mai2UpsertUserAll(
var userId: Long,
var upsertUserAll: Mai2UserAll
)
class Mai2UserAll {
var userData: List<Mai2UserDetail> = emptyList()
var userOption: List<Mai2UserOption>? = null
var userExtend: List<Mai2UserExtend>? = null
var userCharacterList: List<Mai2UserCharacter>? = null
var userGhost: List<Mai2UserGhost>? = null
var userMapList: List<Mai2UserMap>? = null
var userLoginBonusList: List<Mai2UserLoginBonus>? = null
var userRatingList: List<UserRating>? = null
var userItemList: List<Mai2UserItem>? = null
var userMusicDetailList: List<Mai2UserMusicDetail>? = null
var userCourseList: List<Mai2UserCourse>? = null
var userFriendSeasonRankingList: List<Mai2UserFriendSeasonRanking>? = null
var userChargeList: List<Mai2UserCharge>? = null
var userFavoriteList: List<Mai2UserFavorite>? = null
var userActivityList: List<Mai2UserActivity>? = null
var userGamePlaylogList: List<Map<String, Any>>? = null
var userFavoritemusicList: List<Mai2UserFavoriteItem>? = null
var userKaleidxScopeList: List<Mai2UserKaleidx>? = null
var userIntimateList: List<Mai2UserIntimate>? = null
var isNewCharacterList: String? = null
var isNewMapList: String? = null
var isNewLoginBonusList: String? = null
var isNewItemList: String? = null
var isNewMusicDetailList: String? = null
var isNewCourseList: String? = null
var isNewFavoriteList: String? = null
var isNewFriendSeasonRankingList: String? = null
var isNewFavoritemusicList: String? = null
var isNewKaleidxScopeList: String? = null
}
class Mai2UserFavoriteItem {
var orderId = 0
var id = 0
}
class Mai2UserActivity {
var playList: List<Mai2UserAct> = emptyList()
var musicList: List<Mai2UserAct> = emptyList()
}

View File

@@ -0,0 +1,25 @@
package icu.samnyan.aqua.sega.maimai2.model.request
class Mai2UserPhoto {
var orderId = 0
var userId: Long = 0
var divNumber = 0
var divLength = 0
var divData: String? = null
var placeId = 0
var clientId: String? = null
var uploadDate: String? = null
var playlogId: Long = 0
var trackNo = 0
}
class Mai2UserPortrait {
var userId: Long = 0
var divNumber = 0
var divLength = 0
var divData: String? = null
var placeId = 0
var clientId: String = ""
var uploadDate: String = "1970-01-01 09:00:00.0"
var fileName: String = "portrait.jpg"
}

View File

@@ -1,18 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request;
import icu.samnyan.aqua.sega.maimai2.model.request.data.UserPhoto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UploadUserPhoto implements Serializable {
private UserPhoto userPhoto;
}

View File

@@ -1,20 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPlaylog;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UploadUserPlaylog implements Serializable {
private long userId;
private Mai2UserPlaylog userPlaylog;
}

View File

@@ -1,18 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request;
import icu.samnyan.aqua.sega.maimai2.model.request.data.UserPortrait;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UploadUserPortrait implements Serializable {
private UserPortrait userPortrait;
}

View File

@@ -1,25 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import icu.samnyan.aqua.sega.maimai2.model.request.data.UserAll;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UpsertUserAll implements Serializable {
private long userId;
private long playlogId;
@JsonProperty("isEventMode")
private boolean isEventMode;
@JsonProperty("isFreePlay")
private boolean isFreePlay;
private UserAll upsertUserAll;
}

View File

@@ -1,22 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPrintDetail;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Map;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UpsertUserPrint implements Serializable {
private long userId;
private long orderId;
private Map<String, Object> userPrintReserve;
private Mai2UserPrintDetail userPrintDetail;
}

View File

@@ -1,50 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request.data;
import icu.samnyan.aqua.sega.maimai2.model.response.data.Mai2UserActivity;
import icu.samnyan.aqua.sega.maimai2.model.response.data.UserRating;
import icu.samnyan.aqua.sega.maimai2.model.userdata.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserAll implements Serializable {
private List<Mai2UserDetail> userData;
private List<Mai2UserExtend> userExtend;
private List<Mai2UserOption> userOption;
private List<Mai2UserCharacter> userCharacterList;
private List<Mai2UserGhost> userGhost;
private List<Mai2UserMap> userMapList;
private List<Mai2UserLoginBonus> userLoginBonusList;
private List<UserRating> userRatingList;
private List<Mai2UserItem> userItemList;
private List<Mai2UserMusicDetail> userMusicDetailList;
private List<Mai2UserCourse> userCourseList;
private List<Mai2UserFriendSeasonRanking> userFriendSeasonRankingList;
private List<Mai2UserCharge> userChargeList;
private List<Mai2UserFavorite> userFavoriteList;
private List<Mai2UserActivity> userActivityList;
private List<Map<String, Object>> userGamePlaylogList;
private List<UserFavoriteItem> userFavoritemusicList;
private List<Mai2UserKaleidx> userKaleidxScopeList;
private List<Mai2UserIntimate> userIntimateList;
private String isNewCharacterList;
private String isNewMapList;
private String isNewLoginBonusList;
private String isNewItemList;
private String isNewMusicDetailList;
private String isNewCourseList;
private String isNewFavoriteList;
private String isNewFriendSeasonRankingList;
private String isNewFavoritemusicList;
private String isNewKaleidxScopeList;
}

View File

@@ -1,15 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request.data;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserFavoriteItem implements Serializable {
private int orderId;
private int id;
}

View File

@@ -1,26 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request.data;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserPhoto implements Serializable {
private int orderId;
private long userId;
private int divNumber;
private int divLength;
private String divData;
private int placeId;
private String clientId;
private String uploadDate;
private long playlogId;
private int trackNo;
}

View File

@@ -1,24 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.request.data;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserPortrait implements Serializable {
private long userId;
private int divNumber;
private int divLength;
private String divData;
private int placeId;
private String clientId;
private String uploadDate;
private String fileName;
}

View File

@@ -1,19 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.response.data;
import java.util.List;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserAct;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Mai2UserActivity {
private List<Mai2UserAct> playList;
private List<Mai2UserAct> musicList;
}

View File

@@ -1,24 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.response.data;
import java.util.List;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserRate;
import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserUdemae;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserRating {
private int rating;
private List<Mai2UserRate> ratingList;
private List<Mai2UserRate> newRatingList;
private List<Mai2UserRate> nextRatingList;
private List<Mai2UserRate> nextNewRatingList;
private Mai2UserUdemae udemae;
}

View File

@@ -1,15 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.response.data;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserRivalMusic {
private int musicId;
private List<UserRivalMusicDetail> userRivalMusicDetailList;
}

View File

@@ -1,14 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.model.response.data;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserRivalMusicDetail {
private int level;
private int achievement;
private int deluxscoreMax;
}

View File

@@ -1,96 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.worldslink
import ext.*
import icu.samnyan.aqua.net.utils.PathProps
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.io.BufferedWriter
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDateTime
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.io.path.readText
// KotlinX Serialization
@OptIn(ExperimentalSerializationApi::class)
private val KJson = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
coerceInputValues = true
}
// Maximum time to live for a recruit record
const val MAX_TTL = 30 * 1000
@RestController
@RequestMapping(path = ["/mai2-futari"])
class FutariLobby(val paths: PathProps) {
// <IP Address, RecruitInfo>
val recruits = mutableMapOf<UInt, RecruitRecord>()
// Append writer
lateinit var writer: BufferedWriter
val mutex = ReentrantLock()
val log = logger()
init {
paths.init()
writer = FileOutputStream(File(paths.futariRecruitLog), true).bufferedWriter()
}
fun log(data: String) = mutex.withLock {
log.info(data)
writer.write(data)
writer.newLine()
writer.flush()
}
fun log(data: RecruitRecord, msg: String) =
log("${LocalDateTime.now().isoDateTime()}: $msg: ${KJson.encodeToString(data)}")
val RecruitRecord.ip get() = RecruitInfo.MechaInfo.IpAddress
@API("recruit/start")
fun startRecruit(@RB data: String) {
val d = parsing { KJson.decodeFromString<RecruitRecord>(data) }.apply { Time = millis() }
val exists = d.ip in recruits
recruits[d.ip] = d
if (!exists) log(d, "StartRecruit")
d.RecruitInfo.MechaInfo.UserIDs = d.RecruitInfo.MechaInfo.UserIDs.map { it.str.hashToUInt().toLong() }
}
@API("recruit/finish")
fun finishRecruit(@RB data: String) {
val d = parsing { KJson.decodeFromString<RecruitRecord>(data) }
if (d.ip !in recruits) 400 - "Recruit not found"
// if (d.Keychip != recruits[d.ip]!!.Keychip) 400 - "Keychip mismatch"
recruits.remove(d.ip)
log(d, "EndRecruit")
}
@API("recruit/list")
fun listRecruit(): String {
val time = millis()
recruits.filterValues { time - it.Time > MAX_TTL }.keys.forEach { recruits.remove(it) }
return recruits.values.toList().joinToString("\n") { KJson.encodeToString(it) }
}
@API("server-list")
fun serverList() = paths.futariRelayInfo.path().readText().trim()
}
fun main(args: Array<String>) {
val json = """{"RecruitInfo":{"MechaInfo":{"IsJoin":true,"IpAddress":1820162433,"MusicID":11692,"Entrys":[true,false],"UserIDs":[281474976710657,281474976710657],"UserNames":["",""],"IconIDs":[1,1],"FumenDifs":[0,-1],"Rateing":[0,0],"ClassValue":[0,0],"MaxClassValue":[0,0],"UserType":[0,0]},"MusicID":11692,"GroupID":0,"EventModeID":false,"JoinNumber":1,"PartyStance":0,"_startTimeTicks":638725464510308001,"_recvTimeTicks":0}}"""
println(json.jsonMap().toJson())
val data = KJson.decodeFromString<RecruitRecord>(json)
println(json)
println(KJson.encodeToString(data))
println(data)
}

View File

@@ -1,187 +0,0 @@
package icu.samnyan.aqua.sega.maimai2.worldslink
import ext.logger
import ext.md5
import ext.millis
import ext.thread
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketTimeoutException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantLock
import kotlin.collections.set
import kotlin.concurrent.withLock
const val PROTO_VERSION = 1
const val MAX_STREAMS = 10
const val SO_TIMEOUT = 20000
//const val SO_TIMEOUT = 10000000
fun ctlMsg(cmd: UInt, data: String? = null) = Msg(cmd, data = data)
data class ActiveClient(
val clientKey: String,
val socket: Socket,
val reader: BufferedReader,
val writer: BufferedWriter,
val thread: Thread = Thread.currentThread(),
// <Stream ID, Destination client stub IP>
val tcpStreams: MutableMap<UInt, UInt> = mutableMapOf(),
val pendingStreams: MutableSet<UInt> = mutableSetOf(),
) {
val log = logger()
val stubIp = keychipToStubIp(clientKey)
val writeMutex = ReentrantLock()
var lastHeartbeat = millis()
fun send(msg: Msg) {
writeMutex.withLock {
try {
writer.write(msg.toString())
writer.newLine()
writer.flush()
}
catch (e: Exception) {
log.error("Error sending message", e)
socket.close()
thread.interrupt()
}
}
}
}
fun ActiveClient.handle(msg: Msg) {
// Find target by dst IP address or TCP stream ID
val target = (msg.sid?.let { tcpStreams[it] } ?: msg.dst)?.let { clients[it] }
when (msg.cmd) {
Command.CTL_HEARTBEAT -> {
lastHeartbeat = millis()
send(ctlMsg(Command.CTL_HEARTBEAT))
}
Command.DATA_BROADCAST -> {
// Broadcast to all clients. This is only used in UDP so SID is always 0
if (msg.proto != Proto.UDP) return log.warn("TCP Broadcast received, something is wrong.")
clients.values.forEach { it.send(msg.copy(src = stubIp)) }
}
Command.DATA_SEND -> {
target ?: return log.warn("Send: Target not found: ${msg.dst}")
if (msg.proto == Proto.TCP && msg.sid !in tcpStreams)
return log.warn("Stream ID not found: ${msg.sid}")
target.send(msg.copy(src = stubIp, dst = target.stubIp))
}
Command.CTL_TCP_CONNECT -> {
target ?: return log.warn("Connect: Target not found: ${msg.dst}")
val sid = msg.sid ?: return log.warn("Connect: Stream ID not found")
if (sid in tcpStreams || sid in pendingStreams)
return log.warn("Stream ID already in use: $sid")
// Add the stream to the pending list
pendingStreams.add(sid)
if (pendingStreams.size > MAX_STREAMS) {
log.warn("Too many pending streams, closing connection")
return socket.close()
}
target.send(msg.copy(src = stubIp, dst = target.stubIp))
}
Command.CTL_TCP_ACCEPT -> {
target ?: return log.warn("Accept: Target not found: ${msg.dst}")
val sid = msg.sid ?: return log.warn("Accept: Stream ID not found")
if (sid !in target.pendingStreams)
return log.warn("Stream ID not found in pending list: $sid")
// Add the stream to the active list
target.pendingStreams.remove(sid)
target.tcpStreams[sid] = stubIp
tcpStreams[sid] = target.stubIp
target.send(msg.copy(src = stubIp, dst = target.stubIp))
}
}
}
fun String.hashToUInt() = md5().let {
((it[0].toUInt() and 0xFFu) shl 24) or
((it[1].toUInt() and 0xFFu) shl 16) or
((it[2].toUInt() and 0xFFu) shl 8) or
(it[3].toUInt() and 0xFFu)
}
fun keychipToStubIp(keychip: String) = keychip.hashToUInt()
// Keychip ID to Socket
val clients = ConcurrentHashMap<UInt, ActiveClient>()
/**
* Service for the party linker for AquaMai
*/
class MaimaiFutari(private val port: Int = 20101) {
val log = logger()
fun start() {
val serverSocket = ServerSocket(port)
log.info("Server started on port $port")
while (true) {
val clientSocket = serverSocket.accept().apply {
soTimeout = SO_TIMEOUT
log.info("[+] Client connected: $remoteSocketAddress")
}
thread { handleClient(clientSocket) }
}
}
fun handleClient(socket: Socket) {
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream()))
var handler: ActiveClient? = null
try {
while (!Thread.interrupted() && !socket.isClosed) {
val input = (reader.readLine() ?: break).trim('\uFEFF')
if (input != "1,3") log.info("${socket.remoteSocketAddress} (${handler?.clientKey}) <<< $input")
val message = Msg.fromString(input)
when (message.cmd) {
// Start: Register the client. Payload is the keychip
Command.CTL_START -> {
val id = message.data as String
val client = ActiveClient(id, socket, reader, writer)
clients[client.stubIp]?.socket?.close()
clients[client.stubIp] = client
handler = clients[client.stubIp]
log.info("[+] Client registered: ${socket.remoteSocketAddress} -> $id")
// Send back the version
handler?.send(ctlMsg(Command.CTL_START, "version=$PROTO_VERSION"))
}
// Handle any other command using the handler
else -> {
(handler ?: throw Exception("Client not registered")).handle(message)
}
}
}
} catch (e: Exception) {
if (e.message != "Connection reset" && e !is SocketTimeoutException)
log.error("Error in client handler", e)
} finally {
// Remove client
handler?.stubIp?.let { clients.remove(it) }
socket.close()
log.info("[-] Client disconnected: ${handler?.clientKey}")
}
}
}
fun main() = MaimaiFutari().start()

View File

@@ -1,100 +0,0 @@
@file:Suppress("PropertyName")
package icu.samnyan.aqua.sega.maimai2.worldslink
import ext.*
import kotlinx.serialization.Serializable
object Command {
// Control plane
const val CTL_START = 1u
const val CTL_BIND = 2u
const val CTL_HEARTBEAT = 3u
const val CTL_TCP_CONNECT = 4u // Accept a new multiplexed TCP stream
const val CTL_TCP_ACCEPT = 5u
const val CTL_TCP_ACCEPT_ACK = 6u
const val CTL_TCP_CLOSE = 7u
// Data plane
const val DATA_SEND = 21u
const val DATA_BROADCAST = 22u
}
object Proto {
const val TCP = 6u
const val UDP = 17u
}
data class Msg(
var cmd: UInt,
var proto: UInt? = null,
var sid: UInt? = null,
var src: UInt? = null,
var sPort: UInt? = null,
var dst: UInt? = null,
var dPort: UInt? = null,
var data: String? = null
) {
override fun toString() = ls(
1, cmd, proto, sid, src, sPort, dst, dPort,
null, null, null, null, null, null, null, null, // reserved for future use
data
).joinToString(",") { it?.str ?: "" }.trimEnd(',')
companion object {
val fields = arr(Msg::proto, Msg::sid, Msg::src, Msg::sPort, Msg::dst, Msg::dPort)
fun fromString(str: String): Msg {
val sp = str.split(',')
return Msg(0u).apply {
cmd = sp[1].toUInt()
fields.forEachIndexed { i, f -> f.set(this, sp.getOrNull(i + 2)?.some?.toUIntOrNull()) }
data = sp.drop(16).joinToString(",")
}
}
}
}
@Serializable
data class MechaInfo(
val IsJoin: Bool,
val IpAddress: UInt,
val MusicID: Int,
val Entrys: List<Bool>,
var UserIDs: List<Long>,
val UserNames: List<String>,
val IconIDs: List<Int>,
val FumenDifs: List<Int>,
val Rateing: List<Int>,
val ClassValue: List<Int>,
val MaxClassValue: List<Int>,
val UserType: List<Int>
)
@Serializable
data class RecruitInfo(
val MechaInfo: MechaInfo,
val MusicID: Int,
val GroupID: Int,
val EventModeID: Boolean,
val JoinNumber: Int,
val PartyStance: Int,
val _startTimeTicks: Long,
val _recvTimeTicks: Long
)
@Serializable
data class RecruitRecord(
val RecruitInfo: RecruitInfo,
val Keychip: String,
var Server: RelayServerInfo? = null,
var Time: Long = 0,
)
@Serializable
data class RelayServerInfo(
val name: String,
val addr: String,
val port: Int = 20101,
val official: Bool = true
)

View File

@@ -2,8 +2,6 @@ package icu.samnyan.aqua.sega.ongeki.model.userdata;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import icu.samnyan.aqua.sega.util.jackson.UserIdSerializer;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;

View File

@@ -1,29 +0,0 @@
package icu.samnyan.aqua.sega.util
import java.util.*
import kotlin.text.Charsets.UTF_8
object AllNetBillingDecoder {
/**
* Decode the input byte array from Base64 MIME encoding and decompress the decoded byte array
*/
fun decode(src: ByteArray, base64: Boolean, nowrap: Boolean): Map<String, String> {
// Decode the input byte array from Base64 MIME encoding
val bytes = if (!base64) src else Base64.getMimeDecoder().decode(src)
// Decompress the decoded byte array
val output = ZLib.decompress(bytes, nowrap).toString(UTF_8).trim()
// Split the string by '&' symbol to separate key-value pairs
return output.split("&").associate {
val (key, value) = it.split("=")
key to value
}
}
@JvmStatic
fun decodeAllNet(src: ByteArray) = decode(src, base64 = true, nowrap = false)
@JvmStatic
fun decodeBilling(src: ByteArray) = decode(src, base64 = false, nowrap = true)
}

View File

@@ -1,27 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import icu.samnyan.aqua.sega.general.model.Card;
import java.io.IOException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class AccessCodeSerializer extends StdSerializer<Card> {
public AccessCodeSerializer() {
this(null);
}
public AccessCodeSerializer(Class<Card> t) {
super(t);
}
@Override
public void serialize(Card value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString(value.getLuid());
}
}

View File

@@ -1,55 +1,96 @@
package icu.samnyan.aqua.sega.util.jackson package icu.samnyan.aqua.sega.util.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.json.JsonWriteFeature
import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import ext.jsonArray
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
interface IMapper { open class IMapper(val mapper: ObjectMapper) {
fun write(o: Any?): String fun write(o: Any): String = mapper.writeValueAsString(o)
fun <T> convert(map: Any, to: Class<T>) = mapper.convertValue(map, to)
fun <T> convert(map: Any, to: TypeReference<T>): T = mapper.convertValue(map, to)
fun <T> read(json: String, to: Class<T>) = mapper.readValue(json, to)
fun <T> read(json: String, to: TypeReference<T>) = mapper.readValue(json, to)
inline fun <reified T> convert(map: Any): T = convert(map, object : TypeReference<T>() {})
inline fun <reified T> read(json: String): T = read(json, object : TypeReference<T>() {})
}
val BASIC_MAPPER = jacksonObjectMapper().apply {
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
configure(SerializationFeature.WRITE_ENUMS_USING_INDEX, true)
findAndRegisterModules()
registerModule(SimpleModule().apply {
addSerializer(
LocalDateTime::class.java,
LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
)
addDeserializer(
LocalDateTime::class.java,
LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
)
})
} }
@Component @Component
class BasicMapper: IMapper { class BasicMapper: IMapper(BASIC_MAPPER)
companion object {
val BASIC_MAPPER = jacksonObjectMapper().apply {
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
configure(SerializationFeature.WRITE_ENUMS_USING_INDEX, true) val BOOLEAN_SERIALIZER = object : StdSerializer<Boolean>(Boolean::class.java) {
findAndRegisterModules() override fun serialize(v: Boolean, gen: JsonGenerator, p: SerializerProvider) {
registerModule(SimpleModule().apply { gen.writeString(v.toString())
addSerializer(
LocalDateTime::class.java,
LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
)
addDeserializer(
LocalDateTime::class.java,
LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0"))
)
})
}
} }
}
override fun write(o: Any?) =
BASIC_MAPPER.writeValueAsString(o) var STRING_MAPPER = jacksonObjectMapper().apply {
enable(JsonWriteFeature.WRITE_NUMBERS_AS_STRINGS.mappedFeature())
fun <T> read(jsonStr: String?, toClass: Class<T>?) = configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
BASIC_MAPPER.readValue(jsonStr, toClass) configure(SerializationFeature.WRITE_ENUMS_USING_INDEX, true)
findAndRegisterModules()
fun <T> read(jsonStr: String?, toValueTypeRef: TypeReference<T>?) = registerModule(SimpleModule().apply {
BASIC_MAPPER.readValue(jsonStr, toValueTypeRef) addSerializer(
LocalDateTime::class.java,
fun <T> convert(map: Any?, toClass: Class<T>?) = LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
BASIC_MAPPER.convertValue(map, toClass) )
addDeserializer(
fun <T> convert(map: Any?, toValueTypeRef: TypeReference<T>?) = LocalDateTime::class.java,
BASIC_MAPPER.convertValue(map, toValueTypeRef) LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
)
fun toMap(obj: Any?): LinkedHashMap<String, Any?> = addSerializer(Boolean::class.javaObjectType, BOOLEAN_SERIALIZER)
BASIC_MAPPER.convertValue(obj, object : TypeReference<LinkedHashMap<String, Any?>>() {}) addSerializer(Boolean::class.javaPrimitiveType, BOOLEAN_SERIALIZER)
})
}
@Component
class StringMapper: IMapper(STRING_MAPPER)
// Testing code
private class A(
var cat: String = ""
)
fun main(args: Array<String>) {
val json = """{"cat":"meow"}"""
val a = BasicMapper().read(json, A::class.java)
println(a.cat)
val lst = """[{"cat":"meow"}, {"cat":"meow"}]"""
val b = BasicMapper().convert(lst.jsonArray(), object : TypeReference<List<A>>() {})
println(b[0].cat)
println(b.size)
val c = BasicMapper().convert<List<A>>(lst.jsonArray())
println(c[0].cat)
} }

View File

@@ -1,19 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import java.io.IOException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class BooleanNumberDeserializer extends JsonDeserializer<Boolean> {
@Override
public Boolean deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
return !"0".equals(p.getText());
}
}

View File

@@ -1,26 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class BooleanNumberSerializer extends StdSerializer<Boolean> {
public BooleanNumberSerializer() {
this(null);
}
public BooleanNumberSerializer(Class<Boolean> t) {
super(t);
}
@Override
public void serialize(Boolean value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeNumber(value ? 1 : 0);
}
}

View File

@@ -1,23 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import ext.int
class BooleanToIntegerDeserializer : JsonDeserializer<Int>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Int {
return when (p.currentToken) {
JsonToken.VALUE_STRING -> when (val str = p.valueAsString.lowercase()) {
"true" -> 1
"false" -> 0
else -> str.int
}
JsonToken.VALUE_NUMBER_INT -> p.intValue
JsonToken.VALUE_TRUE -> 1
JsonToken.VALUE_FALSE -> 0
else -> throw UnsupportedOperationException("Cannot deserialize to boolean int")
}
}
}

View File

@@ -1,30 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil;
import java.io.IOException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class ByteBufSerializer extends StdSerializer<ByteBuf> {
public ByteBufSerializer() {
this(null);
}
public ByteBufSerializer(Class<ByteBuf> t) {
super(t);
}
@Override
public void serialize(ByteBuf value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString(ByteBufUtil.hexDump(value));
}
}

View File

@@ -0,0 +1,47 @@
package icu.samnyan.aqua.sega.util.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import ext.int
import icu.samnyan.aqua.sega.general.model.Card
import java.time.ZonedDateTime
class AccessCodeSerializer @JvmOverloads constructor(t: Class<Card>? = null) : StdSerializer<Card>(t) {
override fun serialize(value: Card, gen: JsonGenerator, provider: SerializerProvider) {
gen.writeString(value.luid)
}
}
class BooleanToIntegerDeserializer : JsonDeserializer<Int>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext) = when (p.currentToken) {
JsonToken.VALUE_STRING -> when (val str = p.valueAsString.lowercase()) {
"true" -> 1
"false" -> 0
else -> str.int
}
JsonToken.VALUE_NUMBER_INT -> p.intValue
JsonToken.VALUE_TRUE -> 1
JsonToken.VALUE_FALSE -> 0
else -> throw UnsupportedOperationException("Cannot deserialize to boolean int")
}
}
class BooleanNumberSerializer @JvmOverloads constructor(t: Class<Boolean>? = null) : StdSerializer<Boolean>(t) {
override fun serialize(value: Boolean, gen: JsonGenerator, provider: SerializerProvider) {
gen.writeNumber(if (value) 1 else 0)
}
}
class BooleanNumberDeserializer : JsonDeserializer<Boolean>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext) = "0" != p.text
}
class ZonedDateTimeDeserializer : JsonDeserializer<ZonedDateTime>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext) = ZonedDateTime.parse(p.text)
}

View File

@@ -1,51 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.json.JsonWriteFeature
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Component
class StringMapper: IMapper {
override fun write(o: Any?) = STRING_MAPPER.writeValueAsString(o)
fun <T> convert(map: Any?, toClass: Class<T>?) = STRING_MAPPER.convertValue(map, toClass)
final inline fun <reified T> convert(map: Any?) = convert(map, T::class.java)
fun toMap(obj: Any?) = STRING_MAPPER.convertValue(obj, object : TypeReference<LinkedHashMap<String, Any>>() {})
companion object {
val BOOLEAN_SERIALIZER = object : StdSerializer<Boolean>(Boolean::class.java) {
override fun serialize(v: Boolean, gen: JsonGenerator, p: SerializerProvider) {
gen.writeString(v.toString())
}
}
var STRING_MAPPER = jacksonObjectMapper().apply {
enable(JsonWriteFeature.WRITE_NUMBERS_AS_STRINGS.mappedFeature())
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
configure(SerializationFeature.WRITE_ENUMS_USING_INDEX, true)
findAndRegisterModules()
registerModule(SimpleModule().apply {
addSerializer(
LocalDateTime::class.java,
LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
)
addDeserializer(
LocalDateTime::class.java,
LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
)
addSerializer(Boolean::class.javaObjectType, BOOLEAN_SERIALIZER)
addSerializer(Boolean::class.javaPrimitiveType, BOOLEAN_SERIALIZER)
})
}
}
}

View File

@@ -1,27 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import icu.samnyan.aqua.sega.ongeki.model.userdata.UserData;
import java.io.IOException;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class UserIdSerializer extends StdSerializer<UserData> {
public UserIdSerializer() {
this(null);
}
public UserIdSerializer(Class<UserData> t) {
super(t);
}
@Override
public void serialize(UserData value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeNumber(value.getCard().getExtId());
}
}

View File

@@ -1,19 +0,0 @@
package icu.samnyan.aqua.sega.util.jackson;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import java.io.IOException;
import java.time.ZonedDateTime;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
public class ZonedDateTimeDeserializer extends JsonDeserializer<ZonedDateTime> {
@Override
public ZonedDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
return ZonedDateTime.parse(p.getText());
}
}