简介:一套基于小程序与手机NFC(近场通信)技术的同城拓客系统完整源码包,面向本地商户、营销服务商及具备小程序开发基础的工程师。系统通过NFC碰一碰标签快速唤起营销页面,支持一键发布短视频到抖音、种草文案到小红书,并可跳转添加微信、连接门店WiFi,同时引导用户在大众点评等平台评价,用于提升门店曝光与口碑。资源包共2000个文件,约20.05MB,以PHP后端逻辑、HTML页面、JavaScript交互、CSS样式及小程序WXML/WXSS文件为主,辅以图片、SVG图标、字体等多类素材,目录结构清晰,便于按模块定位功能。代码中涉及NFC标签读取、微信开放能力对接、平台分享接口集成等关键实现,可帮助开发者快速搭建本地化拓客工具或进行二次开发。已有834人学习下载,适合希望研究NFC场景化应用、完善O2O营销闭环的读者参考。
1. NFC碰一碰同城拓客系统源码:一次触碰把到店流量变成客户资产
我拆过不少同城流量类项目,最头疼的往往是同一个问题:物料做了一堆,顾客扫码看一眼就划走,店员追着加微信效率又低得离谱。NFC碰一碰拓客系统把整套获客动作压缩成“手机贴近桌贴、小程序自动弹出、优惠券进卡包、手机号授权留资”四步,全程不到十秒,不用下载App、不用搜索公众号。这套源码包里包含C端碰一碰小程序、商家管理后台、后端接口服务以及NFC标签的数据格式约定,拿过来既能直接部署一个小规模同城商户试点,也能拆掉默认的优惠券业务模型,换成储值、预约、会员卡这些适合自己经营场景的东西。适合本地生活服务商、商圈联盟团队,也适合想研究微信小程序NFC能力的开发者。
2. 系统组成与技术选型:小程序触达、后台管理、标签写入三端各司其职
NFC碰一碰的体验在线下,背后却要靠三端协作:顾客手机里的小程序、商家配置用的管理后台、埋在桌贴台卡里的NFC标签。缺任何一块,链路就断。这一章先把源码包的骨架讲清楚,让你知道下载下来的每个目录到底是干什么的。
2.1 整体架构的角色划分
这套源码按部署角色分成三个工程,每个工程都能独立启动、独立部署,中间只通过HTTP接口和数据库通信。三者之间的关系可以用一张表格说明:
| 工程 | 角色 | 入口 |
|---|---|---|
| miniprogram | C端微信小程序 | 顾客和商家都在这里交互 |
| admin | 商家管理后台(Web) | 门店管理员配置活动、看数据 |
| server | 后端接口服务 | 统一处理授权、发券、统计 |
当一个顾客把手机靠近桌贴上的NFC标签时,标签里存的是预先写好的NDEF数据;小程序拿到这段数据后解析出门店ID、活动ID和场景标识,请求后端接口拉取当前门店的活动内容展示在页面上。顾客点击“一键领取”并授权手机号后,后端记录一次留资事件并下发优惠券。商家后台里看到的访客数、授权率、领券量,全部是从这一条条触碰事件汇总出来的。
2.2 技术栈清单与选型理由
这套源码的小程序端是原生微信小程序,没有引入uni-app或Taro这类跨端框架。原因很直接:NFC相关API依赖微信基础库的底层能力,跨端框架在这一块要绕很多兼容层,碰到真机上的NFC玄学问题反而不好定位。后端用Node.js Express,轻量、启动快、适合中小团队二次开发;数据库用MySQL,是最常见的用户表、商户表、门店表、活动表、优惠券表、触碰记录表关系模型。
| 模块 | 技术 | 用途 |
|---|---|---|
| 小程序端 | 原生微信小程序 WXML/WXSS/JS | 页面渲染与NFC读取 |
| 管理后台 | Vue 2 + Element UI | 门店、活动、标签管理 |
| 后端服务 | Node.js + Express | 接口、鉴权、数据汇总 |
| 数据库 | MySQL 8.0 | 业务数据与统计落库 |
| NFC标签载体 | NDEF格式(TEXT+URI) | 承载门店与活动标识 |
管理端选Vue是因为社区资料多,后续接手的人容易上手;后端选Express是因为这个项目的数据模型没有复杂到上微服务的程度,一个进程跑完所有接口,部署也省心。如果你拿到源码之后想换技术栈,后面第6章我会讲怎么低成本替换。
2.3 源码包目录结构
源码解压后,核心是三个目录加一份文档目录,整体结构如下:
nfc-tuoke/ ├── miniprogram/ # 微信小程序端 │ ├── pages/ │ │ ├── landing/ # 触碰后的门店落地页 │ │ ├── coupon/ # 优惠券详情页 │ │ └── stats/ # 商家查看的门店数据页 │ ├── utils/ │ │ ├── ndef.js # NDEF 报文解析工具 │ │ └── request.js # 请求封装与错误码处理 │ └── app.js ├── admin/ # 商家管理后台(Web端) │ ├── src/views/ # 门店、标签、活动、数据报表页面 │ └── package.json ├── server/ # 后端接口服务 │ ├── routes/ # 路由定义 │ ├── controllers/ # 业务逻辑 │ └── models/ # 数据表模型 └── docs/ ├── ndef-decoding.md # NFC 标签内容格式说明 └── deploy.md # 部署指引建议拿到源码先把docs里的两个文档读一遍。ndef-decoding.md讲标签怎么组织NDEF内容,这决定了你怎么烧录标签;deploy.md是给部署用的,连MySQL初始化SQL的路径都写了,照着做基本不会卡住。
我习惯把项目里最常用的三张表先看一遍,它们决定了整个系统能撑起什么业务:门店表(store)、活动配置表(activity)、触碰记录表(touch_log)。商家后台不管是改门店名称、上活动还是看数据报表,最终都是在这三张表上做文章。
2.4 核心表结构:一张触碰流水表撑起整个拓客漏斗
数据库一共八张表,但真正撑起核心的只有三张。门店表和活动表是配置性的,触碰流水表是事实性的,商家后台看板里的每个指标都能追溯到这三张表。核心建表演示如下:
CREATE TABLE store ( id BIGINT PRIMARY KEY AUTO_INCREMENT, store_no VARCHAR(32) NOT NULL UNIQUE, -- 门店编号,对应标签里的storeId store_name VARCHAR(128) NOT NULL, contacts VARCHAR(32), phone VARCHAR(20), status TINYINT DEFAULT 1, -- 1启用 0停用 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE activity ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_no VARCHAR(32) NOT NULL UNIQUE, -- 活动编号,对应标签里的activityId title VARCHAR(128) NOT NULL, coupon_template_id BIGINT, -- 关联优惠券模板 start_time DATETIME, end_time DATETIME, status TINYINT DEFAULT 0 -- 0未发布 1进行中 2已结束 ); CREATE TABLE touch_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tag_id VARCHAR(32), -- NFC标签ID标识 store_id BIGINT, activity_id BIGINT, openid VARCHAR(64), mobile VARCHAR(20), -- 授权后回填 status VARCHAR(20), -- touched/viewed/authorized/coupon_issued created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_store_time (store_id, created_at) );这套表结构最值得借鉴的是touch_log的状态流水设计:一个事件一条记录,每个状态单独一行。很多新手会把“是否授权”“是否领券”做成两个布尔字段,最后统计时发现状态互相覆盖,怎么都对不上。拆成事件流水之后,每个状态都能独立聚合,商家后台看板要什么指标都能直接查。
3. 核心链路拆解:一次“碰一碰”从标签识别到客户转化怎么走
这一章是整套源码里最值得细看的部分。整个拓客链路可以分成四个环节:标签里写什么、小程序怎么读、手机号授权与优惠券下发、后台怎么统计。逐个拆开讲。
3.1 NFC标签上写了什么:NDEF报文格式约定
“碰一碰”的前提是标签里得有一段能被微信小程序读到的数据。源码里的约定是NDEF记录里放两条:一条TEXT记录存门店ID和活动ID的冗余信息,方便排查时直接读卡内容;一条URI记录,用来在支持的系统环境下唤醒对应落地页。实际烧录时,绝大多数情况只需要用任意的NFC写卡工具,把下面的JSON文本按NDEF文本记录写入即可:
{"storeId":"ST_1001","activityId":"A_20250101","sceneId":"SC_TABLE_12"}三个字段的含义要记清楚:
- storeId:门店在系统内的唯一编号,所有数据归集的维度
- activityId:当前桌贴参与的活动批次,控制活动有效期
- sceneId:具体到某张桌贴,比如“大厅12号桌”,方便看数据时知道流量来自哪个位置
小程序读取到这段JSON后,拿storeId和activityId去请求后端接口,拿到门店名称、活动标题、优惠券模板等展示信息。为什么要在标签里冗余一整个JSON而不是只存一个ID?因为真机调试NFC时经常需要人工核对标签内容,字段写全能少踩很多坑。
3.2 小程序端的NFC读取与解析
小程序端在落地页加载时会拉起一段NFC读取逻辑。源码里utils目录下的ndef.js核心代码如下:
// miniprogram/utils/ndef.js const NFDUtil = { // 读取NFC标签内容,兼容不同微信基础库版本 readTag() { return new Promise((resolve, reject) => { // 旧基础库用 getNdef 读取 if (wx.getNdef) { wx.getNdef({ success(res) { const ndefMessages = res.nfcTagInfo && res.nfcTagInfo.ndefMessage; if (!ndefMessages || ndefMessages.length === 0) { reject(new Error('NDEF_MESSAGE_EMPTY')); return; } // 本项目约定取第一条文本记录作为业务数据 const payload = ndefMessages[0].payload; const text = arrayBufferToUtf8(payload); resolve(text); }, fail(err) { reject(err); } }); } else { reject(new Error('NFC_NOT_SUPPORTED')); } }); }, // ArrayBuffer 转 UTF-8 字符串 arrayBufferToUtf8(buffer) { const bytes = new Uint8Array(buffer); const decoder = new TextDecoder('utf-8'); return decoder.decode(bytes); } }; module.exports = NFDUtil;注意一个细节:wx.getNdef在部分安卓机型低基础库下拿到的ndefMessage结构不稳定。代码里先做了一次空值判断,拿不到消息时统一返回NDEF_MESSAGE_EMPTY,由页面层引导用户“重新贴卡”,而不是直接白屏。这个错误码在真机调试时非常有用,能帮你快速区分是标签内容问题还是API兼容问题。
拿到文本之后的处理逻辑在页面层,继续往下看:
// miniprogram/pages/landing/index.js 中节选 const jsonText = await NFDUtil.readTag(); let tagInfo; try { tagInfo = JSON.parse(jsonText); } catch (e) { // 标签内容不是JSON,通常是烧录时写错了 wx.showToast({ title: '标签内容异常', icon: 'none' }); return; } const activity = await request.get('/api/activity/detail', { storeId: tagInfo.storeId, activityId: tagInfo.activityId });逻辑说明:先解析JSON,解析失败直接提示并终止;解析成功的才发起活动详情请求。这一步放在网络请求之前,能避免无意义的后端调用,也避免后端收到大量脏参数。参数说明里有一点容易被忽略:后端activity/detail这个接口同时校验活动状态和有效期,活动还没发布或已结束,返回的code是非零的,前端要根据错误码决定是隐藏领券按钮还是展示“活动已结束”。
3.3 手机号授权与优惠券下发链路
拓客系统的核心目标是留资,留资的载体是微信手机号快速验证。小程序端这里用的是button组件的open-type="getPhoneNumber",用户点击后微信弹出授权框,授权成功后拿到一个临时code,再由后端调微信的接口换取真实手机号。核心代码:
<!-- miniprogram/pages/landing/index.wxml --> <button class="auth-btn" open-type="getPhoneNumber" bindgetphonenumber="handleGetPhoneNumber" loading="{{submitting}}" > 授权手机号,领取商家优惠券 </button>// miniprogram/pages/landing/index.js handleGetPhoneNumber(e) { if (e.detail.errMsg.indexOf('ok') === -1) { // 用户点了取消授权,什么都不做,直接退出 return; } const phoneCode = e.detail.code; this.setData({ submitting: true }); const res = await request.post('/api/tuoke/claim', { phoneCode, storeId: this.data.tagInfo.storeId, activityId: this.data.tagInfo.activityId }); // 后端返回优惠券编号,这里跳转到券详情页 wx.navigateTo({ url: '/pages/coupon/index?couponId=' + res.couponId }); }参数说明:phoneCode是微信生成的临时凭证,5分钟内有效,只能使用一次。后端拿到它之后调用微信的快速验证接口换取手机号再落库。这样一个用户即便多次碰同一张桌贴,后端也能通过手机号识别出是否老用户,不会重复发券。
后端claim接口的核心逻辑是:先按手机号查用户,再按storeId和activityId查活动配置,校验活动是否在有效期,最后在优惠券表插入一条记录,并同步在触碰流水表更新授权和发券状态。整个过程必须保证“碰一次只发一张券”,这一块怎么防并发,在第5章避坑里详细说。
3.4 拓客数据看板:转化漏斗怎么算出来
后台的数据看板依赖的就是touch_log这张触碰记录表。整个链路里每个环节都会在表里打上状态标:标签读取成功记为touched,用户进入落地页记为viewed,用户授权手机号记为authorized,发券成功记为coupon_issued。后台统计时直接按状态聚合:
-- 门店维度拓客漏斗统计 SELECT store_id, SUM(CASE WHEN status = 'touched' THEN 1 ELSE 0 END) AS touch_cnt, SUM(CASE WHEN status = 'viewed' THEN 1 ELSE 0 END) AS view_cnt, SUM(CASE WHEN status = 'authorized' THEN 1 ELSE 0 END) AS auth_cnt, SUM(CASE WHEN status = 'coupon_issued' THEN 1 ELSE 0 END) AS issue_cnt FROM touch_log WHERE activity_id = 'A_20250101' GROUP BY store_id ORDER BY touch_cnt DESC;查询结果是一行一个门店的漏斗数据,商家后台页面直接渲染成表格。为什么每条触碰事件按状态拆成多行而不是一行里塞多个时间字段?因为一个用户可能在一张桌贴前碰了好几次,第一次只触发了读取,第二次才愿意授权手机号,如果都往一行里塞状态字段,会产生状态互相覆盖。拆成事件流水之后,每个状态独立聚合,统计口径才准确。
四层转化率最能定位线下物料的问题:如果授权率远低于领券率,说明优惠力度对用户没吸引力;如果读取成功率本身就低,问题大概率出在标签烧录或贴纸摆放位置。
4. 源码运行指南:从环境准备到真机联调
这一章是照着跑的部分,每一步该干什么直接说清楚,不搭花架子。
4.1 环境准备清单
跑这套源码至少需要以下环境:
| 项目 | 版本建议 | 说明 |
|---|---|---|
| 微信开发者工具 | 最新稳定版 | 编译小程序端 |
| Node.js | 16 及以上 | 运行后端服务 |
| MySQL | 8.0 | 初始化业务库 |
| Vue CLI | 4.x | 编译商家管理后台 |
另外还要准备两个东西:一个已认证的微信小程序AppID,一个已认证的商户主体。NFC碰一碰在小程序后台需要开通“手机号快速验证组件”能力,未认证的账号在真机上调不通,开发版也不行。
4.2 后端服务启动步骤
先处理数据库和配置文件:
cd server cp .env.example .env # 编辑 .env 文件,填入数据库连接、小程序AppID与AppSecret mysql -uroot -p < docs/init.sql npm install node index.js.env文件里比较关键的配置项:
# server/.env 示例 APP_ID=wx1234567890abcdef APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_DB=nfc_tuoke MYSQL_USER=root MYSQL_PASSWORD=yourpassword PORT=8080启动后看到listening on 8080说明后端服务正常了。这里先别急着接小程序,用接口测试工具把/api/activity/detail请求一遍,能拿到活动数据再往后走。后端是整套系统的数据枢纽,它没起来,后面小程序全白屏。
4.3 小程序端导入与配置
微信开发者工具里选“导入项目”,目录指向源码包的miniprogram文件夹,填入自己的AppID。然后修改请求封装里面的baseUrl:
// miniprogram/utils/request.js const BASE_URL = 'http://192.168.1.100:8080'; // 开发环境可填局域网IP module.exports = { BASE_URL };开发阶段还要在微信开发者工具的“详情 -> 本地设置”里勾选“不校验合法域名”,否则请求会被拦截。这一步是新手最常踩的坑,报错看起来像网络挂了,其实是微信的域名校验拦截。小程序编译完成后,先用模拟器访问落地页,确认页面能正常请求后端并展示活动配置。模拟器读不了真实NFC标签,只能验证页面链路,NFC读取必须真机测试。
4.4 商家管理后台启动与账号初始化
管理后台是Vue工程,依赖装好后直接起本地服务:
cd admin npm install npm run serve启动后在浏览器打开,用docs/deploy.md里给出的初始管理员账号登录。第一次登录后建议马上做两件事:建一个门店、建一个活动批次。小程序端落地页的请求全部依赖storeId和activityId,没有这两个基础数据,真机碰一碰时页面会报“活动不存在”。
4.5 真机联调与NFC标签烧录
最后一步是烧录标签。用任意一款支持NDEF写入的App,往NFC标签里写入3.1节那段JSON文本,然后把标签贴到测试位置。真机打开小程序进入落地页,手机靠近标签,观察页面是否弹出“已读取标签”的提示。
这里提醒一句:碰一碰的最佳体感是“手机贴上去小程序自己弹出来”,但真正稳定的落地调试方式是先打开小程序的落地页,再让手机靠近标签读取。系统自动唤起小程序在安卓机型和微信版本之间差异很大,属于看运气的特性,不要太依赖。源码里默认走的也是“页面内读卡”这条稳定链路。如果确实想要系统唤起能力,需要在微信开放平台配置对应的URL Scheme,并且准备多台安卓机型做兼容测试。
5. 常见问题与避坑排查:NFC读取失败、授权拿不到手机号、优惠券超发
跑NFC碰一碰类项目,最大的特点就是本地模拟器好好的,一到真机就翻车。这里把源码运行和二次开发中最常遇到的五个问题列出来,按现象、原因、解决的顺序写,都是实操中真实踩过的。
5.1 安卓手机碰了标签没反应,或者弹出系统层选择框
现象:手机靠近标签后没有任何响应,或者弹出系统应用选择列表,唯独没有小程序选项。
原因:标签NDEF内容没有按约定组织,或者写入的是纯文本内容;部分安卓系统会把它当成通用NFC标签,交给系统默认应用处理。
解决:标签写入内容务必带一个可访问且已备案的HTTPS地址,并且在小程序后台把域名配置为业务域名。源码docs里的ndef-decoding.md有推荐的URI记录写法,直接照抄即可。同时要检查微信版本,旧版本对NFC触发逻辑支持不稳定,建议升级到最新版再测。
5.2 iPhone碰一碰几乎没反应
现象:同一张标签在安卓手机上能读,换到iPhone上手机贴近标签毫无反应。
原因:iOS对被动的NFC标签读取限制比较多。锁屏状态下只能识别特定类型的NDEF记录,小程序打开状态下读取也需要走系统级别的后台NFC扫描流程,和安卓的策略不同。
解决:iPhone场景下不要追求“锁屏碰一下就弹窗”,改为引导用户先进小程序落地页再贴卡。源码落地页里已做了NFC读取状态提示,页面打开后持续监听标签,用户把手机贴上去即可。这一点要写进商家的培训材料里,否则客诉率会很高。
5.3 授权手机号后拿不到手机号,接口报错
现象:用户点击授权按钮后,后端收到phoneCode,但换手机号时微信接口报错,用户被卡在授权这一步。
原因:最常见的是授权按钮没有绑定bindgetphonenumber事件,或code被重复使用。手机号快速验证的code是一次性凭证,用第二次必然报错。另外,小程序主体没完成认证,或账号没有开通手机号组件权限,也会拿不到手机号。
解决:先检查小程序后台的“手机号快速验证组件”是否开通,再核对button上的写法。后端换取手机号失败时不要直接抛错,落一条日志并把用户标记为“未授权”,方便后续排查。我一般还会加一个体验兜底:手机号换取失败时,允许用户手动输入手机号再提交,避免活动流量白白流失。
5.4 优惠券重复领取,库存超发
现象:一个用户碰同一张桌贴多次,后台产生多张券;活动设了库存100张,实际发出去120张。
原因:领券逻辑做了“先查是否已领、再插入发券记录”两步,中间没有唯一性约束。两台手机同时碰同一个标签时,并发请求都查到“未领券”,随后同时执行发券。
解决:给发券表加唯一索引,把user_id和activity_id绑在一起:
-- 优惠券领用表增加唯一索引 ALTER TABLE coupon_claim ADD UNIQUE KEY uk_user_activity (user_id, activity_id);同时把领券接口里的“检查+插入”两步用事务包起来。唯一索引冲突时,接口返回“您已领取过该活动优惠券”。这一条改动成本最低,但上线后最救命,不加的话活动一爆量就会超发。
5.5 后台统计数据和业务库对不上
现象:后台看板显示授权用户100个,实际手机号表里只有90条记录。
原因:授权成功与手机号落库这两步没有在同一个数据库事务里。用户已授权但写手机号时超时或异常,授权标记已置位而手机号数据丢失。
解决:把“写授权标记”和“写手机号记录”放进同一个数据库事务,任一失败整体回滚。改动位置在server/controllers/claim.js文件里,把user更新和phone插入两段代码用事务包裹起来。如果线上已经产生脏数据,写一个修复脚本,以phone表为准回刷授权标记。
6. 二次开发改造成自己的拓客业务:换模型、接CRM、上线验证
跑通源码只是开始,大多数拿这套系统的人都会想把默认的优惠券玩法换成自己的业务模型。这里给三个我常用的改造切入点。
6.1 把优惠券模型换成会员储值或预约单
默认数据模型以coupon_claim为中心,改造时只要把发券表扩展成业务流水表。比如做门店储值,新增一张recharge_order表,字段包含userId、storeId、amount、payStatus,落地页按钮从“领券”改成“充值锁定名额”;做预约场景就把couponId换成appointmentId,前端展示也从卡券样式换成预约卡片。因为前端页面和后端接口是分离的,改业务模型时不需要动NFC标签写入内容,storeId和activityId两个标识可以继续复用。
6.2 把留资事件同步给外部CRM
如果团队已有CRM系统,可以在claim接口成功写库后追加一个Webhook调用,把用户授权时间、门店编号、手机号推送出去。我会在配置里加一个WEBHOOK_URL开关,没配置时静默跳过,配置了就在事务提交后发起异步推送,避免影响主流程耗时。
6.3 上线前的验证流程与灰度策略
上线前我会按固定清单走一遍:真机贴卡读取、授权、领券、重复贴卡、后台看数,五个环节在3台安卓和2台iPhone上各测一轮。接着用故意写错内容的“坏标签”验证前端的报错提示和后台的失败事件回捞是否正常。确认没问题之后,再把标签批量烧录给商家,并且第一天只放三张标签到店内做灰度,追踪一下午授权数据没有异常,再决定要不要全部铺开。
从那以后,我每次做碰一碰类项目上线前,都会强制在正常标签里混入几张故意写错内容的坏标签,用它们验证落地页的报错提示和后台的失败事件回捞。这一招帮我挡掉了不少真机上的玄学问题,真正上线后半夜被拉起来处理客诉的次数明显少了。希望帮到你。
本文还有配套的精品资源,点击获取