Compare commits

...
18 changed files with 759 additions and 86 deletions
+1
View File
@@ -1,4 +1,5 @@
**
!Dockerfile
!requirements.txt
!am.py
!rates_extra.py
+2 -1
View File
@@ -10,11 +10,12 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
| Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support |
| Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout |
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
| 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
View File
@@ -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/
+5 -1
View File
@@ -262,7 +262,11 @@ 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` 的取数实现即可保留聊天契约。
登录后可以直接问“我有多少个运单”“我的订单数量”“已完成的订单有多少”。统计每次按当前账号的授权范围查表,返回订单票数和其中已登记入仓码的票数;不要求先提供入仓码,也不沿用上一票订单作为统计范围。支持订单总量和明确的业务状态;其他筛选条件不冒充总量返回,头程订单完成不等于签收。统计会话与订单卡片一样绑定客户和范围,退出后隐藏,重新登录同一身份及范围后恢复。
点击“查入仓码”会打开查询框,填写号码后查询;游客先登录。回复及历史卡片中的入仓码也可点击,自动带入号码,可复制或查询最新轨迹。查询结果接在当前聊天中,草稿和待发送附件保留,人工接待中需要先返回 AI 问答。
首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
+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
+4 -1
View File
@@ -27,7 +27,8 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 知识检索 | `logistics_chat/knowledge.py`:`knowledge_list`、`search_knowledge` | [knowledge.md](knowledge.md) 与代码同步,程序不读取该文档 |
| 运费与附加费 | `logistics_chat/pricing.py`:`RATES`、`parse_shipping_intent`、`dims_to_cm`、`pick_rate`、`calc_freight`、`freight_reply`、`calc_warehouse_card`、`detect_surcharge`、`check_island` | `logistics_chat/rates_extra.py` 为唯一扩展数据源,根目录同名文件仅兼容转发 |
| 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史卡片保留兼容渲染 |
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL |
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`count_orders`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`、`count_orders`,不让模型拼 SQL |
| 查单交互 | `static/js/order_lookup.js`:`OrderLookup.create`;`chat.js`:`showOrderLookup`、`fillTrackCard`;`support.js`:`copyText` | 快捷入口及当前/历史卡片共用查询框;显式输入后才查单,保留聊天草稿和附件;复制共用安全回退 |
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
@@ -112,8 +113,10 @@ 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 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出立即清除当前可见订单内容和历史标题缓存,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
- 底部及欢迎卡片的查入仓码入口先确认客户登录,再打开 `OrderLookup`,不自动新建会话或发送缺少号码的问题。当前及历史订单卡片的号码也打开该查询框,保留真实的入仓码/订单号标签,支持单独复制和重新查询。提交使用既有 `/stream_chat` 并显示明确的用户问句,结果追加到当前会话;主输入框草稿及已上传附件不清空、不代发。人工接待、未加载历史或其他请求进行中时不抢占路由。切换话题/身份关闭弹窗,迟到的登录/状态/复制结果不得改动新上下文;弹窗跟随手机可视视口并将焦点还给触发按钮。
- 节点导出:`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` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
+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 -1
View File
@@ -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})
+99 -27
View File
@@ -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):
@@ -263,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:
+7 -2
View File
@@ -50,10 +50,13 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.customer-login-dialog label { display: block; margin: 14px 0 6px; font-size: 14px; }
.customer-login-dialog input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 16px; }
.customer-login-dialog input:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); }
#customer-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
#customer-error, #order-lookup-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
.customer-login-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
.customer-login-actions button { min-height: 44px; min-width: 76px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); }
#customer-submit { border-color: var(--brand); background: var(--brand); color: white; }
#customer-submit, #order-lookup-submit { border-color: var(--brand); background: var(--brand); color: white; }
.order-reference-field { display: flex; align-items: center; gap: 8px; }
.order-reference-field input { min-width: 0; flex: 1; }
#order-reference-copy { flex-shrink: 0; min-width: 60px; min-height: 44px; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--brand-text); }
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
.main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; }
@@ -150,6 +153,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.robot-message.track-card { width: min(560px, 100%); max-width: 100%; background: var(--surface); padding: 16px 20px; }
.track-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 14px; }
.track-no { font-weight: 650; font-size: 17px; letter-spacing: .02em; }
.order-reference { display: block; max-width: 100%; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--brand-text); font: inherit; text-align: left; overflow-wrap: anywhere; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
.order-reference:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 3px; }
.track-no span { display: block; font-size: 10px; letter-spacing: 0; font-weight: 400; color: var(--muted); margin-bottom: 3px; }
.track-status { padding: 4px 10px; color: var(--success); background: var(--success-soft); border-radius: 6px; font-size: 12px; white-space: nowrap; }
.track-node { position: relative; padding: 0 0 12px 22px; font-size: 13px; color: var(--secondary); }
+73 -37
View File
@@ -41,6 +41,8 @@ let historyRequestVersion = 0;
let followLatest = true;
let voiceInput = null;
let customerLogin = null;
let orderLookup = null;
let lookupRequestVersion = 0;
let customerRecovery = null;
let creatingConv = null;
let sendPreparing = false;
@@ -234,6 +236,11 @@ const I18N = {
customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码",
customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。",
customerLogoutFailed: "退出失败,请重试。",
lookupTitle: "查入仓码", lookupOrderTitle: "查询订单", lookupPlaceholder: "请输入入仓码",
lookupOrderPlaceholder: "请输入订单号", lookupSubmit: "查询轨迹", lookupSending: "查询中…",
lookupInvalid: "请输入完整的入仓码或订单号。", lookupFailed: "查询未发送成功,请重试。",
lookupBusy: "请等待当前操作完成后再查询。", lookupAIOnly: "请先返回 AI 问答,再查询订单轨迹。",
lookupAction: "查询或复制",
newChat: "新建对话",
clearAll: "清空全部历史",
history: "历史对话",
@@ -345,6 +352,11 @@ const I18N = {
customerUsernamePlaceholder: "Enter username", customerPasswordPlaceholder: "Enter password",
customerSigningIn: "Signing in…", customerLoginFailed: "Could not sign in. Please try again.",
customerLogoutFailed: "Could not sign out. Please try again.",
lookupTitle: "Track by inbound code", lookupOrderTitle: "Look up an order", lookupPlaceholder: "Enter an inbound code",
lookupOrderPlaceholder: "Enter an order number", lookupSubmit: "Track shipment", lookupSending: "Sending…",
lookupInvalid: "Enter a complete inbound code or order number.", lookupFailed: "Could not send the lookup. Please retry.",
lookupBusy: "Wait for the current action to finish before looking up an order.", lookupAIOnly: "Return to AI chat before looking up an order.",
lookupAction: "Look up or copy",
newChat: "New Chat",
clearAll: "Clear All History",
history: "History",
@@ -462,6 +474,10 @@ function applyLanguage(lang){
LANG = lang === "en" ? "en" : "zh";
const dict = I18N[LANG];
if(customerLogin) customerLogin.render();
if(orderLookup) orderLookup.render();
document.querySelectorAll('.order-reference').forEach(function(button){
button.setAttribute('aria-label',t('lookupAction')+' '+t(button.dataset.referenceLabel)+' '+button.textContent);
});
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
document.querySelectorAll("[data-i18n]").forEach(function(element){
const value = dict[element.dataset.i18n];
@@ -617,7 +633,7 @@ function welcomeHTML(){
const h = t("hotList");
const kinds = ["track", "quote", "issue", "human"];
const cards = q.map(function(question, index){
const action = index === 3 ? 'data-human-support' : `data-question="${escHtml(question)}"`;
const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(question)}"`;
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
}).join("");
return `
@@ -859,6 +875,7 @@ function ensureConv(){
function resetConversation(id){
flushDraft();
emojiPicker.close();
if(orderLookup) orderLookup.cancel();
if(voiceInput) voiceInput.cancel();
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
@@ -941,7 +958,16 @@ function fillTrackCard(el, card){
no.className = "track-no";
const inbound = isOrder && (card.fields || []).find(field=>field && field.key === 'inbound' && field.value);
const numberLabel = inbound ? 'order_inbound' : isOrder ? 'orderNo' : 'trackingNo';
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(inbound ? inbound.value : card.no);
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>";
if(isOrder){
const reference=document.createElement('button');
const value=String(inbound ? inbound.value : card.no), version=conversationVersion;
reference.type='button';reference.className='order-reference';reference.textContent=value;
reference.dataset.referenceLabel=numberLabel;
reference.setAttribute('aria-label',t('lookupAction')+' '+t(numberLabel)+' '+value);
reference.onclick=function(){if(version === conversationVersion) return showOrderLookup(value,inbound ? 'inbound' : 'order',reference);};
no.appendChild(reference);
}else no.innerHTML += escHtml(card.no);
const st = document.createElement("div");
st.className = "track-status";
st.textContent = card.status;
@@ -1121,12 +1147,33 @@ window.askNewTopic = function(words){
return doSend();
}
window.sendOrder = function(){
const no = prompt("请输入运单号:");
if(no && no.trim()){
inputDom.value = "我的运单号是 " + no.trim() + ",帮我查一下轨迹";
doSend();
}
window.sendOrder = function(){ return showOrderLookup(); };
function lookupBlocked(){
if(window.humanSid || humanRequestToken || humanClosing || humanSendingToken) return t('lookupAIOnly');
if(openingConv !== null) return t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading');
if(uploadPending || sendPreparing || window.__busy) return t('lookupBusy');
return '';
}
function sameCustomerContext(version,user){
return version === conversationVersion || (customerRecovery &&
customerRecovery.from === version && customerRecovery.to === conversationVersion &&
(!user || user.id === customerRecovery.customerId));
}
async function showOrderLookup(reference,kind,source){
const blocked=lookupBlocked();
if(blocked){setComposerStatus(blocked,true);return;}
const version=conversationVersion;
const requestVersion=++lookupRequestVersion;
function isCurrent(user){return requestVersion === lookupRequestVersion && sameCustomerContext(version,user);}
return customerLogin.require(function(user){
if(!isCurrent(user)) return;
const blocked=lookupBlocked();
if(blocked){setComposerStatus(blocked,true);return;}
orderLookup.open(reference,kind,source);
},isCurrent);
}
function updateHumanMode(){
@@ -1438,9 +1485,7 @@ function attachCustomerLogin(bubble, question){
const version = conversationVersion;
const convId = currentConvId;
function sameContext(user){
return version === conversationVersion || (customerRecovery &&
customerRecovery.from === version && customerRecovery.to === conversationVersion &&
(!user || user.id === customerRecovery.customerId));
return sameCustomerContext(version,user);
}
button.onclick = function(){
if(version !== conversationVersion) return;
@@ -1498,27 +1543,7 @@ function attachActions(bubble, rawText, question, showRetry){
copyBtn.textContent = t(copied ? 'copied' : 'copyFailed');
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000);
};
if(navigator.clipboard && navigator.clipboard.writeText){
return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback);
}else{
return fallback();
}
function fallback(){
const ta = document.createElement("textarea");
ta.value = plain;
ta.readOnly = true;
ta.style.position = "fixed";
ta.style.opacity = "0";
const previousFocus = document.activeElement;
document.body.appendChild(ta);
let copied = false;
try{ ta.focus({preventScroll:true}); ta.select(); copied = Boolean(document.execCommand("copy")); }catch(e){}
finally{
ta.remove();
if(previousFocus && previousFocus.isConnected) previousFocus.focus({preventScroll:true});
}
done(copied);
}
return SupportUI.copyText(plain).then(done);
};
bar.appendChild(copyBtn);
@@ -1604,7 +1629,8 @@ function renderFollowups(bubble){
bubble.appendChild(wrap);
}
async function doSend(retryText){
async function doSend(retryText, showQuestion){
if(showQuestion && lookupBlocked()) return false;
if(voiceInput && voiceInput.busy()){ setComposerStatus(t('voiceWait')); return false; }
const isRetry = typeof retryText === "string";
const sendWord = (isRetry ? retryText : inputDom.value).trim();
@@ -1640,9 +1666,9 @@ async function doSend(retryText){
}finally{
if(version === conversationVersion) sendPreparing = false;
}
if(version !== conversationVersion || humanRequestToken) return;
if(version !== conversationVersion || humanRequestToken || (showQuestion && window.humanSid)) return;
if(!isRetry || showQuestion) addMsg(fullContent, true);
if(!isRetry){
addMsg(fullContent, true);
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
if(attachmentVersion === uploadVersion) clearAttachment();
focusComposer();
@@ -1939,6 +1965,7 @@ function syncChatViewport(){
chatContainer.style.top = '';
}
if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top);
if(orderLookup) orderLookup.viewport(chatContainer.style.height, chatContainer.style.top);
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
autoGrow();
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
@@ -2016,7 +2043,7 @@ voiceInput = ChatVoice.create({
}
});
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); },
onOpen:function(){ if(voiceInput) voiceInput.cancel(); if(isNarrow()){sideOpen=false;renderSidebar();} syncChatViewport(); },
onChange:function(user,previous){
if(previous === undefined) return;
if(previous && (!user || user.id !== previous.id)){
@@ -2034,16 +2061,25 @@ customerLogin = CustomerLogin.create({fetch:fetch, text:t,
});
customerLogin.viewport(chatContainer.style.height,chatContainer.style.top);
customerLogin.refresh();
orderLookup = OrderLookup.create({text:t,canQuery:lookupBlocked,
onOpen:function(){emojiPicker.close();if(voiceInput) voiceInput.cancel();if(isNarrow()){sideOpen=false;renderSidebar();}syncChatViewport();},
query:function(reference,kind){return doSend((kind === 'order' ? '查询订单 ' : '查询入仓码 ')+reference,true);}
});
orderLookup.viewport(chatContainer.style.height,chatContainer.style.top);
window.addEventListener('resize', function(){
if(!isNarrow()) voiceInput.setMode(false);
});
document.querySelectorAll('[data-question-index]').forEach(function(button){
button.addEventListener('click', function(){
window.askNewTopic(t('quick')[Number(button.dataset.questionIndex)]);
const index=Number(button.dataset.questionIndex);
if(index === 0) return showOrderLookup(undefined,undefined,button);
return window.askNewTopic(t('quick')[index]);
});
});
chatDom.addEventListener('click', function(event){
const lookup=event.target.closest('button[data-order-lookup]');
if(lookup){showOrderLookup(undefined,undefined,lookup);return;}
const question = event.target.closest('button[data-question]');
if(question) window.askNewTopic(question.dataset.question);
const human = event.target.closest('button[data-human-support]');
+91
View File
@@ -0,0 +1,91 @@
// One lookup form for shortcuts and saved order cards; no draft or chat ownership changes.
var OrderLookup = {
create(options){
const overlay=document.getElementById('order-lookup');
const form=document.getElementById('order-lookup-form');
const input=document.getElementById('order-reference');
const label=document.getElementById('order-reference-label');
const title=document.getElementById('order-lookup-title');
const submit=document.getElementById('order-lookup-submit');
const cancel=document.getElementById('order-lookup-cancel');
const copy=document.getElementById('order-reference-copy');
const error=document.getElementById('order-lookup-error');
let kind='inbound', opener=null, busy=false, copying=false, copyState='', version=0, timer=null;
overlay.hidden=true;
function valid(value){
return /^[A-Za-z0-9][A-Za-z0-9_-]{5,63}$/.test(value) && (value.match(/[0-9]/g) || []).length >= 3;
}
function status(message){ error.textContent=message || ''; error.hidden=!message; }
function render(){
title.textContent=options.text(kind === 'inbound' ? 'lookupTitle' : 'lookupOrderTitle');
label.textContent=options.text(kind === 'inbound' ? 'order_inbound' : 'orderNo');
input.placeholder=options.text(kind === 'inbound' ? 'lookupPlaceholder' : 'lookupOrderPlaceholder');
submit.textContent=options.text(busy ? 'lookupSending' : 'lookupSubmit');
cancel.textContent=options.text('customerCancel');
copy.textContent=options.text(copyState || 'copy');
submit.disabled=busy; cancel.disabled=busy; input.disabled=busy;
copy.disabled=busy || copying || !valid(input.value.trim());
}
function clearTimer(){ if(timer !== null) clearTimeout(timer); timer=null; }
function close(){
version+=1;clearTimer();overlay.hidden=true;
busy=false;copying=false;copyState='';input.value='';status('');render();
if(opener && opener.isConnected) opener.focus({preventScroll:true});
opener=null;
}
function open(reference, referenceKind, source){
version+=1;clearTimer();kind=referenceKind === 'order' ? 'order' : 'inbound';
busy=false;copying=false;copyState='';
opener=source || document.activeElement;input.value=reference || '';status('');render();overlay.hidden=false;
if(options.onOpen) options.onOpen();
input.focus({preventScroll:true});
}
input.addEventListener('input',function(){ clearTimer();copyState='';status('');render(); });
copy.onclick=async function(){
const reference=input.value.trim(), token=version;
if(busy || copying || !valid(reference)) return;
copying=true;copyState='';status('');render();
const copied=await SupportUI.copyText(reference);
if(token !== version) return;
copying=false;
if(input.value.trim() === reference){
copyState=copied ? 'copied' : 'copyFailed';
if(!copied) status(options.text('copyFailed'));
timer=setTimeout(function(){copyState='';timer=null;render();},copied ? 1200 : 3000);
}
render();
};
form.addEventListener('submit',async function(event){
event.preventDefault();
if(busy) return;
const reference=input.value.trim(), token=version;
if(!valid(reference)){status(options.text('lookupInvalid'));input.focus({preventScroll:true});return;}
const blocked=options.canQuery();
if(blocked){status(blocked);return;}
busy=true;status('');render();
try{
const accepted=await options.query(reference,kind);
if(token !== version) return;
if(accepted) close();else status(options.text('lookupFailed'));
}catch(error){if(token === version) status(options.text('lookupFailed'));}
finally{if(token === version){busy=false;render();}}
});
cancel.onclick=function(){if(!busy) close();};
overlay.addEventListener('click',function(event){if(event.target === overlay && !busy) close();});
overlay.addEventListener('keydown',function(event){
if(event.key === 'Escape' && !busy){event.preventDefault();close();return;}
if(event.key === 'Tab'){
const controls=[input,copy,cancel,submit].filter(control=>!control.disabled);
event.preventDefault();
if(controls.length){
const index=controls.indexOf(document.activeElement);
controls[(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length].focus({preventScroll:true});
}
}
});
render();
return {open:open,cancel:close,render:render,viewport:function(height,top){
overlay.style.height=height || '';overlay.style.top=top || '';overlay.style.bottom=height ? 'auto' : '';
}};
}
};
+21
View File
@@ -1,5 +1,26 @@
// Shared controls for the customer page and service workspace.
var SupportUI = {
async copyText(text){
try{
if(navigator.clipboard && navigator.clipboard.writeText){
await navigator.clipboard.writeText(text);
return true;
}
}catch(error){}
const field = document.createElement('textarea');
field.value=text; field.readOnly=true;
field.style.position='fixed'; field.style.opacity='0';
const previous=document.activeElement;
document.body.appendChild(field);
try{
field.focus({preventScroll:true}); field.select();
return Boolean(document.execCommand('copy'));
}catch(error){ return false; }
finally{
field.remove();
if(previous && previous.isConnected) previous.focus({preventScroll:true});
}
},
initAttachments(container, label){
container.addEventListener('error', function(event){
const image = event.target;
+15
View File
@@ -82,9 +82,24 @@
</form>
</section>
</div>
<div id="order-lookup" class="customer-login-overlay" hidden>
<section class="customer-login-dialog" role="dialog" aria-modal="true" aria-labelledby="order-lookup-title">
<h2 id="order-lookup-title">查入仓码</h2>
<form id="order-lookup-form">
<label id="order-reference-label" for="order-reference">入仓码</label>
<div class="order-reference-field">
<input id="order-reference" name="reference" type="text" placeholder="请输入入仓码" maxlength="64" autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false" required>
<button id="order-reference-copy" type="button">复制</button>
</div>
<p id="order-lookup-error" role="alert" hidden></p>
<div class="customer-login-actions"><button id="order-lookup-cancel" type="button">取消</button><button id="order-lookup-submit" type="submit">查询轨迹</button></div>
</form>
</section>
</div>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
<script src="{{ url_for('static', filename='js/customer.js') }}"></script>
<script src="{{ url_for('static', filename='js/order_lookup.js') }}"></script>
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
+194 -13
View File
@@ -58,6 +58,7 @@ async function customerPage(pageOptions = {}){
closest(selector){
if(selector === 'button[data-question]' && this.dataset.question) return this;
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
if(selector === 'button[data-order-lookup]' && this.attributes['data-order-lookup'] != null) return this;
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
}
remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
@@ -106,6 +107,7 @@ async function customerPage(pageOptions = {}){
}
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/customer/session' ? {ok:true,authenticated:Boolean(pageOptions.customer),user:pageOptions.customer || null}
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
: url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
@@ -113,7 +115,7 @@ async function customerPage(pageOptions = {}){
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'visitor.js', 'customer.js', 'drafts.js', 'voice.js', 'chat.js']){
for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
@@ -270,7 +272,7 @@ test('switching topics aborts an upload and does not erase the new conversation
});
app.element('upload-input').files=[{name:'old.png'}];
app.element('upload-input').onchange();
await app.click(app.quickButtons[0]);
await app.click(app.quickButtons[1]);
await flush();
assert.equal(aborted,true);
assert.equal(app.element('upload-btn').disabled,false);
@@ -308,7 +310,7 @@ test('clearing history blocks new conversations until its timed request settles'
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
await app.runTimers(10000);
assert.match(app.element('historyWrap').textContent,/保留历史/);
await app.click(app.quickButtons[0]);
await app.click(app.quickButtons[1]);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
@@ -379,13 +381,16 @@ test('recalling an earlier reply preserves newer replies in the customer log', a
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
test('all three footer questions leave the human queue and send to separate AI conversations', async () => {
test('tracking does not interrupt human support while quote and exception shortcuts start AI topics', async () => {
const app = await customerPage();
for(const button of app.quickButtons) await app.click(button);
await app.click(app.quickButtons[0]);
assert.equal(app.context.window.humanSid,7);
assert.match(app.element('composer-status').textContent,/先返回 AI/);
for(const button of app.quickButtons.slice(1)) await app.click(button);
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 3);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
assert.deepEqual(requests.map(call => call.body.msg), ['按入仓码查轨迹', '欧洲海运专线报价', '包裹异常处理']);
assert.equal(requests.length, 2);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 2);
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '包裹异常处理']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
@@ -440,8 +445,8 @@ test('a repeated quick click while a new conversation is being created sends onl
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
app.quickButtons[0].click();
app.quickButtons[0].click();
app.quickButtons[1].click();
app.quickButtons[1].click();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
resolve(response({id:12, welcome:'welcome'}));
await flush();
@@ -452,7 +457,7 @@ test('a late conversation creation cannot replace the newest quick question', as
const app = await customerPage({human:false});
let resolve, count=0;
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
app.quickButtons[0].click();
app.quickButtons[1].click();
await app.click(app.quickButtons[2]);
resolve(response({id:21, welcome:'welcome'}));
await flush();
@@ -619,12 +624,188 @@ test('inbound codes are the primary card identity for live replies and restored
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('INB20261029001-01');await flush();
let heading=app.element('chatDom').querySelector('.track-no');
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
assert.match(heading.textContent,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.textContent,/FL202610290001/);
assert.match(app.element('chatDom').querySelector('.order-data-fields').textContent,/订单号.*FL202610290001/);
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
await app.click(app.element('historyWrap').children[0]);
heading=app.element('chatDom').querySelector('.track-no');
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
assert.match(heading.textContent,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.textContent,/FL202610290001/);
});
async function submitOrderLookup(app,reference){
app.element('order-reference').value=reference;
app.element('order-reference').listeners.input();
await app.element('order-lookup-form').listeners.submit({preventDefault(){}});await flush();
}
test('the lookup shortcut keeps the current conversation, draft and attachment until a real reference is submitted', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
await app.context.window.fillInput('原来的聊天');await flush();
app.element('input-text').value='尚未发送的草稿';app.element('input-text').listeners.input();
app.context.window.__pendingImg='/uploads/next-image.png';
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
await app.click(app.quickButtons[0]);
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('.left-panel').classList.contains('left-panel-hide'),false);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
assert.equal(app.element('input-text').value,'尚未发送的草稿');
await submitOrderLookup(app,'hello');
assert.match(app.element('order-lookup-error').textContent,/完整/);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
await submitOrderLookup(app,' INB202611040001 ');
const last=app.calls.filter(call=>call.url==='/stream_chat').at(-1);
assert.equal(last.body.msg,'查询入仓码 INB202611040001');
assert.equal(last.body.conv_id,1);
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat').length,1);
assert.equal(app.element('order-lookup').hidden,true);
assert.match(app.element('chatDom').textContent,/查询入仓码 INB202611040001/);
assert.equal(app.element('input-text').value,'尚未发送的草稿');
assert.equal(app.context.window.__pendingImg,'/uploads/next-image.png');
});
test('the guest shortcut signs in before opening lookup and sends no empty order question', async () => {
const app=await customerPage({human:false});
app.hooks['/api/customer/login']=()=>response({ok:true,user:{id:1,name:'admin'}});
app.element('input-text').value='游客原草稿';app.element('input-text').listeners.input();
await app.click(app.quickButtons[0]);
assert.equal(app.element('customer-login').hidden,false);
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat' || call.url==='/stream_chat').length,0);
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('input-text').value,'游客原草稿');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
await app.click(app.element('order-lookup-cancel'));
assert.equal(app.element('input-text').value,'游客原草稿');
});
test('live and saved inbound references open a prefilled form and copy only the code', async () => {
const copied=[];
const app=await customerPage({human:false,customer:{id:1,name:'admin'},history:[{id:9,title:'历史订单',ctime:'now'}],
clipboard:{writeText:async value=>{copied.push(value);}}});
const card={kind:'order',no:'FL202611040001',status:'履约中',fields:[{key:'inbound',value:'INB202611040001'}],nodes:[],summary:'订单概要'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('INB202611040001');await flush();
await app.click(app.element('chatDom').querySelector('.order-reference'));
assert.equal(app.element('order-reference').value,'INB202611040001');
await app.click(app.element('order-reference-copy'));
assert.deepEqual(copied,['INB202611040001']);
assert.equal(app.element('order-reference-copy').textContent,'已复制');
await app.click(app.element('order-lookup-cancel'));
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
await app.click(app.element('historyWrap').children[0]);
await app.click(app.element('chatDom').querySelector('.order-reference'));
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card:{...card,nodes:[{time:'2026-11-04 12:00',text:'最新物流节点',place:''}]}})+'\n\ndata: {"d":1}\n\n']);
await submitOrderLookup(app,'INB202611040001');
const last=app.calls.filter(call=>call.url==='/stream_chat').at(-1);
assert.equal(last.body.conv_id,9);assert.equal(last.body.msg,'查询入仓码 INB202611040001');
assert.equal(app.element('chatDom').querySelectorAll('.track-card').length,2);
assert.match(app.element('chatDom').querySelectorAll('.track-card').at(-1).textContent,/最新物流节点/);
});
test('an order without an inbound code keeps its truthful order-number label and lookup kind', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
const card={kind:'order',no:'FL202611040001',status:'履约中',fields:[],nodes:[],summary:'订单概要'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput(card.no);await flush();
const header=app.element('chatDom').querySelector('.track-no');
assert.match(header.textContent,/订单号/);assert.doesNotMatch(header.textContent,/入仓码/);
await app.click(header.querySelector('.order-reference'));
assert.equal(app.element('order-reference-label').textContent,'订单号');
await submitOrderLookup(app,card.no);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').at(-1).body.msg,'查询订单 '+card.no);
});
test('lookup copy failure is visible and a retry succeeds without changing the typed code', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},copyResult:false,
clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
await app.click(app.quickButtons[0]);
app.element('order-reference').value='INB202611040001';app.element('order-reference').listeners.input();
await app.click(app.element('order-reference-copy'));
assert.equal(app.element('order-reference-copy').textContent,'复制失败');
assert.match(app.element('order-lookup-error').textContent,/复制失败/);
app.context.document.execCommand=()=>true;
await app.click(app.element('order-reference-copy'));
assert.equal(app.element('order-reference-copy').textContent,'已复制');
assert.equal(app.element('order-lookup-error').hidden,true);
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.context.document.body.children.length,0);
});
test('a duplicated lookup submit sends once and a failed creation keeps the reference editable', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});let release;
await app.click(app.quickButtons[0]);
app.hooks['/api/new_chat']=()=>new Promise(resolve=>{release=resolve;});
const pending=submitOrderLookup(app,'INB202611040001');await flush();
await app.element('order-lookup-form').listeners.submit({preventDefault(){}});
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat').length,1);
release(response({ok:false},503));await pending;
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.element('order-reference').disabled,false);
app.hooks['/api/new_chat']=()=>response({id:11,welcome:'welcome'});
await submitOrderLookup(app,'INB202611040001');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
});
test('lookup does not stop an active AI reply or clear a pending draft', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
app.hooks['/stream_chat']=()=>({ok:true,body:{getReader:()=>({read:()=>new Promise(()=>{})})}});
await app.context.window.fillInput('正在回复的问题');await flush();
app.element('input-text').value='下一条草稿';app.element('input-text').listeners.input();
await app.click(app.quickButtons[0]);
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.context.window.__busy,true);
assert.match(app.element('composer-status').textContent,/等待当前操作/);
assert.equal(app.element('input-text').value,'下一条草稿');
await app.click(app.element('send-button'));
});
test('a late shortcut sign-in cannot open a private lookup after the user changes topics', async () => {
const app=await customerPage({human:false});let release;
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;});
await app.click(app.quickButtons[0]);
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}});
await app.click(app.element('.new-chat-btn'));
app.element('input-text').value='新话题草稿';app.element('input-text').listeners.input();
release(response({ok:true,user:{id:1,name:'admin'}}));await signing;
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.element('input-text').value,'新话题草稿');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('welcome lookup uses the same form and follows the mobile keyboard viewport', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},width:390,viewportHeight:844});
const welcome=app.element('lookup-welcome');welcome.setAttribute('data-order-lookup','');
app.element('chatDom').listeners.click({target:welcome});await flush();
assert.equal(app.element('order-lookup').hidden,false);
await app.resizeViewport(430,1,80);
assert.equal(app.element('order-lookup').style.height,'430px');
assert.equal(app.element('order-lookup').style.top,'80px');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('a late repeated shortcut status response cannot erase the latest lookup input', async () => {
const user={id:1,name:'admin'}, app=await customerPage({human:false,customer:user}), releases=[];
app.hooks['/api/customer/session']=()=>new Promise(resolve=>{releases.push(resolve);});
const first=app.quickButtons[0].click(), second=app.quickButtons[0].click();await flush();
releases[1](response({ok:true,authenticated:true,user}));await second;await flush();
app.element('order-reference').value='INB202611040001';app.element('order-reference').listeners.input();
releases[0](response({ok:true,authenticated:true,user}));await first;await flush();
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('closing lookup restores the shortcut instead of reopening the composer keyboard', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},width:390});
const input=app.element('input-text');input.focus();
await app.click(app.quickButtons[0]);
await app.click(app.element('order-lookup-cancel'));
assert.equal(app.context.document.activeElement,app.quickButtons[0]);
assert.equal(input.focusCalls.length,1);
assert.equal(app.quickButtons[0].focusCalls.at(-1).preventScroll,true);
});
test('order details can expand without hiding the shipment timeline', async () => {
+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',
+155
View File
@@ -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()