Skip to content

Como Construir um Placar (Sistema de Ranking) com NENE2

Este guia percorre a construção de um placar onde usuários enviam pontuações, veem rankings e verificam seu próprio rank. Apenas a melhor pontuação por usuário por placar é mantida.

Field Trial: FT141
Versão do NENE2: ^1.5
Tópicos abordados: padrão UPDATE de melhor pontuação, cálculo de rank com COUNT(*), verificação de propriedade de pontuação, limitação de parâmetro de query, avaliação de vulnerabilidades


O que estamos construindo

  • POST /leaderboards — criar um placar
  • POST /leaderboards/{id}/scores — enviar uma pontuação (mantida apenas se for um novo recorde pessoal)
  • GET /leaderboards/{id}/rankings — top N rankings (pontuação decrescente, ?limit=N)
  • GET /leaderboards/{id}/rankings/me — rank e pontuação do próprio chamador
  • DELETE /leaderboards/{id}/scores/{userId} — deletar própria pontuação (apenas proprietário)

Schema do banco de dados

sql
CREATE TABLE leaderboards (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT NOT NULL,
    created_at TEXT NOT NULL
);

CREATE TABLE scores (
    id             INTEGER PRIMARY KEY AUTOINCREMENT,
    leaderboard_id INTEGER NOT NULL,
    user_id        INTEGER NOT NULL,
    score          INTEGER NOT NULL,
    submitted_at   TEXT    NOT NULL,
    UNIQUE (leaderboard_id, user_id),
    FOREIGN KEY (leaderboard_id) REFERENCES leaderboards(id),
    FOREIGN KEY (user_id)        REFERENCES users(id)
);

UNIQUE (leaderboard_id, user_id) — uma linha de pontuação por usuário por placar; atualizações a substituem.


Padrão UPDATE de melhor pontuação

php
public function submitScore(int $leaderboardId, int $userId, int $score, string $now): bool
{
    $existing = $this->findScore($leaderboardId, $userId);

    if ($existing === null) {
        $this->executor->execute(
            'INSERT INTO scores (leaderboard_id, user_id, score, submitted_at) VALUES (?, ?, ?, ?)',
            [$leaderboardId, $userId, $score, $now],
        );
        return true;
    }

    if ($score > $existing['score']) {
        $this->executor->execute(
            'UPDATE scores SET score = ?, submitted_at = ? WHERE leaderboard_id = ? AND user_id = ?',
            [$score, $now, $leaderboardId, $userId],
        );
        return true;
    }

    return false;  // Não é um novo recorde pessoal
}

Retorna true quando a pontuação é um novo recorde pessoal (útil para feedback da UI), false quando ignorada.


Cálculo de rank com COUNT(*)

Em vez de uma função de janela (RANK() não está disponível em todas as versões do SQLite), conte quantas pontuações são maiores:

php
public function getUserRank(int $leaderboardId, int $userId): ?int
{
    $score = $this->findScore($leaderboardId, $userId);

    if ($score === null) {
        return null;
    }

    $row   = $this->executor->fetchOne(
        'SELECT COUNT(*) as cnt FROM scores WHERE leaderboard_id = ? AND score > ?',
        [$leaderboardId, $score['score']],
    );
    $ahead = isset($row['cnt']) ? (int) $row['cnt'] : 0;

    return $ahead + 1;
}

Se 0 usuários têm pontuação maior, o rank é 1. Se 5 usuários são maiores, o rank é 6. Empates recebem o mesmo rank.


Verificação de propriedade de pontuação (prevenção de IDOR)

php
if ($actorId !== $userId) {
    return $this->responseFactory->create(['error' => 'cannot delete another user\'s score'], 403);
}

Sempre verifique a identidade do chamador contra o usuário alvo antes de DELETE. Sem esta verificação, qualquer usuário autenticado poderia deletar qualquer pontuação.


Limitação de parâmetro de query

php
$limit = isset($query['limit']) && is_numeric($query['limit']) ? (int) $query['limit'] : 10;

if ($limit <= 0 || $limit > 100) {
    $limit = 10;
}

Limite o valor para prevenir que ?limit=99999 varra a tabela inteira.


Avaliação de vulnerabilidades (FT141)

IDAtaqueEsperadoResultado
VULN-AIDOR: deletar pontuação de outro usuário403Pass
VULN-BEnviar pontuação para outro usuário200 (permitido)Pass
VULN-CSQL injection no nome do placar201 (verbatim)Pass
VULN-DX-User-Id ausente em /rankings/me400Pass
VULN-EX-User-Id não numériconão 200Pass
VULN-FID de placar negativonão 200Pass
VULN-GPHP_INT_MAX como pontuação200 (int válido)Pass
VULN-HPontuação float (confusão de tipo)422Pass
VULN-IPontuação string (confusão de tipo)422Pass
VULN-JX-User-Id ausente no DELETE400Pass
VULN-Kuser_id=0 no envio de pontuação422Pass
VULN-L?limit=99999 (limite grande)200 + limitadoPass

Todos os 12 testes de vulnerabilidade passam. Nenhuma vulnerabilidade encontrada.


Armadilhas comuns

ArmadilhaCorreção
Armazenar todos os envios em vez da melhor pontuaçãoVerificação findScore() antes do INSERT; UPDATE se maior
Usar RANK() que pode não existir no SQLiteCOUNT(*) WHERE score > ? dá rank equivalente
IDOR no DELETE de pontuaçãoVerificar $actorId !== $userId → 403
Parâmetro limit sem limite causa varredura de tabelaLimitar limit ao range 1–100
Pontuação float/string ignora is_int()!is_int($score) rejeita floats e strings no JSON decode do PHP 8

Publicado sob a licença MIT.