Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eebaba8084 |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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 开发服务器。公网部署前还需完善鉴权及部署配置。
|
||||
|
||||
@@ -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 小时有效,请求及重启不续期;升级时撤销没有签发时间的旧无限期凭证;退出、密码变化或删除账号使旧会话失效。上游仍只能绑定回环地址;客户登录与游客身份均不能升级为客服权限。
|
||||
|
||||
## 验证与维护
|
||||
|
||||
|
||||
@@ -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 文件系统支持;不支持时备份失败会明确报错,不继续升级。
|
||||
@@ -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
@@ -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 日期保留边界均已补测试修正。
|
||||
@@ -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。
|
||||
@@ -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 秒;后台请求和服务重启均不续期。旧无限期会话无法确定签发时间,升级时撤销并要求重新登录;有效的有限期会话保留原期限,已失效会话不恢复。退出、改密及删除账号继续即时撤销。
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)];
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,/<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,/<script>/);
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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')})
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
|
||||
@@ -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))
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user