ThinkPHP活动报名小程序源码解析:从数据表设计到安全加固
2026/9/14 3:00:28 网站建设 项目流程

简介:一份基于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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动表';
2.1.1 活动表字段与索引说明

start_timeend_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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报名表';

UNIQUE KEY uk_activity_user是防重复报名的第一道保障,数据库层面直接挡住同一用户对同一活动的二次提交。用户表再简单,也必须包含idphonewechat_openidreg_time这几个字段,其中wechat_openid建议加唯一索引,因为小程序登录靠code换 openid,一个 openid 只能绑定一个用户。

2.2 控制器分层:API层和后台管理层的职责划分

源码的 controller 目录一般分成apiadmin两块,入口都是 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,验证码作为 value,5 分钟过期 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_intrand更安全,不会因为种子问题产生可预测序列。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 里,通常写在UserControllerlogin方法中。代码重点在于区分“已有用户”和“新用户”,避免把登录和注册揉成一个接口导致判断混乱。建议把注册与登录拆成两个接口,或者在一个接口里用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 errorSQLSTATESQLSTATE[HY000]通常是数据库连接问题,检查.envconfig/database.php里的主机、端口、用户名密码。Parse error基本就是 PHP 版本不兼容,对照上一节三个坑逐个排查。等接口都返回{"code":0}了,再用微信开发者工具去测试小程序端,此时报错才能区分是前端问题还是后端问题。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询