From eebaba8084b830641ec841f6e725d4c6f52a8b46 Mon Sep 17 00:00:00 2001 From: songxinnuo Date: Mon, 2 Nov 2026 17:51:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E4=BB=8A=E5=A4=A9=E7=9A=84?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + AGENTS.md | 7 +- Dockerfile | 2 +- README.md | 26 +- docs/architecture.md | 10 +- docs/database-maintenance.md | 40 ++ docs/deployment-linux.md | 4 +- docs/deployment.md | 6 +- docs/plans/2026-11-02-database-maintenance.md | 16 + docs/plans/2026-11-02-offline-tickets.md | 42 ++ docs/plans/2026-11-02-staff-sessions.md | 17 + logistics_chat/__init__.py | 10 +- logistics_chat/business_service.py | 28 +- logistics_chat/config.py | 1 + logistics_chat/database_backup.py | 169 ++++++++ logistics_chat/db.py | 88 ++-- logistics_chat/hooks.py | 5 +- logistics_chat/human_service.py | 20 +- logistics_chat/routes/admin.py | 17 +- logistics_chat/routes/business.py | 34 +- logistics_chat/staff_service.py | 161 ++++++++ logistics_chat/ticket_service.py | 243 +++++++++++ scripts/db_maintenance.py | 54 +++ static/css/admin.css | 70 +++- static/css/chat.css | 65 +-- static/js/admin.js | 117 +++++- static/js/admin_login.js | 7 +- static/js/admin_tickets.js | 292 ++++++++++++++ static/js/chat.js | 54 ++- static/js/customer.js | 26 +- templates/admin.html | 36 +- templates/index.html | 3 +- tests/frontend/admin.test.cjs | 381 +++++++++++++++++- tests/frontend/chat.test.cjs | 44 +- tests/staff_fixture.py | 18 + tests/test_api.py | 17 + tests/test_database_maintenance.py | 154 +++++++ tests/test_deployment.py | 6 +- tests/test_dev_integration.py | 5 +- tests/test_staff_login.py | 74 ++++ tests/test_staff_sessions.py | 249 ++++++++++++ tests/test_storage.py | 92 +++++ tests/test_tickets.py | 266 ++++++++++++ tests/test_visitor.py | 7 +- 44 files changed, 2831 insertions(+), 154 deletions(-) create mode 100644 docs/database-maintenance.md create mode 100644 docs/plans/2026-11-02-database-maintenance.md create mode 100644 docs/plans/2026-11-02-offline-tickets.md create mode 100644 docs/plans/2026-11-02-staff-sessions.md create mode 100644 logistics_chat/database_backup.py create mode 100644 logistics_chat/staff_service.py create mode 100644 logistics_chat/ticket_service.py create mode 100644 scripts/db_maintenance.py create mode 100644 static/js/admin_tickets.js create mode 100644 tests/staff_fixture.py create mode 100644 tests/test_database_maintenance.py create mode 100644 tests/test_staff_login.py create mode 100644 tests/test_staff_sessions.py create mode 100644 tests/test_tickets.py diff --git a/.env.example b/.env.example index 9603c6a..1a86c32 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 9cd8c6a..486eb35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile index fffb906..d80d996 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 7a8e69e..9c9a6e9 100644 --- a/README.md +++ b/README.md @@ -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/` | 后台查看留言详情、负责人、备注和完成时间 | +| GET | `/api/tickets//events` | 后台处理记录(最新在前),含操作者、时间、备注前后值;`limit=1..100`、`before=事件id` 分页,默认 20 条 | +| POST | `/api/tickets//claim` | 当前登录客服领取待处理留言,需要 CSRF | +| PATCH | `/api/tickets/` | 负责人更新备注或完成;传 `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 开发服务器。公网部署前还需完善鉴权及部署配置。 diff --git a/docs/architecture.md b/docs/architecture.md index ee425c2..e7f5d17 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 小时有效,请求及重启不续期;升级时撤销没有签发时间的旧无限期凭证;退出、密码变化或删除账号使旧会话失效。上游仍只能绑定回环地址;客户登录与游客身份均不能升级为客服权限。 ## 验证与维护 diff --git a/docs/database-maintenance.md b/docs/database-maintenance.md new file mode 100644 index 0000000..64f2406 --- /dev/null +++ b/docs/database-maintenance.md @@ -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 文件系统支持;不支持时备份失败会明确报错,不继续升级。 diff --git a/docs/deployment-linux.md b/docs/deployment-linux.md index 6063a91..e2c8945 100644 --- a/docs/deployment-linux.md +++ b/docs/deployment-linux.md @@ -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 证书 diff --git a/docs/deployment.md b/docs/deployment.md index 66279b9..ec178c3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 端口可达。其他域名的证书不能直接用于新域名。 diff --git a/docs/plans/2026-11-02-database-maintenance.md b/docs/plans/2026-11-02-database-maintenance.md new file mode 100644 index 0000000..acfd0b0 --- /dev/null +++ b/docs/plans/2026-11-02-database-maintenance.md @@ -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 日期保留边界均已补测试修正。 diff --git a/docs/plans/2026-11-02-offline-tickets.md b/docs/plans/2026-11-02-offline-tickets.md new file mode 100644 index 0000000..8baa370 --- /dev/null +++ b/docs/plans/2026-11-02-offline-tickets.md @@ -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。 diff --git a/docs/plans/2026-11-02-staff-sessions.md b/docs/plans/2026-11-02-staff-sessions.md new file mode 100644 index 0000000..440f8ec --- /dev/null +++ b/docs/plans/2026-11-02-staff-sessions.md @@ -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 秒;后台请求和服务重启均不续期。旧无限期会话无法确定签发时间,升级时撤销并要求重新登录;有效的有限期会话保留原期限,已失效会话不恢复。退出、改密及删除账号继续即时撤销。 diff --git a/logistics_chat/__init__.py b/logistics_chat/__init__.py index 3c5c024..6003be0 100644 --- a/logistics_chat/__init__.py +++ b/logistics_chat/__init__.py @@ -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 diff --git a/logistics_chat/business_service.py b/logistics_chat/business_service.py index 5fab7f8..b6f205f 100644 --- a/logistics_chat/business_service.py +++ b/logistics_chat/business_service.py @@ -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(): diff --git a/logistics_chat/config.py b/logistics_chat/config.py index 6287c4a..f2460aa 100644 --- a/logistics_chat/config.py +++ b/logistics_chat/config.py @@ -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(), diff --git a/logistics_chat/database_backup.py b/logistics_chat/database_backup.py new file mode 100644 index 0000000..1b2881f --- /dev/null +++ b/logistics_chat/database_backup.py @@ -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) diff --git a/logistics_chat/db.py b/logistics_chat/db.py index 77f15a7..d45fb7f 100644 --- a/logistics_chat/db.py +++ b/logistics_chat/db.py @@ -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(): diff --git a/logistics_chat/hooks.py b/logistics_chat/hooks.py index 1530daf..47740e1 100644 --- a/logistics_chat/hooks.py +++ b/logistics_chat/hooks.py @@ -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 diff --git a/logistics_chat/human_service.py b/logistics_chat/human_service.py index 3be083b..c6ee251 100644 --- a/logistics_chat/human_service.py +++ b/logistics_chat/human_service.py @@ -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(): diff --git a/logistics_chat/routes/admin.py b/logistics_chat/routes/admin.py index 253aef1..603f5ae 100644 --- a/logistics_chat/routes/admin.py +++ b/logistics_chat/routes/admin.py @@ -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') diff --git a/logistics_chat/routes/business.py b/logistics_chat/routes/business.py index 7607104..b23ed18 100644 --- a/logistics_chat/routes/business.py +++ b/logistics_chat/routes/business.py @@ -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/') +def get_ticket(ticket_id): + return ticket_service.get_ticket(ticket_id) + + +@bp.get('/api/tickets//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//claim') +def claim_ticket(ticket_id): + return ticket_service.claim_ticket(ticket_id, g.staff) + + +@bp.patch('/api/tickets/') +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']) diff --git a/logistics_chat/staff_service.py b/logistics_chat/staff_service.py new file mode 100644 index 0000000..0d8ffe2 --- /dev/null +++ b/logistics_chat/staff_service.py @@ -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 diff --git a/logistics_chat/ticket_service.py b/logistics_chat/ticket_service.py new file mode 100644 index 0000000..59e80e3 --- /dev/null +++ b/logistics_chat/ticket_service.py @@ -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 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?),' + "(SELECT COUNT(*) FROM tickets WHERE status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime 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} diff --git a/scripts/db_maintenance.py b/scripts/db_maintenance.py new file mode 100644 index 0000000..a893927 --- /dev/null +++ b/scripts/db_maintenance.py @@ -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()) diff --git a/static/css/admin.css b/static/css/admin.css index f4e8fde..9f8dba9 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -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; } diff --git a/static/css/chat.css b/static/css/chat.css index 3cd5484..7879479 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -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; } diff --git a/static/js/admin.js b/static/js/admin.js index 202a466..281c12c 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -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 '
对话 #'+sid+''+escapeText(item.ctime)+'
'; + const age = Number(item.waiting_minutes) || 0; + const wait = item.overdue + ? '排队超时 '+escapeText(item.overdue_minutes)+' 分钟' + : '已等待 '+escapeText(age)+' 分钟'; + return '
对话 #'+sid+wait+''+escapeText(item.ctime)+'
'; }).join("") : '
'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'
'; } @@ -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 '
'+escapeText(ticket.content)+''+escapeText(ticket.ctime)+'
'; - }).join("") : '
暂时没有客户留言
'; - }catch(error){ - element.innerHTML = '
留言暂时无法加载
'; - } + 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)]; diff --git a/static/js/admin_login.js b/static/js/admin_login.js index c0c269a..d9812b3 100644 --- a/static/js/admin_login.js +++ b/static/js/admin_login.js @@ -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{ diff --git a/static/js/admin_tickets.js b/static/js/admin_tickets.js new file mode 100644 index 0000000..6b63897 --- /dev/null +++ b/static/js/admin_tickets.js @@ -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 '
  • '+options.escape(eventLabels[event.action] || event.action)+' · '+options.escape(event.actor_name || "系统")+' · '+options.escape(event.ctime || "")+''+ + (detail ? ''+options.escape(detail)+'' : '')+'
  • '; + }).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 => + '' + ).join("") : '

    '+(activeQuery ? "没有匹配的留言,请更换关键词或状态" : "该状态下暂无留言")+'

    '; + 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); diff --git a/static/js/chat.js b/static/js/chat.js index 60ba6b3..695d396 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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'); } diff --git a/static/js/customer.js b/static/js/customer.js index 1046a30..0084dab 100644 --- a/static/js/customer.js +++ b/static/js/customer.js @@ -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(); diff --git a/templates/admin.html b/templates/admin.html index 268f834..13db223 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -2,6 +2,8 @@ + + 客服工作台 · 柏悦物流 @@ -16,6 +18,8 @@
    + + 客户入口 ↗
    @@ -31,9 +35,12 @@ 导出 Excel ↗
    + + +

    搜索历史记录

    请输入关键词搜索历史消息
    + diff --git a/templates/index.html b/templates/index.html index 94e3d38..cfbaffb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,7 +21,7 @@
    @@ -31,6 +31,7 @@
    BAIYUE · CUSTOMER SUPPORT
    柏悦物流智能助手
    + 智能客服 diff --git a/tests/frontend/admin.test.cjs b/tests/frontend/admin.test.cjs index f7014d8..6b4639d 100644 --- a/tests/frontend/admin.test.cjs +++ b/tests/frontend/admin.test.cjs @@ -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:'',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,/ { + 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:'',ctime:'today',old_note:'旧备注',new_note:''})));}; + await app.context.loadTickets(); await flush(); + assert.match(app.element('ticket-history-list').innerHTML,/<script>/); + assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/