Compare commits

..
1 Commits
Author SHA1 Message Date
songxinnuo eebaba8084 保存今天的修改 2026-11-02 17:51:10 +08:00
44 changed files with 2831 additions and 154 deletions
+2
View File
@@ -3,6 +3,8 @@
# The process environment takes precedence, including an explicitly empty value.
# Keep real credentials in .env; never put them in this example or source files.
DEEPSEEK_API_KEY=
# Daily verified SQLite backup while the application is running (Beijing date).
AUTO_BACKUP_ENABLED=true
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
ORDER_DATA_TENANT_ID=
# Default front-end customer account: admin / 123456. It grants no staff access.
+5 -2
View File
@@ -10,13 +10,16 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
| Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support |
| Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout |
| Staff login | `logistics_chat/staff_service.py`: opaque 24-hour sessions, default-deny endpoint allowlist, CSRF, login/logout; `routes/admin.py`; `static/js/admin.js` |
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.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` |
| 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/` |
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView`; `admin_tools.js`: notes, transfer, search; `support.js`: emoji, notifications, rating |
| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`: `default_config`; `hooks.py`, `runtime.py` in that package |
@@ -64,4 +67,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
## Security & Configuration Tips
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; order and staff tables start empty. The requested default customer account is separate from staff and stores only a password hash. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication relies on Nginx: protect staff and unknown routes by default and expose only the owner-checked customer allowlist. Never expose the upstream port publicly. Guest keys authorize public chat history, never customer orders. Order facts and sensitive conversation history require the server-validated customer and tenant; never accept tenant claims from client input. Do not assign unowned legacy records to a new visitor.
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; order and staff tables start empty. The requested default customer account is separate from staff and stores only a password hash. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication uses independent server-side sessions with absolute 24-hour expiry and CSRF checks; keep the Nginx perimeter, protect staff and unknown routes by default, and expose only the reviewed customer endpoint allowlist. Tests authenticate staff through the real login flow; never bypass authentication in testing. Never expose the upstream port publicly. Guest keys authorize public chat history, never customer orders. Order facts and sensitive conversation history require the server-validated customer and tenant; never accept tenant claims from client input. Do not assign unowned legacy records to a new visitor.
+1 -1
View File
@@ -22,7 +22,7 @@ COPY logistics_chat/ ./logistics_chat/
COPY templates/ ./templates/
COPY static/ ./static/
COPY deploy/__init__.py deploy/wsgi.py ./deploy/
COPY scripts/import_order_test_data.py ./scripts/
COPY scripts/import_order_test_data.py scripts/db_maintenance.py ./scripts/
USER 10001:10001
EXPOSE 5000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
+23 -3
View File
@@ -146,6 +146,7 @@ baiyue-logistics-chat/
│ ├── history_service.py 会话 CRUD
│ ├── human_service.py 人工会话
│ ├── business_service.py 登录、统计、订单、留言、导出
│ ├── staff_service.py 客服登录、24 小时会话、接口鉴权及退出
│ ├── file_service.py 附件与清理
│ ├── hooks.py 限流和缓存头
│ ├── runtime.py 服务启动
@@ -226,13 +227,30 @@ py -3 scripts/close_active_sessions.py
| POST | `/api/rate` | 提交人工服务评价(`sid`、1–5 的 `stars`、`comment`) |
| GET | `/api/agents/list` | 客服列表,不包含密码 |
| GET | `/api/search_chat?q=` | 搜索历史消息,最多返回 50 条 |
| POST | `/api/ticket` | 客户提交离线留言,保留访客身份校验 |
| GET | `/api/tickets` | 后台留言列表,默认待处理;`status=all/pending/processing/completed`、`limit=1..100`、`before=id` 分页;`q` 按内容、电话、负责人和备注搜索,最多 100 字;`overdue=1` 只看超时 |
| GET | `/api/tickets/summary` | 后台全局待处理数量、超时总量及分类数量与最新留言编号;`after=id` 返回其后新增的留言数,不受搜索及分页影响 |
| GET | `/api/tickets/<id>` | 后台查看留言详情、负责人、备注和完成时间 |
| GET | `/api/tickets/<id>/events` | 后台处理记录(最新在前),含操作者、时间、备注前后值;`limit=1..100`、`before=事件id` 分页,默认 20 条 |
| POST | `/api/tickets/<id>/claim` | 当前登录客服领取待处理留言,需要 CSRF |
| PATCH | `/api/tickets/<id>` | 负责人更新备注或完成;传 `version`、`note` 和可选 `status=completed`,需要 CSRF |
| GET | `/admin` | 客服登录页面 |
| POST | `/admin/login` | 校验客服账号密码 |
| POST | `/admin/login` | 校验客服账号密码并签发 24 小时会话 |
| GET | `/api/admin/session` | 获取当前客服身份与 CSRF 令牌(需要后台登录) |
| POST | `/admin/logout` | 撤销当前后台会话(需要 CSRF 请求头) |
| GET | `/admin/dashboard` | 客服工作台 |
| GET | `/api/export_chat` | 导出 Excel;`?format=csv` 保留 CSV 导出 |
人工客服每天 8:00–22:00 接受新申请,由服务端校验时间;其余时间可提交离线留言。客服工作台保留白色界面,转接后由其他客服在排队列表主动接入。页面的铃铛按钮可开启浏览器通知,页面切到后台时提示排队或新消息;实际通知能力取决于浏览器权限。
人工排队等候超过 5 分钟后,排队标签显示超时数量,队列卡片标注超时分钟数;首次进入超时状态时提醒一次。页面切到后台且已授权浏览器通知时会弹出系统通知。
离线留言处理位于工作台「资料与回复 → 离线留言」。点击留言查看详情,领取后自动设为当前客服负责并进入「处理中」;负责人可保存处理备注,填写处理结果后标记「已完成」。完成记录保留,可按状态筛选查询。重复或并发领取会被拒绝;其他客服只能查看,不能覆盖负责人备注。备注发生版本冲突时保留本页草稿,展示当前保存的内容,人工核对后才允许继续保存。列表每次加载 20 条,支持加载更早留言;查看更早留言期间可点击「刷新」返回最新列表。未保存备注在当前页面按留言保留,刷新整个网页或退出时不会持久保存草稿。
工作台顶部「留言」显示全局待处理数量,点击直达待处理列表。独立的 5 秒轮询不受搜索、查看旧页或保存备注影响;新留言到达后显示页面提示,首次打开仅显示已有待处理总数,不重复提醒旧记录。开启右上角浏览器通知后,页面在后台时也可收到新留言通知,提醒只含数量。搜索在数据库执行,支持手机号、留言内容、负责人账号/姓名和处理备注,可与状态及分页同时使用;输入 `%`、`_`、`\` 按普通字符匹配,不当作通配符。
工作台顶部「超时」显示全部未完成超时留言,点击查看;列表可组合「只看超时」、状态和搜索。待处理超过提交时间 30 分钟、处理中超过领取时间 20 分钟即提醒,按服务端本地时间计算;保存备注不重置领取计时,完成后不再计入。详情中的「处理记录」保留提交、领取、备注变更前后和完成结果,操作者取实际登录客服,变更和记录在同一事务提交;记录只对后台开放,不可从接口编辑或删除。旧留言升级时只保留已知状态,不补造历史;原处理中留言以升级前最后更新时间估算领取时间并在页面说明,缺少有效时间则提示无法计算。
SSE 每帧为一条 JSON,主要事件包括:
```text
@@ -268,11 +286,13 @@ data: {"d": 1}
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
数据库已提供版本迁移、升级前校验快照、SQLite 在线每日备份及离线恢复工具,详见 [数据库维护指南](docs/database-maintenance.md)。应用运行时按北京时间跨日备份,`AUTO_BACKUP_ENABLED=false` 可关闭后台每日任务;启动备份仍执行。附件与配置须另行备份。
## 当前限制
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史卡片继续兼容展示。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
- 不预置客服账号;客服登录支持现有 PBKDF2/scrypt 密码哈希,兼容旧明文账号记录,不修改已保存密码。登录签发独立的 HttpOnly、SameSite=Lax Cookie,HTTPS 时启用 Secure;服务端 `staff_sessions` 只保存凭证摘要,后台登录从成功登录起 24 小时有效,到期需要重新登录。访问后台和重启服务不会延长有效期;浏览器 Cookie 同样保存 24 小时,退出、清理 Cookie 或浏览器自动清理后需重新登录。升级时撤销旧的无限期会话,重新登录后使用 24 小时规则。后台页面、接口、搜索、导出及客服附件统一校验会话,未明确列入客户白名单的新增路径默认需要客服登录。客户账号不能访问后台。
- 工作台右上角“退出登录”撤销当前凭证,不停止服务、不删除数据;密码变更或账号删除使旧会话失效。写操作校验 CSRF 请求头和请求来源;登录接口每个账号与连接来源组合在 5 分钟内最多尝试 5 次,成功后清除计数(进程内计数,多进程部署需代理统一限流)。登录凭证被撤销或丢失时停止后台轮询并回到登录页。Nginx 专用访问密码及上游回环绑定继续保留,客户聊天不要求后台密码。
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
- 服务优先使用 Waitress 的 8 个线程;缺少 Waitress 时会回退到 Flask 开发服务器。公网部署前还需完善鉴权及部署配置。
+9 -1
View File
@@ -23,6 +23,7 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| Jenkins 灰度部署 | `Jenkinsfile`、`Dockerfile`;`deploy/gray_config.py`:`GrayConfig`;`publish-gray.sh`、`activate-gray.sh`、`prepare-gray-tls.sh`、`renew-gray-tls.sh` | [部署指南](deployment.md);独立持久化数据、证书和回环端口;`wsgi.py` 提供容器内路径 |
| 请求钩子 | `logistics_chat/hooks.py`:`rate_limit`、`no_cache_html` | 工厂注册钩子;限流状态在 `app.extensions['rate_hits']` |
| SQLite 与维护 | `logistics_chat/db.py`:`connect`、`close_connections`、`init_db`、`backup_db`、`save_message`、`last_user_question`、`load_context` | 维护 CLI 在 `scripts/`;旧会话自动清理仍未启用 |
| 版本、备份、恢复 | `logistics_chat/db.py`:`SCHEMA_VERSION`、`MIGRATIONS`;`database_backup.py`:`snapshot_database`、`daily_backup`、`restore_database`、`DailyBackupWorker` | `scripts/db_maintenance.py`;[数据库维护](database-maintenance.md);启动先快照再事务迁移,测试不启动后台线程 |
| 历史接口 | `logistics_chat/history_service.py`:`new_chat`、`get_history`、`get_messages`、`del_history`、`clear_all`;对应 `routes/history.py` | `static/js/chat.js`:`ensureConv`、`loadHistory`、`openConversation` |
| 知识检索 | `logistics_chat/knowledge.py`:`knowledge_list`、`search_knowledge` | [knowledge.md](knowledge.md) 与代码同步,程序不读取该文档 |
| 运费与附加费 | `logistics_chat/pricing.py`:`RATES`、`parse_shipping_intent`、`dims_to_cm`、`pick_rate`、`calc_freight`、`freight_reply`、`calc_warehouse_card`、`detect_surcharge`、`check_island` | `logistics_chat/rates_extra.py` 为唯一扩展数据源,根目录同名文件仅兼容转发 |
@@ -32,6 +33,8 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
| 附件 | `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 |
| 客服登录会话 | `logistics_chat/staff_service.py`:`login`、`logout`、`attach_staff`、`PUBLIC_ENDPOINTS` | `staff_sessions` 只存凭证摘要;24 小时绝对有效期,密码指纹校验;工厂最先注册默认拒绝鉴权;`admin.js` 携带 CSRF、401 停止轮询并跳转 |
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`;客户与客服脚本先加载 `support.js`(表情、通知、评价);客服再加载 `admin_tools.js`(备注、转接、历史搜索),最后加载 `admin.js`;保留全局事件入口 |
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_dev_integration.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs`、`chat.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证草稿、异步切换、转接、撤回及评价;浏览器使用临时测试库 |
@@ -94,6 +97,10 @@ tests/ # 按业务模块组织的回归
- 人工申请由服务端以 UTC+8 判断每日 08:00–22:00 营业状态,客户手机时区不参与判断。附件清理保留 `messages` 和 `human_messages` 引用的文件,仅删除超过 7 天的未引用临时文件;读取引用失败时停止清理。附件不存在或不属于当前游客均返回相同的私有、不可缓存 404 页面,破图在聊天中显示可读提示。
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
- 人工排队接口按服务端北京时间计算 `waiting_minutes`,等候超过 5 分钟返回超时状态和超时分钟数。`loadQueue` 对每条会话仅在首次进入超时时通知一次,队列计数常驻标签,卡片标红;离开超时队列后清除去重状态。
- 离线留言的 `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 分页;前端独立加载记录并隔离迟到响应。旧数据只保留升级状态,领取时间估算有显式标识。
## 后续结构调整顺序
@@ -117,6 +124,7 @@ tests/ # 按业务模块组织的回归
- 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
- 客服后台 JSON 请求由 `adminRequest` 统一设置 10 秒超时,包含响应正文读取;完成或失败后清理计时器。轮询超时释放锁、显示连接中断提示,后续每秒轮询自动恢复;发送超时保留当前草稿并恢复按钮,不自动重发,需先确认聊天记录再手动重试。
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。
@@ -124,7 +132,7 @@ tests/ # 按业务模块组织的回归
- 手机语音由 `voice.js` 管理按住、松开、上滑取消、60 秒上限及媒体轨道释放;切换会话、离开页面或权限/转写超时取消录音与待处理请求,迟到结果不能进入新会话,已有草稿不自动提交。`/api/speech/config` 仅返回可用性及限制,`/api/speech/transcribe` 有独立 5MB 请求限制并以 `MemoryAudioRequest` 将音频文件保持在内存中;音频不进入 uploads 或聊天历史。百度适配通过 FFmpeg 管道转换 16 kHz 单声道 PCM,校验真实解码长度后调用百度短语音识别,Token 按应用隔离缓存。可选 DashScope 适配和 DS 文字聊天凭据独立,密钥及上游原始错误不得返回客户端。识别结果走既有 AI/人工消息入口。H5 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
- 游客由 `visitor.js` 用安全随机数生成本地 key,经 `/api/visitor/session` 换取 HttpOnly、SameSite Cookie,HTTPS 时设置 Secure;浏览器存储不可用时可用 Cookie 恢复。数据库 `chat_history.visitor_id` 仅存 key 的 SHA-256,历史增删查、SSE 上下文和 `visitor_uploads` 附件索引均按归属校验。旧数据归属为空时保留,不自动授予新访客。客户端公共人工接口为 `/api/visitor/human/{request,poll,send,close}`,校验会话归属、只接受 customer 消息并去掉内部备注;原 `/api/human/*` 留给受保护客服。客服附件用 `/api/staff/uploads/`,所有客户 JSON 与附件禁止共享缓存。
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录和订单关联尚未实现,游客身份不能升级为订单权限。
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免代理密码。应用内 `staff_service.PUBLIC_ENDPOINTS` 按已注册端点列出公共入口,其余页面及接口统一校验独立客服会话;后台未登录时跳转 `/admin`,API 返回 401,写操作另校验 CSRF 与来源。`GET /api/admin/session` 返回当前客服和 CSRF 令牌,`POST /admin/logout` 撤销凭证;Cookie 为 HttpOnly、SameSite=Lax、HTTPS Secure,服务端及浏览器 Cookie 从登录起 24 小时有效,请求及重启不续期;升级时撤销没有签发时间的旧无限期凭证;退出、密码变化或删除账号使旧会话失效。上游仍只能绑定回环地址;客户登录与游客身份均不能升级为客服权限。
## 验证与维护
+40
View File
@@ -0,0 +1,40 @@
# 数据库升级、备份与恢复
普通启动会先在写锁保护下检查数据库版本。已有数据库先生成 `pre-migration-时间-随机值.db` 并执行 SQLite 完整性检查,再按 `MIGRATIONS` 的版本顺序升级;`schema_meta.schema_version` 记录成功版本。当前版本为 3;版本 2 为离线留言增加负责人、处理备注、更新时间、完成时间和并发版本号,版本 3 增加领取时间及处理记录表,既有留言和客服账号保留。旧留言只生成一条升级状态快照,不补造历史;原处理中留言以有效的最后更新时间估算领取时间并标记,无法估算时保留为空。保留既有旧表兼容检查,因此同版本启动也会先保存快照再校验表结构。更新和版本号提交位于同一事务,失败回滚并终止启动;备份失败不执行升级,未来版本的数据库拒绝降级。首次空库无需升级前快照。
新增结构变更时,在 `logistics_chat/db.py` 增加顺序迁移函数并递增 `SCHEMA_VERSION`,加入 `MIGRATIONS`;迁移函数必须使用传入连接,不得自行提交、使用 `executescript` 隐式提交或执行外部副作用。补充旧库升级、失败回滚和重复启动测试。
备份采用 SQLite 在线 backup API,可包含 WAL 中已提交的数据,先写同目录临时文件,完整性检查通过后才发布为正式文件;默认备份文件由临时文件继承私有权限。目标文件不得覆盖。普通每日备份命名 `history_YYYYMMDD.db`,同一天跨进程只保留一个经过校验的快照,保留含当天在内最近 7 个日期的每日备份。启动前 `pre-migration-时间-随机值.db` 快照按文件名中的创建时间只保留最近 5 份,每次成功生成并校验新快照后自动清理更早的同类文件;生成失败不清理,规则从下次启动起生效。`pre-restore-*`、`manual-*` 不自动删除,按需另行归档清理。
启动成功后立即做当日备份;`AUTO_BACKUP_ENABLED=true`(默认)时启动独立后台线程,每分钟检查北京时间日期,跨日自动备份,失败写入应用日志并在下一分钟重试。不依赖网页访问,Windows、Linux 和 Docker 共用同一实现。应用停止期间后台任务不会运行,下次启动补做当日备份;如需脱离应用定时运行,可在系统任务计划中调用下方 `daily` 命令。测试模式不会启动后台线程。
本机数据库为项目根目录 `history.db`,备份目录为 `backups/`。下面命令不初始化应用,不调用模型,也不创建丢失的源数据库。
```powershell
# 手动创建并校验一个快照
.\.venv\Scripts\python.exe scripts/db_maintenance.py backup
# 当日备份(可供系统定时任务调用)
.\.venv\Scripts\python.exe scripts/db_maintenance.py daily
# 列出备份,不输出客户内容
.\.venv\Scripts\python.exe scripts/db_maintenance.py list
# 校验当前数据库或指定备份
.\.venv\Scripts\python.exe scripts/db_maintenance.py check
.\.venv\Scripts\python.exe scripts/db_maintenance.py check --input "D:\1\wuliu_chat\backups\manual-实际文件名.db"
```
服务器需显式指定运行数据库路径,避免误用源码目录下的空库:
```bash
python scripts/db_maintenance.py --db /var/lib/baiyue-logistics-chat/history.db --backup-dir /var/lib/baiyue-logistics-chat/backups daily
```
恢复会将数据库回到备份时刻,之后新增的记录仅保留在恢复前安全快照。先停止应用、定时写入脚本和所有 SQLite 工具,再执行;`--service-stopped` 是操作者对停机状态的显式声明,程序不会自动停止未知进程。输入必须是配置备份目录内的 `.db` 文件,拒绝损坏文件和路径越界。存在 `-wal`、`-shm`、`-journal` 时拒绝恢复:关闭所有数据库使用者并正常 checkpoint 后重试,不要直接删除正在使用的边车文件。
```powershell
# 停止服务后,用 list 中的真实文件名替换占位名称
.\.venv\Scripts\python.exe scripts/db_maintenance.py restore --input "D:\1\wuliu_chat\backups\manual-实际文件名.db" --service-stopped
```
恢复先生成并验证 `pre-restore-*` 当前库快照,然后暂存、校验目标备份,再原子替换数据库,最后手动重新启动项目。源备份不改变;当前库无法完成安全备份时拒绝覆盖。恢复旧版本后,正常启动会按版本规则升级。
数据库备份包含账号、订单、聊天、留言等 SQLite 数据,**不包含 `uploads/` 附件及 `.env` 配置**。附件需要另行复制到私有备份位置;运行数据库、快照和配置不能提交 Git。文件系统需支持同目录硬链接及原子替换,本机 NTFS 和常用 Linux 文件系统支持;不支持时备份失败会明确报错,不继续升级。
+2 -2
View File
@@ -4,7 +4,7 @@
适用场景:Ubuntu / Debian、已有域名。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx(客户白名单公开,客服及其他路径需密码)→ 本机 Waitress → Flask / SQLite。
游客历史、人工会话和附件已按访客归属隔离;客户账号登录和订单关联尚未接入,匿名不能查询真实订单。必须沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
游客历史、人工会话和附件已按访客归属隔离;客户订单按登录账号关联范围校验,匿名不能查询真实订单。客服后台另有独立的应用登录会话,自登录成功起 24 小时有效,访问后台和重启服务不延长期限,写请求校验 CSRF;退出或改密后旧凭证失效。必须继续沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
## 1. 域名与网络
@@ -68,7 +68,7 @@ systemd 自动创建并授权以下目录,重启与更新源码不会替换其
| 应用日志 | `/var/log/baiyue-logistics-chat/app.log` |
| 密钥配置 | `/opt/baiyue-logistics-chat/.env` |
首次启动创建八张业务表,不插入演示数据、默认客服账号或密码。已有表和记录保持兼容。应用当前仍会在启动时清理超过 7 天的上传文件,并执行启动备份;启动备份不能替代定时备份。
首次启动创建所需业务表,已有表和记录保持兼容。已有数据库升级前生成校验快照并记录版本,失败回滚且不启动。启动执行当日备份,运行期间默认每分钟检查北京时间跨日并做每日备份;数据库恢复及服务器路径参数见 [数据库维护](database-maintenance.md)。附件清理只删除未被聊天引用的过期临时文件;附件及配置需另行备份。
## 5. 申请 HTTPS 证书
+5 -1
View File
@@ -2,7 +2,7 @@
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录与订单关联尚未接入,匿名查单不会读取订单数据。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服采用应用内 24 小时会话鉴权,并保留 Nginx 密码保护,不能裸露上游端口。客户订单访问按服务端客户登录及关联范围校验,匿名查单不会读取订单数据。
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
@@ -71,8 +71,12 @@
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
应用内另有升级前快照、版本迁移和运行中的每日数据库备份。维护 CLI 已包含在运行镜像,使用时指定 `/var/lib/baiyue-logistics-chat/history.db`;恢复必须停机并先保留当前数据安全快照,详见 [数据库维护](database-maintenance.md)。
新环境自动创建业务表,不创建默认客服账号或演示数据。专用访问密码用于客服工作台及受保护接口;Nginx 账号不是应用内客服账号。客户首页无需密码,客户端先自动建立访客会话,再访问自己的历史。
进入客服工作台还需使用 `agents` 中的应用账号登录。升级自动新增 `staff_sessions` 表,不覆盖已有账号密码;登录会话存储于数据库,自登录成功起 24 小时有效,浏览器 Cookie 同步保存 24 小时;访问后台或重启服务不会重置期限。升级时撤销旧无限期会话,需重新登录一次。退出、改密或删除账号后旧凭证不可再用。工作台写请求必须携带页面提供的 `X-CSRF-Token`;脚本调用后台 API 也必须先登录、保存 Cookie 并通过 `/api/admin/session` 获取 CSRF 令牌。每个账号与连接来源组合的登录限速为单进程计数,不直接信任客户端转发 IP 头,避免同一代理下某个账号输错导致其他账号被锁,多进程部署需配置统一代理限流。
## HTTPS 及续期
域名须解析到已核实的灰度入口,公网 80、443 端口可达。其他域名的证书不能直接用于新域名。
@@ -0,0 +1,16 @@
# 数据库升级与备份实施计划
目标:保留当前账号、订单和聊天数据,完善升级前快照、版本记录、在线备份、每日备份及离线恢复。
技术方案:现有幂等建表逻辑保留为基线迁移,新增 `schema_meta` 记录版本;每次已有数据库启动校验前创建完整性校验过的快照。迁移在同一 SQLite 写事务内执行,失败回滚并拒绝启动。备份采用 SQLite backup API、临时文件及原子发布。应用运行期间每天北京时间跨日检查一次;失败记录日志并重试。恢复仅提供离线 CLI,要求显式声明服务已停止,先保护当前库,再从已校验快照恢复。
- [x] 编写升级顺序、回滚、损坏文件、WAL、并发及恢复测试,确认旧实现失败。
- [x] `logistics_chat/database_backup.py` 实现快照、校验、保留策略、每日任务和恢复。
- [x] `logistics_chat/db.py` 抽取现有建表逻辑,版本化事务升级、失败保护;启动工厂接入每日任务。
- [x] `scripts/db_maintenance.py` 提供 backup/daily/list/check/restore,支持独立目录及服务器路径。
- [x] 更新配置、维护文档和模块导航,使用临时数据验证恢复,不恢复真实业务库。
- [x] 运行相关测试和全量回归,复核后交付;不提交 Git,不推送。
验收:升级前快照包含升级前结构;备份失败不改库;迁移失败不留下半升级结构;拒绝未来版本;备份包含 WAL 中已提交记录;坏快照不可恢复;恢复前保留现有库;重复启动不丢数据;每日任务不依赖请求访问。
验证结果:存储与维护 27 项测试通过,全套 208 项测试通过(14 项 Linux 专用测试在 Windows 跳过);真实本机数据库副本升级演练保留账号密码和业务数量。人工备份和完整性检查已在本机成功运行,真实库未执行恢复。复核发现的 WAL 临时边车清理、恢复 checkpoint、版本步骤完整性与 7 日期保留边界均已补测试修正。
+42
View File
@@ -0,0 +1,42 @@
# 离线留言处理
用户已确认采用留言领取、负责人、处理备注和待处理/处理中/已完成三种状态。
实现与验证顺序:
- [x] 增加隔离数据库接口测试:旧数据迁移、登录与 CSRF、并发领取、负责人限制、备注与完成、版本冲突和分页。
- [x] 数据库升级至版本 2;迁移前沿用已校验快照和最近 5 份保留规则。增加负责人账号与姓名、备注、更新时间、完成时间和并发版本。
- [x] 新增 ticket_service,业务路由保持客户提交接口及留言列表默认返回数组的契约;新增后台领取和更新接口。
- [x] 后台服务资料增加状态筛选、留言详情、领取、保存备注、完成及加载更多;只有当前负责人可处理,已完成只读。
- [x] 前端沿用后台请求超时、CSRF 和登录失效处理;轮询不覆盖草稿,旧响应不覆盖新选择,冲突明确提示并保留草稿。
- [x] 执行接口、前端与全套回归;在临时数据库和客服账号上做桌面、手机浏览器验证。
验证:218 项 unittest 通过,14 项 Linux 测试跳过;admin 前端 31 项通过(含延迟列表响应、写请求超时与备注保留)。Playwright 临时服务在 1440/390/320px 验证真实登录、领取、负责人、备注轮询保留、保存、完成和刷新后的持久记录,未发现脚本错误或横向溢出。
独立审查无阻塞问题;另补 SQLite 64 位编号与游标范围校验,相关 8 项留言接口测试通过。临时浏览器和测试服务已关闭,真实服务未启动或重启。
## 后续:新留言提醒和搜索(用户确认预览后实施)
- [x] 顶部待处理数量入口及页内新留言提示;独立 summary 轮询,首次建立基线,新记录去重提醒。浏览器后台通知复用既有铃铛权限,不包含客户内容。
- [x] 数据库按手机号、内容、负责人账号/姓名、备注搜索;保留列表数组契约,搜索可组合状态与分页,转义 LIKE 通配符。
- [x] 真实浏览器验证新留言到达、搜索与全局数量、领取后数量更新、无结果状态、直达入口和键盘焦点。独立审查无阻塞问题,修正入口焦点落在加载期间被禁用按钮的问题。
- [x] 全套 222 项 unittest 完成(14 项 Linux 测试跳过);34 项 admin 前端测试通过,含中文输入法 Enter、独立提醒和分页搜索。桌面 1440px、手机 390/320px 无横向溢出,截图保存于 output/playwright/。
本次不变更数据库结构,无需新增迁移版本;正式服务未启动或重启。
## 本轮:处理记录和超时提醒
- [x] 数据库升级至版本 3:增加领取时间、领取时间估算标识及只追加的留言事件;提交、领取、备注前后值、完成结果与状态变更在同一事务记录。
- [x] 旧留言保留升级时状态快照,不伪造既有过程;旧处理中留言以规范有效的最后更新时间估算领取时间并明确标识。
- [x] 增加全局超时统计和筛选:待领取超过 30 分钟、处理中超过领取后 2 小时;备注修改不重置时钟,完成后清除超时。
- [x] 后台展示超时入口、筛选、详情超时说明及按时间排序的可分页处理记录;历史加载失败可重试且不阻塞留言处理。
- [x] 修复并测试无效旧日期在超时筛选、数量与详情中的一致性;独立只读审查未发现 Critical/Important 问题。
- [x] 17 项离线留言后端测试、38 项后台前端测试通过;完整 unittest 228 项通过、14 项按平台跳过,`git diff --check` 通过。Playwright 已验证桌面及 390/320px 手机宽度无横向溢出、记录刷新后保留、完成清除超时。
- [x] 后续将待领取阈值调整为 30 分钟(处理中仍为 20 分钟),并更新边界测试和用户说明。
- [x] 人工排队超过 5 分钟后标红显示超时分钟数,队列标签显示超时数;首次进入超时时去重提醒。34 项接口测试、39 项后台前端测试及全套 229 项 unittest(14 项按平台跳过)通过;浏览器验证 1440/390/320px 无横向溢出。
截图:`output/playwright/ticket-history-success.png`、`ticket-history-mobile-success.png`、`ticket-history-completed-success.png`、`ticket-overdue-success.png`。浏览器使用临时数据库和本地测试客服,测试服务已停止;正式服务未重启,升级真实数据库会在下次常规启动时执行。
人工排队截图:`output/playwright/human-queue-overdue-success.png`、`output/playwright/human-queue-overdue-mobile-success.png`。浏览器使用临时数据库和测试客服;测试服务已停止。
不变更真实客服账号、客户数据,不自动启动正式服务,不提交 Git。
+17
View File
@@ -0,0 +1,17 @@
# 后台登录会话实施计划
已确认范围:保留现有客服账号密码;后台会话有效期 8 小时;保护后台页面及接口;增加退出按钮;客户登录独立;保留 Nginx 保护。
1. 先加入未登录、退出、过期、密码变更、CSRF、凭证重放测试,确认原实现不能满足要求。
2. 新增 `staff_service.py` 和仅存凭证摘要的 `staff_sessions` 表,统一默认拒绝规则、登录限速及 Cookie 签发/撤销。
3. 工作台加入退出按钮、CSRF 请求头、401 跳转和轮询停止;保留原账号验证兼容行为。
4. 既有后台测试通过真实登录获取会话,公共客户测试保持独立。更新文档,运行全套测试。
5. 使用临时数据库和临时端口验证浏览器登录、退出、过期及移动端布局,关闭测试服务,不启动原本已停止的 5000 服务。
实现及复核:已加入服务端会话、CSRF、退出与过期处理;账号删除、更新及 SQLite REPLACE 均撤销旧凭证。后台登录限速按账号与连接来源组合计算,避免同一代理下账号互相锁定。
验证:全套 unittest 187 项,14 项 Linux 专用测试跳过;前端包含 26 项工作台测试。临时服务使用独立数据库,通过真实浏览器登录、退出失败重试、旧 Cookie 重放及过期跳转;320、390、1440px 布局无横向溢出,退出触控高度 44px。测试服务已关闭,5000 未启动。
用户后续调整:后台取消 8 小时限制,改为持续登录直到主动退出;改密、删除账号仍撤销凭证。Cookie 按浏览器支持的 400 天上限持久保存并在后台请求成功时续期,服务端凭证不按时间失效。启动时将仍有效的旧会话迁移为无限期,已经失效的会话删除,不恢复旧凭证。客户登录的 8 小时规则保持独立。
最新用户调整:后台登录改为从登录成功起 24 小时绝对有效,Cookie 同步设置 86400 秒;后台请求和服务重启均不续期。旧无限期会话无法确定签发时间,升级时撤销并要求重新登录;有效的有限期会话保留原期限,已失效会话不恢复。退出、改密及删除账号继续即时撤销。
+9 -1
View File
@@ -10,7 +10,7 @@ def create_app(config=None):
from flask import Flask
from .config import ROOT_DIR, default_config
from . import customer_service, db, file_service, hooks, visitor_service
from . import customer_service, db, file_service, hooks, staff_service, visitor_service
from .routes import register_routes
from .routes.speech import MemoryAudioRequest
@@ -24,6 +24,9 @@ def create_app(config=None):
app.extensions['rate_hits'] = defaultdict(list)
app.extensions['customer_login_attempts'] = {}
app.extensions['customer_login_lock'] = Lock()
app.extensions['staff_login_attempts'] = {}
app.extensions['staff_login_lock'] = Lock()
app.before_request(staff_service.attach_staff)
app.before_request(hooks.rate_limit)
app.before_request(visitor_service.attach_visitor)
app.before_request(customer_service.attach_customer)
@@ -49,4 +52,9 @@ def create_app(config=None):
db.init_db()
db.backup_db()
file_service.cleanup_uploads()
if not app.testing and app.config['AUTO_BACKUP_ENABLED']:
from .database_backup import DailyBackupWorker
worker = DailyBackupWorker(app)
app.extensions['database_backup_worker'] = worker
worker.start()
return app
+8 -20
View File
@@ -1,7 +1,6 @@
"""Feedback, reporting, login, orders and offline tickets."""
from datetime import datetime
from .db import connect, now_str
from .tracking import card_to_text
@@ -55,30 +54,19 @@ def add_ticket(data):
return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400
if not isinstance(phone, str) or len(phone.strip()) > 32:
return {'ok': False, 'error': '联系电话格式无效。'}, 400
conn = connect()
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)',
(content.strip(), phone.strip(), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
conn.commit()
conn.close()
return {'ok': True}
from .ticket_service import create_ticket
return create_ticket(content.strip(), phone.strip())
def list_tickets():
conn = connect()
rows = conn.execute("SELECT id, content, phone, ctime FROM tickets WHERE status='pending' ORDER BY id DESC").fetchall()
conn.close()
return [{'id': r[0], 'content': r[1], 'phone': r[2], 'ctime': r[3]} for r in rows]
def list_tickets(status='pending', limit=20, before=None, query='', overdue='0'):
from .ticket_service import list_tickets as query_tickets
return query_tickets(status, limit, before, query, overdue)
def admin_login(data):
u = data.get('username', '')
p = data.get('password', '')
conn = connect()
row = conn.execute('SELECT name FROM agents WHERE username=? AND password=?', (u, p)).fetchone()
conn.close()
if row:
return {'ok': True, 'name': row[0]}
return ({'ok': False}, 401)
# Retain the service entry point; all credential/session rules live together.
from .staff_service import login
return login(data)
def agents_list():
+1
View File
@@ -18,6 +18,7 @@ def default_config():
'DATABASE': ROOT_DIR / 'history.db',
'UPLOAD_DIR': ROOT_DIR / 'uploads',
'BACKUP_DIR': ROOT_DIR / 'backups',
'AUTO_BACKUP_ENABLED': str(os.environ.get('AUTO_BACKUP_ENABLED', local_config.get('AUTO_BACKUP_ENABLED', 'true'))).lower() in ('1','true','yes'),
'LOG_DIR': ROOT_DIR / 'logs',
'DEEPSEEK_API_KEY': (api_key or '').strip(),
'ASR_API_KEY': str(os.environ.get('ASR_API_KEY', local_config.get('ASR_API_KEY')) or '').strip(),
+169
View File
@@ -0,0 +1,169 @@
"""Verified SQLite snapshots and offline restore, independent of Flask lifecycle."""
from contextlib import closing
from datetime import datetime, timedelta, timezone
import os
from pathlib import Path
import re
import sqlite3
import tempfile
from threading import Event, Thread
import time
import uuid
CHINA_TIME = timezone(timedelta(hours=8))
def read_connection(path):
return sqlite3.connect(Path(path).resolve().as_uri() + '?mode=ro', uri=True, timeout=30)
def check_database(path):
"""Read-only full integrity check; never create a missing source."""
with closing(read_connection(path)) as connection:
rows = connection.execute('PRAGMA integrity_check').fetchall()
if rows != [('ok',)]:
raise sqlite3.DatabaseError('Database integrity check failed')
return 'ok'
def snapshot_database(source, destination):
"""Include committed WAL data, verify, then publish without overwriting a file."""
source, destination = Path(source).resolve(), Path(destination).resolve()
if source == destination:
raise ValueError('Backup must differ from source database')
if destination.exists():
raise FileExistsError(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix='.sqlite-backup-', suffix='.tmp', dir=destination.parent)
os.close(descriptor)
temporary = Path(temporary)
deadline = time.monotonic() + 120
def progress(status, remaining, total):
if time.monotonic() > deadline:
raise TimeoutError('SQLite backup exceeded 120 seconds')
try:
with closing(read_connection(source)) as original, closing(sqlite3.connect(temporary)) as target:
original.backup(target, pages=256, progress=progress, sleep=0.05)
# Publish a standalone file even when the source database uses WAL.
target.execute('PRAGMA journal_mode=DELETE')
check_database(temporary)
with temporary.open('r+b') as handle:
os.fsync(handle.fileno())
# Hard-link publication is atomic and refuses an existing destination.
os.link(temporary, destination)
return destination
finally:
temporary.unlink(missing_ok=True)
for suffix in ('-wal', '-shm', '-journal'):
Path(str(temporary) + suffix).unlink(missing_ok=True)
def unique_backup_path(directory, prefix):
timestamp = datetime.now(CHINA_TIME).strftime('%Y%m%d-%H%M%S-%f')
return Path(directory) / f'{prefix}-{timestamp}-{uuid.uuid4().hex[:8]}.db'
def prune_migration_snapshots(directory, keep=5):
"""Call after publishing a verified snapshot, under the startup write lock."""
if keep < 1:
raise ValueError('At least one migration snapshot must be retained')
snapshots = []
for candidate in Path(directory).iterdir():
if (re.fullmatch(r'pre-migration-\d{8}-\d{6}-\d{6}-[0-9a-f]{8}\.db', candidate.name)
and not candidate.is_symlink() and candidate.is_file()):
snapshots.append(candidate)
# Generated names sort by their creation timestamps, independently of copied mtimes.
for candidate in sorted(snapshots, key=lambda path: path.name, reverse=True)[keep:]:
candidate.unlink(missing_ok=True)
def daily_backup(database, directory, now=None):
now = now or datetime.now(CHINA_TIME)
directory = Path(directory)
destination = directory / ('history_' + now.strftime('%Y%m%d') + '.db')
if destination.exists():
check_database(destination)
else:
try:
snapshot_database(database, destination)
except FileExistsError:
# Another worker published the same day's complete snapshot.
check_database(destination)
cutoff = now.date() - timedelta(days=6)
for candidate in directory.iterdir():
match = re.fullmatch(r'history_(\d{8})\.db', candidate.name)
if not match or candidate.is_symlink() or not candidate.is_file():
continue
try:
old = datetime.strptime(match[1], '%Y%m%d').date()
except ValueError:
continue
if old < cutoff:
candidate.unlink(missing_ok=True)
return destination
def restore_database(database, backup, directory):
"""Offline only: keep current state, stage a verified copy, and replace atomically."""
database, backup, directory = Path(database).resolve(), Path(backup).resolve(), Path(directory).resolve()
if backup.parent != directory or backup == database or backup.suffix != '.db':
raise ValueError('Restore input must be a .db file inside the configured backup directory')
check_database(backup)
# Avoid silently detaching a live WAL/journal from a replacement database.
for suffix in ('-wal', '-shm', '-journal'):
if Path(str(database) + suffix).exists():
raise RuntimeError('Database has SQLite sidecar files; stop all users and checkpoint before restoring')
safety = None
if database.exists():
safety = snapshot_database(database, unique_backup_path(directory, 'pre-restore'))
# Reading a clean WAL database may create coordination files. Let SQLite
# checkpoint/remove these under the declared offline maintenance window.
with closing(sqlite3.connect(database, timeout=30)) as current:
current.execute('PRAGMA wal_checkpoint(TRUNCATE)')
current.execute('PRAGMA journal_mode=DELETE')
for suffix in ('-wal', '-shm', '-journal'):
if Path(str(database) + suffix).exists():
raise RuntimeError('SQLite sidecars remain; database is not ready for offline restore')
staged = database.with_name('.restore-' + uuid.uuid4().hex + '.db')
try:
snapshot_database(backup, staged)
os.replace(staged, database)
finally:
staged.unlink(missing_ok=True)
return safety
class DailyBackupWorker:
"""One stoppable task per app; daily files also coordinate independent processes."""
def __init__(self, app):
self.app = app
self.stop_event = Event()
# First tick rechecks startup's backup, including a startup crossing midnight.
self.last_date = None
self.thread = Thread(target=self.run, name='sqlite-daily-backup', daemon=True)
def tick(self, now=None):
now = now or datetime.now(CHINA_TIME)
if now.date() == self.last_date:
return False
daily_backup(self.app.config['DATABASE'], self.app.config['BACKUP_DIR'], now)
self.last_date = now.date()
return True
def run(self):
while not self.stop_event.wait(60):
try:
self.tick()
except Exception:
self.app.logger.exception('Daily SQLite backup failed; will retry in 60 seconds')
def start(self):
self.thread.start()
def stop(self):
self.stop_event.set()
self.thread.join(timeout=2)
+61 -27
View File
@@ -1,14 +1,14 @@
"""SQLite schema, history persistence and backups; initialized explicitly by create_app."""
import os
import re
import shutil
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
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 = 3
def connect():
@@ -36,9 +36,48 @@ def now_str():
def init_db():
Path(get_setting('DATABASE')).parent.mkdir(parents=True, exist_ok=True)
conn = connect()
try:
# A reserved write lock serializes snapshots and schema migration across workers.
conn.execute('BEGIN IMMEDIATE')
has_tables = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' LIMIT 1").fetchone()
version = 0
if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_meta'").fetchone():
row = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()
if row:
version = int(row[0])
if version > SCHEMA_VERSION:
raise RuntimeError('Database schema is newer than this application; refusing downgrade')
if version < 0:
raise RuntimeError('Invalid database schema version')
if [number for number, operation in MIGRATIONS] != list(range(1, SCHEMA_VERSION + 1)):
raise RuntimeError('Database migration registry has missing or unordered versions')
if has_tables:
snapshot_database(get_setting('DATABASE'), unique_backup_path(get_setting('BACKUP_DIR'), 'pre-migration'))
prune_migration_snapshots(get_setting('BACKUP_DIR'), keep=5)
conn.execute('CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
for target_version, migration in MIGRATIONS:
if version < target_version <= SCHEMA_VERSION:
migration(conn)
conn.execute("INSERT OR REPLACE INTO schema_meta VALUES('schema_version',?)", (str(target_version),))
if version == SCHEMA_VERSION:
# Retain legacy schema reconciliation for tables maintained by older tools.
_initialize_schema(conn)
_upgrade_ticket_workflow(conn)
_upgrade_ticket_history(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')
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _initialize_schema(conn):
"""Version 1 baseline reconciliation preserves supported legacy table layouts."""
cur = conn.cursor()
# Serialize schema inspection and migration when multiple workers start together.
cur.execute('BEGIN IMMEDIATE')
cur.execute('''
CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -149,34 +188,29 @@ def init_db():
init_order_table(conn)
from .customer_service import init_customer_tables
init_customer_tables(conn)
from .staff_service import init_staff_tables
init_staff_tables(conn)
old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall()
for cid, title in old:
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str()))
conn.commit()
conn.close()
def _upgrade_ticket_workflow(conn):
from .ticket_service import init_ticket_tables
init_ticket_tables(conn)
def _upgrade_ticket_history(conn):
from .ticket_service import init_ticket_history
init_ticket_history(conn)
MIGRATIONS = ((1, _initialize_schema), (2, _upgrade_ticket_workflow), (3, _upgrade_ticket_history))
def backup_db():
"""启动时把数据库备份到 backups/,文件名带日期,只保留最近 7 天"""
try:
bdir = get_setting('BACKUP_DIR')
os.makedirs(bdir, exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
dst = os.path.join(bdir, f'history_{today}.db')
if not os.path.exists(dst):
shutil.copy2(get_setting('DATABASE'), dst)
cutoff = datetime.now() - timedelta(days=7)
for f in os.listdir(bdir):
m = re.match('history_(\\d{8})\\.db$', f)
if not m:
continue
try:
if datetime.strptime(m.group(1), '%Y%m%d') < cutoff:
os.remove(os.path.join(bdir, f))
except Exception:
pass
except Exception as e:
print(f'[数据库备份失败] {e}')
"""Verified daily online snapshot; failures propagate rather than claiming success."""
return daily_backup(get_setting('DATABASE'), get_setting('BACKUP_DIR'))
def cleanup_old_conversations():
+4 -1
View File
@@ -2,10 +2,13 @@
import time
from flask import current_app, jsonify, request
from flask import current_app, g, jsonify, request
def rate_limit():
# Authenticated staff polling has a separate trust boundary from public traffic.
if getattr(g, 'staff', None):
return None
path = request.path
if path in ('/healthz', '/api/visitor/human/poll') or path.startswith('/static') or path.startswith('/api/human/'):
return None
+18 -2
View File
@@ -1,11 +1,13 @@
"""Human conversation lifecycle; typing state is supplied by the app."""
import time
from datetime import datetime, timedelta, timezone
from datetime import datetime, datetime as DateTime, timedelta, timezone
from math import ceil
from .db import connect
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
QUEUE_TIMEOUT_SECONDS = 5 * 60
def human_request(data):
@@ -70,10 +72,24 @@ 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()
conn.close()
return [{'id': r[0], 'conv_id': r[1], 'ctime': r[3]} for r in rows]
queue = []
for row in rows:
try:
created = DateTime.fromisoformat(row[3])
if created.strftime('%Y-%m-%d %H:%M:%S') != row[3]:
raise ValueError('Invalid queue timestamp')
elapsed = max(0, int((now-created).total_seconds()))
except (ValueError, TypeError):
elapsed = 0
overdue = elapsed > QUEUE_TIMEOUT_SECONDS
queue.append({'id':row[0], 'conv_id':row[1], 'ctime':row[3],
'waiting_minutes':elapsed//60, 'overdue':overdue,
'overdue_minutes':max(1,ceil((elapsed-QUEUE_TIMEOUT_SECONDS)/60)) if overdue else 0})
return queue
def human_active():
+14 -3
View File
@@ -1,8 +1,8 @@
"""HTTP adapters for admin; business logic lives in services."""
from flask import Blueprint, Response, render_template, request
from flask import Blueprint, Response, g, render_template, request
from .. import business_service
from .. import business_service, staff_service
bp = Blueprint('admin', __name__)
@@ -13,7 +13,18 @@ def admin_stats():
@bp.route('/admin/login', methods=['POST'])
def admin_login():
return business_service.admin_login(request.json or {})
request.max_content_length = 4096
return staff_service.login(request.get_json(silent=True))
@bp.get('/api/admin/session')
def staff_session():
return {'ok': True, **g.staff}
@bp.post('/admin/logout')
def staff_logout():
return staff_service.logout()
@bp.route('/api/export_chat')
+31 -3
View File
@@ -1,8 +1,8 @@
"""HTTP adapters for business; business logic lives in services."""
from flask import Blueprint, request
from flask import Blueprint, g, request
from .. import business_service, visitor_service
from .. import business_service, ticket_service, visitor_service
bp = Blueprint('business', __name__)
@@ -18,7 +18,35 @@ def add_ticket():
@bp.route('/api/tickets')
def list_tickets():
return business_service.list_tickets()
return business_service.list_tickets(request.args.get('status', 'pending'),
request.args.get('limit', 20), request.args.get('before'),
request.args.get('q', ''), request.args.get('overdue', '0'))
@bp.get('/api/tickets/summary')
def ticket_summary():
return ticket_service.ticket_summary(request.args.get('after'))
@bp.get('/api/tickets/<int:ticket_id>')
def get_ticket(ticket_id):
return ticket_service.get_ticket(ticket_id)
@bp.get('/api/tickets/<int:ticket_id>/events')
def ticket_events(ticket_id):
return ticket_service.ticket_events(ticket_id,request.args.get('limit',20),request.args.get('before'))
@bp.post('/api/tickets/<int:ticket_id>/claim')
def claim_ticket(ticket_id):
return ticket_service.claim_ticket(ticket_id, g.staff)
@bp.patch('/api/tickets/<int:ticket_id>')
def update_ticket(ticket_id):
request.max_content_length = 16384
return ticket_service.update_ticket(ticket_id, request.get_json(silent=True), g.staff)
@bp.route('/api/rate', methods=['POST'])
+161
View File
@@ -0,0 +1,161 @@
"""Independent, revocable staff sessions; only token digests are persisted."""
import hashlib
from hmac import compare_digest
import secrets
import time
from flask import current_app, g, jsonify, redirect, request
from werkzeug.security import check_password_hash
from .db import connect
COOKIE_NAME = 'baiyue_staff'
SESSION_SECONDS = 24 * 3600
PUBLIC_ENDPOINTS = frozenset({
'static', 'pages.index', 'pages.health', 'admin.admin', 'admin.admin_login',
'chat.stream_chat', 'chat.feedback', 'history.new_chat', 'history.get_history',
'history.get_messages', 'history.del_history', 'history.clear_all',
'files.upload_file', 'files.uploaded_file', 'business.add_ticket', 'business.rate_chat',
'visitor.visitor_session', 'visitor.visitor_human', 'customer.session',
'customer.login', 'customer.logout', 'speech.speech_config', 'speech.speech_transcribe',
})
def digest(value):
return hashlib.sha256(value.encode('utf-8')).hexdigest()
def init_staff_tables(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(staff_sessions)')}
required = {'token_hash', 'username', 'auth_stamp', 'csrf_token', 'expires_at'}
if columns and not required.issubset(columns):
# Older versions keyed sessions by agent_id. Revoke incompatible credentials
# and rebuild this disposable auth table; never alter accounts/business data.
connection.execute('DROP TABLE staff_sessions')
connection.execute('''CREATE TABLE IF NOT EXISTS staff_sessions (
token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, auth_stamp TEXT NOT NULL,
csrf_token TEXT NOT NULL, expires_at REAL NOT NULL
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_staff_expiry ON staff_sessions(expires_at)')
# Legacy unlimited sessions have no issue time; require a fresh bounded login.
# Leave valid deadlines untouched so restarts never extend a session.
connection.execute('DELETE FROM staff_sessions WHERE expires_at<=?', (time.time(),))
# Recreating a username or restoring an earlier password must not revive cookies.
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_delete_agent
AFTER DELETE ON agents BEGIN
DELETE FROM staff_sessions WHERE username=OLD.username;
END''')
# SQLite REPLACE may skip DELETE triggers; INSERT still fires in every connection.
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_insert_agent
AFTER INSERT ON agents BEGIN
DELETE FROM staff_sessions WHERE username=NEW.username;
END''')
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_change_credentials
AFTER UPDATE OF username,password ON agents
WHEN OLD.username IS NOT NEW.username OR OLD.password IS NOT NEW.password
BEGIN DELETE FROM staff_sessions WHERE username=OLD.username; END''')
def token_hash():
token = request.cookies.get(COOKIE_NAME, '')
return digest(token) if len(token) == 64 and all(c in '0123456789abcdef' for c in token) else None
def same_origin():
origin = request.headers.get('Origin')
return (not origin or origin.rstrip('/') == request.host_url.rstrip('/')) and request.headers.get('Sec-Fetch-Site') != 'cross-site'
def attach_staff():
"""New/unknown endpoints are staff-only until explicitly reviewed as public."""
g.staff = None
if request.endpoint in PUBLIC_ENDPOINTS:
return None
hashed = token_hash()
if hashed:
connection = connect()
try:
row = connection.execute(
'SELECT s.username,a.name,s.csrf_token,s.auth_stamp,a.password '
'FROM staff_sessions s JOIN agents a ON a.username=s.username '
'WHERE s.token_hash=? AND s.expires_at>?', (hashed, time.time())).fetchone()
finally:
connection.close()
if row and compare_digest(row[3], digest(row[4])):
g.staff = {'username': row[0], 'name': row[1], 'csrf_token': row[2]}
if not g.staff:
if request.path.startswith('/admin/') and request.method in ('GET', 'HEAD'):
return redirect('/admin')
return jsonify({'ok': False, 'error': 'staff_login_required', 'e': '后台登录已失效,请重新登录。'}), 401
if request.method not in ('GET', 'HEAD', 'OPTIONS'):
csrf = request.headers.get('X-CSRF-Token', '')
if not same_origin() or not compare_digest(csrf.encode('utf-8'), g.staff['csrf_token'].encode('utf-8')):
return jsonify({'ok': False, 'error': 'csrf_invalid', 'e': '请求校验失败,请刷新后台页面。'}), 403
def login(data):
if not same_origin():
return {'ok': False}, 403
if not request.is_json:
return {'ok': False}, 415
if not isinstance(data, dict):
return {'ok': False}, 401
username, password = data.get('username'), data.get('password')
if not isinstance(username, str) or not isinstance(password, str) or not 0 < len(username) <= 256 or not 0 < len(password) <= 1024:
return {'ok': False}, 401
attempts = current_app.extensions['staff_login_attempts']
lock = current_app.extensions['staff_login_lock']
# Proxy traffic can share a peer IP. Failures for one account must not lock all staff.
attempt_key = (request.remote_addr or 'unknown', username)
now = time.time()
# Reserve attempts before expensive password hashing, including concurrent requests.
with lock:
for key in list(attempts):
attempts[key] = [instant for instant in attempts[key] if now - instant < 300]
if not attempts[key]:
del attempts[key]
if len(attempts.get(attempt_key, [])) >= 5:
return {'ok': False, 'e': '登录尝试过多,请 5 分钟后重试。'}, 429, {'Retry-After': '300'}
attempts.setdefault(attempt_key, []).append(now)
connection = connect()
try:
row = connection.execute('SELECT name,password FROM agents WHERE username=?', (username,)).fetchone()
valid = False
if row and isinstance(row[1], str) and row[1]:
if row[1].startswith(('pbkdf2:', 'scrypt:')):
try:
valid = check_password_hash(row[1], password)
except (ValueError, TypeError):
pass
else:
valid = compare_digest(row[1].encode('utf-8'), password.encode('utf-8'))
if not valid:
return {'ok': False}, 401
token, csrf = secrets.token_hex(32), secrets.token_hex(32)
connection.execute('DELETE FROM staff_sessions WHERE expires_at<=? OR token_hash=?', (now, token_hash()))
connection.execute('INSERT INTO staff_sessions VALUES(?,?,?,?,?)',
(digest(token), username, digest(row[1]), csrf, now + SESSION_SECONDS))
connection.commit()
finally:
connection.close()
with lock:
attempts.pop(attempt_key, None)
response = jsonify({'ok': True, 'name': row[0]})
response.set_cookie(COOKIE_NAME, token, max_age=SESSION_SECONDS, httponly=True,
secure=request.is_secure, samesite='Lax', path='/')
response.headers['Cache-Control'] = 'no-store'
return response
def logout():
connection = connect()
try:
connection.execute('DELETE FROM staff_sessions WHERE token_hash=?', (token_hash(),))
connection.commit()
finally:
connection.close()
response = jsonify({'ok': True})
response.delete_cookie(COOKIE_NAME, path='/', httponly=True, secure=request.is_secure, samesite='Lax')
response.headers['Cache-Control'] = 'no-store'
return response
+243
View File
@@ -0,0 +1,243 @@
"""Staff-owned offline ticket lifecycle and optimistic updates."""
from contextlib import closing
from datetime import datetime, datetime as DateTime, timedelta
from math import ceil
from .db import connect
STATES = ('pending', 'processing', 'completed')
MAX_TICKET_ID = 2**63 - 1
COLUMNS = ('id', 'content', 'phone', 'ctime', 'status', 'owner_username', 'owner_name',
'note', 'updated_at', 'completed_at', 'version', 'claimed_at', 'claimed_at_estimated')
EVENT_COLUMNS = ('id', 'ticket_id', 'action', 'actor_username', 'actor_name', 'ctime',
'old_status', 'new_status', 'old_note', 'new_note')
PENDING_TIMEOUT = 30 * 60
PROCESSING_TIMEOUT = 20 * 60
def init_ticket_tables(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(tickets)')}
additions = {'owner_username':'TEXT', 'owner_name':"TEXT NOT NULL DEFAULT ''",
'note':"TEXT NOT NULL DEFAULT ''", 'updated_at':'TEXT',
'completed_at':'TEXT', 'version':'INTEGER NOT NULL DEFAULT 0'}
for name, declaration in additions.items():
if name not in columns:
connection.execute(f'ALTER TABLE tickets ADD COLUMN {name} {declaration}')
connection.execute("UPDATE tickets SET status='pending' WHERE status IS NULL OR status=''")
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_status_id ON tickets(status,id)')
def init_ticket_history(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(tickets)')}
if 'claimed_at' not in columns:
connection.execute('ALTER TABLE tickets ADD COLUMN claimed_at TEXT')
if 'claimed_at_estimated' not in columns:
connection.execute('ALTER TABLE tickets ADD COLUMN claimed_at_estimated INTEGER NOT NULL DEFAULT 0')
connection.execute('''CREATE TABLE IF NOT EXISTS ticket_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL,
action TEXT NOT NULL, actor_username TEXT, actor_name TEXT NOT NULL,
ctime TEXT NOT NULL, old_status TEXT, new_status TEXT,
old_note TEXT NOT NULL DEFAULT '', new_note TEXT NOT NULL DEFAULT ''
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_events ON ticket_events(ticket_id,id)')
# A legacy baseline records only the known state at upgrade, never invented actions.
connection.execute('''UPDATE tickets SET claimed_at=updated_at,claimed_at_estimated=1
WHERE status='processing' AND claimed_at IS NULL AND datetime(updated_at,'+0 seconds')=updated_at''')
connection.execute('''INSERT INTO ticket_events(ticket_id,action,actor_name,ctime,new_status,new_note)
SELECT id,'legacy_snapshot','系统',?,status,COALESCE(note,'') FROM tickets
WHERE NOT EXISTS(SELECT 1 FROM ticket_events e WHERE e.ticket_id=tickets.id)''',
(datetime.now().strftime('%Y-%m-%d %H:%M:%S'),))
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_pending_time ON tickets(status,ctime)')
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_claimed_time ON tickets(status,claimed_at)')
def record_event(connection, ticket_id, action, now, staff=None, old_status=None,
new_status=None, old_note='', new_note=''):
connection.execute('''INSERT INTO ticket_events(ticket_id,action,actor_username,actor_name,ctime,
old_status,new_status,old_note,new_note) VALUES(?,?,?,?,?,?,?,?,?)''',
(ticket_id, action, staff['username'] if staff else None, staff['name'] if staff else '客户',
now, old_status, new_status, old_note, new_note))
def create_ticket(content, phone):
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with closing(connect()) as connection:
cursor = connection.execute('INSERT INTO tickets(content,phone,ctime,status) VALUES(?,?,?,?)',
(content, phone, now, 'pending'))
record_event(connection, cursor.lastrowid, 'submitted', now, new_status='pending')
connection.commit()
return {'ok': True}
def _with_overdue(ticket, now=None):
ticket.update(overdue_kind=None, overdue_minutes=0)
anchor = ticket['ctime'] if ticket['status'] == 'pending' else ticket['claimed_at']
threshold = PENDING_TIMEOUT if ticket['status'] == 'pending' else PROCESSING_TIMEOUT
if ticket['status'] not in ('pending', 'processing') or not anchor:
return ticket
try:
parsed = DateTime.fromisoformat(anchor)
if parsed.strftime('%Y-%m-%d %H:%M:%S') != anchor:
return ticket
elapsed = ((now or datetime.now()).replace(microsecond=0) - parsed).total_seconds()
except (ValueError, TypeError):
return ticket
if elapsed > threshold:
ticket.update(overdue_kind=ticket['status'], overdue_minutes=max(1,ceil((elapsed-threshold)/60)))
return ticket
def _overdue_predicate():
return "((status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime<?) OR " \
"(status='processing' AND datetime(claimed_at,'+0 seconds')=claimed_at AND claimed_at<?))"
def _cutoffs(now):
return tuple((now-timedelta(seconds=seconds)).strftime('%Y-%m-%d %H:%M:%S')
for seconds in (PENDING_TIMEOUT, PROCESSING_TIMEOUT))
def _ticket(connection, ticket_id):
if not 0 < ticket_id <= MAX_TICKET_ID:
return None
row = connection.execute('SELECT ' + ','.join(COLUMNS) + ' FROM tickets WHERE id=?', (ticket_id,)).fetchone()
return _with_overdue(dict(zip(COLUMNS, row))) if row else None
def get_ticket(ticket_id):
with closing(connect()) as connection:
ticket = _ticket(connection, ticket_id)
return {'ok': True, 'ticket': ticket} if ticket else ({'ok': False, 'error':'留言不存在。'}, 404)
def list_tickets(status='pending', limit=20, before=None, query='', overdue='0'):
if overdue not in ('0','1'):
return {'ok': False, 'error':'超时筛选参数无效。'}, 400
if not isinstance(query, str) or len(query) > 100:
return {'ok': False, 'error':'搜索关键词最多 100 字。'}, 400
if status not in (*STATES, 'all'):
return {'ok': False, 'error':'留言状态无效。'}, 400
try:
limit = int(limit)
before = int(before) if before is not None else None
except (ValueError, TypeError):
return {'ok': False, 'error':'分页参数无效。'}, 400
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_TICKET_ID):
return {'ok': False, 'error':'分页参数无效。'}, 400
clauses, parameters = [], []
now = datetime.now()
if overdue == '1':
clauses.append(_overdue_predicate())
parameters.extend(_cutoffs(now))
if status != 'all':
clauses.append('status=?')
parameters.append(status)
if before is not None:
clauses.append('id<?')
parameters.append(before)
if query.strip():
literal = query.strip().replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
fields = ('content', 'phone', 'owner_username', 'owner_name', 'note')
clauses.append('(' + ' OR '.join(f"{field} LIKE ? ESCAPE '\\'" for field in fields) + ')')
parameters.extend(['%' + literal + '%'] * len(fields))
where = ' WHERE ' + ' AND '.join(clauses) if clauses else ''
with closing(connect()) as connection:
rows = connection.execute('SELECT ' + ','.join(COLUMNS) + ' FROM tickets' + where +
' ORDER BY id DESC LIMIT ?', (*parameters, limit)).fetchall()
return [_with_overdue(dict(zip(COLUMNS, row)), now) for row in rows]
def ticket_summary(after=None):
if after is not None:
try:
after = int(after)
except (TypeError, ValueError):
return {'ok': False, 'error':'留言提醒游标无效。'}, 400
if not 0 <= after <= MAX_TICKET_ID:
return {'ok': False, 'error':'留言提醒游标无效。'}, 400
# One snapshot, independent of the list's status, search and page cursor.
pending_cutoff, processing_cutoff = _cutoffs(datetime.now())
with closing(connect()) as connection:
pending, latest, new, pending_overdue, processing_overdue = connection.execute(
"SELECT (SELECT COUNT(*) FROM tickets WHERE status='pending'),"
'(SELECT COALESCE(MAX(id),0) FROM tickets),'
'(SELECT COUNT(*) FROM tickets WHERE id>?),'
"(SELECT COUNT(*) FROM tickets WHERE status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime<?),"
"(SELECT COUNT(*) FROM tickets WHERE status='processing' AND datetime(claimed_at,'+0 seconds')=claimed_at AND claimed_at<?)",
(after,pending_cutoff,processing_cutoff)).fetchone()
return {'pending_count':pending, 'latest_id':latest, 'new_count':new,
'overdue_count':pending_overdue+processing_overdue,
'pending_overdue_count':pending_overdue, 'processing_overdue_count':processing_overdue}
def ticket_events(ticket_id, limit=20, before=None):
try:
limit = int(limit)
before = int(before) if before is not None else None
except (ValueError, TypeError):
return {'ok':False, 'error':'记录分页参数无效。'}, 400
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_TICKET_ID):
return {'ok':False, 'error':'记录分页参数无效。'}, 400
with closing(connect()) as connection:
if not _ticket(connection,ticket_id):
return {'ok':False, 'error':'留言不存在。'}, 404
rows = connection.execute('SELECT '+','.join(EVENT_COLUMNS)+' FROM ticket_events WHERE ticket_id=?'+
(' AND id<?' if before is not None else '')+' ORDER BY id DESC LIMIT ?',
(ticket_id, before, limit) if before is not None else (ticket_id,limit)).fetchall()
return [dict(zip(EVENT_COLUMNS,row)) for row in rows]
def claim_ticket(ticket_id, staff):
if not 0 < ticket_id <= MAX_TICKET_ID:
return {'ok': False, 'error':'留言不存在。'}, 404
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with closing(connect()) as connection:
# The guarded write makes simultaneous claims select exactly one owner.
changed = connection.execute("UPDATE tickets SET status='processing',owner_username=?,"
"owner_name=?,updated_at=?,claimed_at=?,claimed_at_estimated=0,version=version+1 WHERE id=? AND status='pending' "
'AND owner_username IS NULL', (staff['username'], staff['name'], now, now, ticket_id)).rowcount
ticket = _ticket(connection, ticket_id)
if not changed:
return ({'ok': False, 'error':'留言已被领取或已完成,请刷新查看。'}, 409) if ticket else (
{'ok': False, 'error':'留言不存在。'}, 404)
record_event(connection,ticket_id,'claimed',now,staff,old_status='pending',new_status='processing')
connection.commit()
return {'ok': True, 'ticket': ticket}
def update_ticket(ticket_id, data, staff):
if (not isinstance(data, dict) or set(data) - {'version', 'note', 'status'}
or type(data.get('version')) is not int or data['version'] < 0
or not ({'note', 'status'} & set(data))):
return {'ok': False, 'error':'留言更新格式无效,请刷新后重试。'}, 400
if 'note' in data and (not isinstance(data['note'], str) or len(data['note']) > 2000):
return {'ok': False, 'error':'处理备注最多 2000 字。'}, 400
status = data.get('status', 'processing')
if status not in ('processing', 'completed'):
return {'ok': False, 'error':'处理状态无效。'}, 400
with closing(connect()) as connection:
connection.execute('BEGIN IMMEDIATE')
ticket = _ticket(connection, ticket_id)
if not ticket:
return {'ok': False, 'error':'留言不存在。'}, 404
if ticket['status'] != 'processing':
return {'ok': False, 'error':'请先领取留言,已完成留言不能修改。'}, 409
if ticket['owner_username'] != staff['username']:
return {'ok': False, 'error':'只有负责人可以处理这条留言。'}, 403
if ticket['version'] != data['version']:
return {'ok': False, 'error':'留言已在其他页面更新,请刷新后核对备注。'}, 409
note = data.get('note', ticket['note']).strip()
if status == 'completed' and not note:
return {'ok': False, 'error':'请先填写处理结果,再标记完成。'}, 400
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
connection.execute('UPDATE tickets SET note=?,status=?,updated_at=?,completed_at=?,version=version+1 '
'WHERE id=?', (note, status, now, now if status == 'completed' else None, ticket_id))
if note != ticket['note']:
record_event(connection,ticket_id,'note_updated',now,staff,old_note=ticket['note'],new_note=note)
if status == 'completed':
record_event(connection,ticket_id,'completed',now,staff,old_status='processing',new_status='completed',
new_note=note)
ticket = _ticket(connection, ticket_id)
connection.commit()
return {'ok': True, 'ticket': ticket}
+54
View File
@@ -0,0 +1,54 @@
"""Database backup/check/list and explicitly offline restore; no app initialization."""
import argparse
import json
from pathlib import Path
import sqlite3
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from logistics_chat.database_backup import (check_database, daily_backup, restore_database,
snapshot_database, unique_backup_path)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--db', type=Path, default=ROOT/'history.db')
parser.add_argument('--backup-dir', type=Path, default=None)
parser.add_argument('command', choices=('backup', 'daily', 'list', 'check', 'restore'))
parser.add_argument('--input', type=Path)
parser.add_argument('--service-stopped', action='store_true', help='Confirm all database users are stopped before restore')
args = parser.parse_args(argv)
database = args.db.resolve()
directory = (args.backup_dir or database.parent/'backups').resolve()
try:
if args.command == 'backup':
path = snapshot_database(database, unique_backup_path(directory, 'manual'))
print('Backup: ' + str(path))
elif args.command == 'daily':
print('Backup: ' + str(daily_backup(database, directory)))
elif args.command == 'list':
for path in sorted(directory.glob('*.db'), reverse=True):
print(json.dumps({'file':str(path), 'bytes':path.stat().st_size}, ensure_ascii=False))
elif args.command == 'check':
print(check_database(args.input or database))
elif args.command == 'restore':
if not args.service_stopped:
parser.error('Restore requires --service-stopped; stop the app and all database users first')
if args.input is None:
parser.error('Restore requires --input')
safety = restore_database(database, args.input, directory)
print('Restored: ' + str(database))
if safety:
print('Safety backup: ' + str(safety))
except (OSError, ValueError, RuntimeError, sqlite3.Error) as error:
print('Database operation failed: ' + str(error), file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())
+66 -4
View File
@@ -10,6 +10,9 @@ body { height: 100dvh; min-height: 0; display: flex; flex-direction: column; ove
.app-identity h1 { font-size: 14px; font-weight: 500; color: var(--secondary); white-space: nowrap; }
.workspace-link { font-size: 12px; color: var(--secondary); text-decoration: none; white-space: nowrap; }
.workspace-link:hover { color: var(--brand); }
.staff-logout { min-width: 76px; min-height: 44px; padding: 0 10px; border: 1px solid var(--border); border-radius: 7px; color: var(--secondary); background: var(--surface); font-size: 13px; white-space: nowrap; cursor: pointer; }
.staff-logout:hover, .staff-logout:focus-visible { color: var(--brand); border-color: var(--brand); background: var(--brand-soft); }
.staff-logout:disabled { opacity: .6; cursor: wait; }
.data-overview { position: relative; }
.data-overview summary { display: flex; align-items: center; gap: 7px; padding: 6px 10px; font-size: 12px; color: var(--secondary); cursor: pointer; border-radius: 7px; list-style: none; border: 1px solid var(--border); }
.data-overview summary::-webkit-details-marker { display: none; }
@@ -54,6 +57,9 @@ body { height: 100dvh; min-height: 0; display: flex; flex-direction: column; ove
.badge { flex-shrink: 0; padding: 0 5px; border-radius: 5px; background: var(--brand); color: white; font-size: 10px; }
.queue-item { border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 8px; background: var(--surface); }
.queue-item .qt { font-size: 13px; font-weight: 600; color: var(--text); }
.queue-overdue-count, .queue-overdue { display: inline-block; margin-left: 6px; padding: 2px 6px; border-radius: 5px; color: var(--danger); background: #fde1dc; font-size: 11px; font-weight: 600; }
.queue-wait { display: inline-block; margin-left: 7px; color: var(--secondary); font-size: 11px; font-weight: 400; }
.queue-item.is-overdue { border-color: #e7a29a; background: #fffaf9; }
.queue-item small { display: block; color: var(--muted); font-size: 11px; font-weight: 400; margin-top: 5px; }
.queue-item button { margin-top: 13px; padding: 7px 12px; min-height: 34px; width: 100%; border: 0; border-radius: 6px; background: var(--brand); color: white; font-size: 12px; }
.list-footer { flex-shrink: 0; display: flex; align-items: center; gap: 7px; padding: 14px 20px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; }
@@ -137,8 +143,64 @@ body:not(.has-active-session) .close { display: none; }
.quick { display: block; width: 100%; margin-bottom: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; text-align: left; background: var(--surface); color: var(--secondary); font-size: 12px; }
.quick:hover:enabled { border-color: #a5bdee; color: var(--brand); background: var(--brand-soft); }
.quick:disabled { cursor: default; opacity: .5; }
.ticket-item { padding: 11px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--secondary); font-size: 12px; line-height: 1.8; overflow-wrap: anywhere; }
.ticket-item span { display: block; margin-top: 6px; font-size: 10px; color: var(--muted); }
.ticket-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.ticket-overdue-filter { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; color: var(--secondary); font-size: 12px; }
.ticket-overdue-filter input { width: 16px; height: 16px; }
.ticket-inbox-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 7px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--brand-text); white-space: nowrap; }
#ticket-pending-count { padding: 0 5px; min-width: 22px; border-radius: 5px; background: var(--brand-soft); color: var(--brand-text); font-variant-numeric: tabular-nums; }
.ticket-overdue-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 7px 10px; border: 1px solid #f2c2bb; border-radius: 7px; background: #fff7f5; color: var(--danger); white-space: nowrap; }
#ticket-overdue-count { padding: 0 5px; min-width: 22px; border-radius: 5px; background: #fde1dc; color: var(--danger); font-variant-numeric: tabular-nums; }
.ticket-alert { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding: 8px 22px; background: var(--brand-soft); color: var(--brand-text); border-bottom: 1px solid var(--border); }
.ticket-alert .ticket-action { background: var(--surface); color: var(--brand-text); }
.ticket-section-count { color: var(--brand-text); font-size: 12px; }
.ticket-summary-status { color: var(--danger); font-size: 12px; margin-bottom: 12px; }
.ticket-summary-status:empty { display: none; }
.ticket-timing-rule { color: var(--secondary); font-size: 12px; line-height: 1.6; margin: 0 0 14px; }
.ticket-toolbar .ticket-overdue-filter { min-height: 44px; }
.ticket-search-form { margin-bottom: 16px; }
.ticket-search-form label { display: block; margin-bottom: 8px; }
#ticket-search { width: 100%; min-height: 44px; padding: 9px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); }
.ticket-search-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
#tickets-title { scroll-margin-top: 80px; }
.ticket-toolbar label { color: var(--secondary); }
.ticket-toolbar select { flex: 1; min-width: 0; min-height: 42px; padding: 6px; border: 1px solid var(--border); border-radius: 7px; color: var(--text); background: var(--surface); font: inherit; }
.ticket-item { display: block; width: 100%; text-align: left; padding: 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 14px; overflow-wrap: anywhere; }
.ticket-item[aria-pressed="true"] { background: var(--brand-soft); }
.ticket-summary { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 8px; }
.ticket-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.ticket-row > span:not(.ticket-state), .ticket-time, .ticket-meta { color: var(--secondary); font-size: 12px; overflow-wrap: anywhere; }
.ticket-time { display: block; margin-top: 6px; }
.ticket-state { display: inline-block; padding: 2px 7px; border-radius: 5px; color: var(--secondary); background: var(--surface-soft); font-size: 12px; }
.ticket-state[data-state="processing"] { color: var(--brand-text); background: var(--brand-soft); }
.ticket-state[data-state="completed"] { color: var(--success); background: var(--success-soft); }
.ticket-detail { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
.ticket-detail h4 { font-size: 14px; font-weight: 600; }
.ticket-meta { margin-top: 8px; }
.ticket-content { margin: 14px 0; padding: 12px; border-radius: 8px; background: var(--surface-soft); white-space: pre-wrap; overflow-wrap: anywhere; }
.ticket-owner, .ticket-note-label { display: block; margin-bottom: 10px; }
.ticket-note-label { margin-top: 16px; font-weight: 500; }
#ticket-note { display: block; width: 100%; padding: 10px; resize: vertical; min-height: 100px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); }
#ticket-note:read-only { background: var(--surface-soft); }
.ticket-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.ticket-action { min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; color: var(--text); background: var(--surface); }
.ticket-action:hover { background: var(--surface-hover); }
.ticket-primary { color: #fff; background: var(--brand); border-color: var(--brand); }
.ticket-primary:hover { background: var(--brand-hover); }
.ticket-feedback, .ticket-server-note { margin-top: 10px; font-size: 12px; overflow-wrap: anywhere; white-space: pre-wrap; }
.ticket-feedback { color: var(--success); }
.ticket-feedback.is-error, .ticket-server-note { color: var(--danger); }
.ticket-overdue-hint { margin-top: 8px; padding: 8px 10px; border-radius: 6px; color: var(--danger); background: #fff7f5; font-size: 12px; line-height: 1.6; }
.ticket-overdue-badge { display: inline-block; margin-left: 5px; padding: 2px 6px; border-radius: 5px; color: var(--danger); background: #fde1dc; font-size: 11px; }
.ticket-history { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
.ticket-history h4 { margin: 0; }
.ticket-history-list { margin: 10px 0 0; padding: 0; list-style: none; }
.ticket-history-item { padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.ticket-history-item:last-child { border-bottom: 0; }
.ticket-history-action { color: var(--text); font-weight: 600; }
.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; }
@media (max-width: 720px) { #ticket-note, #ticket-search, .ticket-toolbar select { font-size: 16px; } }
.tools-backdrop, .sessions-back { display: none; }
@media (max-width: 1200px) {
.layout, .tools-open .layout { grid-template-columns: 280px minmax(0, 1fr); }
@@ -156,10 +218,10 @@ body:not(.has-active-session) .close { display: none; }
#chatBox { padding-left: 18px; padding-right: 18px; }
}
@media (max-width: 720px) {
.topbar { height: 52px; min-height: 52px; padding: 0 16px; }
.topbar { height: auto; min-height: 52px; padding: 8px 16px; flex-wrap: wrap; gap: 4px 12px; }
.app-identity h1, .nav-divider { display: none; }
.topbar .brand-name { font-size: 14px; }
.topbar-actions { gap: 12px; }
.topbar-actions { gap: 8px; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
.data-overview summary { font-size: 11px; padding: 5px 8px; }
.stats { width: min(310px, calc(100vw - 32px)); }
.layout, .tools-open .layout { display: flex; flex-direction: column; }
+39 -26
View File
@@ -42,7 +42,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.sidebar-footer { border-top: 1px solid var(--border); padding: 15px 7px 0; margin-top: 13px; white-space: nowrap; }
.clear-all-btn { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; }
.clear-all-btn:hover { color: var(--danger); }
.customer-account { display: block; width: 100%; min-height: 40px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 13px; }
.customer-account { display: block; width: 100%; min-height: 44px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mobile-account { display: none; }
.customer-login-cta { display: block; margin-top: 12px; min-height: 40px; padding: 8px 18px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 14px; }
.customer-login-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 20px; background: rgba(18,33,56,.35); overflow-y: auto; }
.customer-login-dialog { width: min(380px,100%); max-height: 100%; overflow-y: auto; padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
@@ -60,11 +61,11 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.header-copy { min-width: 0; flex: 1; }
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
#app-title { display: block; color: var(--text); font-weight: 650; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.toggle-divider { width: 32px; height: 32px; padding: 0; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); font-size: 0; }
.toggle-divider { width: 44px; height: 44px; padding: 0; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); font-size: 0; }
.toggle-divider::before { content: "☰"; font-size: 16px; }
.toggle-divider:hover { background: var(--surface-soft); color: var(--brand-text); }
.title-actions { display: flex; gap: 7px; align-items: center; flex-shrink: 0; }
.theme-toggle { height: 32px; min-width: 33px; padding: 0 8px; border: 1px solid var(--border); border-radius: 8px; color: var(--secondary); background: var(--surface); font-size: 11px; }
.theme-toggle { height: 44px; min-width: 44px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--secondary); background: var(--surface); font-size: 14px; }
.theme-toggle:hover { border-color: var(--brand); color: var(--brand-text); background: var(--brand-soft); }
.service-label { display: flex; gap: 7px; align-items: center; color: var(--secondary); font-size: 11px; margin-right: 8px; }
#chatDom { flex: 1; min-height: 0; overflow: auto; overflow-x: hidden; padding: 30px 38px 20px; }
@@ -86,7 +87,7 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.hot-questions { margin-top: 24px; }
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
.hot-capsule-wrap { display: flex; flex-wrap: wrap; gap: 7px; }
.hot-capsule { display: inline-flex; align-items: center; border: 1px solid var(--border); background: var(--surface); border-radius: 7px; padding: 6px 10px; color: var(--secondary); font-size: 11px; cursor: pointer; }
.hot-capsule { display: inline-flex; align-items: center; min-height: 40px; border: 1px solid var(--border); background: var(--surface); border-radius: 7px; padding: 8px 12px; color: var(--secondary); font-size: 13px; cursor: pointer; }
.hot-capsule:hover { color: var(--brand-text); background: var(--brand-soft); border-color: #a8bfef; }
.recent-chat { margin-top: 24px; }
#recentChatWrap { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 18px; }
@@ -95,8 +96,13 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.recent-item:hover { color: var(--brand-text); }
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
.composer-toolbar { display: flex; gap: 8px; align-items: center; justify-content: flex-end; margin-bottom: 10px; }
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
/* Welcome cards already offer these actions; keep shortcuts available in conversations. */
.main-chat:has(.ai-welcome-card) .hot-bar { display: none; }
@media (min-width: 901px) {
.main-chat:has(.ai-welcome-card) .composer-toolbar { display: none; }
}
.voice-toggle, .voice-hold { display: none; }
.voice-panel { position: absolute; z-index: 35; left: 50%; bottom: calc(var(--composer-height, 150px) + 14px); transform: translateX(-50%); width: min(330px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow); text-align: center; pointer-events: none; }
.voice-panel p { margin: 9px 0; font-size: 13px; }
@@ -106,7 +112,7 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
.human-session { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; }
.human-session button { flex-shrink: 0; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--brand-text); font-size: 12px; }
.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; }
.hot-bar .hot-capsule { border: 0; padding: 8px 12px; background: var(--surface-soft); font-size: 13px; }
.input-area { display: flex; align-items: flex-end; gap: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 9px; background: var(--surface); box-shadow: 0 3px 15px rgba(28, 47, 80, .035); }
.input-area:focus-within { border-color: #7f9fe8; box-shadow: 0 0 0 3px var(--brand-soft); }
#upload-btn { flex: 0 0 36px; height: 38px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--secondary); font-size: 18px; }
@@ -210,42 +216,51 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
}
@media (max-width: 900px) {
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
.chat-container { --chat-header-height: 72px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
.chat-container { --chat-header-height: 120px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
.left-panel { position: absolute; left: 0; top: calc(var(--chat-header-height) + env(safe-area-inset-top)); bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
.left-panel .brand { display: none; }
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: calc(var(--chat-header-height) + env(safe-area-inset-top)) 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
.main-chat { width: 100%; }
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); padding: 13px 18px; gap: 11px; }
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); display: grid; grid-template-columns: 44px minmax(0, 1fr); grid-template-rows: 44px 44px; padding: 12px 18px; gap: 8px 12px; }
.header-kicker { font-size: 12px; }
#app-title { font-size: 16px; }
.title-actions { grid-column: 1 / -1; min-width: 0; gap: 8px; }
.mobile-account { display: block; flex: 1; min-width: 0; width: auto; margin: 0; background: var(--brand-soft); border-color: var(--brand); font-weight: 600; }
.sidebar-footer .customer-account { display: none; }
.new-chat-btn, .clear-all-btn { min-height: 44px; }
.panel-title, .history-empty, .clear-all-btn { font-size: 14px; }
.history-title { font-size: 15px; }
.history-time { font-size: 12px; }
#chatDom { padding: 24px 20px 15px; overscroll-behavior-y: contain; }
.ai-welcome-card { padding-top: 20px; }
.composer { width: calc(100% - 40px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
.chat-container.is-composing { --chat-header-height: 56px; }
.is-composing .chat-title { padding-top: 8px; padding-bottom: 8px; }
.is-composing .composer { padding-top: 6px; padding-bottom: 6px; }
.is-composing .composer-footer { display: none; }
#input-text, .history-search { font-size: max(16px, 1em); }
.history-search { height: 44px; }
.history-search-wrap svg { top: 14px; }
.composer-toolbar { margin-bottom: 8px; }
.hot-bar .hot-capsule { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; }
.hot-bar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.hot-bar .hot-capsule { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; }
.hot-questions h4, .recent-chat h4 { font-size: 14px; }
.hot-questions .hot-capsule { min-height: 44px; }
.input-area { align-items: center; }
.input-area .emoji-control { display: flex; align-items: center; }
#upload-btn, #emoji-toggle, #send-button { display: inline-flex; align-items: center; justify-content: center; height: 44px; min-height: 44px; padding-top: 0; padding-bottom: 0; border-radius: 8px; line-height: 1; }
#upload-btn, #emoji-toggle, #send-button { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; height: 44px; min-height: 44px; padding-top: 0; padding-bottom: 0; border-radius: 8px; line-height: 1; }
#upload-btn { flex-basis: 44px; }
#emoji-toggle { width: 44px; }
#send-button { font-size: 14px; }
#input-text { min-height: 44px; padding-top: 10px; padding-bottom: 10px; line-height: 1.5; }
.user-message, .robot-message { max-width: 94%; }
.del-history-btn { opacity: 1; }
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; line-height: 1; }
.del-history-btn { opacity: 1; width: 44px; height: 44px; }
.voice-toggle { display: grid; place-items: center; flex: 0 0 44px; height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; line-height: 1; }
.input-area.voice-mode #input-text, .input-area.voice-mode .emoji-control, .input-area.voice-mode #send-button { display: none; }
.input-area.voice-mode .voice-hold { display: block; flex: 1; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); font-size: 15px; font-weight: 600; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
.voice-hold.is-recording { color: var(--brand-text); background: var(--brand-soft); }
}
@media (max-width: 520px) {
.header-kicker { font-size: 9px; }
#app-title { font-size: 13px; }
.chat-title { padding: 12px; gap: 8px; }
.title-actions { gap: 4px; }
.theme-toggle { padding: 0 6px; min-width: 29px; font-size: 10px; }
#font-toggle { display: none; }
#chatDom { padding: 20px 16px; }
.ai-avatar { display: none; }
@@ -254,21 +269,19 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.suggest-grid { gap: 9px; margin-top: 22px; }
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 88px; padding: 13px; gap: 9px; }
.suggest-icon { width: 32px; height: 32px; border-radius: 8px; }
.suggest-copy strong { font-size: 13px; }
.suggest-copy strong { font-size: 14px; }
.suggest-arrow { display: none; }
.hot-questions { margin-top: 22px; }
.hot-questions .hot-capsule-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; }
.hot-questions .hot-capsule { flex-shrink: 0; white-space: nowrap; }
.hot-capsule { font-size: 10px; padding: 6px 8px; }
.hot-questions .hot-capsule-wrap { flex-wrap: wrap; }
.hot-questions .hot-capsule { max-width: 100%; white-space: normal; text-align: left; }
.hot-capsule { font-size: 13px; padding: 8px 10px; }
#recentChatWrap { grid-template-columns: 1fr; gap: 0; }
.composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; }
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; padding: 6px 9px; font-size: 11px; }
.hot-bar .hot-capsule { padding: 8px; }
.input-area { gap: 7px; padding: 7px; }
#send-button { min-width: 57px; padding: 0 12px; }
#upload-btn { flex-basis: 36px; }
#input-text { font-size: 16px; }
.attachment-remove { width: 40px; height: 40px; }
.attachment-remove { width: 44px; height: 44px; }
.track-text { grid-template-columns: 1fr; gap: 2px; }
.track-place { grid-column: 1; }
.robot-message.track-card { padding: 16px; }
+96 -21
View File
@@ -2,6 +2,7 @@ var curSid = 0;
var sessions = {};
var drafts = {};
var queueItems = [];
var queueOverdueNotified = new Set();
var accepting = new Set();
var sendingSid = "";
var closingSid = "";
@@ -10,6 +11,10 @@ var listSignature = "";
var typeTimer = null;
var toolsOpen = false;
var toolsOpener = null;
var staffExpired = false;
var staffLoggingOut = false;
var staffPollTimers = [];
var staffCsrf = document.querySelector('meta[name="staff-csrf-token"]').content;
var replyInput = document.getElementById("replyText");
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
@@ -20,13 +25,66 @@ function escapeText(value){
}
async function adminRequest(path, options){
const response = await fetch(path, options);
if(!response.ok) throw new Error("HTTP " + response.status);
const data = await response.json();
if(data && data.ok === false) throw new Error("Request rejected");
return data;
if(staffExpired) throw new Error("Staff login expired");
const controller = new AbortController();
const timer = setTimeout(function(){ controller.abort(); }, 10000);
try{
const headers = Object.assign({}, options && options.headers);
if(options && options.method && options.method !== "GET") headers["X-CSRF-Token"] = staffCsrf;
const response = await fetch(path, Object.assign({}, options, {headers:headers, signal:controller.signal}));
if(response.status === 401){
if(!staffExpired){
staffExpired = true;
staffPollTimers.forEach(clearInterval);
window.location.replace("/admin?expired=1");
}
throw new Error("Staff login expired");
}
if(staffExpired) throw new Error("Staff login expired");
const data = await response.json();
if(!response.ok || (data && data.ok === false)){
const error = new Error("HTTP " + response.status);
error.status = response.status;
error.userMessage = data && (data.e || data.error);
throw error;
}
return data;
}finally{
clearTimeout(timer);
}
}
async function logoutStaff(){
if(staffLoggingOut || staffExpired) return;
staffLoggingOut = true;
const button = document.getElementById("staff-logout");
const status = document.getElementById("staff-logout-status");
button.disabled = true;
button.textContent = "正在退出…";
status.hidden = true;
try{
await postAdmin("/admin/logout", {});
staffExpired = true;
staffPollTimers.forEach(clearInterval);
window.location.replace("/admin");
}catch(error){
if(!staffExpired){
status.textContent = "退出失败,请检查网络后重试。";
status.hidden = false;
}
}finally{
staffLoggingOut = false;
button.disabled = false;
button.textContent = "退出登录";
}
}
document.getElementById("staff-logout").onclick = logoutStaff;
window.addEventListener("pageshow", function(event){
if(event.persisted){
adminRequest("/api/admin/session").then(function(data){ staffCsrf = data.csrf_token; }).catch(function(){});
}
});
function postAdmin(path, data){
return adminRequest(path, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(data)});
}
@@ -86,11 +144,16 @@ function renderQueue(){
return ("#" + item.id + " " + item.ctime).toLowerCase().includes(term);
});
document.getElementById("queue-count").textContent = queueItems.length;
document.getElementById("queue-overdue-count").textContent = "超时 " + queueItems.filter(function(item){ return item.overdue; }).length;
document.getElementById("session-browser").classList.toggle("queue-empty", queueItems.length === 0);
document.getElementById("queue").innerHTML = visible.length
? visible.map(function(item){
const sid = Number(item.id);
return '<div class="queue-item"><div class="qt">对话 #'+sid+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
const age = Number(item.waiting_minutes) || 0;
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>';
}).join("")
: '<div class="list-empty">'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'</div>';
}
@@ -98,6 +161,17 @@ function renderQueue(){
async function loadQueue(){
try{
const list = await adminRequest("/api/human/queue");
const waitingOverdue = new Set(list.filter(function(item){ return item.overdue; }).map(function(item){ return String(item.id); }));
queueOverdueNotified.forEach(function(id){ if(!waitingOverdue.has(id)) queueOverdueNotified.delete(id); });
const newlyOverdue = list.filter(function(item){
const id = String(item.id);
if(!item.overdue || queueOverdueNotified.has(id)) return false;
queueOverdueNotified.add(id);
return true;
});
if(newlyOverdue.length){
SupportUI.notify("人工排队超时", newlyOverdue.length+" 位客户已等待超过 5 分钟,请尽快接入。 ");
}
if(list.some(function(item){ return !queueItems.some(function(old){ return old.id === item.id; }); })){
SupportUI.notify("新客户排队", "有 " + list.length + " 位客户等待接入");
}
@@ -109,15 +183,7 @@ async function loadQueue(){
}
async function loadTickets(){
const element = document.getElementById("ticketList");
try{
const list = await adminRequest("/api/tickets");
element.innerHTML = list.length ? list.map(function(ticket){
return '<div class="ticket-item">'+escapeText(ticket.content)+'<span>'+escapeText(ticket.ctime)+'</span></div>';
}).join("") : '<div class="list-empty">暂时没有客户留言</div>';
}catch(error){
element.innerHTML = '<div class="list-empty">留言暂时无法加载</div>';
}
return Promise.all([ticketDesk.load(), ticketDesk.refreshSummary()]);
}
async function accept(sid){
@@ -299,7 +365,9 @@ async function sendReply(){
if(curSid === sid){ replyInput.focus(); setReplyStatus("已发送"); }
poll();
}catch(error){
if(curSid === sid) setReplyStatus("发送未成功,内容已保留。请确认后重试。", true);
if(curSid === sid) setReplyStatus(error.name === 'AbortError'
? "发送超时,内容已保留。请先确认聊天记录,再决定是否重试。"
: "发送未成功,内容已保留。请确认后重试。", true);
}finally{
sendingSid = "";
updateComposer();
@@ -386,14 +454,14 @@ 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(fromList ? "tools-from-list" : "tools-toggle").focus();
document.getElementById(["ticket-inbox-toggle", "ticket-overdue-toggle"].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){
if(event.key === "Escape" && toolsOpen) document.getElementById("tools-close").click();
if(event.key === "Escape") document.getElementById("data-overview").open = false;
if(event.key === "Tab" && toolsOpen && window.matchMedia("(max-width: 1200px)").matches){
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href], textarea:not([disabled])'));
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]):not([hidden]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])')).filter(function(item){ return item.getClientRects().length; });
const first = items[0], last = items[items.length-1];
if(event.shiftKey && document.activeElement === first){ event.preventDefault(); last.focus(); }
else if(!event.shiftKey && document.activeElement === last){ event.preventDefault(); first.focus(); }
@@ -414,8 +482,15 @@ setToolsOpen(false);
setSessionTab("active");
updateComposer();
loadQueue();
var ticketDesk = window.AdminTickets.create({request:adminRequest, escape:escapeText,
username:document.querySelector('meta[name="staff-username"]').content,
notify:function(title, body){ SupportUI.notify(title, body); },
open:function(opener){
toolsOpener = opener || "ticket-inbox-toggle";
setToolsOpen(true);
document.getElementById("tickets-title").scrollIntoView({block:"start"});
document.getElementById("ticket-search").focus({preventScroll:true});
}});
loadTickets();
poll();
setInterval(loadQueue, 3000);
setInterval(loadTickets, 5000);
setInterval(poll, 1000);
staffPollTimers = [setInterval(loadQueue, 3000), setInterval(loadTickets, 5000), setInterval(poll, 1000)];
+6 -1
View File
@@ -10,6 +10,10 @@ function showLoginError(message){
loginError.hidden = false;
}
if(new URLSearchParams(window.location.search).has("expired")){
showLoginError("后台登录已失效,请重新登录。");
}
async function login(){
if(loginPending) return;
const username = userInput.value.trim();
@@ -35,7 +39,8 @@ async function login(){
location.href = "/admin/dashboard";
return;
}
showLoginError(response.status === 401 ? "账号或密码不正确,请重新输入" : "暂时无法登录,请稍后重试");
showLoginError(response.status === 401 ? "账号或密码不正确,请重新输入" :
response.status === 429 ? "登录尝试过多,请 5 分钟后重试" : "暂时无法登录,请稍后重试");
}catch(error){
showLoginError("网络连接失败,请检查网络后重试");
}finally{
+292
View File
@@ -0,0 +1,292 @@
/* Offline tickets: server ownership, versioned notes and polling-safe drafts. */
(function(global){
function create(options){
const el = id => document.getElementById(id);
const states = {pending:"待处理", processing:"处理中", completed:"已完成"};
let rows = [], selected = null, busy = false, loading = false;
let generation = 0, expanded = false, hasMore = false;
let activeQuery = "", latestSeen = null, unreadNew = 0, summaryLoading = false, overdueOnly = false;
let historyRows = [], historyHasMore = false, historyTicketId = null, historyLoading = false, historyRequest = 0, historyError = "";
const drafts = new Map();
const filter = el("ticket-filter"), note = el("ticket-note");
filter.value = "all";
async function refreshSummary(){
if(summaryLoading) return;
summaryLoading = true;
try{
const data = await options.request("/api/tickets/summary"+(latestSeen === null ? "" : "?after="+latestSeen));
el("ticket-pending-count").textContent = String(data.pending_count);
const overdueCount = Number(data.overdue_count || 0);
el("ticket-overdue-count").textContent = String(overdueCount);
el("ticket-overdue-toggle").setAttribute("aria-label", "查看超时留言 "+overdueCount+" 条,点击筛选");
el("ticket-section-count").textContent = "待处理 "+data.pending_count+" · 超时 "+overdueCount;
el("ticket-inbox-toggle").setAttribute("aria-label", "待处理留言 "+data.pending_count+" 条,点击查看");
el("ticket-summary-status").textContent = "";
if(latestSeen !== null && data.latest_id > latestSeen && data.new_count > 0){
unreadNew += data.new_count;
el("ticket-alert-text").textContent = "收到 "+unreadNew+" 条新留言";
el("ticket-alert").hidden = false;
if(options.notify) options.notify("新离线留言", "有 "+data.new_count+" 条新留言,请进入后台查看。");
}
latestSeen = Math.max(latestSeen || 0, data.latest_id);
}catch(error){
el("ticket-summary-status").textContent = "待处理数量暂时无法更新,稍后自动重试。";
}finally{ summaryLoading = false; }
}
function message(text, failed){
el("ticket-status-message").textContent = text || "";
el("ticket-status-message").classList.toggle("is-error", Boolean(failed));
}
const eventLabels = {submitted:"提交留言", claimed:"领取留言", note_updated:"修改备注", completed:"标记完成", legacy_snapshot:"升级时保留状态"};
function eventDetail(event){
if(event.action === "note_updated") return "旧备注:"+(event.old_note || "(空)")+"\n新备注:"+(event.new_note || "(空)");
if(event.action === "completed") return event.new_note ? "处理结果:"+event.new_note : "";
if(event.action === "legacy_snapshot") return "升级前的处理过程未记录,仅保留当时已知状态。";
return "";
}
function renderHistory(){
const list = el("ticket-history-list"), status = el("ticket-history-status");
el("ticket-history-more").hidden = !historyHasMore;
el("ticket-history-more").disabled = historyLoading;
if(historyLoading && !historyRows.length){ status.textContent = "正在加载处理记录…"; list.innerHTML = ""; return; }
if(!historyRows.length){ status.textContent = historyError || "暂无处理记录。"; list.innerHTML = ""; return; }
status.textContent = historyError;
list.innerHTML = historyRows.map(function(event){
const detail = eventDetail(event);
return '<li class="ticket-history-item"><span class="ticket-history-action">'+options.escape(eventLabels[event.action] || event.action)+'</span> · '+options.escape(event.actor_name || "系统")+'<span class="ticket-history-meta"> · '+options.escape(event.ctime || "")+'</span>'+
(detail ? '<span class="ticket-history-detail">'+options.escape(detail)+'</span>' : '')+'</li>';
}).join("");
}
async function loadHistory(reset){
if(!selected) return;
const id = selected.id, request = ++historyRequest;
if(reset || historyTicketId !== id){ historyRows = []; historyHasMore = false; historyTicketId = id; }
historyLoading = true; historyError = ""; renderHistory();
const before = !reset && historyRows.length ? "&before="+historyRows[historyRows.length-1].id : "";
try{
const data = await options.request("/api/tickets/"+id+"/events?limit=20"+before);
if(request !== historyRequest || !selected || selected.id !== id) return;
const list = Array.isArray(data) ? data : [];
historyRows = reset ? list : historyRows.concat(list.filter(function(item){ return !historyRows.some(function(row){ return row.id === item.id; }); }));
historyHasMore = list.length === 20;
el("ticket-history-status").textContent = "";
}catch(error){
if(request === historyRequest) historyError = "处理记录加载失败,请点击刷新记录重试。";
}finally{
if(request === historyRequest){ historyLoading = false; renderHistory(); }
}
}
function renderDetail(){
el("ticket-selected").hidden = !selected;
if(!selected) return;
const mine = selected.status === "processing" && selected.owner_username === options.username;
const draft = drafts.get(selected.id);
const conflict = Boolean(draft && draft.version !== selected.version);
el("ticket-content").textContent = selected.content;
el("ticket-meta").textContent = "#"+selected.id+" · "+selected.ctime+" · 电话:"+(selected.phone || "未提供");
el("ticket-owner").textContent = "负责人:"+(selected.owner_name || selected.owner_username || "未领取")+
(selected.owner_username === options.username ? "(我)" : "");
const overdue = el("ticket-overdue-hint");
if(selected.overdue_kind){
overdue.hidden = false;
overdue.textContent = (selected.overdue_kind === "pending" ? "尚未领取,已超时 " : "领取后处理已超时 ")+selected.overdue_minutes+" 分钟。";
}else if(selected.status === "processing" && !selected.claimed_at){
overdue.hidden = false;
overdue.textContent = "缺少领取时间,无法计算处理超时。";
}else{ overdue.hidden = true; overdue.textContent = ""; }
if(selected.status === "processing" && selected.claimed_at_estimated){
overdue.hidden = false;
overdue.textContent += " 旧留言按升级前最后更新时间估算领取时间。";
}
el("ticket-state").textContent = states[selected.status] || selected.status;
el("ticket-state").setAttribute("data-state", selected.status);
note.value = draft ? draft.text : (selected.note || "");
note.readOnly = !mine || busy;
el("ticket-claim").hidden = selected.status !== "pending";
el("ticket-save").hidden = !mine;
el("ticket-complete").hidden = !mine;
["ticket-claim", "ticket-save", "ticket-complete"].forEach(id => {
el(id).disabled = busy || (id !== "ticket-claim" && conflict);
});
el("ticket-server-note").hidden = !conflict;
el("ticket-server-note").textContent = conflict ? "这条留言已更新,当前保存的备注:"+(selected.note || "(空)") : "";
el("ticket-rebase").hidden = !conflict || !mine;
el("ticket-rebase").disabled = busy;
el("ticket-activity").textContent = selected.completed_at ? "完成时间:"+selected.completed_at :
(selected.updated_at ? "更新时间:"+selected.updated_at : "");
renderHistory();
}
function render(){
el("ticketList").innerHTML = rows.length ? rows.map(ticket =>
'<button type="button" class="ticket-item" data-ticket-id="'+ticket.id+'" aria-pressed="'+Boolean(selected && ticket.id === selected.id)+'"'+(busy ? ' disabled' : '')+'>'+
'<span class="ticket-summary">'+options.escape(ticket.content)+'</span><span class="ticket-row"><span><span class="ticket-state" data-state="'+options.escape(ticket.status)+'">'+options.escape(states[ticket.status] || ticket.status)+'</span>'+(ticket.overdue_kind ? '<span class="ticket-overdue-badge">'+options.escape(ticket.overdue_kind === "pending" ? "领取超时 "+ticket.overdue_minutes+" 分钟" : "处理超时 "+ticket.overdue_minutes+" 分钟")+'</span>' : '')+'</span><span>'+options.escape(ticket.owner_name || ticket.owner_username || "未领取")+'</span></span>'+
'<span class="ticket-time">'+options.escape(ticket.ctime)+'</span></button>'
).join("") : '<p class="ticket-empty">'+(activeQuery ? "没有匹配的留言,请更换关键词或状态" : "该状态下暂无留言")+'</p>';
el("ticket-more").hidden = !hasMore;
el("ticket-more").disabled = loading || busy;
el("ticket-refresh").disabled = loading || busy;
filter.disabled = busy;
el("ticket-search").disabled = busy;
el("ticket-search-submit").disabled = busy;
el("ticket-search-clear").hidden = !activeQuery;
el("ticket-search-clear").disabled = busy;
el("ticket-overdue-filter").disabled = busy;
renderDetail();
}
async function load(more, manual){
if(busy || (loading && !manual) || (expanded && !more && !manual)) return;
const current = ++generation;
loading = true;
el("ticket-refresh").disabled = true;
el("ticket-more").disabled = true;
const before = more && rows.length ? "&before="+rows[rows.length-1].id : "";
const previousId = selected && selected.id, previousVersion = selected && selected.version;
try{
const list = await options.request("/api/tickets?status="+filter.value+"&limit=20"+before+
(activeQuery ? "&q="+encodeURIComponent(activeQuery) : "")+(overdueOnly ? "&overdue=1" : ""));
if(current !== generation) return;
rows = more ? rows.concat(list.filter(item => !rows.some(row => row.id === item.id))) : list;
hasMore = list.length === 20;
expanded = Boolean(more);
if(selected){
const found = rows.find(item => item.id === selected.id);
if(found) selected = found;
else{
const id = selected.id;
const data = await options.request("/api/tickets/"+id);
if(current !== generation) return;
if(selected && selected.id === id) selected = data.ticket;
}
}else if(rows.length){ selected = rows[0]; }
if(current === generation){
render();
if(selected && (selected.id !== previousId || selected.version !== previousVersion || historyTicketId !== selected.id)) loadHistory(true);
}
}catch(error){
if(current === generation) message("留言加载失败,请点击刷新重试;未保存的备注仍保留。", true);
}finally{
if(current === generation){ loading = false; render(); }
}
}
function select(id){
if(busy) return;
const ticket = rows.find(row => row.id === Number(id));
if(!ticket) return;
selected = ticket;
message("");
render();
loadHistory(true);
}
async function update(action){
if(busy || !selected) return;
const ticket = selected;
const draft = drafts.get(ticket.id);
if(action !== "claim" && (ticket.status !== "processing" || ticket.owner_username !== options.username)) return;
if(action !== "claim" && draft && draft.version !== ticket.version){
message("留言已更新,请核对当前保存的备注后再处理。", true); return;
}
const text = note.value;
if(action === "completed" && !text.trim()){
message("请先填写处理结果,再标记完成。", true); note.focus(); return;
}
if(action !== "claim") drafts.set(ticket.id, {text:text, version:draft ? draft.version : ticket.version});
++generation;
loading = false;
busy = true;
render();
message(action === "claim" ? "正在领取…" : "正在保存…");
try{
const result = await options.request("/api/tickets/"+ticket.id+(action === "claim" ? "/claim" : ""), {
method: action === "claim" ? "POST" : "PATCH", headers:{"Content-Type":"application/json"},
body:JSON.stringify(action === "claim" ? {} : {version:draft ? draft.version : ticket.version,
note:text, status:action === "completed" ? "completed" : "processing"})
});
selected = result.ticket;
rows = rows.map(row => row.id === ticket.id ? result.ticket : row);
drafts.delete(ticket.id);
message(action === "claim" ? "已领取,由你负责跟进。" : action === "completed" ? "已完成,记录已保留。" : "备注已保存。");
}catch(error){
message(error.userMessage || "操作未确认成功,请刷新核对后重试;备注已保留。", true);
if(error.status === 409 || error.status === 403){
try{
const data = await options.request("/api/tickets/"+ticket.id);
selected = data.ticket;
rows = rows.map(row => row.id === ticket.id ? selected : row);
}catch(refreshError){ /* Keep the local draft and error until explicit retry. */ }
}
}finally{ busy = false; render(); loadHistory(true); await refreshSummary(); }
}
function search(){
if(busy) return;
const query = el("ticket-search").value.trim();
if(query.length > 100){ message("搜索关键词最多 100 字。", true); return; }
activeQuery = query;
selected = null; rows = []; expanded = false; hasMore = false;
message(""); render();
return load(false, true);
}
function viewPending(){
if(busy){ message("正在保存,请稍后查看新留言。"); return; }
unreadNew = 0;
el("ticket-alert").hidden = true;
filter.value = "pending";
overdueOnly = false; el("ticket-overdue-filter").checked = false;
el("ticket-search").value = "";
if(options.open) options.open();
return search();
}
note.addEventListener("input", function(){
if(!selected || note.readOnly) return;
const previous = drafts.get(selected.id);
drafts.set(selected.id, {text:note.value, version:previous ? previous.version : selected.version});
});
el("ticketList").addEventListener("click", function(event){
const button = event.target.closest("[data-ticket-id]");
if(button) select(button.getAttribute("data-ticket-id"));
});
filter.addEventListener("change", function(){ selected = null; rows = []; expanded = false; message(""); render(); load(false, true); });
el("ticket-search-form").addEventListener("submit", function(event){ event.preventDefault(); return search(); });
el("ticket-search").addEventListener("keydown", function(event){
if(event.key === "Enter" && event.isComposing) event.preventDefault();
});
el("ticket-search-clear").onclick = function(){ el("ticket-search").value = ""; return search(); };
el("ticket-inbox-toggle").onclick = viewPending;
el("ticket-alert-view").onclick = viewPending;
el("ticket-overdue-toggle").onclick = function(){
if(busy) return;
filter.value = "all"; overdueOnly = true; el("ticket-overdue-filter").checked = true;
el("ticket-search").value = "";
if(options.open) options.open("ticket-overdue-toggle");
return search();
};
el("ticket-overdue-filter").addEventListener("change", function(){
overdueOnly = Boolean(this.checked); selected = null; rows = []; expanded = false;
return load(false, true);
});
el("ticket-history-reload").onclick = function(){ return loadHistory(true); };
el("ticket-history-more").onclick = function(){ if(!historyLoading && historyHasMore) return loadHistory(false); };
el("ticket-refresh").onclick = function(){ return load(false, true); };
el("ticket-more").onclick = function(){ return load(true); };
el("ticket-claim").onclick = function(){ return update("claim"); };
el("ticket-save").onclick = function(){ return update("save"); };
el("ticket-complete").onclick = function(){ return update("completed"); };
el("ticket-rebase").onclick = function(){
if(!selected || busy) return;
const draft = drafts.get(selected.id);
if(draft){ draft.version = selected.version; render(); message("已保留你的草稿,请确认内容后保存。"); }
};
return {load:load, select:select, refreshSummary:refreshSummary};
}
global.AdminTickets = {create:create};
})(window);
+46 -8
View File
@@ -54,7 +54,10 @@ let humanClosing = false;
window.humanSid = 0;
const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
SupportUI.initAttachments(chatDom, ()=>t('attachmentUnavailable'));
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
document.getElementById('notifications-toggle').onclick = async function(){
await SupportUI.enableNotifications(this);
refreshNotificationLabel();
};
function setComposerStatus(message, isError){
composerStatus.textContent = message || "";
@@ -219,6 +222,7 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
btn.onclick = function(){
idx = (idx + 1) % SIZES.length;
apply();
labelControl(btn, t('fontSize') + ': ' + btn.textContent);
try{ localStorage.setItem(KEY, idx); }catch(e){}
};
}
@@ -228,6 +232,7 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
const I18N = {
zh: {
appTitle: "柏悦物流智能助手",
customerAccount: "客户登录",
customerLogin: "登录", customerLogout: "退出登录", customerCancel: "取消",
customerUsername: "账号", customerPassword: "密码", customerQuery: "登录查询",
customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码",
@@ -237,6 +242,11 @@ const I18N = {
clearAll: "清空全部历史",
history: "历史对话",
searchPh: "搜索历史对话...",
historySearch: "搜索历史对话", closeSidebar: "关闭历史侧栏", home: "柏悦物流首页",
chatLog: "聊天内容", inputLabel: "输入问题或入仓码",
uploadAttachment: "发送附件", chooseEmoji: "选择表情", fontSize: "字体大小",
enableNotifications: "开启浏览器通知", notificationsEnabled: "浏览器通知已开启",
pinConversation: "置顶对话", unpinConversation: "取消置顶",
confirmClear: "确定清空全部历史对话吗?无法恢复!",
confirmDel: "确定要删除这条历史对话吗?",
emptySearch: "没有匹配的历史对话",
@@ -339,6 +349,7 @@ const I18N = {
},
en: {
appTitle: "Baiyue Logistics AI Assistant",
customerAccount: "Customer sign in",
customerLogin: "Sign in", customerLogout: "Sign out", customerCancel: "Cancel",
customerUsername: "Username", customerPassword: "Password", customerQuery: "Sign in to view orders",
customerUsernamePlaceholder: "Enter username", customerPasswordPlaceholder: "Enter password",
@@ -348,6 +359,11 @@ const I18N = {
clearAll: "Clear All History",
history: "History",
searchPh: "Search history...",
historySearch: "Search conversation history", closeSidebar: "Close history sidebar", home: "Baiyue Logistics home",
chatLog: "Conversation messages", inputLabel: "Enter a question or inbound code",
uploadAttachment: "Attach a file", chooseEmoji: "Choose an emoji", fontSize: "Text size",
enableNotifications: "Enable browser notifications", notificationsEnabled: "Browser notifications enabled",
pinConversation: "Pin conversation", unpinConversation: "Unpin conversation",
confirmClear: "Clear all conversation history? This cannot be undone.",
confirmDel: "Delete this conversation?",
emptySearch: "No matching conversations",
@@ -457,6 +473,17 @@ function t(key){
return (I18N[LANG] || I18N.zh)[key];
}
function labelControl(element, label){
if(!element) return;
element.title = label;
element.setAttribute('aria-label', label);
}
function refreshNotificationLabel(){
const button = document.getElementById('notifications-toggle');
labelControl(button, t(button.getAttribute('aria-pressed') === 'true' ? 'notificationsEnabled' : 'enableNotifications'));
}
function applyLanguage(lang){
LANG = lang === "en" ? "en" : "zh";
const dict = I18N[LANG];
@@ -473,21 +500,31 @@ function applyLanguage(lang){
const langBtn = document.getElementById("lang-toggle");
if(langBtn){
langBtn.textContent = LANG === "en" ? "EN" : "中";
langBtn.title = dict.langTip;
labelControl(langBtn, dict.langTip);
}
const themeBtn = document.getElementById("theme-toggle");
if(themeBtn) themeBtn.title = dict.themeTip;
labelControl(themeBtn, dict.themeTip);
const fontBtn = document.getElementById('font-toggle');
if(fontBtn) labelControl(fontBtn, dict.fontSize + ': ' + fontBtn.textContent);
refreshNotificationLabel();
labelControl(uploadBtn, dict.uploadAttachment);
labelControl(document.getElementById('emoji-toggle'), dict.chooseEmoji);
labelControl(document.querySelector('.sidebar-backdrop'), dict.closeSidebar);
labelControl(document.querySelector('.left-panel .brand'), dict.home);
document.querySelector('.left-panel').setAttribute('aria-label', dict.history);
chatDom.setAttribute('aria-label', dict.chatLog);
inputDom.setAttribute('aria-label', dict.inputLabel);
document.querySelectorAll(".new-chat-btn").forEach(function(b){ b.textContent = dict.newChat; });
document.querySelectorAll(".clear-all-btn").forEach(function(b){ b.textContent = dict.clearAll; });
document.querySelectorAll(".panel-title").forEach(function(e){ e.textContent = dict.history; });
const hs = document.getElementById("history-search");
if(hs) hs.placeholder = dict.searchPh;
if(hs){ hs.placeholder = dict.searchPh; hs.setAttribute('aria-label', dict.historySearch); }
if(inputDom) inputDom.placeholder = window.innerWidth > 900 ? dict.inputPh : dict.inputShort;
sendBtn.textContent = window.__busy ? dict.stop : dict.send;
const tb = document.getElementById("to-bottom");
if(tb) tb.title = dict.toBottom;
labelControl(tb, dict.toBottom);
const tg = document.querySelector(".toggle-divider");
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
if(tg) labelControl(tg, tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse);
updateHumanMode();
if(voiceInput) voiceInput.refresh();
@@ -514,6 +551,7 @@ function applyLanguage(lang){
document.querySelectorAll(".retry-btn").forEach(function(b){
if(!b.dataset.keep) b.textContent = dict.clickRetry;
});
document.querySelectorAll('.customer-login-cta').forEach(function(button){ button.textContent = dict.customerQuery; });
}
(function initLang(){
@@ -754,7 +792,7 @@ function renderSideHistory(){
const pinBtn = document.createElement("button");
pinBtn.className = "del-history-btn";
pinBtn.innerText = isPinned ? "📍" : "📌";
pinBtn.title = isPinned ? "取消置顶" : "置顶";
labelControl(pinBtn, t(isPinned ? 'unpinConversation' : 'pinConversation'));
pinBtn.style.marginRight = "4px";
pinBtn.onclick = (e)=>{
e.stopPropagation();
@@ -1888,7 +1926,7 @@ function renderSidebar(){
sidePanel.classList.toggle('left-panel-hide', !narrow && !sideOpen);
sidePanel.setAttribute('aria-hidden', String(!sideOpen));
toggleBtn.setAttribute('aria-expanded', String(sideOpen));
toggleBtn.title = t(sideOpen ? 'sideCollapse' : 'sideExpand');
labelControl(toggleBtn, t(sideOpen ? 'sideCollapse' : 'sideExpand'));
toggleBtn.textContent = "";
inputDom.placeholder = t(narrow ? 'inputShort' : 'inputPh');
}
+19 -7
View File
@@ -8,12 +8,16 @@ var CustomerLogin = {
const submit=document.getElementById('customer-submit');
const cancel=document.getElementById('customer-cancel');
const error=document.getElementById('customer-error');
const account=document.getElementById('customer-account');
const accounts=['customer-account','customer-account-mobile'].map(id=>document.getElementById(id)).filter(Boolean);
let user, pending=null, opener=null, busy=false, version=0;
overlay.hidden=true;
function status(message){ error.textContent=message || ''; error.hidden=!message; }
function render(){
account.textContent=user ? user.name+' · '+options.text('customerLogout') : options.text('customerLogin');
accounts.forEach(function(account){
account.textContent=user ? user.name+' · '+options.text('customerLogout') : options.text('customerAccount');
account.title=account.textContent;
account.setAttribute('aria-label',account.textContent);
});
submit.textContent=options.text(busy ? 'customerSigningIn' : 'customerLogin');
cancel.textContent=options.text('customerCancel');
document.getElementById('customer-login-title').textContent=options.text('customerLogin');
@@ -52,12 +56,17 @@ var CustomerLogin = {
if(token === version) update(result.authenticated && result.user ? result.user : null);
}catch(error){} // Query endpoints still enforce login if status cannot refresh.
}
function restoreFocus(){
if(opener && opener.isConnected && !opener.disabled){
opener.focus({preventScroll:true});
opener=null;
}
}
function close(){
overlay.hidden=true;
password.value='';
pending=null;
if(opener && opener.isConnected) opener.focus({preventScroll:true});
opener=null;
restoreFocus();
}
function open(onSuccess){
if(busy) return;
@@ -68,8 +77,10 @@ var CustomerLogin = {
render(); username.focus({preventScroll:true});
}
function setBusy(value){
busy=value; submit.disabled=value; cancel.disabled=value; account.disabled=value;
busy=value; submit.disabled=value; cancel.disabled=value;
accounts.forEach(account=>{account.disabled=value;});
username.disabled=value; password.disabled=value; render();
if(!busy && overlay.hidden) restoreFocus();
}
form.addEventListener('submit',async function(event){
event.preventDefault();
@@ -98,7 +109,7 @@ var CustomerLogin = {
event.preventDefault();controls[next].focus({preventScroll:true});
}
});
account.onclick=async function(){
async function activateAccount(){
if(busy) return;
if(!user){ open(); return; }
const token=++version;setBusy(true);
@@ -107,7 +118,8 @@ var CustomerLogin = {
if(token === version) update(null);
}catch(failure){ if(options.onError) options.onError(options.text('customerLogoutFailed')); }
finally{ if(token === version) setBusy(false); }
};
}
accounts.forEach(account=>{account.onclick=activateAccount;});
window.addEventListener('focus',refresh);
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
render();
+34 -2
View File
@@ -2,6 +2,8 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="staff-csrf-token" content="{{ g.staff.csrf_token }}">
<meta name="staff-username" content="{{ g.staff.username }}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>客服工作台 · 柏悦物流</title>
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
@@ -16,6 +18,8 @@
</div>
<div class="topbar-actions">
<span id="nightBadge" class="night-badge" hidden>非工作时段</span>
<button id="ticket-inbox-toggle" class="ticket-inbox-toggle" type="button" aria-label="查看待处理留言" aria-controls="service-tools">留言 <span id="ticket-pending-count">—</span></button>
<button id="ticket-overdue-toggle" class="ticket-overdue-toggle" type="button" aria-label="查看超时留言" aria-controls="service-tools">超时 <span id="ticket-overdue-count">—</span></button>
<button id="notifications-toggle" class="icon-button" type="button" aria-label="开启浏览器通知" title="开启浏览器通知" aria-pressed="false">🔔</button>
<a class="workspace-link" href="/">客户入口 ↗</a>
<details class="data-overview" id="data-overview">
@@ -31,9 +35,12 @@
<a class="export-link" href="/api/export_chat">导出 Excel ↗</a>
</div>
</details>
<button id="staff-logout" class="staff-logout" type="button">退出登录</button>
</div>
</header>
<div id="staff-logout-status" class="sync-status" role="alert" hidden></div>
<div id="sync-status" class="sync-status" role="status" hidden>会话连接中断,正在重试…</div>
<div id="ticket-alert" class="ticket-alert" role="status" aria-live="polite" hidden><span id="ticket-alert-text"></span><button id="ticket-alert-view" type="button" class="ticket-action">查看待处理留言</button></div>
<main class="layout">
<aside class="conv-list" id="session-browser" aria-label="会话列表">
<div class="list-heading">
@@ -43,7 +50,7 @@
</div>
<div class="session-tabs" role="tablist" aria-label="会话状态">
<button id="tab-active" type="button" role="tab" aria-selected="true" aria-controls="sessionList">接待中 <span id="active-count" class="section-count">0</span></button>
<button id="tab-queue" type="button" role="tab" aria-selected="false" aria-controls="queue" tabindex="-1">排队中 <span id="queue-count" class="section-count">0</span></button>
<button id="tab-queue" type="button" role="tab" aria-selected="false" aria-controls="queue" tabindex="-1">排队中 <span id="queue-count" class="section-count">0</span><span id="queue-overdue-count" class="queue-overdue-count">超时 0</span></button>
</div>
<div class="session-scroll">
<div id="sessionList" role="tabpanel" aria-labelledby="tab-active"><div class="list-empty">暂无进行中的会话</div></div>
@@ -89,13 +96,38 @@
<button type="button" class="quick" onclick="quickReply('已为您催促,请稍候。')">已为您催促,请稍候</button>
<button type="button" class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询,祝您生活愉快</button>
</section>
<section class="card"><h3>离线留言</h3><div id="ticketList"><div class="list-empty">正在加载留言…</div></div></section>
<section class="card ticket-workspace" aria-labelledby="tickets-title">
<div class="ticket-row"><h3 id="tickets-title">离线留言</h3><span id="ticket-section-count" class="ticket-section-count">待处理 —</span></div>
<p id="ticket-summary-status" class="ticket-summary-status" role="status"></p>
<p class="ticket-timing-rule">超时提醒:30 分钟未领取,或领取后 20 分钟未完成。</p>
<form id="ticket-search-form" class="ticket-search-form" role="search" aria-label="搜索离线留言">
<label for="ticket-search">搜索留言</label><input id="ticket-search" type="search" maxlength="100" placeholder="手机号、关键词、负责人或备注" autocomplete="off">
<div class="ticket-search-actions"><button id="ticket-search-submit" type="submit" class="ticket-action ticket-primary">搜索</button><button id="ticket-search-clear" type="button" class="ticket-action" hidden>清除搜索</button></div>
</form>
<div class="ticket-toolbar"><label for="ticket-filter">状态</label><select id="ticket-filter"><option value="all">全部</option><option value="pending">待处理</option><option value="processing">处理中</option><option value="completed">已完成</option></select><label class="ticket-overdue-filter"><input id="ticket-overdue-filter" type="checkbox"> 只看超时</label><button id="ticket-refresh" type="button" class="ticket-action">刷新</button></div>
<div id="ticketList"><div class="list-empty">正在加载留言…</div></div>
<button id="ticket-more" type="button" class="ticket-action" hidden>加载更早留言</button>
<div id="ticket-selected" class="ticket-detail" hidden>
<div class="ticket-row"><h4>留言详情</h4><span id="ticket-state" class="ticket-state"></span></div>
<p id="ticket-meta" class="ticket-meta"></p><p id="ticket-content" class="ticket-content"></p>
<p id="ticket-owner" class="ticket-owner"></p><p id="ticket-overdue-hint" class="ticket-overdue-hint" hidden></p>
<label for="ticket-note" class="ticket-note-label">处理备注</label>
<textarea id="ticket-note" rows="4" maxlength="2000" placeholder="记录联系情况、处理结果或后续跟进事项" readonly></textarea>
<p id="ticket-server-note" class="ticket-server-note" hidden></p>
<button id="ticket-rebase" type="button" class="ticket-action" hidden>已核对,使用我的备注</button>
<div class="ticket-actions"><button id="ticket-claim" type="button" class="ticket-action ticket-primary" hidden>领取留言</button><button id="ticket-save" type="button" class="ticket-action" hidden>保存备注</button><button id="ticket-complete" type="button" class="ticket-action ticket-primary" hidden>标记已完成</button></div>
<p id="ticket-activity" class="ticket-meta"></p>
<section id="ticket-history" class="ticket-history" aria-labelledby="ticket-history-title"><div class="ticket-row"><h4 id="ticket-history-title">处理记录</h4><button id="ticket-history-reload" type="button" class="ticket-action">刷新记录</button></div><p id="ticket-history-status" class="ticket-meta" role="status"></p><ol id="ticket-history-list" class="ticket-history-list"></ol><button id="ticket-history-more" type="button" class="ticket-action" hidden>加载更早记录</button></section>
</div>
<p id="ticket-status-message" class="ticket-feedback" role="status" aria-live="polite"></p>
</section>
</aside>
</main>
<button class="tools-backdrop" id="tools-backdrop" type="button" aria-label="关闭服务资料"></button>
<dialog id="searchResult" class="search-dialog" aria-labelledby="search-title"><header><h2 id="search-title">搜索历史记录</h2><button id="search-close" class="icon-button" type="button" aria-label="关闭搜索">×</button></header><form id="history-search-form"><label class="sr-only" for="searchBox">历史消息关键词</label><input id="searchBox" type="search" placeholder="输入关键词搜索聊天记录" required><button type="submit" class="quick">搜索</button></form><div id="searchBody" aria-live="polite">请输入关键词搜索历史消息</div></dialog>
<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.js') }}"></script>
</body>
</html>
+2 -1
View File
@@ -21,7 +21,7 @@
</div>
<div class="history-wrap" id="historyWrap"></div>
<div class="sidebar-footer">
<button type="button" id="customer-account" class="customer-account">登录</button>
<button type="button" id="customer-account" class="customer-account">客户登录</button>
<button type="button" class="clear-all-btn">清空全部历史</button>
</div>
</aside>
@@ -31,6 +31,7 @@
<button class="toggle-divider" type="button" aria-label="切换历史侧栏" aria-expanded="true"></button>
<div class="header-copy"><div class="header-kicker">BAIYUE · CUSTOMER SUPPORT</div><span id="app-title">柏悦物流智能助手</span></div>
<div class="title-actions">
<button type="button" id="customer-account-mobile" class="customer-account mobile-account">客户登录</button>
<button type="button" id="notifications-toggle" class="theme-toggle" title="开启浏览器通知" aria-label="开启浏览器通知" aria-pressed="false">🔔</button>
<span class="service-label"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceTag">智能客服</span></span>
<button type="button" id="lang-toggle" class="theme-toggle lang-toggle" title="中英文切换(Ctrl+L)">中</button>
+370 -11
View File
@@ -6,13 +6,14 @@ const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../../static/js/admin.js'), 'utf8');
const sharedSource = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8');
const toolsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tools.js'), 'utf8');
const ticketsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tickets.js'), 'utf8');
const flush = () => new Promise(setImmediate);
function response(data, status = 200){
return {ok: status >= 200 && status < 300, status, json: async () => data};
}
async function workspace(compact = false){
async function workspace(compact = false, initialHooks = {}){
const elements = new Map();
const documentListeners = {};
let document;
@@ -43,6 +44,7 @@ async function workspace(compact = false){
showModal(){ this.open = true; }
close(){ this.open = false; }
focus(){ document.activeElement = this; }
scrollIntoView(){}
click(){ if(this.onclick) return this.onclick(); }
}
const quickButtons = [new Element(), new Element()];
@@ -52,6 +54,8 @@ async function workspace(compact = false){
createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
querySelector(selector){
if(selector === 'meta[name="staff-csrf-token"]') return {content:'fixture-csrf'};
if(selector === 'meta[name="staff-username"]') return {content:'alice'};
const match = selector.match(/data-message-id="(\d+)"/);
if(!match) return null;
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
@@ -59,35 +63,390 @@ async function workspace(compact = false){
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
const calls = [];
const hooks = {};
const hooks = initialHooks;
const timers = new Map(), intervals = new Map();
let timerId = 0;
const context = vm.createContext({
document, console,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
setInterval(){}, setTimeout(){}, clearTimeout(){},
document, console, AbortController,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; },
location:{replace(url){ calls.push({redirect:url}); }}},
setInterval(callback, delay){ const id=++timerId; intervals.set(id,{callback,delay}); return id; },
setTimeout(callback, delay){ const id=++timerId; timers.set(id,{callback,delay}); return id; },
clearTimeout(id){ timers.delete(id); },
clearInterval(id){ intervals.delete(id); },
fetch: async (url, options) => {
const body = options && options.body ? JSON.parse(options.body) : undefined;
calls.push({url, body});
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
calls.push({url, body, signal:options && options.signal, headers:options && options.headers});
if(url.startsWith('/api/tickets/summary')) return hooks.ticketSummary ? hooks.ticketSummary(url, options) : response({pending_count:0,latest_id:0,new_count:0});
if(/^\/api\/tickets\/\d+\/events/.test(url)) return hooks.ticketEvents ? hooks.ticketEvents(url, options) : response([]);
if(url === '/admin/logout' && hooks.logout) return hooks.logout(options);
if(url.startsWith('/api/tickets') && hooks.tickets) return hooks.tickets(url, body, options);
if(url === '/api/human/send' && hooks.send) return hooks.send(body, options);
if(url === '/api/human/close' && hooks.close) return hooks.close(body);
if(url === '/api/human/transfer' && hooks.transfer) return hooks.transfer(body);
if(url === '/api/human/note' && hooks.note) return hooks.note(body);
if(url === '/api/human/queue' && hooks.queue) return hooks.queue();
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url === '/api/human/queue' && hooks.queue) return hooks.queue(options);
if(url === '/api/human/active' && hooks.active) return hooks.active(options);
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url, options);
if(url.includes('/api/search_chat') && hooks.search) return hooks.search(url);
if(url.includes('/api/human/poll')) return response({status:'active', messages:[]});
if(url === '/api/admin/stats') return response({total_conv:0, total_msg:0, today_conv:0, good:0, bad:0});
return response(options ? {ok:true} : []);
return response(options && options.method ? {ok:true} : []);
},
});
vm.runInContext(sharedSource, context, {filename:'support.js'});
vm.runInContext(toolsSource, context, {filename:'admin_tools.js'});
vm.runInContext(ticketsSource, context, {filename:'admin_tickets.js'});
vm.runInContext(source, context, {filename:'admin.js'});
await flush();
await context.accept(7);
await flush();
return {context, element: id => document.getElementById(id), calls, hooks, document, documentListeners};
return {context, element: id => document.getElementById(id), calls, hooks, document, documentListeners, timers,
runTimers:async delay=>{
for(const [id,timer] of Array.from(timers)){
if(timer.delay===delay){ timers.delete(id); timer.callback(); }
}
await flush();
},
tick:async delay=>{
for(const timer of intervals.values()) if(timer.delay===delay) timer.callback();
await flush();
},
};
}
function waitForAbort(options){
return new Promise((resolve,reject)=>{
if(!options || !options.signal) return;
options.signal.addEventListener('abort',()=>{
const error=new Error('Request aborted'); error.name='AbortError'; reject(error);
},{once:true});
});
}
test('ticket polling preserves drafts and stale versions require explicit review', async () => {
const app = await workspace();
let ticket = {id:10,content:'查询派送',phone:'123',ctime:'now',status:'processing',owner_username:'alice',owner_name:'Alice',note:'原备注',version:1};
app.hooks.tickets = async url => response(url.includes('?') ? [ticket] : {ok:true,ticket});
await app.context.loadTickets();
app.element('ticket-note').value='我的未保存备注';
app.element('ticket-note').listeners.input();
ticket = {...ticket, note:'另一页面更新', version:2};
await app.context.loadTickets();
assert.equal(app.element('ticket-note').value,'我的未保存备注');
assert.equal(app.element('ticket-save').disabled,true);
assert.match(app.element('ticket-server-note').textContent,/另一页面更新/);
app.element('ticket-rebase').onclick();
assert.equal(app.element('ticket-save').disabled,false);
});
test('human queue displays five-minute overdue state and notifies once when it crosses the threshold', async () => {
const app=await workspace();
let overdue=false;
app.hooks.queue=async()=>response([{id:119,ctime:'2026-11-02 16:47:39',waiting_minutes:overdue?5:4,overdue,overdue_minutes:overdue?1:0}]);
const notices=[];
app.context.SupportUI.notify=(...args)=>notices.push(args);
await app.context.loadQueue();
assert.match(app.element('queue').innerHTML,/等待 4 分钟/);
assert.equal(app.element('queue-overdue-count').textContent,'超时 0');
assert.equal(notices.filter(item=>item[0]==='新客户排队').length,1);
overdue=true;
await app.context.loadQueue();
assert.match(app.element('queue').innerHTML,/排队超时 1 分钟/);
assert.equal(app.element('queue-overdue-count').textContent,'超时 1');
assert.equal(notices.filter(item=>item[0]==='人工排队超时').length,1);
await app.context.loadQueue();
assert.equal(notices.filter(item=>item[0]==='人工排队超时').length,1);
});
test('ticket writes use current owner, CSRF and preserve the draft on network failure', async () => {
const app = await workspace();
const ticket = {id:10,content:'待处理留言',status:'processing',owner_username:'alice',note:'',version:1};
app.hooks.tickets = async (url, body, options) => options && options.method ? response({ok:false,error:'版本冲突'},409) : response(url.includes('?') ? [ticket] : {ok:true,ticket});
await app.context.loadTickets();
app.element('ticket-note').value='处理结果';
app.element('ticket-note').listeners.input();
await app.element('ticket-complete').onclick();
const call=app.calls.find(call=>call.url==='/api/tickets/10' && call.body);
assert.equal(call.headers['X-CSRF-Token'],'fixture-csrf');
assert.equal(call.body.status,'completed');
assert.equal(call.body.version,1);
assert.equal(app.element('ticket-note').value,'处理结果');
assert.match(app.element('ticket-status-message').textContent,/版本冲突/);
assert.equal(app.element('ticket-complete').disabled,false);
});
test('tickets assigned to other staff are read-only and escaped in the list', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response([{id:12,content:'<img src=x onerror=alert(1)>',status:'processing',owner_username:'bob',owner_name:'Bob',version:1,note:'备注'}]);
await app.context.loadTickets();
assert.equal(app.element('ticket-note').readOnly,true);
assert.equal(app.element('ticket-save').hidden,true);
assert.match(app.element('ticketList').innerHTML,/&lt;img/);
assert.doesNotMatch(app.element('ticketList').innerHTML,/<img/);
});
test('a delayed ticket list cannot overwrite a newer status filter', async () => {
const app=await workspace();
let resolveOld, count=0;
app.hooks.tickets=async()=>{
if(++count===1) return new Promise(resolve=>{resolveOld=resolve;});
return response([{id:20,content:'已完成留言',status:'completed',owner_username:'alice',note:'处理结果',version:2}]);
};
const pending=app.context.loadTickets();
app.element('ticket-filter').value='completed';
app.element('ticket-filter').listeners.change();
await flush();
resolveOld(response([{id:10,content:'旧列表',status:'pending',version:0}]));
await pending;
assert.equal(app.element('ticket-state').textContent,'已完成');
assert.doesNotMatch(app.element('ticketList').innerHTML,/旧列表/);
});
test('a hung ticket save releases controls and retains notes after the request timeout', async () => {
const app=await workspace();
const ticket={id:10,content:'留言',status:'processing',owner_username:'alice',note:'',version:1};
app.hooks.tickets=async(url,body,options)=>options && options.method ? waitForAbort(options) : response([ticket]);
await app.context.loadTickets();
app.element('ticket-note').value='不能丢失的备注';
app.element('ticket-note').listeners.input();
const pending=app.element('ticket-save').onclick();
assert.equal(app.element('ticket-save').disabled,true);
await app.runTimers(10000);
await pending;
assert.equal(app.element('ticket-save').disabled,false);
assert.equal(app.element('ticket-note').value,'不能丢失的备注');
assert.match(app.element('ticket-status-message').textContent,/未确认成功.*备注已保留/);
});
test('ticket search is sent to the server and retained for pagination and status changes', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response(Array.from({length:20},(_,index)=>({id:100-index,content:'查询派送',status:'pending',version:0})));
app.element('ticket-search').value='138 &小林';
await app.element('ticket-search-form').listeners.submit({preventDefault(){}});
let listCalls=app.calls.filter(call=>call.url.startsWith('/api/tickets?'));
assert.equal(new URL(listCalls.at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
await app.element('ticket-more').onclick();
listCalls=app.calls.filter(call=>call.url.startsWith('/api/tickets?'));
assert.match(listCalls.at(-1).url,/before=81/);
assert.equal(new URL(listCalls.at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
app.element('ticket-filter').value='pending';
app.element('ticket-filter').listeners.change();
await flush();
assert.equal(new URL(app.calls.filter(call=>call.url.startsWith('/api/tickets?')).at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
});
test('new ticket alerts skip old records, deduplicate new arrivals and keep polling on older pages', async () => {
let version=1;
const app=await workspace(false,{ticketSummary:async()=>response({pending_count:24,latest_id:version,new_count:version===2?1:0})});
const notices=[];
app.context.SupportUI.notify=(...args)=>notices.push(args);
await app.context.loadTickets();
assert.equal(app.element('ticket-pending-count').textContent,'24');
assert.equal(notices.length,0);
app.hooks.tickets=async()=>response(Array.from({length:20},(_,index)=>({id:40-index,content:'旧留言',status:'pending',version:0})));
await app.context.loadTickets();
await app.element('ticket-more').onclick();
version=2;
await app.context.loadTickets();
assert.match(app.element('ticket-alert-text').textContent,/1 条新留言/);
assert.equal(app.element('ticket-alert').hidden,false);
assert.equal(notices.length,1);
await app.context.loadTickets();
assert.equal(notices.length,1);
version=3;
await app.context.loadTickets();
assert.equal(notices.length,1);
await app.element('ticket-alert-view').onclick();
assert.equal(app.element('ticket-filter').value,'pending');
assert.equal(app.element('ticket-search').value,'');
assert.equal(app.element('ticket-alert').hidden,true);
});
test('ticket header entry focuses the search field and IME confirmation does not submit', async () => {
const app=await workspace(true);
await app.element('ticket-inbox-toggle').onclick();
assert.equal(app.document.body.classList.contains('tools-open'),true);
assert.equal(app.document.activeElement,app.element('ticket-search'));
let prevented=false;
app.element('ticket-search').listeners.keydown({key:'Enter',isComposing:true,preventDefault(){prevented=true;}});
assert.equal(prevented,true);
});
test('overdue count stays global and the header opens all overdue records', async () => {
const app=await workspace(true,{ticketSummary:async()=>response({pending_count:8,latest_id:10,new_count:0,overdue_count:3})});
assert.equal(app.element('ticket-overdue-count').textContent,'3');
app.hooks.tickets=async()=>response([{id:10,content:'超时件',status:'pending',version:0,overdue_kind:'pending',overdue_minutes:7}]);
await app.element('ticket-overdue-toggle').onclick();
const query=new URL(app.calls.filter(call=>call.url.startsWith('/api/tickets?')).at(-1).url,'http://local');
assert.equal(query.searchParams.get('status'),'all');
assert.equal(query.searchParams.get('overdue'),'1');
assert.match(app.element('ticketList').innerHTML,/领取超时 7 分钟/);
assert.match(app.element('ticket-overdue-hint').textContent,/7 分钟/);
app.element('tools-close').onclick();
assert.equal(app.document.activeElement,app.element('ticket-overdue-toggle'));
await app.element('ticket-inbox-toggle').onclick();
assert.equal(app.element('ticket-overdue-filter').checked,false);
});
test('history is escaped, paginated and not reloaded by an unchanged poll', async () => {
const app=await workspace();
app.hooks.tickets=async()=>response([{id:10,content:'留言',status:'processing',version:1}]);
let historyCalls=0;
app.hooks.ticketEvents=async()=>{historyCalls++;return response(Array.from({length:20},(_,index)=>({id:40-index,action:'note_updated',actor_name:'<img>',ctime:'today',old_note:'旧备注',new_note:'<script>evil</script>'})));};
await app.context.loadTickets(); await flush();
assert.match(app.element('ticket-history-list').innerHTML,/&lt;script&gt;/);
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/<script>/);
assert.match(app.element('ticket-history-list').innerHTML,/旧备注/);
await app.context.loadTickets(); await flush();
assert.equal(historyCalls,1);
await app.element('ticket-history-more').onclick();
assert.match(app.calls.filter(call=>call.url.includes('/events?')).at(-1).url,/before=21/);
});
test('a late history response cannot replace the newly selected ticket history', async () => {
const app=await workspace();
let resolveOld;
app.hooks.tickets=async()=>response([{id:10,content:'A',status:'pending',version:0},{id:11,content:'B',status:'pending',version:0}]);
app.hooks.ticketEvents=async url=>url.includes('/10/') ? new Promise(resolve=>{resolveOld=resolve;}) : response([{id:4,action:'claimed',actor_name:'B客服',ctime:'today'}]);
await app.context.loadTickets();
app.element('ticketList').listeners.click({target:{closest(){return {getAttribute(){return '11';}};}}});
await flush();
resolveOld(response([{id:1,action:'claimed',actor_name:'A客服',ctime:'today'}])); await flush();
assert.match(app.element('ticket-history-list').innerHTML,/B客服/);
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/A客服/);
});
test('history failures offer retry without blocking a ticket save', async () => {
const app=await workspace();
const ticket={id:10,content:'留言',status:'processing',owner_username:'alice',version:1};
app.hooks.tickets=async(url,body)=>body ? response({ok:true,ticket:{...ticket,version:2,note:body.note}}) : response([ticket]);
app.hooks.ticketEvents=async()=>response({},500);
await app.context.loadTickets(); await flush();
assert.match(app.element('ticket-history-status').textContent,/失败.*重试/);
app.element('ticket-note').value='处理结果'; app.element('ticket-note').listeners.input();
await app.element('ticket-save').onclick(); await flush();
assert.match(app.element('ticket-status-message').textContent,/备注已保存/);
app.hooks.ticketEvents=async()=>response([{id:2,action:'completed',actor_name:'客服',ctime:'today',new_note:'结果'}]);
await app.element('ticket-history-reload').onclick();
assert.match(app.element('ticket-history-list').innerHTML,/处理结果:结果/);
});
test('staff writes include CSRF and logout revokes the session before redirecting', async () => {
const app = await workspace();
await app.context.logoutStaff();
const logout = app.calls.find(call=>call.url==='/admin/logout');
assert.equal(logout.headers['X-CSRF-Token'], 'fixture-csrf');
assert.equal(app.calls.filter(call=>call.redirect==='/admin').length, 1);
});
test('a rejected logout remains actionable and does not claim success', async () => {
const app = await workspace();
app.hooks.logout=async()=>response({},500);
await app.context.logoutStaff();
assert.equal(app.element('staff-logout').disabled, false);
assert.match(app.element('staff-logout-status').textContent,/退出失败/);
assert.equal(app.calls.filter(call=>call.redirect).length,0);
});
test('expired staff sessions redirect once and stop sending further polling requests', async () => {
const app = await workspace();
app.hooks.queue=async()=>response({},401);
app.hooks.active=async()=>response({},401);
await app.context.loadQueue();
await app.tick(1000);
await app.tick(3000);
const count=app.calls.length;
await app.context.loadQueue();
assert.equal(app.calls.length,count);
assert.equal(app.calls.filter(call=>call.redirect==='/admin?expired=1').length,1);
});
test('duplicate logout clicks send only one request', async () => {
const app=await workspace();
let finish;
app.hooks.logout=()=>new Promise(resolve=>{ finish=resolve; });
const first=app.context.logoutStaff();
await app.context.logoutStaff();
assert.equal(app.calls.filter(call=>call.url==='/admin/logout').length,1);
finish(response({ok:true}));
await first;
});
test('a hanging reply times out, preserves the newer draft and waits for manual retry', async () => {
const app=await workspace();
app.hooks.send=(body,options)=>waitForAbort(options);
app.element('replyText').value='先发送的回复';
const sending=app.context.sendReply();
app.element('replyText').value='继续补充的草稿';
app.element('replyText').listeners.input();
await app.runTimers(10000);
assert.equal(app.element('reply-send').disabled,false);
await sending;
assert.equal(app.context.sendingSid,'');
assert.equal(app.element('replyText').value,'继续补充的草稿');
assert.match(app.element('reply-status').textContent,/发送超时.*内容已保留.*确认聊天记录/);
await app.tick(1000);
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,1);
app.hooks.send=()=>response({ok:true});
await app.context.sendReply();
assert.equal(app.element('replyText').value,'');
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,2);
});
test('a hung message poll times out and the next interval resumes from the last received message', async () => {
const app=await workspace();
app.context.sessions['7'].lastId=12;
app.hooks.poll=(url,options)=>waitForAbort(options);
const polling=app.context.poll();
await flush();
await app.tick(1000);
assert.equal(app.calls.filter(call=>call.url==='/api/human/poll?sid=7&after=12').length,1);
await app.runTimers(10000);
assert.equal(app.context.polling,false);
await polling;
assert.equal(app.element('sync-status').hidden,false);
app.hooks.poll=()=>response({status:'active',messages:[{id:13,side:'customer',content:'断线恢复后的新消息',ctime:'2026-11-02 12:00'}]});
await app.tick(1000);
assert.equal(app.element('sync-status').hidden,true);
assert.equal(app.context.sessions['7'].lastId,13);
assert.equal(app.context.sessions['7'].msgs.length,1);
await app.tick(1000);
assert.equal(app.context.sessions['7'].msgs.length,1);
assert.ok(app.calls.some(call=>call.url==='/api/human/poll?sid=7&after=13'));
});
test('a hung active-session list releases the poll lock on timeout and reconnects', async () => {
const app=await workspace();
app.hooks.active=options=>waitForAbort(options);
const polling=app.context.poll();
await app.runTimers(10000);
assert.equal(app.context.polling,false);
await polling;
assert.equal(app.element('sync-status').hidden,false);
app.hooks.active=()=>response([{id:7}]);
await app.tick(1000);
assert.equal(app.element('sync-status').hidden,true);
});
test('the timeout also covers a response body that never finishes and is cleared after failures', async () => {
const app=await workspace();
app.hooks.queue=options=>({ok:true,json:()=>waitForAbort(options)});
const loading=app.context.loadQueue();
await flush();
await app.runTimers(10000);
assert.match(app.element('queue').innerHTML,/队列暂时无法加载/);
await loading;
assert.equal(app.timers.size,0);
app.hooks.queue=()=>response([],503);
await app.context.loadQueue();
assert.equal(app.timers.size,0);
app.hooks.queue=()=>response([]);
await app.context.loadQueue();
assert.match(app.element('queue').innerHTML,/当前没有排队会话/);
assert.equal(app.timers.size,0);
});
test('failed replies preserve the draft and make retry available', async () => {
const app = await workspace();
app.hooks.send = () => response({ok:false}, 503);
+43 -1
View File
@@ -1045,6 +1045,48 @@ test('invalid customer credentials leave the login form open without sending ano
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
});
test('mobile account entry shares login state and prevents duplicate actions while signing in', async () => {
const app=await customerPage({human:false,width:390});
let finishLogin;
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{finishLogin=resolve;});
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
const mobile=app.element('customer-account-mobile');
mobile.focus();
await app.click(mobile);
assert.equal(app.element('customer-login').hidden,false);
app.element('customer-username').value='customer';app.element('customer-password').value='test-password';
const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}});
assert.equal(mobile.disabled,true);
assert.equal(app.element('customer-account').disabled,true);
await app.click(mobile);
finishLogin(response({ok:true,authenticated:true,user:{id:3,name:'测试客户'}}));
await signing;
assert.equal(app.element('customer-login').hidden,true);
assert.equal(mobile.textContent,app.element('customer-account').textContent);
assert.match(mobile.textContent,/测试客户/);
assert.equal(app.context.document.activeElement,mobile);
await app.click(mobile);
assert.equal(mobile.textContent,'客户登录');
assert.equal(app.element('customer-account').textContent,'客户登录');
assert.equal(app.calls.filter(call=>call.url==='/api/customer/login').length,1);
});
test('language switching updates input controls and sidebar accessible labels', async () => {
const app=await customerPage({human:false,width:390});
await app.click(app.element('lang-toggle'));
assert.equal(app.element('upload-btn').getAttribute('aria-label'),'Attach a file');
assert.equal(app.element('emoji-toggle').getAttribute('aria-label'),'Choose an emoji');
assert.equal(app.element('history-search').getAttribute('aria-label'),'Search conversation history');
assert.equal(app.element('.sidebar-backdrop').getAttribute('aria-label'),'Close history sidebar');
assert.equal(app.element('.toggle-divider').getAttribute('aria-label'),'Expand sidebar');
assert.equal(app.element('customer-account-mobile').textContent,'Customer sign in');
await app.click(app.element('.toggle-divider'));
assert.equal(app.element('.toggle-divider').getAttribute('aria-label'),'Collapse sidebar');
await app.click(app.element('lang-toggle'));
assert.equal(app.element('upload-btn').getAttribute('aria-label'),'发送附件');
assert.equal(app.element('.toggle-divider').getAttribute('aria-label'),'收起侧栏');
});
test('a late login cannot replay an order into a newer conversation', async () => {
const app=await customerPage({human:false});let release;
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null});
@@ -1071,7 +1113,7 @@ test('signing out clears visible order conversation and returns to guest mode',
await app.context.window.fillInput('ORDER20260001');await flush();
assert.ok(app.element('chatDom').querySelector('.user-message'));
await app.click(app.element('customer-account'));
assert.equal(app.element('customer-account').textContent,'登录');
assert.equal(app.element('customer-account').textContent,'客户登录');
assert.equal(app.element('chatDom').querySelector('.user-message'),null);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/logout').length,1);
});
+18
View File
@@ -0,0 +1,18 @@
"""Authenticate test clients through the production login and CSRF contracts."""
from logistics_chat import db
def login_staff(app, client, username='staff-test-fixture', password='fixture-password', seed=True):
if seed:
with app.app_context():
connection = db.connect()
connection.execute('INSERT INTO agents(username,password,name) VALUES(?,?,?)',
(username, password, 'Fixture Staff'))
connection.commit()
response = client.post('/admin/login', json={'username':username, 'password':password})
assert response.status_code == 200, response.status_code
session = client.get('/api/admin/session')
assert session.status_code == 200, session.status_code
client.environ_base['HTTP_X_CSRF_TOKEN'] = session.json['csrf_token']
+17
View File
@@ -14,6 +14,7 @@ from unittest.mock import patch
import requests
from logistics_chat import create_app, db
from staff_fixture import login_staff
class ModelResponse:
@@ -45,6 +46,7 @@ class ApiTests(unittest.TestCase):
self.root = Path(self.directory.name)
self.app = self.application(self.root)
self.client = self.app.test_client()
login_staff(self.app, self.client)
self.client.post('/api/visitor/session', json={'key':'1'*64})
self.app.extensions['rate_hits'].clear()
from logistics_chat.order_data import import_records
@@ -313,6 +315,21 @@ class ApiTests(unittest.TestCase):
self.assertEqual(self.client.get('/api/human/active').json, [])
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'closed')
def test_human_queue_marks_items_over_five_minutes_using_server_clock(self):
from datetime import datetime, timezone, timedelta
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 10, 29, 12, 0, tzinfo=timezone(timedelta(hours=8)))
sid = self.client.post('/api/human/request',json={'conv_id':self.conversation()}).json['session_id']
before = self.client.get('/api/human/queue').json[0]
self.assertEqual((before['waiting_minutes'],before['overdue']), (0,False))
clock.now.return_value = datetime(2026, 10, 29, 12, 5, tzinfo=timezone(timedelta(hours=8)))
boundary = self.client.get('/api/human/queue').json[0]
self.assertFalse(boundary['overdue'])
clock.now.return_value = datetime(2026, 10, 29, 12, 5, 1, tzinfo=timezone(timedelta(hours=8)))
after = self.client.get('/api/human/queue').json[0]
self.assertEqual(after['id'], sid)
self.assertEqual((after['waiting_minutes'],after['overdue'],after['overdue_minutes']), (5,True,1))
def test_application_state_is_not_shared_between_factories(self):
self.app.extensions['human_typing']['1'] = 123
self.app.extensions['rate_hits']['127.0.0.1'].append(123)
+154
View File
@@ -0,0 +1,154 @@
"""CLI database backup, integrity and restore operations."""
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from contextlib import closing
from datetime import datetime, timedelta
from unittest.mock import patch
from logistics_chat import database_backup
from pathlib import Path
class DatabaseMaintenanceTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.database = self.root/'history.db'
self.backups = self.root/'backups'
self.backups.mkdir()
with closing(sqlite3.connect(self.database)) as connection:
connection.execute('CREATE TABLE records(value TEXT NOT NULL)')
connection.execute("INSERT INTO records VALUES('original')")
connection.commit()
def run_cli(self, *args):
return subprocess.run([sys.executable, 'scripts/db_maintenance.py', '--db', str(self.database),
'--backup-dir', str(self.backups), *args], capture_output=True, text=True,
encoding='utf-8', timeout=15)
def test_backup_and_check_create_verified_snapshot(self):
result = self.run_cli('backup')
self.assertEqual(result.returncode, 0, result.stderr)
backup = Path(result.stdout.strip().split(': ', 1)[-1])
self.assertTrue(backup.is_file())
checked = self.run_cli('check', '--input', str(backup))
self.assertEqual(checked.returncode, 0, checked.stderr)
self.assertIn('ok', checked.stdout.lower())
def test_restore_keeps_current_safety_backup_and_replaces_database(self):
source = self.backups/'source.db'
with closing(sqlite3.connect(source)) as connection:
connection.execute('CREATE TABLE records(value TEXT NOT NULL)')
connection.execute("INSERT INTO records VALUES('restored')")
connection.commit()
result = self.run_cli('restore', '--input', str(source), '--service-stopped')
self.assertEqual(result.returncode, 0, result.stderr)
with closing(sqlite3.connect(self.database)) as connection:
self.assertEqual(connection.execute('SELECT value FROM records').fetchone()[0], 'restored')
self.assertTrue(list(self.backups.glob('pre-restore-*.db')))
def test_restore_rejects_a_backup_outside_configured_directory(self):
outside = self.root/'outside.db'
outside.write_bytes(self.database.read_bytes())
result = self.run_cli('restore', '--input', str(outside), '--service-stopped')
self.assertNotEqual(result.returncode, 0)
with closing(sqlite3.connect(self.database)) as connection:
self.assertEqual(connection.execute('SELECT value FROM records').fetchone()[0], 'original')
def test_restore_requires_explicit_stopped_service_declaration(self):
result = self.run_cli('restore', '--input', str(self.backups/'source.db'))
self.assertNotEqual(result.returncode, 0)
def test_corrupt_backup_cannot_overwrite_current_data(self):
corrupt = self.backups/'corrupt.db'
corrupt.write_bytes(b'not sqlite')
result = self.run_cli('restore', '--input', str(corrupt), '--service-stopped')
self.assertNotEqual(result.returncode, 0)
with closing(sqlite3.connect(self.database)) as connection:
self.assertEqual(connection.execute('SELECT value FROM records').fetchone()[0], 'original')
def test_wal_records_are_present_and_snapshot_does_not_overwrite_existing_file(self):
destination = self.backups/'wal.db'
with closing(sqlite3.connect(self.database)) as connection:
connection.execute('PRAGMA journal_mode=WAL')
connection.execute("INSERT INTO records VALUES('committed-wal')")
connection.commit()
database_backup.snapshot_database(self.database, destination)
with closing(sqlite3.connect(destination)) as copied:
self.assertEqual(copied.execute('SELECT COUNT(*) FROM records').fetchone()[0], 2)
with self.assertRaises(FileExistsError):
database_backup.snapshot_database(self.database, destination)
self.assertEqual(list(self.backups.glob('*.tmp*')), [])
def test_restore_of_clean_wal_database_does_not_leave_or_reuse_sidecars(self):
with closing(sqlite3.connect(self.database)) as connection:
connection.execute('PRAGMA journal_mode=WAL')
source = self.backups/'source.db'
database_backup.snapshot_database(self.database, source)
# A read-only WAL source can leave empty coordination sidecars.
with closing(sqlite3.connect(self.database)) as connection:
connection.execute('PRAGMA wal_checkpoint(TRUNCATE)')
database_backup.restore_database(self.database, source, self.backups)
for suffix in ('-wal','-shm'):
self.assertFalse(Path(str(self.database)+suffix).exists())
database_backup.restore_database(self.database, source, self.backups)
def test_daily_worker_works_without_requests_retries_failures_and_deduplicates(self):
from flask import Flask
app = Flask(__name__)
app.config.update(DATABASE=self.database, BACKUP_DIR=self.backups)
worker = database_backup.DailyBackupWorker(app)
tomorrow = datetime.now(database_backup.CHINA_TIME) + timedelta(days=1)
with patch('logistics_chat.database_backup.daily_backup', side_effect=OSError('fixture disk full')):
with self.assertRaises(OSError):
worker.tick(tomorrow)
self.assertTrue(worker.tick(tomorrow))
self.assertFalse(worker.tick(tomorrow))
self.assertEqual(len(list(self.backups.glob('history_*.db'))), 1)
def test_missing_database_and_invalid_snapshot_are_never_created_or_published(self):
missing = self.root/'missing.db'
with self.assertRaises(sqlite3.Error):
database_backup.snapshot_database(missing, self.backups/'bad.db')
self.assertFalse(missing.exists())
self.assertFalse((self.backups/'bad.db').exists())
self.assertEqual(list(self.backups.glob('*.tmp')), [])
def test_daily_retention_leaves_upgrade_and_manual_backups_untouched(self):
for name in ('history_20000101.db','pre-migration-old.db','manual-old.db','history_invalid.db'):
(self.backups/name).write_bytes(b'fixture')
database_backup.daily_backup(self.database, self.backups)
self.assertFalse((self.backups/'history_20000101.db').exists())
for name in ('pre-migration-old.db','manual-old.db','history_invalid.db'):
self.assertTrue((self.backups/name).exists())
def test_daily_backup_keeps_exactly_seven_calendar_dates(self):
today = datetime.now(database_backup.CHINA_TIME)
for offset in range(1, 9):
(self.backups/('history_' + (today-timedelta(days=offset)).strftime('%Y%m%d') + '.db')).write_bytes(b'old')
database_backup.daily_backup(self.database, self.backups, today)
self.assertEqual(len(list(self.backups.glob('history_*.db'))), 7)
def test_migration_retention_removes_only_oldest_recognized_snapshots(self):
names = []
for index in range(7):
name = f'pre-migration-20261102-12000{index}-000000-{index:08x}.db'
names.append(name)
(self.backups/name).write_bytes(b'fixture')
(self.backups/'pre-migration-old.db').write_bytes(b'keep')
(self.backups/'manual-old.db').write_bytes(b'keep')
(self.backups/'pre-migration-20261102-120000-000000-badname.db').write_bytes(b'keep')
(self.backups/'pre-migration-20261102-120000-000000-ffffffff.db').mkdir()
database_backup.prune_migration_snapshots(self.backups, keep=5)
self.assertEqual([name for name in names if (self.backups/name).exists()], names[2:])
self.assertTrue((self.backups/'pre-migration-old.db').exists())
self.assertTrue((self.backups/'manual-old.db').exists())
self.assertTrue((self.backups/'pre-migration-20261102-120000-000000-badname.db').exists())
self.assertTrue((self.backups/'pre-migration-20261102-120000-000000-ffffffff.db').is_dir())
+4 -2
View File
@@ -9,6 +9,7 @@ import unittest
from unittest.mock import patch
from deploy import wsgi
from staff_fixture import login_staff
class DeploymentTests(unittest.TestCase):
@@ -30,9 +31,10 @@ class DeploymentTests(unittest.TestCase):
self.addCleanup(close_handlers)
client = app.test_client()
client.post('/api/visitor/session', json={'key':'1'*64})
for path in ('/', '/admin/dashboard', '/api/tickets', '/api/my_orders?phone=123'):
self.assertEqual(client.get(path).status_code, 200, path)
for path, status in (('/',200), ('/admin/dashboard',302), ('/api/tickets',401), ('/api/my_orders?phone=123',401)):
self.assertEqual(client.get(path).status_code, status, path)
self.assertEqual(client.post('/admin/login', json={'username': 'demo', 'password': 'test-password'}).status_code, 401)
login_staff(app, client)
self.assertEqual(client.post('/api/ticket', json={'content': 'Acceptance ticket', 'phone': '123'}).status_code, 200)
self.assertEqual(client.get('/api/tickets').json[0]['content'], 'Acceptance ticket')
uploaded = client.post('/api/upload', data={'file': (io.BytesIO(b'acceptance'), 'note.txt')})
+4 -1
View File
@@ -10,6 +10,7 @@ from unittest.mock import patch
from openpyxl import load_workbook
from logistics_chat import create_app, db
from staff_fixture import login_staff
class DevIntegrationTests(unittest.TestCase):
@@ -22,6 +23,7 @@ class DevIntegrationTests(unittest.TestCase):
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key': '1' * 64})
login_staff(self.app, self.client)
def session(self):
cid = self.client.post('/api/new_chat').json['id']
@@ -130,6 +132,7 @@ class DevIntegrationTests(unittest.TestCase):
connection.commit()
connection.close()
db.init_db()
login_staff(self.app, self.client, 'demo', seed=False)
results = self.client.get('/api/search_chat?q=专线').json
self.assertEqual(len(results), 1)
self.assertIn('SUM', results[0]['content'])
@@ -160,7 +163,7 @@ class DevIntegrationTests(unittest.TestCase):
state = self.client.get('/api/human/poll?sid=1').json
self.assertEqual(state['messages'][0]['content'], '保留的回复')
self.assertEqual(state['note'], '')
self.assertEqual(self.client.get('/api/agents/list').json[0]['username'], 'fixture-agent')
self.assertIn('fixture-agent', [row['username'] for row in self.client.get('/api/agents/list').json])
if __name__ == '__main__':
+74
View File
@@ -0,0 +1,74 @@
"""Existing staff credentials remain usable across password storage formats."""
from pathlib import Path
import tempfile
import unittest
from werkzeug.security import generate_password_hash
from logistics_chat import create_app, db
class StaffLoginTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.name)
self.app = create_app({'TESTING': True, 'DATABASE': root / 'history.db',
'UPLOAD_DIR': root / 'uploads', 'BACKUP_DIR': root / 'backups',
'LOG_DIR': root / 'logs', 'DEEPSEEK_API_KEY': '',
'CUSTOMER_DEMO_ENABLED': False})
self.client = self.app.test_client()
def add_staff(self, password):
with self.app.app_context():
connection = db.connect()
connection.execute('INSERT INTO agents(username,password,name) VALUES(?,?,?)',
('staff-fixture', password, '测试客服'))
connection.commit()
def login(self, password):
return self.client.post('/admin/login', json={'username': 'staff-fixture', 'password': password})
def test_existing_hashed_password_accepts_original_password(self):
password = 'Fixture-密码-42!'
for method in ('pbkdf2:sha256:1000', 'scrypt:1024:8:1'):
with self.subTest(method=method):
encoded = generate_password_hash(password, method=method)
with self.app.app_context():
connection = db.connect()
connection.execute('DELETE FROM agents')
connection.commit()
self.add_staff(encoded)
response = self.login(password)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {'ok': True, 'name': '测试客服'})
with self.app.app_context():
connection = db.connect()
self.assertEqual(connection.execute('SELECT password FROM agents').fetchone()[0], encoded)
def test_wrong_password_and_stored_hash_cannot_log_in(self):
encoded = generate_password_hash('Fixture-password', method='pbkdf2:sha256:1000')
self.add_staff(encoded)
self.assertEqual(self.login('wrong-password').status_code, 401)
self.assertEqual(self.login(encoded).status_code, 401)
def test_legacy_plain_password_still_works(self):
self.add_staff('旧密码-fixture')
self.assertEqual(self.login('旧密码-fixture').status_code, 200)
self.assertEqual(self.login('wrong').status_code, 401)
def test_invalid_credentials_are_rejected_without_server_errors(self):
self.add_staff('legacy-fixture')
for payload in ({}, [], 'invalid', {'username': [], 'password': {}},
{'username': 'staff-fixture', 'password': ''},
{'username': 'missing', 'password': 'legacy-fixture'}):
with self.subTest(payload=payload):
self.app.extensions['staff_login_attempts'].clear()
self.assertEqual(self.client.post('/admin/login', json=payload).status_code, 401)
def test_corrupt_hash_cannot_be_used_as_a_plain_password(self):
encoded = 'pbkdf2:invalid$fixture$invalid'
self.add_staff(encoded)
self.assertEqual(self.login(encoded).status_code, 401)
self.assertEqual(self.login('test-password').status_code, 401)
+249
View File
@@ -0,0 +1,249 @@
"""Staff authorization uses real logins against disposable storage."""
import hashlib
import unittest
from unittest.mock import patch
import test_staff_login as credentials
from logistics_chat import db
class StaffSessionTests(unittest.TestCase):
add_staff = credentials.StaffLoginTests.add_staff
login = credentials.StaffLoginTests.login
def setUp(self):
credentials.StaffLoginTests.setUp(self)
self.app.config['RATE_LIMIT_PER_MIN'] = 1000
def signed_in(self):
self.add_staff('fixture-password')
response = self.login('fixture-password')
self.assertEqual(response.status_code, 200)
return self.client.get('/api/admin/session').json['csrf_token']
def test_anonymous_staff_pages_and_all_staff_endpoints_are_blocked(self):
self.assertEqual(self.client.get('/admin/dashboard').status_code, 302)
public = {'static', 'pages.index', 'pages.health', 'admin.admin', 'admin.admin_login',
'chat.stream_chat', 'chat.feedback', 'history.new_chat', 'history.get_history',
'history.get_messages', 'history.del_history', 'history.clear_all',
'files.upload_file', 'files.uploaded_file', 'business.add_ticket', 'business.rate_chat',
'visitor.visitor_session', 'visitor.visitor_human', 'customer.session',
'customer.login', 'customer.logout', 'speech.speech_config', 'speech.speech_transcribe'}
for rule in self.app.url_map.iter_rules():
if rule.endpoint in public or rule.rule == '/admin/dashboard':
continue
method = 'POST' if 'POST' in rule.methods else 'GET'
path = rule.rule.replace('<path:name>', 'fixture.txt')
with self.subTest(path=path):
self.assertEqual(self.client.open(path, method=method, json={}).status_code, 401)
self.assertEqual(self.client.get('/api/future-private-route').status_code, 401)
self.assertEqual(self.client.get('/').status_code, 200)
self.assertEqual(self.client.get('/healthz').status_code, 200)
def test_customer_ticket_and_rating_keep_their_existing_guest_contract(self):
self.client.post('/api/visitor/session', json={'key':'a'*64})
self.assertEqual(self.client.post('/api/ticket', json={'content':'fixture ticket'}).status_code, 200)
self.assertEqual(self.client.post('/api/rate', json={'sid':999, 'stars':5}).status_code, 404)
def test_cookie_and_database_store_24_hour_opaque_sessions(self):
self.add_staff('fixture-password')
response = self.client.post('/admin/login', json={'username':'staff-fixture', 'password':'fixture-password'},
base_url='https://localhost')
cookie = response.headers['Set-Cookie']
for flag in ('HttpOnly', 'Secure', 'SameSite=Lax', 'Max-Age=86400'):
self.assertIn(flag, cookie)
token = self.client.get_cookie('baiyue_staff').value
with self.app.app_context():
row = db.connect().execute('SELECT token_hash FROM staff_sessions').fetchone()
self.assertEqual(row[0], hashlib.sha256(token.encode()).hexdigest())
self.assertNotIn(token, self.client.get('/admin/dashboard').text)
self.assertEqual(self.client.get('/admin/dashboard').status_code, 200)
def test_staff_writes_require_csrf_and_same_origin(self):
csrf = self.signed_in()
self.assertEqual(self.client.post('/api/human/note', json={}).status_code, 403)
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':'bad'}).status_code, 403)
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf, 'Origin':'https://evil.test'}).status_code, 403)
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
def test_logout_revokes_replayed_cookie_and_preserves_data(self):
csrf = self.signed_in()
token = self.client.get_cookie('baiyue_staff').value
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf}).status_code, 200)
self.assertIsNone(self.client.get_cookie('baiyue_staff'))
self.client.set_cookie('baiyue_staff', token)
self.assertEqual(self.client.get('/api/admin/stats').status_code, 401)
with self.app.app_context():
self.assertEqual(db.connect().execute('SELECT COUNT(*) FROM agents').fetchone()[0], 1)
def test_login_rotates_current_token(self):
self.signed_in()
old = self.client.get_cookie('baiyue_staff').value
self.login('fixture-password')
self.assertNotEqual(old, self.client.get_cookie('baiyue_staff').value)
self.client.set_cookie('baiyue_staff', old)
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_sessions_expire_at_24_hours_without_renewing_on_requests_or_restart(self):
from logistics_chat import create_app
with patch('logistics_chat.staff_service.time.time', return_value=1000):
self.signed_in()
token = self.client.get_cookie('baiyue_staff').value
restarted = self.client
for elapsed in (8*3600, 23*3600, 24*3600-1):
with self.subTest(elapsed=elapsed), patch('logistics_chat.staff_service.time.time', return_value=1000+elapsed):
response = self.client.get('/api/admin/session')
self.assertEqual(response.status_code, 200)
self.assertNotIn('Set-Cookie', response.headers)
self.assertEqual(self.client.get_cookie('baiyue_staff').value, token)
restarted = create_app(dict(self.app.config)).test_client()
restarted.set_cookie('baiyue_staff', token)
self.assertEqual(restarted.get('/api/admin/session').status_code, 200)
with patch('logistics_chat.staff_service.time.time', return_value=1000+24*3600):
for client in (self.client, restarted):
self.assertEqual(client.get('/api/admin/session').status_code, 401)
self.assertEqual(client.get('/admin/dashboard').status_code, 302)
def test_sessions_survive_app_restart_until_explicit_logout(self):
from logistics_chat import create_app
self.signed_in()
token = self.client.get_cookie('baiyue_staff').value
restarted = create_app(dict(self.app.config)).test_client()
restarted.set_cookie('baiyue_staff', token)
response = restarted.get('/api/admin/session')
self.assertEqual(response.status_code, 200)
self.assertEqual(restarted.post('/admin/logout', headers={'X-CSRF-Token':response.json['csrf_token']}).status_code, 200)
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_another_browser_login_does_not_remove_existing_session(self):
csrf = self.signed_in()
another = self.app.test_client()
self.assertEqual(another.post('/admin/login', json={'username':'staff-fixture','password':'fixture-password'}).status_code, 200)
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf}).status_code, 200)
self.assertEqual(another.get('/api/admin/session').status_code, 200)
def test_upgrade_preserves_deadlines_and_revokes_unbounded_and_expired_sessions(self):
self.signed_in()
with self.app.app_context():
connection = db.connect()
connection.execute('UPDATE staff_sessions SET expires_at=2000')
connection.execute('INSERT INTO staff_sessions SELECT ?,username,auth_stamp,csrf_token,500 FROM staff_sessions',
(hashlib.sha256(('b'*64).encode()).hexdigest(),))
connection.execute('INSERT INTO staff_sessions SELECT ?,username,auth_stamp,csrf_token,0 FROM staff_sessions LIMIT 1',
(hashlib.sha256(('c'*64).encode()).hexdigest(),))
connection.commit()
with patch('logistics_chat.staff_service.time.time', return_value=1000):
db.init_db()
with patch('logistics_chat.staff_service.time.time', return_value=1500):
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
for token in ('b'*64, 'c'*64):
other = self.app.test_client()
other.set_cookie('baiyue_staff', token)
self.assertEqual(other.get('/api/admin/session').status_code, 401)
with patch('logistics_chat.staff_service.time.time', return_value=2000):
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_password_change_and_account_deletion_invalidate_sessions(self):
self.signed_in()
with self.app.app_context():
connection = db.connect()
connection.execute("UPDATE agents SET password='new-fixture-password'")
connection.commit()
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
self.login('new-fixture-password')
with self.app.app_context():
connection = db.connect()
connection.execute('DELETE FROM agents')
connection.commit()
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_legacy_agent_id_session_schema_is_upgraded_without_changing_credentials(self):
from werkzeug.security import generate_password_hash
password_hash = generate_password_hash('fixture-password', method='scrypt:1024:8:1')
with self.app.app_context():
connection = db.connect()
connection.execute('DROP TABLE staff_sessions')
connection.execute('DROP TABLE agents')
connection.execute('CREATE TABLE agents(id INTEGER PRIMARY KEY, username TEXT UNIQUE, '
"password TEXT, name TEXT, role TEXT NOT NULL DEFAULT 'agent')")
connection.execute('INSERT INTO agents VALUES(?,?,?,?,?)', (7,'staff-fixture',password_hash,'测试客服','admin'))
connection.execute('CREATE TABLE staff_sessions(id TEXT PRIMARY KEY, agent_id INTEGER NOT NULL, '
'created_at REAL NOT NULL, expires_at REAL NOT NULL, password_version TEXT NOT NULL)')
connection.execute('INSERT INTO staff_sessions VALUES(?,?,?,?,?)', ('legacy-cookie',7,1000,9999999999,'legacy-version'))
connection.execute("INSERT INTO tickets(content,phone) VALUES('fixture-ticket','fixture-phone')")
connection.commit()
db.init_db()
db.init_db()
self.assertEqual(connection.execute('SELECT id,username,password,name,role FROM agents').fetchone(),
(7,'staff-fixture',password_hash,'测试客服','admin'))
self.assertEqual(connection.execute('SELECT content FROM tickets').fetchone()[0], 'fixture-ticket')
self.assertEqual(self.login('fixture-password').status_code, 200)
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
self.assertEqual(self.client.get('/admin/dashboard').status_code, 200)
with self.app.app_context():
connection = db.connect()
connection.execute("UPDATE agents SET password='new-fixture-password'")
connection.commit()
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
self.login('new-fixture-password')
with self.app.app_context():
connection = db.connect()
connection.execute('DELETE FROM agents')
connection.commit()
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_cross_site_login_and_form_login_are_rejected(self):
self.add_staff('fixture-password')
self.assertEqual(self.client.post('/admin/login', json={'username':'staff-fixture','password':'fixture-password'},
headers={'Origin':'https://evil.test'}).status_code, 403)
self.assertEqual(self.client.post('/admin/login', data={'username':'staff-fixture','password':'fixture-password'}).status_code, 415)
def test_restoring_password_or_recreating_account_cannot_restore_old_session(self):
for change in ('password', 'account', 'replace'):
with self.subTest(change=change):
if change == 'password':
self.signed_in()
else:
self.login('fixture-password')
with self.app.app_context():
connection = db.connect()
if change == 'password':
connection.execute("UPDATE agents SET password='temporary-password'")
connection.execute("UPDATE agents SET password='fixture-password'")
elif change == 'account':
connection.execute('DELETE FROM agents')
connection.execute('INSERT INTO agents VALUES(?,?,?)', ('staff-fixture','fixture-password','测试客服'))
else:
for password in ('temporary-password', 'fixture-password'):
connection.execute('INSERT OR REPLACE INTO agents VALUES(?,?,?)', ('staff-fixture',password,'测试客服'))
connection.commit()
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_failed_logins_are_throttled_separately_from_polling(self):
self.add_staff('fixture-password')
for _ in range(5):
self.assertEqual(self.login('wrong').status_code, 401)
self.assertEqual(self.login('wrong').status_code, 429)
self.assertEqual(self.client.get('/healthz').status_code, 200)
def test_random_cookie_and_customer_session_do_not_authorize_staff(self):
self.client.set_cookie('baiyue_staff', 'a'*64)
self.client.set_cookie('baiyue_customer', 'b'*64)
self.client.post('/api/visitor/session', json={'key':'c'*64})
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/tickets').status_code, 401)
def test_proxy_peer_failures_do_not_lock_other_accounts_or_trust_spoofed_headers(self):
self.add_staff('fixture-password')
with self.app.app_context():
connection = db.connect()
connection.execute('INSERT INTO agents VALUES(?,?,?)', ('other-fixture','other-password','Other Staff'))
connection.commit()
for _ in range(5):
self.assertEqual(self.login('wrong').status_code, 401)
fresh = self.app.test_client()
self.assertEqual(fresh.post('/admin/login', json={'username':'staff-fixture','password':'wrong'},
headers={'X-Forwarded-For':'203.0.113.42'}).status_code, 429)
self.assertEqual(fresh.post('/admin/login', json={'username':'other-fixture','password':'other-password'}).status_code, 200)
+92
View File
@@ -8,6 +8,9 @@ import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch
from contextlib import closing
import sqlite3
from flask import Flask
from werkzeug.datastructures import FileStorage
@@ -115,6 +118,95 @@ class StorageTests(unittest.TestCase):
self.assertFalse(old.exists())
self.assertEqual((uploads/'new.txt').read_text(), 'new')
def test_schema_version_and_pre_migration_snapshot_are_recorded_before_upgrade(self):
connection = db.connect()
connection.execute('DROP TABLE IF EXISTS schema_meta')
connection.commit()
connection.close()
db.init_db()
connection = db.connect()
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
connection.close()
snapshots = list((self.root/'backups').glob('pre-migration-*.db'))
self.assertEqual(len(snapshots), 1)
self.assertEqual(db.check_database(snapshots[0]), 'ok')
with closing(sqlite3.connect(snapshots[0])) as snapshot:
self.assertIsNone(snapshot.execute("SELECT 1 FROM sqlite_master WHERE name='schema_meta'").fetchone())
def test_startup_migration_snapshots_keep_only_the_latest_five(self):
for _ in range(7):
db.init_db()
snapshots = sorted((self.root/'backups').glob('pre-migration-*.db'))
self.assertEqual(len(snapshots), 5)
self.assertTrue(all(path.is_file() for path in snapshots))
self.assertTrue(all(db.check_database(path) == 'ok' for path in snapshots))
def test_backup_failure_prevents_any_schema_changes(self):
with closing(db.connect()) as connection:
connection.execute('DROP TABLE schema_meta')
connection.commit()
with patch('logistics_chat.db.snapshot_database', side_effect=OSError('fixture disk full')):
with self.assertRaisesRegex(OSError, 'disk full'):
db.init_db()
with closing(db.connect()) as connection:
self.assertIsNone(connection.execute("SELECT 1 FROM sqlite_master WHERE name='schema_meta'").fetchone())
def test_failed_startup_snapshot_does_not_prune_previous_backups(self):
backups = self.root/'backups'
backups.mkdir()
previous = []
for index in range(6):
path = backups/f'pre-migration-20260101-00000{index}-000000-{index:08x}.db'
db.snapshot_database(self.root/'history.db', path)
previous.append(path)
with patch('logistics_chat.db.snapshot_database', side_effect=OSError('fixture disk full')):
with self.assertRaises(OSError):
db.init_db()
self.assertTrue(all(path.exists() for path in previous))
def test_failed_migration_rolls_back_schema_and_data_and_keeps_preimage(self):
with closing(db.connect()) as connection:
connection.execute('DROP TABLE schema_meta')
connection.commit()
def broken(connection):
connection.execute('CREATE TABLE partial_upgrade(value TEXT)')
connection.execute("INSERT INTO tickets(content) VALUES('must roll back')")
raise RuntimeError('fixture migration failed')
with patch('logistics_chat.db.MIGRATIONS', ((1, broken), *db.MIGRATIONS[1:])):
with self.assertRaisesRegex(RuntimeError, 'migration failed'):
db.init_db()
with closing(db.connect()) as connection:
self.assertIsNone(connection.execute("SELECT 1 FROM sqlite_master WHERE name='partial_upgrade'").fetchone())
self.assertEqual(connection.execute('SELECT COUNT(*) FROM tickets').fetchone()[0], 0)
self.assertEqual(len(list((self.root/'backups').glob('pre-migration-*.db'))), 1)
def test_newer_database_version_is_never_downgraded(self):
with closing(db.connect()) as connection:
connection.execute("UPDATE schema_meta SET value='999' WHERE key='schema_version'")
connection.commit()
with self.assertRaisesRegex(RuntimeError, 'newer'):
db.init_db()
with closing(db.connect()) as connection:
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], '999')
def test_missing_migration_step_cannot_mark_database_as_upgraded(self):
with patch('logistics_chat.db.SCHEMA_VERSION', db.SCHEMA_VERSION + 1):
with self.assertRaisesRegex(RuntimeError, 'migration'):
db.init_db()
with closing(db.connect()) as connection:
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
def test_snapshot_backup_is_verified_and_restore_source_is_not_modified(self):
destination = self.root/'backups'/'manual.db'
db.snapshot_database(self.root/'history.db', destination)
self.assertEqual(db.check_database(destination), 'ok')
original = destination.read_bytes()
connection = db.connect()
connection.execute("INSERT INTO chat_history(title,ctime) VALUES('after-backup','now')")
connection.commit()
connection.close()
self.assertEqual(destination.read_bytes(), original)
def test_upload_uses_generated_name_and_configured_directory(self):
upload = FileStorage(stream=io.BytesIO(b'hello'), filename='../report.txt')
result = file_service.upload_file({'file': upload})
+266
View File
@@ -0,0 +1,266 @@
"""Offline ticket workflow uses authenticated staff and isolated SQLite storage."""
from contextlib import closing
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Barrier
from unittest.mock import patch
from datetime import datetime
import tempfile
import unittest
from logistics_chat import create_app, db
from staff_fixture import login_staff
class TicketTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
self.app = create_app({'TESTING': True, 'DATABASE': self.root/'history.db',
'UPLOAD_DIR': self.root/'uploads', 'BACKUP_DIR': self.root/'backups',
'LOG_DIR': self.root/'logs', 'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
self.client = self.app.test_client()
self.other = self.app.test_client()
login_staff(self.app, self.client, username='alice')
login_staff(self.app, self.other, username='bob')
self.client.post('/api/visitor/session', json={'key':'a'*64})
self.assertEqual(self.client.post('/api/ticket', json={'content':'请查询派送', 'phone':'123'}).status_code, 200)
self.ticket = self.client.get('/api/tickets').json[0]['id']
def claim(self, client=None):
return (client or self.client).post(f'/api/tickets/{self.ticket}/claim', json={})
def update(self, payload, client=None):
return (client or self.client).patch(f'/api/tickets/{self.ticket}', json=payload)
def test_claim_notes_and_completion_remain_queryable(self):
self.assertEqual(self.update({'version':0,'note':'不能跳过领取','status':'completed'}).status_code, 409)
claimed = self.claim()
self.assertEqual(claimed.status_code, 200)
ticket = claimed.json['ticket']
self.assertEqual((ticket['status'], ticket['owner_username']), ('processing', 'alice'))
saved = self.update({'version':ticket['version'],'note':'已联系派送站'})
self.assertEqual(saved.status_code, 200)
done = self.update({'version':saved.json['ticket']['version'],'note':'已联系并处理完成','status':'completed'})
self.assertEqual(done.status_code, 200)
self.assertTrue(done.json['ticket']['completed_at'])
self.assertEqual(self.client.get('/api/tickets').json, [])
record = self.client.get('/api/tickets?status=completed').json[0]
self.assertEqual(record['note'], '已联系并处理完成')
self.assertEqual(record['owner_username'], 'alice')
self.assertEqual(self.update({'version':record['version'],'note':'重新修改'}).status_code, 409)
def test_other_staff_cannot_take_over_or_change_owner(self):
ticket = self.claim().json['ticket']
self.assertEqual(self.claim(self.other).status_code, 409)
self.assertEqual(self.update({'version':ticket['version'],'note':'覆盖'}, self.other).status_code, 403)
self.assertEqual(self.update({'version':ticket['version'],'note':'伪造','owner_username':'bob'}).status_code, 400)
def test_concurrent_claim_has_one_winner(self):
gate = Barrier(2)
def claim(client):
gate.wait(timeout=5)
return client.post(f'/api/tickets/{self.ticket}/claim', json={}).status_code
with ThreadPoolExecutor(max_workers=2) as workers:
results = list(workers.map(claim, (self.client, self.other)))
self.assertEqual(sorted(results), [200, 409])
events = self.client.get(f'/api/tickets/{self.ticket}/events').json
self.assertEqual(sum(event['action'] == 'claimed' for event in events), 1)
def test_stale_update_and_invalid_input_cannot_overwrite_notes(self):
ticket = self.claim().json['ticket']
version = ticket['version']
self.assertEqual(self.update({'version':version,'note':'第一份备注'}).status_code, 200)
self.assertEqual(self.update({'version':version,'note':'旧页面覆盖'}).status_code, 409)
for payload in ([], {'note':'缺少版本'}, {'version':True,'note':'坏版本'},
{'version':version+1,'note':'x'*2001}, {'version':version+1,'status':'pending'},
{'version':version+1,'note':' ','status':'completed'}):
with self.subTest(payload=str(payload)[:60]):
self.assertEqual(self.update(payload).status_code, 400)
self.assertEqual(self.client.get('/api/tickets?status=processing').json[0]['note'], '第一份备注')
def test_login_csrf_and_missing_ticket_are_enforced(self):
guest = self.app.test_client()
self.assertEqual(guest.post(f'/api/tickets/{self.ticket}/claim', json={}).status_code, 401)
self.assertEqual(guest.patch(f'/api/tickets/{self.ticket}', json={}).status_code, 401)
self.assertEqual(self.client.post(f'/api/tickets/{self.ticket}/claim', json={}, headers={'X-CSRF-Token':''}).status_code, 403)
self.assertEqual(self.client.post('/api/tickets/9999/claim', json={}).status_code, 404)
def test_oversized_ids_and_cursors_return_client_errors(self):
too_large = 2**63
self.assertEqual(self.client.get(f'/api/tickets?before={too_large}').status_code, 400)
self.assertEqual(self.client.get(f'/api/tickets/{too_large}').status_code, 404)
self.assertEqual(self.client.post(f'/api/tickets/{too_large}/claim', json={}).status_code, 404)
self.assertEqual(self.client.patch(f'/api/tickets/{too_large}', json={'version':0,'note':'x'}).status_code, 404)
def test_filters_and_pagination_do_not_hide_old_tickets(self):
for index in range(4):
self.client.post('/api/ticket', json={'content':str(index)})
first = self.client.get('/api/tickets?status=all&limit=2').json
second = self.client.get(f'/api/tickets?status=all&limit=2&before={first[-1]["id"]}').json
self.assertEqual(len({row['id'] for row in first + second}), 4)
self.assertEqual(len(first), 2)
for query in ('status=bad', 'limit=0', 'limit=101', 'before=bad'):
self.assertEqual(self.client.get('/api/tickets?' + query).status_code, 400)
def test_version_one_schema_is_upgraded_without_changing_ticket_or_account(self):
with self.app.app_context(), closing(db.connect()) as connection:
connection.executescript('DROP TABLE tickets; CREATE TABLE tickets('
'id INTEGER PRIMARY KEY,content TEXT,phone TEXT,ctime TEXT,status TEXT);'
"INSERT INTO tickets VALUES(42,'旧留言','123','yesterday','pending');"
"UPDATE schema_meta SET value='1' WHERE key='schema_version';")
db.init_db()
row = connection.execute('SELECT content,status,owner_username,note,version FROM tickets WHERE id=42').fetchone()
self.assertEqual(row, ('旧留言','pending',None,'',0))
self.assertEqual(connection.execute('SELECT COUNT(*) FROM agents').fetchone()[0], 2)
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
def test_search_supports_content_phone_owner_and_notes_with_status(self):
ticket = self.claim().json['ticket']
self.update({'version':ticket['version'],'note':'已联系新加坡派送站'})
for keyword in ('派送', '23', 'alice', 'Fixture Staff', '新加坡'):
with self.subTest(keyword=keyword):
result = self.client.get('/api/tickets', query_string={'status':'processing','q':keyword})
self.assertEqual([row['id'] for row in result.json], [self.ticket])
self.assertEqual(self.client.get('/api/tickets?q=派送').json, [])
self.assertEqual(self.client.get('/api/tickets?status=all&q=找不到').json, [])
self.assertEqual(self.client.get('/api/tickets', query_string={'q':'x'*101}).status_code, 400)
def test_search_treats_sql_wildcards_as_literal_and_keeps_pagination(self):
for content in ('含有 100%_\\ 的留言', '普通记录', '相同查询甲', '相同查询乙', '相同查询丙'):
self.client.post('/api/ticket', json={'content':content})
for keyword in ('%', '_', '\\', '100%_\\'):
result = self.client.get('/api/tickets', query_string={'q':keyword}).json
self.assertEqual(len(result), 1)
self.assertIn('100%', result[0]['content'])
first = self.client.get('/api/tickets?q=相同&limit=2').json
second = self.client.get('/api/tickets', query_string={'q':'相同','limit':2,'before':first[-1]['id']}).json
self.assertEqual(len(first), 2)
self.assertEqual(len(second), 1)
self.assertEqual(len({row['id'] for row in first+second}), 3)
def test_summary_counts_all_pending_and_new_arrivals_independently_of_pages(self):
response = self.client.get('/api/tickets/summary')
self.assertEqual(response.status_code, 200)
baseline = response.json
self.assertEqual({key:baseline[key] for key in ('pending_count','latest_id','new_count')},
{'pending_count':1,'latest_id':self.ticket,'new_count':0})
self.claim()
self.client.post('/api/ticket', json={'content':'新留言'})
changed = self.client.get('/api/tickets/summary', query_string={'after':baseline['latest_id']}).json
self.assertEqual(changed['pending_count'], 1)
self.assertEqual(changed['new_count'], 1)
self.assertGreater(changed['latest_id'], baseline['latest_id'])
self.assertEqual(self.client.get('/api/tickets/summary', query_string={'after':changed['latest_id']}).json['new_count'], 0)
for cursor in ('bad', '-1', str(2**63)):
self.assertEqual(self.client.get('/api/tickets/summary',query_string={'after':cursor}).status_code, 400)
self.assertEqual(self.app.test_client().get('/api/tickets/summary').status_code, 401)
def test_history_records_actor_and_old_new_notes_only_for_successful_changes(self):
ticket = self.claim().json['ticket']
saved = self.update({'version':ticket['version'],'note':'原备注'}).json['ticket']
self.assertEqual(self.update({'version':ticket['version'],'note':'旧版本'}).status_code, 409)
self.assertEqual(self.update({'version':saved['version'],'note':'越权'},self.other).status_code, 403)
done = self.update({'version':saved['version'],'note':'完成结果','status':'completed'})
self.assertEqual(done.status_code, 200)
result = self.client.get(f'/api/tickets/{self.ticket}/events')
self.assertEqual(result.status_code, 200)
events = list(reversed(result.json))
self.assertEqual([event['action'] for event in events], ['submitted','claimed','note_updated','note_updated','completed'])
self.assertEqual(events[1]['actor_username'], 'alice')
self.assertEqual((events[3]['old_note'],events[3]['new_note']), ('原备注','完成结果'))
self.assertTrue(all(event['ctime'] for event in events))
self.assertEqual(self.app.test_client().get(f'/api/tickets/{self.ticket}/events').status_code, 401)
self.assertEqual(self.client.get('/api/tickets/9999/events').status_code, 404)
self.assertEqual(self.client.get(f'/api/tickets/{self.ticket}/events?limit=0').status_code, 400)
page = self.client.get(f'/api/tickets/{self.ticket}/events?limit=2').json
older = self.client.get(f'/api/tickets/{self.ticket}/events?limit=2&before={page[-1]["id"]}').json
self.assertEqual(len({event['id'] for event in page+older}),4)
def test_audit_failure_rolls_back_ticket_mutation(self):
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
with self.assertRaisesRegex(RuntimeError,'audit failure'):
self.claim()
self.assertEqual(self.client.get('/api/tickets').json[0]['status'],'pending')
def test_audit_failure_rolls_back_submission_and_note_completion(self):
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
with self.assertRaises(RuntimeError):
self.client.post('/api/ticket',json={'content':'不应留下未审计的记录'})
self.assertEqual(len(self.client.get('/api/tickets').json),1)
ticket = self.claim().json['ticket']
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
with self.assertRaises(RuntimeError):
self.update({'version':ticket['version'],'note':'结果','status':'completed'})
current = self.client.get(f'/api/tickets/{self.ticket}').json['ticket']
self.assertEqual((current['status'],current['note'],current['version']),('processing','',ticket['version']))
def test_overdue_filters_and_unusable_legacy_times_are_safe(self):
for bad_time in ('not a date','2026-02-30 00:00:00'):
with self.app.app_context(), closing(db.connect()) as connection:
connection.execute('UPDATE tickets SET ctime=? WHERE id=?',(bad_time,self.ticket))
connection.commit()
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
self.assertEqual(self.client.get('/api/tickets?overdue=1').json,[])
self.assertEqual(self.client.get('/api/tickets/summary').json['overdue_count'],0)
with self.app.app_context(), closing(db.connect()) as connection:
connection.execute("UPDATE tickets SET ctime='not a date' WHERE id=?",(self.ticket,))
connection.commit()
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
self.assertEqual(self.client.get('/api/tickets?overdue=1').json,[])
self.assertEqual(self.client.get('/api/tickets?overdue=bad').status_code,400)
self.assertEqual(self.client.get(f'/api/tickets/{2**63}/events').status_code,404)
for query in ('limit=101','before=bad',f'before={2**63}'):
self.assertEqual(self.client.get(f'/api/tickets/{self.ticket}/events?'+query).status_code,400)
with self.app.app_context(), closing(db.connect()) as connection:
connection.execute("UPDATE tickets SET ctime='2026-01-01 00:00:00'")
connection.commit()
self.assertEqual(len(self.client.get('/api/tickets?overdue=1&q=派送').json),1)
self.assertEqual(self.client.get('/api/tickets?overdue=1&q=找不到').json,[])
self.assertEqual(self.client.get('/api/tickets?status=completed&overdue=1').json,[])
def test_overdue_boundaries_and_note_edits_do_not_reset_claim_clock(self):
with self.app.app_context(), closing(db.connect()) as connection:
connection.execute("UPDATE tickets SET ctime='2026-11-02 09:00:00' WHERE id=?",(self.ticket,))
connection.commit()
with patch('logistics_chat.ticket_service.datetime') as clock:
clock.now.return_value=datetime(2026,11,2,9,30)
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
clock.now.return_value=datetime(2026,11,2,9,31)
row=self.client.get('/api/tickets').json[0]
self.assertEqual((row['overdue_kind'],row['overdue_minutes']),('pending',1))
claimed=self.claim().json['ticket']
self.assertEqual(claimed['claimed_at'],'2026-11-02 09:31:00')
clock.now.return_value=datetime(2026,11,2,9,51)
self.assertFalse(self.client.get('/api/tickets?status=processing').json[0]['overdue_kind'])
clock.now.return_value=datetime(2026,11,2,9,52)
saved=self.update({'version':claimed['version'],'note':'仍在跟进'}).json['ticket']
self.assertEqual(saved['overdue_kind'],'processing')
summary=self.client.get('/api/tickets/summary').json
self.assertEqual((summary['overdue_count'],summary['processing_overdue_count']), (1,1))
self.assertEqual(len(self.client.get('/api/tickets?status=all&overdue=1').json),1)
done=self.update({'version':saved['version'],'status':'completed'}).json['ticket']
self.assertFalse(done['overdue_kind'])
self.assertEqual(self.client.get('/api/tickets/summary').json['overdue_count'],0)
def test_v2_upgrade_marks_legacy_baseline_without_inventing_history(self):
ticket=self.claim().json['ticket']
with self.app.app_context(), closing(db.connect()) as connection:
connection.execute('DROP TABLE ticket_events')
connection.execute('DROP INDEX IF EXISTS idx_ticket_claimed_time')
connection.execute('DROP INDEX IF EXISTS idx_ticket_pending_time')
connection.execute('ALTER TABLE tickets DROP COLUMN claimed_at')
connection.execute('ALTER TABLE tickets DROP COLUMN claimed_at_estimated')
connection.execute("UPDATE tickets SET updated_at='2026-11-02 08:00:00',note='旧备注'")
connection.execute("UPDATE schema_meta SET value='2' WHERE key='schema_version'")
connection.commit()
db.init_db()
db.init_db()
events=self.client.get(f'/api/tickets/{self.ticket}/events').json
self.assertEqual([event['action'] for event in events],['legacy_snapshot'])
self.assertIsNone(events[0]['actor_username'])
row=self.client.get(f'/api/tickets/{self.ticket}').json['ticket']
self.assertEqual((row['owner_username'],row['note']),('alice','旧备注'))
self.assertEqual((row['claimed_at'],row['claimed_at_estimated']),('2026-11-02 08:00:00',1))
+5 -2
View File
@@ -8,6 +8,7 @@ from unittest.mock import patch
from datetime import datetime
from logistics_chat import create_app, db
from staff_fixture import login_staff
class VisitorTests(unittest.TestCase):
@@ -18,6 +19,8 @@ class VisitorTests(unittest.TestCase):
self.app = create_app({'TESTING':True, 'DATABASE':root/'history.db', 'UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
self.a, self.b = self.app.test_client(), self.app.test_client()
self.staff = self.app.test_client()
login_staff(self.app, self.staff)
for client, key in ((self.a,'1'*64),(self.b,'2'*64)):
self.assertEqual(client.post('/api/visitor/session',json={'key':key}).status_code,200)
@@ -96,7 +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.a.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'})
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)
self.assertNotIn('STAFF_PRIVATE',response.get_data(as_text=True))
@@ -115,7 +118,7 @@ class VisitorTests(unittest.TestCase):
root=Path(self.app.config['UPLOAD_DIR'])
(root/'legacy.txt').write_text('LEGACY_PRIVATE')
self.assertEqual(self.a.get('/uploads/legacy.txt').status_code,404)
with self.a.get('/api/staff'+url) as staff:
with self.staff.get('/api/staff'+url) as staff:
self.assertEqual(staff.data,b'A_PRIVATE')
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):