From 96e996e9c2ea9d1e6c21ef2878af285135eaf10b Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 23:16:32 +0000 Subject: [PATCH] Cliente SOAP + servicios de personaje SOAP (revive/unstuck) en Next.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- web-next/app/[locale]/revive/page.tsx | 24 +++++++ web-next/app/[locale]/unstuck/page.tsx | 24 +++++++ web-next/app/api/character/revive/route.ts | 15 ++++ web-next/app/api/character/unstuck/route.ts | 15 ++++ web-next/components/CharacterActionForm.tsx | 79 +++++++++++++++++++++ web-next/lib/character-services.ts | 57 +++++++++++++++ web-next/lib/characters.ts | 27 +++++++ web-next/lib/soap.ts | 34 +++++++++ web-next/messages/en.json | 15 ++++ web-next/messages/es.json | 15 ++++ 10 files changed, 305 insertions(+) create mode 100644 web-next/app/[locale]/revive/page.tsx create mode 100644 web-next/app/[locale]/unstuck/page.tsx create mode 100644 web-next/app/api/character/revive/route.ts create mode 100644 web-next/app/api/character/unstuck/route.ts create mode 100644 web-next/components/CharacterActionForm.tsx create mode 100644 web-next/lib/character-services.ts create mode 100644 web-next/lib/characters.ts create mode 100644 web-next/lib/soap.ts diff --git a/web-next/app/[locale]/revive/page.tsx b/web-next/app/[locale]/revive/page.tsx new file mode 100644 index 0000000..6629589 --- /dev/null +++ b/web-next/app/[locale]/revive/page.tsx @@ -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 ( +
+

{t('reviveTitle')}

+ c.name)} endpoint="/api/character/revive" actionLabel={t('reviveAction')} /> +
+ ) +} diff --git a/web-next/app/[locale]/unstuck/page.tsx b/web-next/app/[locale]/unstuck/page.tsx new file mode 100644 index 0000000..72f1085 --- /dev/null +++ b/web-next/app/[locale]/unstuck/page.tsx @@ -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 ( +
+

{t('unstuckTitle')}

+ c.name)} endpoint="/api/character/unstuck" actionLabel={t('unstuckAction')} /> +
+ ) +} diff --git a/web-next/app/api/character/revive/route.ts b/web-next/app/api/character/revive/route.ts new file mode 100644 index 0000000..fd281f0 --- /dev/null +++ b/web-next/app/api/character/revive/route.ts @@ -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)) +} diff --git a/web-next/app/api/character/unstuck/route.ts b/web-next/app/api/character/unstuck/route.ts new file mode 100644 index 0000000..9f119e9 --- /dev/null +++ b/web-next/app/api/character/unstuck/route.ts @@ -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)) +} diff --git a/web-next/components/CharacterActionForm.tsx b/web-next/components/CharacterActionForm.tsx new file mode 100644 index 0000000..c551100 --- /dev/null +++ b/web-next/components/CharacterActionForm.tsx @@ -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

{t('noCharactersYet')}

+ } + + return ( +
+
+ + +
+ {message &&

{message.text}

} +
+ ) +} diff --git a/web-next/lib/character-services.ts b/web-next/lib/character-services.ts new file mode 100644 index 0000000..c75cd2d --- /dev/null +++ b/web-next/lib/character-services.ts @@ -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 { + 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( + `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 { + return soapCharacterAction(accountId, character, { + command: (c) => `.revive ${c}`, + historyTable: 'home_revivehistory', + cooldownHours: 12, + }) +} + +export function unstuckCharacter(accountId: number, character: string): Promise { + return soapCharacterAction(accountId, character, { + command: (c) => `.unstuck ${c}`, + historyTable: 'home_unstuckhistory', + cooldownHours: 12, + }) +} diff --git a/web-next/lib/characters.ts b/web-next/lib/characters.ts new file mode 100644 index 0000000..c565505 --- /dev/null +++ b/web-next/lib/characters.ts @@ -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 { + try { + const [rows] = await db(DB.characters).query( + '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 { + const chars = await getGameCharacters(accountId) + const c = chars.find((ch) => ch.name === name) + return Boolean(c && !c.online) +} diff --git a/web-next/lib/soap.ts b/web-next/lib/soap.ts new file mode 100644 index 0000000..9becdb5 --- /dev/null +++ b/web-next/lib/soap.ts @@ -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, '>') +} + +/** Ejecuta un comando en el worldserver por SOAP. Devuelve el texto de respuesta o null. */ +export async function executeSoapCommand(command: string): Promise { + 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 = ` + + ${escapeXml(command)} +` + + 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 + } +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 1614905..b19c786 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -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." } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index de6b9ab..ed0ace3 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -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." } }