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:
109
DEPLOYMENT.md
109
DEPLOYMENT.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- MySQL 8.0+
|
- MySQL 8.0+ 或 SQLite(本地模式)
|
||||||
- Access to Dify LLM service
|
- Access to Dify LLM service
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -233,18 +233,32 @@ npm start
|
|||||||
- 企業內部部署,簡化用戶操作
|
- 企業內部部署,簡化用戶操作
|
||||||
- 無法獨立架設後端的環境
|
- 無法獨立架設後端的環境
|
||||||
- 快速測試和演示
|
- 快速測試和演示
|
||||||
|
- **離線環境**:使用 SQLite 本地資料庫,無需網路連接資料庫
|
||||||
|
|
||||||
### 打包方式
|
### 打包方式
|
||||||
|
|
||||||
```batch
|
```batch
|
||||||
# Windows 全包打包
|
# Windows 全包打包(MySQL 雲端資料庫,預設)
|
||||||
.\scripts\build-client.bat build --embedded-backend --clean
|
.\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 配置
|
||||||
|
|
||||||
全包模式需要在 `config.json` 中配置資料庫和 API 金鑰:
|
全包模式需要在 `config.json` 中配置資料庫和 API 金鑰:
|
||||||
|
|
||||||
|
#### MySQL 模式(雲端資料庫)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"apiBaseUrl": "http://localhost:8000/api",
|
"apiBaseUrl": "http://localhost:8000/api",
|
||||||
@@ -260,6 +274,8 @@ npm start
|
|||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8000,
|
"port": 8000,
|
||||||
"database": {
|
"database": {
|
||||||
|
"type": "mysql",
|
||||||
|
"sqlitePath": "data/meeting.db",
|
||||||
"host": "mysql.theaken.com",
|
"host": "mysql.theaken.com",
|
||||||
"port": 33306,
|
"port": 33306,
|
||||||
"user": "your_username",
|
"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.embedded` | `true`/`false` | 啟用/停用內嵌後端模式 |
|
||||||
| `backend.host` | IP | 後端監聽地址(通常為 127.0.0.1) |
|
| `backend.host` | IP | 後端監聽地址(通常為 127.0.0.1) |
|
||||||
| `backend.port` | 數字 | 後端監聽端口(預設 8000) |
|
| `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.externalApis.*` | 各欄位 | 外部 API 設定(認證、Dify) |
|
||||||
| `backend.auth.*` | 各欄位 | 認證設定(管理員信箱、JWT 金鑰) |
|
| `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
|
1. 用戶雙擊 exe
|
||||||
2. Electron 主程序啟動
|
2. 解壓縮到 `%TEMP%\Meeting-Assistant`
|
||||||
3. 讀取 `config.json`
|
3. Electron 主程序啟動
|
||||||
4. 啟動內嵌後端 (FastAPI)
|
4. 讀取 `config.json`
|
||||||
5. 健康檢查等待後端就緒(最多 30 秒)
|
5. 啟動內嵌後端 (FastAPI)
|
||||||
6. 後端就緒後,載入前端頁面
|
6. 健康檢查等待後端就緒(最多 30 秒)
|
||||||
7. 啟動 Whisper Sidecar
|
7. 後端就緒後,載入前端頁面
|
||||||
8. 應用就緒
|
8. 啟動 Whisper Sidecar
|
||||||
|
9. 應用就緒
|
||||||
|
|
||||||
### 向後相容性
|
### 向後相容性
|
||||||
|
|
||||||
@@ -367,6 +442,8 @@ Copy `sidecar/dist/` to `client/sidecar/` before building Electron app.
|
|||||||
|
|
||||||
## Database Setup
|
## Database Setup
|
||||||
|
|
||||||
|
### MySQL 模式
|
||||||
|
|
||||||
The backend will automatically create tables on first startup. To manually verify:
|
The backend will automatically create tables on first startup. To manually verify:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -374,7 +451,17 @@ USE your_database;
|
|||||||
SHOW TABLES LIKE 'meeting_%';
|
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_users`
|
||||||
- `meeting_records`
|
- `meeting_records`
|
||||||
- `meeting_conclusions`
|
- `meeting_conclusions`
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class Settings:
|
|||||||
BACKEND_PORT: int = int(os.getenv("BACKEND_PORT", "8000"))
|
BACKEND_PORT: int = int(os.getenv("BACKEND_PORT", "8000"))
|
||||||
|
|
||||||
# Database Configuration
|
# 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_HOST: str = os.getenv("DB_HOST", "mysql.theaken.com")
|
||||||
DB_PORT: int = int(os.getenv("DB_PORT", "33306"))
|
DB_PORT: int = int(os.getenv("DB_PORT", "33306"))
|
||||||
DB_USER: str = os.getenv("DB_USER", "A060")
|
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, self.RECORD_DIR)
|
||||||
return os.path.join(base_dir, "record")
|
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)
|
# Timeout helpers (convert ms to seconds for httpx)
|
||||||
@property
|
@property
|
||||||
def upload_timeout_seconds(self) -> float:
|
def upload_timeout_seconds(self) -> float:
|
||||||
|
|||||||
@@ -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
|
import mysql.connector
|
||||||
from mysql.connector import pooling
|
from mysql.connector import pooling
|
||||||
from contextlib import contextmanager
|
|
||||||
from .config import settings
|
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
|
# Initialization Functions
|
||||||
connection_pool = pooling.MySQLConnectionPool(
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
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_name="meeting_pool",
|
||||||
pool_size=settings.DB_POOL_SIZE,
|
pool_size=settings.DB_POOL_SIZE,
|
||||||
host=settings.DB_HOST,
|
host=settings.DB_HOST,
|
||||||
@@ -17,80 +62,250 @@ def init_db_pool():
|
|||||||
password=settings.DB_PASS,
|
password=settings.DB_PASS,
|
||||||
database=settings.DB_NAME,
|
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
|
@contextmanager
|
||||||
def get_db_connection():
|
def get_db_connection():
|
||||||
conn = connection_pool.get_connection()
|
"""Get a database connection (MySQL or SQLite)."""
|
||||||
try:
|
if _db_type == "sqlite":
|
||||||
yield conn
|
# SQLite uses a single connection with thread lock
|
||||||
finally:
|
yield _sqlite_conn
|
||||||
conn.close()
|
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
|
@contextmanager
|
||||||
def get_db_cursor(commit=False):
|
def get_db_cursor(commit=False):
|
||||||
with get_db_connection() as conn:
|
"""Get a database cursor that returns dict-like rows.
|
||||||
cursor = conn.cursor(dictionary=True)
|
|
||||||
try:
|
Args:
|
||||||
yield cursor
|
commit: If True, commit the transaction after yield.
|
||||||
if commit:
|
|
||||||
conn.commit()
|
Yields:
|
||||||
finally:
|
cursor: A cursor that returns dict-like rows.
|
||||||
cursor.close()
|
"""
|
||||||
|
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():
|
def init_tables():
|
||||||
"""Create all required tables if they don't exist."""
|
"""Create all required tables if they don't exist."""
|
||||||
create_statements = [
|
tables = SQLITE_TABLES if _db_type == "sqlite" else 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
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
]
|
|
||||||
|
|
||||||
with get_db_cursor(commit=True) as cursor:
|
if _db_type == "sqlite":
|
||||||
for statement in create_statements:
|
with _sqlite_lock:
|
||||||
cursor.execute(statement)
|
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)
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ def apply_config_to_env(config: dict) -> None:
|
|||||||
|
|
||||||
# Database configuration - use direct assignment to ensure config values are used
|
# Database configuration - use direct assignment to ensure config values are used
|
||||||
db_config = backend_config.get("database", {})
|
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:
|
if "host" in db_config:
|
||||||
os.environ["DB_HOST"] = db_config["host"]
|
os.environ["DB_HOST"] = db_config["host"]
|
||||||
if "port" in db_config:
|
if "port" in db_config:
|
||||||
@@ -123,11 +127,13 @@ def main():
|
|||||||
print(f"DEBUG: Loaded config keys: {list(config.keys())}", flush=True)
|
print(f"DEBUG: Loaded config keys: {list(config.keys())}", flush=True)
|
||||||
backend_config = config.get("backend", {})
|
backend_config = config.get("backend", {})
|
||||||
db_config = backend_config.get("database", {})
|
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)
|
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)
|
apply_config_to_env(config)
|
||||||
|
|
||||||
# Debug: print env vars after setting
|
# 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_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_USER={os.environ.get('DB_USER')}", flush=True)
|
||||||
print(f"DEBUG: ENV DB_PASS={'***' if os.environ.get('DB_PASS') else 'EMPTY'}", flush=True)
|
print(f"DEBUG: ENV DB_PASS={'***' if os.environ.get('DB_PASS') else 'EMPTY'}", flush=True)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8000,
|
"port": 8000,
|
||||||
"database": {
|
"database": {
|
||||||
|
"type": "mysql",
|
||||||
|
"sqlitePath": "data/meeting.db",
|
||||||
"host": "mysql.theaken.com",
|
"host": "mysql.theaken.com",
|
||||||
"port": 33306,
|
"port": 33306,
|
||||||
"user": "A060",
|
"user": "A060",
|
||||||
|
|||||||
@@ -72,7 +72,8 @@
|
|||||||
"icon": "assets/icon.png"
|
"icon": "assets/icon.png"
|
||||||
},
|
},
|
||||||
"portable": {
|
"portable": {
|
||||||
"artifactName": "${productName}-${version}-portable.${ext}"
|
"artifactName": "${productName}-${version}-portable.${ext}",
|
||||||
|
"unpackDirName": "Meeting-Assistant"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ set "SKIP_BACKEND=true"
|
|||||||
set "EMBEDDED_BACKEND=false"
|
set "EMBEDDED_BACKEND=false"
|
||||||
set "CLEAN_BUILD=false"
|
set "CLEAN_BUILD=false"
|
||||||
set "API_URL="
|
set "API_URL="
|
||||||
|
set "DATABASE_TYPE="
|
||||||
|
|
||||||
REM 解析參數
|
REM 解析參數
|
||||||
set "COMMAND=help"
|
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"=="--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"=="--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"=="--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
|
echo %RED%[ERROR]%NC% 未知參數: %~1
|
||||||
goto :show_help
|
goto :show_help
|
||||||
|
|
||||||
@@ -324,6 +326,42 @@ if errorlevel 1 (
|
|||||||
echo %GREEN%[OK]%NC% 已啟用內嵌後端模式
|
echo %GREEN%[OK]%NC% 已啟用內嵌後端模式
|
||||||
goto :eof
|
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
|
:setup_client
|
||||||
echo %BLUE%[STEP]%NC% 設置前端建置環境...
|
echo %BLUE%[STEP]%NC% 設置前端建置環境...
|
||||||
|
|
||||||
@@ -436,6 +474,9 @@ call :update_config
|
|||||||
REM 更新 embedded backend 設定(如果有指定)
|
REM 更新 embedded backend 設定(如果有指定)
|
||||||
call :update_config_embedded
|
call :update_config_embedded
|
||||||
|
|
||||||
|
REM 更新資料庫類型設定(如果有指定)
|
||||||
|
call :update_config_database
|
||||||
|
|
||||||
if "%SKIP_SIDECAR%"=="false" (
|
if "%SKIP_SIDECAR%"=="false" (
|
||||||
call :setup_sidecar_venv
|
call :setup_sidecar_venv
|
||||||
call :build_sidecar
|
call :build_sidecar
|
||||||
@@ -506,11 +547,13 @@ echo --api-url URL 後端 API URL (預設: http://localhost:8000/api)
|
|||||||
echo --skip-sidecar 跳過 Sidecar 打包
|
echo --skip-sidecar 跳過 Sidecar 打包
|
||||||
echo --skip-backend 跳過 Backend 打包 (預設)
|
echo --skip-backend 跳過 Backend 打包 (預設)
|
||||||
echo --embedded-backend 打包內嵌後端 (全包部署模式)
|
echo --embedded-backend 打包內嵌後端 (全包部署模式)
|
||||||
|
echo --database-type TYPE 資料庫類型: mysql (雲端) 或 sqlite (本地)
|
||||||
echo --clean 建置前先清理
|
echo --clean 建置前先清理
|
||||||
echo.
|
echo.
|
||||||
echo 範例:
|
echo 範例:
|
||||||
echo %~nx0 build 完整建置 (前端+Sidecar)
|
echo %~nx0 build 完整建置 (前端+Sidecar)
|
||||||
echo %~nx0 build --embedded-backend 全包部署 (含內嵌後端)
|
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 build --api-url "http://192.168.1.100:8000/api" 指定遠端後端
|
||||||
echo %~nx0 sidecar 僅打包 Sidecar
|
echo %~nx0 sidecar 僅打包 Sidecar
|
||||||
echo %~nx0 electron --skip-sidecar 僅打包 Electron
|
echo %~nx0 electron --skip-sidecar 僅打包 Electron
|
||||||
@@ -519,6 +562,10 @@ echo 部署模式:
|
|||||||
echo 分離部署(預設): 前端連接遠端後端,使用 --api-url 指定後端地址
|
echo 分離部署(預設): 前端連接遠端後端,使用 --api-url 指定後端地址
|
||||||
echo 全包部署: 使用 --embedded-backend 將後端打包進 exe,雙擊即可運行
|
echo 全包部署: 使用 --embedded-backend 將後端打包進 exe,雙擊即可運行
|
||||||
echo.
|
echo.
|
||||||
|
echo 資料庫模式:
|
||||||
|
echo MySQL(預設): 連接雲端資料庫,需要網路存取
|
||||||
|
echo SQLite: 本地資料庫,適合離線或防火牆環境,使用 --database-type sqlite
|
||||||
|
echo.
|
||||||
echo 注意:
|
echo 注意:
|
||||||
echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間
|
echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間
|
||||||
echo - 全包部署需要額外約 50MB 空間用於後端
|
echo - 全包部署需要額外約 50MB 空間用於後端
|
||||||
|
|||||||
Reference in New Issue
Block a user