"""SQLite schema, history persistence and backups; initialized explicitly by create_app.""" import sqlite3 from datetime import datetime, timedelta from pathlib import Path from .config import get_setting from .tracking import card_to_text from .database_backup import check_database, daily_backup, prune_migration_snapshots, snapshot_database, unique_backup_path SCHEMA_VERSION = 5 def connect(): connection = sqlite3.connect(get_setting('DATABASE')) try: from flask import g, has_app_context except ImportError: return connection if has_app_context(): g.setdefault('_database_connections', []).append(connection) return connection def close_connections(error=None): from flask import g for connection in g.pop('_database_connections', []): connection.close() def now_str(): return datetime.now().strftime('%Y-%m-%d %H:%M') def init_db(): Path(get_setting('DATABASE')).parent.mkdir(parents=True, exist_ok=True) conn = connect() try: # A reserved write lock serializes snapshots and schema migration across workers. conn.execute('BEGIN IMMEDIATE') has_tables = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' LIMIT 1").fetchone() version = 0 if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_meta'").fetchone(): row = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone() if row: version = int(row[0]) if version > SCHEMA_VERSION: raise RuntimeError('Database schema is newer than this application; refusing downgrade') if version < 0: raise RuntimeError('Invalid database schema version') if [number for number, operation in MIGRATIONS] != list(range(1, SCHEMA_VERSION + 1)): raise RuntimeError('Database migration registry has missing or unordered versions') if has_tables: snapshot_database(get_setting('DATABASE'), unique_backup_path(get_setting('BACKUP_DIR'), 'pre-migration')) prune_migration_snapshots(get_setting('BACKUP_DIR'), keep=5) conn.execute('CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)') for target_version, migration in MIGRATIONS: if version < target_version <= SCHEMA_VERSION: migration(conn) conn.execute("INSERT OR REPLACE INTO schema_meta VALUES('schema_version',?)", (str(target_version),)) if version == SCHEMA_VERSION: # Retain legacy schema reconciliation for tables maintained by older tools. _initialize_schema(conn) _upgrade_ticket_workflow(conn) _upgrade_ticket_history(conn) _upgrade_staff_routing(conn) conn.execute("INSERT OR REPLACE INTO schema_meta VALUES('schema_version',?)", (str(SCHEMA_VERSION),)) if conn.execute('PRAGMA quick_check').fetchall() != [('ok',)]: raise sqlite3.DatabaseError('Database validation failed after migration') conn.commit() except Exception: conn.rollback() raise finally: conn.close() def _initialize_schema(conn): """Version 1 baseline reconciliation preserves supported legacy table layouts.""" cur = conn.cursor() cur.execute(''' CREATE TABLE IF NOT EXISTS chat_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, ctime TEXT NOT NULL ) ''') cols = [r[1] for r in cur.execute('PRAGMA table_info(chat_history)').fetchall()] if 'utime' not in cols: cur.execute('ALTER TABLE chat_history ADD COLUMN utime TEXT') cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL') if 'visitor_id' not in cols: cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT') if 'customer_id' not in cols: cur.execute('ALTER TABLE chat_history ADD COLUMN customer_id INTEGER') if 'customer_scope' not in cols: cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT') if 'customer_tenant_id' in cols: # Preserve conversations created by the earlier local single-tenant login. import hashlib import json for cid, tenant in cur.execute('SELECT id,customer_tenant_id FROM chat_history WHERE customer_id IS NOT NULL').fetchall(): scope = hashlib.sha256(json.dumps([tenant],ensure_ascii=False).encode()).hexdigest() if tenant else None cur.execute('UPDATE chat_history SET customer_scope=? WHERE id=?',(scope,cid)) cur.execute('CREATE INDEX IF NOT EXISTS idx_chat_visitor ON chat_history(visitor_id, id)') cur.execute('''CREATE TABLE IF NOT EXISTS visitor_uploads ( filename TEXT PRIMARY KEY, visitor_id TEXT NOT NULL, ctime TEXT NOT NULL )''') cur.execute(''' CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conv_id INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, ctime TEXT NOT NULL ) ''') cur.execute('CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id)') cur.execute(''' CREATE TABLE IF NOT EXISTS feedback ( id INTEGER PRIMARY KEY AUTOINCREMENT, question TEXT, answer TEXT, vote TEXT, ctime TEXT ) ''') cur.execute(''' CREATE TABLE IF NOT EXISTS human_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, conv_id INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'waiting', ctime TEXT NOT NULL ) ''') cur.execute(''' CREATE TABLE IF NOT EXISTS human_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, side TEXT NOT NULL, content TEXT NOT NULL, ctime TEXT NOT NULL ) ''') hm_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_messages)').fetchall()] if 'read_at' not in hm_cols: cur.execute('ALTER TABLE human_messages ADD COLUMN read_at TEXT') if 'recalled_id' not in hm_cols: cur.execute('ALTER TABLE human_messages ADD COLUMN recalled_id INTEGER') hs_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_sessions)').fetchall()] if 'note' not in hs_cols: cur.execute("ALTER TABLE human_sessions ADD COLUMN note TEXT DEFAULT ''") cur.execute(''' CREATE TABLE IF NOT EXISTS ratings ( id INTEGER PRIMARY KEY AUTOINCREMENT, sid INTEGER NOT NULL, stars INTEGER NOT NULL, comment TEXT DEFAULT '', ctime TEXT NOT NULL ) ''') cur.execute(''' CREATE TABLE IF NOT EXISTS agents ( username TEXT PRIMARY KEY, password TEXT NOT NULL, name TEXT NOT NULL ) ''') cur.execute(''' CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, phone TEXT, track_no TEXT, status TEXT, created TEXT ) ''') cur.execute(''' CREATE TABLE IF NOT EXISTS tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT, phone TEXT, ctime TEXT, status TEXT ) ''') from .order_data import init_order_table init_order_table(conn) from .customer_service import init_customer_tables init_customer_tables(conn) from .staff_service import init_staff_tables init_staff_tables(conn) old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall() for cid, title in old: cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str())) def _upgrade_ticket_workflow(conn): from .ticket_service import init_ticket_tables init_ticket_tables(conn) def _upgrade_ticket_history(conn): from .ticket_service import init_ticket_history init_ticket_history(conn) def _upgrade_order_admin(conn): from .order_admin_service import init_order_admin_tables init_order_admin_tables(conn) def _upgrade_staff_routing(conn): from .routing_service import init_routing_tables init_routing_tables(conn) MIGRATIONS = ((1, _initialize_schema), (2, _upgrade_ticket_workflow), (3, _upgrade_ticket_history), (4, _upgrade_order_admin), (5, _upgrade_staff_routing)) def backup_db(): """Verified daily online snapshot; failures propagate rather than claiming success.""" return daily_backup(get_setting('DATABASE'), get_setting('BACKUP_DIR')) def cleanup_old_conversations(): """自动清理 30 天前的旧对话,防止数据库无限膨胀""" try: conn = connect() c = conn.cursor() cutoff = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S') rows = c.execute('SELECT id FROM chat_history WHERE utime < ?', (cutoff,)).fetchall() ids = [r[0] for r in rows] if not ids: return for cid in ids: c.execute('DELETE FROM messages WHERE conv_id=?', (cid,)) c.execute(f"DELETE FROM chat_history WHERE id IN ({','.join('?' * len(ids))})", ids) conn.commit() conn.close() print(f'[自动清理] 已删除 {len(ids)} 条 30 天前的旧对话') except Exception as e: print(f'[自动清理失败] {e}') def make_title(content): """生成会话标题:优先用文字内容,只有附件时显示附件数量""" lines = [l for l in content.split('\n') if not l.strip().startswith('[附件]')] text = ' '.join(lines).strip() if text and text != '(发送了附件)' and (text != '请帮我看看这个附件'): return text[:24] n = len([l for l in content.split('\n') if l.strip().startswith('[附件]')]) if n == 1: return '📎 已上传附件' if n > 1: return f'📎 {n}个附件' return content[:24] def save_message(conv_id, role, content): """把一条消息写进会话;用户消息做幂等处理,重试不会重复入库。""" if not conv_id or not content: return conn = connect() cur = conn.cursor() if role == 'user': last = cur.execute('SELECT role, content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 1', (conv_id,)).fetchone() if last and last[0] == 'user' and (last[1] == content): conn.close() return 'duplicate' inserted = cur.execute('INSERT INTO messages(conv_id, role, content, ctime) ' 'SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM chat_history WHERE id=?)', (conv_id, role, content, now_str(), conv_id)).rowcount if not inserted: conn.close() return 'deleted' if role == 'user': row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone() if row and (not row[0] or row[0] == '新对话'): cur.execute('UPDATE chat_history SET title=? WHERE id=?', (make_title(content), conv_id)) cur.execute('UPDATE chat_history SET utime=? WHERE id=?', (now_str(), conv_id)) conn.commit() conn.close() def last_user_question(conv_id): """上一条用户提问(必须在当前这句入库之前调用)""" if not conv_id: return None conn = connect() row = conn.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone() conn.close() return row[0] if row else None def load_context(conv_id, msg): """取最近 MAX_CONTEXT 条消息,拼成模型要的 messages(不含 system)。""" if not conv_id: return [{'role': 'user', 'content': msg}] conn = connect() rows = conn.execute('SELECT role, content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT ?', (conv_id, get_setting('MAX_CONTEXT'))).fetchall() conn.close() rows.reverse() history = [{'role': r, 'content': card_to_text(c)} for r, c in rows] if not history or history[-1]['role'] != 'user' or history[-1]['content'] != msg: history.append({'role': 'user', 'content': msg}) return history