保存今天的修改
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user