调整客服自动接待和手动排队规则
This commit is contained in:
@@ -8,7 +8,7 @@ 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
|
||||
SCHEMA_VERSION = 5
|
||||
|
||||
|
||||
def connect():
|
||||
@@ -64,6 +64,7 @@ def init_db():
|
||||
_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')
|
||||
@@ -210,8 +211,13 @@ def _upgrade_order_admin(conn):
|
||||
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))
|
||||
(3, _upgrade_ticket_history), (4, _upgrade_order_admin), (5, _upgrade_staff_routing))
|
||||
|
||||
|
||||
def backup_db():
|
||||
|
||||
@@ -4,7 +4,10 @@ import time
|
||||
from datetime import datetime, datetime as DateTime, timedelta, timezone
|
||||
from math import ceil
|
||||
|
||||
from flask import g
|
||||
|
||||
from .db import connect
|
||||
from . import routing_service
|
||||
|
||||
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
|
||||
QUEUE_TIMEOUT_SECONDS = 5 * 60
|
||||
@@ -29,21 +32,24 @@ def human_request(data):
|
||||
else:
|
||||
cur = conn.execute('INSERT INTO human_sessions(conv_id, status, ctime) VALUES(?,?,?)', (conv_id, 'waiting', now))
|
||||
sid = cur.lastrowid
|
||||
routing_service.assign_new_session(conn, sid)
|
||||
status = conn.execute('SELECT status FROM human_sessions WHERE id=?', (sid,)).fetchone()[0]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True, 'session_id': sid}
|
||||
return {'ok': True, 'session_id': sid, 'status': status}
|
||||
|
||||
|
||||
def human_poll(sid, after, typing_map):
|
||||
conn = connect()
|
||||
msgs = conn.execute('SELECT id, side, content, ctime, read_at, recalled_id FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
|
||||
st = conn.execute("SELECT status, COALESCE(note, '') FROM human_sessions WHERE id=?", (sid,)).fetchone()
|
||||
st = conn.execute("SELECT status, COALESCE(note, ''),owner_username FROM human_sessions WHERE id=?", (sid,)).fetchone()
|
||||
conn.close()
|
||||
is_typing = False
|
||||
ts = typing_map.get(str(sid))
|
||||
if ts and time.time() - ts < 5:
|
||||
is_typing = True
|
||||
return {'status': st[0] if st else 'closed', 'note': st[1] if st else '',
|
||||
'owner_username': st[2] if st else None,
|
||||
'typing': is_typing, 'messages': [dict(id=m[0], side=m[1], content=m[2], ctime=m[3],
|
||||
read=bool(m[4]), **({'recalled_id': m[5]} if m[5] else {})) for m in msgs]}
|
||||
|
||||
@@ -65,6 +71,11 @@ def human_send(data):
|
||||
if row[1] == 'closed' or (data.get('conv_id') is not None and data['conv_id'] != row[0]):
|
||||
conn.close()
|
||||
return ({'ok': False, 'error': 'conversation_changed'}, 409)
|
||||
if getattr(g, 'staff', None):
|
||||
error = routing_service.owner_error(conn, sid, g.staff['username'])
|
||||
if error:
|
||||
conn.close()
|
||||
return error
|
||||
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -74,7 +85,9 @@ 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()
|
||||
rows = conn.execute("SELECT h.id,h.conv_id,h.status,h.ctime,h.group_id,g.name FROM human_sessions h "
|
||||
"JOIN support_groups g ON g.id=h.group_id WHERE h.status='waiting' "
|
||||
"OR (h.status='active' AND h.owner_username IS NULL) ORDER BY h.id").fetchall()
|
||||
conn.close()
|
||||
queue = []
|
||||
for row in rows:
|
||||
@@ -87,6 +100,7 @@ def human_queue():
|
||||
elapsed = 0
|
||||
overdue = elapsed > QUEUE_TIMEOUT_SECONDS
|
||||
queue.append({'id':row[0], 'conv_id':row[1], 'ctime':row[3],
|
||||
'group_id':row[4], 'group_name':row[5],
|
||||
'waiting_minutes':elapsed//60, 'overdue':overdue,
|
||||
'overdue_minutes':max(1,ceil((elapsed-QUEUE_TIMEOUT_SECONDS)/60)) if overdue else 0})
|
||||
return queue
|
||||
@@ -94,25 +108,43 @@ def human_queue():
|
||||
|
||||
def human_active():
|
||||
conn = connect()
|
||||
rows = conn.execute("SELECT id, conv_id FROM human_sessions WHERE status='active' ORDER BY id").fetchall()
|
||||
rows = conn.execute("SELECT h.id,h.conv_id,h.owner_username,h.group_id,g.name FROM human_sessions h "
|
||||
"JOIN support_groups g ON g.id=h.group_id WHERE h.status='active' "
|
||||
"AND h.owner_username=? ORDER BY h.id", (g.staff['username'],)).fetchall()
|
||||
conn.close()
|
||||
return [{'id': r[0], 'conv_id': r[1]} for r in rows]
|
||||
return [{'id': r[0], 'conv_id': r[1], 'owner_username':r[2], 'group_id':r[3], 'group_name':r[4]} for r in rows]
|
||||
|
||||
|
||||
def human_accept(data):
|
||||
sid = data.get('sid')
|
||||
conn = connect()
|
||||
changed = conn.execute("UPDATE human_sessions SET status='active' WHERE id=? AND status IN ('waiting','active') "
|
||||
'AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)', (sid,)).rowcount
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
row = conn.execute("SELECT group_id,owner_username,status FROM human_sessions WHERE id=? "
|
||||
"AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)", (sid,)).fetchone()
|
||||
if not row or row[2] == 'closed' or (row[1] and row[1] != g.staff['username']):
|
||||
conn.close()
|
||||
return {'ok': False, 'error': '会话已由其他客服接入或已结束,请刷新。'}, 409
|
||||
if not row[1]:
|
||||
# Explicit manual claims remain available when staff are offline.
|
||||
routing_service.assign_owner(conn, sid, g.staff['username'], row[0], 'claim', g.staff['username'])
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True} if changed else ({'ok':False, 'error':'session_closed_or_missing'}, 409)
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
def human_close(data):
|
||||
sid = data.get('sid')
|
||||
conn = connect()
|
||||
conn.execute("UPDATE human_sessions SET status='closed' WHERE id=?", (sid,))
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
if getattr(g, 'staff', None):
|
||||
error = routing_service.owner_error(conn, sid, g.staff['username'], allow_unassigned=True)
|
||||
if error:
|
||||
conn.close()
|
||||
return error
|
||||
changed = conn.execute("UPDATE human_sessions SET status='closed',routing_version=routing_version+1 "
|
||||
"WHERE id=? AND status!='closed'", (sid,)).rowcount
|
||||
if changed:
|
||||
routing_service.record_event(conn, sid, 'close', g.staff['username'] if getattr(g, 'staff', None) else 'customer')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
@@ -121,12 +153,19 @@ def human_close(data):
|
||||
def human_transfer(data, typing_map):
|
||||
sid = data.get('sid')
|
||||
conn = connect()
|
||||
changed = conn.execute("UPDATE human_sessions SET status='waiting' WHERE id=? AND status='active'", (sid,)).rowcount
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
error = routing_service.owner_error(conn, sid, g.staff['username'])
|
||||
if error:
|
||||
conn.close()
|
||||
return error
|
||||
changed = conn.execute("UPDATE human_sessions SET status='waiting',previous_owner=owner_username,owner_username=NULL,"
|
||||
"assigned_at=NULL,routing_version=routing_version+1 WHERE id=? AND status='active'", (sid,)).rowcount
|
||||
if not changed:
|
||||
conn.close()
|
||||
return ({'ok': False, 'error': '会话当前无法转接'}, 409)
|
||||
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)',
|
||||
(sid, 'system', '客服已转接,请其他客服接入', datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
|
||||
routing_service.record_event(conn, sid, 'transfer', g.staff['username'])
|
||||
conn.commit()
|
||||
conn.close()
|
||||
typing_map.pop(str(sid), None)
|
||||
@@ -138,6 +177,11 @@ def human_note(data):
|
||||
if not isinstance(note, str):
|
||||
return ({'ok': False}, 400)
|
||||
conn = connect()
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
error = routing_service.owner_error(conn, data.get('sid'), g.staff['username'])
|
||||
if error:
|
||||
conn.close()
|
||||
return error
|
||||
changed = conn.execute('UPDATE human_sessions SET note=? WHERE id=?', (note, data.get('sid'))).rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -153,6 +197,10 @@ def human_recall(data):
|
||||
if not row:
|
||||
conn.close()
|
||||
return ({'ok': False}, 404)
|
||||
error = routing_service.owner_error(conn, row[0], g.staff['username'])
|
||||
if error:
|
||||
conn.close()
|
||||
return error
|
||||
if row[1] == 'system' and row[2] == '该消息已撤回':
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
@@ -169,12 +217,18 @@ def human_recall(data):
|
||||
|
||||
def human_typing(data, typing_map):
|
||||
sid = data.get('sid')
|
||||
error = _typing_owner_error(sid)
|
||||
if error:
|
||||
return error
|
||||
typing_map[str(sid)] = time.time()
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
def human_stop_typing(data, typing_map):
|
||||
sid = data.get('sid')
|
||||
error = _typing_owner_error(sid)
|
||||
if error:
|
||||
return error
|
||||
typing_map.pop(str(sid), None)
|
||||
return {'ok': True}
|
||||
|
||||
@@ -182,7 +236,20 @@ def human_stop_typing(data, typing_map):
|
||||
def human_read(data):
|
||||
sid = data.get('sid')
|
||||
conn = connect()
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
error = routing_service.owner_error(conn, sid, g.staff['username'])
|
||||
if error:
|
||||
conn.close()
|
||||
return error
|
||||
conn.execute("UPDATE human_messages SET read_at=? WHERE session_id=? AND side='customer' AND read_at IS NULL", (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), sid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
def _typing_owner_error(sid):
|
||||
connection = connect()
|
||||
try:
|
||||
return routing_service.owner_error(connection, sid, g.staff['username'])
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
from . import admin, business, chat, customer, files, history, human, pages, speech, visitor
|
||||
for module in (admin, business, chat, customer, files, history, human, pages, speech, visitor):
|
||||
from . import admin, business, chat, customer, files, history, human, pages, routing, speech, visitor
|
||||
for module in (admin, business, chat, customer, files, history, human, pages, routing, speech, visitor):
|
||||
app.register_blueprint(module.bp)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Staff-only routing adapters; default-deny authentication and CSRF run first."""
|
||||
|
||||
from flask import Blueprint, g, request
|
||||
|
||||
from .. import routing_service, staff_service
|
||||
|
||||
bp = Blueprint('routing', __name__)
|
||||
|
||||
|
||||
@bp.get('/api/admin/routing')
|
||||
def snapshot():
|
||||
return routing_service.routing_snapshot(g.staff['username'], staff_service.token_hash())
|
||||
|
||||
|
||||
@bp.post('/api/admin/routing/presence')
|
||||
def presence():
|
||||
return routing_service.update_presence(g.staff['username'], staff_service.token_hash(), request.get_json(silent=True))
|
||||
|
||||
|
||||
@bp.post('/api/admin/routing/groups')
|
||||
def create_group():
|
||||
return routing_service.create_group(request.get_json(silent=True))
|
||||
|
||||
|
||||
@bp.post('/api/admin/routing/members')
|
||||
def move_member():
|
||||
return routing_service.move_member(request.get_json(silent=True))
|
||||
|
||||
|
||||
@bp.post('/api/admin/routing/assign')
|
||||
def assign():
|
||||
return routing_service.reassign_session(request.get_json(silent=True), g.staff['username'])
|
||||
@@ -39,6 +39,7 @@ def visitor_human(action):
|
||||
return {'ok': False}, 400
|
||||
result = human_service.human_poll(sid, after, current_app.extensions['human_typing'])
|
||||
result.pop('note', None)
|
||||
result.pop('owner_username', None)
|
||||
return result
|
||||
if action == 'send':
|
||||
if data.get('side', 'customer') != 'customer':
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Persistent staff availability, group membership and serialized conversation assignment."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
from .db import connect
|
||||
|
||||
PRESENCE_SECONDS = 90
|
||||
CHINA_TIME = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def init_routing_tables(connection):
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS support_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE
|
||||
)''')
|
||||
connection.execute("INSERT OR IGNORE INTO support_groups(id,name) VALUES(1,'综合客服')")
|
||||
# Separate profiles retain compatibility with legacy three-column agents tables.
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS staff_routing (
|
||||
username TEXT PRIMARY KEY, group_id INTEGER NOT NULL DEFAULT 1,
|
||||
last_assigned REAL NOT NULL DEFAULT 0
|
||||
)''')
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS staff_presence (
|
||||
token_hash TEXT PRIMARY KEY, username TEXT NOT NULL,
|
||||
online INTEGER NOT NULL DEFAULT 0, last_seen REAL NOT NULL
|
||||
)''')
|
||||
connection.execute('CREATE INDEX IF NOT EXISTS idx_presence_username ON staff_presence(username)')
|
||||
columns = {row[1] for row in connection.execute('PRAGMA table_info(human_sessions)')}
|
||||
for name, definition in (
|
||||
('group_id', 'INTEGER NOT NULL DEFAULT 1'), ('owner_username', 'TEXT'),
|
||||
('assigned_at', 'TEXT'), ('routing_version', 'INTEGER NOT NULL DEFAULT 0'),
|
||||
('previous_owner', 'TEXT')):
|
||||
if name not in columns:
|
||||
connection.execute(f'ALTER TABLE human_sessions ADD COLUMN {name} {definition}')
|
||||
connection.execute('CREATE INDEX IF NOT EXISTS idx_human_routing ON human_sessions(status,group_id,owner_username,id)')
|
||||
connection.execute('CREATE INDEX IF NOT EXISTS idx_human_owner ON human_sessions(owner_username,status)')
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS routing_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL, actor TEXT NOT NULL, group_id INTEGER NOT NULL,
|
||||
owner_username TEXT, ctime TEXT NOT NULL
|
||||
)''')
|
||||
connection.execute('CREATE INDEX IF NOT EXISTS idx_routing_events_session ON routing_events(session_id,id)')
|
||||
connection.execute('''CREATE TRIGGER IF NOT EXISTS routing_delete_agent AFTER DELETE ON agents BEGIN
|
||||
DELETE FROM staff_routing WHERE username=OLD.username;
|
||||
DELETE FROM staff_presence WHERE username=OLD.username;
|
||||
UPDATE human_sessions SET status='waiting', owner_username=NULL, assigned_at=NULL,
|
||||
previous_owner=NULL, routing_version=routing_version+1
|
||||
WHERE owner_username=OLD.username AND status='active';
|
||||
END''')
|
||||
|
||||
|
||||
def timestamp():
|
||||
return datetime.now(CHINA_TIME).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
def record_event(connection, sid, action, actor):
|
||||
connection.execute('''INSERT INTO routing_events(session_id,action,actor,group_id,owner_username,ctime)
|
||||
SELECT id,?,?,group_id,owner_username,? FROM human_sessions WHERE id=?''',
|
||||
(action, actor, timestamp(), sid))
|
||||
|
||||
|
||||
def _online_agents(connection, instant):
|
||||
return {row[0] for row in connection.execute('''SELECT DISTINCT p.username
|
||||
FROM staff_presence p JOIN staff_sessions s ON s.token_hash=p.token_hash AND s.username=p.username
|
||||
JOIN agents a ON a.username=p.username
|
||||
WHERE p.online=1 AND p.last_seen>? AND s.expires_at>?''',
|
||||
(instant - PRESENCE_SECONDS, instant))}
|
||||
|
||||
|
||||
def _agents(connection, instant):
|
||||
online = _online_agents(connection, instant)
|
||||
return [dict(username=row[0], name=row[1], group_id=row[2], group_name=row[3],
|
||||
active_count=row[4], online=row[0] in online, last_assigned=row[5])
|
||||
for row in connection.execute('''SELECT a.username,a.name,COALESCE(r.group_id,1),g.name,
|
||||
(SELECT COUNT(*) FROM human_sessions h WHERE h.owner_username=a.username AND h.status='active'),
|
||||
COALESCE(r.last_assigned,0)
|
||||
FROM agents a LEFT JOIN staff_routing r ON r.username=a.username
|
||||
JOIN support_groups g ON g.id=COALESCE(r.group_id,1) ORDER BY a.username''')]
|
||||
|
||||
|
||||
def assign_owner(connection, sid, username, group_id, action, actor):
|
||||
connection.execute('''UPDATE human_sessions SET owner_username=?,group_id=?,status='active',
|
||||
assigned_at=?,previous_owner=NULL,routing_version=routing_version+1 WHERE id=?''',
|
||||
(username, group_id, timestamp(), sid))
|
||||
connection.execute('''INSERT INTO staff_routing(username,group_id,last_assigned) VALUES(?,1,?)
|
||||
ON CONFLICT(username) DO UPDATE SET last_assigned=excluded.last_assigned''',
|
||||
(username, time.time()))
|
||||
record_event(connection, sid, action, actor)
|
||||
|
||||
|
||||
def assign_new_session(connection, sid):
|
||||
"""Try once at creation under BEGIN IMMEDIATE; existing queue needs manual acceptance."""
|
||||
row = connection.execute('''SELECT group_id FROM human_sessions
|
||||
WHERE id=? AND status='waiting' AND owner_username IS NULL
|
||||
AND EXISTS(SELECT 1 FROM chat_history c WHERE c.id=human_sessions.conv_id)''', (sid,)).fetchone()
|
||||
if not row:
|
||||
return
|
||||
eligible = [agent for agent in _agents(connection, time.time())
|
||||
if agent['online'] and agent['group_id'] == row[0] and agent['active_count'] == 0]
|
||||
if eligible:
|
||||
selected = min(eligible, key=lambda agent: (agent['last_assigned'], agent['username']))
|
||||
assign_owner(connection, sid, selected['username'], row[0], 'auto_assign', 'system')
|
||||
|
||||
|
||||
def _presence(connection, token, instant):
|
||||
row = connection.execute('''SELECT online,last_seen FROM staff_presence WHERE token_hash=?''', (token,)).fetchone()
|
||||
return {'online': bool(row and row[0] and row[1] > instant - PRESENCE_SECONDS)}
|
||||
|
||||
|
||||
def routing_snapshot(username, token):
|
||||
connection = connect()
|
||||
try:
|
||||
connection.execute('BEGIN')
|
||||
groups = [dict(id=row[0], name=row[1]) for row in connection.execute('SELECT id,name FROM support_groups ORDER BY id')]
|
||||
sessions = [dict(id=row[0], status=row[1], group_id=row[2], group_name=row[3],
|
||||
owner_username=row[4], owner_name=row[5], version=row[6], ctime=row[7])
|
||||
for row in connection.execute('''SELECT h.id,h.status,h.group_id,g.name,h.owner_username,
|
||||
a.name,h.routing_version,h.ctime FROM human_sessions h
|
||||
JOIN support_groups g ON g.id=h.group_id LEFT JOIN agents a ON a.username=h.owner_username
|
||||
WHERE h.status IN ('waiting','active') ORDER BY h.id''')]
|
||||
events = [dict(id=row[0], session_id=row[1], action=row[2], actor=row[3], group_name=row[4],
|
||||
owner_username=row[5], ctime=row[6]) for row in connection.execute('''SELECT e.id,e.session_id,
|
||||
e.action,e.actor,g.name,e.owner_username,e.ctime FROM routing_events e
|
||||
JOIN support_groups g ON g.id=e.group_id ORDER BY e.id DESC LIMIT 50''')]
|
||||
return {'ok': True, 'username': username, 'presence': _presence(connection, token, time.time()),
|
||||
'groups': groups, 'agents': _agents(connection, time.time()), 'sessions': sessions, 'events': events}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def update_presence(username, token, data):
|
||||
if not isinstance(data, dict) or ('online' in data and type(data['online']) is not bool):
|
||||
return {'ok': False, 'error': '请选择在线或离线。'}, 400
|
||||
instant = time.time()
|
||||
connection = connect()
|
||||
try:
|
||||
connection.execute('BEGIN IMMEDIATE')
|
||||
if 'online' in data:
|
||||
# Going offline applies across this account's logged-in browsers.
|
||||
if not data['online']:
|
||||
connection.execute('UPDATE staff_presence SET online=0 WHERE username=?', (username,))
|
||||
connection.execute('''INSERT INTO staff_presence VALUES(?,?,?,?)
|
||||
ON CONFLICT(token_hash) DO UPDATE SET online=excluded.online,last_seen=excluded.last_seen''',
|
||||
(token, username, int(data['online']), instant))
|
||||
else:
|
||||
# Heartbeats never turn an offline/expired presence back on implicitly.
|
||||
connection.execute('''UPDATE staff_presence SET online=CASE WHEN last_seen>? THEN online ELSE 0 END,
|
||||
last_seen=? WHERE token_hash=?''', (instant - PRESENCE_SECONDS, instant, token))
|
||||
connection.execute('DELETE FROM staff_presence WHERE NOT EXISTS '
|
||||
'(SELECT 1 FROM staff_sessions s WHERE s.token_hash=staff_presence.token_hash AND s.expires_at>?)', (instant,))
|
||||
result = {'ok': True, **_presence(connection, token, instant)}
|
||||
connection.commit()
|
||||
return result
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def create_group(data):
|
||||
name = data.get('name') if isinstance(data, dict) else None
|
||||
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 40:
|
||||
return {'ok': False, 'error': '分组名称需为 1–40 字。'}, 400
|
||||
connection = connect()
|
||||
try:
|
||||
try:
|
||||
cursor = connection.execute('INSERT INTO support_groups(name) VALUES(?)', (name.strip(),))
|
||||
connection.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
return {'ok': False, 'error': '已存在同名分组。'}, 409
|
||||
return {'ok': True, 'group': {'id': cursor.lastrowid, 'name': name.strip()}}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def _positive_id(value):
|
||||
return type(value) is int and 0 < value <= 9223372036854775807
|
||||
|
||||
|
||||
def move_member(data):
|
||||
if not isinstance(data, dict) or not isinstance(data.get('username'), str) or not _positive_id(data.get('group_id')):
|
||||
return {'ok': False, 'error': '请选择客服与分组。'}, 400
|
||||
connection = connect()
|
||||
try:
|
||||
connection.execute('BEGIN IMMEDIATE')
|
||||
if not connection.execute('SELECT 1 FROM agents WHERE username=?', (data['username'],)).fetchone() or not connection.execute(
|
||||
'SELECT 1 FROM support_groups WHERE id=?', (data['group_id'],)).fetchone():
|
||||
return {'ok': False, 'error': '客服或分组已不存在,请刷新。'}, 404
|
||||
connection.execute('''INSERT INTO staff_routing(username,group_id) VALUES(?,?)
|
||||
ON CONFLICT(username) DO UPDATE SET group_id=excluded.group_id''', (data['username'], data['group_id']))
|
||||
connection.commit()
|
||||
return {'ok': True}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def reassign_session(data, actor):
|
||||
if not isinstance(data, dict) or not _positive_id(data.get('sid')) or not _positive_id(data.get('group_id')) or (
|
||||
type(data.get('version')) is not int or data['version'] < 0 or data['version'] > 9223372036854775807) or (
|
||||
data.get('username') is not None and not isinstance(data['username'], str)):
|
||||
return {'ok': False, 'error': '请选择会话、目标分组与负责人。'}, 400
|
||||
connection = connect()
|
||||
try:
|
||||
connection.execute('BEGIN IMMEDIATE')
|
||||
row = connection.execute('SELECT status,routing_version FROM human_sessions WHERE id=?', (data['sid'],)).fetchone()
|
||||
if not row or row[0] == 'closed' or row[1] != data['version']:
|
||||
return {'ok': False, 'error': '会话已更新或结束,请刷新后重新选择。'}, 409
|
||||
if not connection.execute('SELECT 1 FROM support_groups WHERE id=?', (data['group_id'],)).fetchone():
|
||||
return {'ok': False, 'error': '目标分组已不存在,请刷新。'}, 404
|
||||
username = data.get('username') or None
|
||||
if username:
|
||||
selected = next((agent for agent in _agents(connection, time.time()) if agent['username'] == username), None)
|
||||
if not selected or not selected['online'] or selected['group_id'] != data['group_id']:
|
||||
return {'ok': False, 'error': '目标客服需在所选组内且处于在线状态。'}, 409
|
||||
assign_owner(connection, data['sid'], username, data['group_id'], 'reassign', actor)
|
||||
else:
|
||||
connection.execute('''UPDATE human_sessions SET group_id=?,owner_username=NULL,status='waiting',
|
||||
assigned_at=NULL,previous_owner=NULL,routing_version=routing_version+1 WHERE id=?''',
|
||||
(data['group_id'], data['sid']))
|
||||
record_event(connection, data['sid'], 'queue', actor)
|
||||
connection.commit()
|
||||
return {'ok': True}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def owner_error(connection, sid, username, allow_unassigned=False):
|
||||
"""Call inside a write transaction; assigned sessions cannot be operated by another staff member."""
|
||||
row = connection.execute('SELECT owner_username FROM human_sessions WHERE id=?', (sid,)).fetchone()
|
||||
if row and not row[0] and not allow_unassigned:
|
||||
return {'ok': False, 'error': '请先接入会话,再进行操作。'}, 409
|
||||
if row and row[0] and row[0] != username:
|
||||
return {'ok': False, 'error': '会话已由其他客服负责,请刷新会话列表。'}, 409
|
||||
return None
|
||||
@@ -153,6 +153,7 @@ def logout():
|
||||
connection = connect()
|
||||
try:
|
||||
connection.execute('DELETE FROM staff_sessions WHERE token_hash=?', (token_hash(),))
|
||||
connection.execute('DELETE FROM staff_presence WHERE token_hash=?', (token_hash(),))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
Reference in New Issue
Block a user