Files
baiyue-logistics-chat/logistics_chat/db.py
T

304 lines
12 KiB
Python

"""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 = 4
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)
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)
MIGRATIONS = ((1, _initialize_schema), (2, _upgrade_ticket_workflow),
(3, _upgrade_ticket_history), (4, _upgrade_order_admin))
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