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