使用phpsocket.io开发web即时通讯系统(在线客服)记录

整理下目录 明天周六有时间写

开始

我司是做B端SaaS业务的,移动端比较有代表性的项目是办公微服务,下面涵盖了员工食堂、便利店、物业公司的入驻办公楼服务。既然有B端用户又有C端用户,那就避免不了两个端之间有交流,于是需要开发一款类似淘宝阿里旺旺功能的B2C的即时通讯客服系统。使用的websocket技术是基于workman开发的phpsocket.io。

业务场景分析

业务场景还是相对比较复杂的,因为我们公司的C端相当于是写字楼租户,有自己的后台,相当于说C端有前台、后台账号两个身份。B端也有前台、后台账号。排列组合,近B端与C端通信就有四种情况需要考虑。

第一版我们只考虑C端前台账号和B端所有的账号通信问题。不考虑C2C、B2B的情况。由于是客服系统,需要考虑到B端子账号所有有权限的账号都可被看做是客服,存在一个客服分配的情况。

数据表设计

数据表这块走了一些弯路,之前只用了一张表来存储消息记录。主流的C2C私聊的即时通讯一张表也许就够用了,但是这里分为B端和C端,且B端是店铺为单位的,后面的客服不是唯一的,一张表的话,逻辑不是很清晰。于是我对B端、C端的消息记录分开存放。

im_message_c2b
CREATE TABLE `im_message_c2b` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `uuid` char(36) CHARACTER SET utf8 NOT NULL DEFAULT '' COMMENT '全局唯一uuid',
  `store_id` int(11) unsigned NOT NULL COMMENT '店铺id',
  `store_name` varchar(100) CHARACTER SET utf8 NOT NULL DEFAULT '' COMMENT '店铺名称',
  `type` smallint(3) unsigned NOT NULL DEFAULT '0' COMMENT '消息类型(0-系统消息 1-用户消息)',
  `chat_id` int(11) DEFAULT NULL COMMENT '对话id',
  `sender_from` tinyint(1) DEFAULT NULL COMMENT '发送人 1前台  2后台',
  `sender_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '消息发送者ID(前台账号ID,若为系统消息此字段为0)',
  `sender_name` varchar(100) CHARACTER SET utf8 NOT NULL DEFAULT '' COMMENT '消息发送者名称(仅用于前台展示,不做字段关联)',
  `subject` varchar(255) NOT NULL DEFAULT '' COMMENT '消息标题(仅支持文字)',
  `content_type` int(11) NOT NULL DEFAULT '1' COMMENT '消息类型 1文字 2图片 3文件',
  `content` text COMMENT '消息内容(可支持文字、图片、表情、链接等)',
  `receiver_from` tinyint(1) DEFAULT NULL COMMENT '接收人 1前台 2后台',
  `receiver_id` int(11) unsigned DEFAULT NULL COMMENT '消息接收者ID(前台账号ID)',
  `receiver_name` varchar(100) CHARACTER SET utf8 DEFAULT '' COMMENT '消息接收者名称(仅用于前台展示,不做字段关联)',
  `disabled` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '是否删除(0-否 1-是)',
  `add_time` int(10) unsigned DEFAULT NULL COMMENT '发送时间(unix时间戳)',
  `state` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '状态 默认0未发送  1发送成功  2发送失败',
  `is_read` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '默认0未读  1已读',
  PRIMARY KEY (`id`) USING BTREE,
  KEY ```receiver_id``` (`receiver_id`) USING BTREE,
  KEY ```add_time``` (`add_time`) USING BTREE,
  KEY ```mixkey``` (`sender_id`,`receiver_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=164 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='C端发给B端的消息';
im_message_b2c

CREATE TABLE `im_message_b2c` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `uuid` char(36) CHARACTER SET utf8 NOT NULL DEFAULT '' COMMENT '全局唯一uuid',
  `store_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '店铺id',
  `store_name` varchar(100) CHARACTER SET utf8 NOT NULL DEFAULT '' COMMENT '店铺名称',
  `type` smallint(3) unsigned NOT NULL DEFAULT '0' COMMENT '消息类型(0-系统消息 1-用户消息)',
  `chat_id` int(11) DEFAULT '0' COMMENT '对话id',
  `sender_from` tinyint(1) DEFAULT '0' COMMENT '发送人 1前台  2后台',
  `sender_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '消息发送者ID(前台账号ID,若为系统消息此字段为0)',
  `sender_name` varchar(100) CHARACTER SET utf8 NOT NULL DEFAULT '' COMMENT '消息发送者名称(仅用于前台展示,不做字段关联)',
  `subject` varchar(255) NOT NULL DEFAULT '' COMMENT '消息标题(仅支持文字)',
  `content_type` int(11) NOT NULL DEFAULT '1' COMMENT '消息类型 1文字 2图片 3文件',
  `content` text COMMENT '消息内容(可支持文字、图片、表情、链接等)',
  `receiver_from` tinyint(1) DEFAULT '0' COMMENT '接收人 1前台 2后台',
  `receiver_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '消息接收者ID(前台账号ID)',
  `receiver_name` varchar(100) CHARACTER SET utf8 NOT NULL DEFAULT '' COMMENT '消息接收者名称(仅用于前台展示,不做字段关联)',
  `disabled` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '是否删除(0-否 1-是)',
  `add_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '发送时间(unix时间戳)',
  `state` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '状态 默认0未发送  1发送成功  2发送失败',
  `is_read` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '默认0未读  1已读',
  PRIMARY KEY (`id`) USING BTREE,
  KEY ```receiver_id``` (`receiver_id`) USING BTREE,
  KEY ```add_time``` (`add_time`) USING BTREE,
  KEY ```mixkey``` (`sender_id`,`receiver_id`) USING BTREE,
  KEY ```type``` (`type`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=682754 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='B端发给C端的消息';

这里着重说明的一点是,为了让通信的uid逻辑性一致,receiver_id、sender_id都是取前台账号(后台账号与前台有对应关系),用receiver_from、sender_from区分是前台还是后台,uid用front、back前缀区分是前台还是后台。

另外还有记录会话id的表、聊天媒体文件的表,在这就不做记录了。

主要逻辑处理

  • 聊天列表

    • 对于C端聊天列表,肯定是group by store_id,按照店铺去分组。

    • 按照一般的逻辑,首先列表页排序肯定是按最后一次发送消息的时间戳倒序去排序的。并且要显示最新一条与店铺对话的消息。

    • 这里就很好玩了,因为mysql语句执行顺序group by是在order by前面的,所以兼顾这两个的用法是这里的重点。为实现上述逻辑,我的sql如下:

      SELECT * from (
          select * from (
              select * from (SELECT store_id,type,store_name,receiver_id as chat_user_id,receiver_name as chat_user_name,content,add_time FROM im_message_c2b WHERE sender_id = {$data['user_id']} AND type = 1 order by add_time desc limit 10000) as a group by store_id 
              union all 
              select * from (SELECT store_id,type,store_name,sender_id,sender_name,content,add_time FROM im_message_b2c WHERE receiver_id = {$data['user_id']} order by add_time desc limit 10000) as b group by b.store_id
          ) c order by add_time desc limit 10000
      ) as d GROUP BY store_id ORDER BY add_time desc LIMIT $data[offset],$data[limit]
    • (为什么加limit,因为mysql5.7后就得加,不加group by还是按最原始的排序)

    • B端对话逻辑列表逻辑类似,不过是以发送人id分组的。

  • 聊天记录

    • C端聊天记录:以店铺id分组,查找出与当前店铺所有聊天记录。
    • B端聊天记录:查看店铺与用户所有聊天记录(可能存在分配到其他客服,另一个客服要知道之前聊的内容)。
    • 聊天记录的显示涉及到一个问题,就是消息间隔大于三分钟的话,要显示一个发送时间。我这边想到的方案是,两两比对消息的时间戳,如果大于3分钟,则插入一条时间戳记录,回头把所有的数据放在json里面返给前端。要注意的是,遍历数组同时又插入数组,很容易导致条件判断的错误。贴一下我写的算法。
      // 两两比对,间隔大于两分钟插入时间显示
      $i = 0; 
      //$messageLog['rows']是消息数组
      foreach ($messageLog['rows'] as $k => $v) {
          $count = count($messageLog['rows']);
          if ($i < $count - 1) {
              $head_time = $messageLog['rows'][$i]['add_time'];
              $next_time = $messageLog['rows'][$i+1]['add_time'];
              if ($head_time - $next_time >= 120) {
                  $arr = array(
                      ['time' => $head_time,'mark' => 'middle']
                  );            
                  // 如果间隔大于2min,插一条记录显示时间
                  array_splice($messageLog['rows'], $i+1, 0, $arr);
                  $i += 2; //插入记录了,跳过时间显示这条数据的比对,永远只比对原始的消息时间戳
              } else {
                  $i ++;
              }
          }
      }
  • 标记为已读

    • 当点开聊天页面的时候,肯定要标记双方的消息记录为已读。
    • 当发送消息的时候,当前时间戳之前的双方消息,也要标记为已读。
聊天列表
聊天记录
标记为已读
分配客服

离线消息如何处理

群发消息-大数据量处理

幻想游戏网被攻击实录及总结

最近近一个月幻想游戏网都处于被攻击的状态,每天靠业余时间去维护、防御也搞得焦头烂额。昨晚终于恢复所有功能正式上线,整个过程大部分是我来搞的,应大佬要求,写一篇防御攻击的一些分析方法和总结。

前奏

11月的时候,网站偶尔会访问很慢,一开始也没放在心上,因为是偶尔出现的情况就以为是并发引起的问题,就没有太在意,准备有时间好好检查下程序的瓶颈(最后发现我们网站就没那么大并发-_-难过ing)。

最后有用户反映,简单的一个签到都要等十几秒。看下服务器负载,高的可怕,但当时对这方面经验真的很少,还以为是并发的问题,于是准备去优化下幻想游戏存在的性能问题。

优化

幻想游戏一直使用的是关系型数据库(MySQL),对于数据量比较大时候,读写还是比较消耗IO开销的。最开始做网站的时候,还是个学生,数据量也小,并没有考虑太多数据库层面的事情。一年时间,有的数据表数据已经几十万行了,竟然还没有建索引,着实有点尴尬,于是在数据库层面做了优化。

首先是MySQL的索引,按照经理的说法,每个表都至少有两个索引,虽然会对写操作有影响,但是带来的益处远大于弊端。我对所有读操作比较频繁的表都建立了索引,索引这块涉及的知识也还是很多的,“最左原则”、联合索引等等的(第一次面试的时候就在这块狠狠的被打击了)。

然后是签到数据,理论上来说,做操作的时候只要有用户前一天的数据就可以了(目前的逻辑是读取用户前一天的签到天数,如果是连续就累加,不连续就从1开始),所以之前的数据基本上是没用了,写了个脚本,清理了一个月前的签到数据(哈哈,毕竟感觉离做大数据分析还有点远)。

最后是redis,网站有排行榜功能,目前是实时更新的,像下载表、签到表什么的,数据已经几十万条,不停的读写,消耗应该说还是很大的,于是将排行榜的数据全部放入了redis里面,并配合Linux的crontab定时任务和Laravel的任务调度每周对排行榜进行一次更新。

攻击

本以为做了以上优化,负载会降下去了,现实却给了我一巴掌(最开始也说,网站目前的流量根本到不了优化性能那一步)。负载还是居高不下,网站基本处于瘫痪状态,把网站日志文件下载下来,准备分析一下。

结合Excel的分析能力,发现很多ip访问异常频繁,初步断定是ddos攻击,或者是有人在爬取我们网站。然后,安排,在程序基类写了一个限制ip访问函数,如果一分钟之内访问大于20次,就不让访问了,上线后果然负载降了下来,偶尔会满,但很快就降下来了。心里还挺美的,当时时间是下午五点多。

晚上和同事吃了顿饭,回到家九点多,打开宝塔面板,负载到了四五十···,一下子就懵了,看来对方是加大了力度,而且ip都很分散,基本无法判定是攻击还是普通用户。等到第二天到公司,发现了更可怕的事,由于匆忙上线,redis没有设置密码,被对方植入了挖矿程序(看到了一个shell脚本,查了里面关键的变量名,发现是和挖矿相关的东西)。于是搜相关资料,并在服务器全局搜了相关关键字,没有发现相关的脚本,而且服务器CPU正常。设置了redis密码(强度要高,平时感觉没啥,真被攻击就凉了),就继续搜防攻击的方法了。

防御

首先是预防一下对方是否是大面积的ddos攻击(其实能判定不是针对ip攻击的),还是对ip进行了隐藏,用的是加速乐提供的cdn隐藏服务器真实ip的服务,具体操作就不写了。

其次是分析日志,把一下访问频繁的ip直接禁掉。

但其实作用微乎其微,攻击还在继续。我们继续排查问题,我们把www开头的域名解析给停掉之后,发现负载正常了,所以判定对方攻击的www开头的域名,日志里面还有很多莫名的post请求(一看就不是网站内部路由),无奈我们先暂停了www的解析。

烦人的搜索引擎爬虫

之后我们一直用http://hxyou.cn 对用户进行开放,但也会存在负载爆满的情况,但不像之前那样高的离谱,不确定是不是又遭受到攻击。于是把网站日志下载下来,继续分析。

分析主要是看IP访问的频率以及请求的UA,可以看到有很多都是搜索引擎的爬虫,比如BaiduSpider,bingbot,YiSouSpider。但是也没听过爬虫能把网站爬垮的新闻。经过不断排查,发现有一个请求的UA出现的非常频繁,bytespider。分析得到的结果是今日头条的爬虫暴力爬取导致负载过高(真的很流氓),并且阿里系的神马搜索也很频繁,以及微软的必应。

既然这些搜索引擎这么流氓,不好意思,我也不需要您收录我们了。我在Nginx的配置文件里放了如下代码:

        #禁止Scrapy等工具的抓取
        if ($http_user_agent ~* (Scrapy|Curl|HttpClient)) 
        {
             return 403;
        }
        #禁止指定UA及UA为空的访问
        if ($http_user_agent ~ "FeedDemon|bingbot|JikeSpider|Indy Library|Alexa Toolbar|AskTbFXTV|AhrefsBot|CrawlDaddy|CoolpadWebkit|Java|Feedly|UniversalFeedParser|ApacheBench|Microsoft URL Control|Swiftbot|ZmEu|oBot|jaunty|Python-urllib|lightDeckReports Bot|YYSpider|DigExt|YisouSpider|HttpClient|MJ12bot|heritrix|EasouSpider|Ezooms|^$" ) 
        {
             return 403;             
        }
        #禁止非GET|HEAD|POST方式的抓取
        if ($request_method !~ ^(GET|HEAD|POST)$) 
        {
            return 403;
        }

这样,可以屏蔽你不想要爬取的搜索引擎爬虫。

同时也在网站根目录下面加了robots.txt文件,定义了一下,除了百度、谷歌,都不如爬取了。

监控

平时还有工作,也不能时时刻刻盯着服务器的负载,但是宝塔的监控推送好像是收费的,又不想掏钱,只能自己写一个了,思路如下:

利用php的sys_getloadavg()的函数读取当前服务器负载(这个函数只能在linux下用),把它写成laravel的命令,可以执行命令行返回负载值。然后结合linux的crontab定时任务和laravel的任务调度,每隔一分钟执行以下这条命令,如果负载大于1,就发邮件给我。

(小声bb:laravel的任务调度语法真优雅)

$schedule->command('SendLoadByEmail')->everyMinute()->between('8:00', '22:00');

结尾

网站终于恢复上线了,流失了不少用户,我们也给用户发放了积分、vip补偿,但是无法否认的是,这次真的是被对方吊打,幻想游戏网也是元气大伤。不过也算是给自己交了学费,积累了不少网络安全的知识(虽然很浅薄),要享受分析、解决问题的过程,也是一次很宝贵的经历了。