Compare commits

..
47 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
ouyangxq 27151b3ee5 支持 Jenkins 灰度发布与独立运行数据 2026-10-29 16:25:09 +08:00
ouyangxq ab5b3580c1 支持从本地配置文件加载 DeepSeek 密钥 2026-10-29 14:42:46 +08:00
songxinnuo 2d6806b15d 加会话转接、满意度评价、会话备注、浏览器通知 2026-10-29 11:24:11 +08:00
ouyangxq 805675f8db 重构物流客服模块并完善会话接待工作台
- 拆分应用工厂、业务服务与路由,保留原有 HTTP/SSE 和存储入口
- 分离页面资源,重排客服工作台并完善响应式导航、草稿和附件交互
- 整理维护脚本、请求示例和贡献指南,移除启动脚本中的密钥配置
- 上传文件改为仅本地保留,补充隔离的后端与前端回归验证

验证:46 项 Python 测试通过,含 14 个已有子测试;12 个 Node 前端场景通过。
2026-10-29 10:59:29 +08:00
songxinnuo 761c629788 加多客服登录、导出CSV、离线留言、文件发送、输入状态 2026-10-28 15:40:09 +08:00
songxinnuo fc9897ccb2 加后台登录、图片预览发送、自动清理上传文件 2026-10-28 14:09:55 +08:00
songxinnuo c4ac4f513c 加后台登录、图片预览发送、自动清理上传文件 2026-10-28 14:05:46 +08:00
songxinnuo d8d4ee0a31 后台改成三栏客服工作台布局 2026-10-28 10:44:48 +08:00
songxinnuo 7abd0e898c 加字体大小设置,修复人工客服轮询和限流问题 2026-10-28 10:20:23 +08:00
127 changed files with 13766 additions and 4326 deletions
+30
View File
@@ -0,0 +1,30 @@
**
!requirements.txt
!am.py
!rates_extra.py
!start.bat
!启动.bat
!logistics_chat/
!logistics_chat/**
!templates/
!templates/**
!static/
!static/**
!deploy/
!deploy/**
!scripts/
!scripts/**
!tests/
!tests/**
**/__pycache__/
**/*.pyc
**/.env
**/.env.*
**/*.db
**/*.sqlite3
**/runtime.env
**/release.env
**/*.htpasswd
**/*.pem
**/*.key
**/tls/
+26
View File
@@ -0,0 +1,26 @@
# Copy this file to .env in the repository root and enter your DeepSeek API key.
# The application reads .env at startup. Restart after changing this file.
# 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
+7
View File
@@ -0,0 +1,7 @@
deploy/*.service text eol=lf
deploy/*.sh text eol=lf
deploy/*.example text eol=lf
deploy/*.yml text eol=lf
Jenkinsfile text eol=lf
Dockerfile text eol=lf
.dockerignore text eol=lf
+18 -1
View File
@@ -4,6 +4,10 @@ __pycache__/
*.pyo
.pytest_cache/
# 虚拟环境
.venv/
venv/
# 数据库(含聊天记录,不要提交)
*.db
*.db-journal
@@ -17,11 +21,24 @@ __pycache__/
# 环境变量与密钥
.env
.env.*
!.env.example # 配置示例(不含 Key),需要提交
# 配置示例(不含 Key),需要提交
!.env.example
# 运行时数据,仅保留上传目录占位文件
/logs/
/backups/
/uploads/*
!/uploads/.gitkeep
# 自签证书与私钥(本地演示用,不要提交)
cert.pem
key.pem
*.pem
*.key
*.htpasswd
runtime.env
release.env
/work/
# 系统文件
Thumbs.db
+67
View File
@@ -0,0 +1,67 @@
# Repository Guidelines
## Project Structure & Module Navigation
Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`/`static/js/` for page assets. [Architecture](docs/architecture.md) defines module contracts and extraction rules.
| 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`: 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`; `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` |
## Efficient Search & File Boundaries
Start with mapped files, then follow callers. Prefer symbols over saved line numbers. `rg --files` includes untracked source; respect ignored runtime data.
```powershell
rg --files
rg -n -F -e 'calc_freight' -e '/api/human/' logistics_chat static/js
```
Routes adapt HTTP; services compose rules, persistence, and model calls. Keep pure rules independent of Flask. Preserve URL/SSE/storage contracts and update navigation after moves.
## Build, Test, and Development Commands
Run from the repository root:
```powershell
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
Copy-Item .env.example .env # First run only; set DEEPSEEK_API_KEY inside .env.
.\.venv\Scripts\python.exe am.py
.\.venv\Scripts\python.exe -m unittest discover -s tests -v
```
`start.bat` prefers active `VIRTUAL_ENV`; verify with `start.bat --check-python`. No frontend build is required.
## Coding Style & Naming Conventions
Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, and JavaScript `camelCase`. Preserve Chinese terminology. No formatter/linter is configured.
## Testing Guidelines
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`, `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
History uses concise Chinese summaries. Keep commits focused. PRs explain behavior, validation, linked issues, and UI screenshots.
## 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`; 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.
+44
View File
@@ -0,0 +1,44 @@
ARG PYTHON_BASE_IMAGE=python:3.12-slim
FROM ${PYTHON_BASE_IMAGE} AS runtime
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=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 \
&& mkdir -p /var/lib/baiyue-logistics-chat /var/log/baiyue-logistics-chat \
&& chown 10001:10001 /var/lib/baiyue-logistics-chat /var/log/baiyue-logistics-chat
COPY am.py rates_extra.py ./
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 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=2).close()"]
CMD ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--threads=8", "--channel-timeout=300", "--max-request-body-size=12582912", "--url-scheme=https", "--call", "deploy.wsgi:create_app"]
FROM runtime AS test
COPY deploy/ ./deploy/
COPY tests/ ./tests/
COPY scripts/ ./scripts/
COPY start.bat 启动.bat ./
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.8.0
ENTRYPOINT ["certbot"]
FROM runtime AS production
Vendored
+88
View File
@@ -0,0 +1,88 @@
pipeline {
agent { label 'java-builder' }
options {
timestamps()
disableConcurrentBuilds()
skipDefaultCheckout(true)
timeout(time: 30, unit: 'MINUTES')
}
stages {
stage('Checkout selected gray revision') {
steps {
script {
if (env.JOB_BASE_NAME != 'baiyue-logistics-chat-gray' || params.ENVIRONMENT != 'gray') {
error('This pipeline is restricted to the logistics chat gray job')
}
checkout scm
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.CHAT_IMAGE = "baiyue-logistics-chat-gray:${env.SOURCE_COMMIT.take(12)}-b${env.BUILD_NUMBER}"
env.CERTBOT_IMAGE = "baiyue-logistics-chat-certbot:${env.SOURCE_COMMIT.take(12)}-b${env.BUILD_NUMBER}"
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/*.test.cjs
'''
}
}
stage('Build and test image') {
steps {
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" --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" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CERTBOT_IMAGE" .
'''
}
script {
env.CHAT_IMAGE_ID = sh(script: 'docker image inspect --format "{{.Id}}" "$CHAT_IMAGE"', returnStdout: true).trim()
env.CERTBOT_IMAGE_ID = sh(script: 'docker image inspect --format "{{.Id}}" "$CERTBOT_IMAGE"', returnStdout: true).trim()
}
}
}
stage('Deploy isolated gray service') {
steps {
lock(resource: 'baiyue-logistics-chat-gray-release') {
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}",
"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'
}
}
}
}
}
}
}
}
}
}
+183 -125
View File
@@ -8,28 +8,34 @@
- **知识库优先**:优先使用本地知识库回答,减少模型编造
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
- **运单号查询**:发送 8~20 位运单号,返回时间轴轨迹卡片
- **订单查询**:直接输入入仓码查询订单与已有轨迹,卡片主显示入仓码;兼容订单号与 Shipment ID,展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
- **异常处理**:接口超时、Key 失效、中断时给出明确提示与重试
- **会话恢复**:连接中断保留已收到的正文;切换话题取消旧附件,等待建会话时保留新草稿;历史加载失败保留已有列表
- **深色模式**:右上角切换,偏好本地保存
- **响应式**:窄屏下侧栏自动变为抽屉
- **附件预览**:发送前查看文件名或缩略图,可移除待发送附件
- **客服工作台**:默认展示会话列表与全高聊天区,统计收在「数据概览」,服务资料按需展开;支持会话搜索、多行回复和切换会话保留草稿,常用回复先填入再发送
- **手机客服接待**:会话列表与聊天整屏切换,返回列表保留草稿;回复框固定在聊天底部,可从列表或聊天打开服务资料
## 报价数据
运费试算使用 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)** 中的真实数据,覆盖 8 条渠道:
代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位:
| 渠道 | 21KG+ | 101KG+ | 500KG+ | 2000KG+ | 参考时效 |
|---|---|---|---|---|---|
| 欧洲海运快线 | 12.3 | 10.3 | 10.2 | 10.0 | 开船后 38-45 天 |
| 义乌中欧铁路 | 15.5 | 13.5 | — | 13.5(1000+) | 发车后 36-40 天 |
| 中欧卡航 | 19.4 | 17.4 | 17.4 | 17.3 | 发车后 16-20 天 |
| 欧洲空运 | 39 | 36 | — | 37(71+) | 10-13 天 |
| 英国海运 | 12.8 | 10.9 | — | — | 约 40 天 |
| 英国铁路 | 18.0 | 16.2 | — | — | 25-30 天 |
| 中英卡航 | 21.4 | 19.4 | — | — | 20-22 天 |
| 英国空运 | 48(22+) | 42 | — | 44(51+) | 8-10 天 |
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|---|---|---|
| 欧洲海运快线 | 21KG+ → 12.3;101KG+ → 10.3;500KG+ → 10.2;2000KG+ → 10.0 | 开船后 38-45 天 |
| 义乌中欧铁路 | 21KG+ → 15.5;100KG+ → 13.5;1000KG+ → 13.5 | 发车后 36-40 天 |
| 中欧卡航 | 21KG+ → 19.4;101KG+ → 17.4;500KG+ → 17.4;2000KG+ → 17.3;4000KG+ → 17.3 | 发车后 16-20 天 |
| 欧洲空运 | 21KG+ → 39;45KG+ → 38;71KG+ → 37;101KG+ → 36 | 10-13 天 |
| 英国海运 | 21KG+ → 12.8;101KG+ → 10.9;301KG+ → 10.9 | 约 40 天 |
| 英国铁路 | 21KG+ → 18.0;101KG+ → 16.2;301KG+ → 16.2 | 25-30 天 |
| 中英卡航 | 21KG+ → 21.4;101KG+ → 19.4;301KG+ → 19.4 | 20-22 天 |
| 英国空运 | 22KG+ → 48;51KG+ → 44;101KG+ → 42 | 8-10 天 |
**计费规则**(均来自报价表):
@@ -39,9 +45,11 @@
- 铁路、卡航单件计费重不足 13KG 加收超轻费 5 元/kg;空运单件不足 12KG 按 12KG 计费
- 偏远地址 +7 元/kg,单票最低 250 元
数据定义在 `am.py` 的 `RATES` 字典中,修改价格只需改这里,不需要动计算逻辑。
基础报价维护在 `logistics_chat/pricing.py` 的 `RATES` 字典中;修改价格时同步知识资料,避免调整计算逻辑。
### 扩展数据(rates_extra.py)
`calc_freight()` 的 `total` 当前仅为基础运费。超轻费、偏远费及产品附加费以说明或单独结果展示,尚未统一计入 `total`;上述业务规则不能理解为已全部自动结算。
### 扩展数据(logistics_chat/rates_extra.py)
从同一份报价表提取、目前已支持:
@@ -59,162 +67,212 @@
> ⚠️ 报价表含商业价格与联系人信息,公开发布前请脱敏或替换为示例数据。
## 运行环境
## 运行环境与启动
- Python 3.9 及以上
- Windows / macOS / Linux
项目使用 Python、Flask、SQLite 和 DeepSeek API,无需前端构建。建议使用独立虚拟环境;所有应用启动命令均在项目根目录执行。
## 快速启动(Windows)
双击 `start.bat` 即可,脚本会自动检查端口占用、API Key 和依赖。
## 安装与启动
### 1. 安装依赖
```bash
pip install -r requirements.txt
```
### 2. 配置 API Key
Key 不写进代码,通过系统环境变量提供。
Windows PowerShell:
### Windows
```powershell
setx DEEPSEEK_API_KEY "sk-你的key"
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
Copy-Item .env.example .env # 仅首次运行;已有 .env 时跳过
# 编辑 .env,填写 DEEPSEEK_API_KEY 后再启动
.\.venv\Scripts\python.exe am.py
```
设置后需要**重新打开终端**才生效,可用下面的命令确认:
上述命令固定使用项目 `.venv`,不受其他已激活环境影响。可选的一键入口 `start.bat` 按已激活的 `VIRTUAL_ENV`、项目 `.venv`、`py -3`、PATH 中的 `python` 顺序逐个验证 Python 3。使用它之前先退出不需要的旧环境,或检查诊断结果是否指向预期解释器。`启动.bat` 调用同一入口:
```powershell
echo $env:DEEPSEEK_API_KEY
.\start.bat
```
Linux / macOS:
仅检查并打印选中的解释器,不安装依赖或启动服务:
```powershell
.\start.bat --check-python
```
浏览器访问 http://127.0.0.1:5000;客服登录页为 http://127.0.0.1:5000/admin。
### Linux / macOS
```bash
export DEEPSEEK_API_KEY="sk-你的key"
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
cp .env.example .env # 仅首次运行;已有 .env 时跳过
# 编辑 .env,填写 DEEPSEEK_API_KEY 后再启动
.venv/bin/python am.py
```
Key 申请地址:https://platform.deepseek.com/api_keys
### 配置说明
也可以参考项目根目录的 **.env.example**(不含真实 Key 的配置示例文件),其中列出了需要设置的环境变量及说明。
手机语音输入:点击输入区上方的麦克风按钮,再按住说话,松开后转文字并发送;上滑或取消触摸不发送,最长 60 秒。微信和手机浏览器使用同一服务端转写链路;页面必须通过 HTTPS 或本机 localhost 打开,并允许麦克风。旧版内置浏览器不支持录音时仍可文字输入。手机订单信息默认折叠,物流轨迹优先展示。
### 3. 启动服务
在根目录 `.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 配置。
```bash
cd E:\wuliu_chat
python am.py
```
- 本地密钥放在项目根目录的 `.env` 中:首次运行复制 [`.env.example`](.env.example),填写 `DEEPSEEK_API_KEY=你的密钥`。`.env` 已被 Git 忽略,示例文件中的值应保持为空。
- 应用启动时自动读取根目录 `.env`,不依赖终端的当前目录;`am.py`、Windows 启动脚本和 WSGI 应用工厂均使用同一配置入口。修改密钥后须重启服务。
- 配置优先级为:显式传给 `create_app` 的配置 > 进程环境变量 > `.env` > 空值。环境变量即使为空也会覆盖 `.env`;本地填写后仍提示未配置时,请检查并移除旧的空环境变量。
- 部署环境仍可注入 `DEEPSEEK_API_KEY`。PowerShell 的 `$env:DEEPSEEK_API_KEY` 只影响当前终端及其子进程,双击启动脚本可直接使用 `.env`,无需配置系统环境变量。
- 密钥申请或更换可在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 完成;不要把密钥写入脚本、提交到 Git,或打印到日志。
- 当前服务监听 `0.0.0.0:5000`,局域网设备可能访问。仅供本机开发时,将 `logistics_chat/config.py` 中默认的 `HOST` 设置为 `"127.0.0.1"`。
- 历史启动脚本曾提交明文密钥。当前文件已移除该配置,但 Git 历史仍保留旧内容,应在平台作废旧密钥,并在 `.env` 或部署环境中配置新密钥。
### 4. 打开页面
### Jenkins 灰度部署
浏览器访问 http://127.0.0.1:5000
## 测试
### 单元测试
```bash
pytest -v
```
共 **52 个用例**,覆盖运费意图解析、运单号识别、阶梯价计算、卡派按方、附加费识别、岛屿邮编、卡片转换等。
测试在临时目录中运行,不会改动 `history.db`。
### 回答长度控制
- 中文回答:120~350 字;英文回答:120~700 字
- 超过上限时由代码自动压缩一次(保留全部数字),仍超标再压一次
- 提示词之外的第二道保险,确保字数可控
### 场景测试
`测试记录.md` 记录了 **29 个业务场景**的输入、预期结果、首次结果、修复动作与复测结果,
其中 26 项通过或已修复,3 项列为未解决/待验证(超长输入、并发提问、无答案兜底)。
现有灰度环境按 [Jenkins 部署指南](docs/deployment.md) 发布:构建和测试镜像,SSH 发送至专用 Compose 服务,Nginx 提供 HTTPS。客户聊天入口免登录,只有明确列出的客户 API 开放;客服后台、搜索、导出和其他未知路径继续使用访问密码。密钥由 Jenkins 注入,数据和证书保存在独立宿主机目录;启动失败时恢复之前的应用和站点。上游端口必须仅绑定回环地址。另附 [Linux 单机参考](docs/deployment-linux.md)。
## 目录结构
```
wuliu_chat/
├── am.py 主程序(路由、知识库、对话逻辑、运单查询、运费试算)
├── rates_extra.py 报价表扩展数据(卡派按方、附加费、岛屿邮编)
├── test_am.py 单元测试(52 个用例)
├── start.bat Windows 一键启动脚本
├── requirements.txt 依赖清单
├── README.md 说明文档
├── knowledge.md 知识资料(由 knowledge_list 导出)
├── 测试记录.md 场景测试记录(29 个场景)
├── .gitignore Git 忽略规则
├── .env.example 配置示例(不含 Key)
├── history.db SQLite 数据库(会话与消息)
├── static/ 静态资源
│ └── fa.jpg 网站图标
├── templates/
│ └── index.html 前端页面
└── .vscode/
└── launch.json VS Code 调试配置
```text
baiyue-logistics-chat/
├── am.py CLI/WSGI 兼容入口
├── Jenkinsfile 灰度构建、测试及受管发布
├── Dockerfile 应用、测试及证书运维镜像
├── .dockerignore 显式限制构建上下文
├── rates_extra.py 扩展数据旧导入路径的兼容转发
├── logistics_chat/
│ ├── __init__.py create_app 应用工厂
│ ├── config.py .env、环境变量与绝对路径配置
│ ├── db.py SQLite、历史持久化、备份
│ ├── knowledge.py 知识与关键词检索
│ ├── 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 登录、统计、订单、留言、导出
│ ├── file_service.py 附件与清理
│ ├── hooks.py 限流和缓存头
│ ├── runtime.py 服务启动
│ └── routes/ HTTP Blueprints
├── templates/ 客户、登录、客服工作台 HTML
├── static/
│ ├── css/ theme.css 共享主题 + chat/admin/admin_login 页面样式
│ ├── js/ 对应经典页面脚本
│ └── fa.jpg
├── scripts/ 人工会话检查/关闭 CLI
├── deploy/ 灰度 Compose、发布/回退、证书及单机参考
├── tests/ 规则、存储、HTTP/SSE、启动与维护回归
├── docs/ architecture.md、knowledge.md
├── examples/requests/ 人工接口示例
├── uploads/.gitkeep 本地附件不纳入版本控制
├── start.bat 可选 Windows 启动检查
├── 启动.bat 兼容入口,调用 start.bat
├── requirements.txt
├── .env.example
├── .gitignore
├── AGENTS.md
└── README.md
```
> `kefu.py`、`SQLite.py` 为早期遗留文件,不影响运行,可安全删除。
运行后还会生成 `history.db`、`logs/` 和 `backups/`,这些运行数据以及虚拟环境均由 Git 忽略。已经上传的本地文件在本次目录整理中保留。
## 测试
先安装项目依赖,再使用同一个项目虚拟环境运行:
```powershell
.\.venv\Scripts\python.exe -m pytest -v
```
也可执行 `.\.venv\Scripts\python.exe -m unittest discover -s tests -v`。Linux/macOS 将解释器替换为 `.venv/bin/python`。纯规则和维护 CLI 本身仅依赖标准库,整套业务测试还需要 Flask 等项目依赖。
安装 Node.js 后,Python 测试还会运行 `tests/frontend/admin.test.cjs`,验证发送失败保留草稿、重复点击、切换会话、输入法及手机抽屉;也可单独执行 `node --test tests/frontend/admin.test.cjs`,无需安装 npm 依赖。未安装 Node.js 时该项明确跳过。
覆盖范围包括配置文件加载与优先级、运费边界与单位换算、知识检索、运单识别、旧 SQLite schema 迁移、事务回滚、附件、人工状态、历史、CSV、SSE 压缩/追问/异常/取消,以及 Windows 解释器选择。Windows 启动测试在其他系统上跳过,三个页面的外置资源也由接口测试检查。
模型请求使用模拟响应,数据库和附件使用临时目录;这不代表完成真实 DeepSeek 或真实物流接口联调。旧文档提及的原始 52 个业务测试和 29 个场景记录未随仓库提供,当前结果以实际测试输出为准。
## 维护与调试
在根目录执行:
```powershell
py -3 scripts/inspect_human_sessions.py
py -3 scripts/close_active_sessions.py
```
两个脚本默认定位到仓库根目录的 `history.db`,不依赖调用时的工作目录,也可用 `--db "D:\data\history.db"` 指定已有数据库。检查脚本只读;关闭脚本将**所有 active 人工会话**标记为 closed,保留 waiting 会话和聊天消息。数据库不存在时会报错退出,不会误建空库。
人工客服接口请求示例见 [examples/requests/README.md](examples/requests/README.md)。知识资料见 [docs/knowledge.md](docs/knowledge.md),实际知识内容维护在 `logistics_chat/knowledge.py` 的 `knowledge_list` 中。
模块定位、文件归属和后续拆分规范见 [docs/architecture.md](docs/architecture.md)。该文档与当前已拆分的目录保持同步。
## 主要接口
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/` | 聊天页面 |
| GET | `/stream_chat?msg=&conv_id=` | 流式对话(SSE) |
| POST | `/api/new_chat` | 新建会话,返回 `{"id": 会话id}` |
| GET | `/` | 客户聊天页面 |
| GET / POST | `/stream_chat` | SSE 流式对话;POST JSON 接收 `msg`、`conv_id` |
| POST | `/api/new_chat` | 新建会话并保存欢迎语,返回 `id`、`welcome` |
| GET | `/api/get_history` | 会话列表 |
| GET | `/api/get_messages?conv_id=` | 单个会话的全部消息 |
| DELETE | `/api/del_history?id=` | 删除会话(级联删消息) |
| DELETE | `/api/clear_all` | 清空全部历史 |
| GET | `/api/get_messages?conv_id=` | 单个会话的消息 |
| DELETE | `/api/del_history?id=` | 删除单个会话和消息 |
| DELETE | `/api/clear_all` | 清空聊天历史 |
| POST | `/api/upload` | 上传图片、PDF、TXT、CSV,最大 10MB |
| POST | `/api/human/request` | 申请人工客服 |
| POST | `/api/human/accept` | 客服接待 |
| 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` | 导出 Excel;`?format=csv` 保留 CSV 导出 |
### 流式事件格式
人工客服每天 8:00–22:00 接受新申请,由服务端校验时间;其余时间可提交离线留言。客服工作台保留白色界面,转接后由其他客服在排队列表主动接入。页面的铃铛按钮可开启浏览器通知,页面切到后台时提示排队或新消息;实际通知能力取决于浏览器权限。
`/stream_chat` 返回 Server-Sent Events,每帧是一条 JSON:
SSE 每帧为一条 JSON,主要事件包括:
```
data: {"t": "正文片段"} 正常回答片段
data: {"e": "错误提示"} 出错时
data: {"card": {...}} 运单轨迹卡片
data: {"d": 1} 正常结束
```text
data: {"t": "正文片段"}
data: {"e": "错误提示"}
data: {"card": {...}}
data: {"replace": "精简后的全文"}
data: {"suggest": ["相关追问"]}
data: {"d": 1}
```
## 数据库
## 数据库与运行数据
SQLite,文件为 `history.db`,首次启动自动创建。
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。游客咨询无需登录,查询订单时引导客户登录;查询按服务端账号关联的客户范围过滤。`ORDER_DATA_TENANT_ID` 是额外限制,不能代替客户身份。
- `chat_history`:会话(id、title、ctime、utime)
- `messages`:消息(id、conv_id、role、content、ctime)
默认前台客户账号为 `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` 用于首次创建默认账号。
运单卡片以 `__CARD__{json}` 形式保存在 `messages.content`,前端识别后重新渲染。
登录凭据通过独立 HttpOnly Cookie 及服务端 `customer_sessions` 校验,绑定当前访客,8 小时有效;退出后撤销当前登录。登录后自动继续刚才的订单问题;含订单事实的会话在退出后隐藏,只有相同访客重新登录同一客户和范围后才能恢复。真实用户认证接入时替换账号校验/关联层,继续使用内部客户 ID、可信客户范围和已有登录会话契约;MCP 数据查询仍通过 `lookup_order` 的客户范围过滤。
## 常见问题
游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。
**页面报"服务未配置 API Key"**
环境变量没读到。确认变量名是 `DEEPSEEK_API_KEY`,并**重新打开终端**后再启动。
未发送的文字草稿按游客和会话分别保存在当前浏览器的 localStorage,当前标签页通过 sessionStorage 记录上次打开的会话;切换会话和刷新可恢复对应草稿,发送或删除会话后清理。草稿不跨设备同步,不恢复未发送的附件。浏览器禁止存储或空间不足时只在当前页面暂存,并提示刷新前保留文字;清除站点数据会同时删除草稿。历史加载失败期间禁止发送,重新选择会话或新建对话后再继续。
**页面报"API Key 无效或已过期"**
Key 已作废,去平台重新生成一把,再用 `setx` 覆盖。
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
**端口 5000 被占用**
```powershell
netstat -ano | findstr ":5000 "
taskkill /PID <进程号> /F
.\.venv\Scripts\python.exe scripts/import_order_test_data.py --snapshot "D:\private\orders.snapshot.json" --db "D:\data\history.db"
```
确认只有一行 `LISTENING`,避免两个进程同时抢端口。
**改了 am.py 没生效**
Flask 启动时加载代码,改后端必须重启服务;只改 `index.html` 则刷新页面即可。
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--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` 中的 `build_track()`。
- 服务使用 Waitress 多线程启动(8 个线程),适合内网小范围使用;面向公网仍需反向代理与 HTTPS。
- 默认只监听 `127.0.0.1`,如需局域网访问,把 `am.py` 中的 `HOST` 改为 `"0.0.0.0"`。
- 运费计费方式为**阶梯单价 × 计费重量**,价格与时效来自炜易达报价表。报价更新时只需修改 `RATES`;知识库文案在 `knowledge_list` 中,两者建议同步更新。
- 材积按 ÷6000 计算(1CBM = 167KG),与部分渠道 ÷5000 的口径不同,修改时注意核对报价表。
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
## 当前限制
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史卡片继续兼容展示。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
- 服务优先使用 Waitress 的 8 个线程;缺少 Waitress 时会回退到 Flask 开发服务器。公网部署前还需完善鉴权及部署配置。
+21 -1466
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""Linux acceptance deployment entry points."""
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
umask 077
root=${1:?}
release=${2:?}
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 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_AUTH_DIR) ;;
*) echo 'Unexpected release setting.' >&2; exit 2 ;;
esac
[[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2
printf -v "$name" '%s' "$value"
done < "$release/release.env"
[[ "$CHAT_ROOT" == "$root" && "$CHAT_RELEASE" == "$release" ]] || exit 2
[[ "$CHAT_DATA_DIR" == "$root/data" && "$CHAT_LOG_DIR" == "$root/logs" && "$CHAT_RUNTIME_ENV" == "$release/runtime.env" ]] || exit 2
[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ && "$CHAT_IMAGE_ID" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
[[ "$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
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
container=baiyue-logistics-chat-gray
if docker container inspect "$container" >/dev/null 2>&1; then
[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container")" == baiyue-logistics-chat-gray ]] || exit 2
old_port=$(docker port "$container" 5000/tcp || true)
else
old_port=''
fi
if [[ -n "$(ss -H -ltn "sport = :$HOST_PORT")" && "$old_port" != "127.0.0.1:$HOST_PORT" ]]; then
echo 'The requested host port belongs to another service.' >&2
exit 2
fi
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
[[ "$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
fi
for candidate in "$CHAT_PROXY_DIR/"*.conf; do
[[ -f "$candidate" && "$candidate" != "$site" ]] || continue
if grep -Fq "$CHAT_DOMAIN" "$candidate"; then
echo 'The requested domain is already configured by another site.' >&2
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")
[[ "$previous" == "$root"/releases/* && "$previous" != *..* && -f "$previous/release.env" ]] || exit 2
fi
[[ ! -f "$site" ]] || cp -p "$site" "$release/previous-site.conf"
[[ ! -f "$auth" ]] || cp -p "$auth" "$release/previous-site.htpasswd"
chmod 600 "$release/runtime.env"
compose() { docker compose --env-file "$1/release.env" -f "$1/compose.yml" "${@:2}"; }
activated=0
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
if [[ -f "$release/previous-site.htpasswd" ]]; then cp -p "$release/previous-site.htpasswd" "$auth"; else rm -f -- "$auth"; fi
nginx -t && nginx -s reload
fi
if ((activated)); then
if [[ -n "$previous" ]]; then
compose "$previous" up -d --wait --wait-timeout 120
else
compose "$release" stop chat
compose "$release" rm -f chat
fi
fi
echo 'Gray activation failed; previous application/proxy configuration was restored where available. Persistent data was retained.' >&2
exit "$result"
}
trap rollback ERR
if [[ -f "$root/data/history.db" ]] && docker container inspect "$container" >/dev/null 2>&1; then
docker exec "$container" python -c 'import pathlib,sqlite3,sys; p=pathlib.Path("/var/lib/baiyue-logistics-chat/backups");p.mkdir(exist_ok=True); source=sqlite3.connect("/var/lib/baiyue-logistics-chat/history.db"); target=sqlite3.connect(str(p/("pre-release-"+sys.argv[1]+".db"))); source.backup(target);target.close();source.close()' "$(basename "$release")"
fi
compose "$release" config --quiet
activated=1
compose "$release" up -d --wait --wait-timeout 120
curl --fail --silent --show-error "http://127.0.0.1:$HOST_PORT/healthz" >/dev/null
proxy_changed=1
install -m 644 "$release/site.htpasswd" "$auth"
install -m 644 "$release/site.conf" "$site"
nginx -t
nginx -s reload
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
printf 'Gray service activated at revision %s.\n' "$SOURCE_COMMIT"
+28
View File
@@ -0,0 +1,28 @@
[Unit]
Description=Baiyue Logistics Chat acceptance service
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=baiyue
Group=baiyue
WorkingDirectory=/opt/baiyue-logistics-chat
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/opt/baiyue-logistics-chat/.venv/bin/python -m waitress --listen=127.0.0.1:5000 --threads=8 --channel-timeout=300 --max-request-body-size=12582912 --url-scheme=https --call deploy.wsgi:create_app
Restart=on-failure
RestartSec=3
TimeoutStopSec=90
UMask=0077
StateDirectory=baiyue-logistics-chat
StateDirectoryMode=0700
LogsDirectory=baiyue-logistics-chat
LogsDirectoryMode=0700
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
[Install]
WantedBy=multi-user.target
+25
View File
@@ -0,0 +1,25 @@
name: baiyue-logistics-chat-gray
services:
chat:
image: "${CHAT_IMAGE:?CHAT_IMAGE is required}"
container_name: baiyue-logistics-chat-gray
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:
- "${CHAT_RUNTIME_ENV:?CHAT_RUNTIME_ENV is required}"
volumes:
- "${CHAT_DATA_DIR:?CHAT_DATA_DIR is required}:/var/lib/baiyue-logistics-chat"
- "${CHAT_LOG_DIR:?CHAT_LOG_DIR is required}:/var/log/baiyue-logistics-chat"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stop_grace_period: 90s
+177
View File
@@ -0,0 +1,177 @@
"""Validate gray release inputs and render files without logging credentials."""
import argparse
from dataclasses import dataclass
import os
from pathlib import Path, PurePosixPath
import re
import shutil
from urllib.parse import urlsplit
def absolute_path(value):
path = PurePosixPath(value)
if not re.fullmatch(r'/[A-Za-z0-9_./-]+', value) or '..' in path.parts or str(path) == '/':
raise ValueError('A deployment path is invalid')
return str(path)
@dataclass(frozen=True)
class GrayConfig:
domain: str
port: int
root: str
release: str
proxy_dir: str
certificate: str
private_key: str
commit: str
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):
origin = urlsplit(environment['PUBLIC_ORIGIN'])
if (origin.scheme != 'https' or origin.username or origin.password or origin.port not in (None, 443)
or origin.path not in ('', '/') or origin.query or origin.fragment
or not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', origin.hostname or '')):
raise ValueError('PUBLIC_ORIGIN must be an HTTPS domain without a path')
port = int(environment['HOST_PORT'])
if not 1024 <= port <= 65535:
raise ValueError('HOST_PORT must be an unprivileged TCP port')
root = absolute_path(environment['GRAY_DEPLOY_DIR'])
if PurePosixPath(root).name != 'gray-logistics-chat':
raise ValueError('The deployment must use its dedicated gray-logistics-chat directory')
commit = environment['SOURCE_COMMIT']
image = environment['CHAT_IMAGE']
image_id = environment['CHAT_IMAGE_ID']
certbot_image_id = environment['CERTBOT_IMAGE_ID']
build = environment['BUILD_NUMBER']
if not re.fullmatch(r'[0-9a-f]{40}', commit) or not re.fullmatch(r'[1-9][0-9]*', build):
raise ValueError('Source commit or build number is invalid')
if image != f'baiyue-logistics-chat-gray:{commit[:12]}-b{build}':
raise ValueError('Image coordinates must identify the selected source and build')
if any(not re.fullmatch(r'sha256:[0-9a-f]{64}', value) for value in (image_id, certbot_image_id)):
raise ValueError('Docker image ID is invalid')
return cls(origin.hostname, port, root, f'{root}/releases/{commit[:12]}-b{build}',
absolute_path(environment['GRAY_PROXY_CONF_DIR']),
absolute_path(environment['GRAY_TLS_CERT_FILE']),
absolute_path(environment['GRAY_TLS_KEY_FILE']), commit, image, image_id, certbot_image_id)
def release_environment(self):
fields = {
'CHAT_IMAGE': self.image, 'CHAT_IMAGE_ID': self.image_id,
'CHAT_CERTBOT_IMAGE': self.image.replace('baiyue-logistics-chat-gray:', 'baiyue-logistics-chat-certbot:'),
'CHAT_CERTBOT_IMAGE_ID': self.certbot_image_id,
'SOURCE_COMMIT': self.commit, 'HOST_PORT': str(self.port),
'CHAT_ROOT': self.root, 'CHAT_RELEASE': self.release,
'CHAT_DATA_DIR': self.root + '/data', 'CHAT_LOG_DIR': self.root + '/logs',
'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; guest chat is public and staff routes require authentication.
server {{
listen 80;
server_name {self.domain};
location ^~ /.well-known/acme-challenge/ {{
root /var/lib/baiyue-logistics-chat-acme;
try_files $uri =404;
}}
location / {{ return 301 https://$host$request_uri; }}
}}
server {{
listen 443 ssl;
server_name {self.domain};
ssl_certificate {self.certificate};
ssl_certificate_key {self.private_key};
ssl_protocols TLSv1.2 TLSv1.3;
auth_basic "Baiyue gray acceptance";
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};
}}
}}
'''
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')
(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')
shutil.copyfile(source / 'activate-gray.sh', directory / 'activate.sh')
shutil.copyfile(source / 'prepare-gray-tls.sh', directory / 'prepare-tls.sh')
shutil.copyfile(source / 'renew-gray-tls.sh', directory / 'renew-tls.sh')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('directory', type=Path)
args = parser.parse_args()
try:
config = GrayConfig.from_environment(os.environ)
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')
if __name__ == '__main__':
main()
+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
+16
View File
@@ -0,0 +1,16 @@
# Replace chat.example.com with your actual domain before installing.
# HTTP certificate bootstrap only; the application is not exposed here.
server {
listen 80;
listen [::]:80;
server_name chat.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
try_files $uri =404;
}
location / {
return 404;
}
}
+55
View File
@@ -0,0 +1,55 @@
# Replace every chat.example.com with your actual domain before installing.
# Install only after the certificate and .baiyue-preview.htpasswd exist.
server {
listen 80;
listen [::]:80;
server_name chat.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name chat.example.com;
ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# 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;
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: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; }
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
umask 022
root=${1:?}
release=${2:?}
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
while IFS='=' read -r name value; do
case "$name" in
CHAT_ROOT|CHAT_RELEASE|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT)
[[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2
printf -v "$name" '%s' "$value" ;;
esac
done < "$release/release.env"
[[ "$CHAT_ROOT" == "$root" && "$CHAT_RELEASE" == "$release" ]] || exit 2
[[ "$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
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"
if [[ -f "$site" ]]; then
head -n 1 "$site" | grep -Fq 'Managed for baiyue-logistics-chat-gray' || exit 2
else
for candidate in "$CHAT_PROXY_DIR/"*.conf; do
[[ -f "$candidate" ]] || continue
if grep -Fq "$CHAT_DOMAIN" "$candidate"; then echo 'Domain already belongs to another site.' >&2; exit 2; fi
done
cat > "$release/bootstrap.conf" <<BOOTSTRAP
# Managed for baiyue-logistics-chat-gray; HTTP certificate bootstrap only.
server {
listen 80;
server_name $CHAT_DOMAIN;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/baiyue-logistics-chat-acme;
try_files \$uri =404;
}
location / { return 404; }
}
BOOTSTRAP
install -m 644 "$release/bootstrap.conf" "$site"
if ! nginx -t; then rm -f -- "$site"; exit 2; fi
nginx -s reload
fi
install -d -m 755 /var/lib/baiyue-logistics-chat-acme
install -d -m 700 "$root/tls" "$root/tls/config" "$root/tls/work" "$root/tls/logs"
if [[ ! -f "$CHAT_TLS_CERT" ]] || ! openssl x509 -in "$CHAT_TLS_CERT" -checkend 604800 -noout >/dev/null; then
docker run --rm --name baiyue-logistics-chat-certbot-issue \
-v "$root/tls/config:/etc/letsencrypt" -v "$root/tls/work:/var/lib/letsencrypt" \
-v "$root/tls/logs:/var/log/letsencrypt" -v /var/lib/baiyue-logistics-chat-acme:/webroot \
"$CHAT_CERTBOT_IMAGE" certonly --non-interactive --webroot -w /webroot \
--cert-name "$CHAT_DOMAIN" -d "$CHAT_DOMAIN" --agree-tos --register-unsafely-without-email
fi
openssl x509 -in "$CHAT_TLS_CERT" -checkhost "$CHAT_DOMAIN" -noout >/dev/null
install -m 700 "$release/renew-tls.sh" "$root/tls/renew.sh"
unit=/etc/systemd/system/baiyue-logistics-chat-tls-renew.service
timer=/etc/systemd/system/baiyue-logistics-chat-tls-renew.timer
for file in "$unit" "$timer"; do
if [[ -e "$file" ]]; then head -n 1 "$file" | grep -Fq 'Managed for baiyue-logistics-chat-gray' || exit 2; fi
done
cat > "$unit" <<UNIT
# Managed for baiyue-logistics-chat-gray
[Unit]
Description=Renew logistics chat gray certificate
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/bin/bash $root/tls/renew.sh $root $CHAT_DOMAIN $CHAT_CERTBOT_IMAGE
UNIT
cat > "$timer" <<TIMER
# Managed for baiyue-logistics-chat-gray
[Unit]
Description=Daily logistics chat gray certificate renewal
[Timer]
OnCalendar=daily
RandomizedDelaySec=6h
Persistent=true
[Install]
WantedBy=timers.target
TIMER
chmod 644 "$unit" "$timer"
systemctl daemon-reload
systemctl enable --now baiyue-logistics-chat-tls-renew.timer
printf 'Gray certificate is ready; dedicated renewal timer enabled.\n'
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
umask 077
: "${TARGET_SSH_KEY:?}" "${TARGET_SSH_USER:?}" "${TARGET_SSH_HOST:?}" "${TARGET_SSH_KNOWN_HOSTS:?}"
: "${PREVIEW_USERNAME:?}" "${PREVIEW_PASSWORD:?}" "${CHAT_IMAGE:?}" "${CHAT_IMAGE_ID:?}" "${CERTBOT_IMAGE:?}"
[[ "$PREVIEW_USERNAME" =~ ^[A-Za-z0-9_.@-]+$ ]] || exit 2
[[ "$TARGET_SSH_USER" =~ ^[A-Za-z0-9_.-]+$ ]] || exit 2
[[ "$TARGET_SSH_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || exit 2
temporary=$(mktemp -d)
cleanup() { rm -rf -- "$temporary"; }
trap cleanup EXIT
python3 -m deploy.gray_config "$temporary/release"
release=$(awk -F= '$1 == "CHAT_RELEASE" {print $2}' "$temporary/release/release.env")
root=$(awk -F= '$1 == "CHAT_ROOT" {print $2}' "$temporary/release/release.env")
python3 - <<'TARGET'
import os
import socket
from urllib.parse import urlsplit
def addresses(host):
return {item[4][0] for item in socket.getaddrinfo(host, 443, socket.AF_INET, socket.SOCK_STREAM)}
if not addresses(urlsplit(os.environ['PUBLIC_ORIGIN']).hostname) & addresses(os.environ['TARGET_SSH_HOST']):
raise SystemExit('The public domain does not resolve to the managed gray target.')
TARGET
printf '%s:' "$PREVIEW_USERNAME" > "$temporary/release/site.htpasswd"
printf '%s\n' "$PREVIEW_PASSWORD" | openssl passwd -apr1 -stdin >> "$temporary/release/site.htpasswd"
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"
run_ssh() { ssh "${ssh_options[@]}" "$target" "$@"; }
# Only an owned, dedicated directory may receive releases or runtime data.
run_ssh bash -s -- "$root" "$release" <<'PREPARE'
set -Eeuo pipefail
root=$1
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 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
fi
if [[ -f "$root/.project" ]]; then
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
fi
install -d -m 700 "$root" "$root/releases" "$root/data" "$root/logs"
printf 'baiyue-logistics-chat-gray\n' > "$root/.project"
chown 10001:10001 "$root/data" "$root/logs"
[[ ! -e "$release" ]] || { echo 'Release directory already exists; inspect its state before retrying.' >&2; exit 2; }
install -d -m 700 "$release"
PREPARE
# 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"
# Verify the public TLS endpoint and the actual acceptance password without putting it in arguments.
python3 - <<'VERIFY'
import json
import os
from urllib.request import HTTPBasicAuthHandler, HTTPPasswordMgrWithDefaultRealm, build_opener
origin = os.environ['PUBLIC_ORIGIN'].rstrip('/')
manager = HTTPPasswordMgrWithDefaultRealm()
manager.add_password(None, origin, os.environ['PREVIEW_USERNAME'], os.environ['PREVIEW_PASSWORD'])
opener = build_opener(HTTPBasicAuthHandler(manager))
with opener.open(origin + '/healthz', timeout=30) as response:
assert response.status == 200 and json.load(response) == {'status': 'ok'}
print('Authenticated gray HTTPS health check passed.')
VERIFY
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
/usr/sbin/nginx -t
/usr/bin/systemctl reload nginx
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
root=${1:?}
domain=${2:?}
image=${3:?}
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$domain" =~ ^[a-z0-9.-]+$ && "$image" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
certificate="$root/tls/config/live/$domain/fullchain.pem"
previous=$(sha256sum "$certificate")
docker run --rm --name baiyue-logistics-chat-certbot-renew \
-v "$root/tls/config:/etc/letsencrypt" -v "$root/tls/work:/var/lib/letsencrypt" \
-v "$root/tls/logs:/var/log/letsencrypt" -v /var/lib/baiyue-logistics-chat-acme:/webroot \
"$image" renew --non-interactive --cert-name "$domain"
if [[ "$(sha256sum "$certificate")" != "$previous" ]]; then nginx -t; nginx -s reload; fi
+19
View File
@@ -0,0 +1,19 @@
"""Keep Linux service data outside the read-only application checkout."""
from pathlib import Path
from logistics_chat import create_app as create_application
DATA_DIR = Path('/var/lib/baiyue-logistics-chat')
LOG_DIR = Path('/var/log/baiyue-logistics-chat')
def create_app():
return create_application({
'DATABASE': DATA_DIR / 'history.db',
'UPLOAD_DIR': DATA_DIR / 'uploads',
'BACKUP_DIR': DATA_DIR / 'backups',
'LOG_DIR': LOG_DIR,
'HOST': '127.0.0.1',
})
+133
View File
@@ -0,0 +1,133 @@
# 模块导航与拆分规范
四阶段拆分已落地:`am.py` 保留命令及 WSGI 兼容入口,应用工厂和业务代码位于 `logistics_chat/`,页面样式与脚本位于 `static/`。本文描述当前结构及后续结构调整约定。
## 先定位,再阅读
从 [AGENTS.md](../AGENTS.md) 选择文件,以函数、常量、接口为锚点,搜索后只读取相关实现和调用方。模块移动时更新索引,不保存易失效的行号。默认遵守 Git 忽略规则,排除附件、数据库、日志、备份、虚拟环境和工作草稿。
```powershell
rg --files
rg -n -F 'calc_freight' logistics_chat/pricing.py
rg -n -F '/api/human/' logistics_chat/routes static/js examples
rg -n '^(def |class | def )' scripts tests -g '*.py'
```
PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整行很长,搜索时加 `--max-columns 200 --max-columns-preview`,避免输出整份数据。
## 当前模块索引
| 业务范围 | 实际文件与搜索锚点 | 联动位置 |
|---|---|---|
| 应用入口与配置 | `am.py`:`main`、延迟访问的 `app`;`logistics_chat/__init__.py`:`create_app`;`config.py`:`default_config`、`get_setting`;`runtime.py`:`serve_app` | `start.bat`、`.env.example` |
| Jenkins 灰度部署 | `Jenkinsfile`、`Dockerfile`;`deploy/gray_config.py`:`GrayConfig`;`publish-gray.sh`、`activate-gray.sh`、`prepare-gray-tls.sh`、`renew-gray-tls.sh` | [部署指南](deployment.md);独立持久化数据、证书和回环端口;`wsgi.py` 提供容器内路径 |
| 请求钩子 | `logistics_chat/hooks.py`:`rate_limit`、`no_cache_html` | 工厂注册钩子;限流状态在 `app.extensions['rate_hits']` |
| SQLite 与维护 | `logistics_chat/db.py`:`connect`、`close_connections`、`init_db`、`backup_db`、`save_message`、`last_user_question`、`load_context` | 维护 CLI 在 `scripts/`;旧会话自动清理仍未启用 |
| 历史接口 | `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`:`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`;人工申请每天 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`;客户与客服脚本先加载 `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/`。
## 当前文件布局
```text
am.py # CLI;显式请求 am.app 时创建默认 WSGI 应用
rates_extra.py # 旧导入路径的兼容转发
logistics_chat/
├── __init__.py # create_app 与显式运行时初始化
├── config.py # 根目录 .env、环境变量、稳定根路径、服务/资源配置
├── db.py # 连接、schema、历史持久化、备份
├── knowledge.py # 知识与关键词检索
├── 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 # 反馈、评价、统计、登录、订单、留言、历史搜索、Excel/CSV
├── file_service.py # 附件校验、保存、清理
├── hooks.py # 限流及 HTML/SSE 缓存头
├── runtime.py # Waitress / Flask 启动
└── routes/
├── __init__.py # Blueprint 注册
├── chat.py
├── history.py
├── human.py
├── admin.py
├── business.py
├── files.py
└── pages.py
templates/ # HTML/Jinja 及原有事件属性
static/css/ # theme.css 共享令牌;chat/admin/admin_login 页面样式
static/js/ # chat.js、admin.js、admin_login.js,经典脚本
scripts/ # 可独立运行的维护 CLI
tests/ # 按业务模块组织的回归
```
## 模块边界与依赖方向
- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。
- `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。
- `init_db` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。
- 所有模型 HTTP 经 `ai_client.post_completion`。保留流式问答 `(5, 90)`、压缩 `(5, 30)`、追问 `(5, 15)` 三组连接/读取超时;不能因共用客户端而合并期限。流结束或取消时释放 HTTP 响应。
- 模块导入不建库、不备份、不清理附件、不访问模型。`create_app` 默认显式初始化运行目录、数据库、备份与旧附件清理;`INITIALIZE_RUNTIME=False` 可禁止这些动作,此时调用方负责提供已有 schema。访问兼容入口 `am.app` 视为显式创建应用。
- `config.ROOT_DIR` 指向仓库根目录,模板/静态目录显式提供给 Flask。数据库、上传、备份、日志使用配置中的绝对路径,避免包文件移动改变数据位置。
- `default_config` 用 `python-dotenv` 读取根目录 `.env`,不修改 `os.environ`;只读取已支持的 `DEEPSEEK_API_KEY`,不展开 `${...}`。显式工厂配置优先于进程环境(含空值),再使用文件值。配置在应用创建时确定,修改后重启;真实 `.env` 不纳入 Git。
- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 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 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
## 后续结构调整顺序
| 阶段 | 范围 | 验收要求 |
|---|---|---|
| 1. 纯规则 | 知识、报价、运费、运单识别 | 边界结果与迁移前一致;固定时钟比较模拟轨迹;导入无运行数据副作用 |
| 2. 数据访问 | 配置、SQLite、备份、附件 | 临时目录验证 schema 兼容、事务、保留/清理时机和跨目录 CLI |
| 3. 服务/路由 | 模型、聊天、人工、应用工厂、Blueprint | URL/方法/状态/JSON/SSE 不变;保留限流、HTML/SSE 缓存头及根入口 |
| 4. 前端 | 模板与 CSS/JS | 仅迁移时对照原资源内容;主动改版时验证语法、资源 URL、加载顺序、响应式和客户/客服页交互 |
每阶段完成相关验证再推进依赖阶段。鉴权、缺表和计费的业务修复单独说明,避免在结构调整中顺带改行为。
## 必须保留的接口与数据约定
- 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,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- 手机语音由 `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 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录和订单关联尚未实现,游客身份不能升级为订单权限。
## 验证与维护
先按 README 安装项目依赖。Windows 使用 `.\.venv\Scripts\python.exe -m pytest -v` 或同解释器的 `-m unittest discover -s tests -v`;Linux/macOS 使用 `.venv/bin/python`。业务测试依赖 Flask,不能用未安装依赖的全局 Python 运行整套测试。模型请求全部模拟,不代表真实 DeepSeek 联调。
路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
+154
View File
@@ -0,0 +1,154 @@
# Linux 单机手动部署参考
现行灰度环境使用 [Jenkins 部署流程](deployment.md)。本页保留独立主机的安装方法,不用于覆盖已有灰度服务。
适用场景:Ubuntu / Debian、已有域名。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx(客户白名单公开,客服及其他路径需密码)→ 本机 Waitress → Flask / SQLite。
游客历史、人工会话和附件已按访客归属隔离;客户账号登录和订单关联尚未接入,匿名不能查询真实订单。必须沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
## 1. 域名与网络
- 将验收子域名的 A 记录解析到服务器公网 IPv4;只有正确配置服务器 IPv6 后才添加 AAAA。
- 服务器安全组和防火墙允许 TCP 80、443,保留实际使用的 SSH 管理端口。不要开放应用端口 5000。
- 服务器需要能够出站访问 Python 包源及 `https://api.deepseek.com`。
- 下文的 `chat.example.com` 均需替换成真实域名,包括 Nginx 配置内的证书路径。
## 2. 上传源码并安装依赖
首次部署使用 `baiyue-logistics-chat-linux-acceptance.tar.gz`。部署包包含本次工作区的部署文件和缺表修复,不含 `.git`、`.env`、虚拟环境、日志、数据库或本机附件。之前 Git 推送因认证失败未完成;若改用 Git 拉取,须先确认远端版本包含这些变更。
将部署包上传到服务器 `/tmp/`,以下命令均在服务器执行。首次部署假设 `/opt/baiyue-logistics-chat` 尚不存在;已有安装请使用文末更新流程。
```bash
sudo apt update
sudo apt install -y python3 python3-venv python3-pip nginx apache2-utils certbot
python3 --version
sudo tar --no-same-owner -xzf /tmp/baiyue-logistics-chat-linux-acceptance.tar.gz -C /opt
sudo adduser --system --group --home /nonexistent --no-create-home baiyue
sudo chown root:baiyue /opt/baiyue-logistics-chat
sudo chmod 750 /opt/baiyue-logistics-chat
cd /opt/baiyue-logistics-chat
sudo python3 -m venv .venv
sudo .venv/bin/python -m pip install -r requirements.txt
```
若系统已有专用 `baiyue` 用户,跳过 `adduser`。代码与虚拟环境归 root 所有;服务数据另存到 `/var/lib/`,无需让 Web 进程修改源码。
## 3. 配置 DS 密钥
仅首次复制配置,已有 `.env` 时跳过复制;不要把本机配置打进部署包。
```bash
sudo install -o root -g baiyue -m 640 .env.example .env
sudoedit /opt/baiyue-logistics-chat/.env
```
在编辑器内填写 `DEEPSEEK_API_KEY=你的密钥`,无需加 `export`,不要在终端命令或日志中输出密钥。文件由应用工厂读取;本方案的 systemd 单元不再注入同名环境变量。更换密钥后重启服务。
## 4. 启动应用服务
```bash
sudo install -m 644 deploy/baiyue-logistics-chat.service /etc/systemd/system/baiyue-logistics-chat.service
sudo systemd-analyze verify /etc/systemd/system/baiyue-logistics-chat.service
sudo systemctl daemon-reload
sudo systemctl enable --now baiyue-logistics-chat
sudo systemctl status baiyue-logistics-chat --no-pager
curl -I http://127.0.0.1:5000/
```
本机检查应返回 HTTP 200。服务使用独立系统用户、单进程 8 线程并仅监听 `127.0.0.1:5000`,不走 `am.py` 的 Flask 开发服务器回退。单进程保留当前应用的内存输入状态和限流行为;暂不配置多进程或多个副本。
systemd 自动创建并授权以下目录,重启与更新源码不会替换其中的数据:
| 内容 | 服务器路径 |
|---|---|
| SQLite | `/var/lib/baiyue-logistics-chat/history.db` |
| 上传附件 | `/var/lib/baiyue-logistics-chat/uploads/` |
| 启动备份 | `/var/lib/baiyue-logistics-chat/backups/` |
| 应用日志 | `/var/log/baiyue-logistics-chat/app.log` |
| 密钥配置 | `/opt/baiyue-logistics-chat/.env` |
首次启动创建八张业务表,不插入演示数据、默认客服账号或密码。已有表和记录保持兼容。应用当前仍会在启动时清理超过 7 天的上传文件,并执行启动备份;启动备份不能替代定时备份。
## 5. 申请 HTTPS 证书
先安装只提供证书验证路径的 HTTP 配置,此时不会转发到应用:
```bash
sudo install -d -m 755 /var/www/letsencrypt
sudo install -m 644 deploy/nginx-bootstrap.conf.example /etc/nginx/sites-available/baiyue-logistics-chat
sudoedit /etc/nginx/sites-available/baiyue-logistics-chat
sudo ln -s /etc/nginx/sites-available/baiyue-logistics-chat /etc/nginx/sites-enabled/baiyue-logistics-chat
sudo nginx -t
sudo systemctl enable --now nginx
sudo systemctl reload nginx
sudo certbot certonly --webroot -w /var/www/letsencrypt --cert-name chat.example.com -d chat.example.com
```
编辑配置时替换域名,申请证书时也替换两处域名;按 Certbot 提示填写邮箱和确认条款。若申请失败,先核对 DNS 和外网 80 端口,保留引导配置排查,暂不安装下一步需要证书的 TLS 配置。
## 6. 设置访问密码并开放 HTTPS 验收
创建验收账号 `preview`,密码由命令交互输入,不写入项目配置。`-c` 仅首次创建密码文件时使用;以后修改密码应去掉 `-c`。
```bash
sudo htpasswd -cB /etc/nginx/.baiyue-preview.htpasswd preview
sudo chown root:www-data /etc/nginx/.baiyue-preview.htpasswd
sudo chmod 640 /etc/nginx/.baiyue-preview.htpasswd
sudo install -m 644 deploy/nginx.conf.example /etc/nginx/sites-available/baiyue-logistics-chat
sudoedit /etc/nginx/sites-available/baiyue-logistics-chat
sudo nginx -t
sudo systemctl reload nginx
```
替换 TLS 配置中所有域名及证书路径;`nginx -t` 失败时先修正,不要继续 reload。模板的访问密码覆盖页面、附件、静态资源和全部 API,后端 5000 端口继续只对本机开放。不能只在导航层隐藏后台页面。
Nginx 已关闭代理响应缓冲及压缩,并将等待上游数据的超时设为 300 秒,供 AI SSE 分段输出;上传入口允许 12 MiB 请求体以容纳应用 10 MiB 文件上限外的表单开销。
## 7. 验收和证书续期
```bash
curl -I https://chat.example.com/
curl -I https://chat.example.com/api/export_chat
curl -u preview -I https://chat.example.com/
```
前两条应返回 401,证明页面和数据接口都受保护;第三条交互输入访问密码后应返回 200。浏览器也会先弹出访问密码:
- 客户页:`https://chat.example.com/`,发送消息检查 DS 实际回复及流式显示。
- 验收工作台:`https://chat.example.com/admin/dashboard`,从客户页申请人工,再检查接待和双向消息。
- 当前未预置应用内客服登录账号;Nginx 的 `preview` 是客服及受保护接口的访问账号,不是 `/admin` 页里的客服账号,客户首页无需此账号。
安装证书续期后的 Nginx 重载钩子,并检查续期定时器:
```bash
sudo install -d -m 755 /etc/letsencrypt/renewal-hooks/deploy
sudo install -m 755 deploy/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/baiyue-reload-nginx
sudo systemctl enable --now certbot.timer
sudo certbot renew --dry-run --run-deploy-hooks
```
最终配置保留 HTTP 的 `/.well-known/acme-challenge/` 路径,供 webroot 续期使用;其他 HTTP 请求跳转到 HTTPS。不要删除验证路径或只对自己的 IP 开放证书验证端口,否则后续续期会失败。
## 维护、更新和问题定位
```bash
sudo journalctl -u baiyue-logistics-chat -n 100 --no-pager
sudo tail -n 100 /var/log/baiyue-logistics-chat/app.log
sudo systemctl restart baiyue-logistics-chat
sudo nginx -t
```
- **502**:先查应用状态、journal 日志、依赖安装及 5000 端口本机检查。
- **一直弹访问密码**:检查 Nginx 密码文件和 `www-data` 读权限;不要用应用内客服密码替代验收密码。
- **AI 无回复**:确认服务器 `.env` 已填写并重启,检查服务器能否访问 DS;本机验收成功不代表服务器网络已验证。
- **输出集中到最后**:检查实际生效的 Nginx 配置和 CDN 是否重新启用了缓冲。
- **429**:目前反向代理后的请求共享应用默认限流桶,适合单人验收;多人开放前需正确配置可信代理和业务限流。
更新前停止应用并备份 `/var/lib/baiyue-logistics-chat/`,另行保护 `.env`。源码更新只替换项目文件,不复制本机数据库或 `work/`,不覆盖服务器 `.env`;按需重新安装依赖后重启。若修改 systemd 单元,重新安装单元并执行 `daemon-reload`;若修改 Nginx,先检查再 reload。回退源码时仍保留数据副本,本项目尚无数据库降级迁移工具。
本地已通过应用工厂和全新数据库回归;目标服务器的 `systemd-analyze verify`、`nginx -t`、DNS、证书签发及真实 DS 请求仍需在部署时验证。
## 参考
工厂调用与监听参数依据 [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)。
+109
View File
@@ -0,0 +1,109 @@
# Jenkins 灰度部署
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录与订单关联尚未接入,匿名查单不会读取订单数据。
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
## Jenkins 任务
任务名为 `baiyue-logistics-chat-gray`。定义选择 Pipeline script from SCM,本项目 Git 地址为仓库,分支字段为 `${SOURCE_REF}`,脚本路径为 `Jenkinsfile`。公开仓库可匿名读取;转为私有后配置只读 SCM 凭据。
`java-builder` 标签代理须有 Docker、Bash、Python 3、Node.js 18+、OpenSSL、SSH,并能下载所选 Python 基础镜像和 PyPI 依赖。标签不表示固定物理节点。
以下参数由私有灰度配置提供,实际地址和凭据值不写入源码:
| 参数 | 含义 |
|---|---|
| `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` |
| `GRAY_PROXY_CONF_DIR` | 宿主机 Nginx 实际加载的站点目录 |
| `GRAY_TLS_CERT_FILE` / `GRAY_TLS_KEY_FILE` | 本服务独立证书和私钥路径 |
| `SSH_CREDENTIAL_ID` | SSH 用户与私钥凭据引用 |
| `SSH_HOST_CREDENTIAL_ID` | SSH 目标的 Secret text 引用 |
| `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. 构建完成后才绑定运行凭据。发布脚本校验路径、域名和源提交,生成私有配置并发送镜像及发布文件。服务器验证镜像归档的 SHA-256 后加载,记录目标主机的镜像 ID。
5. 首次签发域名证书,等待应用健康,再安装本服务的 Nginx 配置。已有业务的容器和站点配置不参与本次发布。
6. Jenkins 使用验收账号验证公网 HTTPS `/healthz`;之后在浏览器验证 AI 流式回复、上传、转人工和工作台。
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,不依赖 `latest`。Docker 的 classic 与 containerd 镜像存储可能返回不同 ID;跨主机以归档校验和与源提交标签核验传输,激活前再与 `loaded-image-ids.env` 中的目标 ID 比较,拒绝加载后被替换的镜像。归档加载后删除,构建机 ID 保留在 `release.env` 供追溯。
## 数据与失败回退
`GRAY_DEPLOY_DIR` 如 `/data/services/gray-logistics-chat`,包含:
| 路径 | 内容 |
|---|---|
| `.project` | 服务所有权标记,拒绝复用未知目录 |
| `data/` | SQLite、附件和备份,跨发布保留 |
| `logs/` | 应用日志 |
| `releases/<commit12>-b<build>/` | Compose、镜像坐标、私有运行配置和站点候选 |
| `current-release` | 已通过应用及入口检查的发布目录 |
| `tls/` | 独立 ACME 账号、证书、日志和续期脚本 |
模型密钥写入权限为 600 的 `runtime.env`,由 Compose 注入进程。验收密码只在 Jenkins 生成 Nginx 哈希,服务器保存哈希文件。
密码哈希位于独立的 `/var/lib/baiyue-logistics-chat-gray-proxy/access.htpasswd`,目录 755、文件 644,供 Nginx 工作进程读取;不放在可能只有 root 可访问的宝塔站点配置目录,也不放明文密码。该目录有本服务所有权标记,不复用未知目录。
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
新环境自动创建业务表,不创建默认客服账号或演示数据。专用访问密码用于客服工作台及受保护接口;Nginx 账号不是应用内客服账号。客户首页无需密码,客户端先自动建立访客会话,再访问自己的历史。
## HTTPS 及续期
域名须解析到已核实的灰度入口,公网 80、443 端口可达。其他域名的证书不能直接用于新域名。
独立 Certbot 镜像通过 HTTP-01 webroot 签发证书;首次只新增本服务的 HTTP 验证站点,业务接口尚不公开。验证目录为 `/var/lib/baiyue-logistics-chat-acme`,最终配置持续保留该 HTTP 路径。
证书参数须指向:
```text
<GRAY_DEPLOY_DIR>/tls/config/live/<域名>/fullchain.pem
<GRAY_DEPLOY_DIR>/tls/config/live/<域名>/privkey.pem
```
ACME 账号和私钥仅保存在服务器私有目录,当前使用无邮箱账号。专用 `baiyue-logistics-chat-tls-renew.timer` 每日检查本服务证书,内容变化后检查并重载 Nginx,不接管其他证书任务。
## 排查
先从 Jenkins 构建记录核对阶段及源 SHA。服务器检查:
```bash
docker ps --filter name=baiyue-logistics-chat-gray
docker logs --tail 100 baiyue-logistics-chat-gray
systemctl status baiyue-logistics-chat-tls-renew.timer --no-pager
nginx -t
```
- 构建失败:核对镜像和 PyPI 网络,不跳过测试发布。
- SSH 失败:核对受管凭据和 known_hosts,不禁用指纹检查。
- 证书失败:核对 DNS 和 HTTP 验证路径,保留本服务的验证站点与私有 ACME 状态。
- 容器不健康:检查独立目录的 UID 10001 权限、日志和 `/healthz`;健康检查不计入业务限流。
- 客户首页应匿名返回 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)。
+2 -2
View File
@@ -1,8 +1,8 @@
# 柏悦智能物流客服系统 · 知识资料
> 本文件由 `am.py` 的 `knowledge_list` 自动导出,供评审与知识维护使用。
> 本文件整理了 [logistics_chat/knowledge.py](../logistics_chat/knowledge.py) 中的 `knowledge_list`,供评审与知识维护使用。
> 数据来源:炜易达 VIP 10月15日9点执行报价表。
> 修改知识内容请直接编辑 `am.py` 中的 `knowledge_list`,改完后重新导出本文件。
> 修改知识内容请编辑 `logistics_chat/knowledge.py` 中的 `knowledge_list`,并同步更新本文件;程序不会直接读取此文档。
---
+20
View File
@@ -0,0 +1,20 @@
# 人工客服接口请求示例
这些 JSON 文件用于本地手动调试。示例中的 `conv_id`、`sid` 均须替换成接口实际返回的 ID。
| 文件 | 方法与接口 | 用途 |
|---|---|---|
| `human_request.json` | POST `/api/human/request` | 为已有聊天会话申请人工客服 |
| `human_accept.json` | POST `/api/human/accept` | 客服接待排队中的人工会话 |
| `human_send.json` | POST `/api/human/send` | 客服向已接待会话发送消息 |
| `human_request_invalid.json` | POST `/api/human/request` | `conv_id=0` 的无效请求示例,预期 HTTP 400 |
在项目根目录执行以下 PowerShell 命令,仅申请人工会话:
```powershell
Invoke-RestMethod -Uri 'http://127.0.0.1:5000/api/human/request' `
-Method Post -ContentType 'application/json; charset=utf-8' `
-InFile 'examples/requests/human_request.json'
```
先通过 POST `/api/new_chat` 获取 `conv_id`,再申请人工客服取得 `session_id`,后续请求中的 `sid` 使用该值。接口当前没有完整登录鉴权,请仅在受控的开发环境调试。
+3
View File
@@ -0,0 +1,3 @@
{
"sid": 1
}
+3
View File
@@ -0,0 +1,3 @@
{
"conv_id": 1
}
@@ -0,0 +1,3 @@
{
"conv_id": 0
}
+5
View File
@@ -0,0 +1,5 @@
{
"sid": 1,
"side": "agent",
"content": "您好,请问需要咨询什么?"
}
-1
View File
@@ -1 +0,0 @@
{"conv_id":295}
+52
View File
@@ -0,0 +1,52 @@
"""Application factory; package imports do not initialize storage or networking."""
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 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)
if app.config['INITIALIZE_RUNTIME']:
if not app.testing:
from flask.logging import default_handler
log_directory = Path(app.config['LOG_DIR'])
log_directory.mkdir(parents=True, exist_ok=True)
log_path = str((log_directory/'app.log').resolve())
app.logger = logging.getLogger('logistics_chat.' + log_path)
app.logger.setLevel(logging.INFO)
if default_handler not in app.logger.handlers:
app.logger.addHandler(default_handler)
if not any(getattr(handler, 'baseFilename', None) == log_path for handler in app.logger.handlers):
handler = logging.FileHandler(log_path, encoding='utf-8')
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
app.logger.addHandler(handler)
with app.app_context():
db.init_db()
db.backup_db()
file_service.cleanup_uploads()
return app
+69
View File
@@ -0,0 +1,69 @@
"""DeepSeek HTTP calls; callers retain their streaming/compression/followup deadlines."""
import json
import re
import requests
from requests.exceptions import RequestException, Timeout
from .config import get_setting
def post_completion(body, *, timeout, stream=False):
return requests.post(
get_setting('MODEL_URL'),
headers={'Authorization': f"Bearer {get_setting('DEEPSEEK_API_KEY')}",
'Content-Type': 'application/json'},
json=body, stream=stream, timeout=timeout,
)
EN_COMPRESS_RULE = '原文是英文,压缩后的结果必须仍然使用英文(English),不得翻译成中文。'
def has_chinese(t):
return any(('一' <= ch <= '龥' for ch in t))
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))
if r.status_code == 200:
short = r.json()['choices'][0]['message']['content'].strip()
print(f'[压缩] {len(text)} 字 -> {len(short)} 字')
return short
print(f'[压缩失败] 状态码 {r.status_code}')
except Exception as e:
print(f'[压缩异常] {e}')
finally:
if r is not None:
r.close()
return text
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))
if r.status_code != 200:
return []
text = r.json()['choices'][0]['message']['content'].strip()
m = re.search('\\[.*\\]', text, re.S)
if m:
arr = json.loads(m.group(0))
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 []
+152
View File
@@ -0,0 +1,152 @@
"""Feedback, reporting, login, orders and offline tickets."""
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 (?,?,?,?)',
(question[:500], answer[:2000], vote, now_str()))
conn.commit()
conn.close()
return {'ok': True}
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 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,
'rating_avg': round(rating_avg, 1), 'rating_count': rating_count}
def my_orders(phone):
if not phone:
return {'orders': []}
conn = connect()
rows = conn.execute('SELECT track_no, status, created FROM orders WHERE phone=? ORDER BY id DESC', (phone,)).fetchall()
conn.close()
return {'orders': [{'track_no': r[0], 'status': r[1], 'created': r[2]} for r in rows]}
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(?,?,?,?)',
(content.strip(), phone.strip(), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
conn.commit()
conn.close()
return {'ok': True}
def list_tickets():
conn = connect()
rows = conn.execute("SELECT id, content, phone, ctime FROM tickets WHERE status='pending' ORDER BY id DESC").fetchall()
conn.close()
return [{'id': r[0], 'content': r[1], 'phone': r[2], 'ctime': r[3]} for r in rows]
def admin_login(data):
u = data.get('username', '')
p = data.get('password', '')
conn = connect()
row = conn.execute('SELECT name FROM agents WHERE username=? AND password=?', (u, p)).fetchone()
conn.close()
if row:
return {'ok': True, 'name': row[0]}
return ({'ok': False}, 401)
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 io
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()
+283
View File
@@ -0,0 +1,283 @@
"""Compose knowledge, freight and model output into the existing SSE protocol."""
import json
import re
from . import ai_client
from .ai_client import compress_answer, gen_followups, has_chinese
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,
is_shipping_destination_reply)
from .tracking import CARD_PREFIX
from .order_data import answer_query, extract_reference
def sse(payload):
"""统一的事件格式:data: {"t":正文} / {"e":错误} / {"card":卡片} / {"d":1结束}
JSON 编码可以避免正文里的换行把 SSE 帧撑断。"""
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 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)
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)
freight_text = None
if ship['weight'] or ship['dims']:
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 and ship['region'] != 'uk':
cbm = float(cbm_m.group(1))
ch = '海运'
if re.search('卡航|卡车', msg):
ch = '卡航'
elif re.search('铁路|班列', msg):
ch = '铁路'
elif re.search('普卡', msg):
ch = '普卡'
want = None
for kw, ctry in (('德国', 'DE'), ('法国', 'FR'), ('波兰', 'PL'), ('捷克', 'CZ'), ('西班牙', 'ES'), ('马德里', 'ES'), ('中国', '')):
if kw in msg:
want = ctry
break
card = calc_warehouse_card(cbm, ch, country=want)
if card:
store = card['warehouse'].splitlines()[0]
extra_parts.append(f"【海外仓卡派 · 按方计费】\n 仓点:{store}\n 计费:{card['note']}\n 渠道:{card['channel']},适用档位 {card['tier']}\n 运费:{card['formula']}\n 说明:单箱实重超 35KG 加收 30 元/箱(封顶 3600 元);不接受仓储,特殊情况 85 元/箱/周")
sur = detect_surcharge(msg)
if sur:
extra_parts.append(surcharge_reply(sur[0], sur[1], ship.get('weight')))
pc_m = re.search('(?<![0-9])(\\d{4,5})(?![0-9])', msg)
if pc_m and re.search('邮编|postcode|zip|偏远|岛屿', msg, re.I):
hit = check_island(pc_m.group(1))
if hit:
extra_parts.append(f'【偏远判断】邮编 {pc_m.group(1)} 属于{hit[0]}({hit[1]})的岛屿/偏远地区,需加收 DPD 岛屿附加费 35 欧元/件')
else:
extra_parts.append(f'【偏远判断】邮编 {pc_m.group(1)} 未命中 DPD 岛屿偏远清单;但仍需以 UPS/DPD 官网查询结果为准,偏远费以账单为准')
follow_src = None
if not know_text:
prev_q = last_user_question(conv_id)
if prev_q:
know_text = search_knowledge(prev_q)
if know_text:
follow_src = prev_q
_c = connect()
_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)
LENGTH_RULE = '回答长度要求:全文不少于 120 字、不超过 350 字。内容要完整、信息充分,把关键点讲清楚,可分 2~4 段或用短列表呈现。可以补充相关的说明(例如适用范围、注意事项、下一步建议),让对方看完就能明白。不要寒暄客套,不要重复同一个数字,不要罗列与问题无关的信息。'
is_en = bool(re.search('[A-Za-z]{3,}', msg)) and (not re.search('[\\u4e00-\\u9fa5]', msg))
if is_en:
LANGUAGE_RULE = '【语言要求 · 最高优先级】用户用英文提问,你必须用英文(English)回答全文,包括所有小节标题(如 Conclusion / Calculation / Notes),不要出现任何中文。以下给出的中文结构与参考资料仅用于理解,输出必须翻译成英文。'
else:
LANGUAGE_RULE = '【语言要求】用户用中文提问,请用中文回答。'
if freight_text or extra_parts:
blocks = []
if freight_text:
blocks.append(freight_text)
blocks.extend(extra_parts)
body = '\n\n'.join(blocks)
system_prompt = f'{LANGUAGE_RULE}\n\n你是柏悦物流智能客服。\n下面是根据报价表计算好的结果,请严格按照这些数字向客户说明。\n不要重新计算、不要改动任何数字,但要讲清楚是怎么算出来的。\n\n{body}\n\n请按以下结构输出(用英文回答时请把标题也翻译成英文):\n【结论】先给结论:哪种方式、多少钱、多久到。\n【计算过程】分点列出,最多 5 行,让客户看懂钱花在哪。\n【补充说明】2~3 行:适用范围(如单件重量、尺寸要求)、偏远地区或敏感货附加费、下一步建议(如提供仓点或运单号可进一步核算)。\n\n{LENGTH_RULE}\n客户没指定渠道时,把主要几种渠道(海运/铁路/卡航/空运)列出来对比,并问客户更看重时效还是成本。\n'
elif know_text:
extra = f'\n(以上资料对应上一句提问:{follow_src})' if follow_src else ''
system_prompt = f'{LANGUAGE_RULE}\n\n你是柏悦物流智能客服,严格优先使用下面知识库内容回答,不要自己编造业务信息。\n回答语句通顺自然,分段清晰,让人一眼看清重点。\n要结合上文理解用户的追问(比如"那空运呢""这个多少钱"指的是什么)。\n\n{LENGTH_RULE}\n\n知识库内容(供你理解,输出需按语言要求翻译):{know_text}{extra}\n'
else:
system_prompt = f'{LANGUAGE_RULE}\n\n你是柏悦物流智能客服。\n物流相关简单问题可以正常回答;不属于物流业务的问题礼貌说明无法解答,并建议联系人工客服。\n回答语句通顺,表述自然。\n\n{LENGTH_RULE}\n如果用户在追问,请结合上文的对话继续回答,不要重复问已经说过的信息。\n'
context = load_context(conv_id, msg)
def generate():
body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': system_prompt}] + context, 'stream': True}
try:
resp = ai_client.post_completion(body, stream=True, timeout=(5, 90))
except ai_client.Timeout:
yield sse({'e': '模型响应超时,请稍后重试'})
return
except ai_client.RequestException:
yield sse({'e': '无法连接模型服务,请检查网络后重试'})
return
except Exception:
yield sse({'e': '服务异常,请稍后重试'})
return
if resp.status_code != 200:
detail = ''
try:
detail = (resp.json().get('error') or {}).get('message', '')
except Exception:
detail = (resp.text or '')[:120]
tips = {401: '系统繁忙,请稍后再试', 402: '系统繁忙,请稍后再试', 429: '咨询量较大,请稍后再试'}
tip = tips.get(resp.status_code, '系统繁忙,请稍后再试')
resp.close()
yield sse({'e': tip})
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
line = raw.decode('utf-8', errors='ignore')
if not line.startswith('data:'):
continue
data = line[5:].strip()
if data == '[DONE]':
completed = True
break
try:
j = json.loads(data)
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:
got_text = True
parts.append(chunk)
yield sse({'t': chunk})
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:
short = compress_answer(full_text, limit)
if len(short) > limit:
short = compress_answer(short, limit - 60)
if short != full_text:
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)
if fu:
yield sse({'suggest': fu})
except Exception:
pass
yield sse({'d': 1})
except GeneratorExit:
interrupted = True
raise
except Exception:
interrupted = True
yield sse({'e': '传输中断,请点击重试'})
finally:
text = ''.join(parts)
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'})
+59
View File
@@ -0,0 +1,59 @@
"""Read local configuration without changing the process environment or storage."""
import os
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[1]
ALLOWED_EXT = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf', '.txt', '.csv'}
MAX_UPLOAD_MB = 10
def default_config():
from dotenv import dotenv_values
local_config = dotenv_values(ROOT_DIR / '.env', encoding='utf-8-sig', interpolate=False)
api_key = os.environ.get('DEEPSEEK_API_KEY', local_config.get('DEEPSEEK_API_KEY'))
return {
'DATABASE': ROOT_DIR / 'history.db',
'UPLOAD_DIR': ROOT_DIR / 'uploads',
'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,
'MAX_ANSWER_CHARS': 800,
'MAX_ANSWER_CHARS_EN': 1400,
'RATE_LIMIT_PER_MIN': 60,
'ALLOWED_EXT': set(ALLOWED_EXT),
'MAX_UPLOAD_MB': MAX_UPLOAD_MB,
'HOST': '0.0.0.0',
'PORT': 5000,
'INITIALIZE_RUNTIME': True,
}
def get_setting(name):
try:
from flask import current_app, has_app_context
except ImportError:
return default_config()[name]
if has_app_context():
return current_app.config[name]
return default_config()[name]
+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
+263
View File
@@ -0,0 +1,263 @@
"""SQLite schema, history persistence and backups; initialized explicitly by create_app."""
import os
import re
import shutil
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from .config import get_setting
from .tracking import card_to_text
def connect():
connection = sqlite3.connect(get_setting('DATABASE'))
try:
from flask import g, has_app_context
except ImportError:
return connection
if has_app_context():
g.setdefault('_database_connections', []).append(connection)
return connection
def close_connections(error=None):
from flask import g
for connection in g.pop('_database_connections', []):
connection.close()
def now_str():
return datetime.now().strftime('%Y-%m-%d %H:%M')
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,
title TEXT NOT NULL,
ctime TEXT NOT NULL
)
''')
cols = [r[1] for r in cur.execute('PRAGMA table_info(chat_history)').fetchall()]
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,
conv_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
ctime TEXT NOT NULL
)
''')
cur.execute('CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id)')
cur.execute('''
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question TEXT,
answer TEXT,
vote TEXT,
ctime TEXT
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS human_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conv_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'waiting',
ctime TEXT NOT NULL
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS human_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
side TEXT NOT NULL,
content TEXT NOT NULL,
ctime TEXT NOT NULL
)
''')
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,
password TEXT NOT NULL,
name TEXT NOT NULL
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT,
track_no TEXT,
status TEXT,
created TEXT
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT,
phone TEXT,
ctime TEXT,
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()))
conn.commit()
conn.close()
def backup_db():
"""启动时把数据库备份到 backups/,文件名带日期,只保留最近 7 天"""
try:
bdir = get_setting('BACKUP_DIR')
os.makedirs(bdir, exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
dst = os.path.join(bdir, f'history_{today}.db')
if not os.path.exists(dst):
shutil.copy2(get_setting('DATABASE'), dst)
cutoff = datetime.now() - timedelta(days=7)
for f in os.listdir(bdir):
m = re.match('history_(\\d{8})\\.db$', f)
if not m:
continue
try:
if datetime.strptime(m.group(1), '%Y%m%d') < cutoff:
os.remove(os.path.join(bdir, f))
except Exception:
pass
except Exception as e:
print(f'[数据库备份失败] {e}')
def cleanup_old_conversations():
"""自动清理 30 天前的旧对话,防止数据库无限膨胀"""
try:
conn = connect()
c = conn.cursor()
cutoff = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S')
rows = c.execute('SELECT id FROM chat_history WHERE utime < ?', (cutoff,)).fetchall()
ids = [r[0] for r in rows]
if not ids:
return
for cid in ids:
c.execute('DELETE FROM messages WHERE conv_id=?', (cid,))
c.execute(f"DELETE FROM chat_history WHERE id IN ({','.join('?' * len(ids))})", ids)
conn.commit()
conn.close()
print(f'[自动清理] 已删除 {len(ids)} 条 30 天前的旧对话')
except Exception as e:
print(f'[自动清理失败] {e}')
def make_title(content):
"""生成会话标题:优先用文字内容,只有附件时显示附件数量"""
lines = [l for l in content.split('\n') if not l.strip().startswith('[附件]')]
text = ' '.join(lines).strip()
if text and text != '(发送了附件)' and (text != '请帮我看看这个附件'):
return text[:24]
n = len([l for l in content.split('\n') if l.strip().startswith('[附件]')])
if n == 1:
return '📎 已上传附件'
if n > 1:
return f'📎 {n}个附件'
return content[:24]
def save_message(conv_id, role, content):
"""把一条消息写进会话;用户消息做幂等处理,重试不会重复入库。"""
if not conv_id or not content:
return
conn = connect()
cur = conn.cursor()
if role == 'user':
last = cur.execute('SELECT role, content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 1', (conv_id,)).fetchone()
if last and last[0] == 'user' and (last[1] == content):
conn.close()
return 'duplicate'
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] == '新对话'):
cur.execute('UPDATE chat_history SET title=? WHERE id=?', (make_title(content), conv_id))
cur.execute('UPDATE chat_history SET utime=? WHERE id=?', (now_str(), conv_id))
conn.commit()
conn.close()
def last_user_question(conv_id):
"""上一条用户提问(必须在当前这句入库之前调用)"""
if not conv_id:
return None
conn = connect()
row = conn.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
conn.close()
return row[0] if row else None
def load_context(conv_id, msg):
"""取最近 MAX_CONTEXT 条消息,拼成模型要的 messages(不含 system)。"""
if not conv_id:
return [{'role': 'user', 'content': msg}]
conn = connect()
rows = conn.execute('SELECT role, content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT ?', (conv_id, get_setting('MAX_CONTEXT'))).fetchall()
conn.close()
rows.reverse()
history = [{'role': r, 'content': card_to_text(c)} for r, c in rows]
if not history or history[-1]['role'] != 'user' or history[-1]['content'] != msg:
history.append({'role': 'user', 'content': msg})
return history
+71
View File
@@ -0,0 +1,71 @@
"""Attachment validation, storage and retention without request parsing."""
import os
import secrets
import re
import time
from datetime import datetime
from urllib.parse import unquote
from .config import get_setting
def cleanup_uploads():
"""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)
except Exception as e:
print(f'[清理上传文件失败] {e}')
def upload_file(files):
"""上传附件:保存到 uploads 目录,返回文件名与访问地址"""
if 'file' not in files:
return ({'ok': False, 'error': '没有文件'}, 400)
f = files['file']
if not f or not f.filename:
return ({'ok': False, 'error': '文件名为空'}, 400)
ext = os.path.splitext(f.filename)[1].lower()
if ext not in get_setting('ALLOWED_EXT'):
return ({'ok': False, 'error': f'不支持的格式 {ext},仅支持图片/PDF/TXT/CSV'}, 400)
f.seek(0, os.SEEK_END)
size = f.tell()
f.seek(0)
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 = 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}
+71
View File
@@ -0,0 +1,71 @@
"""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, 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, 'welcome': WELCOME_TEXT}
def get_history():
conn = connect()
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()
rows = rows[::-1]
return [{'role': r[0], 'content': r[1], 'ctime': r[2]} for r in rows]
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()
conn.close()
return {'ok': True}
def clear_all():
conn = connect()
cur = conn.cursor()
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}
+28
View File
@@ -0,0 +1,28 @@
"""Request limiting and HTML/SSE cache headers with app-scoped state."""
import time
from flask import current_app, jsonify, request
def rate_limit():
path = request.path
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()
hits = current_app.extensions['rate_hits'][ip]
while hits and now - hits[0] > 60:
hits.pop(0)
if len(hits) >= current_app.config['RATE_LIMIT_PER_MIN']:
return jsonify({'e': '请求太频繁,请稍后几秒再试', 'd': 1}), 429
hits.append(now)
return None
def no_cache_html(response):
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'
return response
+172
View File
@@ -0,0 +1,172 @@
"""Human conversation lifecycle; typing state is supplied by the app."""
import time
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)
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]
else:
cur = conn.execute('INSERT INTO human_sessions(conv_id, status, ctime) VALUES(?,?,?)', (conv_id, 'waiting', now))
sid = cur.lastrowid
conn.commit()
conn.close()
return {'ok': True, 'session_id': sid}
def human_poll(sid, after, typing_map):
conn = connect()
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', '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 ''
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()
return {'ok': True}
def human_queue():
conn = connect()
rows = conn.execute("SELECT id, conv_id, status, ctime FROM human_sessions WHERE status='waiting' ORDER BY id").fetchall()
conn.close()
return [{'id': r[0], 'conv_id': r[1], 'ctime': r[3]} for r in rows]
def human_active():
conn = connect()
rows = conn.execute("SELECT id, conv_id FROM human_sessions WHERE status='active' ORDER BY id").fetchall()
conn.close()
return [{'id': r[0], 'conv_id': r[1]} for r in rows]
def human_accept(data):
sid = data.get('sid')
conn = connect()
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} if changed else ({'ok':False, 'error':'session_closed_or_missing'}, 409)
def human_close(data):
sid = data.get('sid')
conn = connect()
conn.execute("UPDATE human_sessions SET status='closed' WHERE id=?", (sid,))
conn.commit()
conn.close()
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()
return {'ok': True}
def human_stop_typing(data, typing_map):
sid = data.get('sid')
typing_map.pop(str(sid), None)
return {'ok': True}
def human_read(data):
sid = data.get('sid')
conn = connect()
conn.execute("UPDATE human_messages SET read_at=? WHERE session_id=? AND side='customer' AND read_at IS NULL", (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), sid))
conn.commit()
conn.close()
return {'ok': True}
+290
View File
@@ -0,0 +1,290 @@
"""Extracted knowledge rules with unchanged business behavior."""
knowledge_list = [
# ==================== 一、订单 ====================
{
"keys": ["查询订单", "查订单", "订单查询", "订单在哪里", "我的订单在哪", "帮我查订单", "为什么查不到订单"],
"text": (
"请提供订单号、入仓号或关联运单号,并核验订单归属。"
"查询结果以实际记录为准。查不到时,应核对号码、查询范围和数据同步情况,"
"不能直接认定没有发货,也不能编造运输或清关结果。"
)
},
{
"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":["铁路","中欧班列","班列","义乌铁路","火车","铁路价格","铁路多久","铁路多少钱"],
"text":"义乌-中欧铁路专线单价:21KG+ 15.5元/kg,100KG+ 和 1000KG+ 均为13.5元/kg。时效:发车后36-40天提取。VAT递延减1元/kg。只限送义乌仓,义乌装柜经阿拉山口/霍尔果斯出境。"
},
{
"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":["空运","空派","航空","欧洲空运","空运价格","空运多久","空运多少钱"],
"text":"欧洲空运专线(空派)单价:21KG+ 39元/kg,45KG+ 38元/kg,71KG+ 37元/kg,101KG+ 36元/kg。时效:10-13天左右提取;快线8-13天,直飞快线12-15天。一票多件单件不足12KG按12KG计费。"
},
{
"keys":["英国海运","英国铁路","中英卡航","英国专线"],
"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","伦敦"],
"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":["报价","价格","费用","计费","怎么收费","计费重量","计费重量怎么算","材积重","体积重","多少钱","运费","运费怎么算","单价"],
"text":"运费计算方式:阶梯单价 × 计费重量。计费重量 = 实重与材积重取大值,材积 = 长×宽×高(cm)÷6000,即1CBM=167KG。各渠道最低起收21KG(英国空运22KG)。单件计费重不足13KG的渠道(铁路、卡航)按5元/kg加收超轻费;空运单件不足12KG按12KG计费。偏远地址加7元/kg,单票最低250元。价格不含税,税点以开出的发票为准。"
},
{
"keys":["时效","运输时效","多久","要几天","多长时间","几天到","什么时候到"],
"text":"各渠道时效:欧洲海运快线开船后38-45天提取;中欧铁路发车后36-40天提取;中欧卡航发车后16-20天提取;欧洲空运10-13天提取;英国空运8-10个自然日提取。海关查验、罢工、节假日等不可抗力导致的时间不计入。"
},
{
"keys":["揽收","上门取件","上门收货","可以上门取货吗","交仓","交货","哪里交仓"],
"text":"义乌、深圳(龙华观澜、宝安福园)、广州(石井)、泉州均可交仓,义乌仓10:00-21:00收货。欧洲空运交仓义乌+1元/kg,泉州、广州+0.5元/kg;海运、铁路、卡航泉州交货+0.5元/kg。"
},
{
"keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","深圳仓","广州仓","交仓地址","送仓"],
"text":"义乌仓:义乌市稠江街道梦娜袜业德贸路东三门进,李东文 19558263692(10:00-21:00)。深圳龙华:龙华区观澜街道君子布社区张一村路黄背坑路1号B栋,张长印 18688937838。深圳宝安:福园二路九号海亨通物流B区C09-C14。广州:恒河沙仓储石井仓211,潘志红 13789206500。送货需打印交接清单随货交仓。"
},
{
"keys":["理赔","货损","丢件","赔付","破损","压坏","索赔","怎么理赔","丢件怎么赔","理赔多久"],
"text":"遗失赔付:未提取上网遗失按申报价值赔偿,最高不超过40元/kg,不退运费;已提取遗失按服务商标准,最高100USD/票。开查时间:交仓后7天未提取可申请开查,开查30天无果走索赔。签收后亚马逊未上架需2周内提出调查,包裹异常需1个月内提出。外包装无损的内件丢失不支持理赔,易碎品损坏不支持理赔。可自购保险,保费为货值千分之三(电池千分之五)。"
},
{
"keys":["超轻费","计费重量不足","最低计费","重量不够怎么收费"],
"text":"单件计费重量不得低于13KG(空运12KG),可整票按平均13KG算,不足部分按5元/kg加收超轻费。例:单箱只有12KG,加收5×1=5元超轻费。欧洲热门仓卡派直送要求单件平均计费重12KG,不足按2元/kg加收派送费。"
},
{
"keys":["偏远","偏远费","偏远地址","偏远怎么查","偏远收费"],
"text":"偏远地址加收7元/kg(不含燃油),单票偏远费最低消费250元/票。需要自行到UPS/DPD官网查询目的地址是否偏远,我司出货时不另行通知,以账单为准,偏远费6个月内有效。"
},
{
"keys":["超重","超长","超大","尺寸限制","尺寸超了","超尺寸怎么收费"],
"text":"欧洲空运最长边≤99cm、次长边≤75cm、实重<24KG。英国DPD单件≤30KG,尺寸100×70×60cm以内,超重或超长各加收330元/件。UPS单边最长≤99cm、次边≤75cm、单件<24KG,超出加收240元+100元。热门仓卡派直送:单件超40KG加收30元/件。"
},
{
"keys":["敏感","敏感货能发吗","带电","电池","液体","粉末","禁品","不能寄","什么不能寄","违禁品","拒收","拒接"],
"text":"可接带标准箱100瓦以下非危电池/内电产品(C卡航、海运快线均可),资料齐全加1元/kg,资料不齐加2元/kg;手机、平板电脑加5元/kg。拒接:纯液体、纯粉末、药品、食品、易燃易爆、仿牌侵权、木制品(空运)、陶瓷手表等反倾销品、电子烟、管制刀具、枪支。电池第一个托收400元/件,第二个起100元/件,600元封顶。"
},
{
"keys":["清关","清关资料","清关要什么资料","递延","VAT","VAT递延","双清","双清包税","包税","DDP"],
"text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。"
},
{
"keys":["报关","怎么报关","单独报关","报关费","报关多少钱"],
"text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。"
},
{
"keys":["亚马逊","亚马逊仓","FBA","FBA头程","海外仓","仓库配送","卡派直送"],
"text":"支持FBA头程送亚马逊仓,热门仓卡派直送(德国WRO5/HAJ1/DTM1/DTM2、法国CDG7/LIL8、意大利TRN3、西班牙BCN8、罗马尼亚EMAG等)。卡派直送优势:超重附加费少、不限尺寸重量、上架快、基本无丢件。法国、意大利、西班牙其他亚马逊仓不能超UPS标准。"
},
{
"keys":["人工客服","人工","客服","转人工","在线吗","上班","工作时间","营业时间","几点","周六","周日","星期六","星期日","星期天","礼拜","周末","双休","节假日","放假","休息"],
"text":"人工客服在线时间:周一至周五 9:00—18:00。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。"
},
{
"keys":["电话","联系方式","怎么联系","联系你们","微信"],
"text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
},
{
"keys":["退件","退回","退货","退运","怎么退运"],
"text":"海运/铁路为散货拼柜,已装柜不接受退件和扣件。空运国外扣件费200欧/票,收件人改派地址最低200元/票。退回海外仓:德国境内15欧/件,欧盟境内25欧/件,操作费100元/票,5个工作日免仓储,超期1欧/kg/天。重派德国10欧/件、欧盟20欧/件。"
}
]
def search_knowledge(question):
"""按关键词命中打分:命中的关键词越长越具体,得分越高"""
q = question.lower()
best, best_score = None, 0
for item in knowledge_list:
for k in item["keys"]:
if k.lower() in q:
score = len(k) # 长关键词更具体,优先
if score > best_score:
best, best_score = item["text"], score
return best
+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)}
+361
View File
@@ -0,0 +1,361 @@
"""Extracted pricing rules with unchanged business behavior."""
import math
import re
try:
from . import rates_extra as RX
except Exception:
RX = None
RATES = {
"sea": {
"name": "欧洲海运快线(双清/递延)",
"tiers": [(2000, 10.0), (500, 10.2), (101, 10.3), (21, 12.3)],
"divisor": 6000,
"min_kg": 21,
"time": "开船后 38-45 天提取",
"min_pcs_kg": None,
"remote": 7,
"note": "德国 UPS/DHL/DPD/CWE 统配;欧洲递延 -1.7元/kg;樱花快线 +0.3元/kg;因查验/罢工/节假日顺延",
},
"rail": {
"name": "义乌-中欧铁路专线",
"tiers": [(1000, 13.5), (100, 13.5), (21, 15.5)],
"divisor": 6000,
"min_kg": 21,
"time": "发车后 36-40 天提取",
"min_pcs_kg": 13,
"light_fee": 5,
"remote": 7,
"note": "义乌装柜;VAT 递延 -1元/kg;单件计费重不足 13kg 按超轻费加收",
},
"truck": {
"name": "中欧卡航",
"tiers": [(4000, 17.3), (2000, 17.3), (500, 17.4), (101, 17.4), (21, 19.4)],
"divisor": 6000,
"min_kg": 21,
"time": "发车后欧盟 16-20 天提取",
"min_pcs_kg": 13,
"light_fee": 5,
"remote": 7,
"note": "递延 -1.5元/kg;纯纺织品 +5元/kg;普卡时效 18-22 天",
},
"air": {
"name": "欧洲空运专线(空派)",
"tiers": [(101, 36), (71, 37), (45, 38), (21, 39)],
"divisor": 6000,
"min_kg": 21,
"time": "10-13 天左右提取",
"min_pcs_kg": 12,
"light_fee": None,
"remote": 7,
"note": "一票多件单件不足 12kg 按 12kg 计费;快线 8-13 天;直飞快线 12-15 天",
},
"sea_uk": {
"name": "英国海运(双清)",
"tiers": [(301, 10.9), (101, 10.9), (21, 12.8)],
"divisor": 6000,
"min_kg": 21,
"time": "参考时效 40 天提取",
"remote": None,
"note": "VAT自税/递延 10.0/8.1 元/kg;按KG卡派 12.2/10.3;限时达 13.5/11.6;北极航线 25-28 天(义乌装)",
},
"rail_uk": {
"name": "英国铁路(双清)",
"tiers": [(301, 16.2), (101, 16.2), (21, 18.0)],
"divisor": 6000,
"min_kg": 21,
"time": "发车后 25-30 天提取",
"remote": None,
"note": "VAT自税/递延 15/13.2 元/kg;限时达 19.2/17.4(38天);按KG卡派 17.3/15.5",
},
"truck_uk": {
"name": "中英卡航(双清)",
"tiers": [(301, 19.4), (101, 19.4), (21, 21.4)],
"divisor": 6000,
"min_kg": 21,
"time": "发车后 20-22 天提取",
"remote": None,
"note": "VAT自税/递延 18.4/16.4 元/kg;卡派 20.9/18.9;单件不超30kg,尺寸≤98×68×59cm",
},
"air_uk": {
"name": "英国空运专线(双清)",
"tiers": [(101, 42), (51, 44), (22, 48)],
"divisor": 6000,
"min_kg": 22,
"time": "8-10 个自然日提取",
"min_pcs_kg": 12,
"light_fee": None,
"remote": None,
"note": "不包税 VAT 自税/递延 44/40/38;带电 47.5/42.5/41.5;拒收北爱尔兰/苏格兰高地偏远地址",
},
}
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
def parse_dims(t):
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
"""
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):
"""解析重量、渠道、尺寸和费率区域;尺寸单位由调用方识别。"""
t = text.replace(" ", "")
intent = {"weight": None, "channel": None, "dims": None, "region": shipping_region(text)}
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
intent["dims"] = parse_dims(text)
if not intent["dims"]:
# 支持“长100宽80高60”
dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t)
if dl:
intent["dims"] = [float(x) for x in dl.groups()]
# 重量:带单位的数字。去掉空格后在原始文本上匹配,
# 但不能把尺寸里的数字(如 120*100*100 的第三个数)当成重量
m = re.search(r"(\d+(?:\.\d+)?)\s*(吨|kg|KG|公斤|千克|tons?|kilo(?:grams?)?|lbs?)", text, re.I)
if m:
before = text[:m.start()].rstrip()
adjacent_sep = bool(before) and before[-1] in "*xX×"
if not adjacent_sep:
n = float(m.group(1))
unit_w = m.group(2).lower()
if unit_w in ("吨", "ton", "tons"):
intent["weight"] = n * 1000
elif unit_w.startswith("lb"):
intent["weight"] = round(n * 0.4536, 2) # 磅转公斤
else:
intent["weight"] = n
intent["weight_raw"] = m.group(0).replace(" ", "")
# 渠道:空运 / 铁路 / 卡航 / 海运(按优先级匹配)
if re.search(r"空运|空派|航空", t) or re.search(r"\bair\b|air ?freight|air ?cargo", text, re.I):
intent["channel"] = "air_uk" if re.search(r"英国|UK|伦敦|英格兰", text, re.I) else "air"
elif re.search(r"铁路|班列|火车", t) or re.search(r"\brail\b|train", text, re.I):
intent["channel"] = "rail"
elif re.search(r"卡航|卡车|卡派", t) or re.search(r"\btruck\b|road", text, re.I):
intent["channel"] = "truck"
elif re.search(r"海运|海派|船运|快船|海运快线|樱花快线", t) or re.search(r"\bsea\b|ocean|shipping", text, re.I):
intent["channel"] = "sea"
return intent
def detect_dim_unit(text):
"""识别尺寸单位:cm(默认)/ m / mm"""
low = text.lower()
if re.search(r"毫米|\d\s*mm", low):
return "mm"
if re.search(r"米", low) and not re.search(r"厘米", low):
return "m"
if re.search(r"\d\s*cm|\d\s*厘米", low):
return "cm"
if re.search(r"\d\s*m", low): # 注意:cm 里也含 m,所以放在 cm 之后判断
return "m"
return "cm"
def dims_to_cm(dims, unit):
"""统一换算成厘米,体积重公式按 cm 计算"""
if unit == "m":
return [d * 100 for d in dims]
if unit == "mm":
return [d / 10 for d in dims]
return list(dims)
def pick_rate(weight_kg, tiers):
"""从阶梯价里选档:tiers 为 [(最低重量, 单价), ...],从高到低匹配"""
for min_w, price in tiers:
if weight_kg >= min_w:
return price, min_w
return tiers[-1][1], tiers[-1][0]
def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
"""海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档
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]]
p = row.get(channel) or row["海运"]
price = p["cbm1"] if cbm >= 1 else p["cbm05"]
if price is None:
return 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()]
if not stores:
return None
else:
stores = stores[:1]
note = "报价表中的仓点分组:" + "、".join(stores)
return {
"warehouse": "\n".join(stores), "stores": stores,
"channel": channel, "cbm": cbm, "note": note,
"price_cbm": price, "tier": tier,
"total": round(cbm * price, 2),
"formula": f"{cbm:g} CBM × {price:,.0f} 元/CBM = {cbm * price:,.0f} 元",
}
def detect_surcharge(text):
"""识别产品类型,返回报价表中的附加费条目"""
if not RX:
return None
best = None
for product, fee in RX.PRODUCT_SURCHARGES:
# 用产品名里的前几个字做匹配(如“手机”“电池”“玩具”)
for seg in re.split(r"[,,、\s()()]", product):
seg = seg.strip()
if len(seg) >= 2 and seg in text:
if best is None or len(seg) > len(best[2]):
best = (product, fee, seg)
if best:
return best[0], best[1]
return None
def check_island(postcode):
"""按邮编判断是否属于 DPD 岛屿偏远地区(附加费 35 欧/件)"""
if not RX:
return None
pc = re.sub(r"[^0-9]", "", str(postcode))
if not pc:
return None
for country, info in RX.DPD_ISLAND_POSTCODES.items():
seg = info["text"]
# 直接匹配的 4~5 位邮编
for m in re.finditer(r"\d{4,5}", seg):
if pc.startswith(m.group(0)):
return country, info["code"], m.group(0)
# 区间形式 07000–07999
for m in re.finditer(r"(\d{4,5})\s*[–\-]\s*(\d{4,5})", seg):
lo, hi = int(m.group(1)), int(m.group(2))
if len(m.group(1)) == len(pc) and lo <= int(pc) <= hi:
return country, info["code"], m.group(0)
return None
def surcharge_reply(product, fee, weight_kg=None):
"""附加费说明(能算就给出金额)"""
txt = f"【产品附加费】{product}:{fee}"
if weight_kg and "/KG" in fee.upper():
m = re.search(r"\+?(\d+(?:\.\d+)?)", fee)
if m:
extra = float(m.group(1)) * weight_kg
txt += f"\n 按 {weight_kg:g} kg 计:约 {extra:,.0f} 元(以最终计费重为准)"
return txt
def calc_freight(weight_kg, channel, dims=None, unit="cm"):
"""按报价表规则计算运费(数字全部由代码算)
报价表规则:
- 阶梯单价:按总重量落入的档位取价
- 材积:长×宽×高(cm) ÷ 6000,与实重取大值作为计费重量
- 单件最低计费重 / 超轻费:见 RATES 里的 min_pcs_kg、light_fee
- 偏远地址:+7元/kg(不含油),最低 250 元/票
"""
r = RATES[channel]
cm = dims_to_cm(dims, unit) if dims else None
divisor = r.get("divisor", 6000)
vol = round(cm[0] * cm[1] * cm[2] / divisor, 2) if cm else None
real = weight_kg or 0
charge = round(max(real, vol) if vol else real, 2) # 实重与体积重取大
min_kg = r.get("min_kg") or 0
charge = max(charge, min_kg) # 报价表起收档
price, tier = pick_rate(charge, r["tiers"])
total = round(charge * price, 2)
unit_note = ""
if dims and unit != "cm":
unit_note = "(原尺寸 %g×%g×%g %s,已换算为 %g×%g×%g cm)" % (*dims, unit, *cm)
return {
"channel": channel, "name": r["name"],
"real": real, "vol": vol, "charge": charge,
"dims": dims, "dims_cm": cm, "unit": unit, "unit_note": unit_note,
"dim_rule": ("长×宽×高÷%d = %g×%g×%g÷%d = %g kg" % (divisor, *cm, divisor, vol)) if cm else None,
"price": price, "tier": tier,
"formula": f"{charge:g} kg × {price} 元/kg = {total:,.0f} 元",
"total": total,
"time": r["time"],
"min_pcs_kg": r.get("min_pcs_kg"),
"light_fee": r.get("light_fee"),
"remote": r.get("remote"),
"note": r.get("note"),
}
def freight_reply(intent):
"""根据已知信息拼出可读的计算说明(给模型当参考,也直接展示)"""
w = intent["weight"] or 0
dims = intent["dims"]
unit = intent.get("dim_unit") or "cm"
lines = []
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 "实重:未提供"]
if c["vol"]:
parts.append(f"体积重 {c['vol']:g} kg")
body = (
f"【{c['name']}】按时效 {c['time']},计费方式:阶梯单价 × 计费重量\n"
+ (f" 尺寸:{c['unit_note']}\n" if c["unit_note"] else "")
+ f" 计费重量 {c['charge']:g} kg(" + ",".join(parts) + ",取较大值)\n"
+ (f" 材积算法:{c['dim_rule']}\n" if c["dim_rule"] else "")
+ f" 适用档位:{c['tier']}KG+ 档,单价 {c['price']} 元/kg\n"
+ f" 运费:{c['formula']}\n"
)
if c["min_pcs_kg"] and c["charge"] < c["min_pcs_kg"]:
body += f" 注意:单件计费重不足 {c['min_pcs_kg']}kg,需按 {c['min_pcs_kg']}kg 起算或加收超轻费 {c['light_fee']} 元/kg\n"
if c["remote"]:
body += f" 偏远地址:额外 +{c['remote']} 元/kg,单票最低 250 元\n"
if c["note"]:
body += f" 其他:{c['note']}\n"
lines.append(body.rstrip("\n"))
return "\n\n".join(lines)
+78
View File
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""报价表扩展数据(自动生成,来源:炜易达 VIP 10月15日9点执行报价表)
包含:海外仓卡派、二级补仓、德国仓亚马逊退货、产品附加费、DPD 岛屿偏远邮编。
"""
# 海外仓卡派:按 kg(21KG+/51KG+)或按方(0.5CBM+/1CBM+)计费
# 价格单位:按 kg 为 RMB/kg,按方为 RMB/CBM
WAREHOUSE_CARD = {'谷仓法国巴黎1仓-95500-FR\n谷仓法国巴黎2仓-60440-FR\n谷仓法国巴黎4仓-77230-FR': {'卡航': {'kg21': 19.5, 'kg51': 18.5, 'cbm05': 3200.0, 'cbm1': 3020.0}, '普卡': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2920.0}, '铁路': {'kg21': 16.8, 'kg51': 15.8, 'cbm05': 2722.0, 'cbm1': 2542.0}, '海运': {'kg21': 12.5, 'kg51': 11.5, 'cbm05': 1952.0, 'cbm1': 1772.0}}, '4PX-69110-PL\n谷仓-69110-PL\nSLU2-69110-PL': {'卡航': {'kg21': 18.2, 'kg51': 17.2, 'cbm05': 2966.0, 'cbm1': 2786.0}, '普卡': {'kg21': 17.7, 'kg51': 16.7, 'cbm05': 2876.0, 'cbm1': 2686.0}, '铁路': {'kg21': 15.5, 'kg51': 14.5, 'cbm05': 2488.0, 'cbm1': 2308.0}, '海运': {'kg21': 11.1, 'kg51': 10.1, 'cbm05': 1700.0, 'cbm1': 1520.0}}, 'EDAPL-69100-PL': {'卡航': {'kg21': 18.6, 'kg51': 17.6, 'cbm05': 3038.0, 'cbm1': 2858.0}, '普卡': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2948.0, 'cbm1': 2758.0}, '铁路': {'kg21': 15.9, 'kg51': 14.9, 'cbm05': 2560.0, 'cbm1': 2380.0}, '海运': {'kg21': 11.5, 'kg51': 10.5, 'cbm05': 1772.0, 'cbm1': 1592.0}}, '4PX-66340-PL\n菜鸟仓-66340-PL': {'卡航': {'kg21': 18.9, 'kg51': 17.9, 'cbm05': 3092.0, 'cbm1': 2912.0}, '普卡': {'kg21': 18.4, 'kg51': 17.4, 'cbm05': 3002.0, 'cbm1': 2812.0}, '铁路': {'kg21': 16.2, 'kg51': 15.2, 'cbm05': 2614.0, 'cbm1': 2434.0}, '海运': {'kg21': 11.9, 'kg51': 10.9, 'cbm05': 1844.0, 'cbm1': 1664.0}}, '希音-44141-PL\n希音-55040-PL': {'卡航': {'kg21': 19.5, 'kg51': 18.5, 'cbm05': 3200.0, 'cbm1': 3020.0}, '普卡': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2920.0}, '铁路': {'kg21': 16.8, 'kg51': 15.8, 'cbm05': 2722.0, 'cbm1': 2542.0}, '海运': {'kg21': 12.5, 'kg51': 11.5, 'cbm05': 1952.0, 'cbm1': 1772.0}}, '谷仓捷克1仓-25261-CZ\n谷仓捷克2仓-25261-CZ\n谷仓捷克3仓-41002-CZ\n谷仓捷克4仓-25261-CZ\n谷仓捷克5仓-25219-CZ\n谷仓捷克7仓-25301-CZ\n谷仓捷克8仓-41002-CZ': {'卡航': {'kg21': 18.6, 'kg51': 17.6, 'cbm05': 3038.0, 'cbm1': 2858.0}, '普卡': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2948.0, 'cbm1': 2758.0}, '铁路': {'kg21': 15.9, 'kg51': 14.9, 'cbm05': 2560.0, 'cbm1': 2380.0}, '海运': {'kg21': 11.5, 'kg51': 10.5, 'cbm05': 1772.0, 'cbm1': 1592.0}}, '4PX布拉格A仓-25261-CZ': {'卡航': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2930.0}, '普卡': {'kg21': 18.5, 'kg51': 17.5, 'cbm05': 3020.0, 'cbm1': 2830.0}, '铁路': {'kg21': 16.3, 'kg51': 15.3, 'cbm05': 2632.0, 'cbm1': 2452.0}, '海运': {'kg21': 12.0, 'kg51': 11.0, 'cbm05': 1862.0, 'cbm1': 1682.0}}, '4PX马德里A仓-19171-ES': {'卡航': {'kg21': 20.8, 'kg51': 19.8, 'cbm05': 3434.0, 'cbm1': 3254.0}, '普卡': {'kg21': 20.3, 'kg51': 19.3, 'cbm05': 3344.0, 'cbm1': 3154.0}, '铁路': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2956.0, 'cbm1': 2776.0}, '海运': {'kg21': 13.7, 'kg51': 12.7, 'cbm05': 2168.0, 'cbm1': 1988.0}}, '盘古-63150-DE\n新欧达-31628-DE': {'卡航': {'kg21': 19.4, 'kg51': 18.4, 'cbm05': 3182.0, 'cbm1': 3002.0}, '普卡': {'kg21': 18.9, 'kg51': 17.9, 'cbm05': 3092.0, 'cbm1': 2902.0}, '铁路': {'kg21': 16.7, 'kg51': 15.7, 'cbm05': 2704.0, 'cbm1': 2524.0}, '海运': {'kg21': 12.3, 'kg51': 11.3, 'cbm05': 1916.0, 'cbm1': 1736.0}}}
# 二级补仓(海外仓中转)主要服务费
SECONDARY_WAREHOUSE = {'服务': '1.贴标:2张FBA标签,一张快递面单)单件计费重量范围在(0KG<X≤30KG) 2.打板:实际打板数为准(1个板大概1.2方-1.5方) 3.卸货费', '头程单价指示': '欧洲铁路头程单价:欧洲铁路渠道 中欧卡航头程单价:中欧卡航渠道 欧洲海运头程单价:海运快线/海运热门仓卡派直送 英国头程单价:英国专线(DPD)', '二次派送': '1. 欧洲/英国二级补仓无需二次派送费用,可自提单价-1/KG 2. 海外仓在德国,如需发往其他国家补运费差价', '仓储费': '德国:免费存储25天,超过5RMB/方/天,61-180天 7RMB/方/每天,181天以上 15RMB/方/每天 英国:免费存储25天,超过8RMB/方/天,90-120/方/天 9.5RMB/方/天,超120天 15RMB/方/天', '订单处理费': '1:针对一个SKU发往一个地址作为一个订单收取15RMB订单操作费; 2:针对不同票数合并发往一个地址作为一个订单收取15RMB订单操作费; 出货之前都需要填写出货指令', '销毁费': '德国:单询 英国:50英镑/件', '换产品SKU费': '欧洲:针对一个箱里面是一种SKU要换标:开箱关箱费13RMB/箱换SKU:4RMB/个,清点:2RMB/个针对一箱多个SKU换标(费用单询) 英国:针对一个箱里面是一种SKU要换标:开箱关箱费20RMB/箱换SKU:6RMB/个,清点:3RMB/个针对一箱多个SKU换标(费用单询)'}
# 德国仓亚马逊退货费用(单位:欧元)
DE_RETURN_FEES = [
{'cat': '2. Outbound 出库', 'item': '2.1.1 Picking 整箱出库', 'sub': 'Single Carton 每箱', 'price': 2},
{'cat': '4. In-House Service 库内服务', 'item': '4.4 Packing 打包费', 'sub': 'Per Carton 每箱', 'price': 1.5},
{'cat': '4. In-House Service 库内服务', 'item': '4.5 Unpacking and Inspection 开箱检验', 'sub': 'Per Carton 每箱', 'price': 1},
{'cat': '4. In-House Service 库内服务', 'item': '4.6 Destroy Product without Battery 不带电池产品销毁', 'sub': 'Per 1KG 每公斤', 'price': 1},
{'cat': '4. In-House Service 库内服务', 'item': '4.7 Destroy battery 销毁电池', 'sub': 'Per 1KG 每公斤', 'price': 1.5},
{'cat': '4. In-House Service 库内服务', 'item': '4.8 Cancel Order 截单费', 'sub': 'Per order 每单', 'price': 2},
{'cat': '4. In-House Service 库内服务', 'item': '4.9 Claim Fee 认领费', 'sub': 'Every order 每个订单', 'price': 2},
{'cat': '4. In-House Service 库内服务', 'item': '4.10 Lost Shipping Claim 丢件索赔服务费', 'sub': 'Per order 每单', 'price': 5},
]
# 产品附加费清单
PRODUCT_SURCHARGES = [
('电池 标准箱100瓦以下非危电池/内电不超100瓦产品。头程参考海运快线快递单价标准', '资料齐全+1/KG 资料不齐全+2/KG'),
('手机,平板电脑', '5/KG'),
('铝制品', '2元/KG'),
('瓷砖', '1元/KG'),
('玻璃纤维网格织物、玻璃纤维长丝、聚酯高强力纱', '1元/KG'),
('胶合板', '1元/KG'),
('铜版纸', '1元/KG'),
('钢丝绳和钢缆', '1元/KG'),
('包包、U型枕、吊床,普通纺织品', '1元/KG(中欧卡航+2)'),
('与接触食品类产品(厨具、榨汁机、擀面杖、杯子等)', '1元/KG'),
('LED灯、白炽灯、钨丝灯类、电子温度计', '1元/KG'),
('皮革制品', '1元/KG'),
('球类', '1元/KG'),
('木制品', '1元/KG'),
('带磁产品', '1元/KG'),
('蓝牙产品 无线产品 数据线', '1元/KG'),
('金属及其制品:不锈钢无缝钢管耐腐蚀钢、铸铁产品、可锻铸铁螺纹管和接头、钢铁管配件、钢铁制紧固件、预应力非合金钢丝和钢绞线、活页夹的贱金属附件、铸铁井盖', '1元/KG'),
('光伏、晶体硅光伏组件、太阳能板、眼镜', '2元/KG'),
('玩具、沐浴盐球、碳包 蜡烛', '2元/KG'),
('厨房刀具 (全长不超30CM)', '2元/KG'),
('空气净化器', '2元/KG'),
('工具箱', '2元/KG'),
('眼镜类 笔类', '2元/KG'),
('机顶盒 计时器(不包括大的钟表) 电子手环', '2元/KG'),
('带陶瓷产品', '2元/KG'),
('汽车/摩托车/自行车配件/平衡车/汽车配件', '2元/KG'),
('卡客车轮胎、铝合金轮毂 、笔类', '2元/KG'),
('投影仪', '2元/KG'),
('成人用品、性用品、', '3元/KG'),
('服装类(大衣,棉服,风衣,外套,斗篷,运动服饰,毛衫,T恤,衬衫,连体服饰,套装,裙子,骑行服),毛巾类,床上用品,和内衣内裤(海关编码61-65开头)', '3/KG(中欧卡航+5)'),
('鞋类(包括塑料拖鞋、鞋垫)不包括鞋带', '3/KG(中欧卡航+5)'),
('针织类的帽子、围巾、手套、披肩,窗帘', '3/KG(中欧卡航+5)'),
('U盘/内存卡', '3元/个'),
('各种码表 手表', '3.5元/个'),
('车载DVD/车载显示屏/车载收音机/行车记录仪/行车导航仪', '80元/台'),
('投影仪', '35元/台'),
('平衡车/平板车(单个15KG以下)(超100瓦安时拒接)', '单询'),
('平衡车/平板车(超100瓦安时拒接)', '单询'),
('电动滑板车(单个15KG以下)(超100瓦安时拒接)', '单询'),
('电动滑板车(超100瓦安时拒接)', '单询'),
('平衡车/平板车/滑板车(单个20KG以上)', '单询'),
('自行车、电动自行车、', '400/台(计费重超60KG拒收)'),
('药物、化学产品及原料', '拒接'),
('玩具车、积木类', '不侵权+2/KG'),
('管制刀具,弓箭,枪支弹药及配件', '拒接'),
('电子打火机、电子烟', '拒接'),
('以上表格未列明的产品', '另外询价'),
]
# DPD 欧洲岛屿偏远邮编(附加费 35 欧/件)
DPD_ISLAND_POSTCODES = {'丹麦': {'code': 'DK', 'text': '4592, 5960, 5970, 5985, 6720, 7884,8305,8592, 9940, 9950, 9960, Bornholm: 3700, 3720, 3730,3740,3751, 3760, 3770, 3782, 3790'}, '德国': {'code': 'DE', 'text': 'Hiddensee: 18565, NordfriesischeInseln:25849, 25859, 25863, 25869, 25938, 25946, 25980,25992,25996–25999, Ostfries. Inseln: 26465, 26474, 26486,26548,26571, 26579, 26757, Helgoland: 27498,27499,Chiemsee: 83256 Neuwerk:'}, '爱沙尼亚': {'code': 'EE', 'text': '62601, 88001–88005, 91217,91301–91320,92001–92420, 93001–94799, 96027, 96098'}, '芬兰': {'code': 'FI', 'text': 'Aland–Inseln: 22000–22999'}, '法国': {'code': 'FR', 'text': '17111, 17123, 17190, 17310, 17370,17410, 17480,17550, 17580, 17590, 17630, 17670, 17740, 17880,17940,22870, 29242, 29253, 29259, 29990, 56360, 56590,56780,56840, 85330, 85350, Korsika: 20000–20999'}, '希腊': {'code': 'GR', 'text': 'Saronische Inseln: 180xx, 189xx,Ionische Inseln: 28xxx–29xxx, 31xxx, 49xxx, Berg Athos:63086–63087,Kreta: 70xxx–74xxx und Inseln der Ägäis: 80xxx–85xxx'}, '意大利': {'code': 'IT', 'text': 'Pontinische Inseln: 04020, 04027, Sardinien:07000–09999, Lagune von Venedig: 30010, 30012, 30100,30121–30126, 30131–30133, 30135, 30141, Elba mitTyrrhenischeInseln: 57030–57039, 58012–58013, 58018, Tremiti Inseln:71040, Inseln im Golf von Neapel: 80070–80071, 80073–80077, 80079, Inselgruppen um Sizilien: 90010, 91017, 91023,92010, 98050, 98052, 98055Einige PLZ können Gebiete auf Inseln und dem Festlandbetreffen.'}, '克罗地亚': {'code': 'HR', 'text': '20221–20226, 20260–20263, 20264,20270– 20275, 20290, 21220, 21223–21225, 21400,21403–21405,21410, 21412, 21413, 21420, 21423–21426, 21430, 21432,21450, 21454, 21460, 21462–21463, 21465–21467, 21469,21480, 21483, 21485, 22231–22236, 22240–22244, 23212,23234, 23249–23251, 23262–23264, 23271–23275, 23281–23287, 23291–23296, 51280–51281, 51500, 51511–51517,51521–51523, 51550–51552, 51554–51557, 51559, 51561–51562, 51564, 53291, 53294, 53296'}, '荷兰': {'code': 'NL', 'text': '1156AA–1156ZZ, 1791AA–1797ZZ,8881AA– 8884ZZ, 8891AA–8897ZZ, 8899AA–8899ZZ,9161AA–9164ZZ, 9166AA–9166ZZ'}, '葡萄牙': {'code': 'PT', 'text': 'Madeira: 9000–9390, Porto Santo: 9400,Azoren: 9500–9980'}, '瑞典': {'code': 'SE', 'text': '10005, 13025, 13033, 13034, 13036,13038, 13039, 13042, 13043, 13055, 13056, 37022, 43080–43099,45204–45205, 47112–47115, 47500–47599, 62000–62999,74296–74297, 76017–76019, 91803'}, '西班牙': {'code': 'ES', 'text': 'Balearen: 07000–07999'}}
+7
View File
@@ -0,0 +1,7 @@
"""Blueprint registration, called explicitly by the application factory."""
def register_routes(app):
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)
+44
View File
@@ -0,0 +1,44 @@
"""HTTP adapters for admin; business logic lives in services."""
from flask import Blueprint, Response, render_template, request
from .. import business_service
bp = Blueprint('admin', __name__)
@bp.route('/api/admin/stats')
def admin_stats():
return business_service.admin_stats()
@bp.route('/admin/login', methods=['POST'])
def admin_login():
return business_service.admin_login(request.json or {})
@bp.route('/api/export_chat')
def export_chat():
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')
def admin():
return render_template('admin_login.html')
@bp.route('/admin/dashboard')
def admin_dash():
return render_template('admin.html')
+31
View File
@@ -0,0 +1,31 @@
"""HTTP adapters for business; business logic lives in services."""
from flask import Blueprint, request
from .. import business_service, visitor_service
bp = Blueprint('business', __name__)
@bp.route('/api/my_orders')
def my_orders():
return business_service.my_orders(request.args.get('phone', '').strip())
@bp.route('/api/ticket', methods=['POST'])
def add_ticket():
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)
+48
View File
@@ -0,0 +1,48 @@
"""HTTP adapters for chat; business logic lives in services."""
from flask import Blueprint, Response, request, stream_with_context
from .. import business_service, chat_service
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)
if not isinstance(data, dict):
return error('请求格式无效,请重新发送问题。')
msg = data.get('msg', '')
conv_id = data.get('conv_id')
else:
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)
@bp.route('/api/feedback', methods=['POST'])
def feedback():
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()
+39
View File
@@ -0,0 +1,39 @@
"""HTTP adapters for files; business logic lives in services."""
from flask import Blueprint, render_template, request, send_from_directory
from werkzeug.exceptions import NotFound
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)
@bp.route('/uploads/<path:name>')
def uploaded_file(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
+31
View File
@@ -0,0 +1,31 @@
"""HTTP adapters for history; business logic lives in services."""
from flask import Blueprint, request
from .. import history_service
bp = Blueprint('history', __name__)
@bp.route('/api/new_chat', methods=['POST'])
def new_chat():
return history_service.new_chat()
@bp.route('/api/get_history')
def get_history():
return history_service.get_history()
@bp.route('/api/get_messages')
def get_messages():
return history_service.get_messages(request.args.get('conv_id', type=int))
@bp.route('/api/del_history', methods=['DELETE'])
def del_history():
return history_service.del_history(request.args.get('id', type=int))
@bp.route('/api/clear_all', methods=['DELETE'])
def clear_all():
return history_service.clear_all()
+71
View File
@@ -0,0 +1,71 @@
"""HTTP adapters for human; business logic lives in services."""
from flask import Blueprint, current_app, request
from .. import human_service
bp = Blueprint('human', __name__)
@bp.route('/api/human/request', methods=['POST'])
def human_request():
return human_service.human_request(request.json)
@bp.route('/api/human/poll')
def human_poll():
return human_service.human_poll(request.args.get('sid', type=int), request.args.get('after', 0, type=int), current_app.extensions['human_typing'])
@bp.route('/api/human/send', methods=['POST'])
def human_send():
return human_service.human_send(request.json)
@bp.route('/api/human/queue')
def human_queue():
return human_service.human_queue()
@bp.route('/api/human/active')
def human_active():
return human_service.human_active()
@bp.route('/api/human/accept', methods=['POST'])
def human_accept():
return human_service.human_accept(request.json)
@bp.route('/api/human/close', methods=['POST'])
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'])
@bp.route('/api/human/stop_typing', methods=['POST'])
def human_stop_typing():
return human_service.human_stop_typing(request.json, current_app.extensions['human_typing'])
@bp.route('/api/human/read', methods=['POST'])
def human_read():
return human_service.human_read(request.json)
+15
View File
@@ -0,0 +1,15 @@
"""HTTP adapters for pages; business logic lives in services."""
from flask import Blueprint, render_template
bp = Blueprint('pages', __name__)
@bp.route('/healthz')
def health():
return {'status': 'ok'}
@bp.route('/')
def index():
return render_template('index.html')
+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)
+33
View File
@@ -0,0 +1,33 @@
"""Explicit local serving entry point; importing it starts no server."""
def local_ips():
import socket
ips = set()
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as connection:
connection.connect(('8.8.8.8', 80))
ips.add(connection.getsockname()[0])
except Exception:
pass
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if not ip.startswith('127.'):
ips.add(ip)
except Exception:
pass
return sorted(ips)
def serve_app(app):
host, port = app.config['HOST'], app.config['PORT']
print(f'Local: http://127.0.0.1:{port}')
for ip in local_ips():
print(f'LAN: http://{ip}:{port}')
try:
from waitress import serve
except ImportError:
app.run(host=host, port=port, debug=True, use_reloader=False)
else:
serve(app, host=host, port=port, threads=8)
+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
+80
View File
@@ -0,0 +1,80 @@
"""Extracted tracking rules with unchanged business behavior."""
import json
import random
import re
from datetime import datetime, timedelta
CARD_PREFIX = "__CARD__"
def card_to_text(content):
"""把存库的卡片标记转成人话,避免原始 JSON 进入模型上下文"""
if not content.startswith(CARD_PREFIX):
return content
try:
card = json.loads(content[len(CARD_PREFIX):])
if card.get('kind') == 'order':
return card.get('summary', '')
return f"(历史演示轨迹,非真实物流信息:单号 {card.get('no', '')},演示状态:{card.get('status', '')})"
except Exception:
return "(已为该运单查询过轨迹)"
def is_tracking_no(text):
"""判断一句话是不是"整句运单号"
规则(逐条收紧,避免误判):
1. 去掉空格和横杠后,长度 8~20 位,且只能由字母数字组成
2. 至少包含 3 个数字(英文句子通常只有 1 个数字)
3. 字母数量不能多于数字("i want to go 1t how money" 这类会被排除)
纯数字单号(如 15252125)全部满足上述条件。
"""
# 日期格式(如 2026-10-23、2026/10/23)不算运单号
if re.fullmatch(r"\d{4}[-/.]\d{1,2}[-/.]\d{1,2}", text.strip()):
return False
t = re.sub(r"[\s\-]", "", text)
if not (8 <= len(t) <= 20):
return False
if not re.fullmatch(r"[A-Za-z0-9]+", t):
return False
digits = sum(c.isdigit() for c in t)
letters = sum(c.isalpha() for c in t)
if digits < 3:
return False
if letters > digits:
return False
return True
def extract_tracking_no(text):
"""从一句话里提取运单号:先看整句是不是,再从句子里找最长的字母数字串"""
t = text.strip()
if is_tracking_no(t):
return t
# 找句子里最长的 8~20 位字母数字连续串
candidates = re.findall(r"[A-Za-z0-9]{8,20}", t)
best = None
for c in candidates:
if is_tracking_no(c):
if best is None or len(c) > len(best):
best = c
return best
def build_track(no):
"""Legacy demo helper for old imports; chat queries use order_test_data."""
rnd = random.Random(no)
steps = [
("已揽收", "义乌仓"),
("出口清关放行", "义乌/宁波口岸"),
("已发车", "中欧班列/卡航 · 阿拉山口"),
("到达欧洲中转仓", "波兰 · 华沙"),
("欧盟清关放行", "德国 · 杜伊斯堡"),
("派送中", "收件人所在城市"),
]
gaps = [rnd.randint(6, 40) for _ in steps] # 相邻节点间隔(小时)
t = datetime.now() - timedelta(hours=sum(gaps))
nodes = []
for (text, place), gap in zip(steps, gaps):
t = t + timedelta(hours=gap)
nodes.append({"time": t.strftime("%Y-%m-%d %H:%M"),
"text": text, "place": place})
nodes.reverse() # 最新的排最上面
return {"no": no, "status": nodes[0]["text"], "nodes": nodes}
+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()
+5 -77
View File
@@ -1,78 +1,6 @@
# -*- coding: utf-8 -*-
"""报价表扩展数据(自动生成,来源:炜易达 VIP 10月15日9点执行报价表)
"""Compatibility exports for the relocated quotation data."""
包含:海外仓卡派、二级补仓、德国仓亚马逊退货、产品附加费、DPD 岛屿偏远邮编。
"""
# 海外仓卡派:按 kg(21KG+/51KG+)或按方(0.5CBM+/1CBM+)计费
# 价格单位:按 kg 为 RMB/kg,按方为 RMB/CBM
WAREHOUSE_CARD = {'谷仓法国巴黎1仓-95500-FR\n谷仓法国巴黎2仓-60440-FR\n谷仓法国巴黎4仓-77230-FR': {'卡航': {'kg21': 19.5, 'kg51': 18.5, 'cbm05': 3200.0, 'cbm1': 3020.0}, '普卡': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2920.0}, '铁路': {'kg21': 16.8, 'kg51': 15.8, 'cbm05': 2722.0, 'cbm1': 2542.0}, '海运': {'kg21': 12.5, 'kg51': 11.5, 'cbm05': 1952.0, 'cbm1': 1772.0}}, '4PX-69110-PL\n谷仓-69110-PL\nSLU2-69110-PL': {'卡航': {'kg21': 18.2, 'kg51': 17.2, 'cbm05': 2966.0, 'cbm1': 2786.0}, '普卡': {'kg21': 17.7, 'kg51': 16.7, 'cbm05': 2876.0, 'cbm1': 2686.0}, '铁路': {'kg21': 15.5, 'kg51': 14.5, 'cbm05': 2488.0, 'cbm1': 2308.0}, '海运': {'kg21': 11.1, 'kg51': 10.1, 'cbm05': 1700.0, 'cbm1': 1520.0}}, 'EDAPL-69100-PL': {'卡航': {'kg21': 18.6, 'kg51': 17.6, 'cbm05': 3038.0, 'cbm1': 2858.0}, '普卡': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2948.0, 'cbm1': 2758.0}, '铁路': {'kg21': 15.9, 'kg51': 14.9, 'cbm05': 2560.0, 'cbm1': 2380.0}, '海运': {'kg21': 11.5, 'kg51': 10.5, 'cbm05': 1772.0, 'cbm1': 1592.0}}, '4PX-66340-PL\n菜鸟仓-66340-PL': {'卡航': {'kg21': 18.9, 'kg51': 17.9, 'cbm05': 3092.0, 'cbm1': 2912.0}, '普卡': {'kg21': 18.4, 'kg51': 17.4, 'cbm05': 3002.0, 'cbm1': 2812.0}, '铁路': {'kg21': 16.2, 'kg51': 15.2, 'cbm05': 2614.0, 'cbm1': 2434.0}, '海运': {'kg21': 11.9, 'kg51': 10.9, 'cbm05': 1844.0, 'cbm1': 1664.0}}, '希音-44141-PL\n希音-55040-PL': {'卡航': {'kg21': 19.5, 'kg51': 18.5, 'cbm05': 3200.0, 'cbm1': 3020.0}, '普卡': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2920.0}, '铁路': {'kg21': 16.8, 'kg51': 15.8, 'cbm05': 2722.0, 'cbm1': 2542.0}, '海运': {'kg21': 12.5, 'kg51': 11.5, 'cbm05': 1952.0, 'cbm1': 1772.0}}, '谷仓捷克1仓-25261-CZ\n谷仓捷克2仓-25261-CZ\n谷仓捷克3仓-41002-CZ\n谷仓捷克4仓-25261-CZ\n谷仓捷克5仓-25219-CZ\n谷仓捷克7仓-25301-CZ\n谷仓捷克8仓-41002-CZ': {'卡航': {'kg21': 18.6, 'kg51': 17.6, 'cbm05': 3038.0, 'cbm1': 2858.0}, '普卡': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2948.0, 'cbm1': 2758.0}, '铁路': {'kg21': 15.9, 'kg51': 14.9, 'cbm05': 2560.0, 'cbm1': 2380.0}, '海运': {'kg21': 11.5, 'kg51': 10.5, 'cbm05': 1772.0, 'cbm1': 1592.0}}, '4PX布拉格A仓-25261-CZ': {'卡航': {'kg21': 19.0, 'kg51': 18.0, 'cbm05': 3110.0, 'cbm1': 2930.0}, '普卡': {'kg21': 18.5, 'kg51': 17.5, 'cbm05': 3020.0, 'cbm1': 2830.0}, '铁路': {'kg21': 16.3, 'kg51': 15.3, 'cbm05': 2632.0, 'cbm1': 2452.0}, '海运': {'kg21': 12.0, 'kg51': 11.0, 'cbm05': 1862.0, 'cbm1': 1682.0}}, '4PX马德里A仓-19171-ES': {'卡航': {'kg21': 20.8, 'kg51': 19.8, 'cbm05': 3434.0, 'cbm1': 3254.0}, '普卡': {'kg21': 20.3, 'kg51': 19.3, 'cbm05': 3344.0, 'cbm1': 3154.0}, '铁路': {'kg21': 18.1, 'kg51': 17.1, 'cbm05': 2956.0, 'cbm1': 2776.0}, '海运': {'kg21': 13.7, 'kg51': 12.7, 'cbm05': 2168.0, 'cbm1': 1988.0}}, '盘古-63150-DE\n新欧达-31628-DE': {'卡航': {'kg21': 19.4, 'kg51': 18.4, 'cbm05': 3182.0, 'cbm1': 3002.0}, '普卡': {'kg21': 18.9, 'kg51': 17.9, 'cbm05': 3092.0, 'cbm1': 2902.0}, '铁路': {'kg21': 16.7, 'kg51': 15.7, 'cbm05': 2704.0, 'cbm1': 2524.0}, '海运': {'kg21': 12.3, 'kg51': 11.3, 'cbm05': 1916.0, 'cbm1': 1736.0}}}
# 二级补仓(海外仓中转)主要服务费
SECONDARY_WAREHOUSE = {'服务': '1.贴标:2张FBA标签,一张快递面单)单件计费重量范围在(0KG<X≤30KG) 2.打板:实际打板数为准(1个板大概1.2方-1.5方) 3.卸货费', '头程单价指示': '欧洲铁路头程单价:欧洲铁路渠道 中欧卡航头程单价:中欧卡航渠道 欧洲海运头程单价:海运快线/海运热门仓卡派直送 英国头程单价:英国专线(DPD)', '二次派送': '1. 欧洲/英国二级补仓无需二次派送费用,可自提单价-1/KG 2. 海外仓在德国,如需发往其他国家补运费差价', '仓储费': '德国:免费存储25天,超过5RMB/方/天,61-180天 7RMB/方/每天,181天以上 15RMB/方/每天 英国:免费存储25天,超过8RMB/方/天,90-120/方/天 9.5RMB/方/天,超120天 15RMB/方/天', '订单处理费': '1:针对一个SKU发往一个地址作为一个订单收取15RMB订单操作费; 2:针对不同票数合并发往一个地址作为一个订单收取15RMB订单操作费; 出货之前都需要填写出货指令', '销毁费': '德国:单询 英国:50英镑/件', '换产品SKU费': '欧洲:针对一个箱里面是一种SKU要换标:开箱关箱费13RMB/箱换SKU:4RMB/个,清点:2RMB/个针对一箱多个SKU换标(费用单询) 英国:针对一个箱里面是一种SKU要换标:开箱关箱费20RMB/箱换SKU:6RMB/个,清点:3RMB/个针对一箱多个SKU换标(费用单询)'}
# 德国仓亚马逊退货费用(单位:欧元)
DE_RETURN_FEES = [
{'cat': '2. Outbound 出库', 'item': '2.1.1 Picking 整箱出库', 'sub': 'Single Carton 每箱', 'price': 2},
{'cat': '4. In-House Service 库内服务', 'item': '4.4 Packing 打包费', 'sub': 'Per Carton 每箱', 'price': 1.5},
{'cat': '4. In-House Service 库内服务', 'item': '4.5 Unpacking and Inspection 开箱检验', 'sub': 'Per Carton 每箱', 'price': 1},
{'cat': '4. In-House Service 库内服务', 'item': '4.6 Destroy Product without Battery 不带电池产品销毁', 'sub': 'Per 1KG 每公斤', 'price': 1},
{'cat': '4. In-House Service 库内服务', 'item': '4.7 Destroy battery 销毁电池', 'sub': 'Per 1KG 每公斤', 'price': 1.5},
{'cat': '4. In-House Service 库内服务', 'item': '4.8 Cancel Order 截单费', 'sub': 'Per order 每单', 'price': 2},
{'cat': '4. In-House Service 库内服务', 'item': '4.9 Claim Fee 认领费', 'sub': 'Every order 每个订单', 'price': 2},
{'cat': '4. In-House Service 库内服务', 'item': '4.10 Lost Shipping Claim 丢件索赔服务费', 'sub': 'Per order 每单', 'price': 5},
]
# 产品附加费清单
PRODUCT_SURCHARGES = [
('电池 标准箱100瓦以下非危电池/内电不超100瓦产品。头程参考海运快线快递单价标准', '资料齐全+1/KG 资料不齐全+2/KG'),
('手机,平板电脑', '5/KG'),
('铝制品', '2元/KG'),
('瓷砖', '1元/KG'),
('玻璃纤维网格织物、玻璃纤维长丝、聚酯高强力纱', '1元/KG'),
('胶合板', '1元/KG'),
('铜版纸', '1元/KG'),
('钢丝绳和钢缆', '1元/KG'),
('包包、U型枕、吊床,普通纺织品', '1元/KG(中欧卡航+2)'),
('与接触食品类产品(厨具、榨汁机、擀面杖、杯子等)', '1元/KG'),
('LED灯、白炽灯、钨丝灯类、电子温度计', '1元/KG'),
('皮革制品', '1元/KG'),
('球类', '1元/KG'),
('木制品', '1元/KG'),
('带磁产品', '1元/KG'),
('蓝牙产品 无线产品 数据线', '1元/KG'),
('金属及其制品:不锈钢无缝钢管耐腐蚀钢、铸铁产品、可锻铸铁螺纹管和接头、钢铁管配件、钢铁制紧固件、预应力非合金钢丝和钢绞线、活页夹的贱金属附件、铸铁井盖', '1元/KG'),
('光伏、晶体硅光伏组件、太阳能板、眼镜', '2元/KG'),
('玩具、沐浴盐球、碳包 蜡烛', '2元/KG'),
('厨房刀具 (全长不超30CM)', '2元/KG'),
('空气净化器', '2元/KG'),
('工具箱', '2元/KG'),
('眼镜类 笔类', '2元/KG'),
('机顶盒 计时器(不包括大的钟表) 电子手环', '2元/KG'),
('带陶瓷产品', '2元/KG'),
('汽车/摩托车/自行车配件/平衡车/汽车配件', '2元/KG'),
('卡客车轮胎、铝合金轮毂 、笔类', '2元/KG'),
('投影仪', '2元/KG'),
('成人用品、性用品、', '3元/KG'),
('服装类(大衣,棉服,风衣,外套,斗篷,运动服饰,毛衫,T恤,衬衫,连体服饰,套装,裙子,骑行服),毛巾类,床上用品,和内衣内裤(海关编码61-65开头)', '3/KG(中欧卡航+5)'),
('鞋类(包括塑料拖鞋、鞋垫)不包括鞋带', '3/KG(中欧卡航+5)'),
('针织类的帽子、围巾、手套、披肩,窗帘', '3/KG(中欧卡航+5)'),
('U盘/内存卡', '3元/个'),
('各种码表 手表', '3.5元/个'),
('车载DVD/车载显示屏/车载收音机/行车记录仪/行车导航仪', '80元/台'),
('投影仪', '35元/台'),
('平衡车/平板车(单个15KG以下)(超100瓦安时拒接)', '单询'),
('平衡车/平板车(超100瓦安时拒接)', '单询'),
('电动滑板车(单个15KG以下)(超100瓦安时拒接)', '单询'),
('电动滑板车(超100瓦安时拒接)', '单询'),
('平衡车/平板车/滑板车(单个20KG以上)', '单询'),
('自行车、电动自行车、', '400/台(计费重超60KG拒收)'),
('药物、化学产品及原料', '拒接'),
('玩具车、积木类', '不侵权+2/KG'),
('管制刀具,弓箭,枪支弹药及配件', '拒接'),
('电子打火机、电子烟', '拒接'),
('以上表格未列明的产品', '另外询价'),
]
# DPD 欧洲岛屿偏远邮编(附加费 35 欧/件)
DPD_ISLAND_POSTCODES = {'丹麦': {'code': 'DK', 'text': '4592, 5960, 5970, 5985, 6720, 7884,8305,8592, 9940, 9950, 9960, Bornholm: 3700, 3720, 3730,3740,3751, 3760, 3770, 3782, 3790'}, '德国': {'code': 'DE', 'text': 'Hiddensee: 18565, NordfriesischeInseln:25849, 25859, 25863, 25869, 25938, 25946, 25980,25992,25996–25999, Ostfries. Inseln: 26465, 26474, 26486,26548,26571, 26579, 26757, Helgoland: 27498,27499,Chiemsee: 83256 Neuwerk:'}, '爱沙尼亚': {'code': 'EE', 'text': '62601, 88001–88005, 91217,91301–91320,92001–92420, 93001–94799, 96027, 96098'}, '芬兰': {'code': 'FI', 'text': 'Aland–Inseln: 22000–22999'}, '法国': {'code': 'FR', 'text': '17111, 17123, 17190, 17310, 17370,17410, 17480,17550, 17580, 17590, 17630, 17670, 17740, 17880,17940,22870, 29242, 29253, 29259, 29990, 56360, 56590,56780,56840, 85330, 85350, Korsika: 20000–20999'}, '希腊': {'code': 'GR', 'text': 'Saronische Inseln: 180xx, 189xx,Ionische Inseln: 28xxx–29xxx, 31xxx, 49xxx, Berg Athos:63086–63087,Kreta: 70xxx–74xxx und Inseln der Ägäis: 80xxx–85xxx'}, '意大利': {'code': 'IT', 'text': 'Pontinische Inseln: 04020, 04027, Sardinien:07000–09999, Lagune von Venedig: 30010, 30012, 30100,30121–30126, 30131–30133, 30135, 30141, Elba mitTyrrhenischeInseln: 57030–57039, 58012–58013, 58018, Tremiti Inseln:71040, Inseln im Golf von Neapel: 80070–80071, 80073–80077, 80079, Inselgruppen um Sizilien: 90010, 91017, 91023,92010, 98050, 98052, 98055Einige PLZ können Gebiete auf Inseln und dem Festlandbetreffen.'}, '克罗地亚': {'code': 'HR', 'text': '20221–20226, 20260–20263, 20264,20270– 20275, 20290, 21220, 21223–21225, 21400,21403–21405,21410, 21412, 21413, 21420, 21423–21426, 21430, 21432,21450, 21454, 21460, 21462–21463, 21465–21467, 21469,21480, 21483, 21485, 22231–22236, 22240–22244, 23212,23234, 23249–23251, 23262–23264, 23271–23275, 23281–23287, 23291–23296, 51280–51281, 51500, 51511–51517,51521–51523, 51550–51552, 51554–51557, 51559, 51561–51562, 51564, 53291, 53294, 53296'}, '荷兰': {'code': 'NL', 'text': '1156AA–1156ZZ, 1791AA–1797ZZ,8881AA– 8884ZZ, 8891AA–8897ZZ, 8899AA–8899ZZ,9161AA–9164ZZ, 9166AA–9166ZZ'}, '葡萄牙': {'code': 'PT', 'text': 'Madeira: 9000–9390, Porto Santo: 9400,Azoren: 9500–9980'}, '瑞典': {'code': 'SE', 'text': '10005, 13025, 13033, 13034, 13036,13038, 13039, 13042, 13043, 13055, 13056, 37022, 43080–43099,45204–45205, 47112–47115, 47500–47599, 62000–62999,74296–74297, 76017–76019, 91803'}, '西班牙': {'code': 'ES', 'text': 'Balearen: 07000–07999'}}
from logistics_chat.rates_extra import (
WAREHOUSE_CARD, SECONDARY_WAREHOUSE, DE_RETURN_FEES,
PRODUCT_SURCHARGES, DPD_ISLAND_POSTCODES,
)
BIN
View File
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
"""Close all active human sessions; waiting sessions and messages are preserved."""
import argparse
import sqlite3
from contextlib import closing
from pathlib import Path
DEFAULT_DB = Path(__file__).resolve().parents[1] / "history.db"
def close_active_sessions(db_path=None):
database = Path(db_path if db_path is not None else DEFAULT_DB).expanduser().resolve()
with closing(sqlite3.connect(database.as_uri() + "?mode=rw", uri=True)) as connection:
with connection:
count = connection.execute(
"UPDATE human_sessions SET status='closed' WHERE status='active'"
).rowcount
return count
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="Existing SQLite database")
args = parser.parse_args()
try:
count = close_active_sessions(args.db)
except sqlite3.Error as error:
parser.exit(1, f"Database error: {error}\n")
print(f"Closed {count} active session(s).")
if __name__ == "__main__":
main()
+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()
+38
View File
@@ -0,0 +1,38 @@
"""Inspect recent human messages and open sessions without changing the database."""
import argparse
import sqlite3
from contextlib import closing
from pathlib import Path
DEFAULT_DB = Path(__file__).resolve().parents[1] / "history.db"
def inspect_sessions(db_path=None):
database = Path(db_path if db_path is not None else DEFAULT_DB).expanduser().resolve()
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)) as connection:
messages = connection.execute(
"SELECT * FROM human_messages ORDER BY id DESC LIMIT 5"
).fetchall()
sessions = connection.execute(
"SELECT id, status FROM human_sessions WHERE status!='closed' ORDER BY id"
).fetchall()
return messages, sessions
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="Existing SQLite database")
args = parser.parse_args()
try:
messages, sessions = inspect_sessions(args.db)
except sqlite3.Error as error:
parser.exit(1, f"Database error: {error}\n")
print(messages)
print("---")
print(sessions)
if __name__ == "__main__":
main()
+56 -27
View File
@@ -1,21 +1,46 @@
@echo off
chcp 936 >nul
title Baiyue Logistics AI Customer Service
setlocal DisableDelayedExpansion
cd /d "%~dp0"
set "PYTHON_ARGS="
if defined VIRTUAL_ENV (
set "PYTHON_EXE=%VIRTUAL_ENV%\Scripts\python.exe"
call :probe_python
if not errorlevel 1 goto python_ready
)
set "PYTHON_EXE=%~dp0.venv\Scripts\python.exe"
call :probe_python
if not errorlevel 1 goto python_ready
set "PYTHON_EXE=py.exe"
set "PYTHON_ARGS=-3"
call :probe_python
if not errorlevel 1 goto python_ready
set "PYTHON_EXE=python.exe"
set "PYTHON_ARGS="
call :probe_python
if not errorlevel 1 goto python_ready
echo [ERROR] No working Python 3 interpreter found. Install Python or recreate .venv.
if /i "%~1"=="--check-python" exit /b 1
pause
exit /b 1
:python_ready
if /i "%~1"=="--check-python" (
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import sys; print(sys.executable)"
exit /b
)
chcp 936 >nul
title Baiyue Logistics AI Customer Service
echo ============================================
echo Baiyue Logistics AI Customer Service
echo ============================================
echo.
where python >nul 2>nul
if errorlevel 1 (
echo [ERROR] python not found. Install Python and tick "Add to PATH".
echo.
pause
exit /b 1
)
echo [1/3] Checking port 5000 ...
netstat -ano | findstr "LISTENING" | findstr /C:":5000 " >nul
if not errorlevel 1 (
@@ -34,23 +59,11 @@ if not errorlevel 1 (
)
echo OK - port is free.
echo [2/3] Checking DEEPSEEK_API_KEY ...
if "%DEEPSEEK_API_KEY%"=="" (
echo.
echo [WARN] DEEPSEEK_API_KEY is empty in this window.
echo If you just ran setx, reopen this window or restart VS Code.
echo setx DEEPSEEK_API_KEY "sk-xxxxxxxx"
echo.
pause
) else (
echo OK - key detected.
)
echo [3/3] Checking dependencies ...
python -c "import flask, requests" >nul 2>nul
echo [2/3] Checking dependencies ...
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv, openpyxl" >nul 2>nul
if errorlevel 1 (
echo Missing packages, installing ...
python -m pip install -r requirements.txt
"%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt
if errorlevel 1 (
echo [ERROR] pip install failed, check network and retry.
pause
@@ -59,6 +72,15 @@ if errorlevel 1 (
)
echo OK - dependencies ready.
echo [3/3] Checking DeepSeek configuration ...
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import sys; from logistics_chat.config import default_config; sys.exit(0 if default_config()['DEEPSEEK_API_KEY'] else 1)" >nul
if errorlevel 1 (
echo [WARN] No DeepSeek API key configured.
echo Copy .env.example to .env and set DEEPSEEK_API_KEY, then restart.
) else (
echo OK - key detected in .env or environment.
)
echo.
echo ============================================
echo Starting: http://127.0.0.1:5000
@@ -67,8 +89,15 @@ echo ============================================
echo.
start "" http://127.0.0.1:5000
python am.py
"%PYTHON_EXE%" %PYTHON_ARGS% am.py
set "APP_EXIT_CODE=%ERRORLEVEL%"
echo.
echo Server stopped.
pause
pause
exit /b %APP_EXIT_CODE%
:probe_python
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import sys; sys.exit(0 if sys.version_info.major == 3 else 1)" >nul 2>nul
if "%ERRORLEVEL%"=="0" exit /b 0
exit /b 1
+201
View File
@@ -0,0 +1,201 @@
/* A conversation-first console: compact header, session inbox and full-height chat. */
body { height: 100dvh; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
.topbar { height: 56px; min-height: 56px; display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 22px; background: var(--surface); border-bottom: 1px solid var(--border); z-index: 30; }
.app-identity, .topbar-actions { display: flex; align-items: center; gap: 20px; min-width: 0; }
.brand { gap: 10px; }
.topbar .brand-mark { width: 30px; height: 30px; border-radius: 8px; }
.topbar .brand-mark svg { width: 19px; height: 19px; }
.topbar .brand-name { font-size: 15px; white-space: nowrap; }
.nav-divider { height: 18px; width: 1px; background: var(--border); }
.app-identity h1 { font-size: 14px; font-weight: 500; color: var(--secondary); white-space: nowrap; }
.workspace-link { font-size: 12px; color: var(--secondary); text-decoration: none; white-space: nowrap; }
.workspace-link:hover { color: var(--brand); }
.data-overview { position: relative; }
.data-overview summary { display: flex; align-items: center; gap: 7px; padding: 6px 10px; font-size: 12px; color: var(--secondary); cursor: pointer; border-radius: 7px; list-style: none; border: 1px solid var(--border); }
.data-overview summary::-webkit-details-marker { display: none; }
.data-overview summary svg { width: 15px; height: 15px; }
.data-overview[open] summary, .data-overview summary:hover { color: var(--brand); border-color: #bacbf2; background: var(--brand-soft); }
.stats { position: absolute; top: calc(100% + 11px); right: 0; width: 310px; padding: 18px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); box-shadow: 0 12px 40px #19263d1a; }
.stats h2 { grid-column: 1 / -1; font-size: 13px; font-weight: 600; margin-bottom: 3px; }
.stat { display: flex; flex-direction: column; padding: 10px 12px; background: var(--surface-soft); border-radius: 7px; }
.stat > span { font-size: 11px; color: var(--secondary); }
.stat b { font-size: 23px; font-weight: 600; line-height: 1.5; font-variant-numeric: tabular-nums; }
.export-link { display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; font-size: 12px; color: var(--brand); text-decoration: none; }
.export-link:hover { background: var(--brand-soft); }
.sync-status { flex-shrink: 0; background: var(--danger-soft); color: var(--danger); border-bottom: 1px solid #eed0d3; padding: 7px 22px; font-size: 12px; }
.layout { flex: 1; min-height: 0; display: grid; grid-template-columns: 300px minmax(0, 1fr); overflow: hidden; }
.conv-list { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); }
.list-heading { padding: 23px 20px 18px; }
.list-heading h2 { font-size: 17px; font-weight: 650; }
.list-heading p { font-size: 12px; color: var(--muted); margin-top: 4px; }
.session-search-wrap { position: relative; margin-top: 17px; }
.session-search-wrap svg { position: absolute; top: 12px; left: 11px; width: 15px; height: 15px; color: var(--muted); pointer-events: none; }
#session-search { width: 100%; height: 38px; padding: 8px 10px 8px 34px; border: 1px solid transparent; border-radius: 7px; background: var(--surface-soft); color: var(--text); font-size: 13px; }
#session-search::placeholder { color: var(--muted); }
#session-search:focus { outline: none; border-color: #9ab4ed; background: var(--surface); box-shadow: 0 0 0 3px var(--brand-soft); }
.session-tabs { display: flex; gap: 22px; padding: 0 20px; border-bottom: 1px solid var(--border); }
.session-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 43px; padding: 7px 0 12px; border: 0; background: transparent; color: var(--secondary); font-size: 13px; }
.session-tabs button[aria-selected="true"] { color: var(--brand); font-weight: 600; }
.session-tabs button[aria-selected="true"]::after { position: absolute; content: ""; height: 2px; background: var(--brand); inset: auto 0 -1px; }
.section-count { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 5px; color: var(--muted); background: var(--surface-soft); font-size: 10px; font-variant-numeric: tabular-nums; }
[aria-selected="true"] .section-count { background: var(--brand-soft); color: var(--brand); }
.session-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 10px; }
.list-empty { padding: 33px 14px; text-align: center; font-size: 12px; line-height: 1.8; color: var(--muted); }
.conv-item { display: flex; align-items: center; gap: 11px; width: 100%; padding: 14px 12px; margin-bottom: 4px; border: 1px solid transparent; border-radius: 8px; text-align: left; background: transparent; }
.conv-item:hover { background: var(--surface-soft); }
.conv-item.active { background: #edf3ff; border-color: #e3ebfc; }
.avatar { flex-shrink: 0; display: grid; place-items: center; width: 39px; height: 39px; border-radius: 11px; color: #60728f; background: #eaf0f7; font-size: 14px; font-weight: 600; }
.active .avatar { color: var(--brand); background: #dce7fe; }
.conv-item .info { min-width: 0; flex: 1; }
.conv-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.conv-item .name { display: block; font-size: 14px; font-weight: 600; color: var(--text); white-space: nowrap; }
.conv-time { flex-shrink: 0; color: var(--muted); font-size: 10px; font-weight: 400; }
.conv-item .preview { display: block; margin-top: 5px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--secondary); font-size: 12px; }
.badge { flex-shrink: 0; padding: 0 5px; border-radius: 5px; background: var(--brand); color: white; font-size: 10px; }
.queue-item { border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 8px; background: var(--surface); }
.queue-item .qt { font-size: 13px; font-weight: 600; color: var(--text); }
.queue-item small { display: block; color: var(--muted); font-size: 11px; font-weight: 400; margin-top: 5px; }
.queue-item button { margin-top: 13px; padding: 7px 12px; min-height: 34px; width: 100%; border: 0; border-radius: 6px; background: var(--brand); color: white; font-size: 12px; }
.list-footer { flex-shrink: 0; display: flex; align-items: center; gap: 7px; padding: 14px 20px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; }
.list-footer-mark { width: 6px; height: 6px; border-radius: 50%; background: #91a2bc; }
.list-footer > span { display: flex; align-items: center; gap: 7px; }
#tools-from-list { display: none; }
.chat-area { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: #f5f7fa; }
.chat-header { display: flex; flex-shrink: 0; align-items: center; gap: 12px; min-height: 72px; padding: 13px 28px; background: var(--surface); border-bottom: 1px solid var(--border); }
.participant-avatar { display: grid; place-items: center; flex-shrink: 0; width: 38px; height: 38px; border-radius: 10px; background: #edf2fa; color: #6a7b96; font-size: 14px; }
.participant-copy { flex: 1; min-width: 0; }
.participant-copy h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 650; }
.participant-copy p { margin-top: 2px; color: var(--muted); font-size: 11px; }
.has-active-session .participant-copy p { color: #4b796b; }
.chat-header-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.tools-toggle, .close { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 33px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; background: var(--surface); color: var(--secondary); white-space: nowrap; }
.tools-toggle svg { width: 16px; height: 16px; }
.tools-toggle[aria-expanded="true"], .tools-toggle:hover { border-color: #bacbf2; color: var(--brand); background: var(--brand-soft); }
.close:hover:enabled { border-color: #ecc5ca; background: var(--danger-soft); color: var(--danger); }
.close:disabled { cursor: default; opacity: .45; }
.has-active-session .participant-avatar { color: var(--brand); background: var(--brand-soft); }
body:not(.has-active-session) .close { display: none; }
.empty { flex: 1; display: grid; place-items: center; text-align: center; min-height: 0; padding: 30px; }
.empty-illustration { display: grid; place-items: center; width: 52px; height: 52px; margin: 0 auto 18px; border: 1px solid #e0e6ee; border-radius: 15px; background: var(--surface); color: #879bb9; }
.empty-illustration svg { width: 26px; height: 26px; }
.empty h2 { margin-bottom: 8px; font-size: 18px; font-weight: 600; }
.empty p { font-size: 13px; line-height: 1.9; color: var(--muted); }
.session-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
#chatBox { flex: 1; min-height: 0; overflow-y: auto; padding: 26px clamp(22px, 4vw, 60px); }
.msg { display: flex; align-items: flex-start; gap: 10px; max-width: 1040px; margin: 0 auto 24px; }
.msg.agent { flex-direction: row-reverse; }
.message-avatar { flex-shrink: 0; display: grid; place-items: center; width: 32px; height: 32px; margin-top: 24px; border-radius: 9px; background: #e6ecf4; color: #6e7e97; font-size: 12px; }
.message-avatar svg { width: 17px; height: 17px; }
.msg.agent .message-avatar { background: var(--brand); color: white; }
.message-stack { max-width: min(76%, 720px); min-width: 0; }
.meta { margin-bottom: 6px; font-size: 11px; line-height: 1.5; color: var(--muted); }
.msg.agent .meta { text-align: right; }
.bubble { padding: 12px 16px; border-radius: 3px 10px 10px; border: 1px solid #e4e9f0; background: var(--surface); color: var(--text); font-size: 15px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.msg.agent .bubble { background: #eaf1ff; border-color: #dce6fb; color: #213858; border-radius: 10px 3px 10px 10px; }
.message-image { display: block; max-width: min(240px, 100%); max-height: 320px; object-fit: contain; border-radius: 6px; }
.message-file { color: inherit; text-underline-offset: 3px; font-size: 13px; }
.agent-composer { flex-shrink: 0; padding: 13px 28px 16px; background: var(--surface); border-top: 1px solid var(--border); }
.composer-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; color: var(--secondary); font-size: 12px; }
.composer-heading span { color: var(--muted); font-size: 11px; }
.input-bar { padding: 10px 12px 9px; border: 1px solid #dce3ed; border-radius: 8px; background: var(--surface); }
.input-bar:focus-within { border-color: #8ba8eb; box-shadow: 0 0 0 2px var(--brand-soft); }
#replyText { display: block; width: 100%; min-height: 56px; max-height: 180px; padding: 0; border: 0; resize: none; outline: none; background: transparent; color: var(--text); font-size: 14px; line-height: 1.7; }
#replyText::placeholder { color: #8b98ab; }
.input-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 7px; }
.input-actions > span { color: var(--muted); font-size: 11px; }
#reply-send { min-width: 78px; min-height: 34px; padding: 6px 20px; border: 0; border-radius: 6px; background: var(--brand); color: white; font-size: 13px; }
#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; }
.side-heading { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px; border-bottom: 1px solid var(--border); background: var(--surface); }
.side-heading h2 { font-size: 15px; font-weight: 600; }
.side-heading .icon-button { width: 28px; height: 28px; border: 0; font-size: 20px; }
.card { padding: 20px 18px; border-bottom: 1px solid var(--border); }
.card:last-child { border-bottom: 0; }
.card h3 { margin-bottom: 13px; font-size: 13px; font-weight: 600; }
.card-caption { color: var(--muted); font-size: 11px; margin: -5px 0 12px; }
.info-row { font-size: 12px; line-height: 1.8; color: var(--secondary); }
.info-hint { margin-top: 13px; padding: 10px; font-size: 11px; line-height: 1.9; background: var(--surface-soft); color: var(--secondary); border-radius: 6px; }
.quick { display: block; width: 100%; margin-bottom: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; text-align: left; background: var(--surface); color: var(--secondary); font-size: 12px; }
.quick:hover:enabled { border-color: #a5bdee; color: var(--brand); background: var(--brand-soft); }
.quick:disabled { cursor: default; opacity: .5; }
.ticket-item { padding: 11px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--secondary); font-size: 12px; line-height: 1.8; overflow-wrap: anywhere; }
.ticket-item span { display: block; margin-top: 6px; font-size: 10px; color: var(--muted); }
.tools-backdrop, .sessions-back { display: none; }
@media (max-width: 1200px) {
.layout, .tools-open .layout { grid-template-columns: 280px minmax(0, 1fr); }
.side { display: block; position: fixed; top: 0; bottom: 0; right: 0; z-index: 51; width: min(330px, 88vw); border-left: 1px solid var(--border); transform: translateX(105%); visibility: hidden; box-shadow: -10px 0 40px #19263d22; transition: transform .2s; }
.tools-open .side { transform: translateX(0); visibility: visible; }
.tools-open .tools-backdrop { display: block; position: fixed; inset: 0; z-index: 50; border: 0; background: #19263d55; }
.chat-header { padding-left: 22px; padding-right: 22px; }
.agent-composer { padding-left: 22px; padding-right: 22px; }
}
@media (max-width: 900px) and (min-width: 721px) {
.layout, .tools-open .layout { grid-template-columns: 248px minmax(0, 1fr); }
.list-heading { padding-left: 16px; padding-right: 16px; }
.session-tabs { padding-left: 16px; padding-right: 16px; gap: 17px; }
.tools-toggle span { display: none; }
#chatBox { padding-left: 18px; padding-right: 18px; }
}
@media (max-width: 720px) {
.topbar { height: 52px; min-height: 52px; padding: 0 16px; }
.app-identity h1, .nav-divider { display: none; }
.topbar .brand-name { font-size: 14px; }
.topbar-actions { gap: 12px; }
.data-overview summary { font-size: 11px; padding: 5px 8px; }
.stats { width: min(310px, calc(100vw - 32px)); }
.layout, .tools-open .layout { display: flex; flex-direction: column; }
.conv-list { flex: 1; min-height: 0; border-right: 0; }
.list-footer { justify-content: space-between; }
#tools-from-list { display: inline-block; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--secondary); font-size: 11px; }
.list-heading { padding: 20px 18px 15px; }
.session-tabs { padding-left: 18px; }
.list-heading h2 { font-size: 18px; }
.conv-item { padding-top: 16px; padding-bottom: 16px; }
.chat-area { display: none; flex: 1; min-height: 0; }
.chat-view .conv-list { display: none; }
.chat-view .chat-area { display: flex; }
.sessions-back { display: grid; place-items: center; flex-shrink: 0; width: 30px; height: 34px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--secondary); font-size: 28px; line-height: 1; }
.participant-avatar { display: none; }
.chat-header { min-height: 65px; padding: 11px 13px 11px 9px; gap: 8px; }
.participant-copy h2 { font-size: 14px; }
.participant-copy p { font-size: 10px; }
.chat-header-actions { gap: 7px; }
.tools-toggle { width: 33px; padding: 6px; }
.tools-toggle span { display: none; }
.close { padding: 6px 8px; font-size: 11px; }
#chatBox { padding: 21px 14px; }
.msg { gap: 7px; margin-bottom: 20px; }
.message-avatar { width: 27px; height: 27px; border-radius: 8px; }
.message-stack { max-width: calc(100% - 40px); }
.bubble { padding: 10px 12px; font-size: 15px; }
.agent-composer { padding: 11px 13px calc(11px + env(safe-area-inset-bottom)); }
.composer-heading { margin-bottom: 6px; font-size: 11px; }
.composer-heading span { font-size: 10px; }
#replyText { font-size: 16px; min-height: 54px; }
.input-actions > span { font-size: 10px; max-width: 210px; }
#reply-send { min-width: 66px; min-height: 38px; padding: 7px 14px; font-size: 12px; }
.reply-status { font-size: 11px; }
}
@media (max-width: 420px) {
.workspace-link { display: none; }
.input-actions > span { max-width: 185px; }
}
+57
View File
@@ -0,0 +1,57 @@
/* Split welcome and sign-in card, with a compact mobile fallback. */
.auth-page { min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 36px; background: #edf2f8; }
.auth-shell { width: min(1050px, 100%); min-height: 620px; display: grid; grid-template-columns: 1.08fr 1fr; overflow: hidden; border: 1px solid #dde5ef; border-radius: 19px; box-shadow: 0 24px 75px rgba(31, 53, 87, .1); background: white; }
.auth-story { position: relative; display: flex; flex-direction: column; padding: 38px 40px; background: #17345e; color: white; overflow: hidden; }
.auth-story::before { content: ""; position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); background-size: 40px 40px; pointer-events: none; }
.auth-story > * { position: relative; }
.auth-story .brand { color: white; }
.auth-story .brand-mark { background: #315c9c; }
.auth-story .brand-caption { color: #a9bedb; }
.story-copy { margin-top: 65px; }
.story-copy .eyebrow { color: #a7c3eb; font-size: 11px; }
.story-copy h1 { font-size: 30px; font-weight: 550; line-height: 1.55; letter-spacing: -.03em; margin: 15px 0 14px; }
.story-copy > p { color: #b9cbe3; font-size: 13px; line-height: 1.95; max-width: 340px; }
.service-points { display: grid; gap: 17px; margin-top: 34px; }
.service-point { display: flex; align-items: flex-start; gap: 12px; }
.service-point > span { width: 29px; height: 29px; border: 1px solid #49658b; color: #c8d9f0; border-radius: 8px; display: grid; place-items: center; font-size: 13px; }
.service-point strong { display: block; color: #eaf2ff; font-size: 12px; font-weight: 550; }
.service-point p { color: #aebfd8; font-size: 11px; margin-top: 2px; }
.route-line { display: flex; align-items: center; gap: 12px; margin-top: auto; padding-top: 40px; font-size: 11px; color: #afc6e5; }
.route-line i { height: 1px; flex: 1; background: #506e97; position: relative; }
.route-line i::after { content: ""; position: absolute; right: 0; top: -2px; border-radius: 50%; width: 5px; height: 5px; background: #96b6e2; }
.auth-content { display: flex; flex-direction: column; justify-content: center; padding: 48px 50px 28px; }
.box { width: 100%; max-width: 330px; margin: auto; }
.box .eyebrow { margin-bottom: 10px; }
.box h2 { color: var(--text); font-size: 27px; font-weight: 650; letter-spacing: -.03em; }
.login-description { font-size: 12px; color: var(--muted); margin: 9px 0 30px; line-height: 1.8; }
.field-label { display: block; color: var(--secondary); font-size: 12px; font-weight: 550; margin: 18px 0 8px; }
.field-wrap { position: relative; }
.field-wrap svg { position: absolute; left: 13px; top: 14px; width: 17px; height: 17px; color: var(--muted); pointer-events: none; }
.box input { width: 100%; height: 45px; border: 1px solid var(--border); border-radius: 8px; color: var(--text); background: white; padding: 10px 13px 10px 40px; outline: none; font-size: 13px; }
.box input:focus { border-color: #8ba7e8; box-shadow: 0 0 0 3px var(--brand-soft); }
.box input::placeholder { color: var(--muted); }
.box input[aria-invalid="true"] { border-color: #df8d98; }
#loginButton { display: block; width: 100%; height: 44px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 13px; font-weight: 600; margin-top: 24px; box-shadow: 0 4px 12px rgba(49,95,213,.16); }
#loginButton:hover { background: var(--brand-hover); }
.err { color: var(--danger); background: var(--danger-soft); border-radius: 7px; font-size: 12px; line-height: 1.7; padding: 9px 11px; margin-top: 14px; }
.login-back { display: block; text-align: center; font-size: 12px; color: var(--muted); text-decoration: none; margin-top: 20px; }
.login-back:hover { color: var(--brand-text); }
.auth-footer { color: var(--muted); text-align: center; font-size: 10px; margin-top: 30px; }
.mobile-brand { display: none; }
@media (max-width: 900px) {
.auth-page { padding: 24px; }
.auth-shell { min-height: 590px; grid-template-columns: 1fr 1fr; }
.auth-story { padding: 30px; }
.story-copy h1 { font-size: 27px; }
.story-copy { margin-top: 50px; }
.auth-content { padding: 38px 30px 25px; }
}
@media (max-width: 650px) {
.auth-page { padding: 20px; }
.auth-shell { display: block; max-width: 430px; min-height: 0; border-radius: 14px; }
.auth-story { display: none; }
.auth-content { padding: 30px 28px 22px; }
.mobile-brand { display: flex; margin-bottom: 38px; }
.box h2 { font-size: 25px; }
.auth-footer { margin-top: 35px; }
}
+282
View File
@@ -0,0 +1,282 @@
/* Customer workspace: navigation, welcome, conversation and composer. */
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);
border-radius: 16px; background: var(--surface); box-shadow: var(--shadow);
}
.left-panel {
width: 248px; min-width: 0; flex-shrink: 0; display: flex; flex-direction: column;
padding: 28px 18px 18px; background: var(--surface-soft); border-right: 1px solid var(--border);
transition: width .2s, padding .2s, transform .2s; overflow: hidden;
}
.left-panel .brand { margin: 0 7px 30px; white-space: nowrap; }
.new-chat-btn {
min-height: 43px; width: 100%; padding: 10px; border: 0; border-radius: 9px;
color: white; background: var(--brand); font-weight: 600; white-space: nowrap;
box-shadow: 0 3px 8px rgba(49, 95, 213, .13);
}
.new-chat-btn::before { content: "+"; font-size: 20px; font-weight: 400; margin-right: 9px; line-height: 1; }
.new-chat-btn:hover { background: var(--brand-hover); }
.panel-title { color: var(--secondary); font-size: 12px; font-weight: 600; margin: 29px 9px 10px; letter-spacing: .05em; }
.history-search-wrap { position: relative; margin-bottom: 13px; }
.history-search-wrap svg { position: absolute; left: 12px; top: 12px; width: 15px; height: 15px; color: var(--muted); pointer-events: none; }
.history-search {
width: 100%; height: 38px; padding: 8px 10px 8px 35px; border: 1px solid var(--border);
border-radius: 8px; color: var(--text); background: var(--surface); font-size: 12px;
}
.history-search::placeholder { color: var(--muted); }
.history-search:focus { border-color: var(--brand); outline: none; box-shadow: 0 0 0 3px var(--brand-soft); }
.history-wrap { flex: 1; min-height: 0; overflow-y: auto; margin: 0 -5px; padding: 0 3px; }
.history-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 34px 5px; }
.history-empty::before { content: "◌"; display: block; font-size: 26px; color: var(--secondary); margin-bottom: 7px; }
.history-item { display: flex; align-items: center; gap: 3px; padding: 11px 9px; margin-bottom: 4px; border-radius: 9px; border: 1px solid transparent; cursor: pointer; }
.history-item:hover { background: var(--surface); border-color: var(--border); }
.history-item.active { background: var(--brand-soft); border-color: var(--border); }
.history-text-wrap { flex: 1; min-width: 0; }
.history-title { color: var(--text); font-size: 13px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.history-time { color: var(--muted); font-size: 10px; margin-top: 4px; }
.del-history-btn { flex-shrink: 0; width: 23px; height: 25px; padding: 0; border: 0; background: transparent; color: var(--muted); border-radius: 5px; font-size: 13px; opacity: 0; }
.history-item:hover .del-history-btn, .history-item:focus-within .del-history-btn { opacity: 1; }
.del-history-btn:hover { background: var(--surface-hover); color: var(--danger); }
.sidebar-footer { border-top: 1px solid var(--border); padding: 15px 7px 0; margin-top: 13px; white-space: nowrap; }
.clear-all-btn { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; }
.clear-all-btn:hover { color: var(--danger); }
.customer-account { display: block; width: 100%; min-height: 40px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 13px; }
.customer-login-cta { display: block; margin-top: 12px; min-height: 40px; padding: 8px 18px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 14px; }
.customer-login-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 20px; background: rgba(18,33,56,.35); overflow-y: auto; }
.customer-login-dialog { width: min(380px,100%); max-height: 100%; overflow-y: auto; padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
.customer-login-dialog h2 { margin: 0 0 20px; font-size: 20px; }
.customer-login-dialog label { display: block; margin: 14px 0 6px; font-size: 14px; }
.customer-login-dialog input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 16px; }
.customer-login-dialog input:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); }
#customer-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
.customer-login-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
.customer-login-actions button { min-height: 44px; min-width: 76px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); }
#customer-submit { border-color: var(--brand); background: var(--brand); color: white; }
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
.main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; }
.header-copy { min-width: 0; flex: 1; }
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
#app-title { display: block; color: var(--text); font-weight: 650; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.toggle-divider { width: 32px; height: 32px; padding: 0; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); font-size: 0; }
.toggle-divider::before { content: "☰"; font-size: 16px; }
.toggle-divider:hover { background: var(--surface-soft); color: var(--brand-text); }
.title-actions { display: flex; gap: 7px; align-items: center; flex-shrink: 0; }
.theme-toggle { height: 32px; min-width: 33px; padding: 0 8px; border: 1px solid var(--border); border-radius: 8px; color: var(--secondary); background: var(--surface); font-size: 11px; }
.theme-toggle:hover { border-color: var(--brand); color: var(--brand-text); background: var(--brand-soft); }
.service-label { display: flex; gap: 7px; align-items: center; color: var(--secondary); font-size: 11px; margin-right: 8px; }
#chatDom { flex: 1; min-height: 0; overflow: auto; overflow-x: hidden; padding: 30px 38px 20px; }
.ai-welcome-card, .hot-questions, .recent-chat { max-width: 760px; margin-left: auto; margin-right: auto; }
.ai-welcome-card { padding-top: 22px; }
.ai-welcome-top { display: flex; flex-direction: column; align-items: center; text-align: center; }
.ai-avatar { display: grid; place-items: center; width: 52px; height: 52px; border-radius: 15px; color: var(--brand-text); background: var(--brand-soft); margin-bottom: 18px; }
.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); }
.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); }
.suggest-icon { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 10px; color: var(--brand-text); background: var(--brand-soft); flex-shrink: 0; }
.suggest-icon svg { width: 19px; height: 19px; }
.suggest-copy { min-width: 0; }
.suggest-copy strong { display: block; font-size: 14px; font-weight: 600; }
.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; }
.hot-capsule-wrap { display: flex; flex-wrap: wrap; gap: 7px; }
.hot-capsule { display: inline-flex; align-items: center; border: 1px solid var(--border); background: var(--surface); border-radius: 7px; padding: 6px 10px; color: var(--secondary); font-size: 11px; cursor: pointer; }
.hot-capsule:hover { color: var(--brand-text); background: var(--brand-soft); border-color: #a8bfef; }
.recent-chat { margin-top: 24px; }
#recentChatWrap { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 18px; }
.recent-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); padding: 9px 0; font-size: 11px; color: var(--secondary); cursor: pointer; }
.recent-item > span:first-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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; }
.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); }
.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; }
.author-mark { width: 26px; height: 26px; display: grid; place-items: center; color: var(--brand-text); background: var(--brand-soft); border-radius: 8px; }
.author-mark svg { width: 16px; height: 16px; }
.user-message, .robot-message { max-width: 88%; border-radius: 12px; padding: 14px 17px; font-size: 1em; line-height: 1.8; overflow-wrap: anywhere; min-width: 0; }
.user-message { background: var(--brand); color: white; border-top-right-radius: 4px; white-space: pre-wrap; }
.robot-message { background: var(--surface-soft); border: 1px solid var(--border); color: var(--text); border-top-left-radius: 4px; }
.msg-time { color: var(--muted); font-size: 10px; padding: 0 3px; }
.robot-message p { margin: 4px 0; }
.robot-message ul, .robot-message ol { margin: 7px 0 7px 20px; padding: 0; }
.robot-message li { margin: 5px 0; }
.robot-message code { background: var(--surface-hover); color: var(--brand-text); padding: 2px 5px; border-radius: 4px; font-size: .93em; }
.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; }
.hint-bubble, .stopped-hint { color: var(--muted); font-size: 11px; }
.followup-list { display: grid; gap: 6px; margin-top: 13px; }
.followup-item { cursor: pointer; border: 1px solid var(--border); border-radius: 7px; padding: 6px 10px; color: var(--brand-text); font-size: 11px; background: var(--surface); }
.followup-item:hover { border-color: var(--brand); }
.robot-message.track-card { width: min(560px, 100%); max-width: 100%; background: var(--surface); padding: 16px 20px; }
.track-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 14px; }
.track-no { font-weight: 650; font-size: 17px; letter-spacing: .02em; }
.track-no span { display: block; font-size: 10px; letter-spacing: 0; font-weight: 400; color: var(--muted); margin-bottom: 3px; }
.track-status { padding: 4px 10px; color: var(--success); background: var(--success-soft); border-radius: 6px; font-size: 12px; white-space: nowrap; }
.track-node { position: relative; padding: 0 0 12px 22px; font-size: 13px; color: var(--secondary); }
.track-node::before { content: ""; position: absolute; top: 12px; bottom: 0; left: 3px; width: 1px; background: var(--border); }
.track-node:last-child { padding-bottom: 0; }
.track-node:last-child::before { display: none; }
.track-dot { position: absolute; left: 0; top: 7px; width: 7px; height: 7px; border: 2px solid var(--muted); border-radius: 50%; background: var(--surface); }
.track-node.active .track-dot { border-color: var(--brand); background: var(--brand); box-shadow: 0 0 0 4px var(--brand-soft); }
.track-node.active .track-text { color: var(--text); font-weight: 600; }
.track-text { display: grid; grid-template-columns: 126px 1fr; column-gap: 12px; line-height: 1.7; }
.track-event { font-size: 13px; font-weight: 500; }
.active .track-event { font-weight: 650; }
.track-time { color: var(--muted); font-size: 11px; font-weight: 400; padding-top: 2px; }
.track-place { grid-column: 2; color: var(--muted); font-size: 11px; font-weight: 400; margin-top: 2px; }
.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); }
.typing::after { content: " ···"; color: var(--brand-text); }
.sidebar-backdrop { display: none; }
.attachment-preview { display: flex; align-items: center; gap: 12px; padding: 10px 12px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-soft); color: var(--text); }
.attachment-preview img { height: 42px; width: 42px; object-fit: cover; border: 1px solid var(--border); border-radius: 7px; }
.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-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; }
.composer-status.is-error { color: var(--danger); }
.message-image { display: block; max-width: min(240px, 100%); max-height: 320px; object-fit: contain; border-radius: 8px; }
.message-file { display: block; max-width: 100%; overflow-wrap: anywhere; color: inherit; padding: 6px 0; font-size: 13px; text-underline-offset: 3px; }
@media (min-width: 901px) and (max-height: 780px) {
#chatDom { padding-top: 22px; }
.ai-welcome-card { padding-top: 0; }
.ai-avatar { width: 42px; height: 42px; margin-bottom: 12px; }
.welcome-eyebrow { margin-bottom: 6px; }
.suggest-grid { margin-top: 20px; gap: 10px; }
.suggest-item { min-height: 72px; padding: 13px 16px; }
.hot-questions { margin-top: 18px; }
}
@media (min-width: 1500px) { #chatDom { padding-top: 48px; } .ai-welcome-card { padding-top: 35px; } }
@media (max-width: 1100px) {
body { padding: 12px; }
.left-panel { width: 220px; padding-left: 14px; padding-right: 14px; }
#chatDom { padding: 22px 25px; }
.composer { width: calc(100% - 50px); }
.service-label { display: none; }
.suggest-item { padding: 13px; gap: 10px; }
}
@media (max-width: 900px) {
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: 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: 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); 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; }
#app-title { font-size: 13px; }
.chat-title { padding: 12px; gap: 8px; }
.title-actions { gap: 4px; }
.theme-toggle { padding: 0 6px; min-width: 29px; font-size: 10px; }
#font-toggle { display: none; }
#chatDom { padding: 20px 16px; }
.ai-avatar { display: none; }
.ai-welcome-card { padding-top: 12px; }
.ai-welcome-card h3 { font-size: 23px; }
.suggest-grid { gap: 9px; margin-top: 22px; }
.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-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; }
.hot-questions .hot-capsule { flex-shrink: 0; white-space: nowrap; }
.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; }
.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; }
#input-text { font-size: 16px; }
.attachment-remove { width: 40px; height: 40px; }
.track-text { grid-template-columns: 1fr; gap: 2px; }
.track-place { grid-column: 1; }
.robot-message.track-card { padding: 16px; }
.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; }
}
+92
View File
@@ -0,0 +1,92 @@
/* Shared visual tokens and accessible controls. */
:root {
color-scheme: light;
--page: #edf1f7;
--surface: #ffffff;
--surface-soft: #f6f8fc;
--surface-hover: #edf2fa;
--text: #19263d;
--secondary: #566780;
--muted: #64748b;
--border: #e2e8f1;
--brand: #315fd5;
--brand-hover: #254fbe;
--brand-soft: #edf2ff;
--brand-text: #315fd5;
--success: #148462;
--success-soft: #eaf7f1;
--danger: #c74650;
--danger-soft: #fff0f1;
--shadow: 0 10px 40px rgba(28, 47, 80, .06);
}
body.dark {
color-scheme: dark;
--page: #0c1422;
--surface: #151f30;
--surface-soft: #1a273c;
--surface-hover: #21314b;
--text: #e7eef9;
--secondary: #b2c0d5;
--muted: #95a5bd;
--border: #2a3a53;
--brand: #426bd4;
--brand-hover: #4772db;
--brand-soft: #223759;
--brand-text: #a6c2ff;
--success: #75d7b3;
--success-soft: #193b35;
--danger: #ff9ca4;
--danger-soft: #412b38;
--shadow: 0 10px 40px rgba(0, 0, 0, .2);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: var(--page);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
button, input, textarea { font: inherit; }
button, a, input, textarea { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .65; }
button, input, textarea, a { outline-offset: 3px; }
:focus-visible { outline: 3px solid #92b3ff; }
button { transition: background .16s, border-color .16s, color .16s, box-shadow .16s; }
a { color: var(--brand-text); }
h1, h2, h3, h4, p { margin: 0; }
svg { display: block; flex-shrink: 0; }
[hidden] { display: none !important; }
::selection { background: #cddcff; color: #122b59; }
.brand { display: flex; align-items: center; gap: 11px; text-decoration: none; color: var(--text); }
.brand-mark {
display: grid; place-items: center; width: 38px; height: 38px;
background: var(--brand); color: white; border-radius: 11px; flex-shrink: 0;
}
.brand-mark svg { width: 23px; height: 23px; }
.brand-name { font-size: 17px; font-weight: 700; letter-spacing: .02em; line-height: 1.3; }
.brand-caption { display: block; color: var(--muted); font-size: 9px; font-weight: 600; letter-spacing: .12em; margin-top: 3px; }
.eyebrow { font-size: 11px; font-weight: 600; letter-spacing: .1em; color: var(--brand-text); }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); display: inline-block; flex-shrink: 0; }
.icon-button {
display: inline-grid; place-items: center; width: 36px; height: 36px;
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; }
::-webkit-scrollbar-track { background: transparent; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
+421
View File
@@ -0,0 +1,421 @@
var curSid = 0;
var sessions = {};
var drafts = {};
var queueItems = [];
var accepting = new Set();
var sendingSid = "";
var closingSid = "";
var polling = false;
var listSignature = "";
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){
return {"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"}[character];
});
}
async function adminRequest(path, options){
const response = await fetch(path, options);
if(!response.ok) throw new Error("HTTP " + response.status);
const data = await response.json();
if(data && data.ok === false) throw new Error("Request rejected");
return data;
}
function postAdmin(path, data){
return adminRequest(path, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(data)});
}
function setReplyStatus(message, isError){
const status = document.getElementById("reply-status");
status.textContent = message || "";
status.hidden = !message;
status.classList.toggle("is-error", Boolean(isError));
}
function updateComposer(){
document.body.classList.toggle("has-active-session", Boolean(curSid));
const send = document.getElementById("reply-send");
send.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
send.textContent = sendingSid === curSid ? "发送中…" : "发送";
const close = document.getElementById("close-session");
close.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
close.textContent = closingSid === curSid ? "正在结束…" : "结束会话";
document.querySelectorAll(".quick").forEach(function(button){
button.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
});
updateAdminTools();
}
function resizeReply(){
replyInput.style.height = "auto";
replyInput.style.height = Math.min(replyInput.scrollHeight, 180) + "px";
}
function setSessionTab(tab){
const active = tab !== "queue";
document.getElementById("sessionList").hidden = !active;
document.getElementById("queue").hidden = active;
document.getElementById("tab-active").setAttribute("aria-selected", String(active));
document.getElementById("tab-queue").setAttribute("aria-selected", String(!active));
document.getElementById("tab-active").tabIndex = active ? 0 : -1;
document.getElementById("tab-queue").tabIndex = active ? -1 : 0;
}
function setChatView(open){
document.body.classList.toggle("chat-view", Boolean(open && curSid));
}
function showSessionList(){
setChatView(false);
document.getElementById("tab-active").focus();
}
function searchTerm(){
return document.getElementById("session-search").value.trim().toLowerCase();
}
function renderQueue(){
const term = searchTerm();
const visible = queueItems.filter(function(item){
return ("#" + item.id + " " + item.ctime).toLowerCase().includes(term);
});
document.getElementById("queue-count").textContent = queueItems.length;
document.getElementById("session-browser").classList.toggle("queue-empty", queueItems.length === 0);
document.getElementById("queue").innerHTML = visible.length
? visible.map(function(item){
const sid = Number(item.id);
return '<div class="queue-item"><div class="qt">对话 #'+sid+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
}).join("")
: '<div class="list-empty">'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'</div>';
}
async function loadQueue(){
try{
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>';
}
}
async function loadTickets(){
const element = document.getElementById("ticketList");
try{
const list = await adminRequest("/api/tickets");
element.innerHTML = list.length ? list.map(function(ticket){
return '<div class="ticket-item">'+escapeText(ticket.content)+'<span>'+escapeText(ticket.ctime)+'</span></div>';
}).join("") : '<div class="list-empty">暂时没有客户留言</div>';
}catch(error){
element.innerHTML = '<div class="list-empty">留言暂时无法加载</div>';
}
}
async function accept(sid){
sid = String(sid);
if(sessions[sid]){ switchSession(sid); return; }
if(accepting.has(sid)) return;
accepting.add(sid);
try{
await postAdmin("/api/human/accept", {sid:Number(sid)});
if(!sessions[sid]) sessions[sid] = {lastId:0, msgs:[], unread:0};
switchSession(sid);
loadQueue();
poll();
}catch(error){
document.getElementById("queue").innerHTML = '<div class="list-empty">接入失败,请稍后重试</div>';
}finally{
accepting.delete(sid);
}
}
function switchSession(sid){
sid = String(sid);
if(!sessions[sid]) return;
if(curSid) drafts[curSid] = replyInput.value;
curSid = sid;
document.getElementById("emptyTip").style.display = "none";
document.getElementById("chatContent").style.display = "flex";
setChatView(true);
document.getElementById("chatTitle").textContent = "客户 #" + sid;
document.getElementById("chat-subtitle").textContent = "正在接待 · 人工会话";
document.getElementById("cInfo").textContent = "会话编号:#" + sid;
document.getElementById("chatBox").innerHTML = "";
sessions[sid].msgs.forEach(appendMsg);
sessions[sid].unread = 0;
replyInput.value = drafts[sid] || "";
resizeReply();
document.getElementById("chatBox").scrollTop = document.getElementById("chatBox").scrollHeight;
setReplyStatus("");
loadSessionNote(sid);
updateComposer();
renderList();
setSessionTab("active");
postAdmin("/api/human/read", {sid:Number(sid)}).catch(function(){});
}
function showEmptySession(){
curSid = 0;
clearTimeout(typeTimer);
document.getElementById("chatContent").style.display = "none";
document.getElementById("emptyTip").style.display = "flex";
document.getElementById("cInfo").textContent = "选择会话后查看客户信息";
document.getElementById("chatTitle").textContent = "会话接待";
document.getElementById("chat-subtitle").textContent = "选择一段会话,开始回复客户";
replyInput.value = "";
document.getElementById("noteInput").value = "";
setReplyStatus("");
updateComposer();
setChatView(false);
}
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="/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;
}
function renderList(){
const term = searchTerm();
const ids = Object.keys(sessions);
document.getElementById("active-count").textContent = ids.length;
const signature = JSON.stringify([curSid, term, ids.map(function(sid){
const session = sessions[sid];
const last = session.msgs[session.msgs.length-1];
return [sid, session.unread, last && last.id, last && last.content];
})]);
if(signature === listSignature) return;
listSignature = signature;
const element = document.getElementById("sessionList");
element.innerHTML = "";
const visible = ids.filter(function(sid){
return ("对话 #"+sid).includes(term) || sessions[sid].msgs.some(function(message){
return message.content.toLowerCase().includes(term);
});
});
if(!visible.length){
element.innerHTML = '<div class="list-empty">'+(term ? "没有匹配的进行中会话" : "暂无进行中的会话")+'</div>';
}
visible.forEach(function(sid){
const session = sessions[sid];
const last = session.msgs[session.msgs.length-1];
const preview = last ? (last.side === "agent" ? "客服:" : "") + last.content.substring(0, 36) : "等待客户消息";
const div = document.createElement("button");
div.type = "button";
div.className = "conv-item" + (sid === curSid ? " active" : "");
div.setAttribute("aria-pressed", String(sid === curSid));
div.innerHTML = '<span class="avatar">客</span><span class="info"><span class="conv-title"><span class="name">客户 #'+sid+'</span><span class="conv-time">'+(last && last.ctime ? escapeText(last.ctime.slice(11,16)) : '')+'</span></span><span class="preview">'+escapeText(preview)+'</span></span>'+(session.unread ? '<span class="badge">'+session.unread+'</span>' : '');
div.onclick = function(){ switchSession(sid); };
element.appendChild(div);
});
}
async function poll(){
if(polling) return;
polling = true;
try{
const list = await adminRequest("/api/human/active");
list.forEach(function(session){
if(!sessions[String(session.id)]) sessions[String(session.id)] = {lastId:0, msgs:[], unread:0};
});
await Promise.all(Object.keys(sessions).map(async function(sid){
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 !== "active"){
delete sessions[sid];
delete drafts[sid];
if(sid === curSid) showEmptySession();
return;
}
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);
});
}));
document.getElementById("sync-status").hidden = true;
renderList();
}catch(error){
document.getElementById("sync-status").hidden = false;
}finally{
polling = false;
}
}
async function sendReply(){
if(!curSid || sendingSid || closingSid) return;
const sid = curSid;
const draft = replyInput.value;
if(!draft.trim()) return;
drafts[sid] = draft;
sendingSid = sid;
setReplyStatus("");
updateComposer();
try{
await postAdmin("/api/human/send", {sid:Number(sid), side:"agent", content:draft.trim()});
if(drafts[sid] === draft) drafts[sid] = "";
if(curSid === sid && replyInput.value === draft){
replyInput.value = "";
resizeReply();
}
if(curSid === sid){ replyInput.focus(); setReplyStatus("已发送"); }
poll();
}catch(error){
if(curSid === sid) setReplyStatus("发送未成功,内容已保留。请确认后重试。", true);
}finally{
sendingSid = "";
updateComposer();
}
}
function quickReply(text){
if(!curSid || sendingSid || closingSid) return;
replyInput.value = text;
drafts[curSid] = text;
resizeReply();
setReplyStatus("常用回复已填入,可编辑后发送");
if(toolsOpen) setToolsOpen(false);
replyInput.focus();
}
async function closeSession(){
if(!curSid || closingSid || sendingSid) return;
const sid = curSid;
closingSid = sid;
updateComposer();
try{
await postAdmin("/api/human/close", {sid:Number(sid)});
delete sessions[sid];
delete drafts[sid];
if(curSid === sid) showEmptySession();
renderList();
}catch(error){
if(curSid === sid) setReplyStatus("结束失败,会话仍保留,请稍后重试。", true);
}finally{
closingSid = "";
updateComposer();
}
}
function setToolsOpen(open){
const opening = Boolean(open) && !toolsOpen;
toolsOpen = Boolean(open);
document.body.classList.toggle("tools-open", toolsOpen);
document.getElementById("tools-toggle").setAttribute("aria-expanded", String(toolsOpen));
document.getElementById("tools-from-list").setAttribute("aria-expanded", String(toolsOpen));
const panel = document.getElementById("service-tools");
panel.setAttribute("aria-hidden", String(!toolsOpen));
panel.toggleAttribute("inert", !toolsOpen);
if(opening){
document.getElementById("data-overview").open = false;
document.getElementById("tools-close").focus();
}
}
replyInput.addEventListener("keydown", function(event){
if(event.key === "Enter" && !event.shiftKey && !event.isComposing){
event.preventDefault();
sendReply();
}
});
replyInput.addEventListener("input", function(){
resizeReply();
if(!curSid) return;
drafts[curSid] = replyInput.value;
setReplyStatus("");
const sid = curSid;
postAdmin("/api/human/typing", {sid:Number(sid)}).catch(function(){});
clearTimeout(typeTimer);
typeTimer = setTimeout(function(){
postAdmin("/api/human/stop_typing", {sid:Number(sid)}).catch(function(){});
}, 3000);
});
document.getElementById("session-search").addEventListener("input", function(){ renderList(); renderQueue(); });
document.getElementById("sessions-back").onclick = showSessionList;
document.getElementById("tab-active").onclick = function(){ setSessionTab("active"); };
document.getElementById("tab-queue").onclick = function(){ setSessionTab("queue"); };
["tab-active", "tab-queue"].forEach(function(id){
document.getElementById(id).addEventListener("keydown", function(event){
if(!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" || (event.key !== "End" && id === "tab-queue") ? "tab-active" : "tab-queue";
setSessionTab(next === "tab-active" ? "active" : "queue");
document.getElementById(next).focus();
});
});
document.getElementById("tools-toggle").onclick = function(){ toolsOpener = "tools-toggle"; setToolsOpen(!toolsOpen); };
document.getElementById("tools-from-list").onclick = function(){ toolsOpener = "tools-from-list"; setToolsOpen(!toolsOpen); };
document.getElementById("tools-close").onclick = function(){
setToolsOpen(false);
const fromList = toolsOpener === "tools-from-list" && !document.body.classList.contains("chat-view") && window.matchMedia("(max-width: 720px)").matches;
document.getElementById(fromList ? "tools-from-list" : "tools-toggle").focus();
};
document.getElementById("tools-backdrop").onclick = document.getElementById("tools-close").onclick;
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], 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(); }
}
});
window.addEventListener("resize", function(){ setToolsOpen(toolsOpen); });
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();
loadQueue();
loadTickets();
poll();
setInterval(loadQueue, 3000);
setInterval(loadTickets, 5000);
setInterval(poll, 1000);
+58
View File
@@ -0,0 +1,58 @@
const loginForm = document.getElementById("loginForm");
const loginButton = document.getElementById("loginButton");
const userInput = document.getElementById("user");
const passwordInput = document.getElementById("pwd");
const loginError = document.getElementById("err");
let loginPending = false;
function showLoginError(message){
loginError.textContent = message;
loginError.hidden = false;
}
async function login(){
if(loginPending) return;
const username = userInput.value.trim();
const password = passwordInput.value;
userInput.setAttribute("aria-invalid", String(!username));
passwordInput.setAttribute("aria-invalid", String(!password));
if(!username || !password){
showLoginError("请输入客服账号和登录密码");
(!username ? userInput : passwordInput).focus();
return;
}
loginPending = true;
loginError.hidden = true;
loginButton.disabled = true;
loginButton.textContent = "正在登录…";
loginForm.setAttribute("aria-busy", "true");
try{
const response = await fetch("/admin/login", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({username: username, password: password})
});
if(response.ok){
location.href = "/admin/dashboard";
return;
}
showLoginError(response.status === 401 ? "账号或密码不正确,请重新输入" : "暂时无法登录,请稍后重试");
}catch(error){
showLoginError("网络连接失败,请检查网络后重试");
}finally{
loginPending = false;
loginButton.disabled = false;
loginButton.textContent = "登录工作台 →";
loginForm.removeAttribute("aria-busy");
}
}
loginForm.addEventListener("submit", function(event){
event.preventDefault();
login();
});
[userInput, passwordInput].forEach(function(input){
input.addEventListener("input", function(){
loginError.hidden = true;
input.removeAttribute("aria-invalid");
});
});
+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);
+2018
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)};
}
};
+95 -168
View File
@@ -1,174 +1,101 @@
<!DOCTYPE html>
<html lang="zh">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>柏悦物流 - 管理后台</title>
<style>
body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f4fa;margin:0;padding:20px;}
h1{color:#18479c;}
.card{background:#fff;border-radius:12px;padding:20px;margin-bottom:16px;box-shadow:0 2px 8px rgba(0,0,0,0.06);}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;}
.stat{background:linear-gradient(135deg,#e8f0ff,#fff);border-radius:10px;padding:14px;text-align:center;}
.stat .num{font-size:24px;font-weight:bold;color:#2978e8;}
.stat .label{font-size:12px;color:#666;}
.layout{display:grid;grid-template-columns:220px 1fr;gap:16px;}
#sessionList{max-height:400px;overflow-y:auto;}
.session-item{padding:10px;border-radius:8px;cursor:pointer;margin-bottom:6px;background:#f5f8fc;font-size:13px;}
.session-item.active{background:#2978e8;color:white;}
.session-item .badge{background:#dc2626;color:white;border-radius:10px;padding:1px 6px;font-size:11px;margin-left:4px;}
#chatBox{height:350px;overflow-y:auto;background:#f8fafc;border-radius:8px;padding:10px;margin-bottom:10px;}
.msg{margin-bottom:8px;padding:6px 10px;border-radius:8px;max-width:70%;font-size:14px;}
.cust{background:#dbeafe;margin-right:auto;}
.agent{background:#dcfce7;margin-left:auto;}
.btn{background:#2978e8;color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px;}
.btn:hover{background:#18479c;}
.btn.red{background:#dc2626;}
input[type=text]{width:75%;padding:8px;border:1px solid #ddd;border-radius:6px;}
table{width:100%;border-collapse:collapse;font-size:13px;}
th,td{padding:6px 10px;border-bottom:1px solid #eee;text-align:left;}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>客服工作台 · 柏悦物流</title>
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
</head>
<body>
<h1>📊 柏悦物流客服后台</h1>
<div class="grid">
<div class="stat"><div class="num" id="s1">-</div><div class="label">总对话数</div></div>
<div class="stat"><div class="num" id="s2">-</div><div class="label">总消息数</div></div>
<div class="stat"><div class="num" id="s3">-</div><div class="label">今日新增</div></div>
<div class="stat"><div class="num" id="s4">-</div><div class="label">👍</div></div>
<div class="stat"><div class="num" id="s5">-</div><div class="label">👎</div></div>
</div>
<div class="layout">
<div class="card">
<h3>进行中对话</h3>
<div id="sessionList"></div>
<h3 style="margin-top:16px;">待接入</h3>
<div id="queue"></div>
</div>
<div class="card" id="chatCard" style="display:none;">
<h3 id="chatTitle">对话</h3>
<div id="chatBox"></div>
<input type="text" id="replyText" placeholder="输入回复,Enter发送...">
<button class="btn" onclick="sendReply()">发送</button>
<button class="btn red" onclick="closeSession()">结束会话</button>
</div>
</div>
<div class="card">
<h3>最近对话</h3>
<table><thead><tr><th>ID</th><th>标题</th><th>时间</th></tr></thead><tbody id="tbl"></tbody></table>
</div>
<a href="/">← 返回客服页</a>
<script>
let curSid = 0;
let lastId = 0;
const sessions = {}; // sid -> {lastId, msgs:[]}
fetch("/api/admin/stats").then(r=>r.json()).then(d=>{
s1.textContent=d.total_conv; s2.textContent=d.total_msg;
s3.textContent=d.today_conv; s4.textContent=d.good; s5.textContent=d.bad;
});
function loadQueue(){
fetch("/api/human/queue").then(r=>r.json()).then(list=>{
document.getElementById("queue").innerHTML = list.length
? list.map(x=>`<button class="btn" onclick="accept(${x.id})">#${x.id} ${x.ctime}</button>`).join(" ")
: "暂无";
});
}
loadQueue(); setInterval(loadQueue, 3000);
// 自动接入等待的客户
setInterval(()=>{
fetch("/api/human/queue").then(r=>r.json()).then(list=>{
if(list.length && !sessions[list[0].id]){
accept(list[0].id);
}
});
}, 3000);
function accept(sid){
fetch("/api/human/accept",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid})}).then(()=>{
sessions[sid] = {lastId:0};
switchSession(sid);
});
}
function switchSession(sid){
curSid = sid;
lastId = sessions[sid].lastId;
document.getElementById("chatCard").style.display="block";
document.getElementById("chatTitle").textContent="对话 #"+sid;
// 渲染已有消息
document.getElementById("chatBox").innerHTML = "";
sessions[sid].msgs.forEach(m=>{
const div=document.createElement("div");
div.className="msg "+(m.side==="customer"?"cust":"agent");
div.textContent=(m.side==="customer"?"客户: ":"客服: ")+m.content;
document.getElementById("chatBox").appendChild(div);
});
renderSessionList();
}
function renderSessionList(){
const el=document.getElementById("sessionList");
el.innerHTML="";
Object.keys(sessions).forEach(sid=>{
const div=document.createElement("div");
div.className="session-item"+(sid==curSid?" active":"");
div.textContent="对话 #"+sid;
div.onclick=()=>switchSession(sid);
el.appendChild(div);
});
}
function poll(){
Object.keys(sessions).forEach(sid=>{
fetch("/api/human/poll?sid="+sid+"&after="+sessions[sid].lastId).then(r=>r.json()).then(d=>{
d.messages.forEach(m=>{
sessions[sid].lastId=m.id;
sessions[sid].msgs.push(m);
if(sid==curSid){
const div=document.createElement("div");
div.className="msg "+(m.side==="customer"?"cust":"agent");
div.textContent=(m.side==="customer"?"客户: ":"客服: ")+m.content;
document.getElementById("chatBox").appendChild(div);
document.getElementById("chatBox").scrollTop=999999;
}
});
});
});
setTimeout(poll, 2000);
}
poll();
function sendReply(){
const t=document.getElementById("replyText").value.trim();
if(!t || !curSid) return;
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:curSid,side:"agent",content:t})});
document.getElementById("replyText").value="";
}
function closeSession(){
fetch("/api/human/close",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:curSid})});
delete sessions[curSid];
curSid=0;
document.getElementById("chatCard").style.display="none";
renderSessionList();
}
document.getElementById("replyText").addEventListener("keydown", function(e){
if(e.key==="Enter"){ e.preventDefault(); sendReply(); }
});
fetch("/api/get_history").then(r=>r.json()).then(list=>{
const tb=document.getElementById("tbl");
list.slice(0,20).forEach(c=>{
tb.innerHTML+=`<tr><td>${c.id}</td><td>${c.title}</td><td>${c.utime||c.ctime}</td></tr>`;
});
});
</script>
<header class="topbar">
<div class="app-identity">
<a class="brand" href="/admin/dashboard"><span class="brand-mark"><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></span><span class="brand-name">柏悦物流</span></a>
<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>
<div class="stats" aria-label="历史对话统计">
<h2>历史对话与反馈</h2>
<div class="stat"><span>历史对话</span><b id="s1">—</b></div>
<div class="stat"><span>历史消息</span><b id="s2">—</b></div>
<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>
<div class="stat"><span>服务评价</span><b id="s6">—</b></div>
<a class="export-link" href="/api/export_chat">导出 Excel ↗</a>
</div>
</details>
</div>
</header>
<div id="sync-status" class="sync-status" role="status" hidden>会话连接中断,正在重试…</div>
<main class="layout">
<aside class="conv-list" id="session-browser" aria-label="会话列表">
<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>
<button id="tab-queue" type="button" role="tab" aria-selected="false" aria-controls="queue" tabindex="-1">排队中 <span id="queue-count" class="section-count">0</span></button>
</div>
<div class="session-scroll">
<div id="sessionList" role="tabpanel" aria-labelledby="tab-active"><div class="list-empty">暂无进行中的会话</div></div>
<div id="queue" role="tabpanel" aria-labelledby="tab-queue" hidden><div class="list-empty">正在加载会话…</div></div>
</div>
<div class="list-footer"><span><span class="list-footer-mark" aria-hidden="true"></span>柏悦物流 · 客户服务</span><button id="tools-from-list" type="button" aria-controls="service-tools" aria-expanded="false">服务资料</button></div>
</aside>
<section class="chat-area" id="chatArea" aria-label="会话内容">
<header class="chat-header">
<button id="sessions-back" class="sessions-back" type="button" aria-label="返回会话列表">‹</button>
<div class="participant-avatar" aria-hidden="true">客</div>
<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>
<div class="empty" id="emptyTip"><div>
<div class="empty-illustration"><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>
<h2>选择会话,开始接待</h2><p>在会话列表查看客户咨询,<br>新咨询可从「排队中」接入。</p>
</div></div>
<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><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>
</div>
<p id="reply-status" class="reply-status" role="status" hidden></p>
</footer>
</div>
</section>
<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>
<button type="button" class="quick" onclick="quickReply('请提供运单号,我帮您查询。')">请提供运单号,我帮您查询</button>
<button type="button" class="quick" onclick="quickReply('已为您催促,请稍候。')">已为您催促,请稍候</button>
<button type="button" class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询,祝您生活愉快</button>
</section>
<section class="card"><h3>离线留言</h3><div id="ticketList"><div class="list-empty">正在加载留言…</div></div></section>
</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>
+40
View File
@@ -0,0 +1,40 @@
<!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="icon" href="{{ url_for('static', filename='fa.jpg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin_login.css') }}">
</head>
<body class="auth-page">
<main class="auth-shell">
<section class="auth-story" aria-label="柏悦物流客服中心">
<a class="brand" href="/"><span class="brand-mark"><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></span><span><span class="brand-name">柏悦物流</span><span class="brand-caption">BAIYUE LOGISTICS</span></span></a>
<div class="story-copy"><div class="eyebrow">CONNECT EVERY JOURNEY</div><h1>让每一程物流,<br>都有及时的回应。</h1><p>从咨询到交付,把客户沟通集中在一个工作台,让服务始终有条不紊。</p></div>
<div class="service-points">
<div class="service-point"><span aria-hidden="true">01</span><div><strong>会话集中管理</strong><p>接待客户咨询,查看历史沟通内容</p></div></div>
<div class="service-point"><span aria-hidden="true">02</span><div><strong>人工与智能协作</strong><p>衔接处理报价、轨迹与物流异常咨询</p></div></div>
<div class="service-point"><span aria-hidden="true">03</span><div><strong>消息及时流转</strong><p>关注未读会话,跟进离线留言</p></div></div>
</div>
<div class="route-line"><span>起点</span><i aria-hidden="true"></i><span>运输途中</span><i aria-hidden="true"></i><span>每一次抵达</span></div>
</section>
<section class="auth-content">
<a class="brand mobile-brand" href="/"><span class="brand-mark"><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></span><span><span class="brand-name">柏悦物流</span><span class="brand-caption">BAIYUE LOGISTICS</span></span></a>
<div class="box">
<div class="eyebrow">客服中心</div><h2>欢迎回来</h2><p class="login-description">请使用您的客服账号,登录柏悦物流工作台。</p>
<form id="loginForm" novalidate>
<label class="field-label" for="user">客服账号</label><div class="field-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/></svg><input type="text" id="user" placeholder="请输入客服账号" autocomplete="username" required></div>
<label class="field-label" for="pwd">登录密码</label><div class="field-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3"/></svg><input type="password" id="pwd" placeholder="请输入登录密码" autocomplete="current-password" required></div>
<p class="err" id="err" role="alert" hidden></p>
<button id="loginButton" type="submit">登录工作台 →</button>
</form>
<a class="login-back" href="/">← 返回客户服务入口</a>
</div>
<footer class="auth-footer">柏悦物流 · 客服工作台</footer>
</section>
</main>
<script src="{{ url_for('static', filename='js/admin_login.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>
+75 -2451
View File
File diff suppressed because it is too large Load Diff
+299
View File
@@ -0,0 +1,299 @@
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 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){
return {ok: status >= 200 && status < 300, status, json: async () => data};
}
async function workspace(compact = false){
const elements = new Map();
const documentListeners = {};
let document;
class Element {
constructor(){
this.value = ''; this.children = []; this.attributes = {}; this.listeners = {};
this.style = {}; this.hidden = false; this.scrollHeight = 70;
this.scrollTop = 0; this.clientHeight = 300;
const classes = new Set();
this.classList = {
toggle: (name, enabled) => { enabled ? classes.add(name) : classes.delete(name); },
contains: name => classes.has(name),
};
}
set innerHTML(value){ this.html = value; this.children = []; }
get innerHTML(){ return this.html || ''; }
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)){ 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 : []; },
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(){}, 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);
await flush();
return {context, element: id => document.getElementById(id), calls, hooks, document, documentListeners};
}
test('failed replies preserve the draft and make retry available', async () => {
const app = await workspace();
app.hooks.send = () => response({ok:false}, 503);
app.element('replyText').value = '请确认这批货物的外箱尺寸';
await app.context.sendReply();
assert.equal(app.element('replyText').value, '请确认这批货物的外箱尺寸');
assert.equal(app.element('reply-send').disabled, false);
assert.match(app.element('reply-status').textContent, /内容已保留/);
app.hooks.send = () => response({ok:true});
await app.context.sendReply();
assert.equal(app.element('replyText').value, '');
});
test('a second send click cannot duplicate an in-flight reply', async () => {
const app = await workspace();
let resolve;
app.hooks.send = () => new Promise(done => { resolve = done; });
app.element('replyText').value = '正在确认线路';
const first = app.context.sendReply();
await app.context.sendReply();
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
assert.equal(app.element('reply-send').disabled, true);
resolve(response({ok:true}));
await first;
});
test('new text typed while a reply is sending is retained', async () => {
const app = await workspace();
let resolve;
app.hooks.send = () => new Promise(done => { resolve = done; });
const input = app.element('replyText');
input.value = '第一条回复';
const sending = app.context.sendReply();
input.value = '继续补充的第二条回复';
input.listeners.input();
resolve(response({ok:true}));
await sending;
assert.equal(input.value, '继续补充的第二条回复');
});
test('switching sessions during a send does not erase another customer draft', async () => {
const app = await workspace();
await app.context.accept(8);
app.element('replyText').value = '客户 8 的草稿';
app.context.switchSession('7');
app.element('replyText').value = '发给客户 7';
let resolve;
app.hooks.send = () => new Promise(done => { resolve = done; });
const sending = app.context.sendReply();
app.context.switchSession('8');
resolve(response({ok:true}));
await sending;
assert.equal(app.context.curSid, '8');
assert.equal(app.element('replyText').value, '客户 8 的草稿');
app.context.switchSession('7');
assert.equal(app.element('replyText').value, '');
});
test('quick replies become editable drafts before sending', async () => {
const app = await workspace();
app.context.quickReply('请提供运单号');
assert.equal(app.element('replyText').value, '请提供运单号');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.match(app.element('reply-status').textContent, /可编辑后发送/);
});
test('a failed close keeps the selected session and reply visible', async () => {
const app = await workspace();
app.hooks.close = () => response({ok:false}, 503);
await app.context.closeSession();
assert.equal(app.context.curSid, '7');
assert.equal(app.element('chatContent').style.display, 'flex');
assert.match(app.element('reply-status').textContent, /会话仍保留/);
});
test('IME confirmation and Shift+Enter do not submit a reply', async () => {
const app = await workspace();
app.element('replyText').value = '正在输入中文';
const onKey = app.element('replyText').listeners.keydown;
const preventDefault = () => {};
onKey({key:'Enter', isComposing:true, shiftKey:false, preventDefault});
onKey({key:'Enter', isComposing:false, shiftKey:true, preventDefault});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
onKey({key:'Enter', isComposing:false, shiftKey:false, preventDefault});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
});
test('the compact service drawer is keyboard accessible and closes on Escape', async () => {
const app = await workspace(true);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
app.context.setToolsOpen(true);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
assert.equal(app.document.activeElement, app.element('tools-close'));
app.documentListeners.keydown({key:'Escape'});
assert.equal(app.element('tools-toggle').attributes['aria-expanded'], 'false');
assert.equal(app.document.activeElement, app.element('tools-toggle'));
});
test('desktop service information starts closed and can be toggled on demand', async () => {
const app = await workspace();
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
app.context.setToolsOpen(true);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
assert.equal(app.element('tools-toggle').attributes['aria-expanded'], 'true');
app.context.setToolsOpen(false);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
});
test('returning to the mobile session list keeps the selected session and draft', async () => {
const app = await workspace(true);
app.element('replyText').value = '返回列表前的草稿';
app.context.showSessionList();
assert.equal(app.context.curSid, '7');
assert.equal(app.document.body.classList.contains('chat-view'), false);
assert.equal(app.document.activeElement, app.element('tab-active'));
assert.equal(app.element('replyText').value, '返回列表前的草稿');
app.context.switchSession('7');
assert.equal(app.document.body.classList.contains('chat-view'), true);
assert.equal(app.element('replyText').value, '返回列表前的草稿');
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 0);
});
test('mobile operators can open service information without selecting a conversation', async () => {
const app = await workspace(true);
app.context.showEmptySession();
app.element('tools-from-list').click();
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
app.element('tools-close').click();
assert.equal(app.document.activeElement, app.element('tools-from-list'));
});
test('mobile drafts are measured after the chat view becomes visible', async () => {
const app = await workspace(true);
const input = app.element('replyText');
Object.defineProperty(input, 'scrollHeight', {
get: () => app.document.body.classList.contains('chat-view') ? 128 : 0,
});
input.value = '需要恢复的多行草稿';
app.context.showSessionList();
app.context.switchSession('7');
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);
});
+519
View File
@@ -0,0 +1,519 @@
"""HTTP and SSE contracts across the application factory and extracted services."""
import io
import json
import logging
from html.parser import HTMLParser
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import requests
from logistics_chat import create_app, db
class ModelResponse:
def __init__(self, text='物流测试回答,费用与时效请以报价表为准。', status=200):
self.content = text
self.status_code = status
self.closed = False
self.text = 'model error'
def iter_lines(self):
yield ('data: ' + json.dumps({'choices': [{'delta': {'content': self.content}}]})).encode()
yield b'data: [DONE]'
def json(self):
return {'choices': [{'message': {'content': self.content}}], 'error': {'message': 'model error'}}
def close(self):
self.closed = True
def frames(response):
return [json.loads(part[6:]) for part in response.get_data(as_text=True).split('\n\n') if part.startswith('data: ')]
class ApiTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
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):
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']
def test_pages_static_files_and_cache_headers(self):
for path in ('/', '/admin', '/admin/dashboard'):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertIn('no-store', response.headers['Cache-Control'])
with self.client.get('/static/fa.jpg') as image:
self.assertEqual(image.status_code, 200)
def test_page_styles_and_scripts_are_accessible(self):
class Assets(HTMLParser):
def __init__(self):
super().__init__()
self.urls = []
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'script' and attrs.get('src'):
self.urls.append(attrs['src'])
if tag == 'link' and attrs.get('rel') == 'stylesheet':
self.urls.append(attrs['href'])
for path in ('/', '/admin', '/admin/dashboard'):
parser = Assets()
parser.feed(self.client.get(path).get_data(as_text=True))
self.assertGreaterEqual(len(parser.urls), 3)
for url in parser.urls:
resource = self.client.get(url)
self.assertEqual(resource.status_code, 200, url)
self.assertGreater(len(resource.data), 0)
resource.close()
def test_history_card_roundtrip_and_deletion(self):
cid = self.conversation()
response = self.client.post('/stream_chat', json={'msg': '15252125', 'conv_id': cid})
events = frames(response)
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), 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])
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):
attachment = '[文件]/uploads/20261029100145_6787.txt'
events = frames(self.client.post('/stream_chat', json={'msg': '请查看附件 ' + attachment}))
self.assertFalse(any('card' in event for event in events))
self.assertIn('API Key', events[0]['e'])
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[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)
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'})
self.client.post('/api/human/typing', json={'sid': sid})
state = self.client.get(f'/api/human/poll?sid={sid}&after=0').json
self.assertEqual(state['status'], 'active')
self.assertTrue(state['typing'])
self.assertFalse(state['messages'][0]['read'])
self.client.post('/api/human/read', json={'sid': sid})
self.client.post('/api/human/stop_typing', json={'sid': sid})
state = self.client.get(f'/api/human/poll?sid={sid}').json
self.assertTrue(state['messages'][0]['read'])
self.assertFalse(state['typing'])
self.client.post('/api/human/close', json={'sid': sid})
self.assertEqual(self.client.get('/api/human/active').json, [])
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'closed')
def test_application_state_is_not_shared_between_factories(self):
self.app.extensions['human_typing']['1'] = 123
self.app.extensions['rate_hits']['127.0.0.1'].append(123)
other = self.application(self.root/'other')
self.assertEqual(other.extensions['human_typing'], {})
self.assertEqual(dict(other.extensions['rate_hits']), {})
def test_factories_with_different_log_directories_do_not_cross_write(self):
applications = []
try:
for name in ('first', 'second'):
root = self.root/name
applications.append(create_app({'DATABASE': root/'history.db', 'UPLOAD_DIR': root/'uploads',
'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''}))
applications[0].logger.warning('first application only')
for app in applications:
for handler in app.logger.handlers:
handler.flush()
self.assertIn('first application only', (self.root/'first/logs/app.log').read_text())
self.assertNotIn('first application only', (self.root/'second/logs/app.log').read_text())
finally:
for app in applications:
for handler in list(app.logger.handlers):
if isinstance(handler, logging.FileHandler):
handler.close()
app.logger.removeHandler(handler)
def test_rate_limit_and_exemptions(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 2
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
self.assertEqual(self.client.get('/api/human/queue').status_code, 200)
with self.client.get('/static/fa.jpg') as image:
self.assertEqual(image.status_code, 200)
def test_attachment_roundtrip_and_rejection(self):
response = self.client.post('/api/upload', data={'file': (io.BytesIO(b'hello'), 'hello.txt')})
self.assertEqual(response.status_code, 200)
downloaded = self.client.get(response.json['url'])
self.assertEqual(downloaded.data, b'hello')
downloaded.close()
self.assertEqual(self.client.post('/api/upload', data={'file': (io.BytesIO(b'x'), 'x.exe')}).status_code, 400)
def test_health_check_remains_available_when_business_requests_are_rate_limited(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 1
self.assertEqual(self.client.get('/').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
response = self.client.get('/healthz')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {'status': 'ok'})
def test_feedback_stats_and_csv(self):
cid = self.conversation()
with self.app.app_context():
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?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()
tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
self.assertTrue({'agents', 'orders', 'tickets'}.issubset(tables))
connection.executescript(
"INSERT INTO agents VALUES('demo', 'test-password', 'Agent');"
"INSERT INTO orders VALUES(1, '123', '15252125', 'active', '2026-10-28');")
connection.commit()
connection.close()
self.assertEqual(self.client.post('/admin/login', json={'username': 'demo', 'password': 'wrong'}).status_code, 401)
self.assertEqual(self.client.post('/admin/login', json={'username': 'demo', 'password': 'test-password'}).json['name'], 'Agent')
self.assertEqual(self.client.get('/api/my_orders?phone=123').json['orders'][0]['track_no'], '15252125')
self.assertEqual(self.client.post('/api/ticket', json={'content': 'help', 'phone': '123'}).status_code, 200)
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], 'help')
def test_stream_compression_followups_and_deadlines(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
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()}))
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'
for error, expected in ((requests.exceptions.Timeout, '超时'),
(requests.exceptions.ConnectionError, '无法连接')):
with self.subTest(error=error):
with patch('logistics_chat.ai_client.requests.post', side_effect=error):
events = frames(self.client.post('/stream_chat', json={'msg': '物流咨询'}))
self.assertIn(expected, events[0]['e'])
denied = ModelResponse(status=401)
with patch('logistics_chat.ai_client.requests.post', return_value=denied):
events = frames(self.client.post('/stream_chat', json={'msg': '物流咨询'}))
self.assertEqual(events[0]['e'], '系统繁忙,请稍后再试')
self.assertTrue(denied.closed)
empty = ModelResponse(text='')
with patch('logistics_chat.ai_client.requests.post', return_value=empty):
events = frames(self.client.post('/stream_chat', json={'msg': '物流咨询'}))
self.assertIn('没有返回内容', events[0]['e'])
self.assertTrue(empty.closed)
def test_cancellation_saves_partial_answer_and_closes_http_response(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
cid = self.conversation()
model = ModelResponse('这是长度超过二十字的回答内容,用于验证中断保存行为。')
with patch('logistics_chat.ai_client.requests.post', return_value=model):
response = self.client.post('/stream_chat', json={'msg': '海运咨询', 'conv_id': cid}, buffered=False)
next(response.response)
response.close()
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertIn('回答中断', messages[-1]['content'])
self.assertTrue(model.closed)
def test_entry_and_package_imports_do_not_create_runtime_files(self):
root = str(Path(__file__).resolve().parents[1])
code = 'import sys; sys.path.insert(0, sys.argv[1]); import am; from logistics_chat import create_app'
with tempfile.TemporaryDirectory() as directory:
result = subprocess.run([sys.executable, '-c', code, root], cwd=directory,
capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(list(Path(directory).iterdir()), [])
def test_wsgi_compatibility_creates_one_application_on_explicit_access(self):
import am
try:
with patch.object(am, 'create_app', side_effect=lambda: self.application(self.root/'wsgi')) as factory:
application = getattr(am, 'app')
self.assertIs(getattr(am, 'app'), application)
self.assertEqual(application.test_client().get('/').status_code, 200)
self.assertEqual(factory.call_count, 1)
finally:
am.__dict__.pop('app', None)
if __name__ == '__main__':
unittest.main()
+103
View File
@@ -0,0 +1,103 @@
"""Configuration boundaries; credentials and model calls use synthetic fixtures."""
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch, sentinel
from logistics_chat import config, create_app
class ConfigurationTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
root_patch = patch.object(config, 'ROOT_DIR', self.root)
root_patch.start()
self.addCleanup(root_patch.stop)
environment = patch.dict(os.environ)
environment.start()
self.addCleanup(environment.stop)
os.environ.pop('DEEPSEEK_API_KEY', None)
def write_env(self, text):
(self.root / '.env').write_text(text, encoding='utf-8')
def application(self, **overrides):
return create_app({'TESTING': True, 'INITIALIZE_RUNTIME': False, **overrides})
def test_missing_file_leaves_ai_disabled_without_creating_files(self):
self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], '')
self.assertEqual(list(self.root.iterdir()), [])
def test_file_accepts_bom_quotes_comments_and_whitespace(self):
self.write_env('\ufeff# Local configuration\nexport DEEPSEEK_API_KEY=" file-key " # comment\n')
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'file-key')
self.assertNotIn('DEEPSEEK_API_KEY', os.environ)
def test_empty_or_unassigned_file_value_leaves_ai_disabled(self):
for line in ('DEEPSEEK_API_KEY=', 'DEEPSEEK_API_KEY', 'OTHER_SETTING=unused'):
with self.subTest(line=line):
self.write_env(line + '\n')
self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], '')
def test_environment_overrides_file_including_an_explicit_empty_value(self):
self.write_env('DEEPSEEK_API_KEY=file-key\n')
for value, expected in ((' environment-key ', 'environment-key'), ('', '')):
with self.subTest(value=value):
os.environ['DEEPSEEK_API_KEY'] = value
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], expected)
def test_file_values_are_literal_and_do_not_expand_environment_variables(self):
os.environ['SYNTHETIC_CONFIG_VALUE'] = 'expanded'
self.write_env("DEEPSEEK_API_KEY='literal-${SYNTHETIC_CONFIG_VALUE}'\n")
self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], 'literal-${SYNTHETIC_CONFIG_VALUE}')
def test_file_is_resolved_from_repository_root_instead_of_working_directory(self):
self.write_env('DEEPSEEK_API_KEY=repository-key\n')
with tempfile.TemporaryDirectory() as directory:
(Path(directory) / '.env').write_text('DEEPSEEK_API_KEY=wrong-key\n', encoding='utf-8')
previous = Path.cwd()
try:
os.chdir(directory)
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'repository-key')
finally:
os.chdir(previous)
def test_explicit_factory_configuration_has_highest_priority(self):
self.write_env('DEEPSEEK_API_KEY=file-key\n')
os.environ['DEEPSEEK_API_KEY'] = 'environment-key'
for value in ('override-key', ''):
with self.subTest(value=value):
self.assertEqual(self.application(DEEPSEEK_API_KEY=value).config['DEEPSEEK_API_KEY'], value)
def test_new_factory_reads_updated_file_without_changing_an_existing_app(self):
self.write_env('DEEPSEEK_API_KEY=first-key\n')
first = self.application()
self.write_env('DEEPSEEK_API_KEY=second-key\n')
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'second-key')
self.assertEqual(first.config['DEEPSEEK_API_KEY'], 'first-key')
self.assertNotIn('DEEPSEEK_API_KEY', os.environ)
def test_model_client_sends_file_key_without_a_real_http_request(self):
from logistics_chat.ai_client import post_completion
self.write_env('DEEPSEEK_API_KEY=file-key\n')
app = self.application()
with app.app_context(), patch('logistics_chat.ai_client.requests.post', return_value=sentinel.response) as post:
response = post_completion({'model': 'deepseek-chat'}, timeout=(5, 90), stream=True)
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'])
+48
View File
@@ -0,0 +1,48 @@
"""Exercise the server factory using disposable state/log paths and no model key."""
import io
import logging
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from deploy import wsgi
class DeploymentTests(unittest.TestCase):
def test_fresh_server_persists_business_data_and_uploads_outside_checkout(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.name)
state, logs = root / 'state', root / 'logs'
with patch.object(wsgi, 'DATA_DIR', state), patch.object(wsgi, 'LOG_DIR', logs), \
patch.dict(os.environ, {'DEEPSEEK_API_KEY': ''}):
app = wsgi.create_app()
def close_handlers():
for handler in list(app.logger.handlers):
if isinstance(handler, logging.FileHandler):
handler.close()
app.logger.removeHandler(handler)
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)
self.assertEqual(client.post('/api/ticket', json={'content': 'Acceptance ticket', 'phone': '123'}).status_code, 200)
self.assertEqual(client.get('/api/tickets').json[0]['content'], 'Acceptance ticket')
uploaded = client.post('/api/upload', data={'file': (io.BytesIO(b'acceptance'), 'note.txt')})
self.assertEqual(uploaded.status_code, 200)
self.assertEqual((state / 'uploads' / uploaded.json['url'].rsplit('/', 1)[1]).read_bytes(), b'acceptance')
self.assertTrue((state / 'history.db').is_file())
self.assertEqual(len(list((state / 'backups').glob('history_*.db'))), 1)
self.assertTrue((logs / 'app.log').is_file())
self.assertEqual(app.config['HOST'], '127.0.0.1')
if __name__ == '__main__':
unittest.main()
+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()
+17
View File
@@ -0,0 +1,17 @@
"""Optional Node regression checks for asynchronous customer-service drafts."""
from pathlib import Path
import shutil
import subprocess
import unittest
class FrontendTests(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node.js is not installed')
def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['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)
+176
View File
@@ -0,0 +1,176 @@
"""Exercise real Bash release/rollback file operations with all infrastructure faked."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from deploy.gray_config import GrayConfig, write_release
FAKE_COMMAND = '''import json, os, pathlib, sys
name = pathlib.Path(sys.argv[0]).name
args = sys.argv[1:]
log = pathlib.Path(os.environ['FAKE_LOG'])
with log.open('a') as output:
output.write(json.dumps([name, args]) + '\\n')
scenario = os.environ['FAKE_SCENARIO']
if name == 'docker':
if args[:2] == ['image', 'inspect']:
print('sha256:' + 'b'*64 if args[3] == '{{.Id}}' else 'a'*40)
elif args[:2] == ['container', 'inspect']:
sys.exit(0)
elif args[0] == 'inspect':
print('baiyue-logistics-chat-gray')
elif args[0] == 'port':
print('127.0.0.1:29013')
elif args[0] == 'compose' and 'up' in args and scenario == 'app-fail' and os.environ['FAKE_NEW'] + '/release.env' in args:
sys.exit(17)
elif name == 'nginx' and '-t' in args and scenario == 'proxy-fail':
calls = [json.loads(line) for line in log.read_text().splitlines()]
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:
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
'''
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Gray activation requires Linux Bash')
class GrayActivationTests(unittest.TestCase):
def run_activation(self, scenario):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
directory = Path(temporary.name)
root = directory / 'gray-logistics-chat'
proxy = directory / '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')
certificate, private_key = directory / 'cert.pem', directory / 'key.pem'
certificate.touch()
private_key.touch()
previous = root / 'releases/previous'
previous.mkdir()
(previous / 'release.env').write_text('CHAT_IMAGE=previous\n')
(previous / 'compose.yml').write_text('services: {}\n')
(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)
(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:' + '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', 'sleep'):
command = commands / name
command.write_text('#!' + sys.executable + '\n' + FAKE_COMMAND)
command.chmod(0o755)
environment = os.environ.copy()
log = directory / 'commands.jsonl'
environment.update(PATH=str(commands) + os.pathsep + environment['PATH'],
FAKE_LOG=str(log), FAKE_SCENARIO=scenario, FAKE_NEW=str(release))
result = subprocess.run(['bash', str(release / 'activate.sh'), str(root), str(release)],
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
def test_success_publishes_only_the_owned_site_and_release_pointer(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('success')
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.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')
if __name__ == '__main__':
unittest.main()
+119
View File
@@ -0,0 +1,119 @@
"""Reject unsafe release coordinates and keep secrets out of public release files."""
from dataclasses import replace
from pathlib import Path
import tempfile
import unittest
import re
from dotenv import dotenv_values
from deploy.gray_config import GrayConfig, write_release
class GrayConfigTests(unittest.TestCase):
def settings(self):
return {
'PUBLIC_ORIGIN': 'https://chat.example.com', 'HOST_PORT': '29013',
'GRAY_DEPLOY_DIR': '/data/services/gray-logistics-chat',
'GRAY_PROXY_CONF_DIR': '/etc/nginx/conf.d',
'GRAY_TLS_CERT_FILE': '/etc/ssl/example/fullchain.pem',
'GRAY_TLS_KEY_FILE': '/etc/ssl/example/privkey.pem',
'SOURCE_COMMIT': 'a' * 40, 'BUILD_NUMBER': '7',
'CHAT_IMAGE': 'baiyue-logistics-chat-gray:' + 'a' * 12 + '-b7',
'CHAT_IMAGE_ID': 'sha256:' + 'b' * 64,
'CERTBOT_IMAGE_ID': 'sha256:' + 'c' * 64,
}
def test_unsafe_origins_paths_and_mismatched_images_are_rejected(self):
cases = (
('PUBLIC_ORIGIN', 'http://chat.example.com'),
('PUBLIC_ORIGIN', 'https://user:password@chat.example.com'),
('PUBLIC_ORIGIN', 'https://chat.example.com/admin'),
('GRAY_DEPLOY_DIR', '/data/services/../gray-logistics-chat'),
('GRAY_DEPLOY_DIR', '/data/shared-services'),
('GRAY_PROXY_CONF_DIR', '/etc/nginx; echo unsafe'),
('HOST_PORT', '443'), ('CHAT_IMAGE', 'another-service:latest'),
('SOURCE_COMMIT', 'main'), ('CHAT_IMAGE_ID', 'unverified-image'),
)
for key, value in cases:
with self.subTest(setting=key, value=value):
settings = self.settings()
settings[key] = value
with self.assertRaises(ValueError):
GrayConfig.from_environment(settings)
def test_data_paths_remain_stable_across_releases_and_credentials_stay_separate(self):
config = GrayConfig.from_environment(self.settings())
updated = replace(config, release=config.root + '/releases/next-build')
for release in (config, updated):
self.assertIn('CHAT_DATA_DIR=' + config.root + '/data\n', release.release_environment())
self.assertIn('CHAT_LOG_DIR=' + config.root + '/logs\n', release.release_environment())
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'release'
key = "fixture-${literal}-don't-expand"
write_release(path, config, key)
self.assertEqual(dotenv_values(path / 'runtime.env', interpolate=False)['DEEPSEEK_API_KEY'], key)
for name in ('release.env', 'site.conf', 'compose.yml', 'activate.sh'):
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:
path = Path(directory) / 'release'
with self.assertRaises(ValueError):
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()

Some files were not shown because too many files have changed in this diff Show More