Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd3fb15cc | ||
|
|
1902949f69 | ||
|
|
a415bcdb88 | ||
|
|
8853ea2dfa | ||
|
|
439241dd83 | ||
|
|
147edd042f |
@@ -8,6 +8,7 @@ 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` |
|
||||
| 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 |
|
||||
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
|
||||
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
|
||||
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
||||
@@ -53,7 +54,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`.
|
||||
|
||||
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, and `voice.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`, 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.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
@@ -61,4 +62,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
|
||||
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.
|
||||
|
||||
@@ -121,7 +121,7 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过
|
||||
|
||||
### Jenkins 灰度部署
|
||||
|
||||
现有灰度环境按 [Jenkins 部署指南](docs/deployment.md) 发布:构建和测试镜像,SSH 发送至专用 Compose 服务,再配置独立域名的 HTTPS 和全站访问密码。密钥由 Jenkins 注入,数据和证书保存在独立宿主机目录;启动失败时恢复之前的应用和站点。面向客户开放前仍需补齐鉴权和数据隔离。另附 [Linux 单机参考](docs/deployment-linux.md)。
|
||||
现有灰度环境按 [Jenkins 部署指南](docs/deployment.md) 发布:构建和测试镜像,SSH 发送至专用 Compose 服务,Nginx 提供 HTTPS。客户聊天入口免登录,只有明确列出的客户 API 开放;客服后台、搜索、导出和其他未知路径继续使用访问密码。密钥由 Jenkins 注入,数据和证书保存在独立宿主机目录;启动失败时恢复之前的应用和站点。上游端口必须仅绑定回环地址。另附 [Linux 单机参考](docs/deployment-linux.md)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -246,7 +246,9 @@ data: {"d": 1}
|
||||
|
||||
## 数据库与运行数据
|
||||
|
||||
订单测试数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改。聊天每次重新查表;这份副本不会自动同步灰度业务库。没有新增管理页面和增删改接口。内部验收可查询已导入的测试样本;配置 `ORDER_DATA_TENANT_ID` 可以在服务器侧固定租户范围,不能将其视为客户登录授权。
|
||||
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。匿名聊天不读取订单表;客户登录及关联将在后续接入,届时查询必须使用服务端验证的客户范围。`ORDER_DATA_TENANT_ID` 只是额外限制,不能代替客户身份。
|
||||
|
||||
游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。
|
||||
|
||||
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
|
||||
|
||||
@@ -263,7 +265,7 @@ data: {"d": 1}
|
||||
## 当前限制
|
||||
|
||||
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史中的模拟轨迹保留演示标识。
|
||||
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。公网自用验收可按部署指南使用全站访问密码进入工作台。
|
||||
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。
|
||||
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
|
||||
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
|
||||
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
|
||||
|
||||
+16
-3
@@ -111,17 +111,30 @@ install -m 644 "$release/site.htpasswd" "$auth"
|
||||
install -m 644 "$release/site.conf" "$site"
|
||||
nginx -t
|
||||
nginx -s reload
|
||||
for path in / /api/export_chat /api/get_history; do
|
||||
for check in /:200 /admin/dashboard:401 /api/export_chat:401 /api/get_history:401 /api/human/queue:401; do
|
||||
path=${check%:*}
|
||||
expected=${check##*:}
|
||||
code=''
|
||||
# A successful reload signal precedes the new workers accepting connections.
|
||||
for attempt in {1..10}; do
|
||||
if code=$(curl --silent --connect-timeout 3 --max-time 8 --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path"); then
|
||||
[[ "$code" != 401 ]] || break
|
||||
[[ "$code" != "$expected" ]] || break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "$code" == 401 ]] || { echo 'Gray access protection verification failed.' >&2; false; }
|
||||
[[ "$code" == "$expected" ]] || { echo 'Guest/staff access verification failed.' >&2; false; }
|
||||
done
|
||||
visitor_cookie=$(mktemp "$release/.visitor-smoke.XXXXXX")
|
||||
trap 'rm -f -- "$visitor_cookie"' EXIT
|
||||
code=$(curl --silent --show-error --max-time 10 --resolve "$CHAT_DOMAIN:443:127.0.0.1" \
|
||||
-c "$visitor_cookie" -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \
|
||||
-d '{}' "https://$CHAT_DOMAIN/api/visitor/session")
|
||||
[[ "$code" == 200 ]] || { echo 'Guest session bootstrap verification failed.' >&2; false; }
|
||||
code=$(curl --silent --show-error --max-time 10 --resolve "$CHAT_DOMAIN:443:127.0.0.1" \
|
||||
-b "$visitor_cookie" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN/api/get_history")
|
||||
[[ "$code" == 200 ]] || { echo 'Guest history verification failed.' >&2; false; }
|
||||
rm -f -- "$visitor_cookie"
|
||||
trap - EXIT
|
||||
printf '%s\n' "$release" > "$root/.current-release.new"
|
||||
mv -f "$root/.current-release.new" "$root/current-release"
|
||||
trap - ERR
|
||||
|
||||
+29
-13
@@ -76,7 +76,7 @@ class GrayConfig:
|
||||
return ''.join(f'{key}={value}\n' for key, value in fields.items())
|
||||
|
||||
def nginx_config(self):
|
||||
return f'''# Managed for baiyue-logistics-chat-gray; all application routes require authentication.
|
||||
return f'''# Managed for baiyue-logistics-chat-gray; guest chat is public and staff routes require authentication.
|
||||
server {{
|
||||
listen 80;
|
||||
server_name {self.domain};
|
||||
@@ -95,20 +95,36 @@ server {{
|
||||
auth_basic "Baiyue gray acceptance";
|
||||
auth_basic_user_file {self.auth_dir}/access.htpasswd;
|
||||
client_max_body_size 12m;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Authorization "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
gzip off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
location = / {{
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:{self.port};
|
||||
}}
|
||||
location ^~ /static/ {{
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:{self.port};
|
||||
}}
|
||||
location ^~ /uploads/ {{
|
||||
auth_basic off;
|
||||
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)))$ {{
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:{self.port};
|
||||
}}
|
||||
location / {{
|
||||
proxy_pass http://127.0.0.1:{self.port};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Authorization "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
gzip off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}}
|
||||
}}
|
||||
'''
|
||||
|
||||
+20
-16
@@ -24,28 +24,32 @@ server {
|
||||
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
# Guard all pages, API routes, attachments and history/export operations.
|
||||
# Staff and unknown routes remain protected; guest APIs check their own visitor owner.
|
||||
auth_basic "Baiyue private acceptance";
|
||||
auth_basic_user_file /etc/nginx/.baiyue-preview.htpasswd;
|
||||
|
||||
# Allow multipart overhead around the application's 10 MiB file limit.
|
||||
client_max_body_size 12m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Authorization "";
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Authorization "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
gzip off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
# SSE must reach the browser as chunks arrive.
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
gzip off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
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 ^~ /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)))$ {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
}
|
||||
location / { proxy_pass http://127.0.0.1:5000; }
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ tests/ # 按业务模块组织的回归
|
||||
- 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` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
|
||||
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
|
||||
- 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。客户身份尚未接入,匿名 HTTP 查单在读取订单前返回账号关联提示;`g.customer_tenant_id` 只允许后续可信认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。
|
||||
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
|
||||
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
|
||||
@@ -117,7 +117,8 @@ tests/ # 按业务模块组织的回归
|
||||
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
|
||||
- 手机语音由 `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 表,不预置账号或演示数据。
|
||||
- 后台鉴权和会话隔离仍不完整,订单表为内部测试副本,不提供实时物流轨迹。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
|
||||
- 游客由 `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 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录和订单关联尚未实现,游客身份不能升级为订单权限。
|
||||
|
||||
## 验证与维护
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
现行灰度环境使用 [Jenkins 部署流程](deployment.md)。本页保留独立主机的安装方法,不用于覆盖已有灰度服务。
|
||||
|
||||
适用场景:Ubuntu / Debian、已有域名、仅自己验收。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx 全站访问密码 → 本机 Waitress → Flask / SQLite。
|
||||
适用场景:Ubuntu / Debian、已有域名。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx(客户白名单公开,客服及其他路径需密码)→ 本机 Waitress → Flask / SQLite。
|
||||
|
||||
当前应用尚无完整登录会话、权限和会话数据隔离。验收期间必须保留全站访问密码,不能只保护 `/admin`,因为历史、导出和人工接口也能直接调用。此配置不代表已具备面向客户开放的权限体系。
|
||||
游客历史、人工会话和附件已按访客归属隔离;客户账号登录和订单关联尚未接入,匿名不能查询真实订单。必须沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
|
||||
|
||||
## 1. 域名与网络
|
||||
|
||||
@@ -117,7 +117,7 @@ curl -u preview -I https://chat.example.com/
|
||||
|
||||
- 客户页:`https://chat.example.com/`,发送消息检查 DS 实际回复及流式显示。
|
||||
- 验收工作台:`https://chat.example.com/admin/dashboard`,从客户页申请人工,再检查接待和双向消息。
|
||||
- 当前未预置应用内客服登录账号;Nginx 的 `preview` 是整站验收密码,不是 `/admin` 页里的客服账号。
|
||||
- 当前未预置应用内客服登录账号;Nginx 的 `preview` 是客服及受保护接口的访问账号,不是 `/admin` 页里的客服账号,客户首页无需此账号。
|
||||
|
||||
安装证书续期后的 Nginx 重载钩子,并检查续期定时器:
|
||||
|
||||
@@ -151,4 +151,4 @@ sudo nginx -t
|
||||
|
||||
## 参考
|
||||
|
||||
工厂调用与监听参数依据 [Waitress 官方文档](https://docs.pylonsproject.org/projects/waitress/en/stable/runner.html)。流式转发配置依据 [Nginx 代理模块](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering),全站密码依据 [Nginx Basic Auth 模块](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html)。证书和续期使用 [Certbot webroot](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot);服务目录与只读保护依据 [systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)。
|
||||
工厂调用与监听参数依据 [Waitress 官方文档](https://docs.pylonsproject.org/projects/waitress/en/stable/runner.html)。流式转发配置依据 [Nginx 代理模块](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering),客服后台密码依据 [Nginx Basic Auth 模块](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html)。证书和续期使用 [Certbot webroot](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot);服务目录与只读保护依据 [systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)。
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
# Jenkins 灰度部署
|
||||
|
||||
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS 和全站访问密码。
|
||||
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。
|
||||
|
||||
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。后台鉴权与会话隔离仍不完整,灰度验收须保留全站密码;此流程不代表已具备面向多个客户开放的权限体系。
|
||||
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录与订单关联尚未接入,匿名查单不会读取订单数据。
|
||||
|
||||
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
|
||||
|
||||
新环境自动创建业务表,不创建默认客服账号或演示数据。验收用全站密码直接进入工作台;Nginx 验收账号不是应用内客服账号。
|
||||
新环境自动创建业务表,不创建默认客服账号或演示数据。专用访问密码用于客服工作台及受保护接口;Nginx 账号不是应用内客服账号。客户首页无需密码,客户端先自动建立访客会话,再访问自己的历史。
|
||||
|
||||
## HTTPS 及续期
|
||||
|
||||
@@ -103,7 +103,7 @@ nginx -t
|
||||
- SSH 失败:核对受管凭据和 known_hosts,不禁用指纹检查。
|
||||
- 证书失败:核对 DNS 和 HTTP 验证路径,保留本服务的验证站点与私有 ACME 状态。
|
||||
- 容器不健康:检查独立目录的 UID 10001 权限、日志和 `/healthz`;健康检查不计入业务限流。
|
||||
- 入口 401:未登录时为预期,使用专用验收密码后应返回 200。
|
||||
- 客户首页应匿名返回 200,不出现密码弹窗;客服后台、全站搜索和导出匿名返回 401。客户历史 API 在未建立访客会话时由应用返回 JSON 401,自动建立 `/api/visitor/session` 后应返回当前访客的记录。发布激活脚本同时检查这些边界,不能只删除原有密码配置。
|
||||
- 流式显示延迟:检查实际 Nginx 或 CDN 是否重新启用了响应缓冲。
|
||||
|
||||
独立主机安装另见 [Linux 手动参考](deployment-linux.md)。凭据与 API 依据 [Jenkins 官方文档](https://www.jenkins.io/doc/pipeline/steps/credentials-binding/) 和 [Remote API](https://www.jenkins.io/doc/book/using/remote-access-api/),证书验证与续期依据 [Certbot 文档](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot)。
|
||||
|
||||
@@ -9,7 +9,7 @@ def create_app(config=None):
|
||||
from flask import Flask
|
||||
|
||||
from .config import ROOT_DIR, default_config
|
||||
from . import db, file_service, hooks
|
||||
from . import db, file_service, hooks, visitor_service
|
||||
from .routes import register_routes
|
||||
from .routes.speech import MemoryAudioRequest
|
||||
|
||||
@@ -22,6 +22,7 @@ def create_app(config=None):
|
||||
app.extensions['human_typing'] = {}
|
||||
app.extensions['rate_hits'] = defaultdict(list)
|
||||
app.before_request(hooks.rate_limit)
|
||||
app.before_request(visitor_service.attach_visitor)
|
||||
app.after_request(hooks.no_cache_html)
|
||||
app.teardown_appcontext(db.close_connections)
|
||||
register_routes(app)
|
||||
|
||||
@@ -83,12 +83,8 @@ def create_chat_stream(msg, conv_id):
|
||||
if not msg:
|
||||
return (sse({'e': '问题不能为空'}), {})
|
||||
if conv_id:
|
||||
connection = connect()
|
||||
try:
|
||||
exists = connection.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
if not exists:
|
||||
from .visitor_service import owns_conversation
|
||||
if not owns_conversation(conv_id):
|
||||
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
|
||||
order_answer = answer_query(msg, conv_id)
|
||||
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
|
||||
|
||||
@@ -37,6 +37,8 @@ def init_db():
|
||||
Path(get_setting('DATABASE')).parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = connect()
|
||||
cur = conn.cursor()
|
||||
# Serialize schema inspection and migration when multiple workers start together.
|
||||
cur.execute('BEGIN IMMEDIATE')
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS chat_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -48,6 +50,12 @@ def init_db():
|
||||
if 'utime' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN utime TEXT')
|
||||
cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL')
|
||||
if 'visitor_id' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT')
|
||||
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 (
|
||||
filename TEXT PRIMARY KEY, visitor_id TEXT NOT NULL, ctime TEXT NOT NULL
|
||||
)''')
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Attachment validation, storage and retention without request parsing."""
|
||||
|
||||
import os
|
||||
import random
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
@@ -40,7 +40,18 @@ def upload_file(files):
|
||||
if size > get_setting('MAX_UPLOAD_MB') * 1024 * 1024:
|
||||
return ({'ok': False, 'error': f"文件超过 {get_setting('MAX_UPLOAD_MB')}MB"}, 400)
|
||||
os.makedirs(get_setting('UPLOAD_DIR'), exist_ok=True)
|
||||
safe_name = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{random.randint(1000, 9999)}{ext}"
|
||||
safe_name = secrets.token_hex(16) + ext
|
||||
f.save(os.path.join(get_setting('UPLOAD_DIR'), safe_name))
|
||||
from .db import connect, now_str
|
||||
from .visitor_service import visitor_id
|
||||
owner = visitor_id()
|
||||
if owner:
|
||||
connection = connect()
|
||||
try:
|
||||
with connection:
|
||||
connection.execute('INSERT INTO visitor_uploads(filename,visitor_id,ctime) VALUES(?,?,?)',
|
||||
(safe_name, owner, now_str()))
|
||||
finally:
|
||||
connection.close()
|
||||
is_image = ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp')
|
||||
return {'ok': True, 'name': f.filename, 'url': f'/uploads/{safe_name}', 'is_image': is_image}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"""Conversation CRUD with unchanged response data."""
|
||||
|
||||
from .db import connect, now_str
|
||||
from .visitor_service import visitor_id, owns_conversation
|
||||
|
||||
|
||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。'
|
||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。'
|
||||
|
||||
|
||||
def new_chat():
|
||||
now = now_str()
|
||||
conn = connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute('INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)', ('新对话', now, now))
|
||||
cur.execute('INSERT INTO chat_history(title, ctime, utime, visitor_id) VALUES (?,?,?,?)',
|
||||
('新对话', now, now, visitor_id()))
|
||||
conv_id = cur.lastrowid
|
||||
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)',
|
||||
(conv_id, 'assistant', WELCOME_TEXT, now))
|
||||
@@ -21,12 +23,15 @@ def new_chat():
|
||||
|
||||
def get_history():
|
||||
conn = connect()
|
||||
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history ORDER BY COALESCE(utime, ctime) DESC, id DESC').fetchall()
|
||||
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()
|
||||
conn.close()
|
||||
return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows]
|
||||
|
||||
|
||||
def get_messages(conv_id):
|
||||
if not owns_conversation(conv_id):
|
||||
return []
|
||||
conn = connect()
|
||||
rows = conn.execute('SELECT role, content, ctime FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 100', (conv_id,)).fetchall()
|
||||
conn.close()
|
||||
@@ -35,6 +40,8 @@ def get_messages(conv_id):
|
||||
|
||||
|
||||
def del_history(hid):
|
||||
if not owns_conversation(hid):
|
||||
return {'ok': False}, 404
|
||||
conn = connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions WHERE conv_id=?)', (hid,))
|
||||
@@ -49,10 +56,13 @@ def del_history(hid):
|
||||
def clear_all():
|
||||
conn = connect()
|
||||
cur = conn.cursor()
|
||||
cur.execute('DELETE FROM human_messages')
|
||||
cur.execute('DELETE FROM human_sessions')
|
||||
cur.execute('DELETE FROM messages')
|
||||
cur.execute('DELETE FROM chat_history')
|
||||
owner = visitor_id()
|
||||
owned = 'SELECT id FROM chat_history WHERE visitor_id=?'
|
||||
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions '
|
||||
'WHERE conv_id IN ('+owned+'))', (owner,))
|
||||
cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', (owner,))
|
||||
cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', (owner,))
|
||||
cur.execute('DELETE FROM chat_history WHERE visitor_id=?', (owner,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
|
||||
@@ -7,7 +7,7 @@ from flask import current_app, jsonify, request
|
||||
|
||||
def rate_limit():
|
||||
path = request.path
|
||||
if path == '/healthz' or path.startswith('/static') or path.startswith('/api/human/'):
|
||||
if path in ('/healthz', '/api/visitor/human/poll') or path.startswith('/static') or path.startswith('/api/human/'):
|
||||
return None
|
||||
ip = request.remote_addr or 'unknown'
|
||||
now = time.time()
|
||||
@@ -21,7 +21,7 @@ def rate_limit():
|
||||
|
||||
|
||||
def no_cache_html(response):
|
||||
if response.mimetype in ('text/html', 'text/event-stream'):
|
||||
if response.mimetype in ('text/html', 'text/event-stream') or request.path.startswith('/api/'):
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
|
||||
from .config import get_setting
|
||||
from .order_tracking import build_tracking
|
||||
from .visitor_service import customer_tenant_id
|
||||
|
||||
|
||||
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
|
||||
@@ -22,6 +23,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目
|
||||
'parent':'父订单号', 'updated':'来源更新时间'}
|
||||
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_LOGIN_TEXT = '订单查询将在客户账号登录并关联后开放。您可以先咨询运费、时效,或联系人工客服。'
|
||||
|
||||
|
||||
class OrderDataUnavailable(Exception):
|
||||
@@ -110,7 +112,7 @@ def import_tracking_records(connection, records, replace=False):
|
||||
return {'imported': imported, 'preserved': preserved, 'missing': missing}
|
||||
|
||||
|
||||
def lookup_order(reference):
|
||||
def lookup_order(reference, tenant_id=None):
|
||||
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
|
||||
from .db import connect
|
||||
|
||||
@@ -120,6 +122,12 @@ def lookup_order(reference):
|
||||
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)'
|
||||
values = [reference] * 3
|
||||
tenant = get_setting('ORDER_DATA_TENANT_ID')
|
||||
if tenant_id is not None:
|
||||
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))
|
||||
@@ -210,6 +218,9 @@ def is_order_followup(message):
|
||||
|
||||
def answer_query(message, conv_id):
|
||||
references = extract_reference(message)
|
||||
tenant = customer_tenant_id()
|
||||
if references and tenant is None:
|
||||
return {'t': ORDER_LOGIN_TEXT}
|
||||
if len(references) > 1:
|
||||
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'}
|
||||
reference = references[0] if references else None
|
||||
@@ -238,10 +249,12 @@ def answer_query(message, conv_id):
|
||||
pass
|
||||
if not reference:
|
||||
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|物流轨迹|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
|
||||
return {'t':'请提供订单号、入仓码或 Shipment ID。'}
|
||||
return {'t':ORDER_LOGIN_TEXT if tenant is None else '请提供订单号、入仓码或 Shipment ID。'}
|
||||
return None
|
||||
if tenant is None:
|
||||
return {'t': ORDER_LOGIN_TEXT}
|
||||
try:
|
||||
matches = lookup_order(reference)
|
||||
matches = lookup_order(reference, tenant_id=tenant)
|
||||
except OrderDataUnavailable:
|
||||
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
if not matches:
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
from . import admin, business, chat, files, history, human, pages, speech
|
||||
for module in (admin, business, chat, files, history, human, pages, speech):
|
||||
from . import admin, business, chat, files, history, human, pages, speech, visitor
|
||||
for module in (admin, business, chat, files, history, human, pages, speech, visitor):
|
||||
app.register_blueprint(module.bp)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from .. import business_service
|
||||
from .. import business_service, visitor_service
|
||||
|
||||
bp = Blueprint('business', __name__)
|
||||
|
||||
@@ -23,4 +23,9 @@ def list_tickets():
|
||||
|
||||
@bp.route('/api/rate', methods=['POST'])
|
||||
def rate_chat():
|
||||
return business_service.rate_chat(request.json or {})
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict):
|
||||
return {'ok': False}, 400
|
||||
if not visitor_service.owns_human_session(data.get('sid')):
|
||||
return {'ok': False}, 404
|
||||
return business_service.rate_chat(data)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""HTTP adapters for files; business logic lives in services."""
|
||||
|
||||
from flask import Blueprint, request, send_from_directory
|
||||
from flask import Blueprint, abort, request, send_from_directory
|
||||
|
||||
from .. import file_service
|
||||
from .. import file_service, visitor_service
|
||||
from ..config import get_setting
|
||||
|
||||
bp = Blueprint('files', __name__)
|
||||
@@ -14,4 +14,15 @@ def upload_file():
|
||||
|
||||
@bp.route('/uploads/<path:name>')
|
||||
def uploaded_file(name):
|
||||
return send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
if not visitor_service.owns_upload(name):
|
||||
abort(404)
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
|
||||
@bp.route('/api/staff/uploads/<path:name>')
|
||||
def staff_uploaded_file(name):
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Guest bootstrap and owner-checked human support adapters, separate from staff APIs."""
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
|
||||
from .. import human_service, visitor_service
|
||||
|
||||
|
||||
bp = Blueprint('visitor', __name__)
|
||||
|
||||
|
||||
@bp.post('/api/visitor/session')
|
||||
def visitor_session():
|
||||
request.max_content_length = 1024
|
||||
try:
|
||||
return visitor_service.start_session(request.get_json(silent=True))
|
||||
except RequestEntityTooLarge:
|
||||
return {'ok': False, 'error': '访客会话格式无效。'}, 413
|
||||
|
||||
|
||||
@bp.route('/api/visitor/human/<action>', methods=['GET', 'POST'])
|
||||
def visitor_human(action):
|
||||
methods = {'poll': 'GET', 'request': 'POST', 'send': 'POST', 'close': 'POST'}
|
||||
if action not in methods or request.method != methods[action]:
|
||||
return {'ok': False}, 404
|
||||
data = request.get_json(silent=True) if request.method == 'POST' else {}
|
||||
if not isinstance(data, dict):
|
||||
return {'ok': False}, 400
|
||||
if action == 'request':
|
||||
if not visitor_service.owns_conversation(data.get('conv_id')):
|
||||
return {'ok': False, 'error': 'conversation_not_found'}, 404
|
||||
return human_service.human_request(data)
|
||||
sid = request.args.get('sid', type=int) if action == 'poll' else data.get('sid')
|
||||
if not visitor_service.owns_human_session(sid):
|
||||
return {'ok': False, 'error': 'session_not_found'}, 404
|
||||
if action == 'poll':
|
||||
after = request.args.get('after', 0, type=int)
|
||||
if not 0 <= after <= 9223372036854775807:
|
||||
return {'ok': False}, 400
|
||||
result = human_service.human_poll(sid, after, current_app.extensions['human_typing'])
|
||||
result.pop('note', None)
|
||||
return result
|
||||
if action == 'send':
|
||||
if data.get('side', 'customer') != 'customer':
|
||||
return {'ok': False}, 403
|
||||
return human_service.human_send({**data, 'side': 'customer'})
|
||||
return human_service.human_close(data)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Guest possession tokens isolate chat data; they never identify an order customer."""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
|
||||
from flask import g, has_app_context, jsonify, request
|
||||
|
||||
from .db import connect
|
||||
|
||||
|
||||
COOKIE_NAME = 'baiyue_visitor'
|
||||
KEY_PATTERN = re.compile(r'[0-9a-f]{64}\Z')
|
||||
CUSTOMER_PATHS = {
|
||||
'/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/speech/config', '/api/speech/transcribe',
|
||||
}
|
||||
|
||||
|
||||
def valid_key(value):
|
||||
return isinstance(value, str) and KEY_PATTERN.fullmatch(value) is not None
|
||||
|
||||
|
||||
def visitor_id():
|
||||
return getattr(g, 'visitor_id', None) if has_app_context() else None
|
||||
|
||||
|
||||
def customer_tenant_id():
|
||||
# Future login adapters may set this only after validating a customer session.
|
||||
# Visitor cookies, request parameters and headers are deliberately not accepted.
|
||||
value = getattr(g, 'customer_tenant_id', None) if has_app_context() else None
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
||||
return None
|
||||
tenant = str(value).strip()
|
||||
return tenant if tenant and len(tenant) <= 128 else None
|
||||
|
||||
|
||||
def attach_visitor():
|
||||
key = request.cookies.get(COOKIE_NAME)
|
||||
g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None
|
||||
is_customer = request.path in CUSTOMER_PATHS or request.path.startswith('/api/visitor/human/')
|
||||
if is_customer and not visitor_id():
|
||||
return jsonify({'ok': False, 'e': '访客会话已失效,请刷新页面重试。'}), 401
|
||||
if (is_customer or request.path == '/api/visitor/session') and request.method not in ('GET', 'HEAD', 'OPTIONS'):
|
||||
origin = request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):
|
||||
return jsonify({'ok': False, 'e': '请求来源无效。'}), 403
|
||||
|
||||
|
||||
def start_session(data):
|
||||
if not isinstance(data, dict):
|
||||
return {'ok': False, 'error': '访客会话格式无效。'}, 400
|
||||
key = data.get('key')
|
||||
if key is None:
|
||||
existing = request.cookies.get(COOKIE_NAME)
|
||||
key = existing if valid_key(existing) else secrets.token_hex(32)
|
||||
if not valid_key(key):
|
||||
return {'ok': False, 'error': '访客标识无效。'}, 400
|
||||
response = jsonify({'ok': True, 'key': key})
|
||||
response.set_cookie(COOKIE_NAME, key, max_age=365*24*3600, httponly=True,
|
||||
secure=request.is_secure, samesite='Lax', path='/')
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
|
||||
def owns_conversation(conv_id):
|
||||
owner = visitor_id()
|
||||
if not owner or not isinstance(conv_id, int) or isinstance(conv_id, bool) or not 0 < conv_id <= 9223372036854775807:
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=?',
|
||||
(conv_id, owner)).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def owns_human_session(sid):
|
||||
owner = visitor_id()
|
||||
if not owner or not isinstance(sid, int) or isinstance(sid, bool) or not 0 < sid <= 9223372036854775807:
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
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())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def owns_upload(name):
|
||||
owner = visitor_id()
|
||||
if not owner:
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM visitor_uploads WHERE filename=? AND visitor_id=?',
|
||||
(name, owner)).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
+16
-9
@@ -1,5 +1,5 @@
|
||||
/* Customer workspace: navigation, welcome, conversation and composer. */
|
||||
body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.chat-container {
|
||||
display: flex; width: min(1440px, 100%); height: 100%; margin: auto;
|
||||
overflow: hidden; position: relative; border: 1px solid var(--border);
|
||||
@@ -44,7 +44,7 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.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; }
|
||||
.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; 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; }
|
||||
.header-copy { min-width: 0; flex: 1; }
|
||||
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
|
||||
@@ -205,17 +205,24 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.suggest-copy small { font-size: 11px; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 0; }
|
||||
.chat-container { border: 0; border-radius: 0; height: 100dvh; }
|
||||
.left-panel { position: absolute; left: 0; top: 72px; bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
||||
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
|
||||
.chat-container { --chat-header-height: 72px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
|
||||
.left-panel { position: absolute; left: 0; top: calc(var(--chat-header-height) + env(safe-area-inset-top)); bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
||||
.left-panel .brand { display: none; }
|
||||
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
|
||||
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: 72px 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
||||
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: calc(var(--chat-header-height) + env(safe-area-inset-top)) 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
||||
.main-chat { width: 100%; }
|
||||
.chat-title { min-height: 72px; height: 72px; padding: 13px 18px; gap: 11px; }
|
||||
#chatDom { padding: 24px 20px 15px; }
|
||||
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); padding: 13px 18px; gap: 11px; }
|
||||
#chatDom { padding: 24px 20px 15px; overscroll-behavior-y: contain; }
|
||||
.ai-welcome-card { padding-top: 20px; }
|
||||
.composer { width: calc(100% - 40px); }
|
||||
.composer { width: calc(100% - 40px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.chat-container.is-composing { --chat-header-height: 56px; }
|
||||
.is-composing .chat-title { padding-top: 8px; padding-bottom: 8px; }
|
||||
.is-composing .composer { padding-top: 6px; padding-bottom: 6px; }
|
||||
.is-composing .composer-footer { display: none; }
|
||||
#input-text, .history-search { font-size: max(16px, 1em); }
|
||||
.history-search { height: 44px; }
|
||||
.history-search-wrap svg { top: 14px; }
|
||||
.user-message, .robot-message { max-width: 94%; }
|
||||
.del-history-btn { opacity: 1; }
|
||||
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; }
|
||||
|
||||
+2
-2
@@ -191,8 +191,8 @@ function appendMsg(message){
|
||||
}
|
||||
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
|
||||
let content = escapeText(message.content);
|
||||
content = content.replace(/\[图片\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener"><img src="$1" alt="客户附件" class="message-image"></a>');
|
||||
content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
|
||||
content = content.replace(/\[图片\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener"><img src="/api/staff/uploads/$1" alt="客户附件" class="message-image"></a>');
|
||||
content = content.replace(/\[文件\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
|
||||
const avatar = message.side === "customer" ? "客" : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M4 14v-3a8 8 0 0 1 16 0v3M19 17a4 4 0 0 1-4 4h-3"/><rect x="2" y="11" width="4" height="7" rx="2"/><rect x="18" y="11" width="4" height="7" rx="2"/></svg>';
|
||||
div.innerHTML = '<span class="message-avatar" aria-hidden="true">'+avatar+'</span><div class="message-stack"><div class="meta">'+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'</div><div class="bubble">'+content+'</div></div>';
|
||||
if(message.side === "agent"){
|
||||
|
||||
+63
-40
@@ -1,10 +1,16 @@
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const fetch = VisitorSession.fetch;
|
||||
const chatDom = document.getElementById("chatDom");
|
||||
const inputDom = document.getElementById("input-text");
|
||||
const sendBtn = document.getElementById("send-button");
|
||||
const uploadBtn = document.getElementById("upload-btn");
|
||||
const uploadInput = document.getElementById("upload-input");
|
||||
const composerStatus = document.getElementById("composer-status");
|
||||
function focusComposer(force){
|
||||
if(force || !isNarrow() || document.activeElement === inputDom){
|
||||
inputDom.focus({preventScroll:true});
|
||||
}
|
||||
}
|
||||
let uploadPending = false;
|
||||
let uploadVersion = 0;
|
||||
let uploadController = null;
|
||||
@@ -120,12 +126,12 @@ uploadInput.onchange = function(){
|
||||
remove.className = "attachment-remove";
|
||||
remove.textContent = "×";
|
||||
remove.setAttribute("aria-label", t("removeAttachment"));
|
||||
remove.onclick = function(){ clearAttachment(); inputDom.focus(); };
|
||||
remove.onclick = function(){ clearAttachment(); focusComposer(true); };
|
||||
prev.append(copy, remove);
|
||||
var area = document.querySelector(".input-area");
|
||||
area.before(prev);
|
||||
setComposerStatus("");
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}
|
||||
})
|
||||
.catch(function(error){
|
||||
@@ -210,7 +216,7 @@ const I18N = {
|
||||
thinking: "正在思考中...",
|
||||
busyRetry: "服务繁忙,请稍后重试",
|
||||
sessionFail: "会话创建失败,请确认服务已启动",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。",
|
||||
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||||
offlinePrompt: "请输入您的留言:",
|
||||
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
|
||||
@@ -243,7 +249,7 @@ const I18N = {
|
||||
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
|
||||
clickRetry: "点击重试",
|
||||
welcomeTitle: "物流问题,随时为您解答",
|
||||
welcomeSub: "查询订单状态、了解专线报价,或联系人工客服。",
|
||||
welcomeSub: "咨询运费、时效或联系人工客服。订单查询需关联客户账号。",
|
||||
welcomeEyebrow: "中欧 · 英国物流服务",
|
||||
serviceScope: "中欧与英国专线咨询",
|
||||
serviceTag: "物流咨询服务",
|
||||
@@ -291,7 +297,7 @@ const I18N = {
|
||||
order_parent: "父订单号", order_updated: "来源更新时间",
|
||||
moreTracking: "展开全部轨迹",
|
||||
lessTracking: "收起较早轨迹",
|
||||
quickSubs: ["输入订单号、入仓码或 Shipment ID", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
|
||||
quickSubs: ["关联客户账号后查询订单与轨迹", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
|
||||
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
|
||||
hot: "热点问题",
|
||||
recent: "最近对话记录",
|
||||
@@ -320,7 +326,7 @@ const I18N = {
|
||||
thinking: "Thinking...",
|
||||
busyRetry: "Service busy, please try again later",
|
||||
sessionFail: "Failed to create session, please check the service is running",
|
||||
autoWelcome: "Welcome to Baiyue Logistics. Enter an order number, inbound code or Shipment ID, or choose a quick option below.",
|
||||
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.",
|
||||
humanHours: "Our team is available daily, 08:00–22:00. Please leave a message and we will reply during service hours.",
|
||||
offlinePrompt: "Please leave your message:",
|
||||
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
|
||||
@@ -353,7 +359,7 @@ const I18N = {
|
||||
retryAIOnly: "Return to AI chat before regenerating this answer.",
|
||||
clickRetry: "Retry",
|
||||
welcomeTitle: "Your next shipment, made clearer.",
|
||||
welcomeSub: "Track a parcel, explore freight options, or speak to our team.",
|
||||
welcomeSub: "Ask about rates, transit times or human support. Order lookup requires a linked customer account.",
|
||||
welcomeEyebrow: "EUROPE & UK LOGISTICS",
|
||||
serviceScope: "Europe & UK freight support",
|
||||
serviceTag: "Logistics support",
|
||||
@@ -403,7 +409,7 @@ const I18N = {
|
||||
order_parent: "Parent order", order_updated: "Source updated",
|
||||
moreTracking: "View full tracking history",
|
||||
lessTracking: "Show recent updates",
|
||||
quickSubs: ["Check the latest shipment progress", "Explore routes, rates and transit times", "Get help with delays, delivery or claims", "Discuss your shipping requirements"],
|
||||
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",
|
||||
recent: "Recent Conversations",
|
||||
@@ -1061,24 +1067,6 @@ window.sendOrder = function(){
|
||||
}
|
||||
}
|
||||
|
||||
function myOrders(){
|
||||
var phone = window.prompt("请输入您的手机号:");
|
||||
if(!phone) return;
|
||||
addMsg("查询手机号 "+phone+" 的包裹...", false);
|
||||
fetch("/api/my_orders?phone="+encodeURIComponent(phone))
|
||||
.then(r=>r.json())
|
||||
.then(d=>{
|
||||
if(!d.orders.length){
|
||||
addMsg("手机号 "+phone+" 下暂无运单记录。", false);
|
||||
}else{
|
||||
var txt = "您名下有 "+d.orders.length+" 个包裹:\n";
|
||||
d.orders.forEach(function(o){ txt += "• "+o.track_no+"("+o.status+")\n"; });
|
||||
addMsg(txt, false);
|
||||
}
|
||||
})
|
||||
.catch(function(){ addMsg("查询失败,请重试", false); });
|
||||
}
|
||||
|
||||
function updateHumanMode(){
|
||||
const banner = document.getElementById('human-session');
|
||||
const label = document.getElementById('human-session-label');
|
||||
@@ -1129,7 +1117,7 @@ window.showHuman = async function(){
|
||||
try{
|
||||
const cid = await ensureConv();
|
||||
if(humanRequestToken !== token || token.version !== conversationVersion) return;
|
||||
const data = await humanRequest('/api/human/request', {conv_id:cid});
|
||||
const data = await humanRequest('/api/visitor/human/request', {conv_id:cid});
|
||||
if(humanRequestToken !== token || token.version !== conversationVersion) return;
|
||||
if(data.closed){ await leaveOfflineMessage(); return; }
|
||||
if(!data.ok || !data.session_id) throw new Error('Human session failed');
|
||||
@@ -1151,13 +1139,13 @@ async function returnToAI(){
|
||||
humanClosing = true;
|
||||
updateHumanMode();
|
||||
try{
|
||||
const result = await humanRequest('/api/human/close', {sid:sid});
|
||||
const result = await humanRequest('/api/visitor/human/close', {sid:sid});
|
||||
if(!result.ok) throw new Error('Close failed');
|
||||
if(version !== conversationVersion || window.humanSid !== sid) return;
|
||||
stopHumanMode();
|
||||
setComposerStatus('');
|
||||
addMsg(t('humanClosed'), false);
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}catch(error){
|
||||
if(version === conversationVersion) setComposerStatus(t('humanReturnFailed'), true);
|
||||
}finally{
|
||||
@@ -1176,14 +1164,14 @@ async function sendHumanMessage(content){
|
||||
updateHumanMode();
|
||||
setComposerStatus(t('humanSending'));
|
||||
try{
|
||||
const result = await humanRequest('/api/human/send', {sid:sid, conv_id:cid, side:'customer', content:content});
|
||||
const result = await humanRequest('/api/visitor/human/send', {sid:sid, conv_id:cid, side:'customer', content:content});
|
||||
if(!result.ok) throw new Error('Send failed');
|
||||
if(version !== conversationVersion || humanSendingToken !== token) return;
|
||||
addMsg(content, true);
|
||||
if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); }
|
||||
if(window.__pendingImg === attachment) clearAttachment();
|
||||
setComposerStatus(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting'));
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
return true;
|
||||
}catch(error){
|
||||
if(version !== conversationVersion || humanSendingToken !== token) return;
|
||||
@@ -1221,7 +1209,7 @@ function startHumanPoll(){
|
||||
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid){ clearInterval(pollTimer); return; }
|
||||
if(polling) return;
|
||||
polling=true;
|
||||
humanRequest("/api/human/poll?sid="+sid+"&after="+last)
|
||||
humanRequest("/api/visitor/human/poll?sid="+sid+"&after="+last)
|
||||
.then(d=>{
|
||||
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid) return;
|
||||
if(hadPollError){ setComposerStatus(''); hadPollError=false; }
|
||||
@@ -1253,7 +1241,7 @@ function startHumanPoll(){
|
||||
stopHumanMode();
|
||||
setBusy(false);
|
||||
addMsg(t('humanClosed'), false);
|
||||
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed')});
|
||||
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed'), fetch:fetch});
|
||||
scrollChatToEnd();
|
||||
return;
|
||||
}
|
||||
@@ -1488,7 +1476,7 @@ async function doSend(retryText){
|
||||
var tag = window.__pendingTag || "[图片]";
|
||||
const draft = inputDom.value, attachmentVersion = uploadVersion;
|
||||
if(!sendWord && !imgUrl){
|
||||
inputDom.focus();
|
||||
focusComposer(true);
|
||||
return;
|
||||
}
|
||||
if(uploadPending){ setComposerStatus(t("uploading")); return; }
|
||||
@@ -1521,7 +1509,7 @@ async function doSend(retryText){
|
||||
addMsg(fullContent, true);
|
||||
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
|
||||
if(attachmentVersion === uploadVersion) clearAttachment();
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}
|
||||
|
||||
const aiBubble = addMsg(t("thinking"), false);
|
||||
@@ -1554,7 +1542,6 @@ async function doSend(retryText){
|
||||
setBusy(false);
|
||||
aiBubble.classList.remove("typing");
|
||||
if(activeBubble === aiBubble) activeBubble = null;
|
||||
inputDom.focus();
|
||||
|
||||
let raw = aiBubble.dataset.raw || "";
|
||||
// 模型有时会把卡片标记原样输出,这里兜底转成时间轴
|
||||
@@ -1736,7 +1723,10 @@ function autoGrow(){
|
||||
return;
|
||||
}
|
||||
inputDom.style.height = "auto";
|
||||
inputDom.style.height = Math.min(inputDom.scrollHeight, 140) + "px";
|
||||
const viewport = window.visualViewport;
|
||||
const height = viewport && Math.abs(viewport.scale - 1) < 0.01 ? viewport.height : window.innerHeight;
|
||||
const limit = isNarrow() && height > 0 ? Math.min(140, Math.max(44, Math.floor(height / 4))) : 140;
|
||||
inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px";
|
||||
}
|
||||
inputDom.addEventListener("input", autoGrow);
|
||||
|
||||
@@ -1794,12 +1784,30 @@ renderSidebar();
|
||||
|
||||
function syncChatViewport(){
|
||||
const viewport = window.visualViewport;
|
||||
chatContainer.style.height = viewport && isNarrow() && viewport.scale === 1
|
||||
? Math.round(viewport.height) + 'px' : '';
|
||||
const narrow = isNarrow();
|
||||
const unzoomed = !viewport || Math.abs(viewport.scale - 1) < 0.01;
|
||||
if(narrow && unzoomed){
|
||||
const height = viewport ? viewport.height : window.innerHeight;
|
||||
if(Number.isFinite(height) && height > 0){
|
||||
chatContainer.style.height = Math.round(height) + 'px';
|
||||
// Keyboard focus may pan the visual viewport without changing its height.
|
||||
chatContainer.style.top = Math.max(0, Math.round(viewport && viewport.offsetTop || 0)) + 'px';
|
||||
}
|
||||
}else{
|
||||
chatContainer.style.height = '';
|
||||
chatContainer.style.top = '';
|
||||
}
|
||||
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
|
||||
autoGrow();
|
||||
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
|
||||
updateToBottom();
|
||||
}
|
||||
let viewportSyncQueued = false;
|
||||
function scheduleChatViewport(){
|
||||
if(viewportSyncQueued) return;
|
||||
viewportSyncQueued = true;
|
||||
requestAnimationFrame(function(){ viewportSyncQueued = false; syncChatViewport(); });
|
||||
}
|
||||
|
||||
toggleBtn.onclick = function(){
|
||||
sideOpen = !sideOpen;
|
||||
@@ -1827,10 +1835,25 @@ window.addEventListener("resize", function(){
|
||||
renderSidebar();
|
||||
syncChatViewport();
|
||||
});
|
||||
if(window.visualViewport) window.visualViewport.addEventListener('resize', syncChatViewport);
|
||||
if(window.visualViewport){
|
||||
window.visualViewport.addEventListener('resize', scheduleChatViewport);
|
||||
window.visualViewport.addEventListener('scroll', scheduleChatViewport);
|
||||
}
|
||||
window.addEventListener('pageshow', scheduleChatViewport);
|
||||
inputDom.addEventListener('focus', scheduleChatViewport);
|
||||
inputDom.addEventListener('blur', scheduleChatViewport);
|
||||
// Let toolbar clicks complete before blur changes the keyboard and composer layout.
|
||||
['pointerdown', 'mousedown'].forEach(function(name){
|
||||
document.querySelector('.composer').addEventListener(name, function(event){
|
||||
if(event.button === 0 && document.activeElement === inputDom && event.target.closest('button')){
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
syncChatViewport();
|
||||
|
||||
voiceInput = ChatVoice.create({
|
||||
fetch:fetch,
|
||||
toggle:document.getElementById('voice-toggle'), hold:document.getElementById('voice-hold'),
|
||||
panel:document.getElementById('voice-panel'), label:document.getElementById('voice-label'),
|
||||
clock:document.getElementById('voice-clock'), inputArea:document.querySelector('.input-area'), input:inputDom,
|
||||
|
||||
@@ -15,6 +15,7 @@ var SupportUI = {
|
||||
}catch(error){}
|
||||
},
|
||||
addRating(container, sid, labels){
|
||||
const request = labels.fetch || fetch;
|
||||
const box = document.createElement('section');
|
||||
box.className = 'service-rating';
|
||||
const question = document.createElement('p');
|
||||
@@ -40,7 +41,7 @@ var SupportUI = {
|
||||
sending = true;
|
||||
Array.from(choices.children).forEach(function(item){ item.disabled = true; });
|
||||
try{
|
||||
const response = await fetch('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
|
||||
const response = await request('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
|
||||
const data = await response.json();
|
||||
if(!response.ok || !data.ok) throw new Error('Rating failed');
|
||||
box.textContent = labels.thanks+' '+ '★'.repeat(stars);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// A local random guest key restores this browser's history, never customer order identity.
|
||||
var VisitorSession = (function(){
|
||||
const storageName = 'baiyue-visitor-key';
|
||||
const nativeFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : fetch;
|
||||
let ready = null, established = false;
|
||||
function localKey(){
|
||||
try{
|
||||
const saved = localStorage.getItem(storageName);
|
||||
if(saved && /^[0-9a-f]{64}$/.test(saved)) return saved;
|
||||
const crypto = window.crypto;
|
||||
if(!crypto || !crypto.getRandomValues) return null;
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const key = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
||||
localStorage.setItem(storageName, key);
|
||||
return key;
|
||||
}catch(error){ return null; } // HttpOnly cookie can still retain this guest when storage is disabled.
|
||||
}
|
||||
function ensure(){
|
||||
if(ready) return ready;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10000);
|
||||
ready = nativeFetch('/api/visitor/session', {method:'POST',credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json'},body:JSON.stringify({key:localKey()}),signal:controller.signal})
|
||||
.then(async response => {
|
||||
const data = await response.json();
|
||||
if(!response.ok || !data.ok || typeof data.key !== 'string' || !/^[0-9a-f]{64}$/.test(data.key)){
|
||||
throw new Error('访客会话暂时无法建立,请刷新后重试。');
|
||||
}
|
||||
try{ localStorage.setItem(storageName, data.key); }catch(error){}
|
||||
established = true;
|
||||
}).catch(error => { ready = null; established = false; throw error; }).finally(() => clearTimeout(timer));
|
||||
return ready;
|
||||
}
|
||||
function request(path, options){
|
||||
const init = Object.assign({}, options, {credentials:'same-origin'});
|
||||
function send(){
|
||||
if(init.signal && init.signal.aborted) return Promise.reject(Object.assign(new Error('Aborted'), {name:'AbortError'}));
|
||||
const session = ready;
|
||||
return nativeFetch(path, init).then(async response => {
|
||||
if(response.status !== 401) return response;
|
||||
if(ready === session){ ready = null; established = false; }
|
||||
await ensure();
|
||||
return nativeFetch(path, init);
|
||||
});
|
||||
}
|
||||
return established ? send() : ensure().then(send);
|
||||
}
|
||||
return {fetch:request};
|
||||
})();
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
// Short recordings use the same server ASR in mobile browsers and embedded browsers.
|
||||
var ChatVoice = {
|
||||
create(options){
|
||||
const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch);
|
||||
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
|
||||
let session = null, mode = false;
|
||||
const active = current => session === current;
|
||||
@@ -167,7 +168,7 @@ var ChatVoice = {
|
||||
toggle.onclick = function(){
|
||||
if(!mode && !supported()) return;
|
||||
setMode(!mode);
|
||||
if(!mode) input.focus();
|
||||
if(!mode) input.focus({preventScroll:true});
|
||||
};
|
||||
if(window.PointerEvent){
|
||||
hold.addEventListener('pointerdown', function(event){
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<meta name="theme-color" content="#315fd5">
|
||||
<title>柏悦智能物流客服系统</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
|
||||
@@ -44,7 +44,7 @@
|
||||
<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>
|
||||
<h3>物流问题,随时为您解答</h3>
|
||||
<p>查询订单状态、了解专线报价,或联系人工客服。<br>从这里开始您的物流咨询。</p>
|
||||
<p>咨询运费、时效或联系人工客服。<br>订单查询需登录并关联客户账号。</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@
|
||||
<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>
|
||||
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
|
||||
<textarea id="input-text" rows="1" 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 type="button" id="send-button">发送</button>
|
||||
</div>
|
||||
@@ -73,6 +73,7 @@
|
||||
</main>
|
||||
</div>
|
||||
<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/voice.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
|
||||
</body>
|
||||
|
||||
+121
-25
@@ -13,7 +13,7 @@ async function customerPage(pageOptions = {}){
|
||||
constructor(){
|
||||
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
|
||||
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0;
|
||||
this.listeners = {};
|
||||
this.listeners = {}; this.focusCalls = [];
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { this.className += ' '+name; },
|
||||
@@ -32,7 +32,8 @@ 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}); }
|
||||
appendChild(child){ child.parent = this; this.children.push(child); }
|
||||
append(...children){ children.forEach(child => this.appendChild(child)); }
|
||||
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(); } }
|
||||
querySelector(selector){
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
@@ -67,10 +68,10 @@ async function customerPage(pageOptions = {}){
|
||||
FormData:class { append(){} },
|
||||
requestAnimationFrame:callback => callback(),
|
||||
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
|
||||
window:{innerWidth:pageOptions.width || 1200,
|
||||
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
|
||||
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
|
||||
matchMedia:query => ({matches:pageOptions.width ? pageOptions.width <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1]) : false}),
|
||||
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,
|
||||
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
|
||||
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,offsetTop:0,
|
||||
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
|
||||
prompt:() => pageOptions.offlineMessage || null},
|
||||
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
|
||||
@@ -80,7 +81,7 @@ async function customerPage(pageOptions = {}){
|
||||
fetch: async (url, options) => {
|
||||
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(url.startsWith('/api/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 === '/stream_chat'){
|
||||
let read = false;
|
||||
@@ -91,14 +92,15 @@ async function customerPage(pageOptions = {}){
|
||||
}})}};
|
||||
}
|
||||
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
|
||||
: url === '/api/human/request' ? {ok:true, session_id:7}
|
||||
: url.startsWith('/api/human/poll') ? state
|
||||
: ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
|
||||
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
|
||||
: url === '/api/visitor/human/request' ? {ok:true, session_id:7}
|
||||
: 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}
|
||||
: url === '/api/get_history' ? (pageOptions.history || []) : [];
|
||||
return {ok:true, json:async () => data};
|
||||
},
|
||||
});
|
||||
for(const file of ['support.js', 'voice.js', 'chat.js']){
|
||||
for(const file of ['support.js', 'visitor.js', 'voice.js', 'chat.js']){
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
|
||||
if(file === 'voice.js'){
|
||||
const createVoice=context.ChatVoice.create;
|
||||
@@ -111,15 +113,22 @@ async function customerPage(pageOptions = {}){
|
||||
await flush();
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,
|
||||
resize:async height => {
|
||||
context.window.innerHeight=height;
|
||||
if(context.window.visualViewport) context.window.visualViewport.height=height;
|
||||
(windowListeners.resize || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
resizeViewport:async (height,scale=1) => {
|
||||
Object.assign(context.window.visualViewport,{height,scale});
|
||||
resizeViewport:async (height,scale=1,offsetTop=0) => {
|
||||
Object.assign(context.window.visualViewport,{height,scale,offsetTop});
|
||||
(viewportListeners.resize || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
panViewport:async top => {
|
||||
context.window.visualViewport.offsetTop=top;
|
||||
(viewportListeners.scroll || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); },
|
||||
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(); },
|
||||
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
|
||||
@@ -362,7 +371,7 @@ test('all three footer questions leave the human queue and send to separate AI c
|
||||
assert.equal(requests.length, 3);
|
||||
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['查询订单状态', '欧洲海运专线报价', '包裹异常处理']);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/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.element('human-session').hidden, true);
|
||||
});
|
||||
@@ -377,7 +386,7 @@ test('welcome and hot question buttons use the same AI routing as the footer', a
|
||||
}
|
||||
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
|
||||
});
|
||||
|
||||
test('the welcome contact button opens the human queue and does not send an AI question', async () => {
|
||||
@@ -386,7 +395,7 @@ test('the welcome contact button opens the human queue and does not send an AI q
|
||||
button.setAttribute('data-human-support', '');
|
||||
app.element('chatDom').listeners.click({target:button});
|
||||
await flush();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
|
||||
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 0);
|
||||
assert.equal(app.element('human-session').hidden, false);
|
||||
});
|
||||
@@ -397,7 +406,7 @@ test('new chat clears human routing and the next quote is sent to AI', async ()
|
||||
await app.context.window.fillInput('请报价,30KG发往美国FBA仓');
|
||||
await flush();
|
||||
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '请报价,30KG发往美国FBA仓');
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
|
||||
assert.equal(app.context.window.__busy, false);
|
||||
});
|
||||
|
||||
@@ -454,7 +463,7 @@ test('a late history response cannot overwrite the latest quick conversation', a
|
||||
test('a delayed human connection cannot capture a newly selected AI topic', async () => {
|
||||
const app = await customerPage({human:false});
|
||||
let resolve;
|
||||
app.hooks['/api/human/request'] = () => new Promise(done => { resolve=done; });
|
||||
app.hooks['/api/visitor/human/request'] = () => new Promise(done => { resolve=done; });
|
||||
const connecting = app.context.window.showHuman();
|
||||
await flush();
|
||||
await app.click(app.quickButtons[1]);
|
||||
@@ -468,7 +477,7 @@ test('a delayed human connection cannot capture a newly selected AI topic', asyn
|
||||
test('repeated contact clicks create one human request and show a visible queue state', async () => {
|
||||
const app = await customerPage({human:false});
|
||||
await Promise.all([app.context.window.showHuman(), app.context.window.showHuman()]);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
|
||||
assert.equal(app.element('human-session').hidden, false);
|
||||
assert.match(app.element('human-session-label').textContent, /正在排队/);
|
||||
});
|
||||
@@ -476,7 +485,7 @@ 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 () => {
|
||||
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 0);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 0);
|
||||
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
|
||||
assert.match(app.element('chatDom').textContent, /留言已提交/);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
@@ -484,7 +493,7 @@ test('the contact shortcut outside service hours submits a ticket without enteri
|
||||
|
||||
test('a failed human send preserves the text and makes retry possible', async () => {
|
||||
const app = await customerPage();
|
||||
app.hooks['/api/human/send'] = () => response({ok:false}, 503);
|
||||
app.hooks['/api/visitor/human/send'] = () => response({ok:false}, 503);
|
||||
await app.context.window.fillInput('请确认这批货的价格');
|
||||
assert.equal(app.element('input-text').value, '请确认这批货的价格');
|
||||
assert.match(app.element('composer-status').textContent, /内容已保留/);
|
||||
@@ -494,10 +503,10 @@ test('a failed human send preserves the text and makes retry possible', async ()
|
||||
test('a duplicate human send cannot submit an in-flight message twice', async () => {
|
||||
const app = await customerPage();
|
||||
let resolve;
|
||||
app.hooks['/api/human/send'] = () => new Promise(done => { resolve=done; });
|
||||
app.hooks['/api/visitor/human/send'] = () => new Promise(done => { resolve=done; });
|
||||
const first = app.context.window.fillInput('请确认时效');
|
||||
await app.context.window.fillInput('请确认时效');
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 1);
|
||||
resolve(response({ok:true}));
|
||||
await first;
|
||||
assert.equal(app.element('input-text').value, '');
|
||||
@@ -508,7 +517,7 @@ test('returning to AI explicitly closes the human session and preserves the conv
|
||||
await app.click(app.element('return-ai'));
|
||||
await app.context.window.fillInput('请继续解答');
|
||||
await flush();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 1);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/close').length, 1);
|
||||
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 1);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
});
|
||||
@@ -634,12 +643,99 @@ test('clearing a sent draft restores a single-row composer instead of sizing to
|
||||
assert.equal(input.style.height,'');
|
||||
});
|
||||
|
||||
test('keyboard panning keeps the whole mobile frame in the visual viewport and restores after closing', async () => {
|
||||
const app=await customerPage({human:false,width:390,viewportHeight:844});
|
||||
const input=app.element('input-text'), frame=app.element('.chat-container');
|
||||
input.focus();
|
||||
await app.resizeViewport(430,1,180);
|
||||
assert.equal(frame.style.height,'430px');
|
||||
assert.equal(frame.style.top,'180px');
|
||||
assert.ok(frame.classList.contains('is-composing'));
|
||||
await app.panViewport(260);
|
||||
assert.equal(frame.style.top,'260px');
|
||||
assert.equal(frame.style.height,'430px');
|
||||
await app.resizeViewport(0,1,0);
|
||||
assert.equal(frame.style.height,'430px');
|
||||
input.blur();
|
||||
await app.resizeViewport(844);
|
||||
assert.equal(frame.style.top,'0px');
|
||||
assert.equal(frame.style.height,'844px');
|
||||
assert.equal(frame.classList.contains('is-composing'),false);
|
||||
});
|
||||
|
||||
test('embedded browsers without visualViewport use the resized window and recover on pageshow', async () => {
|
||||
const app=await customerPage({human:false,width:390,height:844});
|
||||
const frame=app.element('.chat-container');
|
||||
assert.equal(frame.style.height,'844px');
|
||||
await app.resize(410);
|
||||
assert.equal(frame.style.height,'410px');
|
||||
app.context.window.innerHeight=844;
|
||||
await app.pageShow();
|
||||
assert.equal(frame.style.height,'844px');
|
||||
app.context.window.innerWidth=1200;
|
||||
await app.resize(720);
|
||||
assert.equal(frame.style.height,'');
|
||||
assert.equal(frame.style.top,'');
|
||||
});
|
||||
|
||||
test('a multiline draft leaves room for messages when the keyboard reduces the viewport', async () => {
|
||||
const app=await customerPage({human:false,width:390,viewportHeight:844});
|
||||
const input=app.element('input-text');
|
||||
input.value='多行草稿';
|
||||
input.scrollHeight=300;
|
||||
input.listeners.input();
|
||||
assert.equal(input.style.height,'140px');
|
||||
await app.resizeViewport(340);
|
||||
assert.equal(input.style.height,'85px');
|
||||
await app.resizeViewport(844);
|
||||
assert.equal(input.style.height,'140px');
|
||||
});
|
||||
|
||||
test('late AI completion does not steal focus or reopen a dismissed mobile keyboard', async () => {
|
||||
for(const width of [390,1200]){
|
||||
const app=await customerPage({human:false,width,viewportHeight:844});
|
||||
const input=app.element('input-text');
|
||||
let reply;
|
||||
app.hooks['/stream_chat']=()=>new Promise(resolve=>{reply=resolve;});
|
||||
input.focus();
|
||||
await app.context.window.fillInput('报价问题');
|
||||
await flush();
|
||||
input.blur();
|
||||
app.element('history-search').focus();
|
||||
const before=input.focusCalls.length;
|
||||
reply(streamResponse(['data: {"t":"完成回复"}\n\ndata: {"d":1}\n\n']));
|
||||
await flush();await flush();
|
||||
assert.equal(input.focusCalls.length,before);
|
||||
assert.equal(app.context.document.activeElement,app.element('history-search'));
|
||||
assert.match(app.element('chatDom').textContent,/完成回复/);
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile quick questions keep the keyboard closed and composer controls finish clicking before blur changes layout', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
const input=app.element('input-text');
|
||||
await app.context.window.fillInput('快捷咨询');await flush();
|
||||
assert.equal(input.focusCalls.length,0);
|
||||
input.focus();
|
||||
for(const event of ['pointerdown','mousedown']){
|
||||
for(const name of ['send-button','voice-toggle','emoji-toggle','upload-btn']){
|
||||
let prevented=false;
|
||||
app.element('.composer').listeners[event]({button:0,target:{closest:()=>app.element(name)},preventDefault(){prevented=true;}});
|
||||
assert.ok(prevented);
|
||||
}
|
||||
}
|
||||
let blockedTextSelection=false;
|
||||
app.element('.composer').listeners.pointerdown({button:0,target:{closest:()=>null},preventDefault(){blockedTextSelection=true;}});
|
||||
assert.equal(blockedTextSelection,false);
|
||||
assert.equal(app.context.document.activeElement,input);
|
||||
});
|
||||
|
||||
test('recognized voice uses the normal AI and human send routes with a visible user message', async () => {
|
||||
for(const human of [false,true]){
|
||||
const app=await customerPage({human,width:390});
|
||||
await app.voiceOptions.onText('语音识别的物流问题');
|
||||
await flush();
|
||||
const call=app.calls.find(call=>call.url===(human ? '/api/human/send' : '/stream_chat'));
|
||||
const call=app.calls.find(call=>call.url===(human ? '/api/visitor/human/send' : '/stream_chat'));
|
||||
assert.ok(call);
|
||||
assert.equal(human ? call.body.content : call.body.msg,'语音识别的物流问题');
|
||||
assert.match(app.element('chatDom').querySelector('.user-message').textContent,/语音识别的物流问题/);
|
||||
@@ -682,7 +778,7 @@ test('an AI regeneration is not forwarded to a human session', async () => {
|
||||
await app.context.window.fillInput('之前的问题');await flush();
|
||||
const retry=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
|
||||
await app.context.window.showHuman();await app.click(retry);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,0);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/human/send').length,0);
|
||||
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
|
||||
assert.match(app.element('composer-status').textContent,/先返回 AI/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const path=require('node:path');
|
||||
const vm=require('node:vm');
|
||||
const {webcrypto}=require('node:crypto');
|
||||
const flush=()=>new Promise(setImmediate);
|
||||
|
||||
function sessionPage(options={}){
|
||||
const storage=options.storage || new Map(),calls=[],timers=new Map();let timerId=0;
|
||||
const response=(data,status=200)=>({ok:status>=200&&status<300,status,json:async()=>data});
|
||||
const context=vm.createContext({console,AbortController,Uint8Array,
|
||||
window:{crypto:webcrypto},
|
||||
localStorage:{getItem:key=>{if(options.storageBlocked) throw Error();return storage.get(key);},
|
||||
setItem:(key,value)=>{if(options.storageBlocked) throw Error();storage.set(key,value);}},
|
||||
setTimeout:(callback,delay)=>{const id=++timerId;timers.set(id,{callback,delay});return id;},clearTimeout:id=>timers.delete(id),
|
||||
fetch:async(url,init)=>{calls.push({url,init});
|
||||
if(options.fetch) return options.fetch(url,init,response);
|
||||
return url==='/api/visitor/session' ? response({ok:true,key:JSON.parse(init.body).key || 'f'.repeat(64)}) : response([]);
|
||||
},
|
||||
});
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/visitor.js'),'utf8'),context);
|
||||
return {fetch:context.VisitorSession.fetch,calls,storage,
|
||||
expire:async()=>{for(const timer of [...timers.values()])timer.callback();await flush();}};
|
||||
}
|
||||
|
||||
test('a local random key is restored across reloads and differs in another browser',async()=>{
|
||||
const a=sessionPage();await a.fetch('/api/get_history');
|
||||
const key=JSON.parse(a.calls[0].init.body).key;
|
||||
assert.match(key,/^[0-9a-f]{64}$/);
|
||||
const restored=sessionPage({storage:a.storage});await restored.fetch('/api/get_history');
|
||||
assert.equal(JSON.parse(restored.calls[0].init.body).key,key);
|
||||
const b=sessionPage();await b.fetch('/api/get_history');
|
||||
assert.notEqual(JSON.parse(b.calls[0].init.body).key,key);
|
||||
assert.equal(a.calls[1].init.credentials,'same-origin');
|
||||
assert.equal(a.calls.some(call=>call.url.includes(key)),false);
|
||||
});
|
||||
|
||||
test('concurrent requests wait for one bootstrap before sending customer data',async()=>{
|
||||
let release;
|
||||
const app=sessionPage({fetch:(url,init,response)=>url==='/api/visitor/session'
|
||||
? new Promise(resolve=>{release=()=>resolve(response({ok:true,key:JSON.parse(init.body).key}));}) : response([])});
|
||||
const a=app.fetch('/api/get_history'),b=app.fetch('/api/new_chat',{method:'POST'});
|
||||
assert.equal(app.calls.length,1);
|
||||
release();await Promise.all([a,b]);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/session').length,1);
|
||||
assert.equal(app.calls.length,3);
|
||||
});
|
||||
|
||||
test('blocked local storage uses a server cookie without falling back to global history',async()=>{
|
||||
const app=sessionPage({storageBlocked:true});await app.fetch('/api/get_history');
|
||||
assert.equal(JSON.parse(app.calls[0].init.body).key,null);
|
||||
assert.equal(app.calls[1].init.credentials,'same-origin');
|
||||
const failed=sessionPage({fetch:(url,init,response)=>response({ok:false},503)});
|
||||
await assert.rejects(failed.fetch('/api/get_history'));
|
||||
assert.equal(failed.calls.length,1);
|
||||
});
|
||||
|
||||
test('expired sessions rebootstrap once and retry the original request once',async()=>{
|
||||
let attempts=0;
|
||||
const app=sessionPage({fetch:(url,init,response)=>url==='/api/visitor/session'
|
||||
? response({ok:true,key:JSON.parse(init.body).key}) : response([],++attempts===1 ? 401 : 200)});
|
||||
const result=await app.fetch('/api/get_history');
|
||||
assert.equal(result.status,200);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/session').length,2);
|
||||
assert.equal(attempts,2);
|
||||
});
|
||||
|
||||
test('a cancelled request waiting for bootstrap does not upload its body',async()=>{
|
||||
let release;
|
||||
const app=sessionPage({fetch:(url,init,response)=>new Promise(resolve=>{
|
||||
release=()=>resolve(response({ok:true,key:JSON.parse(init.body).key}));
|
||||
})});
|
||||
const controller=new AbortController();
|
||||
const pending=app.fetch('/api/upload',{method:'POST',body:'private file',signal:controller.signal});
|
||||
controller.abort();release();
|
||||
await assert.rejects(pending,{name:'AbortError'});
|
||||
assert.equal(app.calls.length,1);
|
||||
});
|
||||
@@ -14,8 +14,8 @@ function voicePage(options={}){
|
||||
setAttribute(name,value){this.attributes[name]=value;}
|
||||
addEventListener(name,callback){this.listeners[name]=callback;}
|
||||
setPointerCapture(){}
|
||||
focus(){}
|
||||
blur(){}
|
||||
focus(options){this.focusOptions=options;}
|
||||
blur(){this.blurred=true;}
|
||||
}
|
||||
for(const key of ['toggle','hold','panel','label','clock','inputArea','input']) elements[key]=new Element();
|
||||
const tracks=[{stop(){trackStops++;}}], stream={getTracks:()=>tracks};
|
||||
@@ -70,6 +70,15 @@ test('hold and release transcribes audio and sends text once',async()=>{
|
||||
assert.equal(app.messages.length,1);
|
||||
});
|
||||
|
||||
test('switching between voice and keyboard avoids scrolling the page on focus',()=>{
|
||||
const app=voicePage();
|
||||
app.open();
|
||||
assert.ok(app.elements.input.blurred);
|
||||
app.open();
|
||||
assert.equal(app.elements.input.focusOptions.preventScroll,true);
|
||||
assert.equal(app.elements.toggle.attributes['aria-pressed'],'false');
|
||||
});
|
||||
|
||||
test('sliding up cancels without uploading and supports another recording',async()=>{
|
||||
const app=voicePage();app.open();await app.down();app.move(200);await app.up();
|
||||
assert.equal(app.messages.length,0);
|
||||
|
||||
+8
-1
@@ -45,6 +45,8 @@ class ApiTests(unittest.TestCase):
|
||||
self.root = Path(self.directory.name)
|
||||
self.app = self.application(self.root)
|
||||
self.client = self.app.test_client()
|
||||
self.client.post('/api/visitor/session', json={'key':'1'*64})
|
||||
self.app.extensions['rate_hits'].clear()
|
||||
from logistics_chat.order_data import import_records
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
@@ -55,9 +57,14 @@ class ApiTests(unittest.TestCase):
|
||||
connection.close()
|
||||
|
||||
def application(self, root):
|
||||
return create_app({'TESTING': True, 'DATABASE': root/'history.db',
|
||||
app = create_app({'TESTING': True, 'DATABASE': root/'history.db',
|
||||
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups',
|
||||
'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''})
|
||||
@app.before_request
|
||||
def fixture_customer():
|
||||
from flask import g
|
||||
g.customer_tenant_id = 'fixture'
|
||||
return app
|
||||
|
||||
def conversation(self):
|
||||
return self.client.post('/api/new_chat').json['id']
|
||||
|
||||
@@ -29,6 +29,7 @@ class DeploymentTests(unittest.TestCase):
|
||||
|
||||
self.addCleanup(close_handlers)
|
||||
client = app.test_client()
|
||||
client.post('/api/visitor/session', json={'key':'1'*64})
|
||||
for path in ('/', '/admin/dashboard', '/api/tickets', '/api/my_orders?phone=123'):
|
||||
self.assertEqual(client.get(path).status_code, 200, path)
|
||||
self.assertEqual(client.post('/admin/login', json={'username': 'demo', 'password': 'test-password'}).status_code, 401)
|
||||
|
||||
@@ -21,6 +21,7 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
'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})
|
||||
|
||||
def session(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
|
||||
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
|
||||
def test_reply_workflows(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs'], cwd=root,
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs'], cwd=root,
|
||||
text=True, encoding='utf-8', capture_output=True, timeout=30,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
@@ -41,7 +41,17 @@ elif name == 'curl' and '-w' in args:
|
||||
if scenario == 'proxy-unsafe':
|
||||
print('200', end='')
|
||||
sys.exit(0)
|
||||
print('401', end='')
|
||||
path = '/' + args[-1].split('://', 1)[-1].partition('/')[2]
|
||||
if '-c' in args:
|
||||
pathlib.Path(args[args.index('-c') + 1]).write_text('fixture-visitor-cookie')
|
||||
if '-b' in args:
|
||||
assert pathlib.Path(args[args.index('-b') + 1]).read_text() == 'fixture-visitor-cookie'
|
||||
status = '200' if path == '/' or path == '/api/visitor/session' or (path == '/api/get_history' and '-b' in args) else '401'
|
||||
if scenario == 'visitor-bootstrap-fail' and path == '/api/visitor/session':
|
||||
status = '503'
|
||||
if scenario == 'visitor-history-fail' and path == '/api/get_history' and '-b' in args:
|
||||
status = '503'
|
||||
print(status, end='')
|
||||
elif name == 'sleep':
|
||||
pass
|
||||
'''
|
||||
@@ -97,6 +107,7 @@ class GrayActivationTests(unittest.TestCase):
|
||||
env=environment, capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual((root / 'data/history.db').read_bytes(), b'preserve-existing-data')
|
||||
self.assertEqual(unrelated.read_text(), '# unrelated site\n')
|
||||
self.assertFalse(list(release.glob('.visitor-smoke.*')), 'Temporary visitor cookies must be removed on success and rollback')
|
||||
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
||||
return result, root, proxy, previous, release, old_site, calls
|
||||
|
||||
@@ -119,6 +130,7 @@ class GrayActivationTests(unittest.TestCase):
|
||||
self.assertEqual((proxy.parent / 'baiyue-logistics-chat-gray-proxy/access.htpasswd').read_text(), 'previous-auth-hash\n')
|
||||
self.assertTrue(any(name == 'docker' and 'up' in args and str(previous / 'release.env') in args
|
||||
for name, args in calls), result.stderr)
|
||||
return result
|
||||
|
||||
def test_failed_application_restores_previous_release(self):
|
||||
self.assert_rolled_back('app-fail')
|
||||
@@ -132,6 +144,14 @@ class GrayActivationTests(unittest.TestCase):
|
||||
def test_unprotected_proxy_never_publishes_the_release_pointer(self):
|
||||
self.assert_rolled_back('proxy-unsafe')
|
||||
|
||||
def test_failed_visitor_bootstrap_rolls_back_and_removes_cookie_file(self):
|
||||
result = self.assert_rolled_back('visitor-bootstrap-fail')
|
||||
self.assertIn('Guest session bootstrap verification failed.', result.stderr)
|
||||
|
||||
def test_failed_cookie_history_rolls_back_and_removes_cookie_file(self):
|
||||
result = self.assert_rolled_back('visitor-history-fail')
|
||||
self.assertIn('Guest history verification failed.', result.stderr)
|
||||
|
||||
def test_image_retagged_after_verified_load_is_rejected_before_activation(self):
|
||||
result, root, proxy, previous, release, old_site, calls = self.run_activation('image-mismatch')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import replace
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import re
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
@@ -57,6 +58,26 @@ class GrayConfigTests(unittest.TestCase):
|
||||
self.assertNotIn(key, (path / name).read_text(encoding='utf-8'))
|
||||
self.assertIn('auth_basic_user_file', (path / 'site.conf').read_text(encoding='utf-8'))
|
||||
|
||||
def test_public_allowlist_keeps_staff_search_exports_and_order_endpoints_protected(self):
|
||||
config = GrayConfig.from_environment(self.settings()).nginx_config()
|
||||
self.assertTrue(config.startswith('# Managed for baiyue-logistics-chat-gray;'))
|
||||
match = re.search(r'location ~ (\S+) \{([^}]+)\}', config)
|
||||
self.assertIsNotNone(match)
|
||||
pattern, body = match.groups()
|
||||
self.assertIn('auth_basic off;', body)
|
||||
self.assertIn('auth_basic "Baiyue gray acceptance";', config)
|
||||
for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages',
|
||||
'/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'):
|
||||
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',
|
||||
'/api/human/queue','/api/human/poll','/api/human/send','/api/staff/uploads/private.txt',
|
||||
'/api/visitor/human/accept','/api/get_history/unknown','/api/unknown'):
|
||||
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')
|
||||
self.assertEqual(re.search(r'location ~ (\S+) \{',example)[1],pattern)
|
||||
self.assertIn('location = / {\n auth_basic off;',config)
|
||||
|
||||
def test_invalid_model_credential_does_not_create_a_release_directory(self):
|
||||
config = GrayConfig.from_environment(self.settings())
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
@@ -19,8 +19,11 @@ class OrderDataTests(unittest.TestCase):
|
||||
'UPLOAD_DIR':root/'uploads', 'BACKUP_DIR':root/'backups', 'LOG_DIR':root/'logs',
|
||||
'DEEPSEEK_API_KEY':'', 'ORDER_DATA_TENANT_ID':'', 'RATE_LIMIT_PER_MIN':1000})
|
||||
self.client = self.app.test_client()
|
||||
self.client.post('/api/visitor/session', json={'key':'1'*64})
|
||||
self.context = self.app.app_context()
|
||||
self.context.push()
|
||||
from flask import g
|
||||
g.customer_tenant_id = 'tenant-a'
|
||||
self.addCleanup(self.context.pop)
|
||||
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
|
||||
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
|
||||
@@ -88,7 +91,8 @@ class OrderDataTests(unittest.TestCase):
|
||||
|
||||
def test_server_tenant_scope_and_ambiguous_identifiers(self):
|
||||
self.import_rows([{**self.record, 'tenant_id':'tenant-b', 'gross_weight_kg':'99'}])
|
||||
self.assertIn('多个订单', self.events(self.record['order_no'])[0]['t'])
|
||||
fields = self.events(self.record['order_no'])[0]['card']['fields']
|
||||
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '30.000 kg')
|
||||
self.app.config['ORDER_DATA_TENANT_ID'] = 'tenant-a'
|
||||
rows = order_data.lookup_order(self.record['order_no'])
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
@@ -37,6 +37,7 @@ class SpeechTests(unittest.TestCase):
|
||||
'DEEPSEEK_API_KEY': 'separate-chat-key', 'ASR_PROVIDER':'dashscope', 'ASR_API_KEY': 'asr-test-key',
|
||||
'ASR_URL': 'https://asr.example.test/chat/completions', 'ASR_MODEL': 'test-asr', 'RATE_LIMIT_PER_MIN':1000})
|
||||
self.client = self.app.test_client()
|
||||
self.client.post('/api/visitor/session', json={'key':'1'*64})
|
||||
|
||||
def recording(self, content=b'\x1aE\xdf\xa3' + b'x' * 40, mime='audio/webm', duration='2.5', **extra):
|
||||
response = self.client.post('/api/speech/transcribe', data={'audio': (io.BytesIO(content), 'private-filename.webm', mime),
|
||||
|
||||
@@ -6,6 +6,8 @@ from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
from flask import Flask
|
||||
from werkzeug.datastructures import FileStorage
|
||||
@@ -29,6 +31,27 @@ class StorageTests(unittest.TestCase):
|
||||
self.addCleanup(self.context.pop)
|
||||
db.init_db()
|
||||
|
||||
def test_concurrent_startup_migrates_legacy_history_once_without_claiming_it(self):
|
||||
connection = db.connect()
|
||||
connection.executescript('DROP TABLE chat_history; CREATE TABLE chat_history '
|
||||
'(id INTEGER PRIMARY KEY, title TEXT NOT NULL, ctime TEXT NOT NULL);'
|
||||
"INSERT INTO chat_history VALUES(1,'旧记录','now');")
|
||||
connection.close()
|
||||
gate = Barrier(4)
|
||||
|
||||
def initialize():
|
||||
with self.app.app_context():
|
||||
gate.wait(timeout=5)
|
||||
db.init_db()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as workers:
|
||||
for result in workers.map(lambda _: initialize(), range(4)):
|
||||
self.assertIsNone(result)
|
||||
connection = db.connect()
|
||||
self.assertEqual(connection.execute('SELECT title, visitor_id FROM chat_history').fetchall(), [('旧记录', None)])
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM messages WHERE conv_id=1').fetchone()[0], 1)
|
||||
connection.close()
|
||||
|
||||
def test_old_schema_is_migrated_and_legacy_title_backfilled(self):
|
||||
connection = db.connect()
|
||||
connection.executescript('DROP TABLE chat_history; CREATE TABLE chat_history '
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Independent guests cannot read, mutate or claim each other's chat data."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from datetime import datetime
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
|
||||
|
||||
class VisitorTests(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.a, self.b = self.app.test_client(), self.app.test_client()
|
||||
for client, key in ((self.a,'1'*64),(self.b,'2'*64)):
|
||||
self.assertEqual(client.post('/api/visitor/session',json={'key':key}).status_code,200)
|
||||
|
||||
def conversation(self, client, text):
|
||||
cid = client.post('/api/new_chat').json['id']
|
||||
with self.app.app_context(): db.save_message(cid,'user',text)
|
||||
return cid
|
||||
|
||||
def human(self, client, cid):
|
||||
with patch('logistics_chat.human_service.datetime') as clock:
|
||||
clock.now.return_value = datetime(2026,10,30,12)
|
||||
return client.post('/api/visitor/human/request',json={'conv_id':cid}).json['session_id']
|
||||
|
||||
def test_entry_is_public_but_customer_apis_need_a_guest_session_without_basic_login(self):
|
||||
fresh = self.app.test_client()
|
||||
self.assertEqual(fresh.get('/').status_code,200)
|
||||
response = fresh.get('/api/get_history')
|
||||
self.assertEqual(response.status_code,401)
|
||||
self.assertNotIn('WWW-Authenticate',response.headers)
|
||||
self.assertEqual(fresh.post('/api/new_chat').status_code,401)
|
||||
self.assertEqual(self.a.get('/api/get_history').json,[])
|
||||
|
||||
def test_guest_key_is_restorable_but_only_its_hash_is_stored(self):
|
||||
cid = self.conversation(self.a,'A_PRIVATE')
|
||||
restored = self.app.test_client()
|
||||
response = restored.post('/api/visitor/session',json={'key':'1'*64},base_url='https://localhost')
|
||||
for flag in ('HttpOnly','Secure','SameSite=Lax'):
|
||||
self.assertIn(flag,response.headers['Set-Cookie'])
|
||||
self.assertEqual(restored.get('/api/get_history').json[0]['id'],cid)
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
owner=connection.execute('SELECT visitor_id FROM chat_history WHERE id=?',(cid,)).fetchone()[0]
|
||||
connection.close()
|
||||
self.assertEqual(len(owner),64)
|
||||
self.assertNotEqual(owner,'1'*64)
|
||||
|
||||
def test_listing_loading_and_streaming_never_expose_another_guest(self):
|
||||
a = self.conversation(self.a,'A_PRIVATE')
|
||||
b = self.conversation(self.b,'B_PRIVATE')
|
||||
self.assertEqual([row['id'] for row in self.a.get('/api/get_history').json],[a])
|
||||
self.assertEqual([row['id'] for row in self.b.get('/api/get_history').json],[b])
|
||||
self.assertEqual(self.a.get(f'/api/get_messages?conv_id={b}').json,[])
|
||||
self.assertEqual(self.b.get(f'/api/get_messages?conv_id={a}').json,[])
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
response=self.a.post('/stream_chat',json={'msg':'继续上一段对话','conv_id':b})
|
||||
self.assertIn('新建对话',response.get_data(as_text=True))
|
||||
self.assertNotIn('B_PRIVATE',response.get_data(as_text=True))
|
||||
self.assertEqual(len(self.b.get(f'/api/get_messages?conv_id={b}').json),2)
|
||||
model.assert_not_called()
|
||||
|
||||
def test_delete_and_clear_only_affect_the_current_guest_and_keep_unowned_legacy_data(self):
|
||||
a = self.conversation(self.a,'A_PRIVATE')
|
||||
b = self.conversation(self.b,'B_PRIVATE')
|
||||
sid_a,sid_b=self.human(self.a,a),self.human(self.b,b)
|
||||
self.a.post('/api/visitor/human/send',json={'sid':sid_a,'content':'A_SUPPORT'})
|
||||
self.b.post('/api/visitor/human/send',json={'sid':sid_b,'content':'B_SUPPORT'})
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
legacy=connection.execute("INSERT INTO chat_history(title,ctime) VALUES('LEGACY_PRIVATE','now')").lastrowid
|
||||
connection.commit();connection.close()
|
||||
self.assertEqual(self.a.delete(f'/api/del_history?id={b}').status_code,404)
|
||||
self.assertTrue(self.a.delete('/api/clear_all').json['ok'])
|
||||
self.assertEqual(self.a.get('/api/get_history').json,[])
|
||||
self.assertEqual(self.b.get('/api/get_history').json[0]['id'],b)
|
||||
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid_b}').json['messages'][0]['content'],'B_SUPPORT')
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
self.assertEqual(connection.execute('SELECT id FROM chat_history ORDER BY id').fetchall(),[(b,),(legacy,)])
|
||||
connection.close()
|
||||
|
||||
def test_customer_human_routes_enforce_owner_and_role_and_hide_internal_notes(self):
|
||||
a=self.conversation(self.a,'A_PRIVATE')
|
||||
sid=self.human(self.a,a)
|
||||
self.assertEqual(self.b.post('/api/visitor/human/request',json={'conv_id':a}).status_code,404)
|
||||
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid}').status_code,404)
|
||||
for action in ('send','close'):
|
||||
self.assertEqual(self.b.post('/api/visitor/human/'+action,json={'sid':sid,'content':'attack'}).status_code,404)
|
||||
self.assertEqual(self.a.post('/api/visitor/human/send',json={'sid':sid,'side':'agent','content':'fake agent'}).status_code,403)
|
||||
self.a.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'})
|
||||
response=self.a.get(f'/api/visitor/human/poll?sid={sid}')
|
||||
self.assertNotIn('note',response.json)
|
||||
self.assertNotIn('STAFF_PRIVATE',response.get_data(as_text=True))
|
||||
self.assertEqual(self.b.post('/api/rate',json={'sid':sid,'stars':5}).status_code,404)
|
||||
self.assertEqual(self.a.post('/api/rate',json=[1]).status_code,400)
|
||||
self.assertEqual(self.a.post('/api/rate',json={'sid':sid,'stars':5}).status_code,200)
|
||||
|
||||
def test_uploads_are_private_and_legacy_files_are_not_assigned_to_new_visitors(self):
|
||||
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'A_PRIVATE'),'note.txt')})
|
||||
url=response.json['url']
|
||||
with self.a.get(url) as downloaded:
|
||||
self.assertEqual(downloaded.data,b'A_PRIVATE')
|
||||
self.assertIn('no-store',downloaded.headers['Cache-Control'])
|
||||
self.assertEqual(self.b.get(url).status_code,404)
|
||||
self.assertEqual(self.app.test_client().get(url).status_code,404)
|
||||
root=Path(self.app.config['UPLOAD_DIR'])
|
||||
(root/'legacy.txt').write_text('LEGACY_PRIVATE')
|
||||
self.assertEqual(self.a.get('/uploads/legacy.txt').status_code,404)
|
||||
with self.a.get('/api/staff'+url) as staff:
|
||||
self.assertEqual(staff.data,b'A_PRIVATE')
|
||||
|
||||
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
|
||||
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001'):
|
||||
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'})
|
||||
self.assertIn('客户账号登录并关联',response.get_data(as_text=True))
|
||||
lookup.assert_not_called();model.assert_not_called()
|
||||
|
||||
def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self):
|
||||
cid=self.conversation(self.a,'A_PRIVATE')
|
||||
for key in ('short','x'*64,'1'*65,[],{},123):
|
||||
self.assertEqual(self.a.post('/api/visitor/session',json={'key':key}).status_code,400)
|
||||
self.assertEqual(self.a.post('/api/visitor/session',json={'key':'2'*64},headers={'Origin':'https://attacker.example'}).status_code,403)
|
||||
self.assertEqual(self.a.post('/api/visitor/session',data={'key':'2'*64}).status_code,400)
|
||||
self.assertEqual(self.a.post('/api/visitor/session',json={'key':'1'*64,'padding':'x'*2000}).status_code,413)
|
||||
self.assertEqual(self.a.get('/api/get_history').json[0]['id'],cid)
|
||||
|
||||
def test_owned_support_polling_is_not_counted_as_chat_generation_and_bounds_its_cursor(self):
|
||||
cid=self.conversation(self.a,'A_PRIVATE')
|
||||
sid=self.human(self.a,cid)
|
||||
self.app.config['RATE_LIMIT_PER_MIN']=1
|
||||
self.app.extensions['rate_hits'].clear()
|
||||
self.assertEqual(self.a.get('/api/get_history').status_code,200)
|
||||
self.assertEqual(self.a.get('/api/get_history').status_code,429)
|
||||
self.assertEqual(self.a.get(f'/api/visitor/human/poll?sid={sid}').status_code,200)
|
||||
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid}').status_code,404)
|
||||
self.assertEqual(self.a.get(f'/api/visitor/human/poll?sid={sid}&after=9999999999999999999999999').status_code,400)
|
||||
Reference in New Issue
Block a user