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

验证:46 项 Python 测试通过,含 14 个已有子测试;12 个 Node 前端场景通过。
2026-10-29 10:59:29 +08:00
102 changed files with 7119 additions and 4635 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/
+5
View File
@@ -0,0 +1,5 @@
# 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=
+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
+62
View File
@@ -0,0 +1,62 @@
# 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` |
| 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` |
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
| Human support | `logistics_chat/human_service.py`; `logistics_chat/routes/human.py` |
| 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` |
| 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` when Node.js is available; no npm dependencies.
## 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`. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
+37
View File
@@ -0,0 +1,37 @@
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 ./
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/
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.9.0
ENTRYPOINT ["certbot"]
FROM runtime AS production
Vendored
+69
View File
@@ -0,0 +1,69 @@
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')
}
}
sh '''set -eu
for file in deploy/*.sh; do bash -n "$file"; done
node --test tests/frontend/admin.test.cjs
'''
}
}
stage('Build and test image') {
steps {
withEnv(["PYTHON_BASE_IMAGE=${params.PYTHON_BASE_IMAGE}"]) {
sh '''set -eu
docker build --target test --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" -t "$CHAT_IMAGE-test" .
docker build --target production --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CHAT_IMAGE" .
docker build --target certbot --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
--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}"
]) {
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'),
usernamePassword(credentialsId: params.PREVIEW_AUTH_CREDENTIAL_ID, usernameVariable: 'PREVIEW_USERNAME', passwordVariable: 'PREVIEW_PASSWORD')
]) {
sh 'bash deploy/publish-gray.sh'
}
}
}
}
}
}
}
+161 -137
View File
@@ -8,28 +8,31 @@
- **知识库优先**:优先使用本地知识库回答,减少模型编造
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
- **运单号查询**:发送 8~20 位运单号,返回时间轴轨迹卡片
- **运单号查询**:发送 8~20 位运单号,返回轨迹卡片;先展示最近节点,可展开全部轨迹
- **历史会话**:会话保存到 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 +42,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 +64,181 @@
> ⚠️ 报价表含商业价格与联系人信息,公开发布前请脱敏或替换为示例数据。
## 运行环境
## 运行环境与启动
- 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 的配置示例文件),其中列出了需要设置的环境变量及说明。
- 本地密钥放在项目根目录的 `.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` 或部署环境中配置新密钥。
### 3. 启动服务
### Jenkins 灰度部署
```bash
cd E:\wuliu_chat
python am.py
```
### 4. 打开页面
浏览器访问 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 服务,再配置独立域名的 HTTPS 和全站访问密码。密钥由 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 模拟轨迹与卡片协议
│ ├── 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` |
| 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` | 关闭人工会话 |
| GET | `/admin` | 客服登录页面 |
| POST | `/admin/login` | 校验客服账号密码 |
| GET | `/admin/dashboard` | 客服工作台 |
| GET | `/api/export_chat` | 导出聊天记录为 CSV |
### 流式事件格式
SSE 每帧为一条 JSON,主要事件包括:
`/stream_chat` 返回 Server-Sent Events,每帧是一条 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`,首次启动自动创建。
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`agents`、`orders` 和 `tickets` 八张表,不自动创建客服账号或演示数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
- `chat_history`:会话(id、title、ctime、utime)
- `messages`:消息(id、conv_id、role、content、ctime)
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
运单卡片以 `__CARD__{json}` 形式保存在 `messages.content`,前端识别后重新渲染。
## 常见问题
**页面报"服务未配置 API Key"**
环境变量没读到。确认变量名是 `DEEPSEEK_API_KEY`,并**重新打开终端**后再启动。
**页面报"API Key 无效或已过期"**
Key 已作废,去平台重新生成一把,再用 `setx` 覆盖。
**端口 5000 被占用**
```powershell
netstat -ano | findstr ":5000 "
taskkill /PID <进程号> /F
```
确认只有一行 `LISTENING`,避免两个进程同时抢端口。
**改了 am.py 没生效**
Flask 启动时加载代码,改后端必须重启服务;只改 `index.html` 则刷新页面即可。
## 说明
- 运单轨迹当前为**模拟数据**(按单号固定生成),接真实接口只需修改 `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 的口径不同,修改时注意核对报价表。
## 当前限制
- 运单轨迹为模拟数据,尚未接入真实物流查询接口。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。公网自用验收可按部署指南使用全站访问密码进入工作台。
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
- 服务优先使用 Waitress 的 8 个线程;缺少 Waitress 时会回退到 Flask 开发服务器。公网部署前还需完善鉴权及部署配置。
+21 -1552
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
{"sid":24}
-5
View File
@@ -1,5 +0,0 @@
import sqlite3
c = sqlite3.connect('history.db')
print(c.execute('SELECT * FROM human_messages ORDER BY id DESC LIMIT 5').fetchall())
print('---')
print(c.execute("SELECT id, status FROM human_sessions WHERE status!='closed'").fetchall())
-5
View File
@@ -1,5 +0,0 @@
import sqlite3
c = sqlite3.connect('history.db')
c.execute("UPDATE human_sessions SET status='closed' WHERE status='active'")
c.commit()
print('ok')
+1
View File
@@ -0,0 +1 @@
"""Linux acceptance deployment entry points."""
+109
View File
@@ -0,0 +1,109 @@
#!/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) ;;
*) 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
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$CHAT_IMAGE_ID" ]] || 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"
auth="$CHAT_PROXY_DIR/.baiyue-logistics-chat.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
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
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 path in / /api/export_chat /api/get_history; do
code=$(curl --silent --show-error --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path")
[[ "$code" == 401 ]] || { echo 'Gray access protection verification failed.' >&2; false; }
done
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
+23
View File
@@ -0,0 +1,23 @@
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
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
+143
View File
@@ -0,0 +1,143 @@
"""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
@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,
}
return ''.join(f'{key}={value}\n' for key, value in fields.items())
def nginx_config(self):
return f'''# Managed for baiyue-logistics-chat-gray; all application routes require authentication.
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.proxy_dir}/.baiyue-logistics-chat.htpasswd;
client_max_body_size 12m;
location / {{
proxy_pass http://127.0.0.1:{self.port};
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}}
}}
'''
def write_release(directory, config, key):
if not key.strip() or any(character in key for character in ('\r', '\n', '\x00')):
raise ValueError('The model credential must be a nonempty single line')
directory.mkdir(parents=True, exist_ok=False)
(directory / 'release.env').write_text(config.release_environment(), encoding='utf-8')
(directory / 'site.conf').write_text(config.nginx_config(), encoding='utf-8')
escaped = key.strip().replace('\\', '\\\\').replace("'", "\\'")
(directory / 'runtime.env').write_text("DEEPSEEK_API_KEY='" + escaped + "'\n", encoding='utf-8')
(directory / 'runtime.env').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'])
except (KeyError, ValueError) as error:
parser.exit(2, 'Gray release configuration is incomplete or invalid.\n')
if __name__ == '__main__':
main()
+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;
}
}
+51
View File
@@ -0,0 +1,51 @@
# 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;
# Guard all pages, API routes, attachments and history/export operations.
auth_basic "Baiyue private acceptance";
auth_basic_user_file /etc/nginx/.baiyue-preview.htpasswd;
# Allow multipart overhead around the application's 10 MiB file limit.
client_max_body_size 12m;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
# SSE must reach the browser as chunks arrive.
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
+89
View File
@@ -0,0 +1,89 @@
#!/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
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$CHAT_CERTBOT_IMAGE_ID" ]] || 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'
+76
View File
@@ -0,0 +1,76 @@
#!/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; 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
# Transport only tested images built without runtime credentials.
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip | run_ssh "bash -o pipefail -c 'gzip -dc | docker load'"
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',
})
+116
View File
@@ -0,0 +1,116 @@
# 模块导航与拆分规范
四阶段拆分已落地:`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`:`extract_tracking_no`、`build_track`、`CARD_PREFIX`、`card_to_text` | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;模拟节点时间取当前时刻,回归对照需固定时钟 |
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`;对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply` |
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` |
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`,再加载对应 `chat`、`admin`、`admin_login` 的 CSS/JS;保留全局事件入口 |
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证异步交互 |
除标明的根目录文件外,表内缩写的 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 # 单号识别、模拟轨迹、卡片协议
├── ai_client.py # 模型 HTTP、压缩与追问
├── chat_service.py # 问答编排与 SSE 帧
├── history_service.py # 历史会话 CRUD
├── human_service.py # 人工状态与消息
├── business_service.py # 反馈、统计、登录、订单、留言、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 窄屏、侧栏、聊天卡片和客服空/活动状态。
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `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`,保留流式输出、停止、异常及中断保存。
- `chat_service.create_chat_stream` 在识别运单号时忽略 `[图片]/uploads/...` 和 `[文件]/uploads/...` 路径,避免附件文件名中的时间戳误触发查询;存储与请求仍保留原始附件标记和 URL。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent`。
- 计费档位、单位换算和舍入保持原样。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- 后台鉴权和会话隔离仍不完整,轨迹为模拟数据。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
## 验证与维护
先按 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)。
+96
View File
@@ -0,0 +1,96 @@
# Jenkins 灰度部署
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS 和全站访问密码。
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。后台鉴权与会话隔离仍不完整,灰度验收须保留全站密码;此流程不代表已具备面向多个客户开放的权限体系。
## 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 基础镜像 |
| `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` | 专用验收用户名和密码引用 |
运行密钥和验收账号保存在私有灰度配置与 Jenkins 凭据库,供后续发布持续复用;轮换时同步更新。不要将密钥放在构建参数、Git URL、镜像 ARG、日志或示例文件中。
## 发布步骤
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名、源提交和镜像 ID,生成私有配置并发送镜像及发布文件。
5. 首次签发域名证书,等待应用健康,再安装本服务的 Nginx 配置。已有业务的容器和站点配置不参与本次发布。
6. Jenkins 使用验收账号验证公网 HTTPS `/healthz`;之后在浏览器验证 AI 流式回复、上传、转人工和工作台。
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,接收主机再次核验镜像 ID,不依赖 `latest`。
## 数据与失败回退
`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 哈希,服务器保存哈希文件。
更新前执行 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`;健康检查不计入业务限流。
- 入口 401:未登录时为预期,使用专用验收密码后应返回 200。
- 流式显示延迟:检查实际 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}
+45
View File
@@ -0,0 +1,45 @@
"""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 flask import Flask
from .config import ROOT_DIR, default_config
from . import db, file_service, hooks
from .routes import register_routes
app = Flask(__name__, template_folder=str(ROOT_DIR/'templates'),
static_folder=str(ROOT_DIR/'static'))
app.config.update(default_config())
if config:
app.config.update(config)
app.extensions['human_typing'] = {}
app.extensions['rate_hits'] = defaultdict(list)
app.before_request(hooks.rate_limit)
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
+61
View File
@@ -0,0 +1,61 @@
"""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
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}')
return text
def gen_followups(user_msg, answer_text):
"""根据本轮问答,生成 3 个客户可能接着问的问题(失败返回空列表,不影响主流程)"""
if not get_setting('DEEPSEEK_API_KEY'):
return []
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
return []
+75
View File
@@ -0,0 +1,75 @@
"""Feedback, reporting, login, orders and offline tickets."""
from datetime import datetime
from .db import connect, now_str
def feedback(data):
"""收集 👍/👎 反馈,存到 feedback 表"""
conn = connect()
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str()))
conn.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=1').fetchone()[0]
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').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]
conn.close()
return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv}
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):
conn = connect()
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
conn.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 export_chat():
conn = connect()
rows = conn.execute('SELECT h.title, m.role, m.content, m.ctime FROM messages m JOIN chat_history h ON m.conv_id=h.id ORDER BY m.id').fetchall()
conn.close()
import csv
import io
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(['标题', '角色', '内容', '时间'])
for r in rows:
w.writerow([r[0], r[1], r[2] or '', r[3]])
return buf.getvalue()
+199
View File
@@ -0,0 +1,199 @@
"""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)
from .tracking import CARD_PREFIX, extract_tracking_no, build_track
def sse(payload):
"""统一的事件格式:data: {"t":正文} / {"e":错误} / {"card":卡片} / {"d":1结束}
JSON 编码可以避免正文里的换行把 SSE 帧撑断。"""
return 'data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n'
def create_chat_stream(msg, conv_id):
if not msg:
return (sse({'e': '问题不能为空'}), {})
if '[图片]' in msg and len(msg) < 300:
return (sse({'t': '收到您的图片,请描述一下您的问题。'}), {})
track_no = extract_tracking_no(re.sub(r'\[(?:图片|文件)\]/uploads/\S+', '', msg))
if track_no:
card = build_track(track_no)
def track_stream():
save_message(conv_id, 'user', msg)
yield sse({'card': card})
save_message(conv_id, 'assistant', CARD_PREFIX + json.dumps(card, ensure_ascii=False))
yield sse({'d': 1})
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
if not get_setting('DEEPSEEK_API_KEY'):
return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {})
know_text = search_knowledge(msg)
ship = parse_shipping_intent(msg)
ship['dim_unit'] = detect_dim_unit(msg)
freight_text = None
if not ship['weight'] and (not ship['dims']) and ship['channel']:
prev_q = last_user_question(conv_id)
if prev_q:
prev_ship = parse_shipping_intent(prev_q)
if prev_ship['weight'] or prev_ship['dims']:
ship['weight'] = prev_ship['weight']
ship['dims'] = prev_ship['dims']
ship['dim_unit'] = detect_dim_unit(prev_q)
if ship['weight'] or ship['dims']:
if re.search('英国|UK|伦敦|英格兰', msg, re.I):
uk_map = {'sea': 'sea_uk', 'rail': 'rail_uk', 'truck': 'truck_uk', 'air': 'air_uk'}
ship['channel'] = uk_map.get(ship['channel'])
freight_text = freight_reply(ship)
if not ship['channel']:
freight_text += '\n\n(客户没有指定渠道,上面已同时给出海运和空运,请在回答末尾询问更看重时效还是成本)'
extra_parts = []
cbm_m = re.search('(\\d+(?:\\.\\d+)?)\\s*(?:cbm|方|立方)', msg, re.I)
if cbm_m:
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=?', (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
try:
got_text = False
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]':
break
try:
j = json.loads(data)
chunk = j['choices'][0]['delta'].get('content') or ''
except Exception:
continue
if chunk:
got_text = True
parts.append(chunk)
yield sse({'t': chunk})
if not got_text:
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)
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)
if interrupted and len(text) >= 20:
save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)')
resp.close()
return (generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
+44
View File
@@ -0,0 +1,44 @@
"""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(),
'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]
+221
View File
@@ -0,0 +1,221 @@
"""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()
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')
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')
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
)
''')
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'
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (conv_id, role, content, now_str()))
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
+46
View File
@@ -0,0 +1,46 @@
"""Attachment validation, storage and retention without request parsing."""
import os
import random
import time
from datetime import datetime
from .config import get_setting
def cleanup_uploads():
"""删除 uploads/ 里 7 天前的图片"""
try:
udir = get_setting('UPLOAD_DIR')
if not os.path.isdir(udir):
return
cutoff = time.time() - 7 * 86400
for f in os.listdir(udir):
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 = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{random.randint(1000, 9999)}{ext}"
f.save(os.path.join(get_setting('UPLOAD_DIR'), safe_name))
is_image = ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp')
return {'ok': True, 'name': f.filename, 'url': f'/uploads/{safe_name}', 'is_image': is_image}
+49
View File
@@ -0,0 +1,49 @@
"""Conversation CRUD with unchanged response data."""
from .db import connect, now_str
def new_chat():
now = now_str()
conn = connect()
cur = conn.cursor()
cur.execute('INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)', ('新对话', now, now))
conv_id = cur.lastrowid
conn.commit()
conn.close()
return {'id': conv_id}
def get_history():
conn = connect()
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history ORDER BY COALESCE(utime, ctime) DESC, id DESC').fetchall()
conn.close()
return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows]
def get_messages(conv_id):
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):
conn = connect()
cur = conn.cursor()
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()
cur.execute('DELETE FROM messages')
cur.execute('DELETE FROM chat_history')
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 == '/healthz' 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'):
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
+102
View File
@@ -0,0 +1,102 @@
"""Human conversation lifecycle; typing state is supplied by the app."""
import time
from datetime import datetime
from .db import connect
def human_request(data):
conv_id = data.get('conv_id')
if not conv_id:
return ({'ok': False, 'error': 'no conv'}, 400)
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn = connect()
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 FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
st = conn.execute('SELECT status FROM human_sessions WHERE id=?', (sid,)).fetchone()
conn.close()
is_typing = False
ts = typing_map.get(str(sid))
if ts and time.time() - ts < 5:
is_typing = True
return {'status': st[0] if st else 'closed', 'typing': is_typing, 'messages': [{'id': m[0], 'side': m[1], 'content': m[2], 'ctime': m[3], 'read': bool(m[4])} for m in msgs]}
def human_send(data):
sid = data.get('sid')
side = data.get('side', 'customer')
content = (data.get('content') or '').strip()
if not sid or not content:
return ({'ok': False}, 400)
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn = connect()
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()
conn.execute("UPDATE human_sessions SET status='active' WHERE id=?", (sid,))
conn.commit()
conn.close()
return {'ok': True}
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_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}
+105
View File
@@ -0,0 +1,105 @@
"""Extracted knowledge rules with unchanged business behavior."""
knowledge_list = [
{
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家"],
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓。"
},
{
"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","双清","包税","DDP"],
"text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。"
},
{
"keys":["报关","单独报关","报关费"],
"text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。"
},
{
"keys":["亚马逊","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
+339
View File
@@ -0,0 +1,339 @@
"""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高,例如 120*100*100 / 100x80x60 / 120×100×100
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
"""
for m in re.finditer(r"(\d+)[*xX×](\d+)[*xX×](\d+)", t):
# 逐段向后延长,保证拿到的是完整数字而不是半截
dims = []
ok = True
pos = m.start()
for g in (1, 2, 3):
end = m.end(g)
# 若后面还紧跟数字,说明这一组没取全,放弃这个候选
if end < len(t) and t[end].isdigit():
ok = False
break
# 若是第一组,前面不能紧挨数字
if g == 1 and m.start(1) > 0 and t[m.start(1) - 1].isdigit():
ok = False
break
dims.append(int(m.group(g)))
if ok:
return [float(x) for x in dims]
return None
def parse_shipping_intent(text):
"""从话里抠出:重量(kg)、渠道、长宽高(cm)"""
t = text.replace(" ", "")
intent = {"weight": None, "channel": None, "dims": None}
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
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 not key and country:
for k in keys:
if ("-" + country) in k.upper():
key = k
break
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()] or stores
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 = []
channels = [intent["channel"]] if intent["channel"] else ["sea", "air"]
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, files, history, human, pages
for module in (admin, business, chat, files, history, human, pages):
app.register_blueprint(module.bp)
+31
View File
@@ -0,0 +1,31 @@
"""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():
return Response(business_service.export_chat(), mimetype='text/csv', headers={'Content-Disposition': 'attachment;filename=chat_export.csv'})
@bp.route('/admin')
def admin():
return render_template('admin_login.html')
@bp.route('/admin/dashboard')
def admin_dash():
return render_template('admin.html')
+21
View File
@@ -0,0 +1,21 @@
"""HTTP adapters for business; business logic lives in services."""
from flask import Blueprint, request
from .. import business_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() or {})
@bp.route('/api/tickets')
def list_tickets():
return business_service.list_tickets()
+29
View File
@@ -0,0 +1,29 @@
"""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():
if request.method == 'POST':
data = request.get_json(silent=True) or {}
msg = (data.get('msg') or '').strip()
conv_id = data.get('conv_id')
try:
conv_id = int(conv_id) if conv_id else None
except (TypeError, ValueError):
conv_id = None
else:
msg = (request.args.get('msg') or '').strip()
conv_id = request.args.get('conv_id', type=int)
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) or {})
+17
View File
@@ -0,0 +1,17 @@
"""HTTP adapters for files; business logic lives in services."""
from flask import Blueprint, request, send_from_directory
from .. import file_service
from ..config import get_setting
bp = Blueprint('files', __name__)
@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):
return send_from_directory(get_setting('UPLOAD_DIR'), name)
+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()
+56
View File
@@ -0,0 +1,56 @@
"""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/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')
+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)
+78
View File
@@ -0,0 +1,78 @@
"""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):])
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):
"""生成固定的模拟轨迹:同一个单号每次查都一样"""
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}
+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.
-1
View File
@@ -1 +0,0 @@
{"sid":48,"side":"agent","content":"test123"}
+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()
+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()
+55 -26
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" >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
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
+186
View File
@@ -0,0 +1,186 @@
/* 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); }
.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; }
}
+240
View File
@@ -0,0 +1,240 @@
/* Customer workspace: navigation, welcome, conversation and composer. */
body { padding: 20px; 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); }
.sidebar-note { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; }
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
.main-chat { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.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); }
.ai-welcome-card p { color: var(--secondary); font-size: 13px; line-height: 1.8; margin-top: 10px; }
.suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 28px 0 0; }
.suggest-item { display: flex; align-items: center; gap: 13px; min-height: 83px; padding: 17px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); color: var(--text); text-align: left; width: 100%; }
.suggest-item:hover { border-color: #9ab6f5; background: var(--brand-soft); box-shadow: 0 3px 12px rgba(49, 95, 213, .04); }
.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-copy small { display: block; color: var(--secondary); font-size: 12px; margin-top: 4px; line-height: 1.6; }
.suggest-arrow { margin-left: auto; color: var(--muted); font-size: 17px; }
.hot-questions { margin-top: 24px; }
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
.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); }
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
.hot-bar-label { font-size: 10px; color: var(--muted); margin-right: 3px; }
.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); }
#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); }
#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; }
#send-button:hover { background: var(--brand-hover); }
#send-button.stop { background: var(--danger); }
.composer-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 2px 0; }
.disclaimer, .keyboard-hint { color: var(--muted); font-size: 11px; }
.keyboard-hint { white-space: nowrap; }
.msg-row { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; max-width: 820px; margin: 0 auto 24px; }
.msg-row.user-line { align-items: flex-end; }
.message-author { display: flex; align-items: center; gap: 8px; min-height: 24px; margin-bottom: 4px; color: var(--secondary); font-size: 12px; font-weight: 600; }
.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); }
.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; }
.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-copy small { display: block; font-size: 10px; color: var(--muted); margin-top: 2px; }
.attachment-remove { flex-shrink: 0; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 7px; color: var(--secondary); background: transparent; font-size: 21px; }
.attachment-remove:hover { color: var(--danger); background: var(--danger-soft); }
.composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; }
.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; }
.ai-welcome-card p { margin-top: 8px; }
.suggest-grid { margin-top: 20px; gap: 10px; }
.suggest-item { min-height: 72px; padding: 13px 16px; }
.hot-questions { margin-top: 18px; }
}
@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; }
.suggest-copy small { font-size: 11px; }
}
@media (max-width: 900px) {
body { padding: 0; }
.chat-container { border: 0; border-radius: 0; height: 100dvh; }
.left-panel { position: absolute; left: 0; top: 72px; bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
.left-panel .brand { display: none; }
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: 72px 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
.main-chat { width: 100%; }
.chat-title { min-height: 72px; height: 72px; padding: 13px 18px; gap: 11px; }
#chatDom { padding: 24px 20px 15px; }
.ai-welcome-card { padding-top: 20px; }
.composer { width: calc(100% - 40px); }
.user-message, .robot-message { max-width: 94%; }
.del-history-btn { opacity: 1; }
}
@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; }
.ai-welcome-card p { max-width: 300px; margin: 10px auto 0; font-size: 12px; }
.suggest-grid { gap: 9px; margin-top: 22px; }
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 114px; padding: 13px; gap: 9px; }
.suggest-icon { width: 32px; height: 32px; border-radius: 8px; }
.suggest-copy strong { font-size: 13px; }
.suggest-copy small { font-size: 11px; }
.suggest-arrow { display: none; }
.hot-questions { margin-top: 22px; }
.hot-questions .hot-capsule-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; }
.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; margin-bottom: 8px; }
.hot-bar-label { display: none; }
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; }
.input-area { gap: 7px; padding: 7px; }
#send-button { min-width: 57px; padding: 0 12px; }
#upload-btn { flex-basis: 36px; height: 42px; }
#input-text { font-size: 16px; }
#send-button { min-height: 42px; }
.attachment-remove { width: 40px; height: 40px; }
.keyboard-hint { display: none; }
.composer-footer { justify-content: center; padding-top: 7px; }
.disclaimer { font-size: 10px; }
.track-text { grid-template-columns: 1fr; gap: 2px; }
.track-place { grid-column: 1; }
.robot-message.track-card { padding: 16px; }
.track-no { font-size: 15px; }
.to-bottom { right: 15px; }
}
+85
View File
@@ -0,0 +1,85 @@
/* 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); }
.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; }
}
+393
View File
@@ -0,0 +1,393 @@
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");
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);
});
}
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{
queueItems = await adminRequest("/api/human/queue");
renderQueue();
if(!curSid && queueItems.length && !sessions[String(queueItems[0].id)]){
accept(queueItems[0].id);
}
}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("");
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 = "";
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.className = "msg " + (message.side === "customer" ? "cust" : "agent");
let content = escapeText(message.content);
content = content.replace(/\[图片\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener"><img src="$1" alt="客户附件" class="message-image"></a>');
content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
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>';
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 === "closed"){
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);
session.msgs.push(message);
if(message.side === "customer" && sid !== curSid) session.unread += 1;
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]'));
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;
});
}).catch(function(){});
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");
});
});
+1406
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
{"conv_id":999}
+80 -254
View File
@@ -1,266 +1,92 @@
<!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>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;height:100vh;display:flex;flex-direction:column;}
.topbar{background:#fff;padding:10px 20px;display:flex;align-items:center;gap:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10;}
.topbar .logo{font-size:18px;font-weight:bold;color:#18479c;}
.topbar .stats{display:flex;gap:20px;font-size:13px;color:#666;margin-left:20px;}
.topbar .stats b{color:#2978e8;font-size:16px;}
.topbar .status{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:13px;color:#666;}
.topbar .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;}
.layout{flex:1;display:grid;grid-template-columns:260px 1fr 240px;overflow:hidden;}
/* 左栏:对话列表 */
.conv-list{background:#fff;border-right:1px solid #e8e8e8;overflow-y:auto;}
.conv-list h3{padding:12px 14px;font-size:13px;color:#999;border-bottom:1px solid #f0f0f0;}
.conv-item{padding:12px 14px;cursor:pointer;border-bottom:1px solid #f5f5f5;display:flex;align-items:center;gap:10px;}
.conv-item:hover{background:#f5f8fc;}
.conv-item.active{background:#e8f0ff;border-left:3px solid #2978e8;}
.conv-item .avatar{width:36px;height:36px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;}
.conv-item .info{flex:1;min-width:0;}
.conv-item .name{font-size:14px;font-weight:500;}
.conv-item .preview{font-size:12px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.conv-item .badge{background:#dc2626;color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;}
.queue-item{padding:10px 14px;background:#fff7ed;border-bottom:1px solid #ffedd5;}
.queue-item .qt{font-size:13px;color:#ea580c;font-weight:500;}
.queue-item button{margin-top:6px;width:100%;background:#ea580c;color:#fff;border:none;padding:5px;border-radius:4px;cursor:pointer;font-size:12px;}
/* 中间:聊天 */
.chat-area{display:flex;flex-direction:column;background:#f8fafc;}
.chat-header{background:#fff;padding:12px 20px;border-bottom:1px solid #e8e8e8;display:flex;align-items:center;justify-content:space-between;}
.chat-header .ct{font-size:15px;font-weight:500;}
.chat-header .close{background:#dc2626;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;}
#chatBox{flex:1;overflow-y:auto;padding:20px;}
.msg{margin-bottom:12px;display:flex;}
.msg .bubble{max-width:60%;padding:10px 14px;border-radius:12px;font-size:14px;line-height:1.5;overflow-wrap:break-word;}
.msg.cust{justify-content:flex-start;}
.msg.cust .bubble{background:#fff;border:1px solid #e8e8e8;border-top-left-radius:4px;}
.msg.agent{justify-content:flex-end;}
.msg.agent .bubble{background:#2978e8;color:#fff;border-top-right-radius:4px;}
.msg .meta{font-size:11px;color:#aaa;margin-top:3px;}
.input-bar{background:#fff;border-top:1px solid #e8e8e8;padding:12px 20px;display:flex;gap:10px;}
.input-bar input{flex:1;border:1px solid #ddd;border-radius:6px;padding:10px 12px;font-size:14px;outline:none;}
.input-bar input:focus{border-color:#2978e8;}
.input-bar button{background:#2978e8;color:#fff;border:none;padding:0 20px;border-radius:6px;cursor:pointer;font-size:14px;}
/* 右栏:客户信息+快捷回复 */
.side{background:#fff;border-left:1px solid #e8e8e8;overflow-y:auto;}
.side .card{padding:16px;border-bottom:1px solid #f0f0f0;}
.side h4{font-size:13px;color:#999;margin-bottom:10px;}
.side .info-row{font-size:13px;padding:4px 0;color:#333;}
.side .quick{padding:8px 12px;background:#f5f8fc;border-radius:6px;margin-bottom:6px;font-size:13px;cursor:pointer;}
.side .quick:hover{background:#e8f0ff;}
.empty{display:flex;align-items:center;justify-content:center;height:100%;color:#bbb;font-size:14px;}
</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>
<div class="topbar">
<div class="logo">🎧 柏悦客服工作台</div>
<div class="stats">
<span>对话 <b id="s1">-</b></span>
<span>消息 <b id="s2">-</b></span>
<span>今日 <b id="s3">-</b></span>
<span>👍 <b id="s4">-</b></span>
<span>👎 <b id="s5">-</b></span>
<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="status"><a href="/api/export_chat" style="font-size:13px;color:#2978e8;text-decoration:none;margin-right:16px;">导出聊天记录</a><span class="dot"></span> 在线</div>
</div>
<div class="layout">
<div class="conv-list">
<h3>待接入</h3>
<div id="queue"></div>
<h3>进行中</h3>
<div id="sessionList"></div>
<div class="topbar-actions">
<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>
<a class="export-link" href="/api/export_chat">导出记录 ↗</a>
</div>
<div class="chat-area" id="chatArea">
<div class="empty" id="emptyTip">← 选择左侧对话开始服务</div>
<div id="chatContent" style="display:none;flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div class="chat-header">
<span class="ct" id="chatTitle">对话</span>
<button class="close" onclick="closeSession()">结束会话</button>
</details>
</div>
<div id="chatBox"></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>
</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="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><span>Shift + Enter 换行</span></div>
<div class="input-bar">
<input type="text" id="replyText" placeholder="输入回复,Enter发送">
<button onclick="sendReply()">发送</button>
<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>
</div>
<div class="side">
<div class="card">
<h4>客户信息</h4>
<div class="info-row" id="cInfo">选择对话后显示</div>
</div>
<div class="card">
<h4>快捷回复</h4>
<div class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,请问有什么可以帮您?</div>
<div class="quick" onclick="quickReply('请提供运单号,我帮您查询。')">请提供运单号</div>
<div class="quick" onclick="quickReply('已为您催促,请稍候。')">已催促,请稍候</div>
<div class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询</div>
</div>
<div class="card">
<h4>离线留言</h4>
<div id="ticketList"></div>
</div>
</div>
</div>
<script>
var curSid = 0;
var sessions = {};
fetch("/api/admin/stats").then(function(r){return r.json()}).then(function(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(function(r){return r.json()}).then(function(list){
document.getElementById("queue").innerHTML = list.length
? list.map(function(x){return '<div class="queue-item"><div class="qt">#'+x.id+' '+x.ctime+'</div><button onclick="accept('+x.id+')">接入</button></div>'}).join("")
: '<div style="padding:10px 14px;font-size:12px;color:#ccc;">暂无排队</div>';
if(list.length && !sessions[String(list[0].id)]) accept(list[0].id);
});
}
setInterval(loadQueue, 3000);
function loadTickets(){
fetch("/api/tickets").then(function(r){return r.json()}).then(function(list){
var el = document.getElementById("ticketList");
if(!list.length){ el.innerHTML = '<div style="font-size:12px;color:#ccc;">暂无留言</div>'; return; }
el.innerHTML = list.map(function(t){
return '<div style="padding:6px;background:#fff7ed;border-radius:6px;margin-bottom:6px;font-size:12px;">'+t.content+'<br><span style="color:#999;">'+t.ctime+'</span></div>';
}).join("");
});
}
setInterval(loadTickets, 5000);
loadTickets();
function accept(sid){
sid = String(sid);
if(sessions[sid]) return;
fetch("/api/human/accept",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(sid)})}).then(function(){
sessions[sid] = {lastId:0, msgs:[], unread:0};
switchSession(sid);
});
}
function switchSession(sid){
curSid = String(sid);
document.getElementById("emptyTip").style.display="none";
document.getElementById("chatContent").style.display="flex";
document.getElementById("chatTitle").textContent="对话 #"+sid;
document.getElementById("cInfo").innerHTML="会话编号:#"+sid;
document.getElementById("chatBox").innerHTML="";
sessions[sid].msgs.forEach(function(m){ appendMsg(m); });
sessions[sid].unread = 0;
renderList();
fetch("/api/human/read",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(sid)})});
}
function appendMsg(m){
var div=document.createElement("div");
div.className="msg "+(m.side==="customer"?"cust":"agent");
var c = m.content.replace(/</g,"&lt;");
var imgMatch = c.match(/\[图片\](\/uploads\/\S+)/);
if(imgMatch){
c = c.replace(imgMatch[0], '<img src="'+imgMatch[1]+'" style="max-width:200px;border-radius:6px;display:block;">');
}
div.innerHTML='<div><div class="bubble">'+c+'</div><div class="meta">'+(m.side==="customer"?"客户":"客服")+'</div></div>';
document.getElementById("chatBox").appendChild(div);
document.getElementById("chatBox").scrollTop=999999;
}
function renderList(){
var el=document.getElementById("sessionList");
el.innerHTML="";
Object.keys(sessions).forEach(function(sid){
var last = sessions[sid].msgs[sessions[sid].msgs.length-1];
var preview = last ? last.content.substring(0,20) : "等待中...";
var unread = sessions[sid].unread || 0;
var div=document.createElement("div");
div.className="conv-item"+(sid==curSid?" active":"");
div.innerHTML='<div class="avatar">'+(sid==curSid?"🟢":"👤")+'</div><div class="info"><div class="name">对话 #'+sid+'</div><div class="preview">'+preview+'</div></div>'+(unread>0?'<span class="badge">'+unread+'</span>':"");
div.onclick=function(){ switchSession(sid); };
el.appendChild(div);
});
}
function poll(){
fetch("/api/human/active").then(function(r){return r.json()}).then(function(list){
list.forEach(function(s){
if(!sessions[String(s.id)]) sessions[String(s.id)] = {lastId:0, msgs:[], unread:0};
});
renderList();
}).then(function(){
Object.keys(sessions).forEach(function(sid){
fetch("/api/human/poll?sid="+parseInt(sid)+"&after="+sessions[sid].lastId)
.then(function(r){return r.json()})
.then(function(d){
if(d.status==="closed"){
delete sessions[sid];
if(sid==curSid){ curSid=0; document.getElementById("chatContent").style.display="none"; document.getElementById("emptyTip").style.display="flex"; }
renderList(); return;
}
// 客户正在输入
var t = document.getElementById("typing-ind");
if(d.typing && sid==curSid){ if(!t){ t=document.createElement("div"); t.id="typing-ind"; t.style.cssText="font-size:12px;color:#999;padding:4px 0;"; t.textContent="客户正在输入..."; document.getElementById("chatBox").appendChild(t); } }
else if(t){ t.remove(); }
d.messages.forEach(function(m){
var exists = sessions[sid].msgs.some(function(x){return x.id===m.id});
if(exists) return;
sessions[sid].lastId=m.id;
sessions[sid].msgs.push(m);
if(m.side==="customer" && sid!=curSid){ sessions[sid].unread=(sessions[sid].unread||0)+1; renderList(); }
if(sid==curSid) appendMsg(m);
});
}).catch(function(){});
});
});
}
setInterval(poll, 1000);
function sendReply(){
var 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:parseInt(curSid),side:"agent",content:t})});
document.getElementById("replyText").value="";
document.getElementById("replyText").focus();
}
function quickReply(t){
document.getElementById("replyText").value=t;
sendReply();
}
function closeSession(){
fetch("/api/human/close",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
delete sessions[curSid];
curSid=0;
document.getElementById("chatContent").style.display="none";
document.getElementById("emptyTip").style.display="flex";
renderList();
}
document.getElementById("replyText").addEventListener("keydown", function(e){
if(e.key==="Enter"){ e.preventDefault(); sendReply(); }
});
// 客服正在输入通知客户
var typeTimer = null;
document.getElementById("replyText").addEventListener("input", function(){
if(!curSid) return;
fetch("/api/human/typing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
clearTimeout(typeTimer);
typeTimer = setTimeout(function(){
fetch("/api/human/stop_typing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
}, 3000);
});
</script>
</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>常用回复</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>
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
</body>
</html>
+35 -28
View File
@@ -1,33 +1,40 @@
<!DOCTYPE html>
<html lang="zh">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>登录 - 客服后台</title>
<style>
body{font-family:sans-serif;background:#f0f2f5;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;}
.box{background:#fff;padding:40px;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,0.1);width:320px;}
h2{text-align:center;color:#18479c;margin-bottom:24px;}
input{width:100%;padding:10px;border:1px solid #ddd;border-radius:6px;margin-bottom:12px;font-size:14px;box-sizing:border-box;}
button{width:100%;padding:10px;background:#2978e8;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;}
.err{color:#dc2626;font-size:13px;text-align:center;display:none;}
</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_login.css') }}">
</head>
<body>
<div class="box">
<h2>🔒 客服后台登录</h2>
<input type="text" id="user" placeholder="账号">
<input type="password" id="pwd" placeholder="密码">
<p class="err" id="err">账号或密码错误</p>
<button onclick="login()">登录</button>
</div>
<script>
function login(){
var u=document.getElementById("user").value;
var p=document.getElementById("pwd").value;
fetch("/admin/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})})
.then(function(r){if(r.ok){location.href="/admin/dashboard"}else{document.getElementById("err").style.display="block"}});
}
document.getElementById("pwd").addEventListener("keydown",function(e){if(e.key==="Enter")login()});
</script>
<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>
+48 -2528
View File
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
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 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); }
addEventListener(name, callback){ this.listeners[name] = callback; }
querySelectorAll(){ return []; }
focus(){ document.activeElement = this; }
click(){ if(this.onclick) return this.onclick(); }
}
const quickButtons = [new Element(), new Element()];
document = {
body: new Element(), activeElement: null,
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
addEventListener(name, callback){ documentListeners[name] = callback; },
};
const calls = [];
const hooks = {};
const context = vm.createContext({
document, console,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}},
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.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(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, '需要恢复的多行草稿');
});
+282
View File
@@ -0,0 +1,282 @@
"""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()
def application(self, root):
return create_app({'TESTING': True, 'DATABASE': root/'history.db',
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups',
'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''})
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.assertEqual(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), 2)
self.assertTrue(messages[1]['content'].startswith('__CARD__'))
self.assertEqual(self.client.get('/api/get_history').json[0]['title'], '15252125')
self.assertEqual(self.client.delete(f'/api/del_history?id={cid}').json, {'ok': True})
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json, [])
def test_empty_image_and_missing_key_streams(self):
self.assertIn('e', frames(self.client.post('/stream_chat', json={'msg': ''}))[0])
self.assertIn('收到您的图片', frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))[0]['t'])
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[0]['content'], '查询15252125 ' + attachment)
def test_human_lifecycle_typing_and_read_receipts(self):
self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400)
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
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')
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_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)
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()
+96
View File
@@ -0,0 +1,96 @@
"""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')
if __name__ == '__main__':
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
"""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()
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()
+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'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+113
View File
@@ -0,0 +1,113 @@
"""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:
print('401', end='')
'''
@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'
for path in (root / 'data', root / 'logs', root / 'releases', proxy):
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)
(proxy / '.baiyue-logistics-chat.htpasswd').write_text('previous-auth-hash\n')
unrelated = proxy / 'unrelated.conf'
unrelated.write_text('# unrelated site\n')
config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'),
str(proxy), str(certificate), str(private_key), 'a'*40,
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'b'*64, 'sha256:' + 'c'*64)
release = Path(config.release)
write_release(release, config, 'fixture-model-key')
(release / 'site.htpasswd').write_text('new-auth-hash\n')
commands = directory / 'commands'
commands.mkdir()
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl'):
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')
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())
def assert_rolled_back(self, scenario):
result, root, proxy, previous, release, old_site, calls = self.run_activation(scenario)
self.assertNotEqual(result.returncode, 0)
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
self.assertEqual((proxy / '.baiyue-logistics-chat.htpasswd').read_text(), 'previous-auth-hash\n')
self.assertTrue(any(name == 'docker' and 'up' in args and str(previous / 'release.env') in args
for name, args in calls), result.stderr)
def test_failed_application_restores_previous_release(self):
self.assert_rolled_back('app-fail')
def test_invalid_nginx_configuration_restores_previous_site_and_application(self):
self.assert_rolled_back('proxy-fail')
if __name__ == '__main__':
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
"""Reject unsafe release coordinates and keep secrets out of public release files."""
from dataclasses import replace
from pathlib import Path
import tempfile
import unittest
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_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())
if __name__ == '__main__':
unittest.main()
+128
View File
@@ -0,0 +1,128 @@
"""Maintenance script regression tests using temporary SQLite databases."""
import os
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from unittest.mock import patch
from scripts import close_active_sessions, inspect_human_sessions
ROOT = Path(__file__).resolve().parents[1]
class MaintenanceScriptTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.sandbox = Path(self.directory.name)
self.database = self.sandbox / "history with spaces.db"
self.other_directory = self.sandbox / "elsewhere"
self.other_directory.mkdir()
with closing(sqlite3.connect(self.database)) as connection:
connection.executescript(
"CREATE TABLE human_sessions(id INTEGER PRIMARY KEY, status TEXT);"
"CREATE TABLE human_messages(id INTEGER PRIMARY KEY, content TEXT);"
"INSERT INTO human_sessions VALUES"
"(1, 'waiting'), (2, 'active'), (3, 'closed'), (4, 'active');"
)
connection.executemany(
"INSERT INTO human_messages VALUES (?, ?)",
[(number, f"message {number}") for number in range(1, 9)],
)
connection.commit()
def run_script(self, name, database):
return subprocess.run(
[sys.executable, str(ROOT / "scripts" / name), "--db", str(database)],
cwd=self.other_directory,
capture_output=True,
text=True,
encoding="utf-8",
timeout=15,
)
def test_inspect_returns_recent_messages_and_open_sessions_without_changes(self):
original = self.database.read_bytes()
messages, sessions = inspect_human_sessions.inspect_sessions(self.database)
self.assertEqual([row[0] for row in messages], [8, 7, 6, 5, 4])
self.assertEqual(sessions, [(1, "waiting"), (2, "active"), (4, "active")])
self.assertEqual(self.database.read_bytes(), original)
def test_close_only_changes_active_sessions_and_can_be_repeated(self):
self.assertEqual(close_active_sessions.close_active_sessions(self.database), 2)
with closing(sqlite3.connect(self.database)) as connection:
sessions = connection.execute(
"SELECT id, status FROM human_sessions ORDER BY id"
).fetchall()
messages = connection.execute("SELECT COUNT(*) FROM human_messages").fetchone()[0]
self.assertEqual(sessions, [(1, "waiting"), (2, "closed"), (3, "closed"), (4, "closed")])
self.assertEqual(messages, 8)
self.assertEqual(close_active_sessions.close_active_sessions(self.database), 0)
def test_missing_database_is_not_created(self):
missing = self.sandbox / "missing.db"
for operation in (
inspect_human_sessions.inspect_sessions,
close_active_sessions.close_active_sessions,
):
with self.subTest(operation=operation.__name__):
with self.assertRaises(sqlite3.OperationalError):
operation(missing)
self.assertFalse(missing.exists())
def test_default_database_is_independent_of_working_directory(self):
original_directory = Path.cwd()
try:
os.chdir(self.other_directory)
with (
patch.object(inspect_human_sessions, "DEFAULT_DB", self.database),
patch.object(close_active_sessions, "DEFAULT_DB", self.database),
):
self.assertEqual(len(inspect_human_sessions.inspect_sessions()[0]), 5)
self.assertEqual(close_active_sessions.close_active_sessions(), 2)
self.assertFalse((self.other_directory / "history.db").exists())
finally:
os.chdir(original_directory)
def test_cli_supports_other_working_directories_and_paths_with_spaces(self):
inspection = self.run_script("inspect_human_sessions.py", self.database)
self.assertEqual(inspection.returncode, 0, inspection.stderr)
self.assertIn("message 8", inspection.stdout)
closing_result = self.run_script("close_active_sessions.py", self.database)
self.assertEqual(closing_result.returncode, 0, closing_result.stderr)
self.assertIn("Closed 2 active session(s).", closing_result.stdout)
self.assertFalse((self.other_directory / "history.db").exists())
def test_cli_reports_missing_database_without_creating_it(self):
missing = self.sandbox / "missing.db"
for script in ("inspect_human_sessions.py", "close_active_sessions.py"):
with self.subTest(script=script):
result = self.run_script(script, missing)
self.assertEqual(result.returncode, 1)
self.assertIn("Database error:", result.stderr)
self.assertFalse(missing.exists())
def test_importing_scripts_has_no_database_side_effects(self):
for script in ("inspect_human_sessions.py", "close_active_sessions.py"):
with self.subTest(script=script):
result = subprocess.run(
[sys.executable, "-c", "import runpy, sys; runpy.run_path(sys.argv[1])",
str(ROOT / "scripts" / script)],
cwd=self.other_directory,
capture_output=True,
text=True,
timeout=15,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "")
self.assertEqual(list(self.other_directory.iterdir()), [])
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
"""Business boundaries for extracted pure rules; no model service is contacted."""
from datetime import datetime
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import knowledge, pricing, tracking
class RuleTests(unittest.TestCase):
def test_specific_knowledge_wins_and_unknown_questions_have_no_match(self):
self.assertIn('22KG+', knowledge.search_knowledge('英国空运价格'))
self.assertIsNone(knowledge.search_knowledge('没有相关关键词的测试'))
def test_sea_tier_boundary_and_minimum(self):
self.assertEqual(pricing.calc_freight(100, 'sea')['price'], 12.3)
self.assertEqual(pricing.calc_freight(101, 'sea')['price'], 10.3)
self.assertEqual(pricing.calc_freight(1, 'sea')['total'], 258.3)
self.assertEqual(pricing.calc_freight(1, 'air_uk')['charge'], 22)
def test_large_air_shipments_use_the_highest_applicable_tier(self):
self.assertEqual(pricing.calc_freight(2000, 'air')['total'], 72000)
self.assertEqual(pricing.calc_freight(2000, 'air_uk')['total'], 84000)
def test_volume_weight_and_unit_conversion(self):
for dims, unit in (([100, 100, 100], 'cm'), ([1, 1, 1], 'm'), ([1000]*3, 'mm')):
with self.subTest(unit=unit):
result = pricing.calc_freight(20, 'sea', dims, unit)
self.assertEqual(result['vol'], 166.67)
self.assertEqual(result['charge'], 166.67)
def test_shipping_intent_keeps_dimensions_separate_from_weight(self):
intent = pricing.parse_shipping_intent('100x80x60 200kg 海运')
self.assertEqual(intent['dims'], [100, 80, 60])
self.assertEqual(intent['weight'], 200)
self.assertEqual(intent['channel'], 'sea')
self.assertEqual(pricing.parse_shipping_intent('1吨铁路')['weight'], 1000)
self.assertEqual(pricing.parse_shipping_intent('100 lbs air')['weight'], 45.36)
def test_warehouse_country_and_surcharge(self):
result = pricing.calc_warehouse_card(2, '海运', country='DE')
self.assertIn('-DE', result['warehouse'])
self.assertEqual(result['total'], 3472)
product, fee = pricing.detect_surcharge('寄手机到德国')
self.assertIn('手机', product)
self.assertEqual(fee, '5/KG')
self.assertIn('500', pricing.surcharge_reply(product, fee, 100))
self.assertEqual(pricing.check_island('07000')[1], 'IT')
self.assertIsNone(pricing.check_island('not a postcode'))
def test_tracking_recognition_and_card_context(self):
self.assertEqual(tracking.extract_tracking_no('查询15252125'), '15252125')
self.assertFalse(tracking.is_tracking_no('2026-10-28'))
self.assertFalse(tracking.is_tracking_no('i want to go 1t how money'))
with patch.object(tracking, 'datetime') as clock:
clock.now.return_value = datetime(2026, 10, 28, 12)
card = tracking.build_track('15252125')
self.assertEqual(card, tracking.build_track('15252125'))
self.assertEqual(card['status'], '派送中')
self.assertEqual(len(card['nodes']), 6)
self.assertIn('15252125', tracking.card_to_text('__CARD__{"no":"15252125","status":"派送中"}'))
def test_pure_modules_import_without_site_packages_or_runtime_files(self):
root = str(Path(__file__).resolve().parents[1])
code = ('import sys; sys.dont_write_bytecode=True; sys.path.insert(0, sys.argv[1]); '
'import logistics_chat.knowledge, logistics_chat.pricing, logistics_chat.tracking; '
'import logistics_chat.config, logistics_chat.db, logistics_chat.file_service; '
'assert "flask" not in sys.modules; assert "requests" not in sys.modules')
with tempfile.TemporaryDirectory() as directory:
result = subprocess.run([sys.executable, '-S', '-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()), [])
if __name__ == '__main__':
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""Exercise the Windows startup entry point without installing or serving anything."""
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
import venv
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
@unittest.skipUnless(os.name == "nt", "Windows batch entry point")
class StartupInterpreterTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.sandbox = Path(self.directory.name)
self.repository = self.sandbox / "project with spaces & sign"
self.repository.mkdir()
shutil.copy2(ROOT / "start.bat", self.repository / "start.bat")
self.commands = self.sandbox / "commands"
self.commands.mkdir()
self.other_directory = self.sandbox / "elsewhere"
self.other_directory.mkdir()
self.system_directory = Path(os.environ["SystemRoot"]) / "System32"
self.environment = os.environ.copy()
for name in ("VIRTUAL_ENV", "PYTHONHOME", "PYTHONPATH", "PYLAUNCHER_DRYRUN"):
self.environment.pop(name, None)
self.environment["PYTHONUTF8"] = "1"
self.environment["PATH"] = os.pathsep.join(
str(path) for path in (
self.commands, Path(sys.executable).parent, self.system_directory,
)
)
def create_environment(self, directory):
venv.EnvBuilder(with_pip=False).create(directory)
return directory / "Scripts" / "python.exe"
def create_failed_candidate(self, destination):
# A real signed Windows executable that rejects Python's command-line flags.
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(self.system_directory / "where.exe", destination)
def diagnose(self):
executable = os.environ.get("COMSPEC", str(self.system_directory / "cmd.exe"))
command = (
subprocess.list2cmdline([executable])
+ ' /d /v:off /c ""'
+ str(self.repository / "start.bat")
+ '" --check-python"'
)
return subprocess.run(
command,
cwd=self.other_directory,
env=self.environment,
capture_output=True,
text=True,
encoding="utf-8",
timeout=15,
)
def assert_selected(self, expected):
result = self.diagnose()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(
os.path.normcase(str(Path(result.stdout.strip()).resolve())),
os.path.normcase(str(expected.resolve())),
)
return result
def test_active_environment_precedes_project_environment(self):
active = self.sandbox / "active env ! & sign"
expected = self.create_environment(active)
self.create_environment(self.repository / ".venv")
self.environment["VIRTUAL_ENV"] = str(active)
self.assert_selected(expected)
def test_project_environment_is_used_when_no_environment_is_active(self):
expected = self.create_environment(self.repository / ".venv")
self.assert_selected(expected)
def test_failed_active_environment_falls_back_to_project_environment(self):
active = self.sandbox / "failed active environment"
self.create_failed_candidate(active / "Scripts" / "python.exe")
expected = self.create_environment(self.repository / ".venv")
self.environment["VIRTUAL_ENV"] = str(active)
self.assert_selected(expected)
def test_failed_project_environment_falls_back_to_path_python(self):
self.create_failed_candidate(self.repository / ".venv" / "Scripts" / "python.exe")
self.create_failed_candidate(self.commands / "py.exe")
self.assert_selected(Path(sys.executable))
def test_failed_launcher_falls_back_to_path_python_without_launcher_flags(self):
self.create_failed_candidate(self.commands / "py.exe")
self.assert_selected(Path(sys.executable))
def test_no_working_interpreter_reports_failure_without_pausing(self):
self.create_failed_candidate(self.commands / "py.exe")
self.create_failed_candidate(self.commands / "python.exe")
self.environment["PATH"] = os.pathsep.join(
(str(self.commands), str(self.system_directory))
)
result = self.diagnose()
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("No working Python 3 interpreter", result.stdout)
def test_diagnostic_mode_leaves_no_application_files(self):
self.create_failed_candidate(self.commands / "py.exe")
self.environment.pop("DEEPSEEK_API_KEY", None)
self.assert_selected(Path(sys.executable))
self.assertEqual({path.name for path in self.repository.iterdir()}, {"start.bat"})
self.assertEqual(list(self.other_directory.iterdir()), [])
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""SQLite migration and attachment boundaries in isolated application contexts."""
import io
import os
from pathlib import Path
import tempfile
import time
import unittest
from flask import Flask
from werkzeug.datastructures import FileStorage
from logistics_chat import db, file_service
from logistics_chat.config import default_config
class StorageTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.app = Flask(__name__)
self.app.config.update(default_config())
self.app.config.update(DATABASE=self.root/'history.db', UPLOAD_DIR=self.root/'uploads',
BACKUP_DIR=self.root/'backups', LOG_DIR=self.root/'logs')
self.app.teardown_appcontext(db.close_connections)
self.context = self.app.app_context()
self.context.push()
self.addCleanup(self.context.pop)
db.init_db()
def test_old_schema_is_migrated_and_legacy_title_backfilled(self):
connection = db.connect()
connection.executescript('DROP TABLE chat_history; CREATE TABLE chat_history '
'(id INTEGER PRIMARY KEY, title TEXT NOT NULL, ctime TEXT NOT NULL);')
connection.execute('INSERT INTO chat_history VALUES(1, ?, ?)', ('旧会话', '2026-10-20'))
connection.commit()
connection.close()
db.init_db()
connection = db.connect()
self.assertEqual(connection.execute('SELECT utime FROM chat_history').fetchone()[0], '2026-10-20')
self.assertEqual(connection.execute('SELECT role, content FROM messages').fetchone(), ('user', '旧会话'))
connection.close()
def test_business_tables_start_empty_and_reinitialization_preserves_records(self):
connection = db.connect()
for table in ('agents', 'orders', 'tickets'):
self.assertEqual(connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0], 0)
connection.executescript(
"INSERT INTO agents VALUES('fixture-agent', 'fixture-password', 'Fixture Agent');"
"INSERT INTO orders(phone, track_no, status, created) VALUES('123', 'fixture-order', 'active', 'now');"
"INSERT INTO tickets(content, phone, ctime, status) VALUES('fixture-ticket', '123', 'now', 'pending');"
)
connection.commit()
connection.close()
db.init_db()
connection = db.connect()
self.assertEqual(connection.execute('SELECT username, password, name FROM agents').fetchall(),
[('fixture-agent', 'fixture-password', 'Fixture Agent')])
self.assertEqual(connection.execute('SELECT track_no FROM orders').fetchall(), [('fixture-order',)])
self.assertEqual(connection.execute('SELECT content FROM tickets').fetchall(), [('fixture-ticket',)])
connection.close()
def test_user_retry_is_idempotent_and_card_context_is_readable(self):
connection = db.connect()
connection.execute('INSERT INTO chat_history(title, ctime, utime) VALUES(?,?,?)', ('新对话', 'now', 'now'))
connection.commit()
connection.close()
db.save_message(1, 'user', '海运多少钱')
self.assertEqual(db.save_message(1, 'user', '海运多少钱'), 'duplicate')
db.save_message(1, 'assistant', '__CARD__{"no":"15252125","status":"派送中"}')
context = db.load_context(1, '然后呢')
self.assertEqual(len(context), 3)
self.assertIn('派送中', context[1]['content'])
self.assertNotIn('__CARD__', context[1]['content'])
self.assertEqual(db.last_user_question(1), '海运多少钱')
def test_backup_and_retention_only_touch_configured_directories(self):
backups = self.root/'backups'
backups.mkdir()
(backups/'history_20000101.db').write_bytes(b'old')
db.backup_db()
self.assertFalse((backups/'history_20000101.db').exists())
self.assertEqual(len(list(backups.glob('history_*.db'))), 1)
uploads = self.root/'uploads'
uploads.mkdir()
old = uploads/'old.txt'
old.write_text('old')
(uploads/'new.txt').write_text('new')
os.utime(old, (time.time()-8*86400, time.time()-8*86400))
file_service.cleanup_uploads()
self.assertFalse(old.exists())
self.assertEqual((uploads/'new.txt').read_text(), 'new')
def test_upload_uses_generated_name_and_configured_directory(self):
upload = FileStorage(stream=io.BytesIO(b'hello'), filename='../report.txt')
result = file_service.upload_file({'file': upload})
name = result['url'].rsplit('/', 1)[1]
self.assertEqual((self.root/'uploads'/name).read_bytes(), b'hello')
self.assertNotIn('..', name)
self.assertFalse(result['is_image'])
def test_invalid_and_oversized_uploads_are_rejected(self):
self.assertEqual(file_service.upload_file({})[1], 400)
denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe')
self.assertEqual(file_service.upload_file({'file': denied})[1], 400)
self.app.config['MAX_UPLOAD_MB'] = 1
oversized = FileStorage(stream=io.BytesIO(b'x'*(1024*1024+1)), filename='test.txt')
self.assertEqual(file_service.upload_file({'file': oversized})[1], 400)
self.assertFalse((self.root/'uploads').exists())
def test_teardown_rolls_back_uncommitted_work(self):
with self.app.app_context():
connection = db.connect()
connection.execute('INSERT INTO chat_history(title, ctime) VALUES(?,?)', ('uncommitted', 'now'))
connection = db.connect()
self.assertEqual(connection.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0], 0)
connection.close()
if __name__ == '__main__':
unittest.main()
View File
-1
View File
@@ -1 +0,0 @@
hello
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

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