保存今天的修改

This commit is contained in:
songxinnuo
2026-11-02 17:51:10 +08:00
parent 04f94ad665
commit eebaba8084
44 changed files with 2831 additions and 154 deletions
+9 -1
View File
@@ -10,7 +10,7 @@ def create_app(config=None):
from flask import Flask
from .config import ROOT_DIR, default_config
from . import customer_service, db, file_service, hooks, visitor_service
from . import customer_service, db, file_service, hooks, staff_service, visitor_service
from .routes import register_routes
from .routes.speech import MemoryAudioRequest
@@ -24,6 +24,9 @@ def create_app(config=None):
app.extensions['rate_hits'] = defaultdict(list)
app.extensions['customer_login_attempts'] = {}
app.extensions['customer_login_lock'] = Lock()
app.extensions['staff_login_attempts'] = {}
app.extensions['staff_login_lock'] = Lock()
app.before_request(staff_service.attach_staff)
app.before_request(hooks.rate_limit)
app.before_request(visitor_service.attach_visitor)
app.before_request(customer_service.attach_customer)
@@ -49,4 +52,9 @@ def create_app(config=None):
db.init_db()
db.backup_db()
file_service.cleanup_uploads()
if not app.testing and app.config['AUTO_BACKUP_ENABLED']:
from .database_backup import DailyBackupWorker
worker = DailyBackupWorker(app)
app.extensions['database_backup_worker'] = worker
worker.start()
return app
+8 -20
View File
@@ -1,7 +1,6 @@
"""Feedback, reporting, login, orders and offline tickets."""
from datetime import datetime
from .db import connect, now_str
from .tracking import card_to_text
@@ -55,30 +54,19 @@ def add_ticket(data):
return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400
if not isinstance(phone, str) or len(phone.strip()) > 32:
return {'ok': False, 'error': '联系电话格式无效。'}, 400
conn = connect()
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)',
(content.strip(), phone.strip(), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
conn.commit()
conn.close()
return {'ok': True}
from .ticket_service import create_ticket
return create_ticket(content.strip(), phone.strip())
def list_tickets():
conn = connect()
rows = conn.execute("SELECT id, content, phone, ctime FROM tickets WHERE status='pending' ORDER BY id DESC").fetchall()
conn.close()
return [{'id': r[0], 'content': r[1], 'phone': r[2], 'ctime': r[3]} for r in rows]
def list_tickets(status='pending', limit=20, before=None, query='', overdue='0'):
from .ticket_service import list_tickets as query_tickets
return query_tickets(status, limit, before, query, overdue)
def admin_login(data):
u = data.get('username', '')
p = data.get('password', '')
conn = connect()
row = conn.execute('SELECT name FROM agents WHERE username=? AND password=?', (u, p)).fetchone()
conn.close()
if row:
return {'ok': True, 'name': row[0]}
return ({'ok': False}, 401)
# Retain the service entry point; all credential/session rules live together.
from .staff_service import login
return login(data)
def agents_list():
+1
View File
@@ -18,6 +18,7 @@ def default_config():
'DATABASE': ROOT_DIR / 'history.db',
'UPLOAD_DIR': ROOT_DIR / 'uploads',
'BACKUP_DIR': ROOT_DIR / 'backups',
'AUTO_BACKUP_ENABLED': str(os.environ.get('AUTO_BACKUP_ENABLED', local_config.get('AUTO_BACKUP_ENABLED', 'true'))).lower() in ('1','true','yes'),
'LOG_DIR': ROOT_DIR / 'logs',
'DEEPSEEK_API_KEY': (api_key or '').strip(),
'ASR_API_KEY': str(os.environ.get('ASR_API_KEY', local_config.get('ASR_API_KEY')) or '').strip(),
+169
View File
@@ -0,0 +1,169 @@
"""Verified SQLite snapshots and offline restore, independent of Flask lifecycle."""
from contextlib import closing
from datetime import datetime, timedelta, timezone
import os
from pathlib import Path
import re
import sqlite3
import tempfile
from threading import Event, Thread
import time
import uuid
CHINA_TIME = timezone(timedelta(hours=8))
def read_connection(path):
return sqlite3.connect(Path(path).resolve().as_uri() + '?mode=ro', uri=True, timeout=30)
def check_database(path):
"""Read-only full integrity check; never create a missing source."""
with closing(read_connection(path)) as connection:
rows = connection.execute('PRAGMA integrity_check').fetchall()
if rows != [('ok',)]:
raise sqlite3.DatabaseError('Database integrity check failed')
return 'ok'
def snapshot_database(source, destination):
"""Include committed WAL data, verify, then publish without overwriting a file."""
source, destination = Path(source).resolve(), Path(destination).resolve()
if source == destination:
raise ValueError('Backup must differ from source database')
if destination.exists():
raise FileExistsError(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix='.sqlite-backup-', suffix='.tmp', dir=destination.parent)
os.close(descriptor)
temporary = Path(temporary)
deadline = time.monotonic() + 120
def progress(status, remaining, total):
if time.monotonic() > deadline:
raise TimeoutError('SQLite backup exceeded 120 seconds')
try:
with closing(read_connection(source)) as original, closing(sqlite3.connect(temporary)) as target:
original.backup(target, pages=256, progress=progress, sleep=0.05)
# Publish a standalone file even when the source database uses WAL.
target.execute('PRAGMA journal_mode=DELETE')
check_database(temporary)
with temporary.open('r+b') as handle:
os.fsync(handle.fileno())
# Hard-link publication is atomic and refuses an existing destination.
os.link(temporary, destination)
return destination
finally:
temporary.unlink(missing_ok=True)
for suffix in ('-wal', '-shm', '-journal'):
Path(str(temporary) + suffix).unlink(missing_ok=True)
def unique_backup_path(directory, prefix):
timestamp = datetime.now(CHINA_TIME).strftime('%Y%m%d-%H%M%S-%f')
return Path(directory) / f'{prefix}-{timestamp}-{uuid.uuid4().hex[:8]}.db'
def prune_migration_snapshots(directory, keep=5):
"""Call after publishing a verified snapshot, under the startup write lock."""
if keep < 1:
raise ValueError('At least one migration snapshot must be retained')
snapshots = []
for candidate in Path(directory).iterdir():
if (re.fullmatch(r'pre-migration-\d{8}-\d{6}-\d{6}-[0-9a-f]{8}\.db', candidate.name)
and not candidate.is_symlink() and candidate.is_file()):
snapshots.append(candidate)
# Generated names sort by their creation timestamps, independently of copied mtimes.
for candidate in sorted(snapshots, key=lambda path: path.name, reverse=True)[keep:]:
candidate.unlink(missing_ok=True)
def daily_backup(database, directory, now=None):
now = now or datetime.now(CHINA_TIME)
directory = Path(directory)
destination = directory / ('history_' + now.strftime('%Y%m%d') + '.db')
if destination.exists():
check_database(destination)
else:
try:
snapshot_database(database, destination)
except FileExistsError:
# Another worker published the same day's complete snapshot.
check_database(destination)
cutoff = now.date() - timedelta(days=6)
for candidate in directory.iterdir():
match = re.fullmatch(r'history_(\d{8})\.db', candidate.name)
if not match or candidate.is_symlink() or not candidate.is_file():
continue
try:
old = datetime.strptime(match[1], '%Y%m%d').date()
except ValueError:
continue
if old < cutoff:
candidate.unlink(missing_ok=True)
return destination
def restore_database(database, backup, directory):
"""Offline only: keep current state, stage a verified copy, and replace atomically."""
database, backup, directory = Path(database).resolve(), Path(backup).resolve(), Path(directory).resolve()
if backup.parent != directory or backup == database or backup.suffix != '.db':
raise ValueError('Restore input must be a .db file inside the configured backup directory')
check_database(backup)
# Avoid silently detaching a live WAL/journal from a replacement database.
for suffix in ('-wal', '-shm', '-journal'):
if Path(str(database) + suffix).exists():
raise RuntimeError('Database has SQLite sidecar files; stop all users and checkpoint before restoring')
safety = None
if database.exists():
safety = snapshot_database(database, unique_backup_path(directory, 'pre-restore'))
# Reading a clean WAL database may create coordination files. Let SQLite
# checkpoint/remove these under the declared offline maintenance window.
with closing(sqlite3.connect(database, timeout=30)) as current:
current.execute('PRAGMA wal_checkpoint(TRUNCATE)')
current.execute('PRAGMA journal_mode=DELETE')
for suffix in ('-wal', '-shm', '-journal'):
if Path(str(database) + suffix).exists():
raise RuntimeError('SQLite sidecars remain; database is not ready for offline restore')
staged = database.with_name('.restore-' + uuid.uuid4().hex + '.db')
try:
snapshot_database(backup, staged)
os.replace(staged, database)
finally:
staged.unlink(missing_ok=True)
return safety
class DailyBackupWorker:
"""One stoppable task per app; daily files also coordinate independent processes."""
def __init__(self, app):
self.app = app
self.stop_event = Event()
# First tick rechecks startup's backup, including a startup crossing midnight.
self.last_date = None
self.thread = Thread(target=self.run, name='sqlite-daily-backup', daemon=True)
def tick(self, now=None):
now = now or datetime.now(CHINA_TIME)
if now.date() == self.last_date:
return False
daily_backup(self.app.config['DATABASE'], self.app.config['BACKUP_DIR'], now)
self.last_date = now.date()
return True
def run(self):
while not self.stop_event.wait(60):
try:
self.tick()
except Exception:
self.app.logger.exception('Daily SQLite backup failed; will retry in 60 seconds')
def start(self):
self.thread.start()
def stop(self):
self.stop_event.set()
self.thread.join(timeout=2)
+61 -27
View File
@@ -1,14 +1,14 @@
"""SQLite schema, history persistence and backups; initialized explicitly by create_app."""
import os
import re
import shutil
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 = 3
def connect():
@@ -36,9 +36,48 @@ def now_str():
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()
# Serialize schema inspection and migration when multiple workers start together.
cur.execute('BEGIN IMMEDIATE')
cur.execute('''
CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -149,34 +188,29 @@ def init_db():
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()))
conn.commit()
conn.close()
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)
MIGRATIONS = ((1, _initialize_schema), (2, _upgrade_ticket_workflow), (3, _upgrade_ticket_history))
def backup_db():
"""启动时把数据库备份到 backups/,文件名带日期,只保留最近 7 天"""
try:
bdir = get_setting('BACKUP_DIR')
os.makedirs(bdir, exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
dst = os.path.join(bdir, f'history_{today}.db')
if not os.path.exists(dst):
shutil.copy2(get_setting('DATABASE'), dst)
cutoff = datetime.now() - timedelta(days=7)
for f in os.listdir(bdir):
m = re.match('history_(\\d{8})\\.db$', f)
if not m:
continue
try:
if datetime.strptime(m.group(1), '%Y%m%d') < cutoff:
os.remove(os.path.join(bdir, f))
except Exception:
pass
except Exception as e:
print(f'[数据库备份失败] {e}')
"""Verified daily online snapshot; failures propagate rather than claiming success."""
return daily_backup(get_setting('DATABASE'), get_setting('BACKUP_DIR'))
def cleanup_old_conversations():
+4 -1
View File
@@ -2,10 +2,13 @@
import time
from flask import current_app, jsonify, request
from flask import current_app, g, jsonify, request
def rate_limit():
# Authenticated staff polling has a separate trust boundary from public traffic.
if getattr(g, 'staff', None):
return None
path = request.path
if path in ('/healthz', '/api/visitor/human/poll') or path.startswith('/static') or path.startswith('/api/human/'):
return None
+18 -2
View File
@@ -1,11 +1,13 @@
"""Human conversation lifecycle; typing state is supplied by the app."""
import time
from datetime import datetime, timedelta, timezone
from datetime import datetime, datetime as DateTime, timedelta, timezone
from math import ceil
from .db import connect
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
QUEUE_TIMEOUT_SECONDS = 5 * 60
def human_request(data):
@@ -70,10 +72,24 @@ def human_send(data):
def human_queue():
now = datetime.now(SERVICE_TIMEZONE).replace(tzinfo=None)
conn = connect()
rows = conn.execute("SELECT id, conv_id, status, ctime FROM human_sessions WHERE status='waiting' ORDER BY id").fetchall()
conn.close()
return [{'id': r[0], 'conv_id': r[1], 'ctime': r[3]} for r in rows]
queue = []
for row in rows:
try:
created = DateTime.fromisoformat(row[3])
if created.strftime('%Y-%m-%d %H:%M:%S') != row[3]:
raise ValueError('Invalid queue timestamp')
elapsed = max(0, int((now-created).total_seconds()))
except (ValueError, TypeError):
elapsed = 0
overdue = elapsed > QUEUE_TIMEOUT_SECONDS
queue.append({'id':row[0], 'conv_id':row[1], 'ctime':row[3],
'waiting_minutes':elapsed//60, 'overdue':overdue,
'overdue_minutes':max(1,ceil((elapsed-QUEUE_TIMEOUT_SECONDS)/60)) if overdue else 0})
return queue
def human_active():
+14 -3
View File
@@ -1,8 +1,8 @@
"""HTTP adapters for admin; business logic lives in services."""
from flask import Blueprint, Response, render_template, request
from flask import Blueprint, Response, g, render_template, request
from .. import business_service
from .. import business_service, staff_service
bp = Blueprint('admin', __name__)
@@ -13,7 +13,18 @@ def admin_stats():
@bp.route('/admin/login', methods=['POST'])
def admin_login():
return business_service.admin_login(request.json or {})
request.max_content_length = 4096
return staff_service.login(request.get_json(silent=True))
@bp.get('/api/admin/session')
def staff_session():
return {'ok': True, **g.staff}
@bp.post('/admin/logout')
def staff_logout():
return staff_service.logout()
@bp.route('/api/export_chat')
+31 -3
View File
@@ -1,8 +1,8 @@
"""HTTP adapters for business; business logic lives in services."""
from flask import Blueprint, request
from flask import Blueprint, g, request
from .. import business_service, visitor_service
from .. import business_service, ticket_service, visitor_service
bp = Blueprint('business', __name__)
@@ -18,7 +18,35 @@ def add_ticket():
@bp.route('/api/tickets')
def list_tickets():
return business_service.list_tickets()
return business_service.list_tickets(request.args.get('status', 'pending'),
request.args.get('limit', 20), request.args.get('before'),
request.args.get('q', ''), request.args.get('overdue', '0'))
@bp.get('/api/tickets/summary')
def ticket_summary():
return ticket_service.ticket_summary(request.args.get('after'))
@bp.get('/api/tickets/<int:ticket_id>')
def get_ticket(ticket_id):
return ticket_service.get_ticket(ticket_id)
@bp.get('/api/tickets/<int:ticket_id>/events')
def ticket_events(ticket_id):
return ticket_service.ticket_events(ticket_id,request.args.get('limit',20),request.args.get('before'))
@bp.post('/api/tickets/<int:ticket_id>/claim')
def claim_ticket(ticket_id):
return ticket_service.claim_ticket(ticket_id, g.staff)
@bp.patch('/api/tickets/<int:ticket_id>')
def update_ticket(ticket_id):
request.max_content_length = 16384
return ticket_service.update_ticket(ticket_id, request.get_json(silent=True), g.staff)
@bp.route('/api/rate', methods=['POST'])
+161
View File
@@ -0,0 +1,161 @@
"""Independent, revocable staff sessions; only token digests are persisted."""
import hashlib
from hmac import compare_digest
import secrets
import time
from flask import current_app, g, jsonify, redirect, request
from werkzeug.security import check_password_hash
from .db import connect
COOKIE_NAME = 'baiyue_staff'
SESSION_SECONDS = 24 * 3600
PUBLIC_ENDPOINTS = frozenset({
'static', 'pages.index', 'pages.health', 'admin.admin', 'admin.admin_login',
'chat.stream_chat', 'chat.feedback', 'history.new_chat', 'history.get_history',
'history.get_messages', 'history.del_history', 'history.clear_all',
'files.upload_file', 'files.uploaded_file', 'business.add_ticket', 'business.rate_chat',
'visitor.visitor_session', 'visitor.visitor_human', 'customer.session',
'customer.login', 'customer.logout', 'speech.speech_config', 'speech.speech_transcribe',
})
def digest(value):
return hashlib.sha256(value.encode('utf-8')).hexdigest()
def init_staff_tables(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(staff_sessions)')}
required = {'token_hash', 'username', 'auth_stamp', 'csrf_token', 'expires_at'}
if columns and not required.issubset(columns):
# Older versions keyed sessions by agent_id. Revoke incompatible credentials
# and rebuild this disposable auth table; never alter accounts/business data.
connection.execute('DROP TABLE staff_sessions')
connection.execute('''CREATE TABLE IF NOT EXISTS staff_sessions (
token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, auth_stamp TEXT NOT NULL,
csrf_token TEXT NOT NULL, expires_at REAL NOT NULL
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_staff_expiry ON staff_sessions(expires_at)')
# Legacy unlimited sessions have no issue time; require a fresh bounded login.
# Leave valid deadlines untouched so restarts never extend a session.
connection.execute('DELETE FROM staff_sessions WHERE expires_at<=?', (time.time(),))
# Recreating a username or restoring an earlier password must not revive cookies.
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_delete_agent
AFTER DELETE ON agents BEGIN
DELETE FROM staff_sessions WHERE username=OLD.username;
END''')
# SQLite REPLACE may skip DELETE triggers; INSERT still fires in every connection.
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_insert_agent
AFTER INSERT ON agents BEGIN
DELETE FROM staff_sessions WHERE username=NEW.username;
END''')
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_change_credentials
AFTER UPDATE OF username,password ON agents
WHEN OLD.username IS NOT NEW.username OR OLD.password IS NOT NEW.password
BEGIN DELETE FROM staff_sessions WHERE username=OLD.username; END''')
def token_hash():
token = request.cookies.get(COOKIE_NAME, '')
return digest(token) if len(token) == 64 and all(c in '0123456789abcdef' for c in token) else None
def same_origin():
origin = request.headers.get('Origin')
return (not origin or origin.rstrip('/') == request.host_url.rstrip('/')) and request.headers.get('Sec-Fetch-Site') != 'cross-site'
def attach_staff():
"""New/unknown endpoints are staff-only until explicitly reviewed as public."""
g.staff = None
if request.endpoint in PUBLIC_ENDPOINTS:
return None
hashed = token_hash()
if hashed:
connection = connect()
try:
row = connection.execute(
'SELECT s.username,a.name,s.csrf_token,s.auth_stamp,a.password '
'FROM staff_sessions s JOIN agents a ON a.username=s.username '
'WHERE s.token_hash=? AND s.expires_at>?', (hashed, time.time())).fetchone()
finally:
connection.close()
if row and compare_digest(row[3], digest(row[4])):
g.staff = {'username': row[0], 'name': row[1], 'csrf_token': row[2]}
if not g.staff:
if request.path.startswith('/admin/') and request.method in ('GET', 'HEAD'):
return redirect('/admin')
return jsonify({'ok': False, 'error': 'staff_login_required', 'e': '后台登录已失效,请重新登录。'}), 401
if request.method not in ('GET', 'HEAD', 'OPTIONS'):
csrf = request.headers.get('X-CSRF-Token', '')
if not same_origin() or not compare_digest(csrf.encode('utf-8'), g.staff['csrf_token'].encode('utf-8')):
return jsonify({'ok': False, 'error': 'csrf_invalid', 'e': '请求校验失败,请刷新后台页面。'}), 403
def login(data):
if not same_origin():
return {'ok': False}, 403
if not request.is_json:
return {'ok': False}, 415
if not isinstance(data, dict):
return {'ok': False}, 401
username, password = data.get('username'), data.get('password')
if not isinstance(username, str) or not isinstance(password, str) or not 0 < len(username) <= 256 or not 0 < len(password) <= 1024:
return {'ok': False}, 401
attempts = current_app.extensions['staff_login_attempts']
lock = current_app.extensions['staff_login_lock']
# Proxy traffic can share a peer IP. Failures for one account must not lock all staff.
attempt_key = (request.remote_addr or 'unknown', username)
now = time.time()
# Reserve attempts before expensive password hashing, including concurrent requests.
with lock:
for key in list(attempts):
attempts[key] = [instant for instant in attempts[key] if now - instant < 300]
if not attempts[key]:
del attempts[key]
if len(attempts.get(attempt_key, [])) >= 5:
return {'ok': False, 'e': '登录尝试过多,请 5 分钟后重试。'}, 429, {'Retry-After': '300'}
attempts.setdefault(attempt_key, []).append(now)
connection = connect()
try:
row = connection.execute('SELECT name,password FROM agents WHERE username=?', (username,)).fetchone()
valid = False
if row and isinstance(row[1], str) and row[1]:
if row[1].startswith(('pbkdf2:', 'scrypt:')):
try:
valid = check_password_hash(row[1], password)
except (ValueError, TypeError):
pass
else:
valid = compare_digest(row[1].encode('utf-8'), password.encode('utf-8'))
if not valid:
return {'ok': False}, 401
token, csrf = secrets.token_hex(32), secrets.token_hex(32)
connection.execute('DELETE FROM staff_sessions WHERE expires_at<=? OR token_hash=?', (now, token_hash()))
connection.execute('INSERT INTO staff_sessions VALUES(?,?,?,?,?)',
(digest(token), username, digest(row[1]), csrf, now + SESSION_SECONDS))
connection.commit()
finally:
connection.close()
with lock:
attempts.pop(attempt_key, None)
response = jsonify({'ok': True, 'name': row[0]})
response.set_cookie(COOKIE_NAME, token, max_age=SESSION_SECONDS, httponly=True,
secure=request.is_secure, samesite='Lax', path='/')
response.headers['Cache-Control'] = 'no-store'
return response
def logout():
connection = connect()
try:
connection.execute('DELETE FROM staff_sessions WHERE token_hash=?', (token_hash(),))
connection.commit()
finally:
connection.close()
response = jsonify({'ok': True})
response.delete_cookie(COOKIE_NAME, path='/', httponly=True, secure=request.is_secure, samesite='Lax')
response.headers['Cache-Control'] = 'no-store'
return response
+243
View File
@@ -0,0 +1,243 @@
"""Staff-owned offline ticket lifecycle and optimistic updates."""
from contextlib import closing
from datetime import datetime, datetime as DateTime, timedelta
from math import ceil
from .db import connect
STATES = ('pending', 'processing', 'completed')
MAX_TICKET_ID = 2**63 - 1
COLUMNS = ('id', 'content', 'phone', 'ctime', 'status', 'owner_username', 'owner_name',
'note', 'updated_at', 'completed_at', 'version', 'claimed_at', 'claimed_at_estimated')
EVENT_COLUMNS = ('id', 'ticket_id', 'action', 'actor_username', 'actor_name', 'ctime',
'old_status', 'new_status', 'old_note', 'new_note')
PENDING_TIMEOUT = 30 * 60
PROCESSING_TIMEOUT = 20 * 60
def init_ticket_tables(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(tickets)')}
additions = {'owner_username':'TEXT', 'owner_name':"TEXT NOT NULL DEFAULT ''",
'note':"TEXT NOT NULL DEFAULT ''", 'updated_at':'TEXT',
'completed_at':'TEXT', 'version':'INTEGER NOT NULL DEFAULT 0'}
for name, declaration in additions.items():
if name not in columns:
connection.execute(f'ALTER TABLE tickets ADD COLUMN {name} {declaration}')
connection.execute("UPDATE tickets SET status='pending' WHERE status IS NULL OR status=''")
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_status_id ON tickets(status,id)')
def init_ticket_history(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(tickets)')}
if 'claimed_at' not in columns:
connection.execute('ALTER TABLE tickets ADD COLUMN claimed_at TEXT')
if 'claimed_at_estimated' not in columns:
connection.execute('ALTER TABLE tickets ADD COLUMN claimed_at_estimated INTEGER NOT NULL DEFAULT 0')
connection.execute('''CREATE TABLE IF NOT EXISTS ticket_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL,
action TEXT NOT NULL, actor_username TEXT, actor_name TEXT NOT NULL,
ctime TEXT NOT NULL, old_status TEXT, new_status TEXT,
old_note TEXT NOT NULL DEFAULT '', new_note TEXT NOT NULL DEFAULT ''
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_events ON ticket_events(ticket_id,id)')
# A legacy baseline records only the known state at upgrade, never invented actions.
connection.execute('''UPDATE tickets SET claimed_at=updated_at,claimed_at_estimated=1
WHERE status='processing' AND claimed_at IS NULL AND datetime(updated_at,'+0 seconds')=updated_at''')
connection.execute('''INSERT INTO ticket_events(ticket_id,action,actor_name,ctime,new_status,new_note)
SELECT id,'legacy_snapshot','系统',?,status,COALESCE(note,'') FROM tickets
WHERE NOT EXISTS(SELECT 1 FROM ticket_events e WHERE e.ticket_id=tickets.id)''',
(datetime.now().strftime('%Y-%m-%d %H:%M:%S'),))
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_pending_time ON tickets(status,ctime)')
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_claimed_time ON tickets(status,claimed_at)')
def record_event(connection, ticket_id, action, now, staff=None, old_status=None,
new_status=None, old_note='', new_note=''):
connection.execute('''INSERT INTO ticket_events(ticket_id,action,actor_username,actor_name,ctime,
old_status,new_status,old_note,new_note) VALUES(?,?,?,?,?,?,?,?,?)''',
(ticket_id, action, staff['username'] if staff else None, staff['name'] if staff else '客户',
now, old_status, new_status, old_note, new_note))
def create_ticket(content, phone):
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with closing(connect()) as connection:
cursor = connection.execute('INSERT INTO tickets(content,phone,ctime,status) VALUES(?,?,?,?)',
(content, phone, now, 'pending'))
record_event(connection, cursor.lastrowid, 'submitted', now, new_status='pending')
connection.commit()
return {'ok': True}
def _with_overdue(ticket, now=None):
ticket.update(overdue_kind=None, overdue_minutes=0)
anchor = ticket['ctime'] if ticket['status'] == 'pending' else ticket['claimed_at']
threshold = PENDING_TIMEOUT if ticket['status'] == 'pending' else PROCESSING_TIMEOUT
if ticket['status'] not in ('pending', 'processing') or not anchor:
return ticket
try:
parsed = DateTime.fromisoformat(anchor)
if parsed.strftime('%Y-%m-%d %H:%M:%S') != anchor:
return ticket
elapsed = ((now or datetime.now()).replace(microsecond=0) - parsed).total_seconds()
except (ValueError, TypeError):
return ticket
if elapsed > threshold:
ticket.update(overdue_kind=ticket['status'], overdue_minutes=max(1,ceil((elapsed-threshold)/60)))
return ticket
def _overdue_predicate():
return "((status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime<?) OR " \
"(status='processing' AND datetime(claimed_at,'+0 seconds')=claimed_at AND claimed_at<?))"
def _cutoffs(now):
return tuple((now-timedelta(seconds=seconds)).strftime('%Y-%m-%d %H:%M:%S')
for seconds in (PENDING_TIMEOUT, PROCESSING_TIMEOUT))
def _ticket(connection, ticket_id):
if not 0 < ticket_id <= MAX_TICKET_ID:
return None
row = connection.execute('SELECT ' + ','.join(COLUMNS) + ' FROM tickets WHERE id=?', (ticket_id,)).fetchone()
return _with_overdue(dict(zip(COLUMNS, row))) if row else None
def get_ticket(ticket_id):
with closing(connect()) as connection:
ticket = _ticket(connection, ticket_id)
return {'ok': True, 'ticket': ticket} if ticket else ({'ok': False, 'error':'留言不存在。'}, 404)
def list_tickets(status='pending', limit=20, before=None, query='', overdue='0'):
if overdue not in ('0','1'):
return {'ok': False, 'error':'超时筛选参数无效。'}, 400
if not isinstance(query, str) or len(query) > 100:
return {'ok': False, 'error':'搜索关键词最多 100 字。'}, 400
if status not in (*STATES, 'all'):
return {'ok': False, 'error':'留言状态无效。'}, 400
try:
limit = int(limit)
before = int(before) if before is not None else None
except (ValueError, TypeError):
return {'ok': False, 'error':'分页参数无效。'}, 400
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_TICKET_ID):
return {'ok': False, 'error':'分页参数无效。'}, 400
clauses, parameters = [], []
now = datetime.now()
if overdue == '1':
clauses.append(_overdue_predicate())
parameters.extend(_cutoffs(now))
if status != 'all':
clauses.append('status=?')
parameters.append(status)
if before is not None:
clauses.append('id<?')
parameters.append(before)
if query.strip():
literal = query.strip().replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
fields = ('content', 'phone', 'owner_username', 'owner_name', 'note')
clauses.append('(' + ' OR '.join(f"{field} LIKE ? ESCAPE '\\'" for field in fields) + ')')
parameters.extend(['%' + literal + '%'] * len(fields))
where = ' WHERE ' + ' AND '.join(clauses) if clauses else ''
with closing(connect()) as connection:
rows = connection.execute('SELECT ' + ','.join(COLUMNS) + ' FROM tickets' + where +
' ORDER BY id DESC LIMIT ?', (*parameters, limit)).fetchall()
return [_with_overdue(dict(zip(COLUMNS, row)), now) for row in rows]
def ticket_summary(after=None):
if after is not None:
try:
after = int(after)
except (TypeError, ValueError):
return {'ok': False, 'error':'留言提醒游标无效。'}, 400
if not 0 <= after <= MAX_TICKET_ID:
return {'ok': False, 'error':'留言提醒游标无效。'}, 400
# One snapshot, independent of the list's status, search and page cursor.
pending_cutoff, processing_cutoff = _cutoffs(datetime.now())
with closing(connect()) as connection:
pending, latest, new, pending_overdue, processing_overdue = connection.execute(
"SELECT (SELECT COUNT(*) FROM tickets WHERE status='pending'),"
'(SELECT COALESCE(MAX(id),0) FROM tickets),'
'(SELECT COUNT(*) FROM tickets WHERE id>?),'
"(SELECT COUNT(*) FROM tickets WHERE status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime<?),"
"(SELECT COUNT(*) FROM tickets WHERE status='processing' AND datetime(claimed_at,'+0 seconds')=claimed_at AND claimed_at<?)",
(after,pending_cutoff,processing_cutoff)).fetchone()
return {'pending_count':pending, 'latest_id':latest, 'new_count':new,
'overdue_count':pending_overdue+processing_overdue,
'pending_overdue_count':pending_overdue, 'processing_overdue_count':processing_overdue}
def ticket_events(ticket_id, limit=20, before=None):
try:
limit = int(limit)
before = int(before) if before is not None else None
except (ValueError, TypeError):
return {'ok':False, 'error':'记录分页参数无效。'}, 400
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_TICKET_ID):
return {'ok':False, 'error':'记录分页参数无效。'}, 400
with closing(connect()) as connection:
if not _ticket(connection,ticket_id):
return {'ok':False, 'error':'留言不存在。'}, 404
rows = connection.execute('SELECT '+','.join(EVENT_COLUMNS)+' FROM ticket_events WHERE ticket_id=?'+
(' AND id<?' if before is not None else '')+' ORDER BY id DESC LIMIT ?',
(ticket_id, before, limit) if before is not None else (ticket_id,limit)).fetchall()
return [dict(zip(EVENT_COLUMNS,row)) for row in rows]
def claim_ticket(ticket_id, staff):
if not 0 < ticket_id <= MAX_TICKET_ID:
return {'ok': False, 'error':'留言不存在。'}, 404
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with closing(connect()) as connection:
# The guarded write makes simultaneous claims select exactly one owner.
changed = connection.execute("UPDATE tickets SET status='processing',owner_username=?,"
"owner_name=?,updated_at=?,claimed_at=?,claimed_at_estimated=0,version=version+1 WHERE id=? AND status='pending' "
'AND owner_username IS NULL', (staff['username'], staff['name'], now, now, ticket_id)).rowcount
ticket = _ticket(connection, ticket_id)
if not changed:
return ({'ok': False, 'error':'留言已被领取或已完成,请刷新查看。'}, 409) if ticket else (
{'ok': False, 'error':'留言不存在。'}, 404)
record_event(connection,ticket_id,'claimed',now,staff,old_status='pending',new_status='processing')
connection.commit()
return {'ok': True, 'ticket': ticket}
def update_ticket(ticket_id, data, staff):
if (not isinstance(data, dict) or set(data) - {'version', 'note', 'status'}
or type(data.get('version')) is not int or data['version'] < 0
or not ({'note', 'status'} & set(data))):
return {'ok': False, 'error':'留言更新格式无效,请刷新后重试。'}, 400
if 'note' in data and (not isinstance(data['note'], str) or len(data['note']) > 2000):
return {'ok': False, 'error':'处理备注最多 2000 字。'}, 400
status = data.get('status', 'processing')
if status not in ('processing', 'completed'):
return {'ok': False, 'error':'处理状态无效。'}, 400
with closing(connect()) as connection:
connection.execute('BEGIN IMMEDIATE')
ticket = _ticket(connection, ticket_id)
if not ticket:
return {'ok': False, 'error':'留言不存在。'}, 404
if ticket['status'] != 'processing':
return {'ok': False, 'error':'请先领取留言,已完成留言不能修改。'}, 409
if ticket['owner_username'] != staff['username']:
return {'ok': False, 'error':'只有负责人可以处理这条留言。'}, 403
if ticket['version'] != data['version']:
return {'ok': False, 'error':'留言已在其他页面更新,请刷新后核对备注。'}, 409
note = data.get('note', ticket['note']).strip()
if status == 'completed' and not note:
return {'ok': False, 'error':'请先填写处理结果,再标记完成。'}, 400
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
connection.execute('UPDATE tickets SET note=?,status=?,updated_at=?,completed_at=?,version=version+1 '
'WHERE id=?', (note, status, now, now if status == 'completed' else None, ticket_id))
if note != ticket['note']:
record_event(connection,ticket_id,'note_updated',now,staff,old_note=ticket['note'],new_note=note)
if status == 'completed':
record_event(connection,ticket_id,'completed',now,staff,old_status='processing',new_status='completed',
new_note=note)
ticket = _ticket(connection, ticket_id)
connection.commit()
return {'ok': True, 'ticket': ticket}