mdbq 4.0.104__tar.gz → 4.0.106__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mdbq might be problematic. Click here for more details.
- {mdbq-4.0.104 → mdbq-4.0.106}/PKG-INFO +2 -2
- mdbq-4.0.106/mdbq/__version__.py +1 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/auth/auth_backend.py +27 -39
- mdbq-4.0.106/mdbq/redis/redis_cache.py +578 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/route/monitor.py +1 -1
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq.egg-info/PKG-INFO +2 -2
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq.egg-info/SOURCES.txt +1 -0
- mdbq-4.0.104/mdbq/__version__.py +0 -1
- {mdbq-4.0.104 → mdbq-4.0.106}/README.txt +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/auth/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/auth/rate_limiter.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/js/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/js/jc.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/log/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/log/mylogger.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/myconf/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/myconf/myconf.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/mysql/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/mysql/deduplicator.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/mysql/mysql.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/mysql/s_query.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/mysql/unique_.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/mysql/uploader.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/other/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/other/download_sku_picture.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/other/error_handler.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/other/otk.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/other/pov_city.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/other/ua_sj.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/pbix/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/pbix/pbix_refresh.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/pbix/refresh_all.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/redis/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/redis/getredis.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/route/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/route/analytics.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/route/routes.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/selenium/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/selenium/get_driver.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq/spider/__init__.py +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq.egg-info/dependency_links.txt +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/mdbq.egg-info/top_level.txt +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/setup.cfg +0 -0
- {mdbq-4.0.104 → mdbq-4.0.106}/setup.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = '4.0.106'
|
|
@@ -516,13 +516,7 @@ class StandaloneAuthManager:
|
|
|
516
516
|
|
|
517
517
|
# 检查账户锁定状态
|
|
518
518
|
current_time = datetime.now()
|
|
519
|
-
if locked_until:
|
|
520
|
-
if locked_until.tzinfo is None:
|
|
521
|
-
locked_until = locked_until.replace(tzinfo=timezone.utc)
|
|
522
|
-
elif locked_until.tzinfo != timezone.utc:
|
|
523
|
-
locked_until = locked_until.astimezone(timezone.utc)
|
|
524
|
-
|
|
525
|
-
if locked_until > current_time:
|
|
519
|
+
if locked_until and locked_until > current_time:
|
|
526
520
|
remaining_seconds = int((locked_until - current_time).total_seconds())
|
|
527
521
|
self._log_login_attempt(username_or_email, ip_address, user_agent, 'failure', 'account_locked', user_id)
|
|
528
522
|
self._record_ip_failure(ip_address, 'login')
|
|
@@ -849,7 +843,7 @@ class StandaloneAuthManager:
|
|
|
849
843
|
cursor = conn.cursor()
|
|
850
844
|
|
|
851
845
|
try:
|
|
852
|
-
|
|
846
|
+
current_time = datetime.now()
|
|
853
847
|
|
|
854
848
|
# 先查询要登出的设备数量
|
|
855
849
|
cursor.execute('''
|
|
@@ -864,7 +858,7 @@ class StandaloneAuthManager:
|
|
|
864
858
|
UPDATE refresh_tokens
|
|
865
859
|
SET is_revoked = 1, revoked_at = %s, revoked_reason = 'logout'
|
|
866
860
|
WHERE user_id = %s AND is_revoked = 0
|
|
867
|
-
''', (
|
|
861
|
+
''', (current_time, user_id))
|
|
868
862
|
|
|
869
863
|
# 停用用户的所有设备会话
|
|
870
864
|
cursor.execute('''
|
|
@@ -919,7 +913,7 @@ class StandaloneAuthManager:
|
|
|
919
913
|
new_salt = secrets.token_hex(32)
|
|
920
914
|
new_password_hash = self._hash_password(new_password, new_salt)
|
|
921
915
|
|
|
922
|
-
|
|
916
|
+
current_time = datetime.now()
|
|
923
917
|
|
|
924
918
|
# 更新密码
|
|
925
919
|
cursor.execute('''
|
|
@@ -934,7 +928,7 @@ class StandaloneAuthManager:
|
|
|
934
928
|
UPDATE refresh_tokens
|
|
935
929
|
SET is_revoked = 1, revoked_at = %s, revoked_reason = 'password_changed'
|
|
936
930
|
WHERE user_id = %s AND is_revoked = 0
|
|
937
|
-
''', (
|
|
931
|
+
''', (current_time, user_id))
|
|
938
932
|
|
|
939
933
|
# 停用所有设备会话
|
|
940
934
|
cursor.execute('''
|
|
@@ -1055,7 +1049,7 @@ class StandaloneAuthManager:
|
|
|
1055
1049
|
if len(new_password) < 6:
|
|
1056
1050
|
return {'success': False, 'message': '新密码至少需要6个字符'}
|
|
1057
1051
|
|
|
1058
|
-
|
|
1052
|
+
current_time = datetime.now()
|
|
1059
1053
|
|
|
1060
1054
|
# 查找有效的重置令牌
|
|
1061
1055
|
cursor.execute('''
|
|
@@ -1064,7 +1058,7 @@ class StandaloneAuthManager:
|
|
|
1064
1058
|
WHERE password_reset_token = %s
|
|
1065
1059
|
AND password_reset_expires > %s
|
|
1066
1060
|
AND is_active = 1
|
|
1067
|
-
''', (reset_token,
|
|
1061
|
+
''', (reset_token, current_time))
|
|
1068
1062
|
|
|
1069
1063
|
user = cursor.fetchone()
|
|
1070
1064
|
if not user:
|
|
@@ -1088,7 +1082,7 @@ class StandaloneAuthManager:
|
|
|
1088
1082
|
UPDATE refresh_tokens
|
|
1089
1083
|
SET is_revoked = 1, revoked_at = %s, revoked_reason = 'password_reset'
|
|
1090
1084
|
WHERE user_id = %s AND is_revoked = 0
|
|
1091
|
-
''', (
|
|
1085
|
+
''', (current_time, user['id']))
|
|
1092
1086
|
|
|
1093
1087
|
# 停用所有设备会话
|
|
1094
1088
|
cursor.execute('''
|
|
@@ -1133,16 +1127,10 @@ class StandaloneAuthManager:
|
|
|
1133
1127
|
|
|
1134
1128
|
locked_until = record['locked_until']
|
|
1135
1129
|
|
|
1136
|
-
|
|
1137
|
-
if locked_until:
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
elif locked_until.tzinfo != timezone.utc:
|
|
1141
|
-
locked_until = locked_until.astimezone(timezone.utc)
|
|
1142
|
-
|
|
1143
|
-
if locked_until > current_time_utc:
|
|
1144
|
-
remaining_seconds = int((locked_until - current_time_utc).total_seconds())
|
|
1145
|
-
return {
|
|
1130
|
+
current_time = datetime.now()
|
|
1131
|
+
if locked_until and locked_until > current_time:
|
|
1132
|
+
remaining_seconds = int((locked_until - current_time).total_seconds())
|
|
1133
|
+
return {
|
|
1146
1134
|
'blocked': True,
|
|
1147
1135
|
'remaining_time': remaining_seconds,
|
|
1148
1136
|
'reason': f'IP被锁定,剩余时间: {remaining_seconds}秒'
|
|
@@ -1163,7 +1151,7 @@ class StandaloneAuthManager:
|
|
|
1163
1151
|
cursor = conn.cursor()
|
|
1164
1152
|
|
|
1165
1153
|
try:
|
|
1166
|
-
now = datetime.now(
|
|
1154
|
+
now = datetime.now()
|
|
1167
1155
|
|
|
1168
1156
|
cursor.execute('''
|
|
1169
1157
|
SELECT failure_count, first_failure, lockout_count
|
|
@@ -1179,7 +1167,7 @@ class StandaloneAuthManager:
|
|
|
1179
1167
|
window_start = now - timedelta(minutes=window_minutes)
|
|
1180
1168
|
first_failure = record['first_failure']
|
|
1181
1169
|
|
|
1182
|
-
if first_failure and first_failure
|
|
1170
|
+
if first_failure and first_failure <= window_start:
|
|
1183
1171
|
# 重置计数器
|
|
1184
1172
|
cursor.execute('''
|
|
1185
1173
|
UPDATE ip_rate_limits
|
|
@@ -1225,14 +1213,14 @@ class StandaloneAuthManager:
|
|
|
1225
1213
|
|
|
1226
1214
|
def _revoke_device_session(self, cursor, device_session_id, reason='manual'):
|
|
1227
1215
|
"""撤销设备会话"""
|
|
1228
|
-
|
|
1216
|
+
current_time = datetime.now()
|
|
1229
1217
|
|
|
1230
1218
|
# 撤销设备相关的refresh token
|
|
1231
1219
|
cursor.execute('''
|
|
1232
1220
|
UPDATE refresh_tokens
|
|
1233
1221
|
SET is_revoked = 1, revoked_at = %s, revoked_reason = %s
|
|
1234
1222
|
WHERE device_session_id = %s AND is_revoked = 0
|
|
1235
|
-
''', (
|
|
1223
|
+
''', (current_time, reason, device_session_id))
|
|
1236
1224
|
|
|
1237
1225
|
# 停用设备会话
|
|
1238
1226
|
cursor.execute('''
|
|
@@ -1446,7 +1434,7 @@ class StandaloneAuthManager:
|
|
|
1446
1434
|
cursor = conn.cursor()
|
|
1447
1435
|
|
|
1448
1436
|
try:
|
|
1449
|
-
|
|
1437
|
+
current_time = datetime.now()
|
|
1450
1438
|
|
|
1451
1439
|
if device_id:
|
|
1452
1440
|
# 方式1:通过device_id查找设备(用于设备管理界面)
|
|
@@ -1504,7 +1492,7 @@ class StandaloneAuthManager:
|
|
|
1504
1492
|
UPDATE refresh_tokens
|
|
1505
1493
|
SET is_revoked = 1, revoked_at = %s, revoked_reason = %s
|
|
1506
1494
|
WHERE device_session_id = %s AND is_revoked = 0
|
|
1507
|
-
''', (
|
|
1495
|
+
''', (current_time, logout_reason, device_session_id))
|
|
1508
1496
|
|
|
1509
1497
|
# 停用设备会话
|
|
1510
1498
|
cursor.execute('''
|
|
@@ -1537,7 +1525,7 @@ class StandaloneAuthManager:
|
|
|
1537
1525
|
cursor = conn.cursor()
|
|
1538
1526
|
|
|
1539
1527
|
try:
|
|
1540
|
-
|
|
1528
|
+
current_time = datetime.now()
|
|
1541
1529
|
|
|
1542
1530
|
if access_token:
|
|
1543
1531
|
# 方式1:通过access_token解析出设备会话信息
|
|
@@ -1586,7 +1574,7 @@ class StandaloneAuthManager:
|
|
|
1586
1574
|
UPDATE refresh_tokens
|
|
1587
1575
|
SET is_revoked = 1, revoked_at = %s, revoked_reason = 'current_device_logout'
|
|
1588
1576
|
WHERE device_session_id = %s AND is_revoked = 0
|
|
1589
|
-
''', (
|
|
1577
|
+
''', (current_time, device_session_id))
|
|
1590
1578
|
|
|
1591
1579
|
# 停用设备会话
|
|
1592
1580
|
cursor.execute('''
|
|
@@ -1618,7 +1606,7 @@ class StandaloneAuthManager:
|
|
|
1618
1606
|
cursor = conn.cursor()
|
|
1619
1607
|
|
|
1620
1608
|
try:
|
|
1621
|
-
|
|
1609
|
+
current_time = datetime.now()
|
|
1622
1610
|
|
|
1623
1611
|
# 通过device_id查找设备,确保属于该用户
|
|
1624
1612
|
cursor.execute('''
|
|
@@ -1639,7 +1627,7 @@ class StandaloneAuthManager:
|
|
|
1639
1627
|
UPDATE refresh_tokens
|
|
1640
1628
|
SET is_revoked = 1, revoked_at = %s, revoked_reason = 'specific_device_logout'
|
|
1641
1629
|
WHERE device_session_id = %s AND is_revoked = 0
|
|
1642
|
-
''', (
|
|
1630
|
+
''', (current_time, device_session_id))
|
|
1643
1631
|
|
|
1644
1632
|
# 停用设备会话
|
|
1645
1633
|
cursor.execute('''
|
|
@@ -1735,8 +1723,8 @@ class StandaloneAuthManager:
|
|
|
1735
1723
|
cursor = conn.cursor()
|
|
1736
1724
|
|
|
1737
1725
|
try:
|
|
1738
|
-
|
|
1739
|
-
threshold_time =
|
|
1726
|
+
current_time = datetime.now()
|
|
1727
|
+
threshold_time = current_time - timedelta(days=days_threshold)
|
|
1740
1728
|
|
|
1741
1729
|
# 查找不活跃的设备
|
|
1742
1730
|
cursor.execute('''
|
|
@@ -1757,7 +1745,7 @@ class StandaloneAuthManager:
|
|
|
1757
1745
|
WHERE device_session_id IN ({})
|
|
1758
1746
|
AND is_revoked = 0
|
|
1759
1747
|
'''.format(','.join(['%s'] * len(device_session_ids))),
|
|
1760
|
-
[
|
|
1748
|
+
[current_time] + device_session_ids)
|
|
1761
1749
|
|
|
1762
1750
|
# 停用设备会话
|
|
1763
1751
|
cursor.execute('''
|
|
@@ -1891,8 +1879,8 @@ class StandaloneAuthManager:
|
|
|
1891
1879
|
cursor = conn.cursor()
|
|
1892
1880
|
|
|
1893
1881
|
try:
|
|
1894
|
-
|
|
1895
|
-
threshold_time =
|
|
1882
|
+
current_time = datetime.now()
|
|
1883
|
+
threshold_time = current_time - timedelta(days=days_threshold)
|
|
1896
1884
|
|
|
1897
1885
|
if user_id:
|
|
1898
1886
|
# 清理特定用户的旧记录
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Redis智能缓存系统
|
|
4
|
+
|
|
5
|
+
主要功能:
|
|
6
|
+
1. Redis缓存的CRUD操作
|
|
7
|
+
2. 命名空间隔离
|
|
8
|
+
3. 分布式锁防止缓存击穿
|
|
9
|
+
4. 自动统计分析并提交到MySQL
|
|
10
|
+
5. 缓存健康检查和监控
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
import threading
|
|
17
|
+
import socket
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Optional, Dict, Any, List, Union
|
|
20
|
+
import redis
|
|
21
|
+
from mdbq.log import mylogger
|
|
22
|
+
logger = mylogger.MyLogger(
|
|
23
|
+
logging_mode='file',
|
|
24
|
+
log_level='info',
|
|
25
|
+
log_format='json',
|
|
26
|
+
max_log_size=50,
|
|
27
|
+
backup_count=5,
|
|
28
|
+
enable_async=False,
|
|
29
|
+
sample_rate=1,
|
|
30
|
+
sensitive_fields=[],
|
|
31
|
+
enable_metrics=False,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CacheConfig:
|
|
36
|
+
"""缓存系统配置类"""
|
|
37
|
+
|
|
38
|
+
# TTL配置(秒)
|
|
39
|
+
DEFAULT_TTL = 3600 # 1小时
|
|
40
|
+
SHORT_TTL = 300 # 5分钟
|
|
41
|
+
MEDIUM_TTL = 1800 # 30分钟
|
|
42
|
+
LONG_TTL = 7200 # 2小时
|
|
43
|
+
VERY_LONG_TTL = 86400 # 24小时
|
|
44
|
+
|
|
45
|
+
# 缓存键前缀
|
|
46
|
+
CACHE_PREFIX = "smart_cache:"
|
|
47
|
+
STATS_PREFIX = "cache_stats:"
|
|
48
|
+
LOCK_PREFIX = "cache_lock:"
|
|
49
|
+
|
|
50
|
+
# 统计配置
|
|
51
|
+
STATS_INTERVAL = 300 # 统计间隔(秒)
|
|
52
|
+
STATS_RETENTION = 7 # 统计数据保留天数
|
|
53
|
+
|
|
54
|
+
# 性能配置
|
|
55
|
+
MAX_KEY_LENGTH = 250
|
|
56
|
+
MAX_VALUE_SIZE = 1024 * 1024 # 1MB
|
|
57
|
+
BATCH_SIZE = 100
|
|
58
|
+
|
|
59
|
+
# 数据库配置
|
|
60
|
+
DB_NAME = "redis统计"
|
|
61
|
+
TABLE_NAME = "dpflask路由分析"
|
|
62
|
+
|
|
63
|
+
# 锁配置
|
|
64
|
+
LOCK_TIMEOUT = 30 # 分布式锁超时时间
|
|
65
|
+
LOCK_RETRY_DELAY = 0.1 # 锁重试延迟
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SmartCacheSystem:
|
|
69
|
+
"""智能缓存系统核心类"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, redis_client: redis.Redis, mysql_pool=None, instance_name: str = "default"):
|
|
72
|
+
self.redis_client = redis_client
|
|
73
|
+
self.mysql_pool = mysql_pool
|
|
74
|
+
self.instance_name = instance_name
|
|
75
|
+
self.config = CacheConfig()
|
|
76
|
+
self.logger = logger
|
|
77
|
+
|
|
78
|
+
# 统计数据
|
|
79
|
+
self.stats = {
|
|
80
|
+
'hits': 0,
|
|
81
|
+
'misses': 0,
|
|
82
|
+
'sets': 0,
|
|
83
|
+
'deletes': 0,
|
|
84
|
+
'errors': 0,
|
|
85
|
+
'total_operations': 0,
|
|
86
|
+
'start_time': time.time(),
|
|
87
|
+
'response_times': []
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# 热点键统计
|
|
91
|
+
self.hot_keys = {}
|
|
92
|
+
self.hot_keys_lock = threading.RLock()
|
|
93
|
+
|
|
94
|
+
# 统计线程控制
|
|
95
|
+
self._stats_running = False
|
|
96
|
+
self._stats_thread = None
|
|
97
|
+
self._stats_lock = threading.RLock()
|
|
98
|
+
|
|
99
|
+
# 初始化
|
|
100
|
+
self._init_mysql_db()
|
|
101
|
+
self._start_stats_worker()
|
|
102
|
+
|
|
103
|
+
self.logger.info("智能缓存系统初始化完成", {
|
|
104
|
+
'instance_name': self.instance_name,
|
|
105
|
+
'mysql_enabled': self.mysql_pool is not None,
|
|
106
|
+
'redis_connected': self._test_redis_connection()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
def _test_redis_connection(self) -> bool:
|
|
110
|
+
"""测试Redis连接"""
|
|
111
|
+
try:
|
|
112
|
+
self.redis_client.ping()
|
|
113
|
+
return True
|
|
114
|
+
except Exception as e:
|
|
115
|
+
self.logger.error(f"Redis连接测试失败: {e}")
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def _init_mysql_db(self) -> bool:
|
|
119
|
+
"""初始化MySQL数据库和表"""
|
|
120
|
+
if not self.mysql_pool:
|
|
121
|
+
self.logger.warning("MySQL连接池未提供,统计功能将被禁用")
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
connection = self.mysql_pool.connection()
|
|
126
|
+
try:
|
|
127
|
+
with connection.cursor() as cursor:
|
|
128
|
+
# 创建数据库
|
|
129
|
+
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{self.config.DB_NAME}` DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci")
|
|
130
|
+
cursor.execute(f"USE `{self.config.DB_NAME}`")
|
|
131
|
+
|
|
132
|
+
# 创建表(MySQL 8.4+兼容语法)
|
|
133
|
+
create_table_sql = f"""
|
|
134
|
+
CREATE TABLE IF NOT EXISTS `{self.config.TABLE_NAME}` (
|
|
135
|
+
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
|
136
|
+
`统计时间` datetime NOT NULL COMMENT '统计时间',
|
|
137
|
+
`时间段` varchar(20) NOT NULL COMMENT '时间段标识',
|
|
138
|
+
`缓存命中数` bigint DEFAULT 0 COMMENT '缓存命中次数',
|
|
139
|
+
`缓存未命中数` bigint DEFAULT 0 COMMENT '缓存未命中次数',
|
|
140
|
+
`缓存设置数` bigint DEFAULT 0 COMMENT '缓存设置次数',
|
|
141
|
+
`缓存删除数` bigint DEFAULT 0 COMMENT '缓存删除次数',
|
|
142
|
+
`缓存错误数` bigint DEFAULT 0 COMMENT '缓存错误次数',
|
|
143
|
+
`命中率` decimal(5,2) DEFAULT 0.00 COMMENT '缓存命中率(%)',
|
|
144
|
+
`总操作数` bigint DEFAULT 0 COMMENT '总操作次数',
|
|
145
|
+
`平均响应时间` decimal(10,4) DEFAULT 0.0000 COMMENT '平均响应时间(ms)',
|
|
146
|
+
`每秒操作数` decimal(10,2) DEFAULT 0.00 COMMENT '每秒操作数',
|
|
147
|
+
`唯一键数量` int DEFAULT 0 COMMENT '唯一键数量',
|
|
148
|
+
`系统运行时间` bigint DEFAULT 0 COMMENT '系统运行时间(秒)',
|
|
149
|
+
`热点键统计` json DEFAULT NULL COMMENT '热点键统计信息',
|
|
150
|
+
`服务器主机` varchar(100) DEFAULT NULL COMMENT '服务器主机名',
|
|
151
|
+
`实例名称` varchar(100) DEFAULT NULL COMMENT '缓存实例名称',
|
|
152
|
+
`创建时间` timestamp DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
|
|
153
|
+
`更新时间` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间',
|
|
154
|
+
PRIMARY KEY (`id`),
|
|
155
|
+
KEY `idx_stats_time` (`统计时间`),
|
|
156
|
+
KEY `idx_time_period` (`时间段`),
|
|
157
|
+
KEY `idx_hit_rate` (`命中率`),
|
|
158
|
+
KEY `idx_instance` (`实例名称`),
|
|
159
|
+
KEY `idx_create_time` (`创建时间`)
|
|
160
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Redis缓存系统统计分析表'
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
cursor.execute(create_table_sql)
|
|
164
|
+
connection.commit()
|
|
165
|
+
|
|
166
|
+
self.logger.info("MySQL数据库表初始化成功", {
|
|
167
|
+
'database': self.config.DB_NAME,
|
|
168
|
+
'table': self.config.TABLE_NAME
|
|
169
|
+
})
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
finally:
|
|
173
|
+
connection.close()
|
|
174
|
+
|
|
175
|
+
except Exception as e:
|
|
176
|
+
self.logger.error(f"MySQL数据库初始化失败: {e}")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def _generate_cache_key(self, key: str, namespace: str = "") -> str:
|
|
180
|
+
"""生成缓存键"""
|
|
181
|
+
if namespace:
|
|
182
|
+
return f"{self.config.CACHE_PREFIX}{namespace}:{key}"
|
|
183
|
+
return f"{self.config.CACHE_PREFIX}{key}"
|
|
184
|
+
|
|
185
|
+
def _record_operation(self, operation: str, response_time: float = 0):
|
|
186
|
+
"""记录操作统计"""
|
|
187
|
+
with self._stats_lock:
|
|
188
|
+
self.stats['total_operations'] += 1
|
|
189
|
+
if operation in self.stats:
|
|
190
|
+
self.stats[operation] += 1
|
|
191
|
+
if response_time > 0:
|
|
192
|
+
self.stats['response_times'].append(response_time)
|
|
193
|
+
# 只保留最近1000次操作的响应时间
|
|
194
|
+
if len(self.stats['response_times']) > 1000:
|
|
195
|
+
self.stats['response_times'] = self.stats['response_times'][-1000:]
|
|
196
|
+
|
|
197
|
+
def _record_hot_key(self, key: str, namespace: str = ""):
|
|
198
|
+
"""记录热点键"""
|
|
199
|
+
cache_key = self._generate_cache_key(key, namespace)
|
|
200
|
+
with self.hot_keys_lock:
|
|
201
|
+
self.hot_keys[cache_key] = self.hot_keys.get(cache_key, 0) + 1
|
|
202
|
+
|
|
203
|
+
def get(self, key: str, namespace: str = "", default=None) -> Any:
|
|
204
|
+
"""获取缓存值"""
|
|
205
|
+
start_time = time.time()
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
cache_key = self._generate_cache_key(key, namespace)
|
|
209
|
+
|
|
210
|
+
# 获取缓存值
|
|
211
|
+
value = self.redis_client.get(cache_key)
|
|
212
|
+
response_time = (time.time() - start_time) * 1000
|
|
213
|
+
|
|
214
|
+
if value is not None:
|
|
215
|
+
# 缓存命中
|
|
216
|
+
self._record_operation('hits', response_time)
|
|
217
|
+
self._record_hot_key(key, namespace)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
return json.loads(value.decode('utf-8'))
|
|
221
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
222
|
+
return value.decode('utf-8')
|
|
223
|
+
else:
|
|
224
|
+
# 缓存未命中
|
|
225
|
+
self._record_operation('misses', response_time)
|
|
226
|
+
return default
|
|
227
|
+
|
|
228
|
+
except Exception as e:
|
|
229
|
+
self._record_operation('errors')
|
|
230
|
+
self.logger.error(f"缓存获取失败: {e}", {
|
|
231
|
+
'key': key,
|
|
232
|
+
'namespace': namespace
|
|
233
|
+
})
|
|
234
|
+
return default
|
|
235
|
+
|
|
236
|
+
def set(self, key: str, value: Any, ttl: int = None, namespace: str = "") -> bool:
|
|
237
|
+
"""设置缓存值"""
|
|
238
|
+
start_time = time.time()
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
cache_key = self._generate_cache_key(key, namespace)
|
|
242
|
+
ttl = ttl or self.config.DEFAULT_TTL
|
|
243
|
+
|
|
244
|
+
# 序列化值
|
|
245
|
+
if isinstance(value, (dict, list, tuple)):
|
|
246
|
+
serialized_value = json.dumps(value, ensure_ascii=False)
|
|
247
|
+
else:
|
|
248
|
+
serialized_value = str(value)
|
|
249
|
+
|
|
250
|
+
# 检查值大小
|
|
251
|
+
if len(serialized_value.encode('utf-8')) > self.config.MAX_VALUE_SIZE:
|
|
252
|
+
self.logger.warning(f"缓存值过大,跳过设置: {len(serialized_value)} bytes")
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
# 设置缓存
|
|
256
|
+
result = self.redis_client.setex(cache_key, ttl, serialized_value)
|
|
257
|
+
response_time = (time.time() - start_time) * 1000
|
|
258
|
+
|
|
259
|
+
self._record_operation('sets', response_time)
|
|
260
|
+
return bool(result)
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
self._record_operation('errors')
|
|
264
|
+
self.logger.error(f"缓存设置失败: {e}", {
|
|
265
|
+
'key': key,
|
|
266
|
+
'namespace': namespace,
|
|
267
|
+
'ttl': ttl
|
|
268
|
+
})
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
def delete(self, key: str, namespace: str = "") -> bool:
|
|
272
|
+
"""删除缓存值"""
|
|
273
|
+
start_time = time.time()
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
cache_key = self._generate_cache_key(key, namespace)
|
|
277
|
+
result = self.redis_client.delete(cache_key)
|
|
278
|
+
response_time = (time.time() - start_time) * 1000
|
|
279
|
+
|
|
280
|
+
self._record_operation('deletes', response_time)
|
|
281
|
+
return bool(result)
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
self._record_operation('errors')
|
|
285
|
+
self.logger.error(f"缓存删除失败: {e}", {
|
|
286
|
+
'key': key,
|
|
287
|
+
'namespace': namespace
|
|
288
|
+
})
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
def exists(self, key: str, namespace: str = "") -> bool:
|
|
292
|
+
"""检查缓存键是否存在"""
|
|
293
|
+
try:
|
|
294
|
+
cache_key = self._generate_cache_key(key, namespace)
|
|
295
|
+
return bool(self.redis_client.exists(cache_key))
|
|
296
|
+
except Exception as e:
|
|
297
|
+
self.logger.error(f"缓存存在性检查失败: {e}")
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
def clear_namespace(self, namespace: str) -> int:
|
|
301
|
+
"""清除指定命名空间的所有缓存"""
|
|
302
|
+
try:
|
|
303
|
+
pattern = f"{self.config.CACHE_PREFIX}{namespace}:*"
|
|
304
|
+
keys = self.redis_client.keys(pattern)
|
|
305
|
+
|
|
306
|
+
if keys:
|
|
307
|
+
deleted = self.redis_client.delete(*keys)
|
|
308
|
+
self.logger.info(f"清除命名空间缓存: {namespace}, 删除键数: {deleted}")
|
|
309
|
+
return deleted
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
self.logger.error(f"清除命名空间缓存失败: {e}")
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
317
|
+
"""获取缓存统计信息"""
|
|
318
|
+
with self._stats_lock:
|
|
319
|
+
total_ops = self.stats['total_operations']
|
|
320
|
+
hits = self.stats['hits']
|
|
321
|
+
|
|
322
|
+
# 计算命中率
|
|
323
|
+
hit_rate = (hits / total_ops * 100) if total_ops > 0 else 0
|
|
324
|
+
|
|
325
|
+
# 计算平均响应时间
|
|
326
|
+
response_times = self.stats['response_times']
|
|
327
|
+
avg_response_time = sum(response_times) / len(response_times) if response_times else 0
|
|
328
|
+
|
|
329
|
+
# 计算运行时间
|
|
330
|
+
uptime = time.time() - self.stats['start_time']
|
|
331
|
+
|
|
332
|
+
# 计算每秒操作数
|
|
333
|
+
ops_per_second = total_ops / uptime if uptime > 0 else 0
|
|
334
|
+
|
|
335
|
+
# 获取热点键(前10个)
|
|
336
|
+
with self.hot_keys_lock:
|
|
337
|
+
top_hot_keys = sorted(self.hot_keys.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
'hits': hits,
|
|
341
|
+
'misses': self.stats['misses'],
|
|
342
|
+
'sets': self.stats['sets'],
|
|
343
|
+
'deletes': self.stats['deletes'],
|
|
344
|
+
'errors': self.stats['errors'],
|
|
345
|
+
'total_operations': total_ops,
|
|
346
|
+
'hit_rate': round(hit_rate, 2),
|
|
347
|
+
'avg_response_time': round(avg_response_time, 4),
|
|
348
|
+
'ops_per_second': round(ops_per_second, 2),
|
|
349
|
+
'uptime_seconds': int(uptime),
|
|
350
|
+
'hot_keys': dict(top_hot_keys),
|
|
351
|
+
'unique_keys_count': len(self.hot_keys)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
def health_check(self) -> Dict[str, Any]:
|
|
355
|
+
"""健康检查"""
|
|
356
|
+
health_info = {
|
|
357
|
+
'redis_connected': False,
|
|
358
|
+
'mysql_available': False,
|
|
359
|
+
'stats_worker_running': self._stats_running,
|
|
360
|
+
'instance_name': self.instance_name,
|
|
361
|
+
'timestamp': datetime.now().isoformat()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
# 检查Redis连接
|
|
365
|
+
try:
|
|
366
|
+
self.redis_client.ping()
|
|
367
|
+
health_info['redis_connected'] = True
|
|
368
|
+
except Exception as e:
|
|
369
|
+
health_info['redis_error'] = str(e)
|
|
370
|
+
|
|
371
|
+
# 检查MySQL连接
|
|
372
|
+
if self.mysql_pool:
|
|
373
|
+
try:
|
|
374
|
+
connection = self.mysql_pool.connection()
|
|
375
|
+
connection.close()
|
|
376
|
+
health_info['mysql_available'] = True
|
|
377
|
+
except Exception as e:
|
|
378
|
+
health_info['mysql_error'] = str(e)
|
|
379
|
+
|
|
380
|
+
return health_info
|
|
381
|
+
|
|
382
|
+
def _start_stats_worker(self):
|
|
383
|
+
"""启动统计工作线程"""
|
|
384
|
+
if not self._stats_running:
|
|
385
|
+
self._stats_running = True
|
|
386
|
+
self._stats_thread = threading.Thread(target=self._stats_worker, daemon=True)
|
|
387
|
+
self._stats_thread.start()
|
|
388
|
+
self.logger.info("统计工作线程已启动")
|
|
389
|
+
|
|
390
|
+
def _stats_worker(self):
|
|
391
|
+
"""后台统计工作线程"""
|
|
392
|
+
while self._stats_running:
|
|
393
|
+
try:
|
|
394
|
+
# 收集统计数据
|
|
395
|
+
stats_data = self.get_stats()
|
|
396
|
+
|
|
397
|
+
# 提交到MySQL
|
|
398
|
+
self._submit_stats_to_mysql(stats_data)
|
|
399
|
+
|
|
400
|
+
# 清理过期的热点键统计
|
|
401
|
+
self._cleanup_hot_keys()
|
|
402
|
+
|
|
403
|
+
except Exception as e:
|
|
404
|
+
self.logger.error(f"统计工作线程异常: {e}")
|
|
405
|
+
|
|
406
|
+
# 等待下一个统计间隔
|
|
407
|
+
time.sleep(self.config.STATS_INTERVAL)
|
|
408
|
+
|
|
409
|
+
def _submit_stats_to_mysql(self, stats_data: Dict[str, Any]):
|
|
410
|
+
"""提交统计数据到MySQL"""
|
|
411
|
+
if not self.mysql_pool:
|
|
412
|
+
self.logger.debug("MySQL连接池不可用,跳过统计数据提交")
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
connection = self.mysql_pool.connection()
|
|
417
|
+
try:
|
|
418
|
+
with connection.cursor() as cursor:
|
|
419
|
+
cursor.execute(f"USE `{self.config.DB_NAME}`")
|
|
420
|
+
|
|
421
|
+
# 准备数据
|
|
422
|
+
now = datetime.now()
|
|
423
|
+
time_period = now.strftime("%Y%m%d_%H%M")
|
|
424
|
+
|
|
425
|
+
insert_sql = f"""
|
|
426
|
+
INSERT INTO `{self.config.TABLE_NAME}` (
|
|
427
|
+
`统计时间`, `时间段`, `缓存命中数`, `缓存未命中数`, `缓存设置数`,
|
|
428
|
+
`缓存删除数`, `缓存错误数`, `命中率`, `总操作数`, `平均响应时间`,
|
|
429
|
+
`每秒操作数`, `唯一键数量`, `系统运行时间`, `热点键统计`,
|
|
430
|
+
`服务器主机`, `实例名称`
|
|
431
|
+
) VALUES (
|
|
432
|
+
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
|
433
|
+
)
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
cursor.execute(insert_sql, (
|
|
437
|
+
now,
|
|
438
|
+
time_period,
|
|
439
|
+
stats_data['hits'],
|
|
440
|
+
stats_data['misses'],
|
|
441
|
+
stats_data['sets'],
|
|
442
|
+
stats_data['deletes'],
|
|
443
|
+
stats_data['errors'],
|
|
444
|
+
stats_data['hit_rate'],
|
|
445
|
+
stats_data['total_operations'],
|
|
446
|
+
stats_data['avg_response_time'],
|
|
447
|
+
stats_data['ops_per_second'],
|
|
448
|
+
stats_data['unique_keys_count'],
|
|
449
|
+
stats_data['uptime_seconds'],
|
|
450
|
+
json.dumps(stats_data['hot_keys'], ensure_ascii=False),
|
|
451
|
+
socket.gethostname(),
|
|
452
|
+
self.instance_name
|
|
453
|
+
))
|
|
454
|
+
|
|
455
|
+
connection.commit()
|
|
456
|
+
|
|
457
|
+
self.logger.debug("统计数据已提交到MySQL", {
|
|
458
|
+
'time_period': time_period,
|
|
459
|
+
'total_operations': stats_data['total_operations'],
|
|
460
|
+
'hit_rate': stats_data['hit_rate']
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
finally:
|
|
464
|
+
connection.close()
|
|
465
|
+
|
|
466
|
+
except Exception as e:
|
|
467
|
+
self.logger.error(f"提交统计数据到MySQL失败: {e}")
|
|
468
|
+
|
|
469
|
+
def _cleanup_hot_keys(self):
|
|
470
|
+
"""清理热点键统计(保留访问次数最高的1000个)"""
|
|
471
|
+
with self.hot_keys_lock:
|
|
472
|
+
if len(self.hot_keys) > 1000:
|
|
473
|
+
# 保留访问次数最高的1000个键
|
|
474
|
+
sorted_keys = sorted(self.hot_keys.items(), key=lambda x: x[1], reverse=True)
|
|
475
|
+
self.hot_keys = dict(sorted_keys[:1000])
|
|
476
|
+
|
|
477
|
+
def shutdown(self):
|
|
478
|
+
"""关闭缓存系统"""
|
|
479
|
+
self.logger.info("正在关闭缓存系统...")
|
|
480
|
+
|
|
481
|
+
# 停止统计线程
|
|
482
|
+
self._stats_running = False
|
|
483
|
+
if self._stats_thread and self._stats_thread.is_alive():
|
|
484
|
+
self._stats_thread.join(timeout=5)
|
|
485
|
+
|
|
486
|
+
# 最后一次提交统计数据
|
|
487
|
+
try:
|
|
488
|
+
stats_data = self.get_stats()
|
|
489
|
+
self._submit_stats_to_mysql(stats_data)
|
|
490
|
+
except Exception as e:
|
|
491
|
+
self.logger.error(f"关闭时提交统计数据失败: {e}")
|
|
492
|
+
|
|
493
|
+
self.logger.info("缓存系统已关闭")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class CacheManager:
|
|
497
|
+
"""缓存管理器 - 单例模式"""
|
|
498
|
+
|
|
499
|
+
_instance = None
|
|
500
|
+
_lock = threading.RLock()
|
|
501
|
+
|
|
502
|
+
def __new__(cls):
|
|
503
|
+
with cls._lock:
|
|
504
|
+
if cls._instance is None:
|
|
505
|
+
cls._instance = super().__new__(cls)
|
|
506
|
+
cls._instance._initialized = False
|
|
507
|
+
return cls._instance
|
|
508
|
+
|
|
509
|
+
def __init__(self):
|
|
510
|
+
if self._initialized:
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
self.cache_instance = None
|
|
514
|
+
self.enabled = True
|
|
515
|
+
self.initialization_error = None
|
|
516
|
+
self._initialized = True
|
|
517
|
+
self.logger = logger
|
|
518
|
+
|
|
519
|
+
def initialize(self, redis_client: redis.Redis, mysql_pool=None, instance_name: str = "default"):
|
|
520
|
+
"""初始化缓存系统"""
|
|
521
|
+
try:
|
|
522
|
+
self.cache_instance = SmartCacheSystem(
|
|
523
|
+
redis_client=redis_client,
|
|
524
|
+
mysql_pool=mysql_pool,
|
|
525
|
+
instance_name=instance_name
|
|
526
|
+
)
|
|
527
|
+
self.initialization_error = None
|
|
528
|
+
self.logger.info("缓存管理器初始化成功", {
|
|
529
|
+
'instance_name': instance_name,
|
|
530
|
+
'mysql_enabled': mysql_pool is not None
|
|
531
|
+
})
|
|
532
|
+
return self # 支持链式调用
|
|
533
|
+
|
|
534
|
+
except Exception as e:
|
|
535
|
+
self.initialization_error = str(e)
|
|
536
|
+
self.cache_instance = None
|
|
537
|
+
self.logger.error(f"缓存管理器初始化失败: {e}")
|
|
538
|
+
return self
|
|
539
|
+
|
|
540
|
+
def get_cache(self) -> Optional[SmartCacheSystem]:
|
|
541
|
+
"""获取缓存实例"""
|
|
542
|
+
return self.cache_instance if self.enabled else None
|
|
543
|
+
|
|
544
|
+
def is_available(self) -> bool:
|
|
545
|
+
"""检查缓存是否可用"""
|
|
546
|
+
return self.cache_instance is not None and self.enabled
|
|
547
|
+
|
|
548
|
+
def enable(self):
|
|
549
|
+
"""启用缓存"""
|
|
550
|
+
self.enabled = True
|
|
551
|
+
self.logger.info("缓存系统已启用")
|
|
552
|
+
return self # 支持链式调用
|
|
553
|
+
|
|
554
|
+
def disable(self):
|
|
555
|
+
"""禁用缓存"""
|
|
556
|
+
self.enabled = False
|
|
557
|
+
self.logger.info("缓存系统已禁用")
|
|
558
|
+
return self # 支持链式调用
|
|
559
|
+
|
|
560
|
+
def get_status(self) -> Dict[str, Any]:
|
|
561
|
+
"""获取缓存状态"""
|
|
562
|
+
return {
|
|
563
|
+
'enabled': self.enabled,
|
|
564
|
+
'available': self.cache_instance is not None,
|
|
565
|
+
'initialization_error': self.initialization_error,
|
|
566
|
+
'instance_name': getattr(self.cache_instance, 'instance_name', None) if self.cache_instance else None
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
def shutdown(self):
|
|
570
|
+
"""关闭缓存系统"""
|
|
571
|
+
if self.cache_instance:
|
|
572
|
+
self.cache_instance.shutdown()
|
|
573
|
+
self.cache_instance = None
|
|
574
|
+
self.logger.info("缓存管理器已关闭")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# 导出单例实例
|
|
578
|
+
cache_manager = CacheManager()
|
mdbq-4.0.104/mdbq/__version__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = '4.0.104'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|