AJAX 投票
Vote endpoints, anti-abuse, live results and concurrent counting.
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
| Method | Effect | Weakness |
|---|---|---|
| 数据库唯一键 | 同身份只能投一次 | 换 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.数据表
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;poll_votes 的主键是 (poll_id, voter_key)——数据库层面保证同一个人对同一个投票只能有一行。这比在 PHP 里 SELECT 再判断可靠得多。
投票接口
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":"投票成功"}重复投票时主键冲突,SQLSTATE 23000,返回 409:
Output
{"ok":false,"msg":"你已经投过票了"}votes = votes + 1 是数据库内原子递增,不存在并发丢更新。如果写成「先 SELECT 出票数再 UPDATE」,两个请求同时进来就会少加一次。结果接口
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}]}前端轮询
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 秒刷新一次style="width:${it.percent}%" 里如果 percent 来自不可信来源就有注入风险。这里是服务端算出来的数字,安全;但任何拼进 HTML 属性的值都要先确认类型。防刷
| 手段 | 效果 | 弱点 |
|---|---|---|
| 数据库唯一键 | 同身份只能投一次 | 换 IP / UA 可绕过 |
| IP 限流 | 挡住脚本批量刷 | 代理池可绕过 |
| 登录后投票 | 最可靠 | 降低参与率 |
| 验证码 | 挡自动化 | 影响体验 |
| 时间窗限制 | 限制频率 | 不解决根本问题 |
匿名投票永远可以被刷,只能提高成本。重要投票必须要求登录,或者至少叠加 IP 限流 + 验证码。
REMOTE_ADDR 在反向代理后面拿到的是代理 IP,所有人看起来都一样。此时要么从可信代理读 X-Forwarded-For,要么直接用登录用户 ID 做标识——别用可伪造的头做安全判断。