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

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
+1
View File
@@ -20,6 +20,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
| DB maintenance | `logistics_chat/db.py`: `SCHEMA_VERSION`, `MIGRATIONS`; `database_backup.py`: verified snapshots, daily worker, offline restore; `scripts/db_maintenance.py`; `docs/database-maintenance.md` |
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall, five-minute queue timeout; `logistics_chat/routes/human.py` |
| Staff routing | `logistics_chat/routing_service.py`: groups, presence leases, one-active-customer automatic assignment, manual queue acceptance, owner/version checks and audit; `routes/routing.py`; `static/js/admin_routing.js`; [routing workflow](docs/staff-routing.md) |
| Offline tickets | `logistics_chat/ticket_service.py`: schema v3, `init_ticket_history`, owner-only claim/notes/completion, search, `ticket_summary`, `ticket_events`, overdue timers; `routes/business.py`; `static/js/admin_tickets.js`: drafts, alerts, timeline |
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
| Customer attachments | `static/js/attachments.js`: paste/drop gestures, image viewer; `chat.js`: `uploadAttachment`, upload lifecycle |
+2
View File
@@ -31,6 +31,7 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL |
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
| 客服分组与分配 | `logistics_chat/routing_service.py`:`assign_new_session`、`update_presence`、`reassign_session`;`routes/routing.py` | `static/js/admin_routing.js`;版本 5;默认综合客服,手动上线,20 秒心跳与 90 秒在线期限,空闲时自动接一个新客户、队列手动领取、原子领取与改派记录;[使用说明](staff-routing.md) |
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` |
| 离线留言处理 | `logistics_chat/ticket_service.py`:`init_ticket_tables`、`init_ticket_history`、`get_ticket`、`list_tickets`、`claim_ticket`、`update_ticket`、`ticket_events` | `routes/business.py`;`static/js/admin_tickets.js`:`AdminTickets.create`;`tickets` 负责人、乐观锁与领取计时,`ticket_events` 同事务处理记录,迁移版本 3 |
@@ -100,6 +101,7 @@ tests/ # 按业务模块组织的回归
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
- 人工排队接口按服务端北京时间计算 `waiting_minutes`,等候超过 5 分钟返回超时状态和超时分钟数。`loadQueue` 对每条会话仅在首次进入超时时通知一次,队列计数常驻标签,卡片标红;离开超时队列后清除去重状态。
- 人工会话持久化分组和负责人;仅在新会话创建时自动分配,选择有效登录且心跳有效、当前无活动会话的同组在线客服,依次比较上次分配时间与账号。每位客服自动接待一个进行中的客户,手动领取和指定负责人改派可多接;已排队或转回队列的会话始终等待手动接入。分配/领取/改派/记录在同一写事务内完成。后台活动列表仅返回本人负责会话;无负责人的旧活动会话在队列中供人工领取。改派版本与前端异步代次分别防止并发覆盖和迟到响应覆盖;旧负责人草稿保留在当前页面恢复区。分组管理沿用同级客服权限,不能代替客户租户授权;所有新接口默认受后台会话与 CSRF 保护。
- 离线留言的 `admin_tickets.js` 在 `admin.js` 之前加载,由后者注入带超时、CSRF 和登录失效处理的请求函数。后台按当前登录账号领取,领取原子比较待处理状态;仅负责人可更新处理中留言,版本冲突返回 409,已完成只读。页面草稿按留言 ID 和原始版本保留,轮询不会隐式重置草稿版本;冲突须人工核对。客户提交接口不接受负责人或处理状态,不能访问后台处理接口。
- 留言搜索由 `ticket_service.list_tickets(query=...)` 统一叠加状态及游标条件,LIKE 参数转义通配符;列表继续返回数组。`ticket_summary(after)` 单独提供不受筛选影响的总待处理数量与新增数量,使用状态索引和主键范围查询。`refreshSummary` 与列表轮询独立,不因查看旧页、搜索或写请求停止;首次结果作为提醒基线,之后只提醒超过已见编号的新记录。
- 留言超时由服务端按待领取 30 分钟、领取后处理 20 分钟计算,备注修改不重置 `claimed_at`;列表 `overdue=1` 与全局 summary 使用一致截止时间,完成记录不计入。处理事件与操作同事务保存,只有后台可读,以事件 ID 分页;前端独立加载记录并隔离迟到响应。旧数据只保留升级状态,领取时间估算有显式标识。
+1 -1
View File
@@ -1,6 +1,6 @@
# 数据库升级、备份与恢复
普通启动会先在写锁保护下检查数据库版本。已有数据库先生成 `pre-migration-时间-随机值.db` 并执行 SQLite 完整性检查,再按 `MIGRATIONS` 的版本顺序升级;`schema_meta.schema_version` 记录成功版本。当前版本为 3;版本 2 为离线留言增加负责人、处理备注、更新时间、完成时间和并发版本号,版本 3 增加领取时间及处理记录表,既有留言和客服账号保留。旧留言只生成一条升级状态快照,不补造历史;原处理中留言以有效的最后更新时间估算领取时间并标记,无法估算时保留为空。保留既有旧表兼容检查,因此同版本启动也会先保存快照再校验表结构。更新和版本号提交位于同一事务,失败回滚并终止启动;备份失败不执行升级,未来版本的数据库拒绝降级。首次空库无需升级前快照。
普通启动会先在写锁保护下检查数据库版本。已有数据库先生成 `pre-migration-时间-随机值.db` 并执行 SQLite 完整性检查,再按 `MIGRATIONS` 的版本顺序升级;`schema_meta.schema_version` 记录成功版本。当前版本为 5;版本 2 为离线留言增加负责人、处理备注、更新时间、完成时间和并发版本号,版本 3 增加领取时间及处理记录表,版本 4 增加订单导入批次及撤销记录,版本 5 增加客服分组、在线心跳、会话负责人及分配记录,既有留言和客服账号保留。旧留言只生成一条升级状态快照,不补造历史;原处理中留言以有效的最后更新时间估算领取时间并标记,无法估算时保留为空。旧人工会话不猜测负责人,无负责人的活动会话可从排队列表手动接入。保留既有旧表兼容检查,因此同版本启动也会先保存快照再校验表结构。更新和版本号提交位于同一事务,失败回滚并终止启动;备份失败不执行升级,未来版本的数据库拒绝降级。首次空库无需升级前快照。
新增结构变更时,在 `logistics_chat/db.py` 增加顺序迁移函数并递增 `SCHEMA_VERSION`,加入 `MIGRATIONS`;迁移函数必须使用传入连接,不得自行提交、使用 `executescript` 隐式提交或执行外部副作用。补充旧库升级、失败回滚和重复启动测试。
+21
View File
@@ -0,0 +1,21 @@
# 客服分组与会话分配
客服登录后台后默认离线,在左侧「上线接待」手动开启自动分配;刷新页面保留当前登录会话已确认的状态。切换离线停止接收新会话,已接待的会话仍由原负责人处理。页面每 20 秒发送心跳,超过 90 秒无有效心跳,或登录过期、退出后,不再参与新分配;心跳恢复不会把已超时的客服自动上线,需要手动确认。
「客服分组与分配」打开服务资料中的配置区,可以创建分组、把已有客服加入某一组、查看在线状态及当前会话数。新咨询默认进入「综合客服」;目前不根据客户文字猜测业务组,客服可将会话改派至其他组。每个客服只属于一个组;改组不搬移已经接待的会话。新建客服账号沿用现有开户工具,本功能不创建账号或修改密码。
新人工咨询创建时只尝试一次自动分配:仅选择同组在线且当前没有活动会话的客服,先比较上次分配时间,再按账号名稳定排序。在线状态同时要求有效的后台登录会话。每位客服有一个进行中的客户后,不再自动接入新客户;手动领取和明确指定负责人的改派允许同时多接。没有合适客服时进入队列,保留现有五分钟排队超时提醒。已排队的客户始终等待手动接入;客服结束会话、上线、心跳、改组、刷新或客户重复申请均不会自动领取旧队列。客服再次空闲后仍可自动接入随后到来的新客户。
人工「接入会话」仍可手动领取未分配会话,在线状态仅控制自动分配。领取、自动分配和改派均在 SQLite 写事务中确认,竞争领取只有一人成功。只有会话负责人可发送、修改备注、撤回、转接或标记已读;待接入会话可由后台结束,客户仍可结束自己的会话。旧版无负责人的活动会话显示在排队列表供人工领取,不猜测历史负责人。
后台「管理分组与改派」选择会话、目标组及负责人;负责人只能选择目标组内的在线客服。未指定负责人时只转入目标组队列,等待手动接入。改派使用独立版本号,信息已改变时返回 409,需要重新选择会话确认,后台刷新不会偷偷更新正在编辑的版本。普通「转接」也回到队列,等待客服手动接入。
改派后旧负责人不能继续回复。当前页面检测到改派会移除旧接待入口,并在服务资料的「改派后保留的未发送草稿」中保留文字和备注,供复制;这些草稿只在当前页面内存中保存,刷新或退出不保留,也不会发给新负责人。
自动分配、领取、改派、转回队列和结束会话都与业务操作同事务写入 `routing_events`,记录操作者、分组、负责人及北京时间;后台展示最近 50 条,数据库保留全部记录。客户轮询不返回内部备注、客服账号或分配记录。当前后台沿用所有客服同级管理的权限模型,分组用于业务分派,不作为客户租户数据授权;管理层级及主管专属权限不在本次范围内。
数据库版本 5 新增 `support_groups`、`staff_routing`、`staff_presence`、`routing_events` 及 `human_sessions` 的分组、负责人、领取时间、版本号和上一负责人字段。`agents` 原有三列不变,兼容旧账号结构。正常启动时沿用升级前快照、完整性检查和事务迁移;本次不修改真实账号、订单或留言内容。
服务端实现:`logistics_chat/routing_service.py`、`routes/routing.py` 与 `human_service.py`。页面控制:`static/js/admin_routing.js`,由 `admin.js` 注入统一超时、CSRF 和登录失效处理,退出时统一停止心跳。
验证:`python -m unittest discover -s tests -p test_staff_routing.py -v`;前端运行 `node --test tests/frontend/admin.test.cjs tests/frontend/admin_routing.test.cjs`。所有客服测试通过真实登录接口获取会话和 CSRF,使用独立临时数据库。
+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()
+27
View File
@@ -200,6 +200,33 @@ body:not(.has-active-session) .close { display: none; }
.ticket-history-meta { color: var(--secondary); }
.ticket-history-detail { display: block; margin-top: 3px; white-space: pre-wrap; }
.ticket-empty { padding: 16px 0; color: var(--secondary); font-size: 13px; }
.routing-heading, .routing-presence { display: flex; align-items: center; gap: 8px; }
.routing-heading { justify-content: space-between; }
.routing-heading > span { color: var(--secondary); font-size: 13px; overflow-wrap: anywhere; }
.routing-presence { margin-top: 12px; flex-wrap: wrap; }
#routing-presence-text { flex: 1; font-size: 13px; }
.routing-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.routing-dot[data-online="true"] { background: var(--success); }
.routing-presence .ticket-action { min-height: 44px; padding: 7px 10px; font-size: 13px; white-space: nowrap; }
.routing-feedback { margin-top: 6px; color: var(--secondary); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.routing-feedback:empty { display: none; }
.routing-feedback.is-error { color: var(--danger); }
.routing-members { margin: 12px 0 0; padding: 0; list-style: none; font-size: 14px; }
.routing-members li { display: grid; gap: 3px; padding: 9px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
.routing-members li span:last-child { color: var(--secondary); font-size: 13px; }
.routing-members small { color: var(--muted); }
.routing-settings, .routing-events { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
.routing-settings summary, .routing-events summary { cursor: pointer; color: var(--secondary); font-size: 14px; min-height: 44px; padding: 10px 0; }
.routing-form { display: grid; gap: 7px; margin-top: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
.routing-form label { color: var(--secondary); font-size: 14px; }
.routing-form input, .routing-form select, #routing-retired-drafts textarea { width: 100%; min-width: 0; min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); font-size: 14px; }
.routing-form button { margin: 0; }
.routing-form :is(input,select):focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.routing-events ul { margin: 8px 0 0; padding: 0; list-style: none; }
.routing-events li { display: grid; gap: 3px; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 13px; overflow-wrap: anywhere; }
.routing-events li span, .routing-events li small { color: var(--muted); }
#routing-retired-drafts label { display: block; margin: 8px 0; font-size: 13px; }
@media (max-width: 720px) { .routing-form input, .routing-form select, #routing-retired-drafts textarea { font-size: 16px; } }
@media (max-width: 720px) { #ticket-note, #ticket-search, .ticket-toolbar select { font-size: 16px; } }
.tools-backdrop, .sessions-back { display: none; }
@media (max-width: 1200px) {
+40 -4
View File
@@ -1,6 +1,7 @@
var curSid = 0;
var sessions = {};
var drafts = {};
var reassignedNotes = {};
var queueItems = [];
var queueOverdueNotified = new Set();
var accepting = new Set();
@@ -15,6 +16,7 @@ var staffExpired = false;
var staffLoggingOut = false;
var staffPollTimers = [];
var staffCsrf = document.querySelector('meta[name="staff-csrf-token"]').content;
var staffUsername = document.querySelector('meta[name="staff-username"]').content;
var replyInput = document.getElementById("replyText");
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
@@ -153,7 +155,7 @@ function renderQueue(){
const wait = item.overdue
? '<span class="queue-overdue">排队超时 '+escapeText(item.overdue_minutes)+' 分钟</span>'
: '<span class="queue-wait">已等待 '+escapeText(age)+' 分钟</span>';
return '<div class="queue-item'+(item.overdue ? ' is-overdue' : '')+'"><div class="qt">对话 #'+sid+wait+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
return '<div class="queue-item'+(item.overdue ? ' is-overdue' : '')+'"><div class="qt">对话 #'+sid+wait+'<small>'+escapeText(item.group_name || '综合客服')+' · '+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
}).join("")
: '<div class="list-empty">'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'</div>';
}
@@ -198,7 +200,7 @@ async function accept(sid){
loadQueue();
poll();
}catch(error){
document.getElementById("queue").innerHTML = '<div class="list-empty">接入失败,请稍后重试</div>';
document.getElementById("queue").innerHTML = '<div class="list-empty">'+escapeText(error.userMessage || '接入失败,请稍后重试')+'</div>';
}finally{
accepting.delete(sid);
}
@@ -213,7 +215,7 @@ function switchSession(sid){
document.getElementById("chatContent").style.display = "flex";
setChatView(true);
document.getElementById("chatTitle").textContent = "客户 #" + sid;
document.getElementById("chat-subtitle").textContent = "正在接待 · 人工会话";
document.getElementById("chat-subtitle").textContent = "我负责 · "+(sessions[sid].group_name || "综合客服");
document.getElementById("cInfo").textContent = "会话编号:#" + sid;
document.getElementById("chatBox").innerHTML = "";
sessions[sid].msgs.forEach(appendMsg);
@@ -316,11 +318,23 @@ async function poll(){
const list = await adminRequest("/api/human/active");
list.forEach(function(session){
if(!sessions[String(session.id)]) sessions[String(session.id)] = {lastId:0, msgs:[], unread:0};
Object.assign(sessions[String(session.id)], {group_name:session.group_name, owner_username:session.owner_username});
});
await Promise.all(Object.keys(sessions).map(async function(sid){
const session = sessions[sid];
const state = await adminRequest("/api/human/poll?sid="+Number(sid)+"&after="+session.lastId);
if(sessions[sid] !== session) return;
if(state.status === 'waiting' || (state.owner_username && state.owner_username !== staffUsername)){
if(!Object.prototype.hasOwnProperty.call(drafts,sid)) drafts[sid] = '';
if(sid === curSid){
drafts[sid] = replyInput.value;
reassignedNotes[sid] = document.getElementById('noteInput').value;
}else if(session.noteDraft){ reassignedNotes[sid] = session.noteDraft; }
delete sessions[sid];
if(sid === curSid) showEmptySession();
renderRecoveredDrafts();
return;
}
if(state.status !== "active"){
delete sessions[sid];
delete drafts[sid];
@@ -339,6 +353,7 @@ async function poll(){
}));
document.getElementById("sync-status").hidden = true;
renderList();
renderRecoveredDrafts();
}catch(error){
document.getElementById("sync-status").hidden = false;
}finally{
@@ -346,6 +361,15 @@ async function poll(){
}
}
function renderRecoveredDrafts(){
const ids = Object.keys(drafts).filter(function(sid){ return !sessions[sid] && (drafts[sid] || reassignedNotes[sid]); });
document.getElementById('routing-draft-recovery').hidden = ids.length === 0;
document.getElementById('routing-retired-drafts').innerHTML = ids.map(function(sid){
return '<label>会话 #'+Number(sid)+' · 未发送草稿<textarea readonly rows="3">'+escapeText(drafts[sid] || '')+'</textarea></label>'+
(reassignedNotes[sid] ? '<label>备注草稿<textarea readonly rows="3">'+escapeText(reassignedNotes[sid])+'</textarea></label>' : '');
}).join('');
}
async function sendReply(){
if(!curSid || sendingSid || closingSid) return;
const sid = curSid;
@@ -454,7 +478,7 @@ document.getElementById("tools-from-list").onclick = function(){ toolsOpener = "
document.getElementById("tools-close").onclick = function(){
setToolsOpen(false);
const fromList = toolsOpener === "tools-from-list" && !document.body.classList.contains("chat-view") && window.matchMedia("(max-width: 720px)").matches;
document.getElementById(["ticket-inbox-toggle", "ticket-overdue-toggle"].includes(toolsOpener) ? toolsOpener : fromList ? "tools-from-list" : "tools-toggle").focus();
document.getElementById(["ticket-inbox-toggle", "ticket-overdue-toggle", "routing-open"].includes(toolsOpener) ? toolsOpener : fromList ? "tools-from-list" : "tools-toggle").focus();
};
document.getElementById("tools-backdrop").onclick = document.getElementById("tools-close").onclick;
document.addEventListener("keydown", function(event){
@@ -494,3 +518,15 @@ var ticketDesk = window.AdminTickets.create({request:adminRequest, escape:escape
loadTickets();
poll();
staffPollTimers = [setInterval(loadQueue, 3000), setInterval(loadTickets, 5000), setInterval(poll, 1000)];
if(window.AdminRouting){
var routingDesk = window.AdminRouting.create({username:staffUsername,request:adminRequest,
changed:function(){ loadQueue(); poll(); }});
routingDesk.refresh();
staffPollTimers.push(setInterval(routingDesk.heartbeat, 20000));
document.getElementById('routing-open').onclick = function(){
toolsOpener = 'routing-open'; setToolsOpen(true);
document.getElementById('routing-title').scrollIntoView({block:'start'});
document.getElementById('routing-refresh').focus({preventScroll:true});
routingDesk.refresh();
};
}
+170
View File
@@ -0,0 +1,170 @@
/* Group configuration, explicit availability and versioned manual reassignment. */
(function(root){
function create(options){
const el = id => document.getElementById(id);
const escape = value => String(value == null ? '' : value).replace(/[&<>"']/g,
character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
let snapshot = null, busy = false, beating = false, generation = 0, selectedVersion = null;
const controls = ['routing-presence-toggle','routing-refresh','routing-create-group','routing-save-member','routing-save-assignment'];
function message(text, error){
['routing-status','routing-management-status'].forEach(id=>{
el(id).textContent = text || '';
el(id).classList.toggle('is-error', Boolean(error));
});
}
function setBusy(value){
busy = value;
controls.forEach(id => { el(id).disabled = value || !snapshot; });
}
function fill(id, items, placeholder){
const input = el(id), previous = input.value;
input.innerHTML = (placeholder ? '<option value="">'+escape(placeholder)+'</option>' : '') +
items.map(item => '<option value="'+escape(item.value)+'">'+escape(item.label)+'</option>').join('');
input.value = items.some(item => String(item.value) === previous) ? previous :
(placeholder ? '' : items.length ? String(items[0].value) : '');
}
function renderPresence(online){
el('routing-presence-text').textContent = online ? '在线,参与自动分配' : '离线,不自动接待';
el('routing-presence-dot').dataset.online = String(online);
el('routing-presence-toggle').textContent = online ? '切换离线' : '上线接待';
el('routing-presence-toggle').setAttribute('aria-pressed', String(online));
}
function targets(preserveUnavailable){
if(!snapshot) return;
const group = Number(el('routing-target-group').value);
const previous = el('routing-target-owner').value;
const items = snapshot.agents.filter(agent => agent.online && agent.group_id === group)
.map(agent => ({value:agent.username,label:(agent.name || agent.username)+' · '+agent.active_count+' 个会话'}));
if(preserveUnavailable && previous && !items.some(item=>item.value === previous)){
items.push({value:previous,label:previous+' · 状态已改变,请重新选择'});
}
fill('routing-target-owner',items,'转入队列,等待手动接入');
}
function selectSession(){
const session = snapshot && snapshot.sessions.find(item => String(item.id) === el('routing-session').value);
selectedVersion = session ? session.version : null;
if(session){
el('routing-target-group').value = String(session.group_id);
targets();
el('routing-target-owner').value = session.owner_username || '';
}
}
function render(data){
snapshot = data;
renderPresence(Boolean(data.presence.online));
const mine = data.agents.find(agent => agent.username === options.username);
el('routing-group-label').textContent = mine ? mine.group_name : '综合客服';
el('routing-members').innerHTML = data.agents.length ? data.agents.map(agent =>
'<li><span>'+escape(agent.name || agent.username)+' <small>'+escape(agent.username)+'</small></span>'+
'<span>'+escape(agent.group_name)+' · '+(agent.online ? '在线' : '离线')+' · '+agent.active_count+' 个会话</span></li>').join('') : '<li>暂无客服账号</li>';
const groups = data.groups.map(group => ({value:group.id,label:group.name}));
fill('routing-member-group', groups);
fill('routing-target-group', groups);
fill('routing-member', data.agents.map(agent => ({value:agent.username,label:(agent.name || agent.username)+'('+agent.username+')'})), '选择客服');
fill('routing-session', data.sessions.map(session => ({value:session.id,
label:'#'+session.id+' · '+session.group_name+' · '+(session.owner_name || session.owner_username || '排队中')})), '选择会话');
if(!el('routing-session').value) selectedVersion = null;
targets(true);
const actions = {auto_assign:'自动分配',claim:'手动接入',reassign:'手动改派',queue:'转入分组队列',transfer:'转回队列',close:'结束会话'};
el('routing-events').innerHTML = data.events.length ? data.events.map(event =>
'<li><strong>#'+event.session_id+' · '+escape(actions[event.action] || event.action)+'</strong><span>'+
escape(event.group_name)+' → '+escape(event.owner_username || '等待分配')+'</span><small>'+escape(event.actor === 'system' ? '系统' : event.actor === 'customer' ? '客户' : event.actor)+' · '+escape(event.ctime)+'</small></li>').join('') : '<li>暂无分配记录</li>';
setBusy(busy);
}
async function refresh(afterWrite){
if(busy && afterWrite !== true) return;
const current = ++generation;
try{
const data = await options.request('/api/admin/routing');
if(current !== generation) return;
if(!data || !Array.isArray(data.groups) || !Array.isArray(data.agents) ||
!Array.isArray(data.sessions) || !Array.isArray(data.events) || !data.presence) throw new Error('Invalid routing data');
render(data);
}catch(error){
if(current === generation) message('分配状态加载失败,已保留现有信息;请点击刷新重试。', true);
}
}
async function heartbeat(){
if(busy || beating) return;
beating = true;
const current = generation;
try{
const data = await options.request('/api/admin/routing/presence', {method:'POST',
headers:{'Content-Type':'application/json'},body:'{}'});
if(current !== generation) return;
if(snapshot) snapshot.presence.online = Boolean(data.online);
renderPresence(Boolean(data.online));
await refresh();
}catch(error){
if(current === generation){
el('routing-presence-text').textContent = '接待状态待确认';
message('接待状态同步失败,断线超过 90 秒将停止自动分配;请刷新核对。', true);
}
}finally{ beating = false; }
}
async function setPresence(){
if(busy || !snapshot) return;
++generation;
setBusy(true);
const online = !snapshot.presence.online;
try{
const data = await options.request('/api/admin/routing/presence', {method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify({online})});
snapshot.presence.online = Boolean(data.online);
renderPresence(Boolean(data.online));
message(data.online ? '已上线,空闲时自动接入一个新客户;多接请手动领取。' : '已离线,已接待的会话继续保留。');
if(options.changed) options.changed();
}catch(error){ message('状态更新未确认成功,请点击刷新核对后重试。', true); }
finally{ setBusy(false); }
}
async function write(path, data, success){
if(busy || !snapshot) return false;
++generation;
setBusy(true);
try{
await options.request('/api/admin/routing/'+path, {method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
message(success);
if(options.changed) options.changed();
return true;
}catch(error){
message(error.userMessage || '操作未确认成功,请刷新核对后重试。', true);
return false;
}finally{ await refresh(true); setBusy(false); }
}
async function addGroup(){
const name = el('routing-group-name').value.trim();
if(!name || name.length > 40){ message('请输入 1–40 字的分组名称。', true); return; }
if(await write('groups',{name},'分组已创建,可将客服加入此组。')){
if(el('routing-group-name').value.trim() === name) el('routing-group-name').value = '';
}
}
async function moveMember(){
const username = el('routing-member').value, group_id = Number(el('routing-member-group').value);
if(!username){ message('请先选择客服。', true); return; }
await write('members',{username,group_id},'客服分组已更新,现有会话负责人不变。');
}
async function assign(){
const sid = Number(el('routing-session').value), group_id = Number(el('routing-target-group').value);
if(!sid || selectedVersion === null){ message('请先选择会话。', true); return; }
const version = selectedVersion, username = el('routing-target-owner').value || null;
await write('assign',{sid,group_id,version,username},'会话分配已更新。');
el('routing-session').value = ''; selectedVersion = null;
}
el('routing-presence-toggle').addEventListener('click',setPresence);
el('routing-refresh').addEventListener('click',refresh);
el('routing-session').addEventListener('change',selectSession);
el('routing-target-group').addEventListener('change',()=>targets(false));
el('routing-member').addEventListener('change',()=>{
const member = snapshot && snapshot.agents.find(agent => agent.username === el('routing-member').value);
if(member) el('routing-member-group').value = String(member.group_id);
});
[['routing-group-form',addGroup],['routing-member-form',moveMember],['routing-assign-form',assign]].forEach(([id,handler])=>{
el(id).addEventListener('submit',event=>{event.preventDefault();return handler();});
});
setBusy(false);
return {refresh,heartbeat,setPresence,assign};
}
root.AdminRouting = {create};
})(window);
+35
View File
@@ -46,6 +46,13 @@
<aside class="conv-list" id="session-browser" aria-label="会话列表">
<div class="list-heading">
<h2>客户会话</h2><p>查看咨询,及时接待与回复</p>
<div class="routing-presence">
<span id="routing-presence-dot" class="routing-dot" data-online="false" aria-hidden="true"></span>
<span id="routing-presence-text" role="status">正在检查接待状态…</span>
<button id="routing-presence-toggle" type="button" class="ticket-action" aria-pressed="false" disabled>上线接待</button>
</div>
<div class="routing-heading"><span id="routing-group-label">综合客服</span><button id="routing-open" type="button" class="text-button" aria-controls="service-tools">客服分组与分配</button></div>
<p id="routing-status" class="routing-feedback" role="status" aria-live="polite"></p>
<div class="session-search-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></svg><input id="session-search" type="search" aria-label="搜索客户会话" placeholder="搜索会话或消息" autocomplete="off"></div>
<button id="history-search-toggle" class="text-button" type="button">搜索历史记录 ↗</button>
</div>
@@ -88,6 +95,33 @@
</section>
<aside class="side" id="service-tools" aria-label="客户信息与回复" aria-hidden="true" inert>
<header class="side-heading"><h2>服务资料</h2><button id="tools-close" type="button" class="icon-button" aria-label="关闭服务资料">×</button></header>
<section class="card routing-card" aria-labelledby="routing-title">
<div class="routing-heading"><h3 id="routing-title">客服分组与分配</h3><button id="routing-refresh" type="button" class="ticket-action">刷新</button></div>
<p class="info-hint">每位在线客服空闲时自动接入一个新客户;忙碌时不再自动接入。排队客户需手动领取,也可手动多接。可在下方改派到其他组。</p>
<ul id="routing-members" class="routing-members" aria-label="客服在线状态"></ul>
<p id="routing-management-status" class="routing-feedback" role="status" aria-live="polite"></p>
<details class="routing-settings">
<summary>管理分组与改派</summary>
<form id="routing-group-form" class="routing-form">
<label for="routing-group-name">新分组名称</label>
<input id="routing-group-name" maxlength="40" placeholder="例如:售后组" required>
<button id="routing-create-group" class="ticket-action" type="submit">创建分组</button>
</form>
<form id="routing-member-form" class="routing-form">
<label for="routing-member">客服账号</label><select id="routing-member" required></select>
<label for="routing-member-group">所属分组</label><select id="routing-member-group" required></select>
<button id="routing-save-member" class="ticket-action" type="submit">保存客服分组</button>
</form>
<form id="routing-assign-form" class="routing-form">
<label for="routing-session">需要改派的会话</label><select id="routing-session" required></select>
<label for="routing-target-group">目标分组</label><select id="routing-target-group" required></select>
<label for="routing-target-owner">负责人</label><select id="routing-target-owner"></select>
<button id="routing-save-assignment" class="ticket-action" type="submit">确认改派</button>
</form>
</details>
<details class="routing-events"><summary>最近 50 条分配记录</summary><ul id="routing-events"></ul></details>
<details id="routing-draft-recovery" class="routing-events" hidden><summary>改派后保留的未发送草稿</summary><div id="routing-retired-drafts"></div></details>
</section>
<section class="card"><h3>客户信息</h3><div class="info-row" id="cInfo">选择会话后查看客户信息</div><p class="info-hint">咨询运费时,可先确认运单号、货物重量、尺寸与目的地。</p></section>
<section class="card"><h3><label for="noteInput">会话备注</label></h3><textarea id="noteInput" class="note-input" rows="3" placeholder="填写本次会话的跟进事项" disabled></textarea><button id="note-save" type="button" class="quick" disabled>保存备注</button></section>
<section class="card"><h3>常用回复</h3>
@@ -129,6 +163,7 @@
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_tools.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_tickets.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_routing.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
</body>
</html>
+22
View File
@@ -614,6 +614,28 @@ test('failed transfers retain the conversation and draft', async () => {
assert.match(app.element('reply-status').textContent, /转接未成功/);
});
test('reassignment removes the old owners composer but retains unsent text for recovery', async () => {
const app = await workspace();
app.element('replyText').value = '尚未发送的处理意见';
app.hooks.poll = () => response({status:'active',owner_username:'bob',messages:[]});
await app.context.poll();
assert.equal(app.context.curSid,0);
assert.equal(app.context.sessions['7'],undefined);
assert.equal(app.context.drafts['7'],'尚未发送的处理意见');
assert.equal(app.element('routing-draft-recovery').hidden,false);
assert.match(app.element('routing-retired-drafts').innerHTML,/尚未发送的处理意见/);
});
test('moving a session to an offline group preserves the previous owners draft', async () => {
const app = await workspace();
app.element('replyText').value = '需要转交的草稿';
app.hooks.poll = () => response({status:'waiting',owner_username:null,messages:[]});
await app.context.poll();
assert.equal(app.context.curSid,0);
assert.equal(app.context.drafts['7'],'需要转交的草稿');
assert.match(app.element('routing-retired-drafts').innerHTML,/需要转交的草稿/);
});
test('cancelling the in-page transfer confirmation leaves the session untouched', async () => {
const app = await workspace(false, {confirmResult:false});
await app.context.transferSession();
+128
View File
@@ -0,0 +1,128 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '../../static/js/admin_routing.js'), 'utf8');
function snapshot(online = false){
return {ok:true, username:'alice', presence:{online},
groups:[{id:1,name:'综合客服'},{id:2,name:'售后组'}],
agents:[{username:'alice',name:'客服甲',group_id:1,group_name:'综合客服',online,active_count:1},
{username:'bob',name:'客服乙',group_id:2,group_name:'售后组',online:true,active_count:0}],
sessions:[{id:7,group_id:1,group_name:'综合客服',owner_username:'alice',owner_name:'客服甲',status:'active',version:3}],
events:[]};
}
function desk(){
const elements = new Map(), calls = [], hooks = {};
function element(id){
if(!elements.has(id)) elements.set(id, {value:'',innerHTML:'',textContent:'',hidden:false,disabled:false,
dataset:{},attributes:{},listeners:{},classList:{toggle(){},add(){},remove(){}},
setAttribute(name,value){this.attributes[name]=value;},
addEventListener(name,listener){this.listeners[name]=listener;}});
return elements.get(id);
}
const context = vm.createContext({window:{},document:{getElementById:element},console});
vm.runInContext(source,context);
const api = context.window.AdminRouting.create({username:'alice',
request:async(url,options)=>{
const data = options && options.body ? JSON.parse(options.body) : undefined;
calls.push({url,data});
if(hooks.request) return hooks.request(url,data);
if(url === '/api/admin/routing') return snapshot();
return {ok:true,online:data && data.online};
},changed(){calls.push({changed:true});}});
return {api,element,calls,hooks};
}
test('loading the desk shows offline without silently enabling automatic assignment',async()=>{
const app = desk();
await app.api.refresh();
assert.match(app.element('routing-presence-text').textContent,/离线/);
assert.equal(app.calls.filter(item=>item.data && item.data.online === true).length,0);
assert.match(app.element('routing-members').innerHTML,/客服乙/);
assert.match(app.element('routing-members').innerHTML,/售后组/);
});
test('heartbeat continues while offline and never sends an online claim',async()=>{
const app = desk();
await app.api.refresh();
await app.api.heartbeat();
const sent = app.calls.find(item=>item.url === '/api/admin/routing/presence');
assert.deepEqual(sent.data,{});
assert.match(app.element('routing-presence-text').textContent,/离线/);
});
test('failed presence change retains the confirmed status and provides retry feedback',async()=>{
const app = desk();
await app.api.refresh();
app.hooks.request=async(url)=>{ if(url.endsWith('/presence')) throw new Error('network'); return snapshot(); };
await app.api.setPresence();
assert.match(app.element('routing-presence-text').textContent,/离线/);
assert.match(app.element('routing-status').textContent,/未确认/);
assert.equal(app.element('routing-presence-toggle').disabled,false);
});
test('changing group only offers online members of that group for reassignment',async()=>{
const app = desk();
await app.api.refresh();
app.element('routing-session').value='7';
app.element('routing-session').listeners.change();
app.element('routing-target-group').value='2';
app.element('routing-target-group').listeners.change();
assert.match(app.element('routing-target-owner').innerHTML,/bob/);
assert.doesNotMatch(app.element('routing-target-owner').innerHTML,/alice/);
});
test('refresh does not silently rebase the selected assignment version',async()=>{
const app = desk();
await app.api.refresh();
app.element('routing-session').value='7';
app.element('routing-session').listeners.change();
app.element('routing-target-group').value='2';
app.element('routing-target-owner').value='bob';
const updated = snapshot(); updated.sessions[0].version=4;
app.hooks.request=async(url)=>url === '/api/admin/routing' ? updated : {ok:true};
await app.api.refresh();
await app.api.assign();
const sent=app.calls.find(item=>item.url === '/api/admin/routing/assign');
assert.equal(sent.data.version,3);
assert.equal(sent.data.username,'bob');
});
test('a late background refresh cannot override a confirmed presence switch',async()=>{
const app=desk(); await app.api.refresh();
let resolve;
app.hooks.request=async(url)=>url === '/api/admin/routing' ? new Promise(done=>{resolve=done;}) : {ok:true,online:true};
const pending=app.api.refresh();
const switching=app.api.setPresence();
await switching;
resolve(snapshot(false)); await pending;
assert.match(app.element('routing-presence-text').textContent,/在线/);
});
test('a manual refresh during a presence write cannot restore an older availability state',async()=>{
const app=desk(); await app.api.refresh();
let confirmWrite, confirmRead;
app.hooks.request=(url)=>url === '/api/admin/routing' ? new Promise(done=>{confirmRead=done;}) :
new Promise(done=>{confirmWrite=done;});
const switching=app.api.setPresence();
const refreshing=app.api.refresh();
confirmWrite({ok:true,online:true}); await switching;
if(confirmRead) confirmRead(snapshot(false));
await refreshing;
assert.match(app.element('routing-presence-text').textContent,/在线/);
});
test('an unavailable selected owner is not silently replaced with automatic queueing',async()=>{
const app=desk(); await app.api.refresh();
app.element('routing-session').value='7'; app.element('routing-session').listeners.change();
app.element('routing-target-group').value='2'; app.element('routing-target-group').listeners.change();
app.element('routing-target-owner').value='bob';
const updated=snapshot(); updated.agents[1].online=false;
app.hooks.request=async(url)=>url === '/api/admin/routing' ? updated : {ok:true};
await app.api.refresh(); await app.api.assign();
const sent=app.calls.find(item=>item.url === '/api/admin/routing/assign');
assert.equal(sent.data.username,'bob');
});
+2
View File
@@ -85,6 +85,7 @@ class DevIntegrationTests(unittest.TestCase):
def test_human_send_rejects_closed_missing_or_mismatched_conversations(self):
sid = self.session()
self.client.post('/api/human/accept', json={'sid': sid})
payload = {'sid': sid, 'side': 'customer', 'content': '不能串到其他会话'}
other_cid = self.client.post('/api/new_chat').json['id']
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'conv_id': other_cid}).status_code, 409)
@@ -96,6 +97,7 @@ class DevIntegrationTests(unittest.TestCase):
def test_recall_targets_older_agent_message_and_is_idempotent(self):
sid = self.session()
self.client.post('/api/human/accept', json={'sid': sid})
for side, text in (('agent', '早先的回复'), ('customer', '客户消息'), ('agent', '最新回复')):
self.client.post('/api/human/send', json={'sid': sid, 'side': side, 'content': text})
before = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
+1 -1
View File
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs', 'tests/frontend/customer_orders.test.cjs'], cwd=root,
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/admin_routing.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs', 'tests/frontend/customer_orders.test.cjs'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+1 -1
View File
@@ -237,7 +237,7 @@ class OrderImportAdminTests(unittest.TestCase):
db.init_db()
connection = db.connect()
try:
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], '4')
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
self.assertEqual(connection.execute("SELECT status FROM order_test_data WHERE order_no='EXISTING001'").fetchone()[0], 'PROCESSING')
self.assertIsNotNone(connection.execute("SELECT 1 FROM sqlite_master WHERE name='order_import_batches'").fetchone())
finally:
+281
View File
@@ -0,0 +1,281 @@
"""Real staff login, SQLite routing and competing claims against isolated storage."""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app, db
from tests.staff_fixture import login_staff
class StaffRoutingTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.name)
self.app = create_app({'TESTING': True, 'DATABASE': root/'test.db',
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs',
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 10000})
self.alice, self.bob = self.app.test_client(), self.app.test_client()
login_staff(self.app, self.alice, username='alice')
login_staff(self.app, self.bob, username='bob')
self.visitor = self.app.test_client()
self.visitor.post('/api/visitor/session', json={'key': 'a'*64})
def request_human(self):
cid = self.visitor.post('/api/new_chat').json['id']
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 11, 4, 10)
response = self.visitor.post('/api/visitor/human/request', json={'conv_id': cid})
self.assertEqual(response.status_code, 200)
return response.json['session_id']
def state(self):
response = self.alice.get('/api/admin/routing')
self.assertEqual(response.status_code, 200)
return response.json
def session(self, sid):
return next(item for item in self.state()['sessions'] if item['id'] == sid)
def online(self, client):
response = client.post('/api/admin/routing/presence', json={'online': True})
self.assertEqual(response.status_code, 200)
return response.json
def test_login_starts_offline_and_no_staff_leaves_customer_in_queue(self):
sid = self.request_human()
self.assertEqual([row['id'] for row in self.alice.get('/api/human/queue').json], [sid])
data = self.state()
self.assertEqual(data['groups'][0]['name'], '综合客服')
self.assertFalse(data['presence']['online'])
self.assertTrue(all(not row['online'] for row in data['agents']))
self.assertIsNone(self.session(sid)['owner_username'])
def test_each_online_agent_automatically_receives_only_one_active_customer(self):
self.online(self.alice)
self.online(self.bob)
first, second = self.request_human(), self.request_human()
third, fourth = self.request_human(), self.request_human()
self.assertEqual(self.session(first)['owner_username'], 'alice')
self.assertEqual(self.session(second)['owner_username'], 'bob')
self.assertEqual([row['id'] for row in self.alice.get('/api/human/queue').json], [third, fourth])
self.assertEqual({row['id'] for row in self.bob.get('/api/human/active').json}, {second})
def test_busy_agent_can_manually_accept_additional_customers(self):
self.online(self.alice)
first, second, third = self.request_human(), self.request_human(), self.request_human()
self.assertIsNone(self.session(second)['owner_username'])
for sid in (second, third):
self.assertEqual(self.alice.post('/api/human/accept', json={'sid': sid}).status_code, 200)
self.assertEqual({row['id'] for row in self.alice.get('/api/human/active').json}, {first, second, third})
self.assertIsNone(self.session(self.request_human())['owner_username'])
def test_queued_customer_requires_manual_accept_after_agent_becomes_idle(self):
self.online(self.alice)
first, queued = self.request_human(), self.request_human()
self.assertEqual(self.alice.post('/api/human/close', json={'sid': first}).status_code, 200)
self.assertEqual(self.alice.post('/api/admin/routing/presence', json={}).status_code, 200)
self.online(self.bob)
self.assertEqual(self.alice.post('/api/admin/routing/members', json={
'username': 'bob', 'group_id': 1}).status_code, 200)
self.assertEqual(self.session(queued)['status'], 'waiting')
self.assertIsNone(self.session(queued)['owner_username'])
with self.app.app_context():
connection = db.connect()
conv_id = connection.execute('SELECT conv_id FROM human_sessions WHERE id=?', (queued,)).fetchone()[0]
connection.close()
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 11, 4, 10)
response = self.visitor.post('/api/visitor/human/request', json={'conv_id': conv_id})
self.assertEqual(response.json['status'], 'waiting')
self.assertEqual(self.alice.post('/api/human/accept', json={'sid': queued}).status_code, 200)
def test_going_online_does_not_automatically_take_existing_queue(self):
queued = self.request_human()
self.online(self.alice)
fresh = self.request_human()
self.assertIsNone(self.session(queued)['owner_username'])
self.assertEqual(self.session(fresh)['owner_username'], 'alice')
def test_concurrent_new_requests_cannot_overfill_one_automatic_slot(self):
self.online(self.alice)
guests = [self.app.test_client(), self.app.test_client()]
for index, client in enumerate(guests):
client.post('/api/visitor/session', json={'key': str(index+1)*64})
conv_ids = [client.post('/api/new_chat').json['id'] for client in guests]
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 11, 4, 10)
with ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(lambda pair: pair[0].post('/api/visitor/human/request',
json={'conv_id': pair[1]}), zip(guests, conv_ids)))
self.assertTrue(all(response.status_code == 200 for response in responses))
self.assertEqual(sorted(response.json['status'] for response in responses), ['active', 'waiting'])
self.assertEqual(len(self.alice.get('/api/human/active').json), 1)
def test_group_membership_limits_automatic_assignments_and_manual_move_is_recorded(self):
created = self.alice.post('/api/admin/routing/groups', json={'name': '售后组'})
self.assertEqual(created.status_code, 200)
group_id = created.json['group']['id']
self.assertEqual(self.alice.post('/api/admin/routing/members', json={
'username': 'bob', 'group_id': group_id}).status_code, 200)
self.online(self.bob)
sid = self.request_human()
self.assertIsNone(self.session(sid)['owner_username'])
moved = self.alice.post('/api/admin/routing/assign', json={
'sid': sid, 'version': self.session(sid)['version'], 'group_id': group_id, 'username': 'bob'})
self.assertEqual(moved.status_code, 200)
self.assertEqual(self.session(sid)['owner_username'], 'bob')
self.assertEqual(self.session(sid)['group_name'], '售后组')
event = self.state()['events'][0]
self.assertEqual((event['actor'], event['owner_username'], event['session_id']), ('alice', 'bob', sid))
self.assertEqual(self.alice.post('/api/admin/routing/assign', json={
'sid': sid, 'version': self.session(sid)['version'], 'group_id': 1, 'username': 'bob'}).status_code, 409)
def test_manual_cross_group_claim_does_not_change_the_agents_membership(self):
group_id = self.alice.post('/api/admin/routing/groups', json={'name': '售后组'}).json['group']['id']
sid = self.request_human()
self.alice.post('/api/admin/routing/assign', json={
'sid': sid, 'version': self.session(sid)['version'], 'group_id': group_id, 'username': None})
self.assertEqual(self.bob.post('/api/human/accept', json={'sid': sid}).status_code, 200)
bob = next(row for row in self.state()['agents'] if row['username'] == 'bob')
self.assertEqual(bob['group_id'], 1)
self.assertEqual(self.session(sid)['group_id'], group_id)
def test_competing_staff_cannot_both_claim_or_write_other_owners_session(self):
sid = self.request_human()
with ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(lambda client: client.post('/api/human/accept', json={'sid': sid}),
(self.alice, self.bob)))
self.assertEqual(sorted(row.status_code for row in responses), [200, 409])
owner = self.session(sid)['owner_username']
other = self.bob if owner == 'alice' else self.alice
for path, payload in (
('send', {'sid': sid, 'side': 'agent', 'content': '抢答'}),
('note', {'sid': sid, 'note': '覆盖备注'}), ('close', {'sid': sid}),
('transfer', {'sid': sid}), ('typing', {'sid': sid}),
('stop_typing', {'sid': sid}), ('read', {'sid': sid})):
with self.subTest(path=path):
self.assertEqual(other.post('/api/human/'+path, json=payload).status_code, 409)
self.assertEqual(self.session(sid)['owner_username'], owner)
def test_unclaimed_session_requires_acceptance_before_staff_can_reply(self):
sid = self.request_human()
response = self.alice.post('/api/human/send', json={'sid': sid, 'side': 'agent', 'content': '尚未接入'})
self.assertEqual(response.status_code, 409)
self.assertEqual(self.alice.post('/api/human/accept', json={'sid': sid}).status_code, 200)
self.assertEqual(self.alice.post('/api/human/send', json={
'sid': sid, 'side': 'agent', 'content': '现在可以回复'}).status_code, 200)
def test_transferred_session_is_not_automatically_returned_to_previous_owner(self):
self.online(self.alice)
sid = self.request_human()
self.assertEqual(self.alice.post('/api/human/transfer', json={'sid': sid}).status_code, 200)
self.alice.post('/api/admin/routing/presence', json={})
self.assertIsNone(self.session(sid)['owner_username'])
self.online(self.bob)
self.assertIsNone(self.session(sid)['owner_username'])
self.assertEqual(self.bob.post('/api/human/accept', json={'sid': sid}).status_code, 200)
self.assertEqual(self.session(sid)['owner_username'], 'bob')
def test_unspecified_manual_reassignment_queues_even_with_idle_online_staff(self):
self.online(self.alice)
sid = self.request_human()
self.online(self.bob)
response = self.alice.post('/api/admin/routing/assign', json={
'sid': sid, 'version': self.session(sid)['version'], 'group_id': 1, 'username': None})
self.assertEqual(response.status_code, 200)
self.assertEqual(self.session(sid)['status'], 'waiting')
self.assertIsNone(self.session(sid)['owner_username'])
def test_failed_audit_write_rolls_back_owner_and_status(self):
sid = self.request_human()
with self.app.app_context():
connection = db.connect()
connection.execute("CREATE TRIGGER reject_routing_event BEFORE INSERT ON routing_events "
"BEGIN SELECT RAISE(ABORT,'fixture audit failure'); END")
connection.commit()
import sqlite3
with self.assertRaises(sqlite3.IntegrityError):
self.alice.post('/api/human/accept', json={'sid': sid})
self.assertIsNone(self.session(sid)['owner_username'])
self.assertEqual(self.session(sid)['status'], 'waiting')
def test_deleted_staff_returns_assigned_sessions_to_unowned_queue(self):
self.online(self.alice)
sid = self.request_human()
with self.app.app_context():
connection = db.connect()
connection.execute("DELETE FROM agents WHERE username='alice'")
connection.commit()
data = self.bob.get('/api/admin/routing').json
row = next(item for item in data['sessions'] if item['id'] == sid)
self.assertEqual(row['status'], 'waiting')
self.assertIsNone(row['owner_username'])
def test_public_poll_does_not_expose_staff_identity_or_routing_events(self):
self.online(self.alice)
sid = self.request_human()
data = self.visitor.get(f'/api/visitor/human/poll?sid={sid}').json
self.assertNotIn('owner_username', data)
self.assertNotIn('note', data)
self.assertEqual(self.visitor.get('/api/admin/routing').status_code, 401)
def test_manual_reassignment_rejects_stale_version_and_revokes_old_owner_writes(self):
sid = self.request_human()
self.alice.post('/api/human/accept', json={'sid': sid})
version = self.session(sid)['version']
self.online(self.bob)
payload = {'sid': sid, 'version': version, 'group_id': 1, 'username': 'bob'}
self.assertEqual(self.alice.post('/api/admin/routing/assign', json=payload).status_code, 200)
self.assertEqual(self.alice.post('/api/admin/routing/assign', json=payload).status_code, 409)
self.assertEqual(self.alice.post('/api/human/send', json={
'sid': sid, 'side': 'agent', 'content': '旧负责人不能发送'}).status_code, 409)
self.assertEqual(self.bob.post('/api/human/send', json={
'sid': sid, 'side': 'agent', 'content': '新负责人接手'}).status_code, 200)
self.assertEqual(self.visitor.get(f'/api/visitor/human/poll?sid={sid}').json['messages'][-1]['content'], '新负责人接手')
def test_expired_heartbeat_logout_and_offline_stop_new_assignments(self):
self.online(self.alice)
first = self.request_human()
self.assertEqual(self.session(first)['owner_username'], 'alice')
self.assertEqual(self.alice.post('/api/admin/routing/presence', json={'online': False}).status_code, 200)
self.assertIsNone(self.session(self.request_human())['owner_username'])
self.online(self.alice)
with self.app.app_context():
connection = db.connect()
connection.execute('UPDATE staff_presence SET last_seen=0')
connection.commit()
self.assertIsNone(self.session(self.request_human())['owner_username'])
self.assertFalse(self.alice.post('/api/admin/routing/presence', json={}).json['online'])
self.online(self.alice)
self.alice.post('/admin/logout', json={})
sid = self.request_human()
response = self.bob.get('/api/admin/routing')
self.assertIsNone(next(row for row in response.json['sessions'] if row['id'] == sid)['owner_username'])
def test_routing_endpoints_require_login_and_csrf_and_reject_invalid_inputs(self):
anonymous = self.app.test_client()
self.assertEqual(anonymous.get('/api/admin/routing').status_code, 401)
self.alice.environ_base.pop('HTTP_X_CSRF_TOKEN')
self.assertEqual(self.alice.post('/api/admin/routing/presence', json={'online': True}).status_code, 403)
login_staff(self.app, self.alice, username='alice', seed=False)
for payload in ({'online': 'true'}, [], {'online': 1}):
self.assertEqual(self.alice.post('/api/admin/routing/presence', json=payload).status_code, 400)
for name in ('', 'x'*41, [], 7):
self.assertEqual(self.alice.post('/api/admin/routing/groups', json={'name': name}).status_code, 400)
self.assertEqual(self.alice.post('/api/admin/routing/groups', json={'name': '综合客服'}).status_code, 409)
def test_reinitialization_preserves_legacy_sessions_without_inventing_an_owner(self):
sid = self.request_human()
with self.app.app_context():
db.init_db()
self.assertIsNone(self.session(sid)['owner_username'])
self.assertEqual(self.session(sid)['group_id'], 1)
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -99,6 +99,7 @@ class VisitorTests(unittest.TestCase):
for action in ('send','close'):
self.assertEqual(self.b.post('/api/visitor/human/'+action,json={'sid':sid,'content':'attack'}).status_code,404)
self.assertEqual(self.a.post('/api/visitor/human/send',json={'sid':sid,'side':'agent','content':'fake agent'}).status_code,403)
self.assertEqual(self.staff.post('/api/human/accept',json={'sid':sid}).status_code,200)
self.assertEqual(self.staff.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'}).status_code,200)
response=self.a.get(f'/api/visitor/human/poll?sid={sid}')
self.assertNotIn('note',response.json)