
简介这是一套基于表白墙系统二次开发的在线留言系统源码面向Web开发初学者、毕设学生以及需要快速搭建留言互动功能的开发者。代码全开源完整覆盖前端交互、后端处理、数据库设计和部署配置等环节可帮助理解一个留言系统的整体运作逻辑。资源共214个文件主要包含PHP服务端脚本、JavaScript逻辑文件、CSS样式、HTML页面以及SQL数据库配置文件压缩包约16.77MB目录结构清晰既有可直接运行的站点文件也便于二次开发时定位与修改。目前已有626人浏览学习。通过研读源码可以掌握匿名留言、用户认证、留言排序展示、数据校验及基础权限控制等关键功能的实现方式同时也能学习到开源项目的代码组织与部署维护思路适合用于课程设计、毕业设计或技术练习。1. 从“能留言”到“敢上线”全开源留言系统的真实门槛如果你以为在线留言系统的开发难点是“写个表单、存个库、列表展示”那大概率会在上线第一周就被垃圾广告、SQL注入和XSS攻击教做人。这个标题里真正值钱的部分不是“最新”也不是“全开源”而是“留言系统”三个字背后的完整链路用户提交、内容审核、XSS过滤、接口防刷、数据归档以及一套能跑在普通服务器上还不至于被爬虫打垮的表结构设计。开源的PHP生态里不缺成型方案但把源码拿到手之后能不能用起来、改得动、扛得住才是从业者真正要评估的。这篇内容会沿着一条可复现的主线走先定技术选型和数据表设计再写核心接口和前端嵌入方式然后落到Nginx部署和容器化最后补上高流量场景下的缓存与归档优化。过程中会用ThinkPHP 8作为示例框架因为它在国内开源部署里约定俗成路由、中间件、验证码和数据库迁移都齐全也是“开源留言系统源码”这个搜索意图下最常见的落地框架。新手可以照步骤跑通熟手可以直接取参数和边界条件去对照自己的实现。2. 选型先于代码为什么“PHPMySQLRedis”是开源留言系统的公约数2.1 技术栈选择的三个硬约束留言系统看起来轻但选型时有三条硬约束第一部署环境不确定用户可能是虚拟主机、也可能是Docker集群因此语言运行时必须普及PHP 8.x、MySQL 5.7、Redis 6.x就是目前兼容面最广的组合第二代码需要能被二次修改纯前端方案比如用Vue全家桶加Node后端不是不行但面向“开源建站”场景PHP能直接嵌入现有CMS修改成本最低第三安全过滤逻辑必须在服务端完成无论前端怎么校验服务端都要有独立的过滤层而PHP的filter_var、htmlspecialchars和preg_replace组合拳已经足够应付绝大多数留言内容。这套组合里Redis不是可选项。留言系统的核心读模型是“最近留言列表”高频场景下MySQL要扛的其实是热点行的重复查询用Redis做两级缓存列表缓存计数缓存能把数据库压力降一个数量级。同时Redis的INCREXPIRE还能做提交频率限制这是纯MySQL方案实现起来非常别扭的功能。2.2 数据表设计主表、回复表和归档表的三层结构大部分开源留言系统只建一张表字段从id、content、create_time一路堆到reply。这在数据量低于一万条时没问题但一旦进入运营状态单表全量SELECT配合ORDER BY id DESC会很快出现慢查询。更合理的设计是拆成三张表messages存主留言message_replies存回复关系message_archive存历史冷数据。设计messages表时有两个隐藏要点一是status字段用TINYINT而不是ENUM因为后续可能会衍生出“待审、通过、拒绝、删除、置顶”五种以上状态ENUM改动需要重建表二是必须存ip_hash而不是原始IP用SHA2截断保留前32位既能做去重又不必触碰隐私合规的敏感红线。CREATE TABLE messages ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, content TEXT NOT NULL COMMENT 留言内容已过滤的富文本, nickname VARCHAR(64) NOT NULL DEFAULT COMMENT 昵称, contact VARCHAR(255) NOT NULL DEFAULT COMMENT 联系方式可选, ip_hash CHAR(32) NOT NULL DEFAULT COMMENT IP哈希用于频率控制, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审 1通过 2拒绝 3删除, like_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 点赞数, parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 父留言ID0为顶层, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME DEFAULT NULL COMMENT 审核时间, PRIMARY KEY (id), KEY idx_status_time (status, create_time DESC), KEY idx_parent (parent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT在线留言主表;status和create_time的联合索引是这套表结构里最重要的一个细节。因为列表页的默认查询永远是“已通过的留言按时间倒序”联合索引能让这个查询走索引覆盖不需要回表排序。parent_id和content整体走utf8mb4别为了省空间换utf8否则罕见汉字和emoji会直接截断报错。2.3 路由与目录约定拿到开源代码先看什么从开源仓库拉下来代码后第一件事不是配数据库而是确认目录结构是否符合Composer自动加载规范。ThinkPHP的app目录下通常按controller、model、validate三层组织留言系统的核心文件就落在app/api/controller/Messages.php、app/api/validate/MessageValidate.php和app/api/model/MessageModel.php里。我一般会先全局搜索sleep(、eval(、base64_decode(函数确认源码没有代码后门。开源留言系统是恶意代码重灾区因为大多数站长会用它做企业官网的公开反馈入口权限给得比较松。删掉所有不相关的压缩混淆文件只保留控制器、模型、视图和静态资源再把runtime目录权限收紧这是任何开源PHP源码落地第一天的标准动作。3. 核心接口落地提交、拉取、审核三段式如何写得稳3.1 留言提交的完整校验链留言提交接口要拦的不是人是脚本。校验链由五层组成表单令牌CSRF Token验证、频率限制Redis滑动窗口、内容长度与类型过滤、昵称与联系方式清洗、以及最终的入库前htmlspecialchars转义。前端可以不做任何校验后端必须每层都过。?php declare(strict_types1); namespace app\api\controller; use think\facade\{Db, Cache, Validate}; use think\response\Json; use app\api\validate\MessageValidate; class Messages { public function submit(): Json { // 第一层基础参数校验 $data request()-post(); $validate new MessageValidate(); if (!$validate-check($data)) { return json([code 400, msg $validate-getError()]); } // 第二层IP频率限制10分钟最多提交3条用Redis实现 $ipHash sha1(request()-ip()); $redisKey msg:rate: . $ipHash; $count Cache::get($redisKey, 0); if ($count 3) { return json([code 429, msg 提交太频繁请稍后再试]); } Cache::set($redisKey, $count 1, 600); // 第三层内容清洗去掉危险标签但保留换行 $content trim($data[content]); $content preg_replace(/script.*?\/script/is, , $content); $content htmlspecialchars($content, ENT_QUOTES, UTF-8); // 第四层入库状态默认待审核 $messageId Db::name(messages)-insertGetId([ content $content, nickname mb_substr($data[nickname], 0, 16), contact mb_substr($data[contact] ?? , 0, 100), ip_hash substr($ipHash, 0, 32), status 0, parent_id (int)($data[parent_id] ?? 0), create_time date(Y-m-d H:i:s) ]); return json([code 0, msg 提交成功等待审核, data [id $messageId]]); } }Cache::set($redisKey, $count 1, 600)这行是最容易写错的地方如果count已经是2写入3并重置过期时间这个窗口期语义是“任意10分钟内最多3条”不是“每秒3条”对普通提交足够同时能拦掉绝大多数的灌水脚本。第三层别看只有两行htmlspecialchars是双保险即使前面preg_replace漏了变种写法入库前也会把尖括号全部转义存进MySQL的是纯文本。3.2 列表拉取游标分页替代传统的OFFSET分页留言列表在数据量稍大后最典型的性能杀手是LIMIT 100000, 20MySQL要扫描完前十万行才能返回目标行。改用游标分页后只依赖索引定位没有偏移扫描性能是稳定的。public function list(): Json { $cursor (int)request()-param(cursor, 0); // 上一页最后一条ID $limit min((int)request()-param(limit, 20), 50); $data Db::name(messages) -where(status, 1) -where(id, , $cursor) -order(id, asc) -limit($limit) -select() -toArray(); $nextCursor 0; if (count($data) $limit) { $nextCursor (int)end($data)[id]; } return json([code 0, data $data, next_cursor $nextCursor, has_more $nextCursor 0]); }此处返回给前端的格式刻意用了next_cursor而不是page因为游标天然适合“加载更多”式的留言展示前端拿到next_cursor直接作为下一次请求参数接口不需要感知总页数。limit做了50封顶防止有人把limit调大拖慢接口这是开放接口的常见边界配置。如果前端产品硬要页码跳转那就只能在messages表上加一个自增seq字段并在写入时用事务维护复杂度会明显上升普通场景不推荐。3.3 后台审核批量操作的幂等设计审核接口是管理后台的一部分关注点不是性能而是误操作风险。批量审核必须做状态幂等同一个id被重复提交审核请求结果不能变化。public function audit(): Json { $ids request()-post(ids/a); $status (int)request()-post(status); if (empty($ids) || !in_array($status, [1, 2, 3], true)) { return json([code 400, msg 参数错误]); } $affected Db::name(messages) -whereIn(id, $ids) -where(status, 0) // 只允许待审核状态的记录流转 -update([ status $status, audit_time date(Y-m-d H:i:s) ]); // 审核通过后清除对应缓存 foreach ($ids as $id) { Cache::delete(msg:detail: . $id); } return json([code 0, msg 已处理{$affected}条]); }where(status, 0)是关键过滤条件保证已审核过的记录不会被再次覆盖配合$affected返回值可以精确知道本次实际修改了多少条前端就能安全提示“成功3条2条已被处理”。清洗后的留言内容在这个阶段不再二次转义因为入库时已经是安全的文本形态重复转义会导致页面显示lt;这样的乱码字符。3.4 验证码机制图形验证码与行为验证码的取舍很多人把开源留言系统的防刷全部押在图形验证码上恕我直言这是体验和效果双输的做法。图形验证码只对人工有效对OCR脚本的拦截率不到70%而且严重降低移动端提交体验。更合理的组合是对匿名用户启用极验或腾讯云验证码这类行为验证对登录用户可以复用你现有的用户体系直接放开再结合Redis频率限制兜底。如果项目不打算接入第三方验证码SDK想保持全开源的纯粹性那么至少要做到“三次失败后强制验证码”。在服务端记录msg:failTimes:{ip}机会计数连续两次提交被审核拒绝后第三次提交要求携带一个由Captcha库生成的校验码。常见做法是提前把校验码存入Redis并设置2分钟有效期提交时比对并立刻删除防止重放攻击。4. Nginx与Docker部署从源码到公网可访问的完整闭环4.1 ThinkPHP在Nginx下的伪静态配置拿到源码后直接扔进Nginx跑很容易出现控制器访问404的问题这是最典型的部署失败原因。ThinkPHP的路由入口是public/index.phpNginx必须把所有非静态资源的请求都转发到入口文件。server { listen 80; server_name guestbook.example.com; root /var/www/guestbook/public; index index.php index.html; charset utf-8; access_log /var/log/nginx/guestbook.access.log; error_log /var/log/nginx/guestbook.error.log; location / { try_files $uri $uri/ /index.php?s$uri$args; } location ~ \.php$ { fastcgi_pass unix:/run/php/php8.2-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_read_timeout 300; fastcgi_send_timeout 300; } location ~* \.(jpg|jpeg|gif|png|css|js|ico|svg|woff2)$ { expires 30d; access_log off; try_files $uri 404; } }try_files $uri $uri/ /index.php?s$uri$args;这条指令是ThinkPHP路由能正常工作的核心它把所有不存在的文件、目录路径全部回退到入口文件。静态资源单独走一个location块并配置expires 30d避免PHP进程处理图片请求。PHP-FPM的sock路径要看实际环境是/run/php/还是/var/run/php/写错会直接报502这个踩坑率非常高频。4.2 安全响应头与CORS策略留言系统通常会被嵌入到不同域名的企业官网里前端请求接口时会触发跨域。直接在Nginx层统一配置跨域头比在每个PHP文件里写header()更集中、更好维护同时顺便收口X-Frame-Options防止留言页面被恶意站点以iframe方式套嵌。location /api { if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods GET, POST, OPTIONS; add_header Access-Control-Allow-Headers X-Requested-With, Content-Type, Authorization; add_header Access-Control-Max-Age 3600; return 204; } add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Credentials true; add_header X-Frame-Options SAMEORIGIN; add_header X-Content-Type-Options nosniff; add_header X-XSS-Protection 1; modeblock; add_header Content-Security-Policy default-src self; }Access-Control-Allow-Origin使用$http_origin变量而不是固定*是因为Allow-Credentials true时浏览器拒绝通配符必须回显具体来源。子域名数量多的时候建议把允许的域名列表写进Nginx的map块做一个白名单校验是相对严谨的跨域方案。注意上面的CORS配置只针对/api路径前缀生效好与前面的留言页面路径隔离。4.3 基于Docker Compose的一键编排生产环境用宝塔面板最省事但更可复现、更“开源原生”的方式仍然是Docker Compose。编排三个服务nginx做反向代理、php-fpm跑应用、mysql存数据。Redis也在其中但用内部端口不向公网暴露。version: 3.8 services: nginx: image: nginx:1.25-alpine ports: - 8080:80 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./www:/var/www:ro networks: - guestbook_net php: build: context: ./php dockerfile: Dockerfile volumes: - ./www:/var/www environment: - DB_HOSTmysql - DB_NAMEguestbook - DB_USERguestbook - DB_PASSWORDyourpassword - REDIS_HOSTredis networks: - guestbook_net mysql: image: mysql:8.0 environment: - MYSQL_ROOT_PASSWORDrootpassword - MYSQL_DATABASEguestbook - MYSQL_USERguestbook - MYSQL_PASSWORDyourpassword volumes: - mysql_data:/var/lib/mysql command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci - --default-authentication-pluginmysql_native_password networks: - guestbook_net redis: image: redis:7-alpine command: redis-server --requirepass redispass networks: - guestbook_net volumes: mysql_data: networks: guestbook_net: driver: bridgePHP容器建议基于官方php:8.2-fpm镜像并在Dockerfile里安装pdo_mysql、redis和opcache扩展这些是ThinkPHP的硬性依赖。MySQL容器启动时会自动执行/docker-entrypoint-initdb.d/目录下的.sql脚本可以把第二章的建表SQL挂到这个目录下实现首次启动时就自动化建表不用额外连进容器执行SQL。4.4 上线前的文件权限与配置文件检查容器环境之外如果是传统LNMP架构最容易忽略的是runtime目录可写权限。ThinkPHP默认会把编译缓存、日志和会话文件写到runtime目录这个目录需要给www用户PHP-FPM运行用户写权限但又不能让这个目录被浏览器直接访问。chown -R www:www /var/www/guestbook/runtime chmod -R 750 /var/www/guestbook/runtime find /var/www/guestbook -type f -name *.sql -exec rm -f {} \; chmod 644 /var/www/guestbook/.env.env文件建议保留644权限里面写的是数据库密码和Redis密码不要让PHP以外的进程修改它。有一些开源源码会带一个install/目录安装完成后必须删除否则攻击者可以重新执行安装程序覆盖配置甚至注入恶意代码。5. JWT鉴权与接口防刷给留言系统加上访问控制层5.1 用户态识别与JWT签发流程纯公开的留言系统用IP限流已经够用但如果做成了包含“我的留言”“我的回复”功能的产品形态就必须引入登录态。开源系统里最常见的实现是HTTP Bearer Token使用firebase/php-jwt库签发和验证整个包体只有几百行比引入OAuth整套体系轻得多。use Firebase\JWT\JWT; use Firebase\JWT\Key; public function issueToken(int $userId): string { $payload [ uid $userId, iat time(), exp time() 7200, // 2小时有效期 jti md5(uniqid(jwt, true)) // JWT ID用于吊销 ]; return JWT::encode($payload, env(JWT_SECRET), HS256); } public function verifyToken(string $token): array { try { return (array)JWT::decode($token, new Key(env(JWT_SECRET), HS256)); } catch (\Exception $e) { return []; } }JWT签发后存在客户端服务端不需要维护会话表。但这里有一个容易踩的坑JWT未过期之前是“无法主动失效”的如果用户被拉黑其手里的旧Token依然有效。因此在留言提交接口里要做一层双重校验先验JWT有效性再查Redis里的用户黑名单ID集合黑名单优先级高于Token过期时间。jti字段专门用于客服在后台操作“强制下线”时加入Redis黑名单。5.2 滑动窗口频率限制前面留言提交接口用INCR加EXPIRE的方式做限流语义是“固定窗口”存在一个边界问题用户在10分59秒时提交了3条又在紧接着的下一个窗口提交3条两个窗口衔接处会形成短时间的每秒1条请求。要彻底消除这个窗口切换漏洞需要用滑动窗口。function rateLimit(string $key, int $max, int $windowSeconds): bool { $redis Cache::handler(); $now microtime(true); $minTime $now - $windowSeconds; // 移除窗口外的旧记录 $redis-zRemRangeByScore($key, 0, $minTime); // 统计当前窗口有效请求数 $current $redis-zCard($key); if ($current $max) { return false; } // 加入新记录并设置整体过期时间 $redis-zAdd($key, $now, (string)$now . # . uniqid()); $redis-expire($key, $windowSeconds); return true; }用ZSET做滑动窗口里边的每个元素是一个时间戳加随机后缀zRemRangeByScore把窗口外的值清掉剩下的zCard就是当前窗口的请求数。这条方案每层多用了两次Redis操作但对留言这种低QPS场景效率可以忽略换来的是限流判定非常平滑。生产环境可以把它封装成中间件在App.php或者middleware.php里全局注册对/api/submit和/api/like两个接口生效。5.3 基于签名防篡改的API网关层方案再往前一步如果这套留言系统要被两个以上站点共用比如同时挂载到企业官网和微信小程序就建议引入腾讯云API网关或者自建签名校验层。常用做法是给每个调用方分配app_id和app_secret请求参数按字典序拼接后加app_secret做HMAC-SHA256加密生成sign字段服务端用同样的规则校验。function verifySign(array $params, string $secret): bool { $sign $params[sign] ?? ; unset($params[sign]); ksort($params); $str urldecode(http_build_query($params)); $calc hash_hmac(sha256, $str, $secret); return hash_equals($calc, $sign); }hash_equals做比较的原因是它执行时间恒定不会被时序攻击猜到正确的签名值。参数里要包含timestamp字段并限制与服务器时间差不超过5分钟防止旧请求被录制重放。app_secret只存放在服务端配置里前端拿到的是app_id签名计算也必须在前端完成这也是该方案实施起来稍微复杂的地方。6. 留言数据的冷热分离与归档策略6.1 表分区还是定时归档这是个判断题数据量到了一百万行之后DELETE语句清理旧留言会锁表导致在线业务抖动。很多工程师第一反应是做表分区但留言系统的查询模式是“按时间倒序”数据写入也基本是追加型这时候的常规做法是定时归档而非分区。把status3已删除且create_time早于半年的数据迁移到message_archive表再用一个事件调度器每日凌晨执行。INSERT INTO message_archive (id, content, nickname, contact, ip_hash, status, create_time, audit_time) SELECT id, content, nickname, contact, ip_hash, status, create_time, audit_time FROM messages WHERE status 3 AND create_time DATE_SUB(NOW(), INTERVAL 6 MONTH) LIMIT 2000; DELETE m FROM messages m JOIN message_archive a ON a.id m.id WHERE m.status 3;为什么限制LIMIT 2000因为一次性删除几万行会让InnoDB的purge线程长时间持有内部锁期间数据库吞吐量显著下降。每次只处理2000条利用Linux Cron每小时调度一次既控制了锁范围又能在几天内消化完积压数据。message_archive表不使用外键因为归档后主表已经删除了对应记录外键约束在这里反而是累赘。6.2 缓存更新策略主动失效还是被动过期列表缓存如果只靠Redis的EXPIRE被动过期用户会在过期瞬间感受到明显的卡顿——因为缓存击穿时瞬间所有请求都打到数据库。主动失效的时机是后台审核通过一条新留言时立刻删除对应的列表缓存键。这样列表页面永远在“审核通过”事件发生时刷新而不是靠定时器。public function clearListCache(): void { Cache::delete(msg:list:page1); Cache::delete(msg:list:page2); Cache::delete(msg:count:all); }如果有多台服务器部署同一套代码需要额外引入消息队列广播失效事件。但绝大多数开源留言系统是单机部署用这个方法已经能保证缓存与数据的一致性。msg:count:all这个计数缓存别直接INCR因为删除时会波动重新从数据库COUNT(*)一次就好毕竟这个数字不需要精确到个位。6.3 数据冷备与恢复验证归档表建好后“备份”这件事容易被忽略。最实用的策略是MySQL按库每天全量备份加Binlog增量备份。备份脚本在凌晨低峰执行用mysqldump打tar包保留最近7天即可。#!/bin/bash DATE$(date %F) mysqldump -u backup -ppassword \ --single-transaction \ --routines \ --triggers \ --databases guestbook /data/mysql_backup/guestbook_$DATE.sql tar czf /data/mysql_backup/guestbook_$DATE.sql.tar.gz \ -C /data/mysql_backup guestbook_$DATE.sql find /data/mysql_backup -name *.tar.gz -mtime 7 -delete--single-transaction对于InnoDB表是关键参数它利用MVCC在一个快照里做一致性导出不会锁住正在写入的留言业务。恢复验证不是简单看备份文件存在每个月要抽一天在测试机导入备份并随机查几条留言记录比对时间戳和内容是否完整这一步能提前发现备份流程静默失效的隐患。6.4 全文搜索的轻量方案与边界留言内容积累多了后台管理可能需要对内容做关键词搜索。多数开源系统直接用WHERE content LIKE %关键词%数据量上到十万行后这种写法会全表扫描超时几乎必然。这里有一条非常划算的中间路在Redis里为每条留言的content生成倒排索引。public function buildInvertedIndex(int $messageId, string $content): void { $words preg_split(/[\s,。.!?、;]/u, $content, -1, PREG_SPLIT_NO_EMPTY); foreach ($words as $word) { $redisKey msg:idx: . mb_strtolower($word); Cache::handler()-sAdd($redisKey, $messageId); Cache::handler()-expire($redisKey, 86400 * 30); } }这套方案适合中文长尾词有限的纯文本留言查询时把关键词拆开再取交集。止损边界是如果留言内容超过两千字分词质量下降明显此时更应改用Elasticsearch或Manticore Search做正经的全文检索而不是在Redis层面硬扛。对于“开源留言系统源码”这个定位的项目把倒排索引作为缓存加速层是性价比最高的策略既不引入Java重型依赖又能让后台搜索响应时间从秒级降到毫秒级。本文还有配套的精品资源点击获取