AJAX 投票

Vote endpoints, anti-abuse, live results and concurrent counting.

Intermediate
Key PointsThe hard part of voting is not incrementing but ensuring one vote per user.

Schema

SQL
CREATE TABLE polls (
    id    INT UNSIGNED NOT NULL AUTO_INCREMENT,
    title VARCHAR(200) NOT NULL,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE poll_options (
    id      INT UNSIGNED NOT NULL AUTO_INCREMENT,
    poll_id INT UNSIGNED NOT NULL,
    label   VARCHAR(100) NOT NULL,
    votes   INT UNSIGNED NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    KEY idx_poll (poll_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE poll_votes (
    poll_id    INT UNSIGNED NOT NULL,
    option_id  INT UNSIGNED NOT NULL,
    voter_key  CHAR(64)     NOT NULL,
    created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (poll_id, voter_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

The primary key (poll_id, voter_key) enforces one row per voter per poll at the database level — far more reliable than a SELECT-then-check in PHP.

The vote endpoint

PHP
<?php
// /api/vote.php
header("Content-Type: application/json; charset=utf-8");

if ($_SERVER["REQUEST_METHOD"] !== "POST") {
    http_response_code(405);
    exit(json_encode(["ok" => false, "msg" => "只接受 POST"]));
}

$data = json_decode(file_get_contents("php://input"), true) ?: [];

$pollId   = (int)($data["poll_id"] ?? 0);
$optionId = (int)($data["option_id"] ?? 0);

if ($pollId <= 0 || $optionId <= 0) {
    http_response_code(400);
    exit(json_encode(["ok" => false, "msg" => "参数错误"]));
}

// 匿名投票也要有稳定的身份标识:IP + UA 的哈希
$voterKey = hash("sha256", ($_SERVER["REMOTE_ADDR"] ?? "") . "|" . ($_SERVER["HTTP_USER_AGENT"] ?? ""));

try {
    $pdo->beginTransaction();

    $stmt = $pdo->prepare("INSERT INTO poll_votes (poll_id, option_id, voter_key) VALUES (?, ?, ?)");
    $stmt->execute([$pollId, $optionId, $voterKey]);

    $stmt = $pdo->prepare("UPDATE poll_options SET votes = votes + 1 WHERE id = ? AND poll_id = ?");
    $stmt->execute([$optionId, $pollId]);

    if ($stmt->rowCount() === 0) {
        throw new RuntimeException("选项不存在");
    }

    $pdo->commit();
} catch (PDOException $e) {
    $pdo->rollBack();
    if ($e->getCode() === "23000") {
        http_response_code(409);
        exit(json_encode(["ok" => false, "msg" => "你已经投过票了"]));
    }
    throw $e;
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    http_response_code(400);
    exit(json_encode(["ok" => false, "msg" => $e->getMessage()]));
}

echo json_encode(["ok" => true, "msg" => "投票成功"]);
Output
{"ok":true,"msg":"投票成功"}

A repeat vote hits a primary key conflict, SQLSTATE 23000, returning 409:

Output
{"ok":false,"msg":"你已经投过票了"}
votes = votes + 1 increments atomically inside the database. A SELECT-then-UPDATE pair loses increments under concurrency.

The results endpoint

PHP
<?php
// /api/results.php?poll_id=1
header("Content-Type: application/json; charset=utf-8");

$pollId = (int)($_GET["poll_id"] ?? 0);

$stmt = $pdo->prepare("SELECT id, label, votes FROM poll_options WHERE poll_id = ? ORDER BY id");
$stmt->execute([$pollId]);
$rows = $stmt->fetchAll();

$total = array_sum(array_column($rows, "votes"));

foreach ($rows as &$r) {
    $r["votes"]   = (int)$r["votes"];
    $r["percent"] = $total > 0 ? round($r["votes"] / $total * 100, 1) : 0.0;
}
unset($r);

echo json_encode(["ok" => true, "total" => $total, "items" => $rows], JSON_UNESCAPED_UNICODE);
Output
{"ok":true,"total":8,"items":[{"id":1,"label":"PHP","votes":5,"percent":62.5},{"id":2,"label":"Go","votes":2,"percent":25},{"id":3,"label":"Rust","votes":1,"percent":12.5}]}

Polling on the client

JavaScript
async function refresh() {
  const res  = await fetch("/api/results.php?poll_id=1");
  const data = await res.json();

  document.querySelector("#total").textContent = data.total;

  document.querySelector("#bars").innerHTML = data.items.map(it => `
    <div class="row">
      <span class="label">${escapeHtml(it.label)}</span>
      <div class="bar" style="width:${it.percent}%"></div>
      <span class="num">${it.votes} (${it.percent}%)</span>
    </div>
  `).join("");
}

refresh();
setInterval(refresh, 5000);      // 5 秒刷新一次
A value interpolated into a style attribute must be trusted. Here percent is computed server-side, but verify the type of anything placed in an attribute.

Anti-abuse

MethodEffectWeakness
数据库唯一键同身份只能投一次换 IP / UA 可绕过
IP 限流挡住脚本批量刷代理池可绕过
登录后投票最可靠降低参与率
验证码挡自动化影响体验
时间窗限制限制频率不解决根本问题

Anonymous polls can always be gamed — you can only raise the cost. Important votes require login, or at least rate limiting plus a captcha.

Behind a proxy REMOTE_ADDR is the proxy's address for everyone. Read X-Forwarded-For only from a trusted proxy, or key on the logged-in user — never trust forgeable headers for security.

Friends & Partners