File size: 30,553 Bytes
17e971c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# coding: utf-8
# -------------------------------------------------------------------
# 宝塔Linux面板-基础网站数据统计模块
# 该模块用于统计基础网站数据,包括IP数量、流量、访问量、PV、UV等数据。
# -------------------------------------------------------------------
# Copyright (c) 2015-2099 宝塔软件(http://bt.cn) All rights reserved.
# -------------------------------------------------------------------
# Author: wpl <wpl@bt.cn>
# -------------------------------------------------------------------
import json
import os
import re
import sys

from datetime import datetime, timedelta
from safeModel.base import safeBase
os.chdir("/www/server/panel")
sys.path.append("class/")
import db
import public

class main(safeBase):
        # ------------------------ 常量设置 ------------------------
    TOTAL_DIR = '/www/server/site_total/data/total'
    SUMMARY_DIR = '/www/server/site_total/data/summary'
    def __init__(self):
        pass

    # ------------------------ 对外接口 ------------------------
    def get_overview(self, get=None):
        """
        @name 总概览接口(全站三日总览 + 网站排名TOP5 + 全站近7天趋势)
        @return public.return_data(True, data)
        安全与约束:
          - Top5站点,固定按pv升序排序(用户确认:默认升序,指标需传参,支持traffic/req_count/ip_count/uv/pv)
          - 日期以服务器本地时间为准:今日/昨日/前日
          - 近7天包含今日在内的连续7个自然日
        """
        public.set_module_logs('site_total', 'get_overview', 1)
        today, yesterday, day_before = self._get_three_days()
        # 三日总览(全站)
        today_total = self._get_all_sites_total_for_date(today)
        yesterday_total = self._get_all_sites_total_for_date(yesterday)
        day_before_total = self._get_all_sites_total_for_date(day_before)
        compare = self._compute_compare(today_total, yesterday_total)
        overview_three_days = {
            'today': self._format_daily_stat(today_total, include_compare=compare),
            'yesterday': self._format_daily_stat(yesterday_total),
            'day_before': self._format_daily_stat(day_before_total)
        }
        # Top5排序参数(支持传递,默认 pv/asc)
        metric = 'pv'
        order = 'asc'
        if get:
            try:
                gm = getattr(get, 'metric', None)
                go = getattr(get, 'order', None)
                if gm is None and isinstance(get, dict):
                    gm = get.get('metric')
                if go is None and isinstance(get, dict):
                    go = get.get('order')
                if gm:
                    metric = str(gm)
                if go:
                    order = str(go)
            except Exception:
                pass
        valid_metrics = ['traffic', 'req_count', 'ip_count', 'uv', 'pv']
        valid_orders = ['asc', 'desc']
        if metric not in valid_metrics:
            metric = 'pv'
        if order not in valid_orders:
            order = 'asc'

        # Top5 当天(支持传递 metric/order,默认 pv/asc)
        top5 = self._get_top_sites_for_date(today, metric=metric, order=order, limit=5)
        # 近7天趋势(全站)
        trend_points = self._build_trend_7days_all()
        rec_status, detail_id = self._get_rec_status_detail()
        data = {
            'date_range': {
                'today': today,
                'yesterday': yesterday,
                'day_before': day_before
            },
            'overview_three_days': overview_three_days,
            'top5_sites': {
                'metric': metric,
                'order': order,
                'timeframe': 'today',
                # 'range': {'date': today},
                'items': top5
            },
            'trend_7days': {
                'timeframe': 'last_7_days',
                'points': trend_points
            },
            'rec_status': rec_status,
            'detail_id': detail_id
        }
        return public.return_data(True, data)

    def get_site_overview(self, get):
        """
        @name 指定站点数据概览接口(单站三日总览 + 单站近7天趋势)
        @param get.site_name 站点名(必填,校验:仅允许字母、数字、点、短横线、下划线)
        @return public.return_data(True, data)
        """
        public.set_module_logs('site_total', 'get_site_overview', 1)
        site_name = getattr(get, 'site_name', None)
        if not site_name:
            return public.returnMsg(False, '站点名非法或缺失')
        today, yesterday, day_before = self._get_three_days()
        # 三日总览(单站)
        today_total = self._get_site_total_for_date(site_name, today)
        yesterday_total = self._get_site_total_for_date(site_name, yesterday)
        day_before_total = self._get_site_total_for_date(site_name, day_before)
        compare = self._compute_compare(today_total, yesterday_total)
        overview_three_days = {
            'today': self._format_daily_stat(today_total, include_compare=compare),
            'yesterday': self._format_daily_stat(yesterday_total),
            'day_before': self._format_daily_stat(day_before_total)
        }
        # 近7天趋势(单站)
        trend_points = self._build_trend_7days_site(site_name)

        # 检查config配置是否需要更新
        self._check_config()
        
        data = {
            'site': site_name,
            'date_range': {
                'today': today,
                'yesterday': yesterday,
                'day_before': day_before
            },
            'overview_three_days': overview_three_days,
            'trend_7days': {
                'site': site_name,
                'timeframe': 'last_7_days',
                'points': trend_points
            }
        }
        return public.return_data(True, data)

    def receive_products(self, get):
        """
        @name 领取产品接口
        @param get.detail_id 活动详情ID(必填)
        @return public.return_data(True, data)
        """
        try:
            u = public.get_user_info()
            if not isinstance(u, dict):
                return public.returnMsg(False, '用户信息获取失败')
            serverid = u.get('serverid')
            access_key = u.get('access_key')
            uid = u.get('uid')
            if not serverid or not access_key or uid is None:
                return public.returnMsg(False, '参数缺失')
            detail_id = None
            try:
                detail_id = getattr(get, 'detail_id', None)
            except Exception:
                detail_id = None
            if detail_id is None and isinstance(get, dict):
                detail_id = get.get('detail_id')
            if detail_id is None:
                return public.returnMsg(False, '缺少detail_id')
            mac = public.get_mac_address()
            payload = {
                'serverid': serverid,
                'access_key': access_key,
                'uid': uid,
                'detail_id': detail_id,
                'mac': mac
            }
            url = 'https://www.bt.cn/newapi/activity/panelapi/receive_products'
            res = public.httpPost(url, payload)
            if not res:
                return public.returnMsg(False, '接口请求失败')
            try:
                obj = json.loads(res)
            except Exception:
                return public.returnMsg(False, '响应解析失败')
            status = obj.get('status')
            success = bool(status)
            # 刷新软件列表状态,确保最新软件列表信息获取
            public.flush_plugin_list()
            return public.returnMsg(success, obj)
        except Exception:
            return public.returnMsg(False, '领取失败')
    
    # ------------------------ 内部工具方法 ------------------------
    def _get_three_days(self):
        """返回今日、昨日、前日的日期字符串(YYYY-MM-DD)"""
        now = datetime.now()
        today = now.strftime('%Y-%m-%d')
        yesterday = (now - timedelta(days=1)).strftime('%Y-%m-%d')
        day_before = (now - timedelta(days=2)).strftime('%Y-%m-%d')
        return today, yesterday, day_before

    def _get_7day_dates(self):
        """返回近7天日期列表(包含今日), 每项格式YYYY-MM-DD"""
        base = datetime.now()
        dates = []
        for i in range(6, -1, -1):
            dates.append((base - timedelta(days=i)).strftime('%Y-%m-%d'))
        return dates

    def _get_7day_range(self):
        """返回近7天范围的字典: {start_date, end_date}"""
        base = datetime.now()
        start_date = (base - timedelta(days=6)).strftime('%Y-%m-%d')
        end_date = base.strftime('%Y-%m-%d')
        return {'start_date': start_date, 'end_date': end_date}

    def _validate_site_name(self, site):
        """校验站点名,仅允许字母、数字、点、短横线、下划线,长度<=128"""
        if not isinstance(site, str):
            return False
        if len(site) == 0 or len(site) > 128:
            return False
        return re.match(r'^[A-Za-z0-9._-]+$', site) is not None

    def _safe_read_json(self, path):
        """安全读取JSON文件,失败返回None"""
        try:
            if not os.path.exists(path):
                return None
            body = public.readFile(path)
            if not body:
                return None
            return json.loads(body)
        except Exception:
            return None

    def _ensure_metrics(self, data):
        """规范化指标字典,缺失字段按0处理,类型转为int"""
        keys = ['traffic', 'requests', 'ip', 'uv', 'pv']
        result = {}
        for k in keys:
            try:
                v = int((data or {}).get(k, 0)) if isinstance(data, dict) else 0
            except Exception:
                v = 0
            result[k] = v
        return result

    def _humanize_bytes(self, n):
        """按1024换算返回人类可读格式"""
        try:
            n = int(n)
        except Exception:
            n = 0
        units = ['B', 'KB', 'MB', 'GB', 'TB']
        size = float(n)
        idx = 0
        while size >= 1024 and idx < len(units) - 1:
            size /= 1024.0
            idx += 1
        # 保留两位小数
        if idx == 0:
            return f"{int(size)} {units[idx]}"
        return f"{round(size, 2)} {units[idx]}"

    def _format_daily_stat(self, metrics, include_compare=None):
        """将指标格式化为返回结构,include_compare用于today对比昨日"""
        m = self._ensure_metrics(metrics or {})
        formatted = {
            'traffic_bytes': m['traffic'],
            'traffic_human': self._humanize_bytes(m['traffic']),
            'req_count': m['requests'],
            'ip_count': m['ip'],
            'uv': m['uv'],
            'pv': m['pv']
        }
        if include_compare is not None:
            formatted['compare_vs_yesterday'] = include_compare
        return formatted

    def _compute_compare(self, today_metrics, yesterday_metrics):
        """计算与昨日的对比,返回各指标的abs/pct/trend,pct保留两位小数"""
        t = self._ensure_metrics(today_metrics or {})
        y = self._ensure_metrics(yesterday_metrics or {})
        result = {}
        for src_k, out_k in [('traffic','traffic'), ('requests','req_count'), ('ip','ip_count'), ('uv','uv'), ('pv','pv')]:
            abs_change = t[src_k] - y[src_k]
            pct = 0.0
            if y[src_k] > 0:
                pct = round((abs_change / y[src_k]) * 100.0, 2)
            else:
                # 昨日为0,无法计算百分比,按规则返回0
                pct = 0.0
            trend = 'flat'
            if abs_change > 0:
                trend = 'up'
            elif abs_change < 0:
                trend = 'down'
            result[out_k] = {'abs': abs_change, 'pct': pct, 'trend': trend}
        return result

    def _monitor_enabled(self):
        """检测是否启用监控报表数据源。存在 /www/server/panel/plugin/monitor 且配置中的 data_save_path 可用时返回 True"""
        try:
            if not os.path.exists('/www/server/panel/plugin/monitor'):
                return False
            db_path = self._get_monitor_db_path()
            return bool(db_path and os.path.isdir(db_path))
        except Exception as e:
            return False

    def _get_monitor_db_path(self):
        """读取监控报表配置,获取 data_save_path。结果缓存到实例属性以减少IO"""
        try:
            if hasattr(self, '_monitor_db_path') and self._monitor_db_path:
                return self._monitor_db_path
            conf_file = '/www/server/panel/plugin/monitor/monitor/config/config.json'
            conf_data = None
            try:
                conf_str = public.readFile(conf_file)
                conf_data = json.loads(conf_str) if conf_str else None
            except Exception:
                conf_data = None
            db_path = None
            if isinstance(conf_data, dict):
                db_path = conf_data.get('data_save_path')
            self._monitor_db_path = db_path
            return db_path
        except Exception:
            return None

    def _list_sites_monitor(self):
        """从监控报表数据目录枚举站点子目录(仅合法站点名且存在 request_total.db)"""
        sites = []
        try:
            base = self._get_monitor_db_path()
            if not base or not os.path.isdir(base):
                return sites
            for name in os.listdir(base):
                full = os.path.join(base, name)
                db_file = os.path.join(full, 'request_total.db')
                if os.path.isdir(full) and self._validate_site_name(name) and os.path.isfile(db_file):
                    sites.append(name)
        except Exception:
            pass
        return sites

    def _read_site_day_from_monitor(self, site, date_str):
        """从监控报表 request_total.db 读取单站某日指标,异常或缺失返回0集"""
        result = {'traffic': 0, 'requests': 0, 'ip': 0, 'uv': 0, 'pv': 0}
        try:
            base = self._get_monitor_db_path()
            if not base:
                return result
            db_file = os.path.join(base, site, 'request_total.db')
            if not os.path.isfile(db_file):
                return result
            # 日期转换为YYYYMMDD
            ymd = date_str.replace('-', '')
            ts = db.Sql()
            ts._Sql__DB_FILE = db_file
            fields = 'SUM(sent_bytes) as traffic, SUM(uv_number) as uv, SUM(ip_number) as ip, SUM(pv_number) as pv, SUM(request) as requests'
            row = ts.table('request_total').where("date=?", (ymd,)).field(fields).find()
            ts.close()
            if isinstance(row, dict) and row:
                for k in result.keys():
                    try:
                        result[k] = int(row.get(k, 0) or 0)
                    except Exception:
                        result[k] = 0
        except Exception:
            pass
        return result

    def _ensure_summary_dir(self):
        """确保SUMMARY_DIR存在"""
        try:
            if not os.path.isdir(self.SUMMARY_DIR):
                os.makedirs(self.SUMMARY_DIR, exist_ok=True)
        except Exception:
            pass

    def _safe_write_json_atomic(self, path, data):
        """原子写入JSON:先写临时文件,再替换为目标文件"""
        try:
             dir_name = os.path.dirname(path)
             try:
                 os.makedirs(dir_name, exist_ok=True)
             except Exception:
                 pass
             base_name = os.path.basename(path)
             tmp_path = os.path.join(dir_name, '.' + base_name + '.tmp')
             with open(tmp_path, 'w', encoding='utf-8') as f:
                 json.dump(data, f, ensure_ascii=False)
             os.replace(tmp_path, path)
             return True
        except Exception:
            try:
                # 回滚临时文件
                if 'tmp_path' in locals() and os.path.exists(tmp_path):
                    os.remove(tmp_path)
            except Exception:
                pass
            return False

    def _list_sites(self):
        """枚举站点列表:优先使用监控报表数据源,否则遍历TOTAL_DIR下的目录"""
        try:
            if self._monitor_enabled():
                return self._list_sites_monitor()
        except Exception:
            # 监控数据源异常时回退旧方式
            pass
        sites = []
        base = self.TOTAL_DIR
        try:
            if not os.path.exists(base):
                return sites
            for name in os.listdir(base):
                full = os.path.join(base, name)
                if os.path.isdir(full) and self._validate_site_name(name):
                    sites.append(name)
        except Exception:
            pass
        return sites

    def _site_day_path(self, site, date_str):
        """拼接单站点某日JSON路径:/total/{site}/{YYYY-MM-DD}.json"""
        return os.path.join(self.TOTAL_DIR, site, f"{date_str}.json")

    def _load_summary_for_date(self, date_str):
        """读取全站汇总持久文件 /summary/{YYYY-MM-DD}.json,返回指标或None"""
        path = os.path.join(self.SUMMARY_DIR, f"{date_str}.json")
        data = self._safe_read_json(path)
        if data is None:
            return None
        return self._ensure_metrics(data)

    def _aggregate_all_sites_for_date(self, date_str):
        """聚合全站在某日的指标(遍历站点逐一读取),缺失文件按0处理"""
        total = {'traffic': 0, 'requests': 0, 'ip': 0, 'uv': 0, 'pv': 0}
        for site in self._list_sites():
            m = self._aggregate_site_for_date(site, date_str)
            for k in total.keys():
                total[k] += m.get(k, 0)
        return total

    def _aggregate_site_for_date(self, site, date_str):
        """读取单站点某日指标(优先监控报表DB,失败回退旧文件),缺失或异常返回全部0"""
        # 优先从监控报表数据源读取
        try:
            if self._monitor_enabled():
                return self._ensure_metrics(self._read_site_day_from_monitor(site, date_str))
        except Exception:
            # 数据源异常时回退旧方式
            pass
        # 旧文件方式
        path = self._site_day_path(site, date_str)
        data = self._safe_read_json(path)
        m = self._ensure_metrics(data or {})
        return m

    def _get_all_sites_total_for_date(self, date_str):
        """优先读取summary;不存在则回退聚合原始站点文件"""
        summary = self._load_summary_for_date(date_str)
        if summary is not None:
            return summary
        return self._aggregate_all_sites_for_date(date_str)

    def _get_site_total_for_date(self, site, date_str):
        """读取单站某日指标(直接读取原始文件)"""
        return self._aggregate_site_for_date(site, date_str)

    def _build_trend_7days_all(self):
        """构建全站近7天趋势points数组(历史6天缺失则计算并写入缓存,今日实时不写缓存)"""
        points = []
        today = datetime.now().strftime('%Y-%m-%d')
        for d in self._get_7day_dates():
            if d == today:
                # 今日实时统计,跳过summary缓存
                m = self._aggregate_all_sites_for_date(d)
            else:
                # 历史天优先读取summary,缺失则实时计算并写入缓存
                summary = self._load_summary_for_date(d)
                if summary is None:
                    m = self._aggregate_all_sites_for_date(d)
                    # 写入SUMMARY_DIR/{YYYY-MM-DD}.json
                    try:
                        self._safe_write_json_atomic(os.path.join(self.SUMMARY_DIR, f"{d}.json"), self._ensure_metrics(m))
                    except Exception:
                        pass
                else:
                    m = summary
            points.append({
                'date': d,
                'traffic_bytes': m['traffic'],
                'traffic_human': self._humanize_bytes(m['traffic']),
                'req_count': m['requests'],
                'ip_count': m['ip'],
                'uv': m['uv'],
                'pv': m['pv']
            })
        return points

    def _build_trend_7days_site(self, site):
        """构建单站近7天趋势points数组(历史天缺失则计算并写入 /total/{site}/{YYYY-MM-DD}.json;今日实时不写缓存)"""
        points = []
        today = datetime.now().strftime('%Y-%m-%d')
        for d in self._get_7day_dates():
            if d == today:
                m = self._aggregate_site_for_date(site, d)
            else:
                path = self._site_day_path(site, d)
                data = self._safe_read_json(path)
                if data is None:
                    m = self._aggregate_site_for_date(site, d)
                    try:
                        self._safe_write_json_atomic(path, self._ensure_metrics(m))
                    except Exception:
                        pass
                else:
                    m = self._ensure_metrics(data)
            points.append({
                'date': d,
                'traffic_bytes': m['traffic'],
                'traffic_human': self._humanize_bytes(m['traffic']),
                'req_count': m['requests'],
                'ip_count': m['ip'],
                'uv': m['uv'],
                'pv': m['pv']
            })
        return points

    def _get_top_sites_for_date(self, date_str, metric='pv', order='asc', limit=5):
        """计算指定日期的站点当天排行"""
        if metric not in ['traffic', 'req_count', 'ip_count', 'uv', 'pv']:
            metric = 'pv'
        if order not in ['asc', 'desc']:
            order = 'asc'
        result = []
        for site in self._list_sites():
            m = self._aggregate_site_for_date(site, date_str)
            result.append({
                'site': site,
                'traffic_bytes': m['traffic'],
                'traffic_human': self._humanize_bytes(m['traffic']),
                'req_count': m['requests'],
                'ip_count': m['ip'],
                'uv': m['uv'],
                'pv': m['pv']
            })
        # 映射排序字段
        metric_alias = {'traffic': 'traffic_bytes', 'req_count': 'req_count', 'ip_count': 'ip_count', 'uv': 'uv', 'pv': 'pv'}
        sort_key = metric_alias.get(metric, 'pv')
        reverse = (order == 'desc')
        result.sort(key=lambda x: int(x.get(sort_key, 0)), reverse=reverse)
        if isinstance(limit, int):
            if limit <= 0:
                limit = 5
            if limit > 50:
                limit = 50
        else:
            limit = 5
        return result[:limit]

    def _get_top_sites_last_7_days(self, metric='pv', order='asc', limit=5):
        """计算近7天累计的站点排行(固定metric=pv,order=asc)"""
        if metric not in ['traffic', 'req_count', 'ip_count', 'uv', 'pv']:
            metric = 'pv'
        if order not in ['asc', 'desc']:
            order = 'asc'
        dates = self._get_7day_dates()
        result = []
        for site in self._list_sites():
            agg = {'traffic': 0, 'requests': 0, 'ip': 0, 'uv': 0, 'pv': 0}
            for d in dates:
                m = self._aggregate_site_for_date(site, d)
                for k in agg.keys():
                    agg[k] += m.get(k, 0)
            result.append({
                'site': site,
                'traffic_bytes': agg['traffic'],
                'traffic_human': self._humanize_bytes(agg['traffic']),
                'req_count': agg['requests'],
                'ip_count': agg['ip'],
                'uv': agg['uv'],
                'pv': agg['pv']
            })
        # 排序字段映射
        metric_alias = {'traffic': 'traffic_bytes', 'req_count': 'req_count', 'ip_count': 'ip_count', 'uv': 'uv', 'pv': 'pv'}
        sort_key = metric_alias.get(metric, 'pv')
        reverse = (order == 'desc')
        result.sort(key=lambda x: int(x.get(sort_key, 0)), reverse=reverse)
        if isinstance(limit, int):
            if limit <= 0:
                limit = 5
            if limit > 50:
                limit = 50
        else:
            limit = 5
        return result[:limit]

    def _get_rec_status(self):
        try:
            u = public.get_user_info()
            if not isinstance(u, dict):
                return False
            serverid = u.get('serverid')
            access_key = u.get('access_key')
            uid = u.get('uid')
            if not serverid or not access_key or uid is None:
                return False
            payload = {
                'serverid': serverid,
                'access_key': access_key,
                'uid': uid,
                'activity_id': 44
            }
            url = 'https://www.bt.cn/newapi/activity/panelapi/get_free_activity_info'
            res = public.httpPost(url, payload)
            if not res:
                return False
            try:
                obj = json.loads(res)
            except Exception:
                return False
            data = obj.get('data')
            if isinstance(data, dict):
                s = data.get('status')
                detail = data.get('detail')
                buy_status = None
                if isinstance(detail, list) and len(detail) > 0:
                    buy_status = detail[0].get('buy_status')
                elif isinstance(detail, dict):
                    buy_status = detail.get('buy_status')
                return (s == 1 or str(s) == '1') and (buy_status == 1 or str(buy_status) == '1')
            if isinstance(data, list) and len(data) > 0:
                item = data[0]
                s = item.get('status')
                detail = item.get('detail')
                buy_status = None
                if isinstance(detail, list) and len(detail) > 0:
                    buy_status = detail[0].get('buy_status')
                elif isinstance(detail, dict):
                    buy_status = detail.get('buy_status')
                return (s == 1 or str(s) == '1') and (buy_status == 1 or str(buy_status) == '1')
            return False
        except Exception:
            return False

    def _get_rec_status_detail(self):
        try:
            u = public.get_user_info()
            if not isinstance(u, dict):
                return False, None
            serverid = u.get('serverid')
            access_key = u.get('access_key')
            uid = u.get('uid')
            if not serverid or not access_key or uid is None:
                return False, None
            payload = {
                'serverid': serverid,
                'access_key': access_key,
                'uid': uid,
                'activity_id': 44
            }
            url = 'https://www.bt.cn/newapi/activity/panelapi/get_free_activity_info'
            res = public.httpPost(url, payload)
            if not res:
                return False, None
            try:
                obj = json.loads(res)
            except Exception:
                return False, None
            data = obj.get('data')
            detail_id = None
            if isinstance(data, dict):
                s = data.get('status')
                detail = data.get('detail')
                buy_status = None
                if isinstance(detail, list) and len(detail) > 0:
                    buy_status = detail[0].get('buy_status')
                    detail_id = detail[0].get('id')
                elif isinstance(detail, dict):
                    buy_status = detail.get('buy_status')
                    detail_id = detail.get('id')
                return (s == 1 or str(s) == '1') and (buy_status == 1 or str(buy_status) == '1'), detail_id
            if isinstance(data, list) and len(data) > 0:
                item = data[0]
                s = item.get('status')
                detail = item.get('detail')
                buy_status = None
                if isinstance(detail, list) and len(detail) > 0:
                    buy_status = detail[0].get('buy_status')
                    detail_id = detail[0].get('id')
                elif isinstance(detail, dict):
                    buy_status = detail.get('buy_status')
                    detail_id = detail.get('id')
                return (s == 1 or str(s) == '1') and (buy_status == 1 or str(buy_status) == '1'), detail_id
            return False, None
        except Exception:
            return False, None

    def _check_config(self):
        """
        @description 检查config配置是否需要更新,修复统计数量不显示问题
        @return None
        """
        header_file = "{}/data/table_header_conf.json".format(public.get_panel_path())
        try:
            if os.path.exists(header_file):
                raw = public.readFile(header_file)
                file_data = json.loads(raw)
                if isinstance(file_data, dict):
                    updated = False
                    val = file_data.get("phpTableColumn", '')
                    if val:
                        try:
                            cols = json.loads(val) or []
                            has_day = False
                            for c in cols:
                                if c.get("label") == "日流量":
                                    has_day = True
                                    if c.get("isCustom") is not True:
                                        c["isCustom"] = True
                                    if c.get("isLtd") is not True:
                                        c["isLtd"] = True
                                    break
                            if not has_day:
                                cols.append({"label": "日流量", "width": 80, "isCustom": True, "isLtd": True})
                            file_data["phpTableColumn"] = json.dumps(cols)
                            updated = True
                        except Exception:
                            pass
                    if updated:
                        public.writeFile(header_file, json.dumps(file_data))
        except Exception:
            pass