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:
2026-07-12 23:16:32 +00:00
parent a0a64bc70a
commit 96e996e9c2
10 changed files with 305 additions and 0 deletions
+24
View File
@@ -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>
)
}
+24
View File
@@ -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>
)
}
+57
View File
@@ -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,
})
}
+27
View File
@@ -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)
}
+34
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 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
}
}
+15
View File
@@ -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."
}
}
+15
View File
@@ -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."
}
}