简介:面向餐饮、零售、美业、健身、景区与酒店等线下商家的NFC碰一碰营销源码包,围绕碰碰卡与爆店码的门店落地,把“手机轻触即触发”的互动入口用于菜品介绍、优惠券领取、电子菜单加菜、会员关注、活动打卡与预约转化。系统支持一键发布抖音、小红书笔记及小红书视频,并同步快手、视频号,也可跳转大众点评、美团、抖音、高德、百度完成打卡收藏,适合做贴牌交付、门店代运营和私域引流的技术与运营团队。
压缩包共1037个文件,约6.32MB,以707个PHP后端文件为主体,配合HTML页面、WXSS/WXML小程序界面、JS脚本、JSON配置及PNG/JPG素材,另有少量文档说明,整体呈可二次开发的完整项目结构,便于拆分模块、替换品牌与接入各平台链路。已有497人学习下载,可作为搭建碰一碰营销系统、研究多平台发布与到店打卡流程的参考蓝本。
1. 碰碰卡源码部署,最先卡住的往往不是 PHP
上周帮人收尾一套 NFC 碰一碰的贴牌源码部署,代码一行没动,光环境就折腾了大半天。碰碰卡这东西原理看着很朴素:一张 NTAG213 贴纸烧进一个 https 链接,顾客手机一碰就打开落地页,页面上给几个按钮,一键跳抖音、小红书、快手、视频号发笔记,或者一键去大众点评、美团、高德、百度打卡收藏,也就是市面上说的碰一碰爆店码。真上手才发现链路上任何一环掉链子,卡片立刻退化成一块塑料片:证书过期、域名没备案、微信内置浏览器把 scheme 拦了、安卓机 NFC 没开、写卡时手滑写成 http,随便中一条,商家拿手机碰十次没反应,最后账都记在装环境的人头上。这套东西适合手里有服务器、要给自己或客户做贴牌的小团队,也适合刚接手维护的运维。下面按我实际的装机顺序走一遍,包括我踩过的坑和最后的验收动作。
2. 环境先钉死:Nginx 1.24 + PHP 8.0 + MySQL 8.0 的组合与证书硬门槛
装这类营销系统最忌讳"顺手装最新版"。国内这套碰碰卡源码大多是 2022 到 2023 年前后的生态,你给它配 PHP 8.3 或者 MySQL 8.4,大概率能跑起来首页,然后在某个后台页面白屏——因为动态属性废弃警告被输出到了 JSON 响应里,前端解析直接失败。这种问题排查起来非常玄学,日志里看着一切正常,接口返回偏偏多了两行 Warning。
2.1 组件版本对照:我实际用的这一套
先看目录结构再定环境。根目录有think命令行文件、入口在public/index.php的,是 ThinkPHP 那一系;有artisan的是 Laravel 那一系。两者后面只在伪静态和定时任务写法上有差别,环境要求基本一致。我这次这套按 ThinkPHP 6 的路子配,具体版本如下。
| 组件 | 我用的版本 | 可接受区间 | 说明 |
|---|---|---|---|
| 操作系统 | Ubuntu 22.04 LTS | 20.04 / 22.04 | CentOS 7 的 OpenSSL 1.0.2 编译 PHP 8 很折腾,不建议 |
| Nginx | 1.24.x | 1.20 以上 | 需要http2和ssl模块 |
| PHP | 8.0.x | 7.4 / 8.0 | 不建议 8.2 以上,废弃警告会污染接口输出 |
| MySQL | 8.0.x | 5.7 / 8.0 | 字符集统一 utf8mb4 |
| Redis | 6.2.x | 5.0 以上 | 缓存加队列,落地页的碰卡计数也走它 |
| Composer | 2.x | 2.x | 只用来补装依赖,不要在生产机上做 update |
安装 PHP 和扩展这一步,我一般直接走系统源加 ondrej 的 PPA,比编译省事得多:
# 添加 PHP 源,装 8.0 及这套系统必需的扩展 add-apt-repository ppa:ondrej/php -y apt update apt install -y php8.0-fpm php8.0-cli php8.0-mysql php8.0-redis \ php8.0-curl php8.0-gd php8.0-mbstring php8.0-xml php8.0-zip \ php8.0-bcmath php8.0-fileinfo php8.0-exif # 让 FPM 和 CLI 用同一份配置,避免定时任务和网页端行为不一致 php -v && systemctl restart php8.0-fpmexif这个扩展容易被漏掉。商家上传门店照片经常是手机竖拍,缺了它图片方向会歪 90 度,后台看着没问题,落到卡片页面上就是横着的门头照,这种细节最影响交付观感。fileinfo也是必须的,上传校验走它,缺了会报"文件类型不允许"却查不出原因。
2.2 域名与证书:碰一碰链路的第一道闸
碰碰卡的整条链路是从一张写死的 URL 开始的,卡片一旦烧好发出去,链接就改不了。所以域名和证书必须在写卡之前全部落定,这是整套部署里唯一没有后悔药的环节。
我的做法是准备两个域名:一个主域名给运营后台,一个泛解析子域名给商户落地页,比如pengka.example.com和*.pengka.example.com。贴牌场景下每个商户一个子域,视觉上更干净,也方便按子域做统计。国内服务器上的域名必须完成备案,这一步没做,微信里打开会直接提示风险,落地页所有按钮都点不动。
证书用 Let's Encrypt 就够,但一定要配自动续期和续期后的 reload:
# 签发泛域名证书,DNS 验证方式 certbot certonly --manual --preferred-challenges dns \ -d pengka.example.com -d '*.pengka.example.com' --agree-tos # 续期钩子:证书更新后自动重载 Nginx,否则新证书不生效 echo 'deploy-hook = systemctl reload nginx' >> /etc/letsencrypt/cli.ini systemctl enable --now certbot.timer参数上重点看两个:--preferred-challenges dns是泛域名唯一可行的验证方式;deploy-hook写进cli.ini而不是 crontab 里手写 reload,是为了保证任何一次续期都会生效。我见过证书续期成功了但 Nginx 没 reload,三十天后卡片集体打不开的事故,商家那边只会说"你的卡坏了"。
提示:强制跳转 https 要配,但 HSTS 头先别加。贴牌客户里只要有一家没配好证书,加了 HSTS 之后浏览器会直接拒绝回退到 http,连救都救不回来。
2.3 扩展之外的三个参数与目录权限
PHP 装完还要改php.ini和 FPM 池配置,这几项不改,后面上传 LOGO、导入门店 Excel 一定失败。
; /etc/php/8.0/fpm/php.ini upload_max_filesize = 20M post_max_size = 24M max_execution_time = 120 date.timezone = Asia/Shanghai ; 有些主机默认禁用了这几个函数,导入导出模块会用到 disable_functions = exec,system,passthru,shell_exec,proc_open,popenpost_max_size必须大于upload_max_filesize,否则大于 20M 的文件会被静默丢弃,$_FILES里空空如也,前端只报"上传失败",属于典型的黑匣子问题。date.timezone不设的话,后台显示的碰卡时间会差 8 小时,商家核对数据时又是一轮扯皮。
目录权限按最小可用原则给,runtime、public/uploads、storage三个目录需要写入,属主给www-data,权限 755,不要图省事chmod -R 777。开了 SELinux 或者装了安全加固脚本的机器,777 反而会被拦,而且报错信息完全看不懂。
chown -R www-data:www-data /data/www/pengka/{runtime,public/uploads,storage} find /data/www/pengka/{runtime,public/uploads,storage} -type d -exec chmod 755 {} \; find /data/www/pengka/{runtime,public/uploads,storage} -type f -exec chmod 644 {} \;最后一条find ... -type f -exec chmod 644是给已有文件的权限做一次归正,新上传的文件由 PHP 按 umask 创建,通常没问题。
3. 源码落地:从解压到首页出画面的六个动作
代码到手先别急着传,本地解压看一眼目录里有没有.sql文件、有没有.env.example。有这两个东西,说明这套源码是按标准流程打包的,部署会顺很多。
3.1 建库、导表与配置文件里那几行
建库时字符集统一 utf8mb4,排序规则用utf8mb4_general_ci而不是utf8mb4_0900_ai_ci,因为后者在 MySQL 5.7 上不存在,将来迁移会踩坑。同时要把sql_mode里的ONLY_FULL_GROUP_BY去掉,营销系统的统计 SQL 十有八九写得不够规范。
# 建库并导入初始数据 mysql -uroot -p -e "CREATE DATABASE pengka DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p pengka < /data/src/pengka.sql # 全局去掉 ONLY_FULL_GROUP_BY,重启后仍然生效 sed -i '/\[mysqld\]/a sql_mode=STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ENGINE_SUBSTITUTION' /etc/mysql/mysql.conf.d/mysqld.cnf systemctl restart mysql导完表接着写配置文件。这套源码的数据库配置一般落在.env里,没有的话就是config/database.php,两者改一处即可,别同时改出两个不同值。配置里的hostname建议写127.0.0.1而不是localhost,后者在部分机器上会走 socket 连接,跟账号授权表里的127.0.0.1对不上,报错是"Access denied",但账号密码明明是对的。
; .env APP_DEBUG = false APP_URL = https://pengka.example.com [DATABASE] TYPE = mysql HOSTNAME = 127.0.0.1 DATABASE = pengka USERNAME = pengka PASSWORD = 换成你自己的强密码 HOSTPORT = 3306 CHARSET = utf8mb4 PREFIX = pk_ [REDIS] HOST = 127.0.0.1 PORT = 6379 PASSWORD = 换成你自己的 SELECT = 0APP_DEBUG上线必须是false,开着它的时候一旦报错,堆栈信息里会带出数据库密码和.env路径,落到公网上就是事故。PREFIX要和.sql文件里建表用的前缀一致,导表之后对着看一遍SHOW TABLES;的输出就行。
3.2 Nginx 站点配置与伪静态
Nginx 的root一定要指到public目录,不是项目根目录。指错了的后果是.env、runtime、备份的 sql 全部可以被直接下载,这是这套系统最常见的安全翻车点。
server { listen 443 ssl http2; server_name pengka.example.com; root /data/www/pengka/public; # 只暴露 public,别指到项目根 index index.php index.html; ssl_certificate /etc/letsencrypt/live/pengka.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/pengka.example.com/privkey.pem; # 隐藏 .env 等敏感文件 location ~ /\.(env|git|svn) { deny all; } location / { # ThinkPHP 系写法:文件不存在时交给 index.php 处理 if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } } location ~ \.php$ { fastcgi_pass unix:/run/php/php8.0-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_read_timeout 120; } # 落地页静态资源给长缓存,卡片打开速度直接影响商家体验 location ~* \.(jpg|png|css|js|woff2)$ { expires 7d; access_log off; } }如果根目录看到的是artisan而不是think,把location /里那段换成 Laravel 的一行try_files $uri $uri/ /index.php?$query_string;就行,其余不动。fastcgi_read_timeout调到 120 是为了配合前面的max_execution_time,两边不一致的时候会出现 PHP 还在跑、Nginx 已经返回 504 的情况,日志里两边都看不到真实原因。
3.3 定时任务、队列与日志目录
这套系统有几类活必须后台跑:碰卡记录的异步落库、平台 token 的定时刷新、多商户子域的证书检查。用的是 Redis 队列,那就得有常驻进程加守护。
# 队列常驻进程,交给 supervisor 管,挂了自动拉起 cat > /etc/supervisor/conf.d/pengka-queue.conf <<'EOF' [program:pengka-queue] command=/usr/bin/php /data/www/pengka/think queue:work --queue default --tries=3 --daemon directory=/data/www/pengka user=www-data autostart=true autorestart=true numprocs=2 stdout_logfile=/data/www/pengka/runtime/log/queue.log EOF supervisorctl reread && supervisorctl update # 定时任务,注意用 www-data 身份跑,否则 runtime 目录写不进 crontab -u www-data -e # * * * * * /usr/bin/php /data/www/pengka/think crontab >> /data/www/pengka/runtime/log/cron.log 2>&1numprocs=2是我一般给的值,商户数量少的时候一个就够,多了反而因为竞争锁出现重复处理。--tries=3表示失败重试三次,配合队列的failed表能捞回大部分失败任务。crontab 一定要用www-data身份,用 root 跑的话生成的文件属主是 root,网页端再去写就报权限拒绝——这个坑我踩过不止一次。
日志目录runtime/log记得配 logrotate,不然一天几千次碰卡,一个月能把磁盘写满:
cat > /etc/logrotate.d/pengka <<'EOF' /data/www/pengka/runtime/log/*.log { daily rotate 14 missingok notifempty copytruncate } EOF4. 贴牌与链路:一套源码跑出多个牌子,卡片怎么烧
装到这一步,后台能登录、首页能出画面,但离"能给商家交付"还差两件事:把牌子换成自己的,以及把卡片烧成真能碰开的。
4.1 贴牌配置项逐条对照
贴牌不是换个 LOGO 就完事。这套源码里决定"看起来是谁家的"的地方散在后台设置、前端模板、分享卡片三处,改漏一处就露馅。我按后台实际菜单整理了一份对照表。
| 配置项 | 后台位置 | 改完必须做的动作 |
|---|---|---|
| 站点名称、LOGO、favicon | 系统设置 → 基础配置 | 清 Redis 缓存,favicon 需强刷 |
| 主题色、按钮圆角 | 系统设置 → 界面配置 | 重新编译前端静态资源(如有) |
| 分享标题、描述、缩略图 | 系统设置 → 分享配置 | 缩略图必须 300×300 以上且 https |
| 落地页模板 | 卡片管理 → 模板 | 逐模板预览,别只测默认那个 |
| 客服二维码、联系方式 | 系统设置 → 联系信息 | 检查二维码图片是否被压缩糊掉 |
| 授权域名 | 系统设置 → 授权 | 换域名后必须重新绑定,否则后台随机掉线 |
分享缩略图这一项最容易出问题。很多平台抓取分享卡片时要求图片走 https 且能被匿名访问,如果图片存在storage目录又配了防盗链,分享出去的卡片就是白板。我一般把分享图单独放public/share/下,不走鉴权。
主题色改完建议清一次缓存再验证。这套系统的缓存键里带了配置版本号,但版本号是手动递增的,改了配置忘了递增,前端读到的还是旧值,表现是"后台明明改了,刷新还是原样"——十个里有八个是缓存没清。
4.2 一键发布与打卡收藏:链接到底是怎么拼的
落地页是整套系统的核心页面,路径形如/p/{卡片码}。用户碰卡进来后,页面根据 UA 判断运行环境,再决定按钮跳哪里。这里必须说清楚一件事:各平台的分享和打卡能力,公开接口能做的事情非常有限,绝大多数场景下靠的是私有 scheme 加降级方案,scheme 会随平台版本变化,上线前必须逐台真机验证,别把它当稳定 API 用。
// 落地页按钮分发逻辑,先判环境再决定跳哪种链路 const ua = navigator.userAgent.toLowerCase(); const isWeixin = /micromessenger/.test(ua); const isIOS = /iphone|ipad|ipod/.test(ua); const schemes = { douyin: 'snssdk1128://', // 抖音 xhs: 'xhsdiscover://', // 小红书 kwai: 'kwai://', // 快手 meituan:'imeituan://', // 美团 dianping:'dianping://', // 大众点评 amap: isIOS ? 'iosamap://' : 'androidamap://', baidu: 'baidumap://' }; function jump(platform, poiId) { // 微信内置浏览器会拦截绝大多数第三方 scheme,只能引导外部打开 if (isWeixin) { showGuideMask(); // 弹出"右上角在浏览器中打开"的遮罩 return; } // 高德/百度走 POI 详情,参数必须是后台录入的门店 poiId if (platform === 'amap') { location.href = `androidamap://poi?sourceApplication=pengka&poiid=${poiId}&dev=0`; return; } location.href = schemes[platform] || '/'; }这段逻辑里有三个判断不能省。第一是微信环境判断,微信内置浏览器对第三方 scheme 的拦截非常彻底,点了没反应是常态,必须在页面层做一个明确的引导遮罩,告诉用户去浏览器打开,而不是让用户以为卡片坏了。第二是 iOS 和安卓的高德 scheme 前缀不同,写死成安卓那套,iPhone 上必然打不开。第三是 POI 参数,打卡收藏类跳转必须带上门店在各平台的 POI 标识,这个标识没法自动获取,只能运营在后台一个个录,录入时把各平台的 ID 都存一份。
分享到抖音、小红书这类"发笔记"的按钮,实际能力比宣传语弱。公开可用的做法是:页面把文案和图片准备好,一键复制文案加保存图片到相册,然后拉起 App 让用户自己粘贴。真正免粘贴的分享需要走各平台的开放平台授权,拿 open_id 和分享凭证,这套流程涉及资质审核,不是源码装完就有的能力。交付时把边界说清楚,比后期扯皮强。
4.3 NFC 写卡:NDEF 记录、容量与真机验证
卡片买 NTAG213 就够用,用户区约 144 字节,写一条https://pengka.example.com/p/8F3K2A这种三十多字节的 URL 绰绰有余。NTAG215(约 504 字节)和 NTAG216(约 888 字节)留给需要带渠道参数的场景,比如同一张卡要区分不同投放位置,URL 会变长。
写卡有两种方式:手机装个写卡 App 手动写,适合零星几张;批量出货用读卡器加脚本。我用 ACR122U 加 Python 的 nfcpy 库,一次写一批,效率差得很明显。
import nfc from ndef import UriRecord # 每张卡的 URL 提前从后台导出,卡片码与 URL 一一对应 TARGET_URL = "https://pengka.example.com/p/8F3K2A" def on_connect(tag): if not tag.ndef: print("不是 NDEF 格式的标签,先格式化") return False if not tag.ndef.is_writeable: print("标签已锁定,无法写入") return False tag.ndef.records = [UriRecord(TARGET_URL)] # 写入 URI 记录 print("写入成功:", tag.ndef.records) # 置为只读,防止顾客或店员误写;此操作不可逆,先小批量验证 tag.protect() return False with nfc.ContactlessFrontend('usb') as clf: clf.connect(rdwr={'on-connect': on_connect})tag.protect()这一步是把卡片锁成只读,写进去的链接再改不了。这是好事也是坏事:好处是商家拿别的 App 误碰也改不掉,坏处是域名一旦失误,整批卡报废。我的做法永远是先写五张、真机全平台测一轮,确认没问题再批量写剩下的。
真机验证的矩阵至少覆盖这几种组合:iPhone 亮屏靠近、iPhone 熄屏靠近、安卓亮屏解锁靠近、安卓微信内打开、安卓浏览器打开。iPhone 的后台读卡在熄屏状态下也能唤起,但要求标签里是标准的 URI 记录,写成纯文本记录就只能亮屏手动扫。安卓这边则要求 NFC 开关打开且屏幕解锁,这两点在给商家的使用说明里必须写清楚,不然第一周就会接到"碰了没反应"的电话。
5. 排查:五个让我推倒重装的坑
装这套系统的过程中,真正耗时间的不是装,是装完之后那些"看起来像代码 bug、实际上是环境问题"的现象。下面五条按现象、原因、解决记下来,都是血泪经验。
5.1 碰卡打开是空白页,日志里什么都没有
现象:手机碰卡后浏览器打开一片白,Nginx 访问日志里有 200,PHP 错误日志空的。
原因:root指到了项目根目录而不是public,请求打到了不存在的入口,被伪静态规则兜底转发后又找不到index.php,返回了一个 200 的空响应。另一种情况是伪静态把静态资源也一起 rewrite 了。
解决:root改回public目录,加一条静态资源的 location 规则排除重写。改完用curl -I https://域名/看返回头里的Content-Type是不是text/html。
5.2 微信里按钮点了完全没反应
现象:安卓微信内打开落地页,点"一键打卡"毫无反应,也没有报错;同一台手机复制链接到浏览器打开就正常。
原因:微信内置浏览器拦截第三方 scheme,这是平台行为,不是代码问题。
解决:在页面层做微信环境判断,弹出引导遮罩提示"右上角在浏览器中打开"。别指望用 JS 强行唤起,尝试多次失败后微信还会限制页面内的跳转,体验更差。
5.3 后台碰卡次数一直是 0
现象:卡片实际能碰开,也能正常发笔记,但后台数据统计里碰卡次数始终为零。
原因:落地页的埋点请求走的是/api/hit,被伪静态规则拦下来当成了页面路由,请求根本没到控制器。也可能是 Redis 里的计数键被设了永不过期,读到的是初始化时的 0。
解决:curl直接请求埋点接口看返回,404 就是路由问题;返回正常但数据不动,去 Redis 里TTL查那个计数键。埋点接口建议放在public下的独立路径,别跟业务路由混在一起。
5.4 上传 LOGO 后整站样式崩了
现象:后台换成客户 LOGO,前台卡片页布局错位,按钮跑到屏幕外。
原因:上传的图是 4000×3000 的手机原图,后台没做等比压缩,直接按原尺寸输出,撑破了容器。另一个可能是图片被压成 webp 格式,部分老版本 iOS 不认。
解决:上传时就限制尺寸,后台一般有图片处理配置项,把最大宽高设成 800 以内,输出固定 jpg 或 png。已经上传坏的,去public/uploads手动删掉重新传。
5.5 定时任务不执行,平台 token 一直过期
现象:后台提示"平台授权已失效",手动点刷新能恢复,过两小时又失效。crontab 里明明加了任务。
原因:crontab 用 root 身份跑的,生成的锁文件和日志属主是 root,www-data再去读写就失败;或者 supervisor 里的队列进程挂了没人拉起。
解决:crontab 统一用www-data身份,日志目录属主改回来;队列一律走 supervisor 托管,用supervisorctl status确认状态是 RUNNING 而不是 FATAL。
6. 装完怎么验收:一张自检表和三个必测动作
部署完成不等于交付完成。我给自己定了一套固定的验收动作,走完这遍才敢把卡发给商家。
先跑一遍环境自检表,五项全绿再往下走:
| 检查项 | 命令或动作 | 期望结果 |
|---|---|---|
| PHP 与扩展 | php -m | grep -E 'redis|gd|fileinfo' | 三个都在 |
| 数据库连接 | 后台登录页正常加载 | 无 500 错误 |
| Redis 连通 | redis-cli -a 密码 ping | 返回 PONG |
| 证书有效期 | echo | openssl s_client -connect 域名:443 2>/dev/null | openssl x509 -noout -dates | 剩余 30 天以上 |
| 队列与定时任务 | supervisorctl status与日志尾部 | RUNNING,日志有输出 |
然后是三个必测动作,一个都不能省。
第一个动作是浏览器直连验证。用curl打开一个真实卡片码对应的落地页,确认返回 200 且 HTML 里有按钮结构;再用手机浏览器打开同一个链接,确认样式和跳转正常。这一步排除的是服务端配置问题。
第二个动作是写卡前的空链接测试。拿一张空白 NTAG213,先不锁,写一个测试码,用 iPhone 和安卓各碰一次,确认能唤起浏览器并打开正确页面。确认之后再锁卡、再批量写。这个顺序要是反了,锁完才发现域名写错,整批卡就废了。
第三个动作是全平台真机矩阵。抖音、小红书、快手、视频号各点一次发布按钮,大众点评、美团、高德、百度各点一次打卡收藏,每个平台都至少在一台 iPhone 和一台安卓上跑。scheme 这东西各版本之间会变,模拟器和桌面浏览器测不出来,只有真机能暴露问题。测的时候顺手把每一台的失败情况记进表格,后面商家报障时能直接对照。
提示:验收记录别只存自己电脑里。把卡片码、对应 URL、测试机型和结果整理成一份表格随卡交付,商家换人对接时不用再从零问一遍。
从那以后我每次交付碰碰卡项目,不管多急,都会强制走一遍"先写五张不锁卡、真机矩阵全过、再批量锁卡"的流程,证书到期提醒也统一挂在监控上,绝不靠记忆。这套链路上没有哪一环是可以事后补救的,卡一旦锁死发出去,剩下的就只有道歉。希望这些步骤能帮你少走一遍我踩过的路,源码和配套的写卡脚本按需取用就行。
本文还有配套的精品资源,点击获取