Cliente SOAP + servicios de personaje SOAP (revive/unstuck) en Next.js
- lib/soap.ts: executeSoapCommand (port de ac_soap.py; POST SOAP + basic auth al worldserver, timeout 5s, null si inaccesible). AC_SOAP_* en .env.local. - lib/characters.ts: getGameCharacters / characterIsOfflineAndOwned (acore_characters). - lib/character-services.ts: soapCharacterAction genérico (valida propiedad+offline, cooldown 12h vía home_revive/unstuckhistory, SOAP, registra historial) -> reviveCharacter / unstuckCharacter. - components/CharacterActionForm.tsx (cliente reutilizable: select + botón). - Routes /api/character/revive|unstuck (guard de sesión) y páginas app/[locale]/revive|unstuck (SSR, guardas, i18n). Namespace Services. Verificado: API sin sesión 401, páginas redirigen a login, cliente SOAP devuelve null limpio sin worldserver. Patrón base para el resto de servicios de personaje. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
import { executeSoapCommand } from './soap'
|
||||
import { characterIsOfflineAndOwned } from './characters'
|
||||
|
||||
export interface Result {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface SoapActionConfig {
|
||||
command: (character: string) => string
|
||||
historyTable: string // tabla django_wow con columnas (character_name, used_at)
|
||||
cooldownHours: number
|
||||
}
|
||||
|
||||
/** Acción de personaje por SOAP con cooldown (revive, unstuck...). */
|
||||
async function soapCharacterAction(accountId: number, character: string, cfg: SoapActionConfig): Promise<Result> {
|
||||
if (!character) return { success: false, error: 'noCharacter' }
|
||||
if (!(await characterIsOfflineAndOwned(accountId, character))) {
|
||||
return { success: false, error: 'notOfflineOrOwned' }
|
||||
}
|
||||
try {
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
`SELECT used_at FROM ${cfg.historyTable} WHERE character_name = ? ORDER BY used_at DESC LIMIT 1`,
|
||||
[character],
|
||||
)
|
||||
if (rows[0] && Date.now() - new Date(rows[0].used_at).getTime() < cfg.cooldownHours * 3600_000) {
|
||||
return { success: false, error: 'cooldown' }
|
||||
}
|
||||
} catch {
|
||||
/* tabla de historial ausente: seguimos */
|
||||
}
|
||||
const resp = await executeSoapCommand(cfg.command(character))
|
||||
if (resp === null) return { success: false, error: 'soapError' }
|
||||
await db(DB.default).query(
|
||||
`INSERT INTO ${cfg.historyTable} (character_name, used_at) VALUES (?, NOW())`,
|
||||
[character],
|
||||
)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export function reviveCharacter(accountId: number, character: string): Promise<Result> {
|
||||
return soapCharacterAction(accountId, character, {
|
||||
command: (c) => `.revive ${c}`,
|
||||
historyTable: 'home_revivehistory',
|
||||
cooldownHours: 12,
|
||||
})
|
||||
}
|
||||
|
||||
export function unstuckCharacter(accountId: number, character: string): Promise<Result> {
|
||||
return soapCharacterAction(accountId, character, {
|
||||
command: (c) => `.unstuck ${c}`,
|
||||
historyTable: 'home_unstuckhistory',
|
||||
cooldownHours: 12,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
export interface GameCharacter {
|
||||
name: string
|
||||
online: boolean
|
||||
}
|
||||
|
||||
/** Personajes de una cuenta de juego (acore_characters). Vacío si no está poblada. */
|
||||
export async function getGameCharacters(accountId: number): Promise<GameCharacter[]> {
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT name, online FROM characters WHERE account = ?',
|
||||
[accountId],
|
||||
)
|
||||
return rows.map((r) => ({ name: r.name, online: r.online === 1 }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** True si el personaje pertenece a la cuenta y está desconectado. */
|
||||
export async function characterIsOfflineAndOwned(accountId: number, name: string): Promise<boolean> {
|
||||
const chars = await getGameCharacters(accountId)
|
||||
const c = chars.find((ch) => ch.name === name)
|
||||
return Boolean(c && !c.online)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Cliente SOAP al worldserver de AzerothCore (port de home/ac_soap.py).
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** Ejecuta un comando en el worldserver por SOAP. Devuelve el texto de respuesta o null. */
|
||||
export async function executeSoapCommand(command: string): Promise<string | null> {
|
||||
const url = process.env.AC_SOAP_URL
|
||||
const user = process.env.AC_SOAP_USER ?? ''
|
||||
const pass = process.env.AC_SOAP_PASSWORD ?? ''
|
||||
const urn = process.env.AC_SOAP_URN ?? 'urn:AC'
|
||||
if (!url) return null
|
||||
|
||||
const soap = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="${urn}">
|
||||
<SOAP-ENV:Body><ns1:executeCommand><command>${escapeXml(command)}</command></ns1:executeCommand></SOAP-ENV:Body>
|
||||
</SOAP-ENV:Envelope>`
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'),
|
||||
'Content-Type': 'application/xml',
|
||||
},
|
||||
body: soap,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return await res.text()
|
||||
} catch {
|
||||
return null // worldserver inaccesible
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user