☰
公众号约玩树洞系统源码解析:匿名会话与陪聊计费设计
2026/10/20 19:19:22 网站建设 项目流程

简介:这份资源是面向公众号运营者与陪聊、约玩类社交产品开发者的树洞系统源码,基于FastAdmin框架构建,属于全开源版本,与市面上常见的加密版不同,其api控制器目录未做加密处理,便于二次开发与调试。前端为H5编译版,需搭配认证服务号使用,支持钱包预充值、店员收藏、礼物打赏,以及上下级分销与多分销返点开通制度,适合想快速搭建陪聊社交平台的团队或个人。压缩包共约2000个文件,以1291个js脚本、226个html页面、131个css样式及146个json配置为主,另有sql建表文件与md说明文档,整体约30.4MB,目录结构完整。目前已有27人浏览学习。资源附带演示站点与后台账号,并说明部署需准备认证服务号、商户号、服务器和域名,建议用电脑查阅文档以提升部署效率,可帮助读者省去从零搭建的时间,直接研究分销、打赏与充值等核心模块的实现思路。

1. 公众号约玩树洞系统到底在做什么:从一条匿名消息到一次陪聊订单

很多人第一次听到「公众号约玩树洞系统」这个词,脑子里浮现的是校园论坛里那种匿名发帖、别人跟帖安慰的板块。真做过一套能跑起来的树洞源码之后你会发现,它和论坛是两回事:论坛的核心是「帖子 + 回复」,而树洞系统的核心是「匿名会话 + 陪聊关系 + 订单结算」。用户从公众号菜单点进来,发一条不想让熟人知道的心事,系统要决定这条消息是丢进公共树洞池,还是转给一个在线的陪聊者,聊完之后还要处理计时、分成、评价。这一整条链路,才是「约玩」两个字真正的分量。

这套东西适合谁?一类是想做情感陪伴、匿名倾诉方向的小团队,手里有公众号流量但缺一个能承接私聊和计费的工具;另一类是做私域运营的开发者,想给现有粉丝加一个「树洞」入口提高停留时长。它不适合想一夜起量的人,因为陪聊是重人力业务,系统只解决匹配和结算,解决不了供给。下面我按「先跑通最小闭环,再补细节」的顺序,把公众号源码里最关键的几块拆开讲,包括数据库怎么设计、消息怎么路由、计时怎么算钱,以及我踩过的那些坑。

2. 树洞源码的会话模型与数据库设计:匿名关系怎么存才不塌

2.1 为什么不能用「用户表 + 消息表」硬扛

新手最容易犯的错,是拿一套普通 IM 的表结构直接套树洞。普通 IM 里,发送方和接收方都是确定的用户 ID,一条消息存from_uid、to_uid就完事。但树洞的匿名性要求「陪聊者看不到倾诉者的真实身份,倾诉者也看不到陪聊者是谁」,同时平台又必须在后台能追溯、能结算。这意味着身份要分两层:一层是公众号 openid 对应的真实账号,另一层是每次会话临时生成的匿名身份。

我一般会拆成四张核心表:user(真实账号,存 openid、余额、状态)、session(一次匿名会话,存双方匿名 ID、开始结束时间、计费状态)、message(消息,只认 session_id 和匿名发送方)、wallet_log(资金流水)。关键在于session表,它是匿名关系和真实关系的唯一桥梁,后台靠它追溯,前端靠它隔离。

-- 会话表:匿名关系的核心,一次陪聊对应一行 CREATE TABLE `session` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `session_no` CHAR(24) NOT NULL COMMENT '对外暴露的会话号,不含任何用户信息', `seeker_anon` CHAR(16) NOT NULL COMMENT '倾诉者匿名ID,每次会话重新生成', `listener_anon` CHAR(16) NOT NULL COMMENT '陪聊者匿名ID', `seeker_uid` BIGINT UNSIGNED NOT NULL COMMENT '真实用户ID,仅后台可见', `listener_uid` BIGINT UNSIGNED NOT NULL COMMENT '真实陪聊者ID,仅后台可见', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待接单 1进行中 2已结束 3已取消', `start_at` DATETIME NULL COMMENT '实际开始计时时间', `end_at` DATETIME NULL, `bill_seconds` INT NOT NULL DEFAULT 0 COMMENT '计费秒数,去掉免费时长', `amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '本单金额', PRIMARY KEY (`id`), UNIQUE KEY `uk_session_no` (`session_no`), KEY `idx_listener_status` (`listener_uid`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段建表里有两个设计点值得说。第一,session_no是对外唯一标识,前端传参只传它,绝不传 uid,这样即使被抓包也拿不到真实身份。第二,bill_seconds和amount冗余存在会话表里,而不是每次去流水表聚合算,因为结算页要高频读取,聚合查询在会话量大时会拖慢响应。代价是要保证写入一致,这个后面避坑章节会讲。

2.2 匿名 ID 的生成与生命周期

匿名 ID 不能简单用 uid 哈希,否则同一个人多次进树洞,陪聊者能通过哈希值认出是老客户,匿名就破了。常见做法是每次会话生成一个随机串,和 session 绑定,会话结束即失效。生成逻辑放在服务端,用加密安全随机数,别用时间戳。

import secrets def gen_anon_id(prefix: str) -> str: # 16 位十六进制随机串,前缀区分倾诉者/陪聊者,方便日志排查 return prefix + secrets.token_hex(7) # 倾诉者匿名ID以 s_ 开头,陪聊者以 l_ 开头 seeker_anon = gen_anon_id("s_") listener_anon = gen_anon_id("l_")

secrets.token_hex(7)产出 14 位十六进制字符,加前缀共 16 位,碰撞概率在单日十万级会话下可以忽略。用secrets而不是random,是因为random的种子可预测,理论上能被推算出下一个匿名 ID,虽然危害有限,但没必要留这个口子。前缀的作用是排查日志时一眼看出这条消息是谁发的,省得去查库。

2.3 消息表的分表时机

消息表是增长最快的表。单表超过两千万行之后,按 session_id 查询会明显变慢。我的经验是,日活过五千、人均消息二十条以上,就该考虑按月分表,比如message_202401。分表键用session_id取模或按时间,取决于查询模式:如果主要是「拉某个会话的历史消息」,按 session_id 哈希分表更均匀;如果还要做「查某人某天的消息」,按时间分表更合适。树洞场景以后者为主,因为客服介入和风控审计都按时间查。

3. 公众号消息路由与陪聊匹配:一条消息该进树洞池还是私聊

3.1 公众号入口的三种消息类型

公众号收到用户消息后,服务端拿到的是 XML 或 JSON 事件推送,里面只有 openid、消息类型、内容。系统要做的第一件事是判断这条消息的意图:是发树洞、是找陪聊、还是普通聊天。判断依据通常是关键词加菜单事件。比如用户点「我要倾诉」菜单,微信推来的是event类型,EventKey带自定义参数;用户直接打字,就是text类型。

def route_message(openid: str, msg_type: str, content: str, event_key: str = ""): # 菜单事件优先,用户点了明确入口就按入口走 if msg_type == "event" and event_key.startswith("TREEHOLE"): return handle_treehole_entry(openid, event_key) if msg_type == "text": # 文本消息先查是否有进行中的会话,有就续聊,没有才当新树洞 active = get_active_session(openid) if active: return forward_to_session(active, content) return create_treehole_post(openid, content) return reply_text(openid, "发送文字才能投递树洞哦")

这段路由的顺序很关键:先查进行中会话,再决定是否新建。如果反过来,用户正在陪聊中发一句话,系统又给他新建一个树洞帖,会话就乱了。get_active_session要加缓存,因为每条消息都查库扛不住,用 Redis 存openid -> session_no映射,设置和会话同长的过期时间。

3.2 陪聊匹配的三种策略与选择

匹配陪聊者,常见有三种做法。第一种是抢单制,树洞帖进公共池,在线陪聊者谁先点谁接,适合供给充足、追求响应速度的场景。第二种是派单制,系统按标签(情感、职场、学业)和负载均衡指派,适合服务质量要求高的场景。第三种是预约制,用户指定时间段,适合高客单价。多数公众号源码默认是抢单制,因为实现最简单,但抢单制有个隐患:陪聊者会挑轻松的接,难缠的没人理。

我一般会做混合:先按标签过滤出候选池,再在候选池里按当前会话数升序排,取前若干名推送接单通知,谁先响应算谁的。这样既保证标签匹配,又避免一个人同时接太多单。

def match_listeners(tag: str, limit: int = 5): # 候选:标签匹配 + 在线 + 当前会话数未超上限 sql = """ SELECT listener_uid, online, active_cnt FROM listener_profile WHERE tag = %s AND online = 1 AND active_cnt < max_cnt ORDER BY active_cnt ASC, last_active_at DESC LIMIT %s """ return db.query(sql, (tag, limit))

active_cnt是当前进行中的会话数,max_cnt是每个陪聊者的并发上限,一般设 1 到 3。设 1 体验最好但供给利用率低,设 3 以上容易出现回复延迟。last_active_at作为次级排序,让最近活跃的人优先,避免通知发给已经挂机的人。

3.3 消息转发的实时性怎么保证

公众号本身不是实时通道,用户发消息后,陪聊者那边要收到提醒,靠的是服务端主动推送。常见做法是陪聊者端用长连接(WebSocket)或轮询。如果陪聊者也是通过公众号操作,那就只能靠模板消息或客服消息推送,延迟在秒级到分钟级。这里有个现实约束:公众号客服消息有 48 小时窗口限制,超过窗口不能主动发。所以陪聊者端我建议单独做一个 H5 页面走 WebSocket,公众号只负责入口和通知,真正的聊天在 H5 里进行。

4. 计时计费与结算:陪聊按分钟算钱最容易翻车的地方

4.1 计费起点到底从哪一刻算

这是纠纷最多的地方。用户点了「开始陪聊」,但陪聊者可能三十秒后才回第一句。如果从点击就开始计时,用户觉得亏;如果从第一句回复开始算,陪聊者又可能被白嫖。我的做法是设一个免费等待时长,比如 60 秒,从会话创建开始算,60 秒内陪聊者首次回复则正式计时从回复那一刻起,超过 60 秒未回复则自动取消并全额退款。这个规则要写进用户协议,前端也要明确提示。

FREE_WAIT_SECONDS = 60 def start_billing(session): now = time.time() created = session.created_at.timestamp() if now - created > FREE_WAIT_SECONDS: # 超时未回复,取消并退款 cancel_session(session, reason="listener_timeout") return session.start_at = datetime.now() session.status = 1 # 进行中 session.save()

FREE_WAIT_SECONDS这个值不要设太大,超过 90 秒用户会不耐烦;也不要太小,30 秒内陪聊者可能还在看前一条消息。60 秒是实测下来投诉率最低的区间。

4.2 计费秒数怎么算才不扯皮

计费秒数 = 结束时间 - 开始时间 - 免费时长(如果规则里有)。但结束时间由谁触发?用户点「结束」、陪聊者点「结束」、还是系统超时自动结束?三种都要支持,且以最先触发的为准。系统超时一般设 30 分钟无消息自动结束,防止用户忘记点结束一直挂着计费。

def calc_bill_seconds(session): if not session.start_at or not session.end_at: return 0 raw = int((session.end_at - session.start_at).total_seconds()) # 不足一分钟按一分钟算,这是行业惯例,要提前告知 minutes = max(1, math.ceil(raw / 60)) return minutes * 60

math.ceil向上取整是陪聊行业的普遍做法,但必须在开始前明确告知用户,否则就是隐形消费。我见过有系统按秒计费却宣传「按分钟」,结算时用户发现多扣了钱,投诉到平台。规则透明比算法精巧重要得多。

4.3 分成与提现的账要分开记

陪聊者拿到的不是全额,平台要抽成。抽成比例常见 20% 到 30%。这里的关键是:用户支付的金额、平台抽成、陪聊者收入,三笔账要分别记流水,不能只记一个净额。因为退款时要按比例退,对账时要能拆开看。

字段含义示例
amount用户支付总额30.00
platform_fee平台抽成6.00
listener_income陪聊者收入24.00
refund_amount退款金额(如有)0.00

退款时按amount比例退,同时冲减platform_fee和listener_income。如果只记净额,退款算不清,这是财务对账时最容易被忽略的坑。

5. 避坑与排查:树洞系统上线后最常炸的五个地方

5.1 现象:用户重复扣费,余额对不上

原因通常是结算接口没有做幂等。用户网络卡顿连点两次「结束」,或者前端重试,导致同一会话被结算两次。解决方法是给结算操作加唯一约束,用session_no作为幂等键,结算前先查状态,已结束的直接返回上次结果。

def settle_session(session_no): # 用数据库行锁保证同一会话只结算一次 with db.transaction(): s = db.query_one( "SELECT * FROM session WHERE session_no=%s FOR UPDATE", (session_no,)) if s.status == 2: return {"code": 0, "msg": "already settled", "amount": s.amount} # ... 执行结算逻辑 s.status = 2 s.save()

FOR UPDATE行锁是关键,没有它两个并发请求会同时读到「进行中」状态,各结算一次。

5.2 现象:陪聊者能看到倾诉者历史会话

原因是匿名 ID 复用了,或者前端把 uid 带进了接口返回。排查时先看接口返回体里有没有 uid 字段,再看匿名 ID 生成逻辑是不是每次新建。解决方法是接口层做字段白名单,只返回该返回的,别图省事SELECT *直接序列化。

5.3 现象:消息延迟高,用户以为对方不理人

多半是轮询间隔太长,或者 WebSocket 断线没重连。排查看服务端推送日志和客户端心跳。解决方法是心跳设 30 秒,断线后指数退避重连,同时前端显示「对方正在输入」这类状态,降低等待焦虑。

5.4 现象:计费时长和用户感知差很多

常见原因是开始计时的时间点和用户理解的不一致。用户以为从自己发第一句开始,系统从会话创建开始。解决方法是前端在会话页顶部实时显示已计费时长,让用户随时能看到,透明化能消掉大部分争议。

5.5 现象:公众号客服消息发不出去

48 小时窗口过期了。用户最后一次互动超过 48 小时,公众号就不能主动推送。解决方法是把重要通知(如接单提醒)放在用户刚发消息后的窗口内发,或者引导用户到 H5 端接收 WebSocket 通知。

6. 让树洞系统跑得更稳的两个进阶技巧

第一个技巧是给会话加状态机,而不是用一堆 if 判断。会话状态就那几个:待接单、进行中、已结束、已取消、已退款。每个状态能做什么操作是固定的,用状态机管起来,代码里就不会出现「已取消的会话还能结算」这种玄学 bug。我一般用一个简单的状态转移表,操作前先校验当前状态是否允许该操作。

当前状态允许操作目标状态
待接单接单进行中
待接单取消已取消
进行中结束已结束
进行中超时已结束
已结束退款已退款

第二个技巧是给计费加一个「后悔药」机制。用户结束会话后 5 分钟内可以发起申诉,系统冻结该笔结算,人工介入。这 5 分钟冻结期能挡掉大部分误操作和情绪化投诉,比事后扯皮成本低得多。实现上就是在结算时把listener_income标记为「待解冻」,5 分钟后定时任务再转为可提现。

def settle_with_hold(session): # 结算但资金冻结,5分钟后由定时任务解冻 income = session.amount * (1 - PLATFORM_RATE) wallet_log.create( listener_uid=session.listener_uid, amount=income, status="holding", # 冻结中 release_at=now() + timedelta(minutes=5), session_no=session.session_no, )

PLATFORM_RATE是平台抽成比例,release_at是解冻时间。定时任务每分钟扫一次到期的冻结记录,转成可提现余额。这个机制上线后,我们那边的结算投诉少了一大半,因为用户知道还有申诉窗口,情绪就没那么冲。

做这类系统这些年,我最大的教训是:技术难点从来不在代码,而在规则设计。匿名怎么匿、计时从哪算、退款怎么退,这些规则一旦定错,代码写得再漂亮也救不回来。所以我现在做任何陪聊类项目,第一步不是建表,是先把规则写成文档,和运营逐条对一遍,确认没有歧义再动手。希望帮到你。

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

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

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

立即咨询