Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80bb27515c | ||
|
|
af7b56b14b | ||
|
|
a80895b75f |
+1
-1
@@ -27,7 +27,7 @@ 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 deploy/ ./deploy/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,7 +113,7 @@ tests/ # 按业务模块组织的回归
|
||||
- `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` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 客户认证使用独立 `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 +124,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)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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":["退件","退回","退货","退运","怎么退运"],
|
||||
|
||||
@@ -212,8 +212,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):
|
||||
@@ -263,11 +292,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',
|
||||
|
||||
@@ -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