
简介这份资源是面向公众号运营者与陪聊、约玩类社交产品开发者的树洞系统源码基于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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表里有两个设计点值得说。第一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, reasonlistener_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 * 60math.ceil向上取整是陪聊行业的普遍做法但必须在开始前明确告知用户否则就是隐形消费。我见过有系统按秒计费却宣传「按分钟」结算时用户发现多扣了钱投诉到平台。规则透明比算法精巧重要得多。4.3 分成与提现的账要分开记陪聊者拿到的不是全额平台要抽成。抽成比例常见 20% 到 30%。这里的关键是用户支付的金额、平台抽成、陪聊者收入三笔账要分别记流水不能只记一个净额。因为退款时要按比例退对账时要能拆开看。字段含义示例amount用户支付总额30.00platform_fee平台抽成6.00listener_income陪聊者收入24.00refund_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_uidsession.listener_uid, amountincome, statusholding, # 冻结中 release_atnow() timedelta(minutes5), session_nosession.session_no, )PLATFORM_RATE是平台抽成比例release_at是解冻时间。定时任务每分钟扫一次到期的冻结记录转成可提现余额。这个机制上线后我们那边的结算投诉少了一大半因为用户知道还有申诉窗口情绪就没那么冲。做这类系统这些年我最大的教训是技术难点从来不在代码而在规则设计。匿名怎么匿、计时从哪算、退款怎么退这些规则一旦定错代码写得再漂亮也救不回来。所以我现在做任何陪聊类项目第一步不是建表是先把规则写成文档和运营逐条对一遍确认没有歧义再动手。希望帮到你。本文还有配套的精品资源点击获取