简介:一套仿Soul交友盲盒1.0全开源源码,以随机盲盒匹配玩法为核心,面向想要自建交友平台或学习移动社交应用开发的开发者。项目完整覆盖前后端:API端基于ThinkPHP框架,后台管理采用Node.js,用户端为H5页面,可直接打包为Android/iOS应用。压缩包约84.21MB,内部包含数据库备份文件blind_box_20211019_193819.sql、详细的搭建文档txt,以及api、admin、h5三个核心目录,分别对应后端接口、管理后台和前端界面。已有627人学习,适合具备一定前端或后端基础的开发者。通过学习这套源码,可掌握随机匹配机制、匿名聊天、后台用户管理等功能的实现思路,并结合文档完成本地部署,或在此基础上二次开发,打造具有自身特色的社交产品。
1. 仿Soul交友盲盒1.0:先把实时通信和随机匹配拆成两条线
交友盲盒表面是玩法,本质是“延迟揭示的随机匹配”。用户点开盒子的那一瞬,系统才决定对面是谁,这个延迟给了服务端操作空间,也决定了它跟列表式交友不同的技术重心——Soul类应用让人上头的不是动画,而是匹配速度和消息实时性。一个仿Soul盲盒1.0的全开源工程,核心模块逃不开两条线:WebSocket长连接负责消息收发,匹配服务负责筛人、抽人、推送开盒结果。打包成APP之后连接不上、消息延迟、开盒失败,九成问题出在这两层。下面从通信选型、匹配算法、打包配置到排错手段完整过一遍,适合有前端或Node基础、准备拿开源工程改造成自己产品的开发者。
2. 通信层选型:仿Soul盲盒为什么用WebSocket而不是轮询
Soul的聊天、匹配结果推送都是服务端主动发起的。HTTP轮询每3秒拉一次,服务端没法在匹配成功那一刻立刻把结果推到客户端;SSE虽然支持服务端推送,但只是单向通道,聊天时的上行消息还得走另一条链路。WebSocket一次握手之后就是全双工通道,匹配结果、广播消息、聊天文本都在同一条连接上跑,这是它成为这类项目通信层默认选型的根本原因。
裸用WebSocket也有一堆问题要解决:断线重连、心跳保活、握手鉴权、消息去重。这几个不处理好,用户量一起来就是事故现场。
2.1 ws、wss 与 HTTPS 的匹配规则:打包后连不上的第一个坑
WebSocket 地址的协议必须跟页面协议对齐:HTTPS 页面里的 ws 连接会被浏览器直接拦截,APP 里内嵌的 WebView 同样遵循这条规则。H5 调试时用的是 http://localhost:8080,WebSocket 写成 ws://localhost:8080/ws 没问题;打包后线上域名是 HTTPS,WebSocket 还保持 ws://,握手直接在协议层被拒绝,表现为 onerror 触发、连接状态停滞在 CONNECTING。所以连接地址我一般抽成一个配置项:
// config.js // 根据当前页面协议自动选择 ws 或 wss,避免打包后忘记改地址 const WS_BASE = location.protocol === 'https:' ? 'wss://api.example.com/ws' : 'ws://api.example.com/ws';之后再封装连接,带着 token 鉴权:
function createSocket(token, userId) { return new WebSocket(WS_BASE + '?token=' + encodeURIComponent(token) + '&uid=' + userId); }token 放 query 里是常见做法,服务端在握手阶段就能校验,不用等第一条消息。缺点是会留在 Nginx 访问日志里,生产环境建议换成首条消息鉴权。
注意:token 放 query 会出现在访问日志和代理日志中,敏感环境建议改为连接建立后第一条消息携带鉴权。
参数说明:WS_BASE 根据页面协议动态选择,解决打包后 url 写死导致协议不匹配的问题;token 用于握手鉴权,过期时服务端返回 4001,前端收到后强制重新登录;uid 让服务端在在线池里快速定位用户身份。
2.2 心跳与断线重连:移动端网络切换的标准解法
移动端 Wi-Fi 和蜂窝网络切换、电梯隧道里的弱网,都会让 TCP 层断开,但 WebSocket 的 onclose 不一定及时触发。标准做法是 30 秒一次 ping,服务端 90 秒内没收到就断开,客户端收到 close 后按指数退避重连:
const HEARTBEAT_INTERVAL = 30000; let heartbeatTimer = null; let reconnectAttempts = 0; function startHeartbeat(ws) { heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } }, HEARTBEAT_INTERVAL); } function handleClose() { clearInterval(heartbeatTimer); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); reconnectAttempts++; setTimeout(setup, delay); } function setup() { const ws = createSocket(token, userId); ws.onopen = () => { reconnectAttempts = 0; startHeartbeat(ws); }; ws.onclose = handleClose; ws.onerror = (e) => console.error('ws error', e); }重连间隔采用指数退避,1秒、2秒、4秒递增,上限30秒。reconnectAttempts 在 onopen 后清零,避免长时间抖动后重连频率失控。心跳包不只是保活,服务端还能根据最近一次心跳时间判断用户是否在线,直接喂给匹配模块的在线池。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 心跳间隔 | 30s | 小于服务端断连超时的一半即可 |
| 服务端超时 | 90s | 连续3个心跳周期无消息则断开 |
| 重连退避基数 | 1000ms | 指数增长的基础值 |
| 重连退避上限 | 30000ms | 防止服务端恢复后客户端还在傻等 |
2.3 消息协议:给盲盒的每种事件指定 type
裸 WebSocket 收的是字符串,不做协议封装的话,收到消息还得靠字符串匹配判断类型。开源工程里常见的是统一 JSON 结构,type 区分事件,payload 带业务数据,seq 做消息去重:
{ "type": "match_success", "seq": 1024, "ts": 1710000000000, "payload": { "roomId": "room_88321", "peer": { "uid": 10086, "nickname": "小鹿", "avatar": "https://..." } } }事件类型表:
| type | 方向 | 含义 |
|---|---|---|
| ping / pong | 双向 | 心跳保活 |
| auth | C->S | 首条消息鉴权 |
| match_request | C->S | 用户开启盲盒 |
| match_success | S->C | 匹配成功,含 roomId |
| chat_msg | 双向 | 聊天文本 |
| sys_notice | S->C | 系统通知,如对方已离线 |
seq 是客户端自增序号,服务端把最近处理过的 seq 缓存起来,重复消息直接丢弃。这个设计在弱网环境下很关键——重连后消息队列重发,没有 seq 就可能把同一条匹配结果推两遍。
3. 盲盒匹配核心:在线池、随机抽取与排重过滤
开盲盒的请求一到,服务端要回答三个问题:从哪个池子抽、怎么抽、抽到的人和之前的是否重复。这三个问题看似简单,放到并发环境里就容易出乱子——两个用户同时点开盒子,可能互相抽到对方,也可能抽出同一个已经拉黑的人。先解决第一问:在线用户池怎么维护。
3.1 用 Redis 维护在线用户池
在线状态如果查数据库,每次开盒都是一次SELECT * FROM users WHERE last_active > NOW() - INTERVAL 5 MINUTE,用户量大时这个查询没法走索引优化。常见做法是维护一个 Redis Set,登录和心跳成功时 SADD,登出或超时 SREM:
# 用户上线 SADD online_pool user:10086 # 心跳续期时,刷新整个池子的过期时间 EXPIRE online_pool 300 # 用户主动下线或被踢下线 SREM online_pool user:10086这是一套简化方案:整个池子的过期时间由最后一次心跳决定,适合小规模工程。要实现按人精确过期,改用 ZSET 按最后活跃时间存储,心跳时ZADD online_pool 时间戳 userId,定时用ZREMRANGEBYSCORE online_pool -inf 当前时间-90s清理超时用户。
参数说明:key 命名为 online_pool,避免多环境共用一套 key 相互污染;过期时间 300 秒,和前端心跳间隔配合;SADD、SREM都是 O(1) 操作,开盒请求高峰期也能撑住。
3.2 SRANDMEMBER 与 Fisher-Yates:两套抽取方案
用户点了盲盒,服务端要从在线池里抽人。方案A直接用 Redis 的 SRANDMEMBER:
# 一次性取出 10 个候选,在业务层过滤性别、拉黑关系、最近匹配记录后挑 1 个 SRANDMEMBER online_pool 10SRANDMEMBER 不会把元素移出集合,并发的开盒请求互不影响,但业务层要保证过滤后结果不为空。方案B把在线用户拉出来,在内存里做洗牌:
function shuffleAndDraw(userIds, count) { const pool = [...userIds]; for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; } return pool.slice(0, count); }Fisher-Yates 是 O(n),n 是候选池大小;Math.random() 的均匀性在这个场景够用。方案A适合在线池规模几万以上的阶段,方案B适合工程刚起步、候选池几百上千的时期。
3.3 避免重复匹配:已见集合与会话锁
盲盒开过一次之后,用户大概率不想再开到同一个人。常见做法是维护一个“已匹配”集合,抽到候选后批量判断是否匹配过:
# 用户 10086 开过 10010 之后,记录下来 SADD matched:10086 user:10010 # 抽到候选后,批量判断是否已经匹配过,返回 0/1 数组,1 表示已匹配 SMISMEMBER matched:10086 user:10010 user:10011SMISMEMBER 返回的数组里值为 1 的直接过滤掉,剩下的才是有效候选。过滤后为空就再抽,最多循环三次,否则返回“暂时没有合适的人”。同时为了处理并发场景——两个人同时抽到对方——匹配成功时要写一条锁定记录,用 SETNX 保证同一对用户只创建一个房间:
# SETNX 返回 1 才继续建房间,返回 0 说明对方已经在开盒流程中,当前请求重新抽 SETNX room_lock:10086_10010 1 EXPIRE room_lock:10086_10010 30这三个点的组合,是一个盲盒匹配服务的最小可用版本。
4. 打包APP:HBuilderX 从 H5 到 APK 的落地步骤
开源工程拿过来一般是 H5 代码,npm run dev 起服务没问题,但要装进手机变成 APK,国内开发者最常用的路线是 HBuilderX 云打包。整个流程不复杂,但配置细节容易踩坑。
4.1 验收工程:先跑通再谈打包
打包前先确认工程可运行。常见检查顺序:
- 看 package.json 里 scripts,确认是 Vue 工程还是 uni-app
- npm install 后 npm run dev 本地跑通
- 确认 WebSocket 地址和 API 地址指到自己的服务器
- 找到 SQL 或初始化脚本,把表建好
npm install npm run dev # 浏览器打开 localhost:8080,确认登录和开盲盒流程走通 npm run buildbuild 产物是后面的打包素材。推荐先跑通再打包,否则问题混在一起,分不清是代码 bug 还是打包配置问题。
4.2 manifest.json 的关键配置
工程里的 manifest.json 要改几个地方。HBuilderX 可视化和源码视图都可以编辑,建议直接看源码视图:
{ "name": "你的应用名", "appid": "__UNI__XXXXXXX", "app-plus": { "distribute": { "android": { "minSdkVersion": 21, "targetSdkVersion": 30, "permissions": [ "android.permission.INTERNET", "android.permission.CAMERA", "android.permission.RECORD_AUDIO" ] } } } }参数说明:name 是 App 在手机桌面显示的名字;minSdkVersion 21 对应 Android 5.0,覆盖绝大多数存量机型;targetSdkVersion 30 对应 Android 11,版本太低上架会被拒,太高会触发新权限模型;permissions 只声明用到的权限,多余权限上架审核会扣分。
4.3 Android 9 的明文流量限制与 WebSocket
如果 WebSocket 用的是 ws:// 而不是 wss://,Android 9(API 28)开始默认禁止明文流量,打包后会出现 H5 能连、App 连不上的现象。处理方式有两种:
方式一:服务器配好 SSL 证书,WebSocket 全部走 wss,最省事,推荐。方式二:允许明文流量。HBuilderX 云打包默认不开放这个开关,需要离线打包工程里改 AndroidManifest.xml:
<application android:usesCleartextTraffic="true" ... />改完重新打包。usesCleartextTraffic 意味着整个 App 的 HTTP 流量都不加密,只能算临时方案。
提示:正式项目优先上 wss,不要依赖 usesCleartextTraffic 放行明文流量。Android 高版本对明文流量的限制只会越来越严。
| 权限 | 用途 | 不声明时的表现 |
|---|---|---|
| INTERNET | 网络请求 / WebSocket | 所有网络请求直接失败 |
| RECORD_AUDIO | 语音聊天 / 语音盲盒 | 录音无声音,权限弹窗不出现 |
| CAMERA | 头像拍摄 / 视频 | 相机黑屏 |
| ACCESS_NETWORK_STATE | 网络切换监听 | 断网重连逻辑受影响 |
4.4 云打包流程与免签封装的区别
HBuilderX 里选择发行 -> 原生App-云打包,传公钥或者用公共测试证书,等云端打完下载 APK。如果要上线,需要自己生成签名证书,keystore 文件一定保存好,后续升级包必须用同一个签名。
热词里提到的“免签封装”是另一类路线,常见于 iOS 的企业签名分发:做一个壳把 H5 包进去,用企业证书签名,不经过 App Store 直接分发。这种方式打包快,但企业证书随时可能被吊销,适合内部测试,不适合作为正式产品的分发方案。无论是云打包还是免签,WebSocket 的配置逻辑不变,协议层的问题在 iOS 和 Android 上都会遇到。
5. 打包为App连接不了:WebSocket 四步定位
“websocket运行到h5可以连接,打包为app连接不了”是这类工程最常见的反馈。H5 环境能连而 App 连不上,说明业务代码大概率没问题,问题出在环境差异,按顺序排查。
5.1 在真机环境复现并抓取日志
HBuilderX 里选择运行 -> 运行到手机或模拟器,App 的 console 日志会输出到 HBuilderX 控制台。这一步能确认四件事:WebSocket 的 readyState 卡在 0 还是到了 3、onerror 里有没有携带错误信息、HTTPS 页面是否混入了 HTTP 资源、鉴权 token 是否已带上。
5.2 分别在 WebView、服务端、抓包工具三个位置看
客户端日志没有明确报错时,抓包看实际握手请求。Charles 或 Fiddler 能看清是 TLS 握手失败、401 鉴权失败,还是请求根本没发出去。服务端同期看 Nginx 访问日志,WebSocket 升级请求(101 Switching Protocols)有没有到达。
5.3 现象速查表
| 现象 | 直接原因 | 排查方向 |
|---|---|---|
| readyState 一直停在 0 | 握手未完成 | 确认 ws/wss 与页面协议一致,服务器端口是否放行 |
| 1006 Abnormal Closure | 连接被中间层断开或证书不受信 | wss 证书链是否完整,Nginx 是否正确代理 Upgrade 头 |
| 400 / 401 后立即关闭 | 鉴权失败 | 查 token 是否过期,query 参数是否被 URL 编码搞乱 |
| 偶尔能连,切网后重连失败 | 断线重连逻辑未生效 | 检查心跳是否续上,重连是否用了新 socket 实例 |
最后一个细节:打包产物里的 WebSocket 地址,我用构建时的环境变量注入,而不是在代码里写死。这样换测试环境和生产环境不用改源码重新打包,也是排查这类问题时的第一道保险。
本文还有配套的精品资源,点击获取