-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathCore.php
More file actions
1912 lines (1739 loc) · 70 KB
/
Copy pathCore.php
File metadata and controls
1912 lines (1739 loc) · 70 KB
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
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
namespace TypechoPlugin\Access;
use Redis;
use Typecho\Config;
use Typecho\Cookie;
use Typecho\Db;
use Typecho\Db\Exception as DbException;
use Typecho\I18n;
use Typecho\Plugin\Exception as PluginException;
use Typecho\Request;
use Typecho\Response;
use Utils\Helper;
use Widget\Options;
use Widget\User;
if (!defined('__TYPECHO_ROOT_DIR__')) {
exit;
}
class Core
{
/** 统计数据所在的数据库,可能是 Typecho 主库,也可能是独立配置的库 */
protected readonly Db $db;
/** Typecho 主库,用于读取文章标题等内容信息 */
protected readonly Db $mainDb;
protected readonly Request $request;
protected readonly Response $response;
/** Redis 缓存实例,未启用时为 null */
protected ?Redis $redis = null;
/** 本次请求是否已经安排过刷库,避免重复注册 */
protected bool $flushScheduled = false;
/** 缺 event_id 唯一索引的判定结果,一次请求里问一次就够 */
private ?bool $degraded = null;
/** Redis 缓存键前缀 */
/** 历史日期的统计不会再变化,缓存 40 天足够覆盖当月图表 */
private const PAST_DAY_TTL = 3456000;
/**
* 全表聚合的「新鲜期」(秒)
*
* 超过这个岁数就该重算了,但重算发生在响应发出之后,读的人拿到的仍是旧值。
* total 比 referer/pie 短,是因为它是首屏三个大数字,看着更该跟得上。
*/
private const TOTAL_FRESH_TTL = 600;
private const LIST_FRESH_TTL = 1800;
/**
* 陈旧值在 Redis 里留多久(秒)
*
* 给得很长是故意的:拿一个礼拜前的总数顶一下,比让访客盯着 504 强得多。
* 只要有人来看,后台刷新就会把它顶成新的。
*/
private const AGGREGATE_STALE_TTL = 604800;
/**
* 「正在重算」互斥锁的存活时间(秒)
*
* 冷缓存时前端会反复轮询,不挡一下的话每轮询一次就多一条全表聚合在跑 ——
* 那正是把数据库 CPU 打满的形状。
*
* **这个值必须覆盖一次重算的最坏耗时,而且不可能靠续租来兜底。**
* 续租要求持锁进程周期性醒过来,而重算是一次阻塞的 PDO 调用:
* PHP 停在 libpq / mysqlnd 的 read 上,中间没有任何执行点。
* 队列的 LOCK_TTL 能取 30 秒是因为刷库天然按批切开,批与批之间可以续租;
* 这里没有那个缝,只能把 TTL 一次性给够。
*
* 取值按实测的最坏情况留余量:n_distinct 没调之前,单条
* 「总计独立 IP」跑过 643 秒(HashAggregate 落盘重分区)。
* 两个方向的代价严重不对等,所以宁可给长:
* 给短了 → 锁提前过期,下一个请求再起一条同样的全表聚合,
* 几轮下来就是这个锁本来要防的那个形状;
* 给长了 → 持锁进程崩掉后重算被推迟最多这么久,但陈旧值照常返回
* (AGGREGATE_STALE_TTL 有 7 天),用户只是看到稍旧的数字。
*/
private const AGGREGATE_LOCK_TTL = 1800;
/**
* 重算失败后的退避时间(秒)
*
* 以前失败退避和互斥锁是同一个键:算失败就不删标记,让它自然过期。
* 但这两件事要的 TTL 方向相反 —— 互斥锁要长到覆盖最坏耗时,
* 退避只要挡住前端接下来几轮轮询就够。合用一个键时只能二选一,
* 选长了失败后要等半小时才肯重试,选短了锁就形同虚设。
* 现在拆成两个键,各给各的 TTL。
*/
private const AGGREGATE_BACKOFF_TTL = 300;
/** 值还没算出来时,告诉前端隔多久再来问(秒) */
private const AGGREGATE_RETRY_AFTER = 2;
/** 匿名埋点接口每个 IP 每分钟允许的次数 */
private const TRACK_RATE_LIMIT = 60;
/**
* 限流计数键的存活时间(秒)
*
* 键名按分钟分桶、靠换键滚动,TTL 只是兜底回收,取两倍桶宽留出时钟误差余量。
*/
private const TRACK_RATE_WINDOW = 120;
public readonly UA $ua;
public readonly Config $config;
public readonly string $action;
public readonly string $title;
public array $logs = [];
public array $overview = [];
public array $referer = [];
public array $postPie = [];
/**
* 构造函数,根据不同类型的请求,计算不同的数据并渲染输出
*
* @access public
* @throws PluginException
* @throws DbException
*/
public function __construct()
{
# Load language pack
if (I18n::getLang() !== 'zh_CN') {
$file = __TYPECHO_ROOT_DIR__ . __TYPECHO_PLUGIN_DIR__ .
'/Access/lang/' . I18n::getLang() . '.mo';
file_exists($file) && I18n::addLang($file);
}
# Init variables
$this->config = Options::alloc()->plugin('Access');
$this->mainDb = Db::get();
$this->db = Database::get($this->config);
$this->request = Request::getInstance();
$this->response = Response::getInstance();
if ($this->config->pageSize == null || $this->config->isDrop == null) {
throw new PluginException(_t('请先设置插件!'));
}
$this->ua = new UA($this->request->getAgent());
$this->initRedis();
switch ($this->request->get('action')) {
case 'overview':
$this->action = 'overview';
$this->title = _t('访问概览');
break;
case 'logs':
default:
$this->action = 'logs';
$this->title = _t('访问日志');
break;
}
}
/**
* 获取概览页全部数据(供 AJAX 接口调用)
*
* @access public
* @return array
* @throws DbException
*/
public function getOverviewData(): array
{
# 先把队列里积压的写进去,否则控制台看到的数字会偏低
$this->flushQueue();
$this->parseOverview();
$this->parseReferer();
$this->parsePostPie();
return [
'overview' => [
'today' => $this->overview['today'],
'yesterday' => $this->overview['yesterday'],
'total' => $this->overview['total'],
],
'referer' => $this->referer,
'chart_data' => json_decode($this->overview['chart_data'], true),
'post_pie' => $this->postPie,
];
}
/**
* 分段获取概览数据
*
* 数据量大时一次算完整个概览会超出 Web 超时(300 万行实测本地就要 6 秒),
* 而首次加载失败又会导致缓存永远建不起来,形成死循环。
* 这里把概览拆成几块分别请求,每块都足够小;算完的部分会写进缓存,
* 于是即使某一块要跑好几次也能逐步推进。
*
* @access public
* @param string $section today / yesterday / month / total / referer / pie
* @param float|null $deadline 时间预算(microtime 时间戳),仅 month 分段使用
* @return array
* @throws DbException
*/
public function getOverviewSection(string $section, ?float $deadline = null): array
{
# 队列只在第一段刷一次就够,避免每段都刷
if ($section === 'today') {
$this->flushQueue();
}
return match ($section) {
'today' => [
'done' => true,
'today' => $this->chartOf($this->queryDayOverview(date('Y-m-d')), 'day'),
],
'yesterday' => [
'done' => true,
'yesterday' => $this->chartOf($this->cachedDayOverview(date('Y-m-d', strtotime('-1 day'))), 'day'),
],
'total' => $this->totalSection(),
'month' => $this->monthSection($deadline),
'referer' => [
'done' => true,
'referer' => $this->refererData(),
],
'pie' => [
'done' => true,
'post_pie' => $this->postPieData(),
],
default => throw new \InvalidArgumentException('unknown section: ' . $section),
};
}
/**
* 概览分段的顺序,控制台按这个顺序逐个请求
*
* @return string[]
*/
public static function overviewSections(): array
{
return ['today', 'yesterday', 'total', 'referer', 'pie', 'month'];
}
/**
* 补上图表需要的标题与横轴
*
* @access protected
* @param array $data
* @param string $kind day / month
* @return array
*/
protected function chartOf(array $data, string $kind): array
{
$data['sub_title'] = 'Generate By AccessPlugin';
$count = count($data['ip']['detail'] ?? []);
if ($kind === 'day') {
$data['xAxis'] = range(0, $count);
$data['title'] = _t('%s 统计', $data['time']);
} else {
$data['xAxis'] = range(1, max(1, $count));
$data['title'] = _t('%s 月统计', $data['time']);
}
return $data;
}
/**
* 带缓存的单日概览(含小时明细)
* 历史日期的数据不会再变,可以长期缓存
*
* @access protected
* @param string $date
* @return array
* @throws DbException
*/
protected function cachedDayOverview(string $date): array
{
$key = 'overview:dayfull:' . $date;
$cached = $this->getCache($key);
if ($cached !== null) {
return $cached;
}
$data = $this->queryDayOverview($date);
if ($date !== date('Y-m-d')) {
$this->setCache($key, $data, self::PAST_DAY_TTL);
}
return $data;
}
/**
* 带缓存的单日汇总(只要 ip/uv/pv 三个数,供月图表使用)
*
* @access protected
* @param string $date
* @return array
* @throws DbException
*/
protected function cachedDayCounts(string $date): array
{
$key = 'overview:daycount:' . $date;
$cached = $this->getCache($key);
if ($cached !== null) {
return $cached;
}
$data = $this->queryDayCounts($date);
if ($date !== date('Y-m-d')) {
$this->setCache($key, $data, self::PAST_DAY_TTL);
}
return $data;
}
/**
* 全表聚合的缓存读取:陈旧优先,重算放到响应发出之后
*
* 这几个数字(总计、来源 Top N、文章饼图)都是不带时间范围的全表聚合,
* 覆盖索引帮不上忙,代价随数据量线性增长且没有上界。以前它们被当成
* 普通缓存:命中就用、没命中就当场算 —— 于是「缓存刚好过期」这件事
* 决定了某个倒霉的访客要不要等上几分钟。
*
* 现在分三种情况:
* 新鲜 直接给
* 过期 还是先把旧值给出去,重算挂到 shutdown,下次来就是新的
* 没有 $allowDefer 为 true 时不算,回 done=false 让前端稍后再问;
* 为 false(一次性返回全部的老接口)时只能当场算
*
* @access protected
* @param string $key 缓存键(不含前缀)
* @param callable $compute 真正干活的闭包,返回要缓存的数据
* @param int $freshTtl 新鲜期(秒)
* @param bool $allowDefer 允许把「还没算出来」如实告诉调用方
* @return array{data: mixed, done: bool, retry_after: int}
*/
protected function cachedAggregate(string $key, callable $compute, int $freshTtl, bool $allowDefer = false): array
{
# 没有 Redis 就没有缓存可言,也无处存中间结果,只能同步算
if ($this->redis === null) {
return ['data' => $compute(), 'done' => true, 'retry_after' => 0];
}
$envelope = $this->getCache($key);
$hasValue = is_array($envelope) && array_key_exists('data', $envelope);
if ($hasValue) {
if (time() - (int)($envelope['at'] ?? 0) < $freshTtl) {
return ['data' => $envelope['data'], 'done' => true, 'retry_after' => 0];
}
# 过期了也先给旧值,重算在后台做,读的人一秒都不用等
$this->refreshAggregateLater($key, $compute);
return ['data' => $envelope['data'], 'done' => true, 'retry_after' => 0];
}
if (!$allowDefer) {
$data = $compute();
$this->setCache($key, ['at' => time(), 'data' => $data], self::AGGREGATE_STALE_TTL);
return ['data' => $data, 'done' => true, 'retry_after' => 0];
}
# 一个值都没有:也绝不让浏览器在一条没有上界的查询上干等
$this->refreshAggregateLater($key, $compute);
return ['data' => null, 'done' => false, 'retry_after' => self::AGGREGATE_RETRY_AFTER];
}
/**
* 把重算挂到响应发出之后
*
* 同一时间只允许一个请求去算:冷缓存时前端每隔两秒问一次,
* 不挡的话每问一次就多一条全表聚合,几轮下来就把数据库压垮了。
*
* 用两个键,因为「有人在算」和「刚算挂了,缓一缓」要的 TTL 方向相反:
* {key}:computing 互斥锁,TTL = AGGREGATE_LOCK_TTL,必须覆盖最坏耗时
* {key}:backoff 失败退避,TTL = AGGREGATE_BACKOFF_TTL,只要挡住几轮轮询
* 合用一个键的话两个 TTL 只能二选一,怎么选都是错的。
*
* @access protected
* @param string $key
* @param callable $compute
* @return void
*/
protected function refreshAggregateLater(string $key, callable $compute): void
{
$redis = $this->redis;
if ($redis === null) {
return;
}
$lockKey = Cache::key($key . ':computing');
$backoffKey = Cache::key($key . ':backoff');
/*
* 锁值用一次性随机 token 而不是固定的 '1',理由同 Queue::acquireLock():
* 重算有可能跑过 AGGREGATE_LOCK_TTL,此时锁已经过期、可能已被下一个请求抢走。
* 固定值分不出「我的锁」和「别人的锁」,收尾时一个 DEL 就把新持有者的锁删了,
* 于是第三个请求又能抢到,同一个聚合上并排跑好几条 —— 正是这把锁要防的东西。
* 这个坑刷库锁踩过一次(当年锁值是 PID),别在这儿再踩一遍。
*/
$token = bin2hex(random_bytes(16));
try {
# 上一轮刚算挂过就先别急着重试,免得前端每问一次就重跑一次全表聚合
if ($redis->exists($backoffKey)) {
return;
}
if (!$redis->set($lockKey, $token, ['nx', 'ex' => self::AGGREGATE_LOCK_TTL])) {
# 已经有人在算了
return;
}
} catch (\Throwable $e) {
return;
}
register_shutdown_function(function () use ($key, $compute, $redis, $lockKey, $backoffKey, $token) {
// 页面已经输出完毕,先把响应交给用户再慢慢算
if (PHP_SAPI === 'fpm-fcgi' && function_exists('fastcgi_finish_request')) {
@fastcgi_finish_request();
}
@set_time_limit(0);
try {
$data = $compute();
/*
* 即使这次重算超时、锁已经不在自己手上,算出来的值照样写回:
* 它总比缓存里那个更新。真正不能做的是替别人释放锁,那由下面按 token 保证。
*/
$this->setCache($key, ['at' => time(), 'data' => $data], self::AGGREGATE_STALE_TTL);
} catch (\Throwable $e) {
// 算不出来不影响任何人,旧值还在。记一条退避,别让下一轮轮询立刻又来一遍
try {
$redis->set($backoffKey, '1', ['ex' => self::AGGREGATE_BACKOFF_TTL]);
} catch (\Throwable $ignored) {
}
} finally {
$this->releaseAggregateLock($redis, $lockKey, $token);
}
});
}
/**
* 释放重算锁,且只释放自己那把
*
* 比较和删除必须原子:先 GET 再 DEL 的话,两步之间锁正好过期并被别人抢走,
* 那个 DEL 删掉的就是新持有者的锁。
*
* @access protected
* @param Redis $redis
* @param string $lockKey 完整键名
* @param string $token 抢锁时写进去的 token
* @return void
*/
protected function releaseAggregateLock(Redis $redis, string $lockKey, string $token): void
{
try {
$script = <<<'LUA'
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
LUA;
$redis->eval($script, [$lockKey, $token], 1);
} catch (\Throwable $e) {
// 释放失败就让它自然过期,最坏是重算被推迟到锁过期为止
}
}
/**
* 带缓存的总计
*
* @access protected
* @param bool $allowDefer 冷缓存时是否允许回「还没算好」而不是当场算
* @return array{data: mixed, done: bool, retry_after: int}
* @throws DbException
*/
protected function cachedTotalOverview(bool $allowDefer = false): array
{
return $this->cachedAggregate(
'overview:total',
fn(): array => $this->queryTotalOverview(),
self::TOTAL_FRESH_TTL,
$allowDefer
);
}
/**
* 总计分段
*
* 这三个数字来自不带时间范围的全表聚合,代价没有上界。冷缓存时不当场算,
* 而是回 done=false 让前端稍后再问 —— 真算起来可能比 nginx 的
* fastcgi_read_timeout 还久,那时浏览器早已 504,而 PHP 还在那儿磨,
* 刷新几次就把 FPM 的 worker 堆满了。
*
* @access protected
* @return array
* @throws DbException
*/
protected function totalSection(): array
{
$result = $this->cachedTotalOverview(true);
if (!$result['done']) {
return [
'done' => false,
'retry_after' => $result['retry_after'],
'total' => null,
];
}
return ['done' => true, 'total' => $result['data']];
}
/**
* 当月图表分段
*
* 逐天计算并逐天缓存。有 Redis 时支持时间预算:跑不完就先返回已完成的部分,
* 下次请求从缓存里直接拿到已算好的天数继续往下推进。
* 没有 Redis 时无处存放中间结果,只能一次算完。
*
* @access protected
* @param float|null $deadline
* @return array
* @throws DbException
*/
protected function monthSection(?float $deadline): array
{
$year = date('Y');
$month = date('m');
$monthDays = (int)date('t');
$today = (int)date('j');
$result = ['time' => $month];
$ready = 0;
$computed = 0;
$done = true;
for ($day = 1; $day <= $monthDays; $day++) {
if ($day > $today) {
# 未来的日期直接补 0,不用查库
$result['ip']['detail'][$day - 1] = 0;
$result['uv']['detail'][$day - 1] = 0;
$result['pv']['detail'][$day - 1] = 0;
$ready++;
continue;
}
$date = sprintf('%s-%s-%02d', $year, $month, $day);
$cached = $this->getCache('overview:daycount:' . $date);
$counts = $cached ?? $this->cachedDayCounts($date);
if ($cached === null) {
$computed++;
}
$result['ip']['detail'][$day - 1] = $counts['ip'];
$result['uv']['detail'][$day - 1] = $counts['uv'];
$result['pv']['detail'][$day - 1] = $counts['pv'];
$ready++;
/*
* 只有能把中间结果缓存下来时,中断才有意义。
* 另外必须至少真算出了一天才允许中断:否则时间预算在「回放缓存」的阶段
* 就耗尽的话,每次请求都停在同一天,前端会一直请求却毫无进展。
*/
if ($computed > 0 && $this->redis !== null && $deadline !== null
&& $day < $today && microtime(true) >= $deadline) {
$done = false;
break;
}
}
if (!$done) {
return ['done' => false, 'progress' => $ready, 'total_days' => $monthDays];
}
return [
'done' => true,
'progress' => $ready,
'total_days' => $monthDays,
'month' => $this->chartOf($result, 'month'),
];
}
/**
* 来源统计数据(供分段接口使用)
*
* @access protected
* @return array
* @throws DbException
*/
protected function refererData(): array
{
$this->parseReferer();
return $this->referer;
}
/**
* 文章饼图数据(供分段接口使用)
*
* @access protected
* @return array
* @throws DbException
*/
protected function postPieData(): array
{
$this->parsePostPie();
return $this->postPie;
}
/**
* 生成文章访问量饼图数据(Top N)
*
* @return void
* @throws DbException
*/
protected function parsePostPie(): void
{
$limit = (int)$this->config->pageSize;
$limit = $limit > 0 ? min($limit, 50) : 20;
$this->postPie = $this->cachedAggregate(
'overview:post_pie:top' . $limit,
fn(): array => $this->buildPostPie($limit),
self::LIST_FRESH_TTL
)['data'];
}
/**
* 真正把文章饼图算出来(全表 GROUP BY content_id,代价随数据量线性增长)
*
* @access protected
* @param int $limit
* @return array
* @throws DbException
*/
protected function buildPostPie(int $limit): array
{
// 统计库与内容库可能不是同一个库,无法 JOIN,改为两次查询后在 PHP 中合并
$counts = $this->fetchContentCounts();
$meta = $this->fetchContentMeta(array_column($counts, 'cid'));
$series = [];
foreach ($counts as $item) {
$cid = $item['cid'];
$info = $meta[$cid] ?? null;
// 只统计文章;页面、附件等不计入,已被删除的内容(查不到)仍然保留
if ($info !== null && $info['type'] !== 'post') {
continue;
}
$title = $info === null ? '' : trim($info['title']);
if ($title === '') {
$title = _t('已删除文章 #%d', $cid);
}
$series[] = [
'cid' => $cid,
'name' => $title,
'y' => $item['count'],
];
if (count($series) >= $limit) {
break;
}
}
return $series;
}
/**
* 从统计库中取出各内容的访问次数,按次数降序
*
* @access protected
* @return array [['cid' => int, 'count' => int], ...]
* @throws DbException
*/
protected function fetchContentCounts(): array
{
$rows = $this->db->fetchAll(
$this->db->select('content_id AS cid', 'COUNT(1) AS count')
->from('table.access')
->where('content_id IS NOT NULL')
->where('content_id <> ?', 0)
->group('content_id')
->order('count', Db::SORT_DESC)
// 次数相同时各数据库的返回顺序不一致,补一个稳定的次级排序
->order('content_id', Db::SORT_ASC)
);
$result = [];
foreach ($rows as $row) {
$cid = (int)($row['cid'] ?? 0);
if ($cid > 0) {
$result[] = ['cid' => $cid, 'count' => (int)($row['count'] ?? 0)];
}
}
return $result;
}
/**
* 到 Typecho 主库按 cid 批量取内容标题与类型
*
* @access protected
* @param array $cids
* @return array cid => ['title' => string, 'type' => string]
* @throws DbException
*/
protected function fetchContentMeta(array $cids): array
{
$cids = array_values(array_unique(array_filter(array_map('intval', $cids))));
$meta = [];
foreach (array_chunk($cids, 200) as $chunk) {
$rows = $this->mainDb->fetchAll(
$this->mainDb->select('cid', 'title', 'type')
->from('table.contents')
->where('cid IN ?', $chunk)
);
foreach ($rows as $row) {
$meta[(int)$row['cid']] = [
'title' => (string)($row['title'] ?? ''),
'type' => (string)($row['type'] ?? ''),
];
}
}
return $meta;
}
/**
* 获取日志页全部数据(供 AJAX 接口调用)
*
* @access public
* @param int $page 页码
* @param int $type 类型 1=人类 2=爬虫 3=全部
* @param string $filter 筛选类型 all/ip/post/path
* @param string $filterValue 筛选值
* @return array
* @throws DbException
*/
public function getLogsData(int $page, int $type, string $filter, string $filterValue): array
{
# 先把队列里积压的写进去,否则最新的访问不会出现在列表里
$this->flushQueue();
$offset = (max($page, 1) - 1) * $this->config->pageSize;
$query = $this->db->select()->from('table.access')
->order('time', Db::SORT_DESC)
->offset($offset)->limit($this->config->pageSize);
$qcount = $this->db->select('count(1) AS count')->from('table.access');
switch ($type) {
case 1:
$query->where('robot = ?', 0);
$qcount->where('robot = ?', 0);
break;
case 2:
$query->where('robot = ?', 1);
$qcount->where('robot = ?', 1);
break;
}
switch ($filter) {
case 'ip':
if (filter_var($filterValue, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$ip = $this->ip62long($filterValue);
} else {
$ip = (string)bindec(decbin((int)ip2long($filterValue)));
}
$query->where('ip = ?', $ip);
$qcount->where('ip = ?', $ip);
break;
case 'post':
// PostgreSQL 对整型列不接受空串等非数字字面量,这里统一转成整型
$cid = (int)$filterValue;
$query->where('content_id = ?', $cid);
$qcount->where('content_id = ?', $cid);
break;
case 'path':
$query->where('path = ?', $filterValue);
$qcount->where('path = ?', $filterValue);
break;
}
$list = $this->db->fetchAll($query);
foreach ($list as &$row) {
if (!empty($row['robot']) && $row['robot'] == 1) {
$name = $row['robot_id'] ?? '';
$version = $row['robot_version'] ?? '';
} else {
$name = $row['browser_id'] ?? '';
$version = $row['browser_version'] ?? '';
}
if ($name === '' && !empty($row['ua'])) {
$ua = new UA($row['ua']);
if ($ua->isRobot()) {
$name = $ua->getRobotID();
$version = $ua->getRobotVersion();
} else {
$name = $ua->getBrowserName();
$version = $ua->getBrowserVersion();
}
}
if ($name == '') {
$row['display_name'] = _t('Unknown');
} elseif ($version == '') {
$row['display_name'] = $name;
} else {
$row['display_name'] = $name . ' / ' . $version;
}
// 转换 IP 以便前端直接使用
$row['ip_display'] = $this->long2ip($row['ip']);
}
/*
* 这里既不转义也不解码,原样交给前端。
*
* 不转义:渲染方(page/console.php)已经逐字段 createTextNode 转过一遍,
* 服务端再转一次,页面上就会显示出 ' 这类字面量而不是引号本身。
*
* 不解码:以前这里对整个 $list 做了一遍 urlDecode,有两个毛病 ——
* 1) 前端对 url 还会再 decodeURIComponent 一次,等于解码两遍,
* 含 %2520 的地址会被还原成一个根本不存在的 URL;
* 2) 它把 path 也一起解码了,而 path 同时是「按路径筛选」的取值,
* 回传给服务端后要跟库里的原始值比对 —— 库里存的是编码过的,
* 解码过的自然就对不上,含 %20 的路径永远筛不出东西。
* 解码只关乎显示,交给渲染层按字段决定,服务端别一刀切。
*/
$rows = (int)$this->db->fetchAll($qcount)[0]['count'];
$filterArr = ['filter' => $filter];
if ($filter !== 'all') {
$filterArr[$filter] = $filterValue;
}
$pageObj = new Page($this->config->pageSize, $rows, $page, 10, array_merge($filterArr, [
'panel' => Plugin::$panel,
'action' => 'logs',
'type' => $type,
]));
// 统计库与内容库可能不是同一个库,无法 JOIN,改为两次查询后在 PHP 中合并
$counts = $this->fetchContentCounts();
$meta = $this->fetchContentMeta(array_column($counts, 'cid'));
$cidList = [];
foreach ($counts as $item) {
$info = $meta[$item['cid']] ?? null;
// 对应原来的 INNER JOIN 语义:已删除的内容不出现在筛选下拉框里
if ($info === null || $info['type'] !== 'post') {
continue;
}
$cidList[] = [
'cid' => $item['cid'],
'count' => $item['count'],
'title' => $info['title'],
];
}
return [
'list' => $list,
'rows' => $rows,
'page' => $pageObj->show(),
'cidList' => $cidList,
];
}
/**
* 初始化 Redis 连接
*
* @access protected
* @return void
*/
protected function initRedis(): void
{
if (!extension_loaded('redis')) {
return;
}
if (!isset($this->config->redisCache) || $this->config->redisCache != '1') {
return;
}
/*
* 上次连接失败后的熔断窗口内直接降级,不再尝试。
* Redis 不可达时连接会一直等到超时,而本类是在每个前台请求里构造的,
* 没有这道闸门的话每个访客都要白等一次。
*/
if (Health::tripped(Health::REDIS)) {
return;
}
try {
# 连接超时、读写超时统一在 Health::connect() 里设置
$this->redis = Health::connect(
$this->config->redisHost ?: '127.0.0.1',
(int)($this->config->redisPort ?: 6379),
(string)($this->config->redisAuth ?? '')
);
# 连上了就立刻解除熔断,Redis 恢复后不用等窗口自然过期
Health::clear(Health::REDIS);
} catch (\Throwable $e) {
Health::trip(Health::REDIS);
$this->redis = null;
}
}
/**
* 从 Redis 获取缓存数据
*
* @access protected
* @param string $key 缓存键名
* @return array|null 缓存数据,未命中返回 null
*/
protected function getCache(string $key): ?array
{
if ($this->redis === null) {
return null;
}
try {
$data = $this->redis->get(Cache::key($key));
if ($data === false) {
return null;
}
$decoded = json_decode($data, true);
return is_array($decoded) ? $decoded : null;
} catch (\Throwable $e) {
return null;
}
}
/**
* 写入 Redis 缓存
* 默认 TTL 为距明天 0 点的剩余秒数,可显式指定(历史日期的统计永不变化,可以长期缓存)
*
* @access protected
* @param string $key 缓存键名
* @param array $data 缓存数据
* @param int|null $ttl 自定义存活秒数
* @return void
*/
protected function setCache(string $key, array $data, ?int $ttl = null): void
{
if ($this->redis === null) {
return;
}
try {
$ttl = $ttl ?? (86400 - (time() - strtotime(date("Y-m-d 00:00:00"))));
$this->redis->setex(
Cache::key($key),
max($ttl, 1),
json_encode($data, JSON_UNESCAPED_UNICODE)
);
} catch (\Throwable $e) {
// 写入失败静默忽略,不影响主流程
}
}
/**
* 生成来源统计数据,提供给页面渲染使用
* 优先从 Redis 缓存读取,缓存未命中时查询数据库并回填缓存
*
* @access protected
* @return void
*/
protected function parseReferer()
{
/*
* 下面两段都只做 URL 解码、不做 HTML 转义,理由同 getLogsData():
* 这些值只经 JSON 接口出去,console.php 渲染时已经逐字段转义过。
* 服务端再转一次,来源里带引号的 URL 就会显示成 ' 这种字面量 ——
* 扫描器把 SQL 注入探测串塞进 Referer 头时尤其明显。
*/
$limit = $this->config->pageSize;
/*
* 只统计「看起来是来源地址」的记录,理由见 plausibleUrl()。
* 过滤必须放在 SQL 里而不是取回来再筛:Top N 是数据库排序取前几名,
* 而垃圾来源往往被扫描器刷出很高的次数、正好占着前排。
*/
$urlOk = $this->urlLikeCondition('entrypoint');
// ── 来源 URL ──
$this->referer['url'] = $this->cachedAggregate(
'referer:url',
fn(): array => $this->urlDecode($this->db->fetchAll(
$this->db->select('DISTINCT entrypoint AS value, COUNT(1) as count')
->from('table.access')->where("entrypoint <> '' AND {$urlOk}")->group('entrypoint')
->order('count', Db::SORT_DESC)->limit($limit)
)),
self::LIST_FRESH_TTL
)['data'];
// ── 来源域名 ──
$this->referer['domain'] = $this->cachedAggregate(
'referer:domain',
fn(): array => $this->urlDecode($this->db->fetchAll(
$this->db->select('DISTINCT entrypoint_domain AS value, COUNT(1) as count')
->from('table.access')->where("entrypoint_domain <> ''")->group('entrypoint_domain')
->order('count', Db::SORT_DESC)->limit($limit)
)),
self::LIST_FRESH_TTL
)['data'];
}