Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17f355366 | ||
|
|
1a82894b6a | ||
|
|
6181760045 | ||
|
|
80bb27515c | ||
|
|
af7b56b14b | ||
|
|
a80895b75f |
@@ -1,4 +1,5 @@
|
||||
**
|
||||
!Dockerfile
|
||||
!requirements.txt
|
||||
!am.py
|
||||
!rates_extra.py
|
||||
|
||||
@@ -14,7 +14,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|
||||
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
|
||||
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
|
||||
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
||||
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
|
||||
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
|
||||
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
|
||||
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.py` |
|
||||
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
|
||||
|
||||
+2
-1
@@ -27,9 +27,10 @@ USER 10001:10001
|
||||
EXPOSE 5000
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=2).close()"]
|
||||
CMD ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--threads=8", "--channel-timeout=300", "--max-request-body-size=12582912", "--url-scheme=https", "--call", "deploy.wsgi:create_app"]
|
||||
CMD ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--threads=8", "--channel-timeout=300", "--max-request-body-size=12582912", "--url-scheme=https", "--trusted-proxy=*", "--trusted-proxy-count=1", "--trusted-proxy-headers=x-forwarded-for", "--call", "deploy.wsgi:create_app"]
|
||||
|
||||
FROM runtime AS test
|
||||
COPY Dockerfile ./
|
||||
COPY deploy/ ./deploy/
|
||||
COPY tests/ ./tests/
|
||||
COPY scripts/ ./scripts/
|
||||
|
||||
@@ -262,7 +262,9 @@ data: {"d": 1}
|
||||
.\.venv\Scripts\python.exe scripts/import_order_test_data.py --snapshot "D:\private\orders.snapshot.json" --db "D:\data\history.db"
|
||||
```
|
||||
|
||||
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order` 的取数实现即可保留聊天契约。
|
||||
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order`、`count_orders` 的取数实现即可保留聊天契约。
|
||||
|
||||
登录后可以直接问“我有多少个运单”“我的订单数量”“已完成的订单有多少”。统计每次按当前账号的授权范围查表,返回订单票数和其中已登记入仓码的票数;不要求先提供入仓码,也不沿用上一票订单作为统计范围。支持订单总量和明确的业务状态;其他筛选条件不冒充总量返回,头程订单完成不等于签收。统计会话与订单卡片一样绑定客户和范围,退出后隐藏,重新登录同一身份及范围后恢复。
|
||||
|
||||
首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Group=baiyue
|
||||
WorkingDirectory=/opt/baiyue-logistics-chat
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/opt/baiyue-logistics-chat/.venv/bin/python -m waitress --listen=127.0.0.1:5000 --threads=8 --channel-timeout=300 --max-request-body-size=12582912 --url-scheme=https --call deploy.wsgi:create_app
|
||||
ExecStart=/opt/baiyue-logistics-chat/.venv/bin/python -m waitress --listen=127.0.0.1:5000 --threads=8 --channel-timeout=300 --max-request-body-size=12582912 --url-scheme=https --trusted-proxy=127.0.0.1 --trusted-proxy-count=1 --trusted-proxy-headers=x-forwarded-for --call deploy.wsgi:create_app
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
TimeoutStopSec=90
|
||||
|
||||
@@ -112,8 +112,9 @@ tests/ # 按业务模块组织的回归
|
||||
- `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` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
|
||||
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。匿名 HTTP 查单在读取订单前返回登录引导;`g.customer_tenant_id` 由可信客户认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 登录后的订单数量查询由 `parse_count_query` 识别,`count_orders` 使用与单号查询相同的 `tenant_filter` 交集规则,必须显式提供非空客户范围;SQL 直接计算订单票数和已登记入仓码的订单票数,按需过滤明确的业务状态。无额外筛选时统计授权范围全部订单,不复用上一票单号;无法识别的附加条件不静默丢弃,也不交给模型猜数。成功统计返回的内部 `_customer_data` 标记由 `chat_service` 消费并移除,使纯文本统计同样在保存/发送前绑定会话客户和范围;不增加公开 SSE 字段或管理接口。
|
||||
- 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。
|
||||
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出后清除当前可见订单内容,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
|
||||
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。登录过期导致界面清空时,只允许同一客户重新通过历史归属校验后恢复原会话并续查;换账号、访问范围变化、历史加载失败或切换话题均不重放旧问题。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 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` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
|
||||
@@ -124,7 +125,7 @@ tests/ # 按业务模块组织的回归
|
||||
- 手机语音由 `voice.js` 管理按住、松开、上滑取消、60 秒上限及媒体轨道释放;切换会话、离开页面或权限/转写超时取消录音与待处理请求,迟到结果不能进入新会话,已有草稿不自动提交。`/api/speech/config` 仅返回可用性及限制,`/api/speech/transcribe` 有独立 5MB 请求限制并以 `MemoryAudioRequest` 将音频文件保持在内存中;音频不进入 uploads 或聊天历史。百度适配通过 FFmpeg 管道转换 16 kHz 单声道 PCM,校验真实解码长度后调用百度短语音识别,Token 按应用隔离缓存。可选 DashScope 适配和 DS 文字聊天凭据独立,密钥及上游原始错误不得返回客户端。识别结果走既有 AI/人工消息入口。H5 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。
|
||||
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
|
||||
- 游客由 `visitor.js` 用安全随机数生成本地 key,经 `/api/visitor/session` 换取 HttpOnly、SameSite Cookie,HTTPS 时设置 Secure;浏览器存储不可用时可用 Cookie 恢复。数据库 `chat_history.visitor_id` 仅存 key 的 SHA-256,历史增删查、SSE 上下文和 `visitor_uploads` 附件索引均按归属校验。旧数据归属为空时保留,不自动授予新访客。客户端公共人工接口为 `/api/visitor/human/{request,poll,send,close}`,校验会话归属、只接受 customer 消息并去掉内部备注;原 `/api/human/*` 留给受保护客服。客服附件用 `/api/staff/uploads/`,所有客户 JSON 与附件禁止共享缓存。
|
||||
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录和订单关联尚未实现,游客身份不能升级为订单权限。
|
||||
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录独立校验,游客身份不能升级为订单权限。
|
||||
|
||||
## 验证与维护
|
||||
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
|
||||
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。
|
||||
|
||||
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录与订单关联尚未接入,匿名查单不会读取订单数据。
|
||||
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录独立验证账号及订单范围,匿名查单不会读取订单数据。
|
||||
|
||||
限流和登录失败次数按客户端 IP 计算。Nginx 必须用 `$remote_addr` 覆盖 `X-Forwarded-For`;Waitress 只信任这一项代理头和最后一跳地址,不接受代理头里的 Host、协议或更早的地址。容器镜像在仅由本机 Nginx 接入、宿主端口保持回环绑定的前提下使用 `--trusted-proxy=*`,以兼容 Docker 网关地址;systemd 入口只信任 `127.0.0.1`。直接运行 `am.py` 不接受客户端自报的代理 IP。代理规则参见 [Waitress 文档](https://docs.pylonsproject.org/projects/waitress/en/stable/reverse-proxy.html)。
|
||||
|
||||
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
|
||||
|
||||
|
||||
+2
-2
@@ -34,11 +34,11 @@
|
||||
|
||||
### 人工客服、人工、客服、转人工、在线吗、上班、工作时间、营业时间、几点、周六、周日、星期六、星期日、星期天、礼拜、周末、双休、节假日、放假、休息
|
||||
|
||||
人工客服在线时间:**周一至周五 9:00—18:00**。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。
|
||||
人工客服在线时间:**每天北京时间 8:00—22:00**,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。
|
||||
|
||||
### 电话、联系方式、怎么联系、联系你们、微信
|
||||
|
||||
可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。
|
||||
可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -103,9 +103,10 @@ def create_chat_stream(msg, conv_id):
|
||||
if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']:
|
||||
order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'}
|
||||
if order_answer is not None:
|
||||
customer_data = order_answer.pop('_customer_data',False) or 'card' in order_answer
|
||||
|
||||
def track_stream():
|
||||
if 'card' in order_answer:
|
||||
if customer_data:
|
||||
from .customer_service import bind_order_conversation, current_customer_id
|
||||
if current_customer_id() and not bind_order_conversation(conv_id):
|
||||
yield sse({'e':'该会话暂时无法访问,请新建对话后重试。','d':1})
|
||||
|
||||
@@ -265,11 +265,11 @@ knowledge_list = [
|
||||
},
|
||||
{
|
||||
"keys":["人工客服","人工","客服","转人工","在线吗","上班","工作时间","营业时间","几点","周六","周日","星期六","星期日","星期天","礼拜","周末","双休","节假日","放假","休息"],
|
||||
"text":"人工客服在线时间:周一至周五 9:00—18:00。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。"
|
||||
"text":"人工客服在线时间:每天北京时间 8:00—22:00,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。"
|
||||
},
|
||||
{
|
||||
"keys":["电话","联系方式","怎么联系","联系你们","微信"],
|
||||
"text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
|
||||
"text":"可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
|
||||
},
|
||||
{
|
||||
"keys":["退件","退回","退货","退运","怎么退运"],
|
||||
|
||||
+130
-29
@@ -112,42 +112,111 @@ def import_tracking_records(connection, records, replace=False):
|
||||
return {'imported': imported, 'preserved': preserved, 'missing': missing}
|
||||
|
||||
|
||||
def tenant_filter(tenant_id=None, tenant_ids=None):
|
||||
"""Share the same server-side scope intersection for exact and aggregate reads."""
|
||||
tenant = get_setting('ORDER_DATA_TENANT_ID')
|
||||
if tenant_ids is not None:
|
||||
if not isinstance(tenant_ids,(tuple,list,set)) or not 0 < len(tenant_ids) <= 256:
|
||||
return None
|
||||
allowed = {value for value in tenant_ids if isinstance(value,str) and 0 < len(value.strip()) <= 128}
|
||||
if tenant_id is not None:
|
||||
allowed.intersection_update({str(tenant_id)})
|
||||
if tenant:
|
||||
allowed.intersection_update({str(tenant)})
|
||||
return (' AND tenant_id IN (' + ','.join('?' for _ in allowed) + ')', sorted(allowed)) if allowed else None
|
||||
if tenant_id is not None:
|
||||
if not str(tenant_id).strip() or (tenant and str(tenant_id) != str(tenant)):
|
||||
return None
|
||||
tenant = str(tenant_id)
|
||||
return (' AND tenant_id=?', [str(tenant)]) if tenant else ('', [])
|
||||
|
||||
|
||||
def lookup_order(reference, tenant_id=None, tenant_ids=None):
|
||||
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
|
||||
from .db import connect
|
||||
|
||||
connection = connect()
|
||||
connection.row_factory = sqlite3.Row
|
||||
scope = tenant_filter(tenant_id, tenant_ids)
|
||||
if scope is None:
|
||||
return []
|
||||
connection = None
|
||||
try:
|
||||
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_ids is not None:
|
||||
if not isinstance(tenant_ids,(tuple,list,set)) or not 0 < len(tenant_ids) <= 256:
|
||||
return []
|
||||
allowed = {value for value in tenant_ids if isinstance(value,str) and 0 < len(value.strip()) <= 128}
|
||||
if tenant_id is not None:
|
||||
allowed.intersection_update({str(tenant_id)})
|
||||
if tenant:
|
||||
allowed.intersection_update({str(tenant)})
|
||||
if not allowed:
|
||||
return []
|
||||
query += ' AND tenant_id IN (' + ','.join('?' for _ in allowed) + ')'
|
||||
values.extend(sorted(allowed))
|
||||
elif 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 and tenant_ids is None:
|
||||
query += ' AND tenant_id=?'
|
||||
values.append(str(tenant))
|
||||
connection = connect()
|
||||
connection.row_factory = sqlite3.Row
|
||||
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)' + scope[0]
|
||||
values = [reference] * 3 + scope[1]
|
||||
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
|
||||
except sqlite3.Error:
|
||||
raise OrderDataUnavailable from None
|
||||
finally:
|
||||
connection.close()
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
|
||||
|
||||
def count_orders(tenant_ids, status=None):
|
||||
"""Fresh totals require an explicit authorized scope; never fall back to all rows."""
|
||||
from .db import connect
|
||||
|
||||
empty = {'total':0, 'with_inbound':0}
|
||||
scope = tenant_filter(tenant_ids=tenant_ids if tenant_ids is not None else ())
|
||||
if scope is None or (status is not None and status not in STATUS_LABELS):
|
||||
return empty
|
||||
connection = None
|
||||
try:
|
||||
connection = connect()
|
||||
query = ("SELECT COUNT(*),COUNT(NULLIF(TRIM(inbound_code,char(9)||char(10)||char(13)||' '),'')) "
|
||||
'FROM order_test_data WHERE 1=1' + scope[0])
|
||||
values = scope[1]
|
||||
if status is not None:
|
||||
query += ' AND status=?'
|
||||
values = values + [status]
|
||||
total, with_inbound = connection.execute(query,values).fetchone()
|
||||
return {'total':total, 'with_inbound':with_inbound}
|
||||
except sqlite3.Error:
|
||||
raise OrderDataUnavailable from None
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
|
||||
|
||||
def parse_count_query(message):
|
||||
"""Recognize bounded total/status questions without ignoring unknown filters."""
|
||||
if is_lookup_help(message):
|
||||
return None
|
||||
noun = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b'
|
||||
count = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b'
|
||||
if not re.search(noun,message,re.I) or not re.search(count,message,re.I):
|
||||
return None
|
||||
if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I):
|
||||
return None
|
||||
remaining = message
|
||||
statuses = []
|
||||
for phrase, status in (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'),
|
||||
('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'),
|
||||
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT')):
|
||||
if phrase in remaining:
|
||||
statuses.append(status)
|
||||
remaining = remaining.replace(phrase,'')
|
||||
remaining = re.sub(count+'|'+noun,'',remaining,flags=re.I)
|
||||
remaining = re.sub(r'当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
|
||||
r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|'
|
||||
r'\b(?:what|is|of|do|i|have|are|there|my|in|all|the|currently)\b|[\s,,。.!!??、::]','',remaining,flags=re.I)
|
||||
return {'status':statuses[0] if statuses else None, 'unsupported':bool(remaining or len(set(statuses)) > 1)}
|
||||
|
||||
|
||||
def answer_count_query(intent, tenants):
|
||||
if not tenants:
|
||||
return order_login_response()
|
||||
if intent['unsupported']:
|
||||
return {'t':'暂不支持按这个条件统计。可以查询订单总数,或按草稿、已提交、履约中、已完成、已取消等业务状态统计。'}
|
||||
try:
|
||||
counts = count_orders(tenants,intent['status'])
|
||||
except OrderDataUnavailable:
|
||||
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
if intent['status']:
|
||||
text = f"您当前有 {counts['total']} 票业务状态为“{STATUS_LABELS[intent['status']]}”的订单"
|
||||
else:
|
||||
text = f"您当前共有 {counts['total']} 票订单"
|
||||
return {'t':text + f",其中 {counts['with_inbound']} 票已登记入仓码。", '_customer_data':True}
|
||||
|
||||
|
||||
def extract_reference(message):
|
||||
@@ -212,8 +281,37 @@ def build_card(row):
|
||||
return card
|
||||
|
||||
|
||||
def is_lookup_help(message):
|
||||
if re.search(r'是什么意思|指什么|怎么(?:查|操作|使用)|如何(?:查|操作|使用)|怎样(?:查|操作|使用)|'
|
||||
r'\bhow\s+(?:do|can|to)\b', message, re.I):
|
||||
return True
|
||||
if re.fullmatch(r'\s*(?:请问)?(?:物流轨迹|物流状态|订单状态|入仓码|入仓号)是什么[。?!?!\s]*', message):
|
||||
return True
|
||||
return bool(re.search(r'\bwhat\s+(?:is|are)\b', message, re.I) and
|
||||
not re.search(r'\b(?:my|this|that|our)\b', message, re.I))
|
||||
|
||||
|
||||
def is_tracking_request(message):
|
||||
"""Bounded tracking commands also work without an identifier or prior context."""
|
||||
question = re.sub(r'[\s,,。.!!??、::]', '', message)
|
||||
command = (r'(?:请问|请|麻烦)?(?:帮我)?(?:查(?:询|一下|下)?(?:物流(?:轨迹|进度|状态)?|轨迹|进度|货)|'
|
||||
r'(?:物流|轨迹|进度)(?:查询|查一下|查下))')
|
||||
personal = (r'(?:我的|(?:我)?(?:这|那)(?:票|个|单)|该)(?:货物?|包裹|快递|订单|运单)'
|
||||
r'(?:现在|目前)?(?:到哪(?:里)?了?|什么时候(?:能|会)?到|何时能?到|多久能?到|'
|
||||
r'签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?')
|
||||
english = (r'(?:where\s+(?:is|are)\s+my|track(?:\s+my)?|check(?:\s+my)?)\s+'
|
||||
r'(?:parcels?|packages?|shipments?|orders?)(?:\s+(?:status|progress))?|'
|
||||
r'what\s+is\s+my\s+(?:parcel|package|shipment|order)\s+(?:status|progress)')
|
||||
return bool(re.fullmatch(command+'|'+personal, question) or
|
||||
re.fullmatch(english, message.strip(' \t\r\n.!?'), re.I))
|
||||
|
||||
|
||||
def is_order_followup(message):
|
||||
"""Reuse an order only for an explicit reference or a short detail question."""
|
||||
if is_lookup_help(message):
|
||||
return False
|
||||
if is_tracking_request(message):
|
||||
return True
|
||||
if not re.search(ORDER_DETAIL, message):
|
||||
return False
|
||||
if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message):
|
||||
@@ -234,6 +332,9 @@ def is_order_followup(message):
|
||||
def answer_query(message, conv_id):
|
||||
references = extract_reference(message)
|
||||
tenants = customer_tenant_ids()
|
||||
count_intent = parse_count_query(message) if not references else None
|
||||
if count_intent is not None:
|
||||
return answer_count_query(count_intent,tenants)
|
||||
if references and not tenants:
|
||||
return order_login_response()
|
||||
if len(references) > 1:
|
||||
@@ -263,11 +364,11 @@ def answer_query(message, conv_id):
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if not reference:
|
||||
if re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
|
||||
if is_tracking_request(message) or (not is_lookup_help(message) and re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
|
||||
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
|
||||
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
|
||||
r'\b(?:my|find|lookup|track)\s+(?:orders?|shipments?)\b|\b(?:order|shipment)\s+(?:status|tracking|lookup)\b|'
|
||||
r'\b(?:track|lookup|find|search)\b.*\binbound\b|\binbound\s+(?:code|number)\s+(?:lookup|tracking|status)\b', message, re.I):
|
||||
r'\b(?:track|lookup|find|search)\b.*\binbound\b|\binbound\s+(?:code|number)\s+(?:lookup|tracking|status)\b', message, re.I)):
|
||||
return order_login_response() if not tenants else {'t':'请提供入仓码查询订单和物流轨迹。'}
|
||||
return None
|
||||
if not tenants:
|
||||
|
||||
+39
-4
@@ -41,6 +41,7 @@ let historyRequestVersion = 0;
|
||||
let followLatest = true;
|
||||
let voiceInput = null;
|
||||
let customerLogin = null;
|
||||
let customerRecovery = null;
|
||||
let creatingConv = null;
|
||||
let sendPreparing = false;
|
||||
let conversationVersion = 0;
|
||||
@@ -877,7 +878,7 @@ function resetConversation(id){
|
||||
|
||||
// 打开历史会话:还原整段对话,而不是把标题重发一遍
|
||||
let openingConv = null;
|
||||
function openConversation(id){
|
||||
function openConversation(id, afterLoad){
|
||||
if(openingConv === 'clearing') return;
|
||||
if(openingConv === id) return; // 防连点
|
||||
const version = resetConversation(id);
|
||||
@@ -895,6 +896,7 @@ function openConversation(id){
|
||||
chatDom.innerHTML = "";
|
||||
if(!msgs.length){
|
||||
chatDom.innerHTML = welcomeHTML();
|
||||
if(afterLoad) afterLoad(false);
|
||||
return;
|
||||
}
|
||||
let previousQuestion = '';
|
||||
@@ -917,10 +919,12 @@ function openConversation(id){
|
||||
});
|
||||
// 等渲染完再滚到底部
|
||||
requestAnimationFrame(function(){ scrollChatToEnd(true); });
|
||||
if(afterLoad) afterLoad(true);
|
||||
}).catch(function(){
|
||||
if(version !== conversationVersion) return;
|
||||
openingConv = 'failed';
|
||||
setComposerStatus(t('historyLoadFailed'), true);
|
||||
if(afterLoad) afterLoad(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1432,10 +1436,35 @@ function attachCustomerLogin(bubble, question){
|
||||
button.type = 'button'; button.className = 'customer-login-cta';
|
||||
button.textContent = t('customerQuery');
|
||||
const version = conversationVersion;
|
||||
const convId = currentConvId;
|
||||
function sameContext(user){
|
||||
return version === conversationVersion || (customerRecovery &&
|
||||
customerRecovery.from === version && customerRecovery.to === conversationVersion &&
|
||||
(!user || user.id === customerRecovery.customerId));
|
||||
}
|
||||
button.onclick = function(){
|
||||
customerLogin.require(async function(){
|
||||
if(version === conversationVersion && question) await retryAnswer(bubble, question);
|
||||
});
|
||||
if(version !== conversationVersion) return;
|
||||
customerLogin.require(async function(user){
|
||||
if(!sameContext(user) || !question) return;
|
||||
if(version === conversationVersion){ await retryAnswer(bubble, question); return; }
|
||||
// Expiry cleared the private screen. Re-open it through the owner-checked
|
||||
// endpoint before continuing a short followup under the same customer.
|
||||
const newerDraft = inputDom.value;
|
||||
customerRecovery = null;
|
||||
let restoredDraft;
|
||||
openConversation(convId, function(loaded){
|
||||
const draft = inputDom.value !== restoredDraft ? inputDom.value : newerDraft;
|
||||
if(!loaded){
|
||||
resetConversation(null); chatDom.innerHTML=welcomeHTML();
|
||||
setComposerStatus(t('historyLoadFailed'), true);
|
||||
}
|
||||
inputDom.value=draft; autoGrow();
|
||||
if(!loaded) return;
|
||||
addMsg(question, true);
|
||||
retryAnswer(bubble, question);
|
||||
});
|
||||
restoredDraft = inputDom.value;
|
||||
}, sameContext);
|
||||
};
|
||||
bubble.appendChild(button);
|
||||
}
|
||||
@@ -1991,7 +2020,13 @@ customerLogin = CustomerLogin.create({fetch:fetch, text:t,
|
||||
onChange:function(user,previous){
|
||||
if(previous === undefined) return;
|
||||
if(previous && (!user || user.id !== previous.id)){
|
||||
const from = conversationVersion;
|
||||
resetConversation(null); chatDom.innerHTML=welcomeHTML();
|
||||
customerRecovery = !user ? {from:from,to:conversationVersion,customerId:previous.id} : null;
|
||||
// Customer titles must disappear even if the next history request fails.
|
||||
historyRequestVersion += 1;
|
||||
historyList=[];
|
||||
renderSideHistory(); renderRecentChat();
|
||||
}
|
||||
loadHistory();
|
||||
},
|
||||
|
||||
@@ -111,10 +111,11 @@ var CustomerLogin = {
|
||||
window.addEventListener('focus',refresh);
|
||||
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
|
||||
render();
|
||||
return {open:open,refresh:refresh,render:render,require:async function(resume){
|
||||
return {open:open,refresh:refresh,render:render,require:async function(resume, canContinue){
|
||||
if(busy) return;
|
||||
await refresh();
|
||||
if(user){ await resume(); }else open(resume);
|
||||
if(canContinue && !canContinue(user)) return;
|
||||
if(user){ await resume(user); }else open(function(){ return resume(user); });
|
||||
},viewport:function(height,top){
|
||||
overlay.style.height=height || ''; overlay.style.top=top || '';
|
||||
overlay.style.bottom=height ? 'auto' : '';
|
||||
|
||||
@@ -1045,6 +1045,52 @@ test('invalid customer credentials leave the login form open without sending ano
|
||||
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
|
||||
});
|
||||
|
||||
test('reauthentication resumes an expired order followup only for the same customer and conversation', async () => {
|
||||
for(const scenario of [{customer:1},{customer:2},{customer:1,denied:true},
|
||||
{customer:1,edit:true},{customer:1,switched:true}]){
|
||||
const nextCustomer=scenario.customer;
|
||||
const app=await customerPage({human:false});let authenticated=false, customer=1;
|
||||
let releaseHistory;
|
||||
const user=()=>({id:customer,name:customer===1 ? 'admin' : 'other'});
|
||||
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated,user:authenticated ? user() : null});
|
||||
app.hooks['/api/customer/login']=()=>{authenticated=true;return response({ok:true,authenticated:true,user:user()});};
|
||||
app.hooks['/api/get_history']=()=>response(authenticated ? [{id:1,title:'订单',ctime:'now'}] : []);
|
||||
const history=scenario.denied ? [] : [{role:'user',content:'INBOUND20260001'},
|
||||
{role:'assistant',content:'已查询的订单轨迹'}];
|
||||
app.hooks.messages=()=>scenario.edit || scenario.switched
|
||||
? new Promise(resolve=>{releaseHistory=()=>resolve(response(history));}) : response(history);
|
||||
app.hooks['/stream_chat']=()=>streamResponse([`data: ${JSON.stringify(authenticated ? {t:'订单轨迹结果'} : {t:'请先登录',login_required:true})}\n\ndata: {"d":1}\n\n`]);
|
||||
await app.click(app.element('customer-account'));
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
|
||||
await app.context.window.fillInput('INBOUND20260001');await flush();
|
||||
authenticated=false;
|
||||
await app.context.window.fillInput('现在到哪了?');await flush();
|
||||
await app.click(app.element('chatDom').querySelector('.customer-login-cta'));
|
||||
assert.equal(app.element('customer-login').hidden,false);
|
||||
assert.doesNotMatch(app.element('chatDom').textContent,/订单轨迹结果/);
|
||||
app.element('input-text').value='下一条未发送';app.element('input-text').listeners.input();
|
||||
customer=nextCustomer;
|
||||
app.element('customer-username').value=customer===1 ? 'admin' : 'other';
|
||||
app.element('customer-password').value='123456';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();await flush();
|
||||
if(releaseHistory){
|
||||
if(scenario.switched) await app.click(app.element('.new-chat-btn'));
|
||||
app.element('input-text').value='恢复期间的新输入';app.element('input-text').listeners.input();
|
||||
releaseHistory();await flush();await flush();
|
||||
}
|
||||
const resumed=nextCustomer===1 && !scenario.denied && !scenario.switched;
|
||||
const sends=app.calls.filter(call=>call.url==='/stream_chat');
|
||||
assert.equal(sends.length,resumed ? 3 : 2);
|
||||
if(resumed){
|
||||
assert.equal(sends[2].body.conv_id,1);assert.equal(sends[2].body.msg,'现在到哪了?');
|
||||
assert.match(app.element('chatDom').textContent,/订单轨迹结果/);
|
||||
assert.match(app.element('chatDom').textContent,/现在到哪了?/);
|
||||
}
|
||||
assert.equal(app.element('input-text').value,releaseHistory ? '恢复期间的新输入' : '下一条未发送');
|
||||
}
|
||||
});
|
||||
|
||||
test('a late login cannot replay an order into a newer conversation', async () => {
|
||||
const app=await customerPage({human:false});let release;
|
||||
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null});
|
||||
@@ -1076,6 +1122,20 @@ test('signing out clears visible order conversation and returns to guest mode',
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/customer/logout').length,1);
|
||||
});
|
||||
|
||||
test('signing out removes customer history titles even if refreshing guest history fails', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'INBOUND20260001 客户订单',ctime:'now'}]});
|
||||
app.hooks['/api/customer/login']=()=>response({ok:true,authenticated:true,user:{id:1,name:'admin'}});
|
||||
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
|
||||
await app.click(app.element('customer-account'));
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();
|
||||
assert.match(app.element('historyWrap').textContent,/INBOUND20260001/);
|
||||
app.hooks['/api/get_history']=()=>response({ok:false},503);
|
||||
await app.click(app.element('customer-account'));
|
||||
assert.doesNotMatch(app.element('historyWrap').textContent,/INBOUND20260001/);
|
||||
assert.equal(app.element('customer-account').textContent,'登录');
|
||||
});
|
||||
|
||||
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
let shown;
|
||||
|
||||
@@ -49,6 +49,20 @@ class CustomerTests(unittest.TestCase):
|
||||
forged=self.a.post('/stream_chat',json={'msg':'ORDER20260002','tenant_id':'tenant-b'},headers={'X-Tenant-Id':'tenant-b'})
|
||||
self.assertNotIn('99 kg',forged.get_data(as_text=True))
|
||||
|
||||
def test_common_tracking_requests_require_login_without_calling_the_model(self):
|
||||
questions = ('查物流', '帮我查一下物流', '物流查询', '查货', '帮我查下轨迹',
|
||||
'我的货到哪了', '我的包裹到哪里了?', '这票货什么时候到?',
|
||||
'Where is my parcel?', 'Track my package', 'What is my shipment status?')
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
for question in questions:
|
||||
with self.subTest(question=question):
|
||||
self.assertTrue(self.events(question)[0].get('login_required'))
|
||||
self.login()
|
||||
for question in questions:
|
||||
with self.subTest(authenticated_question=question):
|
||||
self.assertIn('请提供入仓码', self.events(question)[0]['t'])
|
||||
model.assert_not_called()
|
||||
|
||||
def test_credentials_are_hashed_and_session_is_opaque_secure_expiring_and_revoked(self):
|
||||
secure=self.app.test_client()
|
||||
secure.post('/api/visitor/session',json={'key':'3'*64},base_url='https://localhost')
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"""Exercise the server factory using disposable state/log paths and no model key."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from deploy import wsgi
|
||||
from logistics_chat import create_app
|
||||
from waitress.adjustments import Adjustments
|
||||
from waitress.proxy_headers import proxy_headers_middleware
|
||||
|
||||
|
||||
class DeploymentTests(unittest.TestCase):
|
||||
@@ -44,5 +49,80 @@ class DeploymentTests(unittest.TestCase):
|
||||
self.assertEqual(app.config['HOST'], '127.0.0.1')
|
||||
|
||||
|
||||
class ProxyIdentityTests(unittest.TestCase):
|
||||
def application(self, runtime):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
if runtime == 'docker':
|
||||
command = next(line[4:] for line in (root/'Dockerfile').read_text(encoding='utf-8').splitlines()
|
||||
if line.startswith('CMD '))
|
||||
args = json.loads(command)[3:]
|
||||
else:
|
||||
command = next(line[len('ExecStart='):] for line in
|
||||
(root/'deploy/baiyue-logistics-chat.service').read_text(encoding='utf-8').splitlines()
|
||||
if line.startswith('ExecStart='))
|
||||
args = shlex.split(command)[3:]
|
||||
options, _ = Adjustments.parse_args(args)
|
||||
options.pop('call'); options.pop('help')
|
||||
settings = Adjustments(**options)
|
||||
folder = tempfile.TemporaryDirectory(); self.addCleanup(folder.cleanup)
|
||||
data = Path(folder.name)
|
||||
app = create_app({'TESTING':True,'DATABASE':data/'history.db','UPLOAD_DIR':data/'uploads',
|
||||
'BACKUP_DIR':data/'backups','LOG_DIR':data/'logs','RATE_LIMIT_PER_MIN':1000})
|
||||
app.config['PROXY_TEST_PEER'] = '172.18.0.1' if runtime == 'docker' else '127.0.0.1'
|
||||
app.wsgi_app = proxy_headers_middleware(app.wsgi_app, trusted_proxy=settings.trusted_proxy,
|
||||
trusted_proxy_count=settings.trusted_proxy_count, trusted_proxy_headers=settings.trusted_proxy_headers)
|
||||
return app
|
||||
|
||||
def clients(self, app):
|
||||
clients = []
|
||||
for address in ('198.51.100.10', '198.51.100.20'):
|
||||
client = app.test_client()
|
||||
client.environ_base['REMOTE_ADDR'] = app.config['PROXY_TEST_PEER']
|
||||
client.environ_base['HTTP_X_FORWARDED_FOR'] = address
|
||||
client.post('/api/visitor/session', json={'key':str(len(clients)+1)*64})
|
||||
clients.append(client)
|
||||
app.extensions['rate_hits'].clear()
|
||||
return clients
|
||||
|
||||
def test_proxy_clients_do_not_share_request_rate_limits(self):
|
||||
for runtime in ('docker', 'systemd'):
|
||||
with self.subTest(runtime=runtime):
|
||||
app = self.application(runtime)
|
||||
a, b = self.clients(app)
|
||||
app.config['RATE_LIMIT_PER_MIN'] = 2
|
||||
self.assertEqual(a.get('/api/get_history').status_code, 200)
|
||||
self.assertEqual(a.get('/api/get_history').status_code, 200)
|
||||
self.assertEqual(a.get('/api/get_history').status_code, 429)
|
||||
self.assertEqual(b.get('/api/get_history').status_code, 200)
|
||||
|
||||
def test_proxy_clients_do_not_share_failed_login_budgets(self):
|
||||
for runtime in ('docker', 'systemd'):
|
||||
with self.subTest(runtime=runtime):
|
||||
app = self.application(runtime)
|
||||
a, b = self.clients(app)
|
||||
for _ in range(5):
|
||||
self.assertEqual(a.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code,403)
|
||||
self.assertEqual(a.post('/api/customer/login',json={'username':'admin','password':'123456'}).status_code,429)
|
||||
self.assertTrue(b.post('/api/customer/login',json={'username':'admin','password':'123456'}).json.get('authenticated'))
|
||||
|
||||
def test_only_the_last_forwarded_address_is_used_and_other_proxy_claims_are_ignored(self):
|
||||
from flask import request
|
||||
for runtime in ('docker', 'systemd'):
|
||||
with self.subTest(runtime=runtime):
|
||||
app = self.application(runtime)
|
||||
@app.get('/proxy-probe')
|
||||
def probe():
|
||||
return {'ip':request.remote_addr,'host':request.host,'scheme':request.scheme}
|
||||
response = app.test_client().get('/proxy-probe',base_url='https://chat.example.com',
|
||||
environ_overrides={'REMOTE_ADDR':app.config['PROXY_TEST_PEER']},headers={
|
||||
'X-Forwarded-For':'203.0.113.99, 198.51.100.10',
|
||||
'X-Forwarded-Host':'evil.example','X-Forwarded-Proto':'http'})
|
||||
self.assertEqual(response.json,{'ip':'198.51.100.10','host':'chat.example.com','scheme':'https'})
|
||||
if runtime == 'systemd':
|
||||
untrusted = app.test_client().get('/proxy-probe',environ_overrides={'REMOTE_ADDR':'203.0.113.1'},
|
||||
headers={'X-Forwarded-For':'198.51.100.10'})
|
||||
self.assertEqual(untrusted.json['ip'],'203.0.113.1')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -66,6 +66,10 @@ class GrayConfigTests(unittest.TestCase):
|
||||
pattern, body = match.groups()
|
||||
self.assertIn('auth_basic off;', body)
|
||||
self.assertIn('auth_basic "Baiyue gray acceptance";', config)
|
||||
self.assertIn('proxy_set_header X-Forwarded-For $remote_addr;',config)
|
||||
self.assertNotIn('$proxy_add_x_forwarded_for',config)
|
||||
compose=(Path(__file__).resolve().parents[1]/'deploy/compose.gray.yml').read_text(encoding='utf-8')
|
||||
self.assertIn('"127.0.0.1:${HOST_PORT:?HOST_PORT is required}:5000"',compose)
|
||||
for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages',
|
||||
'/api/customer/session','/api/customer/login','/api/customer/logout',
|
||||
'/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll',
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Order counts are fresh, customer-scoped facts with protected history."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app, db, order_data
|
||||
|
||||
|
||||
class OrderCountTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder = tempfile.TemporaryDirectory(); self.addCleanup(folder.cleanup)
|
||||
root = Path(folder.name)
|
||||
self.app = create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
|
||||
'CUSTOMER_DEMO_TENANT_ID':'tenant-a','ORDER_DATA_TENANT_ID':'','RATE_LIMIT_PER_MIN':1000})
|
||||
self.a, self.b = self.app.test_client(), self.app.test_client()
|
||||
for client,key in ((self.a,'1'*64),(self.b,'2'*64)):
|
||||
client.post('/api/visitor/session',json={'key':key})
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
order_data.import_records(connection,[
|
||||
{'tenant_id':'tenant-a','order_no':'COUNT20260001','inbound_code':'INB20260001','status':'PROCESSING'},
|
||||
{'tenant_id':'tenant-a','order_no':'COUNT20260002','status':'COMPLETED'},
|
||||
{'tenant_id':'tenant-b','order_no':'COUNT20260001','inbound_code':'INB20260099','status':'PROCESSING'},
|
||||
{'tenant_id':'ungranted','order_no':'COUNT20260003','status':'COMPLETED'}])
|
||||
connection.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) VALUES(?,?,?,?)',
|
||||
('other',self.app.config['CUSTOMER_DEMO_PASSWORD_HASH'],'Other','tenant-b'))
|
||||
connection.commit(); connection.close()
|
||||
|
||||
def login(self, client=None, username='admin'):
|
||||
self.assertTrue((client or self.a).post('/api/customer/login',json={'username':username,'password':'123456'}).json['ok'])
|
||||
|
||||
def events(self, message, cid=None, client=None):
|
||||
response = (client or self.a).post('/stream_chat',json={'msg':message,'conv_id':cid})
|
||||
self.assertEqual(response.status_code,200)
|
||||
return [json.loads(frame[6:]) for frame in response.get_data(as_text=True).split('\n\n') if frame.startswith('data: ')]
|
||||
|
||||
def test_guest_counts_require_login_before_reading_any_counts(self):
|
||||
with patch('logistics_chat.order_data.count_orders') as count, patch('logistics_chat.ai_client.post_completion') as model:
|
||||
for question in ('我有多少个运单','我的订单数量','我今天有多少订单','How many shipments do I have?'):
|
||||
with self.subTest(question=question):
|
||||
self.assertTrue(self.events(question)[0]['login_required'])
|
||||
count.assert_not_called(); model.assert_not_called()
|
||||
|
||||
def test_logged_in_counts_accept_common_wording_and_refresh_after_data_changes(self):
|
||||
self.login()
|
||||
cid = self.a.post('/api/new_chat').json['id']
|
||||
self.events('INB20260001',cid)
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
for question in ('我有多少个运单','我一共有几票订单?','请帮我统计一下我的订单数量',
|
||||
'我的运单有多少','运单数','我有多少入仓码','我想知道我有多少个运单',
|
||||
'能帮我查下我的运单总数吗','帮我统计下我名下的订单','How many shipments do I have?',
|
||||
'What is the number of my orders?'):
|
||||
with self.subTest(question=question):
|
||||
events = self.events(question,cid)
|
||||
self.assertEqual(events[0],{'t':'您当前共有 2 票订单,其中 1 票已登记入仓码。'})
|
||||
self.assertEqual(events[-1],{'d':1})
|
||||
model.assert_not_called()
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260004','inbound_code':'INB20260004'}])
|
||||
connection.commit(); connection.close()
|
||||
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
|
||||
|
||||
def test_business_status_filters_are_exact_and_unknown_conditions_never_return_the_total(self):
|
||||
self.login()
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
completed = self.events('我有多少个已完成的订单')[0]['t']
|
||||
self.assertIn('1 票业务状态为“头程订单已完成”',completed)
|
||||
self.assertIn('0 票已登记入仓码',completed)
|
||||
self.assertNotIn('签收',completed)
|
||||
self.assertIn('1 票业务状态为“履约中”',self.events('履约中的运单有多少')[0]['t'])
|
||||
self.assertIn('0 票业务状态为“已取消”',self.events('我有多少已取消订单')[0]['t'])
|
||||
for question in ('我今天有多少订单','我有多少已签收的运单','美国运单有多少',
|
||||
'我有多少未完成的订单','已完成和已取消各有多少订单'):
|
||||
with self.subTest(question=question):
|
||||
result = self.events(question)[0]
|
||||
self.assertIn('暂不支持按这个条件统计',result['t'])
|
||||
self.assertNotIn('您当前共有',result['t'])
|
||||
model.assert_not_called()
|
||||
for question in ('这个订单重量有多少','订单运费是多少','订单数量是什么意思'):
|
||||
self.assertIsNone(order_data.parse_count_query(question))
|
||||
|
||||
def test_multiple_grants_and_environment_restrictions_use_the_same_scope_as_lookup(self):
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=1 WHERE username='admin'")
|
||||
user = connection.execute("SELECT id FROM customer_users WHERE username='admin'").fetchone()[0]
|
||||
connection.executemany('INSERT INTO customer_tenant_grants(customer_id,tenant_id) VALUES(?,?)',
|
||||
[(user,'tenant-a'),(user,'tenant-b')])
|
||||
connection.commit(); connection.close()
|
||||
self.login()
|
||||
self.assertIn('共有 3 票订单,其中 2 票',self.events('我有多少个运单')[0]['t'])
|
||||
forged = self.a.post('/stream_chat',json={'msg':'我有多少个运单','tenant_id':'ungranted'},headers={'X-Tenant-Id':'ungranted'})
|
||||
self.assertIn('共有 3 票订单',forged.get_data(as_text=True))
|
||||
self.app.config['ORDER_DATA_TENANT_ID']='tenant-b'
|
||||
self.assertTrue(self.events('我有多少个运单')[0]['login_required'])
|
||||
self.login()
|
||||
self.assertIn('共有 1 票订单,其中 1 票',self.events('我有多少个运单')[0]['t'])
|
||||
with self.app.app_context():
|
||||
self.assertEqual(order_data.count_orders(('tenant-a','tenant-b')),{'total':1,'with_inbound':1})
|
||||
for scope in (None,(),[],['ungranted'],'tenant-b'):
|
||||
self.assertEqual(order_data.count_orders(scope),{'total':0,'with_inbound':0})
|
||||
|
||||
def test_count_history_is_bound_even_without_an_order_card(self):
|
||||
self.login(); cid = self.a.post('/api/new_chat').json['id']
|
||||
events = self.events('我有多少个运单',cid)
|
||||
self.assertNotIn('_customer_data',json.dumps(events))
|
||||
self.a.post('/api/customer/logout')
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertEqual(self.a.get('/api/get_history').json,[])
|
||||
self.a.delete('/api/clear_all')
|
||||
self.login(username='other')
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.login(self.b)
|
||||
self.assertEqual(self.b.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.login()
|
||||
self.assertTrue(any('共有 2 票订单' in m['content'] for m in self.a.get('/api/get_messages',query_string={'conv_id':cid}).json))
|
||||
|
||||
def test_count_failure_and_failed_history_binding_never_emit_facts(self):
|
||||
self.login(); cid = self.a.post('/api/new_chat').json['id']
|
||||
with patch('logistics_chat.customer_service.bind_order_conversation',return_value=False):
|
||||
events = self.events('我有多少个运单',cid)
|
||||
self.assertIn('无法访问',events[0]['e'])
|
||||
self.assertNotIn('共有',json.dumps(events))
|
||||
with patch('logistics_chat.db.connect',side_effect=sqlite3.OperationalError('private diagnostic')):
|
||||
with self.app.app_context():
|
||||
with self.assertRaises(order_data.OrderDataUnavailable):
|
||||
order_data.count_orders(('tenant-a',))
|
||||
with patch('logistics_chat.order_data.count_orders',side_effect=order_data.OrderDataUnavailable):
|
||||
events = self.events('我有多少个运单')
|
||||
self.assertIn('暂时无法读取',events[0]['t'])
|
||||
|
||||
def test_empty_data_and_blank_codes_are_counted_truthfully(self):
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute('UPDATE order_test_data SET inbound_code=? WHERE order_no=? AND tenant_id=?',
|
||||
(' \t\r\n','COUNT20260002','tenant-a'))
|
||||
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260005','inbound_code':'INB20260001'}])
|
||||
connection.commit(); connection.close()
|
||||
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
|
||||
with self.app.app_context():
|
||||
connection = db.connect()
|
||||
connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-a'")
|
||||
connection.commit(); connection.close()
|
||||
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 0 票订单,其中 0 票已登记入仓码。')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -203,6 +203,24 @@ class OrderDataTests(unittest.TestCase):
|
||||
self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no'])
|
||||
model.assert_not_called()
|
||||
|
||||
def test_tracking_aliases_reuse_only_the_current_orders_context(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
self.events(self.record['inbound_code'], cid)
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
for question in ('查物流', '帮我查一下物流', '物流查询', '帮我查下轨迹', '查一下进度',
|
||||
'我的货到哪了', '这票货什么时候到?', '这票状态是什么?', '这个目的仓是什么?',
|
||||
'Where is my parcel?', 'What is my shipment status?'):
|
||||
with self.subTest(question=question):
|
||||
self.assertEqual(self.events(question, cid)[0]['card']['no'], self.record['order_no'])
|
||||
model.assert_not_called()
|
||||
db.save_message(cid, 'user', '美国海运费用怎么算?')
|
||||
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
|
||||
self.assertIn('请提供入仓码', self.events('查物流', cid)[0]['t'])
|
||||
for question in ('物流轨迹是什么意思?', '物流查询怎么操作?', '怎么查物流?',
|
||||
'海运一般多久到?', '包裹丢了怎么办?', 'What is parcel tracking?', '入仓码是什么?'):
|
||||
with self.subTest(general_question=question):
|
||||
self.assertIsNone(order_data.answer_query(question, None))
|
||||
|
||||
def test_card_storage_and_model_context_contain_only_selected_facts(self):
|
||||
card = self.events(self.record['order_no'])[0]['card']
|
||||
text = card_to_text(CARD_PREFIX+json.dumps(card))
|
||||
|
||||
@@ -18,6 +18,14 @@ class RuleTests(unittest.TestCase):
|
||||
self.assertIn('21KG+', knowledge.search_knowledge('欧洲空运价格'))
|
||||
self.assertIsNone(knowledge.search_knowledge('没有相关关键词的测试'))
|
||||
|
||||
def test_support_knowledge_matches_the_daily_china_time_service_window(self):
|
||||
for question in ('人工客服工作时间', '周末人工客服上班吗', '怎么联系你们'):
|
||||
with self.subTest(question=question):
|
||||
answer = knowledge.search_knowledge(question)
|
||||
self.assertIn('每天北京时间 8:00—22:00', answer)
|
||||
self.assertNotIn('9:00', answer)
|
||||
self.assertNotIn('不上班', answer)
|
||||
|
||||
def test_sea_tier_boundary_and_minimum(self):
|
||||
self.assertEqual(pricing.calc_freight(100, 'sea')['price'], 12.3)
|
||||
self.assertEqual(pricing.calc_freight(101, 'sea')['price'], 10.3)
|
||||
|
||||
Reference in New Issue
Block a user