Cómo crear un sistema de tickets de soporte web para MU Online
Monta un sistema de tickets de soporte en el sitio de tu servidor de MU Online, con apertura por parte de los jugadores, respuestas del equipo, estados, categorías y panel administrativo en PHP y SQL.
Todo servidor de MU Online llega a un punto en que el soporte por Discord y mensajes directos ya no escala: los pedidos se pierden, dos GMs responden el mismo caso y nadie tiene historial de lo que se resolvió. Un sistema de tickets en el sitio organiza ese caos. Cada jugador abre un ticket vinculad
Todo servidor de MU Online llega a un punto en que el soporte por Discord y mensajes directos ya no escala: los pedidos se pierden, dos GMs responden el mismo caso y nadie tiene historial de lo que se resolvió. Un sistema de tickets en el sitio organiza ese caos. Cada jugador abre un ticket vinculado a su cuenta, elige una categoría, sigue el estado y conversa con el equipo en un único lugar — y los administradores tienen un panel para ver, filtrar y responder todo. Este tutorial muestra cómo construir ese sistema en PHP y SQL, desde la base de datos hasta el panel administrativo, con atención a la seguridad y a las buenas prácticas de atención. Los nombres de tablas y columnas de cuenta aparecen como ejemplo y varían según la versión de tu MuServer. Si aún estás montando los cimientos del servidor, comienza por cómo crear un servidor de MU Online.
Requisitos previos
El sistema de tickets es una aplicación web sobre la misma base de datos (o una base auxiliar) de tu servidor. Necesitas:
- Sitio en PHP funcionando con sesiones de login ya implementadas (el jugador entra con la cuenta del juego).
- PHP 7.4 o superior con PDO habilitado.
- Acceso a la base de datos — puede ser el propio SQL Server del MuServer o un MySQL/MariaDB separado solo para el sitio.
- Sistema de login que identifica la cuenta del jugador en la sesión.
- Opcional, pero recomendado: envío de correo por SMTP, para notificar las respuestas.
- Una noción de permisos para separar al jugador común del GM/administrador.
| Perfil | Qué puede hacer | Dónde accede |
|---|---|---|
| Jugador | Abrir ticket, responder el propio, ver historial | Área de la cuenta |
| Agente / GM | Ver la cola, responder, cambiar el estado | Panel admin |
| Administrador | Todo lo anterior + gestionar categorías y asignar tickets | Panel admin |
Modelado de la base de datos
La base de un buen sistema de tickets son dos tablas: una para el ticket en sí y otra para los mensajes de la conversación. Separar las dos permite un intercambio de mensajes ilimitado dentro de cada ticket.
-- Ejemplo (varía según la versión): tablas de tickets
CREATE TABLE SUPPORT_TICKET (
id INT IDENTITY(1,1) PRIMARY KEY,
conta VARCHAR(10) NOT NULL, -- cuenta del juego
personagem VARCHAR(20) NULL, -- personaje afectado
categoria VARCHAR(20) NOT NULL, -- conta, bug, doacao, denuncia
assunto VARCHAR(120) NOT NULL,
prioridade TINYINT NOT NULL DEFAULT 1, -- 1=baja 2=media 3=alta
status VARCHAR(15) NOT NULL DEFAULT 'aberto',
atendente VARCHAR(20) NULL, -- GM responsable
criado_em DATETIME NOT NULL DEFAULT GETDATE(),
atualizado_em DATETIME NOT NULL DEFAULT GETDATE()
);
CREATE TABLE SUPPORT_MESSAGE (
id INT IDENTITY(1,1) PRIMARY KEY,
ticket_id INT NOT NULL,
autor VARCHAR(20) NOT NULL, -- cuenta o GM
is_staff BIT NOT NULL DEFAULT 0,
mensagem NVARCHAR(MAX) NOT NULL,
criado_em DATETIME NOT NULL DEFAULT GETDATE(),
CONSTRAINT fk_ticket FOREIGN KEY (ticket_id) REFERENCES SUPPORT_TICKET(id)
);
El campo status guía todo el flujo de atención. Un conjunto reducido de estados evita confusión:
| Estado | Significado | Quién lo cambia |
|---|---|---|
aberto | Recién creado, esperando al equipo | Sistema |
em_andamento | Un GM asumió el caso | Agente |
aguardando | Esperando respuesta del jugador | Agente |
resolvido | Solución entregada | Agente |
fechado | Cerrado, sin nuevas respuestas | Agente/Sistema |
Apertura de ticket por el jugador
El formulario de apertura debe estar detrás del login, para vincular el ticket a la cuenta y reducir el spam. Valida todo en el servidor: categoría dentro de la lista permitida, tamaño del asunto y del mensaje, y un límite de tickets abiertos por cuenta.
<?php
// abrir-ticket.php
require 'src/db.php';
session_start();
if (!isset($_SESSION['conta'])) {
header('Location: /login');
exit;
}
const CATEGORIAS = ['conta', 'bug', 'doacao', 'denuncia', 'outro'];
const MAX_ABERTOS = 3; // tickets simultáneos por cuenta
function abrirTicket(string $conta, array $dados): array
{
$pdo = getPDO();
$categoria = in_array($dados['categoria'] ?? '', CATEGORIAS, true)
? $dados['categoria'] : null;
$assunto = trim($dados['assunto'] ?? '');
$mensagem = trim($dados['mensagem'] ?? '');
$personagem = trim($dados['personagem'] ?? '') ?: null;
if (!$categoria) {
return ['ok' => false, 'msg' => 'Categoría inválida'];
}
if (mb_strlen($assunto) < 5 || mb_strlen($assunto) > 120) {
return ['ok' => false, 'msg' => 'El asunto debe tener entre 5 y 120 caracteres'];
}
if (mb_strlen($mensagem) < 15) {
return ['ok' => false, 'msg' => 'Describe mejor el problema (mín. 15 caracteres)'];
}
// Límite de tickets abiertos por cuenta
$q = $pdo->prepare(
"SELECT COUNT(*) FROM SUPPORT_TICKET
WHERE conta = ? AND status IN ('aberto','em_andamento','aguardando')"
);
$q->execute([$conta]);
if ($q->fetchColumn() >= MAX_ABERTOS) {
return ['ok' => false, 'msg' => 'Ya tienes demasiados tickets abiertos. Espera la respuesta.'];
}
$pdo->beginTransaction();
$ins = $pdo->prepare(
"INSERT INTO SUPPORT_TICKET (conta, personagem, categoria, assunto)
VALUES (?, ?, ?, ?)"
);
$ins->execute([$conta, $personagem, $categoria, $assunto]);
$ticketId = $pdo->lastInsertId();
// Primer mensaje = descripción inicial
$pdo->prepare(
"INSERT INTO SUPPORT_MESSAGE (ticket_id, autor, is_staff, mensagem)
VALUES (?, ?, 0, ?)"
)->execute([$ticketId, $conta, $mensagem]);
$pdo->commit();
return ['ok' => true, 'id' => $ticketId];
}
Fíjate en que la categoría se valida contra una allowlist (in_array con la constante CATEGORIAS), nunca se acepta directamente del formulario. Eso impide que alguien inyecte valores inesperados. Toda la inserción ocurre en una transacción, para que el ticket y el primer mensaje nazcan juntos.
Mostrando el ticket al jugador
El jugador necesita seguir la conversación. Una regla de seguridad esencial: solo puede ver sus propios tickets. Comprueba siempre que la conta del ticket coincide con la de la sesión antes de mostrar cualquier cosa.
<?php
// ver-ticket.php
require 'src/db.php';
session_start();
$conta = $_SESSION['conta'] ?? null;
$ticketId = (int) ($_GET['id'] ?? 0);
if (!$conta) { header('Location: /login'); exit; }
$pdo = getPDO();
$stmt = $pdo->prepare("SELECT * FROM SUPPORT_TICKET WHERE id = ? AND conta = ?");
$stmt->execute([$ticketId, $conta]);
$ticket = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$ticket) {
http_response_code(404);
exit('Ticket no encontrado');
}
$msgs = $pdo->prepare(
"SELECT autor, is_staff, mensagem, criado_em
FROM SUPPORT_MESSAGE WHERE ticket_id = ? ORDER BY criado_em ASC"
);
$msgs->execute([$ticketId]);
?>
<h1><?= htmlspecialchars($ticket['assunto']) ?></h1>
<p>Estado: <strong><?= htmlspecialchars($ticket['status']) ?></strong>
— Categoría: <?= htmlspecialchars($ticket['categoria']) ?></p>
<div class="conversa">
<?php foreach ($msgs as $m): ?>
<div class="msg <?= $m['is_staff'] ? 'staff' : 'jogador' ?>">
<span class="autor"><?= htmlspecialchars($m['autor']) ?>
<?= $m['is_staff'] ? '(Equipo)' : '' ?></span>
<p><?= nl2br(htmlspecialchars($m['mensagem'])) ?></p>
<time><?= $m['criado_em'] ?></time>
</div>
<?php endforeach; ?>
</div>
El uso de htmlspecialchars en toda salida es innegociable. El mensaje del jugador es contenido no confiable; sin escapar, un <script> en el mensaje se convertiría en un XSS que golpea al GM que abra el ticket en el panel.
Respondiendo un ticket
Tanto el jugador como el equipo responden grabando una nueva fila en SUPPORT_MESSAGE. La diferencia está en el campo is_staff y en los permisos. Al responder, actualiza también el atualizado_em del ticket y ajusta el estado según quién habló.
<?php
// responder-ticket.php
function responder(int $ticketId, string $autor, string $texto, bool $staff): array
{
$pdo = getPDO();
$texto = trim($texto);
if (mb_strlen($texto) < 2) {
return ['ok' => false, 'msg' => 'Mensaje vacío'];
}
// Confirma que el ticket existe y (si es jugador) le pertenece
$t = $pdo->prepare("SELECT conta, status FROM SUPPORT_TICKET WHERE id = ?");
$t->execute([$ticketId]);
$ticket = $t->fetch(PDO::FETCH_ASSOC);
if (!$ticket) return ['ok' => false, 'msg' => 'Ticket inexistente'];
if (!$staff && $ticket['conta'] !== $autor) {
return ['ok' => false, 'msg' => 'Sin permiso'];
}
$pdo->beginTransaction();
$pdo->prepare(
"INSERT INTO SUPPORT_MESSAGE (ticket_id, autor, is_staff, mensagem)
VALUES (?, ?, ?, ?)"
)->execute([$ticketId, $autor, $staff ? 1 : 0, $texto]);
// Staff respondió -> esperando jugador; jugador respondió -> vuelve a abierto
$novoStatus = $staff ? 'aguardando' : 'aberto';
$pdo->prepare(
"UPDATE SUPPORT_TICKET
SET status = ?, atualizado_em = GETDATE() WHERE id = ?"
)->execute([$novoStatus, $ticketId]);
$pdo->commit();
return ['ok' => true];
}
Si configuraste SMTP en el sitio, este es el punto ideal para disparar una notificación por correo al jugador cuando el equipo responde — la respuesta llega más rápido y el ticket se cierra antes.
Panel administrativo
El panel del equipo es donde los tickets se convierten en trabajo organizado. Lista la cola con filtros por estado, categoría y prioridad, y permite asumir y responder. El acceso debe estar restringido a cuentas con permiso de GM/admin — verifica esto al inicio de todo archivo del panel.
<?php
// admin/fila.php
require '../src/db.php';
session_start();
// Comprobación de permiso (adapta a tu tabla de cargos)
if (($_SESSION['nivel'] ?? 0) < 8) { // ej.: 8 = GM
http_response_code(403);
exit('Acceso denegado');
}
$pdo = getPDO();
$status = $_GET['status'] ?? 'aberto';
$stmt = $pdo->prepare(
"SELECT id, conta, categoria, assunto, prioridade, status, atendente, atualizado_em
FROM SUPPORT_TICKET
WHERE status = ?
ORDER BY prioridade DESC, atualizado_em ASC"
);
$stmt->execute([$status]);
$tickets = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>
<table class="fila">
<tr><th>#</th><th>Cuenta</th><th>Categoría</th><th>Asunto</th>
<th>Prioridad</th><th>Agente</th><th>Actualizado</th></tr>
<?php foreach ($tickets as $t): ?>
<tr>
<td><a href="ver.php?id=<?= $t['id'] ?>">#<?= $t['id'] ?></a></td>
<td><?= htmlspecialchars($t['conta']) ?></td>
<td><?= htmlspecialchars($t['categoria']) ?></td>
<td><?= htmlspecialchars($t['assunto']) ?></td>
<td><?= ['','Baja','Media','Alta'][$t['prioridade']] ?></td>
<td><?= htmlspecialchars($t['atendente'] ?? '—') ?></td>
<td><?= $t['atualizado_em'] ?></td>
</tr>
<?php endforeach; ?>
</table>
Ordenar por prioridad y después por el más antiguo actualizado garantiza que los casos críticos vengan primero y que ningún ticket antiguo quede olvidado en el fondo de la cola.
Asignando y cambiando el estado
Deja que el equipo "asuma" un ticket, marcándose como agente. Eso evita que dos GMs trabajen en el mismo caso. Los cambios de estado deben ser acciones simples y registradas.
- El GM abre un ticket
abertoen la cola. - Hace clic en "Asumir", que graba su nombre en
atendentey cambia el estado aem_andamento. - Responde al jugador; el estado pasa a
aguardando. - Cuando el jugador confirma la solución, el GM lo marca como
resolvido. - Los tickets
resolvidossin respuesta por X días pueden convertirse enfechadoautomáticamente mediante una rutina programada.
<?php
// admin/assumir.php
function assumirTicket(int $ticketId, string $gm): void
{
$pdo = getPDO();
$pdo->prepare(
"UPDATE SUPPORT_TICKET
SET atendente = ?, status = 'em_andamento', atualizado_em = GETDATE()
WHERE id = ? AND status = 'aberto'"
)->execute([$gm, $ticketId]);
}
La condición AND status = 'aberto' en la actualización es una traba contra la condición de carrera: si dos GMs hacen clic en "Asumir" casi al mismo tiempo, solo el primero efectúa el cambio.
Seguridad y buenas prácticas
Un sistema de tickets maneja datos de cuenta y recibe texto libre de los usuarios, así que trata la seguridad en serio. Usa siempre prepared statements — nunca concatenes la entrada del usuario en SQL. Escapa toda salida HTML con htmlspecialchars para frenar el XSS, especialmente porque el GM va a leer el texto del jugador en el panel. Verifica los permisos en cada archivo del panel, no confíes solo en ocultar el enlace. Limita el tamaño y la frecuencia de tickets y mensajes para contener el abuso. Registra las acciones del equipo (quién asumió, quién cerró) para tener responsabilidad. Y protege los formularios con token CSRF, para que una página maliciosa no consiga abrir o responder tickets en nombre de un usuario logueado.
Errores comunes y soluciones
| Error | Causa probable | Solución |
|---|---|---|
| El jugador ve el ticket de otro | Falta comprobar conta en la consulta | Filtra siempre por conta = sesión al cargar un ticket |
| Script ejecutado en el panel (XSS) | Salida sin escape | Aplica htmlspecialchars en todo mensaje mostrado |
| Dos GMs en el mismo ticket | Asignación sin traba | Usa AND status = 'aberto' en el UPDATE de "Asumir" |
| Spam de tickets | Sin límite por cuenta | Impón MAX_ABERTOS y CAPTCHA en la apertura |
| Categoría inválida grabada | Aceptar el valor bruto del form | Valida contra una allowlist de categorías |
| El jugador no se entera de la respuesta | Sin notificación | Dispara un correo vía SMTP en cada respuesta del equipo |
| Cola desorganizada | Sin ordenación por prioridad | Ordena por prioridad y después por el más antiguo |
Mejoras opcionales
Después de que lo básico esté funcionando, algunas adiciones aportan mucho valor. Los adjuntos de imagen permiten que el jugador envíe una captura del bug — solo valida el tipo y el tamaño del archivo. Un campo de evaluación al cerrar el ticket ("¿se resolvió tu problema?") genera métricas de calidad de la atención. Las respuestas rápidas prehechas (macros) aceleran el trabajo del equipo en las dudas repetidas. Y un pequeño informe con el tiempo medio de respuesta y el volumen por categoría ayuda a entender dónde el servidor genera más dudas — muchas veces revelando un bug o una confusión de UI que vale la pena corregir en el origen.
Lista de verificación de lanzamiento
- Tablas
SUPPORT_TICKETySUPPORT_MESSAGEcreadas - La apertura de ticket exige login y vincula a la cuenta
- Categorías validadas contra allowlist
- Límite de tickets abiertos por cuenta aplicado
- El jugador solo ve sus propios tickets
- Toda salida escapada con
htmlspecialchars - Panel admin protegido por verificación de permiso
- "Asumir" con traba contra la condición de carrera
- Flujo de estados completo (aberto → fechado)
- Notificación por correo en las respuestas (si hay SMTP)
- Token CSRF en los formularios de abrir y responder
- Prepared statements en todas las consultas
Preguntas frecuentes
¿Por qué usar tickets en vez de solo Discord o WhatsApp?
Los tickets dejan un historial organizado, con estado y responsable, ligado a la cuenta del jugador. En Discord los mensajes se pierden en el flujo y nadie sabe qué ya se resolvió o quién está atendiendo.
¿El jugador necesita estar logueado para abrir un ticket?
Lo ideal es que sí, para vincular el ticket a la cuenta y evitar el spam anónimo. Puedes permitir adjuntar el nombre del personaje afectado, pero la autenticación por la cuenta del juego es lo que da contexto a la atención.
¿Cómo evito que los bots inunden el sistema de tickets?
Combina login obligatorio, límite de tickets abiertos por cuenta, CAPTCHA en la apertura y validación de tamaño mínimo/máximo del mensaje. Eso frena la mayor parte del abuso automatizado.
¿Debo notificar al jugador cuando respondo?
Sí, un correo de aviso en cada respuesta mejora mucho la experiencia. El jugador no necesita estar actualizando la página y la tasa de resolución sube porque responde más rápido.
¿Puedo separar los tickets por categoría y prioridad?
Sí, y es recomendable. Las categorías (cuenta, bug, donación, denuncia) permiten enrutar al equipo correcto, y la prioridad ayuda a atender primero lo que es crítico, como los problemas de pago.