Compare commits

...
Author SHA1 Message Date
ouyangxq 04f94ad665 以入仓码作为查货入口和轨迹卡片主号码 2026-10-30 19:10:22 +08:00
ouyangxq fd1ec32dd6 修复英国空运问题被通用空运关键词覆盖 2026-10-30 18:50:00 +08:00
ouyangxq 9d07793a57 合并远程知识库更新并集成客户登录 2026-10-30 18:47:57 +08:00
ouyangxq d26f6ecfd3 关联默认客户账号与当前本地订单范围 2026-10-30 18:46:03 +08:00
ouyangxq fef233344b 补齐客户登录输入框中英文占位文字 2026-10-30 18:35:52 +08:00
ouyangxq 783f930380 接入客户登录和订单会话权限隔离 2026-10-30 18:27:40 +08:00
songxinnuo 888f323b23 补充物流知识库问答和关键词 2026-10-30 17:21:11 +08:00
ouyangxq 689d55bcf9 精简聊天页面说明并静默恢复草稿 2026-10-30 16:04:40 +08:00
ouyangxq eba3a1b562 修复草稿存储失败提醒被恢复状态遮挡 2026-10-30 15:40:25 +08:00
ouyangxq b764fcb33c 完善会话草稿恢复与附件保留,统一人工服务时区 2026-10-30 15:32:31 +08:00
ouyangxq 78797d8eca 修复评价统计、留言校验和聊天操作反馈 2026-10-30 14:38:25 +08:00
ouyangxq b8f6ca586d 统一手机输入区控件高度并对齐快捷与语音按钮 2026-10-30 13:43:51 +08:00
ouyangxq 767fca6a79 缩短手机输入提示并避免空输入框提示换行 2026-10-30 13:35:42 +08:00
ouyangxq cfd3fb15cc 兼容旧版内嵌浏览器的工具栏点击与视口缩放 2026-10-30 12:57:33 +08:00
ouyangxq 1902949f69 避免手机工具栏按钮在失焦重排时丢失点击 2026-10-30 12:56:26 +08:00
ouyangxq a415bcdb88 修复手机键盘弹出时页面位移与焦点跳转 2026-10-30 12:53:32 +08:00
ouyangxq 8853ea2dfa 修正游客发布验收模拟并覆盖回滚清理 2026-10-30 12:25:55 +08:00
ouyangxq 439241dd83 串行执行数据库升级避免多进程启动冲突 2026-10-30 12:18:52 +08:00
ouyangxq 147edd042f 支持免登录游客聊天并隔离历史与附件 2026-10-30 12:17:43 +08:00
ouyangxq e58dd86d5b 支持灰度构建选择 Debian 国内镜像源 2026-10-30 11:34:14 +08:00
ouyangxq aacdca6930 补齐灰度语音配置与订单样本私有导入 2026-10-30 11:28:07 +08:00
ouyangxq 3f1cee8006 新增H5长按录音并接入百度语音转文字 2026-10-30 11:09:50 +08:00
ouyangxq 99c26a393a 优化H5订单轨迹展示并修复视口变化和输入区高度 2026-10-30 10:33:04 +08:00
ouyangxq e051ab2e9b 补全物流位置和预计到达时间的连续追问 2026-10-30 10:17:41 +08:00
ouyangxq 6a428bd468 接入灰度订单关联物流节点并区分预计与实际轨迹 2026-10-30 10:13:11 +08:00
ouyangxq ce445d0fb8 修复尺寸报价及目的地补充的上下文边界 2026-10-29 19:11:39 +08:00
ouyangxq 8444ded971 修复目的地识别、报价短追问及删除后历史同步 2026-10-29 19:04:05 +08:00
ouyangxq f4e87b0c02 保留历史消息时间并恢复订单卡片操作 2026-10-29 18:43:25 +08:00
ouyangxq 1f66307bba 修复聊天异步状态、流中断和报价上下文 2026-10-29 18:41:02 +08:00
ouyangxq a081966342 精简订单展示并修复查单追问串入报价 2026-10-29 18:17:24 +08:00
ouyangxq cded4238a9 接入本地订单测试表并替换模拟查单 2026-10-29 18:01:28 +08:00
ouyangxq 3c3d273ee3 修复宝塔环境灰度访问密码文件权限 2026-10-29 17:24:28 +08:00
ouyangxq 97162016ee 等待 Nginx 新进程完成灰度入口切换 2026-10-29 17:20:38 +08:00
ouyangxq 167c888718 修复快捷咨询串入人工会话及上下文切换 2026-10-29 17:16:34 +08:00
ouyangxq 97fc9bec14 修复跨 Docker 版本灰度镜像校验 2026-10-29 17:16:30 +08:00
ouyangxq df688844d3 1 2026-10-29 16:49:51 +08:00
ouyangxq 64041893a5 合入灰度部署配置并保留白色版本 2026-10-29 16:30:13 +08:00
ouyangxq 06b80803c4 合并 dev 新功能并统一白色客服界面 2026-10-29 16:28:50 +08:00
songxinnuo 2d6806b15d 加会话转接、满意度评价、会话备注、浏览器通知 2026-10-29 11:24:11 +08:00
81 changed files with 7204 additions and 553 deletions
+21
View File
@@ -3,3 +3,24 @@
# The process environment takes precedence, including an explicitly empty value.
# Keep real credentials in .env; never put them in this example or source files.
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=
BAIDU_ASR_SECRET_KEY=
# Optional absolute ffmpeg executable path. Empty = find ffmpeg on PATH. Docker includes it.
FFMPEG_BIN=
# Optional alternative: ASR_PROVIDER=dashscope, with a separate Alibaba Cloud Model Studio key.
ASR_API_KEY=
ASR_URL=https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
ASR_MODEL=qwen3-asr-flash
+9 -4
View File
@@ -7,13 +7,18 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Area | Files and search anchors |
|---|---|
| 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` |
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
| Human support | `logistics_chat/human_service.py`; `logistics_chat/routes/human.py` |
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.py` |
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView` |
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView`; `admin_tools.js`: notes, transfer, search; `support.js`: emoji, notifications, rating |
| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`: `default_config`; `hooks.py`, `runtime.py` in that package |
| CLI / tests | `start.bat`: `probe_python`; `scripts/`, `tests/`, `examples/requests/` |
| Deployment | `Jenkinsfile`, `Dockerfile`; `deploy/gray_config.py`: `GrayConfig`; publish/activate/TLS scripts; `docs/deployment.md` |
@@ -51,7 +56,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
Tests run `tests/frontend/admin.test.cjs` when Node.js is available; no npm dependencies.
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
## Commit & Pull Request Guidelines
@@ -59,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`. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
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 -1
View File
@@ -6,6 +6,12 @@ ENV PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY requirements.txt ./
ARG DEBIAN_MIRROR=https://deb.debian.org
RUN case "$DEBIAN_MIRROR" in https://deb.debian.org|https://mirrors.tuna.tsinghua.edu.cn) ;; *) exit 2;; esac \
&& sed -i "s|http://deb.debian.org|$DEBIAN_MIRROR|g" /etc/apt/sources.list.d/debian.sources \
&& apt-get -o Acquire::Retries=2 -o Acquire::https::Timeout=30 update \
&& apt-get -o Acquire::Retries=2 -o Acquire::https::Timeout=30 install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
RUN python -m pip install --no-cache-dir -r requirements.txt \
&& groupadd --gid 10001 baiyue \
&& useradd --uid 10001 --gid 10001 --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin baiyue \
@@ -16,6 +22,7 @@ COPY logistics_chat/ ./logistics_chat/
COPY templates/ ./templates/
COPY static/ ./static/
COPY deploy/__init__.py deploy/wsgi.py ./deploy/
COPY scripts/import_order_test_data.py ./scripts/
USER 10001:10001
EXPOSE 5000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
@@ -31,7 +38,7 @@ RUN python -m pytest -q -p no:cacheprovider
FROM ${PYTHON_BASE_IMAGE} AS certbot
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN python -m pip install --no-cache-dir certbot==5.9.0
RUN python -m pip install --no-cache-dir certbot==5.8.0
ENTRYPOINT ["certbot"]
FROM runtime AS production
Vendored
+25 -6
View File
@@ -20,21 +20,27 @@ pipeline {
if (!(params.PYTHON_BASE_IMAGE ==~ /[A-Za-z0-9][A-Za-z0-9._\/:@-]+/)) {
error('Invalid Python base image reference')
}
if (!((params.ASR_PROVIDER ?: '') in ['', 'baidu'])) {
error('Gray speech provider must be baidu or disabled')
}
if (!((params.DEBIAN_MIRROR ?: 'https://deb.debian.org') in ['https://deb.debian.org', 'https://mirrors.tuna.tsinghua.edu.cn'])) {
error('Select a supported Debian mirror')
}
}
sh '''set -eu
for file in deploy/*.sh; do bash -n "$file"; done
node --test tests/frontend/admin.test.cjs
node --test tests/frontend/*.test.cjs
'''
}
}
stage('Build and test image') {
steps {
withEnv(["PYTHON_BASE_IMAGE=${params.PYTHON_BASE_IMAGE}"]) {
withEnv(["PYTHON_BASE_IMAGE=${params.PYTHON_BASE_IMAGE}", "DEBIAN_MIRROR=${params.DEBIAN_MIRROR ?: 'https://deb.debian.org'}"]) {
sh '''set -eu
docker build --target test --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" -t "$CHAT_IMAGE-test" .
docker build --target production --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
docker build --target test --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" -t "$CHAT_IMAGE-test" .
docker build --target production --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CHAT_IMAGE" .
docker build --target certbot --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
docker build --target certbot --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CERTBOT_IMAGE" .
'''
}
@@ -50,16 +56,29 @@ pipeline {
withEnv([
"PUBLIC_ORIGIN=${params.PUBLIC_ORIGIN}", "HOST_PORT=${params.HOST_PORT}",
"GRAY_DEPLOY_DIR=${params.GRAY_DEPLOY_DIR}", "GRAY_PROXY_CONF_DIR=${params.GRAY_PROXY_CONF_DIR}",
"GRAY_TLS_CERT_FILE=${params.GRAY_TLS_CERT_FILE}", "GRAY_TLS_KEY_FILE=${params.GRAY_TLS_KEY_FILE}"
"GRAY_TLS_CERT_FILE=${params.GRAY_TLS_CERT_FILE}", "GRAY_TLS_KEY_FILE=${params.GRAY_TLS_KEY_FILE}",
"ASR_PROVIDER=${params.ASR_PROVIDER ?: ''}"
]) {
withCredentials([
sshUserPrivateKey(credentialsId: params.SSH_CREDENTIAL_ID, keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
string(credentialsId: params.SSH_HOST_CREDENTIAL_ID, variable: 'TARGET_SSH_HOST'),
file(credentialsId: params.SSH_KNOWN_HOSTS_CREDENTIAL_ID, variable: 'TARGET_SSH_KNOWN_HOSTS'),
string(credentialsId: params.DEEPSEEK_CREDENTIAL_ID, variable: 'DEEPSEEK_API_KEY'),
string(credentialsId: params.BAIDU_ASR_API_KEY_CREDENTIAL_ID, variable: 'BAIDU_ASR_API_KEY'),
string(credentialsId: params.BAIDU_ASR_SECRET_KEY_CREDENTIAL_ID, variable: 'BAIDU_ASR_SECRET_KEY'),
usernamePassword(credentialsId: params.PREVIEW_AUTH_CREDENTIAL_ID, usernameVariable: 'PREVIEW_USERNAME', passwordVariable: 'PREVIEW_PASSWORD')
]) {
sh 'bash deploy/publish-gray.sh'
script {
if (params.IMPORT_ORDER_SAMPLES) {
withCredentials([
file(credentialsId: params.ORDER_SNAPSHOT_CREDENTIAL_ID, variable: 'ORDER_SNAPSHOT_FILE'),
file(credentialsId: params.TRACKING_SNAPSHOT_CREDENTIAL_ID, variable: 'TRACKING_SNAPSHOT_FILE')
]) {
sh 'bash deploy/import-gray-order-samples.sh'
}
}
}
}
}
}
+43 -9
View File
@@ -8,11 +8,12 @@
- **知识库优先**:优先使用本地知识库回答,减少模型编造
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
- **运单号查询**:发送 8~20 位运单号,返回轨迹卡片;先展示最近节点,可展开全部轨迹
- **订单查询**:直接输入入仓码查询订单与已有轨迹,卡片主显示入仓码;兼容订单号与 Shipment ID,展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
- **异常处理**:接口超时、Key 失效、中断时给出明确提示与重试
- **会话恢复**:连接中断保留已收到的正文;切换话题取消旧附件,等待建会话时保留新草稿;历史加载失败保留已有列表
- **深色模式**:右上角切换,偏好本地保存
- **响应式**:窄屏下侧栏自动变为抽屉
- **附件预览**:发送前查看文件名或缩略图,可移除待发送附件
@@ -23,6 +24,8 @@
代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位:
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|---|---|---|
| 欧洲海运快线 | 21KG+ → 12.3;101KG+ → 10.3;500KG+ → 10.2;2000KG+ → 10.0 | 开船后 38-45 天 |
@@ -104,6 +107,10 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过
### 配置说明
手机语音输入:点击输入区上方的麦克风按钮,再按住说话,松开后转文字并发送;上滑或取消触摸不发送,最长 60 秒。微信和手机浏览器使用同一服务端转写链路;页面必须通过 HTTPS 或本机 localhost 打开,并允许麦克风。旧版内置浏览器不支持录音时仍可文字输入。手机订单信息默认折叠,物流轨迹优先展示。
在根目录 `.env` 配置 `ASR_PROVIDER=baidu`、`BAIDU_ASR_API_KEY` 和 `BAIDU_ASR_SECRET_KEY`,使用已开通短语音识别的百度语音应用凭据,不能混用 OCR 或 DeepSeek 凭据。服务端须安装 FFmpeg 并加入 PATH,也可用 `FFMPEG_BIN` 指定可执行文件绝对路径;Docker 应用镜像已包含 FFmpeg。浏览器 WebM/MP4 录音通过内存管道转为 16 kHz 单声道 PCM,录音不写入上传目录或历史,只发送识别文字。修改后重启服务。可选的 `ASR_PROVIDER=dashscope` 使用 `.env.example` 中独立的 ASR 配置。
- 本地密钥放在项目根目录的 `.env` 中:首次运行复制 [`.env.example`](.env.example),填写 `DEEPSEEK_API_KEY=你的密钥`。`.env` 已被 Git 忽略,示例文件中的值应保持为空。
- 应用启动时自动读取根目录 `.env`,不依赖终端的当前目录;`am.py`、Windows 启动脚本和 WSGI 应用工厂均使用同一配置入口。修改密钥后须重启服务。
- 配置优先级为:显式传给 `create_app` 的配置 > 进程环境变量 > `.env` > 空值。环境变量即使为空也会覆盖 `.env`;本地填写后仍提示未配置时,请检查并移除旧的空环境变量。
@@ -114,7 +121,7 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过
### Jenkins 灰度部署
现有灰度环境按 [Jenkins 部署指南](docs/deployment.md) 发布:构建和测试镜像,SSH 发送至专用 Compose 服务,再配置独立域名的 HTTPS 和全站访问密码。密钥由 Jenkins 注入,数据和证书保存在独立宿主机目录;启动失败时恢复之前的应用和站点。面向客户开放前仍需补齐鉴权和数据隔离。另附 [Linux 单机参考](docs/deployment-linux.md)。
现有灰度环境按 [Jenkins 部署指南](docs/deployment.md) 发布:构建和测试镜像,SSH 发送至专用 Compose 服务,Nginx 提供 HTTPS。客户聊天入口免登录,只有明确列出的客户 API 开放;客服后台、搜索、导出和其他未知路径继续使用访问密码。密钥由 Jenkins 注入,数据和证书保存在独立宿主机目录;启动失败时恢复之前的应用和站点。上游端口必须仅绑定回环地址。另附 [Linux 单机参考](docs/deployment-linux.md)。
## 目录结构
@@ -132,7 +139,8 @@ baiyue-logistics-chat/
│ ├── knowledge.py 知识与关键词检索
│ ├── pricing.py 运费解析与计算
│ ├── rates_extra.py 扩展报价唯一数据源
│ ├── tracking.py 模拟轨迹与卡片协议
│ ├── tracking.py 卡片协议与旧演示函数兼容
│ ├── order_data.py 本地订单测试表、精确查询与快照导入
│ ├── ai_client.py 模型 HTTP、压缩和追问
│ ├── chat_service.py 问答与 SSE 编排
│ ├── history_service.py 会话 CRUD
@@ -201,7 +209,7 @@ py -3 scripts/close_active_sessions.py
|---|---|---|
| GET | `/` | 客户聊天页面 |
| GET / POST | `/stream_chat` | SSE 流式对话;POST JSON 接收 `msg`、`conv_id` |
| POST | `/api/new_chat` | 新建会话,返回 `id` |
| POST | `/api/new_chat` | 新建会话并保存欢迎语,返回 `id`、`welcome` |
| GET | `/api/get_history` | 会话列表 |
| GET | `/api/get_messages?conv_id=` | 单个会话的消息 |
| DELETE | `/api/del_history?id=` | 删除单个会话和消息 |
@@ -212,10 +220,18 @@ py -3 scripts/close_active_sessions.py
| POST | `/api/human/send` | 发送人工消息 |
| GET | `/api/human/poll` | 轮询人工消息 |
| POST | `/api/human/close` | 关闭人工会话 |
| POST | `/api/human/transfer` | 将接待中的会话转回排队列表 |
| POST | `/api/human/note` | 保存会话备注 |
| POST | `/api/human/recall` | 按 `mid` 撤回客服消息 |
| POST | `/api/rate` | 提交人工服务评价(`sid`、1–5 的 `stars`、`comment`) |
| GET | `/api/agents/list` | 客服列表,不包含密码 |
| GET | `/api/search_chat?q=` | 搜索历史消息,最多返回 50 条 |
| GET | `/admin` | 客服登录页面 |
| POST | `/admin/login` | 校验客服账号密码 |
| GET | `/admin/dashboard` | 客服工作台 |
| GET | `/api/export_chat` | 导出聊天记录为 CSV |
| GET | `/api/export_chat` | 导出 Excel;`?format=csv` 保留 CSV 导出 |
人工客服每天 8:00–22:00 接受新申请,由服务端校验时间;其余时间可提交离线留言。客服工作台保留白色界面,转接后由其他客服在排队列表主动接入。页面的铃铛按钮可开启浏览器通知,页面切到后台时提示排队或新消息;实际通知能力取决于浏览器权限。
SSE 每帧为一条 JSON,主要事件包括:
@@ -230,14 +246,32 @@ data: {"d": 1}
## 数据库与运行数据
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`agents`、`orders` 和 `tickets` 八张表,不自动创建客服账号或演示数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。游客咨询无需登录,查询订单时引导客户登录;查询按服务端账号关联的客户范围过滤。`ORDER_DATA_TENANT_ID` 是额外限制,不能代替客户身份。
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
默认前台客户账号为 `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 查询真实订单。
未发送的文字草稿按游客和会话分别保存在当前浏览器的 localStorage,当前标签页通过 sessionStorage 记录上次打开的会话;切换会话和刷新可恢复对应草稿,发送或删除会话后清理。草稿不跨设备同步,不恢复未发送的附件。浏览器禁止存储或空间不足时只在当前页面暂存,并提示刷新前保留文字;清除站点数据会同时删除草稿。历史加载失败期间禁止发送,重新选择会话或新建对话后再继续。
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
```powershell
.\.venv\Scripts\python.exe scripts/import_order_test_data.py --snapshot "D:\private\orders.snapshot.json" --db "D:\data\history.db"
```
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order` 的取数实现即可保留聊天契约。
首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `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。历史会话的自动清理仍已关闭。
## 当前限制
- 运单轨迹为模拟数据,尚未接入真实物流查询接口。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。公网自用验收可按部署指南使用全站访问密码进入工作台。
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史卡片继续兼容展示。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
+38 -6
View File
@@ -12,7 +12,7 @@ release=${2:?}
# Parse a small allowlist of non-secret release fields; never source a credential file.
while IFS='=' read -r name value; do
case "$name" in
CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY) ;;
CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY|CHAT_AUTH_DIR) ;;
*) echo 'Unexpected release setting.' >&2; exit 2 ;;
esac
[[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2
@@ -24,7 +24,9 @@ done < "$release/release.env"
[[ "$CHAT_IMAGE" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$HOST_PORT" =~ ^[0-9]+$ ]] && ((HOST_PORT >= 1024 && HOST_PORT <= 65535)) || exit 2
[[ -d "$CHAT_PROXY_DIR" && -r "$CHAT_TLS_CERT" && -r "$CHAT_TLS_KEY" ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$CHAT_IMAGE_ID" ]] || exit 2
loaded_id=$(awk -F= '$1 == "CHAT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded application image changed.' >&2; exit 2; }
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
openssl x509 -in "$CHAT_TLS_CERT" -checkhost "$CHAT_DOMAIN" -noout >/dev/null
@@ -41,7 +43,8 @@ if [[ -n "$(ss -H -ltn "sport = :$HOST_PORT")" && "$old_port" != "127.0.0.1:$HOS
fi
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
auth="$CHAT_PROXY_DIR/.baiyue-logistics-chat.htpasswd"
[[ "$CHAT_AUTH_DIR" == /*/baiyue-logistics-chat-gray-proxy && "$CHAT_AUTH_DIR" != *..* ]] || exit 2
auth="$CHAT_AUTH_DIR/access.htpasswd"
if [[ -f "$site" ]] && ! head -n 1 "$site" | grep -Fq 'Managed for baiyue-logistics-chat-gray'; then
echo 'The proxy file is not owned by this application.' >&2
exit 2
@@ -53,6 +56,13 @@ for candidate in "$CHAT_PROXY_DIR/"*.conf; do
exit 2
fi
done
# The Nginx config directory may be root-only (for example BaoTa). Keep this
# application's password hash in a separate worker-readable owned directory.
if [[ -e "$CHAT_AUTH_DIR" ]]; then
[[ -f "$CHAT_AUTH_DIR/.project" && "$(cat "$CHAT_AUTH_DIR/.project")" == baiyue-logistics-chat-gray ]] || exit 2
fi
install -d -m 755 "$CHAT_AUTH_DIR"
printf 'baiyue-logistics-chat-gray\n' > "$CHAT_AUTH_DIR/.project"
previous=''
if [[ -f "$root/current-release" ]]; then
previous=$(cat "$root/current-release")
@@ -68,6 +78,8 @@ proxy_changed=0
rollback() {
result=$?
trap - ERR
# ERR is inherited by command substitutions; only the parent may roll back.
if ((BASH_SUBSHELL)); then exit "$result"; fi
set +e
if ((proxy_changed)); then
if [[ -f "$release/previous-site.conf" ]]; then cp -p "$release/previous-site.conf" "$site"; else rm -f -- "$site"; fi
@@ -99,10 +111,30 @@ install -m 644 "$release/site.htpasswd" "$auth"
install -m 644 "$release/site.conf" "$site"
nginx -t
nginx -s reload
for path in / /api/export_chat /api/get_history; do
code=$(curl --silent --show-error --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path")
[[ "$code" == 401 ]] || { echo 'Gray access protection verification failed.' >&2; false; }
for check in /:200 /admin/dashboard:401 /api/export_chat:401 /api/get_history:401 /api/human/queue:401; do
path=${check%:*}
expected=${check##*:}
code=''
# A successful reload signal precedes the new workers accepting connections.
for attempt in {1..10}; do
if code=$(curl --silent --connect-timeout 3 --max-time 8 --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path"); then
[[ "$code" != "$expected" ]] || break
fi
sleep 1
done
[[ "$code" == "$expected" ]] || { echo 'Guest/staff access verification failed.' >&2; false; }
done
visitor_cookie=$(mktemp "$release/.visitor-smoke.XXXXXX")
trap 'rm -f -- "$visitor_cookie"' EXIT
code=$(curl --silent --show-error --max-time 10 --resolve "$CHAT_DOMAIN:443:127.0.0.1" \
-c "$visitor_cookie" -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \
-d '{}' "https://$CHAT_DOMAIN/api/visitor/session")
[[ "$code" == 200 ]] || { echo 'Guest session bootstrap verification failed.' >&2; false; }
code=$(curl --silent --show-error --max-time 10 --resolve "$CHAT_DOMAIN:443:127.0.0.1" \
-b "$visitor_cookie" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN/api/get_history")
[[ "$code" == 200 ]] || { echo 'Guest history verification failed.' >&2; false; }
rm -f -- "$visitor_cookie"
trap - EXIT
printf '%s\n' "$release" > "$root/.current-release.new"
mv -f "$root/.current-release.new" "$root/current-release"
trap - ERR
+2
View File
@@ -6,6 +6,8 @@ services:
pull_policy: never
restart: unless-stopped
init: true
environment:
TZ: Asia/Shanghai
ports:
- "127.0.0.1:${HOST_PORT:?HOST_PORT is required}:5000"
env_file:
+54 -20
View File
@@ -29,6 +29,7 @@ class GrayConfig:
image: str
image_id: str
certbot_image_id: str
auth_dir: str = '/var/lib/baiyue-logistics-chat-gray-proxy'
@classmethod
def from_environment(cls, environment):
@@ -70,11 +71,12 @@ class GrayConfig:
'CHAT_RUNTIME_ENV': self.release + '/runtime.env',
'CHAT_DOMAIN': self.domain, 'CHAT_PROXY_DIR': self.proxy_dir,
'CHAT_TLS_CERT': self.certificate, 'CHAT_TLS_KEY': self.private_key,
'CHAT_AUTH_DIR': self.auth_dir,
}
return ''.join(f'{key}={value}\n' for key, value in fields.items())
def nginx_config(self):
return f'''# Managed for baiyue-logistics-chat-gray; all application routes require authentication.
return f'''# Managed for baiyue-logistics-chat-gray; guest chat is public and staff routes require authentication.
server {{
listen 80;
server_name {self.domain};
@@ -91,35 +93,67 @@ server {{
ssl_certificate_key {self.private_key};
ssl_protocols TLSv1.2 TLSv1.3;
auth_basic "Baiyue gray acceptance";
auth_basic_user_file {self.proxy_dir}/.baiyue-logistics-chat.htpasswd;
auth_basic_user_file {self.auth_dir}/access.htpasswd;
client_max_body_size 12m;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
location = / {{
auth_basic off;
proxy_pass http://127.0.0.1:{self.port};
}}
location ^~ /static/ {{
auth_basic off;
proxy_pass http://127.0.0.1:{self.port};
}}
location ^~ /uploads/ {{
auth_basic off;
proxy_pass http://127.0.0.1:{self.port};
}}
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};
}}
location / {{
proxy_pass http://127.0.0.1:{self.port};
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}}
}}
'''
def write_release(directory, config, key):
if not key.strip() or any(character in key for character in ('\r', '\n', '\x00')):
raise ValueError('The model credential must be a nonempty single line')
def runtime_environment(key, speech=None):
fields = {'DEEPSEEK_API_KEY': key}
speech = speech or {}
provider = (speech.get('ASR_PROVIDER') or '').strip()
if provider:
if provider != 'baidu':
raise ValueError('Gray speech provider must be baidu or disabled')
fields.update(ASR_PROVIDER=provider,
BAIDU_ASR_API_KEY=speech.get('BAIDU_ASR_API_KEY', ''),
BAIDU_ASR_SECRET_KEY=speech.get('BAIDU_ASR_SECRET_KEY', ''))
for value in fields.values():
if not isinstance(value, str) or not value.strip() or any(c in value for c in ('\r', '\n', '\x00')):
raise ValueError('Runtime credentials must be nonempty single lines')
def literal(value):
return "'" + value.strip().replace('\\', '\\\\').replace("'", "\\'") + "'"
return ''.join(name + '=' + literal(value) + '\n' for name, value in fields.items())
def write_release(directory, config, key, speech=None):
runtime = runtime_environment(key, speech)
directory.mkdir(parents=True, exist_ok=False)
(directory / 'release.env').write_text(config.release_environment(), encoding='utf-8')
(directory / 'site.conf').write_text(config.nginx_config(), encoding='utf-8')
escaped = key.strip().replace('\\', '\\\\').replace("'", "\\'")
(directory / 'runtime.env').write_text("DEEPSEEK_API_KEY='" + escaped + "'\n", encoding='utf-8')
(directory / 'runtime.env').write_text(runtime, encoding='utf-8')
(directory / 'runtime.env').chmod(0o600)
source = Path(__file__).resolve().parent
shutil.copyfile(source / 'compose.gray.yml', directory / 'compose.yml')
@@ -134,7 +168,7 @@ def main():
args = parser.parse_args()
try:
config = GrayConfig.from_environment(os.environ)
write_release(args.directory, config, os.environ['DEEPSEEK_API_KEY'])
write_release(args.directory, config, os.environ['DEEPSEEK_API_KEY'], os.environ)
except (KeyError, ValueError) as error:
parser.exit(2, 'Gray release configuration is incomplete or invalid.\n')
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
umask 077
: "${ORDER_SNAPSHOT_FILE:?}" "${TRACKING_SNAPSHOT_FILE:?}"
: "${TARGET_SSH_KEY:?}" "${TARGET_SSH_USER:?}" "${TARGET_SSH_HOST:?}" "${TARGET_SSH_KNOWN_HOSTS:?}"
: "${GRAY_DEPLOY_DIR:?}" "${SOURCE_COMMIT:?}" "${BUILD_NUMBER:?}"
[[ "$TARGET_SSH_USER" =~ ^[A-Za-z0-9_.-]+$ && "$TARGET_SSH_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || exit 2
[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ && "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]] || exit 2
root=$GRAY_DEPLOY_DIR
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
release_id="${SOURCE_COMMIT:0:12}-b$BUILD_NUMBER"
release="$root/releases/$release_id"
order_sha=$(sha256sum "$ORDER_SNAPSHOT_FILE" | awk '{print $1}')
tracking_sha=$(sha256sum "$TRACKING_SNAPSHOT_FILE" | awk '{print $1}')
ssh_options=(-i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes
-o "UserKnownHostsFile=$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12)
target="$TARGET_SSH_USER@$TARGET_SSH_HOST"
# Names are fixed; credentials and snapshot contents never enter the shell log.
scp "${ssh_options[@]}" "$ORDER_SNAPSHOT_FILE" "$target:$release/order-input.json"
scp "${ssh_options[@]}" "$TRACKING_SNAPSHOT_FILE" "$target:$release/tracking-input.json"
ssh "${ssh_options[@]}" "$target" bash -s -- "$root" "$release_id" "$SOURCE_COMMIT" "$order_sha" "$tracking_sha" <<'IMPORT'
set -Eeuo pipefail
umask 077
root=$1
release_id=$2
source_commit=$3
order_sha=$4
tracking_sha=$5
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release_id" =~ ^[a-f0-9]{12}-b[0-9]+$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
[[ "$order_sha" =~ ^[a-f0-9]{64}$ && "$tracking_sha" =~ ^[a-f0-9]{64}$ ]] || exit 2
release="$root/releases/$release_id"
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray && "$(cat "$root/current-release")" == "$release" ]] || exit 2
container=baiyue-logistics-chat-gray
[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container")" == baiyue-logistics-chat-gray ]] || exit 2
[[ "$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container")" == "$source_commit" ]] || exit 2
printf '%s %s\n' "$order_sha" "$release/order-input.json" "$tracking_sha" "$release/tracking-input.json" | sha256sum --check --status
input="$root/data/imports/$release_id"
[[ ! -e "$input" ]] || { echo 'Existing import input requires inspection.' >&2; exit 2; }
cleanup() {
rm -f -- "$input/orders.json" "$input/tracking.json" "$release/order-input.json" "$release/tracking-input.json"
rmdir -- "$input" 2>/dev/null || true
}
trap cleanup EXIT
install -d -m 700 -o 10001 -g 10001 "$root/data/imports" "$input"
install -m 600 -o 10001 -g 10001 "$release/order-input.json" "$input/orders.json"
install -m 600 -o 10001 -g 10001 "$release/tracking-input.json" "$input/tracking.json"
docker exec "$container" python -c 'import pathlib,sqlite3,sys; p=pathlib.Path("/var/lib/baiyue-logistics-chat/backups")/("pre-order-import-"+sys.argv[1]+".db"); assert not p.exists(); source=sqlite3.connect("/var/lib/baiyue-logistics-chat/history.db"); target=sqlite3.connect(str(p)); source.backup(target); target.close(); source.close(); print("Chat SQLite backup completed before sample import.")' "$release_id"
docker exec "$container" python scripts/import_order_test_data.py \
--db /var/lib/baiyue-logistics-chat/history.db --snapshot "/var/lib/baiyue-logistics-chat/imports/$release_id/orders.json"
docker exec "$container" python scripts/import_order_test_data.py \
--db /var/lib/baiyue-logistics-chat/history.db --snapshot "/var/lib/baiyue-logistics-chat/imports/$release_id/tracking.json" --tracking-only
echo 'Private snapshots imported with existing order fields and nonempty tracking preserved.'
IMPORT
+20 -16
View File
@@ -24,28 +24,32 @@ server {
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Guard all pages, API routes, attachments and history/export operations.
# Staff and unknown routes remain protected; guest APIs check their own visitor owner.
auth_basic "Baiyue private acceptance";
auth_basic_user_file /etc/nginx/.baiyue-preview.htpasswd;
# Allow multipart overhead around the application's 10 MiB file limit.
client_max_body_size 12m;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# SSE must reach the browser as chunks arrive.
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
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))|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;
}
location / { proxy_pass http://127.0.0.1:5000; }
}
+3 -1
View File
@@ -18,7 +18,9 @@ done < "$release/release.env"
[[ "$CHAT_TLS_CERT" == "$root/tls/config/live/$CHAT_DOMAIN/fullchain.pem" ]] || exit 2
[[ "$CHAT_TLS_KEY" == "$root/tls/config/live/$CHAT_DOMAIN/privkey.pem" ]] || exit 2
[[ "$CHAT_CERTBOT_IMAGE" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$CHAT_CERTBOT_IMAGE_ID" ]] || exit 2
loaded_id=$(awk -F= '$1 == "CERTBOT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded certificate image changed.' >&2; exit 2; }
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_CERTBOT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
+33 -3
View File
@@ -40,7 +40,7 @@ release=$2
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release" == "$root"/releases/* ]] || exit 2
[[ "$(id -u)" == 0 ]] || { echo 'The managed gray deployment account must have the required host permissions.' >&2; exit 2; }
for program in docker nginx openssl curl ss systemctl; do command -v "$program" >/dev/null; done
for program in docker nginx openssl curl ss systemctl sha256sum; do command -v "$program" >/dev/null; done
if [[ -e "$root" && ! -f "$root/.project" ]] && [[ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
echo 'Refusing to reuse an unowned deployment directory.' >&2
exit 2
@@ -55,8 +55,38 @@ chown 10001:10001 "$root/data" "$root/logs"
install -d -m 700 "$release"
PREPARE
# Transport only tested images built without runtime credentials.
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip | run_ssh "bash -o pipefail -c 'gzip -dc | docker load'"
# Verify the exact exported archive before loading. Classic and containerd image
# stores can report different IDs for the same archive, so retain target IDs too.
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip > "$temporary/images.tar.gz"
archive_sha=$(sha256sum "$temporary/images.tar.gz" | awk '{print $1}')
scp "${ssh_options[@]}" "$temporary/images.tar.gz" "$target:$release/images.tar.gz"
run_ssh bash -s -- "$root" "$release" "$archive_sha" "$CHAT_IMAGE" "$CERTBOT_IMAGE" "$SOURCE_COMMIT" <<'LOAD'
set -Eeuo pipefail
umask 077
root=$1
release=$2
checksum=$3
app_image=$4
certbot_image=$5
source_commit=$6
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
[[ "$checksum" =~ ^[a-f0-9]{64}$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
[[ "$app_image" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$certbot_image" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
printf '%s %s\n' "$checksum" "$release/images.tar.gz" | sha256sum --check --status
gzip -dc "$release/images.tar.gz" | docker load
for image in "$app_image" "$certbot_image"; do
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" == "$source_commit" ]] || exit 2
done
app_id=$(docker image inspect --format '{{.Id}}' "$app_image")
certbot_id=$(docker image inspect --format '{{.Id}}' "$certbot_image")
[[ "$app_id" =~ ^sha256:[a-f0-9]{64}$ && "$certbot_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
printf 'ARCHIVE_SHA256=%s\nCHAT_IMAGE_ID=%s\nCERTBOT_IMAGE_ID=%s\n' "$checksum" "$app_id" "$certbot_id" > "$release/loaded-image-ids.env"
rm -f -- "$release/images.tar.gz"
echo 'Image archive checksum and source labels verified; target image IDs recorded.'
LOAD
scp "${ssh_options[@]}" "$temporary/release/"* "$target:$release/"
run_ssh bash "$release/prepare-tls.sh" "$root" "$release"
run_ssh bash "$release/activate.sh" "$root" "$release"
+28 -11
View File
@@ -26,13 +26,14 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 历史接口 | `logistics_chat/history_service.py`:`new_chat`、`get_history`、`get_messages`、`del_history`、`clear_all`;对应 `routes/history.py` | `static/js/chat.js`:`ensureConv`、`loadHistory`、`openConversation` |
| 知识检索 | `logistics_chat/knowledge.py`:`knowledge_list`、`search_knowledge` | [knowledge.md](knowledge.md) 与代码同步,程序不读取该文档 |
| 运费与附加费 | `logistics_chat/pricing.py`:`RATES`、`parse_shipping_intent`、`dims_to_cm`、`pick_rate`、`calc_freight`、`freight_reply`、`calc_warehouse_card`、`detect_surcharge`、`check_island` | `logistics_chat/rates_extra.py` 为唯一扩展数据源,根目录同名文件仅兼容转发 |
| 运单轨迹 | `logistics_chat/tracking.py`:`extract_tracking_no`、`build_track`、`CARD_PREFIX`、`card_to_text` | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;模拟节点时间取当前时刻,回归对照需固定时钟 |
| 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史卡片保留兼容渲染 |
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL |
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`;对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` |
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`,再加载对应 `chat`、`admin`、`admin_login` 的 CSS/JS;保留全局事件入口 |
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证异步交互 |
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`;客户与客服脚本先加载 `support.js`(表情、通知、评价);客服再加载 `admin_tools.js`(备注、转接、历史搜索),最后加载 `admin.js`;保留全局事件入口 |
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_dev_integration.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs`、`chat.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证草稿、异步切换、转接、撤回及评价;浏览器使用临时测试库 |
除标明的根目录文件外,表内缩写的 Python 文件均相对 `logistics_chat/`。
@@ -49,11 +50,12 @@ logistics_chat/
├── pricing.py # 运费规则、解析、换算、附加费说明
├── rates_extra.py # 扩展报价数据
├── tracking.py # 单号识别、模拟轨迹、卡片协议
├── order_data.py # 可编辑订单测试表、精确查询、快照导入与卡片事实
├── ai_client.py # 模型 HTTP、压缩与追问
├── chat_service.py # 问答编排与 SSE 帧
├── history_service.py # 历史会话 CRUD
├── human_service.py # 人工状态与消息
├── business_service.py # 反馈、统计、登录、订单、留言、CSV
├── business_service.py # 反馈、评价、统计、登录、订单、留言、历史搜索、Excel/CSV
├── file_service.py # 附件校验、保存、清理
├── hooks.py # 限流及 HTML/SSE 缓存头
├── runtime.py # Waitress / Flask 启动
@@ -85,7 +87,12 @@ tests/ # 按业务模块组织的回归
- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。
- 单文件围绕一个职责;超过约 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 页面,破图在聊天中显示可读提示。
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
## 后续结构调整顺序
@@ -101,13 +108,23 @@ tests/ # 按业务模块组织的回归
## 必须保留的接口与数据约定
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`,保留流式输出、停止、异常及中断保存。
- `chat_service.create_chat_stream` 在识别运单号时忽略 `[图片]/uploads/...` 和 `[文件]/uploads/...` 路径,避免附件文件名中的时间戳误触发查询;存储与请求仍保留原始附件标记和 URL。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent`。
- 计费档位、单位换算和舍入保持原样。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 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` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
- 客户认证使用独立 `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` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- 后台鉴权和会话隔离仍不完整,轨迹为模拟数据。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
- 手机语音由 `voice.js` 管理按住、松开、上滑取消、60 秒上限及媒体轨道释放;切换会话、离开页面或权限/转写超时取消录音与待处理请求,迟到结果不能进入新会话,已有草稿不自动提交。`/api/speech/config` 仅返回可用性及限制,`/api/speech/transcribe` 有独立 5MB 请求限制并以 `MemoryAudioRequest` 将音频文件保持在内存中;音频不进入 uploads 或聊天历史。百度适配通过 FFmpeg 管道转换 16 kHz 单声道 PCM,校验真实解码长度后调用百度短语音识别,Token 按应用隔离缓存。可选 DashScope 适配和 DS 文字聊天凭据独立,密钥及上游原始错误不得返回客户端。识别结果走既有 AI/人工消息入口。H5 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
- 游客由 `visitor.js` 用安全随机数生成本地 key,经 `/api/visitor/session` 换取 HttpOnly、SameSite Cookie,HTTPS 时设置 Secure;浏览器存储不可用时可用 Cookie 恢复。数据库 `chat_history.visitor_id` 仅存 key 的 SHA-256,历史增删查、SSE 上下文和 `visitor_uploads` 附件索引均按归属校验。旧数据归属为空时保留,不自动授予新访客。客户端公共人工接口为 `/api/visitor/human/{request,poll,send,close}`,校验会话归属、只接受 customer 消息并去掉内部备注;原 `/api/human/*` 留给受保护客服。客服附件用 `/api/staff/uploads/`,所有客户 JSON 与附件禁止共享缓存。
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录和订单关联尚未实现,游客身份不能升级为订单权限。
## 验证与维护
+4 -4
View File
@@ -2,9 +2,9 @@
现行灰度环境使用 [Jenkins 部署流程](deployment.md)。本页保留独立主机的安装方法,不用于覆盖已有灰度服务。
适用场景:Ubuntu / Debian、已有域名、仅自己验收。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx 全站访问密码 → 本机 Waitress → Flask / SQLite。
适用场景:Ubuntu / Debian、已有域名。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx(客户白名单公开,客服及其他路径需密码)→ 本机 Waitress → Flask / SQLite。
当前应用尚无完整登录会话、权限和会话数据隔离。验收期间必须保留全站访问密码,不能只保护 `/admin`,因为历史、导出和人工接口也能直接调用。此配置不代表已具备面向客户开放的权限体系。
游客历史、人工会话和附件已按访客归属隔离;客户账号登录和订单关联尚未接入,匿名不能查询真实订单。必须沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
## 1. 域名与网络
@@ -117,7 +117,7 @@ curl -u preview -I https://chat.example.com/
- 客户页:`https://chat.example.com/`,发送消息检查 DS 实际回复及流式显示。
- 验收工作台:`https://chat.example.com/admin/dashboard`,从客户页申请人工,再检查接待和双向消息。
- 当前未预置应用内客服登录账号;Nginx 的 `preview` 是整站验收密码,不是 `/admin` 页里的客服账号。
- 当前未预置应用内客服登录账号;Nginx 的 `preview` 是客服及受保护接口的访问账号,不是 `/admin` 页里的客服账号,客户首页无需此账号。
安装证书续期后的 Nginx 重载钩子,并检查续期定时器:
@@ -151,4 +151,4 @@ sudo nginx -t
## 参考
工厂调用与监听参数依据 [Waitress 官方文档](https://docs.pylonsproject.org/projects/waitress/en/stable/runner.html)。流式转发配置依据 [Nginx 代理模块](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering),全站密码依据 [Nginx Basic Auth 模块](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html)。证书和续期使用 [Certbot webroot](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot);服务目录与只读保护依据 [systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)。
工厂调用与监听参数依据 [Waitress 官方文档](https://docs.pylonsproject.org/projects/waitress/en/stable/runner.html)。流式转发配置依据 [Nginx 代理模块](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering),客服后台密码依据 [Nginx Basic Auth 模块](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html)。证书和续期使用 [Certbot webroot](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot);服务目录与只读保护依据 [systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)。
+19 -6
View File
@@ -1,8 +1,10 @@
# Jenkins 灰度部署
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS 和全站访问密码。
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。后台鉴权与会话隔离仍不完整,灰度验收须保留全站密码;此流程不代表已具备面向多个客户开放的权限体系。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录与订单关联尚未接入,匿名查单不会读取订单数据。
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
## Jenkins 任务
@@ -17,6 +19,7 @@
| `ENVIRONMENT` | 单选 `gray`,流水线拒绝其他值 |
| `SOURCE_REF` | 本次发布的不可变标签或提交,也可明确选择开发分支 |
| `PYTHON_BASE_IMAGE` | 已验证的官方 Python 3.12 基础镜像 |
| `DEBIAN_MIRROR` | 默认 `https://deb.debian.org`;国内可选 `https://mirrors.tuna.tsinghua.edu.cn`,保留 Debian 签名校验 |
| `PUBLIC_ORIGIN` | 独立 HTTPS 域名,不带子路径 |
| `HOST_PORT` | 已确认空闲的宿主机回环端口,如 `29013` |
| `GRAY_DEPLOY_DIR` | 专用目录,末级必须为 `gray-logistics-chat` |
@@ -27,19 +30,27 @@
| `SSH_KNOWN_HOSTS_CREDENTIAL_ID` | 已核实的 known_hosts 文件引用 |
| `DEEPSEEK_CREDENTIAL_ID` | 本服务模型密钥的 Secret text 引用 |
| `PREVIEW_AUTH_CREDENTIAL_ID` | 专用验收用户名和密码引用 |
| `ASR_PROVIDER` | `baidu` 启用语音;空值关闭 |
| `BAIDU_ASR_API_KEY_CREDENTIAL_ID` / `BAIDU_ASR_SECRET_KEY_CREDENTIAL_ID` | 本服务百度语音专用 Secret text 引用 |
| `IMPORT_ORDER_SAMPLES` | 默认关闭;明确需要补样本时才启用 |
| `ORDER_SNAPSHOT_CREDENTIAL_ID` / `TRACKING_SNAPSHOT_CREDENTIAL_ID` | 本次导入的临时 Secret file 引用 |
运行密钥和验收账号保存在私有灰度配置与 Jenkins 凭据库,供后续发布持续复用;轮换时同步更新。不要将密钥放在构建参数、Git URL、镜像 ARG、日志或示例文件中。
语音运行配置只注入 `ASR_PROVIDER`、`BAIDU_ASR_API_KEY`、`BAIDU_ASR_SECRET_KEY`,与 DS 密钥一起写入权限 600 的 `runtime.env`。容器使用镜像内的 FFmpeg,不传本机 `FFMPEG_BIN`,也不使用 OCR 凭据。缺少任一百度凭据时拒绝生成发布配置。
私有订单样本不进入 Git、构建上下文或镜像。可在明确授权后临时上传为 Jenkins Secret file 并启用 `IMPORT_ORDER_SAMPLES`;发布脚本核验快照校验和与实际运行提交,在线备份聊天 SQLite,再调用 `scripts/import_order_test_data.py` 默认导入订单、以 `--tracking-only` 补充轨迹。两步均不使用 `--replace`,保留手工订单字段和已有非空轨迹。临时文件在导入后清除;验收后移除临时 Jenkins 文件凭据并关闭导入开关。此操作只访问聊天服务自己的 SQLite。
## 发布步骤
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名、源提交和镜像 ID,生成私有配置并发送镜像及发布文件。
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名和源提交,生成私有配置并发送镜像及发布文件。服务器验证镜像归档的 SHA-256 后加载,记录目标主机的镜像 ID。
5. 首次签发域名证书,等待应用健康,再安装本服务的 Nginx 配置。已有业务的容器和站点配置不参与本次发布。
6. Jenkins 使用验收账号验证公网 HTTPS `/healthz`;之后在浏览器验证 AI 流式回复、上传、转人工和工作台。
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,接收主机再次核验镜像 ID,不依赖 `latest`。
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,不依赖 `latest`。Docker 的 classic 与 containerd 镜像存储可能返回不同 ID;跨主机以归档校验和与源提交标签核验传输,激活前再与 `loaded-image-ids.env` 中的目标 ID 比较,拒绝加载后被替换的镜像。归档加载后删除,构建机 ID 保留在 `release.env` 供追溯。
## 数据与失败回退
@@ -56,9 +67,11 @@
模型密钥写入权限为 600 的 `runtime.env`,由 Compose 注入进程。验收密码只在 Jenkins 生成 Nginx 哈希,服务器保存哈希文件。
密码哈希位于独立的 `/var/lib/baiyue-logistics-chat-gray-proxy/access.htpasswd`,目录 755、文件 644,供 Nginx 工作进程读取;不放在可能只有 root 可访问的宝塔站点配置目录,也不放明文密码。该目录有本服务所有权标记,不复用未知目录。
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
新环境自动创建八张业务表,不创建默认客服账号或演示数据。验收用全站密码直接进入工作台;Nginx 验收账号不是应用内客服账号。
新环境自动创建业务表,不创建默认客服账号或演示数据。专用访问密码用于客服工作台及受保护接口;Nginx 账号不是应用内客服账号。客户首页无需密码,客户端先自动建立访客会话,再访问自己的历史。
## HTTPS 及续期
@@ -90,7 +103,7 @@ nginx -t
- SSH 失败:核对受管凭据和 known_hosts,不禁用指纹检查。
- 证书失败:核对 DNS 和 HTTP 验证路径,保留本服务的验证站点与私有 ACME 状态。
- 容器不健康:检查独立目录的 UID 10001 权限、日志和 `/healthz`;健康检查不计入业务限流。
- 入口 401:未登录时为预期,使用专用验收密码后应返回 200。
- 客户首页应匿名返回 200,不出现密码弹窗;客服后台、全站搜索和导出匿名返回 401。客户历史 API 在未建立访客会话时由应用返回 JSON 401,自动建立 `/api/visitor/session` 后应返回当前访客的记录。发布激活脚本同时检查这些边界,不能只删除原有密码配置。
- 流式显示延迟:检查实际 Nginx 或 CDN 是否重新启用了响应缓冲。
独立主机安装另见 [Linux 手动参考](deployment-linux.md)。凭据与 API 依据 [Jenkins 官方文档](https://www.jenkins.io/doc/pipeline/steps/credentials-binding/) 和 [Remote API](https://www.jenkins.io/doc/book/using/remote-access-api/),证书验证与续期依据 [Certbot 文档](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot)。
+8 -1
View File
@@ -5,21 +5,28 @@ 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
from . import customer_service, db, file_service, hooks, visitor_service
from .routes import register_routes
from .routes.speech import MemoryAudioRequest
app = Flask(__name__, template_folder=str(ROOT_DIR/'templates'),
static_folder=str(ROOT_DIR/'static'))
app.request_class = MemoryAudioRequest
app.config.update(default_config())
if config:
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)
+8
View File
@@ -29,6 +29,7 @@ def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')):
"""回答超长时,让模型压缩到规定字数内;失败则原样返回"""
if len(text) <= limit:
return text
r = None
try:
body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': (EN_COMPRESS_RULE if has_chinese(text) is False else '') + f'你是文字精简助手。把用户给的内容压缩到 {limit} 字以内(必须严格满足,超出即为不合格),必须保留所有数字、金额、时效和渠道名称,删除重复表述、客套话和次要说明,结构保持不变。'}, {'role': 'user', 'content': text}], 'stream': False, 'max_tokens': 800}
r = post_completion(body, timeout=(5, 30))
@@ -39,6 +40,9 @@ def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')):
print(f'[压缩失败] 状态码 {r.status_code}')
except Exception as e:
print(f'[压缩异常] {e}')
finally:
if r is not None:
r.close()
return text
@@ -46,6 +50,7 @@ def gen_followups(user_msg, answer_text):
"""根据本轮问答,生成 3 个客户可能接着问的问题(失败返回空列表,不影响主流程)"""
if not get_setting('DEEPSEEK_API_KEY'):
return []
r = None
try:
body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': '你是物流客服助手。根据用户问题和你的回答,生成3个客户最可能接着问的简短问题(每个不超过20字),只输出JSON字符串数组,不要任何解释。'}, {'role': 'user', 'content': f'用户问:{user_msg}\n你的回答:{answer_text[:500]}\n\n请生成3个追问:'}], 'stream': False, 'max_tokens': 200}
r = post_completion(body, timeout=(5, 15))
@@ -58,4 +63,7 @@ def gen_followups(user_msg, answer_text):
return [str(x).strip() for x in arr if str(x).strip()][:3]
except Exception:
pass
finally:
if r is not None:
r.close()
return []
+89 -12
View File
@@ -3,12 +3,22 @@
from datetime import datetime
from .db import connect, now_str
from .tracking import card_to_text
def feedback(data):
"""收集 👍/👎 反馈,存到 feedback 表"""
if not isinstance(data, dict):
return {'ok': False, 'error': '评价格式无效。'}, 400
raw_vote = data.get('vote')
votes = {'up': 1, 'down': -1, '1': 1, '-1': -1}
vote = votes.get(raw_vote) if isinstance(raw_vote, str) else raw_vote
question, answer = data.get('question', ''), data.get('answer', '')
if type(vote) is not int or vote not in (-1, 1) or not isinstance(question, str) or not isinstance(answer, str):
return {'ok': False, 'error': '评价内容无效,请重试。'}, 400
conn = connect()
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str()))
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)',
(question[:500], answer[:2000], vote, now_str()))
conn.commit()
conn.close()
return {'ok': True}
@@ -18,12 +28,14 @@ def admin_stats():
conn = connect()
total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0]
total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0]
good = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=1').fetchone()[0]
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0]
good = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('1', 'up')").fetchone()[0]
bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('-1', 'down')").fetchone()[0]
today = datetime.now().strftime('%Y-%m-%d')
today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0]
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
conn.close()
return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv}
return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv,
'rating_avg': round(rating_avg, 1), 'rating_count': rating_count}
def my_orders(phone):
@@ -36,8 +48,16 @@ def my_orders(phone):
def add_ticket(data):
if not isinstance(data, dict):
return {'ok': False, 'error': '留言格式无效。'}, 400
content, phone = data.get('content'), data.get('phone', '')
if not isinstance(content, str) or not 1 <= len(content.strip()) <= 2000:
return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400
if not isinstance(phone, str) or len(phone.strip()) > 32:
return {'ok': False, 'error': '联系电话格式无效。'}, 400
conn = connect()
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)',
(content.strip(), phone.strip(), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
conn.commit()
conn.close()
return {'ok': True}
@@ -61,15 +81,72 @@ def admin_login(data):
return ({'ok': False}, 401)
def export_chat():
def agents_list():
conn = connect()
# rowid also works with the legacy agents table that has an explicit INTEGER id.
rows = conn.execute('SELECT rowid, username, name FROM agents ORDER BY rowid').fetchall()
conn.close()
return [{'id': row[0], 'username': row[1], 'name': row[2]} for row in rows]
def rate_chat(data):
sid, stars = data.get('sid'), data.get('stars', 0)
comment = data.get('comment', '')
if not sid or type(stars) is not int or not 1 <= stars <= 5 or not isinstance(comment, str):
return ({'ok': False}, 400)
conn = connect()
if not conn.execute('SELECT 1 FROM human_sessions WHERE id=?', (sid,)).fetchone():
conn.close()
return ({'ok': False}, 404)
conn.execute('INSERT INTO ratings(sid, stars, comment, ctime) VALUES(?,?,?,?)',
(sid, stars, comment, datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
conn.commit()
conn.close()
return {'ok': True}
def search_chat(keyword):
if not keyword:
return {'results': []}
conn = connect()
rows = conn.execute('''SELECT h.title, m.role, m.content, m.ctime FROM messages m
JOIN chat_history h ON m.conv_id=h.id WHERE m.content LIKE ? ORDER BY m.id DESC LIMIT 50''',
('%' + keyword + '%',)).fetchall()
conn.close()
return [{'title': row[0], 'role': row[1], 'content': card_to_text(row[2]), 'ctime': row[3]} for row in rows]
def export_chat(format='xlsx'):
conn = connect()
rows = conn.execute('SELECT h.title, m.role, m.content, m.ctime FROM messages m JOIN chat_history h ON m.conv_id=h.id ORDER BY m.id').fetchall()
conn.close()
import csv
import io
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(['标题', '角色', '内容', '时间'])
for r in rows:
w.writerow([r[0], r[1], r[2] or '', r[3]])
if format == 'csv':
import csv
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(['标题', '角色', '内容', '时间'])
for r in rows:
w.writerow([r[0], r[1], r[2] or '', r[3]])
return buf.getvalue()
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
workbook = Workbook()
sheet = workbook.active
sheet.title = '聊天记录'
sheet.append(['对话标题', '角色', '内容', '时间'])
for cell in sheet[1]:
cell.font = Font(bold=True, color='FFFFFF')
cell.fill = PatternFill('solid', fgColor='315FD5')
cell.alignment = Alignment(horizontal='center')
roles = {'user': '客户', 'assistant': '客服', 'system': '系统'}
for title, role, content, ctime in rows:
sheet.append([title, roles.get(role, role), content or '', ctime])
for cell in sheet[sheet.max_row]:
cell.data_type = 's'
for column, width in zip('ABCD', (20, 8, 80, 20)):
sheet.column_dimensions[column].width = width
buf = io.BytesIO()
workbook.save(buf)
return buf.getvalue()
+112 -28
View File
@@ -9,8 +9,10 @@ from .config import get_setting
from .db import connect, last_user_question, load_context, save_message
from .knowledge import search_knowledge
from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply,
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island)
from .tracking import CARD_PREFIX, extract_tracking_no, build_track
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island,
is_shipping_destination_reply)
from .tracking import CARD_PREFIX
from .order_data import answer_query, extract_reference
@@ -20,45 +22,112 @@ def sse(payload):
return 'data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n'
QUOTE_FOLLOWUP_PREFIX = r'^(?:那(?:么)?|换成|改成|改为|如果|还是|再算|同样)'
def is_shipping_followup(message, intent=None):
"""Recognize bounded quote edits without carrying facts across new topics."""
if re.search(r'新(?:的)?一(?:票|批)|另(?:一|外一?)(?:票|批)|新(?:的)?(?:货|订单)|换个话题|理赔|赔偿|破损|丢失|签收|退件|退款|投诉', message):
return False
intent = intent if intent is not None else parse_shipping_intent(message)
text = message.strip().strip(',,。.!!??、:: ')
prefix = re.match(QUOTE_FOLLOWUP_PREFIX, text)
if prefix and any(intent.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')):
return True
if intent['region'] is not None and is_shipping_destination_reply(text):
return True
short = re.sub(QUOTE_FOLLOWUP_PREFIX, '', text, count=1).strip()
short = re.sub(r'^(?:请问|请|再问一下)', '', short).strip()
if re.fullmatch(r'(?:海运|海派|空运|空派|铁路|班列|卡航|卡车|sea(?:\s+freight)?|air(?:\s+freight)?|rail|truck)'
r'(?:报价|价格|费用|运费|时效)?(?:呢|怎么样|如何|多少钱|多久|几天)?', short, re.I):
return True
if re.fullmatch(r'(?:价格|费用|运费|时效)(?:呢|是多少|多少|怎么样|如何|多久|几天)?|多少钱', short):
return True
if intent['weight'] is not None and re.fullmatch(r'\d+(?:\.\d+)?\s*(?:kg|公斤|千克|吨|lbs?|tons?|kilograms?)(?:呢)?', short, re.I):
return True
return bool(intent['dims'] and re.fullmatch(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*(?:cm|mm|m|厘米|毫米|米)?(?:呢)?', short, re.I))
def shipping_intent_with_context(message, conv_id):
intent = parse_shipping_intent(message)
intent['dim_unit'] = detect_dim_unit(message)
if conv_id and is_shipping_followup(message, intent):
connection = connect()
try:
recent = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 6", (conv_id,)).fetchall()
finally:
connection.close()
for (previous,) in recent:
if extract_reference(previous):
break
earlier = parse_shipping_intent(previous)
if not any(earlier.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')):
if is_shipping_followup(previous, earlier):
continue
break
for key in ('weight', 'channel', 'region'):
if intent.get(key) is None:
intent[key] = earlier.get(key)
if intent['dims'] is None and earlier['dims']:
intent['dims'] = earlier['dims']
intent['dim_unit'] = detect_dim_unit(previous)
if not is_shipping_followup(previous, earlier):
break
if intent['channel']:
base = intent['channel'].removesuffix('_uk')
intent['channel'] = base + '_uk' if intent['region'] == 'uk' else base
return intent
def create_chat_stream(msg, conv_id):
if not msg:
return (sse({'e': '问题不能为空'}), {})
if '[图片]' in msg and len(msg) < 300:
return (sse({'t': '收到您的图片,请描述一下您的问题。'}), {})
track_no = extract_tracking_no(re.sub(r'\[(?:图片|文件)\]/uploads/\S+', '', msg))
if track_no:
card = build_track(track_no)
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):
order_answer = {'t':'收到您的图片,请描述一下您的问题。'}
ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None
if ship and ship['region'] is None and (ship['weight'] is not None or ship['dims'] or
re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or
(ship['channel'] and (is_shipping_followup(msg, ship) or re.search(r'报价|价格|费用|运费|多少钱|\b(?:quote|cost|price)\b', msg, re.I)))):
order_answer = {'t':'请提供目的国家或地区、目的仓或邮编,以便匹配对应费率;当前无法确定报价,可联系人工客服确认。'}
if ship and ship['region'] == 'unsupported' and (ship['weight'] is not None or ship['dims'] or ship['channel'] or
is_shipping_destination_reply(msg) or re.search(r'报价|价格|运费|费用|多少钱', msg)):
order_answer = {'t':'当前报价表尚未包含该目的地的费率,请提供目的仓或邮编、货物品名、件数和尺寸,由人工客服确认报价。'}
if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']:
order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'}
if order_answer is not None:
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)
yield sse({'card': card})
save_message(conv_id, 'assistant', CARD_PREFIX + json.dumps(card, ensure_ascii=False))
content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False)
if 'card' in order_answer else order_answer['t'])
save_message(conv_id, 'assistant', content)
yield sse(order_answer)
yield sse({'d': 1})
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
if not get_setting('DEEPSEEK_API_KEY'):
return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {})
know_text = search_knowledge(msg)
ship = parse_shipping_intent(msg)
ship['dim_unit'] = detect_dim_unit(msg)
freight_text = None
if not ship['weight'] and (not ship['dims']) and ship['channel']:
prev_q = last_user_question(conv_id)
if prev_q:
prev_ship = parse_shipping_intent(prev_q)
if prev_ship['weight'] or prev_ship['dims']:
ship['weight'] = prev_ship['weight']
ship['dims'] = prev_ship['dims']
ship['dim_unit'] = detect_dim_unit(prev_q)
if ship['weight'] or ship['dims']:
if re.search('英国|UK|伦敦|英格兰', msg, re.I):
uk_map = {'sea': 'sea_uk', 'rail': 'rail_uk', 'truck': 'truck_uk', 'air': 'air_uk'}
ship['channel'] = uk_map.get(ship['channel'])
freight_text = freight_reply(ship)
if not ship['channel']:
freight_text += '\n\n(客户没有指定渠道,上面已同时给出海运和空运,请在回答末尾询问更看重时效还是成本)'
extra_parts = []
cbm_m = re.search('(\\d+(?:\\.\\d+)?)\\s*(?:cbm|方|立方)', msg, re.I)
if cbm_m:
if cbm_m and ship['region'] != 'uk':
cbm = float(cbm_m.group(1))
ch = '海运'
if re.search('卡航|卡车', msg):
@@ -94,7 +163,7 @@ def create_chat_stream(msg, conv_id):
if know_text:
follow_src = prev_q
_c = connect()
_msg_count = _c.execute('SELECT COUNT(*) FROM messages WHERE conv_id=?', (conv_id,)).fetchone()[0]
_msg_count = _c.execute("SELECT COUNT(*) FROM messages WHERE conv_id=? AND role='user'", (conv_id,)).fetchone()[0]
_c.close()
is_first_turn = _msg_count == 0
save_message(conv_id, 'user', msg)
@@ -144,8 +213,11 @@ def create_chat_stream(msg, conv_id):
return
parts = []
interrupted = False
answer_saved = False
try:
got_text = False
completed = False
finish_reason = None
for raw in resp.iter_lines():
if not raw:
continue
@@ -154,10 +226,15 @@ def create_chat_stream(msg, conv_id):
continue
data = line[5:].strip()
if data == '[DONE]':
completed = True
break
try:
j = json.loads(data)
chunk = j['choices'][0]['delta'].get('content') or ''
choice = j['choices'][0]
chunk = choice.get('delta', {}).get('content') or ''
finish_reason = choice.get('finish_reason') or finish_reason
if finish_reason == 'stop':
completed = True
except Exception:
continue
if chunk:
@@ -167,6 +244,10 @@ def create_chat_stream(msg, conv_id):
if not got_text:
yield sse({'e': '模型没有返回内容,请重试'})
return
if not completed or finish_reason not in (None, 'stop'):
interrupted = True
yield sse({'e': '回答未生成完整,请点击重试'})
return
full_text = ''.join(parts)
limit = get_setting('MAX_ANSWER_CHARS') if has_chinese(full_text) else get_setting('MAX_ANSWER_CHARS_EN')
if len(full_text) > limit:
@@ -177,6 +258,7 @@ def create_chat_stream(msg, conv_id):
full_text = short
yield sse({'replace': short})
save_message(conv_id, 'assistant', full_text)
answer_saved = True
if is_first_turn:
try:
fu = gen_followups(msg, full_text)
@@ -193,7 +275,9 @@ def create_chat_stream(msg, conv_id):
yield sse({'e': '传输中断,请点击重试'})
finally:
text = ''.join(parts)
if interrupted and len(text) >= 20:
save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)')
resp.close()
try:
if interrupted and text and not answer_saved:
save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)')
finally:
resp.close()
return (generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
+15
View File
@@ -20,6 +20,21 @@ def default_config():
'BACKUP_DIR': ROOT_DIR / 'backups',
'LOG_DIR': ROOT_DIR / 'logs',
'DEEPSEEK_API_KEY': (api_key or '').strip(),
'ASR_API_KEY': str(os.environ.get('ASR_API_KEY', local_config.get('ASR_API_KEY')) or '').strip(),
'ASR_PROVIDER': str(os.environ.get('ASR_PROVIDER', local_config.get('ASR_PROVIDER')) or 'baidu').strip().lower(),
'BAIDU_ASR_API_KEY': str(os.environ.get('BAIDU_ASR_API_KEY', local_config.get('BAIDU_ASR_API_KEY')) or '').strip(),
'BAIDU_ASR_SECRET_KEY': str(os.environ.get('BAIDU_ASR_SECRET_KEY', local_config.get('BAIDU_ASR_SECRET_KEY')) or '').strip(),
'FFMPEG_BIN': str(os.environ.get('FFMPEG_BIN', local_config.get('FFMPEG_BIN')) or '').strip(),
'ASR_URL': str(os.environ.get('ASR_URL', local_config.get('ASR_URL')) or
'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,
+225
View File
@@ -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
+43 -1
View File
@@ -37,6 +37,8 @@ def init_db():
Path(get_setting('DATABASE')).parent.mkdir(parents=True, exist_ok=True)
conn = connect()
cur = conn.cursor()
# Serialize schema inspection and migration when multiple workers start together.
cur.execute('BEGIN IMMEDIATE')
cur.execute('''
CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -48,6 +50,23 @@ def init_db():
if 'utime' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN utime TEXT')
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
)''')
cur.execute('''
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -87,6 +106,20 @@ def init_db():
hm_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_messages)').fetchall()]
if 'read_at' not in hm_cols:
cur.execute('ALTER TABLE human_messages ADD COLUMN read_at TEXT')
if 'recalled_id' not in hm_cols:
cur.execute('ALTER TABLE human_messages ADD COLUMN recalled_id INTEGER')
hs_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_sessions)').fetchall()]
if 'note' not in hs_cols:
cur.execute("ALTER TABLE human_sessions ADD COLUMN note TEXT DEFAULT ''")
cur.execute('''
CREATE TABLE IF NOT EXISTS ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sid INTEGER NOT NULL,
stars INTEGER NOT NULL,
comment TEXT DEFAULT '',
ctime TEXT NOT NULL
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS agents (
username TEXT PRIMARY KEY,
@@ -112,6 +145,10 @@ def init_db():
status TEXT
)
''')
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()))
@@ -187,7 +224,12 @@ def save_message(conv_id, role, content):
if last and last[0] == 'user' and (last[1] == content):
conn.close()
return 'duplicate'
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (conv_id, role, content, now_str()))
inserted = cur.execute('INSERT INTO messages(conv_id, role, content, ctime) '
'SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM chat_history WHERE id=?)',
(conv_id, role, content, now_str(), conv_id)).rowcount
if not inserted:
conn.close()
return 'deleted'
if role == 'user':
row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone()
if row and (not row[0] or row[0] == '新对话'):
+28 -3
View File
@@ -1,22 +1,36 @@
"""Attachment validation, storage and retention without request parsing."""
import os
import random
import secrets
import re
import time
from datetime import datetime
from urllib.parse import unquote
from .config import get_setting
def cleanup_uploads():
"""删除 uploads/ 里 7 天前的图片"""
"""Keep attachments referenced by chat history; expire unused uploads after 7 days."""
try:
udir = get_setting('UPLOAD_DIR')
if not os.path.isdir(udir):
return
from .db import connect
connection = connect()
try:
referenced = set()
rows = connection.execute("SELECT content FROM messages WHERE content LIKE '%/uploads/%' "
"UNION ALL SELECT content FROM human_messages WHERE content LIKE '%/uploads/%'")
for (content,) in rows:
referenced.update(unquote(name) for name in re.findall(r'/uploads/([A-Za-z0-9_.%-]+)', content or ''))
finally:
connection.close()
cutoff = time.time() - 7 * 86400
for f in os.listdir(udir):
if f in referenced:
continue
fp = os.path.join(udir, f)
if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
os.remove(fp)
@@ -40,7 +54,18 @@ def upload_file(files):
if size > get_setting('MAX_UPLOAD_MB') * 1024 * 1024:
return ({'ok': False, 'error': f"文件超过 {get_setting('MAX_UPLOAD_MB')}MB"}, 400)
os.makedirs(get_setting('UPLOAD_DIR'), exist_ok=True)
safe_name = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{random.randint(1000, 9999)}{ext}"
safe_name = secrets.token_hex(16) + ext
f.save(os.path.join(get_setting('UPLOAD_DIR'), safe_name))
from .db import connect, now_str
from .visitor_service import visitor_id
owner = visitor_id()
if owner:
connection = connect()
try:
with connection:
connection.execute('INSERT INTO visitor_uploads(filename,visitor_id,ctime) VALUES(?,?,?)',
(safe_name, owner, now_str()))
finally:
connection.close()
is_image = ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp')
return {'ok': True, 'name': f.filename, 'url': f'/uploads/{safe_name}', 'is_image': is_image}
+27 -5
View File
@@ -1,27 +1,39 @@
"""Conversation CRUD with unchanged response data."""
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 = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。'
def new_chat():
now = now_str()
conn = connect()
cur = conn.cursor()
cur.execute('INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)', ('新对话', now, now))
cur.execute('INSERT INTO chat_history(title, ctime, utime, visitor_id) VALUES (?,?,?,?)',
('新对话', now, now, visitor_id()))
conv_id = cur.lastrowid
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)',
(conv_id, 'assistant', WELCOME_TEXT, now))
conn.commit()
conn.close()
return {'id': conv_id}
return {'id': conv_id, 'welcome': WELCOME_TEXT}
def get_history():
conn = connect()
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history ORDER BY COALESCE(utime, ctime) DESC, id DESC').fetchall()
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history WHERE visitor_id=? '
'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]
def get_messages(conv_id):
if not owns_conversation(conv_id):
return []
conn = connect()
rows = conn.execute('SELECT role, content, ctime FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 100', (conv_id,)).fetchall()
conn.close()
@@ -30,8 +42,12 @@ def get_messages(conv_id):
def del_history(hid):
if not owns_conversation(hid):
return {'ok': False}, 404
conn = connect()
cur = conn.cursor()
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions WHERE conv_id=?)', (hid,))
cur.execute('DELETE FROM human_sessions WHERE conv_id=?', (hid,))
cur.execute('DELETE FROM messages WHERE conv_id=?', (hid,))
cur.execute('DELETE FROM chat_history WHERE id=?', (hid,))
conn.commit()
@@ -42,8 +58,14 @@ def del_history(hid):
def clear_all():
conn = connect()
cur = conn.cursor()
cur.execute('DELETE FROM messages')
cur.execute('DELETE FROM chat_history')
owner = 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+'))', 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}
+2 -2
View File
@@ -7,7 +7,7 @@ from flask import current_app, jsonify, request
def rate_limit():
path = request.path
if path == '/healthz' or path.startswith('/static') or path.startswith('/api/human/'):
if path in ('/healthz', '/api/visitor/human/poll') or path.startswith('/static') or path.startswith('/api/human/'):
return None
ip = request.remote_addr or 'unknown'
now = time.time()
@@ -21,7 +21,7 @@ def rate_limit():
def no_cache_html(response):
if response.mimetype in ('text/html', 'text/event-stream'):
if response.mimetype in ('text/html', 'text/event-stream') or request.path.startswith('/api/'):
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
+79 -9
View File
@@ -1,17 +1,26 @@
"""Human conversation lifecycle; typing state is supplied by the app."""
import time
from datetime import datetime
from datetime import datetime, timedelta, timezone
from .db import connect
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
def human_request(data):
conv_id = data.get('conv_id')
if not conv_id:
return ({'ok': False, 'error': 'no conv'}, 400)
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
service_time = datetime.now(SERVICE_TIMEZONE)
if not 8 <= service_time.hour < 22:
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天北京时间8:00-22:00,当前为非工作时间,请留言'}
now = service_time.strftime('%Y-%m-%d %H:%M:%S')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone():
conn.close()
return ({'ok':False, 'error':'conversation_not_found'}, 404)
row = conn.execute("SELECT id FROM human_sessions WHERE conv_id=? AND status!='closed' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
if row:
sid = row[0]
@@ -25,24 +34,35 @@ def human_request(data):
def human_poll(sid, after, typing_map):
conn = connect()
msgs = conn.execute('SELECT id, side, content, ctime, read_at FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
st = conn.execute('SELECT status FROM human_sessions WHERE id=?', (sid,)).fetchone()
msgs = conn.execute('SELECT id, side, content, ctime, read_at, recalled_id FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
st = conn.execute("SELECT status, COALESCE(note, '') FROM human_sessions WHERE id=?", (sid,)).fetchone()
conn.close()
is_typing = False
ts = typing_map.get(str(sid))
if ts and time.time() - ts < 5:
is_typing = True
return {'status': st[0] if st else 'closed', 'typing': is_typing, 'messages': [{'id': m[0], 'side': m[1], 'content': m[2], 'ctime': m[3], 'read': bool(m[4])} for m in msgs]}
return {'status': st[0] if st else 'closed', 'note': st[1] if st else '',
'typing': is_typing, 'messages': [dict(id=m[0], side=m[1], content=m[2], ctime=m[3],
read=bool(m[4]), **({'recalled_id': m[5]} if m[5] else {})) for m in msgs]}
def human_send(data):
sid = data.get('sid')
side = data.get('side', 'customer')
content = (data.get('content') or '').strip()
if not sid or not content:
content = data.get('content') or ''
if not sid or not isinstance(content, str) or not content.strip() or side not in ('customer', 'agent'):
return ({'ok': False}, 400)
content = content.strip()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
row = conn.execute('SELECT conv_id, status FROM human_sessions WHERE id=?', (sid,)).fetchone()
if not row:
conn.close()
return ({'ok': False, 'error': 'session_not_found'}, 404)
if row[1] == 'closed' or (data.get('conv_id') is not None and data['conv_id'] != row[0]):
conn.close()
return ({'ok': False, 'error': 'conversation_changed'}, 409)
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now))
conn.commit()
conn.close()
@@ -66,10 +86,11 @@ def human_active():
def human_accept(data):
sid = data.get('sid')
conn = connect()
conn.execute("UPDATE human_sessions SET status='active' WHERE id=?", (sid,))
changed = conn.execute("UPDATE human_sessions SET status='active' WHERE id=? AND status IN ('waiting','active') "
'AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)', (sid,)).rowcount
conn.commit()
conn.close()
return {'ok': True}
return {'ok': True} if changed else ({'ok':False, 'error':'session_closed_or_missing'}, 409)
def human_close(data):
@@ -81,6 +102,55 @@ def human_close(data):
return {'ok': True}
def human_transfer(data, typing_map):
sid = data.get('sid')
conn = connect()
changed = conn.execute("UPDATE human_sessions SET status='waiting' WHERE id=? AND status='active'", (sid,)).rowcount
if not changed:
conn.close()
return ({'ok': False, 'error': '会话当前无法转接'}, 409)
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)',
(sid, 'system', '客服已转接,请其他客服接入', datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
conn.commit()
conn.close()
typing_map.pop(str(sid), None)
return {'ok': True}
def human_note(data):
note = data.get('note', '')
if not isinstance(note, str):
return ({'ok': False}, 400)
conn = connect()
changed = conn.execute('UPDATE human_sessions SET note=? WHERE id=?', (note, data.get('sid'))).rowcount
conn.commit()
conn.close()
return {'ok': True} if changed else ({'ok': False}, 404)
def human_recall(data):
conn = connect()
mid = data.get('mid')
# Only the first request can change the original message; retries emit no duplicate event.
conn.execute('BEGIN IMMEDIATE')
row = conn.execute('SELECT session_id, side, content FROM human_messages WHERE id=?', (mid,)).fetchone()
if not row:
conn.close()
return ({'ok': False}, 404)
if row[1] == 'system' and row[2] == '该消息已撤回':
conn.close()
return {'ok': True}
if row[1] != 'agent':
conn.close()
return ({'ok': False}, 400)
conn.execute("UPDATE human_messages SET side='system', content='该消息已撤回' WHERE id=?", (mid,))
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime, recalled_id) VALUES(?,?,?,?,?)',
(row[0], 'system', '该消息已撤回', datetime.now().strftime('%Y-%m-%d %H:%M:%S'), mid))
conn.commit()
conn.close()
return {'ok': True}
def human_typing(data, typing_map):
sid = data.get('sid')
typing_map[str(sid)] = time.time()
+205 -20
View File
@@ -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欧/件。"
}
]
+285
View File
@@ -0,0 +1,285 @@
"""Local, editable order test data. This repository can later be replaced by MCP."""
import json
import re
import sqlite3
from datetime import datetime
from .config import get_setting
from .order_tracking import build_tracking
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',
'status', 'country_code', 'transport_mode', 'warehouse_code', 'order_role',
'parent_order_no', 'package_count', 'gross_weight_kg', 'volume_cbm',
'history_json', 'tracking_json', 'data_note', 'source_updated_at', 'snapshot_at')
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
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'状态|进度|轨迹|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
class OrderDataUnavailable(Exception):
pass
def init_order_table(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS order_test_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_order_id TEXT, tenant_id TEXT NOT NULL DEFAULT 'test',
order_no TEXT NOT NULL COLLATE NOCASE,
inbound_code TEXT COLLATE NOCASE, shipment_id TEXT COLLATE NOCASE,
status TEXT NOT NULL, country_code TEXT, transport_mode TEXT, warehouse_code TEXT,
order_role TEXT, parent_order_no TEXT,
package_count INTEGER, gross_weight_kg TEXT, volume_cbm TEXT,
history_json TEXT NOT NULL DEFAULT '[]', tracking_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
source_updated_at TEXT, snapshot_at TEXT,
UNIQUE(tenant_id, order_no)
)''')
columns = {row[1] for row in connection.execute('PRAGMA table_info(order_test_data)')}
if 'tracking_json' not in columns:
connection.execute("ALTER TABLE order_test_data ADD COLUMN tracking_json TEXT NOT NULL DEFAULT '[]'")
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_inbound ON order_test_data(inbound_code)')
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_shipment ON order_test_data(shipment_id)')
def import_records(connection, records, replace=False):
"""Import an explicitly supplied snapshot; preserve manual edits unless requested."""
init_order_table(connection)
inserted = skipped = 0
for source in records:
if not isinstance(source, dict) or not str(source.get('order_no') or '').strip():
raise ValueError('Each record requires an order_no')
row = {key: source.get(key) for key in FIELDS}
row['tenant_id'] = str(row['tenant_id'] or 'test')
row['source_order_id'] = str(row['source_order_id']) if row['source_order_id'] is not None else None
row['order_no'] = str(row['order_no']).strip()
row['status'] = str(row['status'] or 'DRAFT')
history = row['history_json'] or []
if isinstance(history, str): history = json.loads(history)
if not isinstance(history, list): raise ValueError('history_json must contain a list')
row['history_json'] = json.dumps(history, ensure_ascii=False)
tracking = row['tracking_json'] or []
if isinstance(tracking, str): tracking = json.loads(tracking)
if not isinstance(tracking, list): raise ValueError('tracking_json must contain a list')
row['tracking_json'] = json.dumps(tracking, ensure_ascii=False)
row['snapshot_at'] = row['snapshot_at'] or datetime.now().isoformat(timespec='seconds')
exists = connection.execute('SELECT 1 FROM order_test_data WHERE tenant_id=? AND order_no=?',
(row['tenant_id'], row['order_no'])).fetchone()
if exists and not replace:
skipped += 1
continue
columns = ','.join(FIELDS)
placeholders = ','.join('?' for _ in FIELDS)
updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no'))
connection.execute(f'INSERT INTO order_test_data ({columns}) VALUES ({placeholders}) '
f'ON CONFLICT(tenant_id,order_no) DO UPDATE SET {updates}', [row[key] for key in FIELDS])
inserted += 1
return {'imported': inserted, 'preserved': skipped}
def import_tracking_records(connection, records, replace=False):
"""Update tracking alone; never replace hand-edited order fields or add unknown orders."""
init_order_table(connection)
imported = preserved = missing = 0
for record in records:
if not isinstance(record, dict) or not record.get('tenant_id') or not record.get('order_no'):
raise ValueError('Tracking record requires tenant_id and order_no')
tracking = record.get('tracking_json')
if isinstance(tracking, str): tracking = json.loads(tracking)
if not isinstance(tracking, list): raise ValueError('tracking_json must contain a list')
row = connection.execute('SELECT id,source_order_id,tracking_json FROM order_test_data '
'WHERE tenant_id=? AND order_no=?',
(str(record['tenant_id']), record['order_no'])).fetchone()
if not row:
missing += 1
continue
if not record.get('source_order_id') or str(record['source_order_id']) != str(row[1]):
raise ValueError('Tracking source_order_id does not match local order')
if not replace and row[2] not in (None, '', '[]'):
preserved += 1
continue
connection.execute('UPDATE order_test_data SET tracking_json=? WHERE id=?',
(json.dumps(tracking, ensure_ascii=False), row[0]))
imported += 1
return {'imported': imported, 'preserved': preserved, 'missing': missing}
def lookup_order(reference, tenant_id=None, tenant_ids=None):
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
from .db import connect
connection = connect()
connection.row_factory = sqlite3.Row
try:
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)'
values = [reference] * 3
tenant = get_setting('ORDER_DATA_TENANT_ID')
if tenant_ids is not None:
if not isinstance(tenant_ids,(tuple,list,set)) or not 0 < len(tenant_ids) <= 256:
return []
allowed = {value for value in tenant_ids if isinstance(value,str) and 0 < len(value.strip()) <= 128}
if tenant_id is not None:
allowed.intersection_update({str(tenant_id)})
if tenant:
allowed.intersection_update({str(tenant)})
if not allowed:
return []
query += ' AND tenant_id IN (' + ','.join('?' for _ in allowed) + ')'
values.extend(sorted(allowed))
elif tenant_id is not None:
if not str(tenant_id).strip():
return []
if tenant and str(tenant_id) != str(tenant):
return []
tenant = str(tenant_id)
if tenant and tenant_ids is None:
query += ' AND tenant_id=?'
values.append(str(tenant))
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
except sqlite3.Error:
raise OrderDataUnavailable from None
finally:
connection.close()
def extract_reference(message):
message = re.sub(r'\[(?:图片|文件)\]/uploads/\S+|https?://\S+', '', message)
message = re.sub(r'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}'
r'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
r'(?![A-Za-z0-9_-])', ' ', message)
message = re.sub(r'(?<![A-Za-z0-9_.-])\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?'
r'(?:\s*(?:cm|mm|m|厘米|毫米|米))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
r'(?:kg|lbs?|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
references = []
for token in IDENTIFIER.findall(message):
if sum(character.isdigit() for character in token) < 3:
continue
if re.fullmatch(r'\d{4}[-_]\d{2}[-_]\d{2}|\d+(?:KG|LBS?|CBM|CM|MM)', token, re.I):
continue
if token.casefold() not in [value.casefold() for value in references]:
references.append(token)
return references
def build_card(row):
fields = []
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'])
field('transport', {'SEA':'海运', 'AIR':'空运', 'RAIL':'铁路', 'TRUCK':'卡航'}.get(row['transport_mode'], row['transport_mode']))
field('packages', row['package_count'])
field('weight', str(row['gross_weight_kg'])+' kg' if row['gross_weight_kg'] is not None else None)
field('volume', str(row['volume_cbm'])+' m³' if row['volume_cbm'] is not None else None)
field('role', {'CHILD':'子订单', 'MASTER':'主订单'}.get(row['order_role']))
field('parent', row['parent_order_no'])
field('updated', row['source_updated_at'])
try:
history = json.loads(row['history_json'])
except (ValueError, TypeError):
history = []
nodes = [{'time': str(event.get('time') or ''),
'text': STATUS_LABELS.get(str(event.get('status')), str(event.get('status') or '状态更新')), 'place': ''}
for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else []
nodes.reverse()
status = STATUS_LABELS.get(row['status'], row['status'])
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'))
if tracking:
card.update(node_kind='logistics', nodes=tracking['nodes'],
estimated_nodes=tracking['estimated_nodes'], tracking_status=tracking['status'])
facts = tracking['nodes'][:10] + tracking['estimated_nodes'][:5]
card['summary'] += '。物流状态:' + (tracking['status'] or '暂无实际节点') + '。'
card['summary'] += ';'.join(f"{node['time']} {node['text']} {node['place']}" for node in facts)
return card
def is_order_followup(message):
"""Reuse an order only for an explicit reference or a short detail question."""
if not re.search(ORDER_DETAIL, message):
return False
if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message):
return True
question = re.sub(r'[\s,,。.!!??、::]', '', message)
details = (
r'(?:(?:(?:物流|运输)?(?:状态|进度|轨迹)|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格)'
r'(?:(?:是|有|为)?(?:多少|什么|怎么样|怎样|如何|哪个))?|(?:有)?(?:几件|几箱|多少公斤|多少千克|多少钱))'
r'(?:呢|吗|呀|啊)?')
tracking = (
r'(?:(?:到哪(?:里)?|开船|到港|清关|提柜|还柜|签收)(?:了)?(?:呢|吗|没|了吗|没有)?|'
r'(?:预计)?(?:什么时候|何时|多久)(?:能|会|可以)?(?:到|到达|到港|到仓|到货|送到)(?:呢|呀|啊)?|还要多久)')
return bool(re.fullmatch(
r'(?:那|那么|还有|请问|请|查一下|看一下|现在|目前)?(?:它的|订单的)?'
r'(?:'+details+'|'+tracking+')', question))
def answer_query(message, conv_id):
references = extract_reference(message)
tenants = customer_tenant_ids()
if references and not tenants:
return order_login_response()
if len(references) > 1:
return {'t':'请一次提供一个入仓码,避免查错订单。'}
reference = references[0] if references else None
if not reference and conv_id and is_order_followup(message):
from .db import connect
connection = connect()
try:
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 12', (conv_id,)).fetchall()
finally:
connection.close()
for role, content in recent:
if role == 'user':
earlier_references = extract_reference(content)
if earlier_references:
reference = earlier_references[0] if len(earlier_references) == 1 else None
break
if not is_order_followup(content):
break
if role == 'assistant' and content.startswith('__CARD__'):
try:
previous = json.loads(content[8:])
if previous.get('kind') == 'order':
reference = previous['no']
break
except (ValueError, KeyError, TypeError):
pass
if not reference:
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 not tenants:
return order_login_response()
try:
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}”对应的订单。请核对入仓码,或联系人工客服。'}
if len(matches) > 1:
return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'}
if re.search(r'运费|费用|多少钱|价格', message):
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
return {'card': build_card(matches[0])}
+93
View File
@@ -0,0 +1,93 @@
"""Customer tracking derived from explicit order-linked facts, without inference."""
import json
from datetime import datetime, timezone
# Maritime codes follow the firstleg standard; warehouse facts remain distinct.
NODE_LABELS = {
'DOMESTIC_RECEIVED': (0, '已入国内仓', '预计入国内仓'),
'LOADING_COMPLETED': (5, '已完成装柜', '预计完成装柜'),
'DEPARTED_WAREHOUSE': (10, '已离开国内仓库', '预计离开国内仓库'),
'AT_ORIGIN_PORT': (20, '重箱已进入起运港或码头', '预计进入起运港或码头'),
'LOADED_ON_VESSEL': (30, '已装船', '预计装船'),
'DEPARTED_ORIGIN_PORT': (40, '船舶已离开起运港', '预计离开起运港'),
'IN_TRANSIT': (50, '海运途中', '预计进入海运阶段'),
'ARRIVED_DESTINATION_PORT': (60, '船舶已到目的港', '预计到达目的港'),
'DISCHARGED_FROM_VESSEL': (70, '已卸船、未清关', '预计卸船'),
'PICKUP_READY': (80, '已清关、可提柜', '预计可提柜'),
'PICKED_UP': (90, '已提柜', '预计提柜'),
'EMPTY_RETURNED': (100, '已还柜', '预计还柜'),
}
def parse_time(value):
if not isinstance(value, str) or not value.strip():
return None
try:
result = datetime.fromisoformat(value.strip().replace('Z', '+00:00'))
if result.tzinfo:
result = result.astimezone(timezone.utc).replace(tzinfo=None)
return result
except (ValueError, OverflowError):
return None
def build_tracking(raw):
"""Latest observation corrects a milestone; forecasts never advance progress."""
try:
facts = json.loads(raw) if isinstance(raw, str) else raw
except (ValueError, TypeError):
return None
if not isinstance(facts, list):
return None
actual, estimated = {}, {}
for index, fact in enumerate(facts):
if not isinstance(fact, dict) or not isinstance(fact.get('code'), str) or fact['code'] not in NODE_LABELS:
continue
shipment = str(fact.get('shipment_key') or '')
key = (shipment, fact['code'])
observation = parse_time(fact.get('observed_at')) or parse_time(fact.get('created_at')) or datetime.min
for field, target in (('actual_at', actual), ('estimated_at', estimated)):
when = parse_time(fact.get(field))
if when is None:
continue
candidate = (observation, index, when, fact)
if key not in target or candidate[:2] > target[key][:2]:
target[key] = candidate
if not actual and not estimated:
return None
shipments = sorted({key[0] for key in actual.keys() | estimated.keys()} - {''})
multiple = len(shipments) > 1
progress = {}
for (shipment, code), (_, _, when, fact) in actual.items():
sequence = NODE_LABELS[code][0]
if shipment not in progress or sequence > progress[shipment][0]:
progress[shipment] = (sequence, code, fact)
def label_shipment(shipment, fact):
if not multiple or not shipment:
return ''
return '货柜 ' + str(fact.get('shipment_no') or (shipments.index(shipment) + 1))
def node(shipment, code, value, forecast=False):
_, _, when, fact = value
place = str(fact.get('place') or '')
scope = label_shipment(shipment, fact)
return {'time': str(fact['estimated_at' if forecast else 'actual_at']),
'text': NODE_LABELS[code][2 if forecast else 1],
'place': ' · '.join(part for part in (scope, place) if part)}
nodes = [node(shipment, code, value) for (shipment, code), value in
sorted(actual.items(), key=lambda item: (item[1][2], NODE_LABELS[item[0][1]][0]), reverse=True)]
forecasts = [node(shipment, code, value, True) for (shipment, code), value in
sorted(estimated.items(), key=lambda item: item[1][2])
if (shipment, code) not in actual and NODE_LABELS[code][0] > progress.get(shipment, (-1,))[0]]
# Multiple containers keep independent progress rather than claiming the whole order advanced.
current = [(shipment, value) for shipment, value in sorted(progress.items()) if shipment or not shipments]
if not current and '' in progress:
current = [('', progress[''])]
statuses = [':'.join(part for part in (label_shipment(shipment, value[2]), NODE_LABELS[value[1]][1]) if part)
for shipment, value in current]
return {'nodes': nodes[:50], 'estimated_nodes': forecasts[:20], 'status': ';'.join(statuses)}
+47 -25
View File
@@ -95,35 +95,50 @@ RATES = {
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
def parse_dims(t):
"""解析 长x宽x高,例如 120*100*100 / 100x80x60 / 120×100×100
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
"""
for m in re.finditer(r"(\d+)[*xX×](\d+)[*xX×](\d+)", t):
# 逐段向后延长,保证拿到的是完整数字而不是半截
dims = []
ok = True
pos = m.start()
for g in (1, 2, 3):
end = m.end(g)
# 若后面还紧跟数字,说明这一组没取全,放弃这个候选
if end < len(t) and t[end].isdigit():
ok = False
break
# 若是第一组,前面不能紧挨数字
if g == 1 and m.start(1) > 0 and t[m.start(1) - 1].isdigit():
ok = False
break
dims.append(int(m.group(g)))
if ok:
return [float(x) for x in dims]
return None
match = re.search(r'(?<![\d.+-])(\d+(?:\.\d+)?)\s*[*xX×]\s*'
r'(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?![\d.])', t)
return [float(value) for value in match.groups()] if match else None
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
('unsupported', r'美国|加拿大|澳大利亚|澳洲|新西兰|日本|韩国|新加坡|马来西亚|泰国|越南|印度|阿联酋|迪拜|沙特|巴西|墨西哥|俄罗斯|'
r'(?<![A-Za-z0-9_])(?-i:US)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?<![A-Za-z0-9_])(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)(?![A-Za-z])'),
('uk', r'英国|伦敦|英格兰|(?<![A-Za-z0-9_])(?:UK|GB)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?<![A-Za-z0-9_])(?:United Kingdom|Britain)(?![A-Za-z])'),
('eu', r'欧洲|欧盟|德国|法国|意大利|西班牙|波兰|捷克|荷兰|比利时|奥地利|葡萄牙|瑞典|丹麦|芬兰|爱尔兰|'
r'(?<![A-Za-z0-9_])(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)(?![A-Za-z])|'
r'(?<![A-Za-z0-9_])(?-i:DE|FR|PL|CZ|ES|NL)(?=\s*(?:仓|FBA|warehouse))'),
('unsupported', r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|\bto\s+us\b'),
))
def shipping_region(text):
return next((region for pattern, region in SHIPPING_REGION_PATTERNS if pattern.search(text)), None)
def is_shipping_destination_reply(text):
text = text.strip().strip(',,。.!!??、:: ')
for pattern, _ in SHIPPING_REGION_PATTERNS:
match = pattern.search(text)
if not match:
continue
prefix = re.fullmatch(r'\s*(?:(?:目的地|目的国家|国家)(?:是|为|:|:)?|(?:发|寄|运|送)(?:往|到)?|到|往|去|to)?\s*', text[:match.start()], re.I)
suffix = re.fullmatch(r'\s*(?:的)?\s*(?:FBA\s*)?(?:(?:海外)?仓(?:库)?|warehouse)?(?:呢)?\s*', text[match.end():], re.I)
if prefix and suffix:
return True
return False
def parse_shipping_intent(text):
"""从话里抠出:重量(kg)、渠道、长宽高(cm)"""
"""解析重量、渠道、尺寸和费率区域;尺寸单位由调用方识别。"""
t = text.replace(" ", "")
intent = {"weight": None, "channel": None, "dims": None}
intent = {"weight": None, "channel": None, "dims": None, "region": shipping_region(text)}
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
intent["dims"] = parse_dims(text)
@@ -192,17 +207,21 @@ def pick_rate(weight_kg, tiers):
def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
"""海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档
country 传入国家代码(DE/FR/PL/CZ/ES)时,优先选择包含该后缀的仓点组。
country 传入国家代码(DE/FR/PL/CZ/ES)时,仅选择包含该后缀的仓点组;无匹配不报价。
"""
if not RX or not RX.WAREHOUSE_CARD:
return None
keys = list(RX.WAREHOUSE_CARD)
key = warehouse_key
if key and key not in RX.WAREHOUSE_CARD:
return None
if not key and country:
for k in keys:
if ("-" + country) in k.upper():
key = k
break
if not key:
return None
if not key:
key = keys[0]
row = RX.WAREHOUSE_CARD.get(key) or RX.WAREHOUSE_CARD[keys[0]]
@@ -213,7 +232,9 @@ def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
tier = "1CBM+" if cbm >= 1 else "0.5CBM+"
stores = [s for s in key.splitlines() if s.strip()]
if country:
stores = [s for s in stores if ("-" + country) in s.upper()] or stores
stores = [s for s in stores if ("-" + country) in s.upper()]
if not stores:
return None
else:
stores = stores[:1]
note = "报价表中的仓点分组:" + "、".join(stores)
@@ -315,7 +336,8 @@ def freight_reply(intent):
dims = intent["dims"]
unit = intent.get("dim_unit") or "cm"
lines = []
channels = [intent["channel"]] if intent["channel"] else ["sea", "air"]
default_channels = ['sea_uk', 'air_uk'] if intent.get('region') == 'uk' else ['sea', 'air']
channels = [intent["channel"]] if intent["channel"] else default_channels
for ch in channels:
c = calc_freight(w, ch, dims, unit)
parts = [f"实重 {w:g} kg" if w else "实重:未提供"]
+2 -2
View File
@@ -2,6 +2,6 @@
def register_routes(app):
from . import admin, business, chat, files, history, human, pages
for module in (admin, business, chat, files, history, human, pages):
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)
+14 -1
View File
@@ -18,7 +18,20 @@ def admin_login():
@bp.route('/api/export_chat')
def export_chat():
return Response(business_service.export_chat(), mimetype='text/csv', headers={'Content-Disposition': 'attachment;filename=chat_export.csv'})
format = 'csv' if request.args.get('format') == 'csv' else 'xlsx'
mimetype = 'text/csv' if format == 'csv' else 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
return Response(business_service.export_chat(format), mimetype=mimetype,
headers={'Content-Disposition': 'attachment;filename=chat_export.' + format})
@bp.route('/api/agents/list')
def agents_list():
return business_service.agents_list()
@bp.route('/api/search_chat')
def search_chat():
return business_service.search_chat(request.args.get('q', '').strip())
@bp.route('/admin')
+12 -2
View File
@@ -2,7 +2,7 @@
from flask import Blueprint, request
from .. import business_service
from .. import business_service, visitor_service
bp = Blueprint('business', __name__)
@@ -13,9 +13,19 @@ def my_orders():
@bp.route('/api/ticket', methods=['POST'])
def add_ticket():
return business_service.add_ticket(request.get_json() or {})
return business_service.add_ticket(request.get_json(silent=True))
@bp.route('/api/tickets')
def list_tickets():
return business_service.list_tickets()
@bp.route('/api/rate', methods=['POST'])
def rate_chat():
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
return {'ok': False}, 400
if not visitor_service.owns_human_session(data.get('sid')):
return {'ok': False}, 404
return business_service.rate_chat(data)
+28 -9
View File
@@ -8,17 +8,36 @@ bp = Blueprint('chat', __name__)
@bp.route('/stream_chat', methods=['GET', 'POST'])
def stream_chat():
def error(message):
return Response(chat_service.sse({'e':message}) + chat_service.sse({'d':1}), mimetype='text/event-stream')
if request.method == 'POST':
data = request.get_json(silent=True) or {}
msg = (data.get('msg') or '').strip()
data = request.get_json(silent=True)
if not isinstance(data, dict):
return error('请求格式无效,请重新发送问题。')
msg = data.get('msg', '')
conv_id = data.get('conv_id')
try:
conv_id = int(conv_id) if conv_id else None
except (TypeError, ValueError):
conv_id = None
else:
msg = (request.args.get('msg') or '').strip()
conv_id = request.args.get('conv_id', type=int)
msg = request.args.get('msg', '')
conv_id = request.args.get('conv_id')
if not isinstance(msg, str):
return error('问题必须是文本,请重新输入。')
msg = msg.strip()
if isinstance(conv_id, (bool, float)):
return error('会话编号无效,请新建对话后重试。')
if conv_id not in (None, '', 0):
if not isinstance(conv_id, (str, int)):
return error('会话编号无效,请新建对话后重试。')
if isinstance(conv_id, str) and (len(conv_id) > 19 or not conv_id.isascii() or not conv_id.isdecimal()):
return error('会话编号无效,请新建对话后重试。')
try:
conv_id = int(conv_id)
except (ValueError, TypeError):
return error('会话编号无效,请新建对话后重试。')
if not 0 < conv_id <= 9223372036854775807:
return error('会话编号无效,请新建对话后重试。')
else:
conv_id = None
frames, headers = chat_service.create_chat_stream(msg, conv_id)
body = frames if isinstance(frames, str) else stream_with_context(frames)
return Response(body, mimetype='text/event-stream', headers=headers)
@@ -26,4 +45,4 @@ def stream_chat():
@bp.route('/api/feedback', methods=['POST'])
def feedback():
return business_service.feedback(request.get_json(silent=True) or {})
return business_service.feedback(request.get_json(silent=True))
+27
View File
@@ -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()
+25 -3
View File
@@ -1,12 +1,17 @@
"""HTTP adapters for files; business logic lives in services."""
from flask import Blueprint, request, send_from_directory
from flask import Blueprint, render_template, request, send_from_directory
from werkzeug.exceptions import NotFound
from .. import file_service
from .. import file_service, visitor_service
from ..config import get_setting
bp = Blueprint('files', __name__)
def unavailable(staff=False):
return render_template('attachment_unavailable.html', staff=staff), 404, {'Cache-Control':'private, no-store'}
@bp.route('/api/upload', methods=['POST'])
def upload_file():
return file_service.upload_file(request.files)
@@ -14,4 +19,21 @@ def upload_file():
@bp.route('/uploads/<path:name>')
def uploaded_file(name):
return send_from_directory(get_setting('UPLOAD_DIR'), name)
if not visitor_service.owns_upload(name):
return unavailable()
try:
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
except NotFound:
return unavailable()
response.headers['Cache-Control'] = 'private, no-store'
return response
@bp.route('/api/staff/uploads/<path:name>')
def staff_uploaded_file(name):
try:
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
except NotFound:
return unavailable(staff=True)
response.headers['Cache-Control'] = 'private, no-store'
return response
+15
View File
@@ -41,6 +41,21 @@ def human_close():
return human_service.human_close(request.json)
@bp.route('/api/human/transfer', methods=['POST'])
def human_transfer():
return human_service.human_transfer(request.json or {}, current_app.extensions['human_typing'])
@bp.route('/api/human/note', methods=['POST'])
def human_note():
return human_service.human_note(request.json or {})
@bp.route('/api/human/recall', methods=['POST'])
def human_recall():
return human_service.human_recall(request.json or {})
@bp.route('/api/human/typing', methods=['POST'])
def human_typing():
return human_service.human_typing(request.json, current_app.extensions['human_typing'])
+47
View File
@@ -0,0 +1,47 @@
"""HTTP adapters for short voice input; no audio is stored or added to history."""
from io import BytesIO
from flask import Blueprint, Request, request
from werkzeug.exceptions import RequestEntityTooLarge
from .. import speech_service
bp = Blueprint('speech', __name__)
class MemoryAudioRequest(Request):
def _get_file_stream(self, total_content_length, content_type, filename=None, content_length=None):
if self.path == '/api/speech/transcribe':
return BytesIO()
return super()._get_file_stream(total_content_length, content_type, filename, content_length)
@bp.after_request
def no_cache(response):
response.headers['Cache-Control'] = 'no-store'
return response
@bp.get('/api/speech/config')
def speech_config():
return {'enabled': speech_service.speech_enabled(), 'max_seconds': speech_service.MAX_DURATION_SECONDS,
'max_bytes': speech_service.MAX_AUDIO_BYTES}, {'Cache-Control': 'no-store'}
@bp.post('/api/speech/transcribe')
def speech_transcribe():
request.max_content_length = speech_service.MAX_AUDIO_BYTES + 64 * 1024
if not speech_service.speech_enabled():
return {'ok': False, 'error': speech_service.UNAVAILABLE}, 503
try:
files = request.files
try:
return speech_service.transcribe_recording(files.get('audio'), request.form.get('duration'),
request.form.get('language'))
finally:
for _, upload in files.items(multi=True):
upload.close()
except RequestEntityTooLarge:
return {'ok': False, 'error': '录音文件过大,请缩短后重试。'}, 413
+47
View File
@@ -0,0 +1,47 @@
"""Guest bootstrap and owner-checked human support adapters, separate from staff APIs."""
from flask import Blueprint, current_app, request
from werkzeug.exceptions import RequestEntityTooLarge
from .. import human_service, visitor_service
bp = Blueprint('visitor', __name__)
@bp.post('/api/visitor/session')
def visitor_session():
request.max_content_length = 1024
try:
return visitor_service.start_session(request.get_json(silent=True))
except RequestEntityTooLarge:
return {'ok': False, 'error': '访客会话格式无效。'}, 413
@bp.route('/api/visitor/human/<action>', methods=['GET', 'POST'])
def visitor_human(action):
methods = {'poll': 'GET', 'request': 'POST', 'send': 'POST', 'close': 'POST'}
if action not in methods or request.method != methods[action]:
return {'ok': False}, 404
data = request.get_json(silent=True) if request.method == 'POST' else {}
if not isinstance(data, dict):
return {'ok': False}, 400
if action == 'request':
if not visitor_service.owns_conversation(data.get('conv_id')):
return {'ok': False, 'error': 'conversation_not_found'}, 404
return human_service.human_request(data)
sid = request.args.get('sid', type=int) if action == 'poll' else data.get('sid')
if not visitor_service.owns_human_session(sid):
return {'ok': False, 'error': 'session_not_found'}, 404
if action == 'poll':
after = request.args.get('after', 0, type=int)
if not 0 <= after <= 9223372036854775807:
return {'ok': False}, 400
result = human_service.human_poll(sid, after, current_app.extensions['human_typing'])
result.pop('note', None)
return result
if action == 'send':
if data.get('side', 'customer') != 'customer':
return {'ok': False}, 403
return human_service.human_send({**data, 'side': 'customer'})
return human_service.human_close(data)
+174
View File
@@ -0,0 +1,174 @@
"""Bounded, in-memory recording transcription; separate from text chat credentials."""
import base64
import json
import math
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import threading
import time
from urllib.parse import urlsplit
import requests
from flask import current_app
from .config import get_setting
MAX_AUDIO_BYTES = 5 * 1024 * 1024
MAX_DURATION_SECONDS = 60
UNAVAILABLE = '语音输入暂不可用,请使用文字输入。'
class SpeechFailure(Exception):
def __init__(self, message, status=502):
self.message, self.status = message, status
def ffmpeg_binary():
configured = get_setting('FFMPEG_BIN')
return str(configured) if configured and Path(configured).is_file() else shutil.which('ffmpeg')
def speech_enabled():
provider = get_setting('ASR_PROVIDER')
if provider == 'baidu':
return bool(get_setting('BAIDU_ASR_API_KEY') and get_setting('BAIDU_ASR_SECRET_KEY') and ffmpeg_binary())
if provider != 'dashscope':
return False
try:
url = urlsplit(get_setting('ASR_URL'))
except ValueError:
return False
return bool(get_setting('ASR_API_KEY') and get_setting('ASR_MODEL')
and url.scheme == 'https' and url.hostname and not url.username and not url.password)
def post_json(url, **options):
response = requests.post(url, timeout=(5, 30), stream=True, allow_redirects=False, **options)
try:
if response.status_code != 200:
raise SpeechFailure('语音转文字失败,请稍后重试。')
data = bytearray()
for chunk in response.iter_content(4096):
data.extend(chunk)
if len(data) > 64 * 1024:
raise SpeechFailure('语音识别结果无效,请重试。')
return json.loads(data)
finally:
response.close()
def baidu_token():
key, secret = get_setting('BAIDU_ASR_API_KEY'), get_setting('BAIDU_ASR_SECRET_KEY')
identity = hashlib.sha256((key + '\0' + secret).encode()).hexdigest()
lock = current_app.extensions.setdefault('speech_token_lock', threading.Lock())
with lock:
cached = current_app.extensions.get('speech_token')
if cached and cached['identity'] == identity and cached['expires'] > time.time():
return cached['token']
data = post_json('https://aip.baidubce.com/oauth/2.0/token', data={
'grant_type':'client_credentials', 'client_id':key, 'client_secret':secret})
token = data.get('access_token')
if not isinstance(token, str) or not token:
raise SpeechFailure(UNAVAILABLE, 503)
current_app.extensions['speech_token'] = {'identity':identity, 'token':token,
'expires':time.time() + max(0, min(float(data.get('expires_in', 0)), 2592000) - 60)}
return token
def to_pcm(content):
"""Decode browser WebM/MP4 in pipes to mono 16 kHz, without temporary audio files."""
binary = ffmpeg_binary()
if not binary:
raise SpeechFailure(UNAVAILABLE, 503)
result = subprocess.run([binary, '-hide_banner', '-loglevel', 'error', '-protocol_whitelist', 'pipe',
'-i', 'pipe:0', '-map', '0:a:0', '-vn', '-sn', '-dn', '-ac', '1', '-ar', '16000',
'-t', '61', '-f', 's16le', 'pipe:1'], input=content, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=10, creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
if result.returncode != 0 or len(result.stdout) < 12800:
raise SpeechFailure('录音无法识别,请重新录制。', 422)
if len(result.stdout) > 16000 * 2 * MAX_DURATION_SECONDS:
raise SpeechFailure('录音超过 60 秒,请缩短后重试。', 400)
return result.stdout
def baidu_transcribe(content, language):
pcm = to_pcm(content)
result = post_json('https://vop.baidu.com/server_api', json={'format':'pcm', 'rate':16000, 'channel':1,
'cuid':'baiyue-logistics-chat', 'token':baidu_token(), 'dev_pid':1737 if language == 'en' else 1537,
'speech':base64.b64encode(pcm).decode('ascii'), 'len':len(pcm)})
code = result.get('err_no')
if code == 3301:
raise SpeechFailure('没有识别到文字,请靠近麦克风重试。', 422)
if code != 0:
current_app.logger.warning('Baidu ASR request failed: code=%s', code)
raise SpeechFailure(UNAVAILABLE if code == 3302 else '语音转文字失败,请稍后重试。', 503 if code == 3302 else 502)
parts = result.get('result')
if not isinstance(parts, list) or any(not isinstance(part, str) for part in parts):
raise SpeechFailure('语音识别结果无效,请重试。')
return ''.join(parts)
def audio_format(content, mime):
"""Check container signatures, never trust the uploaded filename."""
mime = mime.split(';', 1)[0].lower()
if mime == 'audio/webm' and content.startswith(b'\x1aE\xdf\xa3'):
return 'audio/webm'
if mime in ('audio/mp4', 'audio/x-m4a') and content[4:8] == b'ftyp':
return 'audio/mp4'
if mime in ('audio/ogg', 'audio/opus') and content.startswith(b'OggS'):
return 'audio/ogg'
if mime in ('audio/wav', 'audio/x-wav') and content[:4] == b'RIFF' and content[8:12] == b'WAVE':
return 'audio/wav'
return None
def transcribe_recording(upload, duration, language):
if not speech_enabled():
return {'ok': False, 'error': UNAVAILABLE}, 503
if not upload:
return {'ok': False, 'error': '没有收到录音,请重新按住说话。'}, 400
try:
duration = float(duration)
except (TypeError, ValueError):
duration = 0
if not math.isfinite(duration) or not 0.4 <= duration <= MAX_DURATION_SECONDS + 1:
return {'ok': False, 'error': '录音时长无效,请录制 1 至 60 秒语音。'}, 400
content = upload.read(MAX_AUDIO_BYTES + 1)
if len(content) > MAX_AUDIO_BYTES:
return {'ok': False, 'error': '录音文件过大,请缩短后重试。'}, 413
mime = audio_format(content, upload.mimetype)
if len(content) < 16 or not mime:
return {'ok': False, 'error': '录音格式不受支持,请使用文字输入或更换浏览器。'}, 415
language = 'en' if language == 'en' else 'zh'
try:
if get_setting('ASR_PROVIDER') == 'baidu':
text = baidu_transcribe(content, language)
else:
body = {'model': get_setting('ASR_MODEL'), 'stream': False,
'messages': [{'role': 'user', 'content': [{'type': 'input_audio', 'input_audio': {
'data': 'data:' + mime + ';base64,' + base64.b64encode(content).decode('ascii')}}]}],
'asr_options': {'language': language, 'enable_itn': True}}
result = post_json(get_setting('ASR_URL'), json=body,
headers={'Authorization': 'Bearer ' + get_setting('ASR_API_KEY')})
text = result['choices'][0]['message']['content']
if not isinstance(text, str) or not text.strip():
return {'ok': False, 'error': '没有识别到文字,请靠近麦克风重试。'}, 422
text = text.strip()
if len(text) > 2000:
return {'ok': False, 'error': '识别文字过长,请缩短录音后重试。'}, 422
return {'ok': True, 'text': text}, 200
except requests.Timeout:
return {'ok': False, 'error': '语音转文字超时,请重试。'}, 504
except SpeechFailure as error:
return {'ok': False, 'error': error.message}, error.status
except subprocess.TimeoutExpired:
return {'ok': False, 'error': '录音处理超时,请缩短后重试。'}, 504
except OSError:
return {'ok': False, 'error': UNAVAILABLE}, 503
except (requests.RequestException, ValueError, KeyError, IndexError, TypeError):
return {'ok': False, 'error': '语音转文字失败,请稍后重试。'}, 502
+4 -2
View File
@@ -13,7 +13,9 @@ def card_to_text(content):
return content
try:
card = json.loads(content[len(CARD_PREFIX):])
return f"(已为运单 {card.get('no', '')} 查询过轨迹,当前状态:{card.get('status', '')})"
if card.get('kind') == 'order':
return card.get('summary', '')
return f"(历史演示轨迹,非真实物流信息:单号 {card.get('no', '')},演示状态:{card.get('status', '')})"
except Exception:
return "(已为该运单查询过轨迹)"
@@ -57,7 +59,7 @@ def extract_tracking_no(text):
return best
def build_track(no):
"""生成固定的模拟轨迹:同一个单号每次查都一样"""
"""Legacy demo helper for old imports; chat queries use order_test_data."""
rnd = random.Random(no)
steps = [
("已揽收", "义乌仓"),
+119
View File
@@ -0,0 +1,119 @@
"""Guest possession tokens isolate chat data; they never identify an order customer."""
import hashlib
import re
import secrets
from flask import g, has_app_context, jsonify, request
from .db import connect
COOKIE_NAME = 'baiyue_visitor'
KEY_PATTERN = re.compile(r'[0-9a-f]{64}\Z')
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',
}
def valid_key(value):
return isinstance(value, str) and KEY_PATTERN.fullmatch(value) is not None
def visitor_id():
return getattr(g, 'visitor_id', None) if has_app_context() else None
def customer_tenant_id():
# 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)):
return None
tenant = str(value).strip()
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
is_customer = request.path in CUSTOMER_PATHS or request.path.startswith('/api/visitor/human/')
if is_customer and not visitor_id():
return jsonify({'ok': False, 'e': '访客会话已失效,请刷新页面重试。'}), 401
if (is_customer or request.path == '/api/visitor/session') and request.method not in ('GET', 'HEAD', 'OPTIONS'):
origin = request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):
return jsonify({'ok': False, 'e': '请求来源无效。'}), 403
def start_session(data):
if not isinstance(data, dict):
return {'ok': False, 'error': '访客会话格式无效。'}, 400
key = data.get('key')
if key is None:
existing = request.cookies.get(COOKIE_NAME)
key = existing if valid_key(existing) else secrets.token_hex(32)
if not valid_key(key):
return {'ok': False, 'error': '访客标识无效。'}, 400
response = jsonify({'ok': True, 'key': key})
response.set_cookie(COOKIE_NAME, key, max_age=365*24*3600, httponly=True,
secure=request.is_secure, samesite='Lax', path='/')
response.headers['Cache-Control'] = 'no-store'
return response
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=? '
'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=? 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()
def owns_upload(name):
owner = visitor_id()
if not owner:
return False
connection = connect()
try:
return bool(connection.execute('SELECT 1 FROM visitor_uploads WHERE filename=? AND visitor_id=?',
(name, owner)).fetchone())
finally:
connection.close()
+1
View File
@@ -9,6 +9,7 @@ iniconfig==2.3.1
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.4
openpyxl>=3.1,<4
packaging==26.3
pluggy==1.7.0
Pygments==2.21.0
+113
View File
@@ -0,0 +1,113 @@
"""Read order-linked warehouse and shipping facts into an explicit private snapshot.
Requires the optional PyMySQL package. This command never writes to the source DB.
"""
import argparse
import json
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
def export_tracking(connection, orders):
output = {}
for order in orders:
key = (str(order.get('tenant_id') or ''), str(order.get('source_order_id') or ''))
if not all(value.isdecimal() for value in key) or not order.get('order_no'):
raise ValueError('Source snapshot requires numeric tenant_id/source_order_id and order_no')
if key in output:
raise ValueError('Source snapshot contains duplicate order identities')
output[key] = {key_name: order[key_name] for key_name in ('tenant_id', 'source_order_id', 'order_no')}
output[key]['tracking_json'] = []
ids = sorted({key[1] for key in output})
for start in range(0, len(ids), 100):
batch = ids[start:start + 100]
placeholders = ','.join('%s' for _ in batch)
# Every relation uses both source order identity and customer identity.
queries = [
(f'''SELECT o.tenant_id,o.id AS order_id, i.id AS fact_id,
i.inbounded_at AS actual_at,i.updated_at AS observed_at
FROM baiyue_order.ord_order o
JOIN baiyue_firstleg.fl_inbound_order i ON i.order_id=o.id AND i.tenant_id=o.tenant_id
WHERE o.deleted=0 AND i.deleted=0 AND i.inbounded_at IS NOT NULL
AND o.id IN ({placeholders}) ORDER BY i.id''', 'inbound'),
(f'''SELECT DISTINCT o.tenant_id,o.id AS order_id,i.id AS fact_id,
i.frozen_at AS actual_at,i.frozen_at AS observed_at,
p.container_booking_id,b.container_no
FROM baiyue_order.ord_order o
JOIN baiyue_firstleg.fl_loading_plan_item i
ON i.order_id=o.id AND i.account_id=o.tenant_id AND i.deleted=0
JOIN baiyue_firstleg.fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
JOIN baiyue_firstleg.fl_container_booking b ON b.id=p.container_booking_id
WHERE o.deleted=0 AND i.frozen_at IS NOT NULL
AND o.id IN ({placeholders}) ORDER BY i.id''', 'loading'),
(f'''SELECT DISTINCT o.tenant_id,o.id AS order_id,n.id AS fact_id,n.standard_node,
n.actual_at,n.estimated_at,n.observed_at,n.created_at,n.location_name,
n.container_booking_id,b.container_no
FROM baiyue_order.ord_order o
JOIN baiyue_firstleg.fl_loading_plan_item i
ON i.order_id=o.id AND i.account_id=o.tenant_id AND i.deleted=0 AND i.frozen_at IS NOT NULL
JOIN baiyue_firstleg.fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
JOIN baiyue_firstleg.fl_container_booking b ON b.id=p.container_booking_id
JOIN baiyue_firstleg.fl_carrier_schedule_node_fact n ON n.container_booking_id=b.id
WHERE o.deleted=0 AND o.id IN ({placeholders}) ORDER BY n.id''', 'shipping'),
]
for sql, source in queries:
with connection.cursor() as cursor:
cursor.execute(sql, batch)
for row in cursor.fetchall():
key = (str(row['tenant_id']), str(row['order_id']))
if key not in output:
continue
fact = {'event_id': source + ':' + str(row['fact_id']),
'code': {'inbound': 'DOMESTIC_RECEIVED', 'loading': 'LOADING_COMPLETED'}.get(source)
or row['standard_node'],
'shipment_key': str(row.get('container_booking_id') or ''),
'shipment_no': row.get('container_no') or '',
'place': row.get('location_name') or ''}
for field in ('actual_at', 'estimated_at', 'observed_at', 'created_at'):
value = row.get(field)
fact[field] = value.isoformat(sep=' ') if value else None
output[key]['tracking_json'].append(fact)
return list(output.values())
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--database-env', type=Path, required=True)
parser.add_argument('--orders-snapshot', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
if args.output.exists(): parser.error('Output already exists; choose a new private snapshot path')
if not args.output.parent.is_dir(): parser.error('Output parent directory must exist')
if not args.orders_snapshot.is_file() or args.orders_snapshot.stat().st_size > 10 * 1024 * 1024:
parser.error('Orders snapshot must be an existing JSON file smaller than 10 MB')
orders = json.loads(args.orders_snapshot.read_text(encoding='utf-8-sig'))
if isinstance(orders, dict): orders = orders.get('orders')
if not isinstance(orders, list) or not orders or len(orders) > 3000:
parser.error('Orders snapshot must contain 1 to 3000 orders')
import pymysql
from dotenv import dotenv_values
config = dotenv_values(args.database_env, interpolate=False, encoding='utf-8-sig')
required = ('CHONGCHONG_DB_HOST', 'CHONGCHONG_DB_PORT', 'CHONGCHONG_DB_USERNAME', 'CHONGCHONG_DB_PASSWORD')
if any(not config.get(key) for key in required): parser.error('Database env is missing connection settings')
connection = pymysql.connect(host=config[required[0]], port=int(config[required[1]]),
user=config[required[2]], password=config[required[3]], cursorclass=pymysql.cursors.DictCursor,
connect_timeout=10, read_timeout=30, write_timeout=10, autocommit=False)
try:
with connection.cursor() as cursor:
cursor.execute('SET SESSION TRANSACTION READ ONLY')
cursor.execute('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')
records = export_tracking(connection, orders)
finally:
connection.rollback()
connection.close()
args.output.write_text(json.dumps({'orders': records}, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps({'orders': len(records), 'orders_with_tracking': sum(bool(row['tracking_json']) for row in records),
'facts': sum(len(row['tracking_json']) for row in records)}))
if __name__ == '__main__':
main()
+34
View File
@@ -0,0 +1,34 @@
"""Import an explicit, private order snapshot into the chat SQLite database."""
import argparse
import json
from pathlib import Path
import sqlite3
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from logistics_chat.order_data import import_records, import_tracking_records
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--snapshot', type=Path, required=True)
parser.add_argument('--db', type=Path, required=True)
parser.add_argument('--replace', action='store_true', help='Replace existing rows, including manual edits')
parser.add_argument('--tracking-only', action='store_true', help='Update only tracking for matching local orders')
args = parser.parse_args()
if not args.snapshot.is_file() or args.snapshot.stat().st_size > 10*1024*1024:
parser.error('Snapshot must be an existing JSON file smaller than 10 MB')
records = json.loads(args.snapshot.read_text(encoding='utf-8-sig'))
if isinstance(records, dict): records = records.get('orders')
if not isinstance(records, list) or len(records) > 3000:
parser.error('Snapshot must contain a list of at most 3000 orders')
if not args.db.parent.is_dir(): parser.error('Database parent directory must exist')
with sqlite3.connect(args.db) as connection:
importer = import_tracking_records if args.tracking_only else import_records
result = importer(connection, records, replace=args.replace)
print(json.dumps(result))
if __name__ == '__main__':
main()
+1 -1
View File
@@ -60,7 +60,7 @@ if not errorlevel 1 (
echo OK - port is free.
echo [2/3] Checking dependencies ...
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv" >nul 2>nul
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv, openpyxl" >nul 2>nul
if errorlevel 1 (
echo Missing packages, installing ...
"%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt
+15
View File
@@ -107,6 +107,21 @@ body:not(.has-active-session) .close { display: none; }
#reply-send:hover:enabled { background: var(--brand-hover); }
.reply-status { font-size: 12px; line-height: 1.6; color: var(--success); padding-top: 7px; }
.reply-status.is-error { color: var(--danger); }
.system-msg { display: block; text-align: center; color: var(--muted); font-size: 12px; }
.recall-button { border: 0; background: transparent; padding: 0 0 0 10px; color: var(--muted); font-size: 11px; }
.night-badge { padding: 4px 7px; border-radius: 5px; background: var(--brand-soft); color: var(--secondary); font-size: 11px; }
.note-input { width: 100%; padding: 9px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); resize: vertical; font-size: 13px; }
.search-dialog { width: min(660px, calc(100vw - 32px)); max-height: calc(100dvh - 60px); padding: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
.search-dialog::backdrop { background: #19263d55; }
.search-dialog header, .search-dialog form { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
.search-dialog h2 { font-size: 17px; }
.search-dialog input { flex: 1; min-width: 0; padding: 10px; border: 1px solid var(--border); border-radius: 7px; }
.search-dialog .quick { width: auto; margin: 0; }
.search-result { padding: 14px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
.search-result h3 { font-size: 14px; }
.search-result p { margin: 8px 0; white-space: pre-wrap; font-size: 13px; }
.search-result small { color: var(--muted); }
@media (max-width: 720px) { .night-badge { display: none; } .participant-copy { min-width: 55px; } .chat-header-actions { gap: 4px; } }
.side { display: none; min-height: 0; min-width: 0; overflow-y: auto; background: var(--surface); border-left: 1px solid var(--border); }
.tools-open .layout { grid-template-columns: 300px minmax(0, 1fr) 310px; }
.tools-open .side { display: block; }
+73 -31
View File
@@ -1,5 +1,5 @@
/* Customer workspace: navigation, welcome, conversation and composer. */
body { padding: 20px; height: 100dvh; overflow: hidden; }
body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.chat-container {
display: flex; width: min(1440px, 100%); height: 100%; margin: auto;
overflow: hidden; position: relative; border: 1px solid var(--border);
@@ -42,9 +42,20 @@ body { padding: 20px; 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); }
.sidebar-note { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; }
.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; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.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; }
.header-copy { min-width: 0; flex: 1; }
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
@@ -64,7 +75,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.ai-avatar svg { width: 27px; height: 27px; }
.welcome-eyebrow { color: var(--brand-text); font-size: 11px; font-weight: 600; letter-spacing: .06em; margin-bottom: 9px; }
.ai-welcome-card h3 { font-size: clamp(22px, 2.2vw, 30px); line-height: 1.4; letter-spacing: -.035em; font-weight: 650; color: var(--text); }
.ai-welcome-card p { color: var(--secondary); font-size: 13px; line-height: 1.8; margin-top: 10px; }
.suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 28px 0 0; }
.suggest-item { display: flex; align-items: center; gap: 13px; min-height: 83px; padding: 17px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); color: var(--text); text-align: left; width: 100%; }
.suggest-item:hover { border-color: #9ab6f5; background: var(--brand-soft); box-shadow: 0 3px 12px rgba(49, 95, 213, .04); }
@@ -72,7 +82,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.suggest-icon svg { width: 19px; height: 19px; }
.suggest-copy { min-width: 0; }
.suggest-copy strong { display: block; font-size: 14px; font-weight: 600; }
.suggest-copy small { display: block; color: var(--secondary); font-size: 12px; margin-top: 4px; line-height: 1.6; }
.suggest-arrow { margin-left: auto; color: var(--muted); font-size: 17px; }
.hot-questions { margin-top: 24px; }
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
@@ -86,21 +95,33 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.recent-item:hover { color: var(--brand-text); }
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
.voice-toggle, .voice-hold { display: none; }
.voice-panel { position: absolute; z-index: 35; left: 50%; bottom: calc(var(--composer-height, 150px) + 14px); transform: translateX(-50%); width: min(330px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow); text-align: center; pointer-events: none; }
.voice-panel p { margin: 9px 0; font-size: 13px; }
.voice-panel.is-cancelling { color: var(--danger); border-color: var(--danger); }
.voice-wave { display: block; font-size: 26px; letter-spacing: 7px; }
#voice-clock { font-size: 12px; color: var(--muted); }
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
.hot-bar-label { font-size: 10px; color: var(--muted); margin-right: 3px; }
.human-session { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; }
.human-session button { flex-shrink: 0; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--brand-text); font-size: 12px; }
.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; }
.input-area { display: flex; align-items: flex-end; gap: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 9px; background: var(--surface); box-shadow: 0 3px 15px rgba(28, 47, 80, .035); }
.input-area:focus-within { border-color: #7f9fe8; box-shadow: 0 0 0 3px var(--brand-soft); }
#upload-btn { flex: 0 0 36px; height: 38px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--secondary); font-size: 18px; }
#upload-btn:hover { color: var(--brand-text); }
.service-rating { margin: 12px auto; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-soft); text-align: center; color: var(--secondary); font-size: 13px; }
.rating-choices { display: flex; justify-content: center; gap: 4px; margin-top: 6px; }
.rating-choices button { padding: 3px 8px; background: transparent; border: 0; color: var(--muted); font-size: 28px; }
.rating-choices button.selected, .rating-choices button:focus-visible { color: #bd8610; }
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
#input-text::placeholder { color: var(--muted); }
#input-text:placeholder-shown { white-space: nowrap; overflow: hidden; }
#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; }
#send-button:hover { background: var(--brand-hover); }
#send-button.stop { background: var(--danger); }
.composer-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 2px 0; }
.disclaimer, .keyboard-hint { color: var(--muted); font-size: 11px; }
.keyboard-hint { white-space: nowrap; }
.msg-row { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; max-width: 820px; margin: 0 auto 24px; }
.msg-row.user-line { align-items: flex-end; }
.message-author { display: flex; align-items: center; gap: 8px; min-height: 24px; margin-bottom: 4px; color: var(--secondary); font-size: 12px; font-weight: 600; }
@@ -117,6 +138,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); }
.msg-action-btn { min-height: 30px; border: 0; padding: 3px 8px; color: var(--secondary); background: transparent; border-radius: 5px; font-size: 12px; }
.msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); }
.vote-status { align-self: center; color: var(--muted); font-size: 11px; }
.vote-status.is-error { color: var(--danger); }
.error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); }
.err-line { margin-bottom: 9px; }
.retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; }
@@ -144,6 +167,13 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.track-expand { width: 100%; margin-top: 3px; padding: 7px 10px; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; }
.track-expand:hover { background: var(--brand-soft); }
.track-card .msg-actions { margin-top: 8px; padding-top: 8px; }
.order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; }
.order-data-fields dt { color: var(--muted); }
.order-data-fields dd { margin: 0; overflow-wrap: anywhere; }
.order-details-toggle { display: none; }
.order-history-empty { color: var(--muted); font-size: 11px; margin: 10px 0; }
.order-history-title { margin: 18px 0 10px; font-size: 13px; font-weight: 600; }
.order-tracking-status { margin: 0 0 12px; color: var(--brand-text); font-size: 13px; }
.to-bottom { display: none; position: absolute; right: 24px; bottom: calc(var(--composer-height, 150px) + 12px); z-index: 4; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--secondary); box-shadow: var(--shadow); }
.to-bottom.show { display: block; }
.to-bottom:hover { color: var(--brand-text); border-color: var(--brand); }
@@ -154,7 +184,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.attachment-file-icon { display: grid; place-items: center; flex-shrink: 0; width: 42px; height: 42px; font-size: 10px; font-weight: 650; border-radius: 7px; background: var(--brand-soft); color: var(--brand-text); }
.attachment-copy { flex: 1; min-width: 0; }
.attachment-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; }
.attachment-copy small { display: block; font-size: 10px; color: var(--muted); margin-top: 2px; }
.attachment-remove { flex-shrink: 0; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 7px; color: var(--secondary); background: transparent; font-size: 21px; }
.attachment-remove:hover { color: var(--danger); background: var(--danger-soft); }
.composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; }
@@ -166,7 +195,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.ai-welcome-card { padding-top: 0; }
.ai-avatar { width: 42px; height: 42px; margin-bottom: 12px; }
.welcome-eyebrow { margin-bottom: 6px; }
.ai-welcome-card p { margin-top: 8px; }
.suggest-grid { margin-top: 20px; gap: 10px; }
.suggest-item { min-height: 72px; padding: 13px 16px; }
.hot-questions { margin-top: 18px; }
@@ -179,22 +207,38 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.composer { width: calc(100% - 50px); }
.service-label { display: none; }
.suggest-item { padding: 13px; gap: 10px; }
.suggest-copy small { font-size: 11px; }
}
@media (max-width: 900px) {
body { padding: 0; }
.chat-container { border: 0; border-radius: 0; height: 100dvh; }
.left-panel { position: absolute; left: 0; top: 72px; bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
.chat-container { --chat-header-height: 72px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
.left-panel { position: absolute; left: 0; top: calc(var(--chat-header-height) + env(safe-area-inset-top)); bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
.left-panel .brand { display: none; }
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: 72px 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: calc(var(--chat-header-height) + env(safe-area-inset-top)) 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
.main-chat { width: 100%; }
.chat-title { min-height: 72px; height: 72px; padding: 13px 18px; gap: 11px; }
#chatDom { padding: 24px 20px 15px; }
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); padding: 13px 18px; gap: 11px; }
#chatDom { padding: 24px 20px 15px; overscroll-behavior-y: contain; }
.ai-welcome-card { padding-top: 20px; }
.composer { width: calc(100% - 40px); }
.composer { width: calc(100% - 40px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
.chat-container.is-composing { --chat-header-height: 56px; }
.is-composing .chat-title { padding-top: 8px; padding-bottom: 8px; }
.is-composing .composer { padding-top: 6px; padding-bottom: 6px; }
.is-composing .composer-footer { display: none; }
#input-text, .history-search { font-size: max(16px, 1em); }
.history-search { height: 44px; }
.history-search-wrap svg { top: 14px; }
.composer-toolbar { margin-bottom: 8px; }
.hot-bar .hot-capsule { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; }
.input-area { align-items: center; }
.input-area .emoji-control { display: flex; align-items: center; }
#upload-btn, #emoji-toggle, #send-button { display: inline-flex; align-items: center; justify-content: center; height: 44px; min-height: 44px; padding-top: 0; padding-bottom: 0; border-radius: 8px; line-height: 1; }
#input-text { min-height: 44px; padding-top: 10px; padding-bottom: 10px; line-height: 1.5; }
.user-message, .robot-message { max-width: 94%; }
.del-history-btn { opacity: 1; }
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; line-height: 1; }
.input-area.voice-mode #input-text, .input-area.voice-mode .emoji-control, .input-area.voice-mode #send-button { display: none; }
.input-area.voice-mode .voice-hold { display: block; flex: 1; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); font-size: 15px; font-weight: 600; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
.voice-hold.is-recording { color: var(--brand-text); background: var(--brand-soft); }
}
@media (max-width: 520px) {
.header-kicker { font-size: 9px; }
@@ -207,12 +251,10 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.ai-avatar { display: none; }
.ai-welcome-card { padding-top: 12px; }
.ai-welcome-card h3 { font-size: 23px; }
.ai-welcome-card p { max-width: 300px; margin: 10px auto 0; font-size: 12px; }
.suggest-grid { gap: 9px; margin-top: 22px; }
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 114px; padding: 13px; gap: 9px; }
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 88px; padding: 13px; gap: 9px; }
.suggest-icon { width: 32px; height: 32px; border-radius: 8px; }
.suggest-copy strong { font-size: 13px; }
.suggest-copy small { font-size: 11px; }
.suggest-arrow { display: none; }
.hot-questions { margin-top: 22px; }
.hot-questions .hot-capsule-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; }
@@ -220,21 +262,21 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.hot-capsule { font-size: 10px; padding: 6px 8px; }
#recentChatWrap { grid-template-columns: 1fr; gap: 0; }
.composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; margin-bottom: 8px; }
.hot-bar-label { display: none; }
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; }
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; }
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; padding: 6px 9px; font-size: 11px; }
.input-area { gap: 7px; padding: 7px; }
#send-button { min-width: 57px; padding: 0 12px; }
#upload-btn { flex-basis: 36px; height: 42px; }
#upload-btn { flex-basis: 36px; }
#input-text { font-size: 16px; }
#send-button { min-height: 42px; }
.attachment-remove { width: 40px; height: 40px; }
.keyboard-hint { display: none; }
.composer-footer { justify-content: center; padding-top: 7px; }
.disclaimer { font-size: 10px; }
.track-text { grid-template-columns: 1fr; gap: 2px; }
.track-place { grid-column: 1; }
.robot-message.track-card { padding: 16px; }
.track-no { font-size: 15px; }
.track-head { align-items: flex-start; gap: 10px; }
.track-no { min-width: 0; flex: 1; font-size: 15px; }
.order-details-toggle { display: block; width: 100%; min-height: 44px; margin: 0 0 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; }
.order-data-fields.is-collapsed { display: none; }
.track-expand { min-height: 44px; }
.msg-action-btn { min-height: 40px; }
.to-bottom { right: 15px; }
}
+7
View File
@@ -76,6 +76,13 @@ svg { display: block; flex-shrink: 0; }
border: 1px solid var(--border); border-radius: 9px; color: var(--secondary); background: var(--surface);
}
.icon-button:hover { color: var(--brand-text); background: var(--brand-soft); }
.text-button { border: 0; padding: 5px 0; background: transparent; color: var(--brand-text); font-size: 12px; }
.emoji-control { position: relative; flex-shrink: 0; }
.emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
.emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
.emoji-picker button:hover { background: var(--surface-hover); }
.attachment-unavailable { display: block; max-width: 260px; padding: 12px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--secondary); font-size: 12px; line-height: 1.6; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-thumb { background: #b7c3d5; border-radius: 10px; }
+36 -8
View File
@@ -11,6 +11,7 @@ var typeTimer = null;
var toolsOpen = false;
var toolsOpener = null;
var replyInput = document.getElementById("replyText");
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
function escapeText(value){
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
@@ -48,6 +49,7 @@ function updateComposer(){
document.querySelectorAll(".quick").forEach(function(button){
button.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
});
updateAdminTools();
}
function resizeReply(){
@@ -95,11 +97,12 @@ function renderQueue(){
async function loadQueue(){
try{
queueItems = await adminRequest("/api/human/queue");
renderQueue();
if(!curSid && queueItems.length && !sessions[String(queueItems[0].id)]){
accept(queueItems[0].id);
const list = await adminRequest("/api/human/queue");
if(list.some(function(item){ return !queueItems.some(function(old){ return old.id === item.id; }); })){
SupportUI.notify("新客户排队", "有 " + list.length + " 位客户等待接入");
}
queueItems = list;
renderQueue();
}catch(error){
document.getElementById("queue").innerHTML = '<div class="list-empty">队列暂时无法加载,稍后重试</div>';
}
@@ -153,6 +156,7 @@ function switchSession(sid){
resizeReply();
document.getElementById("chatBox").scrollTop = document.getElementById("chatBox").scrollHeight;
setReplyStatus("");
loadSessionNote(sid);
updateComposer();
renderList();
setSessionTab("active");
@@ -168,6 +172,7 @@ function showEmptySession(){
document.getElementById("chatTitle").textContent = "会话接待";
document.getElementById("chat-subtitle").textContent = "选择一段会话,开始回复客户";
replyInput.value = "";
document.getElementById("noteInput").value = "";
setReplyStatus("");
updateComposer();
setChatView(false);
@@ -177,12 +182,28 @@ function appendMsg(message){
const box = document.getElementById("chatBox");
const follow = box.scrollHeight - box.scrollTop - box.clientHeight < 100;
const div = document.createElement("div");
div.setAttribute("data-message-id", String(message.id));
if(message.side === "system"){
div.className = "msg system-msg";
div.textContent = message.content;
box.appendChild(div);
if(follow) box.scrollTop = box.scrollHeight;
return;
}
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
let content = escapeText(message.content);
content = content.replace(/\[图片\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener"><img src="$1" alt="客户附件" class="message-image"></a>');
content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
content = content.replace(/\[图片\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener"><img src="/api/staff/uploads/$1" alt="客户附件" class="message-image"></a>');
content = content.replace(/\[文件\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
const avatar = message.side === "customer" ? "客" : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M4 14v-3a8 8 0 0 1 16 0v3M19 17a4 4 0 0 1-4 4h-3"/><rect x="2" y="11" width="4" height="7" rx="2"/><rect x="18" y="11" width="4" height="7" rx="2"/></svg>';
div.innerHTML = '<span class="message-avatar" aria-hidden="true">'+avatar+'</span><div class="message-stack"><div class="meta">'+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'</div><div class="bubble">'+content+'</div></div>';
if(message.side === "agent"){
const recall = document.createElement("button");
recall.type = "button";
recall.className = "recall-button";
recall.textContent = "撤回";
recall.onclick = function(){ recallMsg(message.id); };
div.querySelector(".meta").appendChild(recall);
}
box.appendChild(div);
if(follow || message.side === "agent") box.scrollTop = box.scrollHeight;
}
@@ -234,7 +255,7 @@ async function poll(){
const session = sessions[sid];
const state = await adminRequest("/api/human/poll?sid="+Number(sid)+"&after="+session.lastId);
if(sessions[sid] !== session) return;
if(state.status === "closed"){
if(state.status !== "active"){
delete sessions[sid];
delete drafts[sid];
if(sid === curSid) showEmptySession();
@@ -243,8 +264,10 @@ async function poll(){
state.messages.forEach(function(message){
if(session.msgs.some(function(existing){ return existing.id === message.id; })) return;
session.lastId = Math.max(session.lastId, message.id);
if(message.recalled_id && applyAdminRecall(sid, message.recalled_id)) return;
session.msgs.push(message);
if(message.side === "customer" && sid !== curSid) session.unread += 1;
if(message.side === "customer") SupportUI.notify("客户消息 #"+sid, message.content.substring(0, 50));
if(sid === curSid) appendMsg(message);
});
}));
@@ -370,7 +393,7 @@ document.addEventListener("keydown", function(event){
if(event.key === "Escape" && toolsOpen) document.getElementById("tools-close").click();
if(event.key === "Escape") document.getElementById("data-overview").open = false;
if(event.key === "Tab" && toolsOpen && window.matchMedia("(max-width: 1200px)").matches){
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href]'));
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href], textarea:not([disabled])'));
const first = items[0], last = items[items.length-1];
if(event.shiftKey && document.activeElement === first){ event.preventDefault(); last.focus(); }
else if(!event.shiftKey && document.activeElement === last){ event.preventDefault(); first.focus(); }
@@ -381,7 +404,12 @@ adminRequest("/api/admin/stats").then(function(data){
[data.total_conv, data.total_msg, data.today_conv, data.good, data.bad].forEach(function(value, index){
document.getElementById("s"+(index+1)).textContent = value;
});
document.getElementById("s6").textContent = (data.rating_avg || 0) + " / 5("+(data.rating_count || 0)+")";
}).catch(function(){});
SupportUI.initEmoji(document.getElementById("agent-emoji-toggle"), document.getElementById("agent-emoji-panel"), replyInput, function(){
if(curSid) drafts[curSid] = replyInput.value;
resizeReply();
});
setToolsOpen(false);
setSessionTab("active");
updateComposer();
+119
View File
@@ -0,0 +1,119 @@
// Workspace notes, transfers, recalls and historical message search.
function updateAdminTools(){
document.getElementById('transfer-session').disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
document.getElementById('agent-emoji-toggle').disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
document.getElementById('noteInput').disabled = !curSid || Boolean(sessions[curSid].noteLoading);
document.getElementById('note-save').disabled = !curSid || !sessions[curSid].noteLoaded || Boolean(sessions[curSid].noteLoading);
}
async function loadSessionNote(sid){
const session = sessions[sid];
const input = document.getElementById('noteInput');
input.value = session.noteDraft || '';
if(session.noteLoaded){ updateAdminTools(); return; }
session.noteLoading = true;
updateAdminTools();
try{
const state = await adminRequest('/api/human/poll?sid='+Number(sid)+'&after=0');
if(sessions[sid] !== session) return;
session.noteDraft = state.note || '';
session.noteLoaded = true;
if(curSid === sid) input.value = session.noteDraft;
}catch(error){
if(curSid === sid) setReplyStatus('备注加载失败,请重新选择会话后重试。', true);
}finally{
session.noteLoading = false;
updateAdminTools();
}
}
async function saveNote(){
if(!curSid || !sessions[curSid].noteLoaded) return;
const sid = curSid, session = sessions[sid];
const note = document.getElementById('noteInput').value;
session.noteDraft = note;
try{
await postAdmin('/api/human/note', {sid:Number(sid), note:note});
if(curSid === sid) setReplyStatus(session.noteDraft === note ? '备注已保存' : '先前备注已保存,当前修改尚未保存');
}catch(error){
if(curSid === sid) setReplyStatus('备注保存失败,内容已保留。', true);
}
}
async function transferSession(){
if(!curSid || closingSid || sendingSid) return;
if(!window.confirm('确定转接这个对话?转接后其他客服可在排队列表接入。')) return;
const sid = curSid;
closingSid = sid;
updateComposer();
try{
await postAdmin('/api/human/transfer', {sid:Number(sid)});
delete sessions[sid];
if(curSid === sid) showEmptySession();
renderList();
loadQueue();
}catch(error){
if(curSid === sid) setReplyStatus('转接未成功,会话仍保留。', true);
}finally{
closingSid = '';
updateComposer();
}
}
function applyAdminRecall(sid, mid){
const message = sessions[sid].msgs.find(function(item){ return item.id === mid; });
if(message){ message.side = 'system'; message.content = '该消息已撤回'; }
if(curSid === sid){
const row = document.querySelector('#chatBox [data-message-id="'+Number(mid)+'"]');
if(row){ row.className = 'msg system-msg'; row.textContent = '该消息已撤回'; }
}
return Boolean(message);
}
async function recallMsg(mid){
if(!curSid) return;
const sid = curSid;
try{
await postAdmin('/api/human/recall', {mid:mid});
if(sessions[sid]){ applyAdminRecall(sid, mid); renderList(); }
}catch(error){
if(curSid === sid) setReplyStatus('撤回失败,请稍后重试。', true);
}
}
let searchVersion = 0;
async function doSearch(){
const keyword = document.getElementById('searchBox').value.trim();
const version = ++searchVersion;
const body = document.getElementById('searchBody');
if(!keyword){ body.textContent = '请输入关键词搜索历史消息'; return; }
body.textContent = '正在搜索…';
try{
const list = await adminRequest('/api/search_chat?q='+encodeURIComponent(keyword));
if(version !== searchVersion) return;
const roles = {user:'客户', assistant:'客服', system:'系统'};
body.innerHTML = list.length ? list.map(function(item){
return '<article class="search-result"><h3>'+escapeText(item.title)+'</h3><p>'+escapeText(item.content)+'</p><small>'+escapeText(roles[item.role] || item.role)+' · '+escapeText(item.ctime)+'</small></article>';
}).join('') : '没有匹配的历史消息';
}catch(error){
if(version === searchVersion) body.textContent = '搜索失败,请稍后重试';
}
}
document.getElementById('history-search-toggle').onclick = function(){
document.getElementById('searchResult').showModal();
document.getElementById('searchBox').focus();
};
document.getElementById('search-close').onclick = function(){ document.getElementById('searchResult').close(); };
document.getElementById('history-search-form').addEventListener('submit', function(event){ event.preventDefault(); doSearch(); });
document.getElementById('note-save').onclick = saveNote;
document.getElementById('noteInput').addEventListener('input', function(){
if(curSid) sessions[curSid].noteDraft = this.value;
});
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
function updateNightBadge(){
const hour = new Date().getHours();
document.getElementById('nightBadge').hidden = hour >= 8 && hour < 22;
}
updateNightBadge();
setInterval(updateNightBadge, 60000);
+882 -270
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -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' : '';
}};
}
};
+77
View File
@@ -0,0 +1,77 @@
// Text drafts stay in this visitor's browser, independently for each conversation.
var ChatDrafts = {
create(options){
const scope = /^[0-9a-f]{64}$/.test(options.scope || '') ? options.scope : null;
const prefix = scope ? 'baiyue-draft-v1:' + scope + ':' : null;
const activeKey = scope ? 'baiyue-active-chat-v1:' + scope : null;
const memory = new Map();
const dirty = new Set();
let current = 'new';
function id(value){
if(value === null || value === 'new') return 'new';
const key = String(value);
return /^[1-9][0-9]{0,18}$/.test(key) ? key : null;
}
function storage(name){
try{ return options[name](); }catch(error){ return null; }
}
function read(value){
const key = id(value);
if(!key) return '';
if(dirty.has(key)) return memory.get(key) || '';
if(prefix){
try{
const saved = storage('storage').getItem(prefix + key);
if(saved !== null){
const data = JSON.parse(saved);
if(data && data.version === 1 && typeof data.text === 'string'){
memory.set(key, data.text);
return data.text;
}
}
memory.delete(key);
return '';
}catch(error){}
}
return memory.get(key) || '';
}
function write(value, text){
const key = id(value);
if(!key || typeof text !== 'string') return false;
if(text) memory.set(key, text); else memory.delete(key);
dirty.add(key);
if(!prefix) return false;
try{
const target = storage('storage');
if(text) target.setItem(prefix + key, JSON.stringify({version:1,text:text}));
else target.removeItem(prefix + key);
dirty.delete(key);
return true;
}catch(error){ return false; }
}
function active(value){
if(arguments.length){
current = id(value) || 'new';
if(activeKey) try{ storage('tabStorage').setItem(activeKey, current); }catch(error){}
}else if(activeKey){
try{ const saved=storage('tabStorage').getItem(activeKey); if(saved !== null) current = id(saved) || current; }catch(error){}
}
return current;
}
function clear(){
memory.clear();
dirty.clear();
if(prefix){
try{
const target = storage('storage');
for(let index = target.length - 1; index >= 0; index--){
const key = target.key(index);
if(key && key.startsWith(prefix)) target.removeItem(key);
}
}catch(error){}
}
active('new');
}
return {read:read,write:write,active:active,remove:value=>write(value,''),clear:clear};
}
};
+109
View File
@@ -0,0 +1,109 @@
// Shared controls for the customer page and service workspace.
var SupportUI = {
initAttachments(container, label){
container.addEventListener('error', function(event){
const image = event.target;
if(!image || image.tagName !== 'IMG' || !image.classList.contains('message-image') || image.dataset.unavailable) return;
image.dataset.unavailable = '1';
image.hidden = true;
const hint = document.createElement('span');
hint.className = 'attachment-unavailable';
hint.textContent = typeof label === 'function' ? label() : label;
image.after(hint);
}, true);
},
async enableNotifications(button){
if(!('Notification' in window)) return;
try{
const permission = await Notification.requestPermission();
button.setAttribute('aria-pressed', String(permission === 'granted'));
}catch(error){}
},
notify(title, body){
if(!document.hidden || !('Notification' in window) || Notification.permission !== 'granted') return;
try{
const notice = new Notification(title, {body:body});
notice.onclick = function(){ window.focus(); notice.close(); };
}catch(error){}
},
addRating(container, sid, labels){
const request = labels.fetch || fetch;
const box = document.createElement('section');
box.className = 'service-rating';
const question = document.createElement('p');
question.textContent = labels.question;
box.appendChild(question);
const choices = document.createElement('div');
choices.className = 'rating-choices';
box.appendChild(choices);
const status = document.createElement('p');
status.setAttribute('role', 'status');
box.appendChild(status);
let sending = false;
[1, 2, 3, 4, 5].forEach(function(stars){
const button = document.createElement('button');
button.type = 'button';
button.textContent = '★';
button.setAttribute('aria-label', stars+' / 5');
button.onmouseenter = function(){
Array.from(choices.children).forEach(function(item, index){ item.classList.toggle('selected', index < stars); });
};
button.onclick = async function(){
if(sending) return;
sending = true;
Array.from(choices.children).forEach(function(item){ item.disabled = true; });
try{
const response = await request('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
const data = await response.json();
if(!response.ok || !data.ok) throw new Error('Rating failed');
box.textContent = labels.thanks+' '+ '★'.repeat(stars);
}catch(error){
sending = false;
status.textContent = labels.failed;
Array.from(choices.children).forEach(function(item){ item.disabled = false; });
}
};
choices.appendChild(button);
});
container.appendChild(box);
return box;
},
initEmoji(button, panel, input, onInsert){
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
function close(){
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
button.onclick = function(){
panel.hidden = !panel.hidden;
button.setAttribute('aria-expanded', String(!panel.hidden));
};
emojis.forEach(function(emoji){
const choice = document.createElement('button');
choice.type = 'button';
choice.textContent = emoji;
choice.onclick = function(){
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
const end = input.selectionEnd == null ? start : input.selectionEnd;
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
close();
input.focus({preventScroll:true});
input.setSelectionRange(start + emoji.length, start + emoji.length);
if(onInsert) onInsert();
};
panel.appendChild(choice);
});
['pointerdown','mousedown'].forEach(function(name){
document.addEventListener(name, function(event){
if(!panel.hidden && !panel.contains(event.target) && !button.contains(event.target)) close();
});
});
document.addEventListener('keydown', function(event){
if(event.key === 'Escape' && !panel.hidden){
close();
button.focus({preventScroll:true});
}
});
return {close:close};
}
};
+49
View File
@@ -0,0 +1,49 @@
// A local random guest key restores this browser's history, never customer order identity.
var VisitorSession = (function(){
const storageName = 'baiyue-visitor-key';
const nativeFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : fetch;
let ready = null, established = false;
function localKey(){
try{
const saved = localStorage.getItem(storageName);
if(saved && /^[0-9a-f]{64}$/.test(saved)) return saved;
const crypto = window.crypto;
if(!crypto || !crypto.getRandomValues) return null;
const bytes = crypto.getRandomValues(new Uint8Array(32));
const key = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
localStorage.setItem(storageName, key);
return key;
}catch(error){ return null; } // HttpOnly cookie can still retain this guest when storage is disabled.
}
function ensure(){
if(ready) return ready;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
ready = nativeFetch('/api/visitor/session', {method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json'},body:JSON.stringify({key:localKey()}),signal:controller.signal})
.then(async response => {
const data = await response.json();
if(!response.ok || !data.ok || typeof data.key !== 'string' || !/^[0-9a-f]{64}$/.test(data.key)){
throw new Error('访客会话暂时无法建立,请刷新后重试。');
}
try{ localStorage.setItem(storageName, data.key); }catch(error){}
established = true;
}).catch(error => { ready = null; established = false; throw error; }).finally(() => clearTimeout(timer));
return ready;
}
function request(path, options){
const init = Object.assign({}, options, {credentials:'same-origin'});
function send(){
if(init.signal && init.signal.aborted) return Promise.reject(Object.assign(new Error('Aborted'), {name:'AbortError'}));
const session = ready;
return nativeFetch(path, init).then(async response => {
if(response.status !== 401) return response;
if(ready === session){ ready = null; established = false; }
await ensure();
return nativeFetch(path, init);
});
}
return established ? send() : ensure().then(send);
}
return {fetch:request,localKey:localKey};
})();
+220
View File
@@ -0,0 +1,220 @@
// Short recordings use the same server ASR in mobile browsers and embedded browsers.
var ChatVoice = {
create(options){
const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch);
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
let session = null, mode = false;
const active = current => session === current;
function stopTracks(current){
if(current.stream) current.stream.getTracks().forEach(track => track.stop());
}
function clearTimers(current){
clearTimeout(current.limitTimer);
clearTimeout(current.permissionTimer);
clearInterval(current.clockTimer);
}
function render(){
toggle.setAttribute('aria-pressed', String(mode));
toggle.setAttribute('aria-label', text(mode ? 'voiceKeyboard' : 'voiceInput'));
toggle.title = text(mode ? 'voiceKeyboard' : 'voiceInput');
toggle.textContent = mode ? '⌨' : '🎙';
inputArea.classList.toggle('voice-mode', mode);
hold.textContent = text(session ? 'voiceRelease' : 'voiceHold');
hold.classList.toggle('is-recording', Boolean(session));
panel.hidden = !session;
}
function cancel(message){
const current = session;
session = null;
if(current){
current.controller.abort();
clearTimers(current);
if(current.recorder && current.recorder.state !== 'inactive'){
try{ current.recorder.stop(); }catch(error){}
}
stopTracks(current);
}
render();
if(message) status(text(message), true);
}
function setMode(on){
cancel();
mode = on;
render();
if(on && input.blur) input.blur();
}
function supported(){
if(window.isSecureContext === false){ status(text('voiceHttps'), true); return false; }
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder){
status(text('voiceUnsupported'), true);
return false;
}
return true;
}
async function jsonRequest(current, url, init, timeout){
const timer = setTimeout(function(){ current.timedOut = true; current.controller.abort(); }, timeout);
try{
const response = await fetch(url, Object.assign({}, init, {signal:current.controller.signal}));
const data = await response.json();
if(!response.ok) throw new Error(data.error || data.e || text('voiceFailed'));
return data;
}finally{ clearTimeout(timer); }
}
async function transcribe(current){
if(!active(current)) return;
if(!current.chunks.length){ cancel('voiceEmpty'); return; }
current.phase = 'transcribing';
label.textContent = text('voiceTranscribing');
const blob = new Blob(current.chunks, {type:current.recorder.mimeType || current.mime});
if(!blob.size || blob.size > current.maxBytes){ cancel('voiceTooLarge'); return; }
const form = new FormData();
form.append('audio', blob, 'recording');
form.append('duration', String(Math.min(60, current.duration / 1000)));
form.append('language', options.language());
try{
const data = await jsonRequest(current, '/api/speech/transcribe', {method:'POST',body:form}, 40000);
if(!active(current)) return;
if(current.context !== options.context()){ cancel(); return; }
if(!data.ok || typeof data.text !== 'string' || !data.text.trim()){
cancel('voiceEmpty'); return;
}
session = null;
render();
await options.onText(data.text.trim());
}catch(error){
if(!active(current)) return;
const message = current.timedOut ? text('voiceTimeout') : error.message || text('voiceFailed');
cancel();
status(message, true);
}
}
function finish(current, cancelled){
if(!active(current) || !current.holding) return;
current.holding = false;
if(cancelled || current.cancelled){ cancel(); status(text('voiceCancelled')); return; }
if(current.phase !== 'recording'){ cancel('voiceTooShort'); return; }
current.duration = Date.now() - current.startedAt;
if(current.duration < 400){ cancel('voiceTooShort'); return; }
current.phase = 'stopping';
label.textContent = text('voiceTranscribing');
clearTimers(current);
try{ current.recorder.stop(); }
catch(error){ cancel('voiceFailed'); }
finally{ stopTracks(current); }
}
async function start(id, y){
if(session || !mode) return;
const reason = options.canStart();
if(reason){ status(reason, true); return; }
if(!supported()) return;
const current = {id:id,startY:y,holding:true,chunks:[],bytes:0,phase:'preparing',
controller:new AbortController(),context:options.context()};
session = current;
label.textContent = text('voicePreparing');
clock.textContent = '';
panel.classList.remove('is-cancelling');
status('');
render();
try{
const config = await jsonRequest(current, '/api/speech/config', {}, 5000);
if(!active(current)) return;
if(!config.enabled){ cancel('voiceUnavailable'); return; }
current.maxBytes = Math.min(Number(config.max_bytes) || 5242880, 5242880);
current.permissionTimer = setTimeout(function(){ if(active(current)) cancel('voiceTimeout'); }, 15000);
const stream = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true},video:false});
clearTimeout(current.permissionTimer);
if(!active(current)){ stream.getTracks().forEach(track => track.stop()); return; }
current.stream = stream;
const formats = ['audio/webm;codecs=opus','audio/mp4','audio/webm','audio/ogg;codecs=opus'];
current.mime = window.MediaRecorder.isTypeSupported
? formats.find(mime => window.MediaRecorder.isTypeSupported(mime)) || '' : '';
current.recorder = new window.MediaRecorder(stream, Object.assign({audioBitsPerSecond:64000},
current.mime ? {mimeType:current.mime} : {}));
current.recorder.ondataavailable = function(event){
if(!active(current) || !event.data || !event.data.size) return;
current.bytes += event.data.size;
if(current.bytes > current.maxBytes){ cancel('voiceTooLarge'); return; }
current.chunks.push(event.data);
};
current.recorder.onerror = function(){ if(active(current)) cancel('voiceFailed'); };
current.recorder.onstop = function(){
if(!active(current)) return;
if(current.phase === 'stopping') transcribe(current);
else cancel('voiceFailed');
};
current.recorder.start(250);
current.startedAt = Date.now();
current.phase = 'recording';
label.textContent = text('voiceCancelTip');
clock.textContent = '0 / 60 s';
current.clockTimer = setInterval(function(){
clock.textContent = Math.min(60, Math.floor((Date.now()-current.startedAt)/1000)) + ' / 60 s';
}, 250);
current.limitTimer = setTimeout(function(){ finish(current, false); }, 60000);
}catch(error){
if(!active(current)) return;
const key = current.timedOut ? 'voiceTimeout'
: ['NotAllowedError','PermissionDeniedError'].includes(error.name) ? 'voicePermission'
: ['NotFoundError','DevicesNotFoundError'].includes(error.name) ? 'voiceNoMic' : 'voiceFailed';
cancel(key);
}
}
function move(id, y){
if(!session || !session.holding || session.id !== id) return;
session.cancelled = session.startY - y > 72;
panel.classList.toggle('is-cancelling', session.cancelled);
if(session.phase === 'recording') label.textContent = text(session.cancelled ? 'voiceCancelRelease' : 'voiceCancelTip');
}
toggle.onclick = function(){
if(!mode && !supported()) return;
setMode(!mode);
if(!mode) input.focus({preventScroll:true});
};
if(window.PointerEvent){
hold.addEventListener('pointerdown', function(event){
if(event.button !== 0) return;
event.preventDefault();
if(hold.setPointerCapture) try{ hold.setPointerCapture(event.pointerId); }catch(error){}
start(event.pointerId, event.clientY);
});
hold.addEventListener('pointermove', event => move(event.pointerId, event.clientY));
hold.addEventListener('pointerup', function(event){
event.preventDefault();
if(session && session.id === event.pointerId) finish(session, false);
});
['pointercancel','lostpointercapture'].forEach(name => hold.addEventListener(name, function(){
if(session && session.holding) finish(session, true);
}));
}else{
hold.addEventListener('touchstart', function(event){
event.preventDefault();
if(event.touches.length !== 1) return;
const touch=event.touches[0]; start(touch.identifier, touch.clientY);
}, {passive:false});
hold.addEventListener('touchmove', function(event){
event.preventDefault();
for(const touch of event.changedTouches) move(touch.identifier, touch.clientY);
}, {passive:false});
hold.addEventListener('touchend', function(event){
event.preventDefault();
for(const touch of event.changedTouches) if(session && session.id === touch.identifier) finish(session, false);
}, {passive:false});
hold.addEventListener('touchcancel', function(){ if(session) finish(session, true); });
}
hold.addEventListener('contextmenu', event => event.preventDefault());
hold.addEventListener('keydown', function(event){
if(![' ','Enter'].includes(event.key) || event.repeat) return;
event.preventDefault(); start('keyboard', 0);
});
hold.addEventListener('keyup', function(event){
if([' ','Enter'].includes(event.key) && session && session.id === 'keyboard'){
event.preventDefault(); finish(session, false);
}
});
document.addEventListener('keydown', event => { if(event.key === 'Escape') cancel(); });
document.addEventListener('visibilitychange', function(){ if(document.hidden) cancel(); });
window.addEventListener('pagehide', function(){ cancel(); });
render();
return {cancel:cancel,setMode:setMode,refresh:render,busy:() => Boolean(session)};
}
};
+11 -2
View File
@@ -15,6 +15,8 @@
<span class="nav-divider" aria-hidden="true"></span><h1>客服工作台</h1>
</div>
<div class="topbar-actions">
<span id="nightBadge" class="night-badge" hidden>非工作时段</span>
<button id="notifications-toggle" class="icon-button" type="button" aria-label="开启浏览器通知" title="开启浏览器通知" aria-pressed="false">🔔</button>
<a class="workspace-link" href="/">客户入口 ↗</a>
<details class="data-overview" id="data-overview">
<summary><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M4 4v16h17M8 16v-5M13 16V7M18 16V3"/></svg>数据概览</summary>
@@ -25,7 +27,8 @@
<div class="stat"><span>今日新对话</span><b id="s3">—</b></div>
<div class="stat"><span>有帮助</span><b id="s4">—</b></div>
<div class="stat"><span>需改进</span><b id="s5">—</b></div>
<a class="export-link" href="/api/export_chat">导出记录 ↗</a>
<div class="stat"><span>服务评价</span><b id="s6">—</b></div>
<a class="export-link" href="/api/export_chat">导出 Excel ↗</a>
</div>
</details>
</div>
@@ -36,6 +39,7 @@
<div class="list-heading">
<h2>客户会话</h2><p>查看咨询,及时接待与回复</p>
<div class="session-search-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></svg><input id="session-search" type="search" aria-label="搜索客户会话" placeholder="搜索会话或消息" autocomplete="off"></div>
<button id="history-search-toggle" class="text-button" type="button">搜索历史记录 ↗</button>
</div>
<div class="session-tabs" role="tablist" aria-label="会话状态">
<button id="tab-active" type="button" role="tab" aria-selected="true" aria-controls="sessionList">接待中 <span id="active-count" class="section-count">0</span></button>
@@ -54,6 +58,7 @@
<div class="participant-copy"><h2 id="chatTitle">会话接待</h2><p id="chat-subtitle">选择一段会话,开始回复客户</p></div>
<div class="chat-header-actions">
<button id="tools-toggle" class="tools-toggle" type="button" aria-label="资料与快捷回复" aria-controls="service-tools" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="M6 16a3 3 0 0 1 6 0M14 8h3M14 12h3M14 16h3"/></svg><span>资料与回复</span></button>
<button id="transfer-session" class="close" type="button" onclick="transferSession()">转接</button>
<button id="close-session" class="close" type="button" onclick="closeSession()">结束会话</button>
</div>
</header>
@@ -64,7 +69,7 @@
<div id="chatContent" class="session-panel" style="display:none">
<div id="chatBox" role="log" aria-live="polite" aria-label="会话消息"></div>
<footer class="agent-composer">
<div class="composer-heading"><label for="replyText">回复客户</label><span>Shift + Enter 换行</span></div>
<div class="composer-heading"><label for="replyText">回复客户</label><div class="emoji-control"><button id="agent-emoji-toggle" class="text-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="agent-emoji-panel">😊 表情</button><div id="agent-emoji-panel" class="emoji-picker" hidden></div></div><span>Shift + Enter 换行</span></div>
<div class="input-bar">
<textarea id="replyText" rows="2" aria-label="回复客户" placeholder="输入回复内容,按 Enter 发送…"></textarea>
<div class="input-actions"><span>常用回复可在「资料与回复」中选择</span><button id="reply-send" type="button" onclick="sendReply()">发送</button></div>
@@ -76,6 +81,7 @@
<aside class="side" id="service-tools" aria-label="客户信息与回复" aria-hidden="true" inert>
<header class="side-heading"><h2>服务资料</h2><button id="tools-close" type="button" class="icon-button" aria-label="关闭服务资料">×</button></header>
<section class="card"><h3>客户信息</h3><div class="info-row" id="cInfo">选择会话后查看客户信息</div><p class="info-hint">咨询运费时,可先确认运单号、货物重量、尺寸与目的地。</p></section>
<section class="card"><h3><label for="noteInput">会话备注</label></h3><textarea id="noteInput" class="note-input" rows="3" placeholder="填写本次会话的跟进事项" disabled></textarea><button id="note-save" type="button" class="quick" disabled>保存备注</button></section>
<section class="card"><h3>常用回复</h3>
<p class="card-caption">点击填入草稿,编辑后再发送</p>
<button type="button" class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,有什么可以帮您?</button>
@@ -87,6 +93,9 @@
</aside>
</main>
<button class="tools-backdrop" id="tools-backdrop" type="button" aria-label="关闭服务资料"></button>
<dialog id="searchResult" class="search-dialog" aria-labelledby="search-title"><header><h2 id="search-title">搜索历史记录</h2><button id="search-close" class="icon-button" type="button" aria-label="关闭搜索">×</button></header><form id="history-search-form"><label class="sr-only" for="searchBox">历史消息关键词</label><input id="searchBox" type="search" placeholder="输入关键词搜索聊天记录" required><button type="submit" class="quick">搜索</button></form><div id="searchBody" aria-live="polite">请输入关键词搜索历史消息</div></dialog>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_tools.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>附件暂时无法访问 · 柏悦物流</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<style>
body { min-height: 100vh; min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
main { width: min(420px, 100%); padding: 28px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }
h1 { font-size: 22px; margin-bottom: 14px; }
p { color: var(--secondary); margin: 10px 0; }
.english { font-size: 13px; }
a { display: inline-block; margin-top: 12px; }
</style>
</head>
<body><main>
<h1>附件暂时无法访问</h1>
<p>文件可能已过期、被删除或当前无法访问。请返回会话重新发送,或联系人工客服。</p>
<p lang="en" class="english">This attachment is unavailable. Please return to your conversation to upload it again or contact support.</p>
<a href="{{ url_for('admin.admin_dash') if staff else url_for('pages.index') }}">{{ '返回客服工作台' if staff else '返回聊天' }}</a>
</main></body>
</html>
+29 -9
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="theme-color" content="#315fd5">
<title>柏悦智能物流客服系统</title>
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
@@ -21,8 +21,8 @@
</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 class="sidebar-note"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceScope">中欧与英国专线咨询</span></div>
</div>
</aside>
<button class="sidebar-backdrop" type="button" aria-label="关闭历史侧栏"></button>
@@ -31,6 +31,7 @@
<button class="toggle-divider" type="button" aria-label="切换历史侧栏" aria-expanded="true"></button>
<div class="header-copy"><div class="header-kicker">BAIYUE · CUSTOMER SUPPORT</div><span id="app-title">柏悦物流智能助手</span></div>
<div class="title-actions">
<button type="button" id="notifications-toggle" class="theme-toggle" title="开启浏览器通知" aria-label="开启浏览器通知" aria-pressed="false">🔔</button>
<span class="service-label"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceTag">智能客服</span></span>
<button type="button" id="lang-toggle" class="theme-toggle lang-toggle" title="中英文切换(Ctrl+L)">中</button>
<button type="button" id="font-toggle" class="theme-toggle" title="字体大小">16px</button>
@@ -43,30 +44,49 @@
<div class="ai-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div>
<div class="welcome-eyebrow">中欧 · 英国物流服务</div>
<h3>物流问题,随时为您解答</h3>
<p>查询物流轨迹、了解专线报价,或联系人工客服。<br>从这里开始您的物流咨询。</p>
</div>
</section>
</div>
<footer class="composer">
<div class="hot-bar">
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span>
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
<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="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>
</div>
</div><button id="voice-toggle" class="voice-toggle" type="button" aria-label="语音输入" aria-pressed="false" title="语音输入">🎙</button></div>
<div class="input-area">
<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" 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>
<p id="composer-status" class="composer-status" role="status" hidden></p>
<div class="composer-footer"><span class="disclaimer" data-i18n="disclaimer">AI 回答仅供参考,具体以人工客服回复为准</span><span class="keyboard-hint" data-i18n="keyboardHint">Enter 发送 · Shift + Enter 换行</span></div>
</footer>
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
<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>
</body>
</html>
+89 -3
View File
@@ -4,6 +4,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../../static/js/admin.js'), 'utf8');
const sharedSource = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8');
const toolsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tools.js'), 'utf8');
const flush = () => new Promise(setImmediate);
function response(data, status = 200){
@@ -30,35 +32,55 @@ async function workspace(compact = false){
setAttribute(name, value){ this.attributes[name] = value; }
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
appendChild(element){ this.children.push(element); }
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
addEventListener(name, callback){ this.listeners[name] = callback; }
querySelectorAll(){ return []; }
querySelector(selector){
if(selector === '.meta') return this.meta || (this.meta = new Element());
return null;
}
setSelectionRange(start, end){ this.selectionStart = start; this.selectionEnd = end; }
showModal(){ this.open = true; }
close(){ this.open = false; }
focus(){ document.activeElement = this; }
click(){ if(this.onclick) return this.onclick(); }
}
const quickButtons = [new Element(), new Element()];
document = {
body: new Element(), activeElement: null,
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); },
createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
addEventListener(name, callback){ documentListeners[name] = callback; },
querySelector(selector){
const match = selector.match(/data-message-id="(\d+)"/);
if(!match) return null;
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
},
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
const calls = [];
const hooks = {};
const context = vm.createContext({
document, console,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}},
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
setInterval(){}, setTimeout(){}, clearTimeout(){},
fetch: async (url, options) => {
const body = options && options.body ? JSON.parse(options.body) : undefined;
calls.push({url, body});
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
if(url === '/api/human/close' && hooks.close) return hooks.close(body);
if(url === '/api/human/transfer' && hooks.transfer) return hooks.transfer(body);
if(url === '/api/human/note' && hooks.note) return hooks.note(body);
if(url === '/api/human/queue' && hooks.queue) return hooks.queue();
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url.includes('/api/search_chat') && hooks.search) return hooks.search(url);
if(url.includes('/api/human/poll')) return response({status:'active', messages:[]});
if(url === '/api/admin/stats') return response({total_conv:0, total_msg:0, today_conv:0, good:0, bad:0});
return response(options ? {ok:true} : []);
},
});
vm.runInContext(sharedSource, context, {filename:'support.js'});
vm.runInContext(toolsSource, context, {filename:'admin_tools.js'});
vm.runInContext(source, context, {filename:'admin.js'});
await flush();
await context.accept(7);
@@ -211,3 +233,67 @@ test('mobile drafts are measured after the chat view becomes visible', async ()
assert.equal(input.style.height, '128px');
assert.equal(input.value, '需要恢复的多行草稿');
});
test('transfer returns a conversation to the queue without auto-accepting it again', async () => {
const app = await workspace();
app.hooks.queue = () => response([{id:7, ctime:'now'}]);
await app.context.transferSession();
await flush();
assert.equal(app.context.curSid, 0);
assert.equal(app.context.sessions['7'], undefined);
assert.equal(app.calls.filter(call => call.url === '/api/human/accept').length, 1);
});
test('failed transfers retain the conversation and draft', async () => {
const app = await workspace();
app.hooks.transfer = () => response({ok:false}, 503);
app.element('replyText').value = '等待确认的草稿';
await app.context.transferSession();
assert.equal(app.context.curSid, '7');
assert.equal(app.element('replyText').value, '等待确认的草稿');
assert.match(app.element('reply-status').textContent, /转接未成功/);
});
test('a late note response cannot overwrite a different session note', async () => {
const app = await workspace();
app.context.sessions['7'].noteLoaded = false;
let resolve;
app.hooks.poll = url => url.includes('sid=7&') ? new Promise(done => { resolve = done; }) : response({status:'active', note:'客户 8 的备注', messages:[]});
const first = app.context.loadSessionNote('7');
app.context.sessions['8'] = {lastId:0, msgs:[], unread:0};
app.context.switchSession('8');
await flush();
resolve(response({status:'active', note:'客户 7 的备注', messages:[]}));
await first;
assert.equal(app.element('noteInput').value, '客户 8 的备注');
});
test('failed note saves keep the edited note available', async () => {
const app = await workspace();
app.hooks.note = () => response({ok:false}, 503);
app.element('noteInput').value = '下个班次继续跟进';
await app.context.saveNote();
assert.equal(app.element('noteInput').value, '下个班次继续跟进');
assert.match(app.element('reply-status').textContent, /备注保存失败/);
});
test('recalling an older reply leaves the newest reply intact', async () => {
const app = await workspace();
app.context.sessions['7'].msgs = [
{id:1, side:'agent', content:'较早回复'}, {id:2, side:'agent', content:'最新回复'},
];
app.context.sessions['7'].msgs.forEach(app.context.appendMsg);
await app.context.recallMsg(1);
assert.equal(app.context.sessions['7'].msgs[0].content, '该消息已撤回');
assert.equal(app.context.sessions['7'].msgs[1].content, '最新回复');
assert.equal(app.element('chatBox').children[0].textContent, '该消息已撤回');
});
test('historical search renders customer text without interpreting HTML', async () => {
const app = await workspace();
app.hooks.search = () => response([{title:'<img onerror=bad()>', content:'<script>bad()</script>', role:'user', ctime:'now'}]);
app.element('searchBox').value = '物流';
await app.context.doSearch();
assert.match(app.element('searchBody').innerHTML, /&lt;script&gt;/);
assert.doesNotMatch(app.element('searchBody').innerHTML, /<script>/);
});
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const vm=require('node:vm');
const path=require('node:path');
const context=vm.createContext({});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/drafts.js'),'utf8'),context);
function storage(){
const data=new Map();
return {data,get length(){return data.size;},key:index=>Array.from(data.keys())[index] ?? null,
getItem:key=>data.get(key) ?? null,setItem:(key,value)=>data.set(key,value),removeItem:key=>data.delete(key)};
}
function drafts(scope='1'.repeat(64),local=storage(),tab=storage()){
return context.ChatDrafts.create({scope,storage:()=>local,tabStorage:()=>tab});
}
test('conversation drafts and active tab restore independently of another visitor or tab',()=>{
const local=storage(),tab=storage(),a=drafts(undefined,local,tab);
a.write(9,'A');a.write(10,'B');a.write(null,'NEW');a.active(9);
const restored=drafts(undefined,local,tab);
assert.equal(restored.read(9),'A');assert.equal(restored.read(10),'B');assert.equal(restored.read(null),'NEW');
assert.equal(restored.active(),'9');
const otherTab=drafts(undefined,local);otherTab.write(10,'B updated');
assert.equal(otherTab.active(),'new');assert.equal(restored.read(10),'B updated');assert.equal(restored.read(9),'A');
otherTab.remove(10);assert.equal(restored.read(10),'');
assert.equal(drafts('2'.repeat(64),local,tab).read(9),'');
});
test('malformed records and invalid identifiers cannot become restored drafts',()=>{
const local=storage(),a=drafts(undefined,local),key='baiyue-draft-v1:'+'1'.repeat(64)+':9';
for(const value of ['null','{"version":2,"text":"old"}','{"version":1,"text":45}']){
local.setItem(key,value);assert.equal(a.read(9),'');
}
assert.equal(a.write('../9','invalid'),false);assert.equal(a.read('../9'),'');
});
test('removing and clearing a visitor never clears another visitor or unrelated preferences',()=>{
const local=storage(),a=drafts(undefined,local),b=drafts('2'.repeat(64),local);
local.setItem('theme','dark');a.write(9,'A');a.write(10,'B');b.write(9,'PRIVATE');
a.remove(9);assert.equal(a.read(9),'');assert.equal(a.read(10),'B');
a.clear();assert.equal(a.read(10),'');assert.equal(b.read(9),'PRIVATE');assert.equal(local.getItem('theme'),'dark');
});
test('blocked storage and quota errors retain the latest in-memory draft without a shared bucket',()=>{
const broken={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}};
const a=drafts(undefined,broken,broken);
assert.equal(a.write(9,'latest'),false);assert.equal(a.read(9),'latest');a.active(9);assert.equal(a.active(),'9');
const local=storage(),b=drafts(undefined,local);b.write(9,'old');
local.setItem=()=>{throw Error('quota');};assert.equal(b.write(9,'new'),false);assert.equal(b.read(9),'new');
const anonymous=drafts(null,local);anonymous.write(9,'memory only');assert.equal(anonymous.read(9),'memory only');
assert.equal(drafts(null,local).read(9),'');
});
+79
View File
@@ -0,0 +1,79 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const vm=require('node:vm');
const {webcrypto}=require('node:crypto');
const flush=()=>new Promise(setImmediate);
function sessionPage(options={}){
const storage=options.storage || new Map(),calls=[],timers=new Map();let timerId=0;
const response=(data,status=200)=>({ok:status>=200&&status<300,status,json:async()=>data});
const context=vm.createContext({console,AbortController,Uint8Array,
window:{crypto:webcrypto},
localStorage:{getItem:key=>{if(options.storageBlocked) throw Error();return storage.get(key);},
setItem:(key,value)=>{if(options.storageBlocked) throw Error();storage.set(key,value);}},
setTimeout:(callback,delay)=>{const id=++timerId;timers.set(id,{callback,delay});return id;},clearTimeout:id=>timers.delete(id),
fetch:async(url,init)=>{calls.push({url,init});
if(options.fetch) return options.fetch(url,init,response);
return url==='/api/visitor/session' ? response({ok:true,key:JSON.parse(init.body).key || 'f'.repeat(64)}) : response([]);
},
});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/visitor.js'),'utf8'),context);
return {fetch:context.VisitorSession.fetch,calls,storage,
expire:async()=>{for(const timer of [...timers.values()])timer.callback();await flush();}};
}
test('a local random key is restored across reloads and differs in another browser',async()=>{
const a=sessionPage();await a.fetch('/api/get_history');
const key=JSON.parse(a.calls[0].init.body).key;
assert.match(key,/^[0-9a-f]{64}$/);
const restored=sessionPage({storage:a.storage});await restored.fetch('/api/get_history');
assert.equal(JSON.parse(restored.calls[0].init.body).key,key);
const b=sessionPage();await b.fetch('/api/get_history');
assert.notEqual(JSON.parse(b.calls[0].init.body).key,key);
assert.equal(a.calls[1].init.credentials,'same-origin');
assert.equal(a.calls.some(call=>call.url.includes(key)),false);
});
test('concurrent requests wait for one bootstrap before sending customer data',async()=>{
let release;
const app=sessionPage({fetch:(url,init,response)=>url==='/api/visitor/session'
? new Promise(resolve=>{release=()=>resolve(response({ok:true,key:JSON.parse(init.body).key}));}) : response([])});
const a=app.fetch('/api/get_history'),b=app.fetch('/api/new_chat',{method:'POST'});
assert.equal(app.calls.length,1);
release();await Promise.all([a,b]);
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/session').length,1);
assert.equal(app.calls.length,3);
});
test('blocked local storage uses a server cookie without falling back to global history',async()=>{
const app=sessionPage({storageBlocked:true});await app.fetch('/api/get_history');
assert.equal(JSON.parse(app.calls[0].init.body).key,null);
assert.equal(app.calls[1].init.credentials,'same-origin');
const failed=sessionPage({fetch:(url,init,response)=>response({ok:false},503)});
await assert.rejects(failed.fetch('/api/get_history'));
assert.equal(failed.calls.length,1);
});
test('expired sessions rebootstrap once and retry the original request once',async()=>{
let attempts=0;
const app=sessionPage({fetch:(url,init,response)=>url==='/api/visitor/session'
? response({ok:true,key:JSON.parse(init.body).key}) : response([],++attempts===1 ? 401 : 200)});
const result=await app.fetch('/api/get_history');
assert.equal(result.status,200);
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/session').length,2);
assert.equal(attempts,2);
});
test('a cancelled request waiting for bootstrap does not upload its body',async()=>{
let release;
const app=sessionPage({fetch:(url,init,response)=>new Promise(resolve=>{
release=()=>resolve(response({ok:true,key:JSON.parse(init.body).key}));
})});
const controller=new AbortController();
const pending=app.fetch('/api/upload',{method:'POST',body:'private file',signal:controller.signal});
controller.abort();release();
await assert.rejects(pending,{name:'AbortError'});
assert.equal(app.calls.length,1);
});
+171
View File
@@ -0,0 +1,171 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const flush = () => new Promise(setImmediate);
function voicePage(options={}){
const elements={}, timers=new Map(), docEvents={}, pageEvents={}, calls=[], messages=[], statuses=[];
let now=1000, timerId=0, contextId='conversation-1', recorder, permissionCalls=0, trackStops=0;
class Element {
constructor(){ this.listeners={}; this.attributes={}; this.classes=new Set(); this.value='';
this.classList={toggle:(name,on) => on ? this.classes.add(name) : this.classes.delete(name),remove:name=>this.classes.delete(name)}; }
setAttribute(name,value){this.attributes[name]=value;}
addEventListener(name,callback){this.listeners[name]=callback;}
setPointerCapture(){}
focus(options){this.focusOptions=options;}
blur(){this.blurred=true;}
}
for(const key of ['toggle','hold','panel','label','clock','inputArea','input']) elements[key]=new Element();
const tracks=[{stop(){trackStops++;}}], stream={getTracks:()=>tracks};
class Recorder {
constructor(source, settings){ recorder=this; this.state='inactive'; this.mimeType=settings.mimeType || 'audio/mp4'; }
static isTypeSupported(type){return options.mp4 ? type==='audio/mp4' : type.startsWith('audio/webm');}
start(){this.state='recording';}
stop(){this.state='inactive'; this.ondataavailable({data:new Blob(['audio fixture'],{type:this.mimeType})}); this.onstop();}
}
class ClockDate extends Date {static now(){return now;}}
const response = data => ({ok:true,json:async()=>data});
const sandbox=vm.createContext({console,Blob,AbortController,Date:ClockDate,
FormData:class{constructor(){this.values={};}append(key,value){this.values[key]=value;}},
navigator:{mediaDevices:{getUserMedia:async()=>{permissionCalls++; if(options.permission) return options.permission(); return stream;}}},
document:{hidden:false,addEventListener:(name,callback)=>{docEvents[name]=callback;}},
window:{isSecureContext:options.secure!==false,MediaRecorder:options.unsupported ? undefined : Recorder,
PointerEvent:options.touch ? undefined : function(){},addEventListener:(name,callback)=>{pageEvents[name]=callback;}},
setTimeout:(callback,delay)=>{const id=++timerId;timers.set(id,{callback,delay});return id;},
clearTimeout:id=>timers.delete(id),setInterval:()=>++timerId,clearInterval(){},
fetch:async(url,init)=>{calls.push({url,init});
if(url==='/api/speech/config') return response({enabled:options.enabled!==false,max_bytes:5242880,max_seconds:60});
if(options.transcribe) return options.transcribe(init);
return response({ok:true,text:'查询订单 FL20261030001'});
},
});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/voice.js'),'utf8'),sandbox);
const voice=sandbox.ChatVoice.create({...elements,text:key=>key,status:(...args)=>statuses.push(args),
language:()=> 'zh',context:()=>contextId,canStart:()=>options.reason || '',onText:async text=>messages.push(text)});
const event=(extra={})=>({button:0,pointerId:7,clientY:300,preventDefault(){},...extra});
return {voice,elements,calls,messages,statuses,stream,
get permissionCalls(){return permissionCalls;},get trackStops(){return trackStops;},get recorder(){return recorder;},
context:value=>{contextId=value;},
open:()=>elements.toggle.onclick(),
down:async()=>{elements.hold.listeners.pointerdown(event());await flush();},
up:async(duration=1000)=>{now+=duration;elements.hold.listeners.pointerup(event());await flush();},
move:y=>elements.hold.listeners.pointermove(event({clientY:y})),
event,docEvents,pageEvents,sandbox,
expire:async delay=>{for(const [id,timer] of [...timers]) if(timer.delay===delay){timers.delete(id);now+=delay;timer.callback();}await flush();},
};
}
test('hold and release transcribes audio and sends text once',async()=>{
const app=voicePage();app.open();await app.down();
assert.equal(app.recorder.state,'recording');
assert.equal(app.elements.panel.hidden,false);
await app.up();
assert.deepEqual(app.messages,['查询订单 FL20261030001']);
assert.equal(app.calls.filter(call=>call.url==='/api/speech/transcribe').length,1);
assert.ok(app.trackStops>0);
assert.equal(app.elements.panel.hidden,true);
await app.up();
assert.equal(app.messages.length,1);
});
test('switching between voice and keyboard avoids scrolling the page on focus',()=>{
const app=voicePage();
app.open();
assert.ok(app.elements.input.blurred);
app.open();
assert.equal(app.elements.input.focusOptions.preventScroll,true);
assert.equal(app.elements.toggle.attributes['aria-pressed'],'false');
});
test('sliding up cancels without uploading and supports another recording',async()=>{
const app=voicePage();app.open();await app.down();app.move(200);await app.up();
assert.equal(app.messages.length,0);
assert.equal(app.calls.filter(call=>call.url==='/api/speech/transcribe').length,0);
assert.ok(app.trackStops>0);
await app.down();await app.up();
assert.equal(app.messages.length,1);
});
test('short presses and unavailable service do not upload recordings',async()=>{
const app=voicePage();app.open();await app.down();await app.up(50);
assert.equal(app.messages.length,0);
assert.equal(app.statuses.at(-1)[0],'voiceTooShort');
const disabled=voicePage({enabled:false});disabled.open();await disabled.down();
assert.equal(disabled.permissionCalls,0);
assert.equal(disabled.statuses.at(-1)[0],'voiceUnavailable');
});
test('late microphone permission after cancellation immediately stops its tracks',async()=>{
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
app.open();await app.down();await app.up();grant(app.stream);await flush();
assert.equal(app.messages.length,0);
assert.ok(app.trackStops>0);
assert.equal(app.voice.busy(),false);
});
test('switching conversation or explicitly cancelling ignores a late transcript',async()=>{
for(const cancelled of [false,true]){
let resolve;const app=voicePage({transcribe:()=>new Promise(done=>{resolve=done;})});
app.open();await app.down();await app.up();
if(cancelled) app.voice.cancel(); else app.context('conversation-2');
resolve({ok:true,json:async()=>({ok:true,text:'must not enter the new conversation'})});await flush();
assert.equal(app.messages.length,0);
assert.equal(app.voice.busy(),false);
}
});
test('permission denial and unsupported or insecure browsers leave typing available',async()=>{
const denied=voicePage({permission:()=>Promise.reject(Object.assign(new Error(),{name:'NotAllowedError'}))});
denied.open();await denied.down();
assert.equal(denied.voice.busy(),false);
assert.equal(denied.statuses.at(-1)[0],'voicePermission');
for(const options of [{unsupported:true},{secure:false}]){
const app=voicePage(options);app.open();
assert.equal(app.elements.inputArea.classes.has('voice-mode'),false);
assert.equal(app.permissionCalls,0);
}
});
test('pending drafts and busy chat do not request microphone access',async()=>{
const app=voicePage({reason:'wait for current chat'});app.open();await app.down();
assert.equal(app.permissionCalls,0);
assert.equal(app.calls.length,0);
assert.equal(app.statuses.at(-1)[0],'wait for current chat');
});
test('maximum recording length stops the microphone and sends once',async()=>{
const app=voicePage({mp4:true});app.open();await app.down();await app.expire(60000);
assert.equal(app.messages.length,1);
assert.ok(app.trackStops>0);
assert.equal(app.calls.find(call=>call.url==='/api/speech/transcribe').init.body.values.audio.type,'audio/mp4');
await app.up();assert.equal(app.messages.length,1);
});
test('touch fallback and browser cancellation do not submit accidentally',async()=>{
const app=voicePage({touch:true});app.open();
app.elements.hold.listeners.touchstart(app.event({touches:[{identifier:1,clientY:300}]}));await flush();
app.elements.hold.listeners.touchcancel();await flush();
assert.equal(app.messages.length,0);
assert.ok(app.trackStops>0);
});
test('a stalled transcription times out and releases the recorder state',async()=>{
const app=voicePage({transcribe:init=>new Promise((resolve,reject)=>{
init.signal.addEventListener('abort',()=>reject(Object.assign(new Error(),{name:'AbortError'})));
})});app.open();await app.down();await app.up();await app.expire(40000);
assert.equal(app.voice.busy(),false);
assert.equal(app.statuses.at(-1)[0],'voiceTimeout');
assert.equal(app.messages.length,0);
});
test('an unanswered microphone permission prompt times out and a late stream is closed',async()=>{
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
app.open();await app.down();await app.expire(15000);
assert.equal(app.voice.busy(),false);
assert.equal(app.statuses.at(-1)[0],'voiceTimeout');
grant(app.stream);await flush();
assert.ok(app.trackStops>0);
assert.equal(app.messages.length,0);
});
+246 -9
View File
@@ -45,11 +45,26 @@ class ApiTests(unittest.TestCase):
self.root = Path(self.directory.name)
self.app = self.application(self.root)
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key':'1'*64})
self.app.extensions['rate_hits'].clear()
from logistics_chat.order_data import import_records
with self.app.app_context():
connection = db.connect()
import_records(connection, [{'tenant_id':'fixture', 'order_no':'15252125',
'status':'PROCESSING', 'country_code':'US', 'gross_weight_kg':'30',
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'}]}])
connection.commit()
connection.close()
def application(self, root):
return create_app({'TESTING': True, 'DATABASE': root/'history.db',
app = create_app({'TESTING': True, 'DATABASE': root/'history.db',
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups',
'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''})
@app.before_request
def fixture_customer():
from flask import g
g.customer_tenant_id = 'fixture'
return app
def conversation(self):
return self.client.post('/api/new_chat').json['id']
@@ -77,7 +92,7 @@ class ApiTests(unittest.TestCase):
for path in ('/', '/admin', '/admin/dashboard'):
parser = Assets()
parser.feed(self.client.get(path).get_data(as_text=True))
self.assertEqual(len(parser.urls), 3)
self.assertGreaterEqual(len(parser.urls), 3)
for url in parser.urls:
resource = self.client.get(url)
self.assertEqual(resource.status_code, 200, url)
@@ -91,15 +106,17 @@ class ApiTests(unittest.TestCase):
self.assertEqual(events[-1], {'d': 1})
self.assertEqual(events[0]['card']['no'], '15252125')
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertEqual(len(messages), 2)
self.assertTrue(messages[1]['content'].startswith('__CARD__'))
self.assertEqual(len(messages), 3)
self.assertTrue(messages[-1]['content'].startswith('__CARD__'))
self.assertEqual(self.client.get('/api/get_history').json[0]['title'], '15252125')
self.assertEqual(self.client.delete(f'/api/del_history?id={cid}').json, {'ok': True})
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json, [])
def test_empty_image_and_missing_key_streams(self):
self.assertIn('e', frames(self.client.post('/stream_chat', json={'msg': ''}))[0])
self.assertIn('收到您的图片', frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))[0]['t'])
image_reply = frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))
self.assertIn('收到您的图片', image_reply[0]['t'])
self.assertEqual(image_reply[-1], {'d': 1})
self.assertIn('API Key', frames(self.client.get('/stream_chat?msg=hello'))[0]['e'])
def test_attachment_filename_is_not_used_as_a_tracking_number(self):
@@ -110,11 +127,175 @@ class ApiTests(unittest.TestCase):
cid = self.conversation()
events = frames(self.client.post('/stream_chat', json={'msg': '查询15252125 ' + attachment, 'conv_id': cid}))
self.assertEqual(events[0]['card']['no'], '15252125')
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[0]['content'], '查询15252125 ' + attachment)
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[1]['content'], '查询15252125 ' + attachment)
def test_image_attachments_do_not_swallow_order_questions_and_are_saved(self):
cid = self.conversation()
image = '[图片]/uploads/20261029100145_6787.png'
events = frames(self.client.post('/stream_chat', json={'msg':'查询15252125 '+image, 'conv_id':cid}))
self.assertEqual(events[0]['card']['no'], '15252125')
events = frames(self.client.post('/stream_chat', json={'msg':image, 'conv_id':cid}))
self.assertIn('收到您的图片', events[0]['t'])
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertEqual(messages[-2]['content'], image)
self.assertIn('收到您的图片', messages[-1]['content'])
def test_bad_chat_payloads_produce_readable_errors_without_calling_model(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
invalid = ([1], 'hello', {'msg':7}, {'msg':['test']}, {'msg':'物流咨询', 'conv_id':{}},
{'msg':'物流咨询', 'conv_id':-1}, {'msg':'物流咨询', 'conv_id':1.5}, {'msg':'物流咨询','conv_id':False},
{'msg':'物流咨询','conv_id':999999999999999999999999},
{'msg':'物流咨询','conv_id':'²'}, {'msg':'物流咨询','conv_id':'9'*5000})
with patch('logistics_chat.ai_client.post_completion') as model:
for index, payload in enumerate(invalid):
with self.subTest(case=index):
events = frames(self.client.post('/stream_chat', json=payload))
self.assertIn('e', events[0])
model.assert_not_called()
def test_deleted_conversations_close_human_sessions_and_reject_new_chat_messages(self):
cid = self.conversation()
with patch('logistics_chat.human_service.datetime') as clock:
from datetime import datetime
clock.now.return_value = datetime(2026,10,29,12)
sid = self.client.post('/api/human/request',json={'conv_id':cid}).json['session_id']
self.client.post('/api/human/send',json={'sid':sid,'content':'原会话内容'})
self.client.delete(f'/api/del_history?id={cid}')
self.assertEqual(self.client.get('/api/human/queue').json, [])
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
with patch('logistics_chat.ai_client.post_completion') as model:
events = frames(self.client.post('/stream_chat',json={'msg':'15252125','conv_id':cid}))
self.assertIn('已删除',events[0]['e'])
model.assert_not_called()
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026,10,29,12)
self.assertEqual(self.client.post('/api/human/request',json={'conv_id':cid}).status_code,404)
def test_uk_quotes_and_followups_keep_the_uk_rates(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
cid = self.conversation()
for message, expected in (('30kg英国空运报价','英国空运专线'), ('那海运呢?','英国海运'),
('那空运呢?','英国空运专线'), ('那50公斤呢?','英国空运专线')):
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
prompt = model.call_args.args[0]['messages'][0]['content']
self.assertIn(expected,prompt)
self.assertNotIn('欧洲空运专线',prompt)
self.assertNotIn('欧洲海运快线',prompt)
def test_us_quotes_never_reuse_european_price_tables(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = frames(self.client.post('/stream_chat',json={'msg':'30KG发美国FBA仓,海运报价','conv_id':self.conversation()}))
self.assertIn('目的地',events[0]['t'])
self.assertIn('人工',events[0]['t'])
model.assert_not_called()
def test_mixed_country_codes_select_the_correct_rates(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
for message, channel, amount in (('30kg发UK海运报价','英国海运','384'),
('30kg发uk海运报价','英国海运','384'),
('30kg发GB海运报价','英国海运','384'),
('30kg发DE仓海运报价','欧洲海运','369')):
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion', return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
events=frames(self.client.post('/stream_chat',json={'msg':message}))
self.assertEqual(events[-1],{'d':1})
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn(channel,prompt)
self.assertIn(amount,prompt)
for message in ('30kg发US仓海运报价','30kg发us仓海运报价','30kg发USFBA仓海运报价',
'30kg发阿根廷海运报价','30kg海运报价'):
with self.subTest(message=message), patch('logistics_chat.ai_client.post_completion') as model:
events=frames(self.client.post('/stream_chat',json={'msg':message}))
self.assertIn('目的',events[0]['t'])
model.assert_not_called()
def test_short_channel_followups_and_retries_keep_weight_and_region(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
cid=self.conversation()
sequence=(('30kg英国海运报价','英国海运','384'), ('海运呢','英国海运','384'),
('空运呢','英国空运','1,440'), ('50公斤呢','英国空运','2,400'))
for message, channel, amount in sequence:
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn(channel,prompt)
self.assertIn(amount,prompt)
with patch('logistics_chat.ai_client.post_completion',side_effect=requests.exceptions.ConnectionError):
self.assertIn('e',frames(self.client.post('/stream_chat',json={'msg':'海运呢','conv_id':cid}))[0])
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model:
frames(self.client.post('/stream_chat',json={'msg':'海运呢','conv_id':cid}))
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn('英国海运',prompt)
self.assertIn('640',prompt)
def test_complete_new_questions_do_not_inherit_an_old_quote(self):
from logistics_chat.chat_service import shipping_intent_with_context
cid=self.conversation()
with self.app.app_context():
db.save_message(cid,'user','30kg英国海运报价')
db.save_message(cid,'assistant','报价结果')
for message in ('新的一票50kg德国空运报价','空运理赔应该怎么办?','海运时效一般多久?'):
with self.subTest(message=message):
intent=shipping_intent_with_context(message,cid)
self.assertNotEqual(intent['region'],'uk')
db.save_message(cid,'user','空运理赔应该怎么办?')
self.assertIsNone(shipping_intent_with_context('海运呢',cid)['weight'])
def test_dimension_quotes_and_changes_do_not_become_order_identifiers(self):
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
cid=self.conversation()
for message, amount in (('30kg英国海运,100x100x100cm','1,817'), ('50x40x30cm呢','384'),
('1.2x0.8x0.6m呢','1,229'),
('那另外一票50kg德国海运','615')):
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
events=frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
self.assertEqual(events[-1],{'d':1})
self.assertTrue(model.called)
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn(amount,prompt)
if '另外一票' in message:
self.assertNotIn('体积重',prompt)
def test_destination_only_replies_continue_the_existing_quote(self):
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
cid=self.conversation()
self.assertIn('目的',frames(self.client.post('/stream_chat',json={'msg':'30kg海运报价','conv_id':cid}))[0]['t'])
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
frames(self.client.post('/stream_chat',json={'msg':'英国','conv_id':cid}))
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn('英国海运',prompt)
self.assertIn('384',prompt)
with patch('logistics_chat.ai_client.post_completion') as model:
events=frames(self.client.post('/stream_chat',json={'msg':'US仓','conv_id':cid}))
self.assertIn('尚未包含',events[0]['t'])
model.assert_not_called()
def test_uk_volume_quotes_do_not_reuse_continental_warehouse_prices(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = frames(self.client.post('/stream_chat',json={'msg':'2方发英国海运多少钱'}))
self.assertIn('未提供',events[0]['t'])
self.assertIn('按方费率',events[0]['t'])
model.assert_not_called()
def test_closed_human_sessions_cannot_be_reopened_by_a_late_accept(self):
with patch('logistics_chat.human_service.datetime') as clock:
from datetime import datetime
clock.now.return_value = datetime(2026,10,29,12)
sid = self.client.post('/api/human/request',json={'conv_id':self.conversation()}).json['session_id']
self.client.post('/api/human/close',json={'sid':sid})
self.assertEqual(self.client.post('/api/human/accept',json={'sid':sid}).status_code,409)
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'],'closed')
def test_human_lifecycle_typing_and_read_receipts(self):
self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400)
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
with patch('logistics_chat.human_service.datetime') as clock:
from datetime import datetime
clock.now.return_value = datetime(2026, 10, 29, 12)
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
self.client.post('/api/human/accept', json={'sid': sid})
self.client.post('/api/human/send', json={'sid': sid, 'side': 'customer', 'content': 'hello'})
@@ -190,13 +371,49 @@ class ApiTests(unittest.TestCase):
db.save_message(cid, 'user', 'hello, "quoted"\nworld')
self.client.post('/api/feedback', json={'vote': 1, 'question': 'hello', 'answer': 'world'})
self.assertEqual(self.client.get('/api/admin/stats').json['good'], 1)
response = self.client.get('/api/export_chat')
response = self.client.get('/api/export_chat?format=csv')
self.assertEqual(response.mimetype, 'text/csv')
self.assertIn('chat_export.csv', response.headers['Content-Disposition'])
self.assertIn('"hello, ""quoted""\nworld"', response.get_data(as_text=True))
self.client.delete('/api/clear_all')
self.assertEqual(self.client.get('/api/get_history').json, [])
def test_browser_votes_and_legacy_rows_are_both_counted_in_admin_stats(self):
for vote in ('up', 'down', 1, -1):
self.assertEqual(self.client.post('/api/feedback', json={'vote':vote,'question':'q','answer':'a'}).status_code,200)
with self.app.app_context():
connection=db.connect()
connection.executemany('INSERT INTO feedback(vote) VALUES(?)', [('up',),('down',)])
connection.commit(); connection.close()
stats=self.client.get('/api/admin/stats').json
self.assertEqual((stats['good'],stats['bad']),(3,3))
def test_invalid_customer_feedback_and_tickets_return_json_errors_without_writes(self):
for data in (None, [], [1], {}, {'vote':True}, {'vote':0}, {'vote':'unknown'},
{'vote':'up','answer':{}}, {'vote':'down','question':[]}):
with self.subTest(feedback=data):
response=self.client.post('/api/feedback',json=data)
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
for data in (None, [], [1], {}, {'content':' '}, {'content':{}}, {'content':'x'*2001},
{'content':'help','phone':[]}, {'content':'help','phone':'1'*33}):
with self.subTest(ticket=data):
response=self.client.post('/api/ticket',json=data)
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
for path in ('/api/feedback','/api/ticket'):
response=self.client.post(path,data='{bad',content_type='application/json')
self.assertEqual(response.status_code,400)
self.assertFalse(response.json['ok'])
with self.app.app_context():
connection=db.connect()
for table in ('feedback','tickets'):
self.assertEqual(connection.execute('SELECT COUNT(*) FROM '+table).fetchone()[0],0)
connection.close()
self.assertEqual(self.client.post('/api/ticket',json={'content':' 请跟进报价 ','phone':' +86 123 '}).status_code,200)
ticket=self.client.get('/api/tickets').json[0]
self.assertEqual((ticket['content'],ticket['phone']),('请跟进报价','+86 123'))
def test_business_routes_with_initialized_tables_and_legacy_auth(self):
with self.app.app_context():
connection = db.connect()
@@ -218,13 +435,33 @@ class ApiTests(unittest.TestCase):
streamed = ModelResponse('这是用于覆盖长回答压缩的说明。'*100)
responses = [streamed, ModelResponse('基础运费2050元,时效38-45天。'), ModelResponse('["费用呢","时效呢","能带电吗"]')]
with patch('logistics_chat.ai_client.requests.post', side_effect=responses) as post:
events = frames(self.client.post('/stream_chat', json={'msg': '200kg 海运', 'conv_id': self.conversation()}))
events = frames(self.client.post('/stream_chat', json={'msg': '200kg 德国海运', 'conv_id': self.conversation()}))
self.assertTrue(any('t' in event for event in events))
self.assertTrue(any('replace' in event for event in events))
self.assertTrue(any('suggest' in event for event in events))
self.assertEqual(events[-1], {'d': 1})
self.assertEqual([call.kwargs['timeout'] for call in post.call_args_list], [(5, 90), (5, 30), (5, 15)])
self.assertTrue(streamed.closed)
self.assertTrue(all(response.closed for response in responses))
def test_incomplete_model_streams_are_reported_and_saved_as_partial(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
for reason in (None, 'length'):
with self.subTest(reason=reason):
cid = self.conversation()
response = ModelResponse('这是已经生成的部分内容,需要保留以便用户了解中断前的信息。')
def lines():
yield ('data: '+json.dumps({'choices':[{'delta':{'content':response.content}, 'finish_reason':reason}]})).encode()
if reason:
yield b'data: [DONE]'
response.iter_lines = lines
with patch('logistics_chat.ai_client.post_completion', return_value=response):
events = frames(self.client.post('/stream_chat', json={'msg':'物流咨询', 'conv_id':cid}))
self.assertTrue(any('e' in event for event in events))
self.assertFalse(any(event.get('d') for event in events))
self.assertTrue(response.closed)
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertIn('回答中断', messages[-1]['content'])
def test_stream_network_and_model_errors(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
+7
View File
@@ -91,6 +91,13 @@ class ConfigurationTests(unittest.TestCase):
self.assertIs(response, sentinel.response)
self.assertEqual(post.call_args.kwargs['headers']['Authorization'], 'Bearer file-key')
def test_order_scope_is_fixed_by_server_file_or_environment(self):
self.write_env('ORDER_DATA_TENANT_ID=tenant-file\n')
os.environ.pop('ORDER_DATA_TENANT_ID', None)
self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-file')
os.environ['ORDER_DATA_TENANT_ID'] = 'tenant-environment'
self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-environment')
if __name__ == '__main__':
unittest.main()
+204
View File
@@ -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'])
+1
View File
@@ -29,6 +29,7 @@ class DeploymentTests(unittest.TestCase):
self.addCleanup(close_handlers)
client = app.test_client()
client.post('/api/visitor/session', json={'key':'1'*64})
for path in ('/', '/admin/dashboard', '/api/tickets', '/api/my_orders?phone=123'):
self.assertEqual(client.get(path).status_code, 200, path)
self.assertEqual(client.post('/admin/login', json={'username': 'demo', 'password': 'test-password'}).status_code, 401)
+167
View File
@@ -0,0 +1,167 @@
"""Remote dev behavior on the modular backend and the light workspace."""
from datetime import datetime, timezone
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from openpyxl import load_workbook
from logistics_chat import create_app, db
class DevIntegrationTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.name)
self.app = create_app({'TESTING': True, 'DATABASE': root/'test.db',
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs',
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key': '1' * 64})
def session(self):
cid = self.client.post('/api/new_chat').json['id']
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 10, 31, 8)
result = self.client.post('/api/human/request', json={'conv_id': cid}).json
return result['session_id']
def test_new_conversation_persists_one_welcome_before_user_message(self):
data = self.client.post('/api/new_chat').json
self.assertIn('欢迎', data['welcome'])
self.client.post('/stream_chat', json={'msg': '15252125', 'conv_id': data['id']}, buffered=True)
messages = self.client.get('/api/get_messages?conv_id='+str(data['id'])).json
self.assertEqual([message['role'] for message in messages], ['assistant', 'user', 'assistant'])
self.assertEqual(messages[0]['content'], data['welcome'])
def test_daily_service_hours_and_offline_ticket_on_fresh_database(self):
cid = self.client.post('/api/new_chat').json['id']
for hour, open_now in ((7, False), (8, True), (21, True), (22, False)):
with self.subTest(hour=hour), patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 10, 31, hour)
result = self.client.post('/api/human/request', json={'conv_id': cid}).json
self.assertEqual(result['ok'], open_now)
if not open_now:
self.assertTrue(result['closed'])
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
def test_service_hours_use_china_time_even_when_the_host_clock_is_utc(self):
cid = self.client.post('/api/new_chat').json['id']
for utc_hour, expected in ((0, True),(13, True),(14, False),(23, False)):
instant = datetime(2026,10,31,utc_hour,tzinfo=timezone.utc)
class HostUtcClock(datetime):
@classmethod
def now(cls, tz=None):
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
with self.subTest(utc_hour=utc_hour), patch('logistics_chat.human_service.datetime', HostUtcClock):
result = self.client.post('/api/visitor/human/request',json={'conv_id':cid}).json
self.assertEqual(result['ok'],expected)
if not expected: self.assertTrue(result['closed'])
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
sid = self.session()
self.client.post('/api/human/accept', json={'sid': sid})
self.client.post('/api/human/note', json={'sid': sid, 'note': '请跟进派送'})
self.client.post('/api/human/send', json={'sid': sid, 'side': 'agent', 'content': '正在处理'})
self.client.post('/api/human/typing', json={'sid': sid})
self.assertEqual(self.client.post('/api/human/transfer', json={'sid': sid}).json, {'ok': True})
self.assertEqual(self.client.get('/api/human/active').json, [])
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
state = self.client.get(f'/api/human/poll?sid={sid}').json
self.assertEqual(state['note'], '请跟进派送')
self.assertFalse(state['typing'])
self.assertEqual(state['messages'][0]['content'], '正在处理')
self.assertEqual(state['messages'][-1]['side'], 'system')
self.assertEqual(self.client.post('/api/human/transfer', json={'sid': sid}).status_code, 409)
self.client.post('/api/human/accept', json={'sid': sid})
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'active')
def test_human_send_rejects_closed_missing_or_mismatched_conversations(self):
sid = self.session()
payload = {'sid': sid, 'side': 'customer', 'content': '不能串到其他会话'}
other_cid = self.client.post('/api/new_chat').json['id']
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'conv_id': other_cid}).status_code, 409)
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'sid': 99999}).status_code, 404)
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'side': 'system'}).status_code, 400)
self.client.post('/api/human/close', json={'sid': sid})
self.assertEqual(self.client.post('/api/human/send', json=payload).status_code, 409)
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
def test_recall_targets_older_agent_message_and_is_idempotent(self):
sid = self.session()
for side, text in (('agent', '早先的回复'), ('customer', '客户消息'), ('agent', '最新回复')):
self.client.post('/api/human/send', json={'sid': sid, 'side': side, 'content': text})
before = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
mid = before[0]['id']
for _ in range(2):
self.assertEqual(self.client.post('/api/human/recall', json={'mid': mid}).json, {'ok': True})
self.assertEqual(self.client.post('/api/human/recall', json={'mid': before[1]['id']}).status_code, 400)
self.assertEqual(self.client.post('/api/human/recall', json={'mid': 99999}).status_code, 404)
updates = self.client.get(f'/api/human/poll?sid={sid}&after={before[-1]["id"]}').json['messages']
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0]['recalled_id'], mid)
all_messages = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
self.assertEqual(all_messages[0]['content'], '该消息已撤回')
self.assertEqual(all_messages[2]['content'], '最新回复')
def test_ratings_validate_stars_and_feed_admin_stats(self):
sid = self.session()
for stars in (0, 6, True, '5'):
self.assertEqual(self.client.post('/api/rate', json={'sid': sid, 'stars': stars}).status_code, 400)
self.assertEqual(self.client.post('/api/rate', json={'sid': 999, 'stars': 5}).status_code, 404)
for stars in (4, 5):
self.assertEqual(self.client.post('/api/rate', json={'sid': sid, 'stars': stars}).json, {'ok': True})
stats = self.client.get('/api/admin/stats').json
self.assertEqual((stats['rating_avg'], stats['rating_count']), (4.5, 2))
def test_excel_export_search_and_legacy_agent_schema(self):
cid = self.client.post('/api/new_chat').json['id']
with self.app.app_context():
db.save_message(cid, 'user', '=SUM(1,2)\n查询专线')
connection = db.connect()
connection.executescript('DROP TABLE agents; CREATE TABLE agents '
'(id INTEGER PRIMARY KEY, username TEXT, password TEXT, name TEXT);'
"INSERT INTO agents VALUES(7, 'demo', 'fixture-password', '测试客服');")
connection.commit()
connection.close()
db.init_db()
results = self.client.get('/api/search_chat?q=专线').json
self.assertEqual(len(results), 1)
self.assertIn('SUM', results[0]['content'])
self.assertEqual(self.client.get('/api/search_chat').json, {'results': []})
self.assertEqual(self.client.get('/api/agents/list').json, [{'id': 7, 'username': 'demo', 'name': '测试客服'}])
exported = self.client.get('/api/export_chat')
self.assertIn('chat_export.xlsx', exported.headers['Content-Disposition'])
workbook = load_workbook(io.BytesIO(exported.data))
sheet = workbook.active
self.assertEqual(sheet['C3'].value, '=SUM(1,2)\n查询专线')
self.assertEqual(sheet['C3'].data_type, 's')
self.assertEqual(sheet['B3'].value, '客户')
workbook.close()
def test_existing_schema_is_upgraded_without_losing_business_data(self):
with self.app.app_context():
connection = db.connect()
connection.executescript('DROP TABLE human_sessions; DROP TABLE human_messages;'
'CREATE TABLE human_sessions(id INTEGER PRIMARY KEY, conv_id INTEGER, status TEXT, ctime TEXT);'
'CREATE TABLE human_messages(id INTEGER PRIMARY KEY, session_id INTEGER, side TEXT, content TEXT, ctime TEXT);'
"INSERT INTO human_sessions VALUES(1, 1, 'active', 'now');"
"INSERT INTO human_messages VALUES(1, 1, 'agent', '保留的回复', 'now');"
"INSERT INTO agents VALUES('fixture-agent', 'fixture-password', 'Fixture Agent');")
connection.commit()
connection.close()
db.init_db()
db.init_db()
state = self.client.get('/api/human/poll?sid=1').json
self.assertEqual(state['messages'][0]['content'], '保留的回复')
self.assertEqual(state['note'], '')
self.assertEqual(self.client.get('/api/agents/list').json[0]['username'], 'fixture-agent')
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['node', '--test', 'tests/frontend/admin.test.cjs'], cwd=root,
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+69 -6
View File
@@ -35,7 +35,25 @@ elif name == 'nginx' and '-t' in args and scenario == 'proxy-fail':
if sum(item[0] == 'nginx' and '-t' in item[1] for item in calls) == 1:
sys.exit(19)
elif name == 'curl' and '-w' in args:
print('401', end='')
calls = [json.loads(line) for line in log.read_text().splitlines()]
if scenario == 'reload-pending' and sum(item[0] == 'curl' and '-w' in item[1] for item in calls) == 1:
sys.exit(60)
if scenario == 'proxy-unsafe':
print('200', end='')
sys.exit(0)
path = '/' + args[-1].split('://', 1)[-1].partition('/')[2]
if '-c' in args:
pathlib.Path(args[args.index('-c') + 1]).write_text('fixture-visitor-cookie')
if '-b' in args:
assert pathlib.Path(args[args.index('-b') + 1]).read_text() == 'fixture-visitor-cookie'
status = '200' if path == '/' or path == '/api/visitor/session' or (path == '/api/get_history' and '-b' in args) else '401'
if scenario == 'visitor-bootstrap-fail' and path == '/api/visitor/session':
status = '503'
if scenario == 'visitor-history-fail' and path == '/api/get_history' and '-b' in args:
status = '503'
print(status, end='')
elif name == 'sleep':
pass
'''
@@ -47,7 +65,8 @@ class GrayActivationTests(unittest.TestCase):
directory = Path(temporary.name)
root = directory / 'gray-logistics-chat'
proxy = directory / 'proxy'
for path in (root / 'data', root / 'logs', root / 'releases', proxy):
auth = directory / 'baiyue-logistics-chat-gray-proxy'
for path in (root / 'data', root / 'logs', root / 'releases', proxy, auth):
path.mkdir(parents=True, exist_ok=True)
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
(root / 'data/history.db').write_bytes(b'preserve-existing-data')
@@ -61,18 +80,22 @@ class GrayActivationTests(unittest.TestCase):
(root / 'current-release').write_text(str(previous) + '\n')
old_site = '# Managed for baiyue-logistics-chat-gray\n# previous site\n'
(proxy / 'baiyue-logistics-chat-gray.conf').write_text(old_site)
(proxy / '.baiyue-logistics-chat.htpasswd').write_text('previous-auth-hash\n')
(auth / '.project').write_text('another-service\n' if scenario == 'unowned-auth' else 'baiyue-logistics-chat-gray\n')
(auth / 'access.htpasswd').write_text('previous-auth-hash\n')
unrelated = proxy / 'unrelated.conf'
unrelated.write_text('# unrelated site\n')
config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'),
str(proxy), str(certificate), str(private_key), 'a'*40,
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'b'*64, 'sha256:' + 'c'*64)
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64, str(auth))
release = Path(config.release)
write_release(release, config, 'fixture-model-key')
# Docker 26 and 29 may assign different IDs after an archive is loaded.
loaded_id = ('e' if scenario == 'image-mismatch' else 'b') * 64
(release / 'loaded-image-ids.env').write_text('CHAT_IMAGE_ID=sha256:' + loaded_id + '\n')
(release / 'site.htpasswd').write_text('new-auth-hash\n')
commands = directory / 'commands'
commands.mkdir()
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl'):
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl', 'sleep'):
command = commands / name
command.write_text('#!' + sys.executable + '\n' + FAKE_COMMAND)
command.chmod(0o755)
@@ -84,6 +107,7 @@ class GrayActivationTests(unittest.TestCase):
env=environment, capture_output=True, text=True, timeout=30)
self.assertEqual((root / 'data/history.db').read_bytes(), b'preserve-existing-data')
self.assertEqual(unrelated.read_text(), '# unrelated site\n')
self.assertFalse(list(release.glob('.visitor-smoke.*')), 'Temporary visitor cookies must be removed on success and rollback')
calls = [json.loads(line) for line in log.read_text().splitlines()]
return result, root, proxy, previous, release, old_site, calls
@@ -92,19 +116,58 @@ class GrayActivationTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
self.assertIn('server_name chat.example.com', (proxy / 'baiyue-logistics-chat-gray.conf').read_text())
auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy'
self.assertEqual((auth / 'access.htpasswd').read_text(), 'new-auth-hash\n')
self.assertIn(str(auth / 'access.htpasswd'), (proxy / 'baiyue-logistics-chat-gray.conf').read_text())
self.assertEqual(auth.stat().st_mode & 0o777, 0o755)
self.assertEqual((auth / 'access.htpasswd').stat().st_mode & 0o777, 0o644)
def assert_rolled_back(self, scenario):
result, root, proxy, previous, release, old_site, calls = self.run_activation(scenario)
self.assertNotEqual(result.returncode, 0)
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
self.assertEqual((proxy / '.baiyue-logistics-chat.htpasswd').read_text(), 'previous-auth-hash\n')
self.assertEqual((proxy.parent / 'baiyue-logistics-chat-gray-proxy/access.htpasswd').read_text(), 'previous-auth-hash\n')
self.assertTrue(any(name == 'docker' and 'up' in args and str(previous / 'release.env') in args
for name, args in calls), result.stderr)
return result
def test_failed_application_restores_previous_release(self):
self.assert_rolled_back('app-fail')
def test_async_nginx_reload_waits_for_the_new_tls_workers(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('reload-pending')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
self.assertTrue(any(name == 'sleep' for name, args in calls))
def test_unprotected_proxy_never_publishes_the_release_pointer(self):
self.assert_rolled_back('proxy-unsafe')
def test_failed_visitor_bootstrap_rolls_back_and_removes_cookie_file(self):
result = self.assert_rolled_back('visitor-bootstrap-fail')
self.assertIn('Guest session bootstrap verification failed.', result.stderr)
def test_failed_cookie_history_rolls_back_and_removes_cookie_file(self):
result = self.assert_rolled_back('visitor-history-fail')
self.assertIn('Guest history verification failed.', result.stderr)
def test_image_retagged_after_verified_load_is_rejected_before_activation(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('image-mismatch')
self.assertNotEqual(result.returncode, 0)
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
def test_password_directory_owned_by_another_service_is_not_modified(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('unowned-auth')
self.assertNotEqual(result.returncode, 0)
auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy'
self.assertEqual((auth / '.project').read_text(), 'another-service\n')
self.assertEqual((auth / 'access.htpasswd').read_text(), 'previous-auth-hash\n')
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
def test_invalid_nginx_configuration_restores_previous_site_and_application(self):
self.assert_rolled_back('proxy-fail')
+49
View File
@@ -4,6 +4,7 @@ from dataclasses import replace
from pathlib import Path
import tempfile
import unittest
import re
from dotenv import dotenv_values
@@ -57,6 +58,28 @@ class GrayConfigTests(unittest.TestCase):
self.assertNotIn(key, (path / name).read_text(encoding='utf-8'))
self.assertIn('auth_basic_user_file', (path / 'site.conf').read_text(encoding='utf-8'))
def test_public_allowlist_keeps_staff_search_exports_and_order_endpoints_protected(self):
config = GrayConfig.from_environment(self.settings()).nginx_config()
self.assertTrue(config.startswith('# Managed for baiyue-logistics-chat-gray;'))
match = re.search(r'location ~ (\S+) \{([^}]+)\}', config)
self.assertIsNotNone(match)
pattern, body = match.groups()
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/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)
self.assertIn('location = / {\n auth_basic off;',config)
def test_invalid_model_credential_does_not_create_a_release_directory(self):
config = GrayConfig.from_environment(self.settings())
with tempfile.TemporaryDirectory() as directory:
@@ -65,6 +88,32 @@ class GrayConfigTests(unittest.TestCase):
write_release(path, config, 'fixture-key\nANOTHER_SETTING=bad')
self.assertFalse(path.exists())
def test_baidu_credentials_only_enter_private_runtime_file(self):
config = GrayConfig.from_environment(self.settings())
speech = {'ASR_PROVIDER': 'baidu', 'BAIDU_ASR_API_KEY': 'fixture-${asr-key}',
'BAIDU_ASR_SECRET_KEY': "fixture-asr-secret's", 'FFMPEG_BIN': r'C:\local\ffmpeg.exe'}
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'release'
write_release(path, config, 'fixture-model-key', speech)
runtime = dotenv_values(path / 'runtime.env', interpolate=False)
self.assertEqual(runtime['ASR_PROVIDER'], 'baidu')
for key in ('BAIDU_ASR_API_KEY', 'BAIDU_ASR_SECRET_KEY'):
self.assertEqual(runtime[key], speech[key])
for file in path.iterdir():
if file.name != 'runtime.env':
self.assertNotIn(speech[key], file.read_text(encoding='utf-8'))
self.assertNotIn('FFMPEG_BIN', runtime)
def test_incomplete_or_multiline_speech_credentials_are_rejected_before_writing(self):
config = GrayConfig.from_environment(self.settings())
for speech in ({'ASR_PROVIDER': 'baidu'}, {'ASR_PROVIDER': 'unknown'},
{'ASR_PROVIDER': 'baidu', 'BAIDU_ASR_API_KEY': 'key', 'BAIDU_ASR_SECRET_KEY': 'secret\nOTHER=bad'}):
with self.subTest(provider=speech['ASR_PROVIDER']), tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'release'
with self.assertRaises(ValueError):
write_release(path, config, 'fixture-key', speech)
self.assertFalse(path.exists())
if __name__ == '__main__':
unittest.main()
+79
View File
@@ -0,0 +1,79 @@
"""Verify the real remote image-load guard without contacting Docker or a host."""
import gzip
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Image transfer requires Linux Bash')
class GrayImageTransferTests(unittest.TestCase):
def run_load(self, scenario):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
directory = Path(temporary.name)
root = directory / 'gray-logistics-chat'
release = root / 'releases/build'
release.mkdir(parents=True)
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
archive = release / 'images.tar.gz'
archive.write_bytes(gzip.compress(b'fixture-image-archive'))
checksum = hashlib.sha256(archive.read_bytes()).hexdigest()
if scenario == 'corrupt':
archive.write_bytes(archive.read_bytes() + b'changed-in-transit')
commands = directory / 'commands'
commands.mkdir()
docker = commands / 'docker'
docker.write_text('#!' + sys.executable + '\n' + '''import os, pathlib, sys
args = sys.argv[1:]
with pathlib.Path(os.environ['FAKE_LOG']).open('a') as output:
output.write(' '.join(args) + '\\n')
if args == ['load']:
assert sys.stdin.buffer.read() == b'fixture-image-archive'
elif args[:2] == ['image', 'inspect']:
if args[3] == '{{.Id}}':
print('sha256:' + ('b' if '-gray:' in args[-1] else 'c') * 64)
else:
print(('e' if os.environ['FAKE_SCENARIO'] == 'wrong-source' else 'a') * 40)
else:
sys.exit(3)
''')
docker.chmod(0o755)
publish = (Path(__file__).resolve().parents[1] / 'deploy/publish-gray.sh').read_text()
remote = publish.split("<<'LOAD'\n", 1)[1].split('\nLOAD\n', 1)[0]
log = directory / 'commands.log'
environment = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ['PATH'],
FAKE_LOG=str(log), FAKE_SCENARIO=scenario)
result = subprocess.run(['bash', '-s', '--', str(root), str(release), checksum,
'baiyue-logistics-chat-gray:' + 'a' * 12 + '-b7',
'baiyue-logistics-chat-certbot:' + 'a' * 12 + '-b7', 'a' * 40],
input=remote, env=environment, capture_output=True, text=True, timeout=15)
return result, release, log
def test_verified_archive_records_target_ids_and_removes_archive(self):
result, release, log = self.run_load('success')
self.assertEqual(result.returncode, 0, result.stderr)
receipt = (release / 'loaded-image-ids.env').read_text()
self.assertIn('CHAT_IMAGE_ID=sha256:' + 'b' * 64, receipt)
self.assertIn('CERTBOT_IMAGE_ID=sha256:' + 'c' * 64, receipt)
self.assertFalse((release / 'images.tar.gz').exists())
def test_modified_archive_is_rejected_before_docker_load(self):
result, release, log = self.run_load('corrupt')
self.assertNotEqual(result.returncode, 0)
self.assertFalse(log.exists())
self.assertFalse((release / 'loaded-image-ids.env').exists())
def test_wrong_source_cannot_create_a_verified_receipt(self):
result, release, log = self.run_load('wrong-source')
self.assertNotEqual(result.returncode, 0)
self.assertFalse((release / 'loaded-image-ids.env').exists())
if __name__ == '__main__':
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
"""Run the real private-import guard against a disposable database and fake Docker."""
import hashlib
import json
import os
from pathlib import Path
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from logistics_chat.order_data import import_records
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Private import requires Linux Bash')
class GrayOrderImportTests(unittest.TestCase):
def run_import(self, corrupt=False):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
directory = Path(temporary.name)
root = directory / 'gray-logistics-chat'
release_id = 'a' * 12 + '-b7'
release = root / 'releases' / release_id
release.mkdir(parents=True)
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
(root / 'current-release').write_text(str(release) + '\n')
data = root / 'data'
(data / 'backups').mkdir(parents=True)
database = data / 'history.db'
with sqlite3.connect(database) as connection:
connection.execute('CREATE TABLE messages (content TEXT)')
connection.execute("INSERT INTO messages VALUES ('keep chat history')")
import_records(connection, [{'order_no': 'EXISTING1', 'source_order_id': '1',
'tenant_id': 'test', 'gross_weight_kg': 'manual weight',
'tracking_json': [{'manual': True}]}])
orders = [{'order_no': 'EXISTING1', 'source_order_id': '1', 'tenant_id': 'test', 'gross_weight_kg': 'source weight'},
{'order_no': 'NEW00002', 'source_order_id': '2', 'tenant_id': 'test'}]
tracking = [{'order_no': row['order_no'], 'source_order_id': row['source_order_id'],
'tenant_id': 'test', 'tracking_json': [{'code': 'IN_TRANSIT', 'actual_at': '2026-10-30'}]}
for row in orders]
hashes = []
for name, records in [('order-input.json', orders), ('tracking-input.json', tracking)]:
path = release / name
path.write_text(json.dumps({'orders': records}))
hashes.append(hashlib.sha256(path.read_bytes()).hexdigest())
if corrupt:
(release / 'order-input.json').write_text('changed in transit')
commands = directory / 'commands'
commands.mkdir()
docker = commands / 'docker'
docker.write_text('#!' + sys.executable + '\n' + '''import os, pathlib, subprocess, sys
args = sys.argv[1:]
if args[0] == 'inspect':
print('a'*40 if 'revision' in args[2] else 'baiyue-logistics-chat-gray')
elif args[0] == 'exec':
command = [sys.executable] + args[3:]
command = [value.replace('/var/lib/baiyue-logistics-chat', os.environ['FAKE_DATA']) for value in command]
if len(command) > 1 and command[1] != '-c':
command[1] = str(pathlib.Path(os.environ['FAKE_SOURCE']) / command[1])
sys.exit(subprocess.run(command).returncode)
else:
sys.exit(3)
''')
docker.chmod(0o755)
source = Path(__file__).resolve().parents[1]
script = (source / 'deploy/import-gray-order-samples.sh').read_text()
remote = script.split("<<'IMPORT'\n", 1)[1].split('\nIMPORT\n', 1)[0]
environment = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ['PATH'],
FAKE_DATA=str(data), FAKE_SOURCE=str(source))
result = subprocess.run(['bash', '-s', '--', str(root), release_id, 'a' * 40, *hashes],
input=remote, env=environment, capture_output=True, text=True, timeout=30)
return result, data, release, release_id
def test_import_backs_up_and_preserves_manual_fields_tracking_and_chat_history(self):
result, data, release, release_id = self.run_import()
self.assertEqual(result.returncode, 0, result.stderr)
with sqlite3.connect(data / 'history.db') as connection:
self.assertEqual(connection.execute('SELECT content FROM messages').fetchall(), [('keep chat history',)])
row = connection.execute('SELECT gross_weight_kg,tracking_json FROM order_test_data WHERE order_no="EXISTING1"').fetchone()
self.assertEqual(row[0], 'manual weight')
self.assertEqual(json.loads(row[1]), [{'manual': True}])
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 2)
row = connection.execute('SELECT tracking_json FROM order_test_data WHERE order_no="NEW00002"').fetchone()
self.assertEqual(json.loads(row[0])[0]['code'], 'IN_TRANSIT')
with sqlite3.connect(data / 'backups' / ('pre-order-import-' + release_id + '.db')) as backup:
self.assertEqual(backup.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 1)
self.assertFalse((data / 'imports' / release_id).exists())
self.assertFalse((release / 'order-input.json').exists())
self.assertFalse((release / 'tracking-input.json').exists())
def test_corrupt_snapshot_is_rejected_before_database_changes(self):
result, data, release, release_id = self.run_import(corrupt=True)
self.assertNotEqual(result.returncode, 0)
with sqlite3.connect(data / 'history.db') as connection:
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 1)
self.assertFalse(list((data / 'backups').iterdir()))
if __name__ == '__main__':
unittest.main()
+232
View File
@@ -0,0 +1,232 @@
"""Local order snapshots, exact lookup and conversational order queries."""
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app, db, order_data
from logistics_chat.tracking import CARD_PREFIX, card_to_text
class OrderDataTests(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':'', 'RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key':'1'*64})
self.context = self.app.app_context()
self.context.push()
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',
'status':'PROCESSING', 'country_code':'US', 'transport_mode':'SEA', 'warehouse_code':'TEST1',
'order_role':'MASTER', 'package_count':3, 'gross_weight_kg':'30.000', 'volume_cbm':'0.2500',
'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()
try:
with connection:
return order_data.import_records(connection, rows, replace=replace)
finally:
connection.close()
def events(self, message, cid=None):
response = self.client.post('/stream_chat', json={'msg':message, 'conv_id':cid})
self.assertEqual(response.status_code, 200)
return [json.loads(frame[6:]) for frame in response.get_data(as_text=True).split('\n\n') if frame.startswith('data: ')]
def test_identifiers_are_exact_case_insensitive_and_preserve_suffixes(self):
for value in ('fl202610290001', 'INB20261029001-01', 'shipment00208'):
self.assertEqual(order_data.lookup_order(value)[0]['order_no'], self.record['order_no'])
self.assertEqual(self.events('查询 '+value)[0]['card']['no'], self.record['order_no'])
self.assertEqual(order_data.lookup_order('FL20261029000'), [])
self.assertEqual(order_data.extract_reference('查询 INB20261029001-01'), ['INB20261029001-01'])
self.import_rows([{**self.record,'order_no':'BOX100x100x100'}])
self.assertEqual(self.events('查询 BOX100x100x100')[0]['card']['no'],'BOX100x100x100')
def test_weight_dates_and_attachment_urls_are_not_order_identifiers(self):
for text in ('2000KG海运报价', '100000 kg海运报价', '30.000000 KG', '2026-10-29',
'2026-10-29T16:53:00', '100x100x100cm', '1200 × 800 × 600 mm',
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
self.assertEqual(order_data.extract_reference(text), [])
events = self.events('帮我查询物流轨迹')
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',
'FL202610290001 运费多少钱?'):
with self.subTest(message=message):
event = self.events(message)[0]
if '运费' in message:
self.assertIn('未记录实际运费', event['t'])
else:
self.assertEqual(event['card']['no'], self.record['order_no'])
def test_unknown_orders_never_produce_fake_tracking_or_call_model(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = self.events('查询 UNKNOWN20260001')
self.assertIn('未找到', events[0]['t'])
self.assertFalse(any('card' in event for event in events))
self.assertEqual(events[-1], {'d':1})
model.assert_not_called()
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'])
model.assert_not_called()
def test_server_tenant_scope_and_ambiguous_identifiers(self):
self.import_rows([{**self.record, 'tenant_id':'tenant-b', 'gross_weight_kg':'99'}])
fields = self.events(self.record['order_no'])[0]['card']['fields']
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '30.000 kg')
self.app.config['ORDER_DATA_TENANT_ID'] = 'tenant-a'
rows = order_data.lookup_order(self.record['order_no'])
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]['gross_weight_kg'], '30.000')
self.app.config['ORDER_DATA_TENANT_ID'] = 'unknown-tenant'
self.assertEqual(order_data.lookup_order(self.record['order_no']), [])
def test_manual_changes_are_visible_and_reimport_preserves_them_by_default(self):
connection = db.connect()
connection.execute("UPDATE order_test_data SET gross_weight_kg='42.000' WHERE order_no=?", (self.record['order_no'],))
connection.commit()
connection.close()
self.assertEqual(self.import_rows([self.record]), {'imported':0, 'preserved':1})
fields = self.events(self.record['order_no'])[0]['card']['fields']
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '42.000 kg')
self.import_rows([self.record], replace=True)
self.assertEqual(order_data.lookup_order(self.record['order_no'])[0]['gross_weight_kg'], '30.000')
def test_new_table_records_can_be_added_without_touching_source_database(self):
self.import_rows([{**self.record, 'order_no':'LOCAL20260002', 'shipment_id':'localship002'}])
self.assertEqual(self.events('查询 LOCAL20260002')[0]['card']['no'], 'LOCAL20260002')
def test_business_status_history_does_not_claim_delivery_or_invent_transport_events(self):
self.import_rows([{**self.record, 'status':'COMPLETED'}], replace=True)
card = self.events(self.record['order_no'])[0]['card']
self.assertEqual(card['status'], '头程订单已完成')
self.assertNotIn('已签收', json.dumps(card, ensure_ascii=False))
self.assertEqual([node['time'] for node in card['nodes']], ['2026-10-29 10:00','2026-10-28 10:00'])
self.assertTrue(all(not node['place'] for node in card['nodes']))
def test_children_do_not_reuse_parent_total_weight(self):
self.import_rows([{**self.record, 'order_no':'CHILD20260001-01', 'order_role':'CHILD',
'gross_weight_kg':None, 'package_count':None, 'volume_cbm':None,
'parent_order_no':self.record['order_no'], 'data_note':'子单独立货物数据未提供'}])
card = self.events('CHILD20260001-01')[0]['card']
self.assertFalse(any(field['key']=='weight' for field in card['fields']))
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='parent'), self.record['order_no'])
def test_tracking_queries_and_followups_read_linked_facts_without_calling_model(self):
self.import_rows([{**self.record, 'tracking_json': [
{'code': 'DEPARTED_ORIGIN_PORT', 'actual_at': '2026-10-28 10:00', 'shipment_key': '1'},
{'code': 'ARRIVED_DESTINATION_PORT', 'estimated_at': '2026-11-02 10:00', 'shipment_key': '1'},
]}], replace=True)
cid = self.client.post('/api/new_chat').json['id']
with patch('logistics_chat.ai_client.post_completion') as model:
for message in (self.record['order_no']+' 物流轨迹', '到哪里了?', '那到港了吗?', '这票签收了吗?',
'轨迹呢?', '现在到哪了?', '什么时候到港?', '预计什么时候到?', '还要多久?'):
card = self.events(message, cid)[0]['card']
self.assertEqual(card['node_kind'], 'logistics')
self.assertEqual(card['tracking_status'], '船舶已离开起运港')
self.assertEqual(card['nodes'][0]['text'], '船舶已离开起运港')
self.assertEqual(card['estimated_nodes'][0]['text'], '预计到达目的港')
self.assertIn('预计到达目的港', card_to_text(CARD_PREFIX+json.dumps(card)))
self.assertNotIn('已签收', json.dumps(card, ensure_ascii=False))
model.assert_not_called()
self.assertIsNone(order_data.answer_query('美国30KG报价', cid))
def test_followups_refresh_local_data_and_remain_within_conversation(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
connection = db.connect()
connection.execute("UPDATE order_test_data SET gross_weight_kg='45.000' WHERE order_no=?", (self.record['order_no'],))
connection.commit()
connection.close()
card = self.events('这票重量是多少?',cid)[0]['card']
self.assertEqual(card['no'], self.record['order_no'])
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='weight'), '45.000 kg')
self.assertIn('未记录实际运费', self.events('这票运费多少钱?',cid)[0]['t'])
self.assertEqual(self.events('那状态呢?',cid)[0]['card']['no'], self.record['order_no'])
self.events('查询 UNKNOWN20260001',cid)
self.assertIn('未找到', self.events('这票重量呢?',cid)[0]['t'])
other_cid = self.client.post('/api/new_chat').json['id']
self.assertIsNone(order_data.answer_query('这票重量是多少?', other_cid))
def test_new_shipping_questions_do_not_reuse_previous_order(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
for message in ('现在有30KG货物想发美国,运费多少?', '美国海运费用怎么算?', '报价30公斤到德国', '物流状态是什么意思?'):
with self.subTest(message=message):
self.assertIsNone(order_data.answer_query(message, cid))
db.save_message(cid, 'user', '美国海运费用怎么算?')
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
self.assertIsNone(order_data.answer_query('那状态呢?', cid))
def test_short_order_followups_read_facts_without_calling_model(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
with patch('logistics_chat.ai_client.post_completion') as model:
for message in ('多少公斤?', '有几箱?', '体积呢?', '那状态呢?'):
with self.subTest(message=message):
self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no'])
model.assert_not_called()
def test_card_storage_and_model_context_contain_only_selected_facts(self):
card = self.events(self.record['order_no'])[0]['card']
text = card_to_text(CARD_PREFIX+json.dumps(card))
self.assertIn('30.000', text)
self.assertIn('US', text)
self.assertNotIn('测试', text)
self.assertNotIn('tenant_id', text)
self.assertNotIn('customer_name', json.dumps(card))
def test_invalid_history_is_safe_and_read_failure_is_explicit(self):
connection = db.connect()
connection.execute("UPDATE order_test_data SET history_json='invalid'")
connection.commit()
self.assertEqual(self.events(self.record['order_no'])[0]['card']['nodes'], [])
connection.execute('DROP TABLE order_test_data')
connection.commit()
connection.close()
self.assertIn('暂时无法读取', self.events(self.record['order_no'])[0]['t'])
def test_import_failure_rolls_back_partial_rows(self):
with self.assertRaises(ValueError):
self.import_rows([{**self.record,'order_no':'ROLLBACK20260001'}, {'order_no':''}])
self.assertEqual(order_data.lookup_order('ROLLBACK20260001'), [])
if __name__ == '__main__':
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
"""Tracking facts preserve actual/estimated meaning, corrections and order identity."""
import json
import sqlite3
import unittest
from contextlib import closing
from logistics_chat.order_data import import_records, import_tracking_records
from logistics_chat.order_tracking import build_tracking
class OrderTrackingTests(unittest.TestCase):
def fact(self, code, actual=None, estimated=None, **extra):
return {'code': code, 'actual_at': actual, 'estimated_at': estimated,
'observed_at': '2026-10-29 12:00', 'shipment_key': 'container-1', **extra}
def test_estimates_do_not_advance_actual_progress_or_fill_missing_milestones(self):
result = build_tracking([
self.fact('DEPARTED_ORIGIN_PORT', '2026-10-25 10:00'),
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-01 10:00'),
])
self.assertEqual(result['status'], '船舶已离开起运港')
self.assertEqual([n['text'] for n in result['nodes']], ['船舶已离开起运港'])
self.assertEqual(result['estimated_nodes'][0]['text'], '预计到达目的港')
self.assertEqual(result['estimated_nodes'][0]['time'], '2026-11-01 10:00')
def test_latest_observation_can_correct_actual_time_backwards(self):
result = build_tracking([
self.fact('ARRIVED_DESTINATION_PORT', '2026-10-28 10:00'),
self.fact('ARRIVED_DESTINATION_PORT', '2026-10-27 10:00', observed_at='2026-10-30 10:00'),
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-10-31 10:00', observed_at='2026-10-31 10:00'),
])
self.assertEqual(len(result['nodes']), 1)
self.assertEqual(result['nodes'][0]['time'], '2026-10-27 10:00')
self.assertEqual(result['estimated_nodes'], [])
def test_delayed_earlier_milestone_and_stale_forecast_do_not_regress_progress(self):
result = build_tracking([
self.fact('PICKED_UP', '2026-10-28 10:00'),
self.fact('AT_ORIGIN_PORT', '2026-10-29 10:00', observed_at='2026-10-30 10:00'),
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-10-29 20:00'),
])
self.assertEqual(result['status'], '已提柜')
self.assertEqual(len(result['nodes']), 2)
self.assertEqual(result['estimated_nodes'], [])
self.assertNotIn('签收', json.dumps(result, ensure_ascii=False))
def test_multiple_containers_keep_independent_progress_and_locations(self):
result = build_tracking([
self.fact('PICKED_UP', '2026-10-28 10:00', shipment_no='BOX-A', place='码头 A'),
self.fact('IN_TRANSIT', '2026-10-27 10:00', shipment_key='container-2', shipment_no='BOX-B'),
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-02 10:00',
shipment_key='container-2', shipment_no='BOX-B'),
])
self.assertEqual(result['status'], '货柜 BOX-A:已提柜;货柜 BOX-B:海运途中')
self.assertEqual(result['nodes'][0]['place'], '货柜 BOX-A · 码头 A')
self.assertEqual(result['estimated_nodes'][0]['place'], '货柜 BOX-B')
def test_domestic_actual_is_retained_when_ship_has_only_forecasts(self):
result = build_tracking([
self.fact('DOMESTIC_RECEIVED', '2026-10-28 10:00', shipment_key=''),
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-02 10:00'),
])
self.assertEqual(result['status'], '已入国内仓')
self.assertEqual(len(result['nodes']), 1)
def test_estimated_only_unknown_codes_and_broken_values_are_safe(self):
result = build_tracking([self.fact('PICKED_UP', estimated='2026-11-02 10:00'),
{'code': []}, None, self.fact('UNRECOGNIZED', '2026-10-28 10:00'),
self.fact('IN_TRANSIT', 'not a time')])
self.assertEqual(result['status'], '')
self.assertEqual(result['nodes'], [])
self.assertEqual(result['estimated_nodes'][0]['text'], '预计提柜')
for value in (None, {}, 'broken json', '[null]', []):
self.assertIsNone(build_tracking(value))
def test_tracking_import_preserves_manual_order_fields_and_scopes_by_identity(self):
with closing(sqlite3.connect(':memory:')) as connection:
orders = [{'order_no': 'ORDER2026001', 'source_order_id': '987654321123456789',
'tenant_id': tenant, 'gross_weight_kg': weight}
for tenant, weight in [('10', '42'), ('20', '80')]]
import_records(connection, orders)
record = {**orders[0], 'tracking_json': [self.fact('IN_TRANSIT', '2026-10-28 10:00')]}
self.assertEqual(import_tracking_records(connection, [record]),
{'imported': 1, 'preserved': 0, 'missing': 0})
self.assertEqual(connection.execute('SELECT gross_weight_kg FROM order_test_data '
'WHERE tenant_id=\'10\'').fetchone()[0], '42')
self.assertEqual(connection.execute('SELECT tracking_json FROM order_test_data '
'WHERE tenant_id=\'20\'').fetchone()[0], '[]')
self.assertEqual(import_tracking_records(connection, [record])['preserved'], 1)
self.assertEqual(import_tracking_records(connection, [record], replace=True)['imported'], 1)
with self.assertRaises(ValueError):
import_tracking_records(connection, [{**record, 'source_order_id': 'wrong'}], replace=True)
self.assertEqual(import_tracking_records(connection, [{**record, 'order_no': 'MISSING2026001'}])['missing'], 1)
def test_old_table_is_migrated_without_losing_data(self):
with closing(sqlite3.connect(':memory:')) as connection:
import_records(connection, [{'tenant_id': '10', 'order_no': 'ORDER2026001'}])
connection.execute('ALTER TABLE order_test_data DROP COLUMN tracking_json')
import_tracking_records(connection, [])
self.assertEqual(connection.execute('SELECT order_no,tracking_json FROM order_test_data').fetchone(),
('ORDER2026001', '[]'))
+22 -1
View File
@@ -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):
@@ -40,6 +42,23 @@ class RuleTests(unittest.TestCase):
self.assertEqual(intent['channel'], 'sea')
self.assertEqual(pricing.parse_shipping_intent('1吨铁路')['weight'], 1000)
self.assertEqual(pricing.parse_shipping_intent('100 lbs air')['weight'], 45.36)
self.assertEqual(pricing.parse_shipping_intent('1.2 x 0.8 x 0.6 m 30kg海运')['dims'],[1.2,0.8,0.6])
self.assertEqual(pricing.parse_shipping_intent('长10.5宽20.5高30.5')['dims'],[10.5,20.5,30.5])
def test_shipping_regions_distinguish_country_codes_from_english_pronouns(self):
self.assertEqual(pricing.shipping_region('Give us a quote for 30kg to Germany'),'eu')
self.assertEqual(pricing.shipping_region('30kg air to US'),'unsupported')
self.assertEqual(pricing.shipping_region('30kg air to us'),'unsupported')
self.assertEqual(pricing.shipping_region('30kg to UK'),'uk')
self.assertEqual(pricing.shipping_region('30kg发UK海运'),'uk')
self.assertEqual(pricing.shipping_region('30kg发US仓海运'),'unsupported')
self.assertIsNone(pricing.shipping_region('talk about us and our work'))
self.assertIsNone(pricing.shipping_region('30kg for UKEURO warehouse'))
self.assertIsNone(pricing.shipping_region('2GB附件,30kg海运报价'))
self.assertTrue(pricing.is_shipping_destination_reply('英国的FBA仓'))
self.assertTrue(pricing.is_shipping_destination_reply('目的地是UK仓'))
self.assertFalse(pricing.is_shipping_destination_reply('英国VAT费用是什么'))
self.assertFalse(pricing.is_shipping_destination_reply('Give us a quote for 30kg to Germany'))
def test_warehouse_country_and_surcharge(self):
result = pricing.calc_warehouse_card(2, '海运', country='DE')
@@ -51,6 +70,8 @@ class RuleTests(unittest.TestCase):
self.assertIn('500', pricing.surcharge_reply(product, fee, 100))
self.assertEqual(pricing.check_island('07000')[1], 'IT')
self.assertIsNone(pricing.check_island('not a postcode'))
self.assertIsNone(pricing.calc_warehouse_card(2, '海运', country='GB'))
self.assertIsNone(pricing.calc_warehouse_card(2, '海运', warehouse_key='unknown'))
def test_tracking_recognition_and_card_context(self):
self.assertEqual(tracking.extract_tracking_no('查询15252125'), '15252125')
+174
View File
@@ -0,0 +1,174 @@
"""Short recordings are validated and sent only to the separately configured ASR."""
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import subprocess
import requests
from logistics_chat import create_app
from logistics_chat.speech_service import MAX_AUDIO_BYTES
class SpeechResponse:
def __init__(self, payload=None, status=200, raw=None):
self.status_code = status
self.raw = raw if raw is not None else json.dumps(payload or {'choices': [{'message': {'content': '查询订单状态'}}]}).encode()
self.closed = False
def iter_content(self, size):
yield self.raw
def close(self):
self.closed = True
class SpeechTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
self.app = create_app({'TESTING': True, 'DATABASE': self.root/'history.db',
'UPLOAD_DIR': self.root/'uploads', 'BACKUP_DIR': self.root/'backups', 'LOG_DIR': self.root/'logs',
'DEEPSEEK_API_KEY': 'separate-chat-key', 'ASR_PROVIDER':'dashscope', 'ASR_API_KEY': 'asr-test-key',
'ASR_URL': 'https://asr.example.test/chat/completions', 'ASR_MODEL': 'test-asr', 'RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key':'1'*64})
def recording(self, content=b'\x1aE\xdf\xa3' + b'x' * 40, mime='audio/webm', duration='2.5', **extra):
response = self.client.post('/api/speech/transcribe', data={'audio': (io.BytesIO(content), 'private-filename.webm', mime),
'duration': duration, 'language':'zh', **extra})
self.addCleanup(response.close)
self.addCleanup(response.request.environ['wsgi.input'].close)
return response
def test_missing_asr_key_does_not_reuse_chat_key_or_accept_audio(self):
self.app.config['ASR_API_KEY'] = ''
response = self.client.get('/api/speech/config')
self.assertFalse(response.json['enabled'])
self.assertIn('no-store', response.headers['Cache-Control'])
self.assertNotIn('key', response.get_data(as_text=True).lower())
with patch('logistics_chat.speech_service.requests.post') as post:
self.assertEqual(self.recording().status_code, 503)
post.assert_not_called()
def test_valid_recording_uses_asr_only_and_never_persists_audio(self):
upstream = SpeechResponse()
with patch('logistics_chat.speech_service.requests.post', return_value=upstream) as post:
response = self.recording()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {'ok': True, 'text': '查询订单状态'})
url = post.call_args.args[0]
options = post.call_args.kwargs
self.assertEqual(url, self.app.config['ASR_URL'])
self.assertEqual(options['headers']['Authorization'], 'Bearer asr-test-key')
self.assertEqual(options['json']['model'], 'test-asr')
self.assertEqual(options['json']['asr_options']['language'], 'zh')
self.assertTrue(options['json']['messages'][0]['content'][0]['input_audio']['data'].startswith('data:audio/webm;base64,'))
self.assertFalse(options['allow_redirects'])
self.assertTrue(upstream.closed)
self.assertEqual(list(self.root.glob('uploads/**/*')), [])
self.assertEqual(self.client.get('/api/get_history').json, [])
def test_browser_audio_containers_are_supported_without_trusting_filename(self):
samples = [(b'\x00\x00\x00\x18ftypisom' + b'x'*20, 'audio/mp4'),
(b'OggS'+b'x'*32, 'audio/ogg'), (b'RIFF'+b'x'*4+b'WAVE'+b'x'*20, 'audio/wav')]
for content, mime in samples:
with self.subTest(mime=mime), patch('logistics_chat.speech_service.requests.post', return_value=SpeechResponse()):
self.assertEqual(self.recording(content, mime).status_code, 200)
def test_invalid_audio_size_duration_and_container_never_reach_provider(self):
with patch('logistics_chat.speech_service.requests.post') as post:
self.assertEqual(self.recording(b'html masquerading as audio'*2).status_code, 415)
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*MAX_AUDIO_BYTES).status_code, 413)
for duration in ('NaN', 'inf', '-1', '0.1', '62', 'broken'):
with self.subTest(duration=duration):
self.assertEqual(self.recording(duration=duration).status_code, 400)
self.assertEqual(self.client.post('/api/speech/transcribe', data={}).status_code, 400)
post.assert_not_called()
def test_request_body_limit_returns_json_instead_of_an_html_error(self):
response = self.recording(b'x'*(MAX_AUDIO_BYTES+128*1024))
self.assertEqual(response.status_code, 413)
self.assertFalse(response.json['ok'])
def test_upstream_failures_timeout_and_malformed_results_are_recoverable(self):
for upstream in (SpeechResponse(status=401, raw=b'secret upstream message'),
SpeechResponse(raw=b'invalid'), SpeechResponse(payload={'choices': []}),
SpeechResponse(raw=b'x' * 65537)):
with self.subTest(raw=upstream.raw[:20]), patch('logistics_chat.speech_service.requests.post', return_value=upstream):
response = self.recording()
self.assertEqual(response.status_code, 502)
self.assertNotIn('secret', response.get_data(as_text=True))
self.assertTrue(upstream.closed)
with patch('logistics_chat.speech_service.requests.post', side_effect=requests.Timeout):
self.assertEqual(self.recording().status_code, 504)
def test_silence_and_excessively_long_transcripts_are_not_sent_to_chat(self):
for text in ('', ' ', '字' * 2001):
with self.subTest(length=len(text)), patch('logistics_chat.speech_service.requests.post',
return_value=SpeechResponse(payload={'choices': [{'message': {'content': text}}]})):
self.assertEqual(self.recording().status_code, 422)
def test_baidu_converts_browser_audio_and_caches_a_separate_access_token(self):
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
responses=[SpeechResponse(payload={'access_token':'baidu-test-token','expires_in':3600}),
SpeechResponse(payload={'err_no':0,'result':['查询订单。']}),
SpeechResponse(payload={'err_no':0,'result':['查询订单。']})]
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
patch('logistics_chat.speech_service.requests.post',side_effect=responses) as post:
for _ in range(2):
response=self.recording()
self.assertEqual(response.status_code,200)
self.assertEqual(response.json['text'],'查询订单。')
self.assertEqual(post.call_count,3)
self.assertEqual(post.call_args_list[0].kwargs['data']['client_id'],'baidu-test-key')
for call in post.call_args_list[1:]:
self.assertEqual(call.args[0],'https://vop.baidu.com/server_api')
self.assertEqual(call.kwargs['json']['format'],'pcm')
self.assertEqual(call.kwargs['json']['rate'],16000)
self.assertEqual(call.kwargs['json']['channel'],1)
self.assertEqual(call.kwargs['json']['token'],'baidu-test-token')
self.assertTrue(all(response.closed for response in responses))
def test_baidu_reports_permission_failure_and_never_returns_upstream_secrets(self):
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
patch('logistics_chat.speech_service.requests.post',side_effect=[
SpeechResponse(payload={'access_token':'token','expires_in':3600}),
SpeechResponse(payload={'err_no':3302,'err_msg':'private provider details'})]):
response=self.recording()
self.assertEqual(response.status_code,503)
self.assertNotIn('private',response.get_data(as_text=True))
def test_decoder_rejects_real_duration_beyond_limit_and_handles_failure_or_timeout(self):
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='test-key', BAIDU_ASR_SECRET_KEY='test-secret')
cases=[(subprocess.CompletedProcess([],0,stdout=b'\0'*(32000*61),stderr=b''),400),
(subprocess.CompletedProcess([],1,stdout=b'',stderr=b'private decoder error'),422)]
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
patch('logistics_chat.speech_service.requests.post') as post:
for result,status in cases:
with patch('logistics_chat.speech_service.subprocess.run',return_value=result):
self.assertEqual(self.recording(duration='1').status_code,status)
with patch('logistics_chat.speech_service.subprocess.run',side_effect=subprocess.TimeoutExpired('ffmpeg',10)):
self.assertEqual(self.recording().status_code,504)
post.assert_not_called()
def test_large_recordings_stay_in_memory_and_the_upload_stream_is_closed(self):
from logistics_chat import speech_service
original=speech_service.transcribe_recording
streams=[]
def inspect(upload,*args):
self.assertIsInstance(upload.stream,io.BytesIO)
streams.append(upload.stream)
return original(upload,*args)
with patch('logistics_chat.speech_service.transcribe_recording',side_effect=inspect), \
patch('logistics_chat.speech_service.requests.post',return_value=SpeechResponse()):
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*600000).status_code,200)
self.assertTrue(streams[0].closed)
+49
View File
@@ -6,6 +6,8 @@ from pathlib import Path
import tempfile
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from flask import Flask
from werkzeug.datastructures import FileStorage
@@ -29,6 +31,27 @@ class StorageTests(unittest.TestCase):
self.addCleanup(self.context.pop)
db.init_db()
def test_concurrent_startup_migrates_legacy_history_once_without_claiming_it(self):
connection = db.connect()
connection.executescript('DROP TABLE chat_history; CREATE TABLE chat_history '
'(id INTEGER PRIMARY KEY, title TEXT NOT NULL, ctime TEXT NOT NULL);'
"INSERT INTO chat_history VALUES(1,'旧记录','now');")
connection.close()
gate = Barrier(4)
def initialize():
with self.app.app_context():
gate.wait(timeout=5)
db.init_db()
with ThreadPoolExecutor(max_workers=4) as workers:
for result in workers.map(lambda _: initialize(), range(4)):
self.assertIsNone(result)
connection = db.connect()
self.assertEqual(connection.execute('SELECT title, visitor_id FROM chat_history').fetchall(), [('旧记录', None)])
self.assertEqual(connection.execute('SELECT COUNT(*) FROM messages WHERE conv_id=1').fetchone()[0], 1)
connection.close()
def test_old_schema_is_migrated_and_legacy_title_backfilled(self):
connection = db.connect()
connection.executescript('DROP TABLE chat_history; CREATE TABLE chat_history '
@@ -100,6 +123,20 @@ class StorageTests(unittest.TestCase):
self.assertNotIn('..', name)
self.assertFalse(result['is_image'])
def test_old_attachments_referenced_by_ai_or_human_history_survive_cleanup(self):
uploads=self.root/'uploads'; uploads.mkdir()
for name in ('ai.png','human.pdf','unused.txt'):
file=uploads/name;file.write_bytes(b'fixture')
os.utime(file,(time.time()-20*86400,time.time()-20*86400))
connection=db.connect()
connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(1,'user','[图片]/uploads/ai.png','now')")
connection.execute("INSERT INTO human_messages(session_id,side,content,ctime) VALUES(1,'customer','[文件]/uploads/human.pdf','now')")
connection.commit();connection.close()
file_service.cleanup_uploads()
self.assertTrue((uploads/'ai.png').exists())
self.assertTrue((uploads/'human.pdf').exists())
self.assertFalse((uploads/'unused.txt').exists())
def test_invalid_and_oversized_uploads_are_rejected(self):
self.assertEqual(file_service.upload_file({})[1], 400)
denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe')
@@ -117,6 +154,18 @@ class StorageTests(unittest.TestCase):
self.assertEqual(connection.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0], 0)
connection.close()
def test_late_stream_writes_cannot_recreate_messages_for_deleted_conversations(self):
connection = db.connect()
cid = connection.execute("INSERT INTO chat_history(title,ctime) VALUES('temporary','now')").lastrowid
connection.commit()
db.save_message(cid,'user','发送中的问题')
connection.execute('DELETE FROM messages WHERE conv_id=?',(cid,))
connection.execute('DELETE FROM chat_history WHERE id=?',(cid,))
connection.commit()
db.save_message(cid,'assistant','迟到的模型回复')
self.assertEqual(connection.execute('SELECT COUNT(*) FROM messages WHERE conv_id=?',(cid,)).fetchone()[0],0)
connection.close()
if __name__ == '__main__':
unittest.main()
+158
View File
@@ -0,0 +1,158 @@
"""Independent guests cannot read, mutate or claim each other's chat data."""
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from datetime import datetime
from logistics_chat import create_app, db
class VisitorTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.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':'','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)):
self.assertEqual(client.post('/api/visitor/session',json={'key':key}).status_code,200)
def conversation(self, client, text):
cid = client.post('/api/new_chat').json['id']
with self.app.app_context(): db.save_message(cid,'user',text)
return cid
def human(self, client, cid):
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026,10,30,12)
return client.post('/api/visitor/human/request',json={'conv_id':cid}).json['session_id']
def test_entry_is_public_but_customer_apis_need_a_guest_session_without_basic_login(self):
fresh = self.app.test_client()
self.assertEqual(fresh.get('/').status_code,200)
response = fresh.get('/api/get_history')
self.assertEqual(response.status_code,401)
self.assertNotIn('WWW-Authenticate',response.headers)
self.assertEqual(fresh.post('/api/new_chat').status_code,401)
self.assertEqual(self.a.get('/api/get_history').json,[])
def test_guest_key_is_restorable_but_only_its_hash_is_stored(self):
cid = self.conversation(self.a,'A_PRIVATE')
restored = self.app.test_client()
response = restored.post('/api/visitor/session',json={'key':'1'*64},base_url='https://localhost')
for flag in ('HttpOnly','Secure','SameSite=Lax'):
self.assertIn(flag,response.headers['Set-Cookie'])
self.assertEqual(restored.get('/api/get_history').json[0]['id'],cid)
with self.app.app_context():
connection=db.connect()
owner=connection.execute('SELECT visitor_id FROM chat_history WHERE id=?',(cid,)).fetchone()[0]
connection.close()
self.assertEqual(len(owner),64)
self.assertNotEqual(owner,'1'*64)
def test_listing_loading_and_streaming_never_expose_another_guest(self):
a = self.conversation(self.a,'A_PRIVATE')
b = self.conversation(self.b,'B_PRIVATE')
self.assertEqual([row['id'] for row in self.a.get('/api/get_history').json],[a])
self.assertEqual([row['id'] for row in self.b.get('/api/get_history').json],[b])
self.assertEqual(self.a.get(f'/api/get_messages?conv_id={b}').json,[])
self.assertEqual(self.b.get(f'/api/get_messages?conv_id={a}').json,[])
with patch('logistics_chat.ai_client.post_completion') as model:
response=self.a.post('/stream_chat',json={'msg':'继续上一段对话','conv_id':b})
self.assertIn('新建对话',response.get_data(as_text=True))
self.assertNotIn('B_PRIVATE',response.get_data(as_text=True))
self.assertEqual(len(self.b.get(f'/api/get_messages?conv_id={b}').json),2)
model.assert_not_called()
def test_delete_and_clear_only_affect_the_current_guest_and_keep_unowned_legacy_data(self):
a = self.conversation(self.a,'A_PRIVATE')
b = self.conversation(self.b,'B_PRIVATE')
sid_a,sid_b=self.human(self.a,a),self.human(self.b,b)
self.a.post('/api/visitor/human/send',json={'sid':sid_a,'content':'A_SUPPORT'})
self.b.post('/api/visitor/human/send',json={'sid':sid_b,'content':'B_SUPPORT'})
with self.app.app_context():
connection=db.connect()
legacy=connection.execute("INSERT INTO chat_history(title,ctime) VALUES('LEGACY_PRIVATE','now')").lastrowid
connection.commit();connection.close()
self.assertEqual(self.a.delete(f'/api/del_history?id={b}').status_code,404)
self.assertTrue(self.a.delete('/api/clear_all').json['ok'])
self.assertEqual(self.a.get('/api/get_history').json,[])
self.assertEqual(self.b.get('/api/get_history').json[0]['id'],b)
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid_b}').json['messages'][0]['content'],'B_SUPPORT')
with self.app.app_context():
connection=db.connect()
self.assertEqual(connection.execute('SELECT id FROM chat_history ORDER BY id').fetchall(),[(b,),(legacy,)])
connection.close()
def test_customer_human_routes_enforce_owner_and_role_and_hide_internal_notes(self):
a=self.conversation(self.a,'A_PRIVATE')
sid=self.human(self.a,a)
self.assertEqual(self.b.post('/api/visitor/human/request',json={'conv_id':a}).status_code,404)
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid}').status_code,404)
for action in ('send','close'):
self.assertEqual(self.b.post('/api/visitor/human/'+action,json={'sid':sid,'content':'attack'}).status_code,404)
self.assertEqual(self.a.post('/api/visitor/human/send',json={'sid':sid,'side':'agent','content':'fake agent'}).status_code,403)
self.a.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'})
response=self.a.get(f'/api/visitor/human/poll?sid={sid}')
self.assertNotIn('note',response.json)
self.assertNotIn('STAFF_PRIVATE',response.get_data(as_text=True))
self.assertEqual(self.b.post('/api/rate',json={'sid':sid,'stars':5}).status_code,404)
self.assertEqual(self.a.post('/api/rate',json=[1]).status_code,400)
self.assertEqual(self.a.post('/api/rate',json={'sid':sid,'stars':5}).status_code,200)
def test_uploads_are_private_and_legacy_files_are_not_assigned_to_new_visitors(self):
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'A_PRIVATE'),'note.txt')})
url=response.json['url']
with self.a.get(url) as downloaded:
self.assertEqual(downloaded.data,b'A_PRIVATE')
self.assertIn('no-store',downloaded.headers['Cache-Control'])
self.assertEqual(self.b.get(url).status_code,404)
self.assertEqual(self.app.test_client().get(url).status_code,404)
root=Path(self.app.config['UPLOAD_DIR'])
(root/'legacy.txt').write_text('LEGACY_PRIVATE')
self.assertEqual(self.a.get('/uploads/legacy.txt').status_code,404)
with self.a.get('/api/staff'+url) as staff:
self.assertEqual(staff.data,b'A_PRIVATE')
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
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))
lookup.assert_not_called();model.assert_not_called()
def test_missing_attachments_show_a_readable_private_page_without_disclosing_ownership(self):
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'fixture'),'missing.txt')})
url=response.json['url']
(Path(self.app.config['UPLOAD_DIR'])/url.rsplit('/',1)[1]).unlink()
own=self.a.get(url);other=self.b.get(url)
self.assertEqual(own.status_code,404)
self.assertIn('附件暂时无法访问',own.get_data(as_text=True))
self.assertIn('no-store',own.headers['Cache-Control'])
self.assertEqual(own.data,other.data)
self.assertNotIn(url,own.get_data(as_text=True))
def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self):
cid=self.conversation(self.a,'A_PRIVATE')
for key in ('short','x'*64,'1'*65,[],{},123):
self.assertEqual(self.a.post('/api/visitor/session',json={'key':key}).status_code,400)
self.assertEqual(self.a.post('/api/visitor/session',json={'key':'2'*64},headers={'Origin':'https://attacker.example'}).status_code,403)
self.assertEqual(self.a.post('/api/visitor/session',data={'key':'2'*64}).status_code,400)
self.assertEqual(self.a.post('/api/visitor/session',json={'key':'1'*64,'padding':'x'*2000}).status_code,413)
self.assertEqual(self.a.get('/api/get_history').json[0]['id'],cid)
def test_owned_support_polling_is_not_counted_as_chat_generation_and_bounds_its_cursor(self):
cid=self.conversation(self.a,'A_PRIVATE')
sid=self.human(self.a,cid)
self.app.config['RATE_LIMIT_PER_MIN']=1
self.app.extensions['rate_hits'].clear()
self.assertEqual(self.a.get('/api/get_history').status_code,200)
self.assertEqual(self.a.get('/api/get_history').status_code,429)
self.assertEqual(self.a.get(f'/api/visitor/human/poll?sid={sid}').status_code,200)
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid}').status_code,404)
self.assertEqual(self.a.get(f'/api/visitor/human/poll?sid={sid}&after=9999999999999999999999999').status_code,400)