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

@@ -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)