350 lines
13 KiB
Python
350 lines
13 KiB
Python
"""
|
|
Module quản lý cơ sở dữ liệu SQLite - Lưu trữ tập trung
|
|
|
|
|
|
"""
|
|
import sqlite3
|
|
import json
|
|
import os
|
|
import logging
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DB_DIR = "data"
|
|
DB_FILE = os.path.join(DB_DIR, "tinix_story.db")
|
|
os.makedirs(DB_DIR, exist_ok=True)
|
|
|
|
_connection: Optional[sqlite3.Connection] = None
|
|
|
|
|
|
def get_db() -> sqlite3.Connection:
|
|
"""Lấy kết nối DB singleton (WAL mode, foreign keys ON)"""
|
|
global _connection
|
|
if _connection is None:
|
|
_connection = sqlite3.connect(DB_FILE, check_same_thread=False)
|
|
_connection.execute("PRAGMA journal_mode=WAL")
|
|
_connection.execute("PRAGMA foreign_keys=ON")
|
|
_connection.row_factory = sqlite3.Row
|
|
init_db(_connection)
|
|
logger.info(f"Database connected: {DB_FILE}")
|
|
return _connection
|
|
|
|
|
|
def init_db(conn: Optional[sqlite3.Connection] = None) -> None:
|
|
"""Tạo tất cả bảng nếu chưa có"""
|
|
if conn is None:
|
|
conn = get_db()
|
|
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS config (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS backends (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
type TEXT NOT NULL,
|
|
base_url TEXT NOT NULL,
|
|
api_key TEXT NOT NULL DEFAULT '',
|
|
model TEXT NOT NULL DEFAULT '',
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
timeout INTEGER NOT NULL DEFAULT 30,
|
|
retry_times INTEGER NOT NULL DEFAULT 3,
|
|
is_default INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS config_backups (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
data TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS response_cache (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
timestamp TEXT NOT NULL,
|
|
ttl INTEGER NOT NULL DEFAULT 3600
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS generation_cache (
|
|
project_id TEXT PRIMARY KEY,
|
|
data TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS chapter_summaries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_id TEXT NOT NULL,
|
|
chapter_num INTEGER NOT NULL,
|
|
summary TEXT NOT NULL,
|
|
generated_at TEXT NOT NULL,
|
|
UNIQUE(project_id, chapter_num)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
genre TEXT NOT NULL DEFAULT '',
|
|
sub_genres TEXT NOT NULL DEFAULT '[]',
|
|
character_setting TEXT NOT NULL DEFAULT '',
|
|
world_setting TEXT NOT NULL DEFAULT '',
|
|
plot_idea TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS chapters (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project_id TEXT NOT NULL,
|
|
num INTEGER NOT NULL,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
desc TEXT NOT NULL DEFAULT '',
|
|
content TEXT NOT NULL DEFAULT '',
|
|
word_count INTEGER NOT NULL DEFAULT 0,
|
|
generated_at TEXT,
|
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
|
UNIQUE(project_id, num)
|
|
);
|
|
""")
|
|
|
|
# Đảm bảo schema cũ được cập nhật
|
|
try:
|
|
conn.execute("ALTER TABLE projects ADD COLUMN sub_genres TEXT NOT NULL DEFAULT '[]'")
|
|
except sqlite3.OperationalError:
|
|
pass # Đã có cột
|
|
|
|
conn.commit()
|
|
logger.info("Database tables initialized")
|
|
|
|
|
|
def migrate_from_files() -> str:
|
|
"""
|
|
Đọc dữ liệu cũ từ file JSON → insert vào DB.
|
|
Không xóa file cũ (giữ lại để phòng lỗi).
|
|
|
|
Returns:
|
|
Báo cáo migration
|
|
"""
|
|
conn = get_db()
|
|
report = []
|
|
now = datetime.now().isoformat()
|
|
|
|
# 1. Migrate config
|
|
config_file = os.path.join("config", "novel_tool_config.json")
|
|
if os.path.exists(config_file):
|
|
try:
|
|
with open(config_file, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
# Migrate backends
|
|
backends = data.get("backends", [])
|
|
migrated_backends = 0
|
|
for b in backends:
|
|
try:
|
|
conn.execute("""
|
|
INSERT OR IGNORE INTO backends
|
|
(name, type, base_url, api_key, model, enabled, timeout, retry_times, is_default, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
b.get("name", ""),
|
|
b.get("type", "openai"),
|
|
b.get("base_url", ""),
|
|
b.get("api_key", ""),
|
|
b.get("model", ""),
|
|
1 if b.get("enabled", True) else 0,
|
|
b.get("timeout", 30),
|
|
b.get("retry_times", 3),
|
|
1 if b.get("is_default", False) else 0,
|
|
now, now
|
|
))
|
|
migrated_backends += 1
|
|
except Exception as e:
|
|
logger.warning(f"Migrate backend failed: {e}")
|
|
|
|
# Migrate generation config
|
|
gen = data.get("generation", {})
|
|
if gen:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, ?)",
|
|
("generation", json.dumps(gen, ensure_ascii=False), now)
|
|
)
|
|
|
|
# Migrate version
|
|
version = data.get("version", "4.0.0")
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, ?)",
|
|
("version", version, now)
|
|
)
|
|
|
|
conn.commit()
|
|
report.append(f"✅ Config: {migrated_backends} backends migrated")
|
|
except Exception as e:
|
|
report.append(f"❌ Config migration failed: {e}")
|
|
else:
|
|
report.append("⏭ Config file not found, skipped")
|
|
|
|
# 2. Migrate config backups
|
|
backup_dir = os.path.join("config", "backups")
|
|
if os.path.exists(backup_dir):
|
|
migrated_backups = 0
|
|
for fname in os.listdir(backup_dir):
|
|
fpath = os.path.join(backup_dir, fname)
|
|
if fname.endswith(".json") and os.path.isfile(fpath):
|
|
try:
|
|
with open(fpath, "r", encoding="utf-8") as f:
|
|
backup_data = f.read()
|
|
# Extract timestamp from filename if possible
|
|
created = now
|
|
if fname.startswith("backup_"):
|
|
parts = fname.replace("backup_", "").replace(".json", "")
|
|
try:
|
|
created = datetime.strptime(parts, "%Y%m%d_%H%M%S").isoformat()
|
|
except ValueError:
|
|
pass
|
|
conn.execute(
|
|
"INSERT INTO config_backups (data, created_at) VALUES (?, ?)",
|
|
(backup_data, created)
|
|
)
|
|
migrated_backups += 1
|
|
except Exception as e:
|
|
logger.warning(f"Migrate backup {fname} failed: {e}")
|
|
conn.commit()
|
|
report.append(f"✅ Config backups: {migrated_backups} backups migrated")
|
|
|
|
# 3. Migrate response cache
|
|
cache_file = os.path.join("cache", "response_cache.json")
|
|
if os.path.exists(cache_file):
|
|
try:
|
|
with open(cache_file, "r", encoding="utf-8") as f:
|
|
cache_data = json.load(f)
|
|
migrated_cache = 0
|
|
for k, v in cache_data.items():
|
|
try:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO response_cache (key, value, timestamp, ttl) VALUES (?, ?, ?, ?)",
|
|
(k, v.get("value", ""), v.get("timestamp", now), int(v.get("ttl", 3600)))
|
|
)
|
|
migrated_cache += 1
|
|
except Exception as e:
|
|
logger.warning(f"Migrate cache entry failed: {e}")
|
|
conn.commit()
|
|
report.append(f"✅ Response cache: {migrated_cache} entries migrated")
|
|
except Exception as e:
|
|
report.append(f"❌ Response cache migration failed: {e}")
|
|
else:
|
|
report.append("⏭ Response cache not found, skipped")
|
|
|
|
# 4. Migrate generation cache
|
|
gen_cache_dir = Path("cache/generation")
|
|
if gen_cache_dir.exists():
|
|
migrated_gen = 0
|
|
for cache_file in gen_cache_dir.glob("*.json"):
|
|
try:
|
|
with open(cache_file, "r", encoding="utf-8") as f:
|
|
gen_data = f.read()
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO generation_cache (project_id, data, updated_at) VALUES (?, ?, ?)",
|
|
(cache_file.stem, gen_data, now)
|
|
)
|
|
migrated_gen += 1
|
|
except Exception as e:
|
|
logger.warning(f"Migrate generation cache {cache_file.name} failed: {e}")
|
|
conn.commit()
|
|
report.append(f"✅ Generation cache: {migrated_gen} entries migrated")
|
|
|
|
# 5. Migrate chapter summaries
|
|
summary_dir = Path("cache/summaries")
|
|
if summary_dir.exists():
|
|
migrated_summaries = 0
|
|
for project_dir in summary_dir.iterdir():
|
|
if not project_dir.is_dir():
|
|
continue
|
|
for summary_file in project_dir.glob("*.json"):
|
|
try:
|
|
with open(summary_file, "r", encoding="utf-8") as f:
|
|
summary_data = json.load(f)
|
|
conn.execute("""
|
|
INSERT OR IGNORE INTO chapter_summaries
|
|
(project_id, chapter_num, summary, generated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
""", (
|
|
project_dir.name,
|
|
summary_data.get("chapter_num", int(summary_file.stem)),
|
|
summary_data.get("summary", ""),
|
|
summary_data.get("generated_at", now)
|
|
))
|
|
migrated_summaries += 1
|
|
except Exception as e:
|
|
logger.warning(f"Migrate summary {summary_file} failed: {e}")
|
|
conn.commit()
|
|
report.append(f"✅ Chapter summaries: {migrated_summaries} entries migrated")
|
|
|
|
# 6. Migrate projects
|
|
projects_dir = "projects"
|
|
if os.path.exists(projects_dir):
|
|
migrated_projects = 0
|
|
for project_id in os.listdir(projects_dir):
|
|
project_path = os.path.join(projects_dir, project_id)
|
|
if not os.path.isdir(project_path):
|
|
continue
|
|
metadata_file = os.path.join(project_path, "metadata.json")
|
|
if not os.path.exists(metadata_file):
|
|
continue
|
|
try:
|
|
with open(metadata_file, "r", encoding="utf-8") as f:
|
|
metadata = json.load(f)
|
|
|
|
conn.execute("""
|
|
INSERT OR IGNORE INTO projects
|
|
(id, title, genre, character_setting, world_setting, plot_idea, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
metadata.get("id", project_id),
|
|
metadata.get("title", ""),
|
|
metadata.get("genre", ""),
|
|
metadata.get("character_setting", ""),
|
|
metadata.get("world_setting", ""),
|
|
metadata.get("plot_idea", ""),
|
|
metadata.get("created_at", now),
|
|
metadata.get("updated_at", now)
|
|
))
|
|
|
|
# Migrate chapters
|
|
for ch in metadata.get("chapters", []):
|
|
try:
|
|
conn.execute("""
|
|
INSERT OR IGNORE INTO chapters
|
|
(project_id, num, title, desc, content, word_count, generated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
metadata.get("id", project_id),
|
|
ch.get("num", 0),
|
|
ch.get("title", ""),
|
|
ch.get("desc", ""),
|
|
ch.get("content", ""),
|
|
ch.get("word_count", 0),
|
|
ch.get("generated_at")
|
|
))
|
|
except Exception as e:
|
|
logger.warning(f"Migrate chapter {ch.get('num')} failed: {e}")
|
|
|
|
migrated_projects += 1
|
|
except Exception as e:
|
|
logger.warning(f"Migrate project {project_id} failed: {e}")
|
|
|
|
conn.commit()
|
|
report.append(f"✅ Projects: {migrated_projects} projects migrated")
|
|
else:
|
|
report.append("⏭ Projects directory not found, skipped")
|
|
|
|
result = "\n".join(report)
|
|
logger.info(f"Migration complete:\n{result}")
|
|
return result
|