NFC碰一碰营销系统开发拆解:从标签到一键发布与打卡收藏
简介一套面向餐饮、零售、美业、旅游等线下商家的NFC碰一碰碰碰卡/爆店码贴牌源码方案支持商家自定义品牌部署聚合抖音、小红书、快手、视频号的内容一键发布以及大众点评、美团、高德、百度的打卡收藏重点解决线下门店引流、锁客与二次消费需求。资源包共1037个文件压缩后约6.32MB以707个PHP后端文件为主辅以HTML页面、微信小程序文件、JSON配置等覆盖服务端逻辑、管理后台、接口配置和基础素材可直接运行或二次开发。目前已有494人学习下载。核心价值在于提供一套完整、可落地的贴牌部署源码能快速搭建碰一碰营销系统适用于需要为商家做数字化赋能的开发者、服务商或运营人员包内目录和文件结构清晰便于按功能模块拆解、替换品牌信息和扩展接口也可作为NFC营销场景的实战参考。1. 碰碰卡一个让顾客“碰”一下就完成打卡、发布、收藏的NFC营销系统线下门店最头疼的就是顾客进店以后留不下任何线上痕迹。扫码关注公众号已经成了玄学加企业微信又嫌麻烦。碰碰卡这套系统解决的恰恰是这个场景把一张NFC贴纸贴在收银台、餐桌或者商品架上顾客手机靠上去碰一下就能立刻打开一个专属页面一键发布抖音笔记、小红书视频、快手作品还能顺手在美团、高德、大众点评上完成打卡收藏。整个过程不到三秒不需要下载APP也不需要扫码关注。它本质上是一个为商家做赋能的SaaS工具如果你是技术商可以直接买源码做贴牌部署把自己的品牌和商户管理后台套上去给下游商家开账号。这套系统从卡片到后台的完整链路正是我这次要拆给你的东西。2. 项目全貌与技术选型从NFC卡片到后台管理端的四层链路接着第一章我先把碰碰卡的整体架构画出来再讲为什么选这套技术栈。习惯了先看目录结构的可以直接跳到2.3但我建议从系统组成开始看因为后面所有代码都是围绕这四层展开的。这种形态在市面上也被叫“爆店码”但爆店码大多只做单点破站碰碰卡把「碰一下」这个动作扩展成了完整的营销闭环。2.1 系统由哪几部分组成一个完整的NFC碰一碰营销系统至少要有四端NFC物理标签、用户端小程序/H5、服务端API、商家管理后台。NFC标签承担的是入口里面存着一个URL不同卡片对应不同门店和活动。用户端小程序负责渲染活动页并调用各个平台的开放API去执行发布、打卡、收藏。服务端是所有逻辑的枢纽处理链接重定向、生成短码、保存发布任务、对接平台授权。商家后台则是给业务方用的可以创建门店、设置NFC编号、查看发布记录和打卡报表。这种拆分最常见也最容易被贴牌。我见过有团队把用户端做成小程序也有做成H5的其实两者可以共存NFC标签里写同一个域名下的短链接服务端根据User-Agent自动判断是在微信里还是浏览器里分别跳转小程序或H5。这样做的好处是同一个URL既能服务微信生态又能覆盖支付宝和普通浏览器适配不同碰一碰的入口。比如用户用手机浏览器碰一碰落到H5页面一样可以完成发布和打卡。后台端不一定非要做成单独的Web系统也可以在已有商家应用里嵌一个管理模块。但为了贴牌方便我建议独立部署因为每个贴牌商都需要改logo、改域名、改主题色独立仓库可以避免和商家现有系统耦合。我在真实项目中见过把后台和服务端混写的后来改需求时一团糟从此我坚持四端分离。2.2 为什么选这套技术栈开发碰碰卡这类系统我建议后端用Node.js Express主要原因是对接第三方平台时各家API签名和回调示例大多数是JavaScript写法同一个语言处理起来省事排查问题也少很多。数据库选MySQL因为要存商户、门店、用户、发布记录关系型数据用MySQL更好做多租户隔离。前端小程序用原生语法毕竟要调NFC能力原生API最直接。管理后台用Vue Element Plus后台交互不算复杂表格、表单占绝大多数。最关键的是任务队列选型。一键发布抖音、小红书、快手这些平台API响应很慢而且有频率限制一个用户一次发布四个平台如果同步循环调用接口可能5秒内都恢复不了。所以必须异步。我在这个项目里用的是BullMQ基于Redis天然支持延迟和重试。发布请求进来后先往队列塞一个任务Worker消费时再逐个请求平台这样接口的响应时间能控制在200ms以内。为什么不选云函数或消息服务当时考虑到贴牌客户可能把代码部署在自己的服务器上不希望强行绑定云厂商所以用Redis队列最通用。如果你已经有云服务用MNS也能达到同样效果但可移植性差一些。部署时把Redis和Node服务放进docker-compose客户一台机器就能起来运维成本低。2.3 目录结构与代码组织先看目录结构我用一个模拟项目来说明。这个结构是按“前后端分离 多平台服务”组织的你可以直接套用nfc-pengpengka/ ├── server/ # Node.js 服务端 │ ├── src/ │ │ ├── controllers/ # 路由控制器 │ │ ├── services/ # 业务逻辑含平台对接 │ │ ├── utils/ # 工具函数签名、短码生成 │ │ ├── routes/ # 路由定义 │ │ ├── middleware/ # 授权、日志、租户判断 │ │ ├── models/ # Sequelize 数据模型 │ │ ├── queues/ # BullMQ 任务队列 │ │ └── app.js │ ├── .env.example │ └── package.json ├── miniapp/ # 微信小程序用户端 │ ├── pages/ │ │ ├── index/ # 碰一碰落地页 │ │ ├── publish/ # 发布笔记/视频页面 │ │ └── member/ # 我的记录 │ └── app.js ├── admin/ # 商家管理后台Vue │ ├── src/ │ │ ├── views/ │ │ │ ├── store/ # 门店管理 │ │ │ ├── card/ # NFC卡片管理 │ │ │ └── records/ # 发布记录 │ └── vite.config.js └── deploy/ # Nginx配置、docker-compose.yml我特别强调server/services/platform这个目录每个平台一个文件比如douyin.js、xiaohongshu.js、kuaishou.js统一导出publish()方法。这是后续扩展新平台的关键后面第三章会讲如何抽象。数据模型方面至少要有这些表sites贴牌站点、stores门店、cardsNFC卡片、users用户、publish_records发布记录、checkin_records打卡记录。其中cards表是核心shortCode字段要唯一索引。以下是一个简化后的建表语句你可以参考CREATE TABLE cards ( id INT AUTO_INCREMENT PRIMARY KEY, site_id INT NOT NULL, store_id INT NOT NULL, short_code CHAR(6) NOT NULL UNIQUE, activity_id INT DEFAULT 0, status TINYINT DEFAULT 1 COMMENT 1启用, 0停用, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_store_short (store_id, short_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里site_id是贴牌站点的标识store_id对应门店。在贴牌部署时每个贴牌商有自己的site_id不同站点之间数据隔离靠这个字段实现。为什么用Sequelize因为它支持迁移文件贴牌客户升级数据库时不用手动跑SQL一条sequelize-cli db:migrate就能完成。3. 核心模块拆解一次NFC碰一碰从闪卡到后台的全过程这一章是全文的重头戏。我会从用户把手机贴近NFC卡片的那一刻开始逐步拆解到后台收到发布记录为止每个关键步骤都配上代码和参数说明。建议对着目录结构看思路会更清晰。3.1 NFC标签写入与URL规则设计首先需要一张NFC写入工具。我一般用安卓手机上的NFC Tools写入NDEF URI类型内容为https://ppk.example.com/c/8f3a2b91。这里8f3a2b91是服务端生成的短码对应cards表里的一条记录记录里有store_id、activity_id、status。为什么要做短码而不是直接写完整URL因为有些NFC标签容量只有48字节完整后台路径可能塞不下而且短码可以随时换跳转地址不用重新烧卡。比如活动结束后你想把卡片跳转到一个新的落地页只需在后台修改cards表里的activity_id卡片物理上不用换。短码生成用Base62随机6位代码很简单// server/src/utils/shortCode.js const ALPHABET 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ; function generateShortCode() { let code ; for (let i 0; i 6; i) { code ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; } return code; } module.exports { generateShortCode };这里用Math.random()就够了因为并发量不大不需要加密级别。插入cards表时我会用shortCode做唯一索引万一撞码就重新生成一次。为了更稳可以在插入时捕获唯一索引冲突再换一个码重试。这个函数没有替代品但要记得把生成逻辑抽出来方便测试。3.2 服务端重定向与用户环境识别用户手机碰一下系统会打开上面的https://ppk.example.com/c/8f3a2b91这个请求到了服务端。服务端根据User-Agent判断来源决定是跳转小程序还是H5。下面是Express路由的代码。// server/src/routes/card.js const express require(express); const router express.Router(); const { getCardByCode } require(../services/cardService); router.get(/c/:code, async (req, res) { const { code } req.params; const card await getCardByCode(code); if (!card || card.status ! active) { return res.redirect(https://ppk.example.com/error?reasoninvalid); } const ua req.headers[user-agent] || ; if (ua.includes(MicroMessenger)) { // 微信内重定向到小程序页面 const path encodeURIComponent(/pages/landing/index?cardId card.id); const url weixin://dl/business/?txxxpath${path}; return res.redirect(url); } if (ua.includes(AlipayClient)) { // 支付宝内打开H5或小程序 return res.redirect(https://ppk.example.com/h5/landing?cardId${card.id}); } // 手机浏览器直接渲染H5 res.redirect(https://ppk.example.com/h5/landing?cardId${card.id}); }); module.exports router;参数说明code是NFC标签里的短码user-agent判断环境这里只区分了微信、支付宝和普通浏览器。注意weixin://dl/business/?txxx中的xxx必须由微信公众平台生成每个小程序都有这个值在后台“链接生成”里可以拿到。如果你的域名没有在微信后台配置成业务域名微信会拦截这个跳转这个坑第四章会细说。3.3 一键发布多平台笔记与视频的任务队列一键发布的本质是并行调用多个平台的开放API但实际不能并行因为抖音、小红书对频率限制很严而且单台服务器IP可能被封。我在服务端使用BullMQ队列把一次发布拆成一个主任务和多个子任务。// server/src/queues/publishQueue.js const { Queue, Worker } require(bullmq); const connection { host: process.env.REDIS_HOST, port: process.env.REDIS_PORT }; const publishQueue new Queue(publish, { connection }); function buildPublishJob(publishId, platforms, payload) { return { name: publish-post, data: { publishId, platforms, payload }, opts: { attempts: 3, backoff: { type: exponential, delay: 5000 }, removeOnComplete: true, removeOnFail: false } }; } module.exports { publishQueue, buildPublishJob };attempts: 3表示失败后重试3次backoff指数延迟5秒起步避免重试太快触发平台封禁。removeOnComplete设为true成功任务不占Redis内存失败的保留方便排查。Worker消费任务时按平台串行调用。注意不同平台的payload结构差异很大抖音要传video_id小红书要传note_image快手要传caption。我这里用一个策略模式把每个平台的实现放到独立文件// server/src/services/platform/index.js const douyin require(./douyin); const xiaohongshu require(./xiaohongshu); const kuaishou require(./kuaishou); const wechatVideo require(./wechatVideo); const platforms { douyin, xiaohongshu, kuaishou, wechatVideo }; async function publishToPlatform(platformName, payload, storeId) { const handler platforms[platformName]; if (!handler) throw new Error(unsupported platform: ${platformName}); if (typeof handler.preprocess function) { payload handler.preprocess(payload, storeId); } const result await handler.publish(payload); return result; } module.exports { publishToPlatform };每个平台的publish()里要处理设备注册、文件上传、文案拼接、频率控制。以小红书为例发布一篇笔记需要先上传图片素材到小红书服务器拿到file_id再调用note/publish接口。上传前要检查图片大小小红书限制单张图片不超过10MB否则会被拒绝。我在preprocess里做图片压缩用sharp库把图片压到8MB以内。3.4 一键打卡收藏的实现细节打卡收藏走的是各平台的POI接口。大众点评、美团、抖音、高德、百度都有自己的位置服务。所谓“打卡”本质是上报用户坐标和POI ID或者调用平台的签到接口。这里最麻烦的是美团需要用户授权高德需要用户打开定位权限。我的实现是在用户端小程序里先通过wx.getLocation拿到经纬度然后后端根据经纬度反查各平台的POI ID。高德有regeo接口百度有geocoder美团和点评则用它们自己的搜索API。代码如下// server/src/services/checkin.js const axios require(axios); async function getAmapPoiId(lng, lat, keyword) { const url https://restapi.amap.com/v3/place/around; const params { key: process.env.AMAP_KEY, location: ${lng},${lat}, keywords: keyword, radius: 1000 }; const resp await axios.get(url, { params }); const poi resp.data.pois[0]; return poi ? poi.id : null; }参数说明lng和lat是用户当前位置的经度和纬度keyword是门店名称。radius设为1000米防止门店位置较偏时搜不到。返回值是POI ID这个ID要传给下一个打卡接口。高德这边如果找不到对应POI就需要调POI上报接口这个在避坑章具体讲。把打卡行为固定下来后后台可以汇总每个门店的打卡次数用于商家结算。用户端落地页的代码也顺带提一下小程序拿到cardId后请求接口渲染门店信息和平台按钮// miniapp/pages/landing/index.js Page({ data: { cardId: , store: {}, platforms: [] }, onLoad(options) { const cardId options.cardId; this.setData({ cardId }); this.fetchStoreInfo(cardId); }, fetchStoreInfo(cardId) { wx.request({ url: https://api.example.com/v1/cards/${cardId}, success: (res) { this.setData({ store: res.data.store, platforms: res.data.platforms }); } }); }, onPublishTap() { // 跳转到多平台发布选择页传递 cardId wx.navigateTo({ url: /pages/publish/index?cardId${this.data.cardId} }); } });这里的platforms数组是服务端根据店铺授权状态返回的比如抖音、小红书是否已经绑定。前端只渲染已绑定的平台避免用户点了没反应。4. 部署避坑指南NFC识别、微信重定向、平台授权那些坑这一章我把自己在开发碰碰卡过程中遇到的几个典型坑记录下来每一条都是「现象 → 原因 → 解决」的格式希望你能少走弯路。这几个坑不是偶发大部分部署上线都会遇到。4.1 手机一碰没反应NFC写入格式不规范现象用户把手机贴近贴纸没有任何反应甚至有时弹窗显示“不支持该标签”。原因很多NFC工具默认写入的是NDEF Text格式部分手机系统只能识别NDEF URI格式。还有一部分标签是NFC Type 1存储容量太小且兼容性差。解决写入时选择“URL/URI”类型并且前缀用https://。如果你手头只有USB读卡器在电脑上用libnfc命令行写入也行。另外标签尽量选NFC Type 2容量至少168字节能覆盖几个平台投放的短链。4.2 微信内打开链接被拦截URL Scheme选择错误现象在微信里碰一碰浏览器明明已经跳转但页面显示“已停止访问该网页”或“非微信官方网页”。原因跳转的域名没有在微信公众平台配置为「业务域名」或者没有通过ICP备案。解决在微信公众平台「开发管理 - 开发设置」里配置JS接口安全域名和业务域名域名必须https。注意不是把短链域名配一次就行实际跳转的H5页面域名也必须配置。另外微信跳小程序的URL Scheme需要在公众平台生成并且有效期只有30天需要定期刷新不能写死。4.3 小红书/抖音授权失败回调域名与平台白名单不一致现象发布笔记时提示access_token无效或redirect_uri错误。原因在小红书开放平台创建应用时填写的授权回调地址必须与代码里的redirect_uri完全一致而且不能带路径参数。抖音同理回调地址要精确到路径。解决统一用一个固定路径/auth/callback在代码里读环境变量不要再拼参数。排查时可以先在平台开放工具的调试页面里测试一次把实际请求的redirect_uri复制下来比对后台配置。我踩过这个坑后每次新增平台都会先写一个auth.test.js专门检查回调地址匹配。4.4 高德打卡位置偏移需要上传POI且审核现象打卡时高德返回INVALID_USER_POI打卡记录显示空白。原因打卡的POI必须是高德地图里已经存在的商户POI且状态为审核通过。很多新门店在地图上还没有POI直接调用place/around搜索不到。解决商家在后台录入门店时代码自动调用高德POI搜索如果返回为空再调POI上报接口提交门店信息等待审核。审核周期一般1-3天测试时可以用高德的沙箱环境避免污染线上数据。我在项目里加了一个定时任务每6小时检查一次未审核的门店POI状态。4.5 贴牌系统下商户数据隔离公共表和私有表的坑现象平台运行一周后A商户在后台能看到B商户的卡片记录。原因贴牌部署时多个商户共用一个数据库某些查询漏加了store_id条件。解决所有涉及商户数据的SQL都必须强制带storeId并且建立联合索引(store_id, id)。在Sequelize中我推荐定义全局作用域如下// server/src/models/Card.js module.exports (sequelize) { const Card sequelize.define(Card, { shortCode: DataTypes.STRING, storeId: DataTypes.INTEGER, status: DataTypes.STRING }, { defaultScope: { where: { status: active } } }); return Card; };这个defaultScope默认只查启用的卡片但数据隔离不能只靠这个。更稳妥的做法是封装一个findByStore(storeId, query)函数内部强制where.storeId storeId业务层只调用这个函数避免到处裸写Card.findAll()。5. 从源码到上线本地跑通与环境配置前面已经理解了核心模块这一章我教你如何把整个系统在本地跑起来并做一次完整的碰一碰模拟。我假设你已经装了Node.js 18、MySQL 8、Redis 6并且有微信小程序开发者工具。没有NFC硬件也能完成大部分测试因为核心逻辑都在服务端。5.1 安装依赖与初始化数据库首先克隆代码在server目录下安装依赖cd server npm install cp .env.example .env然后创建数据库并执行迁移npx sequelize-cli db:create npx sequelize-cli db:migrate.env里需要至少配置以下内容参见下表变量名说明示例DB_HOSTMySQL地址127.0.0.1DB_NAME数据库名ppkREDIS_HOSTRedis地址127.0.0.1REDIS_PORTRedis端口6379WECHAT_APP_ID小程序AppIDwx123456AMAP_KEY高德Web服务Key复制.env后编辑里面参数。我建议用.env管理所有密钥不要提交到仓库。NODE_ENVdevelopment时Sequelize会输出SQL日志方便调试。5.2 配置项说明下面列出关键环境变量按模块分组分组变量名说明示例基础PORT服务端口3000数据库DB_HOSTMySQL地址127.0.0.1RedisREDIS_HOSTRedis地址127.0.0.1微信WECHAT_APP_ID小程序AppIDwx123456抖音DOUYIN_CLIENT_KEY抖音开放平台应用Key小红书XIAOHONGSHU_CLIENT_ID小红书开放平台应用ID高德AMAP_KEY高德开放平台Web服务Key注意抖音和小红书的开放平台都需要企业资质个人开发者不能直接申请。如果你只是本地测试可以先在平台上用一个测试应用跑通流程后再换成正式应用。5.3 测试流程模拟一次完整的碰一碰如果没有NFC标签完全可以用curl模拟。先手动往cards表插一条记录短码设为abc123。然后执行curl -H User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) http://localhost:3000/c/abc123如果返回302并跳转到https://ppk.example.com/h5/landing?cardId1说明服务端路由正常。再用微信的User-Agent试一次看是否跳到weixin://开头的地址。小程序端开发时可以在app.js里把cardId写死直接跳转到落地页这样不用每次都碰卡。提示微信开发工具中打开weixin://协议是无效的请使用真实手机预览或者暂时注释掉微信跳转逻辑直接打开H5页面。5.4 贴牌部署时的多租户配置贴牌部署意味着每个客户拿到源码后想换上自己的品牌。我的做法是后台管理端的site表里存品牌名、logo、域名、备案号前端页面全部通过接口加载这些配置代码里不允许出现硬编码的“碰碰卡”字样。部署时nginx里做多域名的映射每个域名对应一个site_id服务端根据Host头识别租户。// server/src/middleware/tenant.js module.exports async (req, res, next) { const host req.headers.host; const site await getSiteByDomain(host); if (!site) { return res.status(404).json({ message: unknown site }); } req.site site; next(); };在Nginx里可以这样设置server { listen 80; server_name client-a.example.com; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; } }这样每个贴牌客户都有自己的独立域名后端只认域名不认路径数据隔离更清晰。域名和site_id的映射关系存在site_domains表一张表搞定。6. 二次开发实战给碰碰卡增加一个B站发布模块当商家手里的平台越来越多加新平台就成了最常见的需求。我以增加B站动态发布为例带你走一遍完整的改造流程。这个流程可以复用到任何新平台核心思路是“先抽象、再实现、后验证”。6.1 分析第三方平台的通用发布接口首先去B站开放平台看文档它支持发视频动态需要的参数是title、content、coverUrl、videoUrl。授权方式与抖音类似都是OAuth2。差异点在于B站上传视频时需要一个视频预上传接口返回vid。我把它抽象成标准结构核心是media数组每个元素描述一条素材。6.2 扩展任务队列处理器在server/src/services/platform/下新建bilibili.js// server/src/services/platform/bilibili.js async function preprocess(payload, storeId) { if (!payload.videoUrl) { throw new Error(bilibili requires videoUrl); } const uploadInfo await uploadVideo(payload.videoUrl, storeId); return { ...payload, vid: uploadInfo.vid }; } async function publish(payload) { const api https://api.bilibili.com/x/article/creative/draft/update; const resp await axios.post(api, { title: payload.title, content: payload.content, vid: payload.vid }, { headers: { Access-Token: payload.accessToken } }); return resp.data; } module.exports { preprocess, publish };然后在platform/index.js里注册const bilibili require(./bilibili); const platforms { douyin, xiaohongshu, kuaishou, wechatVideo, bilibili };注意我在preprocess里做视频上传这样publish函数就纯粹只负责发布职责单一。以后换平台只需要关注preprocess和publish两个函数是否实现完整。6.3 后台管理端增加配置项在Vue后台的发布页增加一个B站复选框存储到publish_records.platforms字段。这个字段用JSON数组保存[douyin,xiaohongshu,bilibili]。前端提交时直接传数组后端用DataTypes.JSON存储方便以后按平台查询。相比用逗号分隔字符串JSON数组在查询时可以直接用JSON_CONTAINS效率高很多。6.4 验证改造启动后端和Redis Worker用下面这个脚本构造一个包含B站的发布任务const { publishQueue, buildPublishJob } require(./queues/publishQueue); const job buildPublishJob(101, [bilibili], { title: 门店开业优惠, content: 今日到店送饮品, videoUrl: https://example.com/video/xx.mp4, accessToken: process.env.BILIBILI_ACCESS_TOKEN }); await publishQueue.add(job.name, job.data, job.opts);然后观察Worker日志如果返回status字段等于成功说明发布流程走通。再手动去B站后台看草稿箱确认内容已经生成。改造新平台最怕一把梭。从那以后我每次对接新平台都强制走一遍先看官方调试工具把请求参数复制到Postman验证通过再写代码写完代码再用同样的参数跑单测最后才联调。希望帮到你。本文还有配套的精品资源点击获取