Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04f94ad665 | ||
|
|
fd1ec32dd6 | ||
|
|
9d07793a57 | ||
|
|
d26f6ecfd3 | ||
|
|
fef233344b | ||
|
|
783f930380 | ||
|
|
888f323b23 |
@@ -5,6 +5,15 @@
|
||||
DEEPSEEK_API_KEY=
|
||||
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
|
||||
ORDER_DATA_TENANT_ID=
|
||||
# Default front-end customer account: admin / 123456. It grants no staff access.
|
||||
CUSTOMER_DEMO_ENABLED=true
|
||||
CUSTOMER_DEMO_USERNAME=admin
|
||||
# Bind one explicit tenant, or leave empty to use the current local dataset grants.
|
||||
CUSTOMER_DEMO_TENANT_ID=
|
||||
# On initial association, grant the default account the tenants currently in the local table.
|
||||
CUSTOMER_DEMO_LOCAL_ORDERS=true
|
||||
# Optional password hash used only when creating the default account; not plaintext.
|
||||
# CUSTOMER_DEMO_PASSWORD_HASH=
|
||||
# Short-recording ASR is independent of DEEPSEEK_API_KEY. No credentials = voice input unavailable.
|
||||
ASR_PROVIDER=baidu
|
||||
BAIDU_ASR_API_KEY=
|
||||
|
||||
@@ -9,6 +9,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|
||||
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
|
||||
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
|
||||
| Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support |
|
||||
| Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout |
|
||||
| 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` |
|
||||
@@ -63,4 +64,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication relies on Nginx: protect staff and unknown routes by default and expose only the owner-checked customer allowlist. Never expose the upstream port publicly. Guest keys authorize their chat history, never customer orders; customer login/linking remains pending. Do not assign unowned legacy records to a new visitor.
|
||||
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; order and staff tables start empty. The requested default customer account is separate from staff and stores only a password hash. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication relies on Nginx: protect staff and unknown routes by default and expose only the owner-checked customer allowlist. Never expose the upstream port publicly. Guest keys authorize public chat history, never customer orders. Order facts and sensitive conversation history require the server-validated customer and tenant; never accept tenant claims from client input. Do not assign unowned legacy records to a new visitor.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- **知识库优先**:优先使用本地知识库回答,减少模型编造
|
||||
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
|
||||
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
|
||||
- **订单查询**:按订单号、入仓码、Shipment ID 查询本地订单测试表;展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
|
||||
- **订单查询**:直接输入入仓码查询订单与已有轨迹,卡片主显示入仓码;兼容订单号与 Shipment ID,展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
|
||||
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
|
||||
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
|
||||
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
|
||||
@@ -246,7 +246,11 @@ data: {"d": 1}
|
||||
|
||||
## 数据库与运行数据
|
||||
|
||||
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。匿名聊天不读取订单表;客户登录及关联将在后续接入,届时查询必须使用服务端验证的客户范围。`ORDER_DATA_TENANT_ID` 只是额外限制,不能代替客户身份。
|
||||
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。游客咨询无需登录,查询订单时引导客户登录;查询按服务端账号关联的客户范围过滤。`ORDER_DATA_TENANT_ID` 是额外限制,不能代替客户身份。
|
||||
|
||||
默认前台客户账号为 `admin / 123456`,与受 Nginx 保护的客服后台独立。默认按当前本地数据建立客户范围关联:单客户写入 `customer_users.tenant_id`,多客户写入 `customer_tenant_grants`,查询始终按服务端白名单过滤。首次关联完成后不自动加入新客户范围,同一客户下新增订单可直接查询。`CUSTOMER_DEMO_TENANT_ID` 可指定单个范围;`CUSTOMER_DEMO_LOCAL_ORDERS=false` 关闭多客户自动关联。账号保存在 `customer_users`,密码为 PBKDF2 哈希;已有账号启动时不覆盖,可直接维护哈希、`tenant_id`、`enabled` 及客户范围关联表。`CUSTOMER_DEMO_ENABLED=false` 禁用默认账号及其已有登录;真实客户账号不受该开关影响。`CUSTOMER_DEMO_USERNAME` 与 `CUSTOMER_DEMO_PASSWORD_HASH` 用于首次创建默认账号。
|
||||
|
||||
登录凭据通过独立 HttpOnly Cookie 及服务端 `customer_sessions` 校验,绑定当前访客,8 小时有效;退出后撤销当前登录。登录后自动继续刚才的订单问题;含订单事实的会话在退出后隐藏,只有相同访客重新登录同一客户和范围后才能恢复。真实用户认证接入时替换账号校验/关联层,继续使用内部客户 ID、可信客户范围和已有登录会话契约;MCP 数据查询仍通过 `lookup_order` 的客户范围过滤。
|
||||
|
||||
游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。
|
||||
|
||||
@@ -260,7 +264,7 @@ data: {"d": 1}
|
||||
|
||||
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--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` 的取数实现即可保留聊天契约。
|
||||
|
||||
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders`、`tickets` 和 `order_test_data` 十张表,并兼容已有数据,不预置账号或订单数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||
首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||
|
||||
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ server {{
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:{self.port};
|
||||
}}
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:{self.port};
|
||||
}}
|
||||
|
||||
@@ -47,7 +47,7 @@ server {
|
||||
location = / { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||
location ^~ /static/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||
location ^~ /uploads/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {
|
||||
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ tests/ # 按业务模块组织的回归
|
||||
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
|
||||
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
|
||||
- 客户界面不附加测试/演示声明、底部免责声明、键盘说明、快捷入口副标题或附件准备说明;草稿恢复和发送成功静默处理。加载、录音、人工排队状态及操作失败反馈保留,权限校验继续由服务端执行。
|
||||
- 客户查货以入仓码为主要入口,输入占位、快捷入口与查询追问均优先使用入仓码;订单卡片从 `fields.inbound` 取得主显示号码,内部订单号放在展开详情。`card.no` 仍保存内部订单号以兼容历史协议和上下文关联;未提供入仓码时保留准确的订单号标签,不生成替代入仓码。
|
||||
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
|
||||
- 客户文字草稿由 `drafts.js` 的 `ChatDrafts.create` 管理,脚本在 `visitor.js` 之后、`chat.js` 之前加载;localStorage 按访客 key 和会话分条存储,sessionStorage 仅记录当前标签页的活动会话。切换前同步保存,输入防抖及页面隐藏时保存;无持久存储时使用页面内存并提示。恢复历史的异步结果受会话版本约束,加载失败禁止继续发送,删除会话同步清除其草稿。草稿不含未发送附件,不跨设备同步。
|
||||
- 人工申请由服务端以 UTC+8 判断每日 08:00–22:00 营业状态,客户手机时区不参与判断。附件清理保留 `messages` 和 `human_messages` 引用的文件,仅删除超过 7 天的未引用临时文件;读取引用失败时停止清理。附件不存在或不属于当前游客均返回相同的私有、不可缓存 404 页面,破图在聊天中显示可读提示。
|
||||
@@ -110,7 +111,9 @@ tests/ # 按业务模块组织的回归
|
||||
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。
|
||||
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
|
||||
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
|
||||
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。客户身份尚未接入,匿名 HTTP 查单在读取订单前返回账号关联提示;`g.customer_tenant_id` 只允许后续可信认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_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 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出后清除当前可见订单内容,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
|
||||
- 节点导出:`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` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
|
||||
|
||||
@@ -5,11 +5,12 @@ def create_app(config=None):
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from .config import ROOT_DIR, default_config
|
||||
from . import db, file_service, hooks, visitor_service
|
||||
from . import customer_service, db, file_service, hooks, visitor_service
|
||||
from .routes import register_routes
|
||||
from .routes.speech import MemoryAudioRequest
|
||||
|
||||
@@ -21,8 +22,11 @@ def create_app(config=None):
|
||||
app.config.update(config)
|
||||
app.extensions['human_typing'] = {}
|
||||
app.extensions['rate_hits'] = defaultdict(list)
|
||||
app.extensions['customer_login_attempts'] = {}
|
||||
app.extensions['customer_login_lock'] = Lock()
|
||||
app.before_request(hooks.rate_limit)
|
||||
app.before_request(visitor_service.attach_visitor)
|
||||
app.before_request(customer_service.attach_customer)
|
||||
app.after_request(hooks.no_cache_html)
|
||||
app.teardown_appcontext(db.close_connections)
|
||||
register_routes(app)
|
||||
|
||||
@@ -85,6 +85,9 @@ def create_chat_stream(msg, conv_id):
|
||||
if conv_id:
|
||||
from .visitor_service import owns_conversation
|
||||
if not owns_conversation(conv_id):
|
||||
from .customer_service import needs_customer_login, order_login_response
|
||||
if needs_customer_login(conv_id):
|
||||
return (sse(order_login_response()) + sse({'d':1}), {})
|
||||
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
|
||||
order_answer = answer_query(msg, conv_id)
|
||||
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
|
||||
@@ -102,6 +105,11 @@ def create_chat_stream(msg, conv_id):
|
||||
if order_answer is not None:
|
||||
|
||||
def track_stream():
|
||||
if 'card' in order_answer:
|
||||
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})
|
||||
return
|
||||
save_message(conv_id, 'user', msg)
|
||||
content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False)
|
||||
if 'card' in order_answer else order_answer['t'])
|
||||
|
||||
@@ -29,6 +29,12 @@ def default_config():
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').strip(),
|
||||
'ASR_MODEL': str(os.environ.get('ASR_MODEL', local_config.get('ASR_MODEL')) or 'qwen3-asr-flash').strip(),
|
||||
'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) or '').strip(),
|
||||
'CUSTOMER_DEMO_ENABLED': str(os.environ.get('CUSTOMER_DEMO_ENABLED', local_config.get('CUSTOMER_DEMO_ENABLED', 'true'))).lower() in ('1','true','yes'),
|
||||
'CUSTOMER_DEMO_USERNAME': str(os.environ.get('CUSTOMER_DEMO_USERNAME', local_config.get('CUSTOMER_DEMO_USERNAME', 'admin')) or '').strip(),
|
||||
'CUSTOMER_DEMO_PASSWORD_HASH': str(os.environ.get('CUSTOMER_DEMO_PASSWORD_HASH', local_config.get('CUSTOMER_DEMO_PASSWORD_HASH',
|
||||
'pbkdf2:sha256:600000$vY5fe7GwJQRQbhKV$a4c4eeb96e2bda18bfa6b82d263c0cd61dead2e3f224f694e01fc497152df72f')) or ''),
|
||||
'CUSTOMER_DEMO_TENANT_ID': str(os.environ.get('CUSTOMER_DEMO_TENANT_ID', local_config.get('CUSTOMER_DEMO_TENANT_ID')) or '').strip(),
|
||||
'CUSTOMER_DEMO_LOCAL_ORDERS': str(os.environ.get('CUSTOMER_DEMO_LOCAL_ORDERS', local_config.get('CUSTOMER_DEMO_LOCAL_ORDERS', 'true'))).lower() in ('1','true','yes'),
|
||||
'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions',
|
||||
'MODEL_NAME': 'deepseek-chat',
|
||||
'MAX_CONTEXT': 4,
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Customer authentication and order scope, independent of staff authentication."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from flask import g, has_app_context, jsonify, request
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from .config import get_setting
|
||||
from .db import connect
|
||||
from .visitor_service import valid_key, visitor_id
|
||||
|
||||
COOKIE_NAME = 'baiyue_customer'
|
||||
SESSION_SECONDS = 8 * 3600
|
||||
|
||||
|
||||
def demo_tenant(connection):
|
||||
configured = get_setting('CUSTOMER_DEMO_TENANT_ID') or get_setting('ORDER_DATA_TENANT_ID')
|
||||
if configured:
|
||||
return configured
|
||||
rows = connection.execute("SELECT DISTINCT tenant_id FROM order_test_data WHERE tenant_id<>'' LIMIT 2").fetchall()
|
||||
return rows[0][0] if len(rows) == 1 else ''
|
||||
|
||||
|
||||
def user_tenants(connection, row):
|
||||
if row[4]:
|
||||
return (row[4],)
|
||||
rows = connection.execute('SELECT tenant_id FROM customer_tenant_grants WHERE customer_id=? ORDER BY tenant_id LIMIT 257',
|
||||
(row[0],)).fetchall()
|
||||
return tuple(item[0] for item in rows) if len(rows) <= 256 else ()
|
||||
|
||||
|
||||
def seed_local_grants(connection, row):
|
||||
if not row[6] or connection.execute('SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
|
||||
return
|
||||
if not row[4] and get_setting('CUSTOMER_DEMO_LOCAL_ORDERS') and not user_tenants(connection,row):
|
||||
connection.execute('INSERT OR IGNORE INTO customer_tenant_grants(customer_id,tenant_id) '
|
||||
"SELECT ?,tenant_id FROM order_test_data WHERE tenant_id<>'' GROUP BY tenant_id",(row[0],))
|
||||
if user_tenants(connection,row):
|
||||
connection.execute('UPDATE customer_users SET scope_initialized=1 WHERE id=?',(row[0],))
|
||||
|
||||
|
||||
def scope_key(tenants):
|
||||
return hashlib.sha256(json.dumps(sorted(tenants),ensure_ascii=False).encode()).hexdigest() if tenants else None
|
||||
|
||||
|
||||
def effective_tenants(tenants):
|
||||
configured = get_setting('ORDER_DATA_TENANT_ID')
|
||||
return tuple(value for value in tenants if not configured or value == configured)
|
||||
|
||||
|
||||
def current_scope_key():
|
||||
from .visitor_service import customer_tenant_ids
|
||||
return scope_key(customer_tenant_ids())
|
||||
|
||||
|
||||
def init_customer_tables(connection):
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS customer_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, display_name TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_demo INTEGER NOT NULL DEFAULT 0, scope_initialized INTEGER NOT NULL DEFAULT 0)''')
|
||||
if 'scope_initialized' not in [item[1] for item in connection.execute('PRAGMA table_info(customer_users)')]:
|
||||
connection.execute('ALTER TABLE customer_users ADD COLUMN scope_initialized INTEGER NOT NULL DEFAULT 0')
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS customer_sessions (
|
||||
token_hash TEXT PRIMARY KEY, customer_id INTEGER NOT NULL,
|
||||
visitor_id TEXT NOT NULL, auth_stamp TEXT NOT NULL, expires_at INTEGER NOT NULL)''')
|
||||
connection.execute('CREATE INDEX IF NOT EXISTS idx_customer_session_expiry ON customer_sessions(expires_at)')
|
||||
connection.execute('''CREATE TABLE IF NOT EXISTS customer_tenant_grants (
|
||||
customer_id INTEGER NOT NULL, tenant_id TEXT NOT NULL, PRIMARY KEY(customer_id,tenant_id))''')
|
||||
if get_setting('CUSTOMER_DEMO_ENABLED'):
|
||||
connection.execute('''INSERT OR IGNORE INTO customer_users
|
||||
(username,password_hash,display_name,tenant_id,is_demo) VALUES(?,?,?,?,1)''',
|
||||
(get_setting('CUSTOMER_DEMO_USERNAME'), get_setting('CUSTOMER_DEMO_PASSWORD_HASH'),
|
||||
get_setting('CUSTOMER_DEMO_USERNAME'), demo_tenant(connection)))
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE username=?',(get_setting('CUSTOMER_DEMO_USERNAME'),)).fetchone()
|
||||
seed_local_grants(connection,row)
|
||||
|
||||
|
||||
def auth_stamp(row, tenants):
|
||||
return hashlib.sha256((row[2] + '\n' + (scope_key(effective_tenants(tenants)) or '')).encode()).hexdigest()
|
||||
|
||||
|
||||
def allowed_user(row):
|
||||
return bool(row and row[5] and (not row[6] or (
|
||||
get_setting('CUSTOMER_DEMO_ENABLED') and row[1] == get_setting('CUSTOMER_DEMO_USERNAME'))))
|
||||
|
||||
|
||||
def attach_customer():
|
||||
g.customer = None
|
||||
g.customer_tenant_id = None
|
||||
g.customer_tenant_ids = ()
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not visitor_id() or not valid_key(token):
|
||||
return
|
||||
connection = connect()
|
||||
try:
|
||||
session = connection.execute('SELECT customer_id,auth_stamp FROM customer_sessions '
|
||||
'WHERE token_hash=? AND visitor_id=? AND expires_at>?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), visitor_id(), int(time.time()))).fetchone()
|
||||
if not session:
|
||||
return
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE id=?', (session[0],)).fetchone()
|
||||
tenants = effective_tenants(user_tenants(connection,row)) if row else ()
|
||||
if allowed_user(row) and secrets.compare_digest(auth_stamp(row,tenants), session[1]):
|
||||
g.customer = {'id':row[0], 'username':row[1], 'name':row[3]}
|
||||
g.customer_tenant_id = tenants[0] if len(tenants) == 1 else None
|
||||
g.customer_tenant_ids = tenants
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def current_customer_id():
|
||||
customer = getattr(g, 'customer', None) if has_app_context() else None
|
||||
return customer['id'] if customer else None
|
||||
|
||||
|
||||
def bind_order_conversation(conv_id):
|
||||
"""A conversation containing order facts also requires its customer login."""
|
||||
if not conv_id:
|
||||
return True
|
||||
connection = connect()
|
||||
try:
|
||||
result = connection.execute('UPDATE chat_history SET customer_id=?,customer_scope=? WHERE id=? AND visitor_id=? '
|
||||
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
|
||||
(current_customer_id(),current_scope_key(),conv_id,visitor_id(),current_customer_id(),current_scope_key()))
|
||||
connection.commit()
|
||||
return result.rowcount == 1
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def session_status():
|
||||
return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer}
|
||||
|
||||
|
||||
def order_login_response():
|
||||
if current_customer_id():
|
||||
return {'t':'当前账号尚未关联订单,请联系人工客服。'}
|
||||
return {'t':'请先使用客户账号登录并关联订单,再查询订单或物流轨迹。','login_required':True}
|
||||
|
||||
|
||||
def needs_customer_login(conv_id):
|
||||
if current_customer_id():
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
|
||||
'AND customer_id IS NOT NULL',(conv_id,visitor_id())).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def login(data, attempts, lock):
|
||||
if not isinstance(data, dict):
|
||||
return {'ok':False, 'error':'请输入账号和密码。'}, 400
|
||||
username, password = data.get('username'), data.get('password')
|
||||
if not isinstance(username, str) or not isinstance(password, str) or not 1 <= len(username) <= 80 or not 1 <= len(password) <= 256:
|
||||
return {'ok':False, 'error':'请输入有效的账号和密码。'}, 400
|
||||
now = int(time.time())
|
||||
# A separate short failed-login budget supplements the global request limiter.
|
||||
ip = request.remote_addr or 'unknown'
|
||||
with lock:
|
||||
failures = [stamp for stamp in attempts.get(ip, []) if stamp > now - 300]
|
||||
if len(attempts) > 2048:
|
||||
for key in list(attempts):
|
||||
if not attempts[key] or attempts[key][-1] <= now - 300:
|
||||
attempts.pop(key,None)
|
||||
if len(failures) >= 5:
|
||||
return {'ok':False, 'error':'登录尝试过多,请稍后重试。'}, 429
|
||||
attempts[ip] = failures + [now]
|
||||
connection = connect()
|
||||
try:
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE username=?', (username.strip(),)).fetchone()
|
||||
digest = row[2] if row else get_setting('CUSTOMER_DEMO_PASSWORD_HASH')
|
||||
try:
|
||||
valid = check_password_hash(digest, password)
|
||||
except (ValueError, TypeError):
|
||||
valid = False
|
||||
if not valid or not allowed_user(row):
|
||||
return {'ok':False, 'error':'账号或密码不正确。'}, 403
|
||||
with lock:
|
||||
attempts.pop(ip, None)
|
||||
if row[6] and not row[4] and not user_tenants(connection,row) and not connection.execute(
|
||||
'SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
|
||||
tenant = demo_tenant(connection)
|
||||
if tenant:
|
||||
connection.execute("UPDATE customer_users SET tenant_id=? WHERE id=? AND tenant_id=''", (tenant,row[0]))
|
||||
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||
'FROM customer_users WHERE id=?', (row[0],)).fetchone()
|
||||
seed_local_grants(connection,row)
|
||||
old_token = request.cookies.get(COOKIE_NAME)
|
||||
if valid_key(old_token):
|
||||
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
|
||||
(hashlib.sha256(old_token.encode()).hexdigest(), visitor_id()))
|
||||
token = secrets.token_hex(32)
|
||||
connection.execute('DELETE FROM customer_sessions WHERE expires_at<=?', (now,))
|
||||
connection.execute('INSERT INTO customer_sessions VALUES(?,?,?,?,?)',
|
||||
(hashlib.sha256(token.encode()).hexdigest(),row[0],visitor_id(),auth_stamp(row,user_tenants(connection,row)),now+SESSION_SECONDS))
|
||||
connection.commit()
|
||||
response = jsonify({'ok':True,'authenticated':True,'user':{'id':row[0],'username':row[1],'name':row[3]}})
|
||||
response.set_cookie(COOKIE_NAME,token,max_age=SESSION_SECONDS,httponly=True,secure=request.is_secure,samesite='Lax',path='/')
|
||||
return response
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def logout():
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if valid_key(token):
|
||||
connection = connect()
|
||||
try:
|
||||
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(),visitor_id()))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
response = jsonify({'ok':True,'authenticated':False,'user':None})
|
||||
response.delete_cookie(COOKIE_NAME,path='/',secure=request.is_secure,httponly=True,samesite='Lax')
|
||||
return response
|
||||
@@ -52,6 +52,17 @@ def init_db():
|
||||
cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL')
|
||||
if 'visitor_id' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT')
|
||||
if 'customer_id' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_id INTEGER')
|
||||
if 'customer_scope' not in cols:
|
||||
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT')
|
||||
if 'customer_tenant_id' in cols:
|
||||
# Preserve conversations created by the earlier local single-tenant login.
|
||||
import hashlib
|
||||
import json
|
||||
for cid, tenant in cur.execute('SELECT id,customer_tenant_id FROM chat_history WHERE customer_id IS NOT NULL').fetchall():
|
||||
scope = hashlib.sha256(json.dumps([tenant],ensure_ascii=False).encode()).hexdigest() if tenant else None
|
||||
cur.execute('UPDATE chat_history SET customer_scope=? WHERE id=?',(scope,cid))
|
||||
cur.execute('CREATE INDEX IF NOT EXISTS idx_chat_visitor ON chat_history(visitor_id, id)')
|
||||
cur.execute('''CREATE TABLE IF NOT EXISTS visitor_uploads (
|
||||
filename TEXT PRIMARY KEY, visitor_id TEXT NOT NULL, ctime TEXT NOT NULL
|
||||
@@ -136,6 +147,8 @@ def init_db():
|
||||
''')
|
||||
from .order_data import init_order_table
|
||||
init_order_table(conn)
|
||||
from .customer_service import init_customer_tables
|
||||
init_customer_tables(conn)
|
||||
old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall()
|
||||
for cid, title in old:
|
||||
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str()))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from .db import connect, now_str
|
||||
from .visitor_service import visitor_id, owns_conversation
|
||||
from .customer_service import current_customer_id, current_scope_key
|
||||
|
||||
|
||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。'
|
||||
@@ -24,7 +25,8 @@ def new_chat():
|
||||
def get_history():
|
||||
conn = connect()
|
||||
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history WHERE visitor_id=? '
|
||||
'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),)).fetchall()
|
||||
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?)) '
|
||||
'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),current_customer_id(),current_scope_key())).fetchall()
|
||||
conn.close()
|
||||
return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows]
|
||||
|
||||
@@ -57,12 +59,13 @@ def clear_all():
|
||||
conn = connect()
|
||||
cur = conn.cursor()
|
||||
owner = visitor_id()
|
||||
owned = 'SELECT id FROM chat_history WHERE visitor_id=?'
|
||||
owned = 'SELECT id FROM chat_history WHERE visitor_id=? AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))'
|
||||
scope = (owner,current_customer_id(),current_scope_key())
|
||||
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions '
|
||||
'WHERE conv_id IN ('+owned+'))', (owner,))
|
||||
cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', (owner,))
|
||||
cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', (owner,))
|
||||
cur.execute('DELETE FROM chat_history WHERE visitor_id=?', (owner,))
|
||||
'WHERE conv_id IN ('+owned+'))', scope)
|
||||
cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', scope)
|
||||
cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', scope)
|
||||
cur.execute('DELETE FROM chat_history WHERE id IN ('+owned+')', scope)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {'ok': True}
|
||||
|
||||
+205
-20
@@ -2,24 +2,209 @@
|
||||
|
||||
|
||||
knowledge_list = [
|
||||
# ==================== 一、订单 ====================
|
||||
{
|
||||
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家"],
|
||||
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓。"
|
||||
"keys": ["查询订单", "查订单", "订单查询", "订单在哪里", "我的订单在哪", "帮我查订单", "为什么查不到订单"],
|
||||
"text": (
|
||||
"请提供订单号、入仓号或关联运单号,并核验订单归属。"
|
||||
"查询结果以实际记录为准。查不到时,应核对号码、查询范围和数据同步情况,"
|
||||
"不能直接认定没有发货,也不能编造运输或清关结果。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys":["海运","船运","海派","海运快线","樱花快线"],
|
||||
"keys": ["订单号和运单号", "订单号是什么", "运单号是什么", "入仓号是什么", "入仓码是什么", "一票多个单号", "主子订单"],
|
||||
"text": (
|
||||
"订单号识别业务订单,入仓号用于收货归集,运单号识别运输任务或包裹。"
|
||||
"一票货可能因分包、分批或阶段换号产生多个编号,"
|
||||
"应根据实际关联记录分别确认,不能把换号当成新增货物。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["付款后没发货", "付款了没发货", "下单后没发货", "下单后多久发货", "修改订单", "订单能修改吗", "取消订单", "订单能取消吗", "更换渠道"],
|
||||
"text": (
|
||||
"下单或付款成功不代表已出运,还需核对收货、审核和运输安排。"
|
||||
"修改、取消或换渠道需根据当前操作阶段确认。"
|
||||
"变更申请不等于执行成功,是否产生费用按适用规则确认。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 二、客户信息 ====================
|
||||
{
|
||||
"keys": ["手机号查订单", "用手机号查", "客户编号查订单", "客户号查订单", "公司名称查订单", "用公司名查", "代客户查询", "帮别人查订单"],
|
||||
"text": (
|
||||
"可按系统支持的方式辅助查找,但必须核验身份、订单归属及代理权限。"
|
||||
"不能仅凭手机号或公司名称披露他人的完整订单、地址和联系方式。"
|
||||
"查询权限与修改权限需要分别确认。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["联系人填错", "联系人写错", "手机号填错", "电话填错", "邮箱填错", "客户资料修改", "客户信息修改"],
|
||||
"text": (
|
||||
"客户账户资料与本票收发货资料需要分别管理。"
|
||||
"修改账户资料不一定同步修改历史和在途订单。"
|
||||
"在途信息变更需确认相关操作方是否接受并生效。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["订单负责人", "谁负责这票", "客户待办", "没收到通知"],
|
||||
"text": (
|
||||
"负责人、客户待办和通知情况应根据实际记录确认。"
|
||||
"待办需说明具体事项、提交渠道及已确认的期限。"
|
||||
"没有联系或送达记录时,不能认定客户已收到通知;"
|
||||
"没有分配记录时,不能编造负责人和反馈时间。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 三、地址 ====================
|
||||
{
|
||||
"keys": ["收货地址怎么填", "地址怎么填写", "地址怎么写", "邮编不对", "邮编错误", "地址不完整", "地址不全", "缺少门牌"],
|
||||
"text": (
|
||||
"国际收货地址通常需要国家、州省、城市、街道、门牌、邮编、收件人及电话。"
|
||||
"缺失或不匹配可能影响派送,具体要求以目的地和承运商为准。"
|
||||
"补交资料后仍需确认是否修改成功。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["最终送到哪里", "最后送到哪里", "收货地址确认", "目的地址是什么", "提货地址和收货地址", "提货地址一样吗"],
|
||||
"text": (
|
||||
"提货、交仓、中转、清关和最终收货地址用途不同。"
|
||||
"最终目的地应查询本票已生效的地址,并按权限展示。"
|
||||
"到达中转仓或清关场所,不代表到达最终收货地址。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["修改收货地址", "改地址", "地址可以修改吗", "可以改地址吗", "更换收件人", "收件人可以改吗", "收件人改地址", "改送其他仓", "换仓地址", "FBA地址确认"],
|
||||
"text": (
|
||||
"改址需要核对货物阶段和执行方处理能力,"
|
||||
"未确认生效前不能告知新地址已启用。"
|
||||
"FBA或海外仓需核对当前货件完整地址及仓代码,"
|
||||
"不能仅按历史仓代码表安排配送。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 四、节点 ====================
|
||||
{
|
||||
"keys": ["节点是什么意思", "什么叫节点", "节点和轨迹", "节点是什么", "已提交是什么意思", "订单已提交", "已接单是什么意思"],
|
||||
"text": (
|
||||
"节点是某一步发生的事件,轨迹是多个运输事件组成的记录。"
|
||||
"已提交、审核通过、已接单属于业务处理状态,"
|
||||
"本身不能证明仓库收到实物或货物已经出运。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["已预报是什么意思", "预报是什么意思", "已入仓是什么意思", "入仓是什么意思", "已装柜是什么意思", "装柜是什么意思", "已出运是什么意思", "出运了吗"],
|
||||
"text": (
|
||||
"已预报通常表示信息已提交,不一定已交实物;"
|
||||
"已入仓表示仓库收货,不一定完成检查;"
|
||||
"已装柜表示完成装载,不代表已经离港。"
|
||||
"已出运需有实际离港、起飞、发车或交运依据。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["到港是什么意思", "到港了吗", "已提取是什么意思", "提取是什么意思", "已交末端是什么意思", "交末端是什么意思", "派送中是什么意思", "开始派送了吗"],
|
||||
"text": (
|
||||
"到港表示到达港口,之后可能仍需清关和交接。"
|
||||
"已提取、已交末端通常表示当地承运商接收货物,需要核对接收方。"
|
||||
"派送中表示进入配送环节,以上均不等于收件人签收。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 五、轨迹 ====================
|
||||
{
|
||||
"keys": ["完整物流轨迹", "物流轨迹怎么查", "运输轨迹怎么查", "有单号没有轨迹", "有单号查不到", "轨迹好几天没更新", "物流几天没更新", "轨迹不更新"],
|
||||
"text": (
|
||||
"轨迹应说明数据来源、覆盖范围及最后有效事件时间。"
|
||||
"暂无记录可能涉及未交接、扫描未回传或查询源异常,需要核实。"
|
||||
"长期没有更新不能直接认定丢失,没有数据的阶段不能自行补齐。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["换单号后怎么查", "单号换了", "新旧单号", "官网轨迹不一样", "轨迹不一致", "节点重复", "节点时间不一致"],
|
||||
"text": (
|
||||
"换号需保留新旧编号关联。"
|
||||
"不同来源或节点时间不一致时,应核对时区、原始事件、回传及更正记录。"
|
||||
"同步时间较新不一定代表事件较新,排查方向不能当作已确认原因。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["实时位置", "货物现在在哪", "预计到达时间", "预计什么时候到", "轨迹能证明签收吗", "轨迹能当到货证明吗", "模拟轨迹"],
|
||||
"text": (
|
||||
"节点地点通常表示某时经过该处,不等于实时定位。"
|
||||
"预计到达时间属于计划,应注明来源和条件。"
|
||||
"签收争议需核对POD等凭证,演示或模拟轨迹不能证明真实运输和交付。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 六、货物状态 ====================
|
||||
{
|
||||
"keys": ["履约中是什么意思", "履约中代表什么", "头程完成是什么意思", "头程完成了吗", "订单完成就是签收吗", "订单完成是不是签收"],
|
||||
"text": (
|
||||
"履约中表示订单正在执行,不能据此判断具体位置或清关阶段。"
|
||||
"当前系统的头程订单已完成,不等于末端已签收,"
|
||||
"也不能直接证明客户已收到货物。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["货物是否完好", "货物完好吗", "有没有少件", "少了几件", "货物受潮", "货物进水", "部分到货", "部分签收", "只到了一部分"],
|
||||
"text": (
|
||||
"货况需依据检查、交接件数和照片等记录确认,运输正常不能证明完好。"
|
||||
"部分到货或签收时,应分别核对总件数及各件进度。"
|
||||
"没有检查依据时说明尚未核实,不能用一件状态代表整票。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["已签收但没收到", "显示签收没收到", "签收人不认识", "谁签收的", "货物异常", "物流异常", "滞留是什么意思", "货物滞留"],
|
||||
"text": (
|
||||
"签收争议应核对签收人、交付地点、代收及POD,必要时调查。"
|
||||
"异常或滞留需明确发生环节和实际原因,不能直接等同海关扣货。"
|
||||
"调查前不能擅自认定货物丢失或客户已经收到。"
|
||||
)
|
||||
},
|
||||
|
||||
# ==================== 七、清关 ====================
|
||||
{
|
||||
"keys": ["清关了吗", "我的货清关了吗", "是否清关", "清关完成没有", "清关完成了吗", "有没有放行", "出口放行就是清关完成吗"],
|
||||
"text": (
|
||||
"查询清关需明确国家、出口或进口环节,并核对有效记录。"
|
||||
"出口放行不代表目的国进口清关完成,到港和已付款也不能证明放行。"
|
||||
"没有可靠数据时应说明暂未取得结果,不能把未知当作已完成或未完成。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["已申报是什么意思", "申报了吗", "清关中是什么意思", "现在清关中吗", "查验是不是没收", "被查验了吗", "清关为什么慢", "清关怎么还没好"],
|
||||
"text": (
|
||||
"申报动作、申报受理和放行需分别核实;"
|
||||
"清关中表示手续正在处理,查验不等于没收。"
|
||||
"具体等待原因以实际反馈为准,"
|
||||
"不能保证统一天数或一定放行,清关状态也不一定按固定顺序变化。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys": ["清关完为什么没派送", "清关了怎么没派送", "整票都清关了吗", "是不是整票放行", "税款交了吗", "税交了吗", "放行证明", "清关证明"],
|
||||
"text": (
|
||||
"资料、税费和放行状态需分别核实,客户付款不一定代表税款已缴。"
|
||||
"放行范围需核对申报单、批次及包裹,不能用一件代表整票。"
|
||||
"放行后可能仍需提货和末端交接;凭证提供需核对关联、授权和脱敏要求。"
|
||||
)
|
||||
},
|
||||
{
|
||||
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家","公司地址","办公地址","洛杉矶地址","纽约地址"],
|
||||
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓,公司地址位于:洛杉矶:10767 7th Street, Rancho Cucamonga, California 91730纽约:Door 13-14,75 Carter Dr Edison, New Jersey 08817义乌:浙江省义乌市福田街道稠州北路 1399 号 8613-8615 室深圳:深圳市南山区粤海街道海珠社区海德三道 1258 号卓越后海金融中心 1101。"
|
||||
},
|
||||
{
|
||||
"keys":["海运","船运","海派","海运快线","樱花快线","欧洲海运价格","海运多少钱","海运多久"],
|
||||
"text":"欧洲海运快线(双清/递延)单价:21KG+ 12.3元/kg,101KG+ 10.3元/kg,500KG+ 10.2元/kg,2000KG+ 10.0元/kg。时效:开船后38-45天提取。可走樱花快线(+0.3元/kg),递延价减1.7元/kg。主派送为UPS/DHL/DPD/CWE统配。"
|
||||
},
|
||||
{
|
||||
"keys":["铁路","中欧班列","班列","义乌铁路","火车"],
|
||||
"keys":["铁路","中欧班列","班列","义乌铁路","火车","铁路价格","铁路多久","铁路多少钱"],
|
||||
"text":"义乌-中欧铁路专线单价:21KG+ 15.5元/kg,100KG+ 和 1000KG+ 均为13.5元/kg。时效:发车后36-40天提取。VAT递延减1元/kg。只限送义乌仓,义乌装柜经阿拉山口/霍尔果斯出境。"
|
||||
},
|
||||
{
|
||||
"keys":["卡航","卡车","卡派"],
|
||||
"keys":["卡航","卡车","卡派","卡航价格","卡航多久","卡航多少钱"],
|
||||
"text":"中欧卡航单价:21KG+ 19.4元/kg,101KG+ 和 500KG+ 17.4元/kg,2000KG+ 及 4000KG+ 17.3元/kg。时效:发车后欧盟16-20天提取。递延减1.5元/kg,普卡时效18-22天。纯纺织品另加5元/kg。"
|
||||
},
|
||||
{
|
||||
"keys":["空运","空派","航空","欧洲空运"],
|
||||
"keys":["空运","空派","航空","欧洲空运","空运价格","空运多久","空运多少钱"],
|
||||
"text":"欧洲空运专线(空派)单价:21KG+ 39元/kg,45KG+ 38元/kg,71KG+ 37元/kg,101KG+ 36元/kg。时效:10-13天左右提取;快线8-13天,直飞快线12-15天。一票多件单件不足12KG按12KG计费。"
|
||||
},
|
||||
{
|
||||
@@ -27,55 +212,55 @@ knowledge_list = [
|
||||
"text":"英国专线(DPD)双清价格:英国海运21KG+ 12.8元/kg、101KG+/301KG+ 10.9元/kg,时效约40天提取;英国铁路21KG+ 18.0元/kg、101KG+ 16.2元/kg,发车后25-30天提取;中英卡航21KG+ 21.4元/kg、101KG+ 19.4元/kg,发车后20-22天提取。VAT自税/递延价更低(海运10.0/8.1、铁路15/13.2、卡航18.4/16.4)。北极航线英国海运25-28天,价格单询。卡航单件不超30kg、尺寸≤98×68×59cm。"
|
||||
},
|
||||
{
|
||||
"keys":["英国空运","英国","UK","伦敦"],
|
||||
"keys":["英国空运","英国空运价格","英国空运多久","英国空运多少钱","英国","UK","伦敦"],
|
||||
"text":"英国空运专线(双清)单价:22KG+ 48元/kg,51KG+ 44元/kg,101KG+ 42元/kg,时效8-10个自然日提取。VAT自税/递延为44/40/38元/kg,带电货47.5/42.5/41.5元/kg。拒收北爱尔兰、苏格兰高地等偏远地址。"
|
||||
},
|
||||
{
|
||||
"keys":["报价","价格","费用","计费","怎么收费","计费重量","多少钱","运费","单价"],
|
||||
"keys":["报价","价格","费用","计费","怎么收费","计费重量","计费重量怎么算","材积重","体积重","多少钱","运费","运费怎么算","单价"],
|
||||
"text":"运费计算方式:阶梯单价 × 计费重量。计费重量 = 实重与材积重取大值,材积 = 长×宽×高(cm)÷6000,即1CBM=167KG。各渠道最低起收21KG(英国空运22KG)。单件计费重不足13KG的渠道(铁路、卡航)按5元/kg加收超轻费;空运单件不足12KG按12KG计费。偏远地址加7元/kg,单票最低250元。价格不含税,税点以开出的发票为准。"
|
||||
},
|
||||
{
|
||||
"keys":["时效","多久","要几天","多长时间"],
|
||||
"keys":["时效","运输时效","多久","要几天","多长时间","几天到","什么时候到"],
|
||||
"text":"各渠道时效:欧洲海运快线开船后38-45天提取;中欧铁路发车后36-40天提取;中欧卡航发车后16-20天提取;欧洲空运10-13天提取;英国空运8-10个自然日提取。海关查验、罢工、节假日等不可抗力导致的时间不计入。"
|
||||
},
|
||||
{
|
||||
"keys":["揽收","上门取件","上门收货","交仓","交货"],
|
||||
"keys":["揽收","上门取件","上门收货","可以上门取货吗","交仓","交货","哪里交仓"],
|
||||
"text":"义乌、深圳(龙华观澜、宝安福园)、广州(石井)、泉州均可交仓,义乌仓10:00-21:00收货。欧洲空运交仓义乌+1元/kg,泉州、广州+0.5元/kg;海运、铁路、卡航泉州交货+0.5元/kg。"
|
||||
},
|
||||
{
|
||||
"keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","交仓地址","送仓"],
|
||||
"keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","深圳仓","广州仓","交仓地址","送仓"],
|
||||
"text":"义乌仓:义乌市稠江街道梦娜袜业德贸路东三门进,李东文 19558263692(10:00-21:00)。深圳龙华:龙华区观澜街道君子布社区张一村路黄背坑路1号B栋,张长印 18688937838。深圳宝安:福园二路九号海亨通物流B区C09-C14。广州:恒河沙仓储石井仓211,潘志红 13789206500。送货需打印交接清单随货交仓。"
|
||||
},
|
||||
{
|
||||
"keys":["理赔","货损","丢件","赔付","破损","压坏","索赔"],
|
||||
"keys":["理赔","货损","丢件","赔付","破损","压坏","索赔","怎么理赔","丢件怎么赔","理赔多久"],
|
||||
"text":"遗失赔付:未提取上网遗失按申报价值赔偿,最高不超过40元/kg,不退运费;已提取遗失按服务商标准,最高100USD/票。开查时间:交仓后7天未提取可申请开查,开查30天无果走索赔。签收后亚马逊未上架需2周内提出调查,包裹异常需1个月内提出。外包装无损的内件丢失不支持理赔,易碎品损坏不支持理赔。可自购保险,保费为货值千分之三(电池千分之五)。"
|
||||
},
|
||||
{
|
||||
"keys":["超轻费","计费重量不足","最低计费"],
|
||||
"keys":["超轻费","计费重量不足","最低计费","重量不够怎么收费"],
|
||||
"text":"单件计费重量不得低于13KG(空运12KG),可整票按平均13KG算,不足部分按5元/kg加收超轻费。例:单箱只有12KG,加收5×1=5元超轻费。欧洲热门仓卡派直送要求单件平均计费重12KG,不足按2元/kg加收派送费。"
|
||||
},
|
||||
{
|
||||
"keys":["偏远","偏远费","偏远地址"],
|
||||
"keys":["偏远","偏远费","偏远地址","偏远怎么查","偏远收费"],
|
||||
"text":"偏远地址加收7元/kg(不含燃油),单票偏远费最低消费250元/票。需要自行到UPS/DPD官网查询目的地址是否偏远,我司出货时不另行通知,以账单为准,偏远费6个月内有效。"
|
||||
},
|
||||
{
|
||||
"keys":["超重","超长","超大","尺寸限制"],
|
||||
"keys":["超重","超长","超大","尺寸限制","尺寸超了","超尺寸怎么收费"],
|
||||
"text":"欧洲空运最长边≤99cm、次长边≤75cm、实重<24KG。英国DPD单件≤30KG,尺寸100×70×60cm以内,超重或超长各加收330元/件。UPS单边最长≤99cm、次边≤75cm、单件<24KG,超出加收240元+100元。热门仓卡派直送:单件超40KG加收30元/件。"
|
||||
},
|
||||
{
|
||||
"keys":["敏感","带电","电池","液体","粉末","禁品","不能寄","违禁品","拒收","拒接"],
|
||||
"keys":["敏感","敏感货能发吗","带电","电池","液体","粉末","禁品","不能寄","什么不能寄","违禁品","拒收","拒接"],
|
||||
"text":"可接带标准箱100瓦以下非危电池/内电产品(C卡航、海运快线均可),资料齐全加1元/kg,资料不齐加2元/kg;手机、平板电脑加5元/kg。拒接:纯液体、纯粉末、药品、食品、易燃易爆、仿牌侵权、木制品(空运)、陶瓷手表等反倾销品、电子烟、管制刀具、枪支。电池第一个托收400元/件,第二个起100元/件,600元封顶。"
|
||||
},
|
||||
{
|
||||
"keys":["清关","递延","VAT","双清","包税","DDP"],
|
||||
"keys":["清关","清关资料","清关要什么资料","递延","VAT","VAT递延","双清","双清包税","包税","DDP"],
|
||||
"text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。"
|
||||
},
|
||||
{
|
||||
"keys":["报关","单独报关","报关费"],
|
||||
"keys":["报关","怎么报关","单独报关","报关费","报关多少钱"],
|
||||
"text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。"
|
||||
},
|
||||
{
|
||||
"keys":["亚马逊","FBA","海外仓","仓库配送","卡派"],
|
||||
"keys":["亚马逊","亚马逊仓","FBA","FBA头程","海外仓","仓库配送","卡派直送"],
|
||||
"text":"支持FBA头程送亚马逊仓,热门仓卡派直送(德国WRO5/HAJ1/DTM1/DTM2、法国CDG7/LIL8、意大利TRN3、西班牙BCN8、罗马尼亚EMAG等)。卡派直送优势:超重附加费少、不限尺寸重量、上架快、基本无丢件。法国、意大利、西班牙其他亚马逊仓不能超UPS标准。"
|
||||
},
|
||||
{
|
||||
@@ -87,7 +272,7 @@ knowledge_list = [
|
||||
"text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
|
||||
},
|
||||
{
|
||||
"keys":["退件","退回","退货","退运","改地址"],
|
||||
"keys":["退件","退回","退货","退运","怎么退运"],
|
||||
"text":"海运/铁路为散货拼柜,已装柜不接受退件和扣件。空运国外扣件费200欧/票,收件人改派地址最低200元/票。退回海外仓:德国境内15欧/件,欧盟境内25欧/件,操作费100元/票,5个工作日免仓储,超期1欧/kg/天。重派德国10欧/件、欧盟20欧/件。"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,7 +7,8 @@ from datetime import datetime
|
||||
|
||||
from .config import get_setting
|
||||
from .order_tracking import build_tracking
|
||||
from .visitor_service import customer_tenant_id
|
||||
from .visitor_service import customer_tenant_ids
|
||||
from .customer_service import order_login_response
|
||||
|
||||
|
||||
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
|
||||
@@ -17,13 +18,12 @@ FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_
|
||||
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
|
||||
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
|
||||
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
|
||||
FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目的国',
|
||||
FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipment ID', 'country':'目的国',
|
||||
'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数',
|
||||
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
|
||||
'parent':'父订单号', 'updated':'来源更新时间'}
|
||||
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
|
||||
ORDER_DETAIL = r'状态|进度|轨迹|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
|
||||
ORDER_LOGIN_TEXT = '订单查询将在客户账号登录并关联后开放。您可以先咨询运费、时效,或联系人工客服。'
|
||||
|
||||
|
||||
class OrderDataUnavailable(Exception):
|
||||
@@ -112,7 +112,7 @@ def import_tracking_records(connection, records, replace=False):
|
||||
return {'imported': imported, 'preserved': preserved, 'missing': missing}
|
||||
|
||||
|
||||
def lookup_order(reference, tenant_id=None):
|
||||
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
|
||||
|
||||
@@ -122,13 +122,25 @@ def lookup_order(reference, tenant_id=None):
|
||||
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_id is not None:
|
||||
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:
|
||||
if tenant and tenant_ids is None:
|
||||
query += ' AND tenant_id=?'
|
||||
values.append(str(tenant))
|
||||
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
|
||||
@@ -164,6 +176,8 @@ def build_card(row):
|
||||
def field(key, value):
|
||||
if value is not None and str(value).strip(): fields.append({'key': key, 'value': str(value)})
|
||||
field('inbound', row['inbound_code'])
|
||||
if row['inbound_code']:
|
||||
field('number', row['order_no'])
|
||||
field('shipment', row['shipment_id'])
|
||||
field('country', row['country_code'])
|
||||
field('warehouse', row['warehouse_code'])
|
||||
@@ -183,8 +197,9 @@ def build_card(row):
|
||||
for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else []
|
||||
nodes.reverse()
|
||||
status = STATUS_LABELS.get(row['status'], row['status'])
|
||||
summary = f"订单号 {row['order_no']},业务状态:{status}。"
|
||||
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields)
|
||||
inbound = row['inbound_code']
|
||||
summary = f"入仓码 {inbound},业务状态:{status}。" if inbound else f"订单号 {row['order_no']},业务状态:{status}。"
|
||||
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields if item['key'] != 'inbound')
|
||||
card = {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
|
||||
'nodes':nodes, 'summary':summary}
|
||||
tracking = build_tracking(row.get('tracking_json'))
|
||||
@@ -218,11 +233,11 @@ def is_order_followup(message):
|
||||
|
||||
def answer_query(message, conv_id):
|
||||
references = extract_reference(message)
|
||||
tenant = customer_tenant_id()
|
||||
if references and tenant is None:
|
||||
return {'t': ORDER_LOGIN_TEXT}
|
||||
tenants = customer_tenant_ids()
|
||||
if references and not tenants:
|
||||
return order_login_response()
|
||||
if len(references) > 1:
|
||||
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'}
|
||||
return {'t':'请一次提供一个入仓码,避免查错订单。'}
|
||||
reference = references[0] if references else None
|
||||
if not reference and conv_id and is_order_followup(message):
|
||||
from .db import connect
|
||||
@@ -248,19 +263,23 @@ def answer_query(message, conv_id):
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if not reference:
|
||||
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|物流轨迹|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
|
||||
return {'t':ORDER_LOGIN_TEXT if tenant is None else '请提供订单号、入仓码或 Shipment ID。'}
|
||||
if 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):
|
||||
return order_login_response() if not tenants else {'t':'请提供入仓码查询订单和物流轨迹。'}
|
||||
return None
|
||||
if tenant is None:
|
||||
return {'t': ORDER_LOGIN_TEXT}
|
||||
if not tenants:
|
||||
return order_login_response()
|
||||
try:
|
||||
matches = lookup_order(reference, tenant_id=tenant)
|
||||
matches = lookup_order(reference,tenant_id=tenants[0]) if len(tenants) == 1 else lookup_order(reference,tenant_ids=tenants)
|
||||
except OrderDataUnavailable:
|
||||
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
if not matches:
|
||||
return {'t':f'未找到“{reference}”对应的订单。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'}
|
||||
return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'}
|
||||
if len(matches) > 1:
|
||||
return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'}
|
||||
return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'}
|
||||
if re.search(r'运费|费用|多少钱|价格', message):
|
||||
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
|
||||
return {'card': build_card(matches[0])}
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
from . import admin, business, chat, files, history, human, pages, speech, visitor
|
||||
for module in (admin, business, chat, files, history, human, pages, speech, visitor):
|
||||
from . import admin, business, chat, customer, files, history, human, pages, speech, visitor
|
||||
for module in (admin, business, chat, customer, files, history, human, pages, speech, visitor):
|
||||
app.register_blueprint(module.bp)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Public, visitor-bound customer login endpoints. No staff privileges."""
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
from .. import customer_service
|
||||
|
||||
bp = Blueprint('customer', __name__)
|
||||
|
||||
|
||||
@bp.get('/api/customer/session')
|
||||
def session():
|
||||
return customer_service.session_status()
|
||||
|
||||
|
||||
@bp.post('/api/customer/login')
|
||||
def login():
|
||||
request.max_content_length = 2048
|
||||
try:
|
||||
return customer_service.login(request.get_json(silent=True), current_app.extensions['customer_login_attempts'],
|
||||
current_app.extensions['customer_login_lock'])
|
||||
except RequestEntityTooLarge:
|
||||
return {'ok':False,'error':'登录信息过长。'}, 413
|
||||
|
||||
|
||||
@bp.post('/api/customer/logout')
|
||||
def logout():
|
||||
return customer_service.logout()
|
||||
@@ -15,6 +15,7 @@ CUSTOMER_PATHS = {
|
||||
'/stream_chat', '/api/new_chat', '/api/get_history', '/api/get_messages', '/api/del_history',
|
||||
'/api/clear_all', '/api/upload', '/api/ticket', '/api/rate', '/api/feedback',
|
||||
'/api/speech/config', '/api/speech/transcribe',
|
||||
'/api/customer/session', '/api/customer/login', '/api/customer/logout',
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +28,7 @@ def visitor_id():
|
||||
|
||||
|
||||
def customer_tenant_id():
|
||||
# Future login adapters may set this only after validating a customer session.
|
||||
# Set only after validating a server-side customer session.
|
||||
# Visitor cookies, request parameters and headers are deliberately not accepted.
|
||||
value = getattr(g, 'customer_tenant_id', None) if has_app_context() else None
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
||||
@@ -36,6 +37,19 @@ def customer_tenant_id():
|
||||
return tenant if tenant and len(tenant) <= 128 else None
|
||||
|
||||
|
||||
def customer_tenant_ids():
|
||||
if not has_app_context():
|
||||
return ()
|
||||
# Also retain compatibility with a trusted single-tenant authentication adapter.
|
||||
single = customer_tenant_id()
|
||||
if single:
|
||||
return (single,)
|
||||
values = getattr(g,'customer_tenant_ids',())
|
||||
if not isinstance(values,(list,tuple)) or len(values) > 256:
|
||||
return ()
|
||||
return tuple(sorted(set(value for value in values if isinstance(value,str) and 0 < len(value.strip()) <= 128)))
|
||||
|
||||
|
||||
def attach_visitor():
|
||||
key = request.cookies.get(COOKIE_NAME)
|
||||
g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None
|
||||
@@ -65,25 +79,30 @@ def start_session(data):
|
||||
|
||||
|
||||
def owns_conversation(conv_id):
|
||||
from .customer_service import current_customer_id, current_scope_key
|
||||
owner = visitor_id()
|
||||
if not owner or not isinstance(conv_id, int) or isinstance(conv_id, bool) or not 0 < conv_id <= 9223372036854775807:
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=?',
|
||||
(conv_id, owner)).fetchone())
|
||||
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
|
||||
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
|
||||
(conv_id, owner, current_customer_id(),current_scope_key())).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def owns_human_session(sid):
|
||||
from .customer_service import current_customer_id, current_scope_key
|
||||
owner = visitor_id()
|
||||
if not owner or not isinstance(sid, int) or isinstance(sid, bool) or not 0 < sid <= 9223372036854775807:
|
||||
return False
|
||||
connection = connect()
|
||||
try:
|
||||
return bool(connection.execute('SELECT 1 FROM human_sessions s JOIN chat_history h ON h.id=s.conv_id '
|
||||
'WHERE s.id=? AND h.visitor_id=?', (sid, owner)).fetchone())
|
||||
'WHERE s.id=? AND h.visitor_id=? AND (h.customer_id IS NULL OR '
|
||||
'(h.customer_id=? AND h.customer_scope=?))',
|
||||
(sid, owner, current_customer_id(),current_scope_key())).fetchone())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
@@ -42,6 +42,18 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.sidebar-footer { border-top: 1px solid var(--border); padding: 15px 7px 0; margin-top: 13px; white-space: nowrap; }
|
||||
.clear-all-btn { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; }
|
||||
.clear-all-btn:hover { color: var(--danger); }
|
||||
.customer-account { display: block; width: 100%; min-height: 40px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 13px; }
|
||||
.customer-login-cta { display: block; margin-top: 12px; min-height: 40px; padding: 8px 18px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 14px; }
|
||||
.customer-login-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 20px; background: rgba(18,33,56,.35); overflow-y: auto; }
|
||||
.customer-login-dialog { width: min(380px,100%); max-height: 100%; overflow-y: auto; padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.customer-login-dialog h2 { margin: 0 0 20px; font-size: 20px; }
|
||||
.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-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; }
|
||||
.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; }
|
||||
|
||||
+60
-13
@@ -40,6 +40,7 @@ let uploadController = null;
|
||||
let historyRequestVersion = 0;
|
||||
let followLatest = true;
|
||||
let voiceInput = null;
|
||||
let customerLogin = null;
|
||||
let creatingConv = null;
|
||||
let sendPreparing = false;
|
||||
let conversationVersion = 0;
|
||||
@@ -227,6 +228,11 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
|
||||
const I18N = {
|
||||
zh: {
|
||||
appTitle: "柏悦物流智能助手",
|
||||
customerLogin: "登录", customerLogout: "退出登录", customerCancel: "取消",
|
||||
customerUsername: "账号", customerPassword: "密码", customerQuery: "登录查询",
|
||||
customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码",
|
||||
customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。",
|
||||
customerLogoutFailed: "退出失败,请重试。",
|
||||
newChat: "新建对话",
|
||||
clearAll: "清空全部历史",
|
||||
history: "历史对话",
|
||||
@@ -264,7 +270,7 @@ const I18N = {
|
||||
networkFail: "网络连接中断,请检查网络后重试",
|
||||
stopped: "已停止生成",
|
||||
stoppedMark: "(已停止生成)",
|
||||
inputPh: "输入问题、订单号或入仓码…",
|
||||
inputPh: "输入入仓码或问题…",
|
||||
send: "发送",
|
||||
stop: "停止",
|
||||
copy: "复制",
|
||||
@@ -278,11 +284,11 @@ const I18N = {
|
||||
welcomeTitle: "物流问题,随时为您解答",
|
||||
welcomeEyebrow: "中欧 · 英国物流服务",
|
||||
serviceTag: "物流咨询服务",
|
||||
footerTrack: "查订单",
|
||||
footerTrack: "查入仓码",
|
||||
footerQuote: "专线报价",
|
||||
footerException: "异常处理",
|
||||
footerHuman: "联系人工",
|
||||
inputShort: "输入问题…",
|
||||
inputShort: "输入入仓码…",
|
||||
uploading: "附件正在上传,请稍候…",
|
||||
uploadFailed: "附件上传失败,请重新选择文件",
|
||||
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
|
||||
@@ -311,7 +317,7 @@ const I18N = {
|
||||
logisticsHistory: "物流轨迹",
|
||||
noLogisticsHistory: "暂无已发生的物流节点",
|
||||
estimatedTracking: "预计节点",
|
||||
order_inbound: "入仓码", order_shipment: "Shipment ID", order_country: "目的国",
|
||||
order_inbound: "入仓码", order_number: "订单号", order_shipment: "Shipment ID", order_country: "目的国",
|
||||
order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数",
|
||||
order_weight: "申报毛重", order_volume: "申报体积", order_role: "订单层级",
|
||||
order_parent: "父订单号", order_updated: "来源更新时间",
|
||||
@@ -325,7 +331,7 @@ const I18N = {
|
||||
langTip: "中英文切换(Ctrl+L)",
|
||||
toBottom: "回到底部",
|
||||
quick: [
|
||||
"查询订单状态", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
|
||||
"按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
|
||||
],
|
||||
hotList: [
|
||||
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
|
||||
@@ -333,6 +339,11 @@ const I18N = {
|
||||
},
|
||||
en: {
|
||||
appTitle: "Baiyue Logistics AI Assistant",
|
||||
customerLogin: "Sign in", customerLogout: "Sign out", customerCancel: "Cancel",
|
||||
customerUsername: "Username", customerPassword: "Password", customerQuery: "Sign in to view orders",
|
||||
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.",
|
||||
newChat: "New Chat",
|
||||
clearAll: "Clear All History",
|
||||
history: "History",
|
||||
@@ -370,7 +381,7 @@ const I18N = {
|
||||
networkFail: "Connection lost, please check your network and retry",
|
||||
stopped: "Stopped",
|
||||
stoppedMark: "(Generation stopped)",
|
||||
inputPh: "Ask a question or enter an order number…",
|
||||
inputPh: "Enter an inbound code or question…",
|
||||
send: "Send",
|
||||
stop: "Stop",
|
||||
copy: "Copy",
|
||||
@@ -384,11 +395,11 @@ const I18N = {
|
||||
welcomeTitle: "Your next shipment, made clearer.",
|
||||
welcomeEyebrow: "EUROPE & UK LOGISTICS",
|
||||
serviceTag: "Logistics support",
|
||||
footerTrack: "Order lookup",
|
||||
footerTrack: "Inbound lookup",
|
||||
footerQuote: "Get a quote",
|
||||
footerException: "Parcel issue",
|
||||
footerHuman: "Human support",
|
||||
inputShort: "Message…",
|
||||
inputShort: "Inbound code…",
|
||||
uploading: "Uploading your attachment…",
|
||||
uploadFailed: "Upload failed. Please select the file again.",
|
||||
serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.",
|
||||
@@ -419,7 +430,7 @@ const I18N = {
|
||||
logisticsHistory: "Shipment tracking",
|
||||
noLogisticsHistory: "No actual shipment events available",
|
||||
estimatedTracking: "Estimated milestones",
|
||||
order_inbound: "Inbound code", order_shipment: "Shipment ID", order_country: "Destination country",
|
||||
order_inbound: "Inbound code", order_number: "Order number", order_shipment: "Shipment ID", order_country: "Destination country",
|
||||
order_warehouse: "Warehouse", order_transport: "Transport", order_packages: "Packages",
|
||||
order_weight: "Declared gross weight", order_volume: "Declared volume", order_role: "Order level",
|
||||
order_parent: "Parent order", order_updated: "Source updated",
|
||||
@@ -433,7 +444,7 @@ const I18N = {
|
||||
langTip: "Switch language (Ctrl+L)",
|
||||
toBottom: "Back to bottom",
|
||||
quick: [
|
||||
"Track my shipment", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
|
||||
"Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
|
||||
],
|
||||
hotList: [
|
||||
"How do I track my shipment?", "How long does Europe sea freight take?",
|
||||
@@ -449,6 +460,7 @@ function t(key){
|
||||
function applyLanguage(lang){
|
||||
LANG = lang === "en" ? "en" : "zh";
|
||||
const dict = I18N[LANG];
|
||||
if(customerLogin) customerLogin.render();
|
||||
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
|
||||
document.querySelectorAll("[data-i18n]").forEach(function(element){
|
||||
const value = dict[element.dataset.i18n];
|
||||
@@ -885,7 +897,9 @@ function openConversation(id){
|
||||
chatDom.innerHTML = welcomeHTML();
|
||||
return;
|
||||
}
|
||||
let previousQuestion = '';
|
||||
msgs.forEach(m=>{
|
||||
if(m.role === 'user') previousQuestion = m.content;
|
||||
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
|
||||
try{
|
||||
const bubble = addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)), m.ctime);
|
||||
@@ -896,6 +910,9 @@ function openConversation(id){
|
||||
}else{
|
||||
const b = addMsg(m.content, m.role === "user", undefined, m.ctime);
|
||||
if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
|
||||
if(m.role === 'assistant' && b && (/^请先使用客户账号登录并关联订单/.test(m.content) || /^订单查询将在客户账号登录并关联后开放/.test(m.content))){
|
||||
attachCustomerLogin(b, previousQuestion);
|
||||
}
|
||||
}
|
||||
});
|
||||
// 等渲染完再滚到底部
|
||||
@@ -918,8 +935,9 @@ function fillTrackCard(el, card){
|
||||
head.className = "track-head";
|
||||
const no = document.createElement("div");
|
||||
no.className = "track-no";
|
||||
const numberLabel = isOrder ? 'orderNo' : 'trackingNo';
|
||||
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(card.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);
|
||||
const st = document.createElement("div");
|
||||
st.className = "track-status";
|
||||
st.textContent = card.status;
|
||||
@@ -1409,6 +1427,19 @@ function mdToPlain(md){
|
||||
.replace(/“|”/g, '"');
|
||||
}
|
||||
|
||||
function attachCustomerLogin(bubble, question){
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.className = 'customer-login-cta';
|
||||
button.textContent = t('customerQuery');
|
||||
const version = conversationVersion;
|
||||
button.onclick = function(){
|
||||
customerLogin.require(async function(){
|
||||
if(version === conversationVersion && question) await retryAnswer(bubble, question);
|
||||
});
|
||||
};
|
||||
bubble.appendChild(button);
|
||||
}
|
||||
|
||||
async function retryAnswer(bubble, question){
|
||||
if(window.humanSid || humanRequestToken){ setComposerStatus(t('retryAIOnly')); return; }
|
||||
if(await doSend(question)){
|
||||
@@ -1629,7 +1660,8 @@ async function doSend(retryText){
|
||||
raw = "运单号 " + card.no + "(" + (card.status || "") + ")";
|
||||
}catch(e){ /* 解析失败就按普通文本显示 */ }
|
||||
}
|
||||
attachActions(aiBubble, raw, fullContent, true);
|
||||
attachActions(aiBubble, raw, fullContent, !aiBubble.dataset.loginRequired);
|
||||
if(aiBubble.dataset.loginRequired) attachCustomerLogin(aiBubble, fullContent);
|
||||
renderFollowups(aiBubble);
|
||||
updateToBottom();
|
||||
loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下
|
||||
@@ -1679,6 +1711,7 @@ async function doSend(retryText){
|
||||
}
|
||||
if(!obj || typeof obj !== 'object') return;
|
||||
if(obj.e){ fail(obj.e); return; }
|
||||
if(obj.login_required) aiBubble.dataset.loginRequired = '1';
|
||||
if(obj.card){
|
||||
clearTimeout(firstTimer);
|
||||
aiBubble.classList.add("track-card");
|
||||
@@ -1876,6 +1909,7 @@ function syncChatViewport(){
|
||||
chatContainer.style.height = '';
|
||||
chatContainer.style.top = '';
|
||||
}
|
||||
if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top);
|
||||
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
|
||||
autoGrow();
|
||||
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
|
||||
@@ -1952,6 +1986,19 @@ voiceInput = ChatVoice.create({
|
||||
if(!await doSend()) voiceInput.setMode(false);
|
||||
}
|
||||
});
|
||||
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
|
||||
onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); },
|
||||
onChange:function(user,previous){
|
||||
if(previous === undefined) return;
|
||||
if(previous && (!user || user.id !== previous.id)){
|
||||
resetConversation(null); chatDom.innerHTML=welcomeHTML();
|
||||
}
|
||||
loadHistory();
|
||||
},
|
||||
onError:function(message){ setComposerStatus(message,true); }
|
||||
});
|
||||
customerLogin.viewport(chatContainer.style.height,chatContainer.style.top);
|
||||
customerLogin.refresh();
|
||||
window.addEventListener('resize', function(){
|
||||
if(!isNarrow()) voiceInput.setMode(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Customer login has no relationship to the protected staff workspace.
|
||||
var CustomerLogin = {
|
||||
create(options){
|
||||
const overlay=document.getElementById('customer-login');
|
||||
const form=document.getElementById('customer-login-form');
|
||||
const username=document.getElementById('customer-username');
|
||||
const password=document.getElementById('customer-password');
|
||||
const submit=document.getElementById('customer-submit');
|
||||
const cancel=document.getElementById('customer-cancel');
|
||||
const error=document.getElementById('customer-error');
|
||||
const account=document.getElementById('customer-account');
|
||||
let user, pending=null, opener=null, busy=false, version=0;
|
||||
overlay.hidden=true;
|
||||
function status(message){ error.textContent=message || ''; error.hidden=!message; }
|
||||
function render(){
|
||||
account.textContent=user ? user.name+' · '+options.text('customerLogout') : options.text('customerLogin');
|
||||
submit.textContent=options.text(busy ? 'customerSigningIn' : 'customerLogin');
|
||||
cancel.textContent=options.text('customerCancel');
|
||||
document.getElementById('customer-login-title').textContent=options.text('customerLogin');
|
||||
document.getElementById('customer-username-label').textContent=options.text('customerUsername');
|
||||
document.getElementById('customer-password-label').textContent=options.text('customerPassword');
|
||||
username.placeholder=options.text('customerUsernamePlaceholder');
|
||||
password.placeholder=options.text('customerPasswordPlaceholder');
|
||||
}
|
||||
function update(next){
|
||||
const previous=user;
|
||||
user=next;
|
||||
render();
|
||||
if(options.onChange && (previous === undefined || (previous && previous.id) !== (user && user.id))){
|
||||
options.onChange(user,previous);
|
||||
}
|
||||
}
|
||||
async function request(path, data){
|
||||
const controller=new AbortController();
|
||||
const timer=setTimeout(()=>controller.abort(),10000);
|
||||
try{
|
||||
const response=await options.fetch(path,{method:data === undefined ? 'GET' : 'POST',
|
||||
headers:{'Content-Type':'application/json'},body:data === undefined ? undefined : JSON.stringify(data),signal:controller.signal});
|
||||
const result=await response.json();
|
||||
if(!response.ok || !result.ok){
|
||||
const failure=new Error(result.error || result.e || options.text('customerLoginFailed'));
|
||||
failure.publicMessage=true;throw failure;
|
||||
}
|
||||
return result;
|
||||
}finally{ clearTimeout(timer); }
|
||||
}
|
||||
async function refresh(){
|
||||
if(busy) return;
|
||||
const token=++version;
|
||||
try{
|
||||
const result=await request('/api/customer/session');
|
||||
if(token === version) update(result.authenticated && result.user ? result.user : null);
|
||||
}catch(error){} // Query endpoints still enforce login if status cannot refresh.
|
||||
}
|
||||
function close(){
|
||||
overlay.hidden=true;
|
||||
password.value='';
|
||||
pending=null;
|
||||
if(opener && opener.isConnected) opener.focus({preventScroll:true});
|
||||
opener=null;
|
||||
}
|
||||
function open(onSuccess){
|
||||
if(busy) return;
|
||||
pending=typeof onSuccess === 'function' ? onSuccess : null;
|
||||
opener=document.activeElement;
|
||||
status(''); password.value=''; overlay.hidden=false;
|
||||
if(options.onOpen) options.onOpen();
|
||||
render(); username.focus({preventScroll:true});
|
||||
}
|
||||
function setBusy(value){
|
||||
busy=value; submit.disabled=value; cancel.disabled=value; account.disabled=value;
|
||||
username.disabled=value; password.disabled=value; render();
|
||||
}
|
||||
form.addEventListener('submit',async function(event){
|
||||
event.preventDefault();
|
||||
if(busy) return;
|
||||
const token=++version;
|
||||
setBusy(true);status('');
|
||||
try{
|
||||
const result=await request('/api/customer/login',{username:username.value,password:password.value});
|
||||
if(token !== version) return;
|
||||
update(result.user);
|
||||
const resume=pending;close();
|
||||
if(resume) await resume();
|
||||
}catch(failure){
|
||||
if(token === version) status(failure.publicMessage ? failure.message : options.text('customerLoginFailed'));
|
||||
}finally{ if(token === version) setBusy(false); }
|
||||
});
|
||||
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(); }
|
||||
if(event.key === 'Tab'){
|
||||
const controls=[username,password,submit,cancel].filter(control=>!control.disabled);
|
||||
if(!controls.length){ event.preventDefault();return; }
|
||||
const index=controls.indexOf(document.activeElement);
|
||||
const next=(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length;
|
||||
event.preventDefault();controls[next].focus({preventScroll:true});
|
||||
}
|
||||
});
|
||||
account.onclick=async function(){
|
||||
if(busy) return;
|
||||
if(!user){ open(); return; }
|
||||
const token=++version;setBusy(true);
|
||||
try{
|
||||
await request('/api/customer/logout',{});
|
||||
if(token === version) update(null);
|
||||
}catch(failure){ if(options.onError) options.onError(options.text('customerLogoutFailed')); }
|
||||
finally{ if(token === version) setBusy(false); }
|
||||
};
|
||||
window.addEventListener('focus',refresh);
|
||||
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
|
||||
render();
|
||||
return {open:open,refresh:refresh,render:render,require:async function(resume){
|
||||
if(busy) return;
|
||||
await refresh();
|
||||
if(user){ await resume(); }else open(resume);
|
||||
},viewport:function(height,top){
|
||||
overlay.style.height=height || ''; overlay.style.top=top || '';
|
||||
overlay.style.bottom=height ? 'auto' : '';
|
||||
}};
|
||||
}
|
||||
};
|
||||
+17
-2
@@ -21,6 +21,7 @@
|
||||
</div>
|
||||
<div class="history-wrap" id="historyWrap"></div>
|
||||
<div class="sidebar-footer">
|
||||
<button type="button" id="customer-account" class="customer-account">登录</button>
|
||||
<button type="button" class="clear-all-btn">清空全部历史</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -49,7 +50,7 @@
|
||||
<footer class="composer">
|
||||
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
|
||||
<div class="composer-toolbar"><div class="hot-bar">
|
||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查入仓码</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
|
||||
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
|
||||
@@ -58,7 +59,7 @@
|
||||
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
|
||||
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
|
||||
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
|
||||
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
|
||||
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或入仓码" placeholder="输入入仓码或问题…"></textarea>
|
||||
<button id="voice-hold" class="voice-hold" type="button">按住说话</button>
|
||||
<button type="button" id="send-button">发送</button>
|
||||
</div>
|
||||
@@ -68,8 +69,22 @@
|
||||
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
|
||||
</main>
|
||||
</div>
|
||||
<div id="customer-login" class="customer-login-overlay" hidden>
|
||||
<section class="customer-login-dialog" role="dialog" aria-modal="true" aria-labelledby="customer-login-title">
|
||||
<h2 id="customer-login-title">登录</h2>
|
||||
<form id="customer-login-form">
|
||||
<label for="customer-username" id="customer-username-label">账号</label>
|
||||
<input id="customer-username" name="username" type="text" autocomplete="username" placeholder="请输入账号" maxlength="80" required>
|
||||
<label for="customer-password" id="customer-password-label">密码</label>
|
||||
<input id="customer-password" name="password" type="password" autocomplete="current-password" placeholder="请输入密码" maxlength="256" required>
|
||||
<p id="customer-error" role="alert" hidden></p>
|
||||
<div class="customer-login-actions"><button type="button" id="customer-cancel">取消</button><button type="submit" id="customer-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/drafts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
|
||||
|
||||
@@ -113,7 +113,7 @@ async function customerPage(pageOptions = {}){
|
||||
return {ok:true, json:async () => data};
|
||||
},
|
||||
});
|
||||
for(const file of ['support.js', 'visitor.js', 'drafts.js', 'voice.js', 'chat.js']){
|
||||
for(const file of ['support.js', 'visitor.js', 'customer.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;
|
||||
@@ -385,7 +385,7 @@ test('all three footer questions leave the human queue and send to separate AI c
|
||||
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.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);
|
||||
@@ -611,6 +611,22 @@ test('shipment cards separate forecasts from actual progress and retain both in
|
||||
assert.ok(log.querySelector('.msg-actions'));
|
||||
});
|
||||
|
||||
test('inbound codes are the primary card identity for live replies and restored history', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:8,title:'入仓轨迹',ctime:'now'}]});
|
||||
const card={kind:'order',no:'FL202610290001',status:'履约中',
|
||||
fields:[{key:'inbound',value:'INB20261029001-01'},{key:'number',value:'FL202610290001'}],
|
||||
nodes:[],summary:'入仓码 INB20261029001-01'};
|
||||
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(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/);
|
||||
});
|
||||
|
||||
test('order details can expand without hiding the shipment timeline', async () => {
|
||||
const app = await customerPage({human:false,width:390});
|
||||
const card = {kind:'order',no:'ORDER2026001',status:'履约中',node_kind:'logistics',
|
||||
@@ -997,6 +1013,69 @@ test('deleting a conversation removes only its saved draft', async () => {
|
||||
assert.equal(store.read(9),'');assert.equal(store.read(10),'B草稿');
|
||||
});
|
||||
|
||||
test('an order login action resumes the original question and preserves a newer draft', async () => {
|
||||
const app=await customerPage({human:false});let authenticated=false;
|
||||
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated,user:authenticated ? {id:1,name:'admin'} : null});
|
||||
app.hooks['/api/customer/login']=()=>{authenticated=true;return response({ok:true,authenticated:true,user:{id:1,name:'admin'}});};
|
||||
app.hooks['/stream_chat']=()=>streamResponse([`data: ${JSON.stringify(authenticated ? {t:'账号范围内的订单结果'} : {t:'请先登录',login_required:true})}\n\ndata: {"d":1}\n\n`]);
|
||||
await app.context.window.fillInput('查询ORDER20260001');await flush();
|
||||
const action=app.element('chatDom').querySelector('.customer-login-cta');assert.ok(action);
|
||||
app.element('input-text').value='下一条未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(action);assert.equal(app.element('customer-login').hidden,false);
|
||||
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('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
|
||||
assert.equal(app.element('input-text').value,'下一条未发送草稿');
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,2);
|
||||
assert.match(app.element('chatDom').textContent,/账号范围内的订单结果/);
|
||||
assert.match(app.element('customer-account').textContent,/admin/);
|
||||
});
|
||||
|
||||
test('invalid customer credentials leave the login form open without sending another question', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
app.hooks['/api/customer/login']=()=>response({ok:false,error:'账号或密码不正确。'},403);
|
||||
await app.click(app.element('customer-account'));
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='wrong';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
|
||||
assert.equal(app.element('customer-login').hidden,false);
|
||||
assert.match(app.element('customer-error').textContent,/账号或密码/);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/customer/login').length,1);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
|
||||
await app.click(app.element('customer-cancel'));
|
||||
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
|
||||
});
|
||||
|
||||
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});
|
||||
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"先登录","login_required":true}\n\ndata: {"d":1}\n\n']);
|
||||
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;});
|
||||
await app.context.window.fillInput('查询ORDER20260001');await flush();
|
||||
await app.click(app.element('chatDom').querySelector('.customer-login-cta'));
|
||||
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,authenticated:true,user:{id:1,name:'admin'}}));await signing;
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
|
||||
assert.equal(app.element('input-text').value,'新话题');
|
||||
});
|
||||
|
||||
test('signing out clears visible order conversation and returns to guest mode', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
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 app.context.window.fillInput('ORDER20260001');await flush();
|
||||
assert.ok(app.element('chatDom').querySelector('.user-message'));
|
||||
await app.click(app.element('customer-account'));
|
||||
assert.equal(app.element('customer-account').textContent,'登录');
|
||||
assert.equal(app.element('chatDom').querySelector('.user-message'),null);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/customer/logout').length,1);
|
||||
});
|
||||
|
||||
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
let shown;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Customer login, tenant scope and sensitive-history lifecycle."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app, db, order_data
|
||||
from logistics_chat.customer_service import COOKIE_NAME
|
||||
|
||||
|
||||
class CustomerTests(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':'',
|
||||
'ORDER_DATA_TENANT_ID':'','CUSTOMER_DEMO_TENANT_ID':'tenant-a','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,[{'order_no':'ORDER20260001','tenant_id':'tenant-a','gross_weight_kg':30},
|
||||
{'order_no':'ORDER20260002','tenant_id':'tenant-b','gross_weight_kg':99}])
|
||||
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'):
|
||||
return (client or self.a).post('/api/customer/login',json={'username':username,'password':'123456'})
|
||||
|
||||
def events(self,message,conv=None,client=None):
|
||||
response=(client or self.a).post('/stream_chat',json={'msg':message,'conv_id':conv})
|
||||
return [json.loads(part[6:]) for part in response.get_data(as_text=True).split('\n\n') if part.startswith('data: ')]
|
||||
|
||||
def test_guest_must_login_then_customer_reads_only_the_linked_scope(self):
|
||||
with patch('logistics_chat.order_data.lookup_order') as lookup:
|
||||
self.assertTrue(self.events('查询ORDER20260001')[0]['login_required']);lookup.assert_not_called()
|
||||
self.assertTrue(self.login().json['ok'])
|
||||
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.assertEqual(self.events('查询ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||
self.assertIn('未找到',self.events('查询ORDER20260002')[0]['t'])
|
||||
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_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')
|
||||
response=secure.post('/api/customer/login',json={'username':'admin','password':'123456'},base_url='https://localhost')
|
||||
for flag in ('Secure','HttpOnly','SameSite=Lax'):
|
||||
self.assertIn(flag,response.headers['Set-Cookie'])
|
||||
self.login();token=self.a.get_cookie(COOKIE_NAME).value
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
self.assertNotEqual(connection.execute("SELECT password_hash FROM customer_users WHERE username='admin'").fetchone()[0],'123456')
|
||||
self.assertTrue(connection.execute('SELECT 1 FROM customer_sessions WHERE token_hash=?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(),)).fetchone());connection.close()
|
||||
with patch('logistics_chat.customer_service.time.time',return_value=time.time()+9*3600):
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.a.post('/api/customer/logout');self.a.set_cookie(COOKIE_NAME,token)
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
|
||||
def test_customer_cookie_cannot_be_reused_by_another_guest_and_bad_logins_are_limited(self):
|
||||
self.login();self.b.set_cookie(COOKIE_NAME,self.a.get_cookie(COOKIE_NAME).value)
|
||||
self.assertFalse(self.b.get('/api/customer/session').json['authenticated'])
|
||||
self.b.post('/api/customer/logout')
|
||||
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
|
||||
for index in range(5):
|
||||
response=self.b.post('/api/customer/login',json={'username':'admin','password':'wrong'})
|
||||
self.assertEqual(response.status_code,403)
|
||||
self.assertEqual(self.login(self.b).status_code,429)
|
||||
|
||||
def test_order_history_is_hidden_after_logout_and_restored_only_to_its_customer_and_guest(self):
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id']
|
||||
self.events('ORDER20260001',cid)
|
||||
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.assertTrue(self.events('继续查询',cid)[0]['login_required'])
|
||||
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.assertTrue(any('__CARD__' in row['content'] for row in 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,[])
|
||||
|
||||
def test_cross_site_login_logout_and_oversized_or_malformed_requests_fail(self):
|
||||
for path in ('login','logout'):
|
||||
self.assertEqual(self.a.post('/api/customer/'+path,json={'username':'admin','password':'123456'},
|
||||
headers={'Origin':'https://foreign.example'}).status_code,403)
|
||||
self.assertEqual(self.a.post('/api/customer/login',json=[]).status_code,400)
|
||||
self.assertEqual(self.a.post('/api/customer/login',json={'username':[],'password':'123456'}).status_code,400)
|
||||
self.assertEqual(self.a.post('/api/customer/login',json={'padding':'x'*3000}).status_code,413)
|
||||
|
||||
def test_disabled_accounts_and_password_changes_invalidate_existing_sessions(self):
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.assertEqual(self.login().status_code,403)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=1 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET password_hash=password_hash||'changed' WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
|
||||
def test_demo_binding_does_not_pick_an_arbitrary_tenant_when_dataset_has_multiple(self):
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
self.app.config['CUSTOMER_DEMO_LOCAL_ORDERS']=False
|
||||
self.assertTrue(self.login().json['ok'])
|
||||
reply=self.events('ORDER20260001')[0]
|
||||
self.assertIn('尚未关联',reply['t']);self.assertNotIn('login_required',reply)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-b'");connection.commit();connection.close()
|
||||
self.login()
|
||||
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||
|
||||
def test_tenant_reassignment_does_not_reveal_the_accounts_old_order_history(self):
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260001',cid)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='tenant-b' WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.login()
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||
|
||||
def test_demo_disable_revokes_default_login_without_disabling_real_customer_records(self):
|
||||
self.login();self.app.config['CUSTOMER_DEMO_ENABLED']=False
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.assertEqual(self.login().status_code,403)
|
||||
self.assertTrue(self.login(username='other').json['authenticated'])
|
||||
|
||||
def test_parallel_invalid_logins_cannot_bypass_the_attempt_budget(self):
|
||||
barrier=Barrier(6)
|
||||
def attempt(index):
|
||||
with self.app.test_client() as client:
|
||||
client.set_cookie('baiyue_visitor','1'*64)
|
||||
barrier.wait(timeout=5)
|
||||
return client.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code
|
||||
with ThreadPoolExecutor(max_workers=6) as executor:
|
||||
statuses=list(executor.map(attempt,range(6)))
|
||||
self.assertEqual(statuses.count(403),5);self.assertEqual(statuses.count(429),1)
|
||||
|
||||
def test_default_local_account_has_explicit_existing_tenants_and_real_users_remain_scoped(self):
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.assertTrue(self.login().json['authenticated'])
|
||||
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
order_data.import_records(connection,[{'order_no':'ORDER20260003','tenant_id':'new-ungranted-tenant'}]);connection.commit();connection.close()
|
||||
self.assertIn('未找到',self.events('ORDER20260003')[0]['t'])
|
||||
self.login(username='other')
|
||||
self.assertIn('未找到',self.events('ORDER20260001')[0]['t'])
|
||||
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||
|
||||
def test_removing_a_tenant_grant_revokes_sessions_and_old_scope_history(self):
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("DELETE FROM customer_tenant_grants WHERE tenant_id='tenant-b'");connection.commit();connection.close()
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.login()
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
|
||||
|
||||
def test_revoking_every_local_grant_is_not_undone_by_login_or_startup(self):
|
||||
from logistics_chat.customer_service import init_customer_tables
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login()
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute('DELETE FROM customer_tenant_grants');connection.commit()
|
||||
init_customer_tables(connection);connection.commit();connection.close()
|
||||
self.login()
|
||||
self.assertIn('尚未关联',self.events('ORDER20260001')[0]['t'])
|
||||
with self.app.app_context():
|
||||
connection=db.connect();self.assertEqual(connection.execute('SELECT COUNT(*) FROM customer_tenant_grants').fetchone()[0],0);connection.close()
|
||||
|
||||
def test_server_scope_narrowing_revokes_the_session_and_hides_broader_history(self):
|
||||
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||
with self.app.app_context():
|
||||
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
|
||||
self.app.config['ORDER_DATA_TENANT_ID']='tenant-a'
|
||||
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||
self.login()
|
||||
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
|
||||
@@ -67,12 +67,14 @@ class GrayConfigTests(unittest.TestCase):
|
||||
self.assertIn('auth_basic off;', body)
|
||||
self.assertIn('auth_basic "Baiyue gray acceptance";', config)
|
||||
for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages',
|
||||
'/api/customer/session','/api/customer/login','/api/customer/logout',
|
||||
'/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll',
|
||||
'/api/visitor/human/send','/api/visitor/human/close','/api/speech/transcribe'):
|
||||
with self.subTest(public_path=path):self.assertRegex(path,pattern)
|
||||
for path in ('/admin/dashboard','/api/export_chat','/api/search_chat','/api/my_orders','/api/tickets',
|
||||
'/api/human/queue','/api/human/poll','/api/human/send','/api/staff/uploads/private.txt',
|
||||
'/api/visitor/human/accept','/api/get_history/unknown','/api/unknown'):
|
||||
'/api/visitor/human/accept','/api/customer/users','/api/customer/session/extra',
|
||||
'/api/get_history/unknown','/api/unknown'):
|
||||
with self.subTest(staff_path=path):self.assertNotRegex(path,pattern)
|
||||
example=(Path(__file__).resolve().parents[1]/'deploy/nginx.conf.example').read_text(encoding='utf-8')
|
||||
self.assertEqual(re.search(r'location ~ (\S+) \{',example)[1],pattern)
|
||||
|
||||
@@ -22,8 +22,6 @@ class OrderDataTests(unittest.TestCase):
|
||||
self.client.post('/api/visitor/session', json={'key':'1'*64})
|
||||
self.context = self.app.app_context()
|
||||
self.context.push()
|
||||
from flask import g
|
||||
g.customer_tenant_id = 'tenant-a'
|
||||
self.addCleanup(self.context.pop)
|
||||
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
|
||||
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
|
||||
@@ -32,6 +30,8 @@ class OrderDataTests(unittest.TestCase):
|
||||
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'},
|
||||
{'time':'2026-10-29 10:00', 'status':'PROCESSING'}]}
|
||||
self.import_rows([self.record])
|
||||
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
|
||||
self.client.get('/api/customer/session')
|
||||
|
||||
def import_rows(self, rows, replace=False):
|
||||
connection = db.connect()
|
||||
@@ -61,9 +61,26 @@ class OrderDataTests(unittest.TestCase):
|
||||
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
|
||||
self.assertEqual(order_data.extract_reference(text), [])
|
||||
events = self.events('帮我查询物流轨迹')
|
||||
self.assertIn('订单号', events[0]['t'])
|
||||
self.assertIn('入仓码', events[0]['t'])
|
||||
self.assertEqual(events[-1], {'d':1})
|
||||
|
||||
def test_direct_inbound_lookup_and_followup_keep_the_inbound_identity(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
card=self.events(self.record['inbound_code'],cid)[0]['card']
|
||||
self.assertEqual(card['no'],self.record['order_no'])
|
||||
self.assertTrue(card['summary'].startswith('入仓码 '+self.record['inbound_code']))
|
||||
self.assertEqual(next(item['value'] for item in card['fields'] if item['key']=='number'),self.record['order_no'])
|
||||
self.import_rows([{**self.record,'gross_weight_kg':'42.000'}],replace=True)
|
||||
followup=self.events('现在到哪了',cid)[0]['card']
|
||||
self.assertTrue(followup['summary'].startswith('入仓码 '+self.record['inbound_code']))
|
||||
self.assertEqual(next(item['value'] for item in followup['fields'] if item['key']=='weight'),'42.000 kg')
|
||||
|
||||
def test_missing_inbound_code_is_not_replaced_by_the_internal_order_number(self):
|
||||
self.import_rows([{**self.record,'inbound_code':None}],replace=True)
|
||||
card=self.events(self.record['order_no'])[0]['card']
|
||||
self.assertFalse(any(item['key']=='inbound' for item in card['fields']))
|
||||
self.assertTrue(card['summary'].startswith('订单号 '+self.record['order_no']))
|
||||
|
||||
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
|
||||
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
|
||||
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
|
||||
@@ -86,7 +103,7 @@ class OrderDataTests(unittest.TestCase):
|
||||
def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events = self.events('列出所有订单')
|
||||
self.assertIn('请提供订单号', events[0]['t'])
|
||||
self.assertIn('请提供入仓码', events[0]['t'])
|
||||
model.assert_not_called()
|
||||
|
||||
def test_server_tenant_scope_and_ambiguous_identifiers(self):
|
||||
|
||||
+3
-1
@@ -13,7 +13,9 @@ from logistics_chat import knowledge, pricing, tracking
|
||||
|
||||
class RuleTests(unittest.TestCase):
|
||||
def test_specific_knowledge_wins_and_unknown_questions_have_no_match(self):
|
||||
self.assertIn('22KG+', knowledge.search_knowledge('英国空运价格'))
|
||||
for question in ('英国空运价格','英国空运多久','英国空运多少钱'):
|
||||
self.assertIn('22KG+', knowledge.search_knowledge(question))
|
||||
self.assertIn('21KG+', knowledge.search_knowledge('欧洲空运价格'))
|
||||
self.assertIsNone(knowledge.search_knowledge('没有相关关键词的测试'))
|
||||
|
||||
def test_sea_tier_boundary_and_minimum(self):
|
||||
|
||||
@@ -119,7 +119,8 @@ class VisitorTests(unittest.TestCase):
|
||||
self.assertEqual(staff.data,b'A_PRIVATE')
|
||||
|
||||
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
|
||||
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001'):
|
||||
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001','我的订单呢','帮我看下订单','订单查询','查询','my orders','order status',
|
||||
'按入仓码查轨迹','入仓码查询','入仓号查轨迹','Track by inbound code'):
|
||||
with patch('logistics_chat.order_data.lookup_order') as lookup, patch('logistics_chat.ai_client.post_completion') as model:
|
||||
response=self.a.post('/stream_chat',json={'msg':message},headers={'X-Customer-Id':'tenant-a','X-Tenant-Id':'tenant-a'})
|
||||
self.assertIn('客户账号登录并关联',response.get_data(as_text=True))
|
||||
|
||||
Reference in New Issue
Block a user