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

233 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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