Compare commits

...
15 changed files with 268 additions and 17 deletions
+1 -1
View File
@@ -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/
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -34,11 +34,11 @@
### 人工客服、人工、客服、转人工、在线吗、上班、工作时间、营业时间、几点、周六、周日、星期六、星期日、星期天、礼拜、周末、双休、节假日、放假、休息
人工客服在线时间:**周一至周五 9:00—18:00**。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。
人工客服在线时间:**每天北京时间 8:00—22:00**,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。
### 电话、联系方式、怎么联系、联系你们、微信
可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。
可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。
---
+2 -2
View File
@@ -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":["退件","退回","退货","退运","怎么退运"],
+31 -2
View File
@@ -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
View File
@@ -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();
},
+3 -2
View File
@@ -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' : '';
+60
View File
@@ -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;
+14
View File
@@ -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')
+80
View File
@@ -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()
+4
View File
@@ -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',
+18
View File
@@ -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))
+8
View File
@@ -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)