diff --git a/AGENTS.md b/AGENTS.md index 7d57d5d..24cc836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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_.py` and `test_`. 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. diff --git a/README.md b/README.md index 167c906..8cc878d 100644 --- a/README.md +++ b/README.md @@ -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 字符,超过阈值最多尝试压缩两次。 diff --git a/deploy/activate-gray.sh b/deploy/activate-gray.sh index 52dae9a..84a1832 100644 --- a/deploy/activate-gray.sh +++ b/deploy/activate-gray.sh @@ -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 diff --git a/deploy/gray_config.py b/deploy/gray_config.py index 6f82667..57193a9 100644 --- a/deploy/gray_config.py +++ b/deploy/gray_config.py @@ -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; }} }} ''' diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example index b99a5c2..dccc88b 100644 --- a/deploy/nginx.conf.example +++ b/deploy/nginx.conf.example @@ -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; } } diff --git a/docs/architecture.md b/docs/architecture.md index 9e50dcb..7c581a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录和订单关联尚未实现,游客身份不能升级为订单权限。 ## 验证与维护 diff --git a/docs/deployment-linux.md b/docs/deployment-linux.md index 8f33bc0..6063a91 100644 --- a/docs/deployment-linux.md +++ b/docs/deployment-linux.md @@ -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)。 diff --git a/docs/deployment.md b/docs/deployment.md index b225624..66279b9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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)。 diff --git a/logistics_chat/__init__.py b/logistics_chat/__init__.py index ea6520b..2be35e5 100644 --- a/logistics_chat/__init__.py +++ b/logistics_chat/__init__.py @@ -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) diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index 6dc5bf7..ab75d98 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -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): diff --git a/logistics_chat/db.py b/logistics_chat/db.py index d0fc474..1937715 100644 --- a/logistics_chat/db.py +++ b/logistics_chat/db.py @@ -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, diff --git a/logistics_chat/file_service.py b/logistics_chat/file_service.py index 02e23c1..a9ad101 100644 --- a/logistics_chat/file_service.py +++ b/logistics_chat/file_service.py @@ -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} diff --git a/logistics_chat/history_service.py b/logistics_chat/history_service.py index f97dfa2..73b7271 100644 --- a/logistics_chat/history_service.py +++ b/logistics_chat/history_service.py @@ -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} diff --git a/logistics_chat/hooks.py b/logistics_chat/hooks.py index 10757ab..1530daf 100644 --- a/logistics_chat/hooks.py +++ b/logistics_chat/hooks.py @@ -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' diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index 810ad53..9c436ae 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -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'(? 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: diff --git a/logistics_chat/routes/__init__.py b/logistics_chat/routes/__init__.py index 4dad024..a5e3d84 100644 --- a/logistics_chat/routes/__init__.py +++ b/logistics_chat/routes/__init__.py @@ -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) diff --git a/logistics_chat/routes/business.py b/logistics_chat/routes/business.py index 99d1e26..d068b90 100644 --- a/logistics_chat/routes/business.py +++ b/logistics_chat/routes/business.py @@ -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) diff --git a/logistics_chat/routes/files.py b/logistics_chat/routes/files.py index b743009..fb69d66 100644 --- a/logistics_chat/routes/files.py +++ b/logistics_chat/routes/files.py @@ -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/') 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/') +def staff_uploaded_file(name): + response = send_from_directory(get_setting('UPLOAD_DIR'), name) + response.headers['Cache-Control'] = 'private, no-store' + return response diff --git a/logistics_chat/routes/visitor.py b/logistics_chat/routes/visitor.py new file mode 100644 index 0000000..c165fc5 --- /dev/null +++ b/logistics_chat/routes/visitor.py @@ -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/', 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) diff --git a/logistics_chat/visitor_service.py b/logistics_chat/visitor_service.py new file mode 100644 index 0000000..f3d7522 --- /dev/null +++ b/logistics_chat/visitor_service.py @@ -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() diff --git a/static/js/admin.js b/static/js/admin.js index 37826cb..97187d9 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -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, '客户附件'); - content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '↗ 查看附件'); + content = content.replace(/\[图片\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '客户附件'); + content = content.replace(/\[文件\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '↗ 查看附件'); const avatar = message.side === "customer" ? "客" : ''; div.innerHTML = '
'+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'
'+content+'
'; if(message.side === "agent"){ diff --git a/static/js/chat.js b/static/js/chat.js index 286d769..a546933 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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, diff --git a/static/js/support.js b/static/js/support.js index 67dfa5e..6e6dcea 100644 --- a/static/js/support.js +++ b/static/js/support.js @@ -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); diff --git a/static/js/visitor.js b/static/js/visitor.js new file mode 100644 index 0000000..8b1cf1a --- /dev/null +++ b/static/js/visitor.js @@ -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}; +})(); diff --git a/static/js/voice.js b/static/js/voice.js index bd54a56..f723ef9 100644 --- a/static/js/voice.js +++ b/static/js/voice.js @@ -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; diff --git a/templates/index.html b/templates/index.html index 10a1a53..45c122b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -44,7 +44,7 @@
中欧 · 英国物流服务

物流问题,随时为您解答

-

查询订单状态、了解专线报价,或联系人工客服。
从这里开始您的物流咨询。

+

咨询运费、时效或联系人工客服。
订单查询需登录并关联客户账号。

@@ -73,6 +73,7 @@ + diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 16288a6..40eb29c 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -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/); }); diff --git a/tests/frontend/visitor.test.cjs b/tests/frontend/visitor.test.cjs new file mode 100644 index 0000000..8146d0c --- /dev/null +++ b/tests/frontend/visitor.test.cjs @@ -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); +}); diff --git a/tests/test_api.py b/tests/test_api.py index 332dc22..a70363f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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'] diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 9e047b5..5224fb2 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -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) diff --git a/tests/test_dev_integration.py b/tests/test_dev_integration.py index ad24809..c9bd7e6 100644 --- a/tests/test_dev_integration.py +++ b/tests/test_dev_integration.py @@ -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'] diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 7a4f874..cb57c88 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -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) diff --git a/tests/test_gray_config.py b/tests/test_gray_config.py index 6179c5d..f8ecfbc 100644 --- a/tests/test_gray_config.py +++ b/tests/test_gray_config.py @@ -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: diff --git a/tests/test_order_data.py b/tests/test_order_data.py index 6bf0bfe..7fbfa2f 100644 --- a/tests/test_order_data.py +++ b/tests/test_order_data.py @@ -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) diff --git a/tests/test_speech.py b/tests/test_speech.py index cc6d89f..ebec96f 100644 --- a/tests/test_speech.py +++ b/tests/test_speech.py @@ -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), diff --git a/tests/test_visitor.py b/tests/test_visitor.py new file mode 100644 index 0000000..c03f23a --- /dev/null +++ b/tests/test_visitor.py @@ -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)