feat: Add SQLite database support and fixed portable extraction path

- Add SQLite as alternative database for offline/firewall environments
- Add --database-type parameter to build-client.bat (mysql/sqlite)
- Refactor database.py to support both MySQL and SQLite
- Add DB_TYPE and SQLITE_PATH configuration options
- Set fixed unpackDirName for portable exe (Meeting-Assistant)
- Update DEPLOYMENT.md with SQLite mode documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-17 13:57:02 +08:00
parent 34947f6262
commit 01d578c67e
7 changed files with 456 additions and 81 deletions

View File

@@ -21,6 +21,8 @@ class Settings:
BACKEND_PORT: int = int(os.getenv("BACKEND_PORT", "8000"))
# Database Configuration
DB_TYPE: str = os.getenv("DB_TYPE", "mysql") # "mysql" or "sqlite"
SQLITE_PATH: str = os.getenv("SQLITE_PATH", "data/meeting.db")
DB_HOST: str = os.getenv("DB_HOST", "mysql.theaken.com")
DB_PORT: int = int(os.getenv("DB_PORT", "33306"))
DB_USER: str = os.getenv("DB_USER", "A060")
@@ -90,6 +92,21 @@ class Settings:
return os.path.join(base_dir, self.RECORD_DIR)
return os.path.join(base_dir, "record")
def get_sqlite_path(self, base_dir: str | None = None) -> str:
"""Get SQLite database file path, resolving relative paths.
Args:
base_dir: Base directory for relative paths. If None, uses get_base_dir()
which supports frozen executables.
"""
if base_dir is None:
base_dir = get_base_dir()
if self.SQLITE_PATH:
if os.path.isabs(self.SQLITE_PATH):
return self.SQLITE_PATH
return os.path.join(base_dir, self.SQLITE_PATH)
return os.path.join(base_dir, "data", "meeting.db")
# Timeout helpers (convert ms to seconds for httpx)
@property
def upload_timeout_seconds(self) -> float:

View File

@@ -1,14 +1,59 @@
"""
Database abstraction layer supporting both MySQL and SQLite.
Usage:
from app.database import init_db, get_db_cursor, init_tables
# At application startup
init_db()
init_tables()
# In request handlers
with get_db_cursor() as cursor:
cursor.execute("SELECT * FROM meeting_records")
results = cursor.fetchall()
with get_db_cursor(commit=True) as cursor:
cursor.execute("INSERT INTO ...")
"""
import os
import sqlite3
import threading
from contextlib import contextmanager
import mysql.connector
from mysql.connector import pooling
from contextlib import contextmanager
from .config import settings
connection_pool = None
# Global state
_db_type: str = "mysql"
_mysql_pool = None
_sqlite_conn = None
_sqlite_lock = threading.Lock()
def init_db_pool():
global connection_pool
connection_pool = pooling.MySQLConnectionPool(
# ============================================================================
# Initialization Functions
# ============================================================================
def init_db():
"""Initialize database based on DB_TYPE setting."""
global _db_type
_db_type = settings.DB_TYPE.lower()
if _db_type == "sqlite":
init_sqlite()
else:
init_mysql()
def init_mysql():
"""Initialize MySQL connection pool."""
global _mysql_pool
_mysql_pool = pooling.MySQLConnectionPool(
pool_name="meeting_pool",
pool_size=settings.DB_POOL_SIZE,
host=settings.DB_HOST,
@@ -17,80 +62,250 @@ def init_db_pool():
password=settings.DB_PASS,
database=settings.DB_NAME,
)
return connection_pool
return _mysql_pool
def init_sqlite():
"""Initialize SQLite connection with row_factory for dict-like access."""
global _sqlite_conn
db_path = settings.get_sqlite_path()
db_dir = os.path.dirname(db_path)
# Create directory if needed
if db_dir and not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True)
_sqlite_conn = sqlite3.connect(db_path, check_same_thread=False)
_sqlite_conn.row_factory = sqlite3.Row
_sqlite_conn.execute("PRAGMA foreign_keys=ON")
print(f"SQLite database initialized at: {db_path}", flush=True)
return _sqlite_conn
# ============================================================================
# Legacy Compatibility
# ============================================================================
def init_db_pool():
"""Legacy function for backward compatibility. Use init_db() instead."""
return init_db()
# ============================================================================
# Connection Context Managers
# ============================================================================
@contextmanager
def get_db_connection():
conn = connection_pool.get_connection()
try:
yield conn
finally:
conn.close()
"""Get a database connection (MySQL or SQLite)."""
if _db_type == "sqlite":
# SQLite uses a single connection with thread lock
yield _sqlite_conn
else:
# MySQL uses connection pool
conn = _mysql_pool.get_connection()
try:
yield conn
finally:
conn.close()
class SQLiteCursorWrapper:
"""Wrapper to make SQLite cursor behave more like MySQL cursor with dictionary=True."""
def __init__(self, cursor):
self._cursor = cursor
self.lastrowid = None
self.rowcount = 0
def execute(self, query, params=None):
# Convert MySQL-style %s placeholders to SQLite ? placeholders
query = query.replace("%s", "?")
if params:
self._cursor.execute(query, params)
else:
self._cursor.execute(query)
self.lastrowid = self._cursor.lastrowid
self.rowcount = self._cursor.rowcount
def executemany(self, query, params_list):
query = query.replace("%s", "?")
self._cursor.executemany(query, params_list)
self.lastrowid = self._cursor.lastrowid
self.rowcount = self._cursor.rowcount
def fetchone(self):
row = self._cursor.fetchone()
if row is None:
return None
return dict(row)
def fetchall(self):
rows = self._cursor.fetchall()
return [dict(row) for row in rows]
def fetchmany(self, size=None):
if size:
rows = self._cursor.fetchmany(size)
else:
rows = self._cursor.fetchmany()
return [dict(row) for row in rows]
def close(self):
self._cursor.close()
@contextmanager
def get_db_cursor(commit=False):
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
try:
yield cursor
if commit:
conn.commit()
finally:
cursor.close()
"""Get a database cursor that returns dict-like rows.
Args:
commit: If True, commit the transaction after yield.
Yields:
cursor: A cursor that returns dict-like rows.
"""
if _db_type == "sqlite":
with _sqlite_lock:
cursor = SQLiteCursorWrapper(_sqlite_conn.cursor())
try:
yield cursor
if commit:
_sqlite_conn.commit()
except Exception:
_sqlite_conn.rollback()
raise
finally:
cursor.close()
else:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
try:
yield cursor
if commit:
conn.commit()
finally:
cursor.close()
# ============================================================================
# Table Initialization
# ============================================================================
MYSQL_TABLES = [
"""
CREATE TABLE IF NOT EXISTS meeting_users (
user_id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(100) UNIQUE NOT NULL,
display_name VARCHAR(50),
role ENUM('admin', 'user') DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_records (
meeting_id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(64) UNIQUE,
subject VARCHAR(200) NOT NULL,
meeting_time DATETIME NOT NULL,
location VARCHAR(100),
chairperson VARCHAR(50),
recorder VARCHAR(50),
attendees TEXT,
transcript_blob LONGTEXT,
created_by VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_conclusions (
conclusion_id INT PRIMARY KEY AUTO_INCREMENT,
meeting_id INT,
content TEXT,
system_code VARCHAR(20),
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_action_items (
action_id INT PRIMARY KEY AUTO_INCREMENT,
meeting_id INT,
content TEXT,
owner VARCHAR(50),
due_date DATE,
status ENUM('Open', 'In Progress', 'Done', 'Delayed') DEFAULT 'Open',
system_code VARCHAR(20),
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
)
""",
]
SQLITE_TABLES = [
"""
CREATE TABLE IF NOT EXISTS meeting_users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
display_name TEXT,
role TEXT CHECK(role IN ('admin', 'user')) DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_records (
meeting_id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE,
subject TEXT NOT NULL,
meeting_time DATETIME NOT NULL,
location TEXT,
chairperson TEXT,
recorder TEXT,
attendees TEXT,
transcript_blob TEXT,
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_conclusions (
conclusion_id INTEGER PRIMARY KEY AUTOINCREMENT,
meeting_id INTEGER,
content TEXT,
system_code TEXT,
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_action_items (
action_id INTEGER PRIMARY KEY AUTOINCREMENT,
meeting_id INTEGER,
content TEXT,
owner TEXT,
due_date DATE,
status TEXT CHECK(status IN ('Open', 'In Progress', 'Done', 'Delayed')) DEFAULT 'Open',
system_code TEXT,
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
)
""",
]
def init_tables():
"""Create all required tables if they don't exist."""
create_statements = [
"""
CREATE TABLE IF NOT EXISTS meeting_users (
user_id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(100) UNIQUE NOT NULL,
display_name VARCHAR(50),
role ENUM('admin', 'user') DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_records (
meeting_id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(64) UNIQUE,
subject VARCHAR(200) NOT NULL,
meeting_time DATETIME NOT NULL,
location VARCHAR(100),
chairperson VARCHAR(50),
recorder VARCHAR(50),
attendees TEXT,
transcript_blob LONGTEXT,
created_by VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_conclusions (
conclusion_id INT PRIMARY KEY AUTO_INCREMENT,
meeting_id INT,
content TEXT,
system_code VARCHAR(20),
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
)
""",
"""
CREATE TABLE IF NOT EXISTS meeting_action_items (
action_id INT PRIMARY KEY AUTO_INCREMENT,
meeting_id INT,
content TEXT,
owner VARCHAR(50),
due_date DATE,
status ENUM('Open', 'In Progress', 'Done', 'Delayed') DEFAULT 'Open',
system_code VARCHAR(20),
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
)
""",
]
tables = SQLITE_TABLES if _db_type == "sqlite" else MYSQL_TABLES
with get_db_cursor(commit=True) as cursor:
for statement in create_statements:
cursor.execute(statement)
if _db_type == "sqlite":
with _sqlite_lock:
cursor = _sqlite_conn.cursor()
try:
for statement in tables:
cursor.execute(statement)
_sqlite_conn.commit()
finally:
cursor.close()
else:
with get_db_cursor(commit=True) as cursor:
for statement in tables:
cursor.execute(statement)