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,24 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { CharacterActionForm } from '@/components/CharacterActionForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function RevivePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Services')
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
const chars = await getGameCharacters(session.accountId!)
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('reviveTitle')}</h1>
|
||||
<CharacterActionForm characters={chars.map((c) => c.name)} endpoint="/api/character/revive" actionLabel={t('reviveAction')} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { CharacterActionForm } from '@/components/CharacterActionForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function UnstuckPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Services')
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
const chars = await getGameCharacters(session.accountId!)
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('unstuckTitle')}</h1>
|
||||
<CharacterActionForm characters={chars.map((c) => c.name)} endpoint="/api/character/unstuck" actionLabel={t('unstuckAction')} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getSession } from '@/lib/session'
|
||||
import { reviveCharacter } from '@/lib/character-services'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
|
||||
let character = ''
|
||||
try {
|
||||
const body = await request.json()
|
||||
character = String(body.character ?? '')
|
||||
} catch {
|
||||
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||
}
|
||||
return Response.json(await reviveCharacter(session.accountId, character))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getSession } from '@/lib/session'
|
||||
import { unstuckCharacter } from '@/lib/character-services'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
|
||||
let character = ''
|
||||
try {
|
||||
const body = await request.json()
|
||||
character = String(body.character ?? '')
|
||||
} catch {
|
||||
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||
}
|
||||
return Response.json(await unstuckCharacter(session.accountId, character))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
const ERROR_KEYS = ['noCharacter', 'notOfflineOrOwned', 'cooldown', 'soapError'] as const
|
||||
|
||||
interface Props {
|
||||
characters: string[]
|
||||
endpoint: string
|
||||
actionLabel: string
|
||||
}
|
||||
|
||||
/** Formulario reutilizable para acciones de personaje por SOAP (revive, unstuck...). */
|
||||
export function CharacterActionForm({ characters, endpoint, actionLabel }: Props) {
|
||||
const t = useTranslations('Services')
|
||||
const [character, setCharacter] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy || !character) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ character }),
|
||||
})
|
||||
const data: { success?: boolean; error?: string } = await res.json()
|
||||
if (data.success) setMessage({ ok: true, text: t('success') })
|
||||
else {
|
||||
const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError'
|
||||
setMessage({ ok: false, text: t(key) })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, text: t('genericError') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (characters.length === 0) {
|
||||
return <p className="text-amber-200/70">{t('noCharactersYet')}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm text-center">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<select
|
||||
value={character}
|
||||
onChange={(e) => setCharacter(e.target.value)}
|
||||
required
|
||||
className="w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t('selectCharacter')}
|
||||
</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !character}
|
||||
className="w-full rounded bg-amber-600 px-4 py-2 font-semibold text-[#1b120b] disabled:opacity-60"
|
||||
>
|
||||
{busy ? t('processing') : actionLabel}
|
||||
</button>
|
||||
</form>
|
||||
{message && <p className={`mt-3 ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -120,5 +120,20 @@
|
||||
"passwordTooLong": "The password must not exceed 16 characters.",
|
||||
"accountNotFound": "Account not found.",
|
||||
"genericError": "Something went wrong. Please try again later."
|
||||
},
|
||||
"Services": {
|
||||
"reviveTitle": "Revive character",
|
||||
"unstuckTitle": "Unstuck character",
|
||||
"reviveAction": "Revive",
|
||||
"unstuckAction": "Unstuck",
|
||||
"selectCharacter": "Select a character",
|
||||
"noCharactersYet": "You have no available characters.",
|
||||
"processing": "Processing…",
|
||||
"success": "Action completed successfully.",
|
||||
"noCharacter": "Select a character.",
|
||||
"notOfflineOrOwned": "The character must be offline and yours.",
|
||||
"cooldown": "You can only do this once every 12 hours.",
|
||||
"soapError": "Server communication error. Please try again later.",
|
||||
"genericError": "Something went wrong. Please try again later."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,5 +120,20 @@
|
||||
"passwordTooLong": "La contraseña no debe exceder los 16 caracteres.",
|
||||
"accountNotFound": "No se ha encontrado la cuenta.",
|
||||
"genericError": "Algo ha salido mal. Inténtalo más tarde."
|
||||
},
|
||||
"Services": {
|
||||
"reviveTitle": "Revivir personaje",
|
||||
"unstuckTitle": "Desbloquear personaje",
|
||||
"reviveAction": "Revivir",
|
||||
"unstuckAction": "Desbloquear",
|
||||
"selectCharacter": "Selecciona un personaje",
|
||||
"noCharactersYet": "No tienes personajes disponibles.",
|
||||
"processing": "Procesando…",
|
||||
"success": "Acción realizada con éxito.",
|
||||
"noCharacter": "Selecciona un personaje.",
|
||||
"notOfflineOrOwned": "El personaje debe estar desconectado y ser tuyo.",
|
||||
"cooldown": "Solo puedes hacerlo una vez cada 12 horas.",
|
||||
"soapError": "Error de comunicación con el servidor. Inténtalo más tarde.",
|
||||
"genericError": "Algo ha salido mal. Inténtalo más tarde."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user