xiaoyukkkk commited on
Commit
28cfef3
·
verified ·
1 Parent(s): a772b83

Upload 10 files

Browse files
Files changed (3) hide show
  1. core/account.py +64 -27
  2. core/config.py +16 -0
  3. core/storage.py +245 -0
core/account.py CHANGED
@@ -13,6 +13,9 @@ from typing import Dict, List, Optional, TYPE_CHECKING
13
 
14
  from fastapi import HTTPException
15
 
 
 
 
16
  if TYPE_CHECKING:
17
  from core.jwt import JWTManager
18
 
@@ -313,16 +316,41 @@ class MultiAccountManager:
313
 
314
  # ---------- 配置文件管理 ----------
315
 
316
- def save_accounts_to_file(accounts_data: list):
317
- """保存账户配置到文件"""
 
318
  with open(ACCOUNTS_FILE, 'w', encoding='utf-8') as f:
319
  json.dump(accounts_data, f, ensure_ascii=False, indent=2)
320
  logger.info(f"[CONFIG] 配置已保存到 {ACCOUNTS_FILE}")
321
 
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  def load_accounts_from_source() -> list:
324
- """从环境变量或文件加载账户配置,优先使用环境变量"""
325
- # 优先从环境变量加载
326
  env_accounts = os.environ.get('ACCOUNTS_CONFIG')
327
  if env_accounts:
328
  try:
@@ -333,24 +361,33 @@ def load_accounts_from_source() -> list:
333
  logger.warning(f"[CONFIG] 环境变量 ACCOUNTS_CONFIG 为空")
334
  return accounts_data
335
  except Exception as e:
336
- logger.error(f"[CONFIG] 环境变量加载失败: {str(e)},尝试从文件加载")
337
 
338
- # 从文件加载
339
- if os.path.exists(ACCOUNTS_FILE):
340
  try:
341
- with open(ACCOUNTS_FILE, 'r', encoding='utf-8') as f:
342
- accounts_data = json.load(f)
343
- if accounts_data:
344
- logger.info(f"[CONFIG] 从文件加载配置: {ACCOUNTS_FILE},共 {len(accounts_data)} 个账户")
345
- else:
346
- logger.warning(f"[CONFIG] 账户配置为空,请在管理面板添加账户或编辑 {ACCOUNTS_FILE}")
347
- return accounts_data
348
  except Exception as e:
349
- logger.warning(f"[CONFIG] 文件加载失败: {str(e)},创建空配置")
350
-
351
- # 文件不存在,创建空配置
352
- logger.warning(f"[CONFIG] 未找到 {ACCOUNTS_FILE},已创建空配置文件")
353
- logger.info(f"[CONFIG] 💡 请在管理面板添加账户,或直接编辑 {ACCOUNTS_FILE},或使用批量上传功能,或设置环境变量 ACCOUNTS_CONFIG")
 
 
 
 
 
 
 
 
 
354
  save_accounts_to_file([])
355
  return []
356
 
@@ -390,14 +427,14 @@ def load_multi_account_config(
390
  disabled=acc.get("disabled", False) # 读取手动禁用状态,默认为 False
391
  )
392
 
393
- # 检查账户是否已过期(已过期也加载到管理面板)
394
- is_expired = config.is_expired()
395
- if is_expired:
396
- logger.warning(f"[CONFIG] 账户 {config.account_id} 已过期,仍加载用于展示")
397
-
398
- manager.add_account(config, http_client, user_agent, account_failure_threshold, rate_limit_cooldown_seconds, global_stats)
399
- if is_expired:
400
- manager.accounts[config.account_id].is_available = False
401
 
402
  if not manager.accounts:
403
  logger.warning(f"[CONFIG] 没有有效的账户配置,服务将启动但无法处理请求,请在管理面板添加账户")
 
13
 
14
  from fastapi import HTTPException
15
 
16
+ # 导入存储层(支持数据库)
17
+ from core import storage
18
+
19
  if TYPE_CHECKING:
20
  from core.jwt import JWTManager
21
 
 
316
 
317
  # ---------- 配置文件管理 ----------
318
 
319
+ def _save_to_file(accounts_data: list):
320
+ """保存账户配置到本地文件"""
321
+ os.makedirs(os.path.dirname(ACCOUNTS_FILE) or ".", exist_ok=True)
322
  with open(ACCOUNTS_FILE, 'w', encoding='utf-8') as f:
323
  json.dump(accounts_data, f, ensure_ascii=False, indent=2)
324
  logger.info(f"[CONFIG] 配置已保存到 {ACCOUNTS_FILE}")
325
 
326
 
327
+ def _load_from_file() -> list:
328
+ """从本地文件加载账户配置"""
329
+ if os.path.exists(ACCOUNTS_FILE):
330
+ try:
331
+ with open(ACCOUNTS_FILE, 'r', encoding='utf-8') as f:
332
+ return json.load(f)
333
+ except Exception as e:
334
+ logger.warning(f"[CONFIG] 文件加载失败: {str(e)}")
335
+ return None
336
+
337
+
338
+ def save_accounts_to_file(accounts_data: list):
339
+ """保存账户配置(优先数据库,降级到文件)"""
340
+ if storage.is_database_enabled():
341
+ try:
342
+ saved = storage.save_accounts_sync(accounts_data)
343
+ if saved:
344
+ return
345
+ except Exception as e:
346
+ logger.warning(f"[CONFIG] 数据库保存失败: {e},降级到文件存储")
347
+
348
+ _save_to_file(accounts_data)
349
+
350
+
351
  def load_accounts_from_source() -> list:
352
+ """从环境变量、数据库或文件加载账户配置"""
353
+ # 1. 优先从环境变量加载
354
  env_accounts = os.environ.get('ACCOUNTS_CONFIG')
355
  if env_accounts:
356
  try:
 
361
  logger.warning(f"[CONFIG] 环境变量 ACCOUNTS_CONFIG 为空")
362
  return accounts_data
363
  except Exception as e:
364
+ logger.error(f"[CONFIG] 环境变量加载失败: {str(e)}")
365
 
366
+ # 2. 尝试数据库加载
367
+ if storage.is_database_enabled():
368
  try:
369
+ accounts_data = storage.load_accounts_sync()
370
+ if accounts_data is not None:
371
+ if accounts_data:
372
+ logger.info(f"[CONFIG] 从数据库加载配置,共 {len(accounts_data)} 个账户")
373
+ else:
374
+ logger.warning(f"[CONFIG] 数据库中账户配置为空")
375
+ return accounts_data
376
  except Exception as e:
377
+ logger.warning(f"[CONFIG] 数据库加载失败: {e},降级到文件存储")
378
+
379
+ # 3. 从文件加载
380
+ accounts_data = _load_from_file()
381
+ if accounts_data is not None:
382
+ if accounts_data:
383
+ logger.info(f"[CONFIG] 从文件加载配置: {ACCOUNTS_FILE},共 {len(accounts_data)} 个账户")
384
+ else:
385
+ logger.warning(f"[CONFIG] 账户配置为空,请在管理面板添加账户或编辑 {ACCOUNTS_FILE}")
386
+ return accounts_data
387
+
388
+ # 4. 无配置,创建空配置
389
+ logger.warning(f"[CONFIG] 未找到配置,已创建空配置")
390
+ logger.info(f"[CONFIG] 💡 请在管理面板添加账户,或设置 DATABASE_URL 使用数据库存储")
391
  save_accounts_to_file([])
392
  return []
393
 
 
427
  disabled=acc.get("disabled", False) # 读取手动禁用状态,默认为 False
428
  )
429
 
430
+ # 检查账户是否已过期(已过期也加载到管理面板)
431
+ is_expired = config.is_expired()
432
+ if is_expired:
433
+ logger.warning(f"[CONFIG] 账户 {config.account_id} 已过期,仍加载用于展示")
434
+
435
+ manager.add_account(config, http_client, user_agent, account_failure_threshold, rate_limit_cooldown_seconds, global_stats)
436
+ if is_expired:
437
+ manager.accounts[config.account_id].is_available = False
438
 
439
  if not manager.accounts:
440
  logger.warning(f"[CONFIG] 没有有效的账户配置,服务将启动但无法处理请求,请在管理面板添加账户")
core/config.py CHANGED
@@ -19,6 +19,8 @@ from typing import Optional, List
19
  from pydantic import BaseModel, Field, validator
20
  from dotenv import load_dotenv
21
 
 
 
22
  # 加载 .env 文件
23
  load_dotenv()
24
 
@@ -152,6 +154,13 @@ class ConfigManager:
152
 
153
  def _load_yaml(self) -> dict:
154
  """加载 YAML 文件"""
 
 
 
 
 
 
 
155
  if self.yaml_path.exists():
156
  try:
157
  with open(self.yaml_path, 'r', encoding='utf-8') as f:
@@ -166,6 +175,13 @@ class ConfigManager:
166
 
167
  def save_yaml(self, data: dict):
168
  """保存 YAML 配置"""
 
 
 
 
 
 
 
169
  self.yaml_path.parent.mkdir(exist_ok=True)
170
  with open(self.yaml_path, 'w', encoding='utf-8') as f:
171
  yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
 
19
  from pydantic import BaseModel, Field, validator
20
  from dotenv import load_dotenv
21
 
22
+ from core import storage
23
+
24
  # 加载 .env 文件
25
  load_dotenv()
26
 
 
154
 
155
  def _load_yaml(self) -> dict:
156
  """加载 YAML 文件"""
157
+ if storage.is_database_enabled():
158
+ try:
159
+ data = storage.load_settings_sync()
160
+ if isinstance(data, dict):
161
+ return data
162
+ except Exception as e:
163
+ print(f"[WARN] 加载数据库设置失败: {e},使用本地配置")
164
  if self.yaml_path.exists():
165
  try:
166
  with open(self.yaml_path, 'r', encoding='utf-8') as f:
 
175
 
176
  def save_yaml(self, data: dict):
177
  """保存 YAML 配置"""
178
+ if storage.is_database_enabled():
179
+ try:
180
+ saved = storage.save_settings_sync(data)
181
+ if saved:
182
+ return
183
+ except Exception as e:
184
+ print(f"[WARN] 保存数据库设置失败: {e},降级到本地文件")
185
  self.yaml_path.parent.mkdir(exist_ok=True)
186
  with open(self.yaml_path, 'w', encoding='utf-8') as f:
187
  yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
core/storage.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Storage abstraction supporting file and PostgreSQL backends.
3
+
4
+ If DATABASE_URL is set, PostgreSQL is used.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import os
11
+ import threading
12
+ from typing import Optional
13
+
14
+ from dotenv import load_dotenv
15
+
16
+ load_dotenv()
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _db_pool = None
21
+ _db_pool_lock = None
22
+ _db_loop = None
23
+ _db_thread = None
24
+ _db_loop_lock = threading.Lock()
25
+
26
+
27
+ def _get_database_url() -> str:
28
+ return os.environ.get("DATABASE_URL", "").strip()
29
+
30
+
31
+ def is_database_enabled() -> bool:
32
+ """Return True when DATABASE_URL is configured."""
33
+ return bool(_get_database_url())
34
+
35
+
36
+ def _ensure_db_loop() -> asyncio.AbstractEventLoop:
37
+ global _db_loop, _db_thread
38
+ if _db_loop and _db_thread and _db_thread.is_alive():
39
+ return _db_loop
40
+ with _db_loop_lock:
41
+ if _db_loop and _db_thread and _db_thread.is_alive():
42
+ return _db_loop
43
+ loop = asyncio.new_event_loop()
44
+
45
+ def _runner() -> None:
46
+ asyncio.set_event_loop(loop)
47
+ loop.run_forever()
48
+
49
+ thread = threading.Thread(target=_runner, name="storage-db-loop", daemon=True)
50
+ thread.start()
51
+ _db_loop = loop
52
+ _db_thread = thread
53
+ return _db_loop
54
+
55
+
56
+ def _run_in_db_loop(coro):
57
+ loop = _ensure_db_loop()
58
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
59
+ return future.result()
60
+
61
+
62
+ async def _get_pool():
63
+ """Get (or create) the asyncpg connection pool."""
64
+ global _db_pool, _db_pool_lock
65
+ if _db_pool is not None:
66
+ return _db_pool
67
+ if _db_pool_lock is None:
68
+ _db_pool_lock = asyncio.Lock()
69
+ async with _db_pool_lock:
70
+ if _db_pool is not None:
71
+ return _db_pool
72
+ db_url = _get_database_url()
73
+ if not db_url:
74
+ raise ValueError("DATABASE_URL is not set")
75
+ try:
76
+ import asyncpg
77
+ _db_pool = await asyncpg.create_pool(
78
+ db_url,
79
+ min_size=1,
80
+ max_size=10,
81
+ command_timeout=30,
82
+ )
83
+ await _init_tables(_db_pool)
84
+ logger.info("[STORAGE] PostgreSQL pool initialized")
85
+ except ImportError:
86
+ logger.error("[STORAGE] asyncpg is required for database storage")
87
+ raise
88
+ except Exception as e:
89
+ logger.error(f"[STORAGE] Database connection failed: {e}")
90
+ raise
91
+ return _db_pool
92
+
93
+
94
+ async def _init_tables(pool) -> None:
95
+ """Initialize database tables."""
96
+ async with pool.acquire() as conn:
97
+ await conn.execute(
98
+ """
99
+ CREATE TABLE IF NOT EXISTS kv_store (
100
+ key TEXT PRIMARY KEY,
101
+ value JSONB NOT NULL,
102
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
103
+ )
104
+ """
105
+ )
106
+ logger.info("[STORAGE] Database tables initialized")
107
+
108
+
109
+ async def db_get(key: str) -> Optional[dict]:
110
+ """Fetch a value from the database."""
111
+ pool = await _get_pool()
112
+ async with pool.acquire() as conn:
113
+ row = await conn.fetchrow(
114
+ "SELECT value FROM kv_store WHERE key = $1", key
115
+ )
116
+ if not row:
117
+ return None
118
+ value = row["value"]
119
+ if isinstance(value, str):
120
+ return json.loads(value)
121
+ return value
122
+
123
+
124
+ async def db_set(key: str, value: dict) -> None:
125
+ """Persist a value to the database."""
126
+ pool = await _get_pool()
127
+ async with pool.acquire() as conn:
128
+ await conn.execute(
129
+ """
130
+ INSERT INTO kv_store (key, value, updated_at)
131
+ VALUES ($1, $2, CURRENT_TIMESTAMP)
132
+ ON CONFLICT (key) DO UPDATE SET
133
+ value = EXCLUDED.value,
134
+ updated_at = CURRENT_TIMESTAMP
135
+ """,
136
+ key,
137
+ json.dumps(value, ensure_ascii=False),
138
+ )
139
+
140
+
141
+ # ==================== Accounts storage ====================
142
+
143
+ async def load_accounts() -> Optional[list]:
144
+ """
145
+ Load account configuration from database when enabled.
146
+ Return None to indicate file-based fallback.
147
+ """
148
+ if not is_database_enabled():
149
+ return None
150
+ try:
151
+ data = await db_get("accounts")
152
+ if data:
153
+ logger.info(f"[STORAGE] Loaded {len(data)} accounts from database")
154
+ return data
155
+ logger.info("[STORAGE] No accounts found in database")
156
+ return []
157
+ except Exception as e:
158
+ logger.error(f"[STORAGE] Database read failed: {e}")
159
+ return None
160
+
161
+
162
+ async def save_accounts(accounts: list) -> bool:
163
+ """Save account configuration to database when enabled."""
164
+ if not is_database_enabled():
165
+ return False
166
+ try:
167
+ await db_set("accounts", accounts)
168
+ logger.info(f"[STORAGE] Saved {len(accounts)} accounts to database")
169
+ return True
170
+ except Exception as e:
171
+ logger.error(f"[STORAGE] Database write failed: {e}")
172
+ return False
173
+
174
+
175
+ def load_accounts_sync() -> Optional[list]:
176
+ """Sync wrapper for load_accounts (safe in sync/async call sites)."""
177
+ return _run_in_db_loop(load_accounts())
178
+
179
+
180
+ def save_accounts_sync(accounts: list) -> bool:
181
+ """Sync wrapper for save_accounts (safe in sync/async call sites)."""
182
+ return _run_in_db_loop(save_accounts(accounts))
183
+
184
+
185
+ # ==================== Settings storage ====================
186
+
187
+ async def load_settings() -> Optional[dict]:
188
+ if not is_database_enabled():
189
+ return None
190
+ try:
191
+ return await db_get("settings")
192
+ except Exception as e:
193
+ logger.error(f"[STORAGE] Settings read failed: {e}")
194
+ return None
195
+
196
+
197
+ async def save_settings(settings: dict) -> bool:
198
+ if not is_database_enabled():
199
+ return False
200
+ try:
201
+ await db_set("settings", settings)
202
+ logger.info("[STORAGE] Settings saved to database")
203
+ return True
204
+ except Exception as e:
205
+ logger.error(f"[STORAGE] Settings write failed: {e}")
206
+ return False
207
+
208
+
209
+ # ==================== Stats storage ====================
210
+
211
+ async def load_stats() -> Optional[dict]:
212
+ if not is_database_enabled():
213
+ return None
214
+ try:
215
+ return await db_get("stats")
216
+ except Exception as e:
217
+ logger.error(f"[STORAGE] Stats read failed: {e}")
218
+ return None
219
+
220
+
221
+ async def save_stats(stats: dict) -> bool:
222
+ if not is_database_enabled():
223
+ return False
224
+ try:
225
+ await db_set("stats", stats)
226
+ return True
227
+ except Exception as e:
228
+ logger.error(f"[STORAGE] Stats write failed: {e}")
229
+ return False
230
+
231
+
232
+ def load_settings_sync() -> Optional[dict]:
233
+ return _run_in_db_loop(load_settings())
234
+
235
+
236
+ def save_settings_sync(settings: dict) -> bool:
237
+ return _run_in_db_loop(save_settings(settings))
238
+
239
+
240
+ def load_stats_sync() -> Optional[dict]:
241
+ return _run_in_db_loop(load_stats())
242
+
243
+
244
+ def save_stats_sync(stats: dict) -> bool:
245
+ return _run_in_db_loop(save_stats(stats))