支持免登录游客聊天并隔离历史与附件
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -48,6 +48,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()
|
||||
+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"){
|
||||
|
||||
+13
-29
@@ -1,4 +1,5 @@
|
||||
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");
|
||||
@@ -210,7 +211,7 @@ const I18N = {
|
||||
thinking: "正在思考中...",
|
||||
busyRetry: "服务繁忙,请稍后重试",
|
||||
sessionFail: "会话创建失败,请确认服务已启动",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。",
|
||||
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||||
offlinePrompt: "请输入您的留言:",
|
||||
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
|
||||
@@ -243,7 +244,7 @@ const I18N = {
|
||||
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
|
||||
clickRetry: "点击重试",
|
||||
welcomeTitle: "物流问题,随时为您解答",
|
||||
welcomeSub: "查询订单状态、了解专线报价,或联系人工客服。",
|
||||
welcomeSub: "咨询运费、时效或联系人工客服。订单查询需关联客户账号。",
|
||||
welcomeEyebrow: "中欧 · 英国物流服务",
|
||||
serviceScope: "中欧与英国专线咨询",
|
||||
serviceTag: "物流咨询服务",
|
||||
@@ -291,7 +292,7 @@ const I18N = {
|
||||
order_parent: "父订单号", order_updated: "来源更新时间",
|
||||
moreTracking: "展开全部轨迹",
|
||||
lessTracking: "收起较早轨迹",
|
||||
quickSubs: ["输入订单号、入仓码或 Shipment ID", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
|
||||
quickSubs: ["关联客户账号后查询订单与轨迹", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
|
||||
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
|
||||
hot: "热点问题",
|
||||
recent: "最近对话记录",
|
||||
@@ -320,7 +321,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 +354,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 +404,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 +1062,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 +1112,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,7 +1134,7 @@ 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();
|
||||
@@ -1176,7 +1159,7 @@ 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);
|
||||
@@ -1221,7 +1204,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 +1236,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;
|
||||
}
|
||||
@@ -1831,6 +1814,7 @@ if(window.visualViewport) window.visualViewport.addEventListener('resize', syncC
|
||||
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};
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -80,7 +80,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 +91,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;
|
||||
@@ -362,7 +363,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 +378,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 +387,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 +398,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 +455,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 +469,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 +477,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 +485,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 +495,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 +509,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);
|
||||
});
|
||||
@@ -639,7 +640,7 @@ test('recognized voice uses the normal AI and human send routes with a visible u
|
||||
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 +683,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);
|
||||
});
|
||||
+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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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