O maior portal de MU Online do Brasil — desde 2003
Tutorial Intermediário Website

Como criar um sistema de tickets de suporte web para MU Online

Monte um sistema de tickets de suporte no site do seu servidor de MU Online, com abertura por jogadores, respostas da equipe, status, categorias e painel administrativo em PHP e SQL.

BR Bruno · Atualizado em 20 mai 2024 · ⏱ 22 min de leitura
Resposta rápida

Todo servidor de MU Online chega num ponto em que o suporte por Discord e mensagens diretas não escala mais: pedidos se perdem, dois GMs respondem o mesmo caso e ninguém tem histórico do que foi resolvido. Um sistema de tickets no site organiza esse caos. Cada jogador abre um chamado vinculado à sua

Todo servidor de MU Online chega num ponto em que o suporte por Discord e mensagens diretas não escala mais: pedidos se perdem, dois GMs respondem o mesmo caso e ninguém tem histórico do que foi resolvido. Um sistema de tickets no site organiza esse caos. Cada jogador abre um chamado vinculado à sua conta, escolhe uma categoria, acompanha o status e conversa com a equipe num único lugar — e os administradores têm um painel para ver, filtrar e responder tudo. Este tutorial mostra como construir esse sistema em PHP e SQL, do banco de dados ao painel administrativo, com atenção a segurança e a boas práticas de atendimento. Os nomes de tabelas e colunas de conta aparecem como exemplo e variam por versão do seu MuServer. Se ainda está montando a fundação do servidor, comece por como criar um servidor de MU Online.

Pré-requisitos

O sistema de tickets é uma aplicação web sobre o mesmo banco (ou um banco auxiliar) do seu servidor. Você precisa de:

  • Site em PHP funcionando com sessões de login já implementadas (o jogador entra com a conta do jogo).
  • PHP 7.4 ou superior com PDO habilitado.
  • Acesso ao banco — pode ser o próprio SQL Server do MuServer ou um MySQL/MariaDB separado só para o site.
  • Sistema de login que identifica a conta do jogador na sessão.
  • Opcional, mas recomendado: envio de e-mail por SMTP, para notificar respostas.
  • Uma noção de permissões para separar jogador comum de GM/administrador.
PerfilO que pode fazerOnde acessa
JogadorAbrir ticket, responder o próprio, ver históricoÁrea da conta
Atendente / GMVer fila, responder, mudar statusPainel admin
AdministradorTudo acima + gerenciar categorias e atribuir ticketsPainel admin

Modelagem do banco de dados

A base de um bom sistema de tickets são duas tabelas: uma para o ticket em si e outra para as mensagens da conversa. Separar as duas permite uma troca de mensagens ilimitada dentro de cada chamado.

-- Exemplo (varia por versão): tabelas de tickets
CREATE TABLE SUPPORT_TICKET (
    id           INT IDENTITY(1,1) PRIMARY KEY,
    conta        VARCHAR(10)  NOT NULL,        -- conta do jogo
    personagem   VARCHAR(20)  NULL,            -- personagem afetado
    categoria    VARCHAR(20)  NOT NULL,        -- conta, bug, doacao, denuncia
    assunto      VARCHAR(120) NOT NULL,
    prioridade   TINYINT      NOT NULL DEFAULT 1, -- 1=baixa 2=media 3=alta
    status       VARCHAR(15)  NOT NULL DEFAULT 'aberto',
    atendente    VARCHAR(20)  NULL,            -- GM responsável
    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,        -- conta ou 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)
);

O campo status guia todo o fluxo de atendimento. Um conjunto enxuto de estados evita confusão:

StatusSignificadoQuem muda
abertoRecém-criado, aguardando equipeSistema
em_andamentoUm GM assumiu o casoAtendente
aguardandoEsperando resposta do jogadorAtendente
resolvidoSolução entregueAtendente
fechadoEncerrado, sem novas respostasAtendente/Sistema

Abertura de ticket pelo jogador

O formulário de abertura precisa estar atrás do login, para vincular o ticket à conta e reduzir spam. Valide tudo no servidor: categoria dentro da lista permitida, tamanho do assunto e da mensagem, e um limite de tickets abertos por conta.

<?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 conta

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' => 'Categoria inválida'];
    }
    if (mb_strlen($assunto) < 5 || mb_strlen($assunto) > 120) {
        return ['ok' => false, 'msg' => 'O assunto deve ter entre 5 e 120 caracteres'];
    }
    if (mb_strlen($mensagem) < 15) {
        return ['ok' => false, 'msg' => 'Descreva melhor o problema (mín. 15 caracteres)'];
    }

    // Limite de tickets abertos por conta
    $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' => 'Você já tem tickets abertos demais. Aguarde a resposta.'];
    }

    $pdo->beginTransaction();
    $ins = $pdo->prepare(
        "INSERT INTO SUPPORT_TICKET (conta, personagem, categoria, assunto)
         VALUES (?, ?, ?, ?)"
    );
    $ins->execute([$conta, $personagem, $categoria, $assunto]);
    $ticketId = $pdo->lastInsertId();

    // Primeira mensagem = descrição 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];
}

Repare que a categoria é validada contra uma allowlist (in_array com a constante CATEGORIAS), nunca aceita diretamente do formulário. Isso impede que alguém injete valores inesperados. Toda a inserção acontece numa transação, para que ticket e primeira mensagem nasçam juntos.

Exibindo o ticket para o jogador

O jogador precisa acompanhar a conversa. Uma regra de segurança essencial: ele só pode ver os próprios tickets. Sempre confira que o conta do ticket bate com o da sessão antes de exibir qualquer coisa.

<?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 não 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>Status: <strong><?= htmlspecialchars($ticket['status']) ?></strong>
   — Categoria: <?= 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'] ? '(Equipe)' : '' ?></span>
    <p><?= nl2br(htmlspecialchars($m['mensagem'])) ?></p>
    <time><?= $m['criado_em'] ?></time>
  </div>
<?php endforeach; ?>
</div>

O uso de htmlspecialchars em toda saída é inegociável. A mensagem do jogador é conteúdo não confiável; sem escapar, um <script> na mensagem viraria um XSS que atinge o GM que abrir o ticket no painel.

Respondendo um ticket

Tanto o jogador quanto a equipe respondem gravando uma nova linha em SUPPORT_MESSAGE. A diferença está no campo is_staff e nas permissões. Ao responder, também atualize o atualizado_em do ticket e ajuste o status conforme quem falou.

<?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' => 'Mensagem vazia'];
    }

    // Confirma que o ticket existe e (se jogador) pertence a ele
    $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' => 'Sem permissão'];
    }

    $pdo->beginTransaction();
    $pdo->prepare(
        "INSERT INTO SUPPORT_MESSAGE (ticket_id, autor, is_staff, mensagem)
         VALUES (?, ?, ?, ?)"
    )->execute([$ticketId, $autor, $staff ? 1 : 0, $texto]);

    // Staff respondeu -> aguardando jogador; jogador respondeu -> volta a aberto
    $novoStatus = $staff ? 'aguardando' : 'aberto';
    $pdo->prepare(
        "UPDATE SUPPORT_TICKET
         SET status = ?, atualizado_em = GETDATE() WHERE id = ?"
    )->execute([$novoStatus, $ticketId]);
    $pdo->commit();

    return ['ok' => true];
}

Se você configurou SMTP no site, este é o ponto ideal para disparar uma notificação por e-mail ao jogador quando a equipe responde — a resposta chega mais rápido e o ticket fecha mais cedo.

Painel administrativo

O painel da equipe é onde os tickets viram trabalho organizado. Ele lista a fila com filtros por status, categoria e prioridade, e permite assumir e responder. O acesso precisa ser restrito a contas com permissão de GM/admin — verifique isso no topo de todo arquivo do painel.

<?php
// admin/fila.php
require '../src/db.php';
session_start();

// Checagem de permissão (adapte à sua tabela de cargos)
if (($_SESSION['nivel'] ?? 0) < 8) { // ex.: 8 = GM
    http_response_code(403);
    exit('Acesso negado');
}

$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>Conta</th><th>Categoria</th><th>Assunto</th>
      <th>Prioridade</th><th>Atendente</th><th>Atualizado</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><?= ['','Baixa','Média','Alta'][$t['prioridade']] ?></td>
    <td><?= htmlspecialchars($t['atendente'] ?? '—') ?></td>
    <td><?= $t['atualizado_em'] ?></td>
  </tr>
  <?php endforeach; ?>
</table>

Ordenar por prioridade e depois pelo mais antigo atualizado garante que casos críticos venham primeiro e que nenhum ticket antigo fique esquecido no fundo da fila.

Atribuindo e mudando status

Deixe a equipe "assumir" um ticket, marcando-se como atendente. Isso evita que dois GMs trabalhem no mesmo caso. Mudanças de status devem ser ações simples e registradas.

  1. O GM abre um ticket aberto na fila.
  2. Clica em "Assumir", que grava seu nome em atendente e muda o status para em_andamento.
  3. Responde ao jogador; o status passa a aguardando.
  4. Quando o jogador confirma a solução, o GM marca como resolvido.
  5. Tickets resolvidos sem resposta por X dias podem virar fechado automaticamente por uma rotina agendada.
<?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]);
}

A condição AND status = 'aberto' na atualização é uma trava contra corrida: se dois GMs clicarem "Assumir" quase ao mesmo tempo, apenas o primeiro efetiva a mudança.

Segurança e boas práticas

Um sistema de tickets lida com dados de conta e recebe texto livre de usuários, então trate a segurança a sério. Sempre use prepared statements — nunca concatene entrada do usuário em SQL. Escape toda saída HTML com htmlspecialchars para barrar XSS, especialmente porque o GM vai ler o texto do jogador no painel. Verifique permissões em cada arquivo do painel, não confie apenas em esconder o link. Limite o tamanho e a frequência de tickets e mensagens para conter abuso. Registre as ações da equipe (quem assumiu, quem fechou) para ter accountability. E proteja os formulários com token CSRF, para que uma página maliciosa não consiga abrir ou responder tickets em nome de um usuário logado.

Erros comuns e soluções

ErroCausa provávelSolução
Jogador vê ticket de outroFalta checar conta na consultaSempre filtre por conta = sessão ao carregar um ticket
Script executado no painel (XSS)Saída sem escapeAplique htmlspecialchars em toda mensagem exibida
Dois GMs no mesmo ticketAtribuição sem travaUse AND status = 'aberto' no UPDATE de "Assumir"
Spam de ticketsSem limite por contaImponha MAX_ABERTOS e CAPTCHA na abertura
Categoria inválida gravadaAceitar valor bruto do formValide contra allowlist de categorias
Jogador não sabe da respostaSem notificaçãoDispare e-mail via SMTP a cada resposta da equipe
Fila desorganizadaSem ordenação por prioridadeOrdene por prioridade e depois por mais antigo

Melhorias opcionais

Depois que o básico estiver rodando, algumas adições agregam muito valor. Anexos de imagem permitem que o jogador envie um print do bug — apenas valide o tipo e o tamanho do arquivo. Um campo de avaliação ao fechar o ticket ("seu problema foi resolvido?") gera métricas de qualidade do atendimento. Respostas rápidas pré-prontas (macros) aceleram o trabalho da equipe em dúvidas repetidas. E um pequeno relatório com tempo médio de resposta e volume por categoria ajuda a entender onde o servidor mais gera dúvidas — muitas vezes revelando um bug ou uma confusão de UI que vale corrigir na fonte.

Checklist de lançamento

  • Tabelas SUPPORT_TICKET e SUPPORT_MESSAGE criadas
  • Abertura de ticket exige login e vincula à conta
  • Categorias validadas contra allowlist
  • Limite de tickets abertos por conta aplicado
  • Jogador só enxerga os próprios tickets
  • Toda saída escapada com htmlspecialchars
  • Painel admin protegido por verificação de permissão
  • "Assumir" com trava contra corrida
  • Fluxo de status completo (aberto → fechado)
  • Notificação por e-mail nas respostas (se houver SMTP)
  • Token CSRF nos formulários de abrir e responder
  • Prepared statements em todas as queries

Perguntas frequentes

Por que usar tickets em vez de só Discord ou WhatsApp?

Tickets deixam um histórico organizado, com status e responsável, ligado à conta do jogador. No Discord as mensagens se perdem no fluxo e ninguém sabe o que já foi resolvido ou quem está atendendo.

O jogador precisa estar logado para abrir ticket?

O ideal é sim, para vincular o ticket à conta e evitar spam anônimo. Você pode permitir anexar o nome do personagem afetado, mas a autenticação pela conta do jogo é o que dá contexto ao atendimento.

Como evito que bots inundem o sistema de tickets?

Combine login obrigatório, limite de tickets abertos por conta, CAPTCHA na abertura e validação de tamanho mínimo/máximo da mensagem. Isso barra a maior parte do abuso automatizado.

Devo notificar o jogador quando responder?

Sim, um e-mail de aviso a cada resposta melhora muito a experiência. O jogador não precisa ficar atualizando a página e a taxa de resolução sobe porque ele responde mais rápido.

Posso separar os tickets por categoria e prioridade?

Sim, e é recomendável. Categorias (conta, bug, doação, denúncia) permitem rotear para a equipe certa, e a prioridade ajuda a atender primeiro o que é crítico, como problemas de pagamento.

BR
Editor de eventos, mapas e itens

Bruno é especialista em eventos, mapas, bosses e economia de itens do MU Online. Documenta cada detalhe com base em jogo real.

Continue lendo

Artigos relacionados