尧图精选

PHP活动报名系统源码实战:状态机设计、并发扣减与安全加固

🕒 发布时间:2026/9/15 16:14:37 📁 来源:尧图网络
简介这是一款基于PHP和MySQL的在线活动报名管理系统源码采用Laravel 5.1框架开发服务于需要搭建报名网站或维护活动平台的开发者与站点管理员。系统兼顾SEO友好、安全稳定和多终端展示可用于会议、培训、赛事等活动的在线报名、数据采集与后台管理能降低活动组织和统计成本。压缩包共6256个文件以PHP源码为主涵盖前端展示所需的图片、样式、脚本资源以及JSON配置和文本说明文档整体大小约17.79MB目录结构清晰便于按模块检索和二次开发。目前已有1836人学习资源包内还包含部署运行所需的配置项和说明文档适合需要快速落地报名项目的PHP开发者参考使用。代码覆盖报名表单、数据入库、后台审核等核心流程并预留清晰的扩展目录既能帮助理解Laravel框架的项目结构也可作为实际活动报名系统的基础工程。1. 自己写 PHP 活动报名系统源码先想明白缺的不是表单而是状态机做内部活动、社团纳新或技术分享会现成的报名表单工具都能用可一旦要接自己的会员体系、按部门导出名单、限制每场人数并支持候补闭源服务就处处受制。自己维护一套 PHP 活动报名系统源码不是炫技而是让「报名」从提交到签到的每个状态都掌握在自己手里。报名的特征是瞬时并发公告一发出前几分钟的请求密度可能是全天的数倍名额有限多放少放都会引发争议。所以难点不在表单渲染而在数据建模、并发扣减与状态流转。下文按「表结构 → 报名链路 → 后台导出 → 验收加固」推进基线是原生 PDO MySQL 8不绑定具体框架。适合用 PHP 做内部系统、垂直小站点的开发者新手可照此搭出最小可用系统老手直接跳去第三章看行锁、幂等与候补递补。2. 活动报名系统的表结构设计与报名状态机一次建模省掉三个月改版2.1 活动、场次与报名记录别把自定义字段塞进活动表见过不少失败的库表一张activity表存活动报名人信息用 JSON 字段堆。头两场活动挺顺等你要按性别、部门、校区筛选名单或者要给不同活动配不同表单时JSON 解析逻辑会蔓延到每个查询里。我从三张核心表起步activities活动表、registrations报名表、form_fields自定义字段定义表。如果同一活动分上下午场次再多一张sessions表报名记录挂在场次而非活动上。activities表只放与名额、窗口期相关的控制字段不要把每次变动的活动规则塞进去字段类型说明idINT UNSIGNED AUTO_INCREMENT主键titleVARCHAR(100)活动名称max_seatsINT UNSIGNED名额上限0 表示不限制current_seatsINT UNSIGNED当前已占名额用于原子扣减need_reviewTINYINT1 开启人工审核报名先入待审核statusTINYINT0 草稿1 报名中2 已结束signup_start_at / signup_end_atDATETIME报名窗口期current_seats是冗余计数它的值必须和报名表里的已确认记录保持一致。初次建表时先按报名表回填一次之后所有变更都走同一套事务不允许旁路手工改。registrations表的重点是状态和用户标识字段类型说明idBIGINT UNSIGNED主键activity_idINT UNSIGNED活动 ID与 created_at 建复合索引user_keyVARCHAR(64)用户唯一标识手机号或内部用户 ID 均可statusTINYINT状态机见 2.2form_dataJSON报名时刻的自定义字段快照created_atDATETIME报名时间候补排序依据form_data用 JSON 保存快照而不是实时 join 字段定义是为了防止组织者中途改表单项字段定义变了历史报名数据仍然原样可查。这是报名系统里很容易被忽略的需求改版成本最高的往往就是这里。2.2 五种状态的状态机可取消、可候补、可撤销取消的边界报名状态不要设计成「已报名 / 未报名」。真实运营里会有审核、取消、候补递补、现场签到状态少于四五个后期全靠脑补。我用五种0 待审核、1 已确认、2 已取消、3 候补中、4 已签到。流转规则只有几条写死在业务层正常报名落到 1未开启审核或 0开启审核人工审核通过 0 → 1用户取消 1 → 2满额时报名落 3有人取消后最早进入候补的 3 → 1现场核销 1 → 44 是终态不可回到 1。关键约束是「只有 1 和 4 计入名额」。current_seats的加一减一只发生在变成 1、离开 1 的时刻4 是 1 的子状态不计入额外扣减。这个规则写进所有相关 SQL 的 WHERE 条件里比如统计名额时用status IN (1,4)一旦漏掉取消的人会一直占着名额。2.3 名额扣减的两种做法计数列与 COUNT选哪种看报名规模给activities表加current_seats计数列配合WHERE current_seats max_seats的原子 UPDATE是报名场景最省心的方案锁粒度最小、不需要实时读已确认记录数。反过来如果每次都实时SELECT COUNT(*) FROM registrations WHERE activity_id ? AND status IN (1,4)在已确认记录上了万、且「报名中」活动同时有多个时这个扫描会变成明显的热点查询。计数列的唯一代价是多一个数据一致性约定所有改状态的入口必须成对出现。DDL 基线如下生成列那一部分是给 3.3 的幂等兜底用的后面再解释CREATE TABLE registrations ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, activity_id INT UNSIGNED NOT NULL, user_key VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 1, form_data JSON NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_act_time (activity_id, created_at), KEY idx_act_status (activity_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个索引分别喂给候补排序和状态统计。utf8mb4是 charset 的唯一正确选项否则生僻字姓名入库即乱码。提示如果current_seats和名单对不上写一次性脚本全表重建计数不要在线上手工 UPDATE手工改等于把不一致问题的根因埋得更深。3. 用 PHP 实现报名提交链路事务、行锁与幂等一次到位3.1 服务端校验写成 PHP 类规则数组替代 if 堆浏览器端的必填校验在 curl 面前等于没有所以校验必须发生在 PHP。常见做法是把校验逻辑收进独立的类比如SignupValidator返回错误数组而不是直接 die方便前端渲染错误提示。class SignupValidator { public static function run(array $in, array $rules): array { $errors []; foreach ($rules as $field $rule) { $value trim((string)($in[$field] ?? )); if (!empty($rule[required]) $value ) { $errors[$field] 该字段必填; continue; } if (!empty($rule[max]) mb_strlen($value) $rule[max]) { $errors[$field] 长度超出限制; } } if (isset($in[phone]) !preg_match(/^1[3-9]\d{9}$/, $in[phone])) { $errors[phone] 手机号格式不正确; } return $errors; } }mb_strlen代替strlen因为中文姓名的字节数会误判长度手机号正则按常见规则收紧如果系统还接待海外号码这里改成86前缀可选匹配即可。$rules形如[name [required true, max 30]]新增字段只需改配置不用再往方法里堆 if。CSRF 校验放在校验类之前用hash_equals做比对才是 PHP 的正确姿势有时序泄密隐患$_SESSION[csrf_token] $_SESSION[csrf_token] ?? bin2hex(random_bytes(16)); if (!hash_equals($_SESSION[csrf_token], $_POST[csrf_token] ?? )) { http_response_code(400); exit(页面已过期请刷新后重试); }这两段是 PHP 代码审计里出场率最高的检查项表单类功能基本都靠它们兜底。3.2 事务里先锁活动行再写报名SELECT ... FOR UPDATE 的正确用法核心扣减逻辑必须同时满足三个条件名额判断和写入之间不能被其他请求插队、同一用户不能占两个名额、异常时要回滚。原生 PDO 下的写法$pdo-beginTransaction(); try { // 1) 锁住活动行并发请求在这里排队读到的是最新名额 $stmt $pdo-prepare( SELECT id, current_seats, max_seats FROM activities WHERE id ? AND status 1 FOR UPDATE ); $stmt-execute([$activityId]); $act $stmt-fetch(PDO::FETCH_ASSOC); if (!$act || $act[current_seats] $act[max_seats]) { throw new RuntimeException(名额已满请稍后再试); } // 2) 业务幂等已确认或已签到的用户直接拒绝 $dup $pdo-prepare( SELECT id FROM registrations WHERE activity_id ? AND user_key ? AND status IN (1,4) LIMIT 1 ); $dup-execute([$activityId, $userKey]); if ($dup-fetch()) { throw new RuntimeException(你已报名过该活动请勿重复提交); } // 3) 写入报名记录并把计数列加一 $pdo-prepare( INSERT INTO registrations (activity_id, user_key, status, created_at) VALUES (?, ?, 1, NOW()) )-execute([$activityId, $userKey]); $pdo-prepare( UPDATE activities SET current_seats current_seats 1 WHERE id ? )-execute([$activityId]); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); error_log([signup] . $e-getMessage()); // 入库前先落日志 throw $e; }FOR UPDATE是排他行锁两个并发请求同时进来时第二个会等第一个 commit 后才读到新值这就把「判断名额 → 写入」变成原子操作。注意锁的是activities行而不是registrations行锁行数量恒定死锁概率最低。catch (Throwable $e)是 PHP 7 的错误处理姿势PDO 异常和普通异常都能接住。另一种更精简的姿势是去掉 SELECT直接UPDATE activities SET current_seats current_seats 1 WHERE id ? AND current_seats max_seats靠rowCount() 1判断是否抢到名额。两者都可行前者能读到活动详情用于提示「名额已满」还多一点改动余地后者代码更短。我一般保留 FOR UPDATE因为第二步的重复检查也需要在同一把锁保护下进行不然两个请求可能都通过检查再各写一条。3.3 生成列唯一索引兜底幂等历史取消记录不再挡住重新报名业务层的重复检查在极端情况下可能被绕过比如并发窗口、代码改漏。靠数据库兜底最省心但直接给(activity_id, user_key)加唯一索引用户取消后再报名会撞索引。这里用生成列把「活跃报名」单独投影出来ALTER TABLE registrations ADD COLUMN active_key VARCHAR(64) GENERATED ALWAYS AS ( IF(status IN (1,4), CONCAT(activity_id, :, user_key), NULL) ) STORED, ADD UNIQUE KEY uk_active_key (active_key);唯一索引允许 NULL 重复取消记录里active_key是 NULL再报名时生成新值就不会冲突。插入重复值时 MySQL 报 1062PDO 抛 SQLSTATE 23000在 catch 里识别这个错误码返回「请勿重复报名」即可。常见的错误码处理参考SQLSTATE含义处理方式23000唯一索引冲突重复报名捕获后返回「请勿重复报名」40001死锁事务被回滚记日志提示稍后重试HY000 1205锁等待超时检查锁顺序或调大 innodb_lock_wait_timeout3.4 候补队列不建新表状态从 1 改成 3 就算入队满额时把报名记录写成 status 3按created_at排序就是天然队列不需要单独的 waitlist 表。有人取消时在同一事务里完成「释放名额 → 领取最早的候补」$pdo-beginTransaction(); // 0) 所有改名额的事务都先锁活动行统一锁顺序避免死锁 $pdo-prepare(SELECT id FROM activities WHERE id ? FOR UPDATE) -execute([$activityId]); // 1) 取消已确认报名释放一个名额 $pdo-prepare( UPDATE registrations SET status 2, cancelled_at NOW() WHERE id ? AND activity_id ? AND status 1 )-execute([$cancelledId, $activityId]); $pdo-prepare( UPDATE activities SET current_seats current_seats - 1 WHERE id ? )-execute([$activityId]); // 2) 候补第一位升为已确认名额再加回来事务内净变化为 0 $stmt $pdo-prepare( SELECT id FROM registrations WHERE activity_id ? AND status 3 ORDER BY created_at ASC LIMIT 1 FOR UPDATE ); $stmt-execute([$activityId]); $waiter $stmt-fetch(PDO::FETCH_ASSOC); if ($waiter) { $pdo-prepare(UPDATE registrations SET status 1 WHERE id ?) -execute([$waiter[id]]); $pdo-prepare(UPDATE activities SET current_seats current_seats 1 WHERE id ?) -execute([$activityId]); $redis-lPush(signup_notify, (string)$waiter[id]); // 异步通知队列 } $pdo-commit();候补递补和用户取消必须放在同一个事务里顺序固定先减名额、再升候补。如果先发短信再改状态短信发出而事务回滚是最难排查的一类「看起来成功实际没成功」。短信、站内信的发送用 PHP 队列异步处理Redis 消费组或lpush/brpop都行推送失败不至于拖垮报名事务。4. 报名后台与名单导出PHP 把筛选、分页和 CSV 编码一次做对4.1 后台列表筛选用白名单拼 SQL把 $_GET 当成不可信输入后台列表页最常见的需求组合是「按状态看 关键字搜 分页」。筛选项不能直接拼进 SQL但也不用把所有查询都参数化——状态是有限集合白名单 类型转换就够了$statusMap [0 待审核, 1 已确认, 2 已取消, 3 候补中, 4 已签到]; $where [r.activity_id ?]; $params [$activityId]; if (isset($_GET[status]) array_key_exists((int)$_GET[status], $statusMap)) { $where[] r.status ?; $params[] (int)$_GET[status]; } if (!empty($_GET[q])) { $where[] (r.user_key LIKE ? OR JSON_UNQUOTE(JSON_EXTRACT(r.form_data, $.phone)) LIKE ?); $params[] % . $_GET[q] . %; $params[] % . $_GET[q] . %; } $sql SELECT r.*, a.title FROM registrations r JOIN activities a ON a.id r.activity_id WHERE . implode( AND , $where) . ORDER BY r.created_at DESC LIMIT . (($page - 1) * $perPage) . , . $perPage;(int)强转保证array_key_exists永远拿到合法 key分页的两个 LIMIT 参数是强转后的整数可以直接内联不会构成注入。JSON 字段查询用到JSON_EXTRACT报名人数上千后仍够用真要频繁按手机号筛选再考虑把 phone 提成独立列。4.2 CSV 导出先写 BOMfputcsv 和 Excel 的 UTF-8 相处之道名单导出是报名系统最常被使用的功能组织者拿它去签到、做胸牌、导入内部系统。用fputcsv直接写php://output避免先拼一个超大字符串再输出header(Content-Type: text/csv; charsetUTF-8); header(Content-Disposition: attachment; filenamesignup_ . date(Ymd_His) . .csv); $fp fopen(php://output, w); // 写 UTF-8 BOMExcel 打开才不乱码 fwrite($fp, \xEF\xBB\xBF); fputcsv($fp, [序号, 姓名, 手机号, 状态, 报名时间]); $stmt $pdo-query( SELECT user_key, form_data, status, created_at FROM registrations WHERE activity_id . (int)$activityId . ORDER BY created_at ASC ); foreach ($stmt as $i $row) { $phone json_decode($row[form_data], true)[phone] ?? ; fputcsv($fp, [$i 1, $row[user_key], $phone, $statusMap[$row[status]], $row[created_at]]); } fclose($fp);\xEF\xBB\xBF是 UTF-8 的 BOM 字节序列没有它 Excel 会把 UTF-8 按 GBK 解码中文姓名全变乱码。fputcsv自动处理字段里的逗号、引号、换行比手工拼字符串安全。导出全量、不分页几千行 CSV 也就几百 KB一次输出直接结束请求。导出列与数据来源的对应关系固定成一张映射改表单项名不影响导出逻辑导出列数据来源注意序号循环自增 $i 1按报名时间排序后从 1 开始姓名 / 手机号form_data JSON报名时的快照组织者后期改表单项不影响状态status 整数先映射成中文再写入 CSV4.3 取消报名和核销签到状态变更要成对进事务后台最常见的两个操作是取消报名和核销签到。取消不只是改状态「释放名额 递补候补」必须绑在同一个事务里直接复用 3.4 的逻辑。签名要点是给 UPDATE 加上状态条件$pdo-beginTransaction(); try { // 只能取消已确认的记录防止重复取消导致名额被减两次 $upd $pdo-prepare( UPDATE registrations SET status 2, cancelled_at NOW() WHERE id ? AND activity_id ? AND status IN (1,4) ); $upd-execute([$regId, $activityId]); if ($upd-rowCount() 0) { throw new RuntimeException(该记录不存在或不可取消); } // 释放名额再走 3.4 的候补递补递补内部会把名额加回 $pdo-prepare( UPDATE activities SET current_seats current_seats - 1 WHERE id ? )-execute([$activityId]); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); throw $e; }WHERE ... status IN (1,4)是防呆条件已取消的记录再点一次取消rowCount 为 0不会把名额减成负数。签到核销同理UPDATE ... SET status 4 WHERE id ? AND status 1重复扫码只有第一次生效。这两个接口最容易犯的错是「不同时改计数列」一旦漏改后台的数字和名单对不上排查时只能全表重算。5. 用 PHP 与 curl 验收报名系统的并发与安全上线前最后一关5.1 并发扣减与幂等验收20 个并发请求只放行 10 个把测试活动的名额改成 10用 20 个不同的 user_key 并发打报名接口验收人数必须是 10 人已确认、10 人候补再用同一个 user_key 重复提交两次已确认数必须保持 10 不变T$(curl -s -c /tmp/cj http://localhost/signup.php?id2 \ | sed -n s/.*namecsrf_token value\([^]*\).*/\1/p) for i in $(seq 1 20); do curl -s -b /tmp/cj -d csrf_token$Tactivity_id2user_keyu_$i \ http://localhost/signup.php /dev/null done wait mysql -u root -p your_db -e \ SELECT status, COUNT(*) FROM registrations WHERE activity_id2 GROUP BY status;期望输出1 10和3 10多一个少一个都说明锁或幂等没生效。每个 curl 复用同一个 sessionCSRF Token 不变这样测的是业务幂等而不是校验拦截。压测结束后顺手看一眼SHOW ENGINE INNODB STATUS\G里的 LATEST DETECTED DEADLOCK确认没有死锁残留再清掉测试数据。5.2 安全验收清单把 PHP 代码审计的重点过一遍上线前按四点快速自查。第一未登录直接访问export.php必须返回 403后台接口全部先查 session。第二报名姓名提交scriptalert(1)/script列表页应原样显示为转义后的实体而不是弹窗输出侧统一走htmlspecialchars($str, ENT_QUOTES, UTF-8)。第三改 URL 里的activity_id访问别家活动名单必须被后台权限校验挡住活动与管理员的关系要单独建表核对。第四连续点两次提交按钮第二次返回「请勿重复报名」而不是 500。这四点分别对应越权、存储型 XSS、越权访问和幂等是 PHP 活动报名系统源码里最常被扫出来的问题类型。每修一个点都回到 5.1 的脚本重新跑一遍并发用例确认锁的改动没有破坏幂等。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →