)
前言批量删除听起来是列表页加一列复选框、后面接个循环的小功能但它是最容易出事故的列表操作之一。常见症状有四种勾了十条只删掉一条明明只勾了几条数据库里一串无关记录被删了勾选数量一多随机地少删几条而且没有任何报错以及刷新页面导致重复提交。这些症状分别对应四个不同的根因没把复选框提交成数组、把 id 直接拼进了 SQL、表单字段数超过max_input_vars被 PHP 静默截断、没有用 POST/Redirect/Get。它们都不难解决但每一个都必须在写第一行代码时就想到。本文会用 PHP HTML 写一个完整可运行的批量删除前端复选框、全选、CSRF 令牌后端用 PDO 预处理占位符、校验每一个 id、软删除并做 PRG 跳转。示例用 SQLite 内存库跑换成 MySQL 只需改 DSN 和那句datetime(now)其余逻辑完全一致最低 PHP 8.0。一、前端复选框必须提交成数组HTML 表单里同名的多个控件只有写成nameids[]才会被 PHP 解析成数组$_POST[ids]。写成nameids时PHP 只会保留最后一个值——这就是勾了十条只删一条的最常见原因。同时要记住两点一是删除动作必须走 POST不能用a hrefdelete.php?id1因为浏览器预取、爬虫、甚至聊天软件生成链接预览都可能触发 GET二是要带 CSRF 令牌否则任何第三方页面都能诱导已登录用户发出删除请求。form methodpost actionbatch-delete.php idbatch-form input typehidden namecsrf value__CSRF__ table thead tr thinput typecheckbox idcheck-all aria-label全选/th th商品/th th价格/th /tr /thead tbody tr tdinput typecheckbox nameids[] value1 classrow-check/td td机械键盘/td td399.00/td /tr tr tdinput typecheckbox nameids[] value2 classrow-check/td td无线鼠标/td td129.00/td /tr tr tdinput typecheckbox nameids[] value3 classrow-check/td td27 寸显示器/td td1299.00/td /tr /tbody /table button typesubmit idsubmit-btn批量删除/button /form script const all document.getElementById(check-all); const rows document.querySelectorAll(.row-check); all.addEventListener(change, () { rows.forEach((box) { box.checked all.checked; }); }); document.getElementById(batch-form).addEventListener(submit, (event) { if (!document.querySelector(.row-check:checked)) { event.preventDefault(); alert(请至少选择一件商品); } }); /script前端校验只是体验优化服务端永远要再校验一次。二、后端为什么必须用占位符而不是拼接WHERE id IN (1,2,3)里的IN列表长度是可变的很多人图省事直接implode(,, $_POST[ids])这就是注入的入口而且很容易顺手把整个条件拼成WHERE id IN (1,2,3)——在 MySQL 里字符串隐式转换后只匹配到第一行于是出现只删一条的诡异现象。正确做法是把每个 id 严格校验成整数再按数量生成等量的?占位符。-- 软删除保留数据只打时间戳 UPDATE products SET deleted_at datetime(now) WHERE deleted_at IS NULL AND shop_id ? AND id IN (?, ?, ?)注意还有shop_id ?这个归属条件。批量删除最常见的越权事故就是只按 id 删而没有校验这条记录是否属于当前用户或当前租户。三、完整可运行示例下面这份代码可以直接存成batch-delete.php用php -S 127.0.0.1:8000跑起来。它用 SQLite 内存库自建表把处理逻辑和页面放在同一个文件里生产环境建议拆开。?php // 最低版本PHP 8.0 declare(strict_types1); session_start(); function db(): PDO { static $pdo null; if ($pdo instanceof PDO) { return $pdo; } $pdo new PDO(sqlite::memory:, null, null, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, ]); $pdo-exec(CREATE TABLE products ( id INTEGER PRIMARY KEY, shop_id INTEGER NOT NULL, title TEXT NOT NULL, deleted_at TEXT NULL )); $insert $pdo-prepare(INSERT INTO products (id, shop_id, title) VALUES (?, 7, ?)); foreach ([[1, 机械键盘], [2, 无线鼠标], [3, 27 寸显示器]] as [$id, $title]) { $insert-execute([$id, $title]); } return $pdo; } function csrfToken(): string { if (empty($_SESSION[csrf])) { $_SESSION[csrf] bin2hex(random_bytes(16)); } return $_SESSION[csrf]; } function assertCsrf(?string $token): void { $expected $_SESSION[csrf] ?? ; if ($expected || !is_string($token) || !hash_equals($expected, $token)) { http_response_code(419); exit(CSRF 校验失败请刷新页面重试); } } /** * param arraymixed $rawIds * return int 实际删除的行数 */ function deleteProducts(PDO $pdo, array $rawIds, int $shopId): int { $ids []; foreach ($rawIds as $raw) { // 严格校验必须是不含前导零的纯十进制整数 if (!is_string($raw) || !preg_match(/^[1-9][0-9]{0,9}$/, $raw)) { throw new InvalidArgumentException(商品 id 非法); } $ids[] (int) $raw; } $ids array_values(array_unique($ids)); if ($ids []) { return 0; } if (count($ids) 500) { throw new InvalidArgumentException(单次最多删除 500 件商品); } $placeholders implode(,, array_fill(0, count($ids), ?)); $sql UPDATE products SET deleted_at datetime(now) WHERE deleted_at IS NULL AND shop_id ? AND id IN ({$placeholders}); $stmt $pdo-prepare($sql); // 参数顺序归属条件在前id 列表在后 $stmt-execute(array_merge([$shopId], $ids)); return $stmt-rowCount(); } // ---- 请求处理 ---- $flash $_SESSION[flash] ?? null; unset($_SESSION[flash]); if (($_SERVER[REQUEST_METHOD] ?? GET) POST) { assertCsrf($_POST[csrf] ?? null); $raw $_POST[ids] ?? []; if (!is_array($raw)) { $raw []; } try { $deleted deleteProducts(db(), $raw, 7); $_SESSION[flash] 已删除 {$deleted} 件商品; } catch (InvalidArgumentException $e) { $_SESSION[flash] 操作失败 . $e-getMessage(); } // PRG处理完立刻重定向防止刷新重复提交 header(Location: . $_SERVER[PHP_SELF], true, 303); exit; } // ---- 渲染 ---- $csrf csrfToken(); $products db()-query(SELECT id, title FROM products WHERE deleted_at IS NULL ORDER BY id) -fetchAll(); $csrfEscaped htmlspecialchars($csrf, ENT_QUOTES, UTF-8); if ($flash ! null) { echo p . htmlspecialchars($flash, ENT_QUOTES, UTF-8) . /p; } echo form methodpost; echo input typehidden namecsrf value . $csrfEscaped . ; foreach ($products as $p) { printf( labelinput typecheckbox nameids[] value%d %s/labelbr, $p[id], htmlspecialchars($p[title], ENT_QUOTES, UTF-8) ); } echo button typesubmit批量删除/button/form;把上面渲染部分换成前面给出的完整 HTML 表单就是一个可以直接投用的页面。要点是每一次删除都经过preg_match校验、array_unique去重、占位符绑定、并对shop_id做了归属校验。常见坑点1. 复选框没写成数组名❌input typecheckbox nameids value1重复出现——PHP 只保留最后一个值症状是永远只删一条。 ✅ 用nameids[]后端再用is_array()兜底$_POST[ids]也可能因为别的原因变成标量。2. 直接把 id 拼进 SQL❌$sql DELETE FROM products WHERE id IN ( . implode(,, $_POST[ids]) . );——既可注入又会因为值被当成一个字符串而变成IN (1,2,3)只匹配第一行。 ✅ 全部转成整数 array_fill(0, count($ids), ?)生成占位符按顺序绑参。3. 忽略max_input_vars导致的静默截断❌ 商品列表每行有多个表单字段勾选几百行后提交——PHP 的max_input_vars默认是 1000超出部分不会报错只是悄悄丢掉表现为随机少删几条而且$_POST里根本找不到原因。 ✅ 大表单改成fetch()提交 JSON读php://input不受max_input_vars限制或显式调高该 ini 值。4. 用 GET 做删除❌a href/delete.php?ids[]1删除/a——浏览器预取、爬虫、链接预览都会替你点一遍。 ✅ 只接受 POST并在服务端校验$_SERVER[REQUEST_METHOD]。5. 只按 id 删除不做归属校验❌DELETE FROM products WHERE id IN (...)——用户改一下表单里的 value 就能删别人的数据。 ✅ 条件里带上user_id/shop_id并检查rowCount()是否与预期相符。6. 用rowCount()判断是否删除成功❌ 软删除场景下重复提交记录已是deleted_at非空UPDATE影响 0 行代码却报删除失败其实数据早就是删掉的。 ✅rowCount()只表示本次影响的行数要区分没删到和本来就已删除先SELECT查一次状态或在 SQL 的WHERE里保留deleted_at IS NULL并接受 0 行的语义。7. 删完直接输出页面❌ 处理完就地渲染用户按 F5 会弹出重新提交表单并在同意后又删一次。 ✅ 用 PRGPost/Redirect/Get处理完立即303重定向到列表页提示语放 session 里闪现。8. 忘了事务与外键❌ 删除商品主表时关联的库存、图片记录留在库里界面查不到、后台统计又多出来。 ✅ 用事务包住多条写操作外键设ON DELETE CASCADE或按同样的逻辑一并软删除。总结环节关键做法不加会怎样表单控件nameids[]只提交最后一个值只删一条CSRF隐藏字段 hash_equals()第三方页面可诱导删除id 校验正则严格匹配十进制整数注入、或误删无关记录SQL?占位符 array_fill()注入IN (1,2,3)只匹配首行归属校验AND shop_id ?越权删除他人数据大批量控制在max_input_vars内或改用 JSON静默截断随机少删收尾303重定向PRG刷新重复提交批量删除的正确姿势其实是一条很短的链路数组名 - CSRF - 严格校验 - 占位符 - 归属条件 - 重定向。每一步都对应一类真实事故缺任何一环都可能在生产环境上以偶发、难以复现的形式出现。把这些当成模板固化下来比每次临场发挥可靠得多。