基于 Redis 位图实现系统用户登录统计 | BNDong
0%

基于 Redis 位图实现系统用户登录统计

项目需求,试着写了一个简单登录统计,基本功能都实现了,日志数据量小。具体性能没有进行测试~ 记录下开发过程与代码,留着以后改进!

需求

  • 实现记录用户哪天进行了登录,每天只记录是否登录过,重复登录状态算已登录。不需要记录用户的操作行为,不需要记录用户上次登录时间和IP地址(这部分以后需要可以单独拿出来存储)
  • 区分用户类型
  • 查询数据需要精确到天

分析

考虑到只是简单的记录用户是否登录,记录数据比较单一,查询需要精确到天。以百万用户量为前提,前期考虑了几个方案。

使用文件

使用单文件存储:文件占用空间增长速度快,海量数据检索不方便,Map/Reduce 操作也麻烦。

使用多文件存储:按日期对文件进行分割。每天记录当天日志,文件量过大。

使用数据库

不太认同直接使用数据库写入/读取

  • 频繁请求数据库做一些日志记录浪费服务器开销。
  • 随着时间推移数据急剧增大
  • 海量数据检索效率也不高,同时使用索引,易产生碎片,每次插入数据还要维护索引,影响性能

所以只考虑使用数据库做数据备份。

使用Redis位图(BitMap)

这也是在网上看到的方法,比较实用。也是我最终考虑使用的方法,

首先优点:

  • 数据量小:一个bit位来表示某个元素对应的值或者状态,其中的key就是对应元素本身。我们知道8个bit可以组成一个Byte,所以bitmap本身会极大的节省储存空间。1亿人每天的登陆情况,用1亿bit,约1200WByte,约10M 的字符就能表示。

  • 计算方便:实用Redis bit 相关命令可以极大的简化一些统计操作。常用命令 SETBITGETBITBITCOUNTBITOP

再说弊端:

  • 存储单一:位图上存储只是0/1,所以需要存储其他信息就要别的地方单独记录,对于需要存储信息多的记录就需要使用别的方法了

设计

Redis BitMap

Key结构:前缀_年Y-月m_用户类型_用户ID

1
2
3
4
5
6
标准Key:KEYS loginLog_2017-10_client_1001
检索全部:KEYS loginLog_*
检索某年某月全部:KEYS loginLog_2017-10_*
检索单个用户全部:KEYS loginLog_*_client_1001
检索单个类型全部:KEYS loginLog_*_office_*
...

每条BitMap记录单个用户一个月的登录情况,一个bit位表示一天登录情况。

1
2
3
4
5
设置用户1001217-10-25登录:SETBIT loginLog_2017-10_client_1001 25 1
获取用户1001217-10-25是否登录:GETBIT loginLog_2017-10_client_1001 25
获取用户1001217-10月是否登录:BITCOUNT loginLog_2017-10_client_1001
获取用户1001217-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设置过期时间,会造成数据丢失。

1
2
3
4
5
上一次更新时间:         2107-10-02
下一次更新时间: 2017-10-09
Redis BitMap 过期时间: 2017-10-05

这样会造成:2017-10-09同步的时候,3/4/5/6/7/8/9 数据丢失

所以我把Redis过期数据放到同步时进行判断  

我自己想的同步策略(定时每周一凌晨同步):

1
2
3
4
一、验证是否需要进行同步:
1. 当前日期 >= 8号,对本月所有记录进行同步,不对本月之前的记录进行同步
2. 当前日期 < 8号,对本月所有记录进行同步,对本月前一个月的记录进行同步,对本月前一个月之前的所有记录不进行同步
二、验证过期,如果过期,记录日志后删除

数据库,表结构

每周同步一次数据到数据库,表中一条数据对应一个BitMap,记录一个月数据。每次更新已存在的、插入没有的

暂定接口

  1.  设置用户登录
  2.  查询单个用户某天是否登录过
  3. 查询单个用户某月是否登录过
  4.  查询单个用户某个时间段是否登录过
  5.  查询单个用户某个时间段登录信息
  6.  指定用户类型:获取某个时间段内有效登录的用户
  7.  全部用户:获取某个时间段内有效登录的用户

Code

TP3中实现的代码,在接口服务器内部库中,Application\Lib\

├─LoginLog

│ ├─Logs 日志目录,Redis中过期的记录删除写入日志进行备份

│ ├─LoginLog.class.php 对外接口

│ ├─LoginLogCommon.class.php 公共工具类

│ ├─LoginLogDBHandle.class.php 数据库操作类

│ ├─LoginLogRedisHandle.class.php Redis操作类

LoginLog.class.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
<?php

namespace Lib\LoginLog;
use Lib\CLogFileHandler;
use Lib\HObject;
use Lib\Log;
use Lib\Tools;

/**
* 登录日志操作类
* User: dbn
* Date: 2017/10/11
* Time: 12:01
* ------------------------
* 日志最小粒度为:天
*/

class LoginLog extends HObject
{
private $_redisHandle; // Redis登录日志处理
private $_dbHandle; // 数据库登录日志处理

public function __construct()
{
$this->_redisHandle = new LoginLogRedisHandle($this);
$this->_dbHandle = new LoginLogDBHandle($this);

// 初始化日志
$logHandler = new CLogFileHandler(__DIR__ . '/Logs/del.log');
Log::Init($logHandler, 15);
}

/**
* 记录登录:每天只记录一次登录,只允许设置当月内登录记录
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param int $time 时间戳
* @return boolean
*/
public function setLogging($type, $uid, $time)
{
$key = $this->_redisHandle->getLoginLogKey($type, $uid, $time);
if ($this->_redisHandle->checkLoginLogKey($key)) {
return $this->_redisHandle->setLogging($key, $time);
}
return false;
}

/**
* 查询用户某一天是否登录过
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param int $time 时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function getDateWhetherLogin($type, $uid, $time)
{
$key = $this->_redisHandle->getLoginLogKey($type, $uid, $time);
if ($this->_redisHandle->checkLoginLogKey($key)) {

// 判断Redis中是否存在记录
$isRedisExists = $this->_redisHandle->checkRedisLogExists($key);
if ($isRedisExists) {

// 从Redis中进行判断
return $this->_redisHandle->dateWhetherLogin($key, $time);
} else {

// 从数据库中进行判断
return $this->_dbHandle->dateWhetherLogin($type, $uid, $time);
}
}
return false;
}

/**
* 查询用户某月是否登录过
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param int $time 时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function getDateMonthWhetherLogin($type, $uid, $time)
{
$key = $this->_redisHandle->getLoginLogKey($type, $uid, $time);
if ($this->_redisHandle->checkLoginLogKey($key)) {

// 判断Redis中是否存在记录
$isRedisExists = $this->_redisHandle->checkRedisLogExists($key);
if ($isRedisExists) {

// 从Redis中进行判断
return $this->_redisHandle->dateMonthWhetherLogin($key);
} else {

// 从数据库中进行判断
return $this->_dbHandle->dateMonthWhetherLogin($type, $uid, $time);
}
}
return false;
}

/**
* 查询用户在某个时间段是否登录过
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function getTimeRangeWhetherLogin($type, $uid, $startTime, $endTime){
$result = $this->getUserTimeRangeLogin($type, $uid, $startTime, $endTime);
if ($result['hasLog']['count'] > 0) {
return true;
}
return false;
}

/**
* 获取用户某时间段内登录信息
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return array 参数错误或未查询到返回array()
* -------------------------------------------------
* 查询到结果:
* array(
* 'hasLog' => array(
* 'count' => n, // 有效登录次数,每天重复登录算一次
* 'list' => array('2017-10-1', '2017-10-15' ...) // 有效登录日期
* ),
* 'notLog' => array(
* 'count' => n, // 未登录次数
* 'list' => array('2017-10-1', '2017-10-15' ...) // 未登录日期
* )
* )
*/
public function getUserTimeRangeLogin($type, $uid, $startTime, $endTime)
{
$hasCount = 0; // 有效登录次数
$notCount = 0; // 未登录次数
$hasList = array(); // 有效登录日期
$notList = array(); // 未登录日期
$successFlg = false; // 查询到数据标识

if ($this->checkTimeRange($startTime, $endTime)) {

// 获取需要查询的Key
$keyList = $this->_redisHandle->getTimeRangeRedisKey($type, $uid, $startTime, $endTime);

if (!empty($keyList)) {
foreach ($keyList as $key => $val) {

// 判断Redis中是否存在记录
$isRedisExists = $this->_redisHandle->checkRedisLogExists($val['key']);
if ($isRedisExists) {

// 存在,直接从Redis中获取
$logInfo = $this->_redisHandle->getUserTimeRangeLogin($val['key'], $startTime, $endTime);
} else {

// 不存在,尝试从数据库中读取
$logInfo = $this->_dbHandle->getUserTimeRangeLogin($type, $uid, $val['time'], $startTime, $endTime);
}

if (is_array($logInfo)) {
$hasCount += $logInfo['hasLog']['count'];
$hasList = array_merge($hasList, $logInfo['hasLog']['list']);
$notCount += $logInfo['notLog']['count'];
$notList = array_merge($notList, $logInfo['notLog']['list']);
$successFlg = true;
}
}
}
}

if ($successFlg) {
return array(
'hasLog' => array(
'count' => $hasCount,
'list' => $hasList
),
'notLog' => array(
'count' => $notCount,
'list' => $notList
)
);
}

return array();
}

/**
* 获取某段时间内有效登录过的用户 统一接口
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @param array $typeArr 用户类型,为空时获取全部类型
* @return array 参数错误或未查询到返回array()
* -------------------------------------------------
* 查询到结果:指定用户类型
* array(
* 'type1' => array(
* 'count' => n, // type1 有效登录总用户数
* 'list' => array('111', '222' ...) // type1 有效登录用户
* ),
* 'type2' => array(
* 'count' => n, // type2 有效登录总用户数
* 'list' => array('333', '444' ...) // type2 有效登录用户
* )
* )
* -------------------------------------------------
* 查询到结果:未指定用户类型,全部用户,固定键 'all'
* array(
* 'all' => array(
* 'count' => n, // 有效登录总用户数
* 'list' => array('111', '222' ...) // 有效登录用户
* )
* )
*/
public function getOrientedTimeRangeLogin($startTime, $endTime, $typeArr = array())
{
if ($this->checkTimeRange($startTime, $endTime)) {

// 判断是否指定类型
if (is_array($typeArr) && !empty($typeArr)) {

// 指定类型,验证类型合法性
if ($this->checkTypeArr($typeArr)) {

// 依据类型获取
return $this->getSpecifyTypeTimeRangeLogin($startTime, $endTime, $typeArr);
}
} else {

// 未指定类型,统一获取
return $this->getSpecifyAllTimeRangeLogin($startTime, $endTime);
}
}
return array();
}

/**
* 指定类型:获取某段时间内登录过的用户
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @param array $typeArr 用户类型
* @return array
*/
private function getSpecifyTypeTimeRangeLogin($startTime, $endTime, $typeArr)
{
$data = array();
$successFlg = false; // 查询到数据标识

// 指定类型,根据类型单独获取,进行整合
foreach ($typeArr as $typeArrVal) {

// 获取需要查询的Key
$keyList = $this->_redisHandle->getSpecifyTypeTimeRangeRedisKey($typeArrVal, $startTime, $endTime);
if (!empty($keyList)) {

$data[$typeArrVal]['count'] = 0; // 该类型下有效登录用户数
$data[$typeArrVal]['list'] = array(); // 该类型下有效登录用户

foreach ($keyList as $keyListVal) {

// 查询Kye,验证Redis中是否存在:此处为单个类型,所以直接看Redis中是否存在该类型Key即可判断是否存在
// 存在的数据不需要去数据库中去查看
$standardKeyList = $this->_redisHandle->getKeys($keyListVal['key']);
if (is_array($standardKeyList) && count($standardKeyList) > 0) {

// Redis存在
foreach ($standardKeyList as $standardKeyListVal) {

// 验证该用户在此时间段是否登录过
$redisCheckLogin = $this->_redisHandle->getUserTimeRangeLogin($standardKeyListVal, $startTime, $endTime);
if ($redisCheckLogin['hasLog']['count'] > 0) {

// 同一个用户只需记录一次
$uid = $this->_redisHandle->getLoginLogKeyInfo($standardKeyListVal, 'uid');
if (!in_array($uid, $data[$typeArrVal]['list'])) {
$data[$typeArrVal]['count']++;
$data[$typeArrVal]['list'][] = $uid;
}
$successFlg = true;
}
}

} else {

// 不存在,尝试从数据库中获取
$dbResult = $this->_dbHandle->getTimeRangeLoginSuccessUser($keyListVal['time'], $startTime, $endTime, $typeArrVal);
if (!empty($dbResult)) {
foreach ($dbResult as $dbResultVal) {
if (!in_array($dbResultVal, $data[$typeArrVal]['list'])) {
$data[$typeArrVal]['count']++;
$data[$typeArrVal]['list'][] = $dbResultVal;
}
}
$successFlg = true;
}
}
}
}
}

if ($successFlg) { return $data; }
return array();
}

/**
* 全部类型:获取某段时间内登录过的用户
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return array
*/
private function getSpecifyAllTimeRangeLogin($startTime, $endTime)
{
$count = 0; // 有效登录用户数
$list = array(); // 有效登录用户
$successFlg = false; // 查询到数据标识

// 未指定类型,直接对所有数据进行检索
// 获取需要查询的Key
$keyList = $this->_redisHandle->getSpecifyAllTimeRangeRedisKey($startTime, $endTime);

if (!empty($keyList)) {
foreach ($keyList as $keyListVal) {

// 查询Kye
$standardKeyList = $this->_redisHandle->getKeys($keyListVal['key']);

if (is_array($standardKeyList) && count($standardKeyList) > 0) {

// 查询到Key,直接读取数据,记录类型
foreach ($standardKeyList as $standardKeyListVal) {

// 验证该用户在此时间段是否登录过
$redisCheckLogin = $this->_redisHandle->getUserTimeRangeLogin($standardKeyListVal, $startTime, $endTime);
if ($redisCheckLogin['hasLog']['count'] > 0) {

// 同一个用户只需记录一次
$uid = $this->_redisHandle->getLoginLogKeyInfo($standardKeyListVal, 'uid');
if (!in_array($uid, $list)) {
$count++;
$list[] = $uid;
}
$successFlg = true;
}
}
}

// 无论Redis中存在不存在都要尝试从数据库中获取一遍数据,来补充Redis获取的数据,保证检索数据完整(Redis类型缺失可能导致)
$dbResult = $this->_dbHandle->getTimeRangeLoginSuccessUser($keyListVal['time'], $startTime, $endTime);
if (!empty($dbResult)) {
foreach ($dbResult as $dbResultVal) {
if (!in_array($dbResultVal, $list)) {
$count++;
$list[] = $dbResultVal;
}
}
$successFlg = true;
}
}
}

if ($successFlg) {
return array(
'all' => array(
'count' => $count,
'list' => $list
)
);
}
return array();
}

/**
* 验证开始结束时间
* @param string $startTime 开始时间
* @param string $endTime 结束时间
* @return boolean
*/
private function checkTimeRange($startTime, $endTime)
{
return $this->_redisHandle->checkTimeRange($startTime, $endTime);
}

/**
* 批量验证用户类型
* @param array $typeArr 用户类型数组
* @return boolean
*/
private function checkTypeArr($typeArr)
{
$flg = false;
if (is_array($typeArr) && !empty($typeArr)) {
foreach ($typeArr as $val) {
if ($this->_redisHandle->checkType($val)) {
$flg = true;
} else {
$flg = false; break;
}
}
}
return $flg;
}

/**
* 定时任务每周调用一次:从Redis同步登录日志到数据库
* @param int $existsDay 一条记录在Redis中过期时间,单位:天,必须大于31
* @return string
* 'null': Redis中无数据
* 'fail': 同步失败
* 'success':同步成功
*/
public function cronWeeklySync($existsDay)
{

// 验证生存时间
if ($this->_redisHandle->checkExistsDay($existsDay)) {
$likeKey = 'loginLog_*';
$keyList = $this->_redisHandle->getKeys($likeKey);

if (!empty($keyList)) {
foreach ($keyList as $keyVal) {

if ($this->_redisHandle->checkLoginLogKey($keyVal)) {
$keyTime = $this->_redisHandle->getLoginLogKeyInfo($keyVal, 'time');
$thisMonth = date('Y-m');
$beforeMonth = date('Y-m', strtotime('-1 month'));

// 验证是否需要进行同步:
// 1. 当前日期 >= 8号,对本月所有记录进行同步,不对本月之前的记录进行同步
// 2. 当前日期 < 8号,对本月所有记录进行同步,对本月前一个月的记录进行同步,对本月前一个月之前的所有记录不进行同步
if (date('j') >= 8) {

// 只同步本月数据
if ($thisMonth == $keyTime) {
$this->redis2db($keyVal);
}
} else {

// 同步本月或本月前一个月数据
if ($thisMonth == $keyTime || $beforeMonth == $keyTime) {
$this->redis2db($keyVal);
}
}

// 验证是否过期
$existsSecond = $existsDay * 24 * 60 * 60;
if (strtotime($keyTime) + $existsSecond < time()) {

// 过期删除
$bitMap = $this->_redisHandle->getLoginLogBitMap($keyVal);
Log::INFO('删除过期数据[' . $keyVal . ']:' . $bitMap);
$this->_redisHandle->delLoginLog($keyVal);
}
}
}
return 'success';
}
return 'null';
}
return 'fail';
}

/**
* 将记录同步到数据库
* @param string $key 记录Key
* @return boolean
*/
private function redis2db($key)
{
if ($this->_redisHandle->checkLoginLogKey($key) && $this->_redisHandle->checkRedisLogExists($key)) {
$time = $this->_redisHandle->getLoginLogKeyInfo($key, 'time');
$data['id'] = Tools::generateId();
$data['user_id'] = $this->_redisHandle->getLoginLogKeyInfo($key, 'uid');
$data['type'] = $this->_redisHandle->getLoginLogKeyInfo($key, 'type');
$data['year'] = date('Y', strtotime($time));
$data['month'] = date('n', strtotime($time));
$data['bit_log'] = $this->_redisHandle->getLoginLogBitMap($key);
return $this->_dbHandle->redis2db($data);
}
return false;
}
}

LoginLogCommon.class.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
<?php

namespace Lib\LoginLog;

use Lib\RedisData;
use Lib\Status;

/**
* 公共方法
* User: dbn
* Date: 2017/10/11
* Time: 13:11
*/
class LoginLogCommon
{
protected $_loginLog;
protected $_redis;

public function __construct(LoginLog $loginLog)
{
$this->_loginLog = $loginLog;
$this->_redis = RedisData::getRedis();
}

/**
* 验证用户类型
* @param string $type 用户类型
* @return boolean
*/
protected function checkType($type)
{
if (in_array($type, array(
Status::LOGIN_LOG_TYPE_ADMIN,
Status::LOGIN_LOG_TYPE_CARRIER,
Status::LOGIN_LOG_TYPE_DRIVER,
Status::LOGIN_LOG_TYPE_OFFICE,
Status::LOGIN_LOG_TYPE_CLIENT,
))) {
return true;
}
$this->_loginLog->setError('未定义的日志类型:' . $type);
return false;
}

/**
* 验证唯一标识
* @param string $uid
* @return boolean
*/
protected function checkUid($uid)
{
if (is_numeric($uid) && $uid > 0) {
return true;
}
$this->_loginLog->setError('唯一标识非法:' . $uid);
return false;
}

/**
* 验证时间戳
* @param string $time
* @return boolean
*/
protected function checkTime($time)
{
if (is_numeric($time) && $time > 0) {
return true;
}
$this->_loginLog->setError('时间戳非法:' . $time);
return false;
}

/**
* 验证时间是否在当月中
* @param string $time
* @return boolean
*/
protected function checkTimeWhetherThisMonth($time)
{
if ($this->checkTime($time) && $time > strtotime(date('Y-m')) && $time < strtotime(date('Y-m') . '-' . date('t'))) {
return true;
}
$this->_loginLog->setError('时间未在当前月份中:' . $time);
return false;
}

/**
* 验证时间是否超过当前时间
* @param string $time
* @return boolean
*/
protected function checkTimeWhetherFutureTime($time)
{
if ($this->checkTime($time) && $time <= time()) {
return true;
}
return false;
}

/**
* 验证开始/结束时间
* @param string $startTime 开始时间
* @param string $endTime 结束时间
* @return boolean
*/
protected function checkTimeRange($startTime, $endTime)
{
if ($this->checkTime($startTime) &&
$this->checkTime($endTime) &&
$startTime < $endTime &&
$startTime < time()
) {
return true;
}
$this->_loginLog->setError('时间范围非法:' . $startTime . '-' . $endTime);
return false;
}

/**
* 验证时间是否在指定范围内
* @param string $time 需要检查的时间
* @param string $startTime 开始时间
* @param string $endTime 结束时间
* @return boolean
*/
protected function checkTimeWithinTimeRange($time, $startTime, $endTime)
{
if ($this->checkTime($time) &&
$this->checkTimeRange($startTime, $endTime) &&
$startTime <= $time &&
$time <= $endTime
) {
return true;
}
$this->_loginLog->setError('请求时间未在时间范围内:' . $time . '-' . $startTime . '-' . $endTime);
return false;
}

/**
* 验证Redis日志记录标准Key
* @param string $key
* @return boolean
*/
protected function checkLoginLogKey($key)
{
$pattern = '/^loginLog_\d{4}-\d{1,2}_\S+_\d+$/';
$result = preg_match($pattern, $key, $match);
if ($result > 0) {
return true;
}
$this->_loginLog->setError('RedisKey非法:' . $key);
return false;
}

/**
* 获取月份中有多少天
* @param int $time 时间戳
* @return int
*/
protected function getDaysInMonth($time)
{
return date('t', $time);
}

/**
* 对没有前导零的月份或日设置前导零
* @param int $num 月份或日
* @return string
*/
protected function setDateLeadingZero($num)
{
if (is_numeric($num) && strlen($num) <= 2) {
$num = (strlen($num) > 1 ? $num : '0' . $num);
}
return $num;
}

/**
* 验证过期时间
* @param int $existsDay 一条记录在Redis中过期时间,单位:天,必须大于31
* @return boolean
*/
protected function checkExistsDay($existsDay)
{
if (is_numeric($existsDay) && ctype_digit(strval($existsDay)) && $existsDay > 31) {
return true;
}
$this->_loginLog->setError('过期时间非法:' . $existsDay);
return false;
}

/**
* 获取开始日期边界
* @param int $time 需要判断的时间戳
* @param int $startTime 起始时间
* @return int
*/
protected function getStartTimeBorder($time, $startTime)
{
$initDay = 1;
if ($this->checkTime($time) && $this->checkTime($startTime) &&
date('Y-m', $time) === date('Y-m', $startTime) && false !== date('Y-m', $time)) {
$initDay = date('j', $startTime);
}
return $initDay;
}

/**
* 获取结束日期边界
* @param int $time 需要判断的时间戳
* @param int $endTime 结束时间
* @return int
*/
protected function getEndTimeBorder($time, $endTime)
{
$border = $this->getDaysInMonth($time);
if ($this->checkTime($time) && $this->checkTime($endTime) &&
date('Y-m', $time) === date('Y-m', $endTime) && false !== date('Y-m', $time)) {
$border = date('j', $endTime);
}
return $border;
}
}

LoginLogDBHandle.class.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
<?php

namespace Lib\LoginLog;
use Think\Model;

/**
* 数据库登录日志处理类
* User: dbn
* Date: 2017/10/11
* Time: 13:12
*/
class LoginLogDBHandle extends LoginLogCommon
{

/**
* 从数据库中获取用户某月记录在指定时间范围内的用户信息
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param int $time 需要查询月份时间戳
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return array
* array(
* 'hasLog' => array(
* 'count' => n, // 有效登录次数,每天重复登录算一次
* 'list' => array('2017-10-1', '2017-10-15' ...) // 有效登录日期
* ),
* 'notLog' => array(
* 'count' => n, // 未登录次数
* 'list' => array('2017-10-1', '2017-10-15' ...) // 未登录日期
* )
* )
*/
public function getUserTimeRangeLogin($type, $uid, $time, $startTime, $endTime)
{
$hasCount = 0; // 有效登录次数
$notCount = 0; // 未登录次数
$hasList = array(); // 有效登录日期
$notList = array(); // 未登录日期

if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTimeWithinTimeRange($time, $startTime, $endTime)) {

$timeYM = date('Y-m', $time);

// 设置开始时间
$initDay = $this->getStartTimeBorder($time, $startTime);

// 设置结束时间
$border = $this->getEndTimeBorder($time, $endTime);

$bitMap = $this->getBitMapFind($type, $uid, date('Y', $time), date('n', $time));
for ($i = $initDay; $i <= $border; $i++) {

if (!empty($bitMap)) {
if ($bitMap[$i-1] == '1') {
$hasCount++;
$hasList[] = $timeYM . '-' . $this->setDateLeadingZero($i);
} else {
$notCount++;
$notList[] = $timeYM . '-' . $this->setDateLeadingZero($i);
}
} else {
$notCount++;
$notList[] = $timeYM . '-' . $this->setDateLeadingZero($i);
}
}
}

return array(
'hasLog' => array(
'count' => $hasCount,
'list' => $hasList
),
'notLog' => array(
'count' => $notCount,
'list' => $notList
)
);
}

/**
* 从数据库获取用户某月日志位图
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param int $year 年Y
* @param int $month 月n
* @return string
*/
private function getBitMapFind($type, $uid, $year, $month)
{
$model = D('Home/StatLoginLog');
$map['type'] = array('EQ', $type);
$map['user_id'] = array('EQ', $uid);
$map['year'] = array('EQ', $year);
$map['month'] = array('EQ', $month);

$result = $model->field('bit_log')->where($map)->find();
if (false !== $result && isset($result['bit_log']) && !empty($result['bit_log'])) {
return $result['bit_log'];
}
return '';
}

/**
* 从数据库中判断用户在某一天是否登录过
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param int $time 时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function dateWhetherLogin($type, $uid, $time)
{
if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTime($time)) {

$timeInfo = getdate($time);
$bitMap = $this->getBitMapFind($type, $uid, $timeInfo['year'], $timeInfo['mon']);
if (!empty($bitMap)) {
if ($bitMap[$timeInfo['mday']-1] == '1') {
return true;
}
}
}
return false;
}

/**
* 从数据库中判断用户在某月是否登录过
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param int $time 时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function dateMonthWhetherLogin($type, $uid, $time)
{
if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTime($time)) {

$timeInfo = getdate($time);
$userArr = $this->getMonthLoginSuccessUser($timeInfo['year'], $timeInfo['mon'], $type);
if (!empty($userArr)) {
if (in_array($uid, $userArr)) {
return true;
}
}
}
return false;
}

/**
* 获取某月所有有效登录过的用户ID
* @param int $year 年Y
* @param int $month 月n
* @param string $type 用户类型,为空时获取全部类型
* @return array
*/
public function getMonthLoginSuccessUser($year, $month, $type = '')
{
$data = array();
if (is_numeric($year) && is_numeric($month)) {
$model = D('Home/StatLoginLog');
$map['year'] = array('EQ', $year);
$map['month'] = array('EQ', $month);
$map['bit_log'] = array('LIKE', '%1%');
if ($type != '' && $this->checkType($type)) {
$map['type'] = array('EQ', $type);
}
$result = $model->field('user_id')->where($map)->select();
if (false !== $result && count($result) > 0) {
foreach ($result as $val) {
if (isset($val['user_id'])) {
$data[] = $val['user_id'];
}
}
}
}
return $data;
}

/**
* 从数据库中获取某月所有记录在指定时间范围内的用户ID
* @param int $time 查询的时间戳
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @param string $type 用户类型,为空时获取全部类型
* @return array
*/
public function getTimeRangeLoginSuccessUser($time, $startTime, $endTime, $type = '')
{
$data = array();
if ($this->checkTimeWithinTimeRange($time, $startTime, $endTime)) {

$timeInfo = getdate($time);

// 获取满足时间条件的记录
$model = D('Home/StatLoginLog');
$map['year'] = array('EQ', $timeInfo['year']);
$map['month'] = array('EQ', $timeInfo['mon']);
if ($type != '' && $this->checkType($type)) {
$map['type'] = array('EQ', $type);
}

$result = $model->where($map)->select();
if (false !== $result && count($result) > 0) {

// 设置开始时间
$initDay = $this->getStartTimeBorder($time, $startTime);

// 设置结束时间
$border = $this->getEndTimeBorder($time, $endTime);

foreach ($result as $val) {

$bitMap = $val['bit_log'];
for ($i = $initDay; $i <= $border; $i++) {

if ($bitMap[$i-1] == '1' && !in_array($val['user_id'], $data)) {
$data[] = $val['user_id'];
}
}
}
}
}
return $data;
}

/**
* 将数据更新到数据库
* @param array $data 单条记录的数据
* @return boolean
*/
public function redis2db($data)
{
$model = D('Home/StatLoginLog');

// 验证记录是否存在
$map['user_id'] = array('EQ', $data['user_id']);
$map['type'] = array('EQ', $data['type']);
$map['year'] = array('EQ', $data['year']);
$map['month'] = array('EQ', $data['month']);

$count = $model->where($map)->count();
if (false !== $count && $count > 0) {

// 存在记录进行更新
$saveData['bit_log'] = $data['bit_log'];

if (!$model->create($saveData, Model::MODEL_UPDATE)) {

$this->_loginLog->setError('同步登录日志-更新记录,创建数据对象失败:' . $model->getError());
logger()->error('同步登录日志-更新记录,创建数据对象失败:' . $model->getError());
return false;
} else {

$result = $model->where($map)->save();

if (false !== $result) {
return true;
} else {
$this->_loginLog->setError('同步登录日志-更新记录,更新数据失败:' . json_encode($data));
logger()->error('同步登录日志-更新记录,更新数据失败:' . json_encode($data));
return false;
}
}
} else {

// 不存在记录插入一条新的记录
if (!$model->create($data, Model::MODEL_INSERT)) {

$this->_loginLog->setError('同步登录日志-插入记录,创建数据对象失败:' . $model->getError());
logger()->error('同步登录日志-插入记录,创建数据对象失败:' . $model->getError());
return false;
} else {

$result = $model->add();

if (false !== $result) {
return true;
} else {
$this->_loginLog->setError('同步登录日志-插入记录,插入数据失败:' . json_encode($data));
logger()->error('同步登录日志-插入记录,插入数据失败:' . json_encode($data));
return false;
}
}
}
}
}

LoginLogRedisHandle.class.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
<?php

namespace Lib\LoginLog;

/**
* Redis登录日志处理类
* User: dbn
* Date: 2017/10/11
* Time: 15:53
*/
class LoginLogRedisHandle extends LoginLogCommon
{
/**
* 记录登录:每天只记录一次登录,只允许设置当月内登录记录
* @param string $key 日志记录Key
* @param int $time 时间戳
* @return boolean
*/
public function setLogging($key, $time)
{
if ($this->checkLoginLogKey($key) && $this->checkTimeWhetherThisMonth($time)) {

// 判断用户当天是否已经登录过
$whetherLoginResult = $this->dateWhetherLogin($key, $time);
if (!$whetherLoginResult) {

// 当天未登录,记录登录
$this->_redis->setBit($key, date('d', $time), 1);
}
return true;
}
return false;
}

/**
* 从Redis中判断用户在某一天是否登录过
* @param string $key 日志记录Key
* @param int $time 时间戳
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function dateWhetherLogin($key, $time)
{
if ($this->checkLoginLogKey($key) && $this->checkTime($time)) {
$result = $this->_redis->getBit($key, date('d', $time));
if ($result === 1) {
return true;
}
}
return false;
}

/**
* 从Redis中判断用户在某月是否登录过
* @param string $key 日志记录Key
* @return boolean 参数错误或未登录过返回false,登录过返回true
*/
public function dateMonthWhetherLogin($key)
{
if ($this->checkLoginLogKey($key)) {
$result = $this->_redis->bitCount($key);
if ($result > 0) {
return true;
}
}
return false;
}

/**
* 判断某月登录记录在Redis中是否存在
* @param string $key 日志记录Key
* @return boolean
*/
public function checkRedisLogExists($key)
{
if ($this->checkLoginLogKey($key)) {
if ($this->_redis->exists($key)) {
return true;
}
}
return false;
}

/**
* 从Redis中获取用户某月记录在指定时间范围内的用户信息
* @param string $key 日志记录Key
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return array
* array(
* 'hasLog' => array(
* 'count' => n, // 有效登录次数,每天重复登录算一次
* 'list' => array('2017-10-1', '2017-10-15' ...) // 有效登录日期
* ),
* 'notLog' => array(
* 'count' => n, // 未登录次数
* 'list' => array('2017-10-1', '2017-10-15' ...) // 未登录日期
* )
* )
*/
public function getUserTimeRangeLogin($key, $startTime, $endTime)
{
$hasCount = 0; // 有效登录次数
$notCount = 0; // 未登录次数
$hasList = array(); // 有效登录日期
$notList = array(); // 未登录日期

if ($this->checkLoginLogKey($key) && $this->checkTimeRange($startTime, $endTime) && $this->checkRedisLogExists($key)) {

$keyTime = $this->getLoginLogKeyInfo($key, 'time');
$keyTime = strtotime($keyTime);
$timeYM = date('Y-m', $keyTime);

// 设置开始时间
$initDay = $this->getStartTimeBorder($keyTime, $startTime);

// 设置结束时间
$border = $this->getEndTimeBorder($keyTime, $endTime);

for ($i = $initDay; $i <= $border; $i++) {
$result = $this->_redis->getBit($key, $i);
if ($result === 1) {
$hasCount++;
$hasList[] = $timeYM . '-' . $this->setDateLeadingZero($i);
} else {
$notCount++;
$notList[] = $timeYM . '-' . $this->setDateLeadingZero($i);
}
}
}

return array(
'hasLog' => array(
'count' => $hasCount,
'list' => $hasList
),
'notLog' => array(
'count' => $notCount,
'list' => $notList
)
);
}

/**
* 面向用户:获取时间范围内可能需要的Key
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param string $startTime 开始时间
* @param string $endTime 结束时间
* @return array
*/
public function getTimeRangeRedisKey($type, $uid, $startTime, $endTime)
{
$list = array();

if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTimeRange($startTime, $endTime)) {

$data = $this->getSpecifyUserKeyHandle($type, $uid, $startTime);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', strtotime(date('Y-m', $startTime)));

while ($temYM <= $endTime) {
$data = $this->getSpecifyUserKeyHandle($type, $uid, $temYM);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', $temYM);
}
}
return $list;
}
private function getSpecifyUserKeyHandle($type, $uid, $time)
{
$data = array();
$key = $this->getLoginLogKey($type, $uid, $time);
if ($this->checkLoginLogKey($key)) {
$data = array(
'key' => $key,
'time' => $time
);
}
return $data;
}

/**
* 面向类型:获取时间范围内可能需要的Key
* @param string $type 用户类型
* @param string $startTime 开始时间
* @param string $endTime 结束时间
* @return array
*/
public function getSpecifyTypeTimeRangeRedisKey($type, $startTime, $endTime)
{
$list = array();

if ($this->checkType($type) && $this->checkTimeRange($startTime, $endTime)) {

$data = $this->getSpecifyTypeKeyHandle($type, $startTime);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', strtotime(date('Y-m', $startTime)));

while ($temYM <= $endTime) {
$data = $this->getSpecifyTypeKeyHandle($type, $temYM);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', $temYM);
}
}
return $list;
}
private function getSpecifyTypeKeyHandle($type, $time)
{
$data = array();
$temUid = '11111111';

$key = $this->getLoginLogKey($type, $temUid, $time);
if ($this->checkLoginLogKey($key)) {
$arr = explode('_', $key);
$arr[count($arr)-1] = '*';
$key = implode('_', $arr);
$data = array(
'key' => $key,
'time' => $time
);
}
return $data;
}

/**
* 面向全部:获取时间范围内可能需要的Key
* @param string $startTime 开始时间
* @param string $endTime 结束时间
* @return array
*/
public function getSpecifyAllTimeRangeRedisKey($startTime, $endTime)
{
$list = array();

if ($this->checkTimeRange($startTime, $endTime)) {

$data = $this->getSpecifyAllKeyHandle($startTime);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', strtotime(date('Y-m', $startTime)));

while ($temYM <= $endTime) {
$data = $this->getSpecifyAllKeyHandle($temYM);
if (!empty($data)) { $list[] = $data; }

$temYM = strtotime('+1 month', $temYM);
}
}
return $list;
}
private function getSpecifyAllKeyHandle($time)
{
$data = array();
$temUid = '11111111';
$temType = 'office';

$key = $this->getLoginLogKey($temType, $temUid, $time);
if ($this->checkLoginLogKey($key)) {
$arr = explode('_', $key);
array_pop($arr);
$arr[count($arr)-1] = '*';
$key = implode('_', $arr);
$data = array(
'key' => $key,
'time' => $time
);
}
return $data;
}

/**
* 从Redis中查询满足条件的Key
* @param string $key 查询的Key
* @return array
*/
public function getKeys($key)
{
return $this->_redis->keys($key);
}

/**
* 从Redis中删除记录
* @param string $key 记录的Key
* @return boolean
*/
public function delLoginLog($key)
{
return $this->_redis->del($key);
}

/**
* 获取日志标准Key:前缀_年-月_用户类型_唯一标识
* @param string $type 用户类型
* @param int $uid 唯一标识(用户ID)
* @param int $time 时间戳
* @return string
*/
public function getLoginLogKey($type, $uid, $time)
{
if ($this->checkType($type) && $this->checkUid($uid) && $this->checkTime($time)) {
return 'loginLog_' . date('Y-m', $time) . '_' . $type . '_' . $uid;
}
return '';
}

/**
* 获取日志标准Key上信息
* @param string $key key
* @param string $field 需要的参数 time,type,uid
* @return mixed 返回对应的值,没有返回null
*/
public function getLoginLogKeyInfo($key, $field)
{
$param = array();
if ($this->checkLoginLogKey($key)) {
$arr = explode('_', $key);
$param['time'] = $arr[1];
$param['type'] = $arr[2];
$param['uid'] = $arr[3];
}
return $param[$field];
}

/**
* 获取Key记录的登录位图
* @param string $key key
* @return string
*/
public function getLoginLogBitMap($key)
{
$bitMap = '';
if ($this->checkLoginLogKey($key)) {
$time = $this->getLoginLogKeyInfo($key, 'time');
$maxDay = $this->getDaysInMonth(strtotime($time));
for ($i = 1; $i <= $maxDay; $i++) {
$bitMap .= $this->_redis->getBit($key, $i);
}
}
return $bitMap;
}

/**
* 验证日志标准Key
* @param string $key
* @return boolean
*/
public function checkLoginLogKey($key)
{
return parent::checkLoginLogKey($key);
}

/**
* 验证开始/结束时间
* @param string $startTime 开始时间
* @param string $endTime 结束时间
* @return boolean
*/
public function checkTimeRange($startTime, $endTime)
{
return parent::checkTimeRange($startTime, $endTime);
}

/**
* 验证用户类型
* @param string $type
* @return boolean
*/
public function checkType($type)
{
return parent::checkType($type);
}

/**
* 验证过期时间
* @param int $existsDay 一条记录在Redis中过期时间,单位:天,必须大于31
* @return boolean
*/
public function checkExistsDay($existsDay)
{
return parent::checkExistsDay($existsDay);
}
}

参考资料

https://segmentfault.com/a/1190000008188655

http://blog.csdn.net/rdhj5566/article/details/54313840

http://www.redis.net.cn/tutorial/3508.html

↓赏一个鸡腿... 要不,半个也行↓