xiaolongmr commited on
Commit ·
24f4569
1
Parent(s): d94512c
Use persistent SQLite storage for user data
Browse files- .env.example +5 -4
- api/system.py +9 -5
- services/auth_service.py +6 -5
- services/config.py +1 -1
- services/storage/database_storage.py +29 -16
- services/storage/factory.py +82 -4
- services/storage/git_storage.py +63 -31
.env.example
CHANGED
|
@@ -19,8 +19,9 @@ CHATGPT2API_AUTH_KEY=your_secret_key_here
|
|
| 19 |
# ============================================
|
| 20 |
|
| 21 |
# 存储后端类型(可选值: json, sqlite, postgres, git)
|
| 22 |
-
#
|
| 23 |
-
STORAGE_BACKEND
|
|
|
|
| 24 |
|
| 25 |
# ============================================
|
| 26 |
# 数据库配置(当 STORAGE_BACKEND=sqlite/postgres 时使用)
|
|
@@ -32,8 +33,8 @@ STORAGE_BACKEND=json
|
|
| 32 |
# Supabase 示例
|
| 33 |
# DATABASE_URL=postgresql://postgres.xxx:password@aws-1-us-west-1.pooler.supabase.com:5432/postgres
|
| 34 |
|
| 35 |
-
# SQLite 示例(不指定时自动使用
|
| 36 |
-
# DATABASE_URL=sqlite:///app/
|
| 37 |
|
| 38 |
# ============================================
|
| 39 |
# Git 仓库配置(当 STORAGE_BACKEND=git 时使用)
|
|
|
|
| 19 |
# ============================================
|
| 20 |
|
| 21 |
# 存储后端类型(可选值: json, sqlite, postgres, git)
|
| 22 |
+
# 推荐 Hugging Face Storage Bucket 挂载到 /app/persist 后使用 sqlite。
|
| 23 |
+
# 如果没有设置 STORAGE_BACKEND 且 /app/persist 存在,程序会自动使用 /app/persist/accounts.db。
|
| 24 |
+
# STORAGE_BACKEND=sqlite
|
| 25 |
|
| 26 |
# ============================================
|
| 27 |
# 数据库配置(当 STORAGE_BACKEND=sqlite/postgres 时使用)
|
|
|
|
| 33 |
# Supabase 示例
|
| 34 |
# DATABASE_URL=postgresql://postgres.xxx:password@aws-1-us-west-1.pooler.supabase.com:5432/postgres
|
| 35 |
|
| 36 |
+
# SQLite 示例(不指定时自动使用持久化目录 accounts.db)
|
| 37 |
+
# DATABASE_URL=sqlite:////app/persist/accounts.db
|
| 38 |
|
| 39 |
# ============================================
|
| 40 |
# Git 仓库配置(当 STORAGE_BACKEND=git 时使用)
|
api/system.py
CHANGED
|
@@ -90,11 +90,15 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 90 |
if not openid:
|
| 91 |
raise HTTPException(status_code=502, detail={"error": "微信登录服务未返回 openid"})
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
return {
|
| 99 |
"ok": True,
|
| 100 |
"version": app_version,
|
|
|
|
| 90 |
if not openid:
|
| 91 |
raise HTTPException(status_code=502, detail={"error": "微信登录服务未返回 openid"})
|
| 92 |
|
| 93 |
+
try:
|
| 94 |
+
item, raw_key, created = auth_service.get_or_create_wechat_normal_user(
|
| 95 |
+
openid=openid,
|
| 96 |
+
nickname=str(user.get("nickname") or "").strip(),
|
| 97 |
+
avatar=str(user.get("avatar") or "").strip(),
|
| 98 |
+
)
|
| 99 |
+
except Exception as exc:
|
| 100 |
+
print(f"[wechat-login] failed to save user: {exc}")
|
| 101 |
+
raise HTTPException(status_code=503, detail={"error": f"微信登录已验证,但保存用户失败:{exc}"}) from exc
|
| 102 |
return {
|
| 103 |
"ok": True,
|
| 104 |
"version": app_version,
|
services/auth_service.py
CHANGED
|
@@ -80,20 +80,21 @@ class AuthService:
|
|
| 80 |
"wechat_bound_at": self._clean(raw.get("wechat_bound_at")) or None,
|
| 81 |
}
|
| 82 |
|
| 83 |
-
def _load(self) -> list[dict[str, object]]:
|
| 84 |
try:
|
| 85 |
items = self.storage.load_auth_keys()
|
| 86 |
-
except Exception:
|
| 87 |
-
|
|
|
|
| 88 |
if not isinstance(items, list):
|
| 89 |
-
return []
|
| 90 |
return [normalized for item in items if (normalized := self._normalize_item(item)) is not None]
|
| 91 |
|
| 92 |
def _save(self) -> None:
|
| 93 |
self.storage.save_auth_keys(self._items)
|
| 94 |
|
| 95 |
def _reload_locked(self) -> None:
|
| 96 |
-
self._items = self._load()
|
| 97 |
|
| 98 |
@staticmethod
|
| 99 |
def _public_item(item: dict[str, object]) -> dict[str, object]:
|
|
|
|
| 80 |
"wechat_bound_at": self._clean(raw.get("wechat_bound_at")) or None,
|
| 81 |
}
|
| 82 |
|
| 83 |
+
def _load(self, *, fallback: list[dict[str, object]] | None = None) -> list[dict[str, object]]:
|
| 84 |
try:
|
| 85 |
items = self.storage.load_auth_keys()
|
| 86 |
+
except Exception as exc:
|
| 87 |
+
print(f"[auth-service] load auth keys failed: {exc}")
|
| 88 |
+
return [dict(item) for item in (fallback or [])]
|
| 89 |
if not isinstance(items, list):
|
| 90 |
+
return [dict(item) for item in (fallback or [])]
|
| 91 |
return [normalized for item in items if (normalized := self._normalize_item(item)) is not None]
|
| 92 |
|
| 93 |
def _save(self) -> None:
|
| 94 |
self.storage.save_auth_keys(self._items)
|
| 95 |
|
| 96 |
def _reload_locked(self) -> None:
|
| 97 |
+
self._items = self._load(fallback=self._items)
|
| 98 |
|
| 99 |
@staticmethod
|
| 100 |
def _public_item(item: dict[str, object]) -> dict[str, object]:
|
services/config.py
CHANGED
|
@@ -10,7 +10,6 @@ import time
|
|
| 10 |
from services.storage.base import StorageBackend
|
| 11 |
|
| 12 |
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 13 |
-
DATA_DIR = BASE_DIR / "data"
|
| 14 |
PERSIST_DIR = Path(os.getenv("CHATGPT2API_PERSIST_DIR", "").strip() or "/app/persist")
|
| 15 |
|
| 16 |
|
|
@@ -19,6 +18,7 @@ def _env_path(name: str, fallback: Path) -> Path:
|
|
| 19 |
return Path(value) if value else fallback
|
| 20 |
|
| 21 |
|
|
|
|
| 22 |
CONFIG_FILE = _env_path("CHATGPT2API_CONFIG_FILE", PERSIST_DIR / "config" / "config.json" if PERSIST_DIR.exists() else BASE_DIR / "config.json")
|
| 23 |
VERSION_FILE = BASE_DIR / "VERSION"
|
| 24 |
BACKUP_STATE_FILE = DATA_DIR / "backup_state.json"
|
|
|
|
| 10 |
from services.storage.base import StorageBackend
|
| 11 |
|
| 12 |
BASE_DIR = Path(__file__).resolve().parents[1]
|
|
|
|
| 13 |
PERSIST_DIR = Path(os.getenv("CHATGPT2API_PERSIST_DIR", "").strip() or "/app/persist")
|
| 14 |
|
| 15 |
|
|
|
|
| 18 |
return Path(value) if value else fallback
|
| 19 |
|
| 20 |
|
| 21 |
+
DATA_DIR = _env_path("CHATGPT2API_DATA_DIR", PERSIST_DIR if PERSIST_DIR.exists() else BASE_DIR / "data")
|
| 22 |
CONFIG_FILE = _env_path("CHATGPT2API_CONFIG_FILE", PERSIST_DIR / "config" / "config.json" if PERSIST_DIR.exists() else BASE_DIR / "config.json")
|
| 23 |
VERSION_FILE = BASE_DIR / "VERSION"
|
| 24 |
BACKUP_STATE_FILE = DATA_DIR / "backup_state.json"
|
services/storage/database_storage.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
|
|
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
from sqlalchemy import Column, String, Text, create_engine, Integer, text
|
|
@@ -42,13 +44,21 @@ class DatabaseStorageBackend(StorageBackend):
|
|
| 42 |
|
| 43 |
def __init__(self, database_url: str):
|
| 44 |
self.database_url = database_url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
self.engine = create_engine(
|
| 46 |
database_url,
|
| 47 |
pool_pre_ping=True, # 自动检测连接是否有效
|
| 48 |
pool_recycle=3600, # 1小时回收连接
|
|
|
|
| 49 |
)
|
| 50 |
Base.metadata.create_all(self.engine)
|
| 51 |
self.Session = sessionmaker(bind=self.engine)
|
|
|
|
| 52 |
|
| 53 |
def load_accounts(self) -> list[dict[str, Any]]:
|
| 54 |
"""从数据库加载账号数据"""
|
|
@@ -68,7 +78,8 @@ class DatabaseStorageBackend(StorageBackend):
|
|
| 68 |
|
| 69 |
def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
|
| 70 |
"""保存账号数据到数据库"""
|
| 71 |
-
self.
|
|
|
|
| 72 |
|
| 73 |
def load_auth_keys(self) -> list[dict[str, Any]]:
|
| 74 |
"""从数据库加载鉴权密钥数据"""
|
|
@@ -76,7 +87,8 @@ class DatabaseStorageBackend(StorageBackend):
|
|
| 76 |
|
| 77 |
def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
|
| 78 |
"""保存鉴权密钥数据到数据库"""
|
| 79 |
-
self.
|
|
|
|
| 80 |
|
| 81 |
def load_shop_state(self) -> dict[str, Any]:
|
| 82 |
session = self.Session()
|
|
@@ -92,20 +104,21 @@ class DatabaseStorageBackend(StorageBackend):
|
|
| 92 |
session.close()
|
| 93 |
|
| 94 |
def save_shop_state(self, state: dict[str, Any]) -> None:
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
| 109 |
|
| 110 |
def _load_rows(self, model: type[AccountModel] | type[AuthKeyModel]) -> list[dict[str, Any]]:
|
| 111 |
session = self.Session()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from threading import Lock
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
from sqlalchemy import Column, String, Text, create_engine, Integer, text
|
|
|
|
| 44 |
|
| 45 |
def __init__(self, database_url: str):
|
| 46 |
self.database_url = database_url
|
| 47 |
+
connect_args: dict[str, Any] = {}
|
| 48 |
+
if database_url.startswith("sqlite:///"):
|
| 49 |
+
db_path = database_url.removeprefix("sqlite:///")
|
| 50 |
+
if db_path and db_path != ":memory:":
|
| 51 |
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
connect_args["check_same_thread"] = False
|
| 53 |
self.engine = create_engine(
|
| 54 |
database_url,
|
| 55 |
pool_pre_ping=True, # 自动检测连接是否有效
|
| 56 |
pool_recycle=3600, # 1小时回收连接
|
| 57 |
+
connect_args=connect_args,
|
| 58 |
)
|
| 59 |
Base.metadata.create_all(self.engine)
|
| 60 |
self.Session = sessionmaker(bind=self.engine)
|
| 61 |
+
self._lock = Lock()
|
| 62 |
|
| 63 |
def load_accounts(self) -> list[dict[str, Any]]:
|
| 64 |
"""从数据库加载账号数据"""
|
|
|
|
| 78 |
|
| 79 |
def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
|
| 80 |
"""保存账号数据到数据库"""
|
| 81 |
+
with self._lock:
|
| 82 |
+
self._save_rows(AccountModel, accounts, "access_token")
|
| 83 |
|
| 84 |
def load_auth_keys(self) -> list[dict[str, Any]]:
|
| 85 |
"""从数据库加载鉴权密钥数据"""
|
|
|
|
| 87 |
|
| 88 |
def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
|
| 89 |
"""保存鉴权密钥数据到数据库"""
|
| 90 |
+
with self._lock:
|
| 91 |
+
self._save_rows(AuthKeyModel, auth_keys, "id", "key_id")
|
| 92 |
|
| 93 |
def load_shop_state(self) -> dict[str, Any]:
|
| 94 |
session = self.Session()
|
|
|
|
| 104 |
session.close()
|
| 105 |
|
| 106 |
def save_shop_state(self, state: dict[str, Any]) -> None:
|
| 107 |
+
with self._lock:
|
| 108 |
+
session = self.Session()
|
| 109 |
+
try:
|
| 110 |
+
row = session.query(ShopStateModel).filter_by(key="default").first()
|
| 111 |
+
payload = json.dumps(state, ensure_ascii=False)
|
| 112 |
+
if row is None:
|
| 113 |
+
session.add(ShopStateModel(key="default", data=payload))
|
| 114 |
+
else:
|
| 115 |
+
row.data = payload
|
| 116 |
+
session.commit()
|
| 117 |
+
except Exception as e:
|
| 118 |
+
session.rollback()
|
| 119 |
+
raise e
|
| 120 |
+
finally:
|
| 121 |
+
session.close()
|
| 122 |
|
| 123 |
def _load_rows(self, model: type[AccountModel] | type[AuthKeyModel]) -> list[dict[str, Any]]:
|
| 124 |
session = self.Session()
|
services/storage/factory.py
CHANGED
|
@@ -10,6 +10,81 @@ from services.storage.git_storage import GitStorageBackend
|
|
| 10 |
from services.storage.json_storage import JSONStorageBackend
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
def create_storage_backend(data_dir: Path) -> StorageBackend:
|
| 14 |
"""
|
| 15 |
根据环境变量创建存储后端
|
|
@@ -23,7 +98,7 @@ def create_storage_backend(data_dir: Path) -> StorageBackend:
|
|
| 23 |
- GIT_FILE_PATH: Git 仓库中的文件路径 (默认 accounts.json)
|
| 24 |
- GIT_CACHE_DIR: Git 本地缓存目录 (默认系统临时目录)
|
| 25 |
"""
|
| 26 |
-
backend_type =
|
| 27 |
|
| 28 |
print(f"[storage] Initializing storage backend: {backend_type}")
|
| 29 |
|
|
@@ -41,12 +116,15 @@ def create_storage_backend(data_dir: Path) -> StorageBackend:
|
|
| 41 |
|
| 42 |
if not database_url:
|
| 43 |
# 如果没有指定 DATABASE_URL,使用本地 SQLite
|
| 44 |
-
database_url =
|
| 45 |
print(f"[storage] No DATABASE_URL provided, using local SQLite: {database_url}")
|
| 46 |
else:
|
| 47 |
print(f"[storage] Using database storage: {_mask_password(database_url)}")
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
elif backend_type == "git":
|
| 52 |
# Git 仓库存储
|
|
|
|
| 10 |
from services.storage.json_storage import JSONStorageBackend
|
| 11 |
|
| 12 |
|
| 13 |
+
def _default_backend_type(data_dir: Path) -> str:
|
| 14 |
+
if os.getenv("STORAGE_BACKEND") is not None:
|
| 15 |
+
return os.getenv("STORAGE_BACKEND", "json").lower().strip()
|
| 16 |
+
persist_dir = Path(os.getenv("CHATGPT2API_PERSIST_DIR", "").strip() or "/app/persist")
|
| 17 |
+
if persist_dir.exists() or str(data_dir).startswith(str(persist_dir)):
|
| 18 |
+
return "sqlite"
|
| 19 |
+
return "json"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _sqlite_database_url(data_dir: Path) -> str:
|
| 23 |
+
return f"sqlite:///{data_dir / 'accounts.db'}"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _is_database_empty(backend: DatabaseStorageBackend) -> bool:
|
| 27 |
+
return not backend.load_accounts() and not backend.load_auth_keys() and not backend.load_shop_state()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _build_git_backend_from_env() -> GitStorageBackend | None:
|
| 31 |
+
repo_url = os.getenv("GIT_REPO_URL", "").strip()
|
| 32 |
+
token = os.getenv("GIT_TOKEN", "").strip()
|
| 33 |
+
if not repo_url:
|
| 34 |
+
return None
|
| 35 |
+
branch = os.getenv("GIT_BRANCH", "main").strip()
|
| 36 |
+
file_path = os.getenv("GIT_FILE_PATH", "accounts.json").strip()
|
| 37 |
+
auth_keys_file_path = os.getenv("GIT_AUTH_KEYS_FILE_PATH", "auth_keys.json").strip()
|
| 38 |
+
shop_state_file_path = os.getenv("GIT_SHOP_STATE_FILE_PATH", "shop_state.json").strip()
|
| 39 |
+
cache_dir = Path(os.getenv("GIT_CACHE_DIR", "").strip() or (Path(tempfile.gettempdir()) / "chatgpt2api_git_cache"))
|
| 40 |
+
return GitStorageBackend(
|
| 41 |
+
repo_url=repo_url,
|
| 42 |
+
token=token,
|
| 43 |
+
branch=branch,
|
| 44 |
+
file_path=file_path,
|
| 45 |
+
auth_keys_file_path=auth_keys_file_path,
|
| 46 |
+
shop_state_file_path=shop_state_file_path,
|
| 47 |
+
local_cache_dir=cache_dir,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _migrate_git_to_database_if_empty(backend: DatabaseStorageBackend) -> None:
|
| 52 |
+
if os.getenv("CHATGPT2API_SKIP_STORAGE_MIGRATION", "").strip().lower() in {"1", "true", "yes", "on"}:
|
| 53 |
+
return
|
| 54 |
+
try:
|
| 55 |
+
if not _is_database_empty(backend):
|
| 56 |
+
return
|
| 57 |
+
except Exception as exc:
|
| 58 |
+
print(f"[storage] skip migration, database check failed: {exc}")
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
git_backend = _build_git_backend_from_env()
|
| 62 |
+
if git_backend is None:
|
| 63 |
+
return
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
accounts = git_backend.load_accounts()
|
| 67 |
+
auth_keys = git_backend.load_auth_keys()
|
| 68 |
+
shop_state = git_backend.load_shop_state()
|
| 69 |
+
except Exception as exc:
|
| 70 |
+
print(f"[storage] Git to SQLite migration skipped: {exc}")
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
if not accounts and not auth_keys and not shop_state:
|
| 74 |
+
return
|
| 75 |
+
|
| 76 |
+
print(
|
| 77 |
+
"[storage] Migrating Git data to SQLite: "
|
| 78 |
+
f"{len(accounts)} accounts, {len(auth_keys)} auth keys, shop_state={bool(shop_state)}"
|
| 79 |
+
)
|
| 80 |
+
if accounts:
|
| 81 |
+
backend.save_accounts(accounts)
|
| 82 |
+
if auth_keys:
|
| 83 |
+
backend.save_auth_keys(auth_keys)
|
| 84 |
+
if shop_state:
|
| 85 |
+
backend.save_shop_state(shop_state)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
def create_storage_backend(data_dir: Path) -> StorageBackend:
|
| 89 |
"""
|
| 90 |
根据环境变量创建存储后端
|
|
|
|
| 98 |
- GIT_FILE_PATH: Git 仓库中的文件路径 (默认 accounts.json)
|
| 99 |
- GIT_CACHE_DIR: Git 本地缓存目录 (默认系统临时目录)
|
| 100 |
"""
|
| 101 |
+
backend_type = _default_backend_type(data_dir)
|
| 102 |
|
| 103 |
print(f"[storage] Initializing storage backend: {backend_type}")
|
| 104 |
|
|
|
|
| 116 |
|
| 117 |
if not database_url:
|
| 118 |
# 如果没有指定 DATABASE_URL,使用本地 SQLite
|
| 119 |
+
database_url = _sqlite_database_url(data_dir)
|
| 120 |
print(f"[storage] No DATABASE_URL provided, using local SQLite: {database_url}")
|
| 121 |
else:
|
| 122 |
print(f"[storage] Using database storage: {_mask_password(database_url)}")
|
| 123 |
+
|
| 124 |
+
backend = DatabaseStorageBackend(database_url)
|
| 125 |
+
if backend_type in {"sqlite", "database"}:
|
| 126 |
+
_migrate_git_to_database_if_empty(backend)
|
| 127 |
+
return backend
|
| 128 |
|
| 129 |
elif backend_type == "git":
|
| 130 |
# Git 仓库存储
|
services/storage/git_storage.py
CHANGED
|
@@ -4,6 +4,7 @@ import json
|
|
| 4 |
import shutil
|
| 5 |
import tempfile
|
| 6 |
from pathlib import Path
|
|
|
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
from git import Repo
|
|
@@ -31,6 +32,7 @@ class GitStorageBackend(StorageBackend):
|
|
| 31 |
self.file_path = file_path
|
| 32 |
self.auth_keys_file_path = auth_keys_file_path
|
| 33 |
self.shop_state_file_path = shop_state_file_path
|
|
|
|
| 34 |
|
| 35 |
# 本地缓存目录
|
| 36 |
if local_cache_dir is None:
|
|
@@ -61,6 +63,28 @@ class GitStorageBackend(StorageBackend):
|
|
| 61 |
|
| 62 |
return repo_url
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
def _clone_or_pull(self) -> Repo:
|
| 65 |
"""克隆或拉取仓库"""
|
| 66 |
repo_path = self.local_cache_dir / "repo"
|
|
@@ -74,15 +98,14 @@ class GitStorageBackend(StorageBackend):
|
|
| 74 |
return repo
|
| 75 |
except GitCommandError:
|
| 76 |
# 拉取失败,删除重新克隆
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
# 克隆仓库
|
| 80 |
-
|
| 81 |
-
self.auth_repo_url,
|
| 82 |
-
repo_path,
|
| 83 |
-
branch=self.branch,
|
| 84 |
-
)
|
| 85 |
-
return repo
|
| 86 |
|
| 87 |
def load_accounts(self) -> list[dict[str, Any]]:
|
| 88 |
"""从 Git 仓库加载账号数据"""
|
|
@@ -139,31 +162,40 @@ class GitStorageBackend(StorageBackend):
|
|
| 139 |
return data if isinstance(data, list) else []
|
| 140 |
|
| 141 |
def _load_json_value(self, file_path: str) -> Any:
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
| 152 |
|
| 153 |
def _save_json_file(self, file_path: str, items: Any, message: str) -> None:
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
def health_check(self) -> dict[str, Any]:
|
| 169 |
"""健康检查"""
|
|
|
|
| 4 |
import shutil
|
| 5 |
import tempfile
|
| 6 |
from pathlib import Path
|
| 7 |
+
from threading import Lock
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
from git import Repo
|
|
|
|
| 32 |
self.file_path = file_path
|
| 33 |
self.auth_keys_file_path = auth_keys_file_path
|
| 34 |
self.shop_state_file_path = shop_state_file_path
|
| 35 |
+
self._lock = Lock()
|
| 36 |
|
| 37 |
# 本地缓存目录
|
| 38 |
if local_cache_dir is None:
|
|
|
|
| 63 |
|
| 64 |
return repo_url
|
| 65 |
|
| 66 |
+
def _safe_remove_repo(self, repo_path: Path) -> None:
|
| 67 |
+
try:
|
| 68 |
+
if repo_path.exists():
|
| 69 |
+
shutil.rmtree(repo_path)
|
| 70 |
+
except Exception as exc:
|
| 71 |
+
print(f"[git-storage] failed to remove broken cache {repo_path}: {exc}")
|
| 72 |
+
|
| 73 |
+
def _clone_repo(self, repo_path: Path) -> Repo:
|
| 74 |
+
for attempt in range(2):
|
| 75 |
+
try:
|
| 76 |
+
return Repo.clone_from(
|
| 77 |
+
self.auth_repo_url,
|
| 78 |
+
repo_path,
|
| 79 |
+
branch=self.branch,
|
| 80 |
+
)
|
| 81 |
+
except Exception:
|
| 82 |
+
self._safe_remove_repo(repo_path)
|
| 83 |
+
if attempt == 0:
|
| 84 |
+
continue
|
| 85 |
+
raise
|
| 86 |
+
raise RuntimeError("git clone failed")
|
| 87 |
+
|
| 88 |
def _clone_or_pull(self) -> Repo:
|
| 89 |
"""克隆或拉取仓库"""
|
| 90 |
repo_path = self.local_cache_dir / "repo"
|
|
|
|
| 98 |
return repo
|
| 99 |
except GitCommandError:
|
| 100 |
# 拉取失败,删除重新克隆
|
| 101 |
+
self._safe_remove_repo(repo_path)
|
| 102 |
+
except Exception:
|
| 103 |
+
self._safe_remove_repo(repo_path)
|
| 104 |
+
elif repo_path.exists():
|
| 105 |
+
self._safe_remove_repo(repo_path)
|
| 106 |
+
|
| 107 |
# 克隆仓库
|
| 108 |
+
return self._clone_repo(repo_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
def load_accounts(self) -> list[dict[str, Any]]:
|
| 111 |
"""从 Git 仓库加载账号数据"""
|
|
|
|
| 162 |
return data if isinstance(data, list) else []
|
| 163 |
|
| 164 |
def _load_json_value(self, file_path: str) -> Any:
|
| 165 |
+
with self._lock:
|
| 166 |
+
repo = self._clone_or_pull()
|
| 167 |
+
file_full_path = Path(repo.working_dir) / file_path
|
| 168 |
+
if not file_full_path.exists():
|
| 169 |
+
return None
|
| 170 |
+
try:
|
| 171 |
+
content = file_full_path.read_text(encoding="utf-8")
|
| 172 |
+
return json.loads(content)
|
| 173 |
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
| 174 |
+
print(f"[git-storage] Failed to load {file_path}: {e}, returning empty data")
|
| 175 |
+
return None
|
| 176 |
|
| 177 |
def _save_json_file(self, file_path: str, items: Any, message: str) -> None:
|
| 178 |
+
with self._lock:
|
| 179 |
+
payload = json.dumps(items, ensure_ascii=False, indent=2) + "\n"
|
| 180 |
+
for attempt in range(2):
|
| 181 |
+
repo = self._clone_or_pull()
|
| 182 |
+
file_full_path = Path(repo.working_dir) / file_path
|
| 183 |
+
file_full_path.parent.mkdir(parents=True, exist_ok=True)
|
| 184 |
+
file_full_path.write_text(payload, encoding="utf-8")
|
| 185 |
+
repo.index.add([file_path])
|
| 186 |
+
if not repo.is_dirty():
|
| 187 |
+
return
|
| 188 |
+
try:
|
| 189 |
+
# The data repository only stores JSON state; deployment volumes may
|
| 190 |
+
# preserve non-executable sample hooks, so skip hooks for reliability.
|
| 191 |
+
repo.index.commit(message, skip_hooks=True)
|
| 192 |
+
repo.remote("origin").push(self.branch)
|
| 193 |
+
return
|
| 194 |
+
except GitCommandError:
|
| 195 |
+
if attempt == 0:
|
| 196 |
+
self._safe_remove_repo(self.local_cache_dir / "repo")
|
| 197 |
+
continue
|
| 198 |
+
raise
|
| 199 |
|
| 200 |
def health_check(self) -> dict[str, Any]:
|
| 201 |
"""健康检查"""
|