ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用原生PHP从零开发活动报名系统:数据库设计到安全部署

用原生PHP从零开发活动报名系统:数据库设计到安全部署 简介这是一套基于PHP开发的完整活动报名系统源码面向Web开发初学者与中小型项目开发者解决线上活动如讲座、赛事、培训的用户注册、活动浏览、在线报名及后台管理等核心需求。资源包共2000个文件主体为4972个PHP文件构成典型的MVC分层结构含controllers、models、views等标准目录辅以150个txt配置说明、143个md文档、119个js交互脚本、43个css样式文件及120个png图标资源兼顾功能实现与前端体验压缩包大小为13.49MB。已有114人下载学习代码中可见artisan、php_cs、dockerfile等现代PHP工程化痕迹结合内容预览中的asciidoc技术文档与namespace规范可深入理解PHP项目架构设计、数据库交互、表单验证与权限控制等实战要点是掌握Web应用全栈开发流程的优质参考样本。 运营同事周一早上给我甩了句话“周五前要上线一个活动报名页面能收集报名信息、能后台看名单、最好还能导出 Excel。”我当时的表情大概和很多被迫接下这种需求的开发者一样——时间紧、预算没有、服务器还是台老掉牙的虚拟主机没有 Redis、没有队列、甚至不一定装了什么高级扩展。最后落地方案就是一套原生 PHP 写的活动报名系统整个项目压缩成一个 zip 包发给对方解压、改配置、填域名完事。这篇文章就是从这个实战项目里整理出来的。我会从需求分析、数据库设计、核心代码实现、打包发布一直讲到部署后的踩坑和安全加固整体围绕“用 PHP 源码做一个能直接用的活动报名系统”这条主线。如果你需要用 PHP 快速实现类似系统或者正在学习 PHP 实战项目这篇文章应该能帮你在最短时间内理清思路、避开我踩过的坑。1. 活动报名系统的需求拆解与技术选型1.1 先搞清楚运营到底要什么接到需求别急着写代码。我遇到的第一个版本是“做个报名系统”听起来很简单但真正一沟通需求就变成了一串用户在前台填写报名信息活动名称、姓名、手机号、邮箱、备注可能还要传一张凭证图。后台需要一个管理页面至少能登录、查看报名列表、导出 Excel。同一个手机号不能重复报名同一个活动否则运营统计会乱。系统要能配置多个活动而不是写死一个。代码拿到手就能部署后续维护的人不一定是开发。把这些整理成功能清单后系统边界就清晰了用户端一个报名表单页管理端一个带登录的后台中间是数据库和几个核心类。不需要搞微服务不需要上消息队列一个 PHP 项目 MySQL 完全够用。1.2 为什么偏选原生 PHP 而不是现成框架现在做新项目很多人第一反应是 Laravel、ThinkPHP。我当时也纠结过但最后选了原生 PHP理由很现实服务器环境太老PHP 版本 5.6 起步Composer 装依赖都能遇到一堆兼容问题Laravel 新版本直接跑不起来。业务逻辑简单不需要大量框架特性路由就几个页面。交付物是一个 zip 包对方解压后要能立刻看懂、改得动。原生 PHP 没有复杂目录和依赖加载对接手的人来说门槛最低。安全上只要自己处理好 PDO 预处理、会话管理、上传校验原生 PHP 完全可控。如果你有现成的 ThinkPHP 3.2.3 之类的基础环境那用框架没问题但本次分享的项目就是原生 PHP 方案。两种方案在文末我会专门说对比。2. 数据库表结构设计报名系统最关键的一步很多人在这个环节吃亏表设计不合理后面写代码会不断打补丁。我第一版就吃过一个亏——把活动名称直接存在报名表里运营想改活动标题历史报名数据全部要和活动表对不上最后只能手动刷数据库。这个项目的表结构我拆成了三张表后台管理员、活动、报名记录分开存。2.1 管理员表(admin)CREATE TABLE admin ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password_hash varchar(255) NOT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码字段不用password而是password_hash存的是password_hash()函数生成的结果。明文密码永远不要考虑哪怕只是内部的运营后台也一样。2.2 活动表(activity)CREATE TABLE activity ( id int(11) NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL, start_time datetime DEFAULT NULL, end_time datetime DEFAULT NULL, max_num int(11) DEFAULT 0 COMMENT 0表示不限, status tinyint(1) DEFAULT 1, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;max_num默认 0 表示不限制名额如果运营要求限流后台设置具体数字。status控制活动是否开放报名因为有些活动下架后还需要保留历史数据。2.3 报名表(signup)CREATE TABLE signup ( id int(11) NOT NULL AUTO_INCREMENT, activity_id int(11) NOT NULL, name varchar(50) NOT NULL, phone varchar(20) NOT NULL, email varchar(100) DEFAULT NULL, remark text, attachment varchar(255) DEFAULT NULL COMMENT 附件路径, status tinyint(1) DEFAULT 0 COMMENT 0待审核 1通过 2拒绝, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_activity_phone (activity_id, phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;联合索引idx_activity_phone是关键。判断“同一个手机号是否已经报名某个活动”时这个索引能让查询直接命中不需要全表扫描。报名量大了以后这一步省下的成本非常可观。2.4 为什么全面用 utf8mb4早期的 PHP 项目经常用 utf8但遇到生僻字、emoji 表情utf8 会直接报错或乱码。报名备注里 users 发个笑脸 utf8 就顶不住了。utf8mb4是 utf8 的超集能覆盖所有 Unicode 字符和前端表单处理配合也最省心。有旧库要迁移的趁早转查询性能差距可以忽略但乱码问题能堵掉一大半。3. 核心代码实现PDO 封装类与数据库访问层整个系统最常被复用、也最容易被写坏的就是数据库访问层。我见过太多直接把mysqli_connect写进每个页面的项目改个库名要全局搜索替换。这个项目我写了几个通用的类核心是 PDO 单例封装。3.1 PDO 封装类为什么是 PDO 而不是 mysqlimysqli是 PHP 官方针对 MySQL 的扩展代码写起来也直观但它有两个硬伤一是只能连 MySQL以后如果换数据库要重写全部 SQL二是mysqli扩展在较老环境下的预处理写法繁琐而手写拼接 SQL 很容易踩 SQL 注入。PDO 是 PHP 的数据库抽象层支持 MySQL、PostgreSQL、SQLite 等统一接口和预处理绑定一起用参数化查询能直接从根源上防 SQL 注入。用 PDO 写“登录查询”和“插入报名记录”代码一致不会因为数据库类型不同改结构。3.2 一个完整的 PDO 单例封装类?php class Db { private static $instance null; private $pdo; private function __construct() { $config require __DIR__ . /../config/config.php; $dsn sprintf(mysql:host%s;port%s;dbname%s;charset%s, $config[db_host], $config[db_port], $config[db_name], $config[db_charset] ); $this-pdo new PDO($dsn, $config[db_user], $config[db_pass], [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, ]); } public static function getInstance() { if (self::$instance null) { self::$instance new self(); } return self::$instance; } public function query($sql, $params []) { $stmt $this-pdo-prepare($sql); $stmt-execute($params); return $stmt; } public function fetchAll($sql, $params []) { return $this-query($sql, $params)-fetchAll(); } public function fetchOne($sql, $params []) { return $this-query($sql, $params)-fetch(); } public function execute($sql, $params []) { return $this-query($sql, $params)-rowCount(); } private function __clone() {} }三个细节值得注意PDO::ATTR_EMULATE_PREPARES false会使用 MySQL 服务端真正的预处理而不是 PHP 本地模拟。真正的预处理在防止注入上更可靠。PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION让数据库错误抛出异常开发时可以直接看到问题线上再配合统一的异常处理记录日志。单例模式保证一次请求只创建一个 PDO 连接避免多个页面调用反复建连这在高并发下是实打实的性能优化。这里补充一个重要经验如果你的环境 PHP 版本很老比如 5.3请确认 PDO 扩展已经开启不用想着靠代码绕过去。买虚拟主机时先看phpinfo()输出没有 PDO 就换主机商否则后面麻烦很多。3.3 配置文件的拆分配置单独放在config/config.php不放在页面里这样打包发布时对方只需要改这一个文件。?php return [ db_host 127.0.0.1, db_port 3306, db_name activity_signup, db_user root, db_pass your_password, db_charset utf8mb4, site_url http://your-domain.com, upload_dir __DIR__ . /../uploads/, debug false, ];debug参数很关键。开发环境设true页面直接展示错误详情线上设false错误写入日志坚决不把路径、SQL 细节暴露给用户。这个习惯能挡住大量基本信息泄露。4. 管理员登录与会话控制$_SESSION 的正确打开方式后台登录是管理端的门面如果这里放松了整个系统的数据都危险。我在网上看到过一段 CTF 题目里的代码长这样?php if(!isset($_session[username])): ? p classleadyou are c...这段代码想表达“未登录就跳到登录页”但实际生产环境绝不能这么写。一个直接的问题$_session虽然在 PHP 里不区分大小写能起到效果但整个登录模块的严谨程度决定了后台的安全等级。正确做法如下。4.1 登录密码校验?php session_start(); require_once __DIR__ . /../core/Db.php; require_once __DIR__ . /../core/Response.php; if ($_SERVER[REQUEST_METHOD] POST) { $username trim($_POST[username] ?? ); $password $_POST[password] ?? ; if ($username || $password ) { Response::error(用户名和密码不能为空); } $db Db::getInstance(); $user $db-fetchOne( SELECT id, username, password_hash FROM admin WHERE username ?, [$username] ); if (!$user || !password_verify($password, $user[password_hash])) { // 无论用户名不存在还是密码错误统一提示 Response::error(用户名或密码错误); } // 重新生成 session id防止会话固定攻击 session_regenerate_id(true); $_SESSION[admin_id] $user[id]; $_SESSION[admin_name] $user[username]; Response::success(登录成功); }两个关键点password_verify是验证password_hash生成的密码哈希它内置了 salt不需要开发者自己管理。session_regenerate_id(true)在登录成功后重新生成 session ID防止攻击者先让用户拿到固定 session id再诱导用户登录从而劫持会话。这个 API 虽然很小但防的是一类经典攻击。4.2 权限拦截避免每个页面重复写判断后台每个页面都要做“是否已登录”的判断直接复制粘贴容易漏。我封装了一个requireLogin()函数?php function requireLogin() { if (session_status() PHP_SESSION_NONE) { session_start(); } if (empty($_SESSION[admin_id])) { header(Location: login.php); exit; } }把这个函数放进一个公共的bootstrap.php每个后台页面第一行引入即可。用了它之后不需要再在页面里散落isset($_SESSION[username])这种判断代码可维护性高很多。4.3 防止 CSRF 的 Token 机制后台操作比如修改报名状态、删除记录如果只靠登录状态判断攻击者可以诱导管理员打开一个恶意链接在已经登录的情况下偷偷触发这些操作。这就是 CSRF。解决办法是每个表单生成一个随机的 CSRF token存在 session 里提交时校验。?php if (empty($_SESSION[csrf_token])) { $_SESSION[csrf_token] bin2hex(random_bytes(32)); } // 表单里输出 // input typehidden namecsrf_token value?php echo $_SESSION[csrf_token]; ? // 提交后校验 function checkCsrf($token) { if (!isset($_SESSION[csrf_token]) || $token ! $_SESSION[csrf_token]) { Response::error(页面已过期请刷新重试); } }这个机制的底层逻辑很简单攻击者的恶意页面拿不到用户会话里的 token所以提交必失败。别嫌麻烦报名系统的后台一旦被 CSRF 利用数据被批量篡改是分分钟的事。5. 前台报名流程表单、上传与重复报名治理前台报名是整个系统最核心的流量入口我拆成三个点来说HTML 表单与字段校验、附件上传的安全性、重复报名拦截。5.1 表单设计与服务端校验前端表单用 HTML 自带的required、maxlength先做一层提示体验会很顺但服务端必须重新校验一遍。前端校验可以被绕过服务端校验是最后的防线。报名表单核心字段form methodpost action/submit.php enctypemultipart/form-data input typehidden namecsrf_token value... div label活动名称/label input typetext nametitle value... readonly input typehidden nameactivity_id value5 /div div label姓名/label input typetext namename required maxlength50 /div div label手机号/label input typetext namephone required maxlength20 pattern1[3-9][0-9]{9} /div div label邮箱/label input typeemail nameemail /div div label备注/label textarea nameremark maxlength500/textarea /div div label附件/label input typefile nameattachment accept.jpg,.jpeg,.png,.gif /div button typesubmit提交报名/button /form有几个容易忽略的细节活动信息展示时我习惯把活动id放在隐藏域带回。但实际还需要校验这个活动是否存在、是否在报名时间窗口内、是否已经满员不能只信任隐藏域。手机号校验分成两层前端用pattern拦截明显错误服务端用正则再校验一次。推荐写成preg_match(/^1[3-9]\d{9}$/, $phone)。maxlength顺手都写上防止超长字符串把数据库字段打爆也防某些注入尝试。5.2 附件上传GIF 图片也要认真校验热搜里有个“网页允许上传 gif”说明很多人被 GIF 文件坑过。GIF 能头部伪装、内容复杂是最容易出上传漏洞的文件类型之一。处理上传时不能只看扩展名要看文件真实内容。我常用的做法是finfo读取 MIME 类型加getimagesize二次验证?php $file $_FILES[attachment] ?? null; if ($file $file[error] UPLOAD_ERR_OK) { $fileinfo finfo_open(FILEINFO_MIME_TYPE); $mime finfo_file($fileinfo, $file[tmp_name]); finfo_close($fileinfo); $allowed [image/jpeg, image/png, image/gif]; if (!in_array($mime, $allowed, true)) { Response::error(只支持上传 JPG、PNG、GIF 图片); } // 再用 getimagesize 读取图片尺寸能读出来说明确实是图片 $imageInfo getimagesize($file[tmp_name]); if ($imageInfo false) { Response::error(文件内容不是有效图片); } // 限制大小 2MB if ($file[size] 2 * 1024 * 1024) { Response::error(附件不能超过 2MB); } $ext pathinfo($file[name], PATHINFO_EXTENSION); $newName date(YmdHis) . _ . bin2hex(random_bytes(8)) . . . $ext; $savePath __DIR__ . /../uploads/ . $newName; if (!move_uploaded_file($file[tmp_name], $savePath)) { Response::error(附件保存失败); } $attachmentPath uploads/ . $newName; }这里最大的坑是文件名一定要重新生成。为什么要用random_bytes拼一个新名字因为如果直接用用户上传的文件名很可能导致路径穿越../../或覆盖同名文件。改成服务端生成的随机文件名后这两个问题都消失了。另外上传目录要有写权限但不建议把上传目录放在 web 根目录下。如果放在 web 根目录外即使某个文件被上传成可执行脚本也无法通过浏览器直接访问执行风险直接降一档。5.3 重复报名拦截数据库唯一约束兜底同一手机号不能重复报名同一活动代码层面用“先查再插”能挡住大多数请求但高并发下两个请求同时查到“没报过名”然后都执行插入就会出现重复数据。性能再好的查重都不如数据库唯一约束可靠。解决办法是直接给表增加唯一约束。可以在建表时添加ALTER TABLE signup ADD UNIQUE KEY uk_activity_phone (activity_id, phone);然后插入时捕获异常?php try { $db-execute( INSERT INTO signup (activity_id, name, phone, email, remark, attachment) VALUES (?, ?, ?, ?, ?, ?), [$activityId, $name, $phone, $email, $remark, $attachmentPath] ); Response::success(报名成功); } catch (PDOException $e) { // 23000 是唯一约束冲突错误码 if ($e-getCode() 23000) { Response::error(您已经报名过该活动请勿重复提交); } throw $e; }用异常捕获把重复报名直接拦截在数据库层比任何应用层判断都稳。6. 后台管理分页、搜索与 Excel 导出报名数据一旦多了后台列表就没有捷径可走必须分页必须能按活动筛选必须能导出 Excel。这节是运营每天都会打开的功能体验好不好直接影响他们会不会来烦你。6.1 后台列表分页与状态筛选列表页的 SQL 需要根据搜索条件动态拼接参数但这里有一个安全细节条件可以动态拼值不能直接拼进 SQL。?php $where []; $params []; if (!empty($_GET[activity_id])) { $where[] s.activity_id ?; $params[] (int)$_GET[activity_id]; } if (isset($_GET[status]) $_GET[status] ! ) { $where[] s.status ?; $params[] (int)$_GET[status]; } $whereSql $where ? WHERE . implode( AND , $where) : ; $page max(1, (int)($_GET[page] ?? 1)); $pageSize 20; $offset ($page - 1) * $pageSize; $rows $db-fetchAll( SELECT s.*, a.title AS activity_title FROM signup s LEFT JOIN activity a ON s.activity_id a.id {$whereSql} ORDER BY s.id DESC LIMIT {$pageSize} OFFSET {$offset}, $params ); $total $db-fetchOne( SELECT COUNT(*) AS cnt FROM signup s {$whereSql}, $params )[cnt]; $totalPages (int)ceil($total / $pageSize);注意LIMIT和OFFSET是直接拼进 SQL 的但它们的值都已经通过(int)强转所以不会产生注入风险。真正绑定的是用户的输入条件这是安全边界。6.2 Excel 批量导出CSV 和 XLSX 的选择日常报名名单导出其实CSV 比 XLSX 更实用。CSV 格式简单、不依赖任何第三方库、任何 Excel/WPS 都能打开。缺点是打开时如果你用 Excel 直接双击中文可能乱码因为 Excel 默认按 GBK 解析。解决中文乱码有两种思路一种是在 CSV 文件开头加\xEF\xBB\xBFBOM 标记Excel 看到 BOM 就会按 UTF-8 解码另一种是导出时把所有内容转成 GBK 编码。推荐第一种因为 UTF-8 兼容性更好后续如果要把数据导入其他系统也方便。?php header(Content-Type: text/csv; charsetUTF-8); header(Content-Disposition: attachment; filenamesignup_ . date(YmdHis) . .csv); $output fopen(php://output, w); // 加 BOM 解决 Excel 打开乱码 fwrite($output, \xEF\xBB\xBF); fputcsv($output, [ID, 活动名称, 姓名, 手机号, 邮箱, 备注, 报名时间]); foreach ($rows as $row) { fputcsv($output, [ $row[id], $row[activity_title], $row[name], $row[phone], $row[email], $row[remark], $row[created_at], ]); } fclose($output); exit;如果运营明确要求.xlsx格式那就引入 PhpSpreadsheet 库写起来要复杂一些但好在它直接把单元格样式、日期格式都处理好了。先确认对方“Excel”到底指什么很多人实际只需要 CSV用 CSV 省一大堆依赖和内存开销。6.3 报名状态管理与统计报表后台把报名状态分成待审核、通过、拒绝三种运营可以修改。每次变更状态时我会顺带记录一条日志到表格signup_log防止运营误操作后不知道是谁改的。虽然小型系统里这一步不是必须的但真出了纠纷时一条日志能省很多沟通成本。统计报表方面最常用的指标是“每个活动的报名人数”?php $stats $db-fetchAll( SELECT a.id, a.title, COUNT(s.id) AS total_cnt, SUM(CASE WHEN s.status 0 THEN 1 ELSE 0 END) AS pending_cnt, SUM(CASE WHEN s.status 1 THEN 1 ELSE 0 END) AS approved_cnt FROM activity a LEFT JOIN signup s ON a.id s.activity_id GROUP BY a.id ORDER BY a.id DESC );运营后台首页展示这个统计表一眼就能看到每个活动的报名热度比翻明细高效太多。7. 把源码打包成 zip 发布Linux 命令与部署踩坑项目交付要打包成 zip 包这个环节看起来简单其实坑不少。打包不是右键压缩就完事要保证对方解压后能直接运行。7.1 Linux 环境的打包与解压命令如果你的服务器或本地是 Linux最常用的两条命令是# 压缩把 activity-signup 目录打包成 activity-signup.zip zip -r activity-signup.zip activity-signup/ # 排除不需要的目录/文件比如本地测试产生的上传文件、日志 zip -r activity-signup.zip activity-signup/ -x activity-signup/uploads/* activity-signup/logs/* *.DS_Store # 解压 unzip activity-signup.zip -d /www/wwwroot/unzip解压时如果提示没有命令先安装unzip包。我见过太多人在服务器上折腾半天最后发现连解压工具都没装。如果服务器上没有zip可以先用tar打包再传到本机解压也可以直接用 PHP 的ZipArchive类写个在线打包脚本。但最省心的还是让服务器厂商支持zip因为 zip 是跨平台通用性最高的压缩格式Windows、macOS、Linux 开箱即用。7.2 file is not a zip file 类报错到底怎么排查很多用户反馈下载 zip 源码后本地解压弹出一堆报错“file is not a zip file”、“failed to copy spatial iop zip”或“invalid zip archive: could not find eocd”。以我排查过的案例来看造成这种报错的原因基本是这四类文件没下载完整。下载过程中断网、被浏览器替换成了 .html 错误页都会导致 zip 文件不完整。排查方式看文件大小是否和服务器一致拿md5sum对比两边 MD5。md5sum activity-signup.zip用文本编辑器打开过 zip。很多人收到 zip 后想“先看看内容”用记事本打开过再保存zip 头被破坏PK文件头直接没了。检查方式用xxd activity-signup.zip | head -n 1正常 zip 开头应该是50 4B 03 04对应字符就是PK。服务器返回了错误页。有些环境访问 zip 下载链接先被防火墙拦截返回的是 HTML 错误提示但保存的时候还是存成了 .zip。打开看到的全是 HTML 标签。这种情况让你身边的人换网络重新下载或者用浏览器开发工具看响应头确认Content-Type是否为application/zip。压缩包是用特殊算法压缩的。比如用了 7z 的高压缩率模式、加了密码、或使用了非常规扩展记录老版本解压工具不兼容就可能报找不到 EOCD 记录。解决方式是统一用标准 zip 模式或让对方更新解压软件。这里我真心建议大家在自己的文档里写上“请使用 WinRAR 或者 7-Zip 完整版解压不要直接用系统自带的占用较少的压缩工具”能减少一半的投诉。7.3 部署时最容易踩的目录权限问题代码解压后最常遇到 500 错误或者上传失败原因基本都是目录权限不对。# 从命令行解压后权限会被设置成当前用户但 Web 服务器用户通常是 www chown -R www:www /www/wwwroot/activity-signup/ chmod -R 755 /www/wwwroot/activity-signup/ chmod -R 775 /www/wwwroot/activity-signup/uploads/ chmod -R 775 /www/wwwroot/activity-signup/logs/uploads和logs目录需要 Web 进程有写权限否则报名时附件保存失败页面却只显示“文件保存失败”排查半天才发现是权限问题。如果你用的是 1Panel 这类面板部署在面板里直接把目录所有者改为www用户即可。7.4 部署环境从虚拟主机到 Docker我最初在虚拟主机上部署只要求 PHP 5.6、PDO 扩展、MySQL。现在很多人用 Docker这块我也简单提一下可以把项目打包成镜像或者用 docker-compose 一键拉起 php-fpm nginx mysql redis 四个容器。但报名系统数据量不大没必要上这么重的方案离线部署时用 1Panel 面板直接建 PHP 站点、创建 MySQL 库比 Docker 更容易复制给各种环境。8. PHP 错误处理与安全加固别让你的代码成为别人的 CTF 例题网上有个流行词叫“CTF 的 web 题”本质就是通过常见的 PHP 代码漏洞出题。我不想危言耸听但报名系统收集了大量用户手机号、邮箱如果安全没做好后果远远超过一次比赛。8.1 错误处理线上不显示错误线下完整看日志开发时错误要尽量完整展示但上线后必须把display_errors关掉只保留log_errors。?php // config.php if ($config[debug]) { error_reporting(E_ALL); ini_set(display_errors, 1); } else { error_reporting(E_ALL); ini_set(display_errors, 0); ini_set(log_errors, 1); ini_set(error_log, __DIR__ . /../logs/php_error.log); }为什么这么重要因为 PHP 错误信息里经常带出文件路径、数据库表名、SQL 片段攻击者拿到这些信息等于提前拿到了半张地图。只输出到日志文件既不影响排查也不泄露信息。8.2 常见的六个安全细节SQL 注入全部用 PDO 预处理绑定参数。我在前面 PDO 封装类里已经示范了不要偷懒拼 SQL。XSS 跨站脚本所有输出到 HTML 的数据都要转义。用htmlspecialchars($value, ENT_QUOTES, UTF-8)。很多老教材用htmlentities但htmlspecialchars更轻量对双引号和单引号都处理够用。这个话题在 PHP 里对应 “gt lt” 转义本质就是这些字符不直接输出给浏览器的解析器执行。CSRF 跨站请求伪造后台每个操作表单加 token已经在前文说明了。会话固定与劫持登录成功用session_regenerate_id(true)session.cookie_httponly开启防止 JS 读取 session cookie。ini_set(session.cookie_httponly, 1);文件上传漏洞扩展名白名单、MIME 校验、随机文件名、上传目录隔离前文已经完整覆盖。弱口令运营后台账号密码不要用 admin / 123456密码强度至少 8 位以上含大小写和数字。password_hash生成哈希时默认算法会更新不需要手动指定。8.3 一个容易被忽略的JSONP 接口安全如果后续要把报名数据开放给第三方或者做成前后端分离跨域问题就躲不开。热搜里有“php 跨域 jsonp”常见做法是 JSONP。但 JSONP 有一个安全缺陷它只能返回简单数据容易成为 XSS 注入点。?php $callback $_GET[callback] ?? ; if (!preg_match(/^[a-zA-Z_][a-zA-Z0-9_]*$/, $callback)) { header(HTTP/1.1 400 Bad Request); exit(invalid callback); } $data [code 0, data [total 128]]; header(Content-Type: application/javascript; charsetutf-8); echo $callback . ( . json_encode($data, JSON_UNESCAPED_UNICODE) . );这里最关键的是对callback参数做正则白名单校验。如果不校验攻击者把 callback 改成恶意代码返回的内容就会变成可直接执行的脚本用户浏览器一旦访问就中招。如果现代浏览器环境更推荐用标准的 CORS 方案header(Access-Control-Allow-Origin: https://allowed-domain.com); header(Access-Control-Allow-Headers: Content-Type); header(Access-Control-Allow-Methods: GET, POST, OPTIONS);能固定 Origin 就固定不要随意写*否则任何站点都能跨域调你的数据接口。9. 进阶方向从原生 PHP 到 ThinkPHP 与更多能力扩展项目交付后我从没觉得它就定型了。后面接到的需求往往会在原生 PHP 结构上越滚越庞杂这是很自然的演进过程。9.1 什么时候迁移到 ThinkPHP 这类框架我遇到的几个信号值得提一下项目从单活动变成多活动、多用户角色权限逻辑复杂到后台每个页面都需要单独判断。需要引入队列、Redis、定时任务原生 PHP 手工管理这些太痛苦。团队不再是单人维护新同事需要一眼看懂项目结构框架约定比自定义结构更容易培训。ThinkPHP 3.2.3 在很长一段时间里是很多国产项目的首选它的体系是“快速而简单的 OOP PHP 框架”适合快速开发。但 3.2.3 版本比较老新项目我更建议用 ThinkPHP 6 或 Laravel。老项目如果跑在旧环境上迁移要稳扎稳打别指望一步到位。9.2 扩展一个报名 API 接口如果活动报名要嵌入到微信公众号或小程序里原生页面不一定够用。这时候可以把“查询活动列表”和“提交报名”封装成 API?php // api/activity_list.php $db Db::getInstance(); $rows $db-fetchAll( SELECT id, title, start_time, end_time, max_num FROM activity WHERE status 1 ORDER BY id DESC ); foreach ($rows as $row) { $row[start_time] date(Y-m-d H:i:s, strtotime($row[start_time])); $row[end_time] date(Y-m-d H:i:s, strtotime($row[end_time])); } unset($row); header(Content-Type: application/json; charsetutf-8); echo json_encode([code 0, data $rows], JSON_UNESCAPED_UNICODE);API 层要额外做签名验证、频率限制。最简单的方式是给客户端发放 app_key app_secret请求时加时间戳和签名服务端验签后放行。9.3 大数据量阶段从 MySQL 到缓存层如果单场活动报名人数达到几万甚至几十万实时查询报名状态会越来越慢。这时可以在报名接口里引入 Redis 做计数器把“是否满员”的判断放到 Redis 上再用队列异步写 MySQL。但这个阶段原生 PHP 单体项目基本就接近极限了。我的建议是不要提前优化。几百人报名的活动MySQL 加索引完全够用等确实出现慢查询了再上 Redis、上队列都是顺理成章的事。最后再分享一个我在实操中总结的小技巧打包源码时把自己本地的.env、uploads、logs、数据库备份文件全部排除掉再在压缩包根目录放一个README.txt写清楚 PHP 版本要求、伪静态规则、数据库导入方法、默认后台账号。这份说明能帮接手的人省下很多重复提问也让你少接很多深夜求助电话。报名系统的写法并不神秘核心就是一张表、一个表单、一个后台、一个 PDO 封装类。但真正做得让人省心靠的是那些在需求文档里不会写的细节重复报名怎么防、上传文件怎么校验、部署之后怎么改权限、zip 包为什么解不开。把这些问题都在交付前处理好这个项目才算真正“能用”。本文还有配套的精品资源点击获取
返回列表