调整客服自动接待和手动排队规则

This commit is contained in:
songxinnuo
2026-11-04 13:37:26 +08:00
parent efb2e5b4b4
commit ba8a7104b3
22 changed files with 1086 additions and 21 deletions
+8 -2
View File
@@ -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():
+77 -10
View File
@@ -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 -2
View File
@@ -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)
+32
View File
@@ -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'])
+1
View File
@@ -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':
+232
View File
@@ -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
+1
View File
@@ -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()