Compare commits

...
Author SHA1 Message Date
ouyangxq 880a8fb57c 接入真实美国通用价表并直接回复报价 2026-11-05 11:23:52 +08:00
ouyangxq a9404f01e2 优化入仓码查询入口与卡片交互 2026-11-04 19:37:17 +08:00
ouyangxq f17f355366 支持登录后按客户范围统计运单数量 2026-11-04 19:09:17 +08:00
ouyangxq 1a82894b6a 放行镜像测试使用的 Dockerfile 2026-11-04 18:49:52 +08:00
ouyangxq 6181760045 补齐代理回归所需的镜像测试资料 2026-11-04 18:48:55 +08:00
ouyangxq 80bb27515c 修复反向代理后客户共用限流与登录预算 2026-11-04 18:41:50 +08:00
ouyangxq af7b56b14b 区分订单状态追问与查货操作说明 2026-11-04 18:25:16 +08:00
ouyangxq a80895b75f 修复查货识别与登录过期后的会话恢复 2026-11-04 18:21:07 +08:00
ouyangxq 04f94ad665 以入仓码作为查货入口和轨迹卡片主号码 2026-10-30 19:10:22 +08:00
ouyangxq fd1ec32dd6 修复英国空运问题被通用空运关键词覆盖 2026-10-30 18:50:00 +08:00
ouyangxq 9d07793a57 合并远程知识库更新并集成客户登录 2026-10-30 18:47:57 +08:00
ouyangxq d26f6ecfd3 关联默认客户账号与当前本地订单范围 2026-10-30 18:46:03 +08:00
ouyangxq fef233344b 补齐客户登录输入框中英文占位文字 2026-10-30 18:35:52 +08:00
ouyangxq 783f930380 接入客户登录和订单会话权限隔离 2026-10-30 18:27:40 +08:00
songxinnuo 888f323b23 补充物流知识库问答和关键词 2026-10-30 17:21:11 +08:00
ouyangxq 689d55bcf9 精简聊天页面说明并静默恢复草稿 2026-10-30 16:04:40 +08:00
ouyangxq eba3a1b562 修复草稿存储失败提醒被恢复状态遮挡 2026-10-30 15:40:25 +08:00
ouyangxq b764fcb33c 完善会话草稿恢复与附件保留,统一人工服务时区 2026-10-30 15:32:31 +08:00
ouyangxq 78797d8eca 修复评价统计、留言校验和聊天操作反馈 2026-10-30 14:38:25 +08:00
ouyangxq b8f6ca586d 统一手机输入区控件高度并对齐快捷与语音按钮 2026-10-30 13:43:51 +08:00
ouyangxq 767fca6a79 缩短手机输入提示并避免空输入框提示换行 2026-10-30 13:35:42 +08:00
61 changed files with 3638 additions and 282 deletions
+1
View File
@@ -1,4 +1,5 @@
** **
!Dockerfile
!requirements.txt !requirements.txt
!am.py !am.py
!rates_extra.py !rates_extra.py
+9
View File
@@ -5,6 +5,15 @@
DEEPSEEK_API_KEY= DEEPSEEK_API_KEY=
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows. # Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
ORDER_DATA_TENANT_ID= ORDER_DATA_TENANT_ID=
# Default front-end customer account: admin / 123456. It grants no staff access.
CUSTOMER_DEMO_ENABLED=true
CUSTOMER_DEMO_USERNAME=admin
# Bind one explicit tenant, or leave empty to use the current local dataset grants.
CUSTOMER_DEMO_TENANT_ID=
# On initial association, grant the default account the tenants currently in the local table.
CUSTOMER_DEMO_LOCAL_ORDERS=true
# Optional password hash used only when creating the default account; not plaintext.
# CUSTOMER_DEMO_PASSWORD_HASH=
# Short-recording ASR is independent of DEEPSEEK_API_KEY. No credentials = voice input unavailable. # Short-recording ASR is independent of DEEPSEEK_API_KEY. No credentials = voice input unavailable.
ASR_PROVIDER=baidu ASR_PROVIDER=baidu
BAIDU_ASR_API_KEY= BAIDU_ASR_API_KEY=
+7 -3
View File
@@ -9,10 +9,14 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` | | AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` | | 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 | | 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 |
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
| 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` | | 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` | | Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
| US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` |
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` | | 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` | | Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.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` | | Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.py` |
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` | | Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
@@ -54,7 +58,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`. Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, and `visitor.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests. Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
@@ -62,4 +66,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
## Security & Configuration Tips ## 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`; business tables start empty. 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 their chat history, never customer orders; customer login/linking remains pending. 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 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.
+3 -2
View File
@@ -22,14 +22,15 @@ COPY logistics_chat/ ./logistics_chat/
COPY templates/ ./templates/ COPY templates/ ./templates/
COPY static/ ./static/ COPY static/ ./static/
COPY deploy/__init__.py deploy/wsgi.py ./deploy/ COPY deploy/__init__.py deploy/wsgi.py ./deploy/
COPY scripts/import_order_test_data.py ./scripts/ COPY scripts/import_order_test_data.py scripts/import_quote_data.py ./scripts/
USER 10001:10001 USER 10001:10001
EXPOSE 5000 EXPOSE 5000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \ HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=2).close()"] CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=2).close()"]
CMD ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--threads=8", "--channel-timeout=300", "--max-request-body-size=12582912", "--url-scheme=https", "--call", "deploy.wsgi:create_app"] CMD ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--threads=8", "--channel-timeout=300", "--max-request-body-size=12582912", "--url-scheme=https", "--trusted-proxy=*", "--trusted-proxy-count=1", "--trusted-proxy-headers=x-forwarded-for", "--call", "deploy.wsgi:create_app"]
FROM runtime AS test FROM runtime AS test
COPY Dockerfile ./
COPY deploy/ ./deploy/ COPY deploy/ ./deploy/
COPY tests/ ./tests/ COPY tests/ ./tests/
COPY scripts/ ./scripts/ COPY scripts/ ./scripts/
Vendored
+10
View File
@@ -26,6 +26,9 @@ pipeline {
if (!((params.DEBIAN_MIRROR ?: 'https://deb.debian.org') in ['https://deb.debian.org', 'https://mirrors.tuna.tsinghua.edu.cn'])) { if (!((params.DEBIAN_MIRROR ?: 'https://deb.debian.org') in ['https://deb.debian.org', 'https://mirrors.tuna.tsinghua.edu.cn'])) {
error('Select a supported Debian mirror') error('Select a supported Debian mirror')
} }
if (params.IMPORT_QUOTE_DATA && !(params.QUOTE_SNAPSHOT_CREDENTIAL_ID ==~ /[A-Za-z0-9_.-]+/)) {
error('Select the explicitly supplied private quote snapshot credential')
}
} }
sh '''set -eu sh '''set -eu
for file in deploy/*.sh; do bash -n "$file"; done for file in deploy/*.sh; do bash -n "$file"; done
@@ -70,6 +73,13 @@ pipeline {
]) { ]) {
sh 'bash deploy/publish-gray.sh' sh 'bash deploy/publish-gray.sh'
script { script {
if (params.IMPORT_QUOTE_DATA) {
withCredentials([
file(credentialsId: params.QUOTE_SNAPSHOT_CREDENTIAL_ID, variable: 'QUOTE_SNAPSHOT_FILE')
]) {
sh 'bash deploy/import-gray-quotes.sh'
}
}
if (params.IMPORT_ORDER_SAMPLES) { if (params.IMPORT_ORDER_SAMPLES) {
withCredentials([ withCredentials([
file(credentialsId: params.ORDER_SNAPSHOT_CREDENTIAL_ID, variable: 'ORDER_SNAPSHOT_FILE'), file(credentialsId: params.ORDER_SNAPSHOT_CREDENTIAL_ID, variable: 'ORDER_SNAPSHOT_FILE'),
+29 -8
View File
@@ -1,6 +1,6 @@
# 柏悦智能物流客服系统 # 柏悦智能物流客服系统
基于 Flask + DeepSeek 的中欧/英国跨境物流智能客服,支持多轮对话、运费试算、运单查询、历史会话管理。 基于 Flask + DeepSeek 的跨境物流智能客服,支持美国通用报价、中欧/英国运费试算、多轮对话、运单查询、历史会话管理。
## 功能 ## 功能
@@ -8,7 +8,7 @@
- **知识库优先**:优先使用本地知识库回答,减少模型编造 - **知识库优先**:优先使用本地知识库回答,减少模型编造
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索 - **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细 - **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
- **订单查询**:按订单号、入仓码、Shipment ID 查询本地订单测试表;展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹 - **订单查询**:直接输入入仓码查询订单与已有轨迹,卡片主显示入仓码;兼容订单号与 Shipment ID,展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除 - **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部 - **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L` - **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
@@ -22,9 +22,20 @@
## 报价数据 ## 报价数据
美国通用报价通过 `scripts/import_quote_data.py` 将本地 Excel 明细导出为私有快照,再导入运行 SQLite;不读取「快速查询」的外部工作簿,不按文件日期或旧船期停用。原始文件、价格快照及运行数据库均不进入 Git 或镜像。
`quote_books` 保存版本,`quote_rates` 保存线路/交货仓/目的仓或分区/税费方式/重量档位/计费单位,`quote_warehouses` 保留仓码的多种分区,`quote_postcodes` 保存带前导零的邮编及偏远类别,`quote_documents` 保存当前资料中的业务说明。每条价格保留源工作表和单元格。指定 `--activate` 才原子切换美国报价版本,导入不修改订单和客户范围。
```powershell
python scripts/import_quote_data.py --xlsx <本地报价.xlsx> --output <新私有快照.json>
python scripts/import_quote_data.py --snapshot <私有快照.json> --db <运行数据库.db> --activate
```
美国报价先由本地规则计算并通过既有 SSE 返回,不等待模型调用。仅给「美国30KG」可先看到不同海派、交货仓和美西/美中/美东的基础价格;提供仓码、邮编、箱数和尺寸后在当前会话继续核算。同一仓码有多种分区时并列展示;更换目的仓或邮编后不重用旧目的地。体积、附加费和承运限制尚不明确时继续补充条件,不编造缺失费率。现有欧洲/英国报价使用下述原有来源。
代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位: 代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位:
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。 报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。美国使用已激活的本地价表;未导入美国价表或其他未覆盖目的地时补充条件或由人工确认。英国按方报价不套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 | | 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|---|---|---| |---|---|---|
@@ -246,25 +257,35 @@ data: {"d": 1}
## 数据库与运行数据 ## 数据库与运行数据
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。匿名聊天不读取订单表;客户登录及关联将在后续接入,届时查询必须使用服务端验证的客户范围。`ORDER_DATA_TENANT_ID` 只是额外限制,不能代替客户身份。 订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。游客咨询无需登录,查询订单时引导客户登录;查询按服务端账号关联的客户范围过滤。`ORDER_DATA_TENANT_ID` 是额外限制,不能代替客户身份。
默认前台客户账号为 `admin / 123456`,与受 Nginx 保护的客服后台独立。默认按当前本地数据建立客户范围关联:单客户写入 `customer_users.tenant_id`,多客户写入 `customer_tenant_grants`,查询始终按服务端白名单过滤。首次关联完成后不自动加入新客户范围,同一客户下新增订单可直接查询。`CUSTOMER_DEMO_TENANT_ID` 可指定单个范围;`CUSTOMER_DEMO_LOCAL_ORDERS=false` 关闭多客户自动关联。账号保存在 `customer_users`,密码为 PBKDF2 哈希;已有账号启动时不覆盖,可直接维护哈希、`tenant_id`、`enabled` 及客户范围关联表。`CUSTOMER_DEMO_ENABLED=false` 禁用默认账号及其已有登录;真实客户账号不受该开关影响。`CUSTOMER_DEMO_USERNAME` 与 `CUSTOMER_DEMO_PASSWORD_HASH` 用于首次创建默认账号。
登录凭据通过独立 HttpOnly Cookie 及服务端 `customer_sessions` 校验,绑定当前访客,8 小时有效;退出后撤销当前登录。登录后自动继续刚才的订单问题;含订单事实的会话在退出后隐藏,只有相同访客重新登录同一客户和范围后才能恢复。真实用户认证接入时替换账号校验/关联层,继续使用内部客户 ID、可信客户范围和已有登录会话契约;MCP 数据查询仍通过 `lookup_order` 的客户范围过滤。
游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。 游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。
未发送的文字草稿按游客和会话分别保存在当前浏览器的 localStorage,当前标签页通过 sessionStorage 记录上次打开的会话;切换会话和刷新可恢复对应草稿,发送或删除会话后清理。草稿不跨设备同步,不恢复未发送的附件。浏览器禁止存储或空间不足时只在当前页面暂存,并提示刷新前保留文字;清除站点数据会同时删除草稿。历史加载失败期间禁止发送,重新选择会话或新建对话后再继续。
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入: 快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
```powershell ```powershell
.\.venv\Scripts\python.exe scripts/import_order_test_data.py --snapshot "D:\private\orders.snapshot.json" --db "D:\data\history.db" .\.venv\Scripts\python.exe scripts/import_order_test_data.py --snapshot "D:\private\orders.snapshot.json" --db "D:\data\history.db"
``` ```
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order` 的取数实现即可保留聊天契约。 脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order`、`count_orders` 的取数实现即可保留聊天契约。
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders`、`tickets` 和 `order_test_data` 十张表,并兼容已有数据,不预置账号或订单数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。 登录后可以直接问“我有多少个运单”“我的订单数量”“已完成的订单有多少”。统计每次按当前账号的授权范围查表,返回订单票数和其中已登记入仓码的票数;不要求先提供入仓码,也不沿用上一票订单作为统计范围。支持订单总量和明确的业务状态;其他筛选条件不冒充总量返回,头程订单完成不等于签收。统计会话与订单卡片一样绑定客户和范围,退出后隐藏,重新登录同一身份及范围后恢复。
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。 点击“查入仓码”会打开查询框,填写号码后查询;游客先登录。回复及历史卡片中的入仓码也可点击,自动带入号码,可复制或查询最新轨迹。查询结果接在当前聊天中,草稿和待发送附件保留,人工接待中需要先返回 AI 问答。
首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
## 当前限制 ## 当前限制
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史中的模拟轨迹保留演示标识。 - 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史卡片继续兼容展示。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。 - 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。 - 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。 - 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
+1 -1
View File
@@ -10,7 +10,7 @@ Group=baiyue
WorkingDirectory=/opt/baiyue-logistics-chat WorkingDirectory=/opt/baiyue-logistics-chat
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1 Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/opt/baiyue-logistics-chat/.venv/bin/python -m waitress --listen=127.0.0.1:5000 --threads=8 --channel-timeout=300 --max-request-body-size=12582912 --url-scheme=https --call deploy.wsgi:create_app ExecStart=/opt/baiyue-logistics-chat/.venv/bin/python -m waitress --listen=127.0.0.1:5000 --threads=8 --channel-timeout=300 --max-request-body-size=12582912 --url-scheme=https --trusted-proxy=127.0.0.1 --trusted-proxy-count=1 --trusted-proxy-headers=x-forwarded-for --call deploy.wsgi:create_app
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
TimeoutStopSec=90 TimeoutStopSec=90
+1 -1
View File
@@ -119,7 +119,7 @@ server {{
auth_basic off; auth_basic off;
proxy_pass http://127.0.0.1:{self.port}; proxy_pass http://127.0.0.1:{self.port};
}} }}
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{ location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{
auth_basic off; auth_basic off;
proxy_pass http://127.0.0.1:{self.port}; proxy_pass http://127.0.0.1:{self.port};
}} }}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
umask 077
: "${QUOTE_SNAPSHOT_FILE:?}"
: "${TARGET_SSH_KEY:?}" "${TARGET_SSH_USER:?}" "${TARGET_SSH_HOST:?}" "${TARGET_SSH_KNOWN_HOSTS:?}"
: "${GRAY_DEPLOY_DIR:?}" "${SOURCE_COMMIT:?}" "${BUILD_NUMBER:?}"
[[ "$TARGET_SSH_USER" =~ ^[A-Za-z0-9_.-]+$ && "$TARGET_SSH_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || exit 2
[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ && "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]] || exit 2
root=$GRAY_DEPLOY_DIR
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
release_id="${SOURCE_COMMIT:0:12}-b$BUILD_NUMBER"
release="$root/releases/$release_id"
quote_sha=$(sha256sum "$QUOTE_SNAPSHOT_FILE" | awk '{print $1}')
ssh_options=(-i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes
-o "UserKnownHostsFile=$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12)
target="$TARGET_SSH_USER@$TARGET_SSH_HOST"
scp "${ssh_options[@]}" "$QUOTE_SNAPSHOT_FILE" "$target:$release/quote-input.json"
ssh "${ssh_options[@]}" "$target" bash -s -- "$root" "$release_id" "$SOURCE_COMMIT" "$quote_sha" <<'IMPORT'
set -Eeuo pipefail
umask 077
root=$1
release_id=$2
source_commit=$3
quote_sha=$4
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release_id" =~ ^[a-f0-9]{12}-b[0-9]+$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
[[ "$quote_sha" =~ ^[a-f0-9]{64}$ ]] || exit 2
release="$root/releases/$release_id"
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
input="$root/data/imports/quotes-$release_id"
owned_input=0
cleanup() {
rm -f -- "$release/quote-input.json"
if [[ "$owned_input" == 1 ]]; then
rm -f -- "$input/quotes.json"
rmdir -- "$input" 2>/dev/null || true
fi
}
trap cleanup EXIT
[[ "$(cat "$root/current-release")" == "$release" ]] || exit 2
container=baiyue-logistics-chat-gray
[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container")" == baiyue-logistics-chat-gray ]] || exit 2
[[ "$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container")" == "$source_commit" ]] || exit 2
printf '%s %s\n' "$quote_sha" "$release/quote-input.json" | sha256sum --check --status
[[ ! -e "$input" ]] || { echo 'Existing quote import input requires inspection.' >&2; exit 2; }
install -d -m 700 -o 10001 -g 10001 "$root/data/imports" "$input"
owned_input=1
install -m 600 -o 10001 -g 10001 "$release/quote-input.json" "$input/quotes.json"
docker exec "$container" python -c 'import pathlib,sqlite3,sys; p=pathlib.Path("/var/lib/baiyue-logistics-chat/backups")/("pre-quote-import-"+sys.argv[1]+".db"); p.parent.mkdir(exist_ok=True); assert not p.exists(); source=sqlite3.connect("/var/lib/baiyue-logistics-chat/history.db"); target=sqlite3.connect(str(p)); source.backup(target); target.close(); source.close(); print("Chat SQLite backup completed before quote import.")' "$release_id"
docker exec "$container" python scripts/import_quote_data.py \
--db /var/lib/baiyue-logistics-chat/history.db --snapshot "/var/lib/baiyue-logistics-chat/imports/quotes-$release_id/quotes.json" --activate
echo 'General quote version activated; order data and customer grants preserved.'
IMPORT
+1 -1
View File
@@ -47,7 +47,7 @@ server {
location = / { auth_basic off; proxy_pass http://127.0.0.1:5000; } location = / { auth_basic off; proxy_pass http://127.0.0.1:5000; }
location ^~ /static/ { auth_basic off; proxy_pass http://127.0.0.1:5000; } location ^~ /static/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
location ^~ /uploads/ { auth_basic off; proxy_pass http://127.0.0.1:5000; } location ^~ /uploads/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ { location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {
auth_basic off; auth_basic off;
proxy_pass http://127.0.0.1:5000; proxy_pass http://127.0.0.1:5000;
} }
+20 -5
View File
@@ -26,8 +26,9 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 历史接口 | `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/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/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` 为唯一扩展数据源,根目录同名文件仅兼容转发 | | 运费与附加费 | `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` 为唯一扩展数据源,根目录同名文件仅兼容转发 |
| 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史模拟轨迹标为演示数据 | | 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史卡片保留兼容渲染 |
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL | | 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`count_orders`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`、`count_orders`,不让模型拼 SQL |
| 查单交互 | `static/js/order_lookup.js`:`OrderLookup.create`;`chat.js`:`showOrderLookup`、`fillTrackCard`;`support.js`:`copyText` | 快捷入口及当前/历史卡片共用查询框;显式输入后才查单,保留聊天草稿和附件;复制共用安全回退 |
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` | | AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 | | 人工会话 | `logistics_chat/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/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
@@ -87,7 +88,11 @@ tests/ # 按业务模块组织的回归
- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。 - 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。 - 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。 - 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
- 客户界面不附加测试/演示声明、底部免责声明、键盘说明、快捷入口副标题或附件准备说明;草稿恢复和发送成功静默处理。加载、录音、人工排队状态及操作失败反馈保留,权限校验继续由服务端执行。
- 客户查货以入仓码为主要入口,输入占位、快捷入口与查询追问均优先使用入仓码;订单卡片从 `fields.inbound` 取得主显示号码,内部订单号放在展开详情。`card.no` 仍保存内部订单号以兼容历史协议和上下文关联;未提供入仓码时保留准确的订单号标签,不生成替代入仓码。
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。 - 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
- 客户文字草稿由 `drafts.js` 的 `ChatDrafts.create` 管理,脚本在 `visitor.js` 之后、`chat.js` 之前加载;localStorage 按访客 key 和会话分条存储,sessionStorage 仅记录当前标签页的活动会话。切换前同步保存,输入防抖及页面隐藏时保存;无持久存储时使用页面内存并提示。恢复历史的异步结果受会话版本约束,加载失败禁止继续发送,删除会话同步清除其草稿。草稿不含未发送附件,不跨设备同步。
- 人工申请由服务端以 UTC+8 判断每日 08:00–22:00 营业状态,客户手机时区不参与判断。附件清理保留 `messages` 和 `human_messages` 引用的文件,仅删除超过 7 天的未引用临时文件;读取引用失败时停止清理。附件不存在或不属于当前游客均返回相同的私有、不可缓存 404 页面,破图在聊天中显示可读提示。
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。 - 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。 - 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
@@ -107,21 +112,31 @@ tests/ # 按业务模块组织的回归
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。 - SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。 - `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。 - `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。客户身份尚未接入,匿名 HTTP 查单在读取订单前返回账号关联提示;`g.customer_tenant_id` 只允许后续可信认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。 - 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。匿名 HTTP 查单在读取订单前返回登录引导;`g.customer_tenant_id` 由可信客户认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
- 登录后的订单数量查询由 `parse_count_query` 识别,`count_orders` 使用与单号查询相同的 `tenant_filter` 交集规则,必须显式提供非空客户范围;SQL 直接计算订单票数和已登记入仓码的订单票数,按需过滤明确的业务状态。无额外筛选时统计授权范围全部订单,不复用上一票单号;无法识别的附加条件不静默丢弃,也不交给模型猜数。成功统计返回的内部 `_customer_data` 标记由 `chat_service` 消费并移除,使纯文本统计同样在保存/发送前绑定会话客户和范围;不增加公开 SSE 字段或管理接口。
- 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。登录过期导致界面清空时,只允许同一客户重新通过历史归属校验后恢复原会话并续查;换账号、访问范围变化、历史加载失败或切换话题均不重放旧问题。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出立即清除当前可见订单内容和历史标题缓存,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
- 底部及欢迎卡片的查入仓码入口先确认客户登录,再打开 `OrderLookup`,不自动新建会话或发送缺少号码的问题。当前及历史订单卡片的号码也打开该查询框,保留真实的入仓码/订单号标签,支持单独复制和重新查询。提交使用既有 `/stream_chat` 并显示明确的用户问句,结果追加到当前会话;主输入框草稿及已上传附件不清空、不代发。人工接待、未加载历史或其他请求进行中时不抢占路由。切换话题/身份关闭弹窗,迟到的登录/状态/复制结果不得改动新上下文;弹窗跟随手机可视视口并将焦点还给触发按钮。
- 节点导出:`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 接入仍返回同一事实结构,无需改变卡片协议。 - 节点导出:`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 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。 - 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。 - 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。 - 新建会话保存一条 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` 同步维护。 - 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。美国使用已激活的本地通用价表;未导入美国价表或其他未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。 - 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。 - 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- 手机语音由 `voice.js` 管理按住、松开、上滑取消、60 秒上限及媒体轨道释放;切换会话、离开页面或权限/转写超时取消录音与待处理请求,迟到结果不能进入新会话,已有草稿不自动提交。`/api/speech/config` 仅返回可用性及限制,`/api/speech/transcribe` 有独立 5MB 请求限制并以 `MemoryAudioRequest` 将音频文件保持在内存中;音频不进入 uploads 或聊天历史。百度适配通过 FFmpeg 管道转换 16 kHz 单声道 PCM,校验真实解码长度后调用百度短语音识别,Token 按应用隔离缓存。可选 DashScope 适配和 DS 文字聊天凭据独立,密钥及上游原始错误不得返回客户端。识别结果走既有 AI/人工消息入口。H5 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。 - 手机语音由 `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 表,不预置账号或演示数据。 - `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 与附件禁止共享缓存。 - 游客由 `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 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录独立校验,游客身份不能升级为订单权限。
## 验证与维护 ## 验证与维护
美国报价由 `quote_workbook.py` 导出当前 Excel 本地明细;`quote_data.py` 管理版本与 SQLite;`quote_engine.py` 负责纯匹配/Decimal 计价;`quote_service.py` 续接当前会话并通过 `answer_quote` 返回既有 `t`/`d` 事件。分流位置在订单权限检查之后、旧报价/模型分流之前。
新建库不预置价格;私有快照事务导入、显式激活,读取使用同一事务固定版本,后续查询读取更新后的价格。不访问外部工作簿,不按文件日期或旧船期停用。仓码重复分区并列;邮编存文本;上下文仅来自当前访客拥有的同一会话,新的完整问题、其他国家及订单/理赔话题构成边界。回复不包含原始快照、联系人、文件路径或旧船期,不新增公开导入/管理接口。
`quote_documents` 保存当前资料说明,复杂业务说明及尚未实现的附加费仍需逐项扩展匹配规则,不代表已全部自动结算。生产镜像必须显式包含 `scripts/import_quote_data.py`。
先按 README 安装项目依赖。Windows 使用 `.\.venv\Scripts\python.exe -m pytest -v` 或同解释器的 `-m unittest discover -s tests -v`;Linux/macOS 使用 `.venv/bin/python`。业务测试依赖 Flask,不能用未安装依赖的全局 Python 运行整套测试。模型请求全部模拟,不代表真实 DeepSeek 联调。 先按 README 安装项目依赖。Windows 使用 `.\.venv\Scripts\python.exe -m pytest -v` 或同解释器的 `-m unittest discover -s tests -v`;Linux/macOS 使用 `.venv/bin/python`。业务测试依赖 Flask,不能用未安装依赖的全局 Python 运行整套测试。模型请求全部模拟,不代表真实 DeepSeek 联调。
路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。 路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
+7 -1
View File
@@ -2,7 +2,9 @@
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。 现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录与订单关联尚未接入,匿名查单不会读取订单数据。 应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录独立验证账号及订单范围,匿名查单不会读取订单数据。
限流和登录失败次数按客户端 IP 计算。Nginx 必须用 `$remote_addr` 覆盖 `X-Forwarded-For`;Waitress 只信任这一项代理头和最后一跳地址,不接受代理头里的 Host、协议或更早的地址。容器镜像在仅由本机 Nginx 接入、宿主端口保持回环绑定的前提下使用 `--trusted-proxy=*`,以兼容 Docker 网关地址;systemd 入口只信任 `127.0.0.1`。直接运行 `am.py` 不接受客户端自报的代理 IP。代理规则参见 [Waitress 文档](https://docs.pylonsproject.org/projects/waitress/en/stable/reverse-proxy.html)。
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。 容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
@@ -33,6 +35,8 @@
| `ASR_PROVIDER` | `baidu` 启用语音;空值关闭 | | `ASR_PROVIDER` | `baidu` 启用语音;空值关闭 |
| `BAIDU_ASR_API_KEY_CREDENTIAL_ID` / `BAIDU_ASR_SECRET_KEY_CREDENTIAL_ID` | 本服务百度语音专用 Secret text 引用 | | `BAIDU_ASR_API_KEY_CREDENTIAL_ID` / `BAIDU_ASR_SECRET_KEY_CREDENTIAL_ID` | 本服务百度语音专用 Secret text 引用 |
| `IMPORT_ORDER_SAMPLES` | 默认关闭;明确需要补样本时才启用 | | `IMPORT_ORDER_SAMPLES` | 默认关闭;明确需要补样本时才启用 |
| `IMPORT_QUOTE_DATA` | 默认关闭;本次明确导入通用美国报价才启用 |
| `QUOTE_SNAPSHOT_CREDENTIAL_ID` | 默认空;本次私有报价快照的临时 Secret file 引用 |
| `ORDER_SNAPSHOT_CREDENTIAL_ID` / `TRACKING_SNAPSHOT_CREDENTIAL_ID` | 本次导入的临时 Secret file 引用 | | `ORDER_SNAPSHOT_CREDENTIAL_ID` / `TRACKING_SNAPSHOT_CREDENTIAL_ID` | 本次导入的临时 Secret file 引用 |
运行密钥和验收账号保存在私有灰度配置与 Jenkins 凭据库,供后续发布持续复用;轮换时同步更新。不要将密钥放在构建参数、Git URL、镜像 ARG、日志或示例文件中。 运行密钥和验收账号保存在私有灰度配置与 Jenkins 凭据库,供后续发布持续复用;轮换时同步更新。不要将密钥放在构建参数、Git URL、镜像 ARG、日志或示例文件中。
@@ -43,6 +47,8 @@
## 发布步骤 ## 发布步骤
报价导入使用 `IMPORT_QUOTE_DATA` 与临时 Secret file,调用 `deploy/import-gray-quotes.sh`:核对快照 SHA 和运行提交、备份 SQLite,再通过生产镜像中的 `scripts/import_quote_data.py --activate` 事务导入。该开关默认关闭,后续普通发布保留已导入数据;导入不修改订单、轨迹或客户范围。原始 Excel 与快照不进入 Git、Docker 上下文或镜像;导入结束清除临时文件,验收后移除临时 Jenkins 文件凭据。新增参数只追加,不改变既有构建参数及其默认值。
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。 1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。 2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。 3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
+2 -2
View File
@@ -34,11 +34,11 @@
### 人工客服、人工、客服、转人工、在线吗、上班、工作时间、营业时间、几点、周六、周日、星期六、星期日、星期天、礼拜、周末、双休、节假日、放假、休息 ### 人工客服、人工、客服、转人工、在线吗、上班、工作时间、营业时间、几点、周六、周日、星期六、星期日、星期天、礼拜、周末、双休、节假日、放假、休息
人工客服在线时间:**周一至周五 9:00—18:00**。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。 人工客服在线时间:**每天北京时间 8:00—22:00**,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。
### 电话、联系方式、怎么联系、联系你们、微信 ### 电话、联系方式、怎么联系、联系你们、微信
可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。 可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。
--- ---
+5 -1
View File
@@ -5,11 +5,12 @@ def create_app(config=None):
from collections import defaultdict from collections import defaultdict
import logging import logging
from pathlib import Path from pathlib import Path
from threading import Lock
from flask import Flask from flask import Flask
from .config import ROOT_DIR, default_config from .config import ROOT_DIR, default_config
from . import db, file_service, hooks, visitor_service from . import customer_service, db, file_service, hooks, visitor_service
from .routes import register_routes from .routes import register_routes
from .routes.speech import MemoryAudioRequest from .routes.speech import MemoryAudioRequest
@@ -21,8 +22,11 @@ def create_app(config=None):
app.config.update(config) app.config.update(config)
app.extensions['human_typing'] = {} app.extensions['human_typing'] = {}
app.extensions['rate_hits'] = defaultdict(list) app.extensions['rate_hits'] = defaultdict(list)
app.extensions['customer_login_attempts'] = {}
app.extensions['customer_login_lock'] = Lock()
app.before_request(hooks.rate_limit) app.before_request(hooks.rate_limit)
app.before_request(visitor_service.attach_visitor) app.before_request(visitor_service.attach_visitor)
app.before_request(customer_service.attach_customer)
app.after_request(hooks.no_cache_html) app.after_request(hooks.no_cache_html)
app.teardown_appcontext(db.close_connections) app.teardown_appcontext(db.close_connections)
register_routes(app) register_routes(app)
+21 -4
View File
@@ -8,8 +8,17 @@ from .tracking import card_to_text
def feedback(data): def feedback(data):
"""收集 👍/👎 反馈,存到 feedback 表""" """收集 👍/👎 反馈,存到 feedback 表"""
if not isinstance(data, dict):
return {'ok': False, 'error': '评价格式无效。'}, 400
raw_vote = data.get('vote')
votes = {'up': 1, 'down': -1, '1': 1, '-1': -1}
vote = votes.get(raw_vote) if isinstance(raw_vote, str) else raw_vote
question, answer = data.get('question', ''), data.get('answer', '')
if type(vote) is not int or vote not in (-1, 1) or not isinstance(question, str) or not isinstance(answer, str):
return {'ok': False, 'error': '评价内容无效,请重试。'}, 400
conn = connect() conn = connect()
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str())) conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)',
(question[:500], answer[:2000], vote, now_str()))
conn.commit() conn.commit()
conn.close() conn.close()
return {'ok': True} return {'ok': True}
@@ -19,8 +28,8 @@ def admin_stats():
conn = connect() conn = connect()
total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0] total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0]
total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0] total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0]
good = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=1').fetchone()[0] good = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('1', 'up')").fetchone()[0]
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0] bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('-1', 'down')").fetchone()[0]
today = datetime.now().strftime('%Y-%m-%d') today = datetime.now().strftime('%Y-%m-%d')
today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0] today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0]
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone() rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
@@ -39,8 +48,16 @@ def my_orders(phone):
def add_ticket(data): def add_ticket(data):
if not isinstance(data, dict):
return {'ok': False, 'error': '留言格式无效。'}, 400
content, phone = data.get('content'), data.get('phone', '')
if not isinstance(content, str) or not 1 <= len(content.strip()) <= 2000:
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 = connect()
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending')) 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.commit()
conn.close() conn.close()
return {'ok': True} return {'ok': True}
+12
View File
@@ -13,6 +13,7 @@ from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply,
is_shipping_destination_reply) is_shipping_destination_reply)
from .tracking import CARD_PREFIX from .tracking import CARD_PREFIX
from .order_data import answer_query, extract_reference from .order_data import answer_query, extract_reference
from .quote_service import answer_quote
@@ -85,8 +86,13 @@ def create_chat_stream(msg, conv_id):
if conv_id: if conv_id:
from .visitor_service import owns_conversation from .visitor_service import owns_conversation
if not owns_conversation(conv_id): if not owns_conversation(conv_id):
from .customer_service import needs_customer_login, order_login_response
if needs_customer_login(conv_id):
return (sse(order_login_response()) + sse({'d':1}), {})
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {}) return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
order_answer = answer_query(msg, conv_id) order_answer = answer_query(msg, conv_id)
if order_answer is None:
order_answer = answer_quote(msg, conv_id)
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg): if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
order_answer = {'t':'收到您的图片,请描述一下您的问题。'} order_answer = {'t':'收到您的图片,请描述一下您的问题。'}
ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None
@@ -100,8 +106,14 @@ def create_chat_stream(msg, conv_id):
if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']: if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']:
order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'} order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'}
if order_answer is not None: if order_answer is not None:
customer_data = order_answer.pop('_customer_data',False) or 'card' in order_answer
def track_stream(): def track_stream():
if customer_data:
from .customer_service import bind_order_conversation, current_customer_id
if current_customer_id() and not bind_order_conversation(conv_id):
yield sse({'e':'该会话暂时无法访问,请新建对话后重试。','d':1})
return
save_message(conv_id, 'user', msg) save_message(conv_id, 'user', msg)
content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False) content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False)
if 'card' in order_answer else order_answer['t']) if 'card' in order_answer else order_answer['t'])
+6
View File
@@ -29,6 +29,12 @@ def default_config():
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').strip(), 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').strip(),
'ASR_MODEL': str(os.environ.get('ASR_MODEL', local_config.get('ASR_MODEL')) or 'qwen3-asr-flash').strip(), 'ASR_MODEL': str(os.environ.get('ASR_MODEL', local_config.get('ASR_MODEL')) or 'qwen3-asr-flash').strip(),
'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) or '').strip(), 'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) or '').strip(),
'CUSTOMER_DEMO_ENABLED': str(os.environ.get('CUSTOMER_DEMO_ENABLED', local_config.get('CUSTOMER_DEMO_ENABLED', 'true'))).lower() in ('1','true','yes'),
'CUSTOMER_DEMO_USERNAME': str(os.environ.get('CUSTOMER_DEMO_USERNAME', local_config.get('CUSTOMER_DEMO_USERNAME', 'admin')) or '').strip(),
'CUSTOMER_DEMO_PASSWORD_HASH': str(os.environ.get('CUSTOMER_DEMO_PASSWORD_HASH', local_config.get('CUSTOMER_DEMO_PASSWORD_HASH',
'pbkdf2:sha256:600000$vY5fe7GwJQRQbhKV$a4c4eeb96e2bda18bfa6b82d263c0cd61dead2e3f224f694e01fc497152df72f')) or ''),
'CUSTOMER_DEMO_TENANT_ID': str(os.environ.get('CUSTOMER_DEMO_TENANT_ID', local_config.get('CUSTOMER_DEMO_TENANT_ID')) or '').strip(),
'CUSTOMER_DEMO_LOCAL_ORDERS': str(os.environ.get('CUSTOMER_DEMO_LOCAL_ORDERS', local_config.get('CUSTOMER_DEMO_LOCAL_ORDERS', 'true'))).lower() in ('1','true','yes'),
'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions', 'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions',
'MODEL_NAME': 'deepseek-chat', 'MODEL_NAME': 'deepseek-chat',
'MAX_CONTEXT': 4, 'MAX_CONTEXT': 4,
+225
View File
@@ -0,0 +1,225 @@
"""Customer authentication and order scope, independent of staff authentication."""
import hashlib
import json
import secrets
import time
from flask import g, has_app_context, jsonify, request
from werkzeug.security import check_password_hash
from .config import get_setting
from .db import connect
from .visitor_service import valid_key, visitor_id
COOKIE_NAME = 'baiyue_customer'
SESSION_SECONDS = 8 * 3600
def demo_tenant(connection):
configured = get_setting('CUSTOMER_DEMO_TENANT_ID') or get_setting('ORDER_DATA_TENANT_ID')
if configured:
return configured
rows = connection.execute("SELECT DISTINCT tenant_id FROM order_test_data WHERE tenant_id<>'' LIMIT 2").fetchall()
return rows[0][0] if len(rows) == 1 else ''
def user_tenants(connection, row):
if row[4]:
return (row[4],)
rows = connection.execute('SELECT tenant_id FROM customer_tenant_grants WHERE customer_id=? ORDER BY tenant_id LIMIT 257',
(row[0],)).fetchall()
return tuple(item[0] for item in rows) if len(rows) <= 256 else ()
def seed_local_grants(connection, row):
if not row[6] or connection.execute('SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
return
if not row[4] and get_setting('CUSTOMER_DEMO_LOCAL_ORDERS') and not user_tenants(connection,row):
connection.execute('INSERT OR IGNORE INTO customer_tenant_grants(customer_id,tenant_id) '
"SELECT ?,tenant_id FROM order_test_data WHERE tenant_id<>'' GROUP BY tenant_id",(row[0],))
if user_tenants(connection,row):
connection.execute('UPDATE customer_users SET scope_initialized=1 WHERE id=?',(row[0],))
def scope_key(tenants):
return hashlib.sha256(json.dumps(sorted(tenants),ensure_ascii=False).encode()).hexdigest() if tenants else None
def effective_tenants(tenants):
configured = get_setting('ORDER_DATA_TENANT_ID')
return tuple(value for value in tenants if not configured or value == configured)
def current_scope_key():
from .visitor_service import customer_tenant_ids
return scope_key(customer_tenant_ids())
def init_customer_tables(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS customer_users (
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, display_name TEXT NOT NULL,
tenant_id TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1,
is_demo INTEGER NOT NULL DEFAULT 0, scope_initialized INTEGER NOT NULL DEFAULT 0)''')
if 'scope_initialized' not in [item[1] for item in connection.execute('PRAGMA table_info(customer_users)')]:
connection.execute('ALTER TABLE customer_users ADD COLUMN scope_initialized INTEGER NOT NULL DEFAULT 0')
connection.execute('''CREATE TABLE IF NOT EXISTS customer_sessions (
token_hash TEXT PRIMARY KEY, customer_id INTEGER NOT NULL,
visitor_id TEXT NOT NULL, auth_stamp TEXT NOT NULL, expires_at INTEGER NOT NULL)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_customer_session_expiry ON customer_sessions(expires_at)')
connection.execute('''CREATE TABLE IF NOT EXISTS customer_tenant_grants (
customer_id INTEGER NOT NULL, tenant_id TEXT NOT NULL, PRIMARY KEY(customer_id,tenant_id))''')
if get_setting('CUSTOMER_DEMO_ENABLED'):
connection.execute('''INSERT OR IGNORE INTO customer_users
(username,password_hash,display_name,tenant_id,is_demo) VALUES(?,?,?,?,1)''',
(get_setting('CUSTOMER_DEMO_USERNAME'), get_setting('CUSTOMER_DEMO_PASSWORD_HASH'),
get_setting('CUSTOMER_DEMO_USERNAME'), demo_tenant(connection)))
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
'FROM customer_users WHERE username=?',(get_setting('CUSTOMER_DEMO_USERNAME'),)).fetchone()
seed_local_grants(connection,row)
def auth_stamp(row, tenants):
return hashlib.sha256((row[2] + '\n' + (scope_key(effective_tenants(tenants)) or '')).encode()).hexdigest()
def allowed_user(row):
return bool(row and row[5] and (not row[6] or (
get_setting('CUSTOMER_DEMO_ENABLED') and row[1] == get_setting('CUSTOMER_DEMO_USERNAME'))))
def attach_customer():
g.customer = None
g.customer_tenant_id = None
g.customer_tenant_ids = ()
token = request.cookies.get(COOKIE_NAME)
if not visitor_id() or not valid_key(token):
return
connection = connect()
try:
session = connection.execute('SELECT customer_id,auth_stamp FROM customer_sessions '
'WHERE token_hash=? AND visitor_id=? AND expires_at>?',
(hashlib.sha256(token.encode()).hexdigest(), visitor_id(), int(time.time()))).fetchone()
if not session:
return
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
'FROM customer_users WHERE id=?', (session[0],)).fetchone()
tenants = effective_tenants(user_tenants(connection,row)) if row else ()
if allowed_user(row) and secrets.compare_digest(auth_stamp(row,tenants), session[1]):
g.customer = {'id':row[0], 'username':row[1], 'name':row[3]}
g.customer_tenant_id = tenants[0] if len(tenants) == 1 else None
g.customer_tenant_ids = tenants
finally:
connection.close()
def current_customer_id():
customer = getattr(g, 'customer', None) if has_app_context() else None
return customer['id'] if customer else None
def bind_order_conversation(conv_id):
"""A conversation containing order facts also requires its customer login."""
if not conv_id:
return True
connection = connect()
try:
result = connection.execute('UPDATE chat_history SET customer_id=?,customer_scope=? WHERE id=? AND visitor_id=? '
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
(current_customer_id(),current_scope_key(),conv_id,visitor_id(),current_customer_id(),current_scope_key()))
connection.commit()
return result.rowcount == 1
finally:
connection.close()
def session_status():
return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer}
def order_login_response():
if current_customer_id():
return {'t':'当前账号尚未关联订单,请联系人工客服。'}
return {'t':'请先使用客户账号登录并关联订单,再查询订单或物流轨迹。','login_required':True}
def needs_customer_login(conv_id):
if current_customer_id():
return False
connection = connect()
try:
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
'AND customer_id IS NOT NULL',(conv_id,visitor_id())).fetchone())
finally:
connection.close()
def login(data, attempts, lock):
if not isinstance(data, dict):
return {'ok':False, 'error':'请输入账号和密码。'}, 400
username, password = data.get('username'), data.get('password')
if not isinstance(username, str) or not isinstance(password, str) or not 1 <= len(username) <= 80 or not 1 <= len(password) <= 256:
return {'ok':False, 'error':'请输入有效的账号和密码。'}, 400
now = int(time.time())
# A separate short failed-login budget supplements the global request limiter.
ip = request.remote_addr or 'unknown'
with lock:
failures = [stamp for stamp in attempts.get(ip, []) if stamp > now - 300]
if len(attempts) > 2048:
for key in list(attempts):
if not attempts[key] or attempts[key][-1] <= now - 300:
attempts.pop(key,None)
if len(failures) >= 5:
return {'ok':False, 'error':'登录尝试过多,请稍后重试。'}, 429
attempts[ip] = failures + [now]
connection = connect()
try:
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
'FROM customer_users WHERE username=?', (username.strip(),)).fetchone()
digest = row[2] if row else get_setting('CUSTOMER_DEMO_PASSWORD_HASH')
try:
valid = check_password_hash(digest, password)
except (ValueError, TypeError):
valid = False
if not valid or not allowed_user(row):
return {'ok':False, 'error':'账号或密码不正确。'}, 403
with lock:
attempts.pop(ip, None)
if row[6] and not row[4] and not user_tenants(connection,row) and not connection.execute(
'SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
tenant = demo_tenant(connection)
if tenant:
connection.execute("UPDATE customer_users SET tenant_id=? WHERE id=? AND tenant_id=''", (tenant,row[0]))
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
'FROM customer_users WHERE id=?', (row[0],)).fetchone()
seed_local_grants(connection,row)
old_token = request.cookies.get(COOKIE_NAME)
if valid_key(old_token):
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
(hashlib.sha256(old_token.encode()).hexdigest(), visitor_id()))
token = secrets.token_hex(32)
connection.execute('DELETE FROM customer_sessions WHERE expires_at<=?', (now,))
connection.execute('INSERT INTO customer_sessions VALUES(?,?,?,?,?)',
(hashlib.sha256(token.encode()).hexdigest(),row[0],visitor_id(),auth_stamp(row,user_tenants(connection,row)),now+SESSION_SECONDS))
connection.commit()
response = jsonify({'ok':True,'authenticated':True,'user':{'id':row[0],'username':row[1],'name':row[3]}})
response.set_cookie(COOKIE_NAME,token,max_age=SESSION_SECONDS,httponly=True,secure=request.is_secure,samesite='Lax',path='/')
return response
finally:
connection.close()
def logout():
token = request.cookies.get(COOKIE_NAME)
if valid_key(token):
connection = connect()
try:
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
(hashlib.sha256(token.encode()).hexdigest(),visitor_id()))
connection.commit()
finally:
connection.close()
response = jsonify({'ok':True,'authenticated':False,'user':None})
response.delete_cookie(COOKIE_NAME,path='/',secure=request.is_secure,httponly=True,samesite='Lax')
return response
+15
View File
@@ -52,6 +52,17 @@ def init_db():
cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL') cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL')
if 'visitor_id' not in cols: if 'visitor_id' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT') cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT')
if 'customer_id' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_id INTEGER')
if 'customer_scope' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT')
if 'customer_tenant_id' in cols:
# Preserve conversations created by the earlier local single-tenant login.
import hashlib
import json
for cid, tenant in cur.execute('SELECT id,customer_tenant_id FROM chat_history WHERE customer_id IS NOT NULL').fetchall():
scope = hashlib.sha256(json.dumps([tenant],ensure_ascii=False).encode()).hexdigest() if tenant else None
cur.execute('UPDATE chat_history SET customer_scope=? WHERE id=?',(scope,cid))
cur.execute('CREATE INDEX IF NOT EXISTS idx_chat_visitor ON chat_history(visitor_id, id)') cur.execute('CREATE INDEX IF NOT EXISTS idx_chat_visitor ON chat_history(visitor_id, id)')
cur.execute('''CREATE TABLE IF NOT EXISTS visitor_uploads ( cur.execute('''CREATE TABLE IF NOT EXISTS visitor_uploads (
filename TEXT PRIMARY KEY, visitor_id TEXT NOT NULL, ctime TEXT NOT NULL filename TEXT PRIMARY KEY, visitor_id TEXT NOT NULL, ctime TEXT NOT NULL
@@ -136,6 +147,10 @@ def init_db():
''') ''')
from .order_data import init_order_table from .order_data import init_order_table
init_order_table(conn) init_order_table(conn)
from .customer_service import init_customer_tables
init_customer_tables(conn)
from .quote_data import init_quote_tables
init_quote_tables(conn)
old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall() 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: for cid, title in old:
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str())) cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str()))
+15 -1
View File
@@ -2,21 +2,35 @@
import os import os
import secrets import secrets
import re
import time import time
from datetime import datetime from datetime import datetime
from urllib.parse import unquote
from .config import get_setting from .config import get_setting
def cleanup_uploads(): def cleanup_uploads():
"""删除 uploads/ 里 7 天前的图片""" """Keep attachments referenced by chat history; expire unused uploads after 7 days."""
try: try:
udir = get_setting('UPLOAD_DIR') udir = get_setting('UPLOAD_DIR')
if not os.path.isdir(udir): if not os.path.isdir(udir):
return return
from .db import connect
connection = connect()
try:
referenced = set()
rows = connection.execute("SELECT content FROM messages WHERE content LIKE '%/uploads/%' "
"UNION ALL SELECT content FROM human_messages WHERE content LIKE '%/uploads/%'")
for (content,) in rows:
referenced.update(unquote(name) for name in re.findall(r'/uploads/([A-Za-z0-9_.%-]+)', content or ''))
finally:
connection.close()
cutoff = time.time() - 7 * 86400 cutoff = time.time() - 7 * 86400
for f in os.listdir(udir): for f in os.listdir(udir):
if f in referenced:
continue
fp = os.path.join(udir, f) fp = os.path.join(udir, f)
if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff: if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
os.remove(fp) os.remove(fp)
+10 -7
View File
@@ -2,9 +2,10 @@
from .db import connect, now_str from .db import connect, now_str
from .visitor_service import visitor_id, owns_conversation from .visitor_service import visitor_id, owns_conversation
from .customer_service import current_customer_id, current_scope_key
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。' WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。'
def new_chat(): def new_chat():
@@ -24,7 +25,8 @@ def new_chat():
def get_history(): def get_history():
conn = connect() conn = connect()
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history WHERE visitor_id=? ' rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history WHERE visitor_id=? '
'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),)).fetchall() 'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?)) '
'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),current_customer_id(),current_scope_key())).fetchall()
conn.close() conn.close()
return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows] return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows]
@@ -57,12 +59,13 @@ def clear_all():
conn = connect() conn = connect()
cur = conn.cursor() cur = conn.cursor()
owner = visitor_id() owner = visitor_id()
owned = 'SELECT id FROM chat_history WHERE visitor_id=?' owned = 'SELECT id FROM chat_history WHERE visitor_id=? AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))'
scope = (owner,current_customer_id(),current_scope_key())
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions ' cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions '
'WHERE conv_id IN ('+owned+'))', (owner,)) 'WHERE conv_id IN ('+owned+'))', scope)
cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', (owner,)) cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', scope)
cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', (owner,)) cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', scope)
cur.execute('DELETE FROM chat_history WHERE visitor_id=?', (owner,)) cur.execute('DELETE FROM chat_history WHERE id IN ('+owned+')', scope)
conn.commit() conn.commit()
conn.close() conn.close()
return {'ok': True} return {'ok': True}
+7 -4
View File
@@ -1,18 +1,21 @@
"""Human conversation lifecycle; typing state is supplied by the app.""" """Human conversation lifecycle; typing state is supplied by the app."""
import time import time
from datetime import datetime from datetime import datetime, timedelta, timezone
from .db import connect from .db import connect
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
def human_request(data): def human_request(data):
conv_id = data.get('conv_id') conv_id = data.get('conv_id')
if not conv_id: if not conv_id:
return ({'ok': False, 'error': 'no conv'}, 400) return ({'ok': False, 'error': 'no conv'}, 400)
if not 8 <= datetime.now().hour < 22: service_time = datetime.now(SERVICE_TIMEZONE)
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'} if not 8 <= service_time.hour < 22:
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天北京时间8:00-22:00,当前为非工作时间,请留言'}
now = service_time.strftime('%Y-%m-%d %H:%M:%S')
conn = connect() conn = connect()
conn.execute('BEGIN IMMEDIATE') conn.execute('BEGIN IMMEDIATE')
if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone(): if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone():
+207 -22
View File
@@ -2,24 +2,209 @@
knowledge_list = [ knowledge_list = [
# ==================== 一、订单 ====================
{ {
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家"], "keys": ["查询订单", "查订单", "订单查询", "订单在哪里", "我的订单在哪", "帮我查订单", "为什么查不到订单"],
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓。" "text": (
"请提供订单号、入仓号或关联运单号,并核验订单归属。"
"查询结果以实际记录为准。查不到时,应核对号码、查询范围和数据同步情况,"
"不能直接认定没有发货,也不能编造运输或清关结果。"
)
}, },
{ {
"keys":["海运","船运","海派","海运快线","樱花快线"], "keys": ["订单号和运单号", "订单号是什么", "运单号是什么", "入仓号是什么", "入仓码是什么", "一票多个单号", "主子订单"],
"text": (
"订单号识别业务订单,入仓号用于收货归集,运单号识别运输任务或包裹。"
"一票货可能因分包、分批或阶段换号产生多个编号,"
"应根据实际关联记录分别确认,不能把换号当成新增货物。"
)
},
{
"keys": ["付款后没发货", "付款了没发货", "下单后没发货", "下单后多久发货", "修改订单", "订单能修改吗", "取消订单", "订单能取消吗", "更换渠道"],
"text": (
"下单或付款成功不代表已出运,还需核对收货、审核和运输安排。"
"修改、取消或换渠道需根据当前操作阶段确认。"
"变更申请不等于执行成功,是否产生费用按适用规则确认。"
)
},
# ==================== 二、客户信息 ====================
{
"keys": ["手机号查订单", "用手机号查", "客户编号查订单", "客户号查订单", "公司名称查订单", "用公司名查", "代客户查询", "帮别人查订单"],
"text": (
"可按系统支持的方式辅助查找,但必须核验身份、订单归属及代理权限。"
"不能仅凭手机号或公司名称披露他人的完整订单、地址和联系方式。"
"查询权限与修改权限需要分别确认。"
)
},
{
"keys": ["联系人填错", "联系人写错", "手机号填错", "电话填错", "邮箱填错", "客户资料修改", "客户信息修改"],
"text": (
"客户账户资料与本票收发货资料需要分别管理。"
"修改账户资料不一定同步修改历史和在途订单。"
"在途信息变更需确认相关操作方是否接受并生效。"
)
},
{
"keys": ["订单负责人", "谁负责这票", "客户待办", "没收到通知"],
"text": (
"负责人、客户待办和通知情况应根据实际记录确认。"
"待办需说明具体事项、提交渠道及已确认的期限。"
"没有联系或送达记录时,不能认定客户已收到通知;"
"没有分配记录时,不能编造负责人和反馈时间。"
)
},
# ==================== 三、地址 ====================
{
"keys": ["收货地址怎么填", "地址怎么填写", "地址怎么写", "邮编不对", "邮编错误", "地址不完整", "地址不全", "缺少门牌"],
"text": (
"国际收货地址通常需要国家、州省、城市、街道、门牌、邮编、收件人及电话。"
"缺失或不匹配可能影响派送,具体要求以目的地和承运商为准。"
"补交资料后仍需确认是否修改成功。"
)
},
{
"keys": ["最终送到哪里", "最后送到哪里", "收货地址确认", "目的地址是什么", "提货地址和收货地址", "提货地址一样吗"],
"text": (
"提货、交仓、中转、清关和最终收货地址用途不同。"
"最终目的地应查询本票已生效的地址,并按权限展示。"
"到达中转仓或清关场所,不代表到达最终收货地址。"
)
},
{
"keys": ["修改收货地址", "改地址", "地址可以修改吗", "可以改地址吗", "更换收件人", "收件人可以改吗", "收件人改地址", "改送其他仓", "换仓地址", "FBA地址确认"],
"text": (
"改址需要核对货物阶段和执行方处理能力,"
"未确认生效前不能告知新地址已启用。"
"FBA或海外仓需核对当前货件完整地址及仓代码,"
"不能仅按历史仓代码表安排配送。"
)
},
# ==================== 四、节点 ====================
{
"keys": ["节点是什么意思", "什么叫节点", "节点和轨迹", "节点是什么", "已提交是什么意思", "订单已提交", "已接单是什么意思"],
"text": (
"节点是某一步发生的事件,轨迹是多个运输事件组成的记录。"
"已提交、审核通过、已接单属于业务处理状态,"
"本身不能证明仓库收到实物或货物已经出运。"
)
},
{
"keys": ["已预报是什么意思", "预报是什么意思", "已入仓是什么意思", "入仓是什么意思", "已装柜是什么意思", "装柜是什么意思", "已出运是什么意思", "出运了吗"],
"text": (
"已预报通常表示信息已提交,不一定已交实物;"
"已入仓表示仓库收货,不一定完成检查;"
"已装柜表示完成装载,不代表已经离港。"
"已出运需有实际离港、起飞、发车或交运依据。"
)
},
{
"keys": ["到港是什么意思", "到港了吗", "已提取是什么意思", "提取是什么意思", "已交末端是什么意思", "交末端是什么意思", "派送中是什么意思", "开始派送了吗"],
"text": (
"到港表示到达港口,之后可能仍需清关和交接。"
"已提取、已交末端通常表示当地承运商接收货物,需要核对接收方。"
"派送中表示进入配送环节,以上均不等于收件人签收。"
)
},
# ==================== 五、轨迹 ====================
{
"keys": ["完整物流轨迹", "物流轨迹怎么查", "运输轨迹怎么查", "有单号没有轨迹", "有单号查不到", "轨迹好几天没更新", "物流几天没更新", "轨迹不更新"],
"text": (
"轨迹应说明数据来源、覆盖范围及最后有效事件时间。"
"暂无记录可能涉及未交接、扫描未回传或查询源异常,需要核实。"
"长期没有更新不能直接认定丢失,没有数据的阶段不能自行补齐。"
)
},
{
"keys": ["换单号后怎么查", "单号换了", "新旧单号", "官网轨迹不一样", "轨迹不一致", "节点重复", "节点时间不一致"],
"text": (
"换号需保留新旧编号关联。"
"不同来源或节点时间不一致时,应核对时区、原始事件、回传及更正记录。"
"同步时间较新不一定代表事件较新,排查方向不能当作已确认原因。"
)
},
{
"keys": ["实时位置", "货物现在在哪", "预计到达时间", "预计什么时候到", "轨迹能证明签收吗", "轨迹能当到货证明吗", "模拟轨迹"],
"text": (
"节点地点通常表示某时经过该处,不等于实时定位。"
"预计到达时间属于计划,应注明来源和条件。"
"签收争议需核对POD等凭证,演示或模拟轨迹不能证明真实运输和交付。"
)
},
# ==================== 六、货物状态 ====================
{
"keys": ["履约中是什么意思", "履约中代表什么", "头程完成是什么意思", "头程完成了吗", "订单完成就是签收吗", "订单完成是不是签收"],
"text": (
"履约中表示订单正在执行,不能据此判断具体位置或清关阶段。"
"当前系统的头程订单已完成,不等于末端已签收,"
"也不能直接证明客户已收到货物。"
)
},
{
"keys": ["货物是否完好", "货物完好吗", "有没有少件", "少了几件", "货物受潮", "货物进水", "部分到货", "部分签收", "只到了一部分"],
"text": (
"货况需依据检查、交接件数和照片等记录确认,运输正常不能证明完好。"
"部分到货或签收时,应分别核对总件数及各件进度。"
"没有检查依据时说明尚未核实,不能用一件状态代表整票。"
)
},
{
"keys": ["已签收但没收到", "显示签收没收到", "签收人不认识", "谁签收的", "货物异常", "物流异常", "滞留是什么意思", "货物滞留"],
"text": (
"签收争议应核对签收人、交付地点、代收及POD,必要时调查。"
"异常或滞留需明确发生环节和实际原因,不能直接等同海关扣货。"
"调查前不能擅自认定货物丢失或客户已经收到。"
)
},
# ==================== 七、清关 ====================
{
"keys": ["清关了吗", "我的货清关了吗", "是否清关", "清关完成没有", "清关完成了吗", "有没有放行", "出口放行就是清关完成吗"],
"text": (
"查询清关需明确国家、出口或进口环节,并核对有效记录。"
"出口放行不代表目的国进口清关完成,到港和已付款也不能证明放行。"
"没有可靠数据时应说明暂未取得结果,不能把未知当作已完成或未完成。"
)
},
{
"keys": ["已申报是什么意思", "申报了吗", "清关中是什么意思", "现在清关中吗", "查验是不是没收", "被查验了吗", "清关为什么慢", "清关怎么还没好"],
"text": (
"申报动作、申报受理和放行需分别核实;"
"清关中表示手续正在处理,查验不等于没收。"
"具体等待原因以实际反馈为准,"
"不能保证统一天数或一定放行,清关状态也不一定按固定顺序变化。"
)
},
{
"keys": ["清关完为什么没派送", "清关了怎么没派送", "整票都清关了吗", "是不是整票放行", "税款交了吗", "税交了吗", "放行证明", "清关证明"],
"text": (
"资料、税费和放行状态需分别核实,客户付款不一定代表税款已缴。"
"放行范围需核对申报单、批次及包裹,不能用一件代表整票。"
"放行后可能仍需提货和末端交接;凭证提供需核对关联、授权和脱敏要求。"
)
},
{
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家","公司地址","办公地址","洛杉矶地址","纽约地址"],
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓,公司地址位于:洛杉矶:10767 7th Street, Rancho Cucamonga, California 91730纽约:Door 13-14,75 Carter Dr Edison, New Jersey 08817义乌:浙江省义乌市福田街道稠州北路 1399 号 8613-8615 室深圳:深圳市南山区粤海街道海珠社区海德三道 1258 号卓越后海金融中心 1101。"
},
{
"keys":["海运","船运","海派","海运快线","樱花快线","欧洲海运价格","海运多少钱","海运多久"],
"text":"欧洲海运快线(双清/递延)单价:21KG+ 12.3元/kg,101KG+ 10.3元/kg,500KG+ 10.2元/kg,2000KG+ 10.0元/kg。时效:开船后38-45天提取。可走樱花快线(+0.3元/kg),递延价减1.7元/kg。主派送为UPS/DHL/DPD/CWE统配。" "text":"欧洲海运快线(双清/递延)单价:21KG+ 12.3元/kg,101KG+ 10.3元/kg,500KG+ 10.2元/kg,2000KG+ 10.0元/kg。时效:开船后38-45天提取。可走樱花快线(+0.3元/kg),递延价减1.7元/kg。主派送为UPS/DHL/DPD/CWE统配。"
}, },
{ {
"keys":["铁路","中欧班列","班列","义乌铁路","火车"], "keys":["铁路","中欧班列","班列","义乌铁路","火车","铁路价格","铁路多久","铁路多少钱"],
"text":"义乌-中欧铁路专线单价:21KG+ 15.5元/kg,100KG+ 和 1000KG+ 均为13.5元/kg。时效:发车后36-40天提取。VAT递延减1元/kg。只限送义乌仓,义乌装柜经阿拉山口/霍尔果斯出境。" "text":"义乌-中欧铁路专线单价:21KG+ 15.5元/kg,100KG+ 和 1000KG+ 均为13.5元/kg。时效:发车后36-40天提取。VAT递延减1元/kg。只限送义乌仓,义乌装柜经阿拉山口/霍尔果斯出境。"
}, },
{ {
"keys":["卡航","卡车","卡派"], "keys":["卡航","卡车","卡派","卡航价格","卡航多久","卡航多少钱"],
"text":"中欧卡航单价:21KG+ 19.4元/kg,101KG+ 和 500KG+ 17.4元/kg,2000KG+ 及 4000KG+ 17.3元/kg。时效:发车后欧盟16-20天提取。递延减1.5元/kg,普卡时效18-22天。纯纺织品另加5元/kg。" "text":"中欧卡航单价:21KG+ 19.4元/kg,101KG+ 和 500KG+ 17.4元/kg,2000KG+ 及 4000KG+ 17.3元/kg。时效:发车后欧盟16-20天提取。递延减1.5元/kg,普卡时效18-22天。纯纺织品另加5元/kg。"
}, },
{ {
"keys":["空运","空派","航空","欧洲空运"], "keys":["空运","空派","航空","欧洲空运","空运价格","空运多久","空运多少钱"],
"text":"欧洲空运专线(空派)单价:21KG+ 39元/kg,45KG+ 38元/kg,71KG+ 37元/kg,101KG+ 36元/kg。时效:10-13天左右提取;快线8-13天,直飞快线12-15天。一票多件单件不足12KG按12KG计费。" "text":"欧洲空运专线(空派)单价:21KG+ 39元/kg,45KG+ 38元/kg,71KG+ 37元/kg,101KG+ 36元/kg。时效:10-13天左右提取;快线8-13天,直飞快线12-15天。一票多件单件不足12KG按12KG计费。"
}, },
{ {
@@ -27,67 +212,67 @@ knowledge_list = [
"text":"英国专线(DPD)双清价格:英国海运21KG+ 12.8元/kg、101KG+/301KG+ 10.9元/kg,时效约40天提取;英国铁路21KG+ 18.0元/kg、101KG+ 16.2元/kg,发车后25-30天提取;中英卡航21KG+ 21.4元/kg、101KG+ 19.4元/kg,发车后20-22天提取。VAT自税/递延价更低(海运10.0/8.1、铁路15/13.2、卡航18.4/16.4)。北极航线英国海运25-28天,价格单询。卡航单件不超30kg、尺寸≤98×68×59cm。" "text":"英国专线(DPD)双清价格:英国海运21KG+ 12.8元/kg、101KG+/301KG+ 10.9元/kg,时效约40天提取;英国铁路21KG+ 18.0元/kg、101KG+ 16.2元/kg,发车后25-30天提取;中英卡航21KG+ 21.4元/kg、101KG+ 19.4元/kg,发车后20-22天提取。VAT自税/递延价更低(海运10.0/8.1、铁路15/13.2、卡航18.4/16.4)。北极航线英国海运25-28天,价格单询。卡航单件不超30kg、尺寸≤98×68×59cm。"
}, },
{ {
"keys":["英国空运","英国","UK","伦敦"], "keys":["英国空运","英国空运价格","英国空运多久","英国空运多少钱","英国","UK","伦敦"],
"text":"英国空运专线(双清)单价:22KG+ 48元/kg,51KG+ 44元/kg,101KG+ 42元/kg,时效8-10个自然日提取。VAT自税/递延为44/40/38元/kg,带电货47.5/42.5/41.5元/kg。拒收北爱尔兰、苏格兰高地等偏远地址。" "text":"英国空运专线(双清)单价:22KG+ 48元/kg,51KG+ 44元/kg,101KG+ 42元/kg,时效8-10个自然日提取。VAT自税/递延为44/40/38元/kg,带电货47.5/42.5/41.5元/kg。拒收北爱尔兰、苏格兰高地等偏远地址。"
}, },
{ {
"keys":["报价","价格","费用","计费","怎么收费","计费重量","多少钱","运费","单价"], "keys":["报价","价格","费用","计费","怎么收费","计费重量","计费重量怎么算","材积重","体积重","多少钱","运费","运费怎么算","单价"],
"text":"运费计算方式:阶梯单价 × 计费重量。计费重量 = 实重与材积重取大值,材积 = 长×宽×高(cm)÷6000,即1CBM=167KG。各渠道最低起收21KG(英国空运22KG)。单件计费重不足13KG的渠道(铁路、卡航)按5元/kg加收超轻费;空运单件不足12KG按12KG计费。偏远地址加7元/kg,单票最低250元。价格不含税,税点以开出的发票为准。" "text":"运费计算方式:阶梯单价 × 计费重量。计费重量 = 实重与材积重取大值,材积 = 长×宽×高(cm)÷6000,即1CBM=167KG。各渠道最低起收21KG(英国空运22KG)。单件计费重不足13KG的渠道(铁路、卡航)按5元/kg加收超轻费;空运单件不足12KG按12KG计费。偏远地址加7元/kg,单票最低250元。价格不含税,税点以开出的发票为准。"
}, },
{ {
"keys":["时效","多久","要几天","多长时间"], "keys":["时效","运输时效","多久","要几天","多长时间","几天到","什么时候到"],
"text":"各渠道时效:欧洲海运快线开船后38-45天提取;中欧铁路发车后36-40天提取;中欧卡航发车后16-20天提取;欧洲空运10-13天提取;英国空运8-10个自然日提取。海关查验、罢工、节假日等不可抗力导致的时间不计入。" "text":"各渠道时效:欧洲海运快线开船后38-45天提取;中欧铁路发车后36-40天提取;中欧卡航发车后16-20天提取;欧洲空运10-13天提取;英国空运8-10个自然日提取。海关查验、罢工、节假日等不可抗力导致的时间不计入。"
}, },
{ {
"keys":["揽收","上门取件","上门收货","交仓","交货"], "keys":["揽收","上门取件","上门收货","可以上门取货吗","交仓","交货","哪里交仓"],
"text":"义乌、深圳(龙华观澜、宝安福园)、广州(石井)、泉州均可交仓,义乌仓10:00-21:00收货。欧洲空运交仓义乌+1元/kg,泉州、广州+0.5元/kg;海运、铁路、卡航泉州交货+0.5元/kg。" "text":"义乌、深圳(龙华观澜、宝安福园)、广州(石井)、泉州均可交仓,义乌仓10:00-21:00收货。欧洲空运交仓义乌+1元/kg,泉州、广州+0.5元/kg;海运、铁路、卡航泉州交货+0.5元/kg。"
}, },
{ {
"keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","交仓地址","送仓"], "keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","深圳仓","广州仓","交仓地址","送仓"],
"text":"义乌仓:义乌市稠江街道梦娜袜业德贸路东三门进,李东文 19558263692(10:00-21:00)。深圳龙华:龙华区观澜街道君子布社区张一村路黄背坑路1号B栋,张长印 18688937838。深圳宝安:福园二路九号海亨通物流B区C09-C14。广州:恒河沙仓储石井仓211,潘志红 13789206500。送货需打印交接清单随货交仓。" "text":"义乌仓:义乌市稠江街道梦娜袜业德贸路东三门进,李东文 19558263692(10:00-21:00)。深圳龙华:龙华区观澜街道君子布社区张一村路黄背坑路1号B栋,张长印 18688937838。深圳宝安:福园二路九号海亨通物流B区C09-C14。广州:恒河沙仓储石井仓211,潘志红 13789206500。送货需打印交接清单随货交仓。"
}, },
{ {
"keys":["理赔","货损","丢件","赔付","破损","压坏","索赔"], "keys":["理赔","货损","丢件","赔付","破损","压坏","索赔","怎么理赔","丢件怎么赔","理赔多久"],
"text":"遗失赔付:未提取上网遗失按申报价值赔偿,最高不超过40元/kg,不退运费;已提取遗失按服务商标准,最高100USD/票。开查时间:交仓后7天未提取可申请开查,开查30天无果走索赔。签收后亚马逊未上架需2周内提出调查,包裹异常需1个月内提出。外包装无损的内件丢失不支持理赔,易碎品损坏不支持理赔。可自购保险,保费为货值千分之三(电池千分之五)。" "text":"遗失赔付:未提取上网遗失按申报价值赔偿,最高不超过40元/kg,不退运费;已提取遗失按服务商标准,最高100USD/票。开查时间:交仓后7天未提取可申请开查,开查30天无果走索赔。签收后亚马逊未上架需2周内提出调查,包裹异常需1个月内提出。外包装无损的内件丢失不支持理赔,易碎品损坏不支持理赔。可自购保险,保费为货值千分之三(电池千分之五)。"
}, },
{ {
"keys":["超轻费","计费重量不足","最低计费"], "keys":["超轻费","计费重量不足","最低计费","重量不够怎么收费"],
"text":"单件计费重量不得低于13KG(空运12KG),可整票按平均13KG算,不足部分按5元/kg加收超轻费。例:单箱只有12KG,加收5×1=5元超轻费。欧洲热门仓卡派直送要求单件平均计费重12KG,不足按2元/kg加收派送费。" "text":"单件计费重量不得低于13KG(空运12KG),可整票按平均13KG算,不足部分按5元/kg加收超轻费。例:单箱只有12KG,加收5×1=5元超轻费。欧洲热门仓卡派直送要求单件平均计费重12KG,不足按2元/kg加收派送费。"
}, },
{ {
"keys":["偏远","偏远费","偏远地址"], "keys":["偏远","偏远费","偏远地址","偏远怎么查","偏远收费"],
"text":"偏远地址加收7元/kg(不含燃油),单票偏远费最低消费250元/票。需要自行到UPS/DPD官网查询目的地址是否偏远,我司出货时不另行通知,以账单为准,偏远费6个月内有效。" "text":"偏远地址加收7元/kg(不含燃油),单票偏远费最低消费250元/票。需要自行到UPS/DPD官网查询目的地址是否偏远,我司出货时不另行通知,以账单为准,偏远费6个月内有效。"
}, },
{ {
"keys":["超重","超长","超大","尺寸限制"], "keys":["超重","超长","超大","尺寸限制","尺寸超了","超尺寸怎么收费"],
"text":"欧洲空运最长边≤99cm、次长边≤75cm、实重<24KG。英国DPD单件≤30KG,尺寸100×70×60cm以内,超重或超长各加收330元/件。UPS单边最长≤99cm、次边≤75cm、单件<24KG,超出加收240元+100元。热门仓卡派直送:单件超40KG加收30元/件。" "text":"欧洲空运最长边≤99cm、次长边≤75cm、实重<24KG。英国DPD单件≤30KG,尺寸100×70×60cm以内,超重或超长各加收330元/件。UPS单边最长≤99cm、次边≤75cm、单件<24KG,超出加收240元+100元。热门仓卡派直送:单件超40KG加收30元/件。"
}, },
{ {
"keys":["敏感","带电","电池","液体","粉末","禁品","不能寄","违禁品","拒收","拒接"], "keys":["敏感","敏感货能发吗","带电","电池","液体","粉末","禁品","不能寄","什么不能寄","违禁品","拒收","拒接"],
"text":"可接带标准箱100瓦以下非危电池/内电产品(C卡航、海运快线均可),资料齐全加1元/kg,资料不齐加2元/kg;手机、平板电脑加5元/kg。拒接:纯液体、纯粉末、药品、食品、易燃易爆、仿牌侵权、木制品(空运)、陶瓷手表等反倾销品、电子烟、管制刀具、枪支。电池第一个托收400元/件,第二个起100元/件,600元封顶。" "text":"可接带标准箱100瓦以下非危电池/内电产品(C卡航、海运快线均可),资料齐全加1元/kg,资料不齐加2元/kg;手机、平板电脑加5元/kg。拒接:纯液体、纯粉末、药品、食品、易燃易爆、仿牌侵权、木制品(空运)、陶瓷手表等反倾销品、电子烟、管制刀具、枪支。电池第一个托收400元/件,第二个起100元/件,600元封顶。"
}, },
{ {
"keys":["清关","递延","VAT","双清","包税","DDP"], "keys":["清关","清关资料","清关要什么资料","递延","VAT","VAT递延","双清","双清包税","包税","DDP"],
"text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。" "text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。"
}, },
{ {
"keys":["报关","单独报关","报关费"], "keys":["报关","怎么报关","单独报关","报关费","报关多少钱"],
"text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。" "text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。"
}, },
{ {
"keys":["亚马逊","FBA","海外仓","仓库配送","卡派"], "keys":["亚马逊","亚马逊仓","FBA","FBA头程","海外仓","仓库配送","卡派直送"],
"text":"支持FBA头程送亚马逊仓,热门仓卡派直送(德国WRO5/HAJ1/DTM1/DTM2、法国CDG7/LIL8、意大利TRN3、西班牙BCN8、罗马尼亚EMAG等)。卡派直送优势:超重附加费少、不限尺寸重量、上架快、基本无丢件。法国、意大利、西班牙其他亚马逊仓不能超UPS标准。" "text":"支持FBA头程送亚马逊仓,热门仓卡派直送(德国WRO5/HAJ1/DTM1/DTM2、法国CDG7/LIL8、意大利TRN3、西班牙BCN8、罗马尼亚EMAG等)。卡派直送优势:超重附加费少、不限尺寸重量、上架快、基本无丢件。法国、意大利、西班牙其他亚马逊仓不能超UPS标准。"
}, },
{ {
"keys":["人工客服","人工","客服","转人工","在线吗","上班","工作时间","营业时间","几点","周六","周日","星期六","星期日","星期天","礼拜","周末","双休","节假日","放假","休息"], "keys":["人工客服","人工","客服","转人工","在线吗","上班","工作时间","营业时间","几点","周六","周日","星期六","星期日","星期天","礼拜","周末","双休","节假日","放假","休息"],
"text":"人工客服在线时间:周一至周五 9:00—18:00。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。" "text":"人工客服在线时间:每天北京时间 8:00—22:00,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。"
}, },
{ {
"keys":["电话","联系方式","怎么联系","联系你们","微信"], "keys":["电话","联系方式","怎么联系","联系你们","微信"],
"text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。" "text":"可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
}, },
{ {
"keys":["退件","退回","退货","退运","改地址"], "keys":["退件","退回","退货","退运","怎么退运"],
"text":"海运/铁路为散货拼柜,已装柜不接受退件和扣件。空运国外扣件费200欧/票,收件人改派地址最低200元/票。退回海外仓:德国境内15欧/件,欧盟境内25欧/件,操作费100元/票,5个工作日免仓储,超期1欧/kg/天。重派德国10欧/件、欧盟20欧/件。" "text":"海运/铁路为散货拼柜,已装柜不接受退件和扣件。空运国外扣件费200欧/票,收件人改派地址最低200元/票。退回海外仓:德国境内15欧/件,欧盟境内25欧/件,操作费100元/票,5个工作日免仓储,超期1欧/kg/天。重派德国10欧/件、欧盟20欧/件。"
} }
] ]
+152 -32
View File
@@ -7,7 +7,8 @@ from datetime import datetime
from .config import get_setting from .config import get_setting
from .order_tracking import build_tracking from .order_tracking import build_tracking
from .visitor_service import customer_tenant_id from .visitor_service import customer_tenant_ids
from .customer_service import order_login_response
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id', FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
@@ -17,13 +18,12 @@ FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过', STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中', 'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'} 'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目的国', FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipment ID', 'country':'目的国',
'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数', 'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数',
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级', 'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
'parent':'父订单号', 'updated':'来源更新时间'} 'parent':'父订单号', 'updated':'来源更新时间'}
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])') IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
ORDER_DETAIL = r'状态|进度|轨迹|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱' ORDER_DETAIL = r'状态|进度|轨迹|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
ORDER_LOGIN_TEXT = '订单查询将在客户账号登录并关联后开放。您可以先咨询运费、时效,或联系人工客服。'
class OrderDataUnavailable(Exception): class OrderDataUnavailable(Exception):
@@ -112,30 +112,111 @@ def import_tracking_records(connection, records, replace=False):
return {'imported': imported, 'preserved': preserved, 'missing': missing} return {'imported': imported, 'preserved': preserved, 'missing': missing}
def lookup_order(reference, tenant_id=None): def tenant_filter(tenant_id=None, tenant_ids=None):
"""Share the same server-side scope intersection for exact and aggregate reads."""
tenant = get_setting('ORDER_DATA_TENANT_ID')
if tenant_ids is not None:
if not isinstance(tenant_ids,(tuple,list,set)) or not 0 < len(tenant_ids) <= 256:
return None
allowed = {value for value in tenant_ids if isinstance(value,str) and 0 < len(value.strip()) <= 128}
if tenant_id is not None:
allowed.intersection_update({str(tenant_id)})
if tenant:
allowed.intersection_update({str(tenant)})
return (' AND tenant_id IN (' + ','.join('?' for _ in allowed) + ')', sorted(allowed)) if allowed else None
if tenant_id is not None:
if not str(tenant_id).strip() or (tenant and str(tenant_id) != str(tenant)):
return None
tenant = str(tenant_id)
return (' AND tenant_id=?', [str(tenant)]) if tenant else ('', [])
def lookup_order(reference, tenant_id=None, tenant_ids=None):
"""Exact, bounded lookup; scope comes from server configuration, never model SQL.""" """Exact, bounded lookup; scope comes from server configuration, never model SQL."""
from .db import connect from .db import connect
connection = connect() scope = tenant_filter(tenant_id, tenant_ids)
connection.row_factory = sqlite3.Row if scope is None:
return []
connection = None
try: try:
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)' connection = connect()
values = [reference] * 3 connection.row_factory = sqlite3.Row
tenant = get_setting('ORDER_DATA_TENANT_ID') query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)' + scope[0]
if tenant_id is not None: values = [reference] * 3 + scope[1]
if not str(tenant_id).strip():
return []
if tenant and str(tenant_id) != str(tenant):
return []
tenant = str(tenant_id)
if tenant:
query += ' AND tenant_id=?'
values.append(str(tenant))
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)] return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
except sqlite3.Error: except sqlite3.Error:
raise OrderDataUnavailable from None raise OrderDataUnavailable from None
finally: finally:
connection.close() if connection is not None:
connection.close()
def count_orders(tenant_ids, status=None):
"""Fresh totals require an explicit authorized scope; never fall back to all rows."""
from .db import connect
empty = {'total':0, 'with_inbound':0}
scope = tenant_filter(tenant_ids=tenant_ids if tenant_ids is not None else ())
if scope is None or (status is not None and status not in STATUS_LABELS):
return empty
connection = None
try:
connection = connect()
query = ("SELECT COUNT(*),COUNT(NULLIF(TRIM(inbound_code,char(9)||char(10)||char(13)||' '),'')) "
'FROM order_test_data WHERE 1=1' + scope[0])
values = scope[1]
if status is not None:
query += ' AND status=?'
values = values + [status]
total, with_inbound = connection.execute(query,values).fetchone()
return {'total':total, 'with_inbound':with_inbound}
except sqlite3.Error:
raise OrderDataUnavailable from None
finally:
if connection is not None:
connection.close()
def parse_count_query(message):
"""Recognize bounded total/status questions without ignoring unknown filters."""
if is_lookup_help(message):
return None
noun = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b'
count = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b'
if not re.search(noun,message,re.I) or not re.search(count,message,re.I):
return None
if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I):
return None
remaining = message
statuses = []
for phrase, status in (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'),
('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'),
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT')):
if phrase in remaining:
statuses.append(status)
remaining = remaining.replace(phrase,'')
remaining = re.sub(count+'|'+noun,'',remaining,flags=re.I)
remaining = re.sub(r'当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|'
r'\b(?:what|is|of|do|i|have|are|there|my|in|all|the|currently)\b|[\s,,。.!!??、::]','',remaining,flags=re.I)
return {'status':statuses[0] if statuses else None, 'unsupported':bool(remaining or len(set(statuses)) > 1)}
def answer_count_query(intent, tenants):
if not tenants:
return order_login_response()
if intent['unsupported']:
return {'t':'暂不支持按这个条件统计。可以查询订单总数,或按草稿、已提交、履约中、已完成、已取消等业务状态统计。'}
try:
counts = count_orders(tenants,intent['status'])
except OrderDataUnavailable:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
if intent['status']:
text = f"您当前有 {counts['total']} 票业务状态为“{STATUS_LABELS[intent['status']]}”的订单"
else:
text = f"您当前共有 {counts['total']} 票订单"
return {'t':text + f",其中 {counts['with_inbound']} 票已登记入仓码。", '_customer_data':True}
def extract_reference(message): def extract_reference(message):
@@ -164,6 +245,8 @@ def build_card(row):
def field(key, value): def field(key, value):
if value is not None and str(value).strip(): fields.append({'key': key, 'value': str(value)}) if value is not None and str(value).strip(): fields.append({'key': key, 'value': str(value)})
field('inbound', row['inbound_code']) field('inbound', row['inbound_code'])
if row['inbound_code']:
field('number', row['order_no'])
field('shipment', row['shipment_id']) field('shipment', row['shipment_id'])
field('country', row['country_code']) field('country', row['country_code'])
field('warehouse', row['warehouse_code']) field('warehouse', row['warehouse_code'])
@@ -183,8 +266,9 @@ def build_card(row):
for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else [] for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else []
nodes.reverse() nodes.reverse()
status = STATUS_LABELS.get(row['status'], row['status']) status = STATUS_LABELS.get(row['status'], row['status'])
summary = f"订单号 {row['order_no']},业务状态:{status}。" inbound = row['inbound_code']
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields) summary = f"入仓码 {inbound},业务状态:{status}。" if inbound else f"订单号 {row['order_no']},业务状态:{status}。"
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields if item['key'] != 'inbound')
card = {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields, card = {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
'nodes':nodes, 'summary':summary} 'nodes':nodes, 'summary':summary}
tracking = build_tracking(row.get('tracking_json')) tracking = build_tracking(row.get('tracking_json'))
@@ -197,8 +281,37 @@ def build_card(row):
return card return card
def is_lookup_help(message):
if re.search(r'是什么意思|指什么|怎么(?:查|操作|使用)|如何(?:查|操作|使用)|怎样(?:查|操作|使用)|'
r'\bhow\s+(?:do|can|to)\b', message, re.I):
return True
if re.fullmatch(r'\s*(?:请问)?(?:物流轨迹|物流状态|订单状态|入仓码|入仓号)是什么[。?!?!\s]*', message):
return True
return bool(re.search(r'\bwhat\s+(?:is|are)\b', message, re.I) and
not re.search(r'\b(?:my|this|that|our)\b', message, re.I))
def is_tracking_request(message):
"""Bounded tracking commands also work without an identifier or prior context."""
question = re.sub(r'[\s,,。.!!??、::]', '', message)
command = (r'(?:请问|请|麻烦)?(?:帮我)?(?:查(?:询|一下|下)?(?:物流(?:轨迹|进度|状态)?|轨迹|进度|货)|'
r'(?:物流|轨迹|进度)(?:查询|查一下|查下))')
personal = (r'(?:我的|(?:我)?(?:这|那)(?:票|个|单)|该)(?:货物?|包裹|快递|订单|运单)'
r'(?:现在|目前)?(?:到哪(?:里)?了?|什么时候(?:能|会)?到|何时能?到|多久能?到|'
r'签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?')
english = (r'(?:where\s+(?:is|are)\s+my|track(?:\s+my)?|check(?:\s+my)?)\s+'
r'(?:parcels?|packages?|shipments?|orders?)(?:\s+(?:status|progress))?|'
r'what\s+is\s+my\s+(?:parcel|package|shipment|order)\s+(?:status|progress)')
return bool(re.fullmatch(command+'|'+personal, question) or
re.fullmatch(english, message.strip(' \t\r\n.!?'), re.I))
def is_order_followup(message): def is_order_followup(message):
"""Reuse an order only for an explicit reference or a short detail question.""" """Reuse an order only for an explicit reference or a short detail question."""
if is_lookup_help(message):
return False
if is_tracking_request(message):
return True
if not re.search(ORDER_DETAIL, message): if not re.search(ORDER_DETAIL, message):
return False return False
if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message): if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message):
@@ -218,11 +331,14 @@ def is_order_followup(message):
def answer_query(message, conv_id): def answer_query(message, conv_id):
references = extract_reference(message) references = extract_reference(message)
tenant = customer_tenant_id() tenants = customer_tenant_ids()
if references and tenant is None: count_intent = parse_count_query(message) if not references else None
return {'t': ORDER_LOGIN_TEXT} if count_intent is not None:
return answer_count_query(count_intent,tenants)
if references and not tenants:
return order_login_response()
if len(references) > 1: if len(references) > 1:
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'} return {'t':'请一次提供一个入仓码,避免查错订单。'}
reference = references[0] if references else None reference = references[0] if references else None
if not reference and conv_id and is_order_followup(message): if not reference and conv_id and is_order_followup(message):
from .db import connect from .db import connect
@@ -248,19 +364,23 @@ def answer_query(message, conv_id):
except (ValueError, KeyError, TypeError): except (ValueError, KeyError, TypeError):
pass pass
if not reference: if not reference:
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|物流轨迹|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I): if is_tracking_request(message) or (not is_lookup_help(message) and re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
return {'t':ORDER_LOGIN_TEXT if tenant is None else '请提供订单号、入仓码或 Shipment ID。'} r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
r'\b(?:my|find|lookup|track)\s+(?:orders?|shipments?)\b|\b(?:order|shipment)\s+(?:status|tracking|lookup)\b|'
r'\b(?:track|lookup|find|search)\b.*\binbound\b|\binbound\s+(?:code|number)\s+(?:lookup|tracking|status)\b', message, re.I)):
return order_login_response() if not tenants else {'t':'请提供入仓码查询订单和物流轨迹。'}
return None return None
if tenant is None: if not tenants:
return {'t': ORDER_LOGIN_TEXT} return order_login_response()
try: try:
matches = lookup_order(reference, tenant_id=tenant) matches = lookup_order(reference,tenant_id=tenants[0]) if len(tenants) == 1 else lookup_order(reference,tenant_ids=tenants)
except OrderDataUnavailable: except OrderDataUnavailable:
return {'t':'订单信息暂时无法读取,请稍后重试。'} return {'t':'订单信息暂时无法读取,请稍后重试。'}
if not matches: if not matches:
return {'t':f'未找到“{reference}”对应的订单。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'} return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'}
if len(matches) > 1: if len(matches) > 1:
return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'} return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'}
if re.search(r'运费|费用|多少钱|价格', message): if re.search(r'运费|费用|多少钱|价格', message):
return {'t':'该订单未记录实际运费,请联系人工客服核实。'} return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
return {'card': build_card(matches[0])} return {'card': build_card(matches[0])}
+144
View File
@@ -0,0 +1,144 @@
"""Versioned, private quote imports and read-only runtime access."""
import json
import re
import sqlite3
from decimal import Decimal, InvalidOperation
def init_quote_tables(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS quote_books (
id INTEGER PRIMARY KEY, source_sha256 TEXT NOT NULL UNIQUE,
source_name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 0)''')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_rates (
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, service TEXT NOT NULL,
kind TEXT NOT NULL, origin TEXT NOT NULL, zone TEXT NOT NULL,
unit TEXT NOT NULL, tax TEXT NOT NULL, min_weight TEXT NOT NULL,
price TEXT NOT NULL, eta TEXT NOT NULL, destinations_json TEXT NOT NULL,
source_sheet TEXT NOT NULL, source_cell TEXT NOT NULL)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_quote_rates_book ON quote_rates(book_id)')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_warehouses (
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, code TEXT NOT NULL,
zone TEXT NOT NULL, state TEXT NOT NULL, postal TEXT NOT NULL,
address TEXT NOT NULL, source_sheet TEXT NOT NULL, source_cell TEXT NOT NULL)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_quote_warehouse_code ON quote_warehouses(book_id,code)')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_postcodes (
book_id INTEGER NOT NULL, postal TEXT NOT NULL, severity TEXT NOT NULL,
fee TEXT, PRIMARY KEY(book_id,postal))''')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_documents (
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, sheet TEXT NOT NULL,
source_cell TEXT NOT NULL, content TEXT NOT NULL)''')
def decimal_text(value, positive=False):
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError):
raise ValueError('Invalid quote amount') from None
if not number.is_finite() or number < 0 or number > 100000000 or (positive and not number):
raise ValueError('Invalid quote amount')
return format(number, 'f')
def import_snapshot(connection, snapshot, *, activate=False):
"""Validate before writing; replace an active book atomically, never order data."""
if not isinstance(snapshot, dict) or snapshot.get('schema') != 1:
raise ValueError('Unsupported quote snapshot')
digest = snapshot.get('source_sha256', '')
if not re.fullmatch('[0-9a-f]{64}', digest):
raise ValueError('Invalid quote source checksum')
source_name = str(snapshot.get('source_name', ''))[:240]
if not source_name:
raise ValueError('Missing quote source name')
limits = {'rates':10000, 'warehouses':10000, 'postcodes':50000, 'documents':5000}
for key, limit in limits.items():
if not isinstance(snapshot.get(key), list) or len(snapshot[key]) > limit:
raise ValueError('Invalid quote records')
rates = []
for row in snapshot['rates']:
if (row.get('kind') not in ('truck', 'parcel') or row.get('unit') not in ('kg', 'cbm')
or row.get('tax') not in ('included', 'excluded') or not row.get('service')
or not isinstance(row.get('destinations'), list)
or len(row['destinations']) > 200):
raise ValueError('Invalid quote rate')
rates.append((str(row['service'])[:100], row['kind'], str(row.get('origin', ''))[:40],
str(row.get('zone', ''))[:40], row['unit'], row['tax'],
decimal_text(row.get('min_weight', 0)), decimal_text(row['price'], positive=True),
str(row.get('eta', ''))[:200], json.dumps(row['destinations'], ensure_ascii=False),
str(row['source_sheet'])[:100], str(row['source_cell'])[:20]))
warehouses = [(str(r.get('code', '')).strip().upper()[:40], str(r.get('zone', ''))[:40],
str(r.get('state', ''))[:40], str(r.get('postal', ''))[:20], str(r.get('address', ''))[:1000],
str(r['source_sheet'])[:100], str(r['source_cell'])[:20]) for r in snapshot['warehouses']]
postcodes = []
for row in snapshot['postcodes']:
if not re.fullmatch('[0-9]{5}', row.get('postal', '')) or row.get('severity') not in ('remote','extended','extreme'):
raise ValueError('Invalid quote postcode')
postcodes.append((row['postal'], row['severity'], None if row.get('fee') is None else decimal_text(row['fee'])))
documents = [(str(r['sheet'])[:100], str(r['cell'])[:20], str(r['text'])[:16000]) for r in snapshot['documents']]
if not rates:
raise ValueError('Quote snapshot contains no rates')
init_quote_tables(connection)
if not connection.in_transaction:
connection.execute('BEGIN IMMEDIATE')
connection.execute('SAVEPOINT quote_import')
try:
existing = connection.execute('SELECT id FROM quote_books WHERE source_sha256=?', (digest,)).fetchone()
if existing:
book_id = existing[0]
else:
book_id = connection.execute('INSERT INTO quote_books(source_sha256,source_name) VALUES(?,?)',
(digest, source_name)).lastrowid
connection.executemany('''INSERT INTO quote_rates(book_id,service,kind,origin,zone,unit,tax,
min_weight,price,eta,destinations_json,source_sheet,source_cell) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)''',
[(book_id, *r) for r in rates])
connection.executemany('''INSERT INTO quote_warehouses(book_id,code,zone,state,postal,address,source_sheet,source_cell)
VALUES(?,?,?,?,?,?,?,?)''', [(book_id, *r) for r in warehouses])
connection.executemany('INSERT INTO quote_postcodes(book_id,postal,severity,fee) VALUES(?,?,?,?)',
[(book_id, *r) for r in postcodes])
connection.executemany('INSERT INTO quote_documents(book_id,sheet,source_cell,content) VALUES(?,?,?,?)',
[(book_id, *r) for r in documents])
if activate:
connection.execute('UPDATE quote_books SET active=0 WHERE active<>0')
connection.execute('UPDATE quote_books SET active=1 WHERE id=?', (book_id,))
connection.execute('RELEASE quote_import')
except Exception:
connection.execute('ROLLBACK TO quote_import')
connection.execute('RELEASE quote_import')
raise
return {'book_id':book_id, 'rates':len(rates), 'warehouses':len(warehouses),
'postcodes':len(postcodes), 'documents':len(documents), 'active':bool(activate), 'existing':bool(existing)}
def load_book():
"""One read transaction prevents mixed versions during a concurrent import."""
from .db import connect
connection = connect()
try:
connection.row_factory = sqlite3.Row
connection.execute('BEGIN')
book = connection.execute('SELECT id FROM quote_books WHERE active=1 ORDER BY id DESC LIMIT 1').fetchone()
if not book:
return None
book_id = book['id']
result = {'id':book_id}
for table, name in (('quote_rates','rates'), ('quote_warehouses','warehouses'), ('quote_documents','documents')):
result[name] = [dict(r) for r in connection.execute(f'SELECT * FROM {table} WHERE book_id=?', (book_id,))]
for rate in result['rates']:
rate['destinations'] = json.loads(rate.pop('destinations_json'))
return result
finally:
connection.close()
def postcode_fee(book_id, postal):
from .db import connect
connection = connect()
try:
connection.row_factory = sqlite3.Row
row = connection.execute('SELECT severity,fee FROM quote_postcodes WHERE book_id=? AND postal=?',
(book_id, postal)).fetchone()
return dict(row) if row else None
finally:
connection.close()
+191
View File
@@ -0,0 +1,191 @@
"""Bounded quote matching and Decimal arithmetic, independent of Flask/models."""
from decimal import Decimal, ROUND_HALF_UP
import re
from .pricing import parse_shipping_intent, detect_dim_unit, dims_to_cm, shipping_region
US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯顿|萨凡纳|'
r'(?<![A-Za-z])(?:USA|(?-i:US)|United States|America)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往|\bto\s+)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))', re.I)
QUOTE_WORDS = re.compile(r'报价|价格|运费|费用|多少钱|计费|包税|海运|空运|海派|海卡|海铁|美森|合德|普船|\bEXX\b|\b(?:quote|price|cost|freight)\b', re.I)
def parse_intent(message, warehouse_codes):
parsed = parse_shipping_intent(message)
country = 'US' if US.search(message) else ('other' if shipping_region(message) is not None else None)
tokens = re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])', message.upper())
warehouse = next((token for token in tokens if token in warehouse_codes), None)
postal = re.search(r'(?<![0-9])(\d{5})(?:-\d{4})?(?![0-9])', message)
cbm = re.search(r'(\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)', message, re.I)
boxes = re.search(r'(\d+)\s*(?:箱|件|cartons?|boxes?)', message, re.I)
origin = '华南' if re.search(r'华南|深圳|东莞', message) else ('义乌' if re.search(r'华东|义乌', message) else None)
if re.search(r'华南|深圳|东莞', message) and re.search(r'华东|义乌', message):
origin = None
services = [name for name in ('美森正班','美森加班','EXX','合德','普船') if name.lower() in message.lower()]
if '美森' in message and not any(name.startswith('美森') for name in services):
services += ['美森正班','美森加班']
kinds = []
if re.search(r'海派|快递|UPS|FEDEX', message, re.I):
kinds.append('parcel')
if re.search(r'海卡|海铁|全卡|卡派|卡车|铁路', message):
kinds.append('truck')
air_only = bool(re.search(r'空运|空派|\bair\b', message, re.I) and not re.search(r'海运|海派|海卡|海铁|\bsea\b', message, re.I))
zone = next((value for value in ('美西','美中','美东','东1区','东2区','中1区','中2区','西1区','西2区') if value in message), None)
each = re.search(r'每(?:箱|件)\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)', message, re.I)
weight = str(parsed['weight']) if parsed['weight'] is not None else None
count = int(boxes.group(1)) if boxes else None
if each and count:
weight = str(Decimal(each.group(1))*count)
return {'country':country, 'weight':weight, 'dims':parsed['dims'], 'dim_unit':detect_dim_unit(message),
'cbm':cbm.group(1) if cbm else None, 'boxes':count, 'origin':origin, 'services':services or None,
'kinds':kinds or None, 'transport':parsed['channel'], 'air_only':True if air_only else None, 'warehouse':warehouse,
'postal':postal.group(1) if postal else None, 'zone':zone,
'tax':'excluded' if re.search(r'不包税|自税|不含税', message) else ('included' if '包税' in message else None)}
def is_followup(message, intent):
if re.search(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
return False
text = message.strip().strip(',,。.!!?? ')
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样)', text):
return True
if intent['warehouse'] and re.fullmatch(r'(?:目的仓(?:是)?|仓码(?:是)?)?\s*[A-Za-z0-9]+(?:仓)?(?:呢)?', text):
return True
if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?', text, re.I):
return True
if re.fullmatch(r'(?:义乌|华东|深圳|华南|美国|美西|美中|美东|东[12]区|西[12]区|中[12]区|不包税|包税)(?:交仓|仓|呢)?', text):
return True
if len(text) <= 45 and not (intent['country'] and intent['weight']) and (
intent['services'] or intent['kinds'] or intent['transport'] or intent['air_only'] or intent['weight'] or intent['dims'] or intent['cbm']):
return True
return bool(re.fullmatch(r'(?:价格|运费|费用|时效|多少钱)(?:呢|是多少|怎么样)?', text))
def broad_zone(postal):
if not postal:
return None
if postal.startswith(('8','9')):
return '美西'
if postal.startswith('46') or postal[0] in '0123':
return '美东'
return '美中'
def variants(intent, book):
warehouse = intent['warehouse']
found = [r for r in book['warehouses'] if r['code'] == warehouse] if warehouse else []
zoned = [r for r in found if r['zone']]
if zoned:
unique = {}
for row in zoned:
postal = intent['postal'] or row['postal'][:5]
key = (row['zone'], postal)
unique.setdefault(key, {'label':f"{warehouse} · {row['zone']}", 'postal':postal,
'zone':broad_zone(postal), 'warehouse':warehouse})
return list(unique.values())
postal = intent['postal']
if not postal and found:
match = re.search(r'(?<![0-9])([0-9]{5})(?:-[0-9]{4})?(?![0-9])', found[0]['address'])
postal = match.group(1) if match else None
zone = intent['zone']
if zone and zone[0] in '东中西':
zone = {'东':'美东','中':'美中','西':'美西'}[zone[0]]
return [{'label':warehouse or postal or zone or '美国', 'postal':postal, 'zone':zone or broad_zone(postal), 'warehouse':warehouse}]
def quantity(intent, rate):
real = Decimal(intent['weight'] or '0')
volume = Decimal(intent['cbm']) if intent['cbm'] else None
if volume is None and intent['dims']:
cm = [Decimal(str(value)) for value in dims_to_cm(intent['dims'], intent['dim_unit'])]
volume = cm[0]*cm[1]*cm[2]/Decimal(1000000)*(intent['boxes'] or 1)
if rate['unit'] == 'cbm':
if volume is None:
return None
return max(volume, real/Decimal(363))
volume_weight = ((volume or Decimal(0))*1000000/6000 if rate['kind']=='parcel'
else (volume or Decimal(0))*167)
charge = max(real, volume_weight)
if rate['kind'] == 'parcel':
charge = max(charge, Decimal(12)*(intent['boxes'] or 1))
return charge if real or volume else None
def match_rates(intent, book, variant):
selected = []
kinds = intent['kinds'] or (['truck','parcel'] if variant['warehouse'] else ['parcel'])
for rate in book['rates']:
if rate['kind'] not in kinds or (intent['origin'] and intent['origin'] != rate['origin']):
continue
if intent['services'] and not any(name in rate['service'] for name in intent['services']):
continue
if intent['tax'] and rate['tax'] != intent['tax']:
continue
if rate['kind'] == 'truck':
if not variant['warehouse'] or variant['warehouse'] not in [value.upper() for value in rate['destinations']]:
continue
elif variant['zone'] and rate['zone'] != variant['zone']:
continue
selected.append(rate)
groups = {}
for rate in selected:
key = (rate['service'],rate['origin'],rate['zone'],rate['unit'],rate['tax'])
charge = quantity(intent,rate)
if rate['kind'] == 'parcel' and Decimal(rate['min_weight']) > (charge or Decimal(12)):
continue
if key not in groups or Decimal(rate['min_weight']) > Decimal(groups[key]['min_weight']):
groups[key] = rate
return list(groups.values())
def number(value, places=2):
return format(Decimal(value).quantize(Decimal(10)**-places, rounding=ROUND_HALF_UP), 'f').rstrip('0').rstrip('.') if places else str(value)
def render_rates(intent, book, destinations, remote_fees):
lines = []
if intent['weight']:
lines.append(f"美国 {number(intent['weight'])}KG 基础运费:")
else:
lines.append('美国线路报价:')
for destination, fees in zip(destinations, remote_fees):
rates = match_rates(intent, book, destination)
if not rates:
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
continue
if destination['label'] != '美国':
lines.append(destination['label']+':')
grouped = {}
for rate in rates:
key = (rate['service'], rate['origin'], rate['unit'], rate['tax'])
grouped.setdefault(key, []).append(rate)
for (service, origin, unit, tax), rows in grouped.items():
parts = []
for rate in rows:
amount = quantity(intent,rate)
price = Decimal(rate['price'])
rate_text = f"{rate['zone']} " if rate['zone'] and not destination['zone'] else ''
rate_text += f"{number(price)}元/{'KG' if unit == 'kg' else 'CBM'}"
if amount is not None:
total = amount*price
if fees and fees['fee'] is not None and rate['kind'] == 'parcel':
total += amount*Decimal(fees['fee'])
rate_text += f",{number(amount,3)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元"
parts.append(rate_text)
eta = re.sub(r'\s*无时效赔偿', '', rows[0]['eta']).strip()
lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
+ (f'({eta})' if eta else ''))
if fees:
if fees['fee'] is None:
lines.append('该邮编属于遥远地址,派送附加费需单独确认。')
else:
lines.append(f"海派已计入偏远附加费 {number(fees['fee'])}元/KG。")
if not any(intent.get(key) for key in ('warehouse','postal')):
lines.append('发我目的仓码或邮编,可继续匹配到仓线路。')
if not intent['weight'] and not intent['cbm'] and not intent['dims']:
lines.append('货物多少公斤、几箱?')
elif not intent['boxes'] or not intent['dims']:
lines.append('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。')
return '\n'.join(lines)
+94
View File
@@ -0,0 +1,94 @@
"""Resolve same-conversation quote edits and return local facts without model I/O."""
from decimal import Decimal, InvalidOperation
import re
import sqlite3
from .db import connect
from .quote_data import load_book, postcode_fee
from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants, render_rates
def intent_with_context(message, conv_id, book):
codes = {row['code'] for row in book['warehouses']}
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
intent = parse_intent(message,codes)
replaced_destination = ({'postal','zone'} if intent['warehouse'] else
({'warehouse','zone'} if intent['postal'] else
({'warehouse','postal'} if intent['zone'] else set())))
if intent['transport'] or intent['kinds'] or intent['services']:
replaced_destination.add('air_only')
destination_selected = any(intent.get(key) for key in ('warehouse','postal','zone'))
if intent['air_only'] or intent['tax'] == 'excluded':
replaced_destination.add('kinds')
if conv_id and is_followup(message,intent):
connection = connect()
try:
recent = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 8", (conv_id,)).fetchall()
finally:
connection.close()
for (previous,) in recent:
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', previous):
break
earlier = parse_intent(previous,codes)
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):
break
if earlier['country'] == 'other':
break
if not destination_selected:
if earlier['warehouse']:
replaced_destination.update(('postal','zone'))
destination_selected = True
elif earlier['postal']:
replaced_destination.update(('warehouse','zone'))
destination_selected = True
elif earlier['zone']:
replaced_destination.update(('warehouse','postal'))
destination_selected = True
for key, value in earlier.items():
if intent.get(key) is None and key not in replaced_destination:
intent[key] = value
if not is_followup(previous, earlier):
break
if intent['warehouse'] and intent['country'] is None:
intent['country'] = 'US'
return intent
def answer_quote(message, conv_id):
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
return None
# Avoid a new read for ordinary chat; short followups need their local context.
if not (US.search(message) or QUOTE_WORDS.search(message) or conv_id or
re.search(r'(?<![A-Za-z0-9])[A-Za-z]{2,4}[0-9](?![A-Za-z0-9])', message)):
return None
try:
book = load_book()
if not book:
return None
intent = intent_with_context(message, conv_id, book)
if intent['country'] != 'US':
return None
if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or
any(intent.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):
return None
for key in ('weight','cbm'):
if intent[key] is not None and not (Decimal(0) < Decimal(intent[key]) <= Decimal(1000000)):
return {'t':'请提供大于 0 的有效重量或体积。'}
if intent['boxes'] is not None and not 0 < intent['boxes'] <= 10000:
return {'t':'请提供有效的货物箱数。'}
if intent['dims'] and any(not 0 < value <= 100000 for value in intent['dims']):
return {'t':'请提供大于 0 的有效长宽高。'}
if intent['air_only']:
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出。'}
destinations = variants(intent,book)
fees = []
for destination in destinations:
postal = destination['postal']
if postal and (postal[:3] in ('006','007','008','009','966','967','968','969','995','996','997','998','999') or postal=='89124'):
return {'t':f'当前海派资料不接收邮编 {postal} 的地址,请提供其他目的仓码或联系人工确认可用线路。'}
fees.append(postcode_fee(book['id'],postal) if postal else None)
return {'t':render_rates(intent,book,destinations,fees)}
except (sqlite3.Error, ValueError, KeyError, TypeError, InvalidOperation):
return {'t':'报价数据暂时无法读取,请稍后重试。'}
+119
View File
@@ -0,0 +1,119 @@
"""Extract only this workbook's local detail cells; never evaluate external links."""
import hashlib
from pathlib import Path
import posixpath
import re
from xml.etree import ElementTree as ET
from zipfile import ZipFile
from .quote_data import decimal_text
NS = {'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
TRUCK_SHEETS = ('美森正班海卡','美森加班海卡','EXX海卡','合德海卡','纽约EXX海铁',
'洛杉矶普船海卡','洛杉矶普船全卡','芝加哥普船海铁','休斯顿普船海卡','纽约普船海卡','萨凡纳普船海卡')
def read_sheets(path):
path = Path(path)
if path.stat().st_size > 20*1024*1024:
raise ValueError('Quote workbook is too large')
with ZipFile(path) as archive:
if sum(info.file_size for info in archive.infolist()) > 100*1024*1024:
raise ValueError('Expanded quote workbook is too large')
strings = []
if 'xl/sharedStrings.xml' in archive.namelist():
for entry in ET.fromstring(archive.read('xl/sharedStrings.xml')):
strings.append(''.join(t.text or '' for t in entry.findall('.//s:t', NS)))
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
rels = {r.attrib['Id']:r.attrib['Target'] for r in ET.fromstring(archive.read('xl/_rels/workbook.xml.rels'))}
result = {}
for sheet in workbook.find('s:sheets', NS):
target = rels[sheet.attrib['{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id']]
target = target.lstrip('/') if target.startswith('/') else posixpath.normpath('xl/'+target)
rows = {}
for cell in ET.fromstring(archive.read(target)).findall('s:sheetData/s:row/s:c', NS):
ref = cell.attrib['r']
column = re.sub('[0-9]', '', ref)
if len(column) != 1 or column > 'Q':
continue
raw = cell.find('s:v', NS)
value = raw.text if raw is not None else ''
if cell.attrib.get('t') == 's':
value = strings[int(value)]
elif cell.attrib.get('t') == 'inlineStr':
value = ''.join(t.text or '' for t in cell.findall('.//s:t', NS))
formula = cell.find('s:f', NS)
if formula is not None and formula.text and re.search(r'\[[0-9]+\]', formula.text):
continue
if value:
rows.setdefault(int(re.sub('[^0-9]', '', ref)), {})[column] = value
result[sheet.attrib['name']] = rows
return result
def export_snapshot(path):
sheets = read_sheets(path)
missing = set(TRUCK_SHEETS + ('海派','FBA分区表','亚马逊仓库代码表','UPS&FedEx偏远地区表')) - sheets.keys()
if missing:
raise ValueError('Quote workbook is missing required local detail sheets')
result = {'schema':1, 'source_name':Path(path).name,
'source_sha256':hashlib.sha256(Path(path).read_bytes()).hexdigest(),
'rates':[], 'warehouses':[], 'postcodes':[], 'documents':[]}
for name in TRUCK_SHEETS:
rows = sheets[name]
destination_col = 'A' if name in TRUCK_SHEETS[:4] else 'B'
price_cols = ('B','C','D') if destination_col == 'A' else ('C','D','E')
eta_col = 'E' if destination_col == 'A' else 'F'
for number, row in rows.items():
if number < 6 or not re.fullmatch(r'\d+(?:\.\d+)?', row.get(price_cols[0], '')):
continue
destinations = [p.strip() for p in re.split(r'[/\n、]+', row.get(destination_col, '')) if p.strip()]
if not destinations:
raise ValueError('Price row is missing destinations')
for column, origin, unit, tax in zip(price_cols, ('义乌','义乌','华南'), ('cbm','kg','kg'), ('excluded','included','included')):
result['rates'].append({'service':name, 'kind':'truck', 'origin':origin, 'zone':'',
'unit':unit, 'tax':tax, 'min_weight':'0', 'price':decimal_text(row[column], positive=True),
'eta':row.get(eta_col, ''), 'destinations':destinations, 'source_sheet':name, 'source_cell':f'{column}{number}'})
parcel = sheets['海派']
for origin, header, first in (('义乌',4,9), ('华南',13,18)):
for start_col in ('C','F','I','L'):
for offset, tier in enumerate(('12','51','101')):
column = chr(ord(start_col)+offset)
for index, zone in enumerate(('美西','美中','美东')):
number = first+index
result['rates'].append({'service':parcel[header][start_col], 'kind':'parcel', 'origin':origin,
'zone':zone, 'unit':'kg', 'tax':'included', 'min_weight':tier,
'price':decimal_text(parcel[number][column], positive=True),
'eta':parcel[header+2][start_col], 'destinations':[], 'source_sheet':'海派', 'source_cell':f'{column}{number}'})
for name, first in (('FBA分区表',3), ('亚马逊仓库代码表',2)):
for number, row in sheets[name].items():
if number < first or not row.get('A'):
continue
is_zone = name == 'FBA分区表'
result['warehouses'].append({'code':row['A'].strip().upper(),
'zone':row.get('B','') if is_zone else '', 'state':row.get('C','') if is_zone else '',
'postal':row.get('D','') if is_zone else '', 'address':row.get('B','') if not is_zone else '',
'source_sheet':name, 'source_cell':f'A{number}'})
for number, row in sheets['UPS&FedEx偏远地区表'].items():
if number <= 23:
continue
for column, severity, fee in (('B','remote','1'), ('F','extended','2'), ('J','extreme',None)):
postal = row.get(column, '')
if postal:
result['postcodes'].append({'postal':postal, 'severity':severity, 'fee':fee})
for name, rows in sheets.items():
if name in ('快速查询','UPS&FedEx偏远地区表','FBA分区表','亚马逊仓库代码表'):
continue
for number, row in rows.items():
if number <= 1 or (name in TRUCK_SHEETS and number <= 5):
continue
# Long detail text contains rules; short lookup rows contain service fees.
for column, value in row.items():
if len(value) >= 20 and not re.search(r'公司地址|TEL:|船名航次|预计开船日期|关税查询网址', value):
result['documents'].append({'sheet':name, 'cell':f'{column}{number}', 'text':value})
if name in ('一件代发收费','FDA申报清单'):
text = ';'.join(row.values())
result['documents'].append({'sheet':name,'cell':f'A{number}','text':text})
return result
+2 -2
View File
@@ -2,6 +2,6 @@
def register_routes(app): def register_routes(app):
from . import admin, business, chat, files, history, human, pages, speech, visitor from . import admin, business, chat, customer, files, history, human, pages, speech, visitor
for module in (admin, business, chat, files, history, human, pages, speech, visitor): for module in (admin, business, chat, customer, files, history, human, pages, speech, visitor):
app.register_blueprint(module.bp) app.register_blueprint(module.bp)
+1 -1
View File
@@ -13,7 +13,7 @@ def my_orders():
@bp.route('/api/ticket', methods=['POST']) @bp.route('/api/ticket', methods=['POST'])
def add_ticket(): def add_ticket():
return business_service.add_ticket(request.get_json() or {}) return business_service.add_ticket(request.get_json(silent=True))
@bp.route('/api/tickets') @bp.route('/api/tickets')
+1 -1
View File
@@ -45,4 +45,4 @@ def stream_chat():
@bp.route('/api/feedback', methods=['POST']) @bp.route('/api/feedback', methods=['POST'])
def feedback(): def feedback():
return business_service.feedback(request.get_json(silent=True) or {}) return business_service.feedback(request.get_json(silent=True))
+27
View File
@@ -0,0 +1,27 @@
"""Public, visitor-bound customer login endpoints. No staff privileges."""
from flask import Blueprint, current_app, request
from werkzeug.exceptions import RequestEntityTooLarge
from .. import customer_service
bp = Blueprint('customer', __name__)
@bp.get('/api/customer/session')
def session():
return customer_service.session_status()
@bp.post('/api/customer/login')
def login():
request.max_content_length = 2048
try:
return customer_service.login(request.get_json(silent=True), current_app.extensions['customer_login_attempts'],
current_app.extensions['customer_login_lock'])
except RequestEntityTooLarge:
return {'ok':False,'error':'登录信息过长。'}, 413
@bp.post('/api/customer/logout')
def logout():
return customer_service.logout()
+15 -4
View File
@@ -1,12 +1,17 @@
"""HTTP adapters for files; business logic lives in services.""" """HTTP adapters for files; business logic lives in services."""
from flask import Blueprint, abort, request, send_from_directory from flask import Blueprint, render_template, request, send_from_directory
from werkzeug.exceptions import NotFound
from .. import file_service, visitor_service from .. import file_service, visitor_service
from ..config import get_setting from ..config import get_setting
bp = Blueprint('files', __name__) bp = Blueprint('files', __name__)
def unavailable(staff=False):
return render_template('attachment_unavailable.html', staff=staff), 404, {'Cache-Control':'private, no-store'}
@bp.route('/api/upload', methods=['POST']) @bp.route('/api/upload', methods=['POST'])
def upload_file(): def upload_file():
return file_service.upload_file(request.files) return file_service.upload_file(request.files)
@@ -15,14 +20,20 @@ def upload_file():
@bp.route('/uploads/<path:name>') @bp.route('/uploads/<path:name>')
def uploaded_file(name): def uploaded_file(name):
if not visitor_service.owns_upload(name): if not visitor_service.owns_upload(name):
abort(404) return unavailable()
response = send_from_directory(get_setting('UPLOAD_DIR'), name) try:
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
except NotFound:
return unavailable()
response.headers['Cache-Control'] = 'private, no-store' response.headers['Cache-Control'] = 'private, no-store'
return response return response
@bp.route('/api/staff/uploads/<path:name>') @bp.route('/api/staff/uploads/<path:name>')
def staff_uploaded_file(name): def staff_uploaded_file(name):
response = send_from_directory(get_setting('UPLOAD_DIR'), name) try:
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
except NotFound:
return unavailable(staff=True)
response.headers['Cache-Control'] = 'private, no-store' response.headers['Cache-Control'] = 'private, no-store'
return response return response
+23 -4
View File
@@ -15,6 +15,7 @@ CUSTOMER_PATHS = {
'/stream_chat', '/api/new_chat', '/api/get_history', '/api/get_messages', '/api/del_history', '/stream_chat', '/api/new_chat', '/api/get_history', '/api/get_messages', '/api/del_history',
'/api/clear_all', '/api/upload', '/api/ticket', '/api/rate', '/api/feedback', '/api/clear_all', '/api/upload', '/api/ticket', '/api/rate', '/api/feedback',
'/api/speech/config', '/api/speech/transcribe', '/api/speech/config', '/api/speech/transcribe',
'/api/customer/session', '/api/customer/login', '/api/customer/logout',
} }
@@ -27,7 +28,7 @@ def visitor_id():
def customer_tenant_id(): def customer_tenant_id():
# Future login adapters may set this only after validating a customer session. # Set only after validating a server-side customer session.
# Visitor cookies, request parameters and headers are deliberately not accepted. # Visitor cookies, request parameters and headers are deliberately not accepted.
value = getattr(g, 'customer_tenant_id', None) if has_app_context() else None value = getattr(g, 'customer_tenant_id', None) if has_app_context() else None
if isinstance(value, bool) or not isinstance(value, (str, int)): if isinstance(value, bool) or not isinstance(value, (str, int)):
@@ -36,6 +37,19 @@ def customer_tenant_id():
return tenant if tenant and len(tenant) <= 128 else None return tenant if tenant and len(tenant) <= 128 else None
def customer_tenant_ids():
if not has_app_context():
return ()
# Also retain compatibility with a trusted single-tenant authentication adapter.
single = customer_tenant_id()
if single:
return (single,)
values = getattr(g,'customer_tenant_ids',())
if not isinstance(values,(list,tuple)) or len(values) > 256:
return ()
return tuple(sorted(set(value for value in values if isinstance(value,str) and 0 < len(value.strip()) <= 128)))
def attach_visitor(): def attach_visitor():
key = request.cookies.get(COOKIE_NAME) key = request.cookies.get(COOKIE_NAME)
g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None
@@ -65,25 +79,30 @@ def start_session(data):
def owns_conversation(conv_id): def owns_conversation(conv_id):
from .customer_service import current_customer_id, current_scope_key
owner = visitor_id() owner = visitor_id()
if not owner or not isinstance(conv_id, int) or isinstance(conv_id, bool) or not 0 < conv_id <= 9223372036854775807: if not owner or not isinstance(conv_id, int) or isinstance(conv_id, bool) or not 0 < conv_id <= 9223372036854775807:
return False return False
connection = connect() connection = connect()
try: try:
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=?', return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
(conv_id, owner)).fetchone()) 'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
(conv_id, owner, current_customer_id(),current_scope_key())).fetchone())
finally: finally:
connection.close() connection.close()
def owns_human_session(sid): def owns_human_session(sid):
from .customer_service import current_customer_id, current_scope_key
owner = visitor_id() owner = visitor_id()
if not owner or not isinstance(sid, int) or isinstance(sid, bool) or not 0 < sid <= 9223372036854775807: if not owner or not isinstance(sid, int) or isinstance(sid, bool) or not 0 < sid <= 9223372036854775807:
return False return False
connection = connect() connection = connect()
try: try:
return bool(connection.execute('SELECT 1 FROM human_sessions s JOIN chat_history h ON h.id=s.conv_id ' return bool(connection.execute('SELECT 1 FROM human_sessions s JOIN chat_history h ON h.id=s.conv_id '
'WHERE s.id=? AND h.visitor_id=?', (sid, owner)).fetchone()) 'WHERE s.id=? AND h.visitor_id=? AND (h.customer_id IS NULL OR '
'(h.customer_id=? AND h.customer_scope=?))',
(sid, owner, current_customer_id(),current_scope_key())).fetchone())
finally: finally:
connection.close() connection.close()
+40
View File
@@ -0,0 +1,40 @@
"""Export local workbook details or import a private quote snapshot into SQLite."""
import argparse
from contextlib import closing
import json
from pathlib import Path
import sqlite3
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from logistics_chat.quote_data import import_snapshot
from logistics_chat.quote_workbook import export_snapshot
def main():
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument('--xlsx', type=Path)
source.add_argument('--snapshot', type=Path)
parser.add_argument('--output', type=Path, help='New private snapshot output for --xlsx')
parser.add_argument('--db', type=Path)
parser.add_argument('--activate', action='store_true', help='Use this version as general US rates without expiry')
args = parser.parse_args()
if args.xlsx:
if not args.output or args.db or args.output.exists():
parser.error('--xlsx requires a new --output path, without --db')
snapshot = export_snapshot(args.xlsx)
with args.output.open('x', encoding='utf-8') as target:
json.dump(snapshot, target, ensure_ascii=False, separators=(',', ':'))
print(json.dumps({key:len(snapshot[key]) for key in ('rates','warehouses','postcodes','documents')}))
return
if not args.db or not args.db.is_file() or not args.snapshot.is_file() or args.snapshot.stat().st_size > 20*1024*1024:
parser.error('Import requires an existing database and a private snapshot smaller than 20 MB')
snapshot = json.loads(args.snapshot.read_text(encoding='utf-8-sig'))
with closing(sqlite3.connect(args.db)) as connection, connection:
print(json.dumps(import_snapshot(connection, snapshot, activate=args.activate)))
if __name__ == '__main__':
main()
+33 -25
View File
@@ -42,7 +42,21 @@ 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; } .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 { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; }
.clear-all-btn:hover { color: var(--danger); } .clear-all-btn:hover { color: var(--danger); }
.sidebar-note { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; } .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-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); }
.customer-login-dialog h2 { margin: 0 0 20px; font-size: 20px; }
.customer-login-dialog label { display: block; margin: 14px 0 6px; font-size: 14px; }
.customer-login-dialog input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 16px; }
.customer-login-dialog input:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); }
#customer-error, #order-lookup-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
.customer-login-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
.customer-login-actions button { min-height: 44px; min-width: 76px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); }
#customer-submit, #order-lookup-submit { border-color: var(--brand); background: var(--brand); color: white; }
.order-reference-field { display: flex; align-items: center; gap: 8px; }
.order-reference-field input { min-width: 0; flex: 1; }
#order-reference-copy { flex-shrink: 0; min-width: 60px; min-height: 44px; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--brand-text); }
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; } .left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
.main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; } .main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; } .chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; }
@@ -64,7 +78,6 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.ai-avatar svg { width: 27px; height: 27px; } .ai-avatar svg { width: 27px; height: 27px; }
.welcome-eyebrow { color: var(--brand-text); font-size: 11px; font-weight: 600; letter-spacing: .06em; margin-bottom: 9px; } .welcome-eyebrow { color: var(--brand-text); font-size: 11px; font-weight: 600; letter-spacing: .06em; margin-bottom: 9px; }
.ai-welcome-card h3 { font-size: clamp(22px, 2.2vw, 30px); line-height: 1.4; letter-spacing: -.035em; font-weight: 650; color: var(--text); } .ai-welcome-card h3 { font-size: clamp(22px, 2.2vw, 30px); line-height: 1.4; letter-spacing: -.035em; font-weight: 650; color: var(--text); }
.ai-welcome-card p { color: var(--secondary); font-size: 13px; line-height: 1.8; margin-top: 10px; }
.suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 28px 0 0; } .suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 28px 0 0; }
.suggest-item { display: flex; align-items: center; gap: 13px; min-height: 83px; padding: 17px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); color: var(--text); text-align: left; width: 100%; } .suggest-item { display: flex; align-items: center; gap: 13px; min-height: 83px; padding: 17px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); color: var(--text); text-align: left; width: 100%; }
.suggest-item:hover { border-color: #9ab6f5; background: var(--brand-soft); box-shadow: 0 3px 12px rgba(49, 95, 213, .04); } .suggest-item:hover { border-color: #9ab6f5; background: var(--brand-soft); box-shadow: 0 3px 12px rgba(49, 95, 213, .04); }
@@ -72,7 +85,6 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.suggest-icon svg { width: 19px; height: 19px; } .suggest-icon svg { width: 19px; height: 19px; }
.suggest-copy { min-width: 0; } .suggest-copy { min-width: 0; }
.suggest-copy strong { display: block; font-size: 14px; font-weight: 600; } .suggest-copy strong { display: block; font-size: 14px; font-weight: 600; }
.suggest-copy small { display: block; color: var(--secondary); font-size: 12px; margin-top: 4px; line-height: 1.6; }
.suggest-arrow { margin-left: auto; color: var(--muted); font-size: 17px; } .suggest-arrow { margin-left: auto; color: var(--muted); font-size: 17px; }
.hot-questions { margin-top: 24px; } .hot-questions { margin-top: 24px; }
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; } .hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
@@ -86,8 +98,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.recent-item:hover { color: var(--brand-text); } .recent-item:hover { color: var(--brand-text); }
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; } .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 { 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: flex-start; } .composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
.composer-toolbar .hot-bar { flex: 1; min-width: 0; } .composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
.voice-toggle, .voice-hold { 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 { 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; } .voice-panel p { margin: 9px 0; font-size: 13px; }
@@ -97,7 +109,6 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; } .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 { 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; } .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-label { font-size: 10px; color: var(--muted); margin-right: 3px; }
.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; } .hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; }
.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 { 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); } .input-area:focus-within { border-color: #7f9fe8; box-shadow: 0 0 0 3px var(--brand-soft); }
@@ -110,12 +121,10 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; } .human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; } #input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
#input-text::placeholder { color: var(--muted); } #input-text::placeholder { color: var(--muted); }
#input-text:placeholder-shown { white-space: nowrap; overflow: hidden; }
#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; } #send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; }
#send-button:hover { background: var(--brand-hover); } #send-button:hover { background: var(--brand-hover); }
#send-button.stop { background: var(--danger); } #send-button.stop { background: var(--danger); }
.composer-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 2px 0; }
.disclaimer, .keyboard-hint { color: var(--muted); font-size: 11px; }
.keyboard-hint { white-space: nowrap; }
.msg-row { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; max-width: 820px; margin: 0 auto 24px; } .msg-row { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; max-width: 820px; margin: 0 auto 24px; }
.msg-row.user-line { align-items: flex-end; } .msg-row.user-line { align-items: flex-end; }
.message-author { display: flex; align-items: center; gap: 8px; min-height: 24px; margin-bottom: 4px; color: var(--secondary); font-size: 12px; font-weight: 600; } .message-author { display: flex; align-items: center; gap: 8px; min-height: 24px; margin-bottom: 4px; color: var(--secondary); font-size: 12px; font-weight: 600; }
@@ -132,6 +141,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); } .msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); }
.msg-action-btn { min-height: 30px; border: 0; padding: 3px 8px; color: var(--secondary); background: transparent; border-radius: 5px; font-size: 12px; } .msg-action-btn { min-height: 30px; border: 0; padding: 3px 8px; color: var(--secondary); background: transparent; border-radius: 5px; font-size: 12px; }
.msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); } .msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); }
.vote-status { align-self: center; color: var(--muted); font-size: 11px; }
.vote-status.is-error { color: var(--danger); }
.error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); } .error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); }
.err-line { margin-bottom: 9px; } .err-line { margin-bottom: 9px; }
.retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; } .retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; }
@@ -142,6 +153,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.robot-message.track-card { width: min(560px, 100%); max-width: 100%; background: var(--surface); padding: 16px 20px; } .robot-message.track-card { width: min(560px, 100%); max-width: 100%; background: var(--surface); padding: 16px 20px; }
.track-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 14px; } .track-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 14px; }
.track-no { font-weight: 650; font-size: 17px; letter-spacing: .02em; } .track-no { font-weight: 650; font-size: 17px; letter-spacing: .02em; }
.order-reference { display: block; max-width: 100%; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--brand-text); font: inherit; text-align: left; overflow-wrap: anywhere; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
.order-reference:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 3px; }
.track-no span { display: block; font-size: 10px; letter-spacing: 0; font-weight: 400; color: var(--muted); margin-bottom: 3px; } .track-no span { display: block; font-size: 10px; letter-spacing: 0; font-weight: 400; color: var(--muted); margin-bottom: 3px; }
.track-status { padding: 4px 10px; color: var(--success); background: var(--success-soft); border-radius: 6px; font-size: 12px; white-space: nowrap; } .track-status { padding: 4px 10px; color: var(--success); background: var(--success-soft); border-radius: 6px; font-size: 12px; white-space: nowrap; }
.track-node { position: relative; padding: 0 0 12px 22px; font-size: 13px; color: var(--secondary); } .track-node { position: relative; padding: 0 0 12px 22px; font-size: 13px; color: var(--secondary); }
@@ -159,7 +172,6 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.track-expand { width: 100%; margin-top: 3px; padding: 7px 10px; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; } .track-expand { width: 100%; margin-top: 3px; padding: 7px 10px; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; }
.track-expand:hover { background: var(--brand-soft); } .track-expand:hover { background: var(--brand-soft); }
.track-card .msg-actions { margin-top: 8px; padding-top: 8px; } .track-card .msg-actions { margin-top: 8px; padding-top: 8px; }
.order-data-notice { margin: 12px 0; padding: 10px; border-radius: 7px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; line-height: 1.8; }
.order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; } .order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; }
.order-data-fields dt { color: var(--muted); } .order-data-fields dt { color: var(--muted); }
.order-data-fields dd { margin: 0; overflow-wrap: anywhere; } .order-data-fields dd { margin: 0; overflow-wrap: anywhere; }
@@ -177,7 +189,6 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.attachment-file-icon { display: grid; place-items: center; flex-shrink: 0; width: 42px; height: 42px; font-size: 10px; font-weight: 650; border-radius: 7px; background: var(--brand-soft); color: var(--brand-text); } .attachment-file-icon { display: grid; place-items: center; flex-shrink: 0; width: 42px; height: 42px; font-size: 10px; font-weight: 650; border-radius: 7px; background: var(--brand-soft); color: var(--brand-text); }
.attachment-copy { flex: 1; min-width: 0; } .attachment-copy { flex: 1; min-width: 0; }
.attachment-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; } .attachment-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; }
.attachment-copy small { display: block; font-size: 10px; color: var(--muted); margin-top: 2px; }
.attachment-remove { flex-shrink: 0; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 7px; color: var(--secondary); background: transparent; font-size: 21px; } .attachment-remove { flex-shrink: 0; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 7px; color: var(--secondary); background: transparent; font-size: 21px; }
.attachment-remove:hover { color: var(--danger); background: var(--danger-soft); } .attachment-remove:hover { color: var(--danger); background: var(--danger-soft); }
.composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; } .composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; }
@@ -189,7 +200,6 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.ai-welcome-card { padding-top: 0; } .ai-welcome-card { padding-top: 0; }
.ai-avatar { width: 42px; height: 42px; margin-bottom: 12px; } .ai-avatar { width: 42px; height: 42px; margin-bottom: 12px; }
.welcome-eyebrow { margin-bottom: 6px; } .welcome-eyebrow { margin-bottom: 6px; }
.ai-welcome-card p { margin-top: 8px; }
.suggest-grid { margin-top: 20px; gap: 10px; } .suggest-grid { margin-top: 20px; gap: 10px; }
.suggest-item { min-height: 72px; padding: 13px 16px; } .suggest-item { min-height: 72px; padding: 13px 16px; }
.hot-questions { margin-top: 18px; } .hot-questions { margin-top: 18px; }
@@ -202,7 +212,6 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.composer { width: calc(100% - 50px); } .composer { width: calc(100% - 50px); }
.service-label { display: none; } .service-label { display: none; }
.suggest-item { padding: 13px; gap: 10px; } .suggest-item { padding: 13px; gap: 10px; }
.suggest-copy small { font-size: 11px; }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; } body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
@@ -223,9 +232,15 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
#input-text, .history-search { font-size: max(16px, 1em); } #input-text, .history-search { font-size: max(16px, 1em); }
.history-search { height: 44px; } .history-search { height: 44px; }
.history-search-wrap svg { top: 14px; } .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; }
.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; }
#input-text { min-height: 44px; padding-top: 10px; padding-bottom: 10px; line-height: 1.5; }
.user-message, .robot-message { max-width: 94%; } .user-message, .robot-message { max-width: 94%; }
.del-history-btn { opacity: 1; } .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; } .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; }
.input-area.voice-mode #input-text, .input-area.voice-mode .emoji-control, .input-area.voice-mode #send-button { display: none; } .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; } .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); } .voice-hold.is-recording { color: var(--brand-text); background: var(--brand-soft); }
@@ -241,12 +256,10 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.ai-avatar { display: none; } .ai-avatar { display: none; }
.ai-welcome-card { padding-top: 12px; } .ai-welcome-card { padding-top: 12px; }
.ai-welcome-card h3 { font-size: 23px; } .ai-welcome-card h3 { font-size: 23px; }
.ai-welcome-card p { max-width: 300px; margin: 10px auto 0; font-size: 12px; }
.suggest-grid { gap: 9px; margin-top: 22px; } .suggest-grid { gap: 9px; margin-top: 22px; }
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 114px; padding: 13px; gap: 9px; } .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-icon { width: 32px; height: 32px; border-radius: 8px; }
.suggest-copy strong { font-size: 13px; } .suggest-copy strong { font-size: 13px; }
.suggest-copy small { font-size: 11px; }
.suggest-arrow { display: none; } .suggest-arrow { display: none; }
.hot-questions { margin-top: 22px; } .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-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; }
@@ -254,18 +267,13 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.hot-capsule { font-size: 10px; padding: 6px 8px; } .hot-capsule { font-size: 10px; padding: 6px 8px; }
#recentChatWrap { grid-template-columns: 1fr; gap: 0; } #recentChatWrap { grid-template-columns: 1fr; gap: 0; }
.composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); } .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; margin-bottom: 8px; } .hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; }
.hot-bar-label { display: none; } .hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; padding: 6px 9px; font-size: 11px; }
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; min-height: 32px; padding: 6px 9px; font-size: 11px; }
.input-area { gap: 7px; padding: 7px; } .input-area { gap: 7px; padding: 7px; }
#send-button { min-width: 57px; padding: 0 12px; } #send-button { min-width: 57px; padding: 0 12px; }
#upload-btn { flex-basis: 36px; height: 42px; } #upload-btn { flex-basis: 36px; }
#input-text { font-size: 16px; } #input-text { font-size: 16px; }
#send-button { min-height: 42px; }
.attachment-remove { width: 40px; height: 40px; } .attachment-remove { width: 40px; height: 40px; }
.keyboard-hint { display: none; }
.composer-footer { justify-content: center; padding-top: 7px; }
.disclaimer { font-size: 10px; }
.track-text { grid-template-columns: 1fr; gap: 2px; } .track-text { grid-template-columns: 1fr; gap: 2px; }
.track-place { grid-column: 1; } .track-place { grid-column: 1; }
.robot-message.track-card { padding: 16px; } .robot-message.track-card { padding: 16px; }
+1
View File
@@ -81,6 +81,7 @@ svg { display: block; flex-shrink: 0; }
.emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); } .emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
.emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; } .emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
.emoji-picker button:hover { background: var(--surface-hover); } .emoji-picker button:hover { background: var(--surface-hover); }
.attachment-unavailable { display: block; max-width: 260px; padding: 12px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--secondary); font-size: 12px; line-height: 1.6; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; } .empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
::-webkit-scrollbar { width: 5px; height: 5px; } ::-webkit-scrollbar { width: 5px; height: 5px; }
+1
View File
@@ -11,6 +11,7 @@ var typeTimer = null;
var toolsOpen = false; var toolsOpen = false;
var toolsOpener = null; var toolsOpener = null;
var replyInput = document.getElementById("replyText"); var replyInput = document.getElementById("replyText");
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
function escapeText(value){ function escapeText(value){
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){ return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
+300 -103
View File
@@ -6,6 +6,29 @@ const sendBtn = document.getElementById("send-button");
const uploadBtn = document.getElementById("upload-btn"); const uploadBtn = document.getElementById("upload-btn");
const uploadInput = document.getElementById("upload-input"); const uploadInput = document.getElementById("upload-input");
const composerStatus = document.getElementById("composer-status"); const composerStatus = document.getElementById("composer-status");
const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
let draftTimer = null;
let draftSnapshot = null;
function flushDraft(){
if(draftTimer !== null){ clearTimeout(draftTimer); draftTimer = null; }
const saved = drafts.write(currentConvId, inputDom.value);
drafts.active(currentConvId);
if(!saved && inputDom.value && !composerStatus.classList.contains('is-error')) setComposerStatus(t('draftSaveFailed'), true);
else if(saved && composerStatus.textContent === t('draftSaveFailed')) setComposerStatus('');
}
function restoreDraft(){
inputDom.value = drafts.read(currentConvId);
draftSnapshot = String(currentConvId) + '\n' + inputDom.value;
drafts.active(currentConvId);
autoGrow();
}
function queueDraft(){
const snapshot = String(currentConvId) + '\n' + inputDom.value;
if(snapshot === draftSnapshot) return;
draftSnapshot = snapshot;
if(draftTimer !== null) clearTimeout(draftTimer);
draftTimer = setTimeout(flushDraft, 250);
}
function focusComposer(force){ function focusComposer(force){
if(force || !isNarrow() || document.activeElement === inputDom){ if(force || !isNarrow() || document.activeElement === inputDom){
inputDom.focus({preventScroll:true}); inputDom.focus({preventScroll:true});
@@ -17,6 +40,10 @@ let uploadController = null;
let historyRequestVersion = 0; let historyRequestVersion = 0;
let followLatest = true; let followLatest = true;
let voiceInput = null; let voiceInput = null;
let customerLogin = null;
let orderLookup = null;
let lookupRequestVersion = 0;
let customerRecovery = null;
let creatingConv = null; let creatingConv = null;
let sendPreparing = false; let sendPreparing = false;
let conversationVersion = 0; let conversationVersion = 0;
@@ -28,7 +55,8 @@ let humanPollTimer = null;
let humanSendingToken = null; let humanSendingToken = null;
let humanClosing = false; let humanClosing = false;
window.humanSid = 0; window.humanSid = 0;
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); }); 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 = function(){ SupportUI.enableNotifications(this); };
function setComposerStatus(message, isError){ function setComposerStatus(message, isError){
@@ -118,9 +146,7 @@ uploadInput.onchange = function(){
copy.className = "attachment-copy"; copy.className = "attachment-copy";
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = data.name; name.textContent = data.name;
const hint = document.createElement("small"); copy.append(name);
hint.textContent = t("attachmentReady");
copy.append(name, hint);
const remove = document.createElement("button"); const remove = document.createElement("button");
remove.type = "button"; remove.type = "button";
remove.className = "attachment-remove"; remove.className = "attachment-remove";
@@ -205,6 +231,16 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
const I18N = { const I18N = {
zh: { zh: {
appTitle: "柏悦物流智能助手", appTitle: "柏悦物流智能助手",
customerLogin: "登录", customerLogout: "退出登录", customerCancel: "取消",
customerUsername: "账号", customerPassword: "密码", customerQuery: "登录查询",
customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码",
customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。",
customerLogoutFailed: "退出失败,请重试。",
lookupTitle: "查入仓码", lookupOrderTitle: "查询订单", lookupPlaceholder: "请输入入仓码",
lookupOrderPlaceholder: "请输入订单号", lookupSubmit: "查询轨迹", lookupSending: "查询中…",
lookupInvalid: "请输入完整的入仓码或订单号。", lookupFailed: "查询未发送成功,请重试。",
lookupBusy: "请等待当前操作完成后再查询。", lookupAIOnly: "请先返回 AI 问答,再查询订单轨迹。",
lookupAction: "查询或复制",
newChat: "新建对话", newChat: "新建对话",
clearAll: "清空全部历史", clearAll: "清空全部历史",
history: "历史对话", history: "历史对话",
@@ -216,8 +252,8 @@ const I18N = {
thinking: "正在思考中...", thinking: "正在思考中...",
busyRetry: "服务繁忙,请稍后重试", busyRetry: "服务繁忙,请稍后重试",
sessionFail: "会话创建失败,请确认服务已启动", sessionFail: "会话创建失败,请确认服务已启动",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。", autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。",
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。", humanHours: "人工客服工作时间:每天北京时间 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
offlinePrompt: "请输入您的留言:", offlinePrompt: "请输入您的留言:",
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。", offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
humanClosed: "人工客服已结束,您可以继续向 AI 提问。", humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
@@ -226,13 +262,15 @@ const I18N = {
humanWaiting: "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。", humanWaiting: "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。",
humanActive: "人工客服接待中,消息将发送给人工客服。", humanActive: "人工客服接待中,消息将发送给人工客服。",
returnAI: "返回 AI 问答", returnAI: "返回 AI 问答",
humanSent: "已发送给人工客服",
humanSending: "正在发送给人工客服…", humanSending: "正在发送给人工客服…",
humanSendFailed: "消息未发送成功,内容已保留,请重试。", humanSendFailed: "消息未发送成功,内容已保留,请重试。",
humanConnectFailed: "人工客服连接失败,请稍后重试。", humanConnectFailed: "人工客服连接失败,请稍后重试。",
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。", humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
humanReturnFailed: "暂时无法结束人工会话,请重试。", humanReturnFailed: "暂时无法结束人工会话,请重试。",
historyLoading: "正在加载这段会话,请稍候…", historyLoading: "正在加载这段会话,请稍候…",
historyLoadFailed: "会话未加载成功,请重新选择该会话重试,或新建对话。",
draftSaveFailed: "草稿暂时无法保存到当前浏览器,刷新前请保留输入内容。",
attachmentUnavailable: "图片暂时无法访问,请重新发送附件。",
recalled: "该消息已撤回", recalled: "该消息已撤回",
ratingQuestion: "本次服务满意吗?", ratingQuestion: "本次服务满意吗?",
ratingThanks: "感谢您的评价!", ratingThanks: "感谢您的评价!",
@@ -240,27 +278,25 @@ const I18N = {
networkFail: "网络连接中断,请检查网络后重试", networkFail: "网络连接中断,请检查网络后重试",
stopped: "已停止生成", stopped: "已停止生成",
stoppedMark: "(已停止生成)", stoppedMark: "(已停止生成)",
inputPh: "请输入问题、订单号或入仓码…(Enter 发送,Shift+Enter 换行)", inputPh: "输入入仓码或问题…",
send: "发送", send: "发送",
stop: "停止", stop: "停止",
copy: "复制", copy: "复制",
copied: "已复制", copied: "已复制",
copyFailed: "复制失败",
voteUp: "这条回复有帮助",
voteDown: "这条回复没有帮助",
retry: "重答", retry: "重答",
retryAIOnly: "请先返回 AI 问答,再重答这条消息。", retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
clickRetry: "点击重试", clickRetry: "点击重试",
welcomeTitle: "物流问题,随时为您解答", welcomeTitle: "物流问题,随时为您解答",
welcomeSub: "咨询运费、时效或联系人工客服。订单查询需关联客户账号。",
welcomeEyebrow: "中欧 · 英国物流服务", welcomeEyebrow: "中欧 · 英国物流服务",
serviceScope: "中欧与英国专线咨询",
serviceTag: "物流咨询服务", serviceTag: "物流咨询服务",
quickLabel: "快捷咨询", footerTrack: "查入仓码",
footerTrack: "查订单",
footerQuote: "专线报价", footerQuote: "专线报价",
footerException: "异常处理", footerException: "异常处理",
footerHuman: "联系人工", footerHuman: "联系人工",
disclaimer: "报价与时效仅供参考,请以客服最终确认为准", inputShort: "输入入仓码…",
keyboardHint: "Enter 发送 · Shift + Enter 换行",
inputShort: "输入问题、订单号或入仓码…",
uploading: "附件正在上传,请稍候…", uploading: "附件正在上传,请稍候…",
uploadFailed: "附件上传失败,请重新选择文件", uploadFailed: "附件上传失败,请重新选择文件",
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。", serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
@@ -276,7 +312,6 @@ const I18N = {
voiceEmpty: "没有识别到文字,请靠近麦克风重试。", voiceFailed: "语音输入失败,请重试或使用文字输入。", voiceEmpty: "没有识别到文字,请靠近麦克风重试。", voiceFailed: "语音输入失败,请重试或使用文字输入。",
voiceTimeout: "语音处理超时,请重试。", voiceWait: "请等待当前操作完成后,再使用语音输入。", voiceTimeout: "语音处理超时,请重试。", voiceWait: "请等待当前操作完成后,再使用语音输入。",
voiceDraft: "输入框还有未发送内容,请先发送或清空后再使用语音。", voiceDraft: "输入框还有未发送内容,请先发送或清空后再使用语音。",
attachmentReady: "已准备好,发送后将附在消息中",
removeAttachment: "移除待发送附件", removeAttachment: "移除待发送附件",
you: "您", you: "您",
assistant: "柏悦物流助手", assistant: "柏悦物流助手",
@@ -290,15 +325,12 @@ const I18N = {
logisticsHistory: "物流轨迹", logisticsHistory: "物流轨迹",
noLogisticsHistory: "暂无已发生的物流节点", noLogisticsHistory: "暂无已发生的物流节点",
estimatedTracking: "预计节点", estimatedTracking: "预计节点",
legacyTrackingNotice: "历史模拟轨迹,仅供演示,不代表真实订单。", order_inbound: "入仓码", order_number: "订单号", order_shipment: "Shipment ID", order_country: "目的国",
order_inbound: "入仓码", order_shipment: "Shipment ID", order_country: "目的国",
order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数", order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数",
order_weight: "申报毛重", order_volume: "申报体积", order_role: "订单层级", order_weight: "申报毛重", order_volume: "申报体积", order_role: "订单层级",
order_parent: "父订单号", order_updated: "来源更新时间", order_parent: "父订单号", order_updated: "来源更新时间",
moreTracking: "展开全部轨迹", moreTracking: "展开全部轨迹",
lessTracking: "收起较早轨迹", lessTracking: "收起较早轨迹",
quickSubs: ["关联客户账号后查询订单与轨迹", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
hot: "热点问题", hot: "热点问题",
recent: "最近对话记录", recent: "最近对话记录",
sideCollapse: "收起侧栏", sideCollapse: "收起侧栏",
@@ -307,7 +339,7 @@ const I18N = {
langTip: "中英文切换(Ctrl+L)", langTip: "中英文切换(Ctrl+L)",
toBottom: "回到底部", toBottom: "回到底部",
quick: [ quick: [
"查询订单状态", "欧洲海运专线报价", "包裹异常处理", "转接人工客服" "按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
], ],
hotList: [ hotList: [
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?" "如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
@@ -315,6 +347,16 @@ const I18N = {
}, },
en: { en: {
appTitle: "Baiyue Logistics AI Assistant", appTitle: "Baiyue Logistics AI Assistant",
customerLogin: "Sign in", customerLogout: "Sign out", customerCancel: "Cancel",
customerUsername: "Username", customerPassword: "Password", customerQuery: "Sign in to view orders",
customerUsernamePlaceholder: "Enter username", customerPasswordPlaceholder: "Enter password",
customerSigningIn: "Signing in…", customerLoginFailed: "Could not sign in. Please try again.",
customerLogoutFailed: "Could not sign out. Please try again.",
lookupTitle: "Track by inbound code", lookupOrderTitle: "Look up an order", lookupPlaceholder: "Enter an inbound code",
lookupOrderPlaceholder: "Enter an order number", lookupSubmit: "Track shipment", lookupSending: "Sending…",
lookupInvalid: "Enter a complete inbound code or order number.", lookupFailed: "Could not send the lookup. Please retry.",
lookupBusy: "Wait for the current action to finish before looking up an order.", lookupAIOnly: "Return to AI chat before looking up an order.",
lookupAction: "Look up or copy",
newChat: "New Chat", newChat: "New Chat",
clearAll: "Clear All History", clearAll: "Clear All History",
history: "History", history: "History",
@@ -326,8 +368,8 @@ const I18N = {
thinking: "Thinking...", thinking: "Thinking...",
busyRetry: "Service busy, please try again later", busyRetry: "Service busy, please try again later",
sessionFail: "Failed to create session, please check the service is running", sessionFail: "Failed to create session, please check the service is running",
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support. Order lookup will be available after signing in and linking a customer account.", autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support.",
humanHours: "Our team is available daily, 08:00–22:00. Please leave a message and we will reply during service hours.", humanHours: "Our team is available daily, 08:00–22:00 China Standard Time (UTC+8). Please leave a message and we will reply during service hours.",
offlinePrompt: "Please leave your message:", offlinePrompt: "Please leave your message:",
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).", offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
humanClosed: "This support session has ended. You can continue chatting with the AI assistant.", humanClosed: "This support session has ended. You can continue chatting with the AI assistant.",
@@ -336,13 +378,15 @@ const I18N = {
humanWaiting: "You are in the queue. A team member will reply after joining, or you can return to AI chat.", humanWaiting: "You are in the queue. A team member will reply after joining, or you can return to AI chat.",
humanActive: "You are chatting with our support team. Messages will be sent to a team member.", humanActive: "You are chatting with our support team. Messages will be sent to a team member.",
returnAI: "Return to AI chat", returnAI: "Return to AI chat",
humanSent: "Sent to our support team",
humanSending: "Sending to our support team…", humanSending: "Sending to our support team…",
humanSendFailed: "Message not sent. Your text has been kept. Please retry.", humanSendFailed: "Message not sent. Your text has been kept. Please retry.",
humanConnectFailed: "Could not connect to our support team. Please try again.", humanConnectFailed: "Could not connect to our support team. Please try again.",
humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.", humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.",
humanReturnFailed: "Could not end the support session. Please try again.", humanReturnFailed: "Could not end the support session. Please try again.",
historyLoading: "Loading this conversation. Please wait…", historyLoading: "Loading this conversation. Please wait…",
historyLoadFailed: "Conversation could not load. Select it again to retry, or start a new chat.",
draftSaveFailed: "Your browser could not save this draft. Keep a copy before refreshing.",
attachmentUnavailable: "Image unavailable. Please upload the attachment again.",
recalled: "This message was recalled", recalled: "This message was recalled",
ratingQuestion: "How was your support experience?", ratingQuestion: "How was your support experience?",
ratingThanks: "Thank you for your feedback!", ratingThanks: "Thank you for your feedback!",
@@ -350,27 +394,25 @@ const I18N = {
networkFail: "Connection lost, please check your network and retry", networkFail: "Connection lost, please check your network and retry",
stopped: "Stopped", stopped: "Stopped",
stoppedMark: "(Generation stopped)", stoppedMark: "(Generation stopped)",
inputPh: "Ask a question or enter a tracking number... (Enter to send, Shift+Enter for newline)", inputPh: "Enter an inbound code or question…",
send: "Send", send: "Send",
stop: "Stop", stop: "Stop",
copy: "Copy", copy: "Copy",
copied: "Copied", copied: "Copied",
copyFailed: "Copy failed",
voteUp: "This reply was helpful",
voteDown: "This reply was not helpful",
retry: "Regenerate", retry: "Regenerate",
retryAIOnly: "Return to AI chat before regenerating this answer.", retryAIOnly: "Return to AI chat before regenerating this answer.",
clickRetry: "Retry", clickRetry: "Retry",
welcomeTitle: "Your next shipment, made clearer.", welcomeTitle: "Your next shipment, made clearer.",
welcomeSub: "Ask about rates, transit times or human support. Order lookup requires a linked customer account.",
welcomeEyebrow: "EUROPE & UK LOGISTICS", welcomeEyebrow: "EUROPE & UK LOGISTICS",
serviceScope: "Europe & UK freight support",
serviceTag: "Logistics support", serviceTag: "Logistics support",
quickLabel: "Quick help", footerTrack: "Inbound lookup",
footerTrack: "Order lookup",
footerQuote: "Get a quote", footerQuote: "Get a quote",
footerException: "Parcel issue", footerException: "Parcel issue",
footerHuman: "Human support", footerHuman: "Human support",
disclaimer: "Quotes and transit times are subject to final confirmation", inputShort: "Inbound code…",
keyboardHint: "Enter to send · Shift + Enter for a new line",
inputShort: "Question or tracking number…",
uploading: "Uploading your attachment…", uploading: "Uploading your attachment…",
uploadFailed: "Upload failed. Please select the file again.", uploadFailed: "Upload failed. Please select the file again.",
serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.", serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.",
@@ -388,7 +430,6 @@ const I18N = {
voiceFailed: "Voice input failed. Please try again or type your message.", voiceTimeout: "Voice input timed out. Please try again.", voiceFailed: "Voice input failed. Please try again or type your message.", voiceTimeout: "Voice input timed out. Please try again.",
voiceWait: "Please wait for the current operation to finish before recording.", voiceWait: "Please wait for the current operation to finish before recording.",
voiceDraft: "Please send or clear your existing draft before recording.", voiceDraft: "Please send or clear your existing draft before recording.",
attachmentReady: "Ready to attach to your next message",
removeAttachment: "Remove pending attachment", removeAttachment: "Remove pending attachment",
you: "You", you: "You",
assistant: "Baiyue Assistant", assistant: "Baiyue Assistant",
@@ -402,15 +443,12 @@ const I18N = {
logisticsHistory: "Shipment tracking", logisticsHistory: "Shipment tracking",
noLogisticsHistory: "No actual shipment events available", noLogisticsHistory: "No actual shipment events available",
estimatedTracking: "Estimated milestones", estimatedTracking: "Estimated milestones",
legacyTrackingNotice: "Historical simulated tracking for demonstration only; this is not a real order record.", order_inbound: "Inbound code", order_number: "Order number", order_shipment: "Shipment ID", order_country: "Destination country",
order_inbound: "Inbound code", order_shipment: "Shipment ID", order_country: "Destination country",
order_warehouse: "Warehouse", order_transport: "Transport", order_packages: "Packages", order_warehouse: "Warehouse", order_transport: "Transport", order_packages: "Packages",
order_weight: "Declared gross weight", order_volume: "Declared volume", order_role: "Order level", order_weight: "Declared gross weight", order_volume: "Declared volume", order_role: "Order level",
order_parent: "Parent order", order_updated: "Source updated", order_parent: "Parent order", order_updated: "Source updated",
moreTracking: "View full tracking history", moreTracking: "View full tracking history",
lessTracking: "Show recent updates", lessTracking: "Show recent updates",
quickSubs: ["Link a customer account to view orders and tracking", "Explore routes, rates and transit times", "Get help with delays, delivery or claims", "Discuss your shipping requirements"],
welcomeTip: "Type your question, or pick a quick option below.",
hot: "Popular Questions", hot: "Popular Questions",
recent: "Recent Conversations", recent: "Recent Conversations",
sideCollapse: "Collapse sidebar", sideCollapse: "Collapse sidebar",
@@ -419,7 +457,7 @@ const I18N = {
langTip: "Switch language (Ctrl+L)", langTip: "Switch language (Ctrl+L)",
toBottom: "Back to bottom", toBottom: "Back to bottom",
quick: [ quick: [
"Track my shipment", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent" "Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
], ],
hotList: [ hotList: [
"How do I track my shipment?", "How long does Europe sea freight take?", "How do I track my shipment?", "How long does Europe sea freight take?",
@@ -435,6 +473,11 @@ function t(key){
function applyLanguage(lang){ function applyLanguage(lang){
LANG = lang === "en" ? "en" : "zh"; LANG = lang === "en" ? "en" : "zh";
const dict = I18N[LANG]; const dict = I18N[LANG];
if(customerLogin) customerLogin.render();
if(orderLookup) orderLookup.render();
document.querySelectorAll('.order-reference').forEach(function(button){
button.setAttribute('aria-label',t('lookupAction')+' '+t(button.dataset.referenceLabel)+' '+button.textContent);
});
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN"; document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
document.querySelectorAll("[data-i18n]").forEach(function(element){ document.querySelectorAll("[data-i18n]").forEach(function(element){
const value = dict[element.dataset.i18n]; const value = dict[element.dataset.i18n];
@@ -483,6 +526,7 @@ function applyLanguage(lang){
const n = b.dataset.action; const n = b.dataset.action;
if(n === "copy") b.textContent = dict.copy; if(n === "copy") b.textContent = dict.copy;
if(n === "retry") b.textContent = dict.retry; if(n === "retry") b.textContent = dict.retry;
if(n === 'voteUp' || n === 'voteDown') b.setAttribute('aria-label', dict[n]);
}); });
document.querySelectorAll(".retry-btn").forEach(function(b){ document.querySelectorAll(".retry-btn").forEach(function(b){
if(!b.dataset.keep) b.textContent = dict.clickRetry; if(!b.dataset.keep) b.textContent = dict.clickRetry;
@@ -589,8 +633,8 @@ function welcomeHTML(){
const h = t("hotList"); const h = t("hotList");
const kinds = ["track", "quote", "issue", "human"]; const kinds = ["track", "quote", "issue", "human"];
const cards = q.map(function(question, index){ const cards = q.map(function(question, index){
const action = index === 3 ? 'data-human-support' : `data-question="${escHtml(question)}"`; const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(question)}"`;
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong><small>${t("quickSubs")[index]}</small></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`; return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
}).join(""); }).join("");
return ` return `
<div class="ai-welcome-card"> <div class="ai-welcome-card">
@@ -598,7 +642,6 @@ function welcomeHTML(){
<div class="ai-avatar">${welcomeIcon("box")}</div> <div class="ai-avatar">${welcomeIcon("box")}</div>
<div class="welcome-eyebrow">${t("welcomeEyebrow")}</div> <div class="welcome-eyebrow">${t("welcomeEyebrow")}</div>
<h3>${t("welcomeTitle")}</h3> <h3>${t("welcomeTitle")}</h3>
<p>${t("welcomeSub")}</p>
</div> </div>
<div class="suggest-grid">${cards}</div> <div class="suggest-grid">${cards}</div>
</div> </div>
@@ -646,11 +689,12 @@ clearAllBtn.onclick = function(){
if(newChatBtn){ if(newChatBtn){
newChatBtn.onclick = function(){ newChatBtn.onclick = function(){
if(openingConv === 'clearing') return; if(openingConv === 'clearing') return;
const alreadyNew = currentConvId === null;
resetConversation(null); resetConversation(null);
inputDom.value = ""; if(alreadyNew) inputDom.value = "";
autoGrow(); autoGrow();
clearAttachment(); clearAttachment();
setComposerStatus(""); setComposerStatus('');
chatDom.innerHTML = welcomeHTML(); chatDom.innerHTML = welcomeHTML();
chatDom.scrollTop = 0; chatDom.scrollTop = 0;
followLatest = true; followLatest = true;
@@ -666,10 +710,13 @@ function loadHistory(){
historyList = data; historyList = data;
renderSideHistory(); renderSideHistory();
renderRecentChat(); renderRecentChat();
}).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ }); return data;
}).catch(function(){ return null; /* 保留上次成功加载的历史。 */ });
} }
function removeCachedHistory(id){ function removeCachedHistory(id){
if(id === undefined){ drafts.clear(); flushDraft(); }
else drafts.remove(id);
historyRequestVersion += 1; historyRequestVersion += 1;
historyList = id === undefined ? [] : historyList.filter(item => item.id !== id); historyList = id === undefined ? [] : historyList.filter(item => item.id !== id);
renderSideHistory(); renderSideHistory();
@@ -816,6 +863,8 @@ function ensureConv(){
throw error; throw error;
} }
currentConvId = data.id; currentConvId = data.id;
drafts.remove('new');
flushDraft();
if(data.welcome) addMsg(t('autoWelcome'), false); if(data.welcome) addMsg(t('autoWelcome'), false);
return currentConvId; return currentConvId;
}).finally(function(){ if(creatingConv === pending) creatingConv = null; }); }).finally(function(){ if(creatingConv === pending) creatingConv = null; });
@@ -824,6 +873,9 @@ function ensureConv(){
} }
function resetConversation(id){ function resetConversation(id){
flushDraft();
emojiPicker.close();
if(orderLookup) orderLookup.cancel();
if(voiceInput) voiceInput.cancel(); if(voiceInput) voiceInput.cancel();
if(cancelActive){ cancelActive(); cancelActive = null; } if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; } if(sseInstance){ sseInstance.close(); sseInstance = null; }
@@ -831,6 +883,7 @@ function resetConversation(id){
clearAttachment(); clearAttachment();
stopHumanMode(); stopHumanMode();
currentConvId = id; currentConvId = id;
restoreDraft();
openingConv = null; openingConv = null;
creatingConv = null; creatingConv = null;
sendPreparing = false; sendPreparing = false;
@@ -842,11 +895,13 @@ function resetConversation(id){
// 打开历史会话:还原整段对话,而不是把标题重发一遍 // 打开历史会话:还原整段对话,而不是把标题重发一遍
let openingConv = null; let openingConv = null;
function openConversation(id){ function openConversation(id, afterLoad){
if(openingConv === 'clearing') return; if(openingConv === 'clearing') return;
if(openingConv === id) return; // 防连点 if(openingConv === id) return; // 防连点
const version = resetConversation(id); const version = resetConversation(id);
if(isNarrow()){ sideOpen = false; renderSidebar(); }
openingConv = id; openingConv = id;
chatDom.innerHTML = '';
setComposerStatus(t('historyLoading')); setComposerStatus(t('historyLoading'));
renderSideHistory(); renderSideHistory();
requestJson("/api/get_messages?conv_id=" + id) requestJson("/api/get_messages?conv_id=" + id)
@@ -858,9 +913,12 @@ function openConversation(id){
chatDom.innerHTML = ""; chatDom.innerHTML = "";
if(!msgs.length){ if(!msgs.length){
chatDom.innerHTML = welcomeHTML(); chatDom.innerHTML = welcomeHTML();
if(afterLoad) afterLoad(false);
return; return;
} }
let previousQuestion = '';
msgs.forEach(m=>{ msgs.forEach(m=>{
if(m.role === 'user') previousQuestion = m.content;
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){ if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
try{ try{
const bubble = addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)), m.ctime); const bubble = addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)), m.ctime);
@@ -871,14 +929,19 @@ function openConversation(id){
}else{ }else{
const b = addMsg(m.content, m.role === "user", undefined, m.ctime); const b = addMsg(m.content, m.role === "user", undefined, m.ctime);
if(m.role === "assistant" && b) attachActions(b, m.content, "", false); if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
if(m.role === 'assistant' && b && (/^请先使用客户账号登录并关联订单/.test(m.content) || /^订单查询将在客户账号登录并关联后开放/.test(m.content))){
attachCustomerLogin(b, previousQuestion);
}
} }
}); });
// 等渲染完再滚到底部 // 等渲染完再滚到底部
requestAnimationFrame(function(){ scrollChatToEnd(true); }); requestAnimationFrame(function(){ scrollChatToEnd(true); });
if(afterLoad) afterLoad(true);
}).catch(function(){ }).catch(function(){
if(version !== conversationVersion) return; if(version !== conversationVersion) return;
openingConv = null; openingConv = 'failed';
setComposerStatus(t('networkFail'), true); setComposerStatus(t('historyLoadFailed'), true);
if(afterLoad) afterLoad(false);
}); });
} }
@@ -893,8 +956,18 @@ function fillTrackCard(el, card){
head.className = "track-head"; head.className = "track-head";
const no = document.createElement("div"); const no = document.createElement("div");
no.className = "track-no"; no.className = "track-no";
const numberLabel = isOrder ? 'orderNo' : 'trackingNo'; const inbound = isOrder && (card.fields || []).find(field=>field && field.key === 'inbound' && field.value);
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(card.no); const numberLabel = inbound ? 'order_inbound' : isOrder ? 'orderNo' : 'trackingNo';
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>";
if(isOrder){
const reference=document.createElement('button');
const value=String(inbound ? inbound.value : card.no), version=conversationVersion;
reference.type='button';reference.className='order-reference';reference.textContent=value;
reference.dataset.referenceLabel=numberLabel;
reference.setAttribute('aria-label',t('lookupAction')+' '+t(numberLabel)+' '+value);
reference.onclick=function(){if(version === conversationVersion) return showOrderLookup(value,inbound ? 'inbound' : 'order',reference);};
no.appendChild(reference);
}else no.innerHTML += escHtml(card.no);
const st = document.createElement("div"); const st = document.createElement("div");
st.className = "track-status"; st.className = "track-status";
st.textContent = card.status; st.textContent = card.status;
@@ -902,14 +975,6 @@ function fillTrackCard(el, card){
head.appendChild(st); head.appendChild(st);
el.appendChild(head); el.appendChild(head);
if(!isOrder){
const legacyNotice = document.createElement('p');
legacyNotice.className = 'order-data-notice';
legacyNotice.dataset.i18n = 'legacyTrackingNotice';
legacyNotice.textContent = t('legacyTrackingNotice');
el.appendChild(legacyNotice);
}
if(isOrder){ if(isOrder){
const fields = document.createElement('dl'); const fields = document.createElement('dl');
fields.className = 'order-data-fields is-collapsed'; fields.className = 'order-data-fields is-collapsed';
@@ -1038,7 +1103,30 @@ function addTrackCard(card, timeStr){
return bubble; return bubble;
} }
loadHistory(); const restoreId = drafts.active();
currentConvId = restoreId === 'new' ? null : Number(restoreId);
restoreDraft();
if(currentConvId){
const id = currentConvId, version = conversationVersion, initialDraft = inputDom.value;
openingConv = 'restoring';
loadHistory().then(function(history){
if(version !== conversationVersion || currentConvId !== id || openingConv !== 'restoring') return;
if(Array.isArray(history) && !history.some(item=>item.id === id)){
if(inputDom.value !== initialDraft){
openingConv = 'failed';
flushDraft();
setComposerStatus(t('historyLoadFailed'), true);
return;
}
drafts.remove(id);
currentConvId = null;
restoreDraft();
openingConv = null;
return;
}
openConversation(id);
});
}else loadHistory();
window.fillInput = function(words){ window.fillInput = function(words){
inputDom.value = words; inputDom.value = words;
@@ -1059,12 +1147,33 @@ window.askNewTopic = function(words){
return doSend(); return doSend();
} }
window.sendOrder = function(){ window.sendOrder = function(){ return showOrderLookup(); };
const no = prompt("请输入运单号:");
if(no && no.trim()){ function lookupBlocked(){
inputDom.value = "我的运单号是 " + no.trim() + ",帮我查一下轨迹"; if(window.humanSid || humanRequestToken || humanClosing || humanSendingToken) return t('lookupAIOnly');
doSend(); if(openingConv !== null) return t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading');
} if(uploadPending || sendPreparing || window.__busy) return t('lookupBusy');
return '';
}
function sameCustomerContext(version,user){
return version === conversationVersion || (customerRecovery &&
customerRecovery.from === version && customerRecovery.to === conversationVersion &&
(!user || user.id === customerRecovery.customerId));
}
async function showOrderLookup(reference,kind,source){
const blocked=lookupBlocked();
if(blocked){setComposerStatus(blocked,true);return;}
const version=conversationVersion;
const requestVersion=++lookupRequestVersion;
function isCurrent(user){return requestVersion === lookupRequestVersion && sameCustomerContext(version,user);}
return customerLogin.require(function(user){
if(!isCurrent(user)) return;
const blocked=lookupBlocked();
if(blocked){setComposerStatus(blocked,true);return;}
orderLookup.open(reference,kind,source);
},isCurrent);
} }
function updateHumanMode(){ function updateHumanMode(){
@@ -1104,11 +1213,10 @@ async function humanRequest(path, data){
window.showHuman = async function(){ window.showHuman = async function(){
if(voiceInput) voiceInput.cancel(); if(voiceInput) voiceInput.cancel();
if(openingConv === 'clearing') return; if(openingConv === 'clearing') return;
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
if(humanRequestToken || humanClosing) return; if(humanRequestToken || humanClosing) return;
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; } if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
if(window.humanSid) stopHumanMode(); if(window.humanSid) stopHumanMode();
const hour = new Date().getHours();
if(hour < 8 || hour >= 22) return leaveOfflineMessage();
const token = {version:conversationVersion}; const token = {version:conversationVersion};
humanRequestToken = token; humanRequestToken = token;
if(cancelActive){ cancelActive(); cancelActive = null; } if(cancelActive){ cancelActive(); cancelActive = null; }
@@ -1170,7 +1278,7 @@ async function sendHumanMessage(content){
addMsg(content, true); addMsg(content, true);
if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); } if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); }
if(window.__pendingImg === attachment) clearAttachment(); if(window.__pendingImg === attachment) clearAttachment();
setComposerStatus(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting')); setComposerStatus('');
focusComposer(); focusComposer();
return true; return true;
}catch(error){ }catch(error){
@@ -1185,7 +1293,7 @@ async function sendHumanMessage(content){
async function leaveOfflineMessage(){ async function leaveOfflineMessage(){
const version = conversationVersion; const version = conversationVersion;
addMsg(t('humanHours'), false); addMsg(t('humanHours'), false);
const message = window.prompt(t('offlinePrompt')); const message = (window.prompt(t('offlinePrompt')) || '').trim();
if(!message) return; if(!message) return;
try{ try{
const data = await humanRequest('/api/ticket', {content:message, phone:''}); const data = await humanRequest('/api/ticket', {content:message, phone:''});
@@ -1193,8 +1301,8 @@ async function leaveOfflineMessage(){
if(version === conversationVersion) addMsg(t('offlineSaved'), false); if(version === conversationVersion) addMsg(t('offlineSaved'), false);
}catch(error){ }catch(error){
if(version !== conversationVersion) return; if(version !== conversationVersion) return;
if(!inputDom.value) inputDom.value = message; if(!inputDom.value){ inputDom.value = message; autoGrow(); }
setComposerStatus(t('networkFail'), true); setComposerStatus(error.status === 400 ? error.message : t('networkFail'), true);
} }
} }
@@ -1370,6 +1478,42 @@ function mdToPlain(md){
.replace(/“|”/g, '"'); .replace(/“|”/g, '"');
} }
function attachCustomerLogin(bubble, question){
const button = document.createElement('button');
button.type = 'button'; button.className = 'customer-login-cta';
button.textContent = t('customerQuery');
const version = conversationVersion;
const convId = currentConvId;
function sameContext(user){
return sameCustomerContext(version,user);
}
button.onclick = function(){
if(version !== conversationVersion) return;
customerLogin.require(async function(user){
if(!sameContext(user) || !question) return;
if(version === conversationVersion){ await retryAnswer(bubble, question); return; }
// Expiry cleared the private screen. Re-open it through the owner-checked
// endpoint before continuing a short followup under the same customer.
const newerDraft = inputDom.value;
customerRecovery = null;
let restoredDraft;
openConversation(convId, function(loaded){
const draft = inputDom.value !== restoredDraft ? inputDom.value : newerDraft;
if(!loaded){
resetConversation(null); chatDom.innerHTML=welcomeHTML();
setComposerStatus(t('historyLoadFailed'), true);
}
inputDom.value=draft; autoGrow();
if(!loaded) return;
addMsg(question, true);
retryAnswer(bubble, question);
});
restoredDraft = inputDom.value;
}, sameContext);
};
bubble.appendChild(button);
}
async function retryAnswer(bubble, question){ async function retryAnswer(bubble, question){
if(window.humanSid || humanRequestToken){ setComposerStatus(t('retryAIOnly')); return; } if(window.humanSid || humanRequestToken){ setComposerStatus(t('retryAIOnly')); return; }
if(await doSend(question)){ if(await doSend(question)){
@@ -1391,27 +1535,15 @@ function attachActions(bubble, rawText, question, showRetry){
copyBtn.dataset.action = "copy"; copyBtn.dataset.action = "copy";
copyBtn.textContent = t("copy"); copyBtn.textContent = t("copy");
copyBtn.onclick = function(){ copyBtn.onclick = function(){
if(copyBtn.disabled) return;
copyBtn.disabled = true;
const plain = mdToPlain(rawText); const plain = mdToPlain(rawText);
const done = ()=> { const done = function(copied){
copyBtn.textContent = t("copied"); copyBtn.disabled = false;
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, 1200); copyBtn.textContent = t(copied ? 'copied' : 'copyFailed');
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000);
}; };
if(navigator.clipboard && navigator.clipboard.writeText){ return SupportUI.copyText(plain).then(done);
navigator.clipboard.writeText(plain).then(done).catch(fallback);
}else{
fallback();
}
function fallback(){
const ta = document.createElement("textarea");
ta.value = plain;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try{ document.execCommand("copy"); }catch(e){}
document.body.removeChild(ta);
done();
}
}; };
bar.appendChild(copyBtn); bar.appendChild(copyBtn);
@@ -1425,23 +1557,52 @@ function attachActions(bubble, rawText, question, showRetry){
} }
// 👍/👎 反馈 // 👍/👎 反馈
let votePending = false;
const voteButtons = [];
const voteStatus = document.createElement('span');
voteStatus.className = 'vote-status';
voteStatus.setAttribute('role', 'status');
function addVote(vote, label){ function addVote(vote, label){
const b = document.createElement("button"); const b = document.createElement("button");
b.className = "msg-action-btn"; b.className = "msg-action-btn";
b.textContent = label; b.textContent = label;
b.onclick = function(){ b.dataset.action = vote === 'up' ? 'voteUp' : 'voteDown';
if(b.classList.contains("voted")) return; b.setAttribute('aria-label', t(vote === 'up' ? 'voteUp' : 'voteDown'));
bar.querySelectorAll(".msg-action-btn").forEach(function(x){ x.classList.remove("voted"); }); b.setAttribute('aria-pressed', 'false');
b.classList.add("voted"); b.onclick = async function(){
fetch("/api/feedback", { if(votePending || b.classList.contains('voted')) return;
method:"POST", headers:{"Content-Type":"application/json"}, votePending = true;
body: JSON.stringify({question: question, answer: rawText, vote: vote}) voteButtons.forEach(button => { button.disabled = true; });
}).catch(function(){}); voteStatus.textContent = '';
voteStatus.removeAttribute('data-i18n');
voteStatus.classList.remove('is-error');
try{
const data = await requestJson('/api/feedback', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({question:question, answer:rawText, vote:vote})
});
if(!data.ok) throw new Error('Feedback failed');
voteButtons.forEach(function(button){
button.classList.toggle('voted', button === b);
button.setAttribute('aria-pressed', String(button === b));
});
voteStatus.textContent = t('ratingThanks');
voteStatus.setAttribute('data-i18n', 'ratingThanks');
}catch(error){
voteStatus.textContent = t('ratingFailed');
voteStatus.setAttribute('data-i18n', 'ratingFailed');
voteStatus.classList.add('is-error');
}finally{
votePending = false;
voteButtons.forEach(button => { button.disabled = false; });
}
}; };
voteButtons.push(b);
bar.appendChild(b); bar.appendChild(b);
} }
addVote("up", "👍"); addVote("up", "👍");
addVote("down", "👎"); addVote("down", "👎");
bar.appendChild(voteStatus);
bubble.appendChild(bar); bubble.appendChild(bar);
} }
@@ -1468,7 +1629,8 @@ function renderFollowups(bubble){
bubble.appendChild(wrap); bubble.appendChild(wrap);
} }
async function doSend(retryText){ async function doSend(retryText, showQuestion){
if(showQuestion && lookupBlocked()) return false;
if(voiceInput && voiceInput.busy()){ setComposerStatus(t('voiceWait')); return false; } if(voiceInput && voiceInput.busy()){ setComposerStatus(t('voiceWait')); return false; }
const isRetry = typeof retryText === "string"; const isRetry = typeof retryText === "string";
const sendWord = (isRetry ? retryText : inputDom.value).trim(); const sendWord = (isRetry ? retryText : inputDom.value).trim();
@@ -1484,7 +1646,7 @@ async function doSend(retryText){
var fullContent = sendWord; var fullContent = sendWord;
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl; if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
setComposerStatus(""); setComposerStatus("");
if(openingConv !== null){ setComposerStatus(t('historyLoading')); return; } if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
if(humanRequestToken || humanClosing){ updateHumanMode(); return; } if(humanRequestToken || humanClosing){ updateHumanMode(); return; }
// 人工客服模式:直接发给客服,不走AI // 人工客服模式:直接发给客服,不走AI
if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent); if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent);
@@ -1504,9 +1666,9 @@ async function doSend(retryText){
}finally{ }finally{
if(version === conversationVersion) sendPreparing = false; if(version === conversationVersion) sendPreparing = false;
} }
if(version !== conversationVersion || humanRequestToken) return; if(version !== conversationVersion || humanRequestToken || (showQuestion && window.humanSid)) return;
if(!isRetry || showQuestion) addMsg(fullContent, true);
if(!isRetry){ if(!isRetry){
addMsg(fullContent, true);
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); } if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
if(attachmentVersion === uploadVersion) clearAttachment(); if(attachmentVersion === uploadVersion) clearAttachment();
focusComposer(); focusComposer();
@@ -1553,7 +1715,8 @@ async function doSend(retryText){
raw = "运单号 " + card.no + "(" + (card.status || "") + ")"; raw = "运单号 " + card.no + "(" + (card.status || "") + ")";
}catch(e){ /* 解析失败就按普通文本显示 */ } }catch(e){ /* 解析失败就按普通文本显示 */ }
} }
attachActions(aiBubble, raw, fullContent, true); attachActions(aiBubble, raw, fullContent, !aiBubble.dataset.loginRequired);
if(aiBubble.dataset.loginRequired) attachCustomerLogin(aiBubble, fullContent);
renderFollowups(aiBubble); renderFollowups(aiBubble);
updateToBottom(); updateToBottom();
loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下 loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下
@@ -1603,6 +1766,7 @@ async function doSend(retryText){
} }
if(!obj || typeof obj !== 'object') return; if(!obj || typeof obj !== 'object') return;
if(obj.e){ fail(obj.e); return; } if(obj.e){ fail(obj.e); return; }
if(obj.login_required) aiBubble.dataset.loginRequired = '1';
if(obj.card){ if(obj.card){
clearTimeout(firstTimer); clearTimeout(firstTimer);
aiBubble.classList.add("track-card"); aiBubble.classList.add("track-card");
@@ -1718,6 +1882,7 @@ sendBtn.addEventListener("click", function(){
// ===== 多行输入框:自动增高 ===== // ===== 多行输入框:自动增高 =====
function autoGrow(){ function autoGrow(){
queueDraft();
if(!inputDom.value){ if(!inputDom.value){
inputDom.style.height = ''; inputDom.style.height = '';
return; return;
@@ -1729,6 +1894,8 @@ function autoGrow(){
inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px"; inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px";
} }
inputDom.addEventListener("input", autoGrow); inputDom.addEventListener("input", autoGrow);
window.addEventListener('pagehide', flushDraft);
document.addEventListener('visibilitychange', function(){ if(document.hidden) flushDraft(); });
inputDom.addEventListener("keydown",(e)=>{ inputDom.addEventListener("keydown",(e)=>{
// Enter 发送,Shift+Enter 换行 // Enter 发送,Shift+Enter 换行
@@ -1797,6 +1964,8 @@ function syncChatViewport(){
chatContainer.style.height = ''; chatContainer.style.height = '';
chatContainer.style.top = ''; chatContainer.style.top = '';
} }
if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top);
if(orderLookup) orderLookup.viewport(chatContainer.style.height, chatContainer.style.top);
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom); chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
autoGrow(); autoGrow();
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd(); if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
@@ -1873,16 +2042,44 @@ voiceInput = ChatVoice.create({
if(!await doSend()) voiceInput.setMode(false); if(!await doSend()) voiceInput.setMode(false);
} }
}); });
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
onOpen:function(){ if(voiceInput) voiceInput.cancel(); if(isNarrow()){sideOpen=false;renderSidebar();} syncChatViewport(); },
onChange:function(user,previous){
if(previous === undefined) return;
if(previous && (!user || user.id !== previous.id)){
const from = conversationVersion;
resetConversation(null); chatDom.innerHTML=welcomeHTML();
customerRecovery = !user ? {from:from,to:conversationVersion,customerId:previous.id} : null;
// Customer titles must disappear even if the next history request fails.
historyRequestVersion += 1;
historyList=[];
renderSideHistory(); renderRecentChat();
}
loadHistory();
},
onError:function(message){ setComposerStatus(message,true); }
});
customerLogin.viewport(chatContainer.style.height,chatContainer.style.top);
customerLogin.refresh();
orderLookup = OrderLookup.create({text:t,canQuery:lookupBlocked,
onOpen:function(){emojiPicker.close();if(voiceInput) voiceInput.cancel();if(isNarrow()){sideOpen=false;renderSidebar();}syncChatViewport();},
query:function(reference,kind){return doSend((kind === 'order' ? '查询订单 ' : '查询入仓码 ')+reference,true);}
});
orderLookup.viewport(chatContainer.style.height,chatContainer.style.top);
window.addEventListener('resize', function(){ window.addEventListener('resize', function(){
if(!isNarrow()) voiceInput.setMode(false); if(!isNarrow()) voiceInput.setMode(false);
}); });
document.querySelectorAll('[data-question-index]').forEach(function(button){ document.querySelectorAll('[data-question-index]').forEach(function(button){
button.addEventListener('click', function(){ button.addEventListener('click', function(){
window.askNewTopic(t('quick')[Number(button.dataset.questionIndex)]); const index=Number(button.dataset.questionIndex);
if(index === 0) return showOrderLookup(undefined,undefined,button);
return window.askNewTopic(t('quick')[index]);
}); });
}); });
chatDom.addEventListener('click', function(event){ chatDom.addEventListener('click', function(event){
const lookup=event.target.closest('button[data-order-lookup]');
if(lookup){showOrderLookup(undefined,undefined,lookup);return;}
const question = event.target.closest('button[data-question]'); const question = event.target.closest('button[data-question]');
if(question) window.askNewTopic(question.dataset.question); if(question) window.askNewTopic(question.dataset.question);
const human = event.target.closest('button[data-human-support]'); const human = event.target.closest('button[data-human-support]');
+124
View File
@@ -0,0 +1,124 @@
// Customer login has no relationship to the protected staff workspace.
var CustomerLogin = {
create(options){
const overlay=document.getElementById('customer-login');
const form=document.getElementById('customer-login-form');
const username=document.getElementById('customer-username');
const password=document.getElementById('customer-password');
const submit=document.getElementById('customer-submit');
const cancel=document.getElementById('customer-cancel');
const error=document.getElementById('customer-error');
const account=document.getElementById('customer-account');
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');
submit.textContent=options.text(busy ? 'customerSigningIn' : 'customerLogin');
cancel.textContent=options.text('customerCancel');
document.getElementById('customer-login-title').textContent=options.text('customerLogin');
document.getElementById('customer-username-label').textContent=options.text('customerUsername');
document.getElementById('customer-password-label').textContent=options.text('customerPassword');
username.placeholder=options.text('customerUsernamePlaceholder');
password.placeholder=options.text('customerPasswordPlaceholder');
}
function update(next){
const previous=user;
user=next;
render();
if(options.onChange && (previous === undefined || (previous && previous.id) !== (user && user.id))){
options.onChange(user,previous);
}
}
async function request(path, data){
const controller=new AbortController();
const timer=setTimeout(()=>controller.abort(),10000);
try{
const response=await options.fetch(path,{method:data === undefined ? 'GET' : 'POST',
headers:{'Content-Type':'application/json'},body:data === undefined ? undefined : JSON.stringify(data),signal:controller.signal});
const result=await response.json();
if(!response.ok || !result.ok){
const failure=new Error(result.error || result.e || options.text('customerLoginFailed'));
failure.publicMessage=true;throw failure;
}
return result;
}finally{ clearTimeout(timer); }
}
async function refresh(){
if(busy) return;
const token=++version;
try{
const result=await request('/api/customer/session');
if(token === version) update(result.authenticated && result.user ? result.user : null);
}catch(error){} // Query endpoints still enforce login if status cannot refresh.
}
function close(){
overlay.hidden=true;
password.value='';
pending=null;
if(opener && opener.isConnected) opener.focus({preventScroll:true});
opener=null;
}
function open(onSuccess){
if(busy) return;
pending=typeof onSuccess === 'function' ? onSuccess : null;
opener=document.activeElement;
status(''); password.value=''; overlay.hidden=false;
if(options.onOpen) options.onOpen();
render(); username.focus({preventScroll:true});
}
function setBusy(value){
busy=value; submit.disabled=value; cancel.disabled=value; account.disabled=value;
username.disabled=value; password.disabled=value; render();
}
form.addEventListener('submit',async function(event){
event.preventDefault();
if(busy) return;
const token=++version;
setBusy(true);status('');
try{
const result=await request('/api/customer/login',{username:username.value,password:password.value});
if(token !== version) return;
update(result.user);
const resume=pending;close();
if(resume) await resume();
}catch(failure){
if(token === version) status(failure.publicMessage ? failure.message : options.text('customerLoginFailed'));
}finally{ if(token === version) setBusy(false); }
});
cancel.onclick=function(){ if(!busy) close(); };
overlay.addEventListener('click',function(event){ if(event.target === overlay && !busy) close(); });
overlay.addEventListener('keydown',function(event){
if(event.key === 'Escape' && !busy){ event.preventDefault();close(); }
if(event.key === 'Tab'){
const controls=[username,password,submit,cancel].filter(control=>!control.disabled);
if(!controls.length){ event.preventDefault();return; }
const index=controls.indexOf(document.activeElement);
const next=(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length;
event.preventDefault();controls[next].focus({preventScroll:true});
}
});
account.onclick=async function(){
if(busy) return;
if(!user){ open(); return; }
const token=++version;setBusy(true);
try{
await request('/api/customer/logout',{});
if(token === version) update(null);
}catch(failure){ if(options.onError) options.onError(options.text('customerLogoutFailed')); }
finally{ if(token === version) setBusy(false); }
};
window.addEventListener('focus',refresh);
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
render();
return {open:open,refresh:refresh,render:render,require:async function(resume, canContinue){
if(busy) return;
await refresh();
if(canContinue && !canContinue(user)) return;
if(user){ await resume(user); }else open(function(){ return resume(user); });
},viewport:function(height,top){
overlay.style.height=height || ''; overlay.style.top=top || '';
overlay.style.bottom=height ? 'auto' : '';
}};
}
};
+77
View File
@@ -0,0 +1,77 @@
// Text drafts stay in this visitor's browser, independently for each conversation.
var ChatDrafts = {
create(options){
const scope = /^[0-9a-f]{64}$/.test(options.scope || '') ? options.scope : null;
const prefix = scope ? 'baiyue-draft-v1:' + scope + ':' : null;
const activeKey = scope ? 'baiyue-active-chat-v1:' + scope : null;
const memory = new Map();
const dirty = new Set();
let current = 'new';
function id(value){
if(value === null || value === 'new') return 'new';
const key = String(value);
return /^[1-9][0-9]{0,18}$/.test(key) ? key : null;
}
function storage(name){
try{ return options[name](); }catch(error){ return null; }
}
function read(value){
const key = id(value);
if(!key) return '';
if(dirty.has(key)) return memory.get(key) || '';
if(prefix){
try{
const saved = storage('storage').getItem(prefix + key);
if(saved !== null){
const data = JSON.parse(saved);
if(data && data.version === 1 && typeof data.text === 'string'){
memory.set(key, data.text);
return data.text;
}
}
memory.delete(key);
return '';
}catch(error){}
}
return memory.get(key) || '';
}
function write(value, text){
const key = id(value);
if(!key || typeof text !== 'string') return false;
if(text) memory.set(key, text); else memory.delete(key);
dirty.add(key);
if(!prefix) return false;
try{
const target = storage('storage');
if(text) target.setItem(prefix + key, JSON.stringify({version:1,text:text}));
else target.removeItem(prefix + key);
dirty.delete(key);
return true;
}catch(error){ return false; }
}
function active(value){
if(arguments.length){
current = id(value) || 'new';
if(activeKey) try{ storage('tabStorage').setItem(activeKey, current); }catch(error){}
}else if(activeKey){
try{ const saved=storage('tabStorage').getItem(activeKey); if(saved !== null) current = id(saved) || current; }catch(error){}
}
return current;
}
function clear(){
memory.clear();
dirty.clear();
if(prefix){
try{
const target = storage('storage');
for(let index = target.length - 1; index >= 0; index--){
const key = target.key(index);
if(key && key.startsWith(prefix)) target.removeItem(key);
}
}catch(error){}
}
active('new');
}
return {read:read,write:write,active:active,remove:value=>write(value,''),clear:clear};
}
};
+91
View File
@@ -0,0 +1,91 @@
// One lookup form for shortcuts and saved order cards; no draft or chat ownership changes.
var OrderLookup = {
create(options){
const overlay=document.getElementById('order-lookup');
const form=document.getElementById('order-lookup-form');
const input=document.getElementById('order-reference');
const label=document.getElementById('order-reference-label');
const title=document.getElementById('order-lookup-title');
const submit=document.getElementById('order-lookup-submit');
const cancel=document.getElementById('order-lookup-cancel');
const copy=document.getElementById('order-reference-copy');
const error=document.getElementById('order-lookup-error');
let kind='inbound', opener=null, busy=false, copying=false, copyState='', version=0, timer=null;
overlay.hidden=true;
function valid(value){
return /^[A-Za-z0-9][A-Za-z0-9_-]{5,63}$/.test(value) && (value.match(/[0-9]/g) || []).length >= 3;
}
function status(message){ error.textContent=message || ''; error.hidden=!message; }
function render(){
title.textContent=options.text(kind === 'inbound' ? 'lookupTitle' : 'lookupOrderTitle');
label.textContent=options.text(kind === 'inbound' ? 'order_inbound' : 'orderNo');
input.placeholder=options.text(kind === 'inbound' ? 'lookupPlaceholder' : 'lookupOrderPlaceholder');
submit.textContent=options.text(busy ? 'lookupSending' : 'lookupSubmit');
cancel.textContent=options.text('customerCancel');
copy.textContent=options.text(copyState || 'copy');
submit.disabled=busy; cancel.disabled=busy; input.disabled=busy;
copy.disabled=busy || copying || !valid(input.value.trim());
}
function clearTimer(){ if(timer !== null) clearTimeout(timer); timer=null; }
function close(){
version+=1;clearTimer();overlay.hidden=true;
busy=false;copying=false;copyState='';input.value='';status('');render();
if(opener && opener.isConnected) opener.focus({preventScroll:true});
opener=null;
}
function open(reference, referenceKind, source){
version+=1;clearTimer();kind=referenceKind === 'order' ? 'order' : 'inbound';
busy=false;copying=false;copyState='';
opener=source || document.activeElement;input.value=reference || '';status('');render();overlay.hidden=false;
if(options.onOpen) options.onOpen();
input.focus({preventScroll:true});
}
input.addEventListener('input',function(){ clearTimer();copyState='';status('');render(); });
copy.onclick=async function(){
const reference=input.value.trim(), token=version;
if(busy || copying || !valid(reference)) return;
copying=true;copyState='';status('');render();
const copied=await SupportUI.copyText(reference);
if(token !== version) return;
copying=false;
if(input.value.trim() === reference){
copyState=copied ? 'copied' : 'copyFailed';
if(!copied) status(options.text('copyFailed'));
timer=setTimeout(function(){copyState='';timer=null;render();},copied ? 1200 : 3000);
}
render();
};
form.addEventListener('submit',async function(event){
event.preventDefault();
if(busy) return;
const reference=input.value.trim(), token=version;
if(!valid(reference)){status(options.text('lookupInvalid'));input.focus({preventScroll:true});return;}
const blocked=options.canQuery();
if(blocked){status(blocked);return;}
busy=true;status('');render();
try{
const accepted=await options.query(reference,kind);
if(token !== version) return;
if(accepted) close();else status(options.text('lookupFailed'));
}catch(error){if(token === version) status(options.text('lookupFailed'));}
finally{if(token === version){busy=false;render();}}
});
cancel.onclick=function(){if(!busy) close();};
overlay.addEventListener('click',function(event){if(event.target === overlay && !busy) close();});
overlay.addEventListener('keydown',function(event){
if(event.key === 'Escape' && !busy){event.preventDefault();close();return;}
if(event.key === 'Tab'){
const controls=[input,copy,cancel,submit].filter(control=>!control.disabled);
event.preventDefault();
if(controls.length){
const index=controls.indexOf(document.activeElement);
controls[(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length].focus({preventScroll:true});
}
}
});
render();
return {open:open,cancel:close,render:render,viewport:function(height,top){
overlay.style.height=height || '';overlay.style.top=top || '';overlay.style.bottom=height ? 'auto' : '';
}};
}
};
+49 -8
View File
@@ -1,5 +1,38 @@
// Shared controls for the customer page and service workspace. // Shared controls for the customer page and service workspace.
var SupportUI = { var SupportUI = {
async copyText(text){
try{
if(navigator.clipboard && navigator.clipboard.writeText){
await navigator.clipboard.writeText(text);
return true;
}
}catch(error){}
const field = document.createElement('textarea');
field.value=text; field.readOnly=true;
field.style.position='fixed'; field.style.opacity='0';
const previous=document.activeElement;
document.body.appendChild(field);
try{
field.focus({preventScroll:true}); field.select();
return Boolean(document.execCommand('copy'));
}catch(error){ return false; }
finally{
field.remove();
if(previous && previous.isConnected) previous.focus({preventScroll:true});
}
},
initAttachments(container, label){
container.addEventListener('error', function(event){
const image = event.target;
if(!image || image.tagName !== 'IMG' || !image.classList.contains('message-image') || image.dataset.unavailable) return;
image.dataset.unavailable = '1';
image.hidden = true;
const hint = document.createElement('span');
hint.className = 'attachment-unavailable';
hint.textContent = typeof label === 'function' ? label() : label;
image.after(hint);
}, true);
},
async enableNotifications(button){ async enableNotifications(button){
if(!('Notification' in window)) return; if(!('Notification' in window)) return;
try{ try{
@@ -58,6 +91,10 @@ var SupportUI = {
}, },
initEmoji(button, panel, input, onInsert){ initEmoji(button, panel, input, onInsert){
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚']; const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
function close(){
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
button.onclick = function(){ button.onclick = function(){
panel.hidden = !panel.hidden; panel.hidden = !panel.hidden;
button.setAttribute('aria-expanded', String(!panel.hidden)); button.setAttribute('aria-expanded', String(!panel.hidden));
@@ -70,20 +107,24 @@ var SupportUI = {
const start = input.selectionStart == null ? input.value.length : input.selectionStart; const start = input.selectionStart == null ? input.value.length : input.selectionStart;
const end = input.selectionEnd == null ? start : input.selectionEnd; const end = input.selectionEnd == null ? start : input.selectionEnd;
input.value = input.value.slice(0, start) + emoji + input.value.slice(end); input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
panel.hidden = true; close();
button.setAttribute('aria-expanded', 'false'); input.focus({preventScroll:true});
input.focus();
input.setSelectionRange(start + emoji.length, start + emoji.length); input.setSelectionRange(start + emoji.length, start + emoji.length);
if(onInsert) onInsert(); if(onInsert) onInsert();
}; };
panel.appendChild(choice); panel.appendChild(choice);
}); });
panel.addEventListener('keydown', function(event){ ['pointerdown','mousedown'].forEach(function(name){
if(event.key === 'Escape'){ document.addEventListener(name, function(event){
panel.hidden = true; if(!panel.hidden && !panel.contains(event.target) && !button.contains(event.target)) close();
button.setAttribute('aria-expanded', 'false'); });
button.focus(); });
document.addEventListener('keydown', function(event){
if(event.key === 'Escape' && !panel.hidden){
close();
button.focus({preventScroll:true});
} }
}); });
return {close:close};
} }
}; };
+1 -1
View File
@@ -45,5 +45,5 @@ var VisitorSession = (function(){
} }
return established ? send() : ensure().then(send); return established ? send() : ensure().then(send);
} }
return {fetch:request}; return {fetch:request,localKey:localKey};
})(); })();
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>附件暂时无法访问 · 柏悦物流</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<style>
body { min-height: 100vh; min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
main { width: min(420px, 100%); padding: 28px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }
h1 { font-size: 22px; margin-bottom: 14px; }
p { color: var(--secondary); margin: 10px 0; }
.english { font-size: 13px; }
a { display: inline-block; margin-top: 12px; }
</style>
</head>
<body><main>
<h1>附件暂时无法访问</h1>
<p>文件可能已过期、被删除或当前无法访问。请返回会话重新发送,或联系人工客服。</p>
<p lang="en" class="english">This attachment is unavailable. Please return to your conversation to upload it again or contact support.</p>
<a href="{{ url_for('admin.admin_dash') if staff else url_for('pages.index') }}">{{ '返回客服工作台' if staff else '返回聊天' }}</a>
</main></body>
</html>
+33 -6
View File
@@ -21,8 +21,8 @@
</div> </div>
<div class="history-wrap" id="historyWrap"></div> <div class="history-wrap" id="historyWrap"></div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button type="button" id="customer-account" class="customer-account">登录</button>
<button type="button" class="clear-all-btn">清空全部历史</button> <button type="button" class="clear-all-btn">清空全部历史</button>
<div class="sidebar-note"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceScope">中欧与英国专线咨询</span></div>
</div> </div>
</aside> </aside>
<button class="sidebar-backdrop" type="button" aria-label="关闭历史侧栏"></button> <button class="sidebar-backdrop" type="button" aria-label="关闭历史侧栏"></button>
@@ -44,15 +44,13 @@
<div class="ai-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div> <div class="ai-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div>
<div class="welcome-eyebrow">中欧 · 英国物流服务</div> <div class="welcome-eyebrow">中欧 · 英国物流服务</div>
<h3>物流问题,随时为您解答</h3> <h3>物流问题,随时为您解答</h3>
<p>咨询运费、时效或联系人工客服。<br>订单查询需登录并关联客户账号。</p>
</div> </div>
</section> </section>
</div> </div>
<footer class="composer"> <footer class="composer">
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div> <div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
<div class="composer-toolbar"><div class="hot-bar"> <div class="composer-toolbar"><div class="hot-bar">
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span> <button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查入仓码</button>
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button> <button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button> <button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button> <button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
@@ -61,19 +59,48 @@
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button> <button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div> <div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden> <input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea> <textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或入仓码" placeholder="输入入仓码或问题…"></textarea>
<button id="voice-hold" class="voice-hold" type="button">按住说话</button> <button id="voice-hold" class="voice-hold" type="button">按住说话</button>
<button type="button" id="send-button">发送</button> <button type="button" id="send-button">发送</button>
</div> </div>
<p id="composer-status" class="composer-status" role="status" hidden></p> <p id="composer-status" class="composer-status" role="status" hidden></p>
<div class="composer-footer"><span class="disclaimer" data-i18n="disclaimer">AI 回答仅供参考,具体以人工客服回复为准</span><span class="keyboard-hint" data-i18n="keyboardHint">Enter 发送 · Shift + Enter 换行</span></div>
</footer> </footer>
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div> <div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button> <button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
</main> </main>
</div> </div>
<div id="customer-login" class="customer-login-overlay" hidden>
<section class="customer-login-dialog" role="dialog" aria-modal="true" aria-labelledby="customer-login-title">
<h2 id="customer-login-title">登录</h2>
<form id="customer-login-form">
<label for="customer-username" id="customer-username-label">账号</label>
<input id="customer-username" name="username" type="text" autocomplete="username" placeholder="请输入账号" maxlength="80" required>
<label for="customer-password" id="customer-password-label">密码</label>
<input id="customer-password" name="password" type="password" autocomplete="current-password" placeholder="请输入密码" maxlength="256" required>
<p id="customer-error" role="alert" hidden></p>
<div class="customer-login-actions"><button type="button" id="customer-cancel">取消</button><button type="submit" id="customer-submit">登录</button></div>
</form>
</section>
</div>
<div id="order-lookup" class="customer-login-overlay" hidden>
<section class="customer-login-dialog" role="dialog" aria-modal="true" aria-labelledby="order-lookup-title">
<h2 id="order-lookup-title">查入仓码</h2>
<form id="order-lookup-form">
<label id="order-reference-label" for="order-reference">入仓码</label>
<div class="order-reference-field">
<input id="order-reference" name="reference" type="text" placeholder="请输入入仓码" maxlength="64" autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false" required>
<button id="order-reference-copy" type="button">复制</button>
</div>
<p id="order-lookup-error" role="alert" hidden></p>
<div class="customer-login-actions"><button id="order-lookup-cancel" type="button">取消</button><button id="order-lookup-submit" type="submit">查询轨迹</button></div>
</form>
</section>
</div>
<script src="{{ url_for('static', filename='js/support.js') }}"></script> <script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script> <script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
<script src="{{ url_for('static', filename='js/customer.js') }}"></script>
<script src="{{ url_for('static', filename='js/order_lookup.js') }}"></script>
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script> <script src="{{ url_for('static', filename='js/voice.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script> <script src="{{ url_for('static', filename='js/chat.js') }}"></script>
</body> </body>
+3 -2
View File
@@ -32,6 +32,7 @@ async function workspace(compact = false){
setAttribute(name, value){ this.attributes[name] = value; } setAttribute(name, value){ this.attributes[name] = value; }
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; } toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
appendChild(element){ this.children.push(element); } appendChild(element){ this.children.push(element); }
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
addEventListener(name, callback){ this.listeners[name] = callback; } addEventListener(name, callback){ this.listeners[name] = callback; }
querySelectorAll(){ return []; } querySelectorAll(){ return []; }
querySelector(selector){ querySelector(selector){
@@ -47,7 +48,7 @@ async function workspace(compact = false){
const quickButtons = [new Element(), new Element()]; const quickButtons = [new Element(), new Element()];
document = { document = {
body: new Element(), activeElement: null, body: new Element(), activeElement: null,
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); }, getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); },
createElement(){ return new Element(); }, createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; }, querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
querySelector(selector){ querySelector(selector){
@@ -55,7 +56,7 @@ async function workspace(compact = false){
if(!match) return null; if(!match) return null;
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null; return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
}, },
addEventListener(name, callback){ documentListeners[name] = callback; }, addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
}; };
const calls = []; const calls = [];
const hooks = {}; const hooks = {};
+567 -21
View File
@@ -6,6 +6,14 @@ const vm = require('node:vm');
const flush = () => new Promise(setImmediate); const flush = () => new Promise(setImmediate);
async function customerPage(pageOptions = {}){ async function customerPage(pageOptions = {}){
const storageValues = pageOptions.storage || {};
if(!storageValues['baiyue-visitor-key']) storageValues['baiyue-visitor-key']='1'.repeat(64);
const tabValues = pageOptions.tabStorage || {};
function testStorage(values){return {
getItem:key=>values[key] ?? null,setItem:(key,value)=>{values[key]=String(value);},
removeItem:key=>{delete values[key];},key:index=>Object.keys(values)[index] ?? null,
get length(){return Object.keys(values).length;}
};}
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {}; const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1; let intervalId = 0, timerId = 0, nextConvId = 1;
let voiceOptions; let voiceOptions;
@@ -13,7 +21,7 @@ async function customerPage(pageOptions = {}){
constructor(){ constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {}; this.children = []; this.attributes = {}; this.value = ''; this.style = {};
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0; this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0;
this.listeners = {}; this.focusCalls = []; this.listeners = {}; this.focusCalls = []; this.isConnected = true;
this.classList = { this.classList = {
contains: name => this.className.split(' ').includes(name), contains: name => this.className.split(' ').includes(name),
add: name => { this.className += ' '+name; }, add: name => { this.className += ' '+name; },
@@ -32,8 +40,10 @@ async function customerPage(pageOptions = {}){
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); } click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
appendChild(child){ child.parent = this; this.children.push(child); } appendChild(child){ child.parent = this; this.children.push(child); }
append(...children){ children.forEach(child => this.appendChild(child)); } append(...children){ children.forEach(child => this.appendChild(child)); }
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); } focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); }
blur(){ if(document.activeElement === this){ document.activeElement=null; if(this.listeners.blur) this.listeners.blur(); } } blur(){ if(document.activeElement === this){ document.activeElement=null; if(this.listeners.blur) this.listeners.blur(); } }
select(){ document.activeElement=this; }
querySelector(selector){ querySelector(selector){
return this.querySelectorAll(selector)[0] || null; return this.querySelectorAll(selector)[0] || null;
} }
@@ -48,26 +58,29 @@ async function customerPage(pageOptions = {}){
closest(selector){ closest(selector){
if(selector === 'button[data-question]' && this.dataset.question) return this; if(selector === 'button[data-question]' && this.dataset.question) return this;
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this; if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
if(selector === 'button[data-order-lookup]' && this.attributes['data-order-lookup'] != null) return this;
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector); return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
} }
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); } remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
} }
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); }; const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
element('emoji-panel').hidden=true;
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; }); const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
const document = { const document = {
body: new Element(), documentElement: {}, body: new Element(), documentElement: {},
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id), getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [], querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
createElement: () => new Element(), createElement: () => new Element(),
addEventListener: (name, callback) => { listeners[name] = callback; }, execCommand: () => pageOptions.copyResult !== false,
addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
}; };
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } } class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
let state = {status:'active', messages:[]}; let state = {status:'active', messages:[]};
const context = vm.createContext({ const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true, document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
FormData:class { append(){} }, FormData:class { append(){} },
requestAnimationFrame:callback => callback(), requestAnimationFrame:callback => callback(),
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}}, localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800, window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); }, addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}), matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
@@ -81,6 +94,7 @@ async function customerPage(pageOptions = {}){
fetch: async (url, options) => { fetch: async (url, options) => {
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)}); calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
if(hooks[url]) return hooks[url](options); if(hooks[url]) return hooks[url](options);
if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch();
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url); if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url); if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
if(url === '/stream_chat'){ if(url === '/stream_chat'){
@@ -93,14 +107,15 @@ async function customerPage(pageOptions = {}){
} }
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'} const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)} : url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/visitor/human/request' ? {ok:true, session_id:7} : url === '/api/customer/session' ? {ok:true,authenticated:Boolean(pageOptions.customer),user:pageOptions.customer || null}
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
: url.startsWith('/api/visitor/human/poll') ? state : url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true} : ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: url === '/api/get_history' ? (pageOptions.history || []) : []; : url === '/api/get_history' ? (pageOptions.history || []) : [];
return {ok:true, json:async () => data}; return {ok:true, json:async () => data};
}, },
}); });
for(const file of ['support.js', 'visitor.js', 'voice.js', 'chat.js']){ for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file}); vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){ if(file === 'voice.js'){
const createVoice=context.ChatVoice.create; const createVoice=context.ChatVoice.create;
@@ -111,7 +126,8 @@ async function customerPage(pageOptions = {}){
await flush(); await flush();
if(pageOptions.human !== false) await context.window.showHuman(); if(pageOptions.human !== false) await context.window.showHuman();
await flush(); await flush();
return {context, element, calls, hooks, quickButtons, voiceOptions, return {context, element, calls, hooks, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues,
documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); },
resize:async height => { resize:async height => {
context.window.innerHeight=height; context.window.innerHeight=height;
if(context.window.visualViewport) context.window.visualViewport.height=height; if(context.window.visualViewport) context.window.visualViewport.height=height;
@@ -129,6 +145,7 @@ async function customerPage(pageOptions = {}){
await flush(); await flush();
}, },
pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); }, pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); },
pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); },
setState:value => { state=value; }, setState:value => { state=value; },
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); }, runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); }, tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
@@ -255,7 +272,7 @@ test('switching topics aborts an upload and does not erase the new conversation
}); });
app.element('upload-input').files=[{name:'old.png'}]; app.element('upload-input').files=[{name:'old.png'}];
app.element('upload-input').onchange(); app.element('upload-input').onchange();
await app.click(app.quickButtons[0]); await app.click(app.quickButtons[1]);
await flush(); await flush();
assert.equal(aborted,true); assert.equal(aborted,true);
assert.equal(app.element('upload-btn').disabled,false); assert.equal(app.element('upload-btn').disabled,false);
@@ -293,7 +310,7 @@ test('clearing history blocks new conversations until its timed request settles'
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0); assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
await app.runTimers(10000); await app.runTimers(10000);
assert.match(app.element('historyWrap').textContent,/保留历史/); assert.match(app.element('historyWrap').textContent,/保留历史/);
await app.click(app.quickButtons[0]); await app.click(app.quickButtons[1]);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1); assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
}); });
@@ -364,13 +381,16 @@ test('recalling an earlier reply preserves newer replies in the customer log', a
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; } function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
test('all three footer questions leave the human queue and send to separate AI conversations', async () => { test('tracking does not interrupt human support while quote and exception shortcuts start AI topics', async () => {
const app = await customerPage(); const app = await customerPage();
for(const button of app.quickButtons) await app.click(button); await app.click(app.quickButtons[0]);
assert.equal(app.context.window.humanSid,7);
assert.match(app.element('composer-status').textContent,/先返回 AI/);
for(const button of app.quickButtons.slice(1)) await app.click(button);
const requests = app.calls.filter(call => call.url === '/stream_chat'); const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 3); assert.equal(requests.length, 2);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3); assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 2);
assert.deepEqual(requests.map(call => call.body.msg), ['查询订单状态', '欧洲海运专线报价', '包裹异常处理']); assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '包裹异常处理']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0); assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0); assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true); assert.equal(app.element('human-session').hidden, true);
@@ -425,8 +445,8 @@ test('a repeated quick click while a new conversation is being created sends onl
const app = await customerPage({human:false}); const app = await customerPage({human:false});
let resolve; let resolve;
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; }); app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
app.quickButtons[0].click(); app.quickButtons[1].click();
app.quickButtons[0].click(); app.quickButtons[1].click();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1); assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
resolve(response({id:12, welcome:'welcome'})); resolve(response({id:12, welcome:'welcome'}));
await flush(); await flush();
@@ -437,7 +457,7 @@ test('a late conversation creation cannot replace the newest quick question', as
const app = await customerPage({human:false}); const app = await customerPage({human:false});
let resolve, count=0; let resolve, count=0;
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'}); app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
app.quickButtons[0].click(); app.quickButtons[1].click();
await app.click(app.quickButtons[2]); await app.click(app.quickButtons[2]);
resolve(response({id:21, welcome:'welcome'})); resolve(response({id:21, welcome:'welcome'}));
await flush(); await flush();
@@ -483,9 +503,9 @@ test('repeated contact clicks create one human request and show a visible queue
}); });
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => { test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'}); const app = await customerPage({human:false, hour:12,serviceClosed:true, offlineMessage:'请明天跟进报价'});
await app.context.window.showHuman(); await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 0); assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价'); assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
assert.match(app.element('chatDom').textContent, /留言已提交/); assert.match(app.element('chatDom').textContent, /留言已提交/);
assert.equal(app.context.window.humanSid, 0); assert.equal(app.context.window.humanSid, 0);
@@ -500,6 +520,13 @@ test('a failed human send preserves the text and makes retry possible', async ()
assert.equal(app.element('send-button').disabled, false); assert.equal(app.element('send-button').disabled, false);
}); });
test('a visitor whose phone says late at night can still reach an open support service', async () => {
const app=await customerPage({human:false,hour:23});
await app.context.window.showHuman();
assert.equal(app.context.window.humanSid,7);
assert.equal(app.calls.filter(call=>call.url==='/api/ticket').length,0);
});
test('a duplicate human send cannot submit an in-flight message twice', async () => { test('a duplicate human send cannot submit an in-flight message twice', async () => {
const app = await customerPage(); const app = await customerPage();
let resolve; let resolve;
@@ -589,6 +616,198 @@ test('shipment cards separate forecasts from actual progress and retain both in
assert.ok(log.querySelector('.msg-actions')); assert.ok(log.querySelector('.msg-actions'));
}); });
test('inbound codes are the primary card identity for live replies and restored history', async () => {
const app=await customerPage({human:false,history:[{id:8,title:'入仓轨迹',ctime:'now'}]});
const card={kind:'order',no:'FL202610290001',status:'履约中',
fields:[{key:'inbound',value:'INB20261029001-01'},{key:'number',value:'FL202610290001'}],
nodes:[],summary:'入仓码 INB20261029001-01'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('INB20261029001-01');await flush();
let heading=app.element('chatDom').querySelector('.track-no');
assert.match(heading.textContent,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.textContent,/FL202610290001/);
assert.match(app.element('chatDom').querySelector('.order-data-fields').textContent,/订单号.*FL202610290001/);
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
await app.click(app.element('historyWrap').children[0]);
heading=app.element('chatDom').querySelector('.track-no');
assert.match(heading.textContent,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.textContent,/FL202610290001/);
});
async function submitOrderLookup(app,reference){
app.element('order-reference').value=reference;
app.element('order-reference').listeners.input();
await app.element('order-lookup-form').listeners.submit({preventDefault(){}});await flush();
}
test('the lookup shortcut keeps the current conversation, draft and attachment until a real reference is submitted', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
await app.context.window.fillInput('原来的聊天');await flush();
app.element('input-text').value='尚未发送的草稿';app.element('input-text').listeners.input();
app.context.window.__pendingImg='/uploads/next-image.png';
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
await app.click(app.quickButtons[0]);
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('.left-panel').classList.contains('left-panel-hide'),false);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
assert.equal(app.element('input-text').value,'尚未发送的草稿');
await submitOrderLookup(app,'hello');
assert.match(app.element('order-lookup-error').textContent,/完整/);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
await submitOrderLookup(app,' INB202611040001 ');
const last=app.calls.filter(call=>call.url==='/stream_chat').at(-1);
assert.equal(last.body.msg,'查询入仓码 INB202611040001');
assert.equal(last.body.conv_id,1);
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat').length,1);
assert.equal(app.element('order-lookup').hidden,true);
assert.match(app.element('chatDom').textContent,/查询入仓码 INB202611040001/);
assert.equal(app.element('input-text').value,'尚未发送的草稿');
assert.equal(app.context.window.__pendingImg,'/uploads/next-image.png');
});
test('the guest shortcut signs in before opening lookup and sends no empty order question', async () => {
const app=await customerPage({human:false});
app.hooks['/api/customer/login']=()=>response({ok:true,user:{id:1,name:'admin'}});
app.element('input-text').value='游客原草稿';app.element('input-text').listeners.input();
await app.click(app.quickButtons[0]);
assert.equal(app.element('customer-login').hidden,false);
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat' || call.url==='/stream_chat').length,0);
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('input-text').value,'游客原草稿');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
await app.click(app.element('order-lookup-cancel'));
assert.equal(app.element('input-text').value,'游客原草稿');
});
test('live and saved inbound references open a prefilled form and copy only the code', async () => {
const copied=[];
const app=await customerPage({human:false,customer:{id:1,name:'admin'},history:[{id:9,title:'历史订单',ctime:'now'}],
clipboard:{writeText:async value=>{copied.push(value);}}});
const card={kind:'order',no:'FL202611040001',status:'履约中',fields:[{key:'inbound',value:'INB202611040001'}],nodes:[],summary:'订单概要'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('INB202611040001');await flush();
await app.click(app.element('chatDom').querySelector('.order-reference'));
assert.equal(app.element('order-reference').value,'INB202611040001');
await app.click(app.element('order-reference-copy'));
assert.deepEqual(copied,['INB202611040001']);
assert.equal(app.element('order-reference-copy').textContent,'已复制');
await app.click(app.element('order-lookup-cancel'));
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
await app.click(app.element('historyWrap').children[0]);
await app.click(app.element('chatDom').querySelector('.order-reference'));
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card:{...card,nodes:[{time:'2026-11-04 12:00',text:'最新物流节点',place:''}]}})+'\n\ndata: {"d":1}\n\n']);
await submitOrderLookup(app,'INB202611040001');
const last=app.calls.filter(call=>call.url==='/stream_chat').at(-1);
assert.equal(last.body.conv_id,9);assert.equal(last.body.msg,'查询入仓码 INB202611040001');
assert.equal(app.element('chatDom').querySelectorAll('.track-card').length,2);
assert.match(app.element('chatDom').querySelectorAll('.track-card').at(-1).textContent,/最新物流节点/);
});
test('an order without an inbound code keeps its truthful order-number label and lookup kind', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
const card={kind:'order',no:'FL202611040001',status:'履约中',fields:[],nodes:[],summary:'订单概要'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput(card.no);await flush();
const header=app.element('chatDom').querySelector('.track-no');
assert.match(header.textContent,/订单号/);assert.doesNotMatch(header.textContent,/入仓码/);
await app.click(header.querySelector('.order-reference'));
assert.equal(app.element('order-reference-label').textContent,'订单号');
await submitOrderLookup(app,card.no);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').at(-1).body.msg,'查询订单 '+card.no);
});
test('lookup copy failure is visible and a retry succeeds without changing the typed code', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},copyResult:false,
clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
await app.click(app.quickButtons[0]);
app.element('order-reference').value='INB202611040001';app.element('order-reference').listeners.input();
await app.click(app.element('order-reference-copy'));
assert.equal(app.element('order-reference-copy').textContent,'复制失败');
assert.match(app.element('order-lookup-error').textContent,/复制失败/);
app.context.document.execCommand=()=>true;
await app.click(app.element('order-reference-copy'));
assert.equal(app.element('order-reference-copy').textContent,'已复制');
assert.equal(app.element('order-lookup-error').hidden,true);
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.context.document.body.children.length,0);
});
test('a duplicated lookup submit sends once and a failed creation keeps the reference editable', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});let release;
await app.click(app.quickButtons[0]);
app.hooks['/api/new_chat']=()=>new Promise(resolve=>{release=resolve;});
const pending=submitOrderLookup(app,'INB202611040001');await flush();
await app.element('order-lookup-form').listeners.submit({preventDefault(){}});
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat').length,1);
release(response({ok:false},503));await pending;
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.element('order-reference').disabled,false);
app.hooks['/api/new_chat']=()=>response({id:11,welcome:'welcome'});
await submitOrderLookup(app,'INB202611040001');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
});
test('lookup does not stop an active AI reply or clear a pending draft', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
app.hooks['/stream_chat']=()=>({ok:true,body:{getReader:()=>({read:()=>new Promise(()=>{})})}});
await app.context.window.fillInput('正在回复的问题');await flush();
app.element('input-text').value='下一条草稿';app.element('input-text').listeners.input();
await app.click(app.quickButtons[0]);
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.context.window.__busy,true);
assert.match(app.element('composer-status').textContent,/等待当前操作/);
assert.equal(app.element('input-text').value,'下一条草稿');
await app.click(app.element('send-button'));
});
test('a late shortcut sign-in cannot open a private lookup after the user changes topics', async () => {
const app=await customerPage({human:false});let release;
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;});
await app.click(app.quickButtons[0]);
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}});
await app.click(app.element('.new-chat-btn'));
app.element('input-text').value='新话题草稿';app.element('input-text').listeners.input();
release(response({ok:true,user:{id:1,name:'admin'}}));await signing;
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.element('input-text').value,'新话题草稿');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('welcome lookup uses the same form and follows the mobile keyboard viewport', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},width:390,viewportHeight:844});
const welcome=app.element('lookup-welcome');welcome.setAttribute('data-order-lookup','');
app.element('chatDom').listeners.click({target:welcome});await flush();
assert.equal(app.element('order-lookup').hidden,false);
await app.resizeViewport(430,1,80);
assert.equal(app.element('order-lookup').style.height,'430px');
assert.equal(app.element('order-lookup').style.top,'80px');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('a late repeated shortcut status response cannot erase the latest lookup input', async () => {
const user={id:1,name:'admin'}, app=await customerPage({human:false,customer:user}), releases=[];
app.hooks['/api/customer/session']=()=>new Promise(resolve=>{releases.push(resolve);});
const first=app.quickButtons[0].click(), second=app.quickButtons[0].click();await flush();
releases[1](response({ok:true,authenticated:true,user}));await second;await flush();
app.element('order-reference').value='INB202611040001';app.element('order-reference').listeners.input();
releases[0](response({ok:true,authenticated:true,user}));await first;await flush();
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('closing lookup restores the shortcut instead of reopening the composer keyboard', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},width:390});
const input=app.element('input-text');input.focus();
await app.click(app.quickButtons[0]);
await app.click(app.element('order-lookup-cancel'));
assert.equal(app.context.document.activeElement,app.quickButtons[0]);
assert.equal(input.focusCalls.length,1);
assert.equal(app.quickButtons[0].focusCalls.at(-1).preventScroll,true);
});
test('order details can expand without hiding the shipment timeline', async () => { test('order details can expand without hiding the shipment timeline', async () => {
const app = await customerPage({human:false,width:390}); const app = await customerPage({human:false,width:390});
const card = {kind:'order',no:'ORDER2026001',status:'履约中',node_kind:'logistics', const card = {kind:'order',no:'ORDER2026001',status:'履约中',node_kind:'logistics',
@@ -782,3 +1001,330 @@ test('an AI regeneration is not forwarded to a human session', async () => {
assert.match(app.element('chatDom').textContent,/测试 AI 回复/); assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
assert.match(app.element('composer-status').textContent,/先返回 AI/); assert.match(app.element('composer-status').textContent,/先返回 AI/);
}); });
test('feedback waits for server confirmation, ignores rapid duplicates and allows retry after failure', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('反馈测试');await flush();
const log=app.element('chatDom'), buttons=log.querySelectorAll('.msg-action-btn');
const up=buttons.find(button=>button.textContent==='👍'), down=buttons.find(button=>button.textContent==='👎');
let finish;
app.hooks['/api/feedback']=()=>new Promise(resolve=>{finish=resolve;});
const sending=up.onclick();
await flush();
await down.onclick();
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,1);
assert.equal(up.classList.contains('voted'),false);
assert.ok(up.disabled && down.disabled);
finish(response({ok:false},503));await sending;
assert.equal(up.classList.contains('voted'),false);
assert.match(log.textContent,/评价未提交/);
assert.equal(up.disabled,false);
app.hooks['/api/feedback']=()=>response({ok:true});
await up.onclick();
assert.ok(up.classList.contains('voted'));
assert.equal(up.getAttribute('aria-pressed'),'true');
assert.match(log.textContent,/感谢您的评价/);
await up.onclick();
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,2);
});
test('clipboard rejection and a failed fallback never report a successful copy', async () => {
const app=await customerPage({human:false,copyResult:false,clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
await app.context.window.fillInput('复制测试');await flush();
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
app.element('input-text').focus();
await copy.onclick();await flush();
assert.equal(copy.textContent,'复制失败');
assert.equal(copy.disabled,false);
assert.equal(app.context.document.activeElement,app.element('input-text'));
assert.equal(app.element('input-text').focusCalls.at(-1).preventScroll,true);
assert.equal(app.context.document.body.children.length,0);
await app.runTimers(3000);
assert.equal(copy.textContent,'复制');
});
test('successful clipboard fallback reports copied and keeps text focus stable', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('复制测试');await flush();
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
await copy.onclick();
assert.equal(copy.textContent,'已复制');
assert.equal(copy.disabled,false);
await app.runTimers(1200);
assert.equal(copy.textContent,'复制');
});
test('blank offline messages are ignored and invalid ticket text stays editable with a readable error', async () => {
const empty=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:' '});
await empty.context.window.showHuman();await flush();
assert.equal(empty.calls.filter(call=>call.url==='/api/ticket').length,0);
const app=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:'需要保留的留言'});
app.hooks['/api/ticket']=()=>response({ok:false,error:'请输入 1–2000 字的留言。'},400);
await app.context.window.showHuman();await flush();
assert.equal(app.element('input-text').value,'需要保留的留言');
assert.notEqual(app.element('input-text').style.height,'');
assert.match(app.element('composer-status').textContent,/1–2000/);
});
test('mobile history selection closes the drawer while desktop navigation stays visible', async () => {
for(const width of [390,1200]){
const app=await customerPage({human:false,width,history:[{id:9,title:'历史会话',ctime:'now'}]});
if(width===390) await app.click(app.element('.toggle-divider'));
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),'true');
await app.click(app.element('historyWrap').children[0]);
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),String(width>900));
}
});
test('emoji panel dismisses outside or on Escape, stays open for its own controls, and closes on conversation change', async () => {
const app=await customerPage({human:false,width:390});
const toggle=app.element('emoji-toggle'), panel=app.element('emoji-panel');
await app.click(toggle);
assert.equal(panel.hidden,false);
app.documentEvent('pointerdown',{target:panel.children[0]});
assert.equal(panel.hidden,false);
app.documentEvent('pointerdown',{target:app.element('input-text')});
assert.equal(panel.hidden,true);
assert.equal(toggle.getAttribute('aria-expanded'),'false');
await app.click(toggle);
app.documentEvent('keydown',{key:'Escape'});
assert.equal(panel.hidden,true);
await app.click(toggle);
await app.click(app.element('.new-chat-btn'));
assert.equal(panel.hidden,true);
});
test('drafts stay with their own conversation and restore across a page reload', async () => {
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
const app=await customerPage({human:false,history});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='A的未发送草稿';app.element('input-text').listeners.input();
await app.click(app.element('historyWrap').children[1]);
assert.equal(app.element('input-text').value,'');
app.element('input-text').value='B的未发送草稿';app.element('input-text').listeners.input();
await app.click(app.element('historyWrap').children[0]);
assert.equal(app.element('input-text').value,'A的未发送草稿');
await app.pageHide();
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
await flush();
assert.equal(restored.element('input-text').value,'A的未发送草稿');
assert.ok(restored.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
await restored.click(restored.element('historyWrap').children[1]);
assert.equal(restored.element('input-text').value,'B的未发送草稿');
});
test('a new conversation draft restores after refresh and moves to the created conversation on send', async () => {
const app=await customerPage({human:false});
app.element('input-text').value='尚未发送';app.element('input-text').listeners.input();await app.pageHide();
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage});
assert.equal(restored.element('input-text').value,'尚未发送');
await restored.context.window.fillInput('尚未发送');await restored.pageHide();
const store=restored.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>restored.context.localStorage,tabStorage:()=>restored.context.sessionStorage});
assert.equal(store.read(null),'');assert.equal(store.read(1),'');assert.equal(store.active(),'1');
});
test('draft recovery stays silent but quota failure remains visible until storage recovers', async () => {
const history=[{id:9,title:'A',ctime:'now'}],app=await customerPage({human:false,history});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='已保存草稿';app.element('input-text').listeners.input();await app.pageHide();
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
assert.equal(restored.element('input-text').value,'已保存草稿');
assert.equal(restored.element('composer-status').textContent,'');
const setItem=restored.context.localStorage.setItem;
restored.context.localStorage.setItem=()=>{throw Error('quota');};
restored.element('input-text').value='新的未保存输入';restored.element('input-text').listeners.input();await restored.runTimers(250);
assert.match(restored.element('composer-status').textContent,/无法保存/);
assert.equal(restored.element('input-text').value,'新的未保存输入');
restored.context.localStorage.setItem=setItem;await restored.pageHide();
assert.equal(restored.element('composer-status').textContent,'');
});
test('restoring an old conversation cannot replace a newer user-selected draft', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();await app.pageHide();
let release;
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
await restored.click(restored.element('.new-chat-btn'));
restored.element('input-text').value='新话题草稿';restored.element('input-text').listeners.input();
release(response([{id:9,title:'A',ctime:'now'}]));await flush();
assert.equal(restored.element('input-text').value,'新话题草稿');
});
test('failed history loading cannot send a question under a hidden conversation context', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
await app.context.window.fillInput('原会话');await flush();
app.hooks.messages=()=>response({ok:false},503);
await app.click(app.element('historyWrap').children[0]);
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
await app.context.window.fillInput('禁止发到未加载会话');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
assert.match(app.element('composer-status').textContent,/会话未加载成功/);
assert.doesNotMatch(app.element('chatDom').textContent,/原会话/);
});
test('startup removes a deleted active conversation draft without erasing a newer edit', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='旧会话草稿';app.element('input-text').listeners.input();await app.pageHide();
const noEdit=await customerPage({human:false,storage:{...app.storage},tabStorage:{...app.tabStorage}});
assert.equal(noEdit.element('input-text').value,'');
assert.ok(!noEdit.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
let release;
const edited=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
edited.element('input-text').value='加载期间的新输入';edited.element('input-text').listeners.input();
release(response([]));await flush();
assert.equal(edited.element('input-text').value,'加载期间的新输入');
await edited.context.window.fillInput('加载期间的新输入');
assert.ok(!edited.calls.some(call=>call.url==='/stream_chat'));
assert.match(edited.element('composer-status').textContent,/会话未加载成功/);
});
test('deleting a conversation removes only its saved draft', async () => {
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
const app=await customerPage({human:false,history});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();
await app.click(app.element('historyWrap').children[1]);
app.element('input-text').value='B草稿';app.element('input-text').listeners.input();await app.pageHide();
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
const store=app.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>app.context.localStorage,tabStorage:()=>app.context.sessionStorage});
assert.equal(store.read(9),'');assert.equal(store.read(10),'B草稿');
});
test('an order login action resumes the original question and preserves a newer draft', async () => {
const app=await customerPage({human:false});let authenticated=false;
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated,user:authenticated ? {id:1,name:'admin'} : null});
app.hooks['/api/customer/login']=()=>{authenticated=true;return response({ok:true,authenticated:true,user:{id:1,name:'admin'}});};
app.hooks['/stream_chat']=()=>streamResponse([`data: ${JSON.stringify(authenticated ? {t:'账号范围内的订单结果'} : {t:'请先登录',login_required:true})}\n\ndata: {"d":1}\n\n`]);
await app.context.window.fillInput('查询ORDER20260001');await flush();
const action=app.element('chatDom').querySelector('.customer-login-cta');assert.ok(action);
app.element('input-text').value='下一条未发送草稿';app.element('input-text').listeners.input();
await app.click(action);assert.equal(app.element('customer-login').hidden,false);
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
assert.equal(app.element('input-text').value,'下一条未发送草稿');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,2);
assert.match(app.element('chatDom').textContent,/账号范围内的订单结果/);
assert.match(app.element('customer-account').textContent,/admin/);
});
test('invalid customer credentials leave the login form open without sending another question', async () => {
const app=await customerPage({human:false});
app.hooks['/api/customer/login']=()=>response({ok:false,error:'账号或密码不正确。'},403);
await app.click(app.element('customer-account'));
app.element('customer-username').value='admin';app.element('customer-password').value='wrong';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
assert.equal(app.element('customer-login').hidden,false);
assert.match(app.element('customer-error').textContent,/账号或密码/);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/login').length,1);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
await app.click(app.element('customer-cancel'));
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
});
test('reauthentication resumes an expired order followup only for the same customer and conversation', async () => {
for(const scenario of [{customer:1},{customer:2},{customer:1,denied:true},
{customer:1,edit:true},{customer:1,switched:true}]){
const nextCustomer=scenario.customer;
const app=await customerPage({human:false});let authenticated=false, customer=1;
let releaseHistory;
const user=()=>({id:customer,name:customer===1 ? 'admin' : 'other'});
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated,user:authenticated ? user() : null});
app.hooks['/api/customer/login']=()=>{authenticated=true;return response({ok:true,authenticated:true,user:user()});};
app.hooks['/api/get_history']=()=>response(authenticated ? [{id:1,title:'订单',ctime:'now'}] : []);
const history=scenario.denied ? [] : [{role:'user',content:'INBOUND20260001'},
{role:'assistant',content:'已查询的订单轨迹'}];
app.hooks.messages=()=>scenario.edit || scenario.switched
? new Promise(resolve=>{releaseHistory=()=>resolve(response(history));}) : response(history);
app.hooks['/stream_chat']=()=>streamResponse([`data: ${JSON.stringify(authenticated ? {t:'订单轨迹结果'} : {t:'请先登录',login_required:true})}\n\ndata: {"d":1}\n\n`]);
await app.click(app.element('customer-account'));
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
await app.context.window.fillInput('INBOUND20260001');await flush();
authenticated=false;
await app.context.window.fillInput('现在到哪了?');await flush();
await app.click(app.element('chatDom').querySelector('.customer-login-cta'));
assert.equal(app.element('customer-login').hidden,false);
assert.doesNotMatch(app.element('chatDom').textContent,/订单轨迹结果/);
app.element('input-text').value='下一条未发送';app.element('input-text').listeners.input();
customer=nextCustomer;
app.element('customer-username').value=customer===1 ? 'admin' : 'other';
app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();await flush();
if(releaseHistory){
if(scenario.switched) await app.click(app.element('.new-chat-btn'));
app.element('input-text').value='恢复期间的新输入';app.element('input-text').listeners.input();
releaseHistory();await flush();await flush();
}
const resumed=nextCustomer===1 && !scenario.denied && !scenario.switched;
const sends=app.calls.filter(call=>call.url==='/stream_chat');
assert.equal(sends.length,resumed ? 3 : 2);
if(resumed){
assert.equal(sends[2].body.conv_id,1);assert.equal(sends[2].body.msg,'现在到哪了?');
assert.match(app.element('chatDom').textContent,/订单轨迹结果/);
assert.match(app.element('chatDom').textContent,/现在到哪了?/);
}
assert.equal(app.element('input-text').value,releaseHistory ? '恢复期间的新输入' : '下一条未发送');
}
});
test('a late login cannot replay an order into a newer conversation', async () => {
const app=await customerPage({human:false});let release;
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null});
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"先登录","login_required":true}\n\ndata: {"d":1}\n\n']);
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;});
await app.context.window.fillInput('查询ORDER20260001');await flush();
await app.click(app.element('chatDom').querySelector('.customer-login-cta'));
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}});
await app.click(app.element('.new-chat-btn'));
app.element('input-text').value='新话题';app.element('input-text').listeners.input();
release(response({ok:true,authenticated:true,user:{id:1,name:'admin'}}));await signing;
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
assert.equal(app.element('input-text').value,'新话题');
});
test('signing out clears visible order conversation and returns to guest mode', async () => {
const app=await customerPage({human:false});
app.hooks['/api/customer/login']=()=>response({ok:true,authenticated:true,user:{id:1,name:'admin'}});
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
await app.click(app.element('customer-account'));
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
await app.context.window.fillInput('ORDER20260001');await flush();
assert.ok(app.element('chatDom').querySelector('.user-message'));
await app.click(app.element('customer-account'));
assert.equal(app.element('customer-account').textContent,'登录');
assert.equal(app.element('chatDom').querySelector('.user-message'),null);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/logout').length,1);
});
test('signing out removes customer history titles even if refreshing guest history fails', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'INBOUND20260001 客户订单',ctime:'now'}]});
app.hooks['/api/customer/login']=()=>response({ok:true,authenticated:true,user:{id:1,name:'admin'}});
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
await app.click(app.element('customer-account'));
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();
assert.match(app.element('historyWrap').textContent,/INBOUND20260001/);
app.hooks['/api/get_history']=()=>response({ok:false},503);
await app.click(app.element('customer-account'));
assert.doesNotMatch(app.element('historyWrap').textContent,/INBOUND20260001/);
assert.equal(app.element('customer-account').textContent,'登录');
});
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
const app=await customerPage({human:false});
let shown;
const image={tagName:'IMG',classList:{contains:name=>name==='message-image'},dataset:{},after:hint=>{shown=hint;}};
app.element('chatDom').listeners.error({target:image});
assert.equal(image.hidden,true);
assert.match(shown.textContent,/图片暂时无法访问/);
const first=shown;
app.element('chatDom').listeners.error({target:image});
assert.equal(shown,first);
});
+48
View File
@@ -0,0 +1,48 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const vm=require('node:vm');
const path=require('node:path');
const context=vm.createContext({});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/drafts.js'),'utf8'),context);
function storage(){
const data=new Map();
return {data,get length(){return data.size;},key:index=>Array.from(data.keys())[index] ?? null,
getItem:key=>data.get(key) ?? null,setItem:(key,value)=>data.set(key,value),removeItem:key=>data.delete(key)};
}
function drafts(scope='1'.repeat(64),local=storage(),tab=storage()){
return context.ChatDrafts.create({scope,storage:()=>local,tabStorage:()=>tab});
}
test('conversation drafts and active tab restore independently of another visitor or tab',()=>{
const local=storage(),tab=storage(),a=drafts(undefined,local,tab);
a.write(9,'A');a.write(10,'B');a.write(null,'NEW');a.active(9);
const restored=drafts(undefined,local,tab);
assert.equal(restored.read(9),'A');assert.equal(restored.read(10),'B');assert.equal(restored.read(null),'NEW');
assert.equal(restored.active(),'9');
const otherTab=drafts(undefined,local);otherTab.write(10,'B updated');
assert.equal(otherTab.active(),'new');assert.equal(restored.read(10),'B updated');assert.equal(restored.read(9),'A');
otherTab.remove(10);assert.equal(restored.read(10),'');
assert.equal(drafts('2'.repeat(64),local,tab).read(9),'');
});
test('malformed records and invalid identifiers cannot become restored drafts',()=>{
const local=storage(),a=drafts(undefined,local),key='baiyue-draft-v1:'+'1'.repeat(64)+':9';
for(const value of ['null','{"version":2,"text":"old"}','{"version":1,"text":45}']){
local.setItem(key,value);assert.equal(a.read(9),'');
}
assert.equal(a.write('../9','invalid'),false);assert.equal(a.read('../9'),'');
});
test('removing and clearing a visitor never clears another visitor or unrelated preferences',()=>{
const local=storage(),a=drafts(undefined,local),b=drafts('2'.repeat(64),local);
local.setItem('theme','dark');a.write(9,'A');a.write(10,'B');b.write(9,'PRIVATE');
a.remove(9);assert.equal(a.read(9),'');assert.equal(a.read(10),'B');
a.clear();assert.equal(a.read(10),'');assert.equal(b.read(9),'PRIVATE');assert.equal(local.getItem('theme'),'dark');
});
test('blocked storage and quota errors retain the latest in-memory draft without a shared bucket',()=>{
const broken={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}};
const a=drafts(undefined,broken,broken);
assert.equal(a.write(9,'latest'),false);assert.equal(a.read(9),'latest');a.active(9);assert.equal(a.active(),'9');
const local=storage(),b=drafts(undefined,local);b.write(9,'old');
local.setItem=()=>{throw Error('quota');};assert.equal(b.write(9,'new'),false);assert.equal(b.read(9),'new');
const anonymous=drafts(null,local);anonymous.write(9,'memory only');assert.equal(anonymous.read(9),'memory only');
assert.equal(drafts(null,local).read(9),'');
});
+36
View File
@@ -378,6 +378,42 @@ class ApiTests(unittest.TestCase):
self.client.delete('/api/clear_all') self.client.delete('/api/clear_all')
self.assertEqual(self.client.get('/api/get_history').json, []) self.assertEqual(self.client.get('/api/get_history').json, [])
def test_browser_votes_and_legacy_rows_are_both_counted_in_admin_stats(self):
for vote in ('up', 'down', 1, -1):
self.assertEqual(self.client.post('/api/feedback', json={'vote':vote,'question':'q','answer':'a'}).status_code,200)
with self.app.app_context():
connection=db.connect()
connection.executemany('INSERT INTO feedback(vote) VALUES(?)', [('up',),('down',)])
connection.commit(); connection.close()
stats=self.client.get('/api/admin/stats').json
self.assertEqual((stats['good'],stats['bad']),(3,3))
def test_invalid_customer_feedback_and_tickets_return_json_errors_without_writes(self):
for data in (None, [], [1], {}, {'vote':True}, {'vote':0}, {'vote':'unknown'},
{'vote':'up','answer':{}}, {'vote':'down','question':[]}):
with self.subTest(feedback=data):
response=self.client.post('/api/feedback',json=data)
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
for data in (None, [], [1], {}, {'content':' '}, {'content':{}}, {'content':'x'*2001},
{'content':'help','phone':[]}, {'content':'help','phone':'1'*33}):
with self.subTest(ticket=data):
response=self.client.post('/api/ticket',json=data)
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
for path in ('/api/feedback','/api/ticket'):
response=self.client.post(path,data='{bad',content_type='application/json')
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
with self.app.app_context():
connection=db.connect()
for table in ('feedback','tickets'):
self.assertEqual(connection.execute('SELECT COUNT(*) FROM '+table).fetchone()[0],0)
connection.close()
self.assertEqual(self.client.post('/api/ticket',json={'content':' 请跟进报价 ','phone':' +86 123 '}).status_code,200)
ticket=self.client.get('/api/tickets').json[0]
self.assertEqual((ticket['content'],ticket['phone']),('请跟进报价','+86 123'))
def test_business_routes_with_initialized_tables_and_legacy_auth(self): def test_business_routes_with_initialized_tables_and_legacy_auth(self):
with self.app.app_context(): with self.app.app_context():
connection = db.connect() connection = db.connect()
+218
View File
@@ -0,0 +1,218 @@
"""Customer login, tenant scope and sensitive-history lifecycle."""
import hashlib
import json
from pathlib import Path
import tempfile
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch
from logistics_chat import create_app, db, order_data
from logistics_chat.customer_service import COOKIE_NAME
class CustomerTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'ORDER_DATA_TENANT_ID':'','CUSTOMER_DEMO_TENANT_ID':'tenant-a','RATE_LIMIT_PER_MIN':1000})
self.a,self.b=self.app.test_client(),self.app.test_client()
for client,key in ((self.a,'1'*64),(self.b,'2'*64)):
client.post('/api/visitor/session',json={'key':key})
with self.app.app_context():
connection=db.connect()
order_data.import_records(connection,[{'order_no':'ORDER20260001','tenant_id':'tenant-a','gross_weight_kg':30},
{'order_no':'ORDER20260002','tenant_id':'tenant-b','gross_weight_kg':99}])
connection.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) VALUES(?,?,?,?)',
('other',self.app.config['CUSTOMER_DEMO_PASSWORD_HASH'],'Other','tenant-b'))
connection.commit();connection.close()
def login(self, client=None, username='admin'):
return (client or self.a).post('/api/customer/login',json={'username':username,'password':'123456'})
def events(self,message,conv=None,client=None):
response=(client or self.a).post('/stream_chat',json={'msg':message,'conv_id':conv})
return [json.loads(part[6:]) for part in response.get_data(as_text=True).split('\n\n') if part.startswith('data: ')]
def test_guest_must_login_then_customer_reads_only_the_linked_scope(self):
with patch('logistics_chat.order_data.lookup_order') as lookup:
self.assertTrue(self.events('查询ORDER20260001')[0]['login_required']);lookup.assert_not_called()
self.assertTrue(self.login().json['ok'])
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
self.assertEqual(self.events('查询ORDER20260001')[0]['card']['no'],'ORDER20260001')
self.assertIn('未找到',self.events('查询ORDER20260002')[0]['t'])
forged=self.a.post('/stream_chat',json={'msg':'ORDER20260002','tenant_id':'tenant-b'},headers={'X-Tenant-Id':'tenant-b'})
self.assertNotIn('99 kg',forged.get_data(as_text=True))
def test_common_tracking_requests_require_login_without_calling_the_model(self):
questions = ('查物流', '帮我查一下物流', '物流查询', '查货', '帮我查下轨迹',
'我的货到哪了', '我的包裹到哪里了?', '这票货什么时候到?',
'Where is my parcel?', 'Track my package', 'What is my shipment status?')
with patch('logistics_chat.ai_client.post_completion') as model:
for question in questions:
with self.subTest(question=question):
self.assertTrue(self.events(question)[0].get('login_required'))
self.login()
for question in questions:
with self.subTest(authenticated_question=question):
self.assertIn('请提供入仓码', self.events(question)[0]['t'])
model.assert_not_called()
def test_credentials_are_hashed_and_session_is_opaque_secure_expiring_and_revoked(self):
secure=self.app.test_client()
secure.post('/api/visitor/session',json={'key':'3'*64},base_url='https://localhost')
response=secure.post('/api/customer/login',json={'username':'admin','password':'123456'},base_url='https://localhost')
for flag in ('Secure','HttpOnly','SameSite=Lax'):
self.assertIn(flag,response.headers['Set-Cookie'])
self.login();token=self.a.get_cookie(COOKIE_NAME).value
with self.app.app_context():
connection=db.connect()
self.assertNotEqual(connection.execute("SELECT password_hash FROM customer_users WHERE username='admin'").fetchone()[0],'123456')
self.assertTrue(connection.execute('SELECT 1 FROM customer_sessions WHERE token_hash=?',
(hashlib.sha256(token.encode()).hexdigest(),)).fetchone());connection.close()
with patch('logistics_chat.customer_service.time.time',return_value=time.time()+9*3600):
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.a.post('/api/customer/logout');self.a.set_cookie(COOKIE_NAME,token)
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
def test_customer_cookie_cannot_be_reused_by_another_guest_and_bad_logins_are_limited(self):
self.login();self.b.set_cookie(COOKIE_NAME,self.a.get_cookie(COOKIE_NAME).value)
self.assertFalse(self.b.get('/api/customer/session').json['authenticated'])
self.b.post('/api/customer/logout')
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
for index in range(5):
response=self.b.post('/api/customer/login',json={'username':'admin','password':'wrong'})
self.assertEqual(response.status_code,403)
self.assertEqual(self.login(self.b).status_code,429)
def test_order_history_is_hidden_after_logout_and_restored_only_to_its_customer_and_guest(self):
self.login();cid=self.a.post('/api/new_chat').json['id']
self.events('ORDER20260001',cid)
self.a.post('/api/customer/logout')
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.assertEqual(self.a.get('/api/get_history').json,[])
self.assertTrue(self.events('继续查询',cid)[0]['login_required'])
self.a.delete('/api/clear_all')
self.login(username='other')
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.login()
self.assertTrue(any('__CARD__' in row['content'] for row in self.a.get('/api/get_messages',query_string={'conv_id':cid}).json))
self.login(self.b)
self.assertEqual(self.b.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
def test_cross_site_login_logout_and_oversized_or_malformed_requests_fail(self):
for path in ('login','logout'):
self.assertEqual(self.a.post('/api/customer/'+path,json={'username':'admin','password':'123456'},
headers={'Origin':'https://foreign.example'}).status_code,403)
self.assertEqual(self.a.post('/api/customer/login',json=[]).status_code,400)
self.assertEqual(self.a.post('/api/customer/login',json={'username':[],'password':'123456'}).status_code,400)
self.assertEqual(self.a.post('/api/customer/login',json={'padding':'x'*3000}).status_code,413)
def test_disabled_accounts_and_password_changes_invalidate_existing_sessions(self):
self.login()
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=0 WHERE username='admin'");connection.commit();connection.close()
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.assertEqual(self.login().status_code,403)
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=1 WHERE username='admin'");connection.commit();connection.close()
self.login()
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET password_hash=password_hash||'changed' WHERE username='admin'");connection.commit();connection.close()
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
def test_demo_binding_does_not_pick_an_arbitrary_tenant_when_dataset_has_multiple(self):
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
self.app.config['CUSTOMER_DEMO_LOCAL_ORDERS']=False
self.assertTrue(self.login().json['ok'])
reply=self.events('ORDER20260001')[0]
self.assertIn('尚未关联',reply['t']);self.assertNotIn('login_required',reply)
with self.app.app_context():
connection=db.connect();connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-b'");connection.commit();connection.close()
self.login()
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
def test_tenant_reassignment_does_not_reveal_the_accounts_old_order_history(self):
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260001',cid)
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='tenant-b' WHERE username='admin'");connection.commit();connection.close()
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.login()
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
def test_demo_disable_revokes_default_login_without_disabling_real_customer_records(self):
self.login();self.app.config['CUSTOMER_DEMO_ENABLED']=False
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.assertEqual(self.login().status_code,403)
self.assertTrue(self.login(username='other').json['authenticated'])
def test_parallel_invalid_logins_cannot_bypass_the_attempt_budget(self):
barrier=Barrier(6)
def attempt(index):
with self.app.test_client() as client:
client.set_cookie('baiyue_visitor','1'*64)
barrier.wait(timeout=5)
return client.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code
with ThreadPoolExecutor(max_workers=6) as executor:
statuses=list(executor.map(attempt,range(6)))
self.assertEqual(statuses.count(403),5);self.assertEqual(statuses.count(429),1)
def test_default_local_account_has_explicit_existing_tenants_and_real_users_remain_scoped(self):
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
self.assertTrue(self.login().json['authenticated'])
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
with self.app.app_context():
connection=db.connect()
order_data.import_records(connection,[{'order_no':'ORDER20260003','tenant_id':'new-ungranted-tenant'}]);connection.commit();connection.close()
self.assertIn('未找到',self.events('ORDER20260003')[0]['t'])
self.login(username='other')
self.assertIn('未找到',self.events('ORDER20260001')[0]['t'])
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
def test_removing_a_tenant_grant_revokes_sessions_and_old_scope_history(self):
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
with self.app.app_context():
connection=db.connect();connection.execute("DELETE FROM customer_tenant_grants WHERE tenant_id='tenant-b'");connection.commit();connection.close()
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.login()
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
def test_revoking_every_local_grant_is_not_undone_by_login_or_startup(self):
from logistics_chat.customer_service import init_customer_tables
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
self.login()
with self.app.app_context():
connection=db.connect();connection.execute('DELETE FROM customer_tenant_grants');connection.commit()
init_customer_tables(connection);connection.commit();connection.close()
self.login()
self.assertIn('尚未关联',self.events('ORDER20260001')[0]['t'])
with self.app.app_context():
connection=db.connect();self.assertEqual(connection.execute('SELECT COUNT(*) FROM customer_tenant_grants').fetchone()[0],0);connection.close()
def test_server_scope_narrowing_revokes_the_session_and_hides_broader_history(self):
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
self.app.config['ORDER_DATA_TENANT_ID']='tenant-a'
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.login()
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
+80
View File
@@ -1,14 +1,19 @@
"""Exercise the server factory using disposable state/log paths and no model key.""" """Exercise the server factory using disposable state/log paths and no model key."""
import io import io
import json
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import shlex
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from deploy import wsgi from deploy import wsgi
from logistics_chat import create_app
from waitress.adjustments import Adjustments
from waitress.proxy_headers import proxy_headers_middleware
class DeploymentTests(unittest.TestCase): class DeploymentTests(unittest.TestCase):
@@ -44,5 +49,80 @@ class DeploymentTests(unittest.TestCase):
self.assertEqual(app.config['HOST'], '127.0.0.1') self.assertEqual(app.config['HOST'], '127.0.0.1')
class ProxyIdentityTests(unittest.TestCase):
def application(self, runtime):
root = Path(__file__).resolve().parents[1]
if runtime == 'docker':
command = next(line[4:] for line in (root/'Dockerfile').read_text(encoding='utf-8').splitlines()
if line.startswith('CMD '))
args = json.loads(command)[3:]
else:
command = next(line[len('ExecStart='):] for line in
(root/'deploy/baiyue-logistics-chat.service').read_text(encoding='utf-8').splitlines()
if line.startswith('ExecStart='))
args = shlex.split(command)[3:]
options, _ = Adjustments.parse_args(args)
options.pop('call'); options.pop('help')
settings = Adjustments(**options)
folder = tempfile.TemporaryDirectory(); self.addCleanup(folder.cleanup)
data = Path(folder.name)
app = create_app({'TESTING':True,'DATABASE':data/'history.db','UPLOAD_DIR':data/'uploads',
'BACKUP_DIR':data/'backups','LOG_DIR':data/'logs','RATE_LIMIT_PER_MIN':1000})
app.config['PROXY_TEST_PEER'] = '172.18.0.1' if runtime == 'docker' else '127.0.0.1'
app.wsgi_app = proxy_headers_middleware(app.wsgi_app, trusted_proxy=settings.trusted_proxy,
trusted_proxy_count=settings.trusted_proxy_count, trusted_proxy_headers=settings.trusted_proxy_headers)
return app
def clients(self, app):
clients = []
for address in ('198.51.100.10', '198.51.100.20'):
client = app.test_client()
client.environ_base['REMOTE_ADDR'] = app.config['PROXY_TEST_PEER']
client.environ_base['HTTP_X_FORWARDED_FOR'] = address
client.post('/api/visitor/session', json={'key':str(len(clients)+1)*64})
clients.append(client)
app.extensions['rate_hits'].clear()
return clients
def test_proxy_clients_do_not_share_request_rate_limits(self):
for runtime in ('docker', 'systemd'):
with self.subTest(runtime=runtime):
app = self.application(runtime)
a, b = self.clients(app)
app.config['RATE_LIMIT_PER_MIN'] = 2
self.assertEqual(a.get('/api/get_history').status_code, 200)
self.assertEqual(a.get('/api/get_history').status_code, 200)
self.assertEqual(a.get('/api/get_history').status_code, 429)
self.assertEqual(b.get('/api/get_history').status_code, 200)
def test_proxy_clients_do_not_share_failed_login_budgets(self):
for runtime in ('docker', 'systemd'):
with self.subTest(runtime=runtime):
app = self.application(runtime)
a, b = self.clients(app)
for _ in range(5):
self.assertEqual(a.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code,403)
self.assertEqual(a.post('/api/customer/login',json={'username':'admin','password':'123456'}).status_code,429)
self.assertTrue(b.post('/api/customer/login',json={'username':'admin','password':'123456'}).json.get('authenticated'))
def test_only_the_last_forwarded_address_is_used_and_other_proxy_claims_are_ignored(self):
from flask import request
for runtime in ('docker', 'systemd'):
with self.subTest(runtime=runtime):
app = self.application(runtime)
@app.get('/proxy-probe')
def probe():
return {'ip':request.remote_addr,'host':request.host,'scheme':request.scheme}
response = app.test_client().get('/proxy-probe',base_url='https://chat.example.com',
environ_overrides={'REMOTE_ADDR':app.config['PROXY_TEST_PEER']},headers={
'X-Forwarded-For':'203.0.113.99, 198.51.100.10',
'X-Forwarded-Host':'evil.example','X-Forwarded-Proto':'http'})
self.assertEqual(response.json,{'ip':'198.51.100.10','host':'chat.example.com','scheme':'https'})
if runtime == 'systemd':
untrusted = app.test_client().get('/proxy-probe',environ_overrides={'REMOTE_ADDR':'203.0.113.1'},
headers={'X-Forwarded-For':'198.51.100.10'})
self.assertEqual(untrusted.json['ip'],'203.0.113.1')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+14 -1
View File
@@ -1,6 +1,6 @@
"""Remote dev behavior on the modular backend and the light workspace.""" """Remote dev behavior on the modular backend and the light workspace."""
from datetime import datetime from datetime import datetime, timezone
import io import io
from pathlib import Path from pathlib import Path
import tempfile import tempfile
@@ -50,6 +50,19 @@ class DevIntegrationTests(unittest.TestCase):
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True}) self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言') self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
def test_service_hours_use_china_time_even_when_the_host_clock_is_utc(self):
cid = self.client.post('/api/new_chat').json['id']
for utc_hour, expected in ((0, True),(13, True),(14, False),(23, False)):
instant = datetime(2026,10,31,utc_hour,tzinfo=timezone.utc)
class HostUtcClock(datetime):
@classmethod
def now(cls, tz=None):
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
with self.subTest(utc_hour=utc_hour), patch('logistics_chat.human_service.datetime', HostUtcClock):
result = self.client.post('/api/visitor/human/request',json={'conv_id':cid}).json
self.assertEqual(result['ok'],expected)
if not expected: self.assertTrue(result['closed'])
def test_transfer_returns_to_queue_preserves_messages_and_note(self): def test_transfer_returns_to_queue_preserves_messages_and_note(self):
sid = self.session() sid = self.session()
self.client.post('/api/human/accept', json={'sid': sid}) self.client.post('/api/human/accept', json={'sid': sid})
+1 -1
View File
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
def test_reply_workflows(self): def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
result = subprocess.run( result = subprocess.run(
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs'], cwd=root, ['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30, text=True, encoding='utf-8', capture_output=True, timeout=30,
) )
self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+7 -1
View File
@@ -66,13 +66,19 @@ class GrayConfigTests(unittest.TestCase):
pattern, body = match.groups() pattern, body = match.groups()
self.assertIn('auth_basic off;', body) self.assertIn('auth_basic off;', body)
self.assertIn('auth_basic "Baiyue gray acceptance";', config) self.assertIn('auth_basic "Baiyue gray acceptance";', config)
self.assertIn('proxy_set_header X-Forwarded-For $remote_addr;',config)
self.assertNotIn('$proxy_add_x_forwarded_for',config)
compose=(Path(__file__).resolve().parents[1]/'deploy/compose.gray.yml').read_text(encoding='utf-8')
self.assertIn('"127.0.0.1:${HOST_PORT:?HOST_PORT is required}:5000"',compose)
for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages', for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages',
'/api/customer/session','/api/customer/login','/api/customer/logout',
'/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll', '/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll',
'/api/visitor/human/send','/api/visitor/human/close','/api/speech/transcribe'): '/api/visitor/human/send','/api/visitor/human/close','/api/speech/transcribe'):
with self.subTest(public_path=path):self.assertRegex(path,pattern) with self.subTest(public_path=path):self.assertRegex(path,pattern)
for path in ('/admin/dashboard','/api/export_chat','/api/search_chat','/api/my_orders','/api/tickets', for path in ('/admin/dashboard','/api/export_chat','/api/search_chat','/api/my_orders','/api/tickets',
'/api/human/queue','/api/human/poll','/api/human/send','/api/staff/uploads/private.txt', '/api/human/queue','/api/human/poll','/api/human/send','/api/staff/uploads/private.txt',
'/api/visitor/human/accept','/api/get_history/unknown','/api/unknown'): '/api/visitor/human/accept','/api/customer/users','/api/customer/session/extra',
'/api/get_history/unknown','/api/unknown'):
with self.subTest(staff_path=path):self.assertNotRegex(path,pattern) with self.subTest(staff_path=path):self.assertNotRegex(path,pattern)
example=(Path(__file__).resolve().parents[1]/'deploy/nginx.conf.example').read_text(encoding='utf-8') example=(Path(__file__).resolve().parents[1]/'deploy/nginx.conf.example').read_text(encoding='utf-8')
self.assertEqual(re.search(r'location ~ (\S+) \{',example)[1],pattern) self.assertEqual(re.search(r'location ~ (\S+) \{',example)[1],pattern)
+155
View File
@@ -0,0 +1,155 @@
"""Order counts are fresh, customer-scoped facts with protected history."""
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app, db, order_data
class OrderCountTests(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory(); self.addCleanup(folder.cleanup)
root = Path(folder.name)
self.app = create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'CUSTOMER_DEMO_TENANT_ID':'tenant-a','ORDER_DATA_TENANT_ID':'','RATE_LIMIT_PER_MIN':1000})
self.a, self.b = self.app.test_client(), self.app.test_client()
for client,key in ((self.a,'1'*64),(self.b,'2'*64)):
client.post('/api/visitor/session',json={'key':key})
with self.app.app_context():
connection = db.connect()
order_data.import_records(connection,[
{'tenant_id':'tenant-a','order_no':'COUNT20260001','inbound_code':'INB20260001','status':'PROCESSING'},
{'tenant_id':'tenant-a','order_no':'COUNT20260002','status':'COMPLETED'},
{'tenant_id':'tenant-b','order_no':'COUNT20260001','inbound_code':'INB20260099','status':'PROCESSING'},
{'tenant_id':'ungranted','order_no':'COUNT20260003','status':'COMPLETED'}])
connection.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) VALUES(?,?,?,?)',
('other',self.app.config['CUSTOMER_DEMO_PASSWORD_HASH'],'Other','tenant-b'))
connection.commit(); connection.close()
def login(self, client=None, username='admin'):
self.assertTrue((client or self.a).post('/api/customer/login',json={'username':username,'password':'123456'}).json['ok'])
def events(self, message, cid=None, client=None):
response = (client or self.a).post('/stream_chat',json={'msg':message,'conv_id':cid})
self.assertEqual(response.status_code,200)
return [json.loads(frame[6:]) for frame in response.get_data(as_text=True).split('\n\n') if frame.startswith('data: ')]
def test_guest_counts_require_login_before_reading_any_counts(self):
with patch('logistics_chat.order_data.count_orders') as count, patch('logistics_chat.ai_client.post_completion') as model:
for question in ('我有多少个运单','我的订单数量','我今天有多少订单','How many shipments do I have?'):
with self.subTest(question=question):
self.assertTrue(self.events(question)[0]['login_required'])
count.assert_not_called(); model.assert_not_called()
def test_logged_in_counts_accept_common_wording_and_refresh_after_data_changes(self):
self.login()
cid = self.a.post('/api/new_chat').json['id']
self.events('INB20260001',cid)
with patch('logistics_chat.ai_client.post_completion') as model:
for question in ('我有多少个运单','我一共有几票订单?','请帮我统计一下我的订单数量',
'我的运单有多少','运单数','我有多少入仓码','我想知道我有多少个运单',
'能帮我查下我的运单总数吗','帮我统计下我名下的订单','How many shipments do I have?',
'What is the number of my orders?'):
with self.subTest(question=question):
events = self.events(question,cid)
self.assertEqual(events[0],{'t':'您当前共有 2 票订单,其中 1 票已登记入仓码。'})
self.assertEqual(events[-1],{'d':1})
model.assert_not_called()
with self.app.app_context():
connection = db.connect()
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260004','inbound_code':'INB20260004'}])
connection.commit(); connection.close()
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
def test_business_status_filters_are_exact_and_unknown_conditions_never_return_the_total(self):
self.login()
with patch('logistics_chat.ai_client.post_completion') as model:
completed = self.events('我有多少个已完成的订单')[0]['t']
self.assertIn('1 票业务状态为“头程订单已完成”',completed)
self.assertIn('0 票已登记入仓码',completed)
self.assertNotIn('签收',completed)
self.assertIn('1 票业务状态为“履约中”',self.events('履约中的运单有多少')[0]['t'])
self.assertIn('0 票业务状态为“已取消”',self.events('我有多少已取消订单')[0]['t'])
for question in ('我今天有多少订单','我有多少已签收的运单','美国运单有多少',
'我有多少未完成的订单','已完成和已取消各有多少订单'):
with self.subTest(question=question):
result = self.events(question)[0]
self.assertIn('暂不支持按这个条件统计',result['t'])
self.assertNotIn('您当前共有',result['t'])
model.assert_not_called()
for question in ('这个订单重量有多少','订单运费是多少','订单数量是什么意思'):
self.assertIsNone(order_data.parse_count_query(question))
def test_multiple_grants_and_environment_restrictions_use_the_same_scope_as_lookup(self):
with self.app.app_context():
connection = db.connect()
connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=1 WHERE username='admin'")
user = connection.execute("SELECT id FROM customer_users WHERE username='admin'").fetchone()[0]
connection.executemany('INSERT INTO customer_tenant_grants(customer_id,tenant_id) VALUES(?,?)',
[(user,'tenant-a'),(user,'tenant-b')])
connection.commit(); connection.close()
self.login()
self.assertIn('共有 3 票订单,其中 2 票',self.events('我有多少个运单')[0]['t'])
forged = self.a.post('/stream_chat',json={'msg':'我有多少个运单','tenant_id':'ungranted'},headers={'X-Tenant-Id':'ungranted'})
self.assertIn('共有 3 票订单',forged.get_data(as_text=True))
self.app.config['ORDER_DATA_TENANT_ID']='tenant-b'
self.assertTrue(self.events('我有多少个运单')[0]['login_required'])
self.login()
self.assertIn('共有 1 票订单,其中 1 票',self.events('我有多少个运单')[0]['t'])
with self.app.app_context():
self.assertEqual(order_data.count_orders(('tenant-a','tenant-b')),{'total':1,'with_inbound':1})
for scope in (None,(),[],['ungranted'],'tenant-b'):
self.assertEqual(order_data.count_orders(scope),{'total':0,'with_inbound':0})
def test_count_history_is_bound_even_without_an_order_card(self):
self.login(); cid = self.a.post('/api/new_chat').json['id']
events = self.events('我有多少个运单',cid)
self.assertNotIn('_customer_data',json.dumps(events))
self.a.post('/api/customer/logout')
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.assertEqual(self.a.get('/api/get_history').json,[])
self.a.delete('/api/clear_all')
self.login(username='other')
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.login(self.b)
self.assertEqual(self.b.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.login()
self.assertTrue(any('共有 2 票订单' in m['content'] for m in self.a.get('/api/get_messages',query_string={'conv_id':cid}).json))
def test_count_failure_and_failed_history_binding_never_emit_facts(self):
self.login(); cid = self.a.post('/api/new_chat').json['id']
with patch('logistics_chat.customer_service.bind_order_conversation',return_value=False):
events = self.events('我有多少个运单',cid)
self.assertIn('无法访问',events[0]['e'])
self.assertNotIn('共有',json.dumps(events))
with patch('logistics_chat.db.connect',side_effect=sqlite3.OperationalError('private diagnostic')):
with self.app.app_context():
with self.assertRaises(order_data.OrderDataUnavailable):
order_data.count_orders(('tenant-a',))
with patch('logistics_chat.order_data.count_orders',side_effect=order_data.OrderDataUnavailable):
events = self.events('我有多少个运单')
self.assertIn('暂时无法读取',events[0]['t'])
def test_empty_data_and_blank_codes_are_counted_truthfully(self):
self.login()
with self.app.app_context():
connection = db.connect()
connection.execute('UPDATE order_test_data SET inbound_code=? WHERE order_no=? AND tenant_id=?',
(' \t\r\n','COUNT20260002','tenant-a'))
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260005','inbound_code':'INB20260001'}])
connection.commit(); connection.close()
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
with self.app.app_context():
connection = db.connect()
connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-a'")
connection.commit(); connection.close()
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 0 票订单,其中 0 票已登记入仓码。')
if __name__ == '__main__':
unittest.main()
+39 -4
View File
@@ -22,8 +22,6 @@ class OrderDataTests(unittest.TestCase):
self.client.post('/api/visitor/session', json={'key':'1'*64}) self.client.post('/api/visitor/session', json={'key':'1'*64})
self.context = self.app.app_context() self.context = self.app.app_context()
self.context.push() self.context.push()
from flask import g
g.customer_tenant_id = 'tenant-a'
self.addCleanup(self.context.pop) self.addCleanup(self.context.pop)
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a', self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208', 'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
@@ -32,6 +30,8 @@ class OrderDataTests(unittest.TestCase):
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'}, 'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'},
{'time':'2026-10-29 10:00', 'status':'PROCESSING'}]} {'time':'2026-10-29 10:00', 'status':'PROCESSING'}]}
self.import_rows([self.record]) self.import_rows([self.record])
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
self.client.get('/api/customer/session')
def import_rows(self, rows, replace=False): def import_rows(self, rows, replace=False):
connection = db.connect() connection = db.connect()
@@ -61,9 +61,26 @@ class OrderDataTests(unittest.TestCase):
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'): '1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
self.assertEqual(order_data.extract_reference(text), []) self.assertEqual(order_data.extract_reference(text), [])
events = self.events('帮我查询物流轨迹') events = self.events('帮我查询物流轨迹')
self.assertIn('订单号', events[0]['t']) self.assertIn('入仓码', events[0]['t'])
self.assertEqual(events[-1], {'d':1}) self.assertEqual(events[-1], {'d':1})
def test_direct_inbound_lookup_and_followup_keep_the_inbound_identity(self):
cid=self.client.post('/api/new_chat').json['id']
card=self.events(self.record['inbound_code'],cid)[0]['card']
self.assertEqual(card['no'],self.record['order_no'])
self.assertTrue(card['summary'].startswith('入仓码 '+self.record['inbound_code']))
self.assertEqual(next(item['value'] for item in card['fields'] if item['key']=='number'),self.record['order_no'])
self.import_rows([{**self.record,'gross_weight_kg':'42.000'}],replace=True)
followup=self.events('现在到哪了',cid)[0]['card']
self.assertTrue(followup['summary'].startswith('入仓码 '+self.record['inbound_code']))
self.assertEqual(next(item['value'] for item in followup['fields'] if item['key']=='weight'),'42.000 kg')
def test_missing_inbound_code_is_not_replaced_by_the_internal_order_number(self):
self.import_rows([{**self.record,'inbound_code':None}],replace=True)
card=self.events(self.record['order_no'])[0]['card']
self.assertFalse(any(item['key']=='inbound' for item in card['fields']))
self.assertTrue(card['summary'].startswith('订单号 '+self.record['order_no']))
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self): def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的', for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm', 'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
@@ -86,7 +103,7 @@ class OrderDataTests(unittest.TestCase):
def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self): def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self):
with patch('logistics_chat.ai_client.post_completion') as model: with patch('logistics_chat.ai_client.post_completion') as model:
events = self.events('列出所有订单') events = self.events('列出所有订单')
self.assertIn('请提供订单号', events[0]['t']) self.assertIn('请提供入仓码', events[0]['t'])
model.assert_not_called() model.assert_not_called()
def test_server_tenant_scope_and_ambiguous_identifiers(self): def test_server_tenant_scope_and_ambiguous_identifiers(self):
@@ -186,6 +203,24 @@ class OrderDataTests(unittest.TestCase):
self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no']) self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no'])
model.assert_not_called() model.assert_not_called()
def test_tracking_aliases_reuse_only_the_current_orders_context(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['inbound_code'], cid)
with patch('logistics_chat.ai_client.post_completion') as model:
for question in ('查物流', '帮我查一下物流', '物流查询', '帮我查下轨迹', '查一下进度',
'我的货到哪了', '这票货什么时候到?', '这票状态是什么?', '这个目的仓是什么?',
'Where is my parcel?', 'What is my shipment status?'):
with self.subTest(question=question):
self.assertEqual(self.events(question, cid)[0]['card']['no'], self.record['order_no'])
model.assert_not_called()
db.save_message(cid, 'user', '美国海运费用怎么算?')
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
self.assertIn('请提供入仓码', self.events('查物流', cid)[0]['t'])
for question in ('物流轨迹是什么意思?', '物流查询怎么操作?', '怎么查物流?',
'海运一般多久到?', '包裹丢了怎么办?', 'What is parcel tracking?', '入仓码是什么?'):
with self.subTest(general_question=question):
self.assertIsNone(order_data.answer_query(question, None))
def test_card_storage_and_model_context_contain_only_selected_facts(self): def test_card_storage_and_model_context_contain_only_selected_facts(self):
card = self.events(self.record['order_no'])[0]['card'] card = self.events(self.record['order_no'])[0]['card']
text = card_to_text(CARD_PREFIX+json.dumps(card)) text = card_to_text(CARD_PREFIX+json.dumps(card))
+221
View File
@@ -0,0 +1,221 @@
"""Synthetic prices exercise the quote contract; no customer workbook is committed."""
from copy import deepcopy
from decimal import Decimal
from pathlib import Path
import json
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from zipfile import ZipFile
from logistics_chat import create_app, db
from logistics_chat.quote_data import import_snapshot, load_book
from logistics_chat.quote_engine import quantity
def fixture_snapshot():
rates = []
for origin, extra in (('义乌',0), ('华南',1)):
for zone, price in (('美西',2), ('美中',3), ('美东',4)):
for minimum, discount in ((12,0),(51,1),(101,1.5)):
rates.append({'service':'普船海派','kind':'parcel','origin':origin,'zone':zone,
'unit':'kg','tax':'included','min_weight':str(minimum), 'price':str(price+extra-discount),
'eta':'船开后若干天','destinations':[],'source_sheet':'合成海派','source_cell':'C9'})
for unit, tax, price in (('kg','included','3'),('cbm','excluded','100')):
rates.append({'service':'合成海卡','kind':'truck','origin':origin,'zone':'','unit':unit,
'tax':tax,'min_weight':'0','price':price,'eta':'船开后若干天','destinations':['ZZZ1'],
'source_sheet':'合成海卡','source_cell':'C8'})
return {'schema':1,'source_name':'synthetic-quotes.xlsx','source_sha256':'1'*64,'rates':rates,
'warehouses':[{'code':'ZZZ1','zone':zone,'state':'AA','postal':'01234','address':'',
'source_sheet':'合成分区','source_cell':f'A{index+3}'} for index,zone in enumerate(('东1区','东2区'))],
'postcodes':[{'postal':'01234','severity':'remote','fee':'1'},
{'postal':'99901','severity':'extreme','fee':None},
{'postal':'12345','severity':'extreme','fee':None}],
'documents':[{'sheet':'合成说明','cell':'A1','text':'合成业务说明,不含真实数据'}]}
def frames(response):
return [json.loads(f[6:]) for f in response.get_data(as_text=True).split('\n\n') if f.startswith('data: ')]
class QuoteTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory(); self.addCleanup(directory.cleanup)
root = Path(directory.name)
self.app = create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session',json={'key':'1'*64})
with self.app.app_context():
connection = db.connect()
import_snapshot(connection,fixture_snapshot(),activate=True)
connection.commit(); connection.close()
def ask(self, question, cid=None):
events = frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':cid}))
self.assertEqual(events[-1],{'d':1},events)
self.assertFalse(any(e.get('e') for e in events),events)
return events[0]['t']
def conversation(self):
return self.client.post('/api/new_chat').json['id']
def test_guest_us_30kg_always_gets_prices_without_model_or_login(self):
with patch('logistics_chat.ai_client.post_completion') as model:
for question in ('发送美国30KG','30kg发US仓海运报价','30kg发USFBA仓海运报价','30kg to US'):
with self.subTest(question=question):
text = self.ask(question)
self.assertIn('美西 2元/KG,30KG = 60元',text)
self.assertIn('美东 4元/KG,30KG = 120元',text)
self.assertIn('华南',text)
model.assert_not_called()
def test_ambiguous_warehouse_returns_both_zones_and_own_rates(self):
text = self.ask('美国30kg发ZZZ1海派,义乌交仓')
for zone in ('东1区','东2区'):
self.assertIn('ZZZ1 · '+zone,text)
self.assertEqual(text.count('30KG = 150元'),2)
self.assertIn('海派已计入偏远附加费 1元/KG',text)
self.assertNotIn('华南',text)
def test_context_resumes_after_warehouse_origin_weight_and_dimensions(self):
cid = self.conversation()
self.ask('美国30kg报价',cid)
text = self.ask('ZZZ1',cid)
self.assertIn('美国 30KG',text)
self.assertIn('合成海卡',text)
text = self.ask('华南呢',cid)
self.assertIn('30KG',text)
self.assertIn('华南',text)
self.assertNotIn('· 义乌',text)
text = self.ask('50kg呢',cid)
self.assertIn('美国 50KG',text)
text = self.ask('2箱每箱15kg,40x30x20cm呢',cid)
self.assertIn('美国 30KG',text)
fresh = self.ask('ZZZ1')
self.assertNotIn('美国 30KG',fresh)
self.assertIn('多少公斤',fresh)
def test_new_topics_do_not_reuse_old_quote_or_cross_order_privacy(self):
from logistics_chat.quote_service import answer_quote
cid = self.conversation()
self.ask('美国30kg报价',cid)
with self.app.test_request_context('/'):
self.assertIsNone(answer_quote('新的一票50kg发德国报价',cid))
db.save_message(cid,'user','美国货物丢失如何理赔?')
self.assertIsNone(answer_quote('50kg呢',cid))
events = frames(self.client.post('/stream_chat',json={'msg':'查询入仓码 INB20260001','conv_id':cid}))
self.assertTrue(events[0]['login_required'])
def test_replaced_destination_stays_replaced_after_other_followups(self):
cid = self.conversation()
self.ask('美国30kg发ZZZ1海派,义乌',cid)
text = self.ask('换成邮编12345',cid)
self.assertNotIn('ZZZ1',text)
self.ask('那空运呢',cid)
text = self.ask('海运呢',cid)
self.assertNotIn('ZZZ1',text)
self.assertIn('12345',text)
self.assertIn('美国 30KG',text)
def test_new_quote_tables_start_empty_and_existing_orders_are_untouched(self):
with self.app.app_context():
connection = db.connect()
before = connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0]
import_snapshot(connection,fixture_snapshot(),activate=True)
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0],before)
self.assertEqual(connection.execute("SELECT postal FROM quote_postcodes WHERE severity='remote'").fetchone()[0],'01234')
connection.execute('UPDATE quote_books SET active=0'); connection.commit(); connection.close()
text = self.ask('美国30kg海运报价')
self.assertIn('尚未包含',text)
def test_tier_boundaries_and_per_box_minimum_use_decimal_calculation(self):
for weight, amount in ((50,100),(51,51),(100,100),(101,50.5)):
with self.subTest(weight=weight):
self.assertIn(f'{amount:g}元',self.ask(f'美国美西{weight}kg海派,义乌交仓'))
text = self.ask('美国美西2箱每箱5kg,义乌海派')
self.assertIn('美国 10KG',text)
self.assertIn('24KG = 48元',text)
with self.app.app_context():
book = load_book()
rate = next(r for r in book['rates'] if r['kind']=='parcel')
intent = {'weight':'10','cbm':None,'dims':[60,50,40],'dim_unit':'cm','boxes':2}
self.assertEqual(quantity(intent,rate),Decimal(40))
def test_non_tax_volume_uses_density_rule_and_missing_volume_still_replies(self):
text = self.ask('美国30kg发ZZZ1,不包税,义乌')
self.assertIn('100元/CBM',text)
self.assertNotIn('30CBM',text)
text = self.ask('美国363kg发ZZZ1,不包税,0.5方,义乌')
self.assertIn('1CBM = 100元',text)
def test_extreme_remote_air_missing_and_invalid_inputs_finish_cleanly(self):
text = self.ask('美国30kg邮编12345海派,义乌')
self.assertIn('遥远地址',text)
self.assertIn('30KG = 120元',text)
self.assertIn('不接收',self.ask('美国30kg邮编99901海派'))
self.assertIn('空运费率当前资料没有列出',self.ask('美国30kg空运报价'))
self.assertIn('大于 0',self.ask('美国0kg海运报价'))
def test_quote_history_is_saved_and_isolated_by_visitor(self):
cid = self.conversation()
text = self.ask('美国30KG报价',cid)
history = self.client.get('/api/get_messages',query_string={'conv_id':cid}).json
self.assertTrue(any(m['content']==text for m in history))
other = self.app.test_client(); other.post('/api/visitor/session',json={'key':'2'*64})
self.assertEqual(other.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
def test_import_activation_is_fresh_idempotent_and_invalid_import_preserves_current(self):
with self.app.app_context():
connection = db.connect()
result = import_snapshot(connection,fixture_snapshot(),activate=True)
self.assertTrue(result['existing'])
self.assertEqual(connection.execute('SELECT COUNT(*) FROM quote_rates').fetchone()[0],22)
newer = deepcopy(fixture_snapshot()); newer['source_sha256']='2'*64
for rate in newer['rates']:
rate['price']='9'
import_snapshot(connection,newer,activate=True); connection.commit()
invalid = deepcopy(newer); invalid['source_sha256']='3'*64; invalid['rates'][0]['price']='NaN'
with self.assertRaises(ValueError):
import_snapshot(connection,invalid,activate=True)
self.assertEqual(connection.execute('SELECT COUNT(*) FROM quote_books WHERE active=1').fetchone()[0],1)
self.assertEqual(connection.execute('SELECT source_sha256 FROM quote_books WHERE active=1').fetchone()[0],'2'*64)
connection.close()
self.assertIn('美西 9元/KG,30KG = 270元',self.ask('美国30kg海派,义乌'))
def test_failed_insert_rolls_back_entire_version_and_import_does_not_commit_caller(self):
with self.app.app_context():
connection = db.connect()
broken = deepcopy(fixture_snapshot()); broken['source_sha256']='2'*64
broken['postcodes'].append(deepcopy(broken['postcodes'][0]))
with self.assertRaises(sqlite3.IntegrityError):
import_snapshot(connection,broken,activate=True)
self.assertEqual(connection.execute('SELECT COUNT(*) FROM quote_books').fetchone()[0],1)
connection.rollback()
newer = deepcopy(fixture_snapshot()); newer['source_sha256']='3'*64
import_snapshot(connection,newer,activate=True)
connection.rollback()
self.assertEqual(connection.execute('SELECT source_sha256 FROM quote_books WHERE active=1').fetchone()[0],'1'*64)
connection.close()
def test_lowercase_english_pronoun_is_not_a_us_destination(self):
from logistics_chat.quote_service import answer_quote
with self.app.app_context():
self.assertIsNone(answer_quote('Can you help us quote 30kg to Germany?',None))
def test_local_xlsx_reader_ignores_external_formulas_and_fake_full_width_headers(self):
from logistics_chat.quote_workbook import read_sheets
with tempfile.TemporaryDirectory() as folder:
path = Path(folder)/'fixture.xlsx'
ns = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
with ZipFile(path,'w') as archive:
archive.writestr('xl/workbook.xml',f'<workbook xmlns="{ns}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="合成资料" sheetId="1" r:id="rId1"/></sheets></workbook>')
archive.writestr('xl/_rels/workbook.xml.rels','<Relationships><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>')
archive.writestr('xl/worksheets/sheet1.xml',f'<worksheet xmlns="{ns}"><sheetData><row r="2"><c r="A2" t="inlineStr"><is><t>01234</t></is></c><c r="B2"><f>[1]Sheet!A1</f><v>999</v></c><c r="XFD2" t="inlineStr"><is><t>列16384</t></is></c></row></sheetData></worksheet>')
self.assertEqual(read_sheets(path),{'合成资料':{2:{'A':'01234'}}})
if __name__ == '__main__':
unittest.main()
+11 -1
View File
@@ -13,9 +13,19 @@ from logistics_chat import knowledge, pricing, tracking
class RuleTests(unittest.TestCase): class RuleTests(unittest.TestCase):
def test_specific_knowledge_wins_and_unknown_questions_have_no_match(self): def test_specific_knowledge_wins_and_unknown_questions_have_no_match(self):
self.assertIn('22KG+', knowledge.search_knowledge('英国空运价格')) for question in ('英国空运价格','英国空运多久','英国空运多少钱'):
self.assertIn('22KG+', knowledge.search_knowledge(question))
self.assertIn('21KG+', knowledge.search_knowledge('欧洲空运价格'))
self.assertIsNone(knowledge.search_knowledge('没有相关关键词的测试')) self.assertIsNone(knowledge.search_knowledge('没有相关关键词的测试'))
def test_support_knowledge_matches_the_daily_china_time_service_window(self):
for question in ('人工客服工作时间', '周末人工客服上班吗', '怎么联系你们'):
with self.subTest(question=question):
answer = knowledge.search_knowledge(question)
self.assertIn('每天北京时间 8:00—22:00', answer)
self.assertNotIn('9:00', answer)
self.assertNotIn('不上班', answer)
def test_sea_tier_boundary_and_minimum(self): def test_sea_tier_boundary_and_minimum(self):
self.assertEqual(pricing.calc_freight(100, 'sea')['price'], 12.3) self.assertEqual(pricing.calc_freight(100, 'sea')['price'], 12.3)
self.assertEqual(pricing.calc_freight(101, 'sea')['price'], 10.3) self.assertEqual(pricing.calc_freight(101, 'sea')['price'], 10.3)
+14
View File
@@ -123,6 +123,20 @@ class StorageTests(unittest.TestCase):
self.assertNotIn('..', name) self.assertNotIn('..', name)
self.assertFalse(result['is_image']) self.assertFalse(result['is_image'])
def test_old_attachments_referenced_by_ai_or_human_history_survive_cleanup(self):
uploads=self.root/'uploads'; uploads.mkdir()
for name in ('ai.png','human.pdf','unused.txt'):
file=uploads/name;file.write_bytes(b'fixture')
os.utime(file,(time.time()-20*86400,time.time()-20*86400))
connection=db.connect()
connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(1,'user','[图片]/uploads/ai.png','now')")
connection.execute("INSERT INTO human_messages(session_id,side,content,ctime) VALUES(1,'customer','[文件]/uploads/human.pdf','now')")
connection.commit();connection.close()
file_service.cleanup_uploads()
self.assertTrue((uploads/'ai.png').exists())
self.assertTrue((uploads/'human.pdf').exists())
self.assertFalse((uploads/'unused.txt').exists())
def test_invalid_and_oversized_uploads_are_rejected(self): def test_invalid_and_oversized_uploads_are_rejected(self):
self.assertEqual(file_service.upload_file({})[1], 400) self.assertEqual(file_service.upload_file({})[1], 400)
denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe') denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe')
+13 -1
View File
@@ -119,12 +119,24 @@ class VisitorTests(unittest.TestCase):
self.assertEqual(staff.data,b'A_PRIVATE') self.assertEqual(staff.data,b'A_PRIVATE')
def test_guest_keys_and_client_claims_cannot_unlock_orders(self): def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001'): for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001','我的订单呢','帮我看下订单','订单查询','查询','my orders','order status',
'按入仓码查轨迹','入仓码查询','入仓号查轨迹','Track by inbound code'):
with patch('logistics_chat.order_data.lookup_order') as lookup, patch('logistics_chat.ai_client.post_completion') as model: with patch('logistics_chat.order_data.lookup_order') as lookup, patch('logistics_chat.ai_client.post_completion') as model:
response=self.a.post('/stream_chat',json={'msg':message},headers={'X-Customer-Id':'tenant-a','X-Tenant-Id':'tenant-a'}) response=self.a.post('/stream_chat',json={'msg':message},headers={'X-Customer-Id':'tenant-a','X-Tenant-Id':'tenant-a'})
self.assertIn('客户账号登录并关联',response.get_data(as_text=True)) self.assertIn('客户账号登录并关联',response.get_data(as_text=True))
lookup.assert_not_called();model.assert_not_called() lookup.assert_not_called();model.assert_not_called()
def test_missing_attachments_show_a_readable_private_page_without_disclosing_ownership(self):
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'fixture'),'missing.txt')})
url=response.json['url']
(Path(self.app.config['UPLOAD_DIR'])/url.rsplit('/',1)[1]).unlink()
own=self.a.get(url);other=self.b.get(url)
self.assertEqual(own.status_code,404)
self.assertIn('附件暂时无法访问',own.get_data(as_text=True))
self.assertIn('no-store',own.headers['Cache-Control'])
self.assertEqual(own.data,other.data)
self.assertNotIn(url,own.get_data(as_text=True))
def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self): def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self):
cid=self.conversation(self.a,'A_PRIVATE') cid=self.conversation(self.a,'A_PRIVATE')
for key in ('short','x'*64,'1'*65,[],{},123): for key in ('short','x'*64,'1'*65,[],{},123):