200 lines
6.5 KiB
Python
200 lines
6.5 KiB
Python
|
|
"""SQLite 连接管理 (T6.1).
|
|||
|
|
|
|||
|
|
提供:
|
|||
|
|
- get_connection(db_path) : 打开 SQLite(启用外键 + WAL)
|
|||
|
|
- ensure_schema(db_path) : 幂等创建 admin_users 表
|
|||
|
|
- bootstrap_admin(...) : 启动时若无账户则自动创建默认管理员
|
|||
|
|
- AdminUser / AdminUserRepo : dataclass + 简单 DAO
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import sqlite3
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
from admin.config import Settings
|
|||
|
|
from admin.password import hash_password, verify_password
|
|||
|
|
|
|||
|
|
|
|||
|
|
def utc_now_iso() -> str:
|
|||
|
|
"""当前 UTC 时间(ISO8601 秒精度)。"""
|
|||
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_connection(db_path: str) -> sqlite3.Connection:
|
|||
|
|
"""打开 SQLite 连接。
|
|||
|
|
|
|||
|
|
启用外键约束;row_factory = Row(便于列名访问)。
|
|||
|
|
默认路径会创建父目录。
|
|||
|
|
"""
|
|||
|
|
path = Path(db_path)
|
|||
|
|
if path != Path(":memory:") and str(db_path) != ":memory:":
|
|||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
|||
|
|
conn.row_factory = sqlite3.Row
|
|||
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|||
|
|
# WAL 仅对真实文件有效(:memory: 会忽略)
|
|||
|
|
if str(db_path) != ":memory:":
|
|||
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|||
|
|
return conn
|
|||
|
|
|
|||
|
|
|
|||
|
|
ADMIN_USERS_DDL = """
|
|||
|
|
CREATE TABLE IF NOT EXISTS admin_users (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
username TEXT UNIQUE NOT NULL,
|
|||
|
|
password_hash TEXT NOT NULL,
|
|||
|
|
role TEXT NOT NULL DEFAULT 'admin',
|
|||
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|||
|
|
created_at TEXT NOT NULL,
|
|||
|
|
last_login_at TEXT
|
|||
|
|
);
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username);
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def ensure_schema(db_path: str) -> None:
|
|||
|
|
"""幂等创建 admin_users 表。"""
|
|||
|
|
with get_connection(db_path) as conn:
|
|||
|
|
conn.executescript(ADMIN_USERS_DDL)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class AdminUser:
|
|||
|
|
"""管理员用户(不可变视图)。"""
|
|||
|
|
|
|||
|
|
id: int
|
|||
|
|
username: str
|
|||
|
|
role: str
|
|||
|
|
is_active: bool
|
|||
|
|
created_at: str
|
|||
|
|
last_login_at: Optional[str] = None
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_row(cls, row: sqlite3.Row) -> "AdminUser":
|
|||
|
|
return cls(
|
|||
|
|
id=row["id"],
|
|||
|
|
username=row["username"],
|
|||
|
|
role=row["role"],
|
|||
|
|
is_active=bool(row["is_active"]),
|
|||
|
|
created_at=row["created_at"],
|
|||
|
|
last_login_at=row["last_login_at"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def to_public_dict(self) -> dict:
|
|||
|
|
"""公开字段(不含密码)。"""
|
|||
|
|
return {
|
|||
|
|
"id": self.id,
|
|||
|
|
"username": self.username,
|
|||
|
|
"role": self.role,
|
|||
|
|
"is_active": self.is_active,
|
|||
|
|
"created_at": self.created_at,
|
|||
|
|
"last_login_at": self.last_login_at,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AdminUserRepo:
|
|||
|
|
"""AdminUser 仓库(基于 sqlite3)。"""
|
|||
|
|
|
|||
|
|
def __init__(self, db_path: str):
|
|||
|
|
self.db_path = db_path
|
|||
|
|
|
|||
|
|
def _conn(self) -> sqlite3.Connection:
|
|||
|
|
return get_connection(self.db_path)
|
|||
|
|
|
|||
|
|
def get_by_username(self, username: str) -> Optional[tuple[AdminUser, str]]:
|
|||
|
|
"""按用户名查找,返回 (AdminUser, password_hash) 或 None。"""
|
|||
|
|
with self._conn() as conn:
|
|||
|
|
row = conn.execute(
|
|||
|
|
"SELECT id, username, password_hash, role, is_active, "
|
|||
|
|
"created_at, last_login_at FROM admin_users WHERE username = ?",
|
|||
|
|
(username,),
|
|||
|
|
).fetchone()
|
|||
|
|
if row is None:
|
|||
|
|
return None
|
|||
|
|
return AdminUser.from_row(row), row["password_hash"]
|
|||
|
|
|
|||
|
|
def get_by_id(self, user_id: int) -> Optional[AdminUser]:
|
|||
|
|
"""按 ID 查找(不含密码)。"""
|
|||
|
|
with self._conn() as conn:
|
|||
|
|
row = conn.execute(
|
|||
|
|
"SELECT id, username, role, is_active, created_at, last_login_at "
|
|||
|
|
"FROM admin_users WHERE id = ?",
|
|||
|
|
(user_id,),
|
|||
|
|
).fetchone()
|
|||
|
|
return AdminUser.from_row(row) if row else None
|
|||
|
|
|
|||
|
|
def create(self, username: str, password: str, role: str = "admin") -> AdminUser:
|
|||
|
|
"""创建管理员。重复 username 抛 sqlite3.IntegrityError。"""
|
|||
|
|
now = utc_now_iso()
|
|||
|
|
password_hash = hash_password(password)
|
|||
|
|
with self._conn() as conn:
|
|||
|
|
cur = conn.execute(
|
|||
|
|
"INSERT INTO admin_users(username, password_hash, role, "
|
|||
|
|
"is_active, created_at) VALUES(?, ?, ?, 1, ?)",
|
|||
|
|
(username, password_hash, role, now),
|
|||
|
|
)
|
|||
|
|
user_id = cur.lastrowid or 0
|
|||
|
|
if user_id <= 0: # pragma: no cover - 数据库异常兜底
|
|||
|
|
raise RuntimeError("failed to obtain lastrowid for new admin user")
|
|||
|
|
return AdminUser(
|
|||
|
|
id=user_id,
|
|||
|
|
username=username,
|
|||
|
|
role=role,
|
|||
|
|
is_active=True,
|
|||
|
|
created_at=now,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def count(self) -> int:
|
|||
|
|
"""当前账户总数。"""
|
|||
|
|
with self._conn() as conn:
|
|||
|
|
row = conn.execute("SELECT COUNT(*) AS n FROM admin_users").fetchone()
|
|||
|
|
return int(row["n"])
|
|||
|
|
|
|||
|
|
def update_last_login(self, user_id: int) -> None:
|
|||
|
|
"""更新最后登录时间。"""
|
|||
|
|
with self._conn() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE admin_users SET last_login_at = ? WHERE id = ?",
|
|||
|
|
(utc_now_iso(), user_id),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def bootstrap_admin(settings: Settings) -> tuple[bool, str]:
|
|||
|
|
"""若 admin_users 为空,自动创建默认管理员。
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(created, message): created=True 表示已创建, message 为提示文本
|
|||
|
|
"""
|
|||
|
|
ensure_schema(settings.db_path)
|
|||
|
|
repo = AdminUserRepo(settings.db_path)
|
|||
|
|
if repo.count() > 0:
|
|||
|
|
return False, "已存在管理员账户,跳过 bootstrap"
|
|||
|
|
user = repo.create(
|
|||
|
|
username=settings.default_admin_username,
|
|||
|
|
password=settings.default_admin_password,
|
|||
|
|
)
|
|||
|
|
return True, (
|
|||
|
|
f"已创建默认管理员: username={user.username} id={user.id} "
|
|||
|
|
f"(请尽快通过 GAOKAO_ADMIN_PASS 覆盖默认密码!)"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def authenticate(
|
|||
|
|
repo: AdminUserRepo, username: str, password: str
|
|||
|
|
) -> Optional[AdminUser]:
|
|||
|
|
"""校验用户名+密码,成功返回 AdminUser 并更新 last_login_at。"""
|
|||
|
|
result = repo.get_by_username(username)
|
|||
|
|
if result is None:
|
|||
|
|
return None
|
|||
|
|
user, password_hash = result
|
|||
|
|
if not user.is_active:
|
|||
|
|
return None
|
|||
|
|
if not verify_password(password, password_hash):
|
|||
|
|
return None
|
|||
|
|
repo.update_last_login(user.id)
|
|||
|
|
return user
|