Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce445d0fb8 | ||
|
|
8444ded971 | ||
|
|
f4e87b0c02 | ||
|
|
1f66307bba | ||
|
|
a081966342 | ||
|
|
cded4238a9 | ||
|
|
3c3d273ee3 | ||
|
|
97162016ee | ||
|
|
167c888718 | ||
|
|
97fc9bec14 | ||
|
|
df688844d3 | ||
|
|
64041893a5 | ||
|
|
06b80803c4 | ||
|
|
27151b3ee5 | ||
|
|
ab5b3580c1 | ||
|
|
805675f8db |
@@ -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/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Copy this file to .env in the repository root and enter your DeepSeek API key.
|
||||||
|
# The application reads .env at startup. Restart after changing this file.
|
||||||
|
# The process environment takes precedence, including an explicitly empty value.
|
||||||
|
# Keep real credentials in .env; never put them in this example or source files.
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
|
||||||
|
ORDER_DATA_TENANT_ID=
|
||||||
@@ -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
@@ -4,6 +4,10 @@ __pycache__/
|
|||||||
*.pyo
|
*.pyo
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
|
# 虚拟环境
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
# 数据库(含聊天记录,不要提交)
|
# 数据库(含聊天记录,不要提交)
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
@@ -17,11 +21,24 @@ __pycache__/
|
|||||||
# 环境变量与密钥
|
# 环境变量与密钥
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example # 配置示例(不含 Key),需要提交
|
# 配置示例(不含 Key),需要提交
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# 运行时数据,仅保留上传目录占位文件
|
||||||
|
/logs/
|
||||||
|
/backups/
|
||||||
|
/uploads/*
|
||||||
|
!/uploads/.gitkeep
|
||||||
|
|
||||||
# 自签证书与私钥(本地演示用,不要提交)
|
# 自签证书与私钥(本地演示用,不要提交)
|
||||||
cert.pem
|
cert.pem
|
||||||
key.pem
|
key.pem
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.htpasswd
|
||||||
|
runtime.env
|
||||||
|
release.env
|
||||||
|
/work/
|
||||||
|
|
||||||
# 系统文件
|
# 系统文件
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 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` |
|
||||||
|
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
|
||||||
|
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
|
||||||
|
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.py` |
|
||||||
|
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
|
||||||
|
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView`; `admin_tools.js`: notes, transfer, search; `support.js`: emoji, notifications, rating |
|
||||||
|
| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`: `default_config`; `hooks.py`, `runtime.py` in that package |
|
||||||
|
| CLI / tests | `start.bat`: `probe_python`; `scripts/`, `tests/`, `examples/requests/` |
|
||||||
|
| Deployment | `Jenkinsfile`, `Dockerfile`; `deploy/gray_config.py`: `GrayConfig`; publish/activate/TLS scripts; `docs/deployment.md` |
|
||||||
|
|
||||||
|
## Efficient Search & File Boundaries
|
||||||
|
|
||||||
|
Start with mapped files, then follow callers. Prefer symbols over saved line numbers. `rg --files` includes untracked source; respect ignored runtime data.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg --files
|
||||||
|
rg -n -F -e 'calc_freight' -e '/api/human/' logistics_chat static/js
|
||||||
|
```
|
||||||
|
|
||||||
|
Routes adapt HTTP; services compose rules, persistence, and model calls. Keep pure rules independent of Flask. Preserve URL/SSE/storage contracts and update navigation after moves.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
Run from the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3 -m venv .venv
|
||||||
|
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
|
||||||
|
Copy-Item .env.example .env # First run only; set DEEPSEEK_API_KEY inside .env.
|
||||||
|
.\.venv\Scripts\python.exe am.py
|
||||||
|
.\.venv\Scripts\python.exe -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
`start.bat` prefers active `VIRTUAL_ENV`; verify with `start.bat --check-python`. No frontend build is required.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, and JavaScript `camelCase`. Preserve Chinese terminology. No formatter/linter is configured.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
|
||||||
|
|
||||||
|
Tests run `tests/frontend/admin.test.cjs` and `chat.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`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
|
||||||
+37
@@ -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.8.0
|
||||||
|
ENTRYPOINT ["certbot"]
|
||||||
|
|
||||||
|
FROM runtime AS production
|
||||||
Vendored
+69
@@ -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/*.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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,28 +8,34 @@
|
|||||||
- **知识库优先**:优先使用本地知识库回答,减少模型编造
|
- **知识库优先**:优先使用本地知识库回答,减少模型编造
|
||||||
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
|
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
|
||||||
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
|
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
|
||||||
- **运单号查询**:发送 8~20 位运单号,返回时间轴轨迹卡片
|
- **订单查询**:按订单号、入仓码、Shipment ID 查询本地订单测试表;展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
|
||||||
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
|
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
|
||||||
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
|
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
|
||||||
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
|
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
|
||||||
- **异常处理**:接口超时、Key 失效、中断时给出明确提示与重试
|
- **异常处理**:接口超时、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 天 |
|
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|
||||||
| 义乌中欧铁路 | 15.5 | 13.5 | — | 13.5(1000+) | 发车后 36-40 天 |
|
|---|---|---|
|
||||||
| 中欧卡航 | 19.4 | 17.4 | 17.4 | 17.3 | 发车后 16-20 天 |
|
| 欧洲海运快线 | 21KG+ → 12.3;101KG+ → 10.3;500KG+ → 10.2;2000KG+ → 10.0 | 开船后 38-45 天 |
|
||||||
| 欧洲空运 | 39 | 36 | — | 37(71+) | 10-13 天 |
|
| 义乌中欧铁路 | 21KG+ → 15.5;100KG+ → 13.5;1000KG+ → 13.5 | 发车后 36-40 天 |
|
||||||
| 英国海运 | 12.8 | 10.9 | — | — | 约 40 天 |
|
| 中欧卡航 | 21KG+ → 19.4;101KG+ → 17.4;500KG+ → 17.4;2000KG+ → 17.3;4000KG+ → 17.3 | 发车后 16-20 天 |
|
||||||
| 英国铁路 | 18.0 | 16.2 | — | — | 25-30 天 |
|
| 欧洲空运 | 21KG+ → 39;45KG+ → 38;71KG+ → 37;101KG+ → 36 | 10-13 天 |
|
||||||
| 中英卡航 | 21.4 | 19.4 | — | — | 20-22 天 |
|
| 英国海运 | 21KG+ → 12.8;101KG+ → 10.9;301KG+ → 10.9 | 约 40 天 |
|
||||||
| 英国空运 | 48(22+) | 42 | — | 44(51+) | 8-10 天 |
|
| 英国铁路 | 21KG+ → 18.0;101KG+ → 16.2;301KG+ → 16.2 | 25-30 天 |
|
||||||
|
| 中英卡航 | 21KG+ → 21.4;101KG+ → 19.4;301KG+ → 19.4 | 20-22 天 |
|
||||||
|
| 英国空运 | 22KG+ → 48;51KG+ → 44;101KG+ → 42 | 8-10 天 |
|
||||||
|
|
||||||
**计费规则**(均来自报价表):
|
**计费规则**(均来自报价表):
|
||||||
|
|
||||||
@@ -39,9 +45,11 @@
|
|||||||
- 铁路、卡航单件计费重不足 13KG 加收超轻费 5 元/kg;空运单件不足 12KG 按 12KG 计费
|
- 铁路、卡航单件计费重不足 13KG 加收超轻费 5 元/kg;空运单件不足 12KG 按 12KG 计费
|
||||||
- 偏远地址 +7 元/kg,单票最低 250 元
|
- 偏远地址 +7 元/kg,单票最低 250 元
|
||||||
|
|
||||||
数据定义在 `am.py` 的 `RATES` 字典中,修改价格只需改这里,不需要动计算逻辑。
|
基础报价维护在 `logistics_chat/pricing.py` 的 `RATES` 字典中;修改价格时同步知识资料,避免调整计算逻辑。
|
||||||
|
|
||||||
### 扩展数据(rates_extra.py)
|
`calc_freight()` 的 `total` 当前仅为基础运费。超轻费、偏远费及产品附加费以说明或单独结果展示,尚未统一计入 `total`;上述业务规则不能理解为已全部自动结算。
|
||||||
|
|
||||||
|
### 扩展数据(logistics_chat/rates_extra.py)
|
||||||
|
|
||||||
从同一份报价表提取、目前已支持:
|
从同一份报价表提取、目前已支持:
|
||||||
|
|
||||||
@@ -59,162 +67,200 @@
|
|||||||
|
|
||||||
> ⚠️ 报价表含商业价格与联系人信息,公开发布前请脱敏或替换为示例数据。
|
> ⚠️ 报价表含商业价格与联系人信息,公开发布前请脱敏或替换为示例数据。
|
||||||
|
|
||||||
## 运行环境
|
## 运行环境与启动
|
||||||
|
|
||||||
- Python 3.9 及以上
|
项目使用 Python、Flask、SQLite 和 DeepSeek API,无需前端构建。建议使用独立虚拟环境;所有应用启动命令均在项目根目录执行。
|
||||||
- Windows / macOS / Linux
|
|
||||||
|
|
||||||
## 快速启动(Windows)
|
### Windows
|
||||||
|
|
||||||
双击 `start.bat` 即可,脚本会自动检查端口占用、API Key 和依赖。
|
|
||||||
|
|
||||||
## 安装与启动
|
|
||||||
|
|
||||||
### 1. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 配置 API Key
|
|
||||||
|
|
||||||
Key 不写进代码,通过系统环境变量提供。
|
|
||||||
|
|
||||||
Windows PowerShell:
|
|
||||||
|
|
||||||
```powershell
|
```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
|
```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
|
```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
|
现有灰度环境按 [Jenkins 部署指南](docs/deployment.md) 发布:构建和测试镜像,SSH 发送至专用 Compose 服务,再配置独立域名的 HTTPS 和全站访问密码。密钥由 Jenkins 注入,数据和证书保存在独立宿主机目录;启动失败时恢复之前的应用和站点。面向客户开放前仍需补齐鉴权和数据隔离。另附 [Linux 单机参考](docs/deployment-linux.md)。
|
||||||
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 项列为未解决/待验证(超长输入、并发提问、无答案兜底)。
|
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```
|
```text
|
||||||
wuliu_chat/
|
baiyue-logistics-chat/
|
||||||
├── am.py 主程序(路由、知识库、对话逻辑、运单查询、运费试算)
|
├── am.py CLI/WSGI 兼容入口
|
||||||
├── rates_extra.py 报价表扩展数据(卡派按方、附加费、岛屿邮编)
|
├── Jenkinsfile 灰度构建、测试及受管发布
|
||||||
├── test_am.py 单元测试(52 个用例)
|
├── Dockerfile 应用、测试及证书运维镜像
|
||||||
├── start.bat Windows 一键启动脚本
|
├── .dockerignore 显式限制构建上下文
|
||||||
├── requirements.txt 依赖清单
|
├── rates_extra.py 扩展数据旧导入路径的兼容转发
|
||||||
├── README.md 说明文档
|
├── logistics_chat/
|
||||||
├── knowledge.md 知识资料(由 knowledge_list 导出)
|
│ ├── __init__.py create_app 应用工厂
|
||||||
├── 测试记录.md 场景测试记录(29 个场景)
|
│ ├── config.py .env、环境变量与绝对路径配置
|
||||||
├── .gitignore Git 忽略规则
|
│ ├── db.py SQLite、历史持久化、备份
|
||||||
├── .env.example 配置示例(不含 Key)
|
│ ├── knowledge.py 知识与关键词检索
|
||||||
├── history.db SQLite 数据库(会话与消息)
|
│ ├── pricing.py 运费解析与计算
|
||||||
├── static/ 静态资源
|
│ ├── rates_extra.py 扩展报价唯一数据源
|
||||||
│ └── fa.jpg 网站图标
|
│ ├── tracking.py 卡片协议与旧演示函数兼容
|
||||||
├── templates/
|
│ ├── order_data.py 本地订单测试表、精确查询与快照导入
|
||||||
│ └── index.html 前端页面
|
│ ├── ai_client.py 模型 HTTP、压缩和追问
|
||||||
└── .vscode/
|
│ ├── chat_service.py 问答与 SSE 编排
|
||||||
└── launch.json VS Code 调试配置
|
│ ├── 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 | `/` | 客户聊天页面 |
|
||||||
| GET | `/stream_chat?msg=&conv_id=` | 流式对话(SSE) |
|
| GET / POST | `/stream_chat` | SSE 流式对话;POST JSON 接收 `msg`、`conv_id` |
|
||||||
| POST | `/api/new_chat` | 新建会话,返回 `{"id": 会话id}` |
|
| POST | `/api/new_chat` | 新建会话并保存欢迎语,返回 `id`、`welcome` |
|
||||||
| GET | `/api/get_history` | 会话列表 |
|
| GET | `/api/get_history` | 会话列表 |
|
||||||
| GET | `/api/get_messages?conv_id=` | 单个会话的全部消息 |
|
| GET | `/api/get_messages?conv_id=` | 单个会话的消息 |
|
||||||
| DELETE | `/api/del_history?id=` | 删除会话(级联删消息) |
|
| DELETE | `/api/del_history?id=` | 删除单个会话和消息 |
|
||||||
| DELETE | `/api/clear_all` | 清空全部历史 |
|
| DELETE | `/api/clear_all` | 清空聊天历史 |
|
||||||
|
| POST | `/api/upload` | 上传图片、PDF、TXT、CSV,最大 10MB |
|
||||||
|
| POST | `/api/human/request` | 申请人工客服 |
|
||||||
|
| POST | `/api/human/accept` | 客服接待 |
|
||||||
|
| POST | `/api/human/send` | 发送人工消息 |
|
||||||
|
| GET | `/api/human/poll` | 轮询人工消息 |
|
||||||
|
| POST | `/api/human/close` | 关闭人工会话 |
|
||||||
|
| POST | `/api/human/transfer` | 将接待中的会话转回排队列表 |
|
||||||
|
| POST | `/api/human/note` | 保存会话备注 |
|
||||||
|
| POST | `/api/human/recall` | 按 `mid` 撤回客服消息 |
|
||||||
|
| POST | `/api/rate` | 提交人工服务评价(`sid`、1–5 的 `stars`、`comment`) |
|
||||||
|
| GET | `/api/agents/list` | 客服列表,不包含密码 |
|
||||||
|
| GET | `/api/search_chat?q=` | 搜索历史消息,最多返回 50 条 |
|
||||||
|
| GET | `/admin` | 客服登录页面 |
|
||||||
|
| POST | `/admin/login` | 校验客服账号密码 |
|
||||||
|
| GET | `/admin/dashboard` | 客服工作台 |
|
||||||
|
| GET | `/api/export_chat` | 导出 Excel;`?format=csv` 保留 CSV 导出 |
|
||||||
|
|
||||||
### 流式事件格式
|
人工客服每天 8:00–22:00 接受新申请,由服务端校验时间;其余时间可提交离线留言。客服工作台保留白色界面,转接后由其他客服在排队列表主动接入。页面的铃铛按钮可开启浏览器通知,页面切到后台时提示排队或新消息;实际通知能力取决于浏览器权限。
|
||||||
|
|
||||||
`/stream_chat` 返回 Server-Sent Events,每帧是一条 JSON:
|
SSE 每帧为一条 JSON,主要事件包括:
|
||||||
|
|
||||||
```
|
```text
|
||||||
data: {"t": "正文片段"} 正常回答片段
|
data: {"t": "正文片段"}
|
||||||
data: {"e": "错误提示"} 出错时
|
data: {"e": "错误提示"}
|
||||||
data: {"card": {...}} 运单轨迹卡片
|
data: {"card": {...}}
|
||||||
data: {"d": 1} 正常结束
|
data: {"replace": "精简后的全文"}
|
||||||
|
data: {"suggest": ["相关追问"]}
|
||||||
|
data: {"d": 1}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 数据库
|
## 数据库与运行数据
|
||||||
|
|
||||||
SQLite,文件为 `history.db`,首次启动自动创建。
|
订单测试数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改。聊天每次重新查表;这份副本不会自动同步灰度业务库。没有新增管理页面和增删改接口。内部验收可查询已导入的测试样本;配置 `ORDER_DATA_TENANT_ID` 可以在服务器侧固定租户范围,不能将其视为客户登录授权。
|
||||||
|
|
||||||
- `chat_history`:会话(id、title、ctime、utime)
|
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
|
||||||
- `messages`:消息(id、conv_id、role、content、ctime)
|
|
||||||
|
|
||||||
运单卡片以 `__CARD__{json}` 形式保存在 `messages.content`,前端识别后重新渲染。
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
**页面报"服务未配置 API Key"**
|
|
||||||
环境变量没读到。确认变量名是 `DEEPSEEK_API_KEY`,并**重新打开终端**后再启动。
|
|
||||||
|
|
||||||
**页面报"API Key 无效或已过期"**
|
|
||||||
Key 已作废,去平台重新生成一把,再用 `setx` 覆盖。
|
|
||||||
|
|
||||||
**端口 5000 被占用**
|
|
||||||
```powershell
|
```powershell
|
||||||
netstat -ano | findstr ":5000 "
|
.\.venv\Scripts\python.exe scripts/import_order_test_data.py --snapshot "D:\private\orders.snapshot.json" --db "D:\data\history.db"
|
||||||
taskkill /PID <进程号> /F
|
|
||||||
```
|
```
|
||||||
确认只有一行 `LISTENING`,避免两个进程同时抢端口。
|
|
||||||
|
|
||||||
**改了 am.py 没生效**
|
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order` 的取数实现即可保留聊天契约。
|
||||||
Flask 启动时加载代码,改后端必须重启服务;只改 `index.html` 则刷新页面即可。
|
|
||||||
|
|
||||||
## 说明
|
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders`、`tickets` 和 `order_test_data` 十张表,并兼容已有数据,不预置账号或订单数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||||
|
|
||||||
- 运单轨迹当前为**模拟数据**(按单号固定生成),接真实接口只需修改 `am.py` 中的 `build_track()`。
|
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
||||||
- 服务使用 Waitress 多线程启动(8 个线程),适合内网小范围使用;面向公网仍需反向代理与 HTTPS。
|
|
||||||
- 默认只监听 `127.0.0.1`,如需局域网访问,把 `am.py` 中的 `HOST` 改为 `"0.0.0.0"`。
|
|
||||||
- 运费计费方式为**阶梯单价 × 计费重量**,价格与时效来自炜易达报价表。报价更新时只需修改 `RATES`;知识库文案在 `knowledge_list` 中,两者建议同步更新。
|
|
||||||
- 材积按 ÷6000 计算(1CBM = 167KG),与部分渠道 ÷5000 的口径不同,修改时注意核对报价表。
|
|
||||||
|
|
||||||
|
## 当前限制
|
||||||
|
|
||||||
|
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史中的模拟轨迹保留演示标识。
|
||||||
|
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。公网自用验收可按部署指南使用全站访问密码进入工作台。
|
||||||
|
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
|
||||||
|
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
|
||||||
|
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
|
||||||
|
- 服务优先使用 Waitress 的 8 个线程;缺少 Waitress 时会回退到 Flask 开发服务器。公网部署前还需完善鉴权及部署配置。
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
c = sqlite3.connect('history.db')
|
|
||||||
c.execute('''CREATE TABLE IF NOT EXISTS agents (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT UNIQUE,
|
|
||||||
password TEXT,
|
|
||||||
name TEXT
|
|
||||||
)''')
|
|
||||||
c.execute("INSERT OR IGNORE INTO agents(username,password,name) VALUES('admin','123','管理员')")
|
|
||||||
c.execute("INSERT OR IGNORE INTO agents(username,password,name) VALUES('agent1','123','客服小王')")
|
|
||||||
c.execute("INSERT OR IGNORE INTO agents(username,password,name) VALUES('agent2','123','客服小李')")
|
|
||||||
c.commit()
|
|
||||||
print("ok")
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
c = sqlite3.connect('history.db')
|
|
||||||
c.execute('''CREATE TABLE IF NOT EXISTS orders (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
phone TEXT,
|
|
||||||
track_no TEXT,
|
|
||||||
status TEXT DEFAULT "运输中",
|
|
||||||
created TEXT
|
|
||||||
)''')
|
|
||||||
c.commit()
|
|
||||||
print("ok")
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
c = sqlite3.connect('history.db')
|
|
||||||
c.execute("INSERT INTO orders(phone, track_no, status, created) VALUES('13800138000','YT20261028001','运输中','2026-10-28')")
|
|
||||||
c.execute("INSERT INTO orders(phone, track_no, status, created) VALUES('13800138000','YT20261028002','派送中','2026-10-27')")
|
|
||||||
c.execute("INSERT INTO orders(phone, track_no, status, created) VALUES('13900139000','YT20261028003','已签收','2026-10-26')")
|
|
||||||
c.commit()
|
|
||||||
print("ok")
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
c = sqlite3.connect('history.db')
|
|
||||||
c.execute('''CREATE TABLE IF NOT EXISTS tickets (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
content TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
ctime TEXT,
|
|
||||||
status TEXT DEFAULT "pending"
|
|
||||||
)''')
|
|
||||||
c.commit()
|
|
||||||
print("ok")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
c = sqlite3.connect('history.db')
|
|
||||||
print("messages:", [d[1] for d in c.execute("PRAGMA table_info(messages)").fetchall()])
|
|
||||||
print("chat_history:", [d[1] for d in c.execute("PRAGMA table_info(chat_history)").fetchall()])
|
|
||||||
@@ -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')
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Linux acceptance deployment entry points."""
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set +x
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
root=${1:?}
|
||||||
|
release=${2:?}
|
||||||
|
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||||
|
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
|
||||||
|
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||||
|
|
||||||
|
# Parse a small allowlist of non-secret release fields; never source a credential file.
|
||||||
|
while IFS='=' read -r name value; do
|
||||||
|
case "$name" in
|
||||||
|
CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY|CHAT_AUTH_DIR) ;;
|
||||||
|
*) echo 'Unexpected release setting.' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
[[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2
|
||||||
|
printf -v "$name" '%s' "$value"
|
||||||
|
done < "$release/release.env"
|
||||||
|
[[ "$CHAT_ROOT" == "$root" && "$CHAT_RELEASE" == "$release" ]] || exit 2
|
||||||
|
[[ "$CHAT_DATA_DIR" == "$root/data" && "$CHAT_LOG_DIR" == "$root/logs" && "$CHAT_RUNTIME_ENV" == "$release/runtime.env" ]] || exit 2
|
||||||
|
[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ && "$CHAT_IMAGE_ID" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
|
||||||
|
[[ "$CHAT_IMAGE" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
|
||||||
|
[[ "$HOST_PORT" =~ ^[0-9]+$ ]] && ((HOST_PORT >= 1024 && HOST_PORT <= 65535)) || exit 2
|
||||||
|
[[ -d "$CHAT_PROXY_DIR" && -r "$CHAT_TLS_CERT" && -r "$CHAT_TLS_KEY" ]] || exit 2
|
||||||
|
loaded_id=$(awk -F= '$1 == "CHAT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
|
||||||
|
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
|
||||||
|
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded application image changed.' >&2; exit 2; }
|
||||||
|
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
|
||||||
|
openssl x509 -in "$CHAT_TLS_CERT" -checkhost "$CHAT_DOMAIN" -noout >/dev/null
|
||||||
|
|
||||||
|
container=baiyue-logistics-chat-gray
|
||||||
|
if docker container inspect "$container" >/dev/null 2>&1; then
|
||||||
|
[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||||
|
old_port=$(docker port "$container" 5000/tcp || true)
|
||||||
|
else
|
||||||
|
old_port=''
|
||||||
|
fi
|
||||||
|
if [[ -n "$(ss -H -ltn "sport = :$HOST_PORT")" && "$old_port" != "127.0.0.1:$HOST_PORT" ]]; then
|
||||||
|
echo 'The requested host port belongs to another service.' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
|
||||||
|
[[ "$CHAT_AUTH_DIR" == /*/baiyue-logistics-chat-gray-proxy && "$CHAT_AUTH_DIR" != *..* ]] || exit 2
|
||||||
|
auth="$CHAT_AUTH_DIR/access.htpasswd"
|
||||||
|
if [[ -f "$site" ]] && ! head -n 1 "$site" | grep -Fq 'Managed for baiyue-logistics-chat-gray'; then
|
||||||
|
echo 'The proxy file is not owned by this application.' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
for candidate in "$CHAT_PROXY_DIR/"*.conf; do
|
||||||
|
[[ -f "$candidate" && "$candidate" != "$site" ]] || continue
|
||||||
|
if grep -Fq "$CHAT_DOMAIN" "$candidate"; then
|
||||||
|
echo 'The requested domain is already configured by another site.' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# The Nginx config directory may be root-only (for example BaoTa). Keep this
|
||||||
|
# application's password hash in a separate worker-readable owned directory.
|
||||||
|
if [[ -e "$CHAT_AUTH_DIR" ]]; then
|
||||||
|
[[ -f "$CHAT_AUTH_DIR/.project" && "$(cat "$CHAT_AUTH_DIR/.project")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||||
|
fi
|
||||||
|
install -d -m 755 "$CHAT_AUTH_DIR"
|
||||||
|
printf 'baiyue-logistics-chat-gray\n' > "$CHAT_AUTH_DIR/.project"
|
||||||
|
previous=''
|
||||||
|
if [[ -f "$root/current-release" ]]; then
|
||||||
|
previous=$(cat "$root/current-release")
|
||||||
|
[[ "$previous" == "$root"/releases/* && "$previous" != *..* && -f "$previous/release.env" ]] || exit 2
|
||||||
|
fi
|
||||||
|
[[ ! -f "$site" ]] || cp -p "$site" "$release/previous-site.conf"
|
||||||
|
[[ ! -f "$auth" ]] || cp -p "$auth" "$release/previous-site.htpasswd"
|
||||||
|
chmod 600 "$release/runtime.env"
|
||||||
|
|
||||||
|
compose() { docker compose --env-file "$1/release.env" -f "$1/compose.yml" "${@:2}"; }
|
||||||
|
activated=0
|
||||||
|
proxy_changed=0
|
||||||
|
rollback() {
|
||||||
|
result=$?
|
||||||
|
trap - ERR
|
||||||
|
# ERR is inherited by command substitutions; only the parent may roll back.
|
||||||
|
if ((BASH_SUBSHELL)); then exit "$result"; fi
|
||||||
|
set +e
|
||||||
|
if ((proxy_changed)); then
|
||||||
|
if [[ -f "$release/previous-site.conf" ]]; then cp -p "$release/previous-site.conf" "$site"; else rm -f -- "$site"; fi
|
||||||
|
if [[ -f "$release/previous-site.htpasswd" ]]; then cp -p "$release/previous-site.htpasswd" "$auth"; else rm -f -- "$auth"; fi
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
fi
|
||||||
|
if ((activated)); then
|
||||||
|
if [[ -n "$previous" ]]; then
|
||||||
|
compose "$previous" up -d --wait --wait-timeout 120
|
||||||
|
else
|
||||||
|
compose "$release" stop chat
|
||||||
|
compose "$release" rm -f chat
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo 'Gray activation failed; previous application/proxy configuration was restored where available. Persistent data was retained.' >&2
|
||||||
|
exit "$result"
|
||||||
|
}
|
||||||
|
trap rollback ERR
|
||||||
|
|
||||||
|
if [[ -f "$root/data/history.db" ]] && docker container inspect "$container" >/dev/null 2>&1; then
|
||||||
|
docker exec "$container" python -c 'import pathlib,sqlite3,sys; p=pathlib.Path("/var/lib/baiyue-logistics-chat/backups");p.mkdir(exist_ok=True); source=sqlite3.connect("/var/lib/baiyue-logistics-chat/history.db"); target=sqlite3.connect(str(p/("pre-release-"+sys.argv[1]+".db"))); source.backup(target);target.close();source.close()' "$(basename "$release")"
|
||||||
|
fi
|
||||||
|
compose "$release" config --quiet
|
||||||
|
activated=1
|
||||||
|
compose "$release" up -d --wait --wait-timeout 120
|
||||||
|
curl --fail --silent --show-error "http://127.0.0.1:$HOST_PORT/healthz" >/dev/null
|
||||||
|
proxy_changed=1
|
||||||
|
install -m 644 "$release/site.htpasswd" "$auth"
|
||||||
|
install -m 644 "$release/site.conf" "$site"
|
||||||
|
nginx -t
|
||||||
|
nginx -s reload
|
||||||
|
for path in / /api/export_chat /api/get_history; do
|
||||||
|
code=''
|
||||||
|
# A successful reload signal precedes the new workers accepting connections.
|
||||||
|
for attempt in {1..10}; do
|
||||||
|
if code=$(curl --silent --connect-timeout 3 --max-time 8 --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path"); then
|
||||||
|
[[ "$code" != 401 ]] || break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[[ "$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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: baiyue-logistics-chat-gray
|
||||||
|
services:
|
||||||
|
chat:
|
||||||
|
image: "${CHAT_IMAGE:?CHAT_IMAGE is required}"
|
||||||
|
container_name: baiyue-logistics-chat-gray
|
||||||
|
pull_policy: never
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${HOST_PORT:?HOST_PORT is required}:5000"
|
||||||
|
env_file:
|
||||||
|
- "${CHAT_RUNTIME_ENV:?CHAT_RUNTIME_ENV is required}"
|
||||||
|
volumes:
|
||||||
|
- "${CHAT_DATA_DIR:?CHAT_DATA_DIR is required}:/var/lib/baiyue-logistics-chat"
|
||||||
|
- "${CHAT_LOG_DIR:?CHAT_LOG_DIR is required}:/var/log/baiyue-logistics-chat"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,size=64m
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
stop_grace_period: 90s
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Validate gray release inputs and render files without logging credentials."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
def absolute_path(value):
|
||||||
|
path = PurePosixPath(value)
|
||||||
|
if not re.fullmatch(r'/[A-Za-z0-9_./-]+', value) or '..' in path.parts or str(path) == '/':
|
||||||
|
raise ValueError('A deployment path is invalid')
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GrayConfig:
|
||||||
|
domain: str
|
||||||
|
port: int
|
||||||
|
root: str
|
||||||
|
release: str
|
||||||
|
proxy_dir: str
|
||||||
|
certificate: str
|
||||||
|
private_key: str
|
||||||
|
commit: str
|
||||||
|
image: str
|
||||||
|
image_id: str
|
||||||
|
certbot_image_id: str
|
||||||
|
auth_dir: str = '/var/lib/baiyue-logistics-chat-gray-proxy'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_environment(cls, environment):
|
||||||
|
origin = urlsplit(environment['PUBLIC_ORIGIN'])
|
||||||
|
if (origin.scheme != 'https' or origin.username or origin.password or origin.port not in (None, 443)
|
||||||
|
or origin.path not in ('', '/') or origin.query or origin.fragment
|
||||||
|
or not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', origin.hostname or '')):
|
||||||
|
raise ValueError('PUBLIC_ORIGIN must be an HTTPS domain without a path')
|
||||||
|
port = int(environment['HOST_PORT'])
|
||||||
|
if not 1024 <= port <= 65535:
|
||||||
|
raise ValueError('HOST_PORT must be an unprivileged TCP port')
|
||||||
|
root = absolute_path(environment['GRAY_DEPLOY_DIR'])
|
||||||
|
if PurePosixPath(root).name != 'gray-logistics-chat':
|
||||||
|
raise ValueError('The deployment must use its dedicated gray-logistics-chat directory')
|
||||||
|
commit = environment['SOURCE_COMMIT']
|
||||||
|
image = environment['CHAT_IMAGE']
|
||||||
|
image_id = environment['CHAT_IMAGE_ID']
|
||||||
|
certbot_image_id = environment['CERTBOT_IMAGE_ID']
|
||||||
|
build = environment['BUILD_NUMBER']
|
||||||
|
if not re.fullmatch(r'[0-9a-f]{40}', commit) or not re.fullmatch(r'[1-9][0-9]*', build):
|
||||||
|
raise ValueError('Source commit or build number is invalid')
|
||||||
|
if image != f'baiyue-logistics-chat-gray:{commit[:12]}-b{build}':
|
||||||
|
raise ValueError('Image coordinates must identify the selected source and build')
|
||||||
|
if any(not re.fullmatch(r'sha256:[0-9a-f]{64}', value) for value in (image_id, certbot_image_id)):
|
||||||
|
raise ValueError('Docker image ID is invalid')
|
||||||
|
return cls(origin.hostname, port, root, f'{root}/releases/{commit[:12]}-b{build}',
|
||||||
|
absolute_path(environment['GRAY_PROXY_CONF_DIR']),
|
||||||
|
absolute_path(environment['GRAY_TLS_CERT_FILE']),
|
||||||
|
absolute_path(environment['GRAY_TLS_KEY_FILE']), commit, image, image_id, certbot_image_id)
|
||||||
|
|
||||||
|
def release_environment(self):
|
||||||
|
fields = {
|
||||||
|
'CHAT_IMAGE': self.image, 'CHAT_IMAGE_ID': self.image_id,
|
||||||
|
'CHAT_CERTBOT_IMAGE': self.image.replace('baiyue-logistics-chat-gray:', 'baiyue-logistics-chat-certbot:'),
|
||||||
|
'CHAT_CERTBOT_IMAGE_ID': self.certbot_image_id,
|
||||||
|
'SOURCE_COMMIT': self.commit, 'HOST_PORT': str(self.port),
|
||||||
|
'CHAT_ROOT': self.root, 'CHAT_RELEASE': self.release,
|
||||||
|
'CHAT_DATA_DIR': self.root + '/data', 'CHAT_LOG_DIR': self.root + '/logs',
|
||||||
|
'CHAT_RUNTIME_ENV': self.release + '/runtime.env',
|
||||||
|
'CHAT_DOMAIN': self.domain, 'CHAT_PROXY_DIR': self.proxy_dir,
|
||||||
|
'CHAT_TLS_CERT': self.certificate, 'CHAT_TLS_KEY': self.private_key,
|
||||||
|
'CHAT_AUTH_DIR': self.auth_dir,
|
||||||
|
}
|
||||||
|
return ''.join(f'{key}={value}\n' for key, value in fields.items())
|
||||||
|
|
||||||
|
def nginx_config(self):
|
||||||
|
return f'''# Managed for baiyue-logistics-chat-gray; 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.auth_dir}/access.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()
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set +x
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 022
|
||||||
|
root=${1:?}
|
||||||
|
release=${2:?}
|
||||||
|
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||||
|
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
|
||||||
|
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||||
|
while IFS='=' read -r name value; do
|
||||||
|
case "$name" in
|
||||||
|
CHAT_ROOT|CHAT_RELEASE|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT)
|
||||||
|
[[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2
|
||||||
|
printf -v "$name" '%s' "$value" ;;
|
||||||
|
esac
|
||||||
|
done < "$release/release.env"
|
||||||
|
[[ "$CHAT_ROOT" == "$root" && "$CHAT_RELEASE" == "$release" ]] || exit 2
|
||||||
|
[[ "$CHAT_TLS_CERT" == "$root/tls/config/live/$CHAT_DOMAIN/fullchain.pem" ]] || exit 2
|
||||||
|
[[ "$CHAT_TLS_KEY" == "$root/tls/config/live/$CHAT_DOMAIN/privkey.pem" ]] || exit 2
|
||||||
|
[[ "$CHAT_CERTBOT_IMAGE" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
|
||||||
|
loaded_id=$(awk -F= '$1 == "CERTBOT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
|
||||||
|
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
|
||||||
|
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded certificate image changed.' >&2; exit 2; }
|
||||||
|
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_CERTBOT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
|
||||||
|
|
||||||
|
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
|
||||||
|
if [[ -f "$site" ]]; then
|
||||||
|
head -n 1 "$site" | grep -Fq 'Managed for baiyue-logistics-chat-gray' || exit 2
|
||||||
|
else
|
||||||
|
for candidate in "$CHAT_PROXY_DIR/"*.conf; do
|
||||||
|
[[ -f "$candidate" ]] || continue
|
||||||
|
if grep -Fq "$CHAT_DOMAIN" "$candidate"; then echo 'Domain already belongs to another site.' >&2; exit 2; fi
|
||||||
|
done
|
||||||
|
cat > "$release/bootstrap.conf" <<BOOTSTRAP
|
||||||
|
# Managed for baiyue-logistics-chat-gray; HTTP certificate bootstrap only.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $CHAT_DOMAIN;
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /var/lib/baiyue-logistics-chat-acme;
|
||||||
|
try_files \$uri =404;
|
||||||
|
}
|
||||||
|
location / { return 404; }
|
||||||
|
}
|
||||||
|
BOOTSTRAP
|
||||||
|
install -m 644 "$release/bootstrap.conf" "$site"
|
||||||
|
if ! nginx -t; then rm -f -- "$site"; exit 2; fi
|
||||||
|
nginx -s reload
|
||||||
|
fi
|
||||||
|
install -d -m 755 /var/lib/baiyue-logistics-chat-acme
|
||||||
|
install -d -m 700 "$root/tls" "$root/tls/config" "$root/tls/work" "$root/tls/logs"
|
||||||
|
if [[ ! -f "$CHAT_TLS_CERT" ]] || ! openssl x509 -in "$CHAT_TLS_CERT" -checkend 604800 -noout >/dev/null; then
|
||||||
|
docker run --rm --name baiyue-logistics-chat-certbot-issue \
|
||||||
|
-v "$root/tls/config:/etc/letsencrypt" -v "$root/tls/work:/var/lib/letsencrypt" \
|
||||||
|
-v "$root/tls/logs:/var/log/letsencrypt" -v /var/lib/baiyue-logistics-chat-acme:/webroot \
|
||||||
|
"$CHAT_CERTBOT_IMAGE" certonly --non-interactive --webroot -w /webroot \
|
||||||
|
--cert-name "$CHAT_DOMAIN" -d "$CHAT_DOMAIN" --agree-tos --register-unsafely-without-email
|
||||||
|
fi
|
||||||
|
openssl x509 -in "$CHAT_TLS_CERT" -checkhost "$CHAT_DOMAIN" -noout >/dev/null
|
||||||
|
|
||||||
|
install -m 700 "$release/renew-tls.sh" "$root/tls/renew.sh"
|
||||||
|
unit=/etc/systemd/system/baiyue-logistics-chat-tls-renew.service
|
||||||
|
timer=/etc/systemd/system/baiyue-logistics-chat-tls-renew.timer
|
||||||
|
for file in "$unit" "$timer"; do
|
||||||
|
if [[ -e "$file" ]]; then head -n 1 "$file" | grep -Fq 'Managed for baiyue-logistics-chat-gray' || exit 2; fi
|
||||||
|
done
|
||||||
|
cat > "$unit" <<UNIT
|
||||||
|
# Managed for baiyue-logistics-chat-gray
|
||||||
|
[Unit]
|
||||||
|
Description=Renew logistics chat gray certificate
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/bin/bash $root/tls/renew.sh $root $CHAT_DOMAIN $CHAT_CERTBOT_IMAGE
|
||||||
|
UNIT
|
||||||
|
cat > "$timer" <<TIMER
|
||||||
|
# Managed for baiyue-logistics-chat-gray
|
||||||
|
[Unit]
|
||||||
|
Description=Daily logistics chat gray certificate renewal
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
RandomizedDelaySec=6h
|
||||||
|
Persistent=true
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
TIMER
|
||||||
|
chmod 644 "$unit" "$timer"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now baiyue-logistics-chat-tls-renew.timer
|
||||||
|
printf 'Gray certificate is ready; dedicated renewal timer enabled.\n'
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set +x
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
: "${TARGET_SSH_KEY:?}" "${TARGET_SSH_USER:?}" "${TARGET_SSH_HOST:?}" "${TARGET_SSH_KNOWN_HOSTS:?}"
|
||||||
|
: "${PREVIEW_USERNAME:?}" "${PREVIEW_PASSWORD:?}" "${CHAT_IMAGE:?}" "${CHAT_IMAGE_ID:?}" "${CERTBOT_IMAGE:?}"
|
||||||
|
[[ "$PREVIEW_USERNAME" =~ ^[A-Za-z0-9_.@-]+$ ]] || exit 2
|
||||||
|
[[ "$TARGET_SSH_USER" =~ ^[A-Za-z0-9_.-]+$ ]] || exit 2
|
||||||
|
[[ "$TARGET_SSH_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || exit 2
|
||||||
|
|
||||||
|
temporary=$(mktemp -d)
|
||||||
|
cleanup() { rm -rf -- "$temporary"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
python3 -m deploy.gray_config "$temporary/release"
|
||||||
|
release=$(awk -F= '$1 == "CHAT_RELEASE" {print $2}' "$temporary/release/release.env")
|
||||||
|
root=$(awk -F= '$1 == "CHAT_ROOT" {print $2}' "$temporary/release/release.env")
|
||||||
|
python3 - <<'TARGET'
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
def addresses(host):
|
||||||
|
return {item[4][0] for item in socket.getaddrinfo(host, 443, socket.AF_INET, socket.SOCK_STREAM)}
|
||||||
|
if not addresses(urlsplit(os.environ['PUBLIC_ORIGIN']).hostname) & addresses(os.environ['TARGET_SSH_HOST']):
|
||||||
|
raise SystemExit('The public domain does not resolve to the managed gray target.')
|
||||||
|
TARGET
|
||||||
|
printf '%s:' "$PREVIEW_USERNAME" > "$temporary/release/site.htpasswd"
|
||||||
|
printf '%s\n' "$PREVIEW_PASSWORD" | openssl passwd -apr1 -stdin >> "$temporary/release/site.htpasswd"
|
||||||
|
|
||||||
|
ssh_options=(-i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes
|
||||||
|
-o "UserKnownHostsFile=$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12)
|
||||||
|
target="$TARGET_SSH_USER@$TARGET_SSH_HOST"
|
||||||
|
run_ssh() { ssh "${ssh_options[@]}" "$target" "$@"; }
|
||||||
|
|
||||||
|
# Only an owned, dedicated directory may receive releases or runtime data.
|
||||||
|
run_ssh bash -s -- "$root" "$release" <<'PREPARE'
|
||||||
|
set -Eeuo pipefail
|
||||||
|
root=$1
|
||||||
|
release=$2
|
||||||
|
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||||
|
[[ "$release" == "$root"/releases/* ]] || exit 2
|
||||||
|
[[ "$(id -u)" == 0 ]] || { echo 'The managed gray deployment account must have the required host permissions.' >&2; exit 2; }
|
||||||
|
for program in docker nginx openssl curl ss systemctl sha256sum; do command -v "$program" >/dev/null; done
|
||||||
|
if [[ -e "$root" && ! -f "$root/.project" ]] && [[ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
|
||||||
|
echo 'Refusing to reuse an unowned deployment directory.' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -f "$root/.project" ]]; then
|
||||||
|
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||||
|
fi
|
||||||
|
install -d -m 700 "$root" "$root/releases" "$root/data" "$root/logs"
|
||||||
|
printf 'baiyue-logistics-chat-gray\n' > "$root/.project"
|
||||||
|
chown 10001:10001 "$root/data" "$root/logs"
|
||||||
|
[[ ! -e "$release" ]] || { echo 'Release directory already exists; inspect its state before retrying.' >&2; exit 2; }
|
||||||
|
install -d -m 700 "$release"
|
||||||
|
PREPARE
|
||||||
|
|
||||||
|
# Verify the exact exported archive before loading. Classic and containerd image
|
||||||
|
# stores can report different IDs for the same archive, so retain target IDs too.
|
||||||
|
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip > "$temporary/images.tar.gz"
|
||||||
|
archive_sha=$(sha256sum "$temporary/images.tar.gz" | awk '{print $1}')
|
||||||
|
scp "${ssh_options[@]}" "$temporary/images.tar.gz" "$target:$release/images.tar.gz"
|
||||||
|
run_ssh bash -s -- "$root" "$release" "$archive_sha" "$CHAT_IMAGE" "$CERTBOT_IMAGE" "$SOURCE_COMMIT" <<'LOAD'
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
root=$1
|
||||||
|
release=$2
|
||||||
|
checksum=$3
|
||||||
|
app_image=$4
|
||||||
|
certbot_image=$5
|
||||||
|
source_commit=$6
|
||||||
|
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||||
|
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
|
||||||
|
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||||
|
[[ "$checksum" =~ ^[a-f0-9]{64}$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
|
||||||
|
[[ "$app_image" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
|
||||||
|
[[ "$certbot_image" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
|
||||||
|
printf '%s %s\n' "$checksum" "$release/images.tar.gz" | sha256sum --check --status
|
||||||
|
gzip -dc "$release/images.tar.gz" | docker load
|
||||||
|
for image in "$app_image" "$certbot_image"; do
|
||||||
|
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" == "$source_commit" ]] || exit 2
|
||||||
|
done
|
||||||
|
app_id=$(docker image inspect --format '{{.Id}}' "$app_image")
|
||||||
|
certbot_id=$(docker image inspect --format '{{.Id}}' "$certbot_image")
|
||||||
|
[[ "$app_id" =~ ^sha256:[a-f0-9]{64}$ && "$certbot_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
|
||||||
|
printf 'ARCHIVE_SHA256=%s\nCHAT_IMAGE_ID=%s\nCERTBOT_IMAGE_ID=%s\n' "$checksum" "$app_id" "$certbot_id" > "$release/loaded-image-ids.env"
|
||||||
|
rm -f -- "$release/images.tar.gz"
|
||||||
|
echo 'Image archive checksum and source labels verified; target image IDs recorded.'
|
||||||
|
LOAD
|
||||||
|
scp "${ssh_options[@]}" "$temporary/release/"* "$target:$release/"
|
||||||
|
run_ssh bash "$release/prepare-tls.sh" "$root" "$release"
|
||||||
|
run_ssh bash "$release/activate.sh" "$root" "$release"
|
||||||
|
|
||||||
|
# Verify the public TLS endpoint and the actual acceptance password without putting it in arguments.
|
||||||
|
python3 - <<'VERIFY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from urllib.request import HTTPBasicAuthHandler, HTTPPasswordMgrWithDefaultRealm, build_opener
|
||||||
|
origin = os.environ['PUBLIC_ORIGIN'].rstrip('/')
|
||||||
|
manager = HTTPPasswordMgrWithDefaultRealm()
|
||||||
|
manager.add_password(None, origin, os.environ['PREVIEW_USERNAME'], os.environ['PREVIEW_PASSWORD'])
|
||||||
|
opener = build_opener(HTTPBasicAuthHandler(manager))
|
||||||
|
with opener.open(origin + '/healthz', timeout=30) as response:
|
||||||
|
assert response.status == 200 and json.load(response) == {'status': 'ok'}
|
||||||
|
print('Authenticated gray HTTPS health check passed.')
|
||||||
|
VERIFY
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
/usr/sbin/nginx -t
|
||||||
|
/usr/bin/systemctl reload nginx
|
||||||
@@ -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
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 模块导航与拆分规范
|
||||||
|
|
||||||
|
四阶段拆分已落地:`am.py` 保留命令及 WSGI 兼容入口,应用工厂和业务代码位于 `logistics_chat/`,页面样式与脚本位于 `static/`。本文描述当前结构及后续结构调整约定。
|
||||||
|
|
||||||
|
## 先定位,再阅读
|
||||||
|
|
||||||
|
从 [AGENTS.md](../AGENTS.md) 选择文件,以函数、常量、接口为锚点,搜索后只读取相关实现和调用方。模块移动时更新索引,不保存易失效的行号。默认遵守 Git 忽略规则,排除附件、数据库、日志、备份、虚拟环境和工作草稿。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg --files
|
||||||
|
rg -n -F 'calc_freight' logistics_chat/pricing.py
|
||||||
|
rg -n -F '/api/human/' logistics_chat/routes static/js examples
|
||||||
|
rg -n '^(def |class | def )' scripts tests -g '*.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整行很长,搜索时加 `--max-columns 200 --max-columns-preview`,避免输出整份数据。
|
||||||
|
|
||||||
|
## 当前模块索引
|
||||||
|
|
||||||
|
| 业务范围 | 实际文件与搜索锚点 | 联动位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 应用入口与配置 | `am.py`:`main`、延迟访问的 `app`;`logistics_chat/__init__.py`:`create_app`;`config.py`:`default_config`、`get_setting`;`runtime.py`:`serve_app` | `start.bat`、`.env.example` |
|
||||||
|
| Jenkins 灰度部署 | `Jenkinsfile`、`Dockerfile`;`deploy/gray_config.py`:`GrayConfig`;`publish-gray.sh`、`activate-gray.sh`、`prepare-gray-tls.sh`、`renew-gray-tls.sh` | [部署指南](deployment.md);独立持久化数据、证书和回环端口;`wsgi.py` 提供容器内路径 |
|
||||||
|
| 请求钩子 | `logistics_chat/hooks.py`:`rate_limit`、`no_cache_html` | 工厂注册钩子;限流状态在 `app.extensions['rate_hits']` |
|
||||||
|
| SQLite 与维护 | `logistics_chat/db.py`:`connect`、`close_connections`、`init_db`、`backup_db`、`save_message`、`last_user_question`、`load_context` | 维护 CLI 在 `scripts/`;旧会话自动清理仍未启用 |
|
||||||
|
| 历史接口 | `logistics_chat/history_service.py`:`new_chat`、`get_history`、`get_messages`、`del_history`、`clear_all`;对应 `routes/history.py` | `static/js/chat.js`:`ensureConv`、`loadHistory`、`openConversation` |
|
||||||
|
| 知识检索 | `logistics_chat/knowledge.py`:`knowledge_list`、`search_knowledge` | [knowledge.md](knowledge.md) 与代码同步,程序不读取该文档 |
|
||||||
|
| 运费与附加费 | `logistics_chat/pricing.py`:`RATES`、`parse_shipping_intent`、`dims_to_cm`、`pick_rate`、`calc_freight`、`freight_reply`、`calc_warehouse_card`、`detect_surcharge`、`check_island` | `logistics_chat/rates_extra.py` 为唯一扩展数据源,根目录同名文件仅兼容转发 |
|
||||||
|
| 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史模拟轨迹标为演示数据 |
|
||||||
|
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL |
|
||||||
|
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
|
||||||
|
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
|
||||||
|
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
|
||||||
|
| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` |
|
||||||
|
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`;客户与客服脚本先加载 `support.js`(表情、通知、评价);客服再加载 `admin_tools.js`(备注、转接、历史搜索),最后加载 `admin.js`;保留全局事件入口 |
|
||||||
|
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_dev_integration.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs`、`chat.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证草稿、异步切换、转接、撤回及评价;浏览器使用临时测试库 |
|
||||||
|
|
||||||
|
除标明的根目录文件外,表内缩写的 Python 文件均相对 `logistics_chat/`。
|
||||||
|
|
||||||
|
## 当前文件布局
|
||||||
|
|
||||||
|
```text
|
||||||
|
am.py # CLI;显式请求 am.app 时创建默认 WSGI 应用
|
||||||
|
rates_extra.py # 旧导入路径的兼容转发
|
||||||
|
logistics_chat/
|
||||||
|
├── __init__.py # create_app 与显式运行时初始化
|
||||||
|
├── config.py # 根目录 .env、环境变量、稳定根路径、服务/资源配置
|
||||||
|
├── db.py # 连接、schema、历史持久化、备份
|
||||||
|
├── knowledge.py # 知识与关键词检索
|
||||||
|
├── pricing.py # 运费规则、解析、换算、附加费说明
|
||||||
|
├── rates_extra.py # 扩展报价数据
|
||||||
|
├── tracking.py # 单号识别、模拟轨迹、卡片协议
|
||||||
|
├── order_data.py # 可编辑订单测试表、精确查询、快照导入与卡片事实
|
||||||
|
├── ai_client.py # 模型 HTTP、压缩与追问
|
||||||
|
├── chat_service.py # 问答编排与 SSE 帧
|
||||||
|
├── history_service.py # 历史会话 CRUD
|
||||||
|
├── human_service.py # 人工状态与消息
|
||||||
|
├── business_service.py # 反馈、评价、统计、登录、订单、留言、历史搜索、Excel/CSV
|
||||||
|
├── file_service.py # 附件校验、保存、清理
|
||||||
|
├── hooks.py # 限流及 HTML/SSE 缓存头
|
||||||
|
├── runtime.py # Waitress / Flask 启动
|
||||||
|
└── routes/
|
||||||
|
├── __init__.py # Blueprint 注册
|
||||||
|
├── chat.py
|
||||||
|
├── history.py
|
||||||
|
├── human.py
|
||||||
|
├── admin.py
|
||||||
|
├── business.py
|
||||||
|
├── files.py
|
||||||
|
└── pages.py
|
||||||
|
templates/ # HTML/Jinja 及原有事件属性
|
||||||
|
static/css/ # theme.css 共享令牌;chat/admin/admin_login 页面样式
|
||||||
|
static/js/ # chat.js、admin.js、admin_login.js,经典脚本
|
||||||
|
scripts/ # 可独立运行的维护 CLI
|
||||||
|
tests/ # 按业务模块组织的回归
|
||||||
|
```
|
||||||
|
|
||||||
|
## 模块边界与依赖方向
|
||||||
|
|
||||||
|
- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。
|
||||||
|
- `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。
|
||||||
|
- `init_db` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。
|
||||||
|
- 所有模型 HTTP 经 `ai_client.post_completion`。保留流式问答 `(5, 90)`、压缩 `(5, 30)`、追问 `(5, 15)` 三组连接/读取超时;不能因共用客户端而合并期限。流结束或取消时释放 HTTP 响应。
|
||||||
|
- 模块导入不建库、不备份、不清理附件、不访问模型。`create_app` 默认显式初始化运行目录、数据库、备份与旧附件清理;`INITIALIZE_RUNTIME=False` 可禁止这些动作,此时调用方负责提供已有 schema。访问兼容入口 `am.app` 视为显式创建应用。
|
||||||
|
- `config.ROOT_DIR` 指向仓库根目录,模板/静态目录显式提供给 Flask。数据库、上传、备份、日志使用配置中的绝对路径,避免包文件移动改变数据位置。
|
||||||
|
- `default_config` 用 `python-dotenv` 读取根目录 `.env`,不修改 `os.environ`;只读取已支持的 `DEEPSEEK_API_KEY`,不展开 `${...}`。显式工厂配置优先于进程环境(含空值),再使用文件值。配置在应用创建时确定,修改后重启;真实 `.env` 不纳入 Git。
|
||||||
|
- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。
|
||||||
|
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
|
||||||
|
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
|
||||||
|
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
|
||||||
|
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
|
||||||
|
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
|
||||||
|
|
||||||
|
## 后续结构调整顺序
|
||||||
|
|
||||||
|
| 阶段 | 范围 | 验收要求 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. 纯规则 | 知识、报价、运费、运单识别 | 边界结果与迁移前一致;固定时钟比较模拟轨迹;导入无运行数据副作用 |
|
||||||
|
| 2. 数据访问 | 配置、SQLite、备份、附件 | 临时目录验证 schema 兼容、事务、保留/清理时机和跨目录 CLI |
|
||||||
|
| 3. 服务/路由 | 模型、聊天、人工、应用工厂、Blueprint | URL/方法/状态/JSON/SSE 不变;保留限流、HTML/SSE 缓存头及根入口 |
|
||||||
|
| 4. 前端 | 模板与 CSS/JS | 仅迁移时对照原资源内容;主动改版时验证语法、资源 URL、加载顺序、响应式和客户/客服页交互 |
|
||||||
|
|
||||||
|
每阶段完成相关验证再推进依赖阶段。鉴权、缺表和计费的业务修复单独说明,避免在结构调整中顺带改行为。
|
||||||
|
|
||||||
|
## 必须保留的接口与数据约定
|
||||||
|
|
||||||
|
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。
|
||||||
|
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
|
||||||
|
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。子单没有独立货物数据时不继承父单总重量;COMPLETED 只表示头程订单完成,不表示快递签收。页面和模型上下文只显示订单事实,不附加测试副本声明或导入时间;旧历史订单卡片中的相关元数据也不展示。订单追问只在同一 `conv_id` 内复用最近单号,并重新查表取得手工修改后的值;仅明确指向上一票或简短字段追问可复用,新报价或其他话题不会沿用旧订单。
|
||||||
|
- 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。
|
||||||
|
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
|
||||||
|
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
|
||||||
|
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
|
||||||
|
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
|
||||||
|
- 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。
|
||||||
|
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
|
||||||
|
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
|
||||||
|
- 后台鉴权和会话隔离仍不完整,订单表为内部测试副本,不提供实时物流轨迹。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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
|
||||||
@@ -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)。
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Jenkins 灰度部署
|
||||||
|
|
||||||
|
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS 和全站访问密码。
|
||||||
|
|
||||||
|
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。后台鉴权与会话隔离仍不完整,灰度验收须保留全站密码;此流程不代表已具备面向多个客户开放的权限体系。
|
||||||
|
|
||||||
|
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
|
||||||
|
|
||||||
|
## Jenkins 任务
|
||||||
|
|
||||||
|
任务名为 `baiyue-logistics-chat-gray`。定义选择 Pipeline script from SCM,本项目 Git 地址为仓库,分支字段为 `${SOURCE_REF}`,脚本路径为 `Jenkinsfile`。公开仓库可匿名读取;转为私有后配置只读 SCM 凭据。
|
||||||
|
|
||||||
|
`java-builder` 标签代理须有 Docker、Bash、Python 3、Node.js 18+、OpenSSL、SSH,并能下载所选 Python 基础镜像和 PyPI 依赖。标签不表示固定物理节点。
|
||||||
|
|
||||||
|
以下参数由私有灰度配置提供,实际地址和凭据值不写入源码:
|
||||||
|
|
||||||
|
| 参数 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `ENVIRONMENT` | 单选 `gray`,流水线拒绝其他值 |
|
||||||
|
| `SOURCE_REF` | 本次发布的不可变标签或提交,也可明确选择开发分支 |
|
||||||
|
| `PYTHON_BASE_IMAGE` | 已验证的官方 Python 3.12 基础镜像 |
|
||||||
|
| `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. 构建完成后才绑定运行凭据。发布脚本校验路径、域名和源提交,生成私有配置并发送镜像及发布文件。服务器验证镜像归档的 SHA-256 后加载,记录目标主机的镜像 ID。
|
||||||
|
5. 首次签发域名证书,等待应用健康,再安装本服务的 Nginx 配置。已有业务的容器和站点配置不参与本次发布。
|
||||||
|
6. Jenkins 使用验收账号验证公网 HTTPS `/healthz`;之后在浏览器验证 AI 流式回复、上传、转人工和工作台。
|
||||||
|
|
||||||
|
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,不依赖 `latest`。Docker 的 classic 与 containerd 镜像存储可能返回不同 ID;跨主机以归档校验和与源提交标签核验传输,激活前再与 `loaded-image-ids.env` 中的目标 ID 比较,拒绝加载后被替换的镜像。归档加载后删除,构建机 ID 保留在 `release.env` 供追溯。
|
||||||
|
|
||||||
|
## 数据与失败回退
|
||||||
|
|
||||||
|
`GRAY_DEPLOY_DIR` 如 `/data/services/gray-logistics-chat`,包含:
|
||||||
|
|
||||||
|
| 路径 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `.project` | 服务所有权标记,拒绝复用未知目录 |
|
||||||
|
| `data/` | SQLite、附件和备份,跨发布保留 |
|
||||||
|
| `logs/` | 应用日志 |
|
||||||
|
| `releases/<commit12>-b<build>/` | Compose、镜像坐标、私有运行配置和站点候选 |
|
||||||
|
| `current-release` | 已通过应用及入口检查的发布目录 |
|
||||||
|
| `tls/` | 独立 ACME 账号、证书、日志和续期脚本 |
|
||||||
|
|
||||||
|
模型密钥写入权限为 600 的 `runtime.env`,由 Compose 注入进程。验收密码只在 Jenkins 生成 Nginx 哈希,服务器保存哈希文件。
|
||||||
|
|
||||||
|
密码哈希位于独立的 `/var/lib/baiyue-logistics-chat-gray-proxy/access.htpasswd`,目录 755、文件 644,供 Nginx 工作进程读取;不放在可能只有 root 可访问的宝塔站点配置目录,也不放明文密码。该目录有本服务所有权标记,不复用未知目录。
|
||||||
|
|
||||||
|
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
|
||||||
|
|
||||||
|
新环境自动创建八张业务表,不创建默认客服账号或演示数据。验收用全站密码直接进入工作台;Nginx 验收账号不是应用内客服账号。
|
||||||
|
|
||||||
|
## HTTPS 及续期
|
||||||
|
|
||||||
|
域名须解析到已核实的灰度入口,公网 80、443 端口可达。其他域名的证书不能直接用于新域名。
|
||||||
|
|
||||||
|
独立 Certbot 镜像通过 HTTP-01 webroot 签发证书;首次只新增本服务的 HTTP 验证站点,业务接口尚不公开。验证目录为 `/var/lib/baiyue-logistics-chat-acme`,最终配置持续保留该 HTTP 路径。
|
||||||
|
|
||||||
|
证书参数须指向:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<GRAY_DEPLOY_DIR>/tls/config/live/<域名>/fullchain.pem
|
||||||
|
<GRAY_DEPLOY_DIR>/tls/config/live/<域名>/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
ACME 账号和私钥仅保存在服务器私有目录,当前使用无邮箱账号。专用 `baiyue-logistics-chat-tls-renew.timer` 每日检查本服务证书,内容变化后检查并重载 Nginx,不接管其他证书任务。
|
||||||
|
|
||||||
|
## 排查
|
||||||
|
|
||||||
|
先从 Jenkins 构建记录核对阶段及源 SHA。服务器检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --filter name=baiyue-logistics-chat-gray
|
||||||
|
docker logs --tail 100 baiyue-logistics-chat-gray
|
||||||
|
systemctl status baiyue-logistics-chat-tls-renew.timer --no-pager
|
||||||
|
nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
- 构建失败:核对镜像和 PyPI 网络,不跳过测试发布。
|
||||||
|
- SSH 失败:核对受管凭据和 known_hosts,不禁用指纹检查。
|
||||||
|
- 证书失败:核对 DNS 和 HTTP 验证路径,保留本服务的验证站点与私有 ACME 状态。
|
||||||
|
- 容器不健康:检查独立目录的 UID 10001 权限、日志和 `/healthz`;健康检查不计入业务限流。
|
||||||
|
- 入口 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)。
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# 柏悦智能物流客服系统 · 知识资料
|
# 柏悦智能物流客服系统 · 知识资料
|
||||||
|
|
||||||
> 本文件由 `am.py` 的 `knowledge_list` 自动导出,供评审与知识维护使用。
|
> 本文件整理了 [logistics_chat/knowledge.py](../logistics_chat/knowledge.py) 中的 `knowledge_list`,供评审与知识维护使用。
|
||||||
> 数据来源:炜易达 VIP 10月15日9点执行报价表。
|
> 数据来源:炜易达 VIP 10月15日9点执行报价表。
|
||||||
> 修改知识内容请直接编辑 `am.py` 中的 `knowledge_list`,改完后重新导出本文件。
|
> 修改知识内容请编辑 `logistics_chat/knowledge.py` 中的 `knowledge_list`,并同步更新本文件;程序不会直接读取此文档。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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` 使用该值。接口当前没有完整登录鉴权,请仅在受控的开发环境调试。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"sid": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"conv_id": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"conv_id": 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"sid": 1,
|
||||||
|
"side": "agent",
|
||||||
|
"content": "您好,请问需要咨询什么?"
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
c = sqlite3.connect('history.db')
|
|
||||||
for u in ['123','456','789']:
|
|
||||||
c.execute("INSERT OR REPLACE INTO agents(username,password,name) VALUES(?,?,?)", (u,'123',u))
|
|
||||||
c.commit()
|
|
||||||
print("ok")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"conv_id":295}
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""DeepSeek HTTP calls; callers retain their streaming/compression/followup deadlines."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import requests
|
||||||
|
from requests.exceptions import RequestException, Timeout
|
||||||
|
|
||||||
|
from .config import get_setting
|
||||||
|
|
||||||
|
|
||||||
|
def post_completion(body, *, timeout, stream=False):
|
||||||
|
return requests.post(
|
||||||
|
get_setting('MODEL_URL'),
|
||||||
|
headers={'Authorization': f"Bearer {get_setting('DEEPSEEK_API_KEY')}",
|
||||||
|
'Content-Type': 'application/json'},
|
||||||
|
json=body, stream=stream, timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
EN_COMPRESS_RULE = '原文是英文,压缩后的结果必须仍然使用英文(English),不得翻译成中文。'
|
||||||
|
|
||||||
|
|
||||||
|
def has_chinese(t):
|
||||||
|
return any(('一' <= ch <= '龥' for ch in t))
|
||||||
|
|
||||||
|
|
||||||
|
def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')):
|
||||||
|
"""回答超长时,让模型压缩到规定字数内;失败则原样返回"""
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
r = None
|
||||||
|
try:
|
||||||
|
body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': (EN_COMPRESS_RULE if has_chinese(text) is False else '') + f'你是文字精简助手。把用户给的内容压缩到 {limit} 字以内(必须严格满足,超出即为不合格),必须保留所有数字、金额、时效和渠道名称,删除重复表述、客套话和次要说明,结构保持不变。'}, {'role': 'user', 'content': text}], 'stream': False, 'max_tokens': 800}
|
||||||
|
r = post_completion(body, timeout=(5, 30))
|
||||||
|
if r.status_code == 200:
|
||||||
|
short = r.json()['choices'][0]['message']['content'].strip()
|
||||||
|
print(f'[压缩] {len(text)} 字 -> {len(short)} 字')
|
||||||
|
return short
|
||||||
|
print(f'[压缩失败] 状态码 {r.status_code}')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[压缩异常] {e}')
|
||||||
|
finally:
|
||||||
|
if r is not None:
|
||||||
|
r.close()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def gen_followups(user_msg, answer_text):
|
||||||
|
"""根据本轮问答,生成 3 个客户可能接着问的问题(失败返回空列表,不影响主流程)"""
|
||||||
|
if not get_setting('DEEPSEEK_API_KEY'):
|
||||||
|
return []
|
||||||
|
r = None
|
||||||
|
try:
|
||||||
|
body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': '你是物流客服助手。根据用户问题和你的回答,生成3个客户最可能接着问的简短问题(每个不超过20字),只输出JSON字符串数组,不要任何解释。'}, {'role': 'user', 'content': f'用户问:{user_msg}\n你的回答:{answer_text[:500]}\n\n请生成3个追问:'}], 'stream': False, 'max_tokens': 200}
|
||||||
|
r = post_completion(body, timeout=(5, 15))
|
||||||
|
if r.status_code != 200:
|
||||||
|
return []
|
||||||
|
text = r.json()['choices'][0]['message']['content'].strip()
|
||||||
|
m = re.search('\\[.*\\]', text, re.S)
|
||||||
|
if m:
|
||||||
|
arr = json.loads(m.group(0))
|
||||||
|
return [str(x).strip() for x in arr if str(x).strip()][:3]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if r is not None:
|
||||||
|
r.close()
|
||||||
|
return []
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Feedback, reporting, login, orders and offline tickets."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .db import connect, now_str
|
||||||
|
from .tracking import card_to_text
|
||||||
|
|
||||||
|
|
||||||
|
def feedback(data):
|
||||||
|
"""收集 👍/👎 反馈,存到 feedback 表"""
|
||||||
|
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]
|
||||||
|
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
|
||||||
|
conn.close()
|
||||||
|
return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv,
|
||||||
|
'rating_avg': round(rating_avg, 1), 'rating_count': rating_count}
|
||||||
|
|
||||||
|
|
||||||
|
def my_orders(phone):
|
||||||
|
if not phone:
|
||||||
|
return {'orders': []}
|
||||||
|
conn = connect()
|
||||||
|
rows = conn.execute('SELECT track_no, status, created FROM orders WHERE phone=? ORDER BY id DESC', (phone,)).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return {'orders': [{'track_no': r[0], 'status': r[1], 'created': r[2]} for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
def add_ticket(data):
|
||||||
|
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 agents_list():
|
||||||
|
conn = connect()
|
||||||
|
# rowid also works with the legacy agents table that has an explicit INTEGER id.
|
||||||
|
rows = conn.execute('SELECT rowid, username, name FROM agents ORDER BY rowid').fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [{'id': row[0], 'username': row[1], 'name': row[2]} for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def rate_chat(data):
|
||||||
|
sid, stars = data.get('sid'), data.get('stars', 0)
|
||||||
|
comment = data.get('comment', '')
|
||||||
|
if not sid or type(stars) is not int or not 1 <= stars <= 5 or not isinstance(comment, str):
|
||||||
|
return ({'ok': False}, 400)
|
||||||
|
conn = connect()
|
||||||
|
if not conn.execute('SELECT 1 FROM human_sessions WHERE id=?', (sid,)).fetchone():
|
||||||
|
conn.close()
|
||||||
|
return ({'ok': False}, 404)
|
||||||
|
conn.execute('INSERT INTO ratings(sid, stars, comment, ctime) VALUES(?,?,?,?)',
|
||||||
|
(sid, stars, comment, datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
def search_chat(keyword):
|
||||||
|
if not keyword:
|
||||||
|
return {'results': []}
|
||||||
|
conn = connect()
|
||||||
|
rows = conn.execute('''SELECT h.title, m.role, m.content, m.ctime FROM messages m
|
||||||
|
JOIN chat_history h ON m.conv_id=h.id WHERE m.content LIKE ? ORDER BY m.id DESC LIMIT 50''',
|
||||||
|
('%' + keyword + '%',)).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [{'title': row[0], 'role': row[1], 'content': card_to_text(row[2]), 'ctime': row[3]} for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def export_chat(format='xlsx'):
|
||||||
|
conn = connect()
|
||||||
|
rows = conn.execute('SELECT h.title, m.role, m.content, m.ctime FROM messages m JOIN chat_history h ON m.conv_id=h.id ORDER BY m.id').fetchall()
|
||||||
|
conn.close()
|
||||||
|
import io
|
||||||
|
if format == 'csv':
|
||||||
|
import csv
|
||||||
|
buf = io.StringIO()
|
||||||
|
w = csv.writer(buf)
|
||||||
|
w.writerow(['标题', '角色', '内容', '时间'])
|
||||||
|
for r in rows:
|
||||||
|
w.writerow([r[0], r[1], r[2] or '', r[3]])
|
||||||
|
return buf.getvalue()
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
|
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = '聊天记录'
|
||||||
|
sheet.append(['对话标题', '角色', '内容', '时间'])
|
||||||
|
for cell in sheet[1]:
|
||||||
|
cell.font = Font(bold=True, color='FFFFFF')
|
||||||
|
cell.fill = PatternFill('solid', fgColor='315FD5')
|
||||||
|
cell.alignment = Alignment(horizontal='center')
|
||||||
|
roles = {'user': '客户', 'assistant': '客服', 'system': '系统'}
|
||||||
|
for title, role, content, ctime in rows:
|
||||||
|
sheet.append([title, roles.get(role, role), content or '', ctime])
|
||||||
|
for cell in sheet[sheet.max_row]:
|
||||||
|
cell.data_type = 's'
|
||||||
|
for column, width in zip('ABCD', (20, 8, 80, 20)):
|
||||||
|
sheet.column_dimensions[column].width = width
|
||||||
|
buf = io.BytesIO()
|
||||||
|
workbook.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Compose knowledge, freight and model output into the existing SSE protocol."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from . import ai_client
|
||||||
|
from .ai_client import compress_answer, gen_followups, has_chinese
|
||||||
|
from .config import get_setting
|
||||||
|
from .db import connect, last_user_question, load_context, save_message
|
||||||
|
from .knowledge import search_knowledge
|
||||||
|
from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply,
|
||||||
|
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island,
|
||||||
|
is_shipping_destination_reply)
|
||||||
|
from .tracking import CARD_PREFIX
|
||||||
|
from .order_data import answer_query, extract_reference
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def sse(payload):
|
||||||
|
"""统一的事件格式:data: {"t":正文} / {"e":错误} / {"card":卡片} / {"d":1结束}
|
||||||
|
JSON 编码可以避免正文里的换行把 SSE 帧撑断。"""
|
||||||
|
return 'data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n'
|
||||||
|
|
||||||
|
|
||||||
|
QUOTE_FOLLOWUP_PREFIX = r'^(?:那(?:么)?|换成|改成|改为|如果|还是|再算|同样)'
|
||||||
|
|
||||||
|
|
||||||
|
def is_shipping_followup(message, intent=None):
|
||||||
|
"""Recognize bounded quote edits without carrying facts across new topics."""
|
||||||
|
if re.search(r'新(?:的)?一(?:票|批)|另(?:一|外一?)(?:票|批)|新(?:的)?(?:货|订单)|换个话题|理赔|赔偿|破损|丢失|签收|退件|退款|投诉', message):
|
||||||
|
return False
|
||||||
|
intent = intent if intent is not None else parse_shipping_intent(message)
|
||||||
|
text = message.strip().strip(',,。.!!??、:: ')
|
||||||
|
prefix = re.match(QUOTE_FOLLOWUP_PREFIX, text)
|
||||||
|
if prefix and any(intent.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')):
|
||||||
|
return True
|
||||||
|
if intent['region'] is not None and is_shipping_destination_reply(text):
|
||||||
|
return True
|
||||||
|
short = re.sub(QUOTE_FOLLOWUP_PREFIX, '', text, count=1).strip()
|
||||||
|
short = re.sub(r'^(?:请问|请|再问一下)', '', short).strip()
|
||||||
|
if re.fullmatch(r'(?:海运|海派|空运|空派|铁路|班列|卡航|卡车|sea(?:\s+freight)?|air(?:\s+freight)?|rail|truck)'
|
||||||
|
r'(?:报价|价格|费用|运费|时效)?(?:呢|怎么样|如何|多少钱|多久|几天)?', short, re.I):
|
||||||
|
return True
|
||||||
|
if re.fullmatch(r'(?:价格|费用|运费|时效)(?:呢|是多少|多少|怎么样|如何|多久|几天)?|多少钱', short):
|
||||||
|
return True
|
||||||
|
if intent['weight'] is not None and re.fullmatch(r'\d+(?:\.\d+)?\s*(?:kg|公斤|千克|吨|lbs?|tons?|kilograms?)(?:呢)?', short, re.I):
|
||||||
|
return True
|
||||||
|
return bool(intent['dims'] and re.fullmatch(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*(?:cm|mm|m|厘米|毫米|米)?(?:呢)?', short, re.I))
|
||||||
|
|
||||||
|
|
||||||
|
def shipping_intent_with_context(message, conv_id):
|
||||||
|
intent = parse_shipping_intent(message)
|
||||||
|
intent['dim_unit'] = detect_dim_unit(message)
|
||||||
|
if conv_id and is_shipping_followup(message, intent):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
recent = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 6", (conv_id,)).fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
for (previous,) in recent:
|
||||||
|
if extract_reference(previous):
|
||||||
|
break
|
||||||
|
earlier = parse_shipping_intent(previous)
|
||||||
|
if not any(earlier.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')):
|
||||||
|
if is_shipping_followup(previous, earlier):
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
for key in ('weight', 'channel', 'region'):
|
||||||
|
if intent.get(key) is None:
|
||||||
|
intent[key] = earlier.get(key)
|
||||||
|
if intent['dims'] is None and earlier['dims']:
|
||||||
|
intent['dims'] = earlier['dims']
|
||||||
|
intent['dim_unit'] = detect_dim_unit(previous)
|
||||||
|
if not is_shipping_followup(previous, earlier):
|
||||||
|
break
|
||||||
|
if intent['channel']:
|
||||||
|
base = intent['channel'].removesuffix('_uk')
|
||||||
|
intent['channel'] = base + '_uk' if intent['region'] == 'uk' else base
|
||||||
|
return intent
|
||||||
|
|
||||||
|
|
||||||
|
def create_chat_stream(msg, conv_id):
|
||||||
|
if not msg:
|
||||||
|
return (sse({'e': '问题不能为空'}), {})
|
||||||
|
if conv_id:
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
exists = connection.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
if not exists:
|
||||||
|
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
|
||||||
|
order_answer = answer_query(msg, conv_id)
|
||||||
|
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
|
||||||
|
order_answer = {'t':'收到您的图片,请描述一下您的问题。'}
|
||||||
|
ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None
|
||||||
|
if ship and ship['region'] is None and (ship['weight'] is not None or ship['dims'] or
|
||||||
|
re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or
|
||||||
|
(ship['channel'] and (is_shipping_followup(msg, ship) or re.search(r'报价|价格|费用|运费|多少钱|\b(?:quote|cost|price)\b', msg, re.I)))):
|
||||||
|
order_answer = {'t':'请提供目的国家或地区、目的仓或邮编,以便匹配对应费率;当前无法确定报价,可联系人工客服确认。'}
|
||||||
|
if ship and ship['region'] == 'unsupported' and (ship['weight'] is not None or ship['dims'] or ship['channel'] or
|
||||||
|
is_shipping_destination_reply(msg) or re.search(r'报价|价格|运费|费用|多少钱', msg)):
|
||||||
|
order_answer = {'t':'当前报价表尚未包含该目的地的费率,请提供目的仓或邮编、货物品名、件数和尺寸,由人工客服确认报价。'}
|
||||||
|
if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']:
|
||||||
|
order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'}
|
||||||
|
if order_answer is not None:
|
||||||
|
|
||||||
|
def track_stream():
|
||||||
|
save_message(conv_id, 'user', msg)
|
||||||
|
content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False)
|
||||||
|
if 'card' in order_answer else order_answer['t'])
|
||||||
|
save_message(conv_id, 'assistant', content)
|
||||||
|
yield sse(order_answer)
|
||||||
|
yield sse({'d': 1})
|
||||||
|
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||||
|
if not get_setting('DEEPSEEK_API_KEY'):
|
||||||
|
return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {})
|
||||||
|
know_text = search_knowledge(msg)
|
||||||
|
freight_text = None
|
||||||
|
if ship['weight'] or ship['dims']:
|
||||||
|
freight_text = freight_reply(ship)
|
||||||
|
if not ship['channel']:
|
||||||
|
freight_text += '\n\n(客户没有指定渠道,上面已同时给出海运和空运,请在回答末尾询问更看重时效还是成本)'
|
||||||
|
extra_parts = []
|
||||||
|
cbm_m = re.search('(\\d+(?:\\.\\d+)?)\\s*(?:cbm|方|立方)', msg, re.I)
|
||||||
|
if cbm_m and ship['region'] != 'uk':
|
||||||
|
cbm = float(cbm_m.group(1))
|
||||||
|
ch = '海运'
|
||||||
|
if re.search('卡航|卡车', msg):
|
||||||
|
ch = '卡航'
|
||||||
|
elif re.search('铁路|班列', msg):
|
||||||
|
ch = '铁路'
|
||||||
|
elif re.search('普卡', msg):
|
||||||
|
ch = '普卡'
|
||||||
|
want = None
|
||||||
|
for kw, ctry in (('德国', 'DE'), ('法国', 'FR'), ('波兰', 'PL'), ('捷克', 'CZ'), ('西班牙', 'ES'), ('马德里', 'ES'), ('中国', '')):
|
||||||
|
if kw in msg:
|
||||||
|
want = ctry
|
||||||
|
break
|
||||||
|
card = calc_warehouse_card(cbm, ch, country=want)
|
||||||
|
if card:
|
||||||
|
store = card['warehouse'].splitlines()[0]
|
||||||
|
extra_parts.append(f"【海外仓卡派 · 按方计费】\n 仓点:{store}\n 计费:{card['note']}\n 渠道:{card['channel']},适用档位 {card['tier']}\n 运费:{card['formula']}\n 说明:单箱实重超 35KG 加收 30 元/箱(封顶 3600 元);不接受仓储,特殊情况 85 元/箱/周")
|
||||||
|
sur = detect_surcharge(msg)
|
||||||
|
if sur:
|
||||||
|
extra_parts.append(surcharge_reply(sur[0], sur[1], ship.get('weight')))
|
||||||
|
pc_m = re.search('(?<![0-9])(\\d{4,5})(?![0-9])', msg)
|
||||||
|
if pc_m and re.search('邮编|postcode|zip|偏远|岛屿', msg, re.I):
|
||||||
|
hit = check_island(pc_m.group(1))
|
||||||
|
if hit:
|
||||||
|
extra_parts.append(f'【偏远判断】邮编 {pc_m.group(1)} 属于{hit[0]}({hit[1]})的岛屿/偏远地区,需加收 DPD 岛屿附加费 35 欧元/件')
|
||||||
|
else:
|
||||||
|
extra_parts.append(f'【偏远判断】邮编 {pc_m.group(1)} 未命中 DPD 岛屿偏远清单;但仍需以 UPS/DPD 官网查询结果为准,偏远费以账单为准')
|
||||||
|
follow_src = None
|
||||||
|
if not know_text:
|
||||||
|
prev_q = last_user_question(conv_id)
|
||||||
|
if prev_q:
|
||||||
|
know_text = search_knowledge(prev_q)
|
||||||
|
if know_text:
|
||||||
|
follow_src = prev_q
|
||||||
|
_c = connect()
|
||||||
|
_msg_count = _c.execute("SELECT COUNT(*) FROM messages WHERE conv_id=? AND role='user'", (conv_id,)).fetchone()[0]
|
||||||
|
_c.close()
|
||||||
|
is_first_turn = _msg_count == 0
|
||||||
|
save_message(conv_id, 'user', msg)
|
||||||
|
LENGTH_RULE = '回答长度要求:全文不少于 120 字、不超过 350 字。内容要完整、信息充分,把关键点讲清楚,可分 2~4 段或用短列表呈现。可以补充相关的说明(例如适用范围、注意事项、下一步建议),让对方看完就能明白。不要寒暄客套,不要重复同一个数字,不要罗列与问题无关的信息。'
|
||||||
|
is_en = bool(re.search('[A-Za-z]{3,}', msg)) and (not re.search('[\\u4e00-\\u9fa5]', msg))
|
||||||
|
if is_en:
|
||||||
|
LANGUAGE_RULE = '【语言要求 · 最高优先级】用户用英文提问,你必须用英文(English)回答全文,包括所有小节标题(如 Conclusion / Calculation / Notes),不要出现任何中文。以下给出的中文结构与参考资料仅用于理解,输出必须翻译成英文。'
|
||||||
|
else:
|
||||||
|
LANGUAGE_RULE = '【语言要求】用户用中文提问,请用中文回答。'
|
||||||
|
if freight_text or extra_parts:
|
||||||
|
blocks = []
|
||||||
|
if freight_text:
|
||||||
|
blocks.append(freight_text)
|
||||||
|
blocks.extend(extra_parts)
|
||||||
|
body = '\n\n'.join(blocks)
|
||||||
|
system_prompt = f'{LANGUAGE_RULE}\n\n你是柏悦物流智能客服。\n下面是根据报价表计算好的结果,请严格按照这些数字向客户说明。\n不要重新计算、不要改动任何数字,但要讲清楚是怎么算出来的。\n\n{body}\n\n请按以下结构输出(用英文回答时请把标题也翻译成英文):\n【结论】先给结论:哪种方式、多少钱、多久到。\n【计算过程】分点列出,最多 5 行,让客户看懂钱花在哪。\n【补充说明】2~3 行:适用范围(如单件重量、尺寸要求)、偏远地区或敏感货附加费、下一步建议(如提供仓点或运单号可进一步核算)。\n\n{LENGTH_RULE}\n客户没指定渠道时,把主要几种渠道(海运/铁路/卡航/空运)列出来对比,并问客户更看重时效还是成本。\n'
|
||||||
|
elif know_text:
|
||||||
|
extra = f'\n(以上资料对应上一句提问:{follow_src})' if follow_src else ''
|
||||||
|
system_prompt = f'{LANGUAGE_RULE}\n\n你是柏悦物流智能客服,严格优先使用下面知识库内容回答,不要自己编造业务信息。\n回答语句通顺自然,分段清晰,让人一眼看清重点。\n要结合上文理解用户的追问(比如"那空运呢""这个多少钱"指的是什么)。\n\n{LENGTH_RULE}\n\n知识库内容(供你理解,输出需按语言要求翻译):{know_text}{extra}\n'
|
||||||
|
else:
|
||||||
|
system_prompt = f'{LANGUAGE_RULE}\n\n你是柏悦物流智能客服。\n物流相关简单问题可以正常回答;不属于物流业务的问题礼貌说明无法解答,并建议联系人工客服。\n回答语句通顺,表述自然。\n\n{LENGTH_RULE}\n如果用户在追问,请结合上文的对话继续回答,不要重复问已经说过的信息。\n'
|
||||||
|
context = load_context(conv_id, msg)
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': system_prompt}] + context, 'stream': True}
|
||||||
|
try:
|
||||||
|
resp = ai_client.post_completion(body, stream=True, timeout=(5, 90))
|
||||||
|
except ai_client.Timeout:
|
||||||
|
yield sse({'e': '模型响应超时,请稍后重试'})
|
||||||
|
return
|
||||||
|
except ai_client.RequestException:
|
||||||
|
yield sse({'e': '无法连接模型服务,请检查网络后重试'})
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
yield sse({'e': '服务异常,请稍后重试'})
|
||||||
|
return
|
||||||
|
if resp.status_code != 200:
|
||||||
|
detail = ''
|
||||||
|
try:
|
||||||
|
detail = (resp.json().get('error') or {}).get('message', '')
|
||||||
|
except Exception:
|
||||||
|
detail = (resp.text or '')[:120]
|
||||||
|
tips = {401: '系统繁忙,请稍后再试', 402: '系统繁忙,请稍后再试', 429: '咨询量较大,请稍后再试'}
|
||||||
|
tip = tips.get(resp.status_code, '系统繁忙,请稍后再试')
|
||||||
|
resp.close()
|
||||||
|
yield sse({'e': tip})
|
||||||
|
return
|
||||||
|
parts = []
|
||||||
|
interrupted = False
|
||||||
|
answer_saved = False
|
||||||
|
try:
|
||||||
|
got_text = False
|
||||||
|
completed = False
|
||||||
|
finish_reason = None
|
||||||
|
for raw in resp.iter_lines():
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
line = raw.decode('utf-8', errors='ignore')
|
||||||
|
if not line.startswith('data:'):
|
||||||
|
continue
|
||||||
|
data = line[5:].strip()
|
||||||
|
if data == '[DONE]':
|
||||||
|
completed = True
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
j = json.loads(data)
|
||||||
|
choice = j['choices'][0]
|
||||||
|
chunk = choice.get('delta', {}).get('content') or ''
|
||||||
|
finish_reason = choice.get('finish_reason') or finish_reason
|
||||||
|
if finish_reason == 'stop':
|
||||||
|
completed = True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if chunk:
|
||||||
|
got_text = True
|
||||||
|
parts.append(chunk)
|
||||||
|
yield sse({'t': chunk})
|
||||||
|
if not got_text:
|
||||||
|
yield sse({'e': '模型没有返回内容,请重试'})
|
||||||
|
return
|
||||||
|
if not completed or finish_reason not in (None, 'stop'):
|
||||||
|
interrupted = True
|
||||||
|
yield sse({'e': '回答未生成完整,请点击重试'})
|
||||||
|
return
|
||||||
|
full_text = ''.join(parts)
|
||||||
|
limit = get_setting('MAX_ANSWER_CHARS') if has_chinese(full_text) else get_setting('MAX_ANSWER_CHARS_EN')
|
||||||
|
if len(full_text) > limit:
|
||||||
|
short = compress_answer(full_text, limit)
|
||||||
|
if len(short) > limit:
|
||||||
|
short = compress_answer(short, limit - 60)
|
||||||
|
if short != full_text:
|
||||||
|
full_text = short
|
||||||
|
yield sse({'replace': short})
|
||||||
|
save_message(conv_id, 'assistant', full_text)
|
||||||
|
answer_saved = True
|
||||||
|
if is_first_turn:
|
||||||
|
try:
|
||||||
|
fu = gen_followups(msg, full_text)
|
||||||
|
if fu:
|
||||||
|
yield sse({'suggest': fu})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield sse({'d': 1})
|
||||||
|
except GeneratorExit:
|
||||||
|
interrupted = True
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
interrupted = True
|
||||||
|
yield sse({'e': '传输中断,请点击重试'})
|
||||||
|
finally:
|
||||||
|
text = ''.join(parts)
|
||||||
|
try:
|
||||||
|
if interrupted and text and not answer_saved:
|
||||||
|
save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)')
|
||||||
|
finally:
|
||||||
|
resp.close()
|
||||||
|
return (generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""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(),
|
||||||
|
'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) 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]
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""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')
|
||||||
|
if 'recalled_id' not in hm_cols:
|
||||||
|
cur.execute('ALTER TABLE human_messages ADD COLUMN recalled_id INTEGER')
|
||||||
|
hs_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_sessions)').fetchall()]
|
||||||
|
if 'note' not in hs_cols:
|
||||||
|
cur.execute("ALTER TABLE human_sessions ADD COLUMN note TEXT DEFAULT ''")
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS ratings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sid INTEGER NOT NULL,
|
||||||
|
stars INTEGER NOT NULL,
|
||||||
|
comment TEXT DEFAULT '',
|
||||||
|
ctime TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
phone TEXT,
|
||||||
|
track_no TEXT,
|
||||||
|
status TEXT,
|
||||||
|
created TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
ctime TEXT,
|
||||||
|
status TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
from .order_data import init_order_table
|
||||||
|
init_order_table(conn)
|
||||||
|
old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall()
|
||||||
|
for cid, title in old:
|
||||||
|
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str()))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def backup_db():
|
||||||
|
"""启动时把数据库备份到 backups/,文件名带日期,只保留最近 7 天"""
|
||||||
|
try:
|
||||||
|
bdir = get_setting('BACKUP_DIR')
|
||||||
|
os.makedirs(bdir, exist_ok=True)
|
||||||
|
today = datetime.now().strftime('%Y%m%d')
|
||||||
|
dst = os.path.join(bdir, f'history_{today}.db')
|
||||||
|
if not os.path.exists(dst):
|
||||||
|
shutil.copy2(get_setting('DATABASE'), dst)
|
||||||
|
cutoff = datetime.now() - timedelta(days=7)
|
||||||
|
for f in os.listdir(bdir):
|
||||||
|
m = re.match('history_(\\d{8})\\.db$', f)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if datetime.strptime(m.group(1), '%Y%m%d') < cutoff:
|
||||||
|
os.remove(os.path.join(bdir, f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[数据库备份失败] {e}')
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_conversations():
|
||||||
|
"""自动清理 30 天前的旧对话,防止数据库无限膨胀"""
|
||||||
|
try:
|
||||||
|
conn = connect()
|
||||||
|
c = conn.cursor()
|
||||||
|
cutoff = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
rows = c.execute('SELECT id FROM chat_history WHERE utime < ?', (cutoff,)).fetchall()
|
||||||
|
ids = [r[0] for r in rows]
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
for cid in ids:
|
||||||
|
c.execute('DELETE FROM messages WHERE conv_id=?', (cid,))
|
||||||
|
c.execute(f"DELETE FROM chat_history WHERE id IN ({','.join('?' * len(ids))})", ids)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f'[自动清理] 已删除 {len(ids)} 条 30 天前的旧对话')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[自动清理失败] {e}')
|
||||||
|
|
||||||
|
|
||||||
|
def make_title(content):
|
||||||
|
"""生成会话标题:优先用文字内容,只有附件时显示附件数量"""
|
||||||
|
lines = [l for l in content.split('\n') if not l.strip().startswith('[附件]')]
|
||||||
|
text = ' '.join(lines).strip()
|
||||||
|
if text and text != '(发送了附件)' and (text != '请帮我看看这个附件'):
|
||||||
|
return text[:24]
|
||||||
|
n = len([l for l in content.split('\n') if l.strip().startswith('[附件]')])
|
||||||
|
if n == 1:
|
||||||
|
return '📎 已上传附件'
|
||||||
|
if n > 1:
|
||||||
|
return f'📎 {n}个附件'
|
||||||
|
return content[:24]
|
||||||
|
|
||||||
|
|
||||||
|
def save_message(conv_id, role, content):
|
||||||
|
"""把一条消息写进会话;用户消息做幂等处理,重试不会重复入库。"""
|
||||||
|
if not conv_id or not content:
|
||||||
|
return
|
||||||
|
conn = connect()
|
||||||
|
cur = conn.cursor()
|
||||||
|
if role == 'user':
|
||||||
|
last = cur.execute('SELECT role, content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 1', (conv_id,)).fetchone()
|
||||||
|
if last and last[0] == 'user' and (last[1] == content):
|
||||||
|
conn.close()
|
||||||
|
return 'duplicate'
|
||||||
|
inserted = cur.execute('INSERT INTO messages(conv_id, role, content, ctime) '
|
||||||
|
'SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM chat_history WHERE id=?)',
|
||||||
|
(conv_id, role, content, now_str(), conv_id)).rowcount
|
||||||
|
if not inserted:
|
||||||
|
conn.close()
|
||||||
|
return 'deleted'
|
||||||
|
if role == 'user':
|
||||||
|
row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone()
|
||||||
|
if row and (not row[0] or row[0] == '新对话'):
|
||||||
|
cur.execute('UPDATE chat_history SET title=? WHERE id=?', (make_title(content), conv_id))
|
||||||
|
cur.execute('UPDATE chat_history SET utime=? WHERE id=?', (now_str(), conv_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def last_user_question(conv_id):
|
||||||
|
"""上一条用户提问(必须在当前这句入库之前调用)"""
|
||||||
|
if not conv_id:
|
||||||
|
return None
|
||||||
|
conn = connect()
|
||||||
|
row = conn.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def load_context(conv_id, msg):
|
||||||
|
"""取最近 MAX_CONTEXT 条消息,拼成模型要的 messages(不含 system)。"""
|
||||||
|
if not conv_id:
|
||||||
|
return [{'role': 'user', 'content': msg}]
|
||||||
|
conn = connect()
|
||||||
|
rows = conn.execute('SELECT role, content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT ?', (conv_id, get_setting('MAX_CONTEXT'))).fetchall()
|
||||||
|
conn.close()
|
||||||
|
rows.reverse()
|
||||||
|
history = [{'role': r, 'content': card_to_text(c)} for r, c in rows]
|
||||||
|
if not history or history[-1]['role'] != 'user' or history[-1]['content'] != msg:
|
||||||
|
history.append({'role': 'user', 'content': msg})
|
||||||
|
return history
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Conversation CRUD with unchanged response data."""
|
||||||
|
|
||||||
|
from .db import connect, now_str
|
||||||
|
|
||||||
|
|
||||||
|
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。'
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)',
|
||||||
|
(conv_id, 'assistant', WELCOME_TEXT, now))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'id': conv_id, 'welcome': WELCOME_TEXT}
|
||||||
|
|
||||||
|
|
||||||
|
def get_history():
|
||||||
|
conn = connect()
|
||||||
|
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history 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 human_messages WHERE session_id IN (SELECT id FROM human_sessions WHERE conv_id=?)', (hid,))
|
||||||
|
cur.execute('DELETE FROM human_sessions WHERE conv_id=?', (hid,))
|
||||||
|
cur.execute('DELETE FROM messages WHERE conv_id=?', (hid,))
|
||||||
|
cur.execute('DELETE FROM chat_history WHERE id=?', (hid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_all():
|
||||||
|
conn = connect()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('DELETE FROM human_messages')
|
||||||
|
cur.execute('DELETE FROM human_sessions')
|
||||||
|
cur.execute('DELETE FROM messages')
|
||||||
|
cur.execute('DELETE FROM chat_history')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""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)
|
||||||
|
if not 8 <= datetime.now().hour < 22:
|
||||||
|
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'}
|
||||||
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
conn = connect()
|
||||||
|
conn.execute('BEGIN IMMEDIATE')
|
||||||
|
if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone():
|
||||||
|
conn.close()
|
||||||
|
return ({'ok':False, 'error':'conversation_not_found'}, 404)
|
||||||
|
row = conn.execute("SELECT id FROM human_sessions WHERE conv_id=? AND status!='closed' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
sid = row[0]
|
||||||
|
else:
|
||||||
|
cur = conn.execute('INSERT INTO human_sessions(conv_id, status, ctime) VALUES(?,?,?)', (conv_id, 'waiting', now))
|
||||||
|
sid = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True, 'session_id': sid}
|
||||||
|
|
||||||
|
|
||||||
|
def human_poll(sid, after, typing_map):
|
||||||
|
conn = connect()
|
||||||
|
msgs = conn.execute('SELECT id, side, content, ctime, read_at, recalled_id FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
|
||||||
|
st = conn.execute("SELECT status, COALESCE(note, '') FROM human_sessions WHERE id=?", (sid,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
is_typing = False
|
||||||
|
ts = typing_map.get(str(sid))
|
||||||
|
if ts and time.time() - ts < 5:
|
||||||
|
is_typing = True
|
||||||
|
return {'status': st[0] if st else 'closed', 'note': st[1] if st else '',
|
||||||
|
'typing': is_typing, 'messages': [dict(id=m[0], side=m[1], content=m[2], ctime=m[3],
|
||||||
|
read=bool(m[4]), **({'recalled_id': m[5]} if m[5] else {})) for m in msgs]}
|
||||||
|
|
||||||
|
|
||||||
|
def human_send(data):
|
||||||
|
sid = data.get('sid')
|
||||||
|
side = data.get('side', 'customer')
|
||||||
|
content = data.get('content') or ''
|
||||||
|
if not sid or not isinstance(content, str) or not content.strip() or side not in ('customer', 'agent'):
|
||||||
|
return ({'ok': False}, 400)
|
||||||
|
content = content.strip()
|
||||||
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
conn = connect()
|
||||||
|
conn.execute('BEGIN IMMEDIATE')
|
||||||
|
row = conn.execute('SELECT conv_id, status FROM human_sessions WHERE id=?', (sid,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return ({'ok': False, 'error': 'session_not_found'}, 404)
|
||||||
|
if row[1] == 'closed' or (data.get('conv_id') is not None and data['conv_id'] != row[0]):
|
||||||
|
conn.close()
|
||||||
|
return ({'ok': False, 'error': 'conversation_changed'}, 409)
|
||||||
|
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
def human_queue():
|
||||||
|
conn = connect()
|
||||||
|
rows = conn.execute("SELECT id, conv_id, status, ctime FROM human_sessions WHERE status='waiting' ORDER BY id").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [{'id': r[0], 'conv_id': r[1], 'ctime': r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def human_active():
|
||||||
|
conn = connect()
|
||||||
|
rows = conn.execute("SELECT id, conv_id FROM human_sessions WHERE status='active' ORDER BY id").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [{'id': r[0], 'conv_id': r[1]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def human_accept(data):
|
||||||
|
sid = data.get('sid')
|
||||||
|
conn = connect()
|
||||||
|
changed = conn.execute("UPDATE human_sessions SET status='active' WHERE id=? AND status IN ('waiting','active') "
|
||||||
|
'AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)', (sid,)).rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True} if changed else ({'ok':False, 'error':'session_closed_or_missing'}, 409)
|
||||||
|
|
||||||
|
|
||||||
|
def human_close(data):
|
||||||
|
sid = data.get('sid')
|
||||||
|
conn = connect()
|
||||||
|
conn.execute("UPDATE human_sessions SET status='closed' WHERE id=?", (sid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
def human_transfer(data, typing_map):
|
||||||
|
sid = data.get('sid')
|
||||||
|
conn = connect()
|
||||||
|
changed = conn.execute("UPDATE human_sessions SET status='waiting' WHERE id=? AND status='active'", (sid,)).rowcount
|
||||||
|
if not changed:
|
||||||
|
conn.close()
|
||||||
|
return ({'ok': False, 'error': '会话当前无法转接'}, 409)
|
||||||
|
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)',
|
||||||
|
(sid, 'system', '客服已转接,请其他客服接入', datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
typing_map.pop(str(sid), None)
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
def human_note(data):
|
||||||
|
note = data.get('note', '')
|
||||||
|
if not isinstance(note, str):
|
||||||
|
return ({'ok': False}, 400)
|
||||||
|
conn = connect()
|
||||||
|
changed = conn.execute('UPDATE human_sessions SET note=? WHERE id=?', (note, data.get('sid'))).rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True} if changed else ({'ok': False}, 404)
|
||||||
|
|
||||||
|
|
||||||
|
def human_recall(data):
|
||||||
|
conn = connect()
|
||||||
|
mid = data.get('mid')
|
||||||
|
# Only the first request can change the original message; retries emit no duplicate event.
|
||||||
|
conn.execute('BEGIN IMMEDIATE')
|
||||||
|
row = conn.execute('SELECT session_id, side, content FROM human_messages WHERE id=?', (mid,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return ({'ok': False}, 404)
|
||||||
|
if row[1] == 'system' and row[2] == '该消息已撤回':
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True}
|
||||||
|
if row[1] != 'agent':
|
||||||
|
conn.close()
|
||||||
|
return ({'ok': False}, 400)
|
||||||
|
conn.execute("UPDATE human_messages SET side='system', content='该消息已撤回' WHERE id=?", (mid,))
|
||||||
|
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime, recalled_id) VALUES(?,?,?,?,?)',
|
||||||
|
(row[0], 'system', '该消息已撤回', datetime.now().strftime('%Y-%m-%d %H:%M:%S'), mid))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
def human_typing(data, typing_map):
|
||||||
|
sid = data.get('sid')
|
||||||
|
typing_map[str(sid)] = time.time()
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
def human_stop_typing(data, typing_map):
|
||||||
|
sid = data.get('sid')
|
||||||
|
typing_map.pop(str(sid), None)
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
def human_read(data):
|
||||||
|
sid = data.get('sid')
|
||||||
|
conn = connect()
|
||||||
|
conn.execute("UPDATE human_messages SET read_at=? WHERE session_id=? AND side='customer' AND read_at IS NULL", (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), sid))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {'ok': True}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Local, editable order test data. This repository can later be replaced by MCP."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .config import get_setting
|
||||||
|
|
||||||
|
|
||||||
|
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
|
||||||
|
'status', 'country_code', 'transport_mode', 'warehouse_code', 'order_role',
|
||||||
|
'parent_order_no', 'package_count', 'gross_weight_kg', 'volume_cbm',
|
||||||
|
'history_json', 'data_note', 'source_updated_at', 'snapshot_at')
|
||||||
|
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
|
||||||
|
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
|
||||||
|
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
|
||||||
|
FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目的国',
|
||||||
|
'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数',
|
||||||
|
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
|
||||||
|
'parent':'父订单号', 'updated':'来源更新时间'}
|
||||||
|
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
|
||||||
|
ORDER_DETAIL = r'状态|进度|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
|
||||||
|
|
||||||
|
|
||||||
|
class OrderDataUnavailable(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def init_order_table(connection):
|
||||||
|
connection.execute('''CREATE TABLE IF NOT EXISTS order_test_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_order_id TEXT, tenant_id TEXT NOT NULL DEFAULT 'test',
|
||||||
|
order_no TEXT NOT NULL COLLATE NOCASE,
|
||||||
|
inbound_code TEXT COLLATE NOCASE, shipment_id TEXT COLLATE NOCASE,
|
||||||
|
status TEXT NOT NULL, country_code TEXT, transport_mode TEXT, warehouse_code TEXT,
|
||||||
|
order_role TEXT, parent_order_no TEXT,
|
||||||
|
package_count INTEGER, gross_weight_kg TEXT, volume_cbm TEXT,
|
||||||
|
history_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
|
||||||
|
source_updated_at TEXT, snapshot_at TEXT,
|
||||||
|
UNIQUE(tenant_id, order_no)
|
||||||
|
)''')
|
||||||
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_inbound ON order_test_data(inbound_code)')
|
||||||
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_shipment ON order_test_data(shipment_id)')
|
||||||
|
|
||||||
|
|
||||||
|
def import_records(connection, records, replace=False):
|
||||||
|
"""Import an explicitly supplied snapshot; preserve manual edits unless requested."""
|
||||||
|
init_order_table(connection)
|
||||||
|
inserted = skipped = 0
|
||||||
|
for source in records:
|
||||||
|
if not isinstance(source, dict) or not str(source.get('order_no') or '').strip():
|
||||||
|
raise ValueError('Each record requires an order_no')
|
||||||
|
row = {key: source.get(key) for key in FIELDS}
|
||||||
|
row['tenant_id'] = str(row['tenant_id'] or 'test')
|
||||||
|
row['source_order_id'] = str(row['source_order_id']) if row['source_order_id'] is not None else None
|
||||||
|
row['order_no'] = str(row['order_no']).strip()
|
||||||
|
row['status'] = str(row['status'] or 'DRAFT')
|
||||||
|
history = row['history_json'] or []
|
||||||
|
if isinstance(history, str): history = json.loads(history)
|
||||||
|
if not isinstance(history, list): raise ValueError('history_json must contain a list')
|
||||||
|
row['history_json'] = json.dumps(history, ensure_ascii=False)
|
||||||
|
row['snapshot_at'] = row['snapshot_at'] or datetime.now().isoformat(timespec='seconds')
|
||||||
|
exists = connection.execute('SELECT 1 FROM order_test_data WHERE tenant_id=? AND order_no=?',
|
||||||
|
(row['tenant_id'], row['order_no'])).fetchone()
|
||||||
|
if exists and not replace:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
columns = ','.join(FIELDS)
|
||||||
|
placeholders = ','.join('?' for _ in FIELDS)
|
||||||
|
updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no'))
|
||||||
|
connection.execute(f'INSERT INTO order_test_data ({columns}) VALUES ({placeholders}) '
|
||||||
|
f'ON CONFLICT(tenant_id,order_no) DO UPDATE SET {updates}', [row[key] for key in FIELDS])
|
||||||
|
inserted += 1
|
||||||
|
return {'imported': inserted, 'preserved': skipped}
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_order(reference):
|
||||||
|
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)'
|
||||||
|
values = [reference] * 3
|
||||||
|
tenant = get_setting('ORDER_DATA_TENANT_ID')
|
||||||
|
if tenant:
|
||||||
|
query += ' AND tenant_id=?'
|
||||||
|
values.append(str(tenant))
|
||||||
|
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
|
||||||
|
except sqlite3.Error:
|
||||||
|
raise OrderDataUnavailable from None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_reference(message):
|
||||||
|
message = re.sub(r'\[(?:图片|文件)\]/uploads/\S+|https?://\S+', '', message)
|
||||||
|
message = re.sub(r'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}'
|
||||||
|
r'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
|
||||||
|
r'(?![A-Za-z0-9_-])', ' ', message)
|
||||||
|
message = re.sub(r'(?<![A-Za-z0-9_.-])\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?'
|
||||||
|
r'(?:\s*(?:cm|mm|m|厘米|毫米|米))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
|
||||||
|
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
|
||||||
|
r'(?:kg|lbs?|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
|
||||||
|
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
|
||||||
|
references = []
|
||||||
|
for token in IDENTIFIER.findall(message):
|
||||||
|
if sum(character.isdigit() for character in token) < 3:
|
||||||
|
continue
|
||||||
|
if re.fullmatch(r'\d{4}[-_]\d{2}[-_]\d{2}|\d+(?:KG|LBS?|CBM|CM|MM)', token, re.I):
|
||||||
|
continue
|
||||||
|
if token.casefold() not in [value.casefold() for value in references]:
|
||||||
|
references.append(token)
|
||||||
|
return references
|
||||||
|
|
||||||
|
|
||||||
|
def build_card(row):
|
||||||
|
fields = []
|
||||||
|
def field(key, value):
|
||||||
|
if value is not None and str(value).strip(): fields.append({'key': key, 'value': str(value)})
|
||||||
|
field('inbound', row['inbound_code'])
|
||||||
|
field('shipment', row['shipment_id'])
|
||||||
|
field('country', row['country_code'])
|
||||||
|
field('warehouse', row['warehouse_code'])
|
||||||
|
field('transport', {'SEA':'海运', 'AIR':'空运', 'RAIL':'铁路', 'TRUCK':'卡航'}.get(row['transport_mode'], row['transport_mode']))
|
||||||
|
field('packages', row['package_count'])
|
||||||
|
field('weight', str(row['gross_weight_kg'])+' kg' if row['gross_weight_kg'] is not None else None)
|
||||||
|
field('volume', str(row['volume_cbm'])+' m³' if row['volume_cbm'] is not None else None)
|
||||||
|
field('role', {'CHILD':'子订单', 'MASTER':'主订单'}.get(row['order_role']))
|
||||||
|
field('parent', row['parent_order_no'])
|
||||||
|
field('updated', row['source_updated_at'])
|
||||||
|
try:
|
||||||
|
history = json.loads(row['history_json'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
history = []
|
||||||
|
nodes = [{'time': str(event.get('time') or ''),
|
||||||
|
'text': STATUS_LABELS.get(str(event.get('status')), str(event.get('status') or '状态更新')), 'place': ''}
|
||||||
|
for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else []
|
||||||
|
nodes.reverse()
|
||||||
|
status = STATUS_LABELS.get(row['status'], row['status'])
|
||||||
|
summary = f"订单号 {row['order_no']},业务状态:{status}。"
|
||||||
|
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields)
|
||||||
|
return {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
|
||||||
|
'nodes':nodes, 'summary':summary}
|
||||||
|
|
||||||
|
|
||||||
|
def is_order_followup(message):
|
||||||
|
"""Reuse an order only for an explicit reference or a short detail question."""
|
||||||
|
if not re.search(ORDER_DETAIL, message):
|
||||||
|
return False
|
||||||
|
if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message):
|
||||||
|
return True
|
||||||
|
question = re.sub(r'[\s,,。.!!??、::]', '', message)
|
||||||
|
return bool(re.fullmatch(
|
||||||
|
r'(?:那|那么|还有|请问|请|查一下|看一下)?(?:它的|订单的)?'
|
||||||
|
r'(?:(?:状态|进度|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格)'
|
||||||
|
r'(?:(?:是|有|为)?(?:多少|什么|怎么样|怎样|如何|哪个))?|(?:有)?(?:几件|几箱|多少公斤|多少千克|多少钱))'
|
||||||
|
r'(?:呢|吗|呀|啊)?', question))
|
||||||
|
|
||||||
|
|
||||||
|
def answer_query(message, conv_id):
|
||||||
|
references = extract_reference(message)
|
||||||
|
if len(references) > 1:
|
||||||
|
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'}
|
||||||
|
reference = references[0] if references else None
|
||||||
|
if not reference and conv_id and is_order_followup(message):
|
||||||
|
from .db import connect
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 12', (conv_id,)).fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
for role, content in recent:
|
||||||
|
if role == 'user':
|
||||||
|
earlier_references = extract_reference(content)
|
||||||
|
if earlier_references:
|
||||||
|
reference = earlier_references[0] if len(earlier_references) == 1 else None
|
||||||
|
break
|
||||||
|
if not is_order_followup(content):
|
||||||
|
break
|
||||||
|
if role == 'assistant' and content.startswith('__CARD__'):
|
||||||
|
try:
|
||||||
|
previous = json.loads(content[8:])
|
||||||
|
if previous.get('kind') == 'order':
|
||||||
|
reference = previous['no']
|
||||||
|
break
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
pass
|
||||||
|
if not reference:
|
||||||
|
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
|
||||||
|
return {'t':'请提供订单号、入仓码或 Shipment ID。'}
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
matches = lookup_order(reference)
|
||||||
|
except OrderDataUnavailable:
|
||||||
|
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||||
|
if not matches:
|
||||||
|
return {'t':f'未找到“{reference}”对应的订单。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'}
|
||||||
|
if len(matches) > 1:
|
||||||
|
return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'}
|
||||||
|
if re.search(r'运费|费用|多少钱|价格', message):
|
||||||
|
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
|
||||||
|
return {'card': build_card(matches[0])}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""Extracted pricing rules with unchanged business behavior."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
|
||||||
|
try:
|
||||||
|
from . import rates_extra as RX
|
||||||
|
except Exception:
|
||||||
|
RX = None
|
||||||
|
|
||||||
|
RATES = {
|
||||||
|
"sea": {
|
||||||
|
"name": "欧洲海运快线(双清/递延)",
|
||||||
|
"tiers": [(2000, 10.0), (500, 10.2), (101, 10.3), (21, 12.3)],
|
||||||
|
"divisor": 6000,
|
||||||
|
"min_kg": 21,
|
||||||
|
"time": "开船后 38-45 天提取",
|
||||||
|
"min_pcs_kg": None,
|
||||||
|
"remote": 7,
|
||||||
|
"note": "德国 UPS/DHL/DPD/CWE 统配;欧洲递延 -1.7元/kg;樱花快线 +0.3元/kg;因查验/罢工/节假日顺延",
|
||||||
|
},
|
||||||
|
"rail": {
|
||||||
|
"name": "义乌-中欧铁路专线",
|
||||||
|
"tiers": [(1000, 13.5), (100, 13.5), (21, 15.5)],
|
||||||
|
"divisor": 6000,
|
||||||
|
"min_kg": 21,
|
||||||
|
"time": "发车后 36-40 天提取",
|
||||||
|
"min_pcs_kg": 13,
|
||||||
|
"light_fee": 5,
|
||||||
|
"remote": 7,
|
||||||
|
"note": "义乌装柜;VAT 递延 -1元/kg;单件计费重不足 13kg 按超轻费加收",
|
||||||
|
},
|
||||||
|
"truck": {
|
||||||
|
"name": "中欧卡航",
|
||||||
|
"tiers": [(4000, 17.3), (2000, 17.3), (500, 17.4), (101, 17.4), (21, 19.4)],
|
||||||
|
"divisor": 6000,
|
||||||
|
"min_kg": 21,
|
||||||
|
"time": "发车后欧盟 16-20 天提取",
|
||||||
|
"min_pcs_kg": 13,
|
||||||
|
"light_fee": 5,
|
||||||
|
"remote": 7,
|
||||||
|
"note": "递延 -1.5元/kg;纯纺织品 +5元/kg;普卡时效 18-22 天",
|
||||||
|
},
|
||||||
|
"air": {
|
||||||
|
"name": "欧洲空运专线(空派)",
|
||||||
|
"tiers": [(101, 36), (71, 37), (45, 38), (21, 39)],
|
||||||
|
"divisor": 6000,
|
||||||
|
"min_kg": 21,
|
||||||
|
"time": "10-13 天左右提取",
|
||||||
|
"min_pcs_kg": 12,
|
||||||
|
"light_fee": None,
|
||||||
|
"remote": 7,
|
||||||
|
"note": "一票多件单件不足 12kg 按 12kg 计费;快线 8-13 天;直飞快线 12-15 天",
|
||||||
|
},
|
||||||
|
"sea_uk": {
|
||||||
|
"name": "英国海运(双清)",
|
||||||
|
"tiers": [(301, 10.9), (101, 10.9), (21, 12.8)],
|
||||||
|
"divisor": 6000,
|
||||||
|
"min_kg": 21,
|
||||||
|
"time": "参考时效 40 天提取",
|
||||||
|
"remote": None,
|
||||||
|
"note": "VAT自税/递延 10.0/8.1 元/kg;按KG卡派 12.2/10.3;限时达 13.5/11.6;北极航线 25-28 天(义乌装)",
|
||||||
|
},
|
||||||
|
"rail_uk": {
|
||||||
|
"name": "英国铁路(双清)",
|
||||||
|
"tiers": [(301, 16.2), (101, 16.2), (21, 18.0)],
|
||||||
|
"divisor": 6000,
|
||||||
|
"min_kg": 21,
|
||||||
|
"time": "发车后 25-30 天提取",
|
||||||
|
"remote": None,
|
||||||
|
"note": "VAT自税/递延 15/13.2 元/kg;限时达 19.2/17.4(38天);按KG卡派 17.3/15.5",
|
||||||
|
},
|
||||||
|
"truck_uk": {
|
||||||
|
"name": "中英卡航(双清)",
|
||||||
|
"tiers": [(301, 19.4), (101, 19.4), (21, 21.4)],
|
||||||
|
"divisor": 6000,
|
||||||
|
"min_kg": 21,
|
||||||
|
"time": "发车后 20-22 天提取",
|
||||||
|
"remote": None,
|
||||||
|
"note": "VAT自税/递延 18.4/16.4 元/kg;卡派 20.9/18.9;单件不超30kg,尺寸≤98×68×59cm",
|
||||||
|
},
|
||||||
|
"air_uk": {
|
||||||
|
"name": "英国空运专线(双清)",
|
||||||
|
"tiers": [(101, 42), (51, 44), (22, 48)],
|
||||||
|
"divisor": 6000,
|
||||||
|
"min_kg": 22,
|
||||||
|
"time": "8-10 个自然日提取",
|
||||||
|
"min_pcs_kg": 12,
|
||||||
|
"light_fee": None,
|
||||||
|
"remote": None,
|
||||||
|
"note": "不包税 VAT 自税/递延 44/40/38;带电 47.5/42.5/41.5;拒收北爱尔兰/苏格兰高地偏远地址",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
|
||||||
|
|
||||||
|
def parse_dims(t):
|
||||||
|
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
|
||||||
|
|
||||||
|
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
|
||||||
|
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
|
||||||
|
"""
|
||||||
|
match = re.search(r'(?<![\d.+-])(\d+(?:\.\d+)?)\s*[*xX×]\s*'
|
||||||
|
r'(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?![\d.])', t)
|
||||||
|
return [float(value) for value in match.groups()] if match else None
|
||||||
|
|
||||||
|
|
||||||
|
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
|
||||||
|
('unsupported', r'美国|加拿大|澳大利亚|澳洲|新西兰|日本|韩国|新加坡|马来西亚|泰国|越南|印度|阿联酋|迪拜|沙特|巴西|墨西哥|俄罗斯|'
|
||||||
|
r'(?<![A-Za-z0-9_])(?-i:US)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
|
||||||
|
r'(?<![A-Za-z0-9_])(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)(?![A-Za-z])'),
|
||||||
|
('uk', r'英国|伦敦|英格兰|(?<![A-Za-z0-9_])(?:UK|GB)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
|
||||||
|
r'(?<![A-Za-z0-9_])(?:United Kingdom|Britain)(?![A-Za-z])'),
|
||||||
|
('eu', r'欧洲|欧盟|德国|法国|意大利|西班牙|波兰|捷克|荷兰|比利时|奥地利|葡萄牙|瑞典|丹麦|芬兰|爱尔兰|'
|
||||||
|
r'(?<![A-Za-z0-9_])(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)(?![A-Za-z])|'
|
||||||
|
r'(?<![A-Za-z0-9_])(?-i:DE|FR|PL|CZ|ES|NL)(?=\s*(?:仓|FBA|warehouse))'),
|
||||||
|
('unsupported', r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|\bto\s+us\b'),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def shipping_region(text):
|
||||||
|
return next((region for pattern, region in SHIPPING_REGION_PATTERNS if pattern.search(text)), None)
|
||||||
|
|
||||||
|
|
||||||
|
def is_shipping_destination_reply(text):
|
||||||
|
text = text.strip().strip(',,。.!!??、:: ')
|
||||||
|
for pattern, _ in SHIPPING_REGION_PATTERNS:
|
||||||
|
match = pattern.search(text)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
prefix = re.fullmatch(r'\s*(?:(?:目的地|目的国家|国家)(?:是|为|:|:)?|(?:发|寄|运|送)(?:往|到)?|到|往|去|to)?\s*', text[:match.start()], re.I)
|
||||||
|
suffix = re.fullmatch(r'\s*(?:的)?\s*(?:FBA\s*)?(?:(?:海外)?仓(?:库)?|warehouse)?(?:呢)?\s*', text[match.end():], re.I)
|
||||||
|
if prefix and suffix:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_shipping_intent(text):
|
||||||
|
"""解析重量、渠道、尺寸和费率区域;尺寸单位由调用方识别。"""
|
||||||
|
t = text.replace(" ", "")
|
||||||
|
intent = {"weight": None, "channel": None, "dims": None, "region": shipping_region(text)}
|
||||||
|
|
||||||
|
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
|
||||||
|
intent["dims"] = parse_dims(text)
|
||||||
|
if not intent["dims"]:
|
||||||
|
# 支持“长100宽80高60”
|
||||||
|
dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t)
|
||||||
|
if dl:
|
||||||
|
intent["dims"] = [float(x) for x in dl.groups()]
|
||||||
|
|
||||||
|
# 重量:带单位的数字。去掉空格后在原始文本上匹配,
|
||||||
|
# 但不能把尺寸里的数字(如 120*100*100 的第三个数)当成重量
|
||||||
|
m = re.search(r"(\d+(?:\.\d+)?)\s*(吨|kg|KG|公斤|千克|tons?|kilo(?:grams?)?|lbs?)", text, re.I)
|
||||||
|
if m:
|
||||||
|
before = text[:m.start()].rstrip()
|
||||||
|
adjacent_sep = bool(before) and before[-1] in "*xX×"
|
||||||
|
if not adjacent_sep:
|
||||||
|
n = float(m.group(1))
|
||||||
|
unit_w = m.group(2).lower()
|
||||||
|
if unit_w in ("吨", "ton", "tons"):
|
||||||
|
intent["weight"] = n * 1000
|
||||||
|
elif unit_w.startswith("lb"):
|
||||||
|
intent["weight"] = round(n * 0.4536, 2) # 磅转公斤
|
||||||
|
else:
|
||||||
|
intent["weight"] = n
|
||||||
|
intent["weight_raw"] = m.group(0).replace(" ", "")
|
||||||
|
|
||||||
|
# 渠道:空运 / 铁路 / 卡航 / 海运(按优先级匹配)
|
||||||
|
if re.search(r"空运|空派|航空", t) or re.search(r"\bair\b|air ?freight|air ?cargo", text, re.I):
|
||||||
|
intent["channel"] = "air_uk" if re.search(r"英国|UK|伦敦|英格兰", text, re.I) else "air"
|
||||||
|
elif re.search(r"铁路|班列|火车", t) or re.search(r"\brail\b|train", text, re.I):
|
||||||
|
intent["channel"] = "rail"
|
||||||
|
elif re.search(r"卡航|卡车|卡派", t) or re.search(r"\btruck\b|road", text, re.I):
|
||||||
|
intent["channel"] = "truck"
|
||||||
|
elif re.search(r"海运|海派|船运|快船|海运快线|樱花快线", t) or re.search(r"\bsea\b|ocean|shipping", text, re.I):
|
||||||
|
intent["channel"] = "sea"
|
||||||
|
return intent
|
||||||
|
|
||||||
|
def detect_dim_unit(text):
|
||||||
|
"""识别尺寸单位:cm(默认)/ m / mm"""
|
||||||
|
low = text.lower()
|
||||||
|
if re.search(r"毫米|\d\s*mm", low):
|
||||||
|
return "mm"
|
||||||
|
if re.search(r"米", low) and not re.search(r"厘米", low):
|
||||||
|
return "m"
|
||||||
|
if re.search(r"\d\s*cm|\d\s*厘米", low):
|
||||||
|
return "cm"
|
||||||
|
if re.search(r"\d\s*m", low): # 注意:cm 里也含 m,所以放在 cm 之后判断
|
||||||
|
return "m"
|
||||||
|
return "cm"
|
||||||
|
|
||||||
|
def dims_to_cm(dims, unit):
|
||||||
|
"""统一换算成厘米,体积重公式按 cm 计算"""
|
||||||
|
if unit == "m":
|
||||||
|
return [d * 100 for d in dims]
|
||||||
|
if unit == "mm":
|
||||||
|
return [d / 10 for d in dims]
|
||||||
|
return list(dims)
|
||||||
|
|
||||||
|
def pick_rate(weight_kg, tiers):
|
||||||
|
"""从阶梯价里选档:tiers 为 [(最低重量, 单价), ...],从高到低匹配"""
|
||||||
|
for min_w, price in tiers:
|
||||||
|
if weight_kg >= min_w:
|
||||||
|
return price, min_w
|
||||||
|
return tiers[-1][1], tiers[-1][0]
|
||||||
|
|
||||||
|
def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
|
||||||
|
"""海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档
|
||||||
|
|
||||||
|
country 传入国家代码(DE/FR/PL/CZ/ES)时,仅选择包含该后缀的仓点组;无匹配不报价。
|
||||||
|
"""
|
||||||
|
if not RX or not RX.WAREHOUSE_CARD:
|
||||||
|
return None
|
||||||
|
keys = list(RX.WAREHOUSE_CARD)
|
||||||
|
key = warehouse_key
|
||||||
|
if key and key not in RX.WAREHOUSE_CARD:
|
||||||
|
return None
|
||||||
|
if not key and country:
|
||||||
|
for k in keys:
|
||||||
|
if ("-" + country) in k.upper():
|
||||||
|
key = k
|
||||||
|
break
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
if not key:
|
||||||
|
key = keys[0]
|
||||||
|
row = RX.WAREHOUSE_CARD.get(key) or RX.WAREHOUSE_CARD[keys[0]]
|
||||||
|
p = row.get(channel) or row["海运"]
|
||||||
|
price = p["cbm1"] if cbm >= 1 else p["cbm05"]
|
||||||
|
if price is None:
|
||||||
|
return None
|
||||||
|
tier = "1CBM+" if cbm >= 1 else "0.5CBM+"
|
||||||
|
stores = [s for s in key.splitlines() if s.strip()]
|
||||||
|
if country:
|
||||||
|
stores = [s for s in stores if ("-" + country) in s.upper()]
|
||||||
|
if not stores:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
stores = stores[:1]
|
||||||
|
note = "报价表中的仓点分组:" + "、".join(stores)
|
||||||
|
return {
|
||||||
|
"warehouse": "\n".join(stores), "stores": stores,
|
||||||
|
"channel": channel, "cbm": cbm, "note": note,
|
||||||
|
"price_cbm": price, "tier": tier,
|
||||||
|
"total": round(cbm * price, 2),
|
||||||
|
"formula": f"{cbm:g} CBM × {price:,.0f} 元/CBM = {cbm * price:,.0f} 元",
|
||||||
|
}
|
||||||
|
|
||||||
|
def detect_surcharge(text):
|
||||||
|
"""识别产品类型,返回报价表中的附加费条目"""
|
||||||
|
if not RX:
|
||||||
|
return None
|
||||||
|
best = None
|
||||||
|
for product, fee in RX.PRODUCT_SURCHARGES:
|
||||||
|
# 用产品名里的前几个字做匹配(如“手机”“电池”“玩具”)
|
||||||
|
for seg in re.split(r"[,,、\s()()]", product):
|
||||||
|
seg = seg.strip()
|
||||||
|
if len(seg) >= 2 and seg in text:
|
||||||
|
if best is None or len(seg) > len(best[2]):
|
||||||
|
best = (product, fee, seg)
|
||||||
|
if best:
|
||||||
|
return best[0], best[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def check_island(postcode):
|
||||||
|
"""按邮编判断是否属于 DPD 岛屿偏远地区(附加费 35 欧/件)"""
|
||||||
|
if not RX:
|
||||||
|
return None
|
||||||
|
pc = re.sub(r"[^0-9]", "", str(postcode))
|
||||||
|
if not pc:
|
||||||
|
return None
|
||||||
|
for country, info in RX.DPD_ISLAND_POSTCODES.items():
|
||||||
|
seg = info["text"]
|
||||||
|
# 直接匹配的 4~5 位邮编
|
||||||
|
for m in re.finditer(r"\d{4,5}", seg):
|
||||||
|
if pc.startswith(m.group(0)):
|
||||||
|
return country, info["code"], m.group(0)
|
||||||
|
# 区间形式 07000–07999
|
||||||
|
for m in re.finditer(r"(\d{4,5})\s*[–\-]\s*(\d{4,5})", seg):
|
||||||
|
lo, hi = int(m.group(1)), int(m.group(2))
|
||||||
|
if len(m.group(1)) == len(pc) and lo <= int(pc) <= hi:
|
||||||
|
return country, info["code"], m.group(0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def surcharge_reply(product, fee, weight_kg=None):
|
||||||
|
"""附加费说明(能算就给出金额)"""
|
||||||
|
txt = f"【产品附加费】{product}:{fee}"
|
||||||
|
if weight_kg and "/KG" in fee.upper():
|
||||||
|
m = re.search(r"\+?(\d+(?:\.\d+)?)", fee)
|
||||||
|
if m:
|
||||||
|
extra = float(m.group(1)) * weight_kg
|
||||||
|
txt += f"\n 按 {weight_kg:g} kg 计:约 {extra:,.0f} 元(以最终计费重为准)"
|
||||||
|
return txt
|
||||||
|
|
||||||
|
def calc_freight(weight_kg, channel, dims=None, unit="cm"):
|
||||||
|
"""按报价表规则计算运费(数字全部由代码算)
|
||||||
|
|
||||||
|
报价表规则:
|
||||||
|
- 阶梯单价:按总重量落入的档位取价
|
||||||
|
- 材积:长×宽×高(cm) ÷ 6000,与实重取大值作为计费重量
|
||||||
|
- 单件最低计费重 / 超轻费:见 RATES 里的 min_pcs_kg、light_fee
|
||||||
|
- 偏远地址:+7元/kg(不含油),最低 250 元/票
|
||||||
|
"""
|
||||||
|
r = RATES[channel]
|
||||||
|
cm = dims_to_cm(dims, unit) if dims else None
|
||||||
|
divisor = r.get("divisor", 6000)
|
||||||
|
vol = round(cm[0] * cm[1] * cm[2] / divisor, 2) if cm else None
|
||||||
|
real = weight_kg or 0
|
||||||
|
charge = round(max(real, vol) if vol else real, 2) # 实重与体积重取大
|
||||||
|
min_kg = r.get("min_kg") or 0
|
||||||
|
charge = max(charge, min_kg) # 报价表起收档
|
||||||
|
price, tier = pick_rate(charge, r["tiers"])
|
||||||
|
total = round(charge * price, 2)
|
||||||
|
|
||||||
|
unit_note = ""
|
||||||
|
if dims and unit != "cm":
|
||||||
|
unit_note = "(原尺寸 %g×%g×%g %s,已换算为 %g×%g×%g cm)" % (*dims, unit, *cm)
|
||||||
|
return {
|
||||||
|
"channel": channel, "name": r["name"],
|
||||||
|
"real": real, "vol": vol, "charge": charge,
|
||||||
|
"dims": dims, "dims_cm": cm, "unit": unit, "unit_note": unit_note,
|
||||||
|
"dim_rule": ("长×宽×高÷%d = %g×%g×%g÷%d = %g kg" % (divisor, *cm, divisor, vol)) if cm else None,
|
||||||
|
"price": price, "tier": tier,
|
||||||
|
"formula": f"{charge:g} kg × {price} 元/kg = {total:,.0f} 元",
|
||||||
|
"total": total,
|
||||||
|
"time": r["time"],
|
||||||
|
"min_pcs_kg": r.get("min_pcs_kg"),
|
||||||
|
"light_fee": r.get("light_fee"),
|
||||||
|
"remote": r.get("remote"),
|
||||||
|
"note": r.get("note"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def freight_reply(intent):
|
||||||
|
"""根据已知信息拼出可读的计算说明(给模型当参考,也直接展示)"""
|
||||||
|
w = intent["weight"] or 0
|
||||||
|
dims = intent["dims"]
|
||||||
|
unit = intent.get("dim_unit") or "cm"
|
||||||
|
lines = []
|
||||||
|
default_channels = ['sea_uk', 'air_uk'] if intent.get('region') == 'uk' else ['sea', 'air']
|
||||||
|
channels = [intent["channel"]] if intent["channel"] else default_channels
|
||||||
|
for ch in channels:
|
||||||
|
c = calc_freight(w, ch, dims, unit)
|
||||||
|
parts = [f"实重 {w:g} kg" if w else "实重:未提供"]
|
||||||
|
if c["vol"]:
|
||||||
|
parts.append(f"体积重 {c['vol']:g} kg")
|
||||||
|
body = (
|
||||||
|
f"【{c['name']}】按时效 {c['time']},计费方式:阶梯单价 × 计费重量\n"
|
||||||
|
+ (f" 尺寸:{c['unit_note']}\n" if c["unit_note"] else "")
|
||||||
|
+ f" 计费重量 {c['charge']:g} kg(" + ",".join(parts) + ",取较大值)\n"
|
||||||
|
+ (f" 材积算法:{c['dim_rule']}\n" if c["dim_rule"] else "")
|
||||||
|
+ f" 适用档位:{c['tier']}KG+ 档,单价 {c['price']} 元/kg\n"
|
||||||
|
+ f" 运费:{c['formula']}\n"
|
||||||
|
)
|
||||||
|
if c["min_pcs_kg"] and c["charge"] < c["min_pcs_kg"]:
|
||||||
|
body += f" 注意:单件计费重不足 {c['min_pcs_kg']}kg,需按 {c['min_pcs_kg']}kg 起算或加收超轻费 {c['light_fee']} 元/kg\n"
|
||||||
|
if c["remote"]:
|
||||||
|
body += f" 偏远地址:额外 +{c['remote']} 元/kg,单票最低 250 元\n"
|
||||||
|
if c["note"]:
|
||||||
|
body += f" 其他:{c['note']}\n"
|
||||||
|
lines.append(body.rstrip("\n"))
|
||||||
|
return "\n\n".join(lines)
|
||||||
@@ -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'}}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""HTTP adapters for admin; business logic lives in services."""
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, render_template, request
|
||||||
|
|
||||||
|
from .. import business_service
|
||||||
|
|
||||||
|
bp = Blueprint('admin', __name__)
|
||||||
|
|
||||||
|
@bp.route('/api/admin/stats')
|
||||||
|
def admin_stats():
|
||||||
|
return business_service.admin_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin/login', methods=['POST'])
|
||||||
|
def admin_login():
|
||||||
|
return business_service.admin_login(request.json or {})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/export_chat')
|
||||||
|
def export_chat():
|
||||||
|
format = 'csv' if request.args.get('format') == 'csv' else 'xlsx'
|
||||||
|
mimetype = 'text/csv' if format == 'csv' else 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
return Response(business_service.export_chat(format), mimetype=mimetype,
|
||||||
|
headers={'Content-Disposition': 'attachment;filename=chat_export.' + format})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/agents/list')
|
||||||
|
def agents_list():
|
||||||
|
return business_service.agents_list()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/search_chat')
|
||||||
|
def search_chat():
|
||||||
|
return business_service.search_chat(request.args.get('q', '').strip())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin')
|
||||||
|
def admin():
|
||||||
|
return render_template('admin_login.html')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin/dashboard')
|
||||||
|
def admin_dash():
|
||||||
|
return render_template('admin.html')
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""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()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/rate', methods=['POST'])
|
||||||
|
def rate_chat():
|
||||||
|
return business_service.rate_chat(request.json or {})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""HTTP adapters for chat; business logic lives in services."""
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, request, stream_with_context
|
||||||
|
|
||||||
|
from .. import business_service, chat_service
|
||||||
|
|
||||||
|
bp = Blueprint('chat', __name__)
|
||||||
|
|
||||||
|
@bp.route('/stream_chat', methods=['GET', 'POST'])
|
||||||
|
def stream_chat():
|
||||||
|
def error(message):
|
||||||
|
return Response(chat_service.sse({'e':message}) + chat_service.sse({'d':1}), mimetype='text/event-stream')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return error('请求格式无效,请重新发送问题。')
|
||||||
|
msg = data.get('msg', '')
|
||||||
|
conv_id = data.get('conv_id')
|
||||||
|
else:
|
||||||
|
msg = request.args.get('msg', '')
|
||||||
|
conv_id = request.args.get('conv_id')
|
||||||
|
if not isinstance(msg, str):
|
||||||
|
return error('问题必须是文本,请重新输入。')
|
||||||
|
msg = msg.strip()
|
||||||
|
if isinstance(conv_id, (bool, float)):
|
||||||
|
return error('会话编号无效,请新建对话后重试。')
|
||||||
|
if conv_id not in (None, '', 0):
|
||||||
|
if not isinstance(conv_id, (str, int)):
|
||||||
|
return error('会话编号无效,请新建对话后重试。')
|
||||||
|
if isinstance(conv_id, str) and (len(conv_id) > 19 or not conv_id.isascii() or not conv_id.isdecimal()):
|
||||||
|
return error('会话编号无效,请新建对话后重试。')
|
||||||
|
try:
|
||||||
|
conv_id = int(conv_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return error('会话编号无效,请新建对话后重试。')
|
||||||
|
if not 0 < conv_id <= 9223372036854775807:
|
||||||
|
return error('会话编号无效,请新建对话后重试。')
|
||||||
|
else:
|
||||||
|
conv_id = None
|
||||||
|
frames, headers = chat_service.create_chat_stream(msg, conv_id)
|
||||||
|
body = frames if isinstance(frames, str) else stream_with_context(frames)
|
||||||
|
return Response(body, mimetype='text/event-stream', headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/feedback', methods=['POST'])
|
||||||
|
def feedback():
|
||||||
|
return business_service.feedback(request.get_json(silent=True) or {})
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""HTTP adapters for human; business logic lives in services."""
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, request
|
||||||
|
|
||||||
|
from .. import human_service
|
||||||
|
|
||||||
|
bp = Blueprint('human', __name__)
|
||||||
|
|
||||||
|
@bp.route('/api/human/request', methods=['POST'])
|
||||||
|
def human_request():
|
||||||
|
return human_service.human_request(request.json)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/poll')
|
||||||
|
def human_poll():
|
||||||
|
return human_service.human_poll(request.args.get('sid', type=int), request.args.get('after', 0, type=int), current_app.extensions['human_typing'])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/send', methods=['POST'])
|
||||||
|
def human_send():
|
||||||
|
return human_service.human_send(request.json)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/queue')
|
||||||
|
def human_queue():
|
||||||
|
return human_service.human_queue()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/active')
|
||||||
|
def human_active():
|
||||||
|
return human_service.human_active()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/accept', methods=['POST'])
|
||||||
|
def human_accept():
|
||||||
|
return human_service.human_accept(request.json)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/close', methods=['POST'])
|
||||||
|
def human_close():
|
||||||
|
return human_service.human_close(request.json)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/transfer', methods=['POST'])
|
||||||
|
def human_transfer():
|
||||||
|
return human_service.human_transfer(request.json or {}, current_app.extensions['human_typing'])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/note', methods=['POST'])
|
||||||
|
def human_note():
|
||||||
|
return human_service.human_note(request.json or {})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/recall', methods=['POST'])
|
||||||
|
def human_recall():
|
||||||
|
return human_service.human_recall(request.json or {})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/typing', methods=['POST'])
|
||||||
|
def human_typing():
|
||||||
|
return human_service.human_typing(request.json, current_app.extensions['human_typing'])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/stop_typing', methods=['POST'])
|
||||||
|
def human_stop_typing():
|
||||||
|
return human_service.human_stop_typing(request.json, current_app.extensions['human_typing'])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/human/read', methods=['POST'])
|
||||||
|
def human_read():
|
||||||
|
return human_service.human_read(request.json)
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Extracted tracking rules with unchanged business behavior."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
CARD_PREFIX = "__CARD__"
|
||||||
|
|
||||||
|
def card_to_text(content):
|
||||||
|
"""把存库的卡片标记转成人话,避免原始 JSON 进入模型上下文"""
|
||||||
|
if not content.startswith(CARD_PREFIX):
|
||||||
|
return content
|
||||||
|
try:
|
||||||
|
card = json.loads(content[len(CARD_PREFIX):])
|
||||||
|
if card.get('kind') == 'order':
|
||||||
|
return card.get('summary', '')
|
||||||
|
return f"(历史演示轨迹,非真实物流信息:单号 {card.get('no', '')},演示状态:{card.get('status', '')})"
|
||||||
|
except Exception:
|
||||||
|
return "(已为该运单查询过轨迹)"
|
||||||
|
|
||||||
|
def is_tracking_no(text):
|
||||||
|
"""判断一句话是不是"整句运单号"
|
||||||
|
|
||||||
|
规则(逐条收紧,避免误判):
|
||||||
|
1. 去掉空格和横杠后,长度 8~20 位,且只能由字母数字组成
|
||||||
|
2. 至少包含 3 个数字(英文句子通常只有 1 个数字)
|
||||||
|
3. 字母数量不能多于数字("i want to go 1t how money" 这类会被排除)
|
||||||
|
纯数字单号(如 15252125)全部满足上述条件。
|
||||||
|
"""
|
||||||
|
# 日期格式(如 2026-10-23、2026/10/23)不算运单号
|
||||||
|
if re.fullmatch(r"\d{4}[-/.]\d{1,2}[-/.]\d{1,2}", text.strip()):
|
||||||
|
return False
|
||||||
|
t = re.sub(r"[\s\-]", "", text)
|
||||||
|
if not (8 <= len(t) <= 20):
|
||||||
|
return False
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9]+", t):
|
||||||
|
return False
|
||||||
|
digits = sum(c.isdigit() for c in t)
|
||||||
|
letters = sum(c.isalpha() for c in t)
|
||||||
|
if digits < 3:
|
||||||
|
return False
|
||||||
|
if letters > digits:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def extract_tracking_no(text):
|
||||||
|
"""从一句话里提取运单号:先看整句是不是,再从句子里找最长的字母数字串"""
|
||||||
|
t = text.strip()
|
||||||
|
if is_tracking_no(t):
|
||||||
|
return t
|
||||||
|
# 找句子里最长的 8~20 位字母数字连续串
|
||||||
|
candidates = re.findall(r"[A-Za-z0-9]{8,20}", t)
|
||||||
|
best = None
|
||||||
|
for c in candidates:
|
||||||
|
if is_tracking_no(c):
|
||||||
|
if best is None or len(c) > len(best):
|
||||||
|
best = c
|
||||||
|
return best
|
||||||
|
|
||||||
|
def build_track(no):
|
||||||
|
"""Legacy demo helper for old imports; chat queries use order_test_data."""
|
||||||
|
rnd = random.Random(no)
|
||||||
|
steps = [
|
||||||
|
("已揽收", "义乌仓"),
|
||||||
|
("出口清关放行", "义乌/宁波口岸"),
|
||||||
|
("已发车", "中欧班列/卡航 · 阿拉山口"),
|
||||||
|
("到达欧洲中转仓", "波兰 · 华沙"),
|
||||||
|
("欧盟清关放行", "德国 · 杜伊斯堡"),
|
||||||
|
("派送中", "收件人所在城市"),
|
||||||
|
]
|
||||||
|
gaps = [rnd.randint(6, 40) for _ in steps] # 相邻节点间隔(小时)
|
||||||
|
t = datetime.now() - timedelta(hours=sum(gaps))
|
||||||
|
nodes = []
|
||||||
|
for (text, place), gap in zip(steps, gaps):
|
||||||
|
t = t + timedelta(hours=gap)
|
||||||
|
nodes.append({"time": t.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"text": text, "place": place})
|
||||||
|
nodes.reverse() # 最新的排最上面
|
||||||
|
return {"no": no, "status": nodes[0]["text"], "nodes": nodes}
|
||||||
+5
-77
@@ -1,78 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
"""Compatibility exports for the relocated quotation data."""
|
||||||
"""报价表扩展数据(自动生成,来源:炜易达 VIP 10月15日9点执行报价表)
|
|
||||||
|
|
||||||
包含:海外仓卡派、二级补仓、德国仓亚马逊退货、产品附加费、DPD 岛屿偏远邮编。
|
from logistics_chat.rates_extra import (
|
||||||
"""
|
WAREHOUSE_CARD, SECONDARY_WAREHOUSE, DE_RETURN_FEES,
|
||||||
|
PRODUCT_SURCHARGES, DPD_ISLAND_POSTCODES,
|
||||||
# 海外仓卡派:按 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'}}
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Import an explicit, private order snapshot into the chat SQLite database."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from logistics_chat.order_data import import_records
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--snapshot', type=Path, required=True)
|
||||||
|
parser.add_argument('--db', type=Path, required=True)
|
||||||
|
parser.add_argument('--replace', action='store_true', help='Replace existing rows, including manual edits')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.snapshot.is_file() or args.snapshot.stat().st_size > 10*1024*1024:
|
||||||
|
parser.error('Snapshot must be an existing JSON file smaller than 10 MB')
|
||||||
|
records = json.loads(args.snapshot.read_text(encoding='utf-8-sig'))
|
||||||
|
if isinstance(records, dict): records = records.get('orders')
|
||||||
|
if not isinstance(records, list) or len(records) > 3000:
|
||||||
|
parser.error('Snapshot must contain a list of at most 3000 orders')
|
||||||
|
if not args.db.parent.is_dir(): parser.error('Database parent directory must exist')
|
||||||
|
with sqlite3.connect(args.db) as connection:
|
||||||
|
result = import_records(connection, records, replace=args.replace)
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -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()
|
||||||
@@ -1,21 +1,46 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 936 >nul
|
setlocal DisableDelayedExpansion
|
||||||
title Baiyue Logistics AI Customer Service
|
|
||||||
cd /d "%~dp0"
|
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 ============================================
|
||||||
echo Baiyue Logistics AI Customer Service
|
echo Baiyue Logistics AI Customer Service
|
||||||
echo ============================================
|
echo ============================================
|
||||||
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 ...
|
echo [1/3] Checking port 5000 ...
|
||||||
netstat -ano | findstr "LISTENING" | findstr /C:":5000 " >nul
|
netstat -ano | findstr "LISTENING" | findstr /C:":5000 " >nul
|
||||||
if not errorlevel 1 (
|
if not errorlevel 1 (
|
||||||
@@ -34,23 +59,11 @@ if not errorlevel 1 (
|
|||||||
)
|
)
|
||||||
echo OK - port is free.
|
echo OK - port is free.
|
||||||
|
|
||||||
echo [2/3] Checking DEEPSEEK_API_KEY ...
|
echo [2/3] Checking dependencies ...
|
||||||
if "%DEEPSEEK_API_KEY%"=="" (
|
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv, openpyxl" >nul 2>nul
|
||||||
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
|
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo Missing packages, installing ...
|
echo Missing packages, installing ...
|
||||||
python -m pip install -r requirements.txt
|
"%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo [ERROR] pip install failed, check network and retry.
|
echo [ERROR] pip install failed, check network and retry.
|
||||||
pause
|
pause
|
||||||
@@ -59,6 +72,15 @@ if errorlevel 1 (
|
|||||||
)
|
)
|
||||||
echo OK - dependencies ready.
|
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 ============================================
|
echo ============================================
|
||||||
echo Starting: http://127.0.0.1:5000
|
echo Starting: http://127.0.0.1:5000
|
||||||
@@ -67,8 +89,15 @@ echo ============================================
|
|||||||
echo.
|
echo.
|
||||||
|
|
||||||
start "" http://127.0.0.1:5000
|
start "" http://127.0.0.1:5000
|
||||||
python am.py
|
"%PYTHON_EXE%" %PYTHON_ARGS% am.py
|
||||||
|
set "APP_EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo Server stopped.
|
echo Server stopped.
|
||||||
pause
|
pause
|
||||||
|
exit /b %APP_EXIT_CODE%
|
||||||
|
|
||||||
|
:probe_python
|
||||||
|
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import sys; sys.exit(0 if sys.version_info.major == 3 else 1)" >nul 2>nul
|
||||||
|
if "%ERRORLEVEL%"=="0" exit /b 0
|
||||||
|
exit /b 1
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/* A conversation-first console: compact header, session inbox and full-height chat. */
|
||||||
|
body { height: 100dvh; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
|
||||||
|
.topbar { height: 56px; min-height: 56px; display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 22px; background: var(--surface); border-bottom: 1px solid var(--border); z-index: 30; }
|
||||||
|
.app-identity, .topbar-actions { display: flex; align-items: center; gap: 20px; min-width: 0; }
|
||||||
|
.brand { gap: 10px; }
|
||||||
|
.topbar .brand-mark { width: 30px; height: 30px; border-radius: 8px; }
|
||||||
|
.topbar .brand-mark svg { width: 19px; height: 19px; }
|
||||||
|
.topbar .brand-name { font-size: 15px; white-space: nowrap; }
|
||||||
|
.nav-divider { height: 18px; width: 1px; background: var(--border); }
|
||||||
|
.app-identity h1 { font-size: 14px; font-weight: 500; color: var(--secondary); white-space: nowrap; }
|
||||||
|
.workspace-link { font-size: 12px; color: var(--secondary); text-decoration: none; white-space: nowrap; }
|
||||||
|
.workspace-link:hover { color: var(--brand); }
|
||||||
|
.data-overview { position: relative; }
|
||||||
|
.data-overview summary { display: flex; align-items: center; gap: 7px; padding: 6px 10px; font-size: 12px; color: var(--secondary); cursor: pointer; border-radius: 7px; list-style: none; border: 1px solid var(--border); }
|
||||||
|
.data-overview summary::-webkit-details-marker { display: none; }
|
||||||
|
.data-overview summary svg { width: 15px; height: 15px; }
|
||||||
|
.data-overview[open] summary, .data-overview summary:hover { color: var(--brand); border-color: #bacbf2; background: var(--brand-soft); }
|
||||||
|
.stats { position: absolute; top: calc(100% + 11px); right: 0; width: 310px; padding: 18px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); box-shadow: 0 12px 40px #19263d1a; }
|
||||||
|
.stats h2 { grid-column: 1 / -1; font-size: 13px; font-weight: 600; margin-bottom: 3px; }
|
||||||
|
.stat { display: flex; flex-direction: column; padding: 10px 12px; background: var(--surface-soft); border-radius: 7px; }
|
||||||
|
.stat > span { font-size: 11px; color: var(--secondary); }
|
||||||
|
.stat b { font-size: 23px; font-weight: 600; line-height: 1.5; font-variant-numeric: tabular-nums; }
|
||||||
|
.export-link { display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; font-size: 12px; color: var(--brand); text-decoration: none; }
|
||||||
|
.export-link:hover { background: var(--brand-soft); }
|
||||||
|
.sync-status { flex-shrink: 0; background: var(--danger-soft); color: var(--danger); border-bottom: 1px solid #eed0d3; padding: 7px 22px; font-size: 12px; }
|
||||||
|
.layout { flex: 1; min-height: 0; display: grid; grid-template-columns: 300px minmax(0, 1fr); overflow: hidden; }
|
||||||
|
.conv-list { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); }
|
||||||
|
.list-heading { padding: 23px 20px 18px; }
|
||||||
|
.list-heading h2 { font-size: 17px; font-weight: 650; }
|
||||||
|
.list-heading p { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||||
|
.session-search-wrap { position: relative; margin-top: 17px; }
|
||||||
|
.session-search-wrap svg { position: absolute; top: 12px; left: 11px; width: 15px; height: 15px; color: var(--muted); pointer-events: none; }
|
||||||
|
#session-search { width: 100%; height: 38px; padding: 8px 10px 8px 34px; border: 1px solid transparent; border-radius: 7px; background: var(--surface-soft); color: var(--text); font-size: 13px; }
|
||||||
|
#session-search::placeholder { color: var(--muted); }
|
||||||
|
#session-search:focus { outline: none; border-color: #9ab4ed; background: var(--surface); box-shadow: 0 0 0 3px var(--brand-soft); }
|
||||||
|
.session-tabs { display: flex; gap: 22px; padding: 0 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.session-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 43px; padding: 7px 0 12px; border: 0; background: transparent; color: var(--secondary); font-size: 13px; }
|
||||||
|
.session-tabs button[aria-selected="true"] { color: var(--brand); font-weight: 600; }
|
||||||
|
.session-tabs button[aria-selected="true"]::after { position: absolute; content: ""; height: 2px; background: var(--brand); inset: auto 0 -1px; }
|
||||||
|
.section-count { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 5px; color: var(--muted); background: var(--surface-soft); font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||||
|
[aria-selected="true"] .section-count { background: var(--brand-soft); color: var(--brand); }
|
||||||
|
.session-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 10px; }
|
||||||
|
.list-empty { padding: 33px 14px; text-align: center; font-size: 12px; line-height: 1.8; color: var(--muted); }
|
||||||
|
.conv-item { display: flex; align-items: center; gap: 11px; width: 100%; padding: 14px 12px; margin-bottom: 4px; border: 1px solid transparent; border-radius: 8px; text-align: left; background: transparent; }
|
||||||
|
.conv-item:hover { background: var(--surface-soft); }
|
||||||
|
.conv-item.active { background: #edf3ff; border-color: #e3ebfc; }
|
||||||
|
.avatar { flex-shrink: 0; display: grid; place-items: center; width: 39px; height: 39px; border-radius: 11px; color: #60728f; background: #eaf0f7; font-size: 14px; font-weight: 600; }
|
||||||
|
.active .avatar { color: var(--brand); background: #dce7fe; }
|
||||||
|
.conv-item .info { min-width: 0; flex: 1; }
|
||||||
|
.conv-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.conv-item .name { display: block; font-size: 14px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||||
|
.conv-time { flex-shrink: 0; color: var(--muted); font-size: 10px; font-weight: 400; }
|
||||||
|
.conv-item .preview { display: block; margin-top: 5px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--secondary); font-size: 12px; }
|
||||||
|
.badge { flex-shrink: 0; padding: 0 5px; border-radius: 5px; background: var(--brand); color: white; font-size: 10px; }
|
||||||
|
.queue-item { border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 8px; background: var(--surface); }
|
||||||
|
.queue-item .qt { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
|
.queue-item small { display: block; color: var(--muted); font-size: 11px; font-weight: 400; margin-top: 5px; }
|
||||||
|
.queue-item button { margin-top: 13px; padding: 7px 12px; min-height: 34px; width: 100%; border: 0; border-radius: 6px; background: var(--brand); color: white; font-size: 12px; }
|
||||||
|
.list-footer { flex-shrink: 0; display: flex; align-items: center; gap: 7px; padding: 14px 20px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; }
|
||||||
|
.list-footer-mark { width: 6px; height: 6px; border-radius: 50%; background: #91a2bc; }
|
||||||
|
.list-footer > span { display: flex; align-items: center; gap: 7px; }
|
||||||
|
#tools-from-list { display: none; }
|
||||||
|
.chat-area { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: #f5f7fa; }
|
||||||
|
.chat-header { display: flex; flex-shrink: 0; align-items: center; gap: 12px; min-height: 72px; padding: 13px 28px; background: var(--surface); border-bottom: 1px solid var(--border); }
|
||||||
|
.participant-avatar { display: grid; place-items: center; flex-shrink: 0; width: 38px; height: 38px; border-radius: 10px; background: #edf2fa; color: #6a7b96; font-size: 14px; }
|
||||||
|
.participant-copy { flex: 1; min-width: 0; }
|
||||||
|
.participant-copy h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 650; }
|
||||||
|
.participant-copy p { margin-top: 2px; color: var(--muted); font-size: 11px; }
|
||||||
|
.has-active-session .participant-copy p { color: #4b796b; }
|
||||||
|
.chat-header-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||||
|
.tools-toggle, .close { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 33px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; background: var(--surface); color: var(--secondary); white-space: nowrap; }
|
||||||
|
.tools-toggle svg { width: 16px; height: 16px; }
|
||||||
|
.tools-toggle[aria-expanded="true"], .tools-toggle:hover { border-color: #bacbf2; color: var(--brand); background: var(--brand-soft); }
|
||||||
|
.close:hover:enabled { border-color: #ecc5ca; background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.close:disabled { cursor: default; opacity: .45; }
|
||||||
|
.has-active-session .participant-avatar { color: var(--brand); background: var(--brand-soft); }
|
||||||
|
body:not(.has-active-session) .close { display: none; }
|
||||||
|
.empty { flex: 1; display: grid; place-items: center; text-align: center; min-height: 0; padding: 30px; }
|
||||||
|
.empty-illustration { display: grid; place-items: center; width: 52px; height: 52px; margin: 0 auto 18px; border: 1px solid #e0e6ee; border-radius: 15px; background: var(--surface); color: #879bb9; }
|
||||||
|
.empty-illustration svg { width: 26px; height: 26px; }
|
||||||
|
.empty h2 { margin-bottom: 8px; font-size: 18px; font-weight: 600; }
|
||||||
|
.empty p { font-size: 13px; line-height: 1.9; color: var(--muted); }
|
||||||
|
.session-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
#chatBox { flex: 1; min-height: 0; overflow-y: auto; padding: 26px clamp(22px, 4vw, 60px); }
|
||||||
|
.msg { display: flex; align-items: flex-start; gap: 10px; max-width: 1040px; margin: 0 auto 24px; }
|
||||||
|
.msg.agent { flex-direction: row-reverse; }
|
||||||
|
.message-avatar { flex-shrink: 0; display: grid; place-items: center; width: 32px; height: 32px; margin-top: 24px; border-radius: 9px; background: #e6ecf4; color: #6e7e97; font-size: 12px; }
|
||||||
|
.message-avatar svg { width: 17px; height: 17px; }
|
||||||
|
.msg.agent .message-avatar { background: var(--brand); color: white; }
|
||||||
|
.message-stack { max-width: min(76%, 720px); min-width: 0; }
|
||||||
|
.meta { margin-bottom: 6px; font-size: 11px; line-height: 1.5; color: var(--muted); }
|
||||||
|
.msg.agent .meta { text-align: right; }
|
||||||
|
.bubble { padding: 12px 16px; border-radius: 3px 10px 10px; border: 1px solid #e4e9f0; background: var(--surface); color: var(--text); font-size: 15px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.msg.agent .bubble { background: #eaf1ff; border-color: #dce6fb; color: #213858; border-radius: 10px 3px 10px 10px; }
|
||||||
|
.message-image { display: block; max-width: min(240px, 100%); max-height: 320px; object-fit: contain; border-radius: 6px; }
|
||||||
|
.message-file { color: inherit; text-underline-offset: 3px; font-size: 13px; }
|
||||||
|
.agent-composer { flex-shrink: 0; padding: 13px 28px 16px; background: var(--surface); border-top: 1px solid var(--border); }
|
||||||
|
.composer-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; color: var(--secondary); font-size: 12px; }
|
||||||
|
.composer-heading span { color: var(--muted); font-size: 11px; }
|
||||||
|
.input-bar { padding: 10px 12px 9px; border: 1px solid #dce3ed; border-radius: 8px; background: var(--surface); }
|
||||||
|
.input-bar:focus-within { border-color: #8ba8eb; box-shadow: 0 0 0 2px var(--brand-soft); }
|
||||||
|
#replyText { display: block; width: 100%; min-height: 56px; max-height: 180px; padding: 0; border: 0; resize: none; outline: none; background: transparent; color: var(--text); font-size: 14px; line-height: 1.7; }
|
||||||
|
#replyText::placeholder { color: #8b98ab; }
|
||||||
|
.input-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 7px; }
|
||||||
|
.input-actions > span { color: var(--muted); font-size: 11px; }
|
||||||
|
#reply-send { min-width: 78px; min-height: 34px; padding: 6px 20px; border: 0; border-radius: 6px; background: var(--brand); color: white; font-size: 13px; }
|
||||||
|
#reply-send:hover:enabled { background: var(--brand-hover); }
|
||||||
|
.reply-status { font-size: 12px; line-height: 1.6; color: var(--success); padding-top: 7px; }
|
||||||
|
.reply-status.is-error { color: var(--danger); }
|
||||||
|
.system-msg { display: block; text-align: center; color: var(--muted); font-size: 12px; }
|
||||||
|
.recall-button { border: 0; background: transparent; padding: 0 0 0 10px; color: var(--muted); font-size: 11px; }
|
||||||
|
.night-badge { padding: 4px 7px; border-radius: 5px; background: var(--brand-soft); color: var(--secondary); font-size: 11px; }
|
||||||
|
.note-input { width: 100%; padding: 9px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); resize: vertical; font-size: 13px; }
|
||||||
|
.search-dialog { width: min(660px, calc(100vw - 32px)); max-height: calc(100dvh - 60px); padding: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
|
||||||
|
.search-dialog::backdrop { background: #19263d55; }
|
||||||
|
.search-dialog header, .search-dialog form { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
|
||||||
|
.search-dialog h2 { font-size: 17px; }
|
||||||
|
.search-dialog input { flex: 1; min-width: 0; padding: 10px; border: 1px solid var(--border); border-radius: 7px; }
|
||||||
|
.search-dialog .quick { width: auto; margin: 0; }
|
||||||
|
.search-result { padding: 14px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
||||||
|
.search-result h3 { font-size: 14px; }
|
||||||
|
.search-result p { margin: 8px 0; white-space: pre-wrap; font-size: 13px; }
|
||||||
|
.search-result small { color: var(--muted); }
|
||||||
|
@media (max-width: 720px) { .night-badge { display: none; } .participant-copy { min-width: 55px; } .chat-header-actions { gap: 4px; } }
|
||||||
|
.side { display: none; min-height: 0; min-width: 0; overflow-y: auto; background: var(--surface); border-left: 1px solid var(--border); }
|
||||||
|
.tools-open .layout { grid-template-columns: 300px minmax(0, 1fr) 310px; }
|
||||||
|
.tools-open .side { display: block; }
|
||||||
|
.side-heading { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px; border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||||
|
.side-heading h2 { font-size: 15px; font-weight: 600; }
|
||||||
|
.side-heading .icon-button { width: 28px; height: 28px; border: 0; font-size: 20px; }
|
||||||
|
.card { padding: 20px 18px; border-bottom: 1px solid var(--border); }
|
||||||
|
.card:last-child { border-bottom: 0; }
|
||||||
|
.card h3 { margin-bottom: 13px; font-size: 13px; font-weight: 600; }
|
||||||
|
.card-caption { color: var(--muted); font-size: 11px; margin: -5px 0 12px; }
|
||||||
|
.info-row { font-size: 12px; line-height: 1.8; color: var(--secondary); }
|
||||||
|
.info-hint { margin-top: 13px; padding: 10px; font-size: 11px; line-height: 1.9; background: var(--surface-soft); color: var(--secondary); border-radius: 6px; }
|
||||||
|
.quick { display: block; width: 100%; margin-bottom: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; text-align: left; background: var(--surface); color: var(--secondary); font-size: 12px; }
|
||||||
|
.quick:hover:enabled { border-color: #a5bdee; color: var(--brand); background: var(--brand-soft); }
|
||||||
|
.quick:disabled { cursor: default; opacity: .5; }
|
||||||
|
.ticket-item { padding: 11px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--secondary); font-size: 12px; line-height: 1.8; overflow-wrap: anywhere; }
|
||||||
|
.ticket-item span { display: block; margin-top: 6px; font-size: 10px; color: var(--muted); }
|
||||||
|
.tools-backdrop, .sessions-back { display: none; }
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.layout, .tools-open .layout { grid-template-columns: 280px minmax(0, 1fr); }
|
||||||
|
.side { display: block; position: fixed; top: 0; bottom: 0; right: 0; z-index: 51; width: min(330px, 88vw); border-left: 1px solid var(--border); transform: translateX(105%); visibility: hidden; box-shadow: -10px 0 40px #19263d22; transition: transform .2s; }
|
||||||
|
.tools-open .side { transform: translateX(0); visibility: visible; }
|
||||||
|
.tools-open .tools-backdrop { display: block; position: fixed; inset: 0; z-index: 50; border: 0; background: #19263d55; }
|
||||||
|
.chat-header { padding-left: 22px; padding-right: 22px; }
|
||||||
|
.agent-composer { padding-left: 22px; padding-right: 22px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) and (min-width: 721px) {
|
||||||
|
.layout, .tools-open .layout { grid-template-columns: 248px minmax(0, 1fr); }
|
||||||
|
.list-heading { padding-left: 16px; padding-right: 16px; }
|
||||||
|
.session-tabs { padding-left: 16px; padding-right: 16px; gap: 17px; }
|
||||||
|
.tools-toggle span { display: none; }
|
||||||
|
#chatBox { padding-left: 18px; padding-right: 18px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar { height: 52px; min-height: 52px; padding: 0 16px; }
|
||||||
|
.app-identity h1, .nav-divider { display: none; }
|
||||||
|
.topbar .brand-name { font-size: 14px; }
|
||||||
|
.topbar-actions { gap: 12px; }
|
||||||
|
.data-overview summary { font-size: 11px; padding: 5px 8px; }
|
||||||
|
.stats { width: min(310px, calc(100vw - 32px)); }
|
||||||
|
.layout, .tools-open .layout { display: flex; flex-direction: column; }
|
||||||
|
.conv-list { flex: 1; min-height: 0; border-right: 0; }
|
||||||
|
.list-footer { justify-content: space-between; }
|
||||||
|
#tools-from-list { display: inline-block; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--secondary); font-size: 11px; }
|
||||||
|
.list-heading { padding: 20px 18px 15px; }
|
||||||
|
.session-tabs { padding-left: 18px; }
|
||||||
|
.list-heading h2 { font-size: 18px; }
|
||||||
|
.conv-item { padding-top: 16px; padding-bottom: 16px; }
|
||||||
|
.chat-area { display: none; flex: 1; min-height: 0; }
|
||||||
|
.chat-view .conv-list { display: none; }
|
||||||
|
.chat-view .chat-area { display: flex; }
|
||||||
|
.sessions-back { display: grid; place-items: center; flex-shrink: 0; width: 30px; height: 34px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--secondary); font-size: 28px; line-height: 1; }
|
||||||
|
.participant-avatar { display: none; }
|
||||||
|
.chat-header { min-height: 65px; padding: 11px 13px 11px 9px; gap: 8px; }
|
||||||
|
.participant-copy h2 { font-size: 14px; }
|
||||||
|
.participant-copy p { font-size: 10px; }
|
||||||
|
.chat-header-actions { gap: 7px; }
|
||||||
|
.tools-toggle { width: 33px; padding: 6px; }
|
||||||
|
.tools-toggle span { display: none; }
|
||||||
|
.close { padding: 6px 8px; font-size: 11px; }
|
||||||
|
#chatBox { padding: 21px 14px; }
|
||||||
|
.msg { gap: 7px; margin-bottom: 20px; }
|
||||||
|
.message-avatar { width: 27px; height: 27px; border-radius: 8px; }
|
||||||
|
.message-stack { max-width: calc(100% - 40px); }
|
||||||
|
.bubble { padding: 10px 12px; font-size: 15px; }
|
||||||
|
.agent-composer { padding: 11px 13px calc(11px + env(safe-area-inset-bottom)); }
|
||||||
|
.composer-heading { margin-bottom: 6px; font-size: 11px; }
|
||||||
|
.composer-heading span { font-size: 10px; }
|
||||||
|
#replyText { font-size: 16px; min-height: 54px; }
|
||||||
|
.input-actions > span { font-size: 10px; max-width: 210px; }
|
||||||
|
#reply-send { min-width: 66px; min-height: 38px; padding: 7px 14px; font-size: 12px; }
|
||||||
|
.reply-status { font-size: 11px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.workspace-link { display: none; }
|
||||||
|
.input-actions > span { max-width: 185px; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/* 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; }
|
||||||
|
.human-session { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; }
|
||||||
|
.human-session button { flex-shrink: 0; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--brand-text); font-size: 12px; }
|
||||||
|
.hot-bar-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); }
|
||||||
|
.service-rating { margin: 12px auto; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-soft); text-align: center; color: var(--secondary); font-size: 13px; }
|
||||||
|
.rating-choices { display: flex; justify-content: center; gap: 4px; margin-top: 6px; }
|
||||||
|
.rating-choices button { padding: 3px 8px; background: transparent; border: 0; color: var(--muted); font-size: 28px; }
|
||||||
|
.rating-choices button.selected, .rating-choices button:focus-visible { color: #bd8610; }
|
||||||
|
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
|
||||||
|
#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
|
||||||
|
#input-text::placeholder { color: var(--muted); }
|
||||||
|
#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; }
|
||||||
|
.order-data-notice { margin: 12px 0; padding: 10px; border-radius: 7px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; line-height: 1.8; }
|
||||||
|
.order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; }
|
||||||
|
.order-data-fields dt { color: var(--muted); }
|
||||||
|
.order-data-fields dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.order-history-empty { color: var(--muted); font-size: 11px; margin: 10px 0; }
|
||||||
|
.order-history-title { margin: 18px 0 10px; font-size: 13px; font-weight: 600; }
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/* Shared visual tokens and accessible controls. */
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--page: #edf1f7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f6f8fc;
|
||||||
|
--surface-hover: #edf2fa;
|
||||||
|
--text: #19263d;
|
||||||
|
--secondary: #566780;
|
||||||
|
--muted: #64748b;
|
||||||
|
--border: #e2e8f1;
|
||||||
|
--brand: #315fd5;
|
||||||
|
--brand-hover: #254fbe;
|
||||||
|
--brand-soft: #edf2ff;
|
||||||
|
--brand-text: #315fd5;
|
||||||
|
--success: #148462;
|
||||||
|
--success-soft: #eaf7f1;
|
||||||
|
--danger: #c74650;
|
||||||
|
--danger-soft: #fff0f1;
|
||||||
|
--shadow: 0 10px 40px rgba(28, 47, 80, .06);
|
||||||
|
}
|
||||||
|
body.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page: #0c1422;
|
||||||
|
--surface: #151f30;
|
||||||
|
--surface-soft: #1a273c;
|
||||||
|
--surface-hover: #21314b;
|
||||||
|
--text: #e7eef9;
|
||||||
|
--secondary: #b2c0d5;
|
||||||
|
--muted: #95a5bd;
|
||||||
|
--border: #2a3a53;
|
||||||
|
--brand: #426bd4;
|
||||||
|
--brand-hover: #4772db;
|
||||||
|
--brand-soft: #223759;
|
||||||
|
--brand-text: #a6c2ff;
|
||||||
|
--success: #75d7b3;
|
||||||
|
--success-soft: #193b35;
|
||||||
|
--danger: #ff9ca4;
|
||||||
|
--danger-soft: #412b38;
|
||||||
|
--shadow: 0 10px 40px rgba(0, 0, 0, .2);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--page);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
button, input, textarea { font: inherit; }
|
||||||
|
button, a, input, textarea { -webkit-tap-highlight-color: transparent; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:disabled { cursor: wait; opacity: .65; }
|
||||||
|
button, input, textarea, a { outline-offset: 3px; }
|
||||||
|
:focus-visible { outline: 3px solid #92b3ff; }
|
||||||
|
button { transition: background .16s, border-color .16s, color .16s, box-shadow .16s; }
|
||||||
|
a { color: var(--brand-text); }
|
||||||
|
h1, h2, h3, h4, p { margin: 0; }
|
||||||
|
svg { display: block; flex-shrink: 0; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
::selection { background: #cddcff; color: #122b59; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 11px; text-decoration: none; color: var(--text); }
|
||||||
|
.brand-mark {
|
||||||
|
display: grid; place-items: center; width: 38px; height: 38px;
|
||||||
|
background: var(--brand); color: white; border-radius: 11px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.brand-mark svg { width: 23px; height: 23px; }
|
||||||
|
.brand-name { font-size: 17px; font-weight: 700; letter-spacing: .02em; line-height: 1.3; }
|
||||||
|
.brand-caption { display: block; color: var(--muted); font-size: 9px; font-weight: 600; letter-spacing: .12em; margin-top: 3px; }
|
||||||
|
.eyebrow { font-size: 11px; font-weight: 600; letter-spacing: .1em; color: var(--brand-text); }
|
||||||
|
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); display: inline-block; flex-shrink: 0; }
|
||||||
|
.icon-button {
|
||||||
|
display: inline-grid; place-items: center; width: 36px; height: 36px;
|
||||||
|
border: 1px solid var(--border); border-radius: 9px; color: var(--secondary); background: var(--surface);
|
||||||
|
}
|
||||||
|
.icon-button:hover { color: var(--brand-text); background: var(--brand-soft); }
|
||||||
|
.text-button { border: 0; padding: 5px 0; background: transparent; color: var(--brand-text); font-size: 12px; }
|
||||||
|
.emoji-control { position: relative; flex-shrink: 0; }
|
||||||
|
.emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
|
||||||
|
.emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
|
||||||
|
.emoji-picker button:hover { background: var(--surface-hover); }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||||
|
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
|
||||||
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #b7c3d5; border-radius: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
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 {"&":"&", "<":"<", ">":">", '"':""", "'":"'"}[character];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminRequest(path, options){
|
||||||
|
const response = await fetch(path, options);
|
||||||
|
if(!response.ok) throw new Error("HTTP " + response.status);
|
||||||
|
const data = await response.json();
|
||||||
|
if(data && data.ok === false) throw new Error("Request rejected");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function postAdmin(path, data){
|
||||||
|
return adminRequest(path, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(data)});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReplyStatus(message, isError){
|
||||||
|
const status = document.getElementById("reply-status");
|
||||||
|
status.textContent = message || "";
|
||||||
|
status.hidden = !message;
|
||||||
|
status.classList.toggle("is-error", Boolean(isError));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateComposer(){
|
||||||
|
document.body.classList.toggle("has-active-session", Boolean(curSid));
|
||||||
|
const send = document.getElementById("reply-send");
|
||||||
|
send.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
|
||||||
|
send.textContent = sendingSid === curSid ? "发送中…" : "发送";
|
||||||
|
const close = document.getElementById("close-session");
|
||||||
|
close.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
|
||||||
|
close.textContent = closingSid === curSid ? "正在结束…" : "结束会话";
|
||||||
|
document.querySelectorAll(".quick").forEach(function(button){
|
||||||
|
button.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
|
||||||
|
});
|
||||||
|
updateAdminTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeReply(){
|
||||||
|
replyInput.style.height = "auto";
|
||||||
|
replyInput.style.height = Math.min(replyInput.scrollHeight, 180) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionTab(tab){
|
||||||
|
const active = tab !== "queue";
|
||||||
|
document.getElementById("sessionList").hidden = !active;
|
||||||
|
document.getElementById("queue").hidden = active;
|
||||||
|
document.getElementById("tab-active").setAttribute("aria-selected", String(active));
|
||||||
|
document.getElementById("tab-queue").setAttribute("aria-selected", String(!active));
|
||||||
|
document.getElementById("tab-active").tabIndex = active ? 0 : -1;
|
||||||
|
document.getElementById("tab-queue").tabIndex = active ? -1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChatView(open){
|
||||||
|
document.body.classList.toggle("chat-view", Boolean(open && curSid));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSessionList(){
|
||||||
|
setChatView(false);
|
||||||
|
document.getElementById("tab-active").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTerm(){
|
||||||
|
return document.getElementById("session-search").value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQueue(){
|
||||||
|
const term = searchTerm();
|
||||||
|
const visible = queueItems.filter(function(item){
|
||||||
|
return ("#" + item.id + " " + item.ctime).toLowerCase().includes(term);
|
||||||
|
});
|
||||||
|
document.getElementById("queue-count").textContent = queueItems.length;
|
||||||
|
document.getElementById("session-browser").classList.toggle("queue-empty", queueItems.length === 0);
|
||||||
|
document.getElementById("queue").innerHTML = visible.length
|
||||||
|
? visible.map(function(item){
|
||||||
|
const sid = Number(item.id);
|
||||||
|
return '<div class="queue-item"><div class="qt">对话 #'+sid+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
|
||||||
|
}).join("")
|
||||||
|
: '<div class="list-empty">'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQueue(){
|
||||||
|
try{
|
||||||
|
const list = await adminRequest("/api/human/queue");
|
||||||
|
if(list.some(function(item){ return !queueItems.some(function(old){ return old.id === item.id; }); })){
|
||||||
|
SupportUI.notify("新客户排队", "有 " + list.length + " 位客户等待接入");
|
||||||
|
}
|
||||||
|
queueItems = list;
|
||||||
|
renderQueue();
|
||||||
|
}catch(error){
|
||||||
|
document.getElementById("queue").innerHTML = '<div class="list-empty">队列暂时无法加载,稍后重试</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTickets(){
|
||||||
|
const element = document.getElementById("ticketList");
|
||||||
|
try{
|
||||||
|
const list = await adminRequest("/api/tickets");
|
||||||
|
element.innerHTML = list.length ? list.map(function(ticket){
|
||||||
|
return '<div class="ticket-item">'+escapeText(ticket.content)+'<span>'+escapeText(ticket.ctime)+'</span></div>';
|
||||||
|
}).join("") : '<div class="list-empty">暂时没有客户留言</div>';
|
||||||
|
}catch(error){
|
||||||
|
element.innerHTML = '<div class="list-empty">留言暂时无法加载</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accept(sid){
|
||||||
|
sid = String(sid);
|
||||||
|
if(sessions[sid]){ switchSession(sid); return; }
|
||||||
|
if(accepting.has(sid)) return;
|
||||||
|
accepting.add(sid);
|
||||||
|
try{
|
||||||
|
await postAdmin("/api/human/accept", {sid:Number(sid)});
|
||||||
|
if(!sessions[sid]) sessions[sid] = {lastId:0, msgs:[], unread:0};
|
||||||
|
switchSession(sid);
|
||||||
|
loadQueue();
|
||||||
|
poll();
|
||||||
|
}catch(error){
|
||||||
|
document.getElementById("queue").innerHTML = '<div class="list-empty">接入失败,请稍后重试</div>';
|
||||||
|
}finally{
|
||||||
|
accepting.delete(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchSession(sid){
|
||||||
|
sid = String(sid);
|
||||||
|
if(!sessions[sid]) return;
|
||||||
|
if(curSid) drafts[curSid] = replyInput.value;
|
||||||
|
curSid = sid;
|
||||||
|
document.getElementById("emptyTip").style.display = "none";
|
||||||
|
document.getElementById("chatContent").style.display = "flex";
|
||||||
|
setChatView(true);
|
||||||
|
document.getElementById("chatTitle").textContent = "客户 #" + sid;
|
||||||
|
document.getElementById("chat-subtitle").textContent = "正在接待 · 人工会话";
|
||||||
|
document.getElementById("cInfo").textContent = "会话编号:#" + sid;
|
||||||
|
document.getElementById("chatBox").innerHTML = "";
|
||||||
|
sessions[sid].msgs.forEach(appendMsg);
|
||||||
|
sessions[sid].unread = 0;
|
||||||
|
replyInput.value = drafts[sid] || "";
|
||||||
|
resizeReply();
|
||||||
|
document.getElementById("chatBox").scrollTop = document.getElementById("chatBox").scrollHeight;
|
||||||
|
setReplyStatus("");
|
||||||
|
loadSessionNote(sid);
|
||||||
|
updateComposer();
|
||||||
|
renderList();
|
||||||
|
setSessionTab("active");
|
||||||
|
postAdmin("/api/human/read", {sid:Number(sid)}).catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmptySession(){
|
||||||
|
curSid = 0;
|
||||||
|
clearTimeout(typeTimer);
|
||||||
|
document.getElementById("chatContent").style.display = "none";
|
||||||
|
document.getElementById("emptyTip").style.display = "flex";
|
||||||
|
document.getElementById("cInfo").textContent = "选择会话后查看客户信息";
|
||||||
|
document.getElementById("chatTitle").textContent = "会话接待";
|
||||||
|
document.getElementById("chat-subtitle").textContent = "选择一段会话,开始回复客户";
|
||||||
|
replyInput.value = "";
|
||||||
|
document.getElementById("noteInput").value = "";
|
||||||
|
setReplyStatus("");
|
||||||
|
updateComposer();
|
||||||
|
setChatView(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMsg(message){
|
||||||
|
const box = document.getElementById("chatBox");
|
||||||
|
const follow = box.scrollHeight - box.scrollTop - box.clientHeight < 100;
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.setAttribute("data-message-id", String(message.id));
|
||||||
|
if(message.side === "system"){
|
||||||
|
div.className = "msg system-msg";
|
||||||
|
div.textContent = message.content;
|
||||||
|
box.appendChild(div);
|
||||||
|
if(follow) box.scrollTop = box.scrollHeight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
|
||||||
|
let content = escapeText(message.content);
|
||||||
|
content = content.replace(/\[图片\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$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>';
|
||||||
|
if(message.side === "agent"){
|
||||||
|
const recall = document.createElement("button");
|
||||||
|
recall.type = "button";
|
||||||
|
recall.className = "recall-button";
|
||||||
|
recall.textContent = "撤回";
|
||||||
|
recall.onclick = function(){ recallMsg(message.id); };
|
||||||
|
div.querySelector(".meta").appendChild(recall);
|
||||||
|
}
|
||||||
|
box.appendChild(div);
|
||||||
|
if(follow || message.side === "agent") box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(){
|
||||||
|
const term = searchTerm();
|
||||||
|
const ids = Object.keys(sessions);
|
||||||
|
document.getElementById("active-count").textContent = ids.length;
|
||||||
|
const signature = JSON.stringify([curSid, term, ids.map(function(sid){
|
||||||
|
const session = sessions[sid];
|
||||||
|
const last = session.msgs[session.msgs.length-1];
|
||||||
|
return [sid, session.unread, last && last.id, last && last.content];
|
||||||
|
})]);
|
||||||
|
if(signature === listSignature) return;
|
||||||
|
listSignature = signature;
|
||||||
|
const element = document.getElementById("sessionList");
|
||||||
|
element.innerHTML = "";
|
||||||
|
const visible = ids.filter(function(sid){
|
||||||
|
return ("对话 #"+sid).includes(term) || sessions[sid].msgs.some(function(message){
|
||||||
|
return message.content.toLowerCase().includes(term);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if(!visible.length){
|
||||||
|
element.innerHTML = '<div class="list-empty">'+(term ? "没有匹配的进行中会话" : "暂无进行中的会话")+'</div>';
|
||||||
|
}
|
||||||
|
visible.forEach(function(sid){
|
||||||
|
const session = sessions[sid];
|
||||||
|
const last = session.msgs[session.msgs.length-1];
|
||||||
|
const preview = last ? (last.side === "agent" ? "客服:" : "") + last.content.substring(0, 36) : "等待客户消息";
|
||||||
|
const div = document.createElement("button");
|
||||||
|
div.type = "button";
|
||||||
|
div.className = "conv-item" + (sid === curSid ? " active" : "");
|
||||||
|
div.setAttribute("aria-pressed", String(sid === curSid));
|
||||||
|
div.innerHTML = '<span class="avatar">客</span><span class="info"><span class="conv-title"><span class="name">客户 #'+sid+'</span><span class="conv-time">'+(last && last.ctime ? escapeText(last.ctime.slice(11,16)) : '')+'</span></span><span class="preview">'+escapeText(preview)+'</span></span>'+(session.unread ? '<span class="badge">'+session.unread+'</span>' : '');
|
||||||
|
div.onclick = function(){ switchSession(sid); };
|
||||||
|
element.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll(){
|
||||||
|
if(polling) return;
|
||||||
|
polling = true;
|
||||||
|
try{
|
||||||
|
const list = await adminRequest("/api/human/active");
|
||||||
|
list.forEach(function(session){
|
||||||
|
if(!sessions[String(session.id)]) sessions[String(session.id)] = {lastId:0, msgs:[], unread:0};
|
||||||
|
});
|
||||||
|
await Promise.all(Object.keys(sessions).map(async function(sid){
|
||||||
|
const session = sessions[sid];
|
||||||
|
const state = await adminRequest("/api/human/poll?sid="+Number(sid)+"&after="+session.lastId);
|
||||||
|
if(sessions[sid] !== session) return;
|
||||||
|
if(state.status !== "active"){
|
||||||
|
delete sessions[sid];
|
||||||
|
delete drafts[sid];
|
||||||
|
if(sid === curSid) showEmptySession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.messages.forEach(function(message){
|
||||||
|
if(session.msgs.some(function(existing){ return existing.id === message.id; })) return;
|
||||||
|
session.lastId = Math.max(session.lastId, message.id);
|
||||||
|
if(message.recalled_id && applyAdminRecall(sid, message.recalled_id)) return;
|
||||||
|
session.msgs.push(message);
|
||||||
|
if(message.side === "customer" && sid !== curSid) session.unread += 1;
|
||||||
|
if(message.side === "customer") SupportUI.notify("客户消息 #"+sid, message.content.substring(0, 50));
|
||||||
|
if(sid === curSid) appendMsg(message);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
document.getElementById("sync-status").hidden = true;
|
||||||
|
renderList();
|
||||||
|
}catch(error){
|
||||||
|
document.getElementById("sync-status").hidden = false;
|
||||||
|
}finally{
|
||||||
|
polling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply(){
|
||||||
|
if(!curSid || sendingSid || closingSid) return;
|
||||||
|
const sid = curSid;
|
||||||
|
const draft = replyInput.value;
|
||||||
|
if(!draft.trim()) return;
|
||||||
|
drafts[sid] = draft;
|
||||||
|
sendingSid = sid;
|
||||||
|
setReplyStatus("");
|
||||||
|
updateComposer();
|
||||||
|
try{
|
||||||
|
await postAdmin("/api/human/send", {sid:Number(sid), side:"agent", content:draft.trim()});
|
||||||
|
if(drafts[sid] === draft) drafts[sid] = "";
|
||||||
|
if(curSid === sid && replyInput.value === draft){
|
||||||
|
replyInput.value = "";
|
||||||
|
resizeReply();
|
||||||
|
}
|
||||||
|
if(curSid === sid){ replyInput.focus(); setReplyStatus("已发送"); }
|
||||||
|
poll();
|
||||||
|
}catch(error){
|
||||||
|
if(curSid === sid) setReplyStatus("发送未成功,内容已保留。请确认后重试。", true);
|
||||||
|
}finally{
|
||||||
|
sendingSid = "";
|
||||||
|
updateComposer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickReply(text){
|
||||||
|
if(!curSid || sendingSid || closingSid) return;
|
||||||
|
replyInput.value = text;
|
||||||
|
drafts[curSid] = text;
|
||||||
|
resizeReply();
|
||||||
|
setReplyStatus("常用回复已填入,可编辑后发送");
|
||||||
|
if(toolsOpen) setToolsOpen(false);
|
||||||
|
replyInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSession(){
|
||||||
|
if(!curSid || closingSid || sendingSid) return;
|
||||||
|
const sid = curSid;
|
||||||
|
closingSid = sid;
|
||||||
|
updateComposer();
|
||||||
|
try{
|
||||||
|
await postAdmin("/api/human/close", {sid:Number(sid)});
|
||||||
|
delete sessions[sid];
|
||||||
|
delete drafts[sid];
|
||||||
|
if(curSid === sid) showEmptySession();
|
||||||
|
renderList();
|
||||||
|
}catch(error){
|
||||||
|
if(curSid === sid) setReplyStatus("结束失败,会话仍保留,请稍后重试。", true);
|
||||||
|
}finally{
|
||||||
|
closingSid = "";
|
||||||
|
updateComposer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToolsOpen(open){
|
||||||
|
const opening = Boolean(open) && !toolsOpen;
|
||||||
|
toolsOpen = Boolean(open);
|
||||||
|
document.body.classList.toggle("tools-open", toolsOpen);
|
||||||
|
document.getElementById("tools-toggle").setAttribute("aria-expanded", String(toolsOpen));
|
||||||
|
document.getElementById("tools-from-list").setAttribute("aria-expanded", String(toolsOpen));
|
||||||
|
const panel = document.getElementById("service-tools");
|
||||||
|
panel.setAttribute("aria-hidden", String(!toolsOpen));
|
||||||
|
panel.toggleAttribute("inert", !toolsOpen);
|
||||||
|
if(opening){
|
||||||
|
document.getElementById("data-overview").open = false;
|
||||||
|
document.getElementById("tools-close").focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replyInput.addEventListener("keydown", function(event){
|
||||||
|
if(event.key === "Enter" && !event.shiftKey && !event.isComposing){
|
||||||
|
event.preventDefault();
|
||||||
|
sendReply();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
replyInput.addEventListener("input", function(){
|
||||||
|
resizeReply();
|
||||||
|
if(!curSid) return;
|
||||||
|
drafts[curSid] = replyInput.value;
|
||||||
|
setReplyStatus("");
|
||||||
|
const sid = curSid;
|
||||||
|
postAdmin("/api/human/typing", {sid:Number(sid)}).catch(function(){});
|
||||||
|
clearTimeout(typeTimer);
|
||||||
|
typeTimer = setTimeout(function(){
|
||||||
|
postAdmin("/api/human/stop_typing", {sid:Number(sid)}).catch(function(){});
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
document.getElementById("session-search").addEventListener("input", function(){ renderList(); renderQueue(); });
|
||||||
|
document.getElementById("sessions-back").onclick = showSessionList;
|
||||||
|
document.getElementById("tab-active").onclick = function(){ setSessionTab("active"); };
|
||||||
|
document.getElementById("tab-queue").onclick = function(){ setSessionTab("queue"); };
|
||||||
|
["tab-active", "tab-queue"].forEach(function(id){
|
||||||
|
document.getElementById(id).addEventListener("keydown", function(event){
|
||||||
|
if(!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = event.key === "Home" || (event.key !== "End" && id === "tab-queue") ? "tab-active" : "tab-queue";
|
||||||
|
setSessionTab(next === "tab-active" ? "active" : "queue");
|
||||||
|
document.getElementById(next).focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById("tools-toggle").onclick = function(){ toolsOpener = "tools-toggle"; setToolsOpen(!toolsOpen); };
|
||||||
|
document.getElementById("tools-from-list").onclick = function(){ toolsOpener = "tools-from-list"; setToolsOpen(!toolsOpen); };
|
||||||
|
document.getElementById("tools-close").onclick = function(){
|
||||||
|
setToolsOpen(false);
|
||||||
|
const fromList = toolsOpener === "tools-from-list" && !document.body.classList.contains("chat-view") && window.matchMedia("(max-width: 720px)").matches;
|
||||||
|
document.getElementById(fromList ? "tools-from-list" : "tools-toggle").focus();
|
||||||
|
};
|
||||||
|
document.getElementById("tools-backdrop").onclick = document.getElementById("tools-close").onclick;
|
||||||
|
document.addEventListener("keydown", function(event){
|
||||||
|
if(event.key === "Escape" && toolsOpen) document.getElementById("tools-close").click();
|
||||||
|
if(event.key === "Escape") document.getElementById("data-overview").open = false;
|
||||||
|
if(event.key === "Tab" && toolsOpen && window.matchMedia("(max-width: 1200px)").matches){
|
||||||
|
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href], textarea:not([disabled])'));
|
||||||
|
const first = items[0], last = items[items.length-1];
|
||||||
|
if(event.shiftKey && document.activeElement === first){ event.preventDefault(); last.focus(); }
|
||||||
|
else if(!event.shiftKey && document.activeElement === last){ event.preventDefault(); first.focus(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", function(){ setToolsOpen(toolsOpen); });
|
||||||
|
adminRequest("/api/admin/stats").then(function(data){
|
||||||
|
[data.total_conv, data.total_msg, data.today_conv, data.good, data.bad].forEach(function(value, index){
|
||||||
|
document.getElementById("s"+(index+1)).textContent = value;
|
||||||
|
});
|
||||||
|
document.getElementById("s6").textContent = (data.rating_avg || 0) + " / 5("+(data.rating_count || 0)+")";
|
||||||
|
}).catch(function(){});
|
||||||
|
SupportUI.initEmoji(document.getElementById("agent-emoji-toggle"), document.getElementById("agent-emoji-panel"), replyInput, function(){
|
||||||
|
if(curSid) drafts[curSid] = replyInput.value;
|
||||||
|
resizeReply();
|
||||||
|
});
|
||||||
|
setToolsOpen(false);
|
||||||
|
setSessionTab("active");
|
||||||
|
updateComposer();
|
||||||
|
loadQueue();
|
||||||
|
loadTickets();
|
||||||
|
poll();
|
||||||
|
setInterval(loadQueue, 3000);
|
||||||
|
setInterval(loadTickets, 5000);
|
||||||
|
setInterval(poll, 1000);
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Workspace notes, transfers, recalls and historical message search.
|
||||||
|
function updateAdminTools(){
|
||||||
|
document.getElementById('transfer-session').disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
|
||||||
|
document.getElementById('agent-emoji-toggle').disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
|
||||||
|
document.getElementById('noteInput').disabled = !curSid || Boolean(sessions[curSid].noteLoading);
|
||||||
|
document.getElementById('note-save').disabled = !curSid || !sessions[curSid].noteLoaded || Boolean(sessions[curSid].noteLoading);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionNote(sid){
|
||||||
|
const session = sessions[sid];
|
||||||
|
const input = document.getElementById('noteInput');
|
||||||
|
input.value = session.noteDraft || '';
|
||||||
|
if(session.noteLoaded){ updateAdminTools(); return; }
|
||||||
|
session.noteLoading = true;
|
||||||
|
updateAdminTools();
|
||||||
|
try{
|
||||||
|
const state = await adminRequest('/api/human/poll?sid='+Number(sid)+'&after=0');
|
||||||
|
if(sessions[sid] !== session) return;
|
||||||
|
session.noteDraft = state.note || '';
|
||||||
|
session.noteLoaded = true;
|
||||||
|
if(curSid === sid) input.value = session.noteDraft;
|
||||||
|
}catch(error){
|
||||||
|
if(curSid === sid) setReplyStatus('备注加载失败,请重新选择会话后重试。', true);
|
||||||
|
}finally{
|
||||||
|
session.noteLoading = false;
|
||||||
|
updateAdminTools();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNote(){
|
||||||
|
if(!curSid || !sessions[curSid].noteLoaded) return;
|
||||||
|
const sid = curSid, session = sessions[sid];
|
||||||
|
const note = document.getElementById('noteInput').value;
|
||||||
|
session.noteDraft = note;
|
||||||
|
try{
|
||||||
|
await postAdmin('/api/human/note', {sid:Number(sid), note:note});
|
||||||
|
if(curSid === sid) setReplyStatus(session.noteDraft === note ? '备注已保存' : '先前备注已保存,当前修改尚未保存');
|
||||||
|
}catch(error){
|
||||||
|
if(curSid === sid) setReplyStatus('备注保存失败,内容已保留。', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transferSession(){
|
||||||
|
if(!curSid || closingSid || sendingSid) return;
|
||||||
|
if(!window.confirm('确定转接这个对话?转接后其他客服可在排队列表接入。')) return;
|
||||||
|
const sid = curSid;
|
||||||
|
closingSid = sid;
|
||||||
|
updateComposer();
|
||||||
|
try{
|
||||||
|
await postAdmin('/api/human/transfer', {sid:Number(sid)});
|
||||||
|
delete sessions[sid];
|
||||||
|
if(curSid === sid) showEmptySession();
|
||||||
|
renderList();
|
||||||
|
loadQueue();
|
||||||
|
}catch(error){
|
||||||
|
if(curSid === sid) setReplyStatus('转接未成功,会话仍保留。', true);
|
||||||
|
}finally{
|
||||||
|
closingSid = '';
|
||||||
|
updateComposer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAdminRecall(sid, mid){
|
||||||
|
const message = sessions[sid].msgs.find(function(item){ return item.id === mid; });
|
||||||
|
if(message){ message.side = 'system'; message.content = '该消息已撤回'; }
|
||||||
|
if(curSid === sid){
|
||||||
|
const row = document.querySelector('#chatBox [data-message-id="'+Number(mid)+'"]');
|
||||||
|
if(row){ row.className = 'msg system-msg'; row.textContent = '该消息已撤回'; }
|
||||||
|
}
|
||||||
|
return Boolean(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recallMsg(mid){
|
||||||
|
if(!curSid) return;
|
||||||
|
const sid = curSid;
|
||||||
|
try{
|
||||||
|
await postAdmin('/api/human/recall', {mid:mid});
|
||||||
|
if(sessions[sid]){ applyAdminRecall(sid, mid); renderList(); }
|
||||||
|
}catch(error){
|
||||||
|
if(curSid === sid) setReplyStatus('撤回失败,请稍后重试。', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchVersion = 0;
|
||||||
|
async function doSearch(){
|
||||||
|
const keyword = document.getElementById('searchBox').value.trim();
|
||||||
|
const version = ++searchVersion;
|
||||||
|
const body = document.getElementById('searchBody');
|
||||||
|
if(!keyword){ body.textContent = '请输入关键词搜索历史消息'; return; }
|
||||||
|
body.textContent = '正在搜索…';
|
||||||
|
try{
|
||||||
|
const list = await adminRequest('/api/search_chat?q='+encodeURIComponent(keyword));
|
||||||
|
if(version !== searchVersion) return;
|
||||||
|
const roles = {user:'客户', assistant:'客服', system:'系统'};
|
||||||
|
body.innerHTML = list.length ? list.map(function(item){
|
||||||
|
return '<article class="search-result"><h3>'+escapeText(item.title)+'</h3><p>'+escapeText(item.content)+'</p><small>'+escapeText(roles[item.role] || item.role)+' · '+escapeText(item.ctime)+'</small></article>';
|
||||||
|
}).join('') : '没有匹配的历史消息';
|
||||||
|
}catch(error){
|
||||||
|
if(version === searchVersion) body.textContent = '搜索失败,请稍后重试';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('history-search-toggle').onclick = function(){
|
||||||
|
document.getElementById('searchResult').showModal();
|
||||||
|
document.getElementById('searchBox').focus();
|
||||||
|
};
|
||||||
|
document.getElementById('search-close').onclick = function(){ document.getElementById('searchResult').close(); };
|
||||||
|
document.getElementById('history-search-form').addEventListener('submit', function(event){ event.preventDefault(); doSearch(); });
|
||||||
|
document.getElementById('note-save').onclick = saveNote;
|
||||||
|
document.getElementById('noteInput').addEventListener('input', function(){
|
||||||
|
if(curSid) sessions[curSid].noteDraft = this.value;
|
||||||
|
});
|
||||||
|
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
|
||||||
|
function updateNightBadge(){
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
document.getElementById('nightBadge').hidden = hour >= 8 && hour < 22;
|
||||||
|
}
|
||||||
|
updateNightBadge();
|
||||||
|
setInterval(updateNightBadge, 60000);
|
||||||
+1745
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
// Shared controls for the customer page and service workspace.
|
||||||
|
var SupportUI = {
|
||||||
|
async enableNotifications(button){
|
||||||
|
if(!('Notification' in window)) return;
|
||||||
|
try{
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
button.setAttribute('aria-pressed', String(permission === 'granted'));
|
||||||
|
}catch(error){}
|
||||||
|
},
|
||||||
|
notify(title, body){
|
||||||
|
if(!document.hidden || !('Notification' in window) || Notification.permission !== 'granted') return;
|
||||||
|
try{
|
||||||
|
const notice = new Notification(title, {body:body});
|
||||||
|
notice.onclick = function(){ window.focus(); notice.close(); };
|
||||||
|
}catch(error){}
|
||||||
|
},
|
||||||
|
addRating(container, sid, labels){
|
||||||
|
const box = document.createElement('section');
|
||||||
|
box.className = 'service-rating';
|
||||||
|
const question = document.createElement('p');
|
||||||
|
question.textContent = labels.question;
|
||||||
|
box.appendChild(question);
|
||||||
|
const choices = document.createElement('div');
|
||||||
|
choices.className = 'rating-choices';
|
||||||
|
box.appendChild(choices);
|
||||||
|
const status = document.createElement('p');
|
||||||
|
status.setAttribute('role', 'status');
|
||||||
|
box.appendChild(status);
|
||||||
|
let sending = false;
|
||||||
|
[1, 2, 3, 4, 5].forEach(function(stars){
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = '★';
|
||||||
|
button.setAttribute('aria-label', stars+' / 5');
|
||||||
|
button.onmouseenter = function(){
|
||||||
|
Array.from(choices.children).forEach(function(item, index){ item.classList.toggle('selected', index < stars); });
|
||||||
|
};
|
||||||
|
button.onclick = async function(){
|
||||||
|
if(sending) return;
|
||||||
|
sending = true;
|
||||||
|
Array.from(choices.children).forEach(function(item){ item.disabled = true; });
|
||||||
|
try{
|
||||||
|
const response = await fetch('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
|
||||||
|
const data = await response.json();
|
||||||
|
if(!response.ok || !data.ok) throw new Error('Rating failed');
|
||||||
|
box.textContent = labels.thanks+' '+ '★'.repeat(stars);
|
||||||
|
}catch(error){
|
||||||
|
sending = false;
|
||||||
|
status.textContent = labels.failed;
|
||||||
|
Array.from(choices.children).forEach(function(item){ item.disabled = false; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
choices.appendChild(button);
|
||||||
|
});
|
||||||
|
container.appendChild(box);
|
||||||
|
return box;
|
||||||
|
},
|
||||||
|
initEmoji(button, panel, input, onInsert){
|
||||||
|
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
|
||||||
|
button.onclick = function(){
|
||||||
|
panel.hidden = !panel.hidden;
|
||||||
|
button.setAttribute('aria-expanded', String(!panel.hidden));
|
||||||
|
};
|
||||||
|
emojis.forEach(function(emoji){
|
||||||
|
const choice = document.createElement('button');
|
||||||
|
choice.type = 'button';
|
||||||
|
choice.textContent = emoji;
|
||||||
|
choice.onclick = function(){
|
||||||
|
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
|
||||||
|
const end = input.selectionEnd == null ? start : input.selectionEnd;
|
||||||
|
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
|
||||||
|
panel.hidden = true;
|
||||||
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||||
|
if(onInsert) onInsert();
|
||||||
|
};
|
||||||
|
panel.appendChild(choice);
|
||||||
|
});
|
||||||
|
panel.addEventListener('keydown', function(event){
|
||||||
|
if(event.key === 'Escape'){
|
||||||
|
panel.hidden = true;
|
||||||
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
button.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+85
-410
@@ -1,426 +1,101 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>柏悦物流 - 客服工作台</title>
|
<title>客服工作台 · 柏悦物流</title>
|
||||||
<style>
|
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
|
||||||
*{box-sizing:border-box;margin:0;padding:0;}
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||||
body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;height:100vh;display:flex;flex-direction:column;}
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||||
.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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<header class="topbar">
|
||||||
<div class="logo">🎧 柏悦客服工作台</div>
|
<div class="app-identity">
|
||||||
<div class="stats">
|
<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>对话 <b id="s1">-</b></span>
|
<span class="nav-divider" aria-hidden="true"></span><h1>客服工作台</h1>
|
||||||
<span>消息 <b id="s2">-</b></span>
|
|
||||||
<span>今日 <b id="s3">-</b></span>
|
|
||||||
<span>👍 <b id="s4">-</b></span>
|
|
||||||
<span>👎 <b id="s5">-</b></span>
|
|
||||||
<span>⭐ <b id="s6">-</b></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="status">
|
<div class="topbar-actions">
|
||||||
<span id="nightBadge" style="display:none;font-size:12px;background:#1e3a5f;color:#fbbf24;padding:3px 10px;border-radius:10px;margin-right:12px;">🌙 夜间时段</span>
|
<span id="nightBadge" class="night-badge" hidden>非工作时段</span>
|
||||||
<a href="/api/export_chat" style="font-size:13px;color:#2978e8;text-decoration:none;margin-right:16px;">导出Excel</a>
|
<button id="notifications-toggle" class="icon-button" type="button" aria-label="开启浏览器通知" title="开启浏览器通知" aria-pressed="false">🔔</button>
|
||||||
<span class="dot"></span> 在线
|
<a class="workspace-link" href="/">客户入口 ↗</a>
|
||||||
|
<details class="data-overview" id="data-overview">
|
||||||
|
<summary><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M4 4v16h17M8 16v-5M13 16V7M18 16V3"/></svg>数据概览</summary>
|
||||||
|
<div class="stats" aria-label="历史对话统计">
|
||||||
|
<h2>历史对话与反馈</h2>
|
||||||
|
<div class="stat"><span>历史对话</span><b id="s1">—</b></div>
|
||||||
|
<div class="stat"><span>历史消息</span><b id="s2">—</b></div>
|
||||||
|
<div class="stat"><span>今日新对话</span><b id="s3">—</b></div>
|
||||||
|
<div class="stat"><span>有帮助</span><b id="s4">—</b></div>
|
||||||
|
<div class="stat"><span>需改进</span><b id="s5">—</b></div>
|
||||||
|
<div class="stat"><span>服务评价</span><b id="s6">—</b></div>
|
||||||
|
<a class="export-link" href="/api/export_chat">导出 Excel ↗</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
<div class="layout">
|
|
||||||
<div class="conv-list">
|
|
||||||
<div style="padding:10px;border-bottom:1px solid #f0f0f0;">
|
|
||||||
<input id="searchBox" placeholder="搜索聊天记录..." style="width:100%;padding:6px 8px;border:1px solid #ddd;border-radius:4px;font-size:12px;box-sizing:border-box;" onkeydown="if(event.key==='Enter')doSearch()">
|
|
||||||
</div>
|
</div>
|
||||||
<div id="searchResult" style="position:fixed;top:70px;right:20px;background:#fff;border:2px solid #dc2626;border-radius:8px;max-height:500px;overflow-y:auto;z-index:9999;display:none;width:500px;box-shadow:0 4px 16px rgba(0,0,0,0.2);resize:both;overflow:auto;min-width:300px;min-height:200px;">
|
</header>
|
||||||
<button onclick="this.parentElement.style.display='none';document.getElementById('searchBox').value='';" style="position:absolute;top:8px;right:12px;border:none;background:#fff;color:#dc2626;border-radius:50%;width:30px;height:30px;font-size:16px;cursor:pointer;z-index:10;font-weight:bold;">✕</button>
|
<div id="sync-status" class="sync-status" role="status" hidden>会话连接中断,正在重试…</div>
|
||||||
<div id="searchHeader" style="padding:12px;background:#dc2626;color:#fff;font-weight:bold;cursor:move;">搜索结果</div>
|
<main class="layout">
|
||||||
<div id="searchBody"></div>
|
<aside class="conv-list" id="session-browser" aria-label="会话列表">
|
||||||
|
<div class="list-heading">
|
||||||
|
<h2>客户会话</h2><p>查看咨询,及时接待与回复</p>
|
||||||
|
<div class="session-search-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></svg><input id="session-search" type="search" aria-label="搜索客户会话" placeholder="搜索会话或消息" autocomplete="off"></div>
|
||||||
|
<button id="history-search-toggle" class="text-button" type="button">搜索历史记录 ↗</button>
|
||||||
</div>
|
</div>
|
||||||
<h3>待接入</h3>
|
<div class="session-tabs" role="tablist" aria-label="会话状态">
|
||||||
<div id="queue"></div>
|
<button id="tab-active" type="button" role="tab" aria-selected="true" aria-controls="sessionList">接待中 <span id="active-count" class="section-count">0</span></button>
|
||||||
<h3>进行中</h3>
|
<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 id="sessionList"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="session-scroll">
|
||||||
<div class="chat-area" id="chatArea">
|
<div id="sessionList" role="tabpanel" aria-labelledby="tab-active"><div class="list-empty">暂无进行中的会话</div></div>
|
||||||
<div class="empty" id="emptyTip">← 选择左侧对话开始服务</div>
|
<div id="queue" role="tabpanel" aria-labelledby="tab-queue" hidden><div class="list-empty">正在加载会话…</div></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>
|
|
||||||
<div style="display:flex;gap:8px;">
|
|
||||||
<button onclick="transferSession()" style="background:#f59e0b;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;">转接</button>
|
|
||||||
<button class="close" onclick="closeSession()">结束会话</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="list-footer"><span><span class="list-footer-mark" aria-hidden="true"></span>柏悦物流 · 客户服务</span><button id="tools-from-list" type="button" aria-controls="service-tools" aria-expanded="false">服务资料</button></div>
|
||||||
|
</aside>
|
||||||
|
<section class="chat-area" id="chatArea" aria-label="会话内容">
|
||||||
|
<header class="chat-header">
|
||||||
|
<button id="sessions-back" class="sessions-back" type="button" aria-label="返回会话列表">‹</button>
|
||||||
|
<div class="participant-avatar" aria-hidden="true">客</div>
|
||||||
|
<div class="participant-copy"><h2 id="chatTitle">会话接待</h2><p id="chat-subtitle">选择一段会话,开始回复客户</p></div>
|
||||||
|
<div class="chat-header-actions">
|
||||||
|
<button id="tools-toggle" class="tools-toggle" type="button" aria-label="资料与快捷回复" aria-controls="service-tools" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="M6 16a3 3 0 0 1 6 0M14 8h3M14 12h3M14 16h3"/></svg><span>资料与回复</span></button>
|
||||||
|
<button id="transfer-session" class="close" type="button" onclick="transferSession()">转接</button>
|
||||||
|
<button id="close-session" class="close" type="button" onclick="closeSession()">结束会话</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="chatBox"></div>
|
</header>
|
||||||
|
<div class="empty" id="emptyTip"><div>
|
||||||
|
<div class="empty-illustration"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div>
|
||||||
|
<h2>选择会话,开始接待</h2><p>在会话列表查看客户咨询,<br>新咨询可从「排队中」接入。</p>
|
||||||
|
</div></div>
|
||||||
|
<div id="chatContent" class="session-panel" style="display:none">
|
||||||
|
<div id="chatBox" role="log" aria-live="polite" aria-label="会话消息"></div>
|
||||||
|
<footer class="agent-composer">
|
||||||
|
<div class="composer-heading"><label for="replyText">回复客户</label><div class="emoji-control"><button id="agent-emoji-toggle" class="text-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="agent-emoji-panel">😊 表情</button><div id="agent-emoji-panel" class="emoji-picker" hidden></div></div><span>Shift + Enter 换行</span></div>
|
||||||
<div class="input-bar">
|
<div class="input-bar">
|
||||||
<button onclick="document.getElementById('emojiPanel2').style.display=document.getElementById('emojiPanel2').style.display==='block'?'none':'block'" style="font-size:20px;background:none;border:none;cursor:pointer;">😊</button>
|
<textarea id="replyText" rows="2" aria-label="回复客户" placeholder="输入回复内容,按 Enter 发送…"></textarea>
|
||||||
<div id="emojiPanel2" style="display:none;position:absolute;bottom:60px;left:10px;background:#fff;border:1px solid #ddd;border-radius:8px;padding:10px;box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:99;">
|
<div class="input-actions"><span>常用回复可在「资料与回复」中选择</span><button id="reply-send" type="button" onclick="sendReply()">发送</button></div>
|
||||||
<span onclick="insertEmoji2('😊')" style="cursor:pointer;font-size:20px;margin:2px;">😊</span>
|
|
||||||
<span onclick="insertEmoji2('😂')" style="cursor:pointer;font-size:20px;margin:2px;">😂</span>
|
|
||||||
<span onclick="insertEmoji2('👍')" style="cursor:pointer;font-size:20px;margin:2px;">👍</span>
|
|
||||||
<span onclick="insertEmoji2('❤️')" style="cursor:pointer;font-size:20px;margin:2px;">❤️</span>
|
|
||||||
<span onclick="insertEmoji2('🙏')" style="cursor:pointer;font-size:20px;margin:2px;">🙏</span>
|
|
||||||
<span onclick="insertEmoji2('😅')" style="cursor:pointer;font-size:20px;margin:2px;">😅</span>
|
|
||||||
<span onclick="insertEmoji2('😢')" style="cursor:pointer;font-size:20px;margin:2px;">😢</span>
|
|
||||||
<span onclick="insertEmoji2('🎉')" style="cursor:pointer;font-size:20px;margin:2px;">🎉</span>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="replyText" placeholder="输入回复,Enter发送">
|
<p id="reply-status" class="reply-status" role="status" hidden></p>
|
||||||
<button onclick="sendReply()">发送</button>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
<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>
|
||||||
<div class="side">
|
<section class="card"><h3>客户信息</h3><div class="info-row" id="cInfo">选择会话后查看客户信息</div><p class="info-hint">咨询运费时,可先确认运单号、货物重量、尺寸与目的地。</p></section>
|
||||||
<div class="card">
|
<section class="card"><h3><label for="noteInput">会话备注</label></h3><textarea id="noteInput" class="note-input" rows="3" placeholder="填写本次会话的跟进事项" disabled></textarea><button id="note-save" type="button" class="quick" disabled>保存备注</button></section>
|
||||||
<h4>客户信息</h4>
|
<section class="card"><h3>常用回复</h3>
|
||||||
<div class="info-row" id="cInfo">选择对话后显示</div>
|
<p class="card-caption">点击填入草稿,编辑后再发送</p>
|
||||||
</div>
|
<button type="button" class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,有什么可以帮您?</button>
|
||||||
<div class="card">
|
<button type="button" class="quick" onclick="quickReply('请提供运单号,我帮您查询。')">请提供运单号,我帮您查询</button>
|
||||||
<h4>会话备注</h4>
|
<button type="button" class="quick" onclick="quickReply('已为您催促,请稍候。')">已为您催促,请稍候</button>
|
||||||
<textarea id="noteInput" placeholder="给这个对话加备注..." style="width:100%;height:60px;border:1px solid #ddd;border-radius:4px;padding:6px;font-size:12px;resize:none;box-sizing:border-box;"></textarea>
|
<button type="button" class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询,祝您生活愉快</button>
|
||||||
<button onclick="saveNote()" style="width:100%;margin-top:6px;background:#2978e8;color:#fff;border:none;padding:5px;border-radius:4px;cursor:pointer;font-size:12px;">保存备注</button>
|
</section>
|
||||||
</div>
|
<section class="card"><h3>离线留言</h3><div id="ticketList"><div class="list-empty">正在加载留言…</div></div></section>
|
||||||
<div class="card">
|
</aside>
|
||||||
<h4>快捷回复</h4>
|
</main>
|
||||||
<div class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,请问有什么可以帮您?</div>
|
<button class="tools-backdrop" id="tools-backdrop" type="button" aria-label="关闭服务资料"></button>
|
||||||
<div class="quick" onclick="quickReply('请提供运单号,我帮您查询。')">请提供运单号</div>
|
<dialog id="searchResult" class="search-dialog" aria-labelledby="search-title"><header><h2 id="search-title">搜索历史记录</h2><button id="search-close" class="icon-button" type="button" aria-label="关闭搜索">×</button></header><form id="history-search-form"><label class="sr-only" for="searchBox">历史消息关键词</label><input id="searchBox" type="search" placeholder="输入关键词搜索聊天记录" required><button type="submit" class="quick">搜索</button></form><div id="searchBody" aria-live="polite">请输入关键词搜索历史消息</div></dialog>
|
||||||
<div class="quick" onclick="quickReply('已为您催促,请稍候。')">已催促,请稍候</div>
|
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
|
||||||
<div class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询</div>
|
<script src="{{ url_for('static', filename='js/admin_tools.js') }}"></script>
|
||||||
</div>
|
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
|
||||||
<div class="card">
|
|
||||||
<h4>离线留言</h4>
|
|
||||||
<div id="ticketList"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var curSid = 0;
|
|
||||||
var sessions = {};
|
|
||||||
|
|
||||||
// 浏览器通知权限
|
|
||||||
if("Notification" in window && Notification.permission === "default"){
|
|
||||||
Notification.requestPermission();
|
|
||||||
}
|
|
||||||
function notify(title, body){
|
|
||||||
if(!("Notification" in window) || Notification.permission !== "granted") return;
|
|
||||||
if(!document.hidden) return; // 页面可见时不弹
|
|
||||||
var n = new Notification(title, {body: body, icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎧</text></svg>"});
|
|
||||||
n.onclick = function(){ window.focus(); n.close(); };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 夜间时段提示(22:00-8:00)
|
|
||||||
(function checkNight(){
|
|
||||||
var h = new Date().getHours();
|
|
||||||
var badge = document.getElementById("nightBadge");
|
|
||||||
if(h >= 22 || h < 8){ badge.style.display = "inline-block"; }
|
|
||||||
setInterval(checkNight, 60000);
|
|
||||||
})();
|
|
||||||
|
|
||||||
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;
|
|
||||||
s6.textContent=d.rating_avg+"("+d.rating_count+")";
|
|
||||||
});
|
|
||||||
|
|
||||||
// 拖拽搜索弹窗
|
|
||||||
(function(){
|
|
||||||
var header = document.getElementById("searchHeader");
|
|
||||||
var box = document.getElementById("searchResult");
|
|
||||||
var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
|
|
||||||
header.onmousedown = dragMouseDown;
|
|
||||||
function dragMouseDown(e){
|
|
||||||
e = e || window.event;
|
|
||||||
e.preventDefault();
|
|
||||||
pos3 = e.clientX;
|
|
||||||
pos4 = e.clientY;
|
|
||||||
document.onmouseup = closeDragElement;
|
|
||||||
document.onmousemove = elementDrag;
|
|
||||||
}
|
|
||||||
function elementDrag(e){
|
|
||||||
e = e || window.event;
|
|
||||||
e.preventDefault();
|
|
||||||
pos1 = pos3 - e.clientX;
|
|
||||||
pos2 = pos4 - e.clientY;
|
|
||||||
pos3 = e.clientX;
|
|
||||||
pos4 = e.clientY;
|
|
||||||
box.style.right = "auto";
|
|
||||||
box.style.left = (box.offsetLeft - pos1) + "px";
|
|
||||||
box.style.top = (box.offsetTop - pos2) + "px";
|
|
||||||
}
|
|
||||||
function closeDragElement(){
|
|
||||||
document.onmouseup = null;
|
|
||||||
document.onmousemove = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
function doSearch(){
|
|
||||||
var q = document.getElementById("searchBox").value.trim();
|
|
||||||
if(!q) return;
|
|
||||||
fetch("/api/search_chat?q="+encodeURIComponent(q)).then(function(r){return r.json()}).then(function(list){
|
|
||||||
document.getElementById("searchResult").style.display = "block";
|
|
||||||
var body = document.getElementById("searchBody");
|
|
||||||
if(!list.length){ body.innerHTML = '<div style="padding:10px;color:#999;">无结果</div>'; return; }
|
|
||||||
body.innerHTML = list.map(function(r){
|
|
||||||
return '<div style="padding:8px 12px;border-bottom:1px solid #f0f0f0;font-size:12px;"><b>'+r.title+'</b> ['+r.role+']<br>'+r.content+'<br><span style="color:#999;">'+r.ctime+'</span></div>';
|
|
||||||
}).join("");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadQueue(){
|
|
||||||
fetch("/api/human/queue").then(function(r){return r.json()}).then(function(list){
|
|
||||||
var prevCount = document.getElementById("queue").dataset.count || 0;
|
|
||||||
var curCount = list.length;
|
|
||||||
document.getElementById("queue").dataset.count = curCount;
|
|
||||||
if(curCount > prevCount && document.hidden){
|
|
||||||
notify("新客户排队", "有 " + curCount + " 位客户等待接入");
|
|
||||||
}
|
|
||||||
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>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
// 从后端拉备注
|
|
||||||
document.getElementById("noteInput").value = "加载中...";
|
|
||||||
fetch("/api/human/poll?sid="+parseInt(sid)+"&after=0").then(function(r){return r.json()}).then(function(d){
|
|
||||||
document.getElementById("noteInput").value = d.note || "";
|
|
||||||
});
|
|
||||||
fetch("/api/human/read",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(sid)})});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveNote(){
|
|
||||||
if(!curSid){ alert("请先选择一个对话"); return; }
|
|
||||||
var note = document.getElementById("noteInput").value;
|
|
||||||
sessions[curSid].note = note;
|
|
||||||
fetch("/api/human/note",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid), note:note})}).then(function(){
|
|
||||||
var btn = event.target;
|
|
||||||
var old = btn.textContent;
|
|
||||||
btn.textContent = "已保存 ✓";
|
|
||||||
btn.style.background = "#22c55e";
|
|
||||||
setTimeout(function(){ btn.textContent = old; btn.style.background = "#2978e8"; }, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendMsg(m){
|
|
||||||
var div=document.createElement("div");
|
|
||||||
if(m.side==="system"){
|
|
||||||
div.className="msg system-msg";
|
|
||||||
div.innerHTML='<div style="text-align:center;font-size:12px;color:#999;padding:4px 0;">'+m.content+'</div>';
|
|
||||||
document.getElementById("chatBox").appendChild(div);
|
|
||||||
document.getElementById("chatBox").scrollTop=999999;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
div.className="msg "+(m.side==="customer"?"cust":"agent");
|
|
||||||
var c = m.content.replace(/</g,"<");
|
|
||||||
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;">');
|
|
||||||
}
|
|
||||||
var recallBtn = m.side==="agent" ? '<span onclick="recallMsg('+m.id+',this)" style="font-size:11px;color:#999;cursor:pointer;margin-left:6px;">撤回</span>' : '';
|
|
||||||
div.innerHTML='<div><div class="bubble">'+c+'</div><div class="meta">'+(m.side==="customer"?"客户":"客服")+recallBtn+'</div></div>';
|
|
||||||
document.getElementById("chatBox").appendChild(div);
|
|
||||||
document.getElementById("chatBox").scrollTop=999999;
|
|
||||||
}
|
|
||||||
|
|
||||||
function recallMsg(mid, el){
|
|
||||||
fetch("/api/human/recall",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({mid:mid})}).then(function(){
|
|
||||||
var msgDiv = el.closest(".msg");
|
|
||||||
if(msgDiv){
|
|
||||||
var bubble = msgDiv.querySelector(".bubble");
|
|
||||||
if(bubble) bubble.innerHTML = "";
|
|
||||||
var meta = msgDiv.querySelector(".meta");
|
|
||||||
if(meta) meta.innerHTML = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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(m.side==="customer" && document.hidden){
|
|
||||||
notify("客户消息 #"+sid, m.content.substring(0, 50));
|
|
||||||
}
|
|
||||||
if(sid==curSid) appendMsg(m);
|
|
||||||
});
|
|
||||||
}).catch(function(){});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setInterval(poll, 1000);
|
|
||||||
|
|
||||||
function insertEmoji2(e){
|
|
||||||
document.getElementById("replyText").value += e;
|
|
||||||
document.getElementById("replyText").focus();
|
|
||||||
document.getElementById("emojiPanel2").style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
function transferSession(){
|
|
||||||
if(!curSid) return;
|
|
||||||
if(!confirm("确定转接这个对话?转接后其他客服可在排队列表接入。")) return;
|
|
||||||
fetch("/api/human/transfer",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})}).then(function(){
|
|
||||||
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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+35
-28
@@ -1,33 +1,40 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>登录 - 客服后台</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<style>
|
<title>登录 · 柏悦客服工作台</title>
|
||||||
body{font-family:sans-serif;background:#f0f2f5;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;}
|
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
|
||||||
.box{background:#fff;padding:40px;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,0.1);width:320px;}
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||||
h2{text-align:center;color:#18479c;margin-bottom:24px;}
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin_login.css') }}">
|
||||||
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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="auth-page">
|
||||||
<div class="box">
|
<main class="auth-shell">
|
||||||
<h2>🔒 客服后台登录</h2>
|
<section class="auth-story" aria-label="柏悦物流客服中心">
|
||||||
<input type="text" id="user" placeholder="账号">
|
<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>
|
||||||
<input type="password" id="pwd" placeholder="密码">
|
<div class="story-copy"><div class="eyebrow">CONNECT EVERY JOURNEY</div><h1>让每一程物流,<br>都有及时的回应。</h1><p>从咨询到交付,把客户沟通集中在一个工作台,让服务始终有条不紊。</p></div>
|
||||||
<p class="err" id="err">账号或密码错误</p>
|
<div class="service-points">
|
||||||
<button onclick="login()">登录</button>
|
<div class="service-point"><span aria-hidden="true">01</span><div><strong>会话集中管理</strong><p>接待客户咨询,查看历史沟通内容</p></div></div>
|
||||||
</div>
|
<div class="service-point"><span aria-hidden="true">02</span><div><strong>人工与智能协作</strong><p>衔接处理报价、轨迹与物流异常咨询</p></div></div>
|
||||||
<script>
|
<div class="service-point"><span aria-hidden="true">03</span><div><strong>消息及时流转</strong><p>关注未读会话,跟进离线留言</p></div></div>
|
||||||
function login(){
|
</div>
|
||||||
var u=document.getElementById("user").value;
|
<div class="route-line"><span>起点</span><i aria-hidden="true"></i><span>运输途中</span><i aria-hidden="true"></i><span>每一次抵达</span></div>
|
||||||
var p=document.getElementById("pwd").value;
|
</section>
|
||||||
fetch("/admin/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})})
|
<section class="auth-content">
|
||||||
.then(function(r){if(r.ok){location.href="/admin/dashboard"}else{document.getElementById("err").style.display="block"}});
|
<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">
|
||||||
document.getElementById("pwd").addEventListener("keydown",function(e){if(e.key==="Enter")login()});
|
<div class="eyebrow">客服中心</div><h2>欢迎回来</h2><p class="login-description">请使用您的客服账号,登录柏悦物流工作台。</p>
|
||||||
</script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+52
-2612
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../../static/js/admin.js'), 'utf8');
|
||||||
|
const sharedSource = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8');
|
||||||
|
const toolsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tools.js'), 'utf8');
|
||||||
|
const flush = () => new Promise(setImmediate);
|
||||||
|
|
||||||
|
function response(data, status = 200){
|
||||||
|
return {ok: status >= 200 && status < 300, status, json: async () => data};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workspace(compact = false){
|
||||||
|
const elements = new Map();
|
||||||
|
const documentListeners = {};
|
||||||
|
let document;
|
||||||
|
class Element {
|
||||||
|
constructor(){
|
||||||
|
this.value = ''; this.children = []; this.attributes = {}; this.listeners = {};
|
||||||
|
this.style = {}; this.hidden = false; this.scrollHeight = 70;
|
||||||
|
this.scrollTop = 0; this.clientHeight = 300;
|
||||||
|
const classes = new Set();
|
||||||
|
this.classList = {
|
||||||
|
toggle: (name, enabled) => { enabled ? classes.add(name) : classes.delete(name); },
|
||||||
|
contains: name => classes.has(name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
set innerHTML(value){ this.html = value; this.children = []; }
|
||||||
|
get innerHTML(){ return this.html || ''; }
|
||||||
|
setAttribute(name, value){ this.attributes[name] = value; }
|
||||||
|
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
|
||||||
|
appendChild(element){ this.children.push(element); }
|
||||||
|
addEventListener(name, callback){ this.listeners[name] = callback; }
|
||||||
|
querySelectorAll(){ return []; }
|
||||||
|
querySelector(selector){
|
||||||
|
if(selector === '.meta') return this.meta || (this.meta = new Element());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setSelectionRange(start, end){ this.selectionStart = start; this.selectionEnd = end; }
|
||||||
|
showModal(){ this.open = true; }
|
||||||
|
close(){ this.open = false; }
|
||||||
|
focus(){ document.activeElement = this; }
|
||||||
|
click(){ if(this.onclick) return this.onclick(); }
|
||||||
|
}
|
||||||
|
const quickButtons = [new Element(), new Element()];
|
||||||
|
document = {
|
||||||
|
body: new Element(), activeElement: null,
|
||||||
|
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
|
||||||
|
createElement(){ return new Element(); },
|
||||||
|
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
|
||||||
|
querySelector(selector){
|
||||||
|
const match = selector.match(/data-message-id="(\d+)"/);
|
||||||
|
if(!match) return null;
|
||||||
|
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
|
||||||
|
},
|
||||||
|
addEventListener(name, callback){ documentListeners[name] = callback; },
|
||||||
|
};
|
||||||
|
const calls = [];
|
||||||
|
const hooks = {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
document, console,
|
||||||
|
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
|
||||||
|
setInterval(){}, setTimeout(){}, clearTimeout(){},
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
const body = options && options.body ? JSON.parse(options.body) : undefined;
|
||||||
|
calls.push({url, body});
|
||||||
|
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
|
||||||
|
if(url === '/api/human/close' && hooks.close) return hooks.close(body);
|
||||||
|
if(url === '/api/human/transfer' && hooks.transfer) return hooks.transfer(body);
|
||||||
|
if(url === '/api/human/note' && hooks.note) return hooks.note(body);
|
||||||
|
if(url === '/api/human/queue' && hooks.queue) return hooks.queue();
|
||||||
|
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
|
||||||
|
if(url.includes('/api/search_chat') && hooks.search) return hooks.search(url);
|
||||||
|
if(url.includes('/api/human/poll')) return response({status:'active', messages:[]});
|
||||||
|
if(url === '/api/admin/stats') return response({total_conv:0, total_msg:0, today_conv:0, good:0, bad:0});
|
||||||
|
return response(options ? {ok:true} : []);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(sharedSource, context, {filename:'support.js'});
|
||||||
|
vm.runInContext(toolsSource, context, {filename:'admin_tools.js'});
|
||||||
|
vm.runInContext(source, context, {filename:'admin.js'});
|
||||||
|
await flush();
|
||||||
|
await context.accept(7);
|
||||||
|
await flush();
|
||||||
|
return {context, element: id => document.getElementById(id), calls, hooks, document, documentListeners};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('failed replies preserve the draft and make retry available', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
app.hooks.send = () => response({ok:false}, 503);
|
||||||
|
app.element('replyText').value = '请确认这批货物的外箱尺寸';
|
||||||
|
await app.context.sendReply();
|
||||||
|
assert.equal(app.element('replyText').value, '请确认这批货物的外箱尺寸');
|
||||||
|
assert.equal(app.element('reply-send').disabled, false);
|
||||||
|
assert.match(app.element('reply-status').textContent, /内容已保留/);
|
||||||
|
app.hooks.send = () => response({ok:true});
|
||||||
|
await app.context.sendReply();
|
||||||
|
assert.equal(app.element('replyText').value, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second send click cannot duplicate an in-flight reply', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
let resolve;
|
||||||
|
app.hooks.send = () => new Promise(done => { resolve = done; });
|
||||||
|
app.element('replyText').value = '正在确认线路';
|
||||||
|
const first = app.context.sendReply();
|
||||||
|
await app.context.sendReply();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
|
||||||
|
assert.equal(app.element('reply-send').disabled, true);
|
||||||
|
resolve(response({ok:true}));
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new text typed while a reply is sending is retained', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
let resolve;
|
||||||
|
app.hooks.send = () => new Promise(done => { resolve = done; });
|
||||||
|
const input = app.element('replyText');
|
||||||
|
input.value = '第一条回复';
|
||||||
|
const sending = app.context.sendReply();
|
||||||
|
input.value = '继续补充的第二条回复';
|
||||||
|
input.listeners.input();
|
||||||
|
resolve(response({ok:true}));
|
||||||
|
await sending;
|
||||||
|
assert.equal(input.value, '继续补充的第二条回复');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching sessions during a send does not erase another customer draft', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
await app.context.accept(8);
|
||||||
|
app.element('replyText').value = '客户 8 的草稿';
|
||||||
|
app.context.switchSession('7');
|
||||||
|
app.element('replyText').value = '发给客户 7';
|
||||||
|
let resolve;
|
||||||
|
app.hooks.send = () => new Promise(done => { resolve = done; });
|
||||||
|
const sending = app.context.sendReply();
|
||||||
|
app.context.switchSession('8');
|
||||||
|
resolve(response({ok:true}));
|
||||||
|
await sending;
|
||||||
|
assert.equal(app.context.curSid, '8');
|
||||||
|
assert.equal(app.element('replyText').value, '客户 8 的草稿');
|
||||||
|
app.context.switchSession('7');
|
||||||
|
assert.equal(app.element('replyText').value, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quick replies become editable drafts before sending', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
app.context.quickReply('请提供运单号');
|
||||||
|
assert.equal(app.element('replyText').value, '请提供运单号');
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
|
||||||
|
assert.match(app.element('reply-status').textContent, /可编辑后发送/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed close keeps the selected session and reply visible', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
app.hooks.close = () => response({ok:false}, 503);
|
||||||
|
await app.context.closeSession();
|
||||||
|
assert.equal(app.context.curSid, '7');
|
||||||
|
assert.equal(app.element('chatContent').style.display, 'flex');
|
||||||
|
assert.match(app.element('reply-status').textContent, /会话仍保留/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IME confirmation and Shift+Enter do not submit a reply', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
app.element('replyText').value = '正在输入中文';
|
||||||
|
const onKey = app.element('replyText').listeners.keydown;
|
||||||
|
const preventDefault = () => {};
|
||||||
|
onKey({key:'Enter', isComposing:true, shiftKey:false, preventDefault});
|
||||||
|
onKey({key:'Enter', isComposing:false, shiftKey:true, preventDefault});
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
|
||||||
|
onKey({key:'Enter', isComposing:false, shiftKey:false, preventDefault});
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the compact service drawer is keyboard accessible and closes on Escape', async () => {
|
||||||
|
const app = await workspace(true);
|
||||||
|
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
|
||||||
|
app.context.setToolsOpen(true);
|
||||||
|
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
|
||||||
|
assert.equal(app.document.activeElement, app.element('tools-close'));
|
||||||
|
app.documentListeners.keydown({key:'Escape'});
|
||||||
|
assert.equal(app.element('tools-toggle').attributes['aria-expanded'], 'false');
|
||||||
|
assert.equal(app.document.activeElement, app.element('tools-toggle'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop service information starts closed and can be toggled on demand', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
|
||||||
|
app.context.setToolsOpen(true);
|
||||||
|
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
|
||||||
|
assert.equal(app.element('tools-toggle').attributes['aria-expanded'], 'true');
|
||||||
|
app.context.setToolsOpen(false);
|
||||||
|
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returning to the mobile session list keeps the selected session and draft', async () => {
|
||||||
|
const app = await workspace(true);
|
||||||
|
app.element('replyText').value = '返回列表前的草稿';
|
||||||
|
app.context.showSessionList();
|
||||||
|
assert.equal(app.context.curSid, '7');
|
||||||
|
assert.equal(app.document.body.classList.contains('chat-view'), false);
|
||||||
|
assert.equal(app.document.activeElement, app.element('tab-active'));
|
||||||
|
assert.equal(app.element('replyText').value, '返回列表前的草稿');
|
||||||
|
app.context.switchSession('7');
|
||||||
|
assert.equal(app.document.body.classList.contains('chat-view'), true);
|
||||||
|
assert.equal(app.element('replyText').value, '返回列表前的草稿');
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile operators can open service information without selecting a conversation', async () => {
|
||||||
|
const app = await workspace(true);
|
||||||
|
app.context.showEmptySession();
|
||||||
|
app.element('tools-from-list').click();
|
||||||
|
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
|
||||||
|
app.element('tools-close').click();
|
||||||
|
assert.equal(app.document.activeElement, app.element('tools-from-list'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile drafts are measured after the chat view becomes visible', async () => {
|
||||||
|
const app = await workspace(true);
|
||||||
|
const input = app.element('replyText');
|
||||||
|
Object.defineProperty(input, 'scrollHeight', {
|
||||||
|
get: () => app.document.body.classList.contains('chat-view') ? 128 : 0,
|
||||||
|
});
|
||||||
|
input.value = '需要恢复的多行草稿';
|
||||||
|
app.context.showSessionList();
|
||||||
|
app.context.switchSession('7');
|
||||||
|
assert.equal(input.style.height, '128px');
|
||||||
|
assert.equal(input.value, '需要恢复的多行草稿');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transfer returns a conversation to the queue without auto-accepting it again', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
app.hooks.queue = () => response([{id:7, ctime:'now'}]);
|
||||||
|
await app.context.transferSession();
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.context.curSid, 0);
|
||||||
|
assert.equal(app.context.sessions['7'], undefined);
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/accept').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed transfers retain the conversation and draft', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
app.hooks.transfer = () => response({ok:false}, 503);
|
||||||
|
app.element('replyText').value = '等待确认的草稿';
|
||||||
|
await app.context.transferSession();
|
||||||
|
assert.equal(app.context.curSid, '7');
|
||||||
|
assert.equal(app.element('replyText').value, '等待确认的草稿');
|
||||||
|
assert.match(app.element('reply-status').textContent, /转接未成功/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a late note response cannot overwrite a different session note', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
app.context.sessions['7'].noteLoaded = false;
|
||||||
|
let resolve;
|
||||||
|
app.hooks.poll = url => url.includes('sid=7&') ? new Promise(done => { resolve = done; }) : response({status:'active', note:'客户 8 的备注', messages:[]});
|
||||||
|
const first = app.context.loadSessionNote('7');
|
||||||
|
app.context.sessions['8'] = {lastId:0, msgs:[], unread:0};
|
||||||
|
app.context.switchSession('8');
|
||||||
|
await flush();
|
||||||
|
resolve(response({status:'active', note:'客户 7 的备注', messages:[]}));
|
||||||
|
await first;
|
||||||
|
assert.equal(app.element('noteInput').value, '客户 8 的备注');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed note saves keep the edited note available', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
app.hooks.note = () => response({ok:false}, 503);
|
||||||
|
app.element('noteInput').value = '下个班次继续跟进';
|
||||||
|
await app.context.saveNote();
|
||||||
|
assert.equal(app.element('noteInput').value, '下个班次继续跟进');
|
||||||
|
assert.match(app.element('reply-status').textContent, /备注保存失败/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recalling an older reply leaves the newest reply intact', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
app.context.sessions['7'].msgs = [
|
||||||
|
{id:1, side:'agent', content:'较早回复'}, {id:2, side:'agent', content:'最新回复'},
|
||||||
|
];
|
||||||
|
app.context.sessions['7'].msgs.forEach(app.context.appendMsg);
|
||||||
|
await app.context.recallMsg(1);
|
||||||
|
assert.equal(app.context.sessions['7'].msgs[0].content, '该消息已撤回');
|
||||||
|
assert.equal(app.context.sessions['7'].msgs[1].content, '最新回复');
|
||||||
|
assert.equal(app.element('chatBox').children[0].textContent, '该消息已撤回');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historical search renders customer text without interpreting HTML', async () => {
|
||||||
|
const app = await workspace();
|
||||||
|
app.hooks.search = () => response([{title:'<img onerror=bad()>', content:'<script>bad()</script>', role:'user', ctime:'now'}]);
|
||||||
|
app.element('searchBox').value = '物流';
|
||||||
|
await app.context.doSearch();
|
||||||
|
assert.match(app.element('searchBody').innerHTML, /<script>/);
|
||||||
|
assert.doesNotMatch(app.element('searchBody').innerHTML, /<script>/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const flush = () => new Promise(setImmediate);
|
||||||
|
|
||||||
|
async function customerPage(pageOptions = {}){
|
||||||
|
const elements = new Map(), listeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
|
||||||
|
let intervalId = 0, timerId = 0, nextConvId = 1;
|
||||||
|
class Element {
|
||||||
|
constructor(){
|
||||||
|
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
|
||||||
|
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300;
|
||||||
|
this.listeners = {};
|
||||||
|
this.classList = {
|
||||||
|
contains: name => this.className.split(' ').includes(name),
|
||||||
|
add: name => { this.className += ' '+name; },
|
||||||
|
remove: name => { this.className = this.className.split(' ').filter(value => value !== name).join(' '); },
|
||||||
|
toggle: (name, on) => { this.classList.remove(name); if(on) this.classList.add(name); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
set innerHTML(value){ this.html = value; this.children = []; }
|
||||||
|
get innerHTML(){ return this.html || ''; }
|
||||||
|
set textContent(value){ this.text = value; this.html = ''; this.children = []; }
|
||||||
|
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
|
||||||
|
setAttribute(name, value){ this.attributes[name] = value; }
|
||||||
|
removeAttribute(name){ delete this.attributes[name]; }
|
||||||
|
getAttribute(name){ return this.attributes[name]; }
|
||||||
|
addEventListener(name, callback){ this.listeners[name] = callback; }
|
||||||
|
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
|
||||||
|
appendChild(child){ child.parent = this; this.children.push(child); }
|
||||||
|
append(...children){ children.forEach(child => this.appendChild(child)); }
|
||||||
|
focus(){}
|
||||||
|
querySelector(selector){
|
||||||
|
return this.querySelectorAll(selector)[0] || null;
|
||||||
|
}
|
||||||
|
querySelectorAll(selector){
|
||||||
|
const matches = element => {
|
||||||
|
if(selector.startsWith('.')) return element.classList.contains(selector.slice(1));
|
||||||
|
const id = selector.match(/data-human-message-id="(\d+)"/);
|
||||||
|
return id && element.attributes['data-human-message-id'] === id[1];
|
||||||
|
};
|
||||||
|
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
|
||||||
|
}
|
||||||
|
closest(selector){
|
||||||
|
if(selector === 'button[data-question]' && this.dataset.question) return this;
|
||||||
|
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
|
||||||
|
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
|
||||||
|
}
|
||||||
|
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
|
||||||
|
}
|
||||||
|
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
|
||||||
|
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
|
||||||
|
const document = {
|
||||||
|
body: new Element(), documentElement: {},
|
||||||
|
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
|
||||||
|
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
|
||||||
|
createElement: () => new Element(),
|
||||||
|
addEventListener: (name, callback) => { listeners[name] = callback; },
|
||||||
|
};
|
||||||
|
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
|
||||||
|
let state = {status:'active', messages:[]};
|
||||||
|
const context = vm.createContext({
|
||||||
|
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true,
|
||||||
|
FormData:class { append(){} },
|
||||||
|
requestAnimationFrame:callback => callback(),
|
||||||
|
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
|
||||||
|
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false}), prompt:() => pageOptions.offlineMessage || null},
|
||||||
|
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
|
||||||
|
clearInterval:id => intervals.delete(id),
|
||||||
|
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; },
|
||||||
|
clearTimeout:id => timers.delete(id),
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
|
||||||
|
if(hooks[url]) return hooks[url](options);
|
||||||
|
if(url.startsWith('/api/human/poll') && hooks.poll) return hooks.poll(url);
|
||||||
|
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
|
||||||
|
if(url === '/stream_chat'){
|
||||||
|
let read = false;
|
||||||
|
return {ok:true, body:{getReader:() => ({read:async () => {
|
||||||
|
if(read) return {done:true};
|
||||||
|
read = true;
|
||||||
|
return {value:new TextEncoder().encode('data: {"t":"测试 AI 回复"}\n\ndata: {"d":1}\n\n'), done:false};
|
||||||
|
}})}};
|
||||||
|
}
|
||||||
|
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
|
||||||
|
: url === '/api/human/request' ? {ok:true, session_id:7}
|
||||||
|
: url.startsWith('/api/human/poll') ? state
|
||||||
|
: ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
|
||||||
|
: url === '/api/get_history' ? (pageOptions.history || []) : [];
|
||||||
|
return {ok:true, json:async () => data};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for(const file of ['support.js', 'chat.js']){
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
|
||||||
|
}
|
||||||
|
listeners.DOMContentLoaded();
|
||||||
|
await flush();
|
||||||
|
if(pageOptions.human !== false) await context.window.showHuman();
|
||||||
|
await flush();
|
||||||
|
return {context, element, calls, hooks, quickButtons,
|
||||||
|
setState:value => { state=value; },
|
||||||
|
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
|
||||||
|
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
|
||||||
|
click:async button => { await button.click(); await flush(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the final customer-visible reply is processed before closure and a rating can be submitted', async () => {
|
||||||
|
const app = await customerPage();
|
||||||
|
app.setState({status:'closed', messages:[{id:1, side:'agent', content:'最后一条回复'}]});
|
||||||
|
await app.tick();
|
||||||
|
const log = app.element('chatDom');
|
||||||
|
assert.match(log.textContent, /最后一条回复/);
|
||||||
|
assert.match(log.textContent, /人工客服已结束/);
|
||||||
|
const rating = log.querySelector('.service-rating');
|
||||||
|
assert.ok(rating);
|
||||||
|
await rating.querySelector('.rating-choices').children[4].onclick();
|
||||||
|
assert.match(rating.textContent, /感谢您的评价/);
|
||||||
|
assert.equal(app.calls.find(call => call.url === '/api/rate').body.stars, 5);
|
||||||
|
assert.equal(app.context.window.humanSid, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function streamResponse(chunks){
|
||||||
|
let index=0;
|
||||||
|
return {ok:true, body:{getReader:() => ({read:async () => index < chunks.length
|
||||||
|
? {done:false, value:typeof chunks[index] === 'string' ? new TextEncoder().encode(chunks[index++]) : chunks[index++]}
|
||||||
|
: {done:true}})}};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('AI send preserves a newer draft typed while the conversation is being created', async () => {
|
||||||
|
const app=await customerPage({human:false});
|
||||||
|
let resolve;
|
||||||
|
app.hooks['/api/new_chat']=() => new Promise(done => { resolve=done; });
|
||||||
|
const sending=app.context.window.fillInput('第一条问题');
|
||||||
|
app.element('input-text').value='正在补充的下一条问题';
|
||||||
|
resolve(response({id:18,welcome:'welcome'}));
|
||||||
|
await sending;
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '第一条问题');
|
||||||
|
assert.equal(app.element('input-text').value, '正在补充的下一条问题');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching to a quick topic discards the previous topic attachment', async () => {
|
||||||
|
const app=await customerPage({human:false});
|
||||||
|
app.context.window.__pendingImg='/uploads/20261029100145_6787.txt';
|
||||||
|
app.context.window.__pendingTag='[文件]';
|
||||||
|
await app.click(app.quickButtons[1]);
|
||||||
|
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => {
|
||||||
|
const app=await customerPage({human:false});
|
||||||
|
const bytes=new TextEncoder().encode('data: {"t":"完整中文回复"}\r\n\r\ndata: {"d":1}\r\n\r\n');
|
||||||
|
const chunks=Array.from({length:Math.ceil(bytes.length/5)}, (_,index) => bytes.slice(index*5,index*5+5));
|
||||||
|
app.hooks['/stream_chat']=() => streamResponse(chunks);
|
||||||
|
await app.context.window.fillInput('请回复');
|
||||||
|
await flush();
|
||||||
|
assert.match(app.element('chatDom').textContent, /完整中文回复/);
|
||||||
|
assert.equal(app.element('chatDom').querySelector('.retry-btn'), null);
|
||||||
|
assert.equal(app.context.window.__busy, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stream ending without completion retains partial text and offers retry', async () => {
|
||||||
|
const app=await customerPage({human:false});
|
||||||
|
app.hooks['/stream_chat']=() => streamResponse(['data: {"t":"已经收到的部分回答"}\n\n']);
|
||||||
|
await app.context.window.fillInput('请继续说明');
|
||||||
|
await flush();
|
||||||
|
assert.match(app.element('chatDom').textContent, /已经收到的部分回答/);
|
||||||
|
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
|
||||||
|
assert.equal(app.context.window.__busy, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rate limit responses show the server message and allow retry', async () => {
|
||||||
|
const app=await customerPage({human:false});
|
||||||
|
app.hooks['/stream_chat']=() => response({e:'请求太频繁,请稍后再试'},429);
|
||||||
|
await app.context.window.fillInput('请查单');
|
||||||
|
await flush();
|
||||||
|
assert.match(app.element('chatDom').textContent, /请求太频繁/);
|
||||||
|
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stalled conversation creation times out and can be retried without losing the draft', async () => {
|
||||||
|
const app=await customerPage({human:false});
|
||||||
|
app.hooks['/api/new_chat']=options => new Promise((resolve,reject) => {
|
||||||
|
if(options.signal) options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'), {name:'AbortError'})));
|
||||||
|
});
|
||||||
|
app.context.window.fillInput('请帮我查询');
|
||||||
|
await app.runTimers(10000);
|
||||||
|
assert.match(app.element('composer-status').textContent, /会话创建失败/);
|
||||||
|
assert.equal(app.element('input-text').value, '请帮我查询');
|
||||||
|
app.hooks['/api/new_chat']=() => response({id:19,welcome:'welcome'});
|
||||||
|
await app.context.window.fillInput('请帮我查询');
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history refresh failures preserve the last successfully loaded list', async () => {
|
||||||
|
const app=await customerPage({human:false,history:[{id:9,title:'保留的对话',ctime:'2026-10-29 12:00'}]});
|
||||||
|
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
|
||||||
|
await app.click(app.element('.new-chat-btn'));
|
||||||
|
await flush();
|
||||||
|
assert.match(app.element('historyWrap').textContent, /保留的对话/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting the active conversation aborts its stream and resets the composer', async () => {
|
||||||
|
const app=await customerPage({human:false,history:[{id:9,title:'当前对话',ctime:'2026-10-29 12:00'}]});
|
||||||
|
await app.click(app.element('historyWrap').children[0]);
|
||||||
|
app.hooks['/stream_chat']=() => ({ok:true,body:{getReader:() => ({read:() => new Promise(() => {})})}});
|
||||||
|
await app.context.window.fillInput('请等待回复');
|
||||||
|
await flush();
|
||||||
|
const remove=app.element('historyWrap').children[0].children[2];
|
||||||
|
await remove.onclick({stopPropagation(){}});
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.context.window.__busy,false);
|
||||||
|
assert.equal(app.element('send-button').textContent,'发送');
|
||||||
|
assert.ok(app.calls.find(call => call.url === '/stream_chat'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching topics aborts an upload and does not erase the new conversation status', async () => {
|
||||||
|
const app=await customerPage({human:false});
|
||||||
|
let aborted=false;
|
||||||
|
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
|
||||||
|
options.signal.addEventListener('abort', () => { aborted=true; reject(Object.assign(new Error('aborted'),{name:'AbortError'})); });
|
||||||
|
});
|
||||||
|
app.element('upload-input').files=[{name:'old.png'}];
|
||||||
|
app.element('upload-input').onchange();
|
||||||
|
await app.click(app.quickButtons[0]);
|
||||||
|
await flush();
|
||||||
|
assert.equal(aborted,true);
|
||||||
|
assert.equal(app.element('upload-btn').disabled,false);
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an upload timeout releases the composer and reports an actionable failure', async () => {
|
||||||
|
const app=await customerPage({human:false});
|
||||||
|
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
|
||||||
|
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'),{name:'AbortError'})));
|
||||||
|
});
|
||||||
|
app.element('upload-input').files=[{name:'slow.png'}];
|
||||||
|
app.element('upload-input').onchange();
|
||||||
|
await app.runTimers(15000);
|
||||||
|
assert.equal(app.element('upload-btn').disabled,false);
|
||||||
|
assert.match(app.element('composer-status').textContent,/附件上传失败/);
|
||||||
|
await app.context.window.fillInput('请继续查单');
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed stored pin preferences cannot break the conversation list', async () => {
|
||||||
|
const app=await customerPage({human:false,storage:{pinned_conv:'{broken'},history:[{id:9,title:'正常对话',ctime:'now'}]});
|
||||||
|
assert.match(app.element('historyWrap').textContent,/正常对话/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing history blocks new conversations until its timed request settles', async () => {
|
||||||
|
const app=await customerPage({human:false,history:[{id:9,title:'保留历史',ctime:'now'}]});
|
||||||
|
app.hooks['/api/clear_all']=options => new Promise((resolve,reject) => {
|
||||||
|
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'),{name:'AbortError'})));
|
||||||
|
});
|
||||||
|
app.element('.clear-all-btn').click();
|
||||||
|
await app.click(app.quickButtons[1]);
|
||||||
|
await app.context.window.showHuman();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
|
||||||
|
await app.runTimers(10000);
|
||||||
|
assert.match(app.element('historyWrap').textContent,/保留历史/);
|
||||||
|
await app.click(app.quickButtons[0]);
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed history deletion preserves the active conversation for the next message', async () => {
|
||||||
|
const app=await customerPage({human:false,history:[{id:9,title:'当前历史',ctime:'now'}]});
|
||||||
|
await app.click(app.element('historyWrap').children[0]);
|
||||||
|
app.hooks['/api/del_history?id=9']=() => response({ok:false},503);
|
||||||
|
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
|
||||||
|
await app.context.window.fillInput('继续这个问题');
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id,9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful deletion updates the local list even when its refresh is rate limited', async () => {
|
||||||
|
const app=await customerPage({human:false,history:[{id:9,title:'待删除会话',ctime:'now'},{id:10,title:'保留会话',ctime:'now'}]});
|
||||||
|
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
|
||||||
|
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
|
||||||
|
assert.doesNotMatch(app.element('historyWrap').textContent,/待删除会话/);
|
||||||
|
assert.match(app.element('historyWrap').textContent,/保留会话/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful clearing removes cached history when the followup refresh fails', async () => {
|
||||||
|
const app=await customerPage({human:false,history:[{id:9,title:'待清空会话',ctime:'now'}]});
|
||||||
|
app.hooks['/api/clear_all']=() => response({ok:true});
|
||||||
|
app.hooks['/api/get_history']=() => Promise.reject(new Error('offline'));
|
||||||
|
app.element('.clear-all-btn').click();
|
||||||
|
await flush();
|
||||||
|
assert.doesNotMatch(app.element('historyWrap').textContent,/待清空会话/);
|
||||||
|
assert.ok(app.element('historyWrap').querySelector('.history-empty'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a history refresh started before a deletion cannot resurrect the deleted item', async () => {
|
||||||
|
const app=await customerPage({human:false,history:[{id:9,title:'旧刷新中的会话',ctime:'now'}]});
|
||||||
|
let resolve, refreshes=0;
|
||||||
|
app.hooks['/api/get_history']=() => ++refreshes === 1 ? new Promise(done => { resolve=done; }) : response({e:'请求太频繁'},429);
|
||||||
|
await app.click(app.element('.new-chat-btn'));
|
||||||
|
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
|
||||||
|
resolve(response([{id:9,title:'旧刷新中的会话',ctime:'now'}]));
|
||||||
|
await flush();
|
||||||
|
assert.doesNotMatch(app.element('historyWrap').textContent,/旧刷新中的会话/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history text and order cards preserve their original times and card actions', async () => {
|
||||||
|
const app=await customerPage({human:false,history:[{id:9,title:'历史时间',ctime:'2026-10-28 07:12'}]});
|
||||||
|
app.hooks.messages=() => response([
|
||||||
|
{role:'user',content:'FL202610290001',ctime:'2026-10-28 07:12'},
|
||||||
|
{role:'assistant',content:'__CARD__'+JSON.stringify({kind:'order',no:'FL202610290001',status:'履约中',fields:[],nodes:[],summary:'订单概要'}),ctime:'2026-10-28 07:13'},
|
||||||
|
{role:'assistant',content:'后续补充回复',ctime:'2026-10-28 07:14'}
|
||||||
|
]);
|
||||||
|
await app.click(app.element('historyWrap').children[0]);
|
||||||
|
const times=app.element('chatDom').querySelectorAll('.msg-time');
|
||||||
|
assert.deepEqual(times.map(item => item.textContent),['07:12','07:13','07:14']);
|
||||||
|
assert.equal(times[0].title,'2026-10-28 07:12');
|
||||||
|
assert.ok(app.element('chatDom').querySelector('.track-card').querySelector('.msg-actions'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recalling an earlier reply preserves newer replies in the customer log', async () => {
|
||||||
|
const app = await customerPage();
|
||||||
|
app.setState({status:'active', messages:[{id:1, side:'agent', content:'早先的回复'}, {id:2, side:'agent', content:'最新回复'}]});
|
||||||
|
await app.tick();
|
||||||
|
app.setState({status:'active', messages:[{id:3, side:'system', content:'该消息已撤回', recalled_id:1}]});
|
||||||
|
await app.tick();
|
||||||
|
const log = app.element('chatDom');
|
||||||
|
assert.match(log.textContent, /该消息已撤回/);
|
||||||
|
assert.match(log.textContent, /最新回复/);
|
||||||
|
assert.doesNotMatch(log.textContent, /早先的回复/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
|
||||||
|
|
||||||
|
test('all three footer questions leave the human queue and send to separate AI conversations', async () => {
|
||||||
|
const app = await customerPage();
|
||||||
|
for(const button of app.quickButtons) await app.click(button);
|
||||||
|
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||||
|
assert.equal(requests.length, 3);
|
||||||
|
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
|
||||||
|
assert.deepEqual(requests.map(call => call.body.msg), ['查询订单状态', '欧洲海运专线报价', '包裹异常处理']);
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
|
||||||
|
assert.equal(app.context.window.humanSid, 0);
|
||||||
|
assert.equal(app.element('human-session').hidden, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
|
||||||
|
const app = await customerPage();
|
||||||
|
for(const question of ['欧洲海运专线报价', '如何申请理赔?']){
|
||||||
|
const button = app.element('question-'+question);
|
||||||
|
button.dataset.question = question;
|
||||||
|
app.element('chatDom').listeners.click({target:button});
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||||
|
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the welcome contact button opens the human queue and does not send an AI question', async () => {
|
||||||
|
const app = await customerPage({human:false});
|
||||||
|
const button = app.element('contact-card');
|
||||||
|
button.setAttribute('data-human-support', '');
|
||||||
|
app.element('chatDom').listeners.click({target:button});
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 0);
|
||||||
|
assert.equal(app.element('human-session').hidden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new chat clears human routing and the next quote is sent to AI', async () => {
|
||||||
|
const app = await customerPage();
|
||||||
|
await app.click(app.element('.new-chat-btn'));
|
||||||
|
await app.context.window.fillInput('请报价,30KG发往美国FBA仓');
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '请报价,30KG发往美国FBA仓');
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
|
||||||
|
assert.equal(app.context.window.__busy, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening AI history clears the human session and retains that conversation id', async () => {
|
||||||
|
const app = await customerPage({history:[{id:9, title:'之前的对话', ctime:'2026-10-29 12:00'}]});
|
||||||
|
await app.click(app.element('historyWrap').children[0]);
|
||||||
|
await app.context.window.fillInput('请继续回答这个问题');
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 9);
|
||||||
|
assert.equal(app.context.window.humanSid, 0);
|
||||||
|
const authors = app.element('chatDom').querySelectorAll('.message-author');
|
||||||
|
assert.ok(authors.some(author => author.innerHTML.includes('data-i18n="assistant"')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a repeated quick click while a new conversation is being created sends only once', async () => {
|
||||||
|
const app = await customerPage({human:false});
|
||||||
|
let resolve;
|
||||||
|
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
|
||||||
|
app.quickButtons[0].click();
|
||||||
|
app.quickButtons[0].click();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
|
||||||
|
resolve(response({id:12, welcome:'welcome'}));
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a late conversation creation cannot replace the newest quick question', async () => {
|
||||||
|
const app = await customerPage({human:false});
|
||||||
|
let resolve, count=0;
|
||||||
|
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
|
||||||
|
app.quickButtons[0].click();
|
||||||
|
await app.click(app.quickButtons[2]);
|
||||||
|
resolve(response({id:21, welcome:'welcome'}));
|
||||||
|
await flush();
|
||||||
|
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
assert.equal(requests[0].body.conv_id, 22);
|
||||||
|
assert.equal(requests[0].body.msg, '包裹异常处理');
|
||||||
|
assert.equal(app.context.window.__busy, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a late history response cannot overwrite the latest quick conversation', async () => {
|
||||||
|
const app = await customerPage({history:[{id:9, title:'旧会话', ctime:'2026-10-29 12:00'}]});
|
||||||
|
let resolve;
|
||||||
|
app.hooks.messages = () => new Promise(done => { resolve=done; });
|
||||||
|
await app.click(app.element('historyWrap').children[0]);
|
||||||
|
await app.click(app.quickButtons[1]);
|
||||||
|
resolve(response([{role:'user', content:'旧会话的内容不应显示'}]));
|
||||||
|
await flush();
|
||||||
|
assert.doesNotMatch(app.element('chatDom').textContent, /旧会话的内容不应显示/);
|
||||||
|
assert.match(app.element('chatDom').textContent, /测试 AI 回复/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a delayed human connection cannot capture a newly selected AI topic', async () => {
|
||||||
|
const app = await customerPage({human:false});
|
||||||
|
let resolve;
|
||||||
|
app.hooks['/api/human/request'] = () => new Promise(done => { resolve=done; });
|
||||||
|
const connecting = app.context.window.showHuman();
|
||||||
|
await flush();
|
||||||
|
await app.click(app.quickButtons[1]);
|
||||||
|
resolve(response({ok:true, session_id:8}));
|
||||||
|
await connecting;
|
||||||
|
assert.equal(app.context.window.humanSid, 0);
|
||||||
|
assert.equal(app.element('human-session').hidden, true);
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated contact clicks create one human request and show a visible queue state', async () => {
|
||||||
|
const app = await customerPage({human:false});
|
||||||
|
await Promise.all([app.context.window.showHuman(), app.context.window.showHuman()]);
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
|
||||||
|
assert.equal(app.element('human-session').hidden, false);
|
||||||
|
assert.match(app.element('human-session-label').textContent, /正在排队/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
|
||||||
|
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
|
||||||
|
await app.context.window.showHuman();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 0);
|
||||||
|
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
|
||||||
|
assert.match(app.element('chatDom').textContent, /留言已提交/);
|
||||||
|
assert.equal(app.context.window.humanSid, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed human send preserves the text and makes retry possible', async () => {
|
||||||
|
const app = await customerPage();
|
||||||
|
app.hooks['/api/human/send'] = () => response({ok:false}, 503);
|
||||||
|
await app.context.window.fillInput('请确认这批货的价格');
|
||||||
|
assert.equal(app.element('input-text').value, '请确认这批货的价格');
|
||||||
|
assert.match(app.element('composer-status').textContent, /内容已保留/);
|
||||||
|
assert.equal(app.element('send-button').disabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a duplicate human send cannot submit an in-flight message twice', async () => {
|
||||||
|
const app = await customerPage();
|
||||||
|
let resolve;
|
||||||
|
app.hooks['/api/human/send'] = () => new Promise(done => { resolve=done; });
|
||||||
|
const first = app.context.window.fillInput('请确认时效');
|
||||||
|
await app.context.window.fillInput('请确认时效');
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
|
||||||
|
resolve(response({ok:true}));
|
||||||
|
await first;
|
||||||
|
assert.equal(app.element('input-text').value, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returning to AI explicitly closes the human session and preserves the conversation', async () => {
|
||||||
|
const app = await customerPage();
|
||||||
|
await app.click(app.element('return-ai'));
|
||||||
|
await app.context.window.fillInput('请继续解答');
|
||||||
|
await flush();
|
||||||
|
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 1);
|
||||||
|
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 1);
|
||||||
|
assert.equal(app.context.window.humanSid, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale human poll cannot append messages to a newly selected AI topic', async () => {
|
||||||
|
const app = await customerPage();
|
||||||
|
let resolve;
|
||||||
|
app.hooks.poll = () => new Promise(done => { resolve=done; });
|
||||||
|
await app.tick();
|
||||||
|
await app.click(app.quickButtons[1]);
|
||||||
|
resolve(response({status:'active', messages:[{id:1, side:'agent', content:'旧人工会话的回复'}]}));
|
||||||
|
await flush();
|
||||||
|
assert.doesNotMatch(app.element('chatDom').textContent, /旧人工会话的回复/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('human poll failures are visible and clear after the connection recovers', async () => {
|
||||||
|
const app = await customerPage();
|
||||||
|
app.hooks.poll = () => Promise.reject(new Error('Disconnected'));
|
||||||
|
await app.tick();
|
||||||
|
assert.match(app.element('composer-status').textContent, /连接中断/);
|
||||||
|
delete app.hooks.poll;
|
||||||
|
await app.tick();
|
||||||
|
assert.equal(app.element('composer-status').hidden, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('order cards show facts as text without import metadata, including older saved cards', async () => {
|
||||||
|
const app = await customerPage({human:false});
|
||||||
|
const card = {kind:'order', no:'FL202610290001', status:'履约中',
|
||||||
|
fields:[{key:'weight',value:'30.000 kg'},{key:'warehouse',value:'<img src=x onerror=bad()>'}],
|
||||||
|
nodes:[{time:'2026-10-29 10:00',text:'已提交',place:''}],
|
||||||
|
summary:'订单 FL202610290001,申报毛重30.000 kg', snapshot_at:'2026-10-29 10:30', notice:'旧数据说明'};
|
||||||
|
app.hooks['/stream_chat'] = () => {
|
||||||
|
let read = false;
|
||||||
|
return {ok:true, body:{getReader:() => ({read:async () => {
|
||||||
|
if(read) return {done:true};
|
||||||
|
read = true;
|
||||||
|
return {done:false,value:new TextEncoder().encode('data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n')};
|
||||||
|
}})}};
|
||||||
|
};
|
||||||
|
await app.context.window.fillInput('FL202610290001');
|
||||||
|
await flush();
|
||||||
|
const log = app.element('chatDom');
|
||||||
|
const fields = log.querySelector('.order-data-fields');
|
||||||
|
assert.ok(fields);
|
||||||
|
assert.equal(fields.children[1].textContent, '30.000 kg');
|
||||||
|
assert.equal(fields.children[3].innerHTML, '');
|
||||||
|
assert.equal(log.querySelector('.order-data-notice'), null);
|
||||||
|
assert.doesNotMatch(log.textContent, /旧数据说明|数据导入|2026-10-29 10:30/);
|
||||||
|
assert.equal(app.context.window.__busy, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"""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()
|
||||||
|
from logistics_chat.order_data import import_records
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
import_records(connection, [{'tenant_id':'fixture', 'order_no':'15252125',
|
||||||
|
'status':'PROCESSING', 'country_code':'US', 'gross_weight_kg':'30',
|
||||||
|
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'}]}])
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def application(self, root):
|
||||||
|
return create_app({'TESTING': True, 'DATABASE': root/'history.db',
|
||||||
|
'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.assertGreaterEqual(len(parser.urls), 3)
|
||||||
|
for url in parser.urls:
|
||||||
|
resource = self.client.get(url)
|
||||||
|
self.assertEqual(resource.status_code, 200, url)
|
||||||
|
self.assertGreater(len(resource.data), 0)
|
||||||
|
resource.close()
|
||||||
|
|
||||||
|
def test_history_card_roundtrip_and_deletion(self):
|
||||||
|
cid = self.conversation()
|
||||||
|
response = self.client.post('/stream_chat', json={'msg': '15252125', 'conv_id': cid})
|
||||||
|
events = frames(response)
|
||||||
|
self.assertEqual(events[-1], {'d': 1})
|
||||||
|
self.assertEqual(events[0]['card']['no'], '15252125')
|
||||||
|
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
|
||||||
|
self.assertEqual(len(messages), 3)
|
||||||
|
self.assertTrue(messages[-1]['content'].startswith('__CARD__'))
|
||||||
|
self.assertEqual(self.client.get('/api/get_history').json[0]['title'], '15252125')
|
||||||
|
self.assertEqual(self.client.delete(f'/api/del_history?id={cid}').json, {'ok': True})
|
||||||
|
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json, [])
|
||||||
|
|
||||||
|
def test_empty_image_and_missing_key_streams(self):
|
||||||
|
self.assertIn('e', frames(self.client.post('/stream_chat', json={'msg': ''}))[0])
|
||||||
|
image_reply = frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))
|
||||||
|
self.assertIn('收到您的图片', image_reply[0]['t'])
|
||||||
|
self.assertEqual(image_reply[-1], {'d': 1})
|
||||||
|
self.assertIn('API Key', frames(self.client.get('/stream_chat?msg=hello'))[0]['e'])
|
||||||
|
|
||||||
|
def test_attachment_filename_is_not_used_as_a_tracking_number(self):
|
||||||
|
attachment = '[文件]/uploads/20261029100145_6787.txt'
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg': '请查看附件 ' + attachment}))
|
||||||
|
self.assertFalse(any('card' in event for event in events))
|
||||||
|
self.assertIn('API Key', events[0]['e'])
|
||||||
|
cid = self.conversation()
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg': '查询15252125 ' + attachment, 'conv_id': cid}))
|
||||||
|
self.assertEqual(events[0]['card']['no'], '15252125')
|
||||||
|
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[1]['content'], '查询15252125 ' + attachment)
|
||||||
|
|
||||||
|
def test_image_attachments_do_not_swallow_order_questions_and_are_saved(self):
|
||||||
|
cid = self.conversation()
|
||||||
|
image = '[图片]/uploads/20261029100145_6787.png'
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg':'查询15252125 '+image, 'conv_id':cid}))
|
||||||
|
self.assertEqual(events[0]['card']['no'], '15252125')
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg':image, 'conv_id':cid}))
|
||||||
|
self.assertIn('收到您的图片', events[0]['t'])
|
||||||
|
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
|
||||||
|
self.assertEqual(messages[-2]['content'], image)
|
||||||
|
self.assertIn('收到您的图片', messages[-1]['content'])
|
||||||
|
|
||||||
|
def test_bad_chat_payloads_produce_readable_errors_without_calling_model(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
invalid = ([1], 'hello', {'msg':7}, {'msg':['test']}, {'msg':'物流咨询', 'conv_id':{}},
|
||||||
|
{'msg':'物流咨询', 'conv_id':-1}, {'msg':'物流咨询', 'conv_id':1.5}, {'msg':'物流咨询','conv_id':False},
|
||||||
|
{'msg':'物流咨询','conv_id':999999999999999999999999},
|
||||||
|
{'msg':'物流咨询','conv_id':'²'}, {'msg':'物流咨询','conv_id':'9'*5000})
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
for index, payload in enumerate(invalid):
|
||||||
|
with self.subTest(case=index):
|
||||||
|
events = frames(self.client.post('/stream_chat', json=payload))
|
||||||
|
self.assertIn('e', events[0])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_deleted_conversations_close_human_sessions_and_reject_new_chat_messages(self):
|
||||||
|
cid = self.conversation()
|
||||||
|
with patch('logistics_chat.human_service.datetime') as clock:
|
||||||
|
from datetime import datetime
|
||||||
|
clock.now.return_value = datetime(2026,10,29,12)
|
||||||
|
sid = self.client.post('/api/human/request',json={'conv_id':cid}).json['session_id']
|
||||||
|
self.client.post('/api/human/send',json={'sid':sid,'content':'原会话内容'})
|
||||||
|
self.client.delete(f'/api/del_history?id={cid}')
|
||||||
|
self.assertEqual(self.client.get('/api/human/queue').json, [])
|
||||||
|
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events = frames(self.client.post('/stream_chat',json={'msg':'15252125','conv_id':cid}))
|
||||||
|
self.assertIn('已删除',events[0]['e'])
|
||||||
|
model.assert_not_called()
|
||||||
|
with patch('logistics_chat.human_service.datetime') as clock:
|
||||||
|
clock.now.return_value = datetime(2026,10,29,12)
|
||||||
|
self.assertEqual(self.client.post('/api/human/request',json={'conv_id':cid}).status_code,404)
|
||||||
|
|
||||||
|
def test_uk_quotes_and_followups_keep_the_uk_rates(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
cid = self.conversation()
|
||||||
|
for message, expected in (('30kg英国空运报价','英国空运专线'), ('那海运呢?','英国海运'),
|
||||||
|
('那空运呢?','英国空运专线'), ('那50公斤呢?','英国空运专线')):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||||
|
frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
|
||||||
|
prompt = model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn(expected,prompt)
|
||||||
|
self.assertNotIn('欧洲空运专线',prompt)
|
||||||
|
self.assertNotIn('欧洲海运快线',prompt)
|
||||||
|
|
||||||
|
def test_us_quotes_never_reuse_european_price_tables(self):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events = frames(self.client.post('/stream_chat',json={'msg':'30KG发美国FBA仓,海运报价','conv_id':self.conversation()}))
|
||||||
|
self.assertIn('目的地',events[0]['t'])
|
||||||
|
self.assertIn('人工',events[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_mixed_country_codes_select_the_correct_rates(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
for message, channel, amount in (('30kg发UK海运报价','英国海运','384'),
|
||||||
|
('30kg发uk海运报价','英国海运','384'),
|
||||||
|
('30kg发GB海运报价','英国海运','384'),
|
||||||
|
('30kg发DE仓海运报价','欧洲海运','369')):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion', return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||||
|
events=frames(self.client.post('/stream_chat',json={'msg':message}))
|
||||||
|
self.assertEqual(events[-1],{'d':1})
|
||||||
|
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn(channel,prompt)
|
||||||
|
self.assertIn(amount,prompt)
|
||||||
|
for message in ('30kg发US仓海运报价','30kg发us仓海运报价','30kg发USFBA仓海运报价',
|
||||||
|
'30kg发阿根廷海运报价','30kg海运报价'):
|
||||||
|
with self.subTest(message=message), patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events=frames(self.client.post('/stream_chat',json={'msg':message}))
|
||||||
|
self.assertIn('目的',events[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_short_channel_followups_and_retries_keep_weight_and_region(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
cid=self.conversation()
|
||||||
|
sequence=(('30kg英国海运报价','英国海运','384'), ('海运呢','英国海运','384'),
|
||||||
|
('空运呢','英国空运','1,440'), ('50公斤呢','英国空运','2,400'))
|
||||||
|
for message, channel, amount in sequence:
|
||||||
|
with self.subTest(message=message):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||||
|
frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
|
||||||
|
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn(channel,prompt)
|
||||||
|
self.assertIn(amount,prompt)
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',side_effect=requests.exceptions.ConnectionError):
|
||||||
|
self.assertIn('e',frames(self.client.post('/stream_chat',json={'msg':'海运呢','conv_id':cid}))[0])
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model:
|
||||||
|
frames(self.client.post('/stream_chat',json={'msg':'海运呢','conv_id':cid}))
|
||||||
|
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn('英国海运',prompt)
|
||||||
|
self.assertIn('640',prompt)
|
||||||
|
|
||||||
|
def test_complete_new_questions_do_not_inherit_an_old_quote(self):
|
||||||
|
from logistics_chat.chat_service import shipping_intent_with_context
|
||||||
|
cid=self.conversation()
|
||||||
|
with self.app.app_context():
|
||||||
|
db.save_message(cid,'user','30kg英国海运报价')
|
||||||
|
db.save_message(cid,'assistant','报价结果')
|
||||||
|
for message in ('新的一票50kg德国空运报价','空运理赔应该怎么办?','海运时效一般多久?'):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
intent=shipping_intent_with_context(message,cid)
|
||||||
|
self.assertNotEqual(intent['region'],'uk')
|
||||||
|
db.save_message(cid,'user','空运理赔应该怎么办?')
|
||||||
|
self.assertIsNone(shipping_intent_with_context('海运呢',cid)['weight'])
|
||||||
|
|
||||||
|
def test_dimension_quotes_and_changes_do_not_become_order_identifiers(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
|
||||||
|
cid=self.conversation()
|
||||||
|
for message, amount in (('30kg英国海运,100x100x100cm','1,817'), ('50x40x30cm呢','384'),
|
||||||
|
('1.2x0.8x0.6m呢','1,229'),
|
||||||
|
('那另外一票50kg德国海运','615')):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||||
|
events=frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
|
||||||
|
self.assertEqual(events[-1],{'d':1})
|
||||||
|
self.assertTrue(model.called)
|
||||||
|
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn(amount,prompt)
|
||||||
|
if '另外一票' in message:
|
||||||
|
self.assertNotIn('体积重',prompt)
|
||||||
|
|
||||||
|
def test_destination_only_replies_continue_the_existing_quote(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
|
||||||
|
cid=self.conversation()
|
||||||
|
self.assertIn('目的',frames(self.client.post('/stream_chat',json={'msg':'30kg海运报价','conv_id':cid}))[0]['t'])
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||||
|
frames(self.client.post('/stream_chat',json={'msg':'英国','conv_id':cid}))
|
||||||
|
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn('英国海运',prompt)
|
||||||
|
self.assertIn('384',prompt)
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events=frames(self.client.post('/stream_chat',json={'msg':'US仓','conv_id':cid}))
|
||||||
|
self.assertIn('尚未包含',events[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_uk_volume_quotes_do_not_reuse_continental_warehouse_prices(self):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events = frames(self.client.post('/stream_chat',json={'msg':'2方发英国海运多少钱'}))
|
||||||
|
self.assertIn('未提供',events[0]['t'])
|
||||||
|
self.assertIn('按方费率',events[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_closed_human_sessions_cannot_be_reopened_by_a_late_accept(self):
|
||||||
|
with patch('logistics_chat.human_service.datetime') as clock:
|
||||||
|
from datetime import datetime
|
||||||
|
clock.now.return_value = datetime(2026,10,29,12)
|
||||||
|
sid = self.client.post('/api/human/request',json={'conv_id':self.conversation()}).json['session_id']
|
||||||
|
self.client.post('/api/human/close',json={'sid':sid})
|
||||||
|
self.assertEqual(self.client.post('/api/human/accept',json={'sid':sid}).status_code,409)
|
||||||
|
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'],'closed')
|
||||||
|
|
||||||
|
def test_human_lifecycle_typing_and_read_receipts(self):
|
||||||
|
self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400)
|
||||||
|
with patch('logistics_chat.human_service.datetime') as clock:
|
||||||
|
from datetime import datetime
|
||||||
|
clock.now.return_value = datetime(2026, 10, 29, 12)
|
||||||
|
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
|
||||||
|
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
|
||||||
|
self.client.post('/api/human/accept', json={'sid': sid})
|
||||||
|
self.client.post('/api/human/send', json={'sid': sid, 'side': 'customer', 'content': 'hello'})
|
||||||
|
self.client.post('/api/human/typing', json={'sid': sid})
|
||||||
|
state = self.client.get(f'/api/human/poll?sid={sid}&after=0').json
|
||||||
|
self.assertEqual(state['status'], 'active')
|
||||||
|
self.assertTrue(state['typing'])
|
||||||
|
self.assertFalse(state['messages'][0]['read'])
|
||||||
|
self.client.post('/api/human/read', json={'sid': sid})
|
||||||
|
self.client.post('/api/human/stop_typing', json={'sid': sid})
|
||||||
|
state = self.client.get(f'/api/human/poll?sid={sid}').json
|
||||||
|
self.assertTrue(state['messages'][0]['read'])
|
||||||
|
self.assertFalse(state['typing'])
|
||||||
|
self.client.post('/api/human/close', json={'sid': sid})
|
||||||
|
self.assertEqual(self.client.get('/api/human/active').json, [])
|
||||||
|
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'closed')
|
||||||
|
|
||||||
|
def test_application_state_is_not_shared_between_factories(self):
|
||||||
|
self.app.extensions['human_typing']['1'] = 123
|
||||||
|
self.app.extensions['rate_hits']['127.0.0.1'].append(123)
|
||||||
|
other = self.application(self.root/'other')
|
||||||
|
self.assertEqual(other.extensions['human_typing'], {})
|
||||||
|
self.assertEqual(dict(other.extensions['rate_hits']), {})
|
||||||
|
|
||||||
|
def test_factories_with_different_log_directories_do_not_cross_write(self):
|
||||||
|
applications = []
|
||||||
|
try:
|
||||||
|
for name in ('first', 'second'):
|
||||||
|
root = self.root/name
|
||||||
|
applications.append(create_app({'DATABASE': root/'history.db', 'UPLOAD_DIR': root/'uploads',
|
||||||
|
'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''}))
|
||||||
|
applications[0].logger.warning('first application only')
|
||||||
|
for app in applications:
|
||||||
|
for handler in app.logger.handlers:
|
||||||
|
handler.flush()
|
||||||
|
self.assertIn('first application only', (self.root/'first/logs/app.log').read_text())
|
||||||
|
self.assertNotIn('first application only', (self.root/'second/logs/app.log').read_text())
|
||||||
|
finally:
|
||||||
|
for app in applications:
|
||||||
|
for handler in list(app.logger.handlers):
|
||||||
|
if isinstance(handler, logging.FileHandler):
|
||||||
|
handler.close()
|
||||||
|
app.logger.removeHandler(handler)
|
||||||
|
|
||||||
|
def test_rate_limit_and_exemptions(self):
|
||||||
|
self.app.config['RATE_LIMIT_PER_MIN'] = 2
|
||||||
|
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
|
||||||
|
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
|
||||||
|
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
|
||||||
|
self.assertEqual(self.client.get('/api/human/queue').status_code, 200)
|
||||||
|
with self.client.get('/static/fa.jpg') as image:
|
||||||
|
self.assertEqual(image.status_code, 200)
|
||||||
|
|
||||||
|
def test_attachment_roundtrip_and_rejection(self):
|
||||||
|
response = self.client.post('/api/upload', data={'file': (io.BytesIO(b'hello'), 'hello.txt')})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
downloaded = self.client.get(response.json['url'])
|
||||||
|
self.assertEqual(downloaded.data, b'hello')
|
||||||
|
downloaded.close()
|
||||||
|
self.assertEqual(self.client.post('/api/upload', data={'file': (io.BytesIO(b'x'), 'x.exe')}).status_code, 400)
|
||||||
|
|
||||||
|
def test_health_check_remains_available_when_business_requests_are_rate_limited(self):
|
||||||
|
self.app.config['RATE_LIMIT_PER_MIN'] = 1
|
||||||
|
self.assertEqual(self.client.get('/').status_code, 200)
|
||||||
|
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
|
||||||
|
response = self.client.get('/healthz')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json, {'status': 'ok'})
|
||||||
|
|
||||||
|
def test_feedback_stats_and_csv(self):
|
||||||
|
cid = self.conversation()
|
||||||
|
with self.app.app_context():
|
||||||
|
db.save_message(cid, 'user', 'hello, "quoted"\nworld')
|
||||||
|
self.client.post('/api/feedback', json={'vote': 1, 'question': 'hello', 'answer': 'world'})
|
||||||
|
self.assertEqual(self.client.get('/api/admin/stats').json['good'], 1)
|
||||||
|
response = self.client.get('/api/export_chat?format=csv')
|
||||||
|
self.assertEqual(response.mimetype, 'text/csv')
|
||||||
|
self.assertIn('chat_export.csv', response.headers['Content-Disposition'])
|
||||||
|
self.assertIn('"hello, ""quoted""\nworld"', response.get_data(as_text=True))
|
||||||
|
self.client.delete('/api/clear_all')
|
||||||
|
self.assertEqual(self.client.get('/api/get_history').json, [])
|
||||||
|
|
||||||
|
def test_business_routes_with_initialized_tables_and_legacy_auth(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||||
|
self.assertTrue({'agents', 'orders', 'tickets'}.issubset(tables))
|
||||||
|
connection.executescript(
|
||||||
|
"INSERT INTO agents VALUES('demo', 'test-password', 'Agent');"
|
||||||
|
"INSERT INTO orders VALUES(1, '123', '15252125', 'active', '2026-10-28');")
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
self.assertEqual(self.client.post('/admin/login', json={'username': 'demo', 'password': 'wrong'}).status_code, 401)
|
||||||
|
self.assertEqual(self.client.post('/admin/login', json={'username': 'demo', 'password': 'test-password'}).json['name'], 'Agent')
|
||||||
|
self.assertEqual(self.client.get('/api/my_orders?phone=123').json['orders'][0]['track_no'], '15252125')
|
||||||
|
self.assertEqual(self.client.post('/api/ticket', json={'content': 'help', 'phone': '123'}).status_code, 200)
|
||||||
|
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], 'help')
|
||||||
|
|
||||||
|
def test_stream_compression_followups_and_deadlines(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
streamed = ModelResponse('这是用于覆盖长回答压缩的说明。'*100)
|
||||||
|
responses = [streamed, ModelResponse('基础运费2050元,时效38-45天。'), ModelResponse('["费用呢","时效呢","能带电吗"]')]
|
||||||
|
with patch('logistics_chat.ai_client.requests.post', side_effect=responses) as post:
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg': '200kg 德国海运', 'conv_id': self.conversation()}))
|
||||||
|
self.assertTrue(any('t' in event for event in events))
|
||||||
|
self.assertTrue(any('replace' in event for event in events))
|
||||||
|
self.assertTrue(any('suggest' in event for event in events))
|
||||||
|
self.assertEqual(events[-1], {'d': 1})
|
||||||
|
self.assertEqual([call.kwargs['timeout'] for call in post.call_args_list], [(5, 90), (5, 30), (5, 15)])
|
||||||
|
self.assertTrue(streamed.closed)
|
||||||
|
self.assertTrue(all(response.closed for response in responses))
|
||||||
|
|
||||||
|
def test_incomplete_model_streams_are_reported_and_saved_as_partial(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
for reason in (None, 'length'):
|
||||||
|
with self.subTest(reason=reason):
|
||||||
|
cid = self.conversation()
|
||||||
|
response = ModelResponse('这是已经生成的部分内容,需要保留以便用户了解中断前的信息。')
|
||||||
|
def lines():
|
||||||
|
yield ('data: '+json.dumps({'choices':[{'delta':{'content':response.content}, 'finish_reason':reason}]})).encode()
|
||||||
|
if reason:
|
||||||
|
yield b'data: [DONE]'
|
||||||
|
response.iter_lines = lines
|
||||||
|
with patch('logistics_chat.ai_client.post_completion', return_value=response):
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg':'物流咨询', 'conv_id':cid}))
|
||||||
|
self.assertTrue(any('e' in event for event in events))
|
||||||
|
self.assertFalse(any(event.get('d') for event in events))
|
||||||
|
self.assertTrue(response.closed)
|
||||||
|
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
|
||||||
|
self.assertIn('回答中断', messages[-1]['content'])
|
||||||
|
|
||||||
|
def test_stream_network_and_model_errors(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
for error, expected in ((requests.exceptions.Timeout, '超时'),
|
||||||
|
(requests.exceptions.ConnectionError, '无法连接')):
|
||||||
|
with self.subTest(error=error):
|
||||||
|
with patch('logistics_chat.ai_client.requests.post', side_effect=error):
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg': '物流咨询'}))
|
||||||
|
self.assertIn(expected, events[0]['e'])
|
||||||
|
denied = ModelResponse(status=401)
|
||||||
|
with patch('logistics_chat.ai_client.requests.post', return_value=denied):
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg': '物流咨询'}))
|
||||||
|
self.assertEqual(events[0]['e'], '系统繁忙,请稍后再试')
|
||||||
|
self.assertTrue(denied.closed)
|
||||||
|
empty = ModelResponse(text='')
|
||||||
|
with patch('logistics_chat.ai_client.requests.post', return_value=empty):
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg': '物流咨询'}))
|
||||||
|
self.assertIn('没有返回内容', events[0]['e'])
|
||||||
|
self.assertTrue(empty.closed)
|
||||||
|
|
||||||
|
def test_cancellation_saves_partial_answer_and_closes_http_response(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
cid = self.conversation()
|
||||||
|
model = ModelResponse('这是长度超过二十字的回答内容,用于验证中断保存行为。')
|
||||||
|
with patch('logistics_chat.ai_client.requests.post', return_value=model):
|
||||||
|
response = self.client.post('/stream_chat', json={'msg': '海运咨询', 'conv_id': cid}, buffered=False)
|
||||||
|
next(response.response)
|
||||||
|
response.close()
|
||||||
|
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
|
||||||
|
self.assertIn('回答中断', messages[-1]['content'])
|
||||||
|
self.assertTrue(model.closed)
|
||||||
|
|
||||||
|
def test_entry_and_package_imports_do_not_create_runtime_files(self):
|
||||||
|
root = str(Path(__file__).resolve().parents[1])
|
||||||
|
code = 'import sys; sys.path.insert(0, sys.argv[1]); import am; from logistics_chat import create_app'
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
result = subprocess.run([sys.executable, '-c', code, root], cwd=directory,
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||||
|
|
||||||
|
def test_wsgi_compatibility_creates_one_application_on_explicit_access(self):
|
||||||
|
import am
|
||||||
|
try:
|
||||||
|
with patch.object(am, 'create_app', side_effect=lambda: self.application(self.root/'wsgi')) as factory:
|
||||||
|
application = getattr(am, 'app')
|
||||||
|
self.assertIs(getattr(am, 'app'), application)
|
||||||
|
self.assertEqual(application.test_client().get('/').status_code, 200)
|
||||||
|
self.assertEqual(factory.call_count, 1)
|
||||||
|
finally:
|
||||||
|
am.__dict__.pop('app', None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Configuration boundaries; credentials and model calls use synthetic fixtures."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, sentinel
|
||||||
|
|
||||||
|
from logistics_chat import config, create_app
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(directory.cleanup)
|
||||||
|
self.root = Path(directory.name)
|
||||||
|
root_patch = patch.object(config, 'ROOT_DIR', self.root)
|
||||||
|
root_patch.start()
|
||||||
|
self.addCleanup(root_patch.stop)
|
||||||
|
environment = patch.dict(os.environ)
|
||||||
|
environment.start()
|
||||||
|
self.addCleanup(environment.stop)
|
||||||
|
os.environ.pop('DEEPSEEK_API_KEY', None)
|
||||||
|
|
||||||
|
def write_env(self, text):
|
||||||
|
(self.root / '.env').write_text(text, encoding='utf-8')
|
||||||
|
|
||||||
|
def application(self, **overrides):
|
||||||
|
return create_app({'TESTING': True, 'INITIALIZE_RUNTIME': False, **overrides})
|
||||||
|
|
||||||
|
def test_missing_file_leaves_ai_disabled_without_creating_files(self):
|
||||||
|
self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], '')
|
||||||
|
self.assertEqual(list(self.root.iterdir()), [])
|
||||||
|
|
||||||
|
def test_file_accepts_bom_quotes_comments_and_whitespace(self):
|
||||||
|
self.write_env('\ufeff# Local configuration\nexport DEEPSEEK_API_KEY=" file-key " # comment\n')
|
||||||
|
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'file-key')
|
||||||
|
self.assertNotIn('DEEPSEEK_API_KEY', os.environ)
|
||||||
|
|
||||||
|
def test_empty_or_unassigned_file_value_leaves_ai_disabled(self):
|
||||||
|
for line in ('DEEPSEEK_API_KEY=', 'DEEPSEEK_API_KEY', 'OTHER_SETTING=unused'):
|
||||||
|
with self.subTest(line=line):
|
||||||
|
self.write_env(line + '\n')
|
||||||
|
self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], '')
|
||||||
|
|
||||||
|
def test_environment_overrides_file_including_an_explicit_empty_value(self):
|
||||||
|
self.write_env('DEEPSEEK_API_KEY=file-key\n')
|
||||||
|
for value, expected in ((' environment-key ', 'environment-key'), ('', '')):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
os.environ['DEEPSEEK_API_KEY'] = value
|
||||||
|
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], expected)
|
||||||
|
|
||||||
|
def test_file_values_are_literal_and_do_not_expand_environment_variables(self):
|
||||||
|
os.environ['SYNTHETIC_CONFIG_VALUE'] = 'expanded'
|
||||||
|
self.write_env("DEEPSEEK_API_KEY='literal-${SYNTHETIC_CONFIG_VALUE}'\n")
|
||||||
|
self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], 'literal-${SYNTHETIC_CONFIG_VALUE}')
|
||||||
|
|
||||||
|
def test_file_is_resolved_from_repository_root_instead_of_working_directory(self):
|
||||||
|
self.write_env('DEEPSEEK_API_KEY=repository-key\n')
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
(Path(directory) / '.env').write_text('DEEPSEEK_API_KEY=wrong-key\n', encoding='utf-8')
|
||||||
|
previous = Path.cwd()
|
||||||
|
try:
|
||||||
|
os.chdir(directory)
|
||||||
|
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'repository-key')
|
||||||
|
finally:
|
||||||
|
os.chdir(previous)
|
||||||
|
|
||||||
|
def test_explicit_factory_configuration_has_highest_priority(self):
|
||||||
|
self.write_env('DEEPSEEK_API_KEY=file-key\n')
|
||||||
|
os.environ['DEEPSEEK_API_KEY'] = 'environment-key'
|
||||||
|
for value in ('override-key', ''):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertEqual(self.application(DEEPSEEK_API_KEY=value).config['DEEPSEEK_API_KEY'], value)
|
||||||
|
|
||||||
|
def test_new_factory_reads_updated_file_without_changing_an_existing_app(self):
|
||||||
|
self.write_env('DEEPSEEK_API_KEY=first-key\n')
|
||||||
|
first = self.application()
|
||||||
|
self.write_env('DEEPSEEK_API_KEY=second-key\n')
|
||||||
|
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'second-key')
|
||||||
|
self.assertEqual(first.config['DEEPSEEK_API_KEY'], 'first-key')
|
||||||
|
self.assertNotIn('DEEPSEEK_API_KEY', os.environ)
|
||||||
|
|
||||||
|
def test_model_client_sends_file_key_without_a_real_http_request(self):
|
||||||
|
from logistics_chat.ai_client import post_completion
|
||||||
|
|
||||||
|
self.write_env('DEEPSEEK_API_KEY=file-key\n')
|
||||||
|
app = self.application()
|
||||||
|
with app.app_context(), patch('logistics_chat.ai_client.requests.post', return_value=sentinel.response) as post:
|
||||||
|
response = post_completion({'model': 'deepseek-chat'}, timeout=(5, 90), stream=True)
|
||||||
|
self.assertIs(response, sentinel.response)
|
||||||
|
self.assertEqual(post.call_args.kwargs['headers']['Authorization'], 'Bearer file-key')
|
||||||
|
|
||||||
|
def test_order_scope_is_fixed_by_server_file_or_environment(self):
|
||||||
|
self.write_env('ORDER_DATA_TENANT_ID=tenant-file\n')
|
||||||
|
os.environ.pop('ORDER_DATA_TENANT_ID', None)
|
||||||
|
self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-file')
|
||||||
|
os.environ['ORDER_DATA_TENANT_ID'] = 'tenant-environment'
|
||||||
|
self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-environment')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Remote dev behavior on the modular backend and the light workspace."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
from logistics_chat import create_app, db
|
||||||
|
|
||||||
|
|
||||||
|
class DevIntegrationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(directory.cleanup)
|
||||||
|
root = Path(directory.name)
|
||||||
|
self.app = create_app({'TESTING': True, 'DATABASE': root/'test.db',
|
||||||
|
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs',
|
||||||
|
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
def session(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
with patch('logistics_chat.human_service.datetime') as clock:
|
||||||
|
clock.now.return_value = datetime(2026, 10, 31, 8)
|
||||||
|
result = self.client.post('/api/human/request', json={'conv_id': cid}).json
|
||||||
|
return result['session_id']
|
||||||
|
|
||||||
|
def test_new_conversation_persists_one_welcome_before_user_message(self):
|
||||||
|
data = self.client.post('/api/new_chat').json
|
||||||
|
self.assertIn('欢迎', data['welcome'])
|
||||||
|
self.client.post('/stream_chat', json={'msg': '15252125', 'conv_id': data['id']}, buffered=True)
|
||||||
|
messages = self.client.get('/api/get_messages?conv_id='+str(data['id'])).json
|
||||||
|
self.assertEqual([message['role'] for message in messages], ['assistant', 'user', 'assistant'])
|
||||||
|
self.assertEqual(messages[0]['content'], data['welcome'])
|
||||||
|
|
||||||
|
def test_daily_service_hours_and_offline_ticket_on_fresh_database(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
for hour, open_now in ((7, False), (8, True), (21, True), (22, False)):
|
||||||
|
with self.subTest(hour=hour), patch('logistics_chat.human_service.datetime') as clock:
|
||||||
|
clock.now.return_value = datetime(2026, 10, 31, hour)
|
||||||
|
result = self.client.post('/api/human/request', json={'conv_id': cid}).json
|
||||||
|
self.assertEqual(result['ok'], open_now)
|
||||||
|
if not open_now:
|
||||||
|
self.assertTrue(result['closed'])
|
||||||
|
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
|
||||||
|
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
|
||||||
|
|
||||||
|
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
|
||||||
|
sid = self.session()
|
||||||
|
self.client.post('/api/human/accept', json={'sid': sid})
|
||||||
|
self.client.post('/api/human/note', json={'sid': sid, 'note': '请跟进派送'})
|
||||||
|
self.client.post('/api/human/send', json={'sid': sid, 'side': 'agent', 'content': '正在处理'})
|
||||||
|
self.client.post('/api/human/typing', json={'sid': sid})
|
||||||
|
self.assertEqual(self.client.post('/api/human/transfer', json={'sid': sid}).json, {'ok': True})
|
||||||
|
self.assertEqual(self.client.get('/api/human/active').json, [])
|
||||||
|
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
|
||||||
|
state = self.client.get(f'/api/human/poll?sid={sid}').json
|
||||||
|
self.assertEqual(state['note'], '请跟进派送')
|
||||||
|
self.assertFalse(state['typing'])
|
||||||
|
self.assertEqual(state['messages'][0]['content'], '正在处理')
|
||||||
|
self.assertEqual(state['messages'][-1]['side'], 'system')
|
||||||
|
self.assertEqual(self.client.post('/api/human/transfer', json={'sid': sid}).status_code, 409)
|
||||||
|
self.client.post('/api/human/accept', json={'sid': sid})
|
||||||
|
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'active')
|
||||||
|
|
||||||
|
def test_human_send_rejects_closed_missing_or_mismatched_conversations(self):
|
||||||
|
sid = self.session()
|
||||||
|
payload = {'sid': sid, 'side': 'customer', 'content': '不能串到其他会话'}
|
||||||
|
other_cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'conv_id': other_cid}).status_code, 409)
|
||||||
|
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'sid': 99999}).status_code, 404)
|
||||||
|
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'side': 'system'}).status_code, 400)
|
||||||
|
self.client.post('/api/human/close', json={'sid': sid})
|
||||||
|
self.assertEqual(self.client.post('/api/human/send', json=payload).status_code, 409)
|
||||||
|
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
|
||||||
|
|
||||||
|
def test_recall_targets_older_agent_message_and_is_idempotent(self):
|
||||||
|
sid = self.session()
|
||||||
|
for side, text in (('agent', '早先的回复'), ('customer', '客户消息'), ('agent', '最新回复')):
|
||||||
|
self.client.post('/api/human/send', json={'sid': sid, 'side': side, 'content': text})
|
||||||
|
before = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
|
||||||
|
mid = before[0]['id']
|
||||||
|
for _ in range(2):
|
||||||
|
self.assertEqual(self.client.post('/api/human/recall', json={'mid': mid}).json, {'ok': True})
|
||||||
|
self.assertEqual(self.client.post('/api/human/recall', json={'mid': before[1]['id']}).status_code, 400)
|
||||||
|
self.assertEqual(self.client.post('/api/human/recall', json={'mid': 99999}).status_code, 404)
|
||||||
|
updates = self.client.get(f'/api/human/poll?sid={sid}&after={before[-1]["id"]}').json['messages']
|
||||||
|
self.assertEqual(len(updates), 1)
|
||||||
|
self.assertEqual(updates[0]['recalled_id'], mid)
|
||||||
|
all_messages = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
|
||||||
|
self.assertEqual(all_messages[0]['content'], '该消息已撤回')
|
||||||
|
self.assertEqual(all_messages[2]['content'], '最新回复')
|
||||||
|
|
||||||
|
def test_ratings_validate_stars_and_feed_admin_stats(self):
|
||||||
|
sid = self.session()
|
||||||
|
for stars in (0, 6, True, '5'):
|
||||||
|
self.assertEqual(self.client.post('/api/rate', json={'sid': sid, 'stars': stars}).status_code, 400)
|
||||||
|
self.assertEqual(self.client.post('/api/rate', json={'sid': 999, 'stars': 5}).status_code, 404)
|
||||||
|
for stars in (4, 5):
|
||||||
|
self.assertEqual(self.client.post('/api/rate', json={'sid': sid, 'stars': stars}).json, {'ok': True})
|
||||||
|
stats = self.client.get('/api/admin/stats').json
|
||||||
|
self.assertEqual((stats['rating_avg'], stats['rating_count']), (4.5, 2))
|
||||||
|
|
||||||
|
def test_excel_export_search_and_legacy_agent_schema(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
with self.app.app_context():
|
||||||
|
db.save_message(cid, 'user', '=SUM(1,2)\n查询专线')
|
||||||
|
connection = db.connect()
|
||||||
|
connection.executescript('DROP TABLE agents; CREATE TABLE agents '
|
||||||
|
'(id INTEGER PRIMARY KEY, username TEXT, password TEXT, name TEXT);'
|
||||||
|
"INSERT INTO agents VALUES(7, 'demo', 'fixture-password', '测试客服');")
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
db.init_db()
|
||||||
|
results = self.client.get('/api/search_chat?q=专线').json
|
||||||
|
self.assertEqual(len(results), 1)
|
||||||
|
self.assertIn('SUM', results[0]['content'])
|
||||||
|
self.assertEqual(self.client.get('/api/search_chat').json, {'results': []})
|
||||||
|
self.assertEqual(self.client.get('/api/agents/list').json, [{'id': 7, 'username': 'demo', 'name': '测试客服'}])
|
||||||
|
exported = self.client.get('/api/export_chat')
|
||||||
|
self.assertIn('chat_export.xlsx', exported.headers['Content-Disposition'])
|
||||||
|
workbook = load_workbook(io.BytesIO(exported.data))
|
||||||
|
sheet = workbook.active
|
||||||
|
self.assertEqual(sheet['C3'].value, '=SUM(1,2)\n查询专线')
|
||||||
|
self.assertEqual(sheet['C3'].data_type, 's')
|
||||||
|
self.assertEqual(sheet['B3'].value, '客户')
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
def test_existing_schema_is_upgraded_without_losing_business_data(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
connection.executescript('DROP TABLE human_sessions; DROP TABLE human_messages;'
|
||||||
|
'CREATE TABLE human_sessions(id INTEGER PRIMARY KEY, conv_id INTEGER, status TEXT, ctime TEXT);'
|
||||||
|
'CREATE TABLE human_messages(id INTEGER PRIMARY KEY, session_id INTEGER, side TEXT, content TEXT, ctime TEXT);'
|
||||||
|
"INSERT INTO human_sessions VALUES(1, 1, 'active', 'now');"
|
||||||
|
"INSERT INTO human_messages VALUES(1, 1, 'agent', '保留的回复', 'now');"
|
||||||
|
"INSERT INTO agents VALUES('fixture-agent', 'fixture-password', 'Fixture Agent');")
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
db.init_db()
|
||||||
|
db.init_db()
|
||||||
|
state = self.client.get('/api/human/poll?sid=1').json
|
||||||
|
self.assertEqual(state['messages'][0]['content'], '保留的回复')
|
||||||
|
self.assertEqual(state['note'], '')
|
||||||
|
self.assertEqual(self.client.get('/api/agents/list').json[0]['username'], 'fixture-agent')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Optional Node regression checks for asynchronous customer-service drafts."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendTests(unittest.TestCase):
|
||||||
|
@unittest.skipUnless(shutil.which('node'), 'Node.js is not installed')
|
||||||
|
def test_reply_workflows(self):
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
result = subprocess.run(
|
||||||
|
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs'], cwd=root,
|
||||||
|
text=True, encoding='utf-8', capture_output=True, timeout=30,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Exercise real Bash release/rollback file operations with all infrastructure faked."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from deploy.gray_config import GrayConfig, write_release
|
||||||
|
|
||||||
|
|
||||||
|
FAKE_COMMAND = '''import json, os, pathlib, sys
|
||||||
|
name = pathlib.Path(sys.argv[0]).name
|
||||||
|
args = sys.argv[1:]
|
||||||
|
log = pathlib.Path(os.environ['FAKE_LOG'])
|
||||||
|
with log.open('a') as output:
|
||||||
|
output.write(json.dumps([name, args]) + '\\n')
|
||||||
|
scenario = os.environ['FAKE_SCENARIO']
|
||||||
|
if name == 'docker':
|
||||||
|
if args[:2] == ['image', 'inspect']:
|
||||||
|
print('sha256:' + 'b'*64 if args[3] == '{{.Id}}' else 'a'*40)
|
||||||
|
elif args[:2] == ['container', 'inspect']:
|
||||||
|
sys.exit(0)
|
||||||
|
elif args[0] == 'inspect':
|
||||||
|
print('baiyue-logistics-chat-gray')
|
||||||
|
elif args[0] == 'port':
|
||||||
|
print('127.0.0.1:29013')
|
||||||
|
elif args[0] == 'compose' and 'up' in args and scenario == 'app-fail' and os.environ['FAKE_NEW'] + '/release.env' in args:
|
||||||
|
sys.exit(17)
|
||||||
|
elif name == 'nginx' and '-t' in args and scenario == 'proxy-fail':
|
||||||
|
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
||||||
|
if sum(item[0] == 'nginx' and '-t' in item[1] for item in calls) == 1:
|
||||||
|
sys.exit(19)
|
||||||
|
elif name == 'curl' and '-w' in args:
|
||||||
|
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
||||||
|
if scenario == 'reload-pending' and sum(item[0] == 'curl' and '-w' in item[1] for item in calls) == 1:
|
||||||
|
sys.exit(60)
|
||||||
|
if scenario == 'proxy-unsafe':
|
||||||
|
print('200', end='')
|
||||||
|
sys.exit(0)
|
||||||
|
print('401', end='')
|
||||||
|
elif name == 'sleep':
|
||||||
|
pass
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Gray activation requires Linux Bash')
|
||||||
|
class GrayActivationTests(unittest.TestCase):
|
||||||
|
def run_activation(self, scenario):
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
directory = Path(temporary.name)
|
||||||
|
root = directory / 'gray-logistics-chat'
|
||||||
|
proxy = directory / 'proxy'
|
||||||
|
auth = directory / 'baiyue-logistics-chat-gray-proxy'
|
||||||
|
for path in (root / 'data', root / 'logs', root / 'releases', proxy, auth):
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
|
||||||
|
(root / 'data/history.db').write_bytes(b'preserve-existing-data')
|
||||||
|
certificate, private_key = directory / 'cert.pem', directory / 'key.pem'
|
||||||
|
certificate.touch()
|
||||||
|
private_key.touch()
|
||||||
|
previous = root / 'releases/previous'
|
||||||
|
previous.mkdir()
|
||||||
|
(previous / 'release.env').write_text('CHAT_IMAGE=previous\n')
|
||||||
|
(previous / 'compose.yml').write_text('services: {}\n')
|
||||||
|
(root / 'current-release').write_text(str(previous) + '\n')
|
||||||
|
old_site = '# Managed for baiyue-logistics-chat-gray\n# previous site\n'
|
||||||
|
(proxy / 'baiyue-logistics-chat-gray.conf').write_text(old_site)
|
||||||
|
(auth / '.project').write_text('another-service\n' if scenario == 'unowned-auth' else 'baiyue-logistics-chat-gray\n')
|
||||||
|
(auth / 'access.htpasswd').write_text('previous-auth-hash\n')
|
||||||
|
unrelated = proxy / 'unrelated.conf'
|
||||||
|
unrelated.write_text('# unrelated site\n')
|
||||||
|
config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'),
|
||||||
|
str(proxy), str(certificate), str(private_key), 'a'*40,
|
||||||
|
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64, str(auth))
|
||||||
|
release = Path(config.release)
|
||||||
|
write_release(release, config, 'fixture-model-key')
|
||||||
|
# Docker 26 and 29 may assign different IDs after an archive is loaded.
|
||||||
|
loaded_id = ('e' if scenario == 'image-mismatch' else 'b') * 64
|
||||||
|
(release / 'loaded-image-ids.env').write_text('CHAT_IMAGE_ID=sha256:' + loaded_id + '\n')
|
||||||
|
(release / 'site.htpasswd').write_text('new-auth-hash\n')
|
||||||
|
commands = directory / 'commands'
|
||||||
|
commands.mkdir()
|
||||||
|
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl', 'sleep'):
|
||||||
|
command = commands / name
|
||||||
|
command.write_text('#!' + sys.executable + '\n' + FAKE_COMMAND)
|
||||||
|
command.chmod(0o755)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
log = directory / 'commands.jsonl'
|
||||||
|
environment.update(PATH=str(commands) + os.pathsep + environment['PATH'],
|
||||||
|
FAKE_LOG=str(log), FAKE_SCENARIO=scenario, FAKE_NEW=str(release))
|
||||||
|
result = subprocess.run(['bash', str(release / 'activate.sh'), str(root), str(release)],
|
||||||
|
env=environment, capture_output=True, text=True, timeout=30)
|
||||||
|
self.assertEqual((root / 'data/history.db').read_bytes(), b'preserve-existing-data')
|
||||||
|
self.assertEqual(unrelated.read_text(), '# unrelated site\n')
|
||||||
|
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
||||||
|
return result, root, proxy, previous, release, old_site, calls
|
||||||
|
|
||||||
|
def test_success_publishes_only_the_owned_site_and_release_pointer(self):
|
||||||
|
result, root, proxy, previous, release, old_site, calls = self.run_activation('success')
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
|
||||||
|
self.assertIn('server_name chat.example.com', (proxy / 'baiyue-logistics-chat-gray.conf').read_text())
|
||||||
|
auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy'
|
||||||
|
self.assertEqual((auth / 'access.htpasswd').read_text(), 'new-auth-hash\n')
|
||||||
|
self.assertIn(str(auth / 'access.htpasswd'), (proxy / 'baiyue-logistics-chat-gray.conf').read_text())
|
||||||
|
self.assertEqual(auth.stat().st_mode & 0o777, 0o755)
|
||||||
|
self.assertEqual((auth / 'access.htpasswd').stat().st_mode & 0o777, 0o644)
|
||||||
|
|
||||||
|
def assert_rolled_back(self, scenario):
|
||||||
|
result, root, proxy, previous, release, old_site, calls = self.run_activation(scenario)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
|
||||||
|
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
|
||||||
|
self.assertEqual((proxy.parent / 'baiyue-logistics-chat-gray-proxy/access.htpasswd').read_text(), 'previous-auth-hash\n')
|
||||||
|
self.assertTrue(any(name == 'docker' and 'up' in args and str(previous / 'release.env') in args
|
||||||
|
for name, args in calls), result.stderr)
|
||||||
|
|
||||||
|
def test_failed_application_restores_previous_release(self):
|
||||||
|
self.assert_rolled_back('app-fail')
|
||||||
|
|
||||||
|
def test_async_nginx_reload_waits_for_the_new_tls_workers(self):
|
||||||
|
result, root, proxy, previous, release, old_site, calls = self.run_activation('reload-pending')
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
|
||||||
|
self.assertTrue(any(name == 'sleep' for name, args in calls))
|
||||||
|
|
||||||
|
def test_unprotected_proxy_never_publishes_the_release_pointer(self):
|
||||||
|
self.assert_rolled_back('proxy-unsafe')
|
||||||
|
|
||||||
|
def test_image_retagged_after_verified_load_is_rejected_before_activation(self):
|
||||||
|
result, root, proxy, previous, release, old_site, calls = self.run_activation('image-mismatch')
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
|
||||||
|
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
|
||||||
|
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
|
||||||
|
|
||||||
|
def test_password_directory_owned_by_another_service_is_not_modified(self):
|
||||||
|
result, root, proxy, previous, release, old_site, calls = self.run_activation('unowned-auth')
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy'
|
||||||
|
self.assertEqual((auth / '.project').read_text(), 'another-service\n')
|
||||||
|
self.assertEqual((auth / 'access.htpasswd').read_text(), 'previous-auth-hash\n')
|
||||||
|
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
|
||||||
|
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
|
||||||
|
|
||||||
|
def test_invalid_nginx_configuration_restores_previous_site_and_application(self):
|
||||||
|
self.assert_rolled_back('proxy-fail')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Verify the real remote image-load guard without contacting Docker or a host."""
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Image transfer requires Linux Bash')
|
||||||
|
class GrayImageTransferTests(unittest.TestCase):
|
||||||
|
def run_load(self, scenario):
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
directory = Path(temporary.name)
|
||||||
|
root = directory / 'gray-logistics-chat'
|
||||||
|
release = root / 'releases/build'
|
||||||
|
release.mkdir(parents=True)
|
||||||
|
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
|
||||||
|
archive = release / 'images.tar.gz'
|
||||||
|
archive.write_bytes(gzip.compress(b'fixture-image-archive'))
|
||||||
|
checksum = hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||||
|
if scenario == 'corrupt':
|
||||||
|
archive.write_bytes(archive.read_bytes() + b'changed-in-transit')
|
||||||
|
commands = directory / 'commands'
|
||||||
|
commands.mkdir()
|
||||||
|
docker = commands / 'docker'
|
||||||
|
docker.write_text('#!' + sys.executable + '\n' + '''import os, pathlib, sys
|
||||||
|
args = sys.argv[1:]
|
||||||
|
with pathlib.Path(os.environ['FAKE_LOG']).open('a') as output:
|
||||||
|
output.write(' '.join(args) + '\\n')
|
||||||
|
if args == ['load']:
|
||||||
|
assert sys.stdin.buffer.read() == b'fixture-image-archive'
|
||||||
|
elif args[:2] == ['image', 'inspect']:
|
||||||
|
if args[3] == '{{.Id}}':
|
||||||
|
print('sha256:' + ('b' if '-gray:' in args[-1] else 'c') * 64)
|
||||||
|
else:
|
||||||
|
print(('e' if os.environ['FAKE_SCENARIO'] == 'wrong-source' else 'a') * 40)
|
||||||
|
else:
|
||||||
|
sys.exit(3)
|
||||||
|
''')
|
||||||
|
docker.chmod(0o755)
|
||||||
|
publish = (Path(__file__).resolve().parents[1] / 'deploy/publish-gray.sh').read_text()
|
||||||
|
remote = publish.split("<<'LOAD'\n", 1)[1].split('\nLOAD\n', 1)[0]
|
||||||
|
log = directory / 'commands.log'
|
||||||
|
environment = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ['PATH'],
|
||||||
|
FAKE_LOG=str(log), FAKE_SCENARIO=scenario)
|
||||||
|
result = subprocess.run(['bash', '-s', '--', str(root), str(release), checksum,
|
||||||
|
'baiyue-logistics-chat-gray:' + 'a' * 12 + '-b7',
|
||||||
|
'baiyue-logistics-chat-certbot:' + 'a' * 12 + '-b7', 'a' * 40],
|
||||||
|
input=remote, env=environment, capture_output=True, text=True, timeout=15)
|
||||||
|
return result, release, log
|
||||||
|
|
||||||
|
def test_verified_archive_records_target_ids_and_removes_archive(self):
|
||||||
|
result, release, log = self.run_load('success')
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
receipt = (release / 'loaded-image-ids.env').read_text()
|
||||||
|
self.assertIn('CHAT_IMAGE_ID=sha256:' + 'b' * 64, receipt)
|
||||||
|
self.assertIn('CERTBOT_IMAGE_ID=sha256:' + 'c' * 64, receipt)
|
||||||
|
self.assertFalse((release / 'images.tar.gz').exists())
|
||||||
|
|
||||||
|
def test_modified_archive_is_rejected_before_docker_load(self):
|
||||||
|
result, release, log = self.run_load('corrupt')
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertFalse(log.exists())
|
||||||
|
self.assertFalse((release / 'loaded-image-ids.env').exists())
|
||||||
|
|
||||||
|
def test_wrong_source_cannot_create_a_verified_receipt(self):
|
||||||
|
result, release, log = self.run_load('wrong-source')
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertFalse((release / 'loaded-image-ids.env').exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Local order snapshots, exact lookup and conversational order queries."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from logistics_chat import create_app, db, order_data
|
||||||
|
from logistics_chat.tracking import CARD_PREFIX, card_to_text
|
||||||
|
|
||||||
|
|
||||||
|
class OrderDataTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
folder = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(folder.cleanup)
|
||||||
|
root = Path(folder.name)
|
||||||
|
self.app = create_app({'TESTING':True, 'DATABASE':root/'history.db',
|
||||||
|
'UPLOAD_DIR':root/'uploads', 'BACKUP_DIR':root/'backups', 'LOG_DIR':root/'logs',
|
||||||
|
'DEEPSEEK_API_KEY':'', 'ORDER_DATA_TENANT_ID':'', 'RATE_LIMIT_PER_MIN':1000})
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
self.context = self.app.app_context()
|
||||||
|
self.context.push()
|
||||||
|
self.addCleanup(self.context.pop)
|
||||||
|
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
|
||||||
|
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
|
||||||
|
'status':'PROCESSING', 'country_code':'US', 'transport_mode':'SEA', 'warehouse_code':'TEST1',
|
||||||
|
'order_role':'MASTER', 'package_count':3, 'gross_weight_kg':'30.000', 'volume_cbm':'0.2500',
|
||||||
|
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'},
|
||||||
|
{'time':'2026-10-29 10:00', 'status':'PROCESSING'}]}
|
||||||
|
self.import_rows([self.record])
|
||||||
|
|
||||||
|
def import_rows(self, rows, replace=False):
|
||||||
|
connection = db.connect()
|
||||||
|
try:
|
||||||
|
with connection:
|
||||||
|
return order_data.import_records(connection, rows, replace=replace)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def events(self, message, cid=None):
|
||||||
|
response = self.client.post('/stream_chat', json={'msg':message, 'conv_id':cid})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
return [json.loads(frame[6:]) for frame in response.get_data(as_text=True).split('\n\n') if frame.startswith('data: ')]
|
||||||
|
|
||||||
|
def test_identifiers_are_exact_case_insensitive_and_preserve_suffixes(self):
|
||||||
|
for value in ('fl202610290001', 'INB20261029001-01', 'shipment00208'):
|
||||||
|
self.assertEqual(order_data.lookup_order(value)[0]['order_no'], self.record['order_no'])
|
||||||
|
self.assertEqual(self.events('查询 '+value)[0]['card']['no'], self.record['order_no'])
|
||||||
|
self.assertEqual(order_data.lookup_order('FL20261029000'), [])
|
||||||
|
self.assertEqual(order_data.extract_reference('查询 INB20261029001-01'), ['INB20261029001-01'])
|
||||||
|
self.import_rows([{**self.record,'order_no':'BOX100x100x100'}])
|
||||||
|
self.assertEqual(self.events('查询 BOX100x100x100')[0]['card']['no'],'BOX100x100x100')
|
||||||
|
|
||||||
|
def test_weight_dates_and_attachment_urls_are_not_order_identifiers(self):
|
||||||
|
for text in ('2000KG海运报价', '100000 kg海运报价', '30.000000 KG', '2026-10-29',
|
||||||
|
'2026-10-29T16:53:00', '100x100x100cm', '1200 × 800 × 600 mm',
|
||||||
|
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
|
||||||
|
self.assertEqual(order_data.extract_reference(text), [])
|
||||||
|
events = self.events('帮我查询物流轨迹')
|
||||||
|
self.assertIn('订单号', events[0]['t'])
|
||||||
|
self.assertEqual(events[-1], {'d':1})
|
||||||
|
|
||||||
|
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
|
||||||
|
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
|
||||||
|
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
|
||||||
|
'FL202610290001 运费多少钱?'):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
event = self.events(message)[0]
|
||||||
|
if '运费' in message:
|
||||||
|
self.assertIn('未记录实际运费', event['t'])
|
||||||
|
else:
|
||||||
|
self.assertEqual(event['card']['no'], self.record['order_no'])
|
||||||
|
|
||||||
|
def test_unknown_orders_never_produce_fake_tracking_or_call_model(self):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events = self.events('查询 UNKNOWN20260001')
|
||||||
|
self.assertIn('未找到', events[0]['t'])
|
||||||
|
self.assertFalse(any('card' in event for event in events))
|
||||||
|
self.assertEqual(events[-1], {'d':1})
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events = self.events('列出所有订单')
|
||||||
|
self.assertIn('请提供订单号', events[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_server_tenant_scope_and_ambiguous_identifiers(self):
|
||||||
|
self.import_rows([{**self.record, 'tenant_id':'tenant-b', 'gross_weight_kg':'99'}])
|
||||||
|
self.assertIn('多个订单', self.events(self.record['order_no'])[0]['t'])
|
||||||
|
self.app.config['ORDER_DATA_TENANT_ID'] = 'tenant-a'
|
||||||
|
rows = order_data.lookup_order(self.record['order_no'])
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]['gross_weight_kg'], '30.000')
|
||||||
|
self.app.config['ORDER_DATA_TENANT_ID'] = 'unknown-tenant'
|
||||||
|
self.assertEqual(order_data.lookup_order(self.record['order_no']), [])
|
||||||
|
|
||||||
|
def test_manual_changes_are_visible_and_reimport_preserves_them_by_default(self):
|
||||||
|
connection = db.connect()
|
||||||
|
connection.execute("UPDATE order_test_data SET gross_weight_kg='42.000' WHERE order_no=?", (self.record['order_no'],))
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
self.assertEqual(self.import_rows([self.record]), {'imported':0, 'preserved':1})
|
||||||
|
fields = self.events(self.record['order_no'])[0]['card']['fields']
|
||||||
|
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '42.000 kg')
|
||||||
|
self.import_rows([self.record], replace=True)
|
||||||
|
self.assertEqual(order_data.lookup_order(self.record['order_no'])[0]['gross_weight_kg'], '30.000')
|
||||||
|
|
||||||
|
def test_new_table_records_can_be_added_without_touching_source_database(self):
|
||||||
|
self.import_rows([{**self.record, 'order_no':'LOCAL20260002', 'shipment_id':'localship002'}])
|
||||||
|
self.assertEqual(self.events('查询 LOCAL20260002')[0]['card']['no'], 'LOCAL20260002')
|
||||||
|
|
||||||
|
def test_business_status_history_does_not_claim_delivery_or_invent_transport_events(self):
|
||||||
|
self.import_rows([{**self.record, 'status':'COMPLETED'}], replace=True)
|
||||||
|
card = self.events(self.record['order_no'])[0]['card']
|
||||||
|
self.assertEqual(card['status'], '头程订单已完成')
|
||||||
|
self.assertNotIn('已签收', json.dumps(card, ensure_ascii=False))
|
||||||
|
self.assertEqual([node['time'] for node in card['nodes']], ['2026-10-29 10:00','2026-10-28 10:00'])
|
||||||
|
self.assertTrue(all(not node['place'] for node in card['nodes']))
|
||||||
|
|
||||||
|
def test_children_do_not_reuse_parent_total_weight(self):
|
||||||
|
self.import_rows([{**self.record, 'order_no':'CHILD20260001-01', 'order_role':'CHILD',
|
||||||
|
'gross_weight_kg':None, 'package_count':None, 'volume_cbm':None,
|
||||||
|
'parent_order_no':self.record['order_no'], 'data_note':'子单独立货物数据未提供'}])
|
||||||
|
card = self.events('CHILD20260001-01')[0]['card']
|
||||||
|
self.assertFalse(any(field['key']=='weight' for field in card['fields']))
|
||||||
|
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='parent'), self.record['order_no'])
|
||||||
|
|
||||||
|
def test_followups_refresh_local_data_and_remain_within_conversation(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
self.events(self.record['order_no'], cid)
|
||||||
|
connection = db.connect()
|
||||||
|
connection.execute("UPDATE order_test_data SET gross_weight_kg='45.000' WHERE order_no=?", (self.record['order_no'],))
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
card = self.events('这票重量是多少?',cid)[0]['card']
|
||||||
|
self.assertEqual(card['no'], self.record['order_no'])
|
||||||
|
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='weight'), '45.000 kg')
|
||||||
|
self.assertIn('未记录实际运费', self.events('这票运费多少钱?',cid)[0]['t'])
|
||||||
|
self.assertEqual(self.events('那状态呢?',cid)[0]['card']['no'], self.record['order_no'])
|
||||||
|
self.events('查询 UNKNOWN20260001',cid)
|
||||||
|
self.assertIn('未找到', self.events('这票重量呢?',cid)[0]['t'])
|
||||||
|
other_cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
self.assertIsNone(order_data.answer_query('这票重量是多少?', other_cid))
|
||||||
|
|
||||||
|
def test_new_shipping_questions_do_not_reuse_previous_order(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
self.events(self.record['order_no'], cid)
|
||||||
|
for message in ('现在有30KG货物想发美国,运费多少?', '美国海运费用怎么算?', '报价30公斤到德国', '物流状态是什么意思?'):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
self.assertIsNone(order_data.answer_query(message, cid))
|
||||||
|
db.save_message(cid, 'user', '美国海运费用怎么算?')
|
||||||
|
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
|
||||||
|
self.assertIsNone(order_data.answer_query('那状态呢?', cid))
|
||||||
|
|
||||||
|
def test_short_order_followups_read_facts_without_calling_model(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
self.events(self.record['order_no'], cid)
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
for message in ('多少公斤?', '有几箱?', '体积呢?', '那状态呢?'):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_card_storage_and_model_context_contain_only_selected_facts(self):
|
||||||
|
card = self.events(self.record['order_no'])[0]['card']
|
||||||
|
text = card_to_text(CARD_PREFIX+json.dumps(card))
|
||||||
|
self.assertIn('30.000', text)
|
||||||
|
self.assertIn('US', text)
|
||||||
|
self.assertNotIn('测试', text)
|
||||||
|
self.assertNotIn('tenant_id', text)
|
||||||
|
self.assertNotIn('customer_name', json.dumps(card))
|
||||||
|
|
||||||
|
def test_invalid_history_is_safe_and_read_failure_is_explicit(self):
|
||||||
|
connection = db.connect()
|
||||||
|
connection.execute("UPDATE order_test_data SET history_json='invalid'")
|
||||||
|
connection.commit()
|
||||||
|
self.assertEqual(self.events(self.record['order_no'])[0]['card']['nodes'], [])
|
||||||
|
connection.execute('DROP TABLE order_test_data')
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
self.assertIn('暂时无法读取', self.events(self.record['order_no'])[0]['t'])
|
||||||
|
|
||||||
|
def test_import_failure_rolls_back_partial_rows(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.import_rows([{**self.record,'order_no':'ROLLBACK20260001'}, {'order_no':''}])
|
||||||
|
self.assertEqual(order_data.lookup_order('ROLLBACK20260001'), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""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)
|
||||||
|
self.assertEqual(pricing.parse_shipping_intent('1.2 x 0.8 x 0.6 m 30kg海运')['dims'],[1.2,0.8,0.6])
|
||||||
|
self.assertEqual(pricing.parse_shipping_intent('长10.5宽20.5高30.5')['dims'],[10.5,20.5,30.5])
|
||||||
|
|
||||||
|
def test_shipping_regions_distinguish_country_codes_from_english_pronouns(self):
|
||||||
|
self.assertEqual(pricing.shipping_region('Give us a quote for 30kg to Germany'),'eu')
|
||||||
|
self.assertEqual(pricing.shipping_region('30kg air to US'),'unsupported')
|
||||||
|
self.assertEqual(pricing.shipping_region('30kg air to us'),'unsupported')
|
||||||
|
self.assertEqual(pricing.shipping_region('30kg to UK'),'uk')
|
||||||
|
self.assertEqual(pricing.shipping_region('30kg发UK海运'),'uk')
|
||||||
|
self.assertEqual(pricing.shipping_region('30kg发US仓海运'),'unsupported')
|
||||||
|
self.assertIsNone(pricing.shipping_region('talk about us and our work'))
|
||||||
|
self.assertIsNone(pricing.shipping_region('30kg for UKEURO warehouse'))
|
||||||
|
self.assertIsNone(pricing.shipping_region('2GB附件,30kg海运报价'))
|
||||||
|
self.assertTrue(pricing.is_shipping_destination_reply('英国的FBA仓'))
|
||||||
|
self.assertTrue(pricing.is_shipping_destination_reply('目的地是UK仓'))
|
||||||
|
self.assertFalse(pricing.is_shipping_destination_reply('英国VAT费用是什么'))
|
||||||
|
self.assertFalse(pricing.is_shipping_destination_reply('Give us a quote for 30kg to Germany'))
|
||||||
|
|
||||||
|
def test_warehouse_country_and_surcharge(self):
|
||||||
|
result = pricing.calc_warehouse_card(2, '海运', country='DE')
|
||||||
|
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'))
|
||||||
|
self.assertIsNone(pricing.calc_warehouse_card(2, '海运', country='GB'))
|
||||||
|
self.assertIsNone(pricing.calc_warehouse_card(2, '海运', warehouse_key='unknown'))
|
||||||
|
|
||||||
|
def test_tracking_recognition_and_card_context(self):
|
||||||
|
self.assertEqual(tracking.extract_tracking_no('查询15252125'), '15252125')
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""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()
|
||||||
|
|
||||||
|
def test_late_stream_writes_cannot_recreate_messages_for_deleted_conversations(self):
|
||||||
|
connection = db.connect()
|
||||||
|
cid = connection.execute("INSERT INTO chat_history(title,ctime) VALUES('temporary','now')").lastrowid
|
||||||
|
connection.commit()
|
||||||
|
db.save_message(cid,'user','发送中的问题')
|
||||||
|
connection.execute('DELETE FROM messages WHERE conv_id=?',(cid,))
|
||||||
|
connection.execute('DELETE FROM chat_history WHERE id=?',(cid,))
|
||||||
|
connection.commit()
|
||||||
|
db.save_message(cid,'assistant','迟到的模型回复')
|
||||||
|
self.assertEqual(connection.execute('SELECT COUNT(*) FROM messages WHERE conv_id=?',(cid,)).fetchone()[0],0)
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -1 +0,0 @@
|
|||||||
hello
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user