项目需求,试着写了一个简单登录统计,基本功能都实现了,日志数据量小。具体性能没有进行测试~ 记录下开发过程与代码,留着以后改进!
1. 需求
- 实现记录用户哪天进行了登录,每天只记录是否登录过,重复登录状态算已登录。不需要记录用户的操作行为,不需要记录用户上次登录时间和IP地址(这部分以后需要可以单独拿出来存储)
- 区分用户类型
- 查询数据需要精确到天
2. 分析
考虑到只是简单的记录用户是否登录,记录数据比较单一,查询需要精确到天。以百万用户量为前提,前期考虑了几个方案
2.1 使用文件
使用单文件存储:文件占用空间增长速度快,海量数据检索不方便,Map/Reduce操作也麻烦
使用多文件存储:按日期对文件进行分割。每天记录当天日志,文件量过大
2.2 使用数据库
不太认同直接使用数据库写入/读取
- 频繁请求数据库做一些日志记录浪费服务器开销。
- 随着时间推移数据急剧增大
- 海量数据检索效率也不高,同时使用索引,易产生碎片,每次插入数据还要维护索引,影响性能
所以只考虑使用数据库做数据备份。
2.3 使用Redis位图(BitMap)
这也是在网上看到的方法,比较实用。也是我最终考虑使用的方法,
首先优点:
数据量小:一个bit位来表示某个元素对应的值或者状态,其中的key就是对应元素本身。我们知道8个bit可以组成一个Byte,所以bitmap本身会极大的节省储存空间。1亿人每天的登陆情况,用1亿bit,约1200WByte,约10M 的字符就能表示。
计算方便:实用Redis bit 相关命令可以极大的简化一些统计操作。常用命令 SETBIT、GETBIT、BITCOUNT、BITOP
再说弊端:
存储单一:这也算不上什么缺点,位图上存储只是0/1,所以需要存储其他信息就要别的地方单独记录,对于需要存储信息多的记录就需要使用别的方法了
3. 设计
3.1 Redis BitMap
Key结构:前缀_年Y-月m_用户类型_用户ID
标准Key: KEYS loginLog_2017-10_client_1001
检索全部: KEYS loginLog_*
检索某年某月全部: KEYS loginLog_2017-10_*
检索单个用户全部: KEYS loginLog_*_client_1001
检索单个类型全部: KEYS loginLog_*_office_*
...
每条BitMap记录单个用户一个月的登录情况,一个bit位表示一天登录情况。
设置用户1001,217-10-25登录: SETBIT loginLog_2017-10_client_1001 25 1
获取用户1001,217-10-25是否登录:GETBIT loginLog_2017-10_client_1001 25
获取用户1001,217-10月是否登录: GETCOUNT loginLog_2017-10_client_1001
获取用户1001,217-10/9/7月是否登录:BITOP OR stat loginLog_2017-10_client_1001 loginLog_2017-09_client_1001 loginLog_2017-07_client_1001
...
关于获取登录信息,就得获取BitMap然后拆开,循环进行判断。特别涉及时间范围,需要注意时间边界的问题,不要查询出多余的数据
获取数据Redis优先级高于数据库,Redis有的记录不要去数据库获取
Redis数据过期:在数据同步中进行判断,过期时间自己定义(我定义的过期时间单位为“天”,必须大于31)。
在不能保证同步与过期一致性的问题,不要给Key设置过期时间,会造成数据丢失。
上一次更新时间: 2107-10-02
下一次更新时间: 2017-10-09
Redis BitMap 过期时间: 2017-10-05
这样会造成:2017-10-09同步的时候,3/4/5/6/7/8/9 数据丢失
所以我把Redis过期数据放到同步时进行判断
我自己想的同步策略(定时每周一凌晨同步):
一、验证是否需要进行同步:
1. 当前日期 >= 8号,对本月所有记录进行同步,不对本月之前的记录进行同步
2. 当前日期 < 8号,对本月所有记录进行同步,对本月前一个月的记录进行同步,对本月前一个月之前的所有记录不进行同步
二、验证过期,如果过期,记录日志后删除
3.2 数据库,表结构
每周同步一次数据到数据库,表中一条数据对应一个BitMap,记录一个月数据。每次更新已存在的、插入没有的
3.3 暂定接口
- 设置用户登录
- 查询单个用户某天是否登录过
- 查询单个用户某月是否登录过
- 查询单个用户某个时间段是否登录过
- 查询单个用户某个时间段登录信息
- 指定用户类型:获取某个时间段内有效登录的用户
- 全部用户:获取某个时间段内有效登录的用户
4. Code
TP3中实现的代码,在接口服务器内部库中,Application\Lib\
├─LoginLog
│ ├─Logs 日志目录,Redis中过期的记录删除写入日志进行备份
│ ├─LoginLog.class.php 对外接口
│ ├─LoginLogCommon.class.php 公共工具类
│ ├─LoginLogDBHandle.class.php 数据库操作类
│ ├─LoginLogRedisHandle.class.php Redis操作类
4.1 LoginLog.class.php
1 <?php 2 3 namespace Lib\LoginLog; 4 use Lib\CLogFileHandler; 5 use Lib\HObject; 6 use Lib\Log; 7 use Lib\Tools; 8 9 /** 10 * 登录日志操作类 11 * User: dbn 12 * Date: 2017/10/11 13 * Time: 12:01 14 * ------------------------ 15 * 日志最小粒度为:天 16 */ 17 18 class LoginLog extends HObject 19 { 20 private $_redisHandle; // Redis登录日志处理 21 private $_dbHandle; // 数据库登录日志处理 22 23 public function __construct() 24 { 25 $this->_redisHandle = new LoginLogRedisHandle($this); 26 $this->_dbHandle = new LoginLogDBHandle($this); 27 28 // 初始化日志 29 $logHandler = new CLogFileHandler(__DIR__ . '/Logs/del.log'); 30 Log::Init($logHandler, 15); 31 } 32 33 /** 34 * 记录登录:每天只记录一次登录,只允许设置当月内登录记录 35 * @param string $type 用户类型 36 * @param int $uid 唯一标识(用户ID) 37 * @param int $time 时间戳 38 * @return boolean 39 */ 40 public function setLogging($type, $uid, $time) 41 { 42 $key = $this->_redisHandle->getLoginLogKey($type, $uid, $time); 43 if ($this->_redisHandle->checkLoginLogKey($key)) { 44 return $this->_redisHandle->setLogging($key, $time); 45 } 46 return false; 47 } 48 49 /** 50 * 查询用户某一天是否登录过 51 * @param string $type 用户类型 52 * @param int $uid 唯一标识(用户ID) 53 * @param int $time 时间戳 54 * @return boolean 参数错误或未登录过返回false,登录过返回true 55 */ 56 public function getDateWhetherLogin($type, $uid, $time) 57 { 58 $key = $this->_redisHandle->getLoginLogKey($type, $uid, $time); 59 if ($this->_redisHandle->checkLoginLogKey($key)) { 60 61 // 判断Redis中是否存在记录 62 $isRedisExists = $this->_redisHandle->checkRedisLogExists($key); 63 if ($isRedisExists) { 64 65 // 从Redis中进行判断 66 return $this->_redisHandle->dateWhetherLogin($key, $time); 67 } else { 68 69 // 从数据库中进行判断 70 return $this->_dbHandle->dateWhetherLogin($type, $uid, $time); 71 } 72 } 73 return false; 74 } 75 76 /** 77 * 查询用户某月是否登录过 78 * @param string $type 用户类型 79 * @param int $uid 唯一标识(用户ID) 80 * @param int $time 时间戳 81 * @return boolean 参数错误或未登录过返回false,登录过返回true 82 */ 83 public function getDateMonthWhetherLogin($type, $uid, $time) 84 { 85 $key = $this->_redisHandle->getLoginLogKey($type, $uid, $time); 86 if ($this->_redisHandle->checkLoginLogKey($key)) { 87 88 // 判断Redis中是否存在记录 89 $isRedisExists = $this->_redisHandle->checkRedisLogExists($key); 90 if ($isRedisExists) { 91 92 // 从Redis中进行判断 93 return $this->_redisHandle->dateMonthWhetherLogin($key); 94 } else { 95 96 // 从数据库中进行判断 97 return $this->_dbHandle->dateMonthWhetherLogin($type, $uid, $time); 98 } 99 } 100 return false; 101 } 102 103 /** 104 * 查询用户在某个时间段是否登录过 105 * @param string $type 用户类型 106 * @param int $uid 唯一标识(用户ID) 107 * @param int $startTime 开始时间戳 108 * @param int $endTime 结束时间戳 109 * @return boolean 参数错误或未登录过返回false,登录过返回true 110 */ 111 public function getTimeRangeWhetherLogin($type, $uid, $startTime, $endTime){ 112 $result = $this->getUserTimeRangeLogin($type, $uid, $startTime, $endTime); 113 if ($result['hasLog']['count'] > 0) { 114 return true; 115 } 116 return false; 117 } 118 119 /** 120 * 获取用户某时间段内登录信息 121 * @param string $type 用户类型 122 * @param int $uid 唯一标识(用户ID) 123 * @param int $startTime 开始时间戳 124 * @param int $endTime 结束时间戳 125 * @return array 参数错误或未查询到返回array() 126 * ------------------------------------------------- 127 * 查询到结果: 128 * array( 129 * 'hasLog' => array( 130 * 'count' => n, // 有效登录次数,每天重复登录算一次 131 * 'list' => array('2017-10-1', '2017-10-15' ...) // 有效登录日期 132 * ), 133 * 'notLog' => array( 134 * 'count' => n, // 未登录次数 135 * 'list' => array('2017-10-1', '2017-10-15' ...) // 未登录日期 136 * ) 137 * ) 138 */ 139 public function getUserTimeRangeLogin($type, $uid, $startTime, $endTime) 140 { 141 $hasCount = 0; // 有效登录次数 142 $notCount = 0; // 未登录次数 143 $hasList = array(); // 有效登录日期 144 $notList = array(); // 未登录日期 145 $successFlg = false; // 查询到数据标识 146 147 if ($this->checkTimeRange($startTime, $endTime)) { 148 149 // 获取需要查询的Key 150 $keyList = $this->_redisHandle->getTimeRangeRedisKey($type, $uid, $startTime, $endTime); 151 152 if (!empty($keyList)) { 153 foreach ($keyList as $key => $val) { 154 155 // 判断Redis中是否存在记录 156 $isRedisExists = $this->_redisHandle->checkRedisLogExists($val['key']); 157 if ($isRedisExists) { 158 159 // 存在,直接从Redis中获取 160 $logInfo = $this->_redisHandle->getUserTimeRangeLogin($val['key'], $startTime, $endTime); 161 } else { 162 163 // 不存在,尝试从数据库中读取 164 $logInfo = $this->_dbHandle->getUserTimeRangeLogin($type, $uid, $val['time'], $startTime, $endTime); 165 } 166 167 if (is_array($logInfo)) { 168 $hasCount += $logInfo['hasLog']['count']; 169 $hasList = array_merge($hasList, $logInfo['hasLog']['list']); 170 $notCount += $logInfo['notLog']['count']; 171 $notList = array_merge($notList, $logInfo['notLog']['list']); 172 $successFlg = true; 173 } 174 } 175 } 176 } 177 178 if ($successFlg) { 179 return array( 180 'hasLog' => array( 181 'count' => $hasCount, 182 'list' => $hasList 183 ), 184 'notLog' => array( 185 'count' => $notCount, 186 'list' => $notList 187 ) 188 ); 189 } 190 191 return array(); 192 } 193 194 /** 195 * 获取某段时间内有效登录过的用户 统一接口 196 * @param int $startTime 开始时间戳 197 * @param int $endTime 结束时间戳 198 * @param array $typeArr 用户类型,为空时获取全部类型 199 * @return array 参数错误或未查询到返回array() 200 * ------------------------------------------------- 201 * 查询到结果:指定用户类型 202 * array( 203 * 'type1' => array( 204 * 'count' => n, // type1 有效登录总用户数 205 * 'list' => array('111', '222' ...) // type1 有效登录用户 206 * ), 207 * 'type2' => array( 208 * 'count' => n, // type2 有效登录总用户数 209 * 'list' => array('333', '444' ...) // type2 有效登录用户 210 * ) 211 * ) 212 * ------------------------------------------------- 213 * 查询到结果:未指定用户类型,全部用户,固定键 'all' 214 * array( 215 * 'all' => array( 216 * 'count' => n, // 有效登录总用户数 217 * 'list' => array('111', '222' ...) // 有效登录用户 218 * ) 219 * ) 220 */ 221 public function getOrientedTimeRangeLogin($startTime, $endTime, $typeArr = array()) 222 { 223 if ($this->checkTimeRange($startTime, $endTime)) { 224 225 // 判断是否指定类型 226 if (is_array($typeArr) && !empty($typeArr)) { 227 228 // 指定类型,验证类型合法性 229 if ($this->checkTypeArr($typeArr)) { 230 231 // 依据类型获取 232 return $this->getSpecifyTypeTimeRangeLogin($startTime, $endTime, $typeArr); 233 } 234 } else { 235 236 // 未指定类型,统一获取 237 return $this->getSpecifyAllTimeRangeLogin($startTime, $endTime); 238 } 239 } 240 return array(); 241 } 242 243 /** 244 * 指定类型:获取某段时间内登录过的用户 245 * @param int $startTime 开始时间戳 246 * @param int $endTime 结束时间戳 247 * @param array $typeArr 用户类型 248 * @return array 249 */ 250 private function getSpecifyTypeTimeRangeLogin($startTime, $endTime, $typeArr) 251 { 252 $data = array(); 253 $successFlg = false; // 查询到数据标识 254 255 // 指定类型,根据类型单独获取,进行整合 256 foreach ($typeArr as $typeArrVal) { 257 258 // 获取需要查询的Key 259 $keyList = $this->_redisHandle->getSpecifyTypeTimeRangeRedisKey($typeArrVal, $startTime, $endTime); 260 if (!empty($keyList)) { 261 262 $data[$typeArrVal]['count'] = 0; // 该类型下有效登录用户数 263 $data[$typeArrVal]['list'] = array(); // 该类型下有效登录用户 264 265 foreach ($keyList as $keyListVal) { 266 267 // 查询Kye,验证Redis中是否存在:此处为单个类型,所以直接看Redis中是否存在该类型Key即可判断是否存在 268 // 存在的数据不需要去数据库中去查看 269 $standardKeyList = $this->_redisHandle->getKeys($keyListVal['key']); 270 if (is_array($standardKeyList) && count($standardKeyList) > 0) { 271 272 // Redis存在 273 foreach ($standardKeyList as $standardKeyListVal) { 274 275 // 验证该用户在此时间段是否登录过 276 $redisCheckLogin = $this->_redisHandle->getUserTimeRangeLogin($standardKeyListVal, $startTime, $endTime); 277 if ($redisCheckLogin['hasLog']['count'] > 0) { 278 279 // 同一个用户只需记录一次 280 $uid = $this->_redisHandle->getLoginLogKeyInfo($standardKeyListVal, 'uid'); 281 if (!in_array($uid, $data[$typeArrVal]['list'])) { 282 $data[$typeArrVal]['count']++; 283 $data[$typeArrVal]['list'][] = $uid; 284 } 285 $successFlg = true; 286 } 287 } 288 289 } else { 290 291 // 不存在,尝试从数据库中获取 292 $dbResult = $this->_dbHandle->getTimeRangeLoginSuccessUser($keyListVal['time'], $startTime, $endTime, $typeArrVal); 293 if (!empty($dbResult)) { 294 foreach ($dbResult as $dbResultVal) { 295 if (!in_array($dbResultVal, $data[$typeArrVal]['list'])) { 296 $data[$typeArrVal]['count']++; 297 $data[$typeArrVal]['list'][] = $dbResultVal; 298 } 299 } 300 $successFlg = true; 301 } 302 } 303 } 304 } 305 } 306 307 if ($successFlg) { return $data; } 308 return array(); 309 } 310 311 /** 312 * 全部类型:获取某段时间内登录过的用户 313 * @param int $startTime 开始时间戳 314 * @param int $endTime 结束时间戳 315 * @return array 316 */ 317 private function getSpecifyAllTimeRangeLogin($startTime, $endTime) 318 { 319 $count = 0; // 有效登录用户数 320 $list = array(); // 有效登录用户 321 $successFlg = false; // 查询到数据标识 322 323 // 未指定类型,直接对所有数据进行检索 324 // 获取需要查询的Key 325 $keyList = $this->_redisHandle->getSpecifyAllTimeRangeRedisKey($startTime, $endTime); 326 327 if (!empty($keyList)) { 328 foreach ($keyList as $keyListVal) { 329 330 // 查询Kye 331 $standardKeyList = $this->_redisHandle->getKeys($keyListVal['key']); 332 333 if (is_array($standardKeyList) && count($standardKeyList) > 0) { 334 335 // 查询到Key,直接读取数据,记录类型 336 foreach ($standardKeyList as $standardKeyListVal) { 337 338 // 验证该用户在此时间段是否登录过 339 $redisCheckLogin = $this->_redisHandle->getUserTimeRangeLogin($standardKeyListVal, $startTime, $endTime); 340 if ($redisCheckLogin['hasLog']['count'] > 0) { 341 342 // 同一个用户只需记录一次 343 $uid = $this->_redisHandle->getLoginLogKeyInfo($standardKeyListVal, 'uid'); 344 if (!in_array($uid, $list)) { 345 $count++; 346 $list[] = $uid; 347 } 348 $successFlg = true; 349 } 350 } 351 } 352 353 // 无论Redis中存在不存在都要尝试从数据库中获取一遍数据,来补充Redis获取的数据,保证检索数据完整(Redis类型缺失可能导致) 354 $dbResult = $this->_dbHandle->getTimeRangeLoginSuccessUser($keyListVal['time'], $startTime, $endTime); 355 if (!empty($dbResult)) { 356 foreach ($dbResult as $dbResultVal) { 357 if (!in_array($dbResultVal, $list)) { 358 $count++; 359 $list[] = $dbResultVal; 360 } 361 } 362 $successFlg = true; 363 } 364 } 365 } 366 367 if ($successFlg) { 368 return array( 369 'all' => array( 370 'count' => $count, 371 'list' => $list 372 ) 373 ); 374 } 375 return array(); 376 } 377 378 /** 379 * 验证开始结束时间 380 * @param string $startTime 开始时间 381 * @param string $endTime 结束时间 382 * @return boolean 383 */ 384 private function checkTimeRange($startTime, $endTime) 385 { 386 return $this->_redisHandle->checkTimeRange($startTime, $endTime); 387 } 388 389 /** 390 * 批量验证用户类型 391 * @param array $typeArr 用户类型数组 392 * @return boolean 393 */ 394 private function checkTypeArr($typeArr) 395 { 396 $flg = false; 397 if (is_array($typeArr) && !empty($typeArr)) { 398 foreach ($typeArr as $val) { 399 if ($this->_redisHandle->checkType($val)) { 400 $flg = true; 401 } else { 402 $flg = false; break; 403 } 404 } 405 } 406 return $flg; 407 } 408 409 /** 410 * 定时任务每周调用一次:从Redis同步登录日志到数据库 411 * @param int $existsDay 一条记录在Redis中过期时间,单位:天,必须大于31 412 * @return string 413 * 'null': Redis中无数据 414 * 'fail': 同步失败 415 * 'success':同步成功 416 */ 417 public function cronWeeklySync($existsDay) 418 { 419 420 // 验证生存时间 421 if ($this->_redisHandle->checkExistsDay($existsDay)) { 422 $likeKey = 'loginLog_*'; 423 $keyList = $this->_redisHandle->getKeys($likeKey); 424 425 if (!empty($keyList)) { 426 foreach ($keyList as $keyVal) { 427 428 if ($this->_redisHandle->checkLoginLogKey($keyVal)) { 429 $keyTime = $this->_redisHandle->getLoginLogKeyInfo($keyVal, 'time'); 430 $thisMonth = date('Y-m'); 431 $beforeMonth = date('Y-m', strtotime('-1 month')); 432 433 // 验证是否需要进行同步: 434 // 1. 当前日期 >= 8号,对本月所有记录进行同步,不对本月之前的记录进行同步 435 // 2. 当前日期 < 8号,对本月所有记录进行同步,对本月前一个月的记录进行同步,对本月前一个月之前的所有记录不进行同步 436 if (date('j') >= 8) { 437 438 // 只同步本月数据 439 if ($thisMonth == $keyTime) { 440 $this->redis2db($keyVal); 441 } 442 } else { 443 444 // 同步本月或本月前一个月数据 445 if ($thisMonth == $keyTime || $beforeMonth == $keyTime) { 446 $this->redis2db($keyVal); 447 } 448 } 449 450 // 验证是否过期 451 $existsSecond = $existsDay * 24 * 60 * 60; 452 if (strtotime($keyTime) + $existsSecond < time()) { 453 454 // 过期删除 455 $bitMap = $this->_redisHandle->getLoginLogBitMap($keyVal); 456 Log::INFO('删除过期数据[' . $keyVal . ']:' . $bitMap); 457 $this->_redisHandle->delLoginLog($keyVal); 458 } 459 } 460 } 461 return 'success'; 462 } 463 return 'null'; 464 } 465 return 'fail'; 466 } 467 468 /** 469 * 将记录同步到数据库 470 * @param string $key 记录Key 471 * @return boolean 472 */ 473 private function redis2db($key) 474 { 475 if ($this->_redisHandle->checkLoginLogKey($key) && $this->_redisHandle->checkRedisLogExists($key)) { 476 $time = $this->_redisHandle->getLoginLogKeyInfo($key, 'time'); 477 $data['id'] = Tools::generateId(); 478 $data['user_id'] = $this->_redisHandle->getLoginLogKeyInfo($key, 'uid'); 479 $data['type'] = $this->_redisHandle->getLoginLogKeyInfo($key, 'type'); 480 $data['year'] = date('Y', strtotime($time)); 481 $data['month'] = date('n', strtotime($time)); 482 $data['bit_log'] = $this->_redisHandle->getLoginLogBitMap($key); 483 return $this->_dbHandle->redis2db($data); 484 } 485 return false; 486 } 487 }
4.2 LoginLogCommon.class.php
1 <?php 2 3 namespace Lib\LoginLog; 4 5 use Lib\RedisData; 6 use Lib\Status; 7 8 /** 9 * 公共方法 10 * User: dbn 11 * Date: 2017/10/11 12 * Time: 13:11 13 */ 14 class LoginLogCommon 15 { 16 protected $_loginLog; 17 protected $_redis; 18 19 public function __construct(LoginLog $loginLog) 20 { 21 $this->_loginLog = $loginLog; 22 $this->_redis = RedisData::getRedis(); 23 } 24 25 /** 26 * 验证用户类型 27 * @param string $type 用户类型 28 * @return boolean 29 */ 30 protected function checkType($type) 31 { 32 if (in_array($type, array( 33 Status::LOGIN_LOG_TYPE_ADMIN, 34 Status::LOGIN_LOG_TYPE_CARRIER, 35 Status::LOGIN_LOG_TYPE_DRIVER, 36 Status::LOGIN_LOG_TYPE_OFFICE, 37 Status::LOGIN_LOG_TYPE_CLIENT, 38 ))) { 39 return true; 40 } 41 $this->_loginLog->setError('未定义的日志类型:' . $type); 42 return false; 43 } 44 45 /** 46 * 验证唯一标识 47 * @param string $uid 48 * @return boolean 49 */ 50 protected function checkUid($uid) 51 { 52 if (is_numeric($uid) && $uid > 0) { 53 return true; 54 } 55 $this->_loginLog->setError('唯一标识非法:' . $uid); 56 return false; 57 } 58 59 /** 60 * 验证时间戳 61 * @param string $time 62 * @return boolean 63 */ 64 protected function checkTime($time) 65 { 66 if (is_numeric($time) && $time > 0) { 67 return true; 68 } 69 $this->_loginLog->setError('时间戳非法:' . $time); 70 return false; 71 } 72 73 /** 74 * 验证时间是否在当月中 75 * @param string $time 76 * @return boolean 77 */ 78 protected function checkTimeWhetherThisMonth($time) 79 { 80 if ($this->checkTime($time) && $time > strtotime(date('Y-m')) && $time < strtotime(date('Y-m') . '-' . date('t'))) { 81 return true; 82 } 83 $this->_loginLog->setError('时间未在当前月份中:' . $time); 84 return false; 85 } 86 87 /** 88 * 验证时间是否超过当前时间 89 * @param string $time 90 * @return boolean 91 */ 92 protected function checkTimeWhetherFutureTime($time) 93 { 94 if ($this->checkTime($time) && $time <= time()) { 95 return true; 96 } 97 return false; 98 } 99 100 /** 101 * 验证开始/结束时间 102 * @param string $startTime 开始时间 103 * @param string $endTime 结束时间 104 * @return boolean 105 */ 106 protected function checkTimeRange($startTime, $endTime) 107 { 108 if ($this->checkTime($startTime) && 109 $this->checkTime($endTime) && 110 $startTime < $endTime && 111 $startTime < time() 112 ) { 113 return true; 114 } 115 $this->_loginLog->setError('时间范围非法:' . $startTime . '-' . $endTime); 116 return false; 117 } 118 119 /** 120 * 验证时间是否在指定范围内 121 * @param string $time 需要检查的时间 122 * @param string $startTime 开始时间 123 * @param string $endTime 结束时间 124 * @return boolean 125 */ 126 protected function checkTimeWithinTimeRange($time, $startTime, $endTime) 127 { 128 if ($this->checkTime($time) && 129 $this->checkTimeRange($startTime, $endTime) && 130 $startTime <= $time && 131 $time <= $endTime 132 ) { 133 return true; 134 } 135 $this->_loginLog->setError('请求时间未在时间范围内:' . $time . '-' . $startTime . '-' . $endTime); 136 return false; 137 } 138 139 /** 140 * 验证Redis日志记录标准Key 141 * @param string $key 142 * @return boolean 143 */ 144 protected function checkLoginLogKey($key) 145 { 146 $pattern = '/^loginLog_\d{4}-\d{1,2}_\S+_\d+$/'; 147 $result = preg_match($pattern, $key, $match); 148 if ($result > 0) { 149 return true; 150 } 151 $this->_loginLog->setError('RedisKey非法:' . $key); 152 return false; 153 } 154 155 /** 156 * 获取月份中有多少天 157 * @param int $time 时间戳 158 * @return int 159 */ 160 protected function getDaysInMonth($time) 161 { 162 return date('t', $time); 163 } 164 165 /** 166 * 对没有前导零的月份或日设置前导零 167 * @param int $num 月份或日 168 * @return string 169 */ 170 protected function setDateLeadingZero($num) 171 { 172 if (is_numeric($num) && strlen($num) <= 2) { 173 $num = (strlen($num) > 1 ? $num : '0' . $num); 174 } 175 return $num; 176 } 177 178 /** 179 * 验证过期时间 180 * @param int $existsDay 一条记录在Redis中过期时间,单位:天,必须大于31 181 * @return boolean 182 */ 183 protected function checkExistsDay($existsDay) 184 { 185 if (is_numeric($existsDay) && ctype_digit(strval($existsDay)) && $existsDay > 31) { 186 return true; 187 } 188 $this->_loginLog->setError('过期时间非法:' . $existsDay); 189 return false; 190 } 191 192 /** 193 * 获取开始日期边界 194 * @param int $time 需要判断的时间戳 195 * @param int $startTime 起始时间 196 * @return int 197 */ 198 protected function getStartTimeBorder($time, $startTime) 199 { 200 $initDay = 1; 201 if ($this->checkTime($time) && $this->checkTime($startTime) && 202 date('Y-m', $time) === date('Y-m', $startTime) && false !== date('Y-m', $time)) { 203 $initDay = date('j', $startTime); 204 } 205 return $initDay; 206 } 207 208 /** 209 * 获取结束日期边界 210 * @param int $time 需要判断的时间戳 211 * @param int $endTime 结束时间 212 * @return int 213 */ 214 protected function getEndTimeBorder($time, $endTime) 215 { 216 $border = $this->getDaysInMonth($time); 217 if ($this->checkTime($time) && $this->checkTime($endTime) && 218 date('Y-m', $time) === date('Y-m', $endTime) && false !== date('Y-m', $time)) { 219 $border = date('j', $endTime); 220 } 221 return $border; 222 } 223 }
4.3 LoginLogDBHandle.class.php
1 <?php 2 3 namespace Lib\LoginLog; 4 use Think\Model; 5 6 /** 7 * 数据库登录日志处理类 8 * User: dbn 9 * Date: 2017/10/11 10 * Time: 13:12 11 */ 12 class LoginLogDBHandle extends LoginLogCommon 13 { 14 15 /** 16 * 从数据库中获取用户某月记录在指定时间范围内的用户信息 17 * @param string $type 用户类型 18 * @param int $uid 唯一标识(用户ID) 19 * @param int $time 需要查询月份时间戳 20 * @param int $startTime 开始时间戳 21 * @param int $endTime 结束时间戳 22 * @return array 23 * array( 24 * 'hasLog' => array( 25 * 'count' => n, // 有效登录次数,每天重复登录算一次 26 * 'list' => array('2017-10-1', '2017-10-15' ...) // 有效登录日期 27 * ), 28 * 'notLog' => array( 29 * 'count' => n, // 未登录次数 30 * 'list' => array('2017-10-1', '2017-10-15' ...) // 未登录日期 31 * ) 32 * ) 33 */ 34 public function getUserTimeRangeLogin($type, $uid, $time, $startTime, $endTime) 35 { 36 $hasCount = 0; // 有效登录次数 37 $notCount = 0; // 未登录次数 38 $hasList = array(); // 有效登录日期 39 $notList = array(); // 未登录日期 40 41 if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTimeWithinTimeRange($time, $startTime, $endTime)) { 42 43 $timeYM = date('Y-m', $time); 44 45 // 设置开始时间 46 $initDay = $this->getStartTimeBorder($time, $startTime); 47 48 // 设置结束时间 49 $border = $this->getEndTimeBorder($time, $endTime); 50 51 $bitMap = $this->getBitMapFind($type, $uid, date('Y', $time), date('n', $time)); 52 for ($i = $initDay; $i <= $border; $i++) { 53 54 if (!empty($bitMap)) { 55 if ($bitMap[$i-1] == '1') { 56 $hasCount++; 57 $hasList[] = $timeYM . '-' . $this->setDateLeadingZero($i); 58 } else { 59 $notCount++; 60 $notList[] = $timeYM . '-' . $this->setDateLeadingZero($i); 61 } 62 } else { 63 $notCount++; 64 $notList[] = $timeYM . '-' . $this->setDateLeadingZero($i); 65 } 66 } 67 } 68 69 return array( 70 'hasLog' => array( 71 'count' => $hasCount, 72 'list' => $hasList 73 ), 74 'notLog' => array( 75 'count' => $notCount, 76 'list' => $notList 77 ) 78 ); 79 } 80 81 /** 82 * 从数据库获取用户某月日志位图 83 * @param string $type 用户类型 84 * @param int $uid 唯一标识(用户ID) 85 * @param int $year 年Y 86 * @param int $month 月n 87 * @return string 88 */ 89 private function getBitMapFind($type, $uid, $year, $month) 90 { 91 $model = D('Home/StatLoginLog'); 92 $map['type'] = array('EQ', $type); 93 $map['user_id'] = array('EQ', $uid); 94 $map['year'] = array('EQ', $year); 95 $map['month'] = array('EQ', $month); 96 97 $result = $model->field('bit_log')->where($map)->find(); 98 if (false !== $result && isset($result['bit_log']) && !empty($result['bit_log'])) { 99 return $result['bit_log']; 100 } 101 return ''; 102 } 103 104 /** 105 * 从数据库中判断用户在某一天是否登录过 106 * @param string $type 用户类型 107 * @param int $uid 唯一标识(用户ID) 108 * @param int $time 时间戳 109 * @return boolean 参数错误或未登录过返回false,登录过返回true 110 */ 111 public function dateWhetherLogin($type, $uid, $time) 112 { 113 if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTime($time)) { 114 115 $timeInfo = getdate($time); 116 $bitMap = $this->getBitMapFind($type, $uid, $timeInfo['year'], $timeInfo['mon']); 117 if (!empty($bitMap)) { 118 if ($bitMap[$timeInfo['mday']-1] == '1') { 119 return true; 120 } 121 } 122 } 123 return false; 124 } 125 126 /** 127 * 从数据库中判断用户在某月是否登录过 128 * @param string $type 用户类型 129 * @param int $uid 唯一标识(用户ID) 130 * @param int $time 时间戳 131 * @return boolean 参数错误或未登录过返回false,登录过返回true 132 */ 133 public function dateMonthWhetherLogin($type, $uid, $time) 134 { 135 if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTime($time)) { 136 137 $timeInfo = getdate($time); 138 $userArr = $this->getMonthLoginSuccessUser($timeInfo['year'], $timeInfo['mon'], $type); 139 if (!empty($userArr)) { 140 if (in_array($uid, $userArr)) { 141 return true; 142 } 143 } 144 } 145 return false; 146 } 147 148 /** 149 * 获取某月所有有效登录过的用户ID 150 * @param int $year 年Y 151 * @param int $month 月n 152 * @param string $type 用户类型,为空时获取全部类型 153 * @return array 154 */ 155 public function getMonthLoginSuccessUser($year, $month, $type = '') 156 { 157 $data = array(); 158 if (is_numeric($year) && is_numeric($month)) { 159 $model = D('Home/StatLoginLog'); 160 $map['year'] = array('EQ', $year); 161 $map['month'] = array('EQ', $month); 162 $map['bit_log'] = array('LIKE', '%1%'); 163 if ($type != '' && $this->checkType($type)) { 164 $map['type'] = array('EQ', $type); 165 } 166 $result = $model->field('user_id')->where($map)->select(); 167 if (false !== $result && count($result) > 0) { 168 foreach ($result as $val) { 169 if (isset($val['user_id'])) { 170 $data[] = $val['user_id']; 171 } 172 } 173 } 174 } 175 return $data; 176 } 177 178 /** 179 * 从数据库中获取某月所有记录在指定时间范围内的用户ID 180 * @param int $time 查询的时间戳 181 * @param int $startTime 开始时间戳 182 * @param int $endTime 结束时间戳 183 * @param string $type 用户类型,为空时获取全部类型 184 * @return array 185 */ 186 public function getTimeRangeLoginSuccessUser($time, $startTime, $endTime, $type = '') 187 { 188 $data = array(); 189 if ($this->checkTimeWithinTimeRange($time, $startTime, $endTime)) { 190 191 $timeInfo = getdate($time); 192 193 // 获取满足时间条件的记录 194 $model = D('Home/StatLoginLog'); 195 $map['year'] = array('EQ', $timeInfo['year']); 196 $map['month'] = array('EQ', $timeInfo['mon']); 197 if ($type != '' && $this->checkType($type)) { 198 $map['type'] = array('EQ', $type); 199 } 200 201 $result = $model->where($map)->select(); 202 if (false !== $result && count($result) > 0) { 203 204 // 设置开始时间 205 $initDay = $this->getStartTimeBorder($time, $startTime); 206 207 // 设置结束时间 208 $border = $this->getEndTimeBorder($time, $endTime); 209 210 foreach ($result as $val) { 211 212 $bitMap = $val['bit_log']; 213 for ($i = $initDay; $i <= $border; $i++) { 214 215 if ($bitMap[$i-1] == '1' && !in_array($val['user_id'], $data)) { 216 $data[] = $val['user_id']; 217 } 218 } 219 } 220 } 221 } 222 return $data; 223 } 224 225 /** 226 * 将数据更新到数据库 227 * @param array $data 单条记录的数据 228 * @return boolean 229 */ 230 public function redis2db($data) 231 { 232 $model = D('Home/StatLoginLog'); 233 234 // 验证记录是否存在 235 $map['user_id'] = array('EQ', $data['user_id']); 236 $map['type'] = array('EQ', $data['type']); 237 $map['year'] = array('EQ', $data['year']); 238 $map['month'] = array('EQ', $data['month']); 239 240 $count = $model->where($map)->count(); 241 if (false !== $count && $count > 0) { 242 243 // 存在记录进行更新 244 $saveData['bit_log'] = $data['bit_log']; 245 246 if (!$model->create($saveData, Model::MODEL_UPDATE)) { 247 248 $this->_loginLog->setError('同步登录日志-更新记录,创建数据对象失败:' . $model->getError()); 249 logger()->error('同步登录日志-更新记录,创建数据对象失败:' . $model->getError()); 250 return false; 251 } else { 252 253 $result = $model->where($map)->save(); 254 255 if (false !== $result) { 256 return true; 257 } else { 258 $this->_loginLog->setError('同步登录日志-更新记录,更新数据失败:' . json_encode($data)); 259 logger()->error('同步登录日志-更新记录,更新数据失败:' . json_encode($data)); 260 return false; 261 } 262 } 263 } else { 264 265 // 不存在记录插入一条新的记录 266 if (!$model->create($data, Model::MODEL_INSERT)) { 267 268 $this->_loginLog->setError('同步登录日志-插入记录,创建数据对象失败:' . $model->getError()); 269 logger()->error('同步登录日志-插入记录,创建数据对象失败:' . $model->getError()); 270 return false; 271 } else { 272 273 $result = $model->add(); 274 275 if (false !== $result) { 276 return true; 277 } else { 278 $this->_loginLog->setError('同步登录日志-插入记录,插入数据失败:' . json_encode($data)); 279 logger()->error('同步登录日志-插入记录,插入数据失败:' . json_encode($data)); 280 return false; 281 } 282 } 283 } 284 } 285 }
4.4 LoginLogRedisHandle.class.php
1 <?php 2 3 namespace Lib\LoginLog; 4 5 /** 6 * Redis登录日志处理类 7 * User: dbn 8 * Date: 2017/10/11 9 * Time: 15:53 10 */ 11 class LoginLogRedisHandle extends LoginLogCommon 12 { 13 /** 14 * 记录登录:每天只记录一次登录,只允许设置当月内登录记录 15 * @param string $key 日志记录Key 16 * @param int $time 时间戳 17 * @return boolean 18 */ 19 public function setLogging($key, $time) 20 { 21 if ($this->checkLoginLogKey($key) && $this->checkTimeWhetherThisMonth($time)) { 22 23 // 判断用户当天是否已经登录过 24 $whetherLoginResult = $this->dateWhetherLogin($key, $time); 25 if (!$whetherLoginResult) { 26 27 // 当天未登录,记录登录 28 $this->_redis->setBit($key, date('d', $time), 1); 29 } 30 return true; 31 } 32 return false; 33 } 34 35 /** 36 * 从Redis中判断用户在某一天是否登录过 37 * @param string $key 日志记录Key 38 * @param int $time 时间戳 39 * @return boolean 参数错误或未登录过返回false,登录过返回true 40 */ 41 public function dateWhetherLogin($key, $time) 42 { 43 if ($this->checkLoginLogKey($key) && $this->checkTime($time)) { 44 $result = $this->_redis->getBit($key, date('d', $time)); 45 if ($result === 1) { 46 return true; 47 } 48 } 49 return false; 50 } 51 52 /** 53 * 从Redis中判断用户在某月是否登录过 54 * @param string $key 日志记录Key 55 * @return boolean 参数错误或未登录过返回false,登录过返回true 56 */ 57 public function dateMonthWhetherLogin($key) 58 { 59 if ($this->checkLoginLogKey($key)) { 60 $result = $this->_redis->bitCount($key); 61 if ($result > 0) { 62 return true; 63 } 64 } 65 return false; 66 } 67 68 /** 69 * 判断某月登录记录在Redis中是否存在 70 * @param string $key 日志记录Key 71 * @return boolean 72 */ 73 public function checkRedisLogExists($key) 74 { 75 if ($this->checkLoginLogKey($key)) { 76 if ($this->_redis->exists($key)) { 77 return true; 78 } 79 } 80 return false; 81 } 82 83 /** 84 * 从Redis中获取用户某月记录在指定时间范围内的用户信息 85 * @param string $key 日志记录Key 86 * @param int $startTime 开始时间戳 87 * @param int $endTime 结束时间戳 88 * @return array 89 * array( 90 * 'hasLog' => array( 91 * 'count' => n, // 有效登录次数,每天重复登录算一次 92 * 'list' => array('2017-10-1', '2017-10-15' ...) // 有效登录日期 93 * ), 94 * 'notLog' => array( 95 * 'count' => n, // 未登录次数 96 * 'list' => array('2017-10-1', '2017-10-15' ...) // 未登录日期 97 * ) 98 * ) 99 */ 100 public function getUserTimeRangeLogin($key, $startTime, $endTime) 101 { 102 $hasCount = 0; // 有效登录次数 103 $notCount = 0; // 未登录次数 104 $hasList = array(); // 有效登录日期 105 $notList = array(); // 未登录日期 106 107 if ($this->checkLoginLogKey($key) && $this->checkTimeRange($startTime, $endTime) && $this->checkRedisLogExists($key)) { 108 109 $keyTime = $this->getLoginLogKeyInfo($key, 'time'); 110 $keyTime = strtotime($keyTime); 111 $timeYM = date('Y-m', $keyTime); 112 113 // 设置开始时间 114 $initDay = $this->getStartTimeBorder($keyTime, $startTime); 115 116 // 设置结束时间 117 $border = $this->getEndTimeBorder($keyTime, $endTime); 118 119 for ($i = $initDay; $i <= $border; $i++) { 120 $result = $this->_redis->getBit($key, $i); 121 if ($result === 1) { 122 $hasCount++; 123 $hasList[] = $timeYM . '-' . $this->setDateLeadingZero($i); 124 } else { 125 $notCount++; 126 $notList[] = $timeYM . '-' . $this->setDateLeadingZero($i); 127 } 128 } 129 } 130 131 return array( 132 'hasLog' => array( 133 'count' => $hasCount, 134 'list' => $hasList 135 ), 136 'notLog' => array( 137 'count' => $notCount, 138 'list' => $notList 139 ) 140 ); 141 } 142 143 /** 144 * 面向用户:获取时间范围内可能需要的Key 145 * @param string $type 用户类型 146 * @param int $uid 唯一标识(用户ID) 147 * @param string $startTime 开始时间 148 * @param string $endTime 结束时间 149 * @return array 150 */ 151 public function getTimeRangeRedisKey($type, $uid, $startTime, $endTime) 152 { 153 $list = array(); 154 155 if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTimeRange($startTime, $endTime)) { 156 157 $data = $this->getSpecifyUserKeyHandle($type, $uid, $startTime); 158 if (!empty($data)) { $list[] = $data; } 159 160 $temYM = strtotime('+1 month', strtotime(date('Y-m', $startTime))); 161 162 while ($temYM <= $endTime) { 163 $data = $this->getSpecifyUserKeyHandle($type, $uid, $temYM); 164 if (!empty($data)) { $list[] = $data; } 165 166 $temYM = strtotime('+1 month', $temYM); 167 } 168 } 169 return $list; 170 } 171 private function getSpecifyUserKeyHandle($type, $uid, $time) 172 { 173 $data = array(); 174 $key = $this->getLoginLogKey($type, $uid, $time); 175 if ($this->checkLoginLogKey($key)) { 176 $data = array( 177 'key' => $key, 178 'time' => $time 179 ); 180 } 181 return $data; 182 } 183 184 /** 185 * 面向类型:获取时间范围内可能需要的Key 186 * @param string $type 用户类型 187 * @param string $startTime 开始时间 188 * @param string $endTime 结束时间 189 * @return array 190 */ 191 public function getSpecifyTypeTimeRangeRedisKey($type, $startTime, $endTime) 192 { 193 $list = array(); 194 195 if ($this->checkType($type) && $this->checkTimeRange($startTime, $endTime)) { 196 197 $data = $this->getSpecifyTypeKeyHandle($type, $startTime); 198 if (!empty($data)) { $list[] = $data; } 199 200 $temYM = strtotime('+1 month', strtotime(date('Y-m', $startTime))); 201 202 while ($temYM <= $endTime) { 203 $data = $this->getSpecifyTypeKeyHandle($type, $temYM); 204 if (!empty($data)) { $list[] = $data; } 205 206 $temYM = strtotime('+1 month', $temYM); 207 } 208 } 209 return $list; 210 } 211 private function getSpecifyTypeKeyHandle($type, $time) 212 { 213 $data = array(); 214 $temUid = '11111111'; 215 216 $key = $this->getLoginLogKey($type, $temUid, $time); 217 if ($this->checkLoginLogKey($key)) { 218 $arr = explode('_', $key); 219 $arr[count($arr)-1] = '*'; 220 $key = implode('_', $arr); 221 $data = array( 222 'key' => $key, 223 'time' => $time 224 ); 225 } 226 return $data; 227 } 228 229 /** 230 * 面向全部:获取时间范围内可能需要的Key 231 * @param string $startTime 开始时间 232 * @param string $endTime 结束时间 233 * @return array 234 */ 235 public function getSpecifyAllTimeRangeRedisKey($startTime, $endTime) 236 { 237 $list = array(); 238 239 if ($this->checkTimeRange($startTime, $endTime)) { 240 241 $data = $this->getSpecifyAllKeyHandle($startTime); 242 if (!empty($data)) { $list[] = $data; } 243 244 $temYM = strtotime('+1 month', strtotime(date('Y-m', $startTime))); 245 246 while ($temYM <= $endTime) { 247 $data = $this->getSpecifyAllKeyHandle($temYM); 248 if (!empty($data)) { $list[] = $data; } 249 250 $temYM = strtotime('+1 month', $temYM); 251 } 252 } 253 return $list; 254 } 255 private function getSpecifyAllKeyHandle($time) 256 { 257 $data = array(); 258 $temUid = '11111111'; 259 $temType = 'office'; 260 261 $key = $this->getLoginLogKey($temType, $temUid, $time); 262 if ($this->checkLoginLogKey($key)) { 263 $arr = explode('_', $key); 264 array_pop($arr); 265 $arr[count($arr)-1] = '*'; 266 $key = implode('_', $arr); 267 $data = array( 268 'key' => $key, 269 'time' => $time 270 ); 271 } 272 return $data; 273 } 274 275 /** 276 * 从Redis中查询满足条件的Key 277 * @param string $key 查询的Key 278 * @return array 279 */ 280 public function getKeys($key) 281 { 282 return $this->_redis->keys($key); 283 } 284 285 /** 286 * 从Redis中删除记录 287 * @param string $key 记录的Key 288 * @return boolean 289 */ 290 public function delLoginLog($key) 291 { 292 return $this->_redis->del($key); 293 } 294 295 /** 296 * 获取日志标准Key:前缀_年-月_用户类型_唯一标识 297 * @param string $type 用户类型 298 * @param int $uid 唯一标识(用户ID) 299 * @param int $time 时间戳 300 * @return string 301 */ 302 public function getLoginLogKey($type, $uid, $time) 303 { 304 if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTime($time)) { 305 return 'loginLog_' . date('Y-m', $time) . '_' . $type . '_' . $uid; 306 } 307 return ''; 308 } 309 310 /** 311 * 获取日志标准Key上信息 312 * @param string $key key 313 * @param string $field 需要的参数 time,type,uid 314 * @return mixed 返回对应的值,没有返回null 315 */ 316 public function getLoginLogKeyInfo($key, $field) 317 { 318 $param = array(); 319 if ($this->checkLoginLogKey($key)) { 320 $arr = explode('_', $key); 321 $param['time'] = $arr[1]; 322 $param['type'] = $arr[2]; 323 $param['uid'] = $arr[3]; 324 } 325 return $param[$field]; 326 } 327 328 /** 329 * 获取Key记录的登录位图 330 * @param string $key key 331 * @return string 332 */ 333 public function getLoginLogBitMap($key) 334 { 335 $bitMap = ''; 336 if ($this->checkLoginLogKey($key)) { 337 $time = $this->getLoginLogKeyInfo($key, 'time'); 338 $maxDay = $this->getDaysInMonth(strtotime($time)); 339 for ($i = 1; $i <= $maxDay; $i++) { 340 $bitMap .= $this->_redis->getBit($key, $i); 341 } 342 } 343 return $bitMap; 344 } 345 346 /** 347 * 验证日志标准Key 348 * @param string $key 349 * @return boolean 350 */ 351 public function checkLoginLogKey($key) 352 { 353 return parent::checkLoginLogKey($key); 354 } 355 356 /** 357 * 验证开始/结束时间 358 * @param string $startTime 开始时间 359 * @param string $endTime 结束时间 360 * @return boolean 361 */ 362 public function checkTimeRange($startTime, $endTime) 363 { 364 return parent::checkTimeRange($startTime, $endTime); 365 } 366 367 /** 368 * 验证用户类型 369 * @param string $type 370 * @return boolean 371 */ 372 public function checkType($type) 373 { 374 return parent::checkType($type); 375 } 376 377 /** 378 * 验证过期时间 379 * @param int $existsDay 一条记录在Redis中过期时间,单位:天,必须大于31 380 * @return boolean 381 */ 382 public function checkExistsDay($existsDay) 383 { 384 return parent::checkExistsDay($existsDay); 385 } 386 }
5. 参考资料
https://segmentfault.com/a/1190000008188655
http://blog.csdn.net/rdhj5566/article/details/54313840
http://www.redis.net.cn/tutorial/3508.html