
简介一份基于ThinkPHP框架开发的活动报名小程序源码面向需要快速搭建活动发布与报名管理场景的PHP开发者也可用于校园社团、企业内训、线下聚会等轻量级报名需求。资源包含小程序前端页面与后台管理逻辑用户端可发布活动、浏览并提交报名后台支持对报名数据进行统一查看与管理整体流程基本可用。同时短信验证码环节目前采用后台生成后直接返回前端的实现方式尚未对接正规短信服务商资源描述也明确提示了这一待完善项便于使用者评估二次开发成本与改造方向。压缩包约10.91MB官方未提供具体文件统计目前已有385人学习浏览比较适合作为ThinkPHP与小程序前后端协作开发的实战参考重点可学习活动管理模块设计、报名流程状态处理以及后台接口与小程序的数据交互方式。1. 拿到手先拆开这个ThinkPHP报名小程序到底能干什么我最近收到一份“活动报名小程序源码”技术栈是ThinkPHP前端是微信小程序原生写法。活动发布、报名登记、后台管理都有测试下来流程能跑通。但注册环节的短信验证码处理让我眼前一亮——后端把验证码直接响应给小程序前端等于把答案写在试卷封面上。这种代码可以改但得先吃透它的整体结构。这篇博客就顺着“活动发布 → 用户注册 → 报名 → 后台管理”这条链路拆穿每个模块的代码位置、数据流和需要加固的点适合正在接ThinkPHP小程序单、或者想在报名类项目里快速落地的PHP开发者。2. 后端骨架活动发布与报名管理的数据表和控制器设计2.1 数据表设计活动、报名、用户三张表就够了先看数据库这是理解源码的第一个入口。大部分活动报名类项目不需要过度设计三张核心表activity保存活动signup保存报名记录user保存注册用户。源码里通常还有一张admin表那是后台管理员的和业务解耦。下面以最常见的字段为例CREATE TABLE activity ( id int(11) NOT NULL AUTO_INCREMENT, title varchar(100) NOT NULL COMMENT 活动标题, cover varchar(255) DEFAULT COMMENT 封面图, location varchar(255) DEFAULT COMMENT 地点, start_time int(11) DEFAULT 0 COMMENT 开始时间戳, end_time int(11) DEFAULT 0 COMMENT 结束时间戳, quota int(11) DEFAULT 0 COMMENT 报名名额0不限制, status tinyint(1) DEFAULT 1 COMMENT 1上架 0下架, create_time int(11) DEFAULT 0, PRIMARY KEY (id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT活动表;2.1.1 活动表字段与索引说明start_time和end_time使用 int 时间戳而不是 datetime这是 PHP 项目里常见的做法避免时区转换麻烦。quota为 0 表示不限名额后台表单里要判断是否为 0否则会漏掉“不限”这个语义。status控制前端展示但真正判断活动是否可报名还需要在接口里同时校验当前时间和start_time/end_time的关系不能只信status。2.1.2 报名表和用户表的关系CREATE TABLE signup ( id int(11) NOT NULL AUTO_INCREMENT, activity_id int(11) NOT NULL COMMENT 活动ID, user_id int(11) NOT NULL COMMENT 用户ID, name varchar(50) DEFAULT COMMENT 报名人姓名, phone varchar(20) DEFAULT COMMENT 报名人手机号, remark varchar(500) DEFAULT COMMENT 备注, create_time int(11) DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_activity_user (activity_id,user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT报名表;UNIQUE KEY uk_activity_user是防重复报名的第一道保障数据库层面直接挡住同一用户对同一活动的二次提交。用户表再简单也必须包含id、phone、wechat_openid、reg_time这几个字段其中wechat_openid建议加唯一索引因为小程序登录靠code换 openid一个 openid 只能绑定一个用户。2.2 控制器分层API层和后台管理层的职责划分源码的 controller 目录一般分成api和admin两块入口都是 ThinkPHP 的路由或index.php分发。API 层面向小程序返回 JSON后台层面向浏览器返回模板或 JSON。我建议一开始就把两层分离不要混用。2.2.1 小程序API控制器示例?php namespace app\api\controller; use think\Controller; use think\Request; class Activity extends Controller { public function lists(Request $request) { $page (int)$request-param(page, 1); $size (int)$request-param(size, 10); $now time(); $list db(activity) -where(status, 1) -where(end_time, , $now) -page($page, $size) -select(); return json([code 0, data $list]); } }$request-param(page, 1)里的第二个参数是默认值前端不传 page 时不会报错。这段代码没有做异常捕获如果数据库连不上ThinkPHP 会输出一段 HTML 错误页小程序端就懵了。实际使用中要在外层挂一个全局异常处理或者把调试模式关掉。2.2.2 后台管理控制器的权限校验后台管理面不能裸奔至少要有登录态校验。我的习惯是在后台基类的构造函数里检查 session?php namespace app\admin\controller; use think\Controller; class Base extends Controller { protected function _initialize() { parent::_initialize(); if (!session(admin_id)) { $this-redirect(login/index); } } }注意 ThinkPHP 5 的_initialize方法在控制器继承时有坑子类如果定义了_initialize要记得调用parent::_initialize()。源码里如果用的是 TP3.2构造函数是__construct写法又不一样这是后面兼容性章节要展开的点。2.3 常用查询场景的SQL与ThinkPHP写法活动报名后台最常见的需求是“统计每个活动的报名人数”。用原生 SQL 是SELECT a.id, a.title, COUNT(s.id) AS signup_count FROM activity a LEFT JOIN signup s ON s.activity_id a.id GROUP BY a.id ORDER BY signup_count DESC;ThinkPHP 查询构造器写法对应$list db(activity) -field(activity.*, COUNT(signup.id) as signup_count) -join(signup, signup.activity_id activity.id, LEFT) -group(activity.id) -order(signup_count DESC) -select();这里的LEFT JOIN保证了没有报名记录的活动也能显示如果写成INNER JOIN零报名的活动会从列表里消失。COUNT(signup.id)只统计非空 id比COUNT(*)更严谨。后续要按日期筛选直接在 join 前加where条件即可注意分组字段必须出现在 select 中。3. 小程序端从注册到报名的完整调用链3.1 请求封装为什么需要统一处理登录态和错误码小程序前端代码里通常有个utils/request.js所有接口都走它。我看到源码里很多页面是wx.request直接裸调超时和错误码处理各写各的后面改起来极其痛苦。如果你准备接手这个项目第一步就是把请求封装成统一函数。// utils/request.js const BASE_URL https://yourdomain.com/api; function request(url, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: ${BASE_URL}${url}, method, data, header: { Content-Type: application/json }, success(res) { if (res.statusCode ! 200) { reject(new Error(网络异常${res.statusCode})); return; } if (res.data.code ! 0) { wx.showToast({ title: res.data.msg || 请求失败, icon: none }); reject(new Error(res.data.msg)); return; } resolve(res.data.data); }, fail(err) { wx.showToast({ title: 网络连接失败, icon: none }); reject(err); } }); }); } module.exports { request };这里约定后端返回格式是{ code: 0, msg: ok, data: ... }。code ! 0时直接弹 toast业务代码里就不用每个页面都处理错误分支了。注意BASE_URL必须和微信小程序管理后台里的 request 合法域名一致本地调试用开发者工具时也要勾选“不校验合法域名”才能访问 http 接口。3.2 发布活动与报名提交的前端代码3.2.1 发布活动的表单与提交逻辑发布活动页通常会出现在管理员小程序端。表单字段对应activity表的列提交时把日期时间转成时间戳再传给后端。// pages/publish.js const { request } require(../../utils/request); Page({ data: { form: { title: , location: , quota: 0, start_time: , end_time: } }, onInput(e) { const field e.currentTarget.dataset.field; this.setData({ [form.${field}]: e.detail.value }); }, async submit() { const form this.data.form; if (!form.title.trim()) { wx.showToast({ title: 请填写标题, icon: none }); return; } const payload { ...form, quota: parseInt(form.quota, 10) || 0, start_time: new Date(form.start_time.replace(/-/g, /)).getTime() / 1000, end_time: new Date(form.end_time.replace(/-/g, /)).getTime() / 1000 }; try { const res await request(/activity/add, POST, payload); wx.showToast({ title: 发布成功, icon: success }); } catch (err) { console.error(发布失败, err); } } });把日期字符串2025-05-01 10:00转时间戳时iOS 真机不认带连字符的日期格式需要先替换成斜杠。这是微信小程序跨端兼容的老坑哪怕后端没问题这行代码也能帮你少一次线上事故。3.2.2 报名按钮的防重复提交处理报名接口很怕用户连点两次数据库唯一索引能拦截但用户体验是第二次点击会直接报错。更好的做法是前端按钮加 loading 锁// pages/detail.js async signup() { if (this._submitting) return; this._submitting true; wx.showLoading({ title: 提交中 }); try { await request(/signup/add, POST, { activity_id: this.data.activityId }); wx.showToast({ title: 报名成功 }); } finally { this._submitting false; wx.hideLoading(); } }_submitting是挂载在 Page 实例上的属性不是 data 里的字段避免 setData 触发视图更新。注意finally在开发者工具和大部分真机环境都支持低版本基础库可能会报错保守方案是改成try/catch里手动复位。3.3 后台管理端的小程序端接口映射后台管理页面访问的接口分发在/admin下比如/admin/activity/list、/admin/activity/edit、/admin/signup/export等。如果你拿到源码后想二次开发建议先画一张接口表场景接口路径方法参数活动列表/api/activity/listsGETpage, size活动详情/api/activity/detailGETid发布活动/api/activity/addPOSTtitle, location, start_time, end_time, quota提交报名/api/signup/addPOSTactivity_id, name, phone, remark后台活动列表/admin/activity/indexGETpage, size后台导出报名/admin/signup/exportGETactivity_id后端要注意POST /api/activity/add必须做身份校验否则任何人都可以往库里塞垃圾活动。很多开源源码只校验了登录态没有校验角色结果是普通用户也能调用发布接口这是比短信验证码明文更严重的安全漏洞。4. 短信验证码的“明文回传”问题与安全改造4.1 问题复现抓包就能看到验证码源码里的注册流程大概是小程序输入手机号点击发送验证码后端先生成一个随机四位数字然后再把验证码以 JSON 的形式直接返回给前端。微信开发者工具里打开调试模式在 Network 面板能看到send_sms接口的响应体里清清楚楚写着code: 1234。这等于验证码短信不用看了直接抓包就能填进去。稍微懂点网络协议的人用 Charles 或者 Fiddler 一点都有。这样设计的原因通常是用户没有真正接入短信服务商只是模拟发送或者用了测试短信平台把验证码打出来方便调试。问题是线上不能这么玩。更隐蔽的坑是即使你接了阿里云短信代码里如果同时echo了验证码用于调试忘了删上线后同样裸奔。4.2 正确姿势验证码只走短信通道后端只存指纹正确的流程是后端生成验证码后存到缓存数据库表也行然后调用短信服务商接口把验证码发给用户接口响应里只返回“发送成功”的状态回传任何与验证码相关的字段都是错的。4.2.1 验证码生成与缓存存储?php namespace app\api\controller; use think\Cache; use think\Controller; class Sms extends Controller { public function sendCode() { $phone input(post.phone); if (!preg_match(/^1\d{10}$/, $phone)) { return json([code 1, msg 手机号格式不正确]); } $code $this-generateCode(); // 使用手机号作为缓存 key验证码作为 value5 分钟过期 Cache::set(sms_code_ . $phone, $code, 300); // 这里调用短信服务商例如阿里云短信 // sendSms($phone, $code); // 本地测试时可以开启日志 trace(验证码给 $phone 是 $code, debug); return json([code 0, msg 短信已发送]); } private function generateCode() { return str_pad((string)random_int(0, 9999), 4, 0, STR_PAD_LEFT); } }Cache::set的第三个参数是有效期秒数。random_int比rand更安全不会因为种子问题产生可预测序列。str_pad保证验证码是四位用户收到0001也不会丢前导零。代码里注释sendSms($phone, $code)的地方就是你接入服务商的位置平时不接服务商就把 trace 打开在日志里查验证码。4.2.2 校验接口的防爆破处理注册和登录接口里校验验证码时要注意两个点验证码是否过期、是否和缓存中的一致。同时要限制试错次数不然用户可以用暴力穷举的方式破解任意手机号的验证码。public function checkCode($phone, $inputCode) { $key sms_code_ . $phone; $cacheCode Cache::get($key); if (empty($cacheCode)) { return [code 1, msg 验证码已过期]; } if ($inputCode ! $cacheCode) { return [code 1, msg 验证码错误]; } // 验证成功后立即删除缓存防止重复使用 Cache::rm($key); return [code 0, msg ok]; }这里最容易被忽略的是“验证成功后删除缓存”。如果不删同一个验证码可以被多次使用攻击者拿到一个验证码就能反复注册账号。真正常见的短信验证码攻击还有两种一是接口没加频率限制导致短信轰炸二是手机号没绑定 openid 导致先注册别人手机号再把账号抢走。前者需要限制同一手机号 60 秒内只能发一次后者则要求注册和登录区分清楚。4.3 另一个隐患手机号未绑定时直接注册源码里的用户表如果没有openid唯一索引就会出现同一微信用户先注册后退出再用另一个微信号注册同样手机号的情况。微信小程序里合理的注册流程应该先用wx.login获取 code 换取 openid再让用户填手机号后端检查 openid 是否已存在。如果用户已存在直接返回登录态不再要求填写验证码只有 openid 不存在时才走注册流程。这个逻辑放到 ThinkPHP 里通常写在UserController的login方法中。代码重点在于区分“已有用户”和“新用户”避免把登录和注册揉成一个接口导致判断混乱。建议把注册与登录拆成两个接口或者在一个接口里用if (userExists) { login } else { register }清晰处理。5. ThinkPHP 3.2到8的兼容部署与验证技巧5.1 Nginx PHP环境的伪静态配置不管源码是 TP3.2 还是 TP6部署到 Nginx 时都要把请求重写到index.php。常见配置server { listen 80; server_name yourdomain.com; root /var/www/html/public; # TP5入口在public目录TP3.2在根目录 location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }TP3.2 的入口文件在应用根目录root指向项目根目录TP5 以后入口移到publicroot要指向public目录否则会暴露 application 目录源码。pathinfo模式在nginx.conf里还需要额外设置fastcgi_split_path_info如果你看到的是 404八成是这里没配。5.2 版本兼容的三个坑热词里“thinkphp 3.2 版本兼容 php8”是真实存在的痛点。TP3.2 年代写的代码用了大量mysql_开头的方法PHP7 之后就被移除。源码里如果用了M()或I()函数PHP8 环境下可能直接报Call to undefined function。常见的三个兼容处理第一把M(user)换成db(user)。TP3.2 的M()返回模型实例TP5 以后的db()返回查询构造器两者 API 相似但参数和返回值有差异。第二I(post.phone)是 TP3.2 的输入类TP5 以后改成input(post.phone)。第三session()函数在 TP3.2 是 S()TP5 以后换成think\facade\Session或者session()助手函数。如果你的环境是 PHP8 旧 TP3.2 源码最省事的方式是升级到 TP6但业务代码改动量会比较大。另一个迂回方案是跑 PHP5.6 环境的 Docker 容器不折腾源码。我一般会先看composer.json里php: 5.6还是php: 7.1来判断这套源码的真实年代。5.3 上线前的一分钟自检命令部署完成后不要急着在小程序里点先用命令行验证接口是否正常# 检查PHP版本与已禁用的函数 php -v php -m | grep -E curl|redis|pdo_mysql # 测试一个API接口是否返回JSON curl -X POST https://yourdomain.com/api/activity/lists \ -H Content-Type: application/json \ -d {page:1,size:5} # 查看ThinkPHP日志是否有报错 tail -n 50 /var/www/html/runtime/log/$(date %Y%m).log如果curl返回的是 500 或 HTML 错误页马上看日志重点搜Parse error和SQLSTATE。SQLSTATE[HY000]通常是数据库连接问题检查.env或config/database.php里的主机、端口、用户名密码。Parse error基本就是 PHP 版本不兼容对照上一节三个坑逐个排查。等接口都返回{code:0}了再用微信开发者工具去测试小程序端此时报错才能区分是前端问题还是后端问题。本文还有配套的精品资源点击获取