diff --git a/auth_system/__init__.py b/auth_system/__init__.py index 33d4df5..13d1286 100644 --- a/auth_system/__init__.py +++ b/auth_system/__init__.py @@ -13,6 +13,14 @@ # 兼容门面:实现已按职责拆分到同包各子模块,此处原样再导出。 # 用 `__all__` 声明再导出(ruff F401 与 pyright 均据此认定「有意再导出」), # 使既有调用点(auth_system.xxx / from auth_system import Y)零改动。 +# +# ⚠️ 路径常量(DATA_DIR / DB_PATH / BACKUP_DIR / SECRET_KEY_PATH)在此只是 +# **import 时的值快照**:它们是再导出的绑定,运行时改 auth_system.DB_PATH +# 不会影响 db / backup 的实际读写路径(那只会静默失效,且看起来"改成功了")。 +# 一律不要经由包属性做重定向: +# - 测试:patch `auth_system.db.`(唯一来源,db 与 backup 都随动) +# - 部署:设置环境变量 SITE_DATA_DIR(须在进程启动前) +# 此处保留再导出仅为不破坏既有 `auth_system.DB_PATH` 读取方(如黄金测试)。 from .backup import ( _BACKUP_ERR_TS, diff --git a/auth_system/backup.py b/auth_system/backup.py index 6f2f725..2569e37 100644 --- a/auth_system/backup.py +++ b/auth_system/backup.py @@ -14,13 +14,8 @@ import time from pathlib import Path from typing import Any -import auth_system # 路径常量以包属性为唯一来源(动态读取,便于测试/运维覆盖) +import auth_system.db as db # 绝对导入子模块:不经过包属性,避免与包 __init__ 互指成环 -from .db import ( - _DB_LOCK, - get_conn, - init_db, -) from .settings import ( get_setting, set_setting, @@ -39,17 +34,17 @@ def create_backup() -> str: WAL 模式下用独立只读连接走 SQLite 在线备份 API:拿到的是一致性快照, 且不持有 _DB_LOCK——原实现备份期间阻塞全站写请求,大库时会卡住所有用户。""" - auth_system.BACKUP_DIR.mkdir(parents=True, exist_ok=True) + db.BACKUP_DIR.mkdir(parents=True, exist_ok=True) with _BACKUP_NAME_LOCK: base = 'backup_' + time.strftime('%Y%m%d_%H%M%S') name = base + '.db' k = 1 - while (auth_system.BACKUP_DIR / name).exists(): + while (db.BACKUP_DIR / name).exists(): name = f'{base}-{k}.db' k += 1 # 占位创建目标文件(锁内),保证并发下同秒命名仍唯一 - dest = sqlite3.connect(str(auth_system.BACKUP_DIR / name)) - conn = get_conn() + dest = sqlite3.connect(str(db.BACKUP_DIR / name)) + conn = db.get_conn() try: try: conn.backup(dest) @@ -64,7 +59,7 @@ def create_backup() -> str: except Exception: # 备份失败:清掉占位文件,不留半个坏备份 try: - (auth_system.BACKUP_DIR / name).unlink(missing_ok=True) + (db.BACKUP_DIR / name).unlink(missing_ok=True) except OSError: pass raise @@ -74,10 +69,10 @@ def create_backup() -> str: def list_backups() -> list[dict[str, Any]]: """备份列表(按文件修改时间倒序——按名字排序会让 upload_ 永远排前,误删新自动备份)""" - if not auth_system.BACKUP_DIR.exists(): + if not db.BACKUP_DIR.exists(): return [] out = [] - for p in auth_system.BACKUP_DIR.glob('*.db'): + for p in db.BACKUP_DIR.glob('*.db'): st = p.stat() out.append({'name': p.name, 'size': st.st_size, 'time': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(st.st_mtime)), @@ -90,7 +85,7 @@ def list_backups() -> list[dict[str, Any]]: def backup_path(name: str) -> Path | None: name = Path(name).name # 防目录穿越 - p = auth_system.BACKUP_DIR / name + p = db.BACKUP_DIR / name return p if p.exists() and p.suffix == '.db' else None @@ -132,11 +127,11 @@ def restore_backup(name: str) -> None: _before_restore_hook() except Exception: pass - safety = auth_system.BACKUP_DIR / ('pre_restore_' + time.strftime('%Y%m%d_%H%M%S') + '.db') - with _DB_LOCK: + safety = db.BACKUP_DIR / ('pre_restore_' + time.strftime('%Y%m%d_%H%M%S') + '.db') + with db._DB_LOCK: # 0) 先尽力清 WAL 并合并进主库(被占用导致 checkpoint 忙时明确失败,不假装成功); # 必须在留安全副本之前做——否则副本缺 WAL 里未合并的最近提交,回滚点不完整 - conn = get_conn() + conn = db.get_conn() try: row = conn.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchone() busy = bool(row and row[0] == 1) # 1 = busy @@ -145,18 +140,18 @@ def restore_backup(name: str) -> None: if busy: raise RuntimeError('数据库当前有其他读写在途(checkpoint 忙),请稍后重试恢复') # 1) checkpoint 后留安全副本(包含全部已提交数据,可回滚) - shutil.copy2(str(auth_system.DB_PATH), str(safety)) + shutil.copy2(str(db.DB_PATH), str(safety)) # 2) 源备份复制到临时文件(不动源备份本身) - tmp = auth_system.DB_PATH.with_suffix('.db.restore_tmp') + tmp = db.DB_PATH.with_suffix('.db.restore_tmp') shutil.copy2(str(src), str(tmp)) # 3) 原子替换 + 立即校验 - os.replace(str(tmp), str(auth_system.DB_PATH)) + os.replace(str(tmp), str(db.DB_PATH)) try: - init_db() + db.init_db() except Exception: # 替换后初始化失败:回滚安全副本,恢复原库 - shutil.copy2(str(safety), str(auth_system.DB_PATH)) - init_db() + shutil.copy2(str(safety), str(db.DB_PATH)) + db.init_db() raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None # 裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写 try: @@ -195,7 +190,7 @@ def prune_backups(keep_normal: int = 20, keep_pre_restore: int = 5) -> None: pre = [b for b in list_backups() if b['name'].startswith('pre_restore_')] for b in pre[keep_pre_restore:]: try: - (auth_system.BACKUP_DIR / b['name']).unlink() + (db.BACKUP_DIR / b['name']).unlink() except OSError: pass diff --git a/auth_system/db.py b/auth_system/db.py index bf91867..1754d32 100644 --- a/auth_system/db.py +++ b/auth_system/db.py @@ -12,8 +12,6 @@ import threading import time from pathlib import Path -import auth_system # 路径常量以包属性为唯一来源(动态读取,便于测试/运维覆盖) - from .security import ( _hash_new, _normalize_email, @@ -46,8 +44,8 @@ _DB_LOCK = threading.RLock() def get_conn() -> sqlite3.Connection: - auth_system.DATA_DIR.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(auth_system.DB_PATH), timeout=15) + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH), timeout=15) conn.row_factory = sqlite3.Row conn.execute('PRAGMA journal_mode=WAL') return conn @@ -81,7 +79,7 @@ def _x(sql: str, params: tuple = ()) -> int: def init_db() -> None: """初始化表结构与默认数据""" - auth_system.DATA_DIR.mkdir(parents=True, exist_ok=True) + DATA_DIR.mkdir(parents=True, exist_ok=True) with _DB_LOCK: conn = get_conn() try: @@ -227,21 +225,21 @@ def get_secret_key() -> str: """获取(或生成)持久化的会话签名密钥(原子写入 + 失败显式告警)""" key = '' try: - if auth_system.SECRET_KEY_PATH.exists(): - key = auth_system.SECRET_KEY_PATH.read_text(encoding='utf-8').strip() + if SECRET_KEY_PATH.exists(): + key = SECRET_KEY_PATH.read_text(encoding='utf-8').strip() if key: return key except OSError: pass new_key = secrets.token_hex(32) try: - auth_system.SECRET_KEY_PATH.parent.mkdir(parents=True, exist_ok=True) + SECRET_KEY_PATH.parent.mkdir(parents=True, exist_ok=True) # 先写临时文件再原子替换,避免部分写入留下损坏密钥 - tmp = auth_system.SECRET_KEY_PATH.with_suffix('.key.tmp') + tmp = SECRET_KEY_PATH.with_suffix('.key.tmp') tmp.write_text(new_key, encoding='utf-8') - os.replace(str(tmp), str(auth_system.SECRET_KEY_PATH)) + os.replace(str(tmp), str(SECRET_KEY_PATH)) # 并发初始化者以"替换后文件里的值"为准(最后写入者胜出但全员一致) - final = auth_system.SECRET_KEY_PATH.read_text(encoding='utf-8').strip() + final = SECRET_KEY_PATH.read_text(encoding='utf-8').strip() return final or new_key except OSError as e: # 写入失败不能装作成功:显著告警,返回临时值(本进程会话可用,重启后失效) diff --git a/tests/test_admin_backup_upload.py b/tests/test_admin_backup_upload.py index 26f38f7..512561b 100644 --- a/tests/test_admin_backup_upload.py +++ b/tests/test_admin_backup_upload.py @@ -29,10 +29,10 @@ def client(tmp_path, monkeypatch): import auth_system as auth import core.db as db - monkeypatch.setattr(auth, 'DATA_DIR', tmp_path) - monkeypatch.setattr(auth, 'DB_PATH', tmp_path / 'site.db') - monkeypatch.setattr(auth, 'BACKUP_DIR', tmp_path / 'backups') - monkeypatch.setattr(auth, 'SECRET_KEY_PATH', tmp_path / 'secret.key') + monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth.db, 'DB_PATH', tmp_path / 'site.db') + monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups') + monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'site.db') auth.init_db() diff --git a/tests/test_auth_db_concurrency.py b/tests/test_auth_db_concurrency.py index 39213c2..8d3823d 100644 --- a/tests/test_auth_db_concurrency.py +++ b/tests/test_auth_db_concurrency.py @@ -11,10 +11,10 @@ import auth_system @pytest.fixture() def temp_site(tmp_path, monkeypatch): - monkeypatch.setattr(auth_system, 'DATA_DIR', tmp_path) - monkeypatch.setattr(auth_system, 'DB_PATH', tmp_path / 'site.db') - monkeypatch.setattr(auth_system, 'BACKUP_DIR', tmp_path / 'backups') - monkeypatch.setattr(auth_system, 'SECRET_KEY_PATH', tmp_path / 'secret.key') + monkeypatch.setattr(auth_system.db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth_system.db, 'DB_PATH', tmp_path / 'site.db') + monkeypatch.setattr(auth_system.db, 'BACKUP_DIR', tmp_path / 'backups') + monkeypatch.setattr(auth_system.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') auth_system.init_db() return tmp_path / 'site.db' diff --git a/tests/test_auth_routes.py b/tests/test_auth_routes.py index 465308e..4241219 100644 --- a/tests/test_auth_routes.py +++ b/tests/test_auth_routes.py @@ -20,10 +20,10 @@ ADMIN_PW = 'AdminPass123' def app_env(tmp_path, monkeypatch): """沙箱站点库 + 独立密钥,隔离生产数据。""" db_path = tmp_path / 'site.db' - monkeypatch.setattr(auth, 'DATA_DIR', tmp_path) - monkeypatch.setattr(auth, 'DB_PATH', db_path) - monkeypatch.setattr(auth, 'BACKUP_DIR', tmp_path / 'backups') - monkeypatch.setattr(auth, 'SECRET_KEY_PATH', tmp_path / 'secret.key') + monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth.db, 'DB_PATH', db_path) + monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups') + monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', db_path) auth.init_db() diff --git a/tests/test_download_billing.py b/tests/test_download_billing.py index 7500534..adc12c1 100644 --- a/tests/test_download_billing.py +++ b/tests/test_download_billing.py @@ -21,10 +21,10 @@ PW = 'Passw0rd12' @pytest.fixture() def env(tmp_path, monkeypatch): db_path = tmp_path / 'site.db' - monkeypatch.setattr(auth, 'DATA_DIR', tmp_path) - monkeypatch.setattr(auth, 'DB_PATH', db_path) - monkeypatch.setattr(auth, 'BACKUP_DIR', tmp_path / 'backups') - monkeypatch.setattr(auth, 'SECRET_KEY_PATH', tmp_path / 'secret.key') + monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth.db, 'DB_PATH', db_path) + monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups') + monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', db_path) auth.init_db() diff --git a/tests/test_golden.py b/tests/test_golden.py index bd5c08f..b8fae68 100644 --- a/tests/test_golden.py +++ b/tests/test_golden.py @@ -41,7 +41,7 @@ def _session_for(role): 读的是 `auth_system.DB_PATH`(而非写死 'data/site.db')——fixture 已把它 指向临时副本,这样造 Cookie 与请求命中的是同一个库。 """ - conn = sqlite3.connect(str(auth_system.DB_PATH)) + conn = sqlite3.connect(str(auth_system.db.DB_PATH)) conn.row_factory = sqlite3.Row try: where = "role = 'admin'" if role == 'admin' else "role != 'admin' AND status = 1" @@ -76,8 +76,8 @@ def client(tmp_path, monkeypatch): # 把 users.last_active 顶到测试时刻)。改成副本后契约保真度不变(同一份数据), # 写入全部落在 tmp_path。 _copy_db(Path('data/site.db'), tmp_path / 'site.db') - monkeypatch.setattr(auth_system, 'DATA_DIR', tmp_path) - monkeypatch.setattr(auth_system, 'DB_PATH', tmp_path / 'site.db') + monkeypatch.setattr(auth_system.db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth_system.db, 'DB_PATH', tmp_path / 'site.db') monkeypatch.setattr(core_db, 'DATA_DIR', tmp_path) monkeypatch.setattr(core_db, 'DB_PATH', tmp_path / 'site.db') monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) diff --git a/tests/test_register.py b/tests/test_register.py index 98753de..09dd733 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -22,10 +22,10 @@ SECRET = 'register-test-secret' @pytest.fixture() def env(tmp_path, monkeypatch): db_path = tmp_path / 'site.db' - monkeypatch.setattr(auth, 'DATA_DIR', tmp_path) - monkeypatch.setattr(auth, 'DB_PATH', db_path) - monkeypatch.setattr(auth, 'BACKUP_DIR', tmp_path / 'backups') - monkeypatch.setattr(auth, 'SECRET_KEY_PATH', tmp_path / 'secret.key') + monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth.db, 'DB_PATH', db_path) + monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups') + monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', db_path) auth.init_db() diff --git a/tests/test_root_mode.py b/tests/test_root_mode.py index 29e010a..2936347 100644 --- a/tests/test_root_mode.py +++ b/tests/test_root_mode.py @@ -35,10 +35,10 @@ def root_client(tmp_path, monkeypatch): import auth_system as auth import core.db as db - monkeypatch.setattr(auth, 'DATA_DIR', tmp_path) - monkeypatch.setattr(auth, 'DB_PATH', tmp_path / 'site.db') - monkeypatch.setattr(auth, 'BACKUP_DIR', tmp_path / 'backups') - monkeypatch.setattr(auth, 'SECRET_KEY_PATH', tmp_path / 'secret.key') + monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth.db, 'DB_PATH', tmp_path / 'site.db') + monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups') + monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'site.db') auth.init_db() diff --git a/web/routers/admin_site.py b/web/routers/admin_site.py index e3002a6..4e43c9a 100644 --- a/web/routers/admin_site.py +++ b/web/routers/admin_site.py @@ -444,7 +444,7 @@ async def backup_list(_admin: AdminUser): interval = await asyncio.to_thread(auth.get_setting, 'backup_interval_hours', '0') def _size() -> int: - return auth.DB_PATH.stat().st_size if auth.DB_PATH.exists() else 0 + return auth.db.DB_PATH.stat().st_size if auth.db.DB_PATH.exists() else 0 return success({'backups': backups, 'interval_hours': interval, 'db_size': await asyncio.to_thread(_size)}) @@ -474,14 +474,14 @@ async def backup_upload(admin: AdminUser, request: Request): return error('请选择数据库文件') if not f.filename.endswith('.db'): return error('仅支持 .db 文件') - auth.BACKUP_DIR.mkdir(parents=True, exist_ok=True) + auth.db.BACKUP_DIR.mkdir(parents=True, exist_ok=True) base = 'upload_' + time.strftime('%Y%m%d_%H%M%S') name = base + '.db' k = 1 - while (auth.BACKUP_DIR / name).exists(): + while (auth.db.BACKUP_DIR / name).exists(): name = f'{base}-{k}.db' k += 1 - dest = auth.BACKUP_DIR / name + dest = auth.db.BACKUP_DIR / name content = await f.read() await asyncio.to_thread(dest.write_bytes, content) with dest.open('rb') as hf: