简介:这套源码基于FastAdmin框架,以公众号服务号为入口,定位是可直接部署运营的约玩/树洞陪聊系统,面向具备认证服务号、商户号、服务器和域名的开发者或中小团队。功能上除了聊天,还整合钱包预充值、店员收藏、礼物打赏以及上下级分销返点制度,后端已形成完整闭环;同时附有FastAdmin环境下的安装部署说明,方便快速跑通前后端。资源包共2000个文件,以1291个js业务脚本、226个html页面、131个css样式为主,配以150个md说明、146个json配置、49个txt文本及SQL数据库文件,包体仅约30.4MB,目录结构清晰,便于按前后端模块定位修改。已有27人学习下载;值得一提的是,不同于市场上常见的加密流程版本,这套代码全开源、无加密文件,可直接研究接口、支付与分销逻辑,并根据运营需求调整布局或扩展功能。
1. 树洞/陪聊类公众号源码包,拿到手先别急着解压
“公众号约玩树洞系统,树洞源码,陪聊源码,公众号源码.zip”听着像解压即用,实际要跨过公众号服务器配置、PHP 环境、数据库导入、被动消息响应四层,才能让用户真正在微信里和你对话。zip 里大概率是 PHP 源码、建表 SQL 和 README,真正的地基是把 URL、Token、EncodingAESKey 填对,再让请求落到入口文件。这套交付包面向准备部署上线的开发者,也面向想改造树洞/情感陪伴服务的运营者。后面我会从解压目录讲起,给出一套可落地的消息路由与人工转接代码,再把 Token 失败、消息不回复的常见坑一次讲清。
2. 解压源码后先理清三件事:目录、配置、数据库
2.1 目录骨架和入口文件在哪里确认
一个交付性质的公众号源码包,解压后第一眼不要急着打开 index.php,先做一次“按文件类型扫描”,确认三个东西:入口文件、建表 SQL、配置项。
unzip 公众号约玩树洞系统-源码.zip -d tree-soul cd tree-soul find . -maxdepth 3 -type f \( -name "*.php" -o -name "*.sql" -o -name "*.md" \) | sortunzip直接解出来的目录如果中文名乱码,说明压缩包用 GBK 存了文件名,后面第 5 章会专门说。扫出来之后重点看这几个文件:
index.php或api.php—— 微信公众号后台的 URL 要指向它,通常是接收微信服务器 POST 的入口。config.php/config.ini.php/.env—— 数据库连接、Token、日志开关都在这。install.sql/wechat.sql/init.sql—— 建表脚本。README.md—— 交付作者会把服务器配置步骤写在里面,比代码里写死的内容更可信。
这里有一个常见误判:部分源码包入口不是根目录的index.php,而是app/index.php。你填公众号后台 URL 时必须填到路由真正能解析到的地方。我一般会直接在命令行里用 PHP 内置服务器先试跑一遍:
cd tree-soul php -S 0.0.0.0:8080 -t public curl -I http://127.0.0.1:8080/index.phpphp -S是 PHP 自带开发服务器,-t public把站点根目录指到public文件夹。如果项目依赖路由重写(Apache.htaccess或 Nginxtry_files),内置服务器不一定能完整模拟,只能验证“入口能不能被 PHP 解析”。想看依赖,就cat composer.json或grep -r "require_once" index.php,这比盲猜框架要快。没有composer.json的包通常就是纯 PHP 工程,Nginx 里配置一句location / { try_files $uri $uri/ /index.php$is_args$args; }就能把请求全部交给index.php。
2.2 公众号服务器配置:先把 echostr 这关过了再说业务
微信公众号开发里所谓的“服务器配置”,本质是微信服务器向你的服务器发一次带签名的 GET 请求,要求原样返回echostr。这个校验发生在任何业务逻辑之前,很多树洞/陪聊源码包在 README 里只写了“填入 URL 和 Token”,但没告诉你一旦入口 PHP 文件里有 BOM 头,或者框架自动输出了一行空行,校验就会失败。
后端代码要过的第一道关是这样的:
<?php // 这个值必须和公众号后台填的 Token 完全一致 define('WECHAT_TOKEN', 'tree_soul_2024_token'); $signature = $_GET['signature'] ?? ''; $timestamp = $_GET['timestamp'] ?? ''; $nonce = $_GET['nonce'] ?? ''; $echostr = $_GET['echostr'] ?? ''; // 微信的签名规则:token、timestamp、nonce 字典序排序后做 sha1 $tmpArr = [WECHAT_TOKEN, $timestamp, $nonce]; sort($tmpArr, SORT_STRING); if (sha1(implode($tmpArr)) === $signature) { // 校验通过,原样返回 echostr,微信才会保存配置 exit($echostr); } http_response_code(403);完成签名比对的三个参数里,signature是微信按固定规则算好的,你本地不能改;timestamp和nonce随请求带来,校验时直接用即可。重点说两个坑:
第一,exit($echostr)之前任何一个函数都不能输出内容。config.php里如果末尾多了个换行,php -l不会报错,但微信会收到带空白字符的响应,校验直接失败。此时在浏览器里模拟一下sha1比对,往往能定位出是哪个文件多输出了。
第二,sort()要用SORT_STRING。PHP 默认的sort()在某些版本对纯数字字符串会按数值排序,字符串之间顺序可能与微信期望不一致,表现就是“代码完全没问题但 Token 校验失败”。所有看到sort($tmpArr)的交付代码,我都建议改成显式SORT_STRING。
如果后端代码没有这一层校验逻辑,只有 POST 处理,说明入口设计成“GET 走验证、POST 走业务”两用结构,那你要检查$_SERVER['REQUEST_METHOD']分支是否完整,否则会出现配置保存后回显正常,但真实消息进来不被处理的怪象。
2.3 初始化数据库:先建库导表,再改连接参数
树洞/陪聊这类业务虽然消息入口在公众号,但会话状态、打赏订单、人工转接记录都得落库。一个最小的交付包至少会给你三张表:用户表、消息记录表、关键词配置表。我按一般做法给出建表脚本的骨架:
CREATE DATABASE IF NOT EXISTS tree_soul DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE tree_soul; CREATE TABLE `users` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `openid` VARCHAR(64) NOT NULL, `nickname` VARCHAR(64) DEFAULT '', `balance` DECIMAL(10,2) NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uniq_openid` (`openid`) ) ENGINE=InnoDB; CREATE TABLE `dialog_logs` ( `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `openid` VARCHAR(64) NOT NULL, `direction` TINYINT NOT NULL DEFAULT 0 COMMENT '0=用户发,1=系统回', `msg_type` VARCHAR(16) NOT NULL DEFAULT 'text', `content` TEXT, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_openid_time` (`openid`, `created_at`) ) ENGINE=InnoDB; CREATE TABLE `keyword_routes` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `keyword` VARCHAR(64) NOT NULL, `mode` VARCHAR(32) NOT NULL, `reply` TEXT, `enabled` TINYINT NOT NULL DEFAULT 1 ) ENGINE=InnoDB;重点说两个参数。一个是utf8mb4,用户偶尔会发 emoji,普通的utf8存不进去,整条 SQL 写入会报 “Incorrect string value”。另一个是openid加唯一索引,微信的 openid 是账号维度下的唯一标识,同一用户重复关注不会产生第二行。
导表之后打开config.php,改数据库连接:
return [ 'db' => [ 'host' => '127.0.0.1', 'port' => 3306, 'database' => 'tree_soul', 'username' => 'app_user', 'password' => getenv('DB_PASS') ?: '请改成随机密码', 'charset' => 'utf8mb4', ], 'wechat' => [ 'appid' => 'wx你的appid', 'secret' => '你的secret', 'token' => 'tree_soul_2024_token', ], ];我不建议把密钥写死在代码里,交付包开箱即用可以,但一旦代码上了 Git 仓库,secret就等于泄露了。改用getenv()或者单独放一个.env且让.gitignore排除它,是成本最低的加固方式。连接参数里的port也很容易被忽略:很多 PHP 源码包默认写的是 3306,如果你用云数据库把端口改成了 3307,那报错会是 “Connection refused” 而不是登录失败,排查方向会偏到账号密码上。
数据库连接测试用命令行做最直接:
mysql -h127.0.0.1 -P3306 -uapp_user -p tree_soul -e "select count(*) from users" php -r "new PDO('mysql:host=127.0.0.1;dbname=tree_soul', 'app_user', getenv('DB_PASS')); echo 'ok';"我一般会顺手查一下php -m | grep pdo_mysql,如果云主机上没装这个扩展,PHP 直接白屏,错误信息又不显示,那才是真正的时间黑洞。
3. 让公众号先会说话:消息收发与关键字分流
3.1 被动消息响应链路:XML 解析和 5 秒超时
服务器配置校验通过后,用户在前台给公众号发消息,微信服务器会向你的入口 URL 发一个 POST,Content-Type是text/xml。入口代码需要拿到原始请求体,解析MsgType、Content、FromUserName这几个字段,再返回一个被动回复的 XML。这是比 echostr 更关键的链路,因为树洞/陪聊源码里最容易出现“验证通过但消息没人理”的情况。
一个可以落地的入口处理逻辑:
$raw = file_get_contents('php://input'); // LIBXML_NOCDATA 很关键,否则 CDATA 内容被拿成节点对象 $xml = simplexml_load_string($raw, 'SimpleXMLElement', LIBXML_NOCDATA); if ($xml === false) { log_msg('warn', 'parse xml failed: ' . $raw); exit('success'); } $fromUser = (string)$xml->FromUserName; $toUser = (string)$xml->ToUserName; $msgType = (string)$xml->MsgType; $content = trim((string)$xml->Content); // 无论走什么分支,最后都要返回 XML 或空串 $reply = route_message($fromUser, $msgType, $content); echo build_text_reply($toUser, $fromUser, $reply);file_get_contents('php://input')是拿到微信 POST 原始 XML 的标准姿势,不要用$_POST,微信不是按表单提交发的。LIBXML_NOCDATA也不是可选优化:如果 XML 里<Content><![CDATA[你好]]></Content>,不加这个常量时你会得到一个SimpleXMLElement对象,转字符串结果是空。这是树洞源码里最常见的一类“用户发消息,日志里 content 全空”的来源。
被动回复的 XML 构造:
function build_text_reply($to, $from, $text): string { $time = time(); return <<<XML <xml> <ToUserName><![CDATA[{$to}]]></ToUserName> <FromUserName><![CDATA[{$from}]]></FromUserName> <CreateTime>{$time}</CreateTime> <MsgType><![CDATA[text]]></MsgType> <Content><![CDATA[{$text}]]></Content> </xml> XML; }注意这里ToUserName和FromUserName是反的:发给用户时,ToUserName填用户 openid,FromUserName填你自己的公众号原始 ID(也就是微信 POST 过来时身上的ToUserName)。写反的后果是用户会收到一条发送失败提示,日志里却看不到明显的报错。我早期排查这类问题时,总怀疑是签名问题,后来把入站 XML 和出站 XML 打印出来一对比才看出方向写反了。
被动回复还有一个硬约束:微信要求服务器在 5 秒内返回响应,否则会重试三次,且用户端看到“该公众号暂时无法提供服务”。树洞/陪聊场景里如果你在响应流程中去调一个 3 秒才返回的智能回复 API,就很容易踩中这个超时。稳妥的做法是:入口只做快校验,先返回一句“稍等,正在为你匹配”,真正的内容由异步任务通过客服消息下发。客服消息的发送窗口是用户最近 48 小时内有互动,树洞这种高频聊天场景基本够用。
3.2 关键词分流:树洞、陪聊、约玩怎么落到不同模块
所谓的“树洞系统”和“陪聊源码”,业务上的差异主要不是技术而是话术和流程。有的按关键词区分入口:用户发“树洞”进匿名倾诉,发“陪聊”进付费聊天,发“约玩”进组队匹配。这个分流逻辑在 PHP 里最好维护成一张配置表而不是硬编码 if else。
function route_message($openid, $msgType, $content): string { if ($msgType !== 'text' || $content === '') { return '目前只支持文字消息,请直接输入关键词试试。'; } // 先从数据库读关键词路由,避免每次改规则都动代码 $routes = load_keyword_routes(); // 每行包含 keyword, mode, reply foreach ($routes as $route) { // 支持正则,关键词表里写 /树洞|倾诉/ 这样的模式 if (preg_match($route['keyword'], $content)) { return dispatch_mode($openid, $route['mode'], $content); } } return "回复“树洞”开始匿名倾诉,回复“陪聊”进入人工聊天,回复“约玩”查看当前小伙伴。"; }这里有两个参数值得讲究。一个是匹配方式,我建议在后台存正则而不是存普通字符串,因为“树洞”和“倾诉”很可能是同一类目的不同叫法,一条正则覆盖,比用户在菜单里慢慢找入口体验好。另一个是preg_match的定界符,如果关键词里带/字符(例如约玩群号LOL/王者),忘记转义会让整个匹配静默失败,连报错都不给。因此入库前要做一次preg_quote或校验规则合法性。
分流的最终动作是dispatch_mode(),它决定这轮对话进入哪个状态机:树洞模式只接收内容并回执“已收到”,陪聊模式要判断当前是否有空闲人工,约玩模式可能要写入一条匹配订单。不同模式不会互相干扰,核心是不把会话状态放在全局变量里,而是落库。每次用户消息进来都先查当前 openid 在sessions表里的状态,再接处理函数:
function dispatch_mode($openid, $mode, $content): string { $session = find_active_session($openid); $currentMode = $session ? $session['mode'] : 'default'; if ($currentMode === 'chat' && $mode !== 'cancel') { return send_to_manual_queue($openid, $content); } // 切换模式 if (in_array($mode, ['tree', 'chat', 'play'], true)) { update_session_mode($openid, $mode); return "已进入“{$mode}”模式,直接发消息即可。"; } return '未知指令'; }有状态的分流逻辑才是树洞系统的骨架。否则每次收到消息都当作全新会话,用户先发“树洞”再倾诉一段很长的心事,后端只会再次弹菜单,这种翻车现场几乎是所有陪聊源码被吐槽“机器人太笨”的头号原因。
3.3 用 cron 驱动状态流转:让超时订单自动掉线
公众号源码里不能只靠“用户发一条,后端回一条”的被动模型,很多流程需要定时器:树洞留言超过 2 小时没人回要自动打回待处理队列,陪聊会话超过 20 分钟没新消息要把人工客服释放出来。PHP 没有常驻进程,最常见的落地做法是 cron 每分钟跑一个 CLI 脚本。
# crontab -e,注意 php 路径要用 which php 确认 * * * * * /usr/bin/php /data/www/tree-soul/bin/release_timeout_sessions.php >> /data/www/tree-soul/logs/cron.log 2>&1对应的 PHP 脚本:
#!/usr/bin/env php <?php require __DIR__ . '/../config.php'; // 把超过 20 分钟没有任何新消息的陪聊会话释放掉 $stmt = $pdo->prepare( 'UPDATE sessions SET status = :free WHERE mode = "chat" AND status = "occupied" AND updated_at < DATE_SUB(NOW(), INTERVAL 20 MINUTE)' ); $stmt->execute([':free' => 'free']); // 把树洞里等待回复超过 2 小时的留言标为“需要人工介入” $pdo->exec( 'UPDATE dialog_logs SET need_manual = 1 WHERE need_manual = 0 AND direction = 0 AND created_at < DATE_SUB(NOW(), INTERVAL 2 HOUR)' );cron 脚本的关键不是 SQL 本身,而是幂等和日志。SQL 里的WHERE status = "occupied"保证了同一会话不会被重复释放;updated_at字段需要在你每次收到用户消息时都刷新,否则 20 分钟不更新可能是人工还在编辑回复,不是真的没人管。
我一般会把这类脚本的执行结果写进独立文件,而不是直接丢系统日志。因为cron.log里全是 cron 本身的空输出,真正要排查的是“为什么今天凌晨没有释放会话”——这时脚本里只有一个echo是远远不够的,要有执行行数、耗时、错误信息。
这里也点一下异步通知的边界:cron 能处理“超时”,但处理不了“实时来了新消息要立刻找人”,后者要么用队列服务(Redis + 消费进程),要么退而求其次,让每轮关键词路由直接尝试匹配人工空闲。对树洞/陪聊这种低频小团队业务,直接用数据库轮询反而是最稳的,别一上来就上消息队列。
4. 陪聊业务闭环:人工转接、订单与支付回调
4.1 三条人工转接路线:多客服转发、客服消息接口、企业微信接入
陪聊/约玩类公众号真正的核心不是自动回复,而是“把机器人会话交给真人”。源码包里常写的转接方式有三种。
第一种是被动响应里返回多客服转发 XML,用户端会切到公众号自带的“客服”聊天界面。这是老一代做法,依赖服务号权限。
第二种是用客服消息接口主动推送,任意消息进来后 48 小时内都可以调接口发文本,适合“我先秒回‘收到’,再把内容交给人工,人工回复后主动推给用户”的时序。被动转接会遇到一个现实问题:一旦切到多客服,你后端的路由控制就弱了,无法按画像分人工。所以我更推荐第二种思路:后端永远保持被动回复的主动权,人工只是在会话表里多读一条待办。
function send_customer_msg($openid, $text): bool { $accessToken = get_access_token(); $url = 'https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=' . $accessToken; $body = json_encode([ 'touser' => $openid, 'msgtype' => 'text', 'text' => ['content' => $text], ], JSON_UNESCAPED_UNICODE); $resp = curl_post($url, $body); if (isset($resp['errcode']) && $resp['errcode'] === 0) { return true; } // 45015 / 48001 等错误码需要单独处理 log_msg('error', 'custom msg failed: ' . json_encode($resp, JSON_UNESCAPED_UNICODE)); return false; }这条链路里最容易出问题的是access_token。微信的access_token有效期两小时,公众号后台有 IP 白名单限制,而且同一时刻只能存在两份有效 token。源码里如果每个用户消息都去重新获取,高频场景下会把老 token 顶掉,表现为“谁发消息都成功,但只有最后一个人的消息能收到回复”,这是典型的 token 生命线没人管。
常用做法是拿一个本地缓存文件或数据库表存 token,加一个提前 5 分钟的过期判断:
$cached = $pdo->query('SELECT token, expires_at FROM wechat_tokens WHERE id=1')->fetch(); if ($cached && $cached['expires_at'] > time() + 300) { return $cached['token']; } // 重新获取并更新缓存第三种路线是接企业微信或自建 Web 工作台,让客服在浏览器里处理树洞消息。这个方案适合多人团队,但已经不是公众号源码本身能覆盖的范畴,通常需要你把dialog_logs表导出一个待办视图,再单独做一个小后台。树洞/陪聊源码里如果有现成的管理后台,优先复用它的会话列表,没有就先用数据库命令行盯表。
4.2 微信支付回调验签:金额、订单号、签名一起校验
约玩和陪聊一旦涉及收费,订单状态就要依赖微信支付的回调。支付成功之后,微信服务器向你配置的“支付通知地址”POST 一段 XML,你的程序必须在 15 秒内返回SUCCESS,而且验签、幂等、金额比对一步都不能省。
$raw = file_get_contents('php://input'); $data = xml_to_array($raw); if (($data['return_code'] ?? '') !== 'SUCCESS') { exit('<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[失败]]></return_msg></xml>'); } // 第一步:用商户密钥重新算签名 $sign = build_pay_sign($data, MERCHANT_KEY); if ($sign !== ($data['sign'] ?? '')) { exit('<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[签名失败]]></return_msg></xml>'); } // 第二步:比对订单号、金额、商户号 $order = find_order($data['out_trade_no']); if (!$order || $order['total_fee'] !== intval($data['total_fee']) || $data['mch_id'] !== MERCHANT_ID) { exit('<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[参数不一致]]></return_msg></xml>'); } // 第三步:幂等更新,避免重复回调 mark_order_paid($order['id'], $data['transaction_id']); // 第四步:回执,微信收到后停止重试 exit('<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>');验签算法本身不难,但三个参数必须同时校验。只验签不看金额,被篡改回调后订单状态也会被打成已支付;只比订单号不看商户号,多账号跑同一套源码时可能互相发通知。金额的单位必须用“分”,微信文档里total_fee的单位是分,如果代码里直接拿元去比,用户付了 10 元你会以为他付了 100 元或者反过来,这种错账通常是上线后才发现,属于最伤的一类 bug。
mark_order_paid的幂等写法是灵魂:加一个UPDATE orders SET status='paid' WHERE id=:id AND status='pending',并检查rowCount(),返回 1 才说明是首次进入已支付状态。否则微信重试的同一条回调可能把订单总额、赠品等字段重复累加。
4.3 会话表设计:防止两个客服抢同一个用户
树洞/陪聊业务多人同时在线时,会话表要承担“锁”的功能。一张最小可用的sessions表结构如下:
CREATE TABLE `sessions` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `openid` VARCHAR(64) NOT NULL, `mode` VARCHAR(16) NOT NULL DEFAULT 'tree', `status` VARCHAR(16) NOT NULL DEFAULT 'waiting' COMMENT 'waiting/occupied/free/closed', `kf_id` INT UNSIGNED DEFAULT NULL COMMENT '当前接单的人工客服ID', `last_msg_at` DATETIME NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_status_mode` (`status`, `mode`) ) ENGINE=InnoDB;人工接单时,不能用“先查一个 waiting 会话再更新”这种两步走,否则两个人同时点接单,会读到同一条。落地写法是:
function grab_session($kfId, $sessionId): bool { $sql = 'UPDATE sessions SET status = "occupied", kf_id = :kf WHERE id = :id AND status = "waiting"'; $stmt = $pdo->prepare($sql); $stmt->execute([':kf' => $kfId, ':id' => $sessionId]); return $stmt->rowCount() === 1; }这条UPDATE ... WHERE status='waiting'是行级锁的乐观实现,只有一条会话能被抢到,第二次抢同一单返回rowCount()为 0。这个细节很多“陪聊源码”交付包都懒得写,导致运营起来出现两个人回复同一个人,用户被吓跑。
last_msg_at字段也要在每次用户消息到达时刷新,它同时被 cron 释放逻辑和人工工作台的“超时未回复”列表使用。字段名和时间精度要统一,PHP 端用date('Y-m-d H:i:s')写入,MySQL 端用NOW(),避免时区或格式偏差导致 20 分钟判断失效。
5. 公众号源码部署避坑:Token 失效与消息静默
5.1 Token 校验一直失败:先排除 BOM 和多余输出
现象:公众号后台保存配置提示“Token 验证失败”,代码看起来和 README 里一模一样。
原因:最常见有三类。一是index.php被文本编辑器保存成带 BOM 的 UTF-8,BOM 在<?php之前输出了三个不可见字节;二是config.php或公共函数文件在末尾多了一个空行、一个?>后有空格;三是入口文件在exit($echostr)之前调了session_start()或某个自动加载函数,向客户端吐了内容。
解决:先php -l检查语法,再看文件头是不是EF BB BF:
php -l index.php head -c 8 index.php | od -An -tx1看到efbbbf开头就去掉 BOM。接着在浏览器直接访问带参数的 URL,对比微信签名和你本地算的sha1:
curl -G "https://你的域名/index.php" \ --data-urlencode "timestamp=1700000000" \ --data-urlencode "nonce=12345"curl 收到响应后,把响应头Content-Length与实际长度对齐。如果Content-Length比echostr长度多,就说明前面有多余输出。同道排查法是在入口文件顶部加ini_set('display_errors','Off'),同时配合错误日志,让问题从“白屏”变成“日志”。
5.2 消息发出但公众号不回复:被动消息 5 秒超时和重试
现象:用户能看到自己发出去的消息,但公众号一直转圈然后提示“该公众号暂时无法提供服务”,查看后端日志发现入口其实被访问了几次。
原因:微信在 5 秒内没收到你的被动响应,会重试三次。重试时MsgType和内容不变,所以你的代码每次都会重新处理一遍。如果处理逻辑里有对外部 API 的同步调用(智能回复、敏感词云接口),就可能每次都超时,用户端表现为永远不回。
解决:入口只处理“该收的收到了”并立即回执,真正的生成内容放到异步任务里:
$raw = file_get_contents('php://input'); // 快速入库 save_raw_message($raw); // 立刻返回 success,微信不再重试 echo 'success';随后由队列消费或 cron 轮询pending_logs,用客服消息接口把结果主动推给用户。如果只是想维持一个最小可用的陪聊业务,我建议在入口同步返回一条固定文案比如“正在为你匹配,请稍候”,把耗时的内容推到 2 秒之后的任务里去。需要提醒的是,echo 'success'是纯文本不是 XML,微信因此判定这个 POST 已经被正常消费,这个字符串本身也是约定的,不能乱改成别的英文。
5.3 zip 解压出现乱码和“文件不完整”
现象:解压后目录是乱码,index.php打开后开头几行正常,后面的中文注释变成�;进 Linux 后find看到的文件名完全对不上 README。
原因:zip 包在 Windows 下打包时默认用 GBK 存文件名,而 Linux 的unzip用 UTF-8 解释,导致文件名乱码;另一个高频原因是传输工具在 FTP/网盘下载时把 zip 当文本文件改坏,文件大小对不上。
解决:文件名乱码用unzip -O GBK解:
unzip -O GBK 公众号约玩树洞系统-源码.zip -d src/文件内容本身乱码则要看 zip 内部是否已经是乱码,建议先用unzip -l查看列表,再决定要不要重新找交付方要一份 UTF-8 打包的版本。解压完成后检查权限,PHP-FPM 写的缓存和日志目录要可写:
chown -R www-data:www-data src/storage src/logs chmod -R 755 src权限过松会出现另一个隐患:config.php被其他人读取,微信secret和数据库密码直接暴露,所以755只是目录权限,配置文件我一般单独chmod 600。
5.4 网页授权域名、IP 白名单和“链接内容不属于当前公众号”
现象:网页授权链接打不开,或用户点开公众号菜单里的链接,提示“链接内容不属于当前公众号”。
原因:公众号后台“网页授权域名”填的是域名而不是完整路径,回调地址却带了http://和路径,导致微信拿不到预期的域名;同一域名如果被多个不同的公众号绑定,用户走 A 公众号访问却用 B 公众号的 appid 请求授权,也会出现“内容不属于当前公众号”的提示。
解决:网页授权域名只填example.com,不带www不带路径;在这个域名下放一个校验文件(微信后台会生成MP_verify_xxxx.txt)放在网站根目录。回调地址里的redirect_uri必须 URL 编码后再拼,而且回调路径要和你填的域名同源。排查时先打印你拼接出来的redirect_uri,再去微信后台确认它确实属于当前公众号的授权域名。网络层的 IP 白名单则换一种表现:获取access_token时报错40164,原因是你的服务器出口 IP 不在公众号后台白名单里,添加公网 IP 后等 1 分钟再试。这里容易把“测试环境 IP”和“线上 IP”搞混,上线当天最容易翻车的就是把本地联调的 IP 当成线上出口。
6. 上线前少做一步,后悔药都找不到
二维码贴出去之前,我每次都会强迫自己把三件事做完,否则不碰线上配置。
第一件事,把所有调试用的入口封死。源码包里常见的test.php、demo.php、debug.php这类文件,线上环境直接改名加.bak或者删除,只留入口和计划任务需要的 CLI 文件。我的习惯是写一个 Nginx 的location规则,把/tests/、/debug这类路径直接deny all,比在 PHP 层判断更快。
第二件事,把日志补上。很多交付源码只有一个echo或者完全静默,上线后用户反馈“没反应”,你连错误日志都翻不到。我会先加一个最朴素的文件日志函数,保证所有入口都有一条记录:
function log_msg($level, $msg): void { $line = sprintf( "[%s] [%s] %s".PHP_EOL, date('Y-m-d H:i:s'), strtoupper($level), $msg ); file_put_contents( dirname(__DIR__).'/logs/app.log', $line, FILE_APPEND | LOCK_EX ); }FILE_APPEND | LOCK_EX是让多进程并发写时不相互覆盖,日志目录提前建好并授权给 PHP-FPM 用户。排查消息不回复时,我会在save_raw_message()和route_message()两处各打一行,靠这两行日志的时间差判断是入口没收到,还是处理逻辑卡住。
第三件事,给内容安全留一道钩子。树洞、陪聊这类 UGC 场景,用户输入不能直接透传,至少要做一层敏感词拦截和“是否涉及个人隐私”的提示。最粗的实现也能挡住八成的麻烦事:
$banned = ['联系方式', '私聊', '线下交易']; foreach ($banned as $word) { if (mb_strpos($content, $word) !== false) { log_msg('policy', "hit: {$word} openid={$openid}"); return '为了账号安全,这里不能展示这类内容,请换个说法。'; } }如果把过滤逻辑放在route_message()最前面,它还能顺带挡住关键词路由里的骚扰类内容,比在客服人工环节拦要好得多,至少不会让真人客服对着违规消息浪费时间,也不会让账号因为一条漏网内容被投诉。
这些年我接手过的公众号源码包里,凡是“解压就能跑”的开箱项目,上线顺序里都有同一个陷阱:重功能、轻运维。表面上路由、支付、人工都能跑通,但一旦少了日志、少了钩子、少了幂等,真实流量一来就变成黑匣子。我的习惯是把这三步当作上线清单而不是可选项,宁可晚一天发布,也不能让第一个用户替你踩坑。希望帮到你。
本文还有配套的精品资源,点击获取