ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP客服系统源码:EasySwoole常驻内存WebSocket与Redis消息分发实战

PHP客服系统源码:EasySwoole常驻内存WebSocket与Redis消息分发实战 简介这是基于EasySwoole框架、Redis缓存、MySQL数据库和LayIM即时通讯组件构建的客服系统源码面向需要快速搭建在线客服平台的PHP开发工程师适合有一定Swoole基础并希望深入协程、WebSocket长连接应用的读者。压缩包共三十三个文件其中十四个PHP业务文件覆盖WebSocket控制器、HTTP控制器、事件注册与工具类是理解系统逻辑的核心两个SQL脚本完成聊天记录表和房间表的建表操作两个HTML文件对应客服端与用户端界面另有Dockerfile、docker-compose.yml、composer.json等配置与部署辅助文件整体仅三百零五KB。项目完整演示了EasySwoole协程处理并发长连接、Redis缓存会话与消息以降低数据库压力、MySQL持久化用户与聊天日志并借助LayIM双向通信实现文本、图片等消息的实时收发客服人员可一对多并行接待客户。已有三百零四人浏览学习对照源码可以看清即时通讯系统的完整链路方便按业务需求扩展消息类型、完善会话管理是学习和二次开发的实用参考。1. 一套 PHP 客服系统源码EasySwoole 常驻内存跑 WebSocket多少项目够用做客服系统是我见过最多“半成品”的方向之一。不少团队聊到即时通讯就默认要上 Java Netty 或 Go实际上用 PHP 技术栈也能做出能用的客服 IM服务端用 EasySwoole 常驻内存前端用 LayIM中间用 Redis 扛在线状态和消息转发MySQL 落历史记录。这套源码把整条链路已经搭好不是 PPT 架构是能直接php easyswoole start跑起来的工程。它解决的核心问题很具体前台用户发起会话消息怎么实时推到客服工作台、客服不在线时消息怎么补、聊天记录怎么落库。适合用 PHP 做业务但被长连接卡住的中小团队也适合想摸清 WebSocket 加 Redis 消息队列这套配合的开发者不需要你一开始就吃透 Swoole 全部底层跟着链路改就行。2. 三层链路先立住WebSocket 网关、Redis 消息分发、MySQL 落库各管什么客服系统最容易翻车的地方是所有人把逻辑都堆在 MySQL 上在线状态查库、消息分发查库、历史记录也查库连接瞬间被打满。这套源码把职责拆成三层EasySwoole 的 WebSocket 服务只负责维持长连接和收消息Redis 负责在线状态、消息转发和离线缓冲MySQL 只落最终聊天记录。下面把链路先讲透后面启动服务你才知道每一步在干什么。2.1 连接接入层EasySwoole 的 WebSocket 事件注册与 Worker 进程模型EasySwoole 是跑在 Swoole 之上的常驻内存框架启动后进程模型分三层Master 进程管事件循环Manager 负责 fork 和管理 WorkerWorker 才是真正干活的地方。它跟 PHP-FPM 最大的区别是进程不销毁连接状态、全局变量、Redis 连接都能复用代价是一旦代码里有死循环或阻塞 IO整个 Worker 都会卡住。事件注册统一在EasySwooleEvent.php里做WebSocket 的三个关键事件是 onOpen、onMessage、onClose。?php // EasySwooleEvent.php 中 mainServerCreate 方法的核心片段 use EasySwoole\EasySwoole\Swoole\EventRegister; public static function mainServerCreate(EventRegister $register): void { $register-add(EventRegister::onOpen, function (\Swoole\WebSocket\Server $server, \Swoole\Http\Request $request) { $fd $request-fd; $userId (int) $request-get[user_id]; // 握手时通过 GET 带上的用户标识 go(function () use ($userId, $fd) { $redis \EasySwoole\RedisPool\RedisPool::getInstance()-get(redis); $redis-hSet(im:online:user, $userId, $fd); // 用户 - fd $redis-hSet(im:online:fd, $fd, $userId); // fd - 用户 $redis-close(); }); }); $register-add(EventRegister::onMessage, function (\Swoole\WebSocket\Server $server, \Swoole\WebSocket\Frame $frame) { $data json_decode($frame-data, true); // 转发逻辑根据消息类型决定直接推给在线客服还是先塞 Redis 队列 dispatchMessage($data); }); }逻辑说明onOpen 时只登记连接不干耗时的事。go()是 Swoole 协程的写法把 Redis 操作丢进协程避免阻塞 Worker 的事件循环这是 EasySwoole 比直接用原生 Swoole 更顺手的地方。im:online:user和im:online:fd两个 Hash 构成双向映射前者用来查“用户当前连在哪个 fd”后者用来在 onClose 时按 fd 快速反查用户 ID不用遍历。参数说明user_id走 GET 带上来是最省事的握手鉴权方式生产环境一般换成 token 校验后放在 Query 里。fd是 Swoole 分配给每个连接的文件描述符标识同一用户极端情况下会有多个 fd比如手机和电脑同时登录后面避坑章节会讲旧 fd 怎么清理。2.2 状态与消息层Redis 四类 key 与数据类型选型在线状态双向映射Redis 在这个系统里不只是缓存更像是消息中枢。这套源码的 key 大概分成四类正好对应 Redis 的几种核心数据类型。这也是为什么学 Redis 数据类型不能只看命令手册要看落在真实场景里怎么配、为什么选它。key 前缀类型用途过期/清理策略im:online:userHash用户 ID 到 fd 的在线映射连接关闭时 hDelim:online:fdHashfd 到用户 ID 的反查表连接关闭时 hDelim:queue:msgList待转发消息队列Worker 消费后分发消息确认后 LREMim:offline:{userId}List用户离线期间的消息上线后补推补推成功后删除?php // dispatchMessage消息分发核心逻辑 function dispatchMessage(array $data): void { $redis \EasySwoole\RedisPool\RedisPool::getInstance()-get(redis); $toUserId (int) $data[to_user_id]; // 先查目标用户是否在线 $fd $redis-hGet(im:online:user, $toUserId); if ($fd) { // 在线直接通过 fd 推给前端同时写入 MySQL 落库 $server-push($fd, json_encode($data)); saveMessageToMysql($data); } else { // 离线塞进离线队列等上线事件触发补推 $redis-rPush(im:offline: . $toUserId, json_encode($data)); } // 无论在线与否都进待落库队列由专门进程批量处理 $redis-rPush(im:queue:persist, json_encode($data)); $redis-close(); }这段逻辑回答客服系统最核心的问题目标不在线怎么办。直接丢离线队列而不是让 MySQL 去感知在线状态。im:offline:{userId}用 List 是因为需要顺序消费先发的先补推消息量一般也就几十条LPOP 足够。im:queue:persist是专门给落库用的队列避免每条消息都立刻写 MySQL攒一批再写这是减少数据库压力的关键设计。后面如果把这套源码改成多服务实例部署客服分配那段逻辑记得用 Redis 分布式锁抢一下会话防止同一个用户被两个客服同时接待。2.3 持久化层三张表结构、落库时机与事务处理的取舍MySQL 这边不复杂但表结构设计得很克制。用户表、会话表、消息表三张会话表承担了“哪个用户对哪个客服”的绑定关系。客服分配策略通常是在用户第一次发消息时按在线客服列表做简单选择空闲客服优先。-- 用户表客服和普通用户共用一张表用 role 区分 CREATE TABLE im_user ( id int unsigned NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录名, role tinyint NOT NULL DEFAULT 2 COMMENT 1客服, 2普通用户, status tinyint NOT NULL DEFAULT 1 COMMENT 1可用, 0禁用, created_at datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 会话表记录一次客服接待 CREATE TABLE im_session ( id int unsigned NOT NULL AUTO_INCREMENT, user_id int NOT NULL COMMENT 发起咨询的用户, service_id int NOT NULL COMMENT 接待的客服, status tinyint NOT NULL DEFAULT 0 COMMENT 0进行中, 1已结束, created_at datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 消息表所有聊天记录按会话和时间建索引 CREATE TABLE im_message ( id bigint unsigned NOT NULL AUTO_INCREMENT, session_id int NOT NULL, from_user_id int NOT NULL, to_user_id int NOT NULL, content text NOT NULL, msg_type tinyint NOT NULL DEFAULT 1 COMMENT 1文本, 2图片链接, created_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_session_created (session_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;落库的规矩是在线消息实时写离线消息等补推成功后写批量消息走队列攒批写。攒批的常见做法是收集 50 条或者 500ms 内的一批消息用一条多值 INSERT 插入。事务处理只需要保证单条消息插入的原子性不需要跨表事务im_session状态更新和im_message插入之间允许短暂不一致客服系统能容忍这种延迟。插入im_message时靠session_id做逻辑关联不建物理外键是为了避免高并发插入时的行锁竞争。MySQL 锁的分类里行锁和间隙锁对热表影响最大物理外键在子表插入时会触发对父表的 S 锁校验客服系统这种高频插入场景不合适。2.4 前端接入层LayIM 握手参数与消息协议LayIM 是 Layui 生态里的 IM 组件JS 层已经把消息气泡、会话列表渲染好了要接的就是 WebSocket 握手和消息协议。前端在layIM.config里指定chat的接口地址WebSocket 连接地址由后端登录接口返回握手时带上用户标识和 token。// 前端 LayIM 初始化片段 layui.layim.config({ msgurl: /im/send, chatLog: /im/history, init: { mine: { username: 访客_9527, id: 9527, mine: true, avatar: }, friend: [ // 客服列表由后端接口给出 ] } }); // 建立 WebSocket握手时带上 user_id 和 token var ws new WebSocket(ws://127.0.0.1:9501?user_id9527tokenabc);LayIM 自己会处理消息列表的滚动和气泡渲染后端只要按约定好的 JSON 结构返回{code:0,msg:,data:{...}}就行。从源码角度看前端真正的活儿是拿到客服列表后把客服 ID 存进会话发消息时带上to_user_id。图片消息走 HTTP 上传接口传完拿到 URL 再走 WebSocket 推一个带链接的 JSONLayIM 自动渲染成图片气泡二进制数据不走长连接帧否则一张大图会阻塞整个连接的消息收发。心跳一般由前端 JS 定时发送服务端在 onMessage 里识别到 ping 直接回 pong不落库不转发。3. 把源码跑起来从空环境到第一条客服消息的完整步骤这一章解决“源码在手怎么让它跑”。很多人卡在环境上PHP 版本不对、Swoole 扩展没装、EasySwoole 依赖拉不下来。按下面顺序做半小时内能跑到第一条消息。3.1 环境准备PHP 版本、Swoole 扩展与 EasySwoole 安装EasySwoole 3.x 要求 PHP 7.1 以上实际生产建议 PHP 7.4 或 8.0。Swoole 扩展要求 4.4 以上且需要开启--enable-openssl。这里有一个容易踩的坑很多人用包管理器装的 PHP 不带pcntl、posix扩展EasySwoole 安装时会直接报错。用 pecl 装 Swoole 之前先确认有 gcc 和 php-devel装完在 PHP 配置里建议设置swoole.use_shortnameOff防止协程关键字被短别名干扰。# 1. 安装 Swoole 扩展以 pecl 为例 pecl install swoole # 2. 检查扩展是否加载下面几个都要有 php -m | grep -E swoole|pcntl|posix|redis # 3. 创建项目并引入 EasySwoole composer require easyswoole/easyswoole:^3.0 # 4. 生成框架基础文件 php vendor/bin/easyswoole install # 5. 启动开发模式 php easyswoole start参数说明php vendor/bin/easyswoole install会在项目根目录生成easyswoole启动脚本和默认的EasySwooleEvent.php、dev.php配置。php easyswoole start是前台启动带-d参数才 daemonize 后台运行。第一次启动看到EasySwoole current version就对了。如果php -m里没有swoole先回到第一步不要往下走。3.2 初始化数据库导入建表 SQL 并写入客服账号把上一章的三张表存成im.sql导入后再插入一个客服账号和一个测试用户。这套源码的客服登录通常直接查im_user表密码字段生产环境用password_hash生成测试阶段可以先跳过密码校验。-- 初始化客服账号和测试用户自动分配 ID 1、2 INSERT INTO im_user (username, role, status, created_at) VALUES (service01, 1, 1, NOW()), (user01, 2, 1, NOW()); -- 插入一个测试会话把用户和客服绑上 INSERT INTO im_session (user_id, service_id, status, created_at) VALUES (2, 1, 0, NOW());导入时注意数据库字符集必须是 utf8mb4否则 emoji 进不去这是高频报错点。用mysql -uroot -p im im.sql导入时如果报 Unknown collation多半是 MySQL 版本低于 5.7 或者字符集没建对。im_user表插入后记下自增 ID后面前端握手要用。im_session在这个阶段可以先手动插入也可以等客服系统按固定规则自动分配看你看的这套源码有没有实现分配逻辑。3.3 启动服务监听端口、Worker 数量与重启方式dev.php配置里可以改监听端口和 Worker 数量。客服系统连接数不大时Worker 数量不用贪多跟 CPU 核数一致即可。配置文件在config/dev.php或者EasySwooleEvent的initialize里做。// config/dev.php 关键配置 MAIN_SERVER [ SOCK_TYPE SWOOLE_TCP, // 本地测试可不加 SSL LISTEN_ADDRESS 0.0.0.0, LISTEN_PORT 9501, RUN_MODEL SWOOLE_PROCESS, // 多进程模式 SETTING [ worker_num 4, max_request 100000, // 防内存泄漏的兜底值 heartbeat_idle_time 60, heartbeat_check_interval 15, ], ],参数说明RUN_MODEL用进程模式每个 Worker 独立处理一组连接。max_request是单个 Worker 处理连接数的上限超过后自动重启该 Worker这是常驻内存进程防 OOM 的兜底手段设置 0 表示永不回收测试无妨生产一定要给个值。heartbeat_idle_time是服务端清理无心跳连接的时间前端心跳间隔必须小于它不然会被服务端误杀。改完配置必须重启才能生效php easyswoole reload只重载业务代码端口、worker_num这类进程级配置必须php easyswoole stop php easyswoole start -d这是血泪经验早期我改完直接 reload端口一直没变过来。注意常驻内存框架的配置生效方式和 PHP-FPM 完全不一样改完MAIN_SERVER一定要完整 stop 再 start别指望 reload 全搞定。3.4 本地联调验证redis-cli 看消息流转、可视化客户端排 key服务起来后开两个浏览器窗口一个登录客服一个登录普通用户分别建立 WebSocket。发消息后按下面的顺序看数据流转每一步都能看到真实痕迹。# 查看当前在线映射有登录时应该有两条记录 redis-cli hgetall im:online:user # 查看待落库队列的长度 redis-cli llen im:queue:persist # 看某个用户的离线消息队列用户不在线时发一条再看 redis-cli lrange im:offline:2 0 -1如果本机装了 Redis Desktop Manager 或 Another Redis Desktop Manager直接连 6379 看这几个 key 会更直观。这里想强调一个联调习惯不要只盯着聊天窗口看消息是否发出要同时在 Redis 里确认消息确实经过了im:queue:persist这能避免“前端显示成功、后端根本没收到”的假象。MySQL 侧用SELECT COUNT(*) FROM im_message;数一下行数三层各看一次链路就通了。线上要做 WSS 时Nginx 层挂证书后配 WebSocket 代理proxy_read_timeout要拉到 300 秒以上否则长连接会被 Nginx 静默掐断。4. 避坑与常见问题连接闪断、连接池打满、消息丢与进程内存上涨正常流程跑通后这一章写真实运维里一定会碰到的四个问题。拆过几套类似客服源码下面这些现象基本每套都会中至少两个。每条按现象、原因、解决三步说清楚。4.1 WebSocket 频繁断开心跳间隔与服务端清理参数不匹配现象客服挂机五分钟重新回来看页面消息收不到页面上的 WebSocket 显示已断开。有些系统更隐蔽连接断得毫无规律一压测就掉线。原因服务端设置了heartbeat_idle_time为 60 秒超过这个时间没收到任何数据帧就强制关闭连接。前端如果不做心跳或者心跳间隔大于 60 秒浏览器和服务器之间没有数据流动就会被服务端误杀。另外一个隐蔽点是 Nginx 前置代理时proxy_read_timeout默认只有 60 秒同样会掐断连接现象是客户端没报错但连接已断。解决前端统一加心跳每 20 秒发一条{type:ping}服务端在 onMessage 里识别到直接回 pong不落库不转发。// 前端心跳每20秒发一次服务端收到拦截回pong setInterval(function () { if (ws.readyState 1) { ws.send(JSON.stringify({type: ping})); } }, 20000);逻辑说明readyState 1判断连接已建立避免连接断开后还在空发白耗定时器。服务端收到typeping时直接从 onMessage 返回不进入 dispatchMessage 流程。改完参数后先重启服务再看连接是否稳定不要只改一端就觉得完事。Nginx 代理场景下记得同步把proxy_read_timeout和proxy_send_timeout都改成 300 秒。4.2 Redis 连接数打满连接池没有复用每个 fd 都在建新连接现象在线用户到几十个的时候Redis 报ERR max number of clients reached或者客户端侧出现类似command timed out的异常。服务端执行redis-cli info clients看到 connected_clients 异常高。原因代码里每次操作 Redis 都手动new Redis()而没有归还连接也没走连接池。在 WebSocket 长连接场景下每个 fd 哪怕是空闲也会不断创建 Redis 连接几十个用户七手八脚就能把默认的 10000 连接数打掉一大截。这个问题在源码包和二次开发代码里很常见因为短连接时代“用完就 close”习惯了常驻内存里这种写法会无限放大连接数。解决统一走 RedisPool取连接用getInstance()-get(redis)用完close()归还而不是断开。检查所有业务代码不能出现new \Redis()这种裸创建。服务端 redis.conf 里的相关参数也要心里有数。# redis.conf 中与连接相关的关键参数 maxclients 10000 timeout 0 tcp-keepalive 60参数说明timeout 0表示空闲连接不主动断开适合长连接场景tcp-keepalive让系统层定期探测死链。改完后用redis-cli info clients观察连接数稳定后应该等于连接池配置的容量而不是等于在线用户数。4.3 消息丢或重复缺 ACK 确认与唯一索引兜底现象离线用户重新上线后补推的消息偶尔少一条或者出现两条一模一样的消息。压力测试时更明显。原因丢消息是因为消费者从im:offline:{userId}里 LPOP 之后还没 push 给客户端进程就崩了消息已经消失。重复消息是因为前端因为网络抖动重发了同样的内容或者同一帧被两个消费者同时取走。MySQL 侧缺少唯一索引兜底无法拦截重复行。解决不要把“取消息”和“发消息”做成一个原子步骤。先从 Redis 里拿到消息push 成功后再执行删除。im_message表加一个客户端生成的业务单号字段建唯一索引重复插入会被数据库直接挡掉。-- 用 msg_no 做数据库层的唯一兜底 ALTER TABLE im_message ADD COLUMN msg_no varchar(32) NOT NULL DEFAULT COMMENT 客户端生成的消息唯一ID, ADD UNIQUE KEY uk_msg_no (msg_no);逻辑说明msg_no由前端生成可以用 UUID 或者时间戳加随机数同一个消息无论重发多少次这个值不变。数据库唯一索引是最后一道闸门即使上层逻辑漏了判重重复数据也进不了表。这条规则要写进开发约定我一般会把消费函数拆成fetchMessage和confirmMessage两个方法逻辑清晰后排查也容易。4.4 Worker 进程内存持续上涨单测没事跑一天后 OOM现象服务刚启动时内存占用 200MB跑一天后变成 1GB再往上就 OOM 被杀。重启后恢复但过一天又涨上去。原因常驻内存进程里所有static变量、单例对象都不随连接释放。最容易出问题的是把连接对象、大数组缓存在静态属性里或者调试代码里把每个消息塞进内存数组没有清理。PHP 的 GC 对循环引用的回收本来就弱一旦形成引用环内存只能看着涨。解决先用命令确认是哪个 Worker 内存异常再观察是持续增长还是波动。# 看每个 PHP 进程的内存占用RSS 列 ps -eo pid,ppid,rss,comm | grep php # 看系统整体内存水位 free -m逻辑说明如果某个 Worker 的 RSS 从 100MB 涨到 800MB 且不回落说明有静态缓存或连接泄漏。代码审计时重点排查静态属性里存了哪些东西比如static $clients []这种连接关闭时要unset对应项。生产环境用max_request100000做兜底Worker 处理满连接数后自动重启配合heartbeat_idle_time做平滑替换不要等到 OOM 才手动拉服务。5. 往生产环境走Redis Streams 改造队列、协程压测与消息对账基础链路能跑之后剩下的是“怎么证明它能扛业务”以及“怎么从小打小闹变成能扩容的结构”。这一章给三个可以立刻用上的手段。5.1 用 Redis Streams 替换 List 队列消费确认不再靠猜im:queue:msg用 List 是入门做法问题是 LPOP 即删没有消费确认做事务回滚也麻烦。Redis 5.0 之后的 Streams 很适合这个消息场景自带消费者组和 ACK配合离线补推逻辑能把“消息到底发没发成功”从玄学变成可追踪。?php // 消费者组写入与读取示例 $redis-xAdd(im:stream:msg, *, [payload $content, to_user_id $to]); // 创建消费者组按客服实例分组 $redis-xGroup(CREATE, im:stream:msg, service_workers, 0, true); // worker 读取待处理消息 表示只取未投递给当前消费者的消息 $messages $redis-xReadGroup(service_workers, worker_1, [im:stream:msg ], 1); // 处理成功后显式 ACK foreach ($messages as $msgId $msg) { dispatchToClient($msg); $redis-xAck(im:stream:msg, service_workers, [$msgId]); }参数说明xReadGroup的是特殊 ID表示“只返回从来没投递给这个消费者的消息”配合xAck才是完整消费流程。比起 LPOPStreams 允许消费者处理失败后不 ACK消息不会丢能重新投递。横向扩容时新加一个客服 Worker注册到同一个消费者组消息会自动负载到新消费者上不需要改业务代码。如果生产用 Docker 部署 Redis 主从Streams 的消费者组一样能用但主从切换后偏移量要核对。5.2 用 Swoole 协程客户端做 WebSocket 并发压测压测客服系统跟压测 HTTP 接口不一样要模拟的不是瞬时请求而是长时间挂着的连接持续收发消息。Swoole 的协程 HTTP 客户端可以直接当 WebSocket 压测工具不需要引额外的 JMeter 插件。?php use Swoole\Coroutine; use Swoole\Coroutine\Http\Client; Coroutine\run(function () { for ($i 0; $i 50; $i) { go(function () use ($i) { $client new Client(127.0.0.1, 9501); $client-set([websocket_mask false, timeout 5]); $ret $client-upgrade(/?user_id . (1000 $i)); if (!$ret) { echo connect fail: {$i}\n; return; } // 每秒发一条消息持续 10 秒 $n 0; while ($n 10) { $client-push(json_encode([type text, content hello . $i])); $res $client-recv(); if ($res $res-data) { // 服务端转发的消息在这里拿到 } Coroutine::sleep(1); $n; } $client-close(); }); } });这个脚本模拟 50 个用户同时挂连接每人不间断发消息。跑完之后做两件事一是看im_message表总行数是不是等于预期消息数二是看 Redis 里im:offline:*还有没有残余数据。如果表里少了优先查im:queue:persist的消费能力是不是跟不上。5.3 最后一道关用 SQL 对账验证消息一致性压测通过不等于链路正确每次上线客服系统前我都会做一次对账。手工数消息太原始直接按会话统计数量同时把 Redis 队列长度拉出来比对。消息量对不上只有两个可能要么丢了要么有重复被唯一索引挡了。-- 统计每个会话的消息总量和业务日志里的发送量对比 SELECT session_id, COUNT(*) AS cnt FROM im_message GROUP BY session_id ORDER BY cnt DESC; -- 如果有 msg_no 唯一索引这个查询应该返回 0 行 SELECT msg_no, COUNT(*) FROM im_message GROUP BY msg_no HAVING COUNT(*) 1;对账的意义在于把“我觉得没丢”变成“数据证明没丢”。从那以后我每次上线 IM 系统都会强制走一遍同一套流程先压测再查队列残余最后跑对账 SQL。这套源码虽然叫简单客服系统但三层链路和关键坑都在里面了把单机 Redis 队列再替换成消息中间件也只是时间问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表