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

@@ -4,7 +4,7 @@
- Python 3.10+
- Node.js 18+
- MySQL 8.0+
- MySQL 8.0+ 或 SQLite本地模式
- Access to Dify LLM service
## Quick Start
@@ -233,18 +233,32 @@ npm start
- 企業內部部署,簡化用戶操作
- 無法獨立架設後端的環境
- 快速測試和演示
- **離線環境**:使用 SQLite 本地資料庫,無需網路連接資料庫
### 打包方式
```batch
# Windows 全包打包
# Windows 全包打包MySQL 雲端資料庫,預設)
.\scripts\build-client.bat build --embedded-backend --clean
# Windows 全包打包SQLite 本地資料庫,適合離線/防火牆環境)
.\scripts\build-client.bat build --embedded-backend --database-type sqlite --clean
```
**打包參數說明:**
| 參數 | 說明 |
|------|------|
| `--embedded-backend` | 啟用內嵌後端模式 |
| `--database-type TYPE` | 資料庫類型:`mysql`(雲端)或 `sqlite`(本地) |
| `--clean` | 建置前清理所有暫存檔案 |
### config.json 配置
全包模式需要在 `config.json` 中配置資料庫和 API 金鑰:
#### MySQL 模式(雲端資料庫)
```json
{
"apiBaseUrl": "http://localhost:8000/api",
@@ -260,6 +274,8 @@ npm start
"host": "127.0.0.1",
"port": 8000,
"database": {
"type": "mysql",
"sqlitePath": "data/meeting.db",
"host": "mysql.theaken.com",
"port": 33306,
"user": "your_username",
@@ -281,6 +297,45 @@ npm start
}
```
#### SQLite 模式(本地資料庫)
適合離線環境或網路防火牆阻擋資料庫連線的情況:
```json
{
"apiBaseUrl": "http://localhost:8000/api",
"uploadTimeout": 600000,
"appTitle": "Meeting Assistant",
"whisper": {
"model": "medium",
"device": "cpu",
"compute": "int8"
},
"backend": {
"embedded": true,
"host": "127.0.0.1",
"port": 8000,
"database": {
"type": "sqlite",
"sqlitePath": "data/meeting.db"
},
"externalApis": {
"authApiUrl": "https://pj-auth-api.vercel.app/api/auth/login",
"difyApiUrl": "https://dify.theaken.com/v1",
"difyApiKey": "app-xxxxxxxxxx",
"difySttApiKey": "app-xxxxxxxxxx"
},
"auth": {
"adminEmail": "admin@example.com",
"jwtSecret": "your_secure_jwt_secret",
"jwtExpireHours": 24
}
}
}
```
> **注意**SQLite 模式下,資料庫檔案位於 `%TEMP%\Meeting-Assistant\resources\backend\backend\data\meeting.db`
### 配置說明
| 區段 | 欄位 | 說明 |
@@ -288,20 +343,40 @@ npm start
| `backend.embedded` | `true`/`false` | 啟用/停用內嵌後端模式 |
| `backend.host` | IP | 後端監聽地址(通常為 127.0.0.1 |
| `backend.port` | 數字 | 後端監聽端口(預設 8000 |
| `backend.database.*` | 各欄位 | MySQL 資料庫連線資訊 |
| `backend.database.type` | `mysql`/`sqlite` | 資料庫類型(預設 mysql |
| `backend.database.sqlitePath` | 路徑 | SQLite 資料庫檔案路徑(相對或絕對) |
| `backend.database.*` | 各欄位 | MySQL 資料庫連線資訊(僅 MySQL 模式需要) |
| `backend.externalApis.*` | 各欄位 | 外部 API 設定認證、Dify |
| `backend.auth.*` | 各欄位 | 認證設定管理員信箱、JWT 金鑰) |
### 資料庫模式比較
| 特性 | MySQL 模式 | SQLite 模式 |
|------|------------|-------------|
| 網路需求 | 需連接遠端資料庫 | 完全離線運作 |
| 資料位置 | 雲端資料庫伺服器 | 本機檔案 |
| 多用戶共享 | ✅ 支援 | ❌ 僅單機使用 |
| 適用場景 | 企業部署、多人共用 | 離線環境、防火牆限制 |
| 資料備份 | 使用資料庫工具 | 複製 `.db` 檔案即可 |
### Portable 執行檔解壓縮位置
Portable exe 執行時會解壓縮到 `%TEMP%\Meeting-Assistant` 資料夾(固定路徑,非隨機資料夾)。
- **優點**Windows Defender 不會每次都提示警告
- **SQLite 資料庫位置**`%TEMP%\Meeting-Assistant\resources\backend\backend\data\meeting.db`
### 啟動流程
1. 用戶雙擊 exe
2. Electron 主程序啟動
3. 讀取 `config.json`
4. 啟動內嵌後端 (FastAPI)
5. 健康檢查等待後端就緒(最多 30 秒)
6. 後端就緒後,載入前端頁面
7. 啟動 Whisper Sidecar
8. 應用就緒
2. 解壓縮到 `%TEMP%\Meeting-Assistant`
3. Electron 主程序啟動
4. 讀取 `config.json`
5. 啟動內嵌後端 (FastAPI)
6. 健康檢查等待後端就緒(最多 30 秒)
7. 後端就緒後,載入前端頁面
8. 啟動 Whisper Sidecar
9. 應用就緒
### 向後相容性
@@ -367,6 +442,8 @@ Copy `sidecar/dist/` to `client/sidecar/` before building Electron app.
## Database Setup
### MySQL 模式
The backend will automatically create tables on first startup. To manually verify:
```sql
@@ -374,7 +451,17 @@ USE your_database;
SHOW TABLES LIKE 'meeting_%';
```
Expected tables:
### SQLite 模式
SQLite 資料庫檔案會在首次啟動時自動建立:
- **開發環境**`backend/data/meeting.db`
- **打包後**`%TEMP%\Meeting-Assistant\resources\backend\backend\data\meeting.db`
**備份方式**:直接複製 `.db` 檔案即可。
### Expected tables
- `meeting_users`
- `meeting_records`
- `meeting_conclusions`

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,20 +62,125 @@ 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()
"""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):
"""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:
@@ -41,9 +191,11 @@ def get_db_cursor(commit=False):
cursor.close()
def init_tables():
"""Create all required tables if they don't exist."""
create_statements = [
# ============================================================================
# Table Initialization
# ============================================================================
MYSQL_TABLES = [
"""
CREATE TABLE IF NOT EXISTS meeting_users (
user_id INT PRIMARY KEY AUTO_INCREMENT,
@@ -89,8 +241,71 @@ def init_tables():
FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE
)
""",
]
]
with get_db_cursor(commit=True) as cursor:
for statement in create_statements:
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."""
tables = SQLITE_TABLES if _db_type == "sqlite" else MYSQL_TABLES
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)

View File

@@ -47,6 +47,10 @@ def apply_config_to_env(config: dict) -> None:
# Database configuration - use direct assignment to ensure config values are used
db_config = backend_config.get("database", {})
if "type" in db_config:
os.environ["DB_TYPE"] = db_config["type"]
if "sqlitePath" in db_config:
os.environ["SQLITE_PATH"] = db_config["sqlitePath"]
if "host" in db_config:
os.environ["DB_HOST"] = db_config["host"]
if "port" in db_config:
@@ -123,11 +127,13 @@ def main():
print(f"DEBUG: Loaded config keys: {list(config.keys())}", flush=True)
backend_config = config.get("backend", {})
db_config = backend_config.get("database", {})
print(f"DEBUG: DB type={db_config.get('type', 'mysql')}", flush=True)
print(f"DEBUG: DB config: host={db_config.get('host')}, user={db_config.get('user')}, pass={'***' if db_config.get('password') else 'EMPTY'}", flush=True)
apply_config_to_env(config)
# Debug: print env vars after setting
print(f"DEBUG: ENV DB_TYPE={os.environ.get('DB_TYPE', 'mysql')}", flush=True)
print(f"DEBUG: ENV DB_HOST={os.environ.get('DB_HOST')}", flush=True)
print(f"DEBUG: ENV DB_USER={os.environ.get('DB_USER')}", flush=True)
print(f"DEBUG: ENV DB_PASS={'***' if os.environ.get('DB_PASS') else 'EMPTY'}", flush=True)

View File

@@ -12,6 +12,8 @@
"host": "127.0.0.1",
"port": 8000,
"database": {
"type": "mysql",
"sqlitePath": "data/meeting.db",
"host": "mysql.theaken.com",
"port": 33306,
"user": "A060",

View File

@@ -72,7 +72,8 @@
"icon": "assets/icon.png"
},
"portable": {
"artifactName": "${productName}-${version}-portable.${ext}"
"artifactName": "${productName}-${version}-portable.${ext}",
"unpackDirName": "Meeting-Assistant"
}
}
}

View File

@@ -27,6 +27,7 @@ set "SKIP_BACKEND=true"
set "EMBEDDED_BACKEND=false"
set "CLEAN_BUILD=false"
set "API_URL="
set "DATABASE_TYPE="
REM 解析參數
set "COMMAND=help"
@@ -42,6 +43,7 @@ if /i "%~1"=="--skip-backend" (set "SKIP_BACKEND=true" & shift & goto :parse_arg
if /i "%~1"=="--embedded-backend" (set "EMBEDDED_BACKEND=true" & set "SKIP_BACKEND=false" & shift & goto :parse_args)
if /i "%~1"=="--clean" (set "CLEAN_BUILD=true" & shift & goto :parse_args)
if /i "%~1"=="--api-url" (set "API_URL=%~2" & shift & shift & goto :parse_args)
if /i "%~1"=="--database-type" (set "DATABASE_TYPE=%~2" & shift & shift & goto :parse_args)
echo %RED%[ERROR]%NC% 未知參數: %~1
goto :show_help
@@ -324,6 +326,42 @@ if errorlevel 1 (
echo %GREEN%[OK]%NC% 已啟用內嵌後端模式
goto :eof
:update_config_database
REM 更新 config.json 的資料庫類型
if "%DATABASE_TYPE%"=="" goto :eof
echo %BLUE%[STEP]%NC% 設定資料庫類型...
set "CONFIG_FILE=%CLIENT_DIR%\config.json"
if not exist "%CONFIG_FILE%" (
echo %YELLOW%[WARN]%NC% 找不到 config.json跳過資料庫類型設定
goto :eof
)
REM 驗證資料庫類型
if /i not "%DATABASE_TYPE%"=="mysql" if /i not "%DATABASE_TYPE%"=="sqlite" (
echo %RED%[ERROR]%NC% 無效的資料庫類型: %DATABASE_TYPE%
echo %BLUE%[INFO]%NC% 有效選項: mysql, sqlite
exit /b 1
)
REM 使用 PowerShell 更新 database.type (使用 UTF8 without BOM)
if /i "%DATABASE_TYPE%"=="sqlite" (
REM SQLite 模式: 設定 type=sqlite清空 MySQL 連線資訊
powershell -Command "$config = Get-Content '%CONFIG_FILE%' -Raw | ConvertFrom-Json; $config.backend.database.type = 'sqlite'; $config.backend.database.host = ''; $config.backend.database.user = ''; $config.backend.database.password = ''; $config.backend.database.database = ''; $json = $config | ConvertTo-Json -Depth 10; [System.IO.File]::WriteAllText('%CONFIG_FILE%', $json, [System.Text.UTF8Encoding]::new($false))"
echo %GREEN%[OK]%NC% 資料庫類型已設定為: SQLite (本地模式)
) else (
REM MySQL 模式: 僅設定 type=mysql保留連線資訊
powershell -Command "$config = Get-Content '%CONFIG_FILE%' -Raw | ConvertFrom-Json; $config.backend.database.type = 'mysql'; $json = $config | ConvertTo-Json -Depth 10; [System.IO.File]::WriteAllText('%CONFIG_FILE%', $json, [System.Text.UTF8Encoding]::new($false))"
echo %GREEN%[OK]%NC% 資料庫類型已設定為: MySQL (雲端模式)
)
if errorlevel 1 (
echo %RED%[ERROR]%NC% 更新 config.json database.type 失敗
exit /b 1
)
goto :eof
:setup_client
echo %BLUE%[STEP]%NC% 設置前端建置環境...
@@ -436,6 +474,9 @@ call :update_config
REM 更新 embedded backend 設定(如果有指定)
call :update_config_embedded
REM 更新資料庫類型設定(如果有指定)
call :update_config_database
if "%SKIP_SIDECAR%"=="false" (
call :setup_sidecar_venv
call :build_sidecar
@@ -506,11 +547,13 @@ echo --api-url URL 後端 API URL (預設: http://localhost:8000/api)
echo --skip-sidecar 跳過 Sidecar 打包
echo --skip-backend 跳過 Backend 打包 (預設)
echo --embedded-backend 打包內嵌後端 (全包部署模式)
echo --database-type TYPE 資料庫類型: mysql (雲端) 或 sqlite (本地)
echo --clean 建置前先清理
echo.
echo 範例:
echo %~nx0 build 完整建置 (前端+Sidecar)
echo %~nx0 build --embedded-backend 全包部署 (含內嵌後端)
echo %~nx0 build --embedded-backend --database-type sqlite 全包部署 + SQLite
echo %~nx0 build --api-url "http://192.168.1.100:8000/api" 指定遠端後端
echo %~nx0 sidecar 僅打包 Sidecar
echo %~nx0 electron --skip-sidecar 僅打包 Electron
@@ -519,6 +562,10 @@ echo 部署模式:
echo 分離部署(預設): 前端連接遠端後端,使用 --api-url 指定後端地址
echo 全包部署: 使用 --embedded-backend 將後端打包進 exe雙擊即可運行
echo.
echo 資料庫模式:
echo MySQL預設: 連接雲端資料庫,需要網路存取
echo SQLite: 本地資料庫,適合離線或防火牆環境,使用 --database-type sqlite
echo.
echo 注意:
echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間
echo - 全包部署需要額外約 50MB 空間用於後端